refactor: migrate world rendering from programmatic sprites to PNG assets

Replace pixel-art drawing with pre-rendered PNG map backgrounds and
character/NPC sprite images from pokemon-js reference. Maps now use
coordinate-based wall arrays instead of tile grids.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jose Luis
2026-03-20 16:02:44 +01:00
parent e4cf35701e
commit c836ccbb21
24 changed files with 479 additions and 989 deletions

View File

@@ -1,694 +1,238 @@
// Cyberpunk pixel-art sprite system
// All sprites drawn on canvas, no image assets
// 16x16 tile size with 3x scaling for screen rendering
// 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_SIZE = 16;
export const TILE = 16;
export const SCALE = 3;
export const TILE_PX = TILE * SCALE; // 48px on screen
// Color palette
const COLORS = {
// Neon palette
neonGreen: '#00e599',
neonPink: '#ff44aa',
neonPurple: '#9900ff',
neonCyan: '#44ddff',
neonOrange: '#ff8844',
// Also export as TILE_SIZE for backward compat
export const TILE_SIZE = TILE;
// Dark palette
black: '#0a0e27',
darkGray: '#1a1f3a',
gray: '#3a3f5a',
lightGray: '#5a5f7a',
// ==================== Image cache ====================
// Skin tones & details
skinLight: '#d4a574',
skinMid: '#c89860',
skinDark: '#a0704c',
const imageCache = {};
let assetsLoaded = false;
let onAssetsReady = null;
// Material colors
metalDark: '#2a2f4a',
metalLight: '#4a4f6a',
copper: '#b87333',
blue: '#4488dd',
red: '#ee4444',
green: '#44aa44',
white: '#ffffff',
};
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;
});
}
/**
* Helper function to draw a single scaled pixel
* @param {CanvasRenderingContext2D} ctx
* @param {number} baseX - Base X position (in pixels on screen)
* @param {number} baseY - Base Y position (in pixels on screen)
* @param {number} px - Pixel X offset (0-15 within tile)
* @param {number} py - Pixel Y offset (0-15 within tile)
* @param {string} color - Color hex code
*/
function pixel(ctx, baseX, baseY, px, py, color) {
ctx.fillStyle = color;
ctx.fillRect(baseX + px * SCALE, baseY + py * SCALE, SCALE, SCALE);
export function getImage(key) {
return imageCache[key] || null;
}
/**
* Draw a filled rectangle in tile space
* Preload all game assets. Returns a promise that resolves when done.
*/
function rect(ctx, baseX, baseY, x, y, w, h, color) {
ctx.fillStyle = color;
ctx.fillRect(baseX + x * SCALE, baseY + y * SCALE, w * SCALE, h * SCALE);
export async function preloadAssets() {
if (assetsLoaded) return;
const loads = [];
// Map backgrounds
loads.push(loadImage('map:lab', 'assets/map/lab.png'));
loads.push(loadImage('map:pallet-town', 'assets/map/pallet-town.png'));
loads.push(loadImage('map:house-a-1f', 'assets/map/house-a-1f.png'));
loads.push(loadImage('map:route-1', '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, `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}`, `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} x - Screen X position
* @param {number} y - Screen Y position
* @param {string} direction - 'up', 'down', 'left', 'right'
* @param {number} frame - Animation frame (0 or 1)
* @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, x, y, direction, frame) {
const baseX = x * SCALE;
const baseY = y * SCALE;
// Idle position or walking offset
const walkOffset = frame === 1 ? 1 : 0;
// Head position shifts slightly with walk cycle
let headY = 2;
let legOffset = 0;
if (frame === 1 && direction === 'down') legOffset = 1;
if (frame === 1 && direction === 'up') legOffset = -1;
if (direction === 'down') {
// Facing down
// Hair/head
pixel(ctx, baseX, baseY, 7, headY, COLORS.darkGray);
pixel(ctx, baseX, baseY, 8, headY, COLORS.darkGray);
// Face
pixel(ctx, baseX, baseY, 7, headY + 1, COLORS.skinLight);
pixel(ctx, baseX, baseY, 8, headY + 1, COLORS.skinLight);
// Hair back
pixel(ctx, baseX, baseY, 6, headY + 1, COLORS.darkGray);
pixel(ctx, baseX, baseY, 9, headY + 1, COLORS.darkGray);
// Eyes (neon glow)
pixel(ctx, baseX, baseY, 7, headY + 2, COLORS.neonGreen);
pixel(ctx, baseX, baseY, 8, headY + 2, COLORS.neonGreen);
// Mouth
pixel(ctx, baseX, baseY, 7, headY + 3, COLORS.red);
pixel(ctx, baseX, baseY, 8, headY + 3, COLORS.red);
// Torso - black outfit with neon trim
pixel(ctx, baseX, baseY, 7, headY + 4, COLORS.black);
pixel(ctx, baseX, baseY, 8, headY + 4, COLORS.black);
pixel(ctx, baseX, baseY, 6, headY + 5, COLORS.neonGreen);
pixel(ctx, baseX, baseY, 7, headY + 5, COLORS.darkGray);
pixel(ctx, baseX, baseY, 8, headY + 5, COLORS.darkGray);
pixel(ctx, baseX, baseY, 9, headY + 5, COLORS.neonGreen);
// Chest neon accent
pixel(ctx, baseX, baseY, 7, headY + 6, COLORS.neonPink);
pixel(ctx, baseY, baseY, 8, headY + 6, COLORS.neonPink);
// Arms
pixel(ctx, baseX, baseY, 5, headY + 5, COLORS.skinDark);
pixel(ctx, baseX, baseY, 10, headY + 5, COLORS.skinDark);
// Gloves/wrists - neon
pixel(ctx, baseX, baseY, 5, headY + 6, COLORS.neonCyan);
pixel(ctx, baseX, baseY, 10, headY + 6, COLORS.neonCyan);
// Legs
pixel(ctx, baseX, baseY, 6, headY + 9 + legOffset, COLORS.black);
pixel(ctx, baseX, baseY, 7, headY + 9 + legOffset, COLORS.black);
pixel(ctx, baseX, baseY, 8, headY + 9 + legOffset, COLORS.black);
pixel(ctx, baseX, baseY, 9, headY + 9 + legOffset, COLORS.black);
// Feet - boots with neon
pixel(ctx, baseX, baseY, 6, headY + 11 + legOffset, COLORS.neonGreen);
pixel(ctx, baseX, baseY, 7, headY + 11 + legOffset, COLORS.neonGreen);
pixel(ctx, baseX, baseY, 8, headY + 11 + legOffset, COLORS.neonGreen);
pixel(ctx, baseX, baseY, 9, headY + 11 + legOffset, COLORS.neonGreen);
} else if (direction === 'up') {
// Facing up - back view
// Hair
pixel(ctx, baseX, baseY, 7, headY, COLORS.darkGray);
pixel(ctx, baseX, baseY, 8, headY, COLORS.darkGray);
pixel(ctx, baseX, baseY, 6, headY + 1, COLORS.darkGray);
pixel(ctx, baseX, baseY, 9, headY + 1, COLORS.darkGray);
// Back of head
pixel(ctx, baseX, baseY, 7, headY + 1, COLORS.skinDark);
pixel(ctx, baseX, baseY, 8, headY + 1, COLORS.skinDark);
// Neck
pixel(ctx, baseX, baseY, 7, headY + 2, COLORS.skinLight);
pixel(ctx, baseX, baseY, 8, headY + 2, COLORS.skinLight);
// Jacket back with neon stripe
pixel(ctx, baseX, baseY, 6, headY + 3, COLORS.black);
pixel(ctx, baseX, baseY, 7, headY + 3, COLORS.neonCyan);
pixel(ctx, baseX, baseY, 8, headY + 3, COLORS.neonCyan);
pixel(ctx, baseX, baseY, 9, headY + 3, COLORS.black);
// Torso
pixel(ctx, baseX, baseY, 6, headY + 4, COLORS.darkGray);
pixel(ctx, baseX, baseY, 7, headY + 4, COLORS.black);
pixel(ctx, baseX, baseY, 8, headY + 4, COLORS.black);
pixel(ctx, baseX, baseY, 9, headY + 4, COLORS.darkGray);
// Waist - neon bands
pixel(ctx, baseX, baseY, 6, headY + 5, COLORS.neonPink);
pixel(ctx, baseX, baseY, 7, headY + 5, COLORS.darkGray);
pixel(ctx, baseX, baseY, 8, headY + 5, COLORS.darkGray);
pixel(ctx, baseX, baseY, 9, headY + 5, COLORS.neonPink);
// Arms back
pixel(ctx, baseX, baseY, 5, headY + 4, COLORS.darkGray);
pixel(ctx, baseX, baseY, 10, headY + 4, COLORS.darkGray);
// Legs
pixel(ctx, baseX, baseY, 6, headY + 9 - legOffset, COLORS.black);
pixel(ctx, baseX, baseY, 7, headY + 9 - legOffset, COLORS.black);
pixel(ctx, baseX, baseY, 8, headY + 9 - legOffset, COLORS.black);
pixel(ctx, baseX, baseY, 9, headY + 9 - legOffset, COLORS.black);
// Feet
pixel(ctx, baseX, baseY, 6, headY + 11 - legOffset, COLORS.neonGreen);
pixel(ctx, baseX, baseY, 7, headY + 11 - legOffset, COLORS.neonGreen);
pixel(ctx, baseX, baseY, 8, headY + 11 - legOffset, COLORS.neonGreen);
pixel(ctx, baseX, baseY, 9, headY + 11 - legOffset, COLORS.neonGreen);
} else if (direction === 'left') {
// Facing left
// Hair
pixel(ctx, baseX, baseY, 6, headY, COLORS.darkGray);
pixel(ctx, baseX, baseY, 7, headY, COLORS.darkGray);
// Face
pixel(ctx, baseX, baseY, 6, headY + 1, COLORS.skinLight);
pixel(ctx, baseX, baseY, 7, headY + 1, COLORS.darkGray);
// Eye (neon)
pixel(ctx, baseX, baseY, 6, headY + 2, COLORS.neonGreen);
// Mouth
pixel(ctx, baseX, baseY, 6, headY + 3, COLORS.red);
// Torso with side view
pixel(ctx, baseX, baseY, 5, headY + 4, COLORS.neonGreen);
pixel(ctx, baseX, baseY, 6, headY + 4, COLORS.black);
pixel(ctx, baseX, baseY, 7, headY + 4, COLORS.darkGray);
pixel(ctx, baseX, baseY, 5, headY + 5, COLORS.neonGreen);
pixel(ctx, baseX, baseY, 6, headY + 5, COLORS.darkGray);
pixel(ctx, baseX, baseY, 7, headY + 5, COLORS.black);
// Left arm
pixel(ctx, baseX, baseY, 4, headY + 5 - walkOffset, COLORS.skinDark);
pixel(ctx, baseX, baseY, 4, headY + 6, COLORS.neonCyan);
// Right arm (back)
pixel(ctx, baseX, baseY, 8, headY + 5 + walkOffset, COLORS.skinDark);
pixel(ctx, baseX, baseY, 8, headY + 6, COLORS.darkGray);
// Legs
pixel(ctx, baseX, baseY, 5, headY + 9, COLORS.black);
pixel(ctx, baseX, baseY, 6, headY + 9, COLORS.black);
pixel(ctx, baseX, baseY, 7, headY + 9, COLORS.darkGray);
// Feet
pixel(ctx, baseX, baseY, 5, headY + 11, COLORS.neonGreen);
pixel(ctx, baseX, baseY, 6, headY + 11, COLORS.neonGreen);
} else if (direction === 'right') {
// Facing right
// Hair
pixel(ctx, baseX, baseY, 8, headY, COLORS.darkGray);
pixel(ctx, baseX, baseY, 9, headY, COLORS.darkGray);
// Face
pixel(ctx, baseX, baseY, 8, headY + 1, COLORS.darkGray);
pixel(ctx, baseX, baseY, 9, headY + 1, COLORS.skinLight);
// Eye (neon)
pixel(ctx, baseX, baseY, 9, headY + 2, COLORS.neonGreen);
// Mouth
pixel(ctx, baseX, baseY, 9, headY + 3, COLORS.red);
// Torso with side view
pixel(ctx, baseX, baseY, 8, headY + 4, COLORS.darkGray);
pixel(ctx, baseX, baseY, 9, headY + 4, COLORS.black);
pixel(ctx, baseX, baseY, 10, headY + 4, COLORS.neonGreen);
pixel(ctx, baseX, baseY, 8, headY + 5, COLORS.black);
pixel(ctx, baseX, baseY, 9, headY + 5, COLORS.darkGray);
pixel(ctx, baseX, baseY, 10, headY + 5, COLORS.neonGreen);
// Left arm (back)
pixel(ctx, baseX, baseY, 7, headY + 5 + walkOffset, COLORS.skinDark);
pixel(ctx, baseX, baseY, 7, headY + 6, COLORS.darkGray);
// Right arm
pixel(ctx, baseX, baseY, 11, headY + 5 - walkOffset, COLORS.skinDark);
pixel(ctx, baseX, baseY, 11, headY + 6, COLORS.neonCyan);
// Legs
pixel(ctx, baseX, baseY, 8, headY + 9, COLORS.darkGray);
pixel(ctx, baseX, baseY, 9, headY + 9, COLORS.black);
pixel(ctx, baseX, baseY, 10, headY + 9, COLORS.black);
// Feet
pixel(ctx, baseX, baseY, 9, headY + 11, COLORS.neonGreen);
pixel(ctx, baseX, baseY, 10, headY + 11, COLORS.neonGreen);
}
}
/**
* Draw a tile
* @param {CanvasRenderingContext2D} ctx
* @param {number} x - Tile X position
* @param {number} y - Tile Y position
* @param {number} tileType - Tile type (0-9)
*/
export function drawTile(ctx, x, y, tileType) {
const baseX = x * SCALE;
const baseY = y * SCALE;
switch (tileType) {
case 0: // Floor - metal grid pattern
rect(ctx, baseX, baseY, 0, 0, 16, 16, COLORS.metalDark);
// Grid pattern
for (let i = 0; i < 16; i += 4) {
rect(ctx, baseX, baseY, i, 0, 1, 16, COLORS.metalLight);
rect(ctx, baseX, baseY, 0, i, 16, 1, COLORS.metalLight);
}
// Corner accents
pixel(ctx, baseX, baseY, 0, 0, COLORS.neonGreen);
pixel(ctx, baseX, baseY, 15, 0, COLORS.neonGreen);
pixel(ctx, baseX, baseY, 0, 15, COLORS.neonGreen);
pixel(ctx, baseX, baseY, 15, 15, COLORS.neonGreen);
break;
case 1: // Wall - solid dark with neon trim
rect(ctx, baseX, baseY, 0, 0, 16, 16, COLORS.darkGray);
rect(ctx, baseX, baseY, 1, 1, 14, 14, COLORS.black);
// Neon edges
rect(ctx, baseX, baseY, 0, 0, 16, 1, COLORS.neonCyan);
rect(ctx, baseX, baseY, 0, 15, 16, 1, COLORS.neonPink);
rect(ctx, baseX, baseY, 0, 0, 1, 16, COLORS.neonPurple);
rect(ctx, baseX, baseY, 15, 0, 1, 16, COLORS.neonOrange);
break;
case 2: // Grass/outdoor ground
rect(ctx, baseX, baseY, 0, 0, 16, 16, COLORS.green);
// Grass tufts
for (let i = 0; i < 16; i += 4) {
for (let j = 0; j < 16; j += 4) {
if ((i + j) % 8 === 0) {
pixel(ctx, baseX, baseY, i, j, COLORS.neonGreen);
pixel(ctx, baseX, baseY, i + 1, j, COLORS.neonGreen);
}
}
}
break;
case 3: // Workshop table
// Table surface
rect(ctx, baseX, baseY, 1, 1, 14, 10, COLORS.copper);
rect(ctx, baseX, baseY, 2, 2, 12, 8, COLORS.lightGray);
// Electronic components on table
rect(ctx, baseX, baseY, 3, 3, 3, 3, COLORS.neonPurple);
rect(ctx, baseX, baseY, 10, 3, 3, 3, COLORS.neonCyan);
rect(ctx, baseX, baseY, 6, 5, 4, 2, COLORS.neonGreen);
// Table legs
rect(ctx, baseX, baseY, 2, 11, 2, 5, COLORS.gray);
rect(ctx, baseX, baseY, 12, 11, 2, 5, COLORS.gray);
break;
case 4: // Puzzle door - locked
rect(ctx, baseX, baseY, 0, 0, 16, 16, COLORS.metalDark);
// Door frame
rect(ctx, baseX, baseY, 1, 1, 14, 14, COLORS.darkGray);
// LED indicators (locked - red)
pixel(ctx, baseX, baseY, 4, 4, COLORS.red);
pixel(ctx, baseX, baseY, 12, 4, COLORS.red);
pixel(ctx, baseX, baseY, 4, 12, COLORS.red);
pixel(ctx, baseX, baseY, 12, 12, COLORS.red);
// Center lock symbol
rect(ctx, baseX, baseY, 6, 6, 4, 4, COLORS.neonPink);
pixel(ctx, baseX, baseY, 8, 8, COLORS.black);
break;
case 5: // Puzzle door - open
rect(ctx, baseX, baseY, 0, 0, 16, 16, COLORS.metalLight);
// Door frame
rect(ctx, baseX, baseY, 1, 1, 14, 14, COLORS.darkGray);
// LED indicators (unlocked - green)
pixel(ctx, baseX, baseY, 4, 4, COLORS.neonGreen);
pixel(ctx, baseX, baseY, 12, 4, COLORS.neonGreen);
pixel(ctx, baseX, baseY, 4, 12, COLORS.neonGreen);
pixel(ctx, baseX, baseY, 12, 12, COLORS.neonGreen);
// Open door effect
rect(ctx, baseX, baseY, 6, 6, 4, 4, COLORS.neonGreen);
break;
case 6: // NPC spot - empty, marked with neon circle
rect(ctx, baseX, baseY, 0, 0, 16, 16, COLORS.darkGray);
// Neon circle outline
for (let i = 3; i < 13; i++) {
pixel(ctx, baseX, baseY, i, 3, COLORS.neonCyan);
pixel(ctx, baseX, baseY, i, 12, COLORS.neonCyan);
pixel(ctx, baseX, baseY, 3, i, COLORS.neonCyan);
pixel(ctx, baseX, baseY, 12, i, COLORS.neonCyan);
}
break;
case 7: // Path/road
rect(ctx, baseX, baseY, 0, 0, 16, 16, COLORS.gray);
// Road markings
rect(ctx, baseX, baseY, 6, 0, 4, 16, COLORS.neonOrange);
// Dashes
for (let i = 0; i < 16; i += 4) {
rect(ctx, baseX, baseY, 7, i, 2, 2, COLORS.black);
}
break;
case 8: // Water/void
rect(ctx, baseX, baseY, 0, 0, 16, 16, COLORS.black);
// Ripple effect (animated-looking)
for (let i = 2; i < 14; i += 3) {
for (let j = 2; j < 14; j += 3) {
if ((i + j) % 6 === 0) {
pixel(ctx, baseX, baseY, i, j, COLORS.neonCyan);
}
}
}
// Neon glow edges
rect(ctx, baseX, baseY, 0, 0, 16, 1, COLORS.neonPurple);
rect(ctx, baseX, baseY, 0, 15, 16, 1, COLORS.neonPurple);
rect(ctx, baseX, baseY, 0, 0, 1, 16, COLORS.neonPurple);
rect(ctx, baseX, baseY, 15, 0, 1, 16, COLORS.neonPurple);
break;
case 9: // Terminal/computer
rect(ctx, baseX, baseY, 0, 0, 16, 16, COLORS.black);
// Screen border
rect(ctx, baseX, baseY, 1, 1, 14, 12, COLORS.metalDark);
rect(ctx, baseX, baseY, 2, 2, 12, 10, COLORS.neonGreen);
// Screen display with scanlines effect
for (let i = 0; i < 10; i += 2) {
rect(ctx, baseX, baseY, 3, 3 + i, 10, 1, COLORS.darkGray);
}
// Keyboard
rect(ctx, baseX, baseY, 2, 13, 12, 2, COLORS.gray);
pixel(ctx, baseX, baseY, 4, 14, COLORS.neonPink);
pixel(ctx, baseX, baseY, 8, 14, COLORS.neonPink);
pixel(ctx, baseX, baseY, 12, 14, COLORS.neonPink);
break;
default:
// Default: empty space
rect(ctx, baseX, baseY, 0, 0, 16, 16, COLORS.black);
}
}
/**
* Draw an NPC character
* @param {CanvasRenderingContext2D} ctx
* @param {number} x - Screen X position
* @param {number} y - Screen Y position
* @param {number} npcType - NPC type (0 = scientist, 1 = guard, 2 = merchant)
* @param {number} frame - Animation frame (0 or 1)
*/
export function drawNPC(ctx, x, y, npcType, frame) {
const baseX = x * SCALE;
const baseY = y * SCALE;
const wobble = frame === 1 ? 1 : 0;
if (npcType === 0) {
// Scientist - lab coat, goggles
// Hair
pixel(ctx, baseX, baseY, 7, 2, COLORS.lightGray);
pixel(ctx, baseX, baseY, 8, 2, COLORS.lightGray);
// Goggles
pixel(ctx, baseX, baseY, 6, 3, COLORS.neonCyan);
pixel(ctx, baseX, baseY, 7, 3, COLORS.neonCyan);
pixel(ctx, baseX, baseY, 8, 3, COLORS.neonCyan);
pixel(ctx, baseX, baseY, 9, 3, COLORS.neonCyan);
pixel(ctx, baseX, baseY, 6, 4, COLORS.black);
pixel(ctx, baseX, baseY, 9, 4, COLORS.black);
// Face
pixel(ctx, baseX, baseY, 7, 4, COLORS.skinLight);
pixel(ctx, baseX, baseY, 8, 4, COLORS.skinLight);
pixel(ctx, baseX, baseY, 7, 5, COLORS.skinLight);
pixel(ctx, baseX, baseY, 8, 5, COLORS.skinLight);
// Nose
pixel(ctx, baseX, baseY, 7, 5, COLORS.skinMid);
// Lab coat - white with neon trim
pixel(ctx, baseX, baseY, 5, 6, COLORS.white);
pixel(ctx, baseX, baseY, 6, 6, COLORS.white);
pixel(ctx, baseX, baseY, 7, 6, COLORS.white);
pixel(ctx, baseX, baseY, 8, 6, COLORS.white);
pixel(ctx, baseX, baseY, 9, 6, COLORS.white);
pixel(ctx, baseX, baseY, 10, 6, COLORS.white);
// Coat buttons - neon
pixel(ctx, baseX, baseY, 7, 7, COLORS.neonGreen);
pixel(ctx, baseX, baseY, 8, 7, COLORS.neonGreen);
// Arms
pixel(ctx, baseX, baseY, 4, 7, COLORS.skinDark);
pixel(ctx, baseX, baseY, 11, 7, COLORS.skinDark);
// Hands
pixel(ctx, baseX, baseY, 4, 8 + wobble, COLORS.skinLight);
pixel(ctx, baseX, baseY, 11, 8 + wobble, COLORS.skinLight);
// Legs
pixel(ctx, baseX, baseY, 6, 10, COLORS.gray);
pixel(ctx, baseX, baseY, 7, 10, COLORS.gray);
pixel(ctx, baseX, baseY, 8, 10, COLORS.gray);
pixel(ctx, baseX, baseY, 9, 10, COLORS.gray);
// Feet
pixel(ctx, baseX, baseY, 6, 12, COLORS.black);
pixel(ctx, baseX, baseY, 7, 12, COLORS.black);
pixel(ctx, baseX, baseY, 8, 12, COLORS.black);
pixel(ctx, baseX, baseY, 9, 12, COLORS.black);
} else if (npcType === 1) {
// Guard - helmet, armor
// Helmet with visor
pixel(ctx, baseX, baseY, 7, 2, COLORS.metalLight);
pixel(ctx, baseX, baseY, 8, 2, COLORS.metalLight);
pixel(ctx, baseX, baseY, 6, 3, COLORS.metalLight);
pixel(ctx, baseX, baseY, 7, 3, COLORS.neonPink);
pixel(ctx, baseX, baseY, 8, 3, COLORS.neonPink);
pixel(ctx, baseX, baseY, 9, 3, COLORS.metalLight);
// Face hidden by visor
pixel(ctx, baseX, baseY, 7, 4, COLORS.black);
pixel(ctx, baseX, baseY, 8, 4, COLORS.black);
// Armor - angular, metallic
pixel(ctx, baseX, baseY, 5, 5, COLORS.metalLight);
pixel(ctx, baseX, baseY, 6, 5, COLORS.metalLight);
pixel(ctx, baseX, baseY, 7, 5, COLORS.metalDark);
pixel(ctx, baseX, baseY, 8, 5, COLORS.metalDark);
pixel(ctx, baseX, baseY, 9, 5, COLORS.metalLight);
pixel(ctx, baseX, baseY, 10, 5, COLORS.metalLight);
// Chest plate
pixel(ctx, baseX, baseY, 5, 6, COLORS.neonPurple);
pixel(ctx, baseX, baseY, 6, 6, COLORS.metalLight);
pixel(ctx, baseX, baseY, 7, 6, COLORS.metalLight);
pixel(ctx, baseX, baseY, 8, 6, COLORS.metalLight);
pixel(ctx, baseX, baseY, 9, 6, COLORS.metalLight);
pixel(ctx, baseX, baseY, 10, 6, COLORS.neonPurple);
// Arms - armored
pixel(ctx, baseX, baseY, 4, 6, COLORS.metalLight);
pixel(ctx, baseX, baseY, 11, 6, COLORS.metalLight);
// Gauntlets - neon edge
pixel(ctx, baseX, baseY, 4, 7, COLORS.neonCyan);
pixel(ctx, baseX, baseY, 11, 7, COLORS.neonCyan);
// Legs - armored
pixel(ctx, baseX, baseY, 6, 10, COLORS.metalLight);
pixel(ctx, baseX, baseY, 7, 10, COLORS.metalLight);
pixel(ctx, baseX, baseY, 8, 10, COLORS.metalLight);
pixel(ctx, baseX, baseY, 9, 10, COLORS.metalLight);
// Boots
pixel(ctx, baseX, baseY, 6, 12, COLORS.neonOrange);
pixel(ctx, baseX, baseY, 7, 12, COLORS.neonOrange);
pixel(ctx, baseX, baseY, 8, 12, COLORS.neonOrange);
pixel(ctx, baseX, baseY, 9, 12, COLORS.neonOrange);
} else if (npcType === 2) {
// Merchant - fancy outfit, hat
// Hat
pixel(ctx, baseX, baseY, 6, 1, COLORS.neonPink);
pixel(ctx, baseX, baseY, 7, 1, COLORS.neonPink);
pixel(ctx, baseX, baseY, 8, 1, COLORS.neonPink);
pixel(ctx, baseX, baseY, 9, 1, COLORS.neonPink);
pixel(ctx, baseX, baseY, 6, 2, COLORS.neonPink);
pixel(ctx, baseX, baseY, 9, 2, COLORS.neonPink);
// Face
pixel(ctx, baseX, baseY, 7, 3, COLORS.skinLight);
pixel(ctx, baseX, baseY, 8, 3, COLORS.skinLight);
pixel(ctx, baseX, baseY, 7, 4, COLORS.skinLight);
pixel(ctx, baseX, baseY, 8, 4, COLORS.skinLight);
// Mustache (fancy)
pixel(ctx, baseX, baseY, 6, 4, COLORS.darkGray);
pixel(ctx, baseX, baseY, 9, 4, COLORS.darkGray);
// Fancy jacket - colorful
pixel(ctx, baseX, baseY, 5, 5, COLORS.neonOrange);
pixel(ctx, baseX, baseY, 6, 5, COLORS.blue);
pixel(ctx, baseX, baseY, 7, 5, COLORS.blue);
pixel(ctx, baseX, baseY, 8, 5, COLORS.blue);
pixel(ctx, baseX, baseY, 9, 5, COLORS.blue);
pixel(ctx, baseX, baseY, 10, 5, COLORS.neonOrange);
// Vest with gems
pixel(ctx, baseX, baseY, 5, 6, COLORS.neonGreen);
pixel(ctx, baseX, baseY, 6, 6, COLORS.blue);
pixel(ctx, baseX, baseY, 7, 6, COLORS.neonCyan);
pixel(ctx, baseX, baseY, 8, 6, COLORS.neonCyan);
pixel(ctx, baseX, baseY, 9, 6, COLORS.blue);
pixel(ctx, baseX, baseY, 10, 6, COLORS.neonGreen);
// Arms
pixel(ctx, baseX, baseY, 4, 6, COLORS.skinDark);
pixel(ctx, baseX, baseY, 11, 6, COLORS.skinDark);
// Rings on fingers - neon
pixel(ctx, baseX, baseY, 4, 7, COLORS.neonOrange);
pixel(ctx, baseX, baseY, 11, 7, COLORS.neonOrange);
// Legs - fancy pants
pixel(ctx, baseX, baseY, 6, 10, COLORS.blue);
pixel(ctx, baseX, baseY, 7, 10, COLORS.blue);
pixel(ctx, baseX, baseY, 8, 10, COLORS.blue);
pixel(ctx, baseX, baseY, 9, 10, COLORS.blue);
// Fancy shoes
pixel(ctx, baseX, baseY, 6, 12, COLORS.neonPink);
pixel(ctx, baseX, baseY, 7, 12, COLORS.neonPink);
pixel(ctx, baseX, baseY, 8, 12, COLORS.neonPink);
pixel(ctx, baseX, baseY, 9, 12, COLORS.neonPink);
}
}
/**
* Draw interaction prompt
* @param {CanvasRenderingContext2D} ctx
* @param {number} x - Screen X position
* @param {number} y - Screen Y position
*/
export function drawInteractionPrompt(ctx, x, y) {
const baseX = x * SCALE;
const baseY = y * SCALE;
ctx.fillStyle = COLORS.neonGreen;
ctx.font = `bold ${12 * SCALE}px monospace`;
ctx.textAlign = 'center';
ctx.textBaseline = 'bottom';
ctx.fillText('E', baseX + 8 * SCALE, baseY);
}
/**
* Draw a dialog box at the bottom of the screen
* @param {CanvasRenderingContext2D} ctx
* @param {number} canvasWidth - Canvas width in pixels
* @param {number} canvasHeight - Canvas height in pixels
* @param {string} text - Dialog text
* @param {string} speakerName - NPC name
*/
export function drawDialogBox(ctx, canvasWidth, canvasHeight, text, speakerName) {
const padding = 20;
const boxHeight = 120;
const boxY = canvasHeight - boxHeight - padding;
const boxX = padding;
const boxWidth = canvasWidth - 2 * padding;
// Dialog box background
ctx.fillStyle = COLORS.black;
ctx.fillRect(boxX, boxY, boxWidth, boxHeight);
// Neon border
ctx.strokeStyle = COLORS.neonCyan;
ctx.lineWidth = 3;
ctx.strokeRect(boxX, boxY, boxWidth, boxHeight);
// Corner accents
ctx.fillStyle = COLORS.neonGreen;
const cornerSize = 10;
ctx.fillRect(boxX, boxY, cornerSize, 3);
ctx.fillRect(boxX, boxY, 3, cornerSize);
ctx.fillRect(boxX + boxWidth - cornerSize, boxY, cornerSize, 3);
ctx.fillRect(boxX + boxWidth - 3, boxY, 3, cornerSize);
// Speaker name - neon green
ctx.fillStyle = COLORS.neonGreen;
ctx.font = `bold 16px monospace`;
ctx.textAlign = 'left';
ctx.textBaseline = 'top';
ctx.fillText(speakerName, boxX + 15, boxY + 10);
// Dialog text - cyan
ctx.fillStyle = COLORS.neonCyan;
ctx.font = `14px monospace`;
ctx.textAlign = 'left';
ctx.textBaseline = 'top';
// Word wrap
const maxWidth = boxWidth - 30;
const lineHeight = 18;
const words = text.split(' ');
let line = '';
let lineNum = 0;
for (const word of words) {
const testLine = line + (line ? ' ' : '') + word;
const metrics = ctx.measureText(testLine);
if (metrics.width > maxWidth && line) {
ctx.fillText(line, boxX + 15, boxY + 40 + lineNum * lineHeight);
line = word;
lineNum++;
} else {
line = testLine;
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;
}
}
if (line) {
ctx.fillText(line, boxX + 15, boxY + 40 + lineNum * lineHeight);
}
// Cursor prompt - blinking indicator
ctx.fillStyle = COLORS.neonPink;
ctx.font = `bold 16px monospace`;
ctx.fillText('▼', boxX + boxWidth - 30, boxY + boxHeight - 20);
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);
}