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