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 <noreply@anthropic.com>
This commit is contained in:
Jose Luis
2026-03-20 21:15:28 +01:00
parent 9d218c8728
commit 9ffd9c113e
7 changed files with 354 additions and 20 deletions

View File

@@ -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>} 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;
}
}

View File

@@ -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) {

View File

@@ -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);
}

View File

@@ -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);
}
}