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

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

BIN
assets/map/house-a-1f.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

BIN
assets/map/lab.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

BIN
assets/map/pallet-town.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

BIN
assets/map/route-1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

BIN
assets/npcs/a-down.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

BIN
assets/npcs/a-left.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

BIN
assets/npcs/a-right.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

BIN
assets/npcs/a-up.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@@ -1,271 +1,233 @@
/**
* maps.js - Tile-based world map definitions
* maps.js - PNG-based world maps with wall coordinate arrays
*
* Tile types:
* 0 = floor
* 1 = wall
* 2 = grass
* 3 = workshop table
* 4 = puzzle door locked
* 5 = puzzle door open
* 6 = NPC spot
* 7 = path
* 8 = water
* 9 = terminal
* Each map has a pre-rendered PNG background image and defines:
* - walls: coordinate-based collision data { row: [col1, col2, ...] }
* - npcs, interactions, exits as position-based objects
*
* Map images are drawn at 3x scale (16px native → 48px on screen)
*/
// Helper to fill a rectangular area with a tile type
function fillRect(tiles, x, y, w, h, tileType) {
for (let row = y; row < y + h; row++) {
for (let col = x; col < x + w; col++) {
if (row >= 0 && row < tiles.length && col >= 0 && col < tiles[0].length) {
tiles[row][col] = tileType;
}
}
}
}
// Helper to place a horizontal line
function hline(tiles, x, y, length, tileType) {
for (let i = 0; i < length; i++) {
if (y >= 0 && y < tiles.length && x + i >= 0 && x + i < tiles[0].length) {
tiles[y][x + i] = tileType;
}
}
}
// Helper to place a vertical line
function vline(tiles, x, y, length, tileType) {
for (let i = 0; i < length; i++) {
if (x >= 0 && x < tiles[0].length && y + i >= 0 && y + i < tiles.length) {
tiles[y + i][x] = tileType;
}
}
}
// ==================== Map definitions ====================
/**
* MAP 1: CIRCUIT LAB (20×15)
* Indoor tech lab with workstations, terminals, and a puzzle door
* MAP: LAB (10×12 tiles — lab.png is 160×192)
* Pokemon professor's lab interior
*/
function createLabMap() {
const width = 20;
const height = 15;
const labMap = {
id: 'lab',
name: 'Circuit Lab',
image: 'map:lab',
widthTiles: 10,
heightTiles: 12,
spawn: { x: 4, y: 10 },
// Start with all floor
const tiles = Array(height).fill(null).map(() => Array(width).fill(0));
// Walls: { row: [col, col, ...] }
// Row 0-1: top shelves/machines
walls: {
0: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
1: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
2: [0, 1, 2, 7, 8, 9],
3: [0, 3, 4, 5, 6, 9],
4: [0, 3, 4, 5, 6, 9],
5: [0, 3, 4, 5, 6, 9],
6: [0, 9],
7: [0, 9],
8: [0, 1, 2, 7, 8, 9],
9: [0, 1, 8, 9],
10: [0, 1, 2, 3, 6, 7, 8, 9],
11: [0, 1, 2, 3, 4, 6, 7, 8, 9]
},
// Border walls
for (let i = 0; i < width; i++) {
tiles[0][i] = 1; // top
tiles[height - 1][i] = 1; // bottom
}
for (let i = 0; i < height; i++) {
tiles[i][0] = 1; // left
tiles[i][width - 1] = 1; // right
}
exits: [
// Exit at bottom center — door to town
{ x: 4, y: 11, targetMap: 'town', targetX: 12, targetY: 10 },
{ x: 5, y: 11, targetMap: 'town', targetX: 12, targetY: 10 }
],
// Internal wall structure - create lab rooms
// Vertical divider wall down the middle area
vline(tiles, 13, 1, 10, 1);
npcs: [
{
id: 'professor',
x: 4, y: 3,
facing: 'down',
dialog: [
'Welcome to the Circuit Lab!',
'I\'m the Professor. We study logic gates here.',
'Use the workshop tables to design circuits.',
'Press TAB to open the Workshop anytime!'
]
}
],
// Horizontal divider
hline(tiles, 1, 7, 12, 1);
// Workshop area - left side with tables
tiles[4][3] = 3; // workshop table 1
tiles[4][5] = 3; // workshop table 2
tiles[4][7] = 3; // workshop table 3
tiles[6][3] = 3; // workshop table 4
tiles[6][5] = 3; // workshop table 5
// Terminal near workshop area
tiles[5][10] = 9; // terminal
// Professor NPC spot at top
tiles[2][10] = 6; // NPC spawn location
// Puzzle door leading to back room (top-right area)
tiles[2][16] = 4; // locked puzzle door
// Door opening in the internal wall for navigation
tiles[7][7] = 0; // create passage
// Exit point at bottom (to town)
tiles[13][10] = 0; // clear exit path
return {
id: 'lab',
name: 'Circuit Lab',
width: width,
height: height,
tiles: tiles,
spawn: { x: 10, y: 12 },
exits: [
{ x: 10, y: 13, targetMap: 'town', targetX: 15, targetY: 1 }
],
npcs: [
{
id: 'professor',
type: 0,
x: 10,
y: 2,
facing: 'down',
dialog: [
'Welcome to the Circuit Lab!',
'I\'m the Professor. We study logic gates here.',
'Try using the workshop tables to design circuits.',
'Once you\'ve created some components, you can use them to solve puzzles.'
]
}
],
interactions: [
{ x: 3, y: 4, type: 'workshop', action: 'openWorkshop', label: 'Workshop Table' },
{ x: 5, y: 4, type: 'workshop', action: 'openWorkshop', label: 'Workshop Table' },
{ x: 7, y: 4, type: 'workshop', action: 'openWorkshop', label: 'Workshop Table' },
{ x: 10, y: 5, type: 'terminal', action: 'openTerminal', label: 'Terminal' },
{ x: 16, y: 2, type: 'puzzle_door', puzzleId: 'lab_door_1', requiredOutputs: [1, 0, 1, 1], label: 'Locked Door' }
]
};
}
/**
* MAP 2: NEON TOWN (30×20)
* Outdoor town with buildings, NPCs, water feature, and puzzle areas
*/
function createTownMap() {
const width = 30;
const height = 20;
// Start with grass
const tiles = Array(height).fill(null).map(() => Array(width).fill(2));
// Add some paths (lighter grass/paths)
hline(tiles, 0, 10, width, 7); // horizontal path
vline(tiles, 15, 0, height, 7); // vertical path
// Water feature on the left (pond)
fillRect(tiles, 2, 5, 5, 6, 8);
// Lab entrance at top
tiles[0][15] = 0; // entrance floor
tiles[1][15] = 6; // NPC spawn for entrance
// Building 1 (top-left) - House structure
fillRect(tiles, 5, 2, 7, 5, 1); // walls
fillRect(tiles, 6, 3, 5, 3, 0); // interior floor
tiles[4][8] = 0; // door to building 1
// Building 2 (top-right) - Shop
fillRect(tiles, 20, 2, 7, 5, 1); // walls
fillRect(tiles, 21, 3, 5, 3, 0); // interior floor
tiles[4][23] = 0; // door to building 2
// Building 3 (bottom-left) - Guard post
fillRect(tiles, 5, 14, 7, 5, 1); // walls
fillRect(tiles, 6, 15, 5, 3, 0); // interior floor
tiles[13][8] = 0; // door to guard post
// Building 4 (bottom-right) - Town Hall
fillRect(tiles, 20, 14, 7, 5, 1); // walls
fillRect(tiles, 21, 15, 5, 3, 0); // interior floor
tiles[13][23] = 0; // door to town hall
// Merchant NPC in center town square
tiles[10][15] = 6; // NPC spawn
// Guard NPC at guard post entrance
tiles[13][8] = 6; // NPC spawn (overlays door, but NPC takes priority)
// Puzzle door to eastern area (locked)
tiles[10][28] = 4; // locked puzzle door
return {
id: 'town',
name: 'Neon Town',
width: width,
height: height,
tiles: tiles,
spawn: { x: 15, y: 2 },
exits: [
{ x: 15, y: 0, targetMap: 'lab', targetX: 10, targetY: 13 }
],
npcs: [
{
id: 'merchant',
type: 0,
x: 15,
y: 10,
facing: 'down',
dialog: [
'Welcome to Neon Town!',
'I trade in rare logic components.',
'Show me what circuits you\'ve designed, and maybe we can make a deal.',
'Some items are only available if you\'ve solved certain puzzles.'
]
},
{
id: 'guard',
type: 0,
x: 8,
y: 13,
facing: 'right',
dialog: [
'I guard the eastern territories.',
'You need to solve the puzzle at the gate before you can pass.',
'Bring me a component that produces the right output pattern!'
]
}
],
interactions: [
{ x: 8, y: 4, type: 'door', action: 'openBuilding', label: 'House', buildingId: 'house_1' },
{ x: 23, y: 4, type: 'door', action: 'openBuilding', label: 'Shop', buildingId: 'shop_1' },
{ x: 8, y: 13, type: 'door', action: 'openBuilding', label: 'Guard Post', buildingId: 'guardpost_1' },
{ x: 23, y: 13, type: 'door', action: 'openBuilding', label: 'Town Hall', buildingId: 'townhall_1' },
{ x: 28, y: 10, type: 'puzzle_door', puzzleId: 'town_gate_1', requiredOutputs: [0, 1, 1, 0], label: 'Locked Gate' }
]
};
}
// Map registry
const maps = {
lab: createLabMap(),
town: createTownMap()
interactions: [
// Workshop tables (the big table in the middle of lab)
{ x: 3, y: 5, type: 'workshop', label: 'Workshop Table' },
{ x: 4, y: 5, type: 'workshop', label: 'Workshop Table' },
{ x: 5, y: 5, type: 'workshop', label: 'Workshop Table' },
{ x: 6, y: 5, type: 'workshop', label: 'Workshop Table' },
// Machine/bookshelf
{ x: 8, y: 2, type: 'terminal', label: 'Terminal',
dialog: ['Circuit analysis terminal.', 'Connect components to solve puzzles.'] },
// Puzzle door in top-right area
{ x: 8, y: 3, type: 'puzzle_door', puzzleId: 'lab_door_1',
requiredOutputs: [1, 0, 1, 1], label: 'Locked Door' }
]
};
/**
* Get a complete map by ID
* MAP: TOWN (20×18 tiles — pallet-town.png is 320×288)
* Pokemon-style starting town with houses and paths
*/
const townMap = {
id: 'town',
name: 'Neon Town',
image: 'map:pallet-town',
widthTiles: 20,
heightTiles: 18,
spawn: { x: 9, y: 9 },
// Walls based on pallet-town visual layout
// Trees around border, houses, fences, water
walls: (() => {
const w = {};
// Helper to add walls
function addWall(row, cols) {
if (!w[row]) w[row] = [];
w[row].push(...cols);
}
function addRange(row, from, to) {
const cols = [];
for (let c = from; c <= to; c++) cols.push(c);
addWall(row, cols);
}
function addRect(rowStart, rowEnd, colStart, colEnd) {
for (let r = rowStart; r <= rowEnd; r++) addRange(r, colStart, colEnd);
}
// Top border (trees/fence) — rows 0-1
addRange(0, 0, 19);
addRange(1, 0, 19);
// Left border trees
for (let r = 2; r <= 17; r++) addWall(r, [0, 1]);
// Right border trees
for (let r = 2; r <= 17; r++) addWall(r, [18, 19]);
// Bottom border
addRange(17, 0, 19);
// House 1 (top-left area) — roughly rows 3-6, cols 3-7
addRect(3, 5, 3, 7);
// House 2 (top-right area) — rows 3-6, cols 12-16
addRect(3, 5, 12, 16);
// Fence segments
addRange(7, 2, 7);
addRange(7, 12, 17);
// Water/pond (bottom-left)
addRect(13, 15, 2, 5);
// Some trees/obstacles in bottom area
addWall(16, [2, 3, 4, 5, 6, 7]);
addWall(16, [12, 13, 14, 15, 16, 17]);
return w;
})(),
exits: [
// North exit — goes to lab
{ x: 12, y: 9, targetMap: 'lab', targetX: 4, targetY: 10 },
// Route 1 south (future)
// { x: 9, y: 17, targetMap: 'route1', targetX: 10, targetY: 0 }
],
npcs: [
{
id: 'merchant',
x: 9, y: 11,
facing: 'down',
dialog: [
'Welcome to Neon Town!',
'I trade in rare logic components.',
'Craft some circuits in the Lab workshop!',
'Some doors need special output patterns to open.'
]
},
{
id: 'guide',
x: 14, y: 9,
facing: 'left',
dialog: [
'The Circuit Lab is just up ahead.',
'Professor Oak.. I mean, the Professor can teach you about logic gates!',
'Press TAB anytime to open your Workshop.'
]
}
],
interactions: [
// House 1 door
{ x: 5, y: 6, type: 'door', label: 'House',
dialog: ['The door is locked.', 'Nobody seems to be home.'] },
// House 2 door
{ x: 14, y: 6, type: 'door', label: 'House',
dialog: ['This is the component shop.', 'Coming soon!'] },
// Sign
{ x: 10, y: 8, type: 'sign', label: 'Sign',
dialog: ['Welcome to Neon Town!', 'Circuit Lab ↑'] }
]
};
// ==================== Map registry ====================
const maps = {
lab: labMap,
town: townMap
};
// ==================== Public API ====================
export function getMap(id) {
return maps[id] || null;
}
/**
* Get tile type at position
* Check if a tile position is a wall
*/
export function getTile(mapId, x, y) {
export function isWall(mapId, x, y) {
const map = maps[mapId];
if (!map) return null;
if (x < 0 || x >= map.width || y < 0 || y >= map.height) return null;
return map.tiles[y][x];
if (!map) return true;
// Out of bounds = wall
if (x < 0 || x >= map.widthTiles || y < 0 || y >= map.heightTiles) return true;
const row = map.walls[y];
if (!row) return false;
return row.includes(x);
}
/**
* Get interaction at position (if any)
* Check if a tile is walkable (not a wall and no NPC blocking)
*/
export function isWalkable(mapId, x, y) {
if (isWall(mapId, x, y)) return false;
if (getNPC(mapId, x, y)) return false;
return true;
}
/**
* Get interaction at position
*/
export function getInteraction(mapId, x, y) {
const map = maps[mapId];
if (!map) return null;
return map.interactions.find(inter => inter.x === x && inter.y === y) || null;
return map.interactions.find(i => i.x === x && i.y === y) || null;
}
/**
* Get NPC at position (if any)
* Get NPC at position
*/
export function getNPC(mapId, x, y) {
const map = maps[mapId];
@@ -274,32 +236,17 @@ export function getNPC(mapId, x, y) {
}
/**
* Get exit at position (if any)
* Get exit at position
*/
export function getExit(mapId, x, y) {
const map = maps[mapId];
if (!map) return null;
return map.exits.find(exit => exit.x === x && exit.y === y) || null;
return map.exits.find(e => e.x === x && e.y === y) || null;
}
/**
* Check if a tile is walkable
* Walls (1), water (8), workshop tables (3), terminals (9), locked puzzle doors (4), and NPCs are not walkable
*/
export function isWalkable(mapId, x, y) {
const tile = getTile(mapId, x, y);
// Out of bounds
if (tile === null) return false;
// Non-walkable tiles
const nonWalkable = [1, 3, 4, 8, 9];
if (nonWalkable.includes(tile)) return false;
// Check for NPC
if (getNPC(mapId, x, y)) return false;
return true;
// No longer needed but keep for compat — returns null always
export function getTile(mapId, x, y) {
return isWall(mapId, x, y) ? 1 : 0;
}
export { maps };

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);
}

View File

@@ -1,7 +1,10 @@
// worldRenderer.js - Renders the tile-based game world on canvas
import { drawPlayer, drawTile, drawNPC, drawInteractionPrompt, drawDialogBox, TILE_SIZE, SCALE } from './sprites.js';
// worldRenderer.js - Renders PNG-based game world on canvas
import {
drawMapImage, drawPlayer, drawNPC, drawInteractionPrompt,
drawDialogBox, preloadAssets, TILE_PX, SCALE
} from './sprites.js';
import { worldState } from './worldState.js';
import { getMap, getTile, getInteraction, getNPC } from './maps.js';
import { getMap, getInteraction, getNPC } from './maps.js';
import { updateMovement } from './worldInput.js';
let canvas = null;
@@ -9,14 +12,11 @@ let ctx = null;
let animFrameId = null;
let lastTime = 0;
const TILE_PX = TILE_SIZE * SCALE; // 48px per tile on screen
export function initWorldRenderer() {
canvas = document.getElementById('canvas');
ctx = canvas.getContext('2d');
resizeCanvas();
window.addEventListener('resize', resizeCanvas);
return true;
}
function resizeCanvas() {
@@ -25,36 +25,30 @@ function resizeCanvas() {
canvas.height = canvas.offsetHeight || window.innerHeight;
}
/** Convert tile coords → screen pixel coords (camera-relative) */
export function worldToScreen(tileX, tileY) {
// ==================== Camera ====================
/** Get the pixel offset to draw the map so the player is centered */
function getCameraOffset() {
const p = worldState.player;
const cx = canvas.width / 2;
const cy = canvas.height / 2;
// Player world position with interpolation
const pwx = (p.x + p.px) * TILE_PX;
const pwy = (p.y + p.py) * TILE_PX;
const playerWorldX = (p.x + p.px) * TILE_PX;
const playerWorldY = (p.y + p.py) * TILE_PX;
return {
x: cx + (tileX * TILE_PX - pwx),
y: cy + (tileY * TILE_PX - pwy)
x: canvas.width / 2 - playerWorldX - TILE_PX / 2,
y: canvas.height / 2 - playerWorldY - TILE_PX / 2
};
}
/** Visible tile range for culling */
function getVisibleBounds() {
const p = worldState.player;
const halfW = canvas.width / 2;
const halfH = canvas.height / 2;
const pwx = (p.x + p.px) * TILE_PX;
const pwy = (p.y + p.py) * TILE_PX;
/** Convert tile position to screen position */
function tileToScreen(tileX, tileY) {
const cam = getCameraOffset();
return {
minX: Math.floor((pwx - halfW) / TILE_PX) - 1,
minY: Math.floor((pwy - halfH) / TILE_PX) - 1,
maxX: Math.ceil((pwx + halfW) / TILE_PX) + 1,
maxY: Math.ceil((pwy + halfH) / TILE_PX) + 1
x: tileX * TILE_PX + cam.x,
y: tileY * TILE_PX + cam.y
};
}
/** Tile the player is facing */
// ==================== Facing tile ====================
function getFacingTile() {
const p = worldState.player;
let x = p.x, y = p.y;
@@ -65,12 +59,13 @@ function getFacingTile() {
return { x, y };
}
/** Main render frame */
// ==================== Main render ====================
export function renderWorld(timestamp) {
const dt = (timestamp - lastTime) / 1000;
lastTime = timestamp;
// Update movement interpolation
// Update movement
updateMovement(dt);
// Resize check
@@ -85,34 +80,34 @@ export function renderWorld(timestamp) {
const map = getMap(worldState.currentMap);
if (!map) return;
const bounds = getVisibleBounds();
const cam = getCameraOffset();
// === Layer 1: Tiles ===
for (let ty = bounds.minY; ty <= bounds.maxY; ty++) {
for (let tx = bounds.minX; tx <= bounds.maxX; tx++) {
const tileType = getTile(worldState.currentMap, tx, ty);
if (tileType === null) continue;
const pos = worldToScreen(tx, ty);
drawTile(ctx, pos.x, pos.y, tileType);
}
}
// === Layer 1: Map background (PNG) ===
drawMapImage(ctx, map.image, cam.x, cam.y);
// === Layer 2: NPCs ===
if (map.npcs) {
for (const npc of map.npcs) {
const pos = worldToScreen(npc.x, npc.y);
drawNPC(ctx, pos.x, pos.y, npc.type, 0);
const pos = tileToScreen(npc.x, npc.y);
drawNPC(ctx, pos.x, pos.y, npc.facing || 'down');
}
}
// === Layer 3: Player ===
const pp = worldToScreen(worldState.player.x + worldState.player.px,
worldState.player.y + worldState.player.py);
// Adjust: worldToScreen already offsets from player, so player is always at center
const pcx = canvas.width / 2 - TILE_PX / 2;
const pcy = canvas.height / 2 - TILE_PX / 2;
const frame = worldState.player.moving ? (Math.floor(Date.now() / 120) % 2) : 0;
drawPlayer(ctx, pcx, pcy, worldState.player.direction, frame);
// Character sprite is 32x32 native (2 tiles tall)
// Position so bottom half aligns with player tile, top half overlaps above
const playerScreen = tileToScreen(
worldState.player.x + worldState.player.px,
worldState.player.y + worldState.player.py
);
// Offset upward by 1 tile since character is 2 tiles tall
const playerDrawX = playerScreen.x;
const playerDrawY = playerScreen.y - TILE_PX;
const walkFrame = worldState.player.moving
? (Math.floor(Date.now() / 150) % 2) + 1 // alternates 1, 2
: 0;
drawPlayer(ctx, playerDrawX, playerDrawY, worldState.player.direction, walkFrame);
// === Layer 4: Interaction prompt ===
if (!worldState.dialog && !worldState.player.moving) {
@@ -120,12 +115,12 @@ export function renderWorld(timestamp) {
const inter = getInteraction(worldState.currentMap, ft.x, ft.y);
const npc = getNPC(worldState.currentMap, ft.x, ft.y);
if (inter || npc) {
const pos = worldToScreen(ft.x, ft.y);
const pos = tileToScreen(ft.x, ft.y);
drawInteractionPrompt(ctx, pos.x, pos.y);
}
}
// === Layer 5: Dialog box ===
// === Layer 5: Dialog ===
if (worldState.dialog) {
const line = worldState.dialog.lines[worldState.dialog.currentLine] || '';
const speaker = worldState.dialog.speakerName || '';
@@ -133,11 +128,10 @@ export function renderWorld(timestamp) {
}
// === HUD ===
drawHUD();
drawHUD(map);
}
function drawHUD() {
const map = getMap(worldState.currentMap);
function drawHUD(map) {
const mapName = map ? map.name : worldState.currentMap;
// Background bar
@@ -164,7 +158,12 @@ function drawHUD() {
ctx.fillText('WASD: Move | E: Interact | TAB: Workshop', canvas.width / 2, 16);
}
export function startWorldLoop() {
// ==================== Loop control ====================
export async function startWorldLoop() {
// Ensure assets are loaded before starting
await preloadAssets();
lastTime = performance.now();
function loop(ts) {
renderWorld(ts);

View File

@@ -11,8 +11,8 @@ export const worldState = {
// Player
player: {
x: 10,
y: 12, // tile position in current map
x: 4,
y: 10, // tile position in current map
px: 0,
py: 0, // pixel offset for smooth movement (interpolation)
direction: 'down', // 'up' | 'down' | 'left' | 'right'
@@ -58,8 +58,8 @@ export const worldState = {
*/
export function resetWorldState() {
worldState.mode = 'world';
worldState.player.x = 10;
worldState.player.y = 12;
worldState.player.x = 4;
worldState.player.y = 10;
worldState.player.px = 0;
worldState.player.py = 0;
worldState.player.direction = 'down';