From 9ffd9c113e8e8b72488e9f2bf8b38a4333e84c25 Mon Sep 17 00:00:00 2001 From: Jose Luis Date: Fri, 20 Mar 2026 21:15:28 +0100 Subject: [PATCH] feat: character/NPC management system with spritesheet support Add drag & drop spritesheet upload in editor, character registry in sprites.js, character selector for NPCs, sprite rendering on editor canvas, server API for character persistence, and game-side character loading via characterLoader.js. Co-Authored-By: Claude Opus 4.6 --- Dockerfile | 1 + editor.html | 219 +++++++++++++++++++++++++++++++++--- js/world/characterLoader.js | 26 +++++ js/world/gameMode.js | 6 +- js/world/sprites.js | 82 +++++++++++++- js/world/worldRenderer.js | 2 +- server.js | 38 +++++++ 7 files changed, 354 insertions(+), 20 deletions(-) create mode 100644 js/world/characterLoader.js diff --git a/Dockerfile b/Dockerfile index 28d12a2..a2efdbd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,5 +7,6 @@ COPY editor.html public/ COPY css/ public/css/ COPY js/ public/js/ COPY assets/ public/assets/ +RUN mkdir -p public/data EXPOSE 80 CMD ["node", "server.js"] diff --git a/editor.html b/editor.html index 54324ad..c0c757e 100644 --- a/editor.html +++ b/editor.html @@ -63,6 +63,23 @@ body { background: var(--bg); color: var(--text); font-family: 'Segoe UI', syste #status-bar span { font-family: monospace; } #zoom-info { margin-left: auto; } +/* Characters section */ +#char-section { border-bottom: 1px solid var(--border); max-height: 200px; overflow-y: auto; } +#char-section h3 { padding: 4px 16px; font-size: 11px; color: var(--text2); text-transform: uppercase; letter-spacing: 0.5px; display: flex; align-items: center; gap: 6px; } +#char-section h3 button { background: var(--bg); border: 1px solid var(--border); color: var(--accent); border-radius: 3px; padding: 1px 6px; cursor: pointer; font-size: 10px; } +#char-section h3 button:hover { background: var(--accent); color: #000; } +.char-drop-zone { margin: 4px 12px 8px; padding: 12px; border: 2px dashed var(--border); border-radius: 6px; text-align: center; font-size: 11px; color: var(--text2); cursor: pointer; transition: all 0.2s; } +.char-drop-zone.dragover { border-color: var(--accent); background: rgba(0,229,153,0.08); color: var(--accent); } +.char-card { display: flex; align-items: center; gap: 8px; padding: 5px 12px; cursor: pointer; border-left: 3px solid transparent; font-size: 12px; } +.char-card:hover { background: rgba(255,255,255,0.04); } +.char-card.selected { background: rgba(0,229,153,0.1); border-left-color: var(--accent); } +.char-card canvas { border: 1px solid var(--border); border-radius: 2px; image-rendering: pixelated; flex-shrink: 0; } +.char-card .char-info { flex: 1; overflow: hidden; } +.char-card .char-name { color: var(--text); font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.char-card .char-meta { font-size: 10px; color: var(--text2); } +.char-card .char-delete { background: none; border: none; color: var(--red); cursor: pointer; font-size: 14px; opacity: 0.5; padding: 2px 4px; } +.char-card .char-delete:hover { opacity: 1; } + /* Toast */ #toast { position: fixed; top: 16px; right: 16px; padding: 10px 18px; background: var(--accent); color: #000; border-radius: 6px; font-weight: 600; font-size: 13px; opacity: 0; transition: opacity 0.3s; pointer-events: none; z-index: 999; } #toast.show { opacity: 1; } @@ -195,6 +212,14 @@ body { background: var(--bg); color: var(--text); font-family: 'Segoe UI', syste +
+

Characters

+
+
+ Drop spritesheet PNG here
3 cols (still, walk1, walk2) ร— 4 rows (โ†“โ†‘โ†โ†’) +
+ +

Spawn

@@ -320,6 +345,8 @@ const mapConfigs = { let currentMapId = 'lab'; let mapData = {}; // { [mapId]: { walls: Set, spawn, npcs, exits, interactions } } +let characters = {}; // { [charId]: { id, name, spritesheet (base64), frameW, frameH, img (HTMLImageElement) } } +let selectedCharId = null; let selectedTool = 'wall'; let selectedEntity = null; // { type, index } let isPainting = false; @@ -391,10 +418,122 @@ function init() { document.getElementById('btn-load-server').addEventListener('click', loadFromServer); document.getElementById('btn-do-bilink').addEventListener('click', createBiLink); + initCharacterUI(); resizeCanvas(); updateEntityList(); } +// ==================== Character management ==================== + +function addCharacterFromFile(file) { + const reader = new FileReader(); + reader.onload = (e) => { + const base64 = e.target.result; + const img = new Image(); + img.onload = () => { + // Auto-detect frame size: assume 3 cols ร— 4 rows + const frameW = Math.floor(img.width / 3); + const frameH = Math.floor(img.height / 4); + const id = file.name.replace(/\.png$/i, '').replace(/[^a-zA-Z0-9_-]/g, '_').toLowerCase(); + const name = id.replace(/[_-]/g, ' ').replace(/\b\w/g, c => c.toUpperCase()); + + characters[id] = { id, name, spritesheet: base64, frameW, frameH, img }; + updateCharList(); + selectCharacter(id); + toast(`Character "${name}" added (${frameW}ร—${frameH}px frames)`); + }; + img.src = base64; + }; + reader.readAsDataURL(file); +} + +function removeCharacter(charId) { + delete characters[charId]; + if (selectedCharId === charId) selectedCharId = null; + // Remove charId from NPCs that reference it + for (const md of Object.values(mapData)) { + for (const npc of (md.npcs || [])) { + if (npc.charId === charId) delete npc.charId; + } + } + updateCharList(); + updateEntityList(); + render(); +} + +function selectCharacter(charId) { + selectedCharId = charId; + updateCharList(); +} + +function updateCharList() { + const container = document.getElementById('char-list'); + const ids = Object.keys(characters); + if (ids.length === 0) { + container.innerHTML = ''; + return; + } + container.innerHTML = ids.map(id => { + const c = characters[id]; + const sel = id === selectedCharId ? ' selected' : ''; + return `
+ +
+
${c.name}
+
${c.frameW}ร—${c.frameH}px ยท ${id}
+
+ +
`; + }).join(''); + + // Draw sprite previews (down-still frame) + container.querySelectorAll('.char-preview').forEach(cvs => { + const c = characters[cvs.dataset.charId]; + if (!c || !c.img) return; + const pctx = cvs.getContext('2d'); + pctx.imageSmoothingEnabled = false; + pctx.clearRect(0, 0, 32, 32); + // Draw the down-still frame (row 0, col 0) + pctx.drawImage(c.img, 0, 0, c.frameW, c.frameH, 0, 0, 32, 32); + }); + + // Wire click events + container.querySelectorAll('.char-card').forEach(card => { + card.addEventListener('click', (e) => { + if (e.target.classList.contains('char-delete')) return; + selectCharacter(card.dataset.charId); + }); + }); + container.querySelectorAll('.char-delete').forEach(btn => { + btn.addEventListener('click', () => removeCharacter(btn.dataset.charId)); + }); +} + +// Wire drag & drop + file input +function initCharacterUI() { + const dropZone = document.getElementById('char-drop-zone'); + const fileInput = document.getElementById('char-file-input'); + const addBtn = document.getElementById('btn-add-char'); + + dropZone.addEventListener('dragover', (e) => { e.preventDefault(); dropZone.classList.add('dragover'); }); + dropZone.addEventListener('dragleave', () => dropZone.classList.remove('dragover')); + dropZone.addEventListener('drop', (e) => { + e.preventDefault(); + dropZone.classList.remove('dragover'); + for (const file of e.dataTransfer.files) { + if (file.type === 'image/png') addCharacterFromFile(file); + } + }); + dropZone.addEventListener('click', () => fileInput.click()); + addBtn.addEventListener('click', () => fileInput.click()); + fileInput.addEventListener('change', () => { + for (const file of fileInput.files) { + if (file.type === 'image/png') addCharacterFromFile(file); + } + fileInput.value = ''; + }); +} + // ==================== Load game data ==================== function loadCurrentGameData() { @@ -596,12 +735,22 @@ function render() { // NPCs md.npcs.forEach((npc, i) => { - ctx.fillStyle = 'rgba(200, 50, 255, 0.35)'; - ctx.fillRect(npc.x * TILE_PX, npc.y * TILE_PX, TILE_PX, TILE_PX); + const sx = npc.x * TILE_PX, sy = npc.y * TILE_PX; + const char = npc.charId ? characters[npc.charId] : null; + if (char && char.img) { + // Draw spritesheet frame: down-still (row 0, col 0) + const dirRow = { down: 0, up: 1, left: 2, right: 3 }; + const row = dirRow[npc.facing] ?? 0; + ctx.imageSmoothingEnabled = false; + ctx.drawImage(char.img, 0, row * char.frameH, char.frameW, char.frameH, sx, sy, TILE_PX, TILE_PX); + } else { + ctx.fillStyle = 'rgba(200, 50, 255, 0.35)'; + ctx.fillRect(sx, sy, TILE_PX, TILE_PX); + drawLabel(ctx, '๐Ÿ‘ค', npc.x, npc.y); + } ctx.strokeStyle = selectedEntity?.type === 'npc' && selectedEntity.index === i ? '#fff' : '#cc55ff'; ctx.lineWidth = selectedEntity?.type === 'npc' && selectedEntity.index === i ? 2.5 : 1.5; - ctx.strokeRect(npc.x * TILE_PX, npc.y * TILE_PX, TILE_PX, TILE_PX); - drawLabel(ctx, '๐Ÿ‘ค', npc.x, npc.y); + ctx.strokeRect(sx, sy, TILE_PX, TILE_PX); }); // Tile coordinates (when zoomed in enough) @@ -686,11 +835,14 @@ function onMouseDown(e) { } updateEntityList(); updateProps(); render(); break; - case 'npc': - md.npcs.push({ id: `npc_${Date.now()}`, x: tile.x, y: tile.y, facing: 'down', dialog: ['Hello!'] }); + case 'npc': { + const npcData = { id: `npc_${Date.now()}`, x: tile.x, y: tile.y, facing: 'down', dialog: ['Hello!'] }; + if (selectedCharId && characters[selectedCharId]) npcData.charId = selectedCharId; + md.npcs.push(npcData); selectedEntity = { type: 'npc', index: md.npcs.length - 1 }; updateEntityList(); updateProps(); render(); break; + } case 'exit': { // Default to a different map than the current one const otherMaps = Object.keys(mapConfigs).filter(id => id !== currentMapId); @@ -893,9 +1045,10 @@ function updateEntityList() { ? makeEntityItem('spawn', 0, '๐Ÿ ', `Spawn`, md.spawn.x, md.spawn.y) : '
None (use ๐Ÿ  tool to place)
'; - document.getElementById('npc-list').innerHTML = md.npcs.map((n, i) => - makeEntityItem('npc', i, '๐Ÿ‘ค', n.id, n.x, n.y, '#cc55ff') - ).join('') || '
None
'; + document.getElementById('npc-list').innerHTML = md.npcs.map((n, i) => { + const charLabel = n.charId && characters[n.charId] ? ` [${characters[n.charId].name}]` : ''; + return makeEntityItem('npc', i, '๐Ÿ‘ค', n.id + charLabel, n.x, n.y, '#cc55ff'); + }).join('') || '
None
'; document.getElementById('exit-list').innerHTML = md.exits.map((e, i) => { const hasCoords = e.targetX != null && e.targetY != null; @@ -965,6 +1118,9 @@ function updateProps() { if (t === 'npc') { html += propText('ID', 'id', ent.id); html += propSelect('Facing', 'facing', ent.facing, ['down','up','left','right']); + // Character selector + const charOpts = ['(none)', ...Object.keys(characters)]; + html += propSelect('Character', 'charId', ent.charId || '(none)', charOpts); html += propTextarea('Dialog', 'dialog', (ent.dialog || []).join('\n')); } else if (t === 'exit') { // Show map options with both editor ID and game ID @@ -1036,7 +1192,10 @@ function applyPropChange(prop, value, inputType) { const ent = getSelectedEntityData(); if (!ent) return; - if (prop === 'dialog') { + if (prop === 'charId') { + if (value === '(none)') delete ent.charId; + else ent.charId = value; + } else if (prop === 'dialog') { ent.dialog = value.split('\n').filter(l => l.trim()); } else if (prop === 'requiredOutputs') { ent.requiredOutputs = value.split(',').map(Number); @@ -1136,7 +1295,10 @@ function generateMapsJS() { // NPCs out += ` npcs: [\n`; for (const n of md.npcs) { - out += ` { id: '${n.id}', x: ${n.x}, y: ${n.y}, facing: '${n.facing}', dialog: ${JSON.stringify(n.dialog)} },\n`; + let npcLine = ` { id: '${n.id}', x: ${n.x}, y: ${n.y}, facing: '${n.facing}'`; + if (n.charId) npcLine += `, charId: '${n.charId}'`; + npcLine += `, dialog: ${JSON.stringify(n.dialog)} },\n`; + out += npcLine; } out += ` ],\n\n`; @@ -1256,14 +1418,28 @@ function toast(msg) { async function saveToServer() { const code = generateMapsJS(); try { + // Save maps.js const res = await fetch('/api/maps', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ content: code }) }); const data = await res.json(); + + // Save characters + const charData = {}; + for (const [id, c] of Object.entries(characters)) { + charData[id] = { id: c.id, name: c.name, spritesheet: c.spritesheet, frameW: c.frameW, frameH: c.frameH }; + } + await fetch('/api/characters', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ characters: charData }) + }); + if (data.ok) { - toast(`Saved maps.js (${data.bytes} bytes)`); + const charCount = Object.keys(charData).length; + toast(`Saved maps.js (${data.bytes}b) + ${charCount} character(s)`); } else { toast('Error: ' + (data.error || 'Unknown')); } @@ -1279,11 +1455,26 @@ async function loadFromServer() { if (!data.content) { toast('No maps.js found on server'); return; } // Parse the maps.js to extract wall data and entities - // We parse the JS source to extract the wall objects and map definitions const src = data.content; parseAndLoadMapsJS(src); + + // Load characters + try { + const charRes = await fetch('/api/characters'); + const charData = await charRes.json(); + if (charData.characters) { + characters = {}; + for (const [id, c] of Object.entries(charData.characters)) { + const img = new Image(); + img.src = c.spritesheet; + characters[id] = { id: c.id, name: c.name, spritesheet: c.spritesheet, frameW: c.frameW, frameH: c.frameH, img }; + } + updateCharList(); + } + } catch (ce) { console.warn('[editor] Character load failed:', ce); } + updateEntityList(); updateProps(); render(); - toast('Loaded maps.js from server'); + toast('Loaded maps.js + characters from server'); } catch (e) { toast('Load failed: ' + e.message); } diff --git a/js/world/characterLoader.js b/js/world/characterLoader.js new file mode 100644 index 0000000..2198c18 --- /dev/null +++ b/js/world/characterLoader.js @@ -0,0 +1,26 @@ +// characterLoader.js - Loads character spritesheets from server and registers them +import { registerCharacter } from './sprites.js'; + +/** + * Fetch character data from the server and register all characters + * with the sprite system so NPCs can use them. + * @returns {Promise} number of characters loaded + */ +export async function loadCharacters() { + try { + const res = await fetch('/api/characters'); + const data = await res.json(); + if (!data.characters) return 0; + + const entries = Object.entries(data.characters); + const promises = entries.map(([id, c]) => + registerCharacter(id, c.name, c.spritesheet, c.frameW, c.frameH) + ); + await Promise.all(promises); + console.log(`[characterLoader] loaded ${entries.length} character(s)`); + return entries.length; + } catch (e) { + console.warn('[characterLoader] failed to load characters:', e); + return 0; + } +} diff --git a/js/world/gameMode.js b/js/world/gameMode.js index f056d32..5951935 100644 --- a/js/world/gameMode.js +++ b/js/world/gameMode.js @@ -5,6 +5,7 @@ import { initWorldInput, destroyWorldInput, setInteractionHandler } from './worl import { getMap } from './maps.js'; import { saveGadget, openBackpack, getGadgets, openNamingScreen, showNotification } from './inventory.js'; import { openWiringPanel } from './wiringPanel.js'; +import { loadCharacters } from './characterLoader.js'; // Circuit editor stop function (to stop its render loop when switching modes) import { stopCircuitLoop } from '../renderer.js'; @@ -28,7 +29,10 @@ export function registerCircuitEditor(initFn, destroyFn) { /** * Boot the game โ€” start in world mode */ -export function startGame() { +export async function startGame() { + // Load character spritesheets before entering world + await loadCharacters(); + // Set spawn const map = getMap(worldState.currentMap); if (map && map.spawn) { diff --git a/js/world/sprites.js b/js/world/sprites.js index ba680aa..b79375d 100644 --- a/js/world/sprites.js +++ b/js/world/sprites.js @@ -67,6 +67,73 @@ export async function preloadAssets() { console.log('[sprites] all assets loaded'); } +// ==================== Character Registry ==================== +// Characters are stored as spritesheets: 3 cols (still, walk1, walk2) ร— 4 rows (down, up, left, right) + +const characterRegistry = {}; + +/** + * Register a character from a spritesheet image (or base64 data URL). + * @param {string} charId - unique character ID + * @param {string} name - display name + * @param {string|HTMLImageElement} source - image URL, base64 data URL, or Image element + * @param {number} frameW - frame width in px (default 16) + * @param {number} frameH - frame height in px (default 16) + * @returns {Promise} resolves when character is loaded + */ +export function registerCharacter(charId, name, source, frameW = 16, frameH = 16) { + return new Promise((resolve) => { + const char = { id: charId, name, frameW, frameH, img: null }; + if (source instanceof HTMLImageElement) { + char.img = source; + characterRegistry[charId] = char; + resolve(char); + } else { + const img = new Image(); + img.onload = () => { char.img = img; characterRegistry[charId] = char; resolve(char); }; + img.onerror = () => { console.warn(`[sprites] failed to load char: ${charId}`); resolve(null); }; + img.src = source; + } + }); +} + +/** Get a registered character definition */ +export function getCharacter(charId) { return characterRegistry[charId] || null; } + +/** Get all registered characters */ +export function getAllCharacters() { return { ...characterRegistry }; } + +/** Remove a character from the registry */ +export function removeCharacter(charId) { delete characterRegistry[charId]; } + +// Direction โ†’ row index in spritesheet +const DIR_ROW = { down: 0, up: 1, left: 2, right: 3 }; + +/** + * Draw a character from the registry using its spritesheet. + * @param {CanvasRenderingContext2D} ctx + * @param {string} charId - character ID from registry + * @param {number} screenX - top-left X on screen + * @param {number} screenY - top-left Y on screen + * @param {string} facing - 'up'|'down'|'left'|'right' + * @param {number} walkFrame - 0=still, 1=walk-1, 2=walk-2 + */ +export function drawCharacter(ctx, charId, screenX, screenY, facing, walkFrame = 0) { + const char = characterRegistry[charId]; + if (!char || !char.img) { + // Fallback: magenta box + ctx.fillStyle = '#ff44aa'; + ctx.fillRect(screenX, screenY, TILE_PX, TILE_PX); + return; + } + const row = DIR_ROW[facing] ?? 0; + const col = Math.min(walkFrame, 2); + const sx = col * char.frameW; + const sy = row * char.frameH; + ctx.imageSmoothingEnabled = false; + ctx.drawImage(char.img, sx, sy, char.frameW, char.frameH, screenX, screenY, TILE_PX, TILE_PX); +} + // ==================== Direction mapping ==================== // Map game direction to character sprite prefix @@ -128,24 +195,31 @@ export function drawPlayer(ctx, screenX, screenY, direction, walkFrame) { } /** - * Draw an NPC + * Draw an NPC โ€” if it has a charId, uses the character registry spritesheet. + * Otherwise falls back to the default hardcoded NPC sprites. * @param {CanvasRenderingContext2D} ctx * @param {number} screenX - top-left X on screen * @param {number} screenY - top-left Y on screen * @param {string} facing - 'up'|'down'|'left'|'right' + * @param {string} [charId] - optional character ID from registry + * @param {number} [walkFrame=0] - animation frame (0=still, 1=walk-1, 2=walk-2) */ -export function drawNPC(ctx, screenX, screenY, facing) { +export function drawNPC(ctx, screenX, screenY, facing, charId, walkFrame = 0) { + // If a character is registered, use the spritesheet renderer + if (charId && characterRegistry[charId]) { + drawCharacter(ctx, charId, screenX, screenY, facing, walkFrame); + return; + } + // Fallback: legacy hardcoded sprites const dir = DIR_TO_NPC[facing] || 'down'; const key = `npc:a-${dir}`; const img = imageCache[key]; if (!img) { - // Fallback ctx.fillStyle = '#ff44aa'; ctx.fillRect(screenX, screenY, TILE_PX, TILE_PX); return; } ctx.imageSmoothingEnabled = false; - // NPC is 16x16 native = 1 tile ctx.drawImage(img, screenX, screenY, TILE_PX, TILE_PX); } diff --git a/js/world/worldRenderer.js b/js/world/worldRenderer.js index 0f0bc00..52021f8 100644 --- a/js/world/worldRenderer.js +++ b/js/world/worldRenderer.js @@ -103,7 +103,7 @@ export function renderWorld(timestamp) { if (map.npcs) { for (const npc of map.npcs) { const pos = tileToScreen(npc.x, npc.y); - drawNPC(ctx, pos.x, pos.y, npc.facing || 'down'); + drawNPC(ctx, pos.x, pos.y, npc.facing || 'down', npc.charId || null); } } diff --git a/server.js b/server.js index a2d3887..f859edf 100644 --- a/server.js +++ b/server.js @@ -8,6 +8,7 @@ const path = require('path'); const PORT = process.env.PORT || 80; const STATIC_DIR = path.join(__dirname, 'public'); const MAPS_FILE = path.join(STATIC_DIR, 'js', 'world', 'maps.js'); +const CHARS_FILE = path.join(STATIC_DIR, 'data', 'characters.json'); const MIME = { '.html': 'text/html', @@ -92,6 +93,43 @@ const server = http.createServer((req, res) => { return; } + // === API: GET /api/characters โ€” read character data === + if (req.method === 'GET' && req.url === '/api/characters') { + fs.readFile(CHARS_FILE, 'utf-8', (err, data) => { + if (err) { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ characters: {} })); + return; + } + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(data); + }); + return; + } + + // === API: PUT /api/characters โ€” write character data === + if (req.method === 'PUT' && req.url === '/api/characters') { + let body = ''; + req.on('data', chunk => { body += chunk; }); + req.on('end', () => { + try { + const parsed = JSON.parse(body); + // Ensure data dir exists + const dataDir = path.dirname(CHARS_FILE); + if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true }); + fs.writeFileSync(CHARS_FILE, JSON.stringify(parsed, null, 2), 'utf-8'); + const count = Object.keys(parsed.characters || {}).length; + console.log(`[server] characters.json saved (${count} characters)`); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true, count })); + } catch (e) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: e.message })); + } + }); + return; + } + // === Static file serving === let filePath = path.join(STATIC_DIR, req.url === '/' ? 'index.html' : req.url); // Prevent directory traversal