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>
317 lines
10 KiB
JavaScript
317 lines
10 KiB
JavaScript
// sprites.js - PNG image-based sprite system
|
||
// Uses pre-rendered assets from assets/ directory
|
||
// 16px native tile size, 3x scale for screen rendering
|
||
|
||
export const TILE = 16;
|
||
export const SCALE = 3;
|
||
export const TILE_PX = TILE * SCALE; // 48px on screen
|
||
|
||
// Also export as TILE_SIZE for backward compat
|
||
export const TILE_SIZE = TILE;
|
||
|
||
// ==================== Image cache ====================
|
||
|
||
const imageCache = {};
|
||
let assetsLoaded = false;
|
||
let onAssetsReady = null;
|
||
|
||
function loadImage(key, src) {
|
||
return new Promise((resolve, reject) => {
|
||
if (imageCache[key]) { resolve(imageCache[key]); return; }
|
||
const img = new Image();
|
||
img.onload = () => { imageCache[key] = img; resolve(img); };
|
||
img.onerror = () => { console.warn(`[sprites] failed to load: ${src}`); resolve(null); };
|
||
img.src = src;
|
||
});
|
||
}
|
||
|
||
export function getImage(key) {
|
||
return imageCache[key] || null;
|
||
}
|
||
|
||
/**
|
||
* Preload all game assets. Returns a promise that resolves when done.
|
||
*/
|
||
export async function preloadAssets() {
|
||
if (assetsLoaded) return;
|
||
|
||
const loads = [];
|
||
|
||
// Resolve asset base path relative to the HTML document
|
||
const base = new URL('.', document.baseURI).href;
|
||
|
||
// Map backgrounds
|
||
loads.push(loadImage('map:lab', `${base}assets/map/lab.png`));
|
||
loads.push(loadImage('map:pallet-town', `${base}assets/map/pallet-town.png`));
|
||
loads.push(loadImage('map:house-a-1f', `${base}assets/map/house-a-1f.png`));
|
||
loads.push(loadImage('map:route-1', `${base}assets/map/route-1.png`));
|
||
|
||
// Character sprites (32x32 each)
|
||
const dirs = ['front', 'back', 'left', 'right'];
|
||
const frames = ['still', 'walk-1', 'walk-2'];
|
||
for (const dir of dirs) {
|
||
for (const frame of frames) {
|
||
const key = `char:${dir}-${frame}`;
|
||
loads.push(loadImage(key, `${base}assets/character/${dir}-${frame}.png`));
|
||
}
|
||
}
|
||
|
||
// NPC sprites (16x16 each)
|
||
const npcDirs = ['down', 'up', 'left', 'right'];
|
||
for (const d of npcDirs) {
|
||
loads.push(loadImage(`npc:a-${d}`, `${base}assets/npcs/a-${d}.png`));
|
||
}
|
||
|
||
await Promise.all(loads);
|
||
assetsLoaded = true;
|
||
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
|
||
const DIR_TO_SPRITE = {
|
||
down: 'front',
|
||
up: 'back',
|
||
left: 'left',
|
||
right: 'right'
|
||
};
|
||
|
||
// Map game direction to NPC sprite suffix
|
||
const DIR_TO_NPC = {
|
||
down: 'down',
|
||
up: 'up',
|
||
left: 'left',
|
||
right: 'right'
|
||
};
|
||
|
||
// ==================== Drawing functions ====================
|
||
|
||
/**
|
||
* Draw a map background image
|
||
* @param {CanvasRenderingContext2D} ctx
|
||
* @param {string} mapImageKey - key in imageCache (e.g. 'map:lab')
|
||
* @param {number} offsetX - pixel offset for camera
|
||
* @param {number} offsetY - pixel offset for camera
|
||
*/
|
||
export function drawMapImage(ctx, mapImageKey, offsetX, offsetY) {
|
||
const img = imageCache[mapImageKey];
|
||
if (!img) return;
|
||
// Draw scaled: native pixels * SCALE
|
||
ctx.imageSmoothingEnabled = false;
|
||
ctx.drawImage(img, offsetX, offsetY, img.width * SCALE, img.height * SCALE);
|
||
}
|
||
|
||
/**
|
||
* Draw the player character
|
||
* @param {CanvasRenderingContext2D} ctx
|
||
* @param {number} screenX - top-left X on screen
|
||
* @param {number} screenY - top-left Y on screen
|
||
* @param {string} direction - 'up'|'down'|'left'|'right'
|
||
* @param {number} walkFrame - 0=still, 1=walk-1, 2=walk-2
|
||
*/
|
||
export function drawPlayer(ctx, screenX, screenY, direction, walkFrame) {
|
||
const spriteDir = DIR_TO_SPRITE[direction] || 'front';
|
||
const frameName = walkFrame === 0 ? 'still' : walkFrame === 1 ? 'walk-1' : 'walk-2';
|
||
const key = `char:${spriteDir}-${frameName}`;
|
||
const img = imageCache[key];
|
||
if (!img) {
|
||
// Fallback: colored rectangle
|
||
ctx.fillStyle = '#00e599';
|
||
ctx.fillRect(screenX, screenY, TILE_PX, TILE_PX * 2);
|
||
return;
|
||
}
|
||
ctx.imageSmoothingEnabled = false;
|
||
// Character is 32x32 native but represents a 1-tile-wide, 2-tile-tall entity
|
||
// Draw at TILE_PX wide x TILE_PX tall (square, matching NPC size on grid)
|
||
ctx.drawImage(img, screenX, screenY, TILE_PX, TILE_PX);
|
||
}
|
||
|
||
/**
|
||
* 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, 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) {
|
||
ctx.fillStyle = '#ff44aa';
|
||
ctx.fillRect(screenX, screenY, TILE_PX, TILE_PX);
|
||
return;
|
||
}
|
||
ctx.imageSmoothingEnabled = false;
|
||
ctx.drawImage(img, screenX, screenY, TILE_PX, TILE_PX);
|
||
}
|
||
|
||
/**
|
||
* Draw the interaction prompt (E button hint) above a tile
|
||
*/
|
||
export function drawInteractionPrompt(ctx, screenX, screenY) {
|
||
const cx = screenX + TILE_PX / 2;
|
||
const cy = screenY - 12;
|
||
|
||
// Bubble background
|
||
ctx.fillStyle = 'rgba(0, 0, 0, 0.7)';
|
||
ctx.beginPath();
|
||
ctx.roundRect(cx - 18, cy - 12, 36, 22, 6);
|
||
ctx.fill();
|
||
|
||
// Border
|
||
ctx.strokeStyle = '#00e599';
|
||
ctx.lineWidth = 1.5;
|
||
ctx.beginPath();
|
||
ctx.roundRect(cx - 18, cy - 12, 36, 22, 6);
|
||
ctx.stroke();
|
||
|
||
// Text
|
||
ctx.fillStyle = '#ffffff';
|
||
ctx.font = 'bold 12px "Segoe UI", system-ui, sans-serif';
|
||
ctx.textAlign = 'center';
|
||
ctx.textBaseline = 'middle';
|
||
ctx.fillText('[E]', cx, cy);
|
||
}
|
||
|
||
/**
|
||
* Draw the dialog box at the bottom of the screen
|
||
*/
|
||
export function drawDialogBox(ctx, canvasW, canvasH, text, speakerName) {
|
||
const boxH = 100;
|
||
const boxY = canvasH - boxH - 16;
|
||
const boxX = 32;
|
||
const boxW = canvasW - 64;
|
||
|
||
// Background
|
||
ctx.fillStyle = 'rgba(10, 14, 39, 0.92)';
|
||
ctx.beginPath();
|
||
ctx.roundRect(boxX, boxY, boxW, boxH, 10);
|
||
ctx.fill();
|
||
|
||
// Border
|
||
ctx.strokeStyle = '#00e599';
|
||
ctx.lineWidth = 2;
|
||
ctx.beginPath();
|
||
ctx.roundRect(boxX, boxY, boxW, boxH, 10);
|
||
ctx.stroke();
|
||
|
||
// Speaker name
|
||
if (speakerName) {
|
||
ctx.fillStyle = '#00e599';
|
||
ctx.font = 'bold 14px "Segoe UI", system-ui, sans-serif';
|
||
ctx.textAlign = 'left';
|
||
ctx.textBaseline = 'top';
|
||
ctx.fillText(speakerName, boxX + 16, boxY + 12);
|
||
}
|
||
|
||
// Text
|
||
ctx.fillStyle = '#ffffff';
|
||
ctx.font = '14px "Segoe UI", system-ui, sans-serif';
|
||
ctx.textAlign = 'left';
|
||
ctx.textBaseline = 'top';
|
||
const textY = speakerName ? boxY + 34 : boxY + 16;
|
||
// Simple word wrap
|
||
wrapText(ctx, text, boxX + 16, textY, boxW - 32, 20);
|
||
|
||
// Continue prompt
|
||
ctx.fillStyle = '#555';
|
||
ctx.font = '11px "Segoe UI", system-ui, sans-serif';
|
||
ctx.textAlign = 'right';
|
||
ctx.fillText('Press E to continue ▶', boxX + boxW - 16, boxY + boxH - 16);
|
||
}
|
||
|
||
function wrapText(ctx, text, x, y, maxWidth, lineHeight) {
|
||
const words = text.split(' ');
|
||
let line = '';
|
||
let currentY = y;
|
||
for (const word of words) {
|
||
const test = line + (line ? ' ' : '') + word;
|
||
if (ctx.measureText(test).width > maxWidth && line) {
|
||
ctx.fillText(line, x, currentY);
|
||
line = word;
|
||
currentY += lineHeight;
|
||
} else {
|
||
line = test;
|
||
}
|
||
}
|
||
if (line) ctx.fillText(line, x, currentY);
|
||
}
|