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:
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user