Files
logic-gates/js/world/sprites.js
Jose Luis bc9786ce49 fix: resolve asset paths relative to document base URI
Assets were 404ing because relative paths resolved against the wrong
base when the page was served from a subdirectory.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 16:04:14 +01:00

242 lines
7.2 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');
}
// ==================== 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 = 2x2 tiles, draw at SCALE
ctx.drawImage(img, screenX, screenY, 32 * SCALE, 32 * SCALE);
}
/**
* Draw an NPC
* @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'
*/
export function drawNPC(ctx, screenX, screenY, facing) {
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);
}
/**
* 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);
}