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
+
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