diff --git a/assets/character/back-still.png b/assets/character/back-still.png new file mode 100644 index 0000000..4b75198 Binary files /dev/null and b/assets/character/back-still.png differ diff --git a/assets/character/back-walk-1.png b/assets/character/back-walk-1.png new file mode 100644 index 0000000..b8487a8 Binary files /dev/null and b/assets/character/back-walk-1.png differ diff --git a/assets/character/back-walk-2.png b/assets/character/back-walk-2.png new file mode 100644 index 0000000..4b75198 Binary files /dev/null and b/assets/character/back-walk-2.png differ diff --git a/assets/character/front-still.png b/assets/character/front-still.png new file mode 100644 index 0000000..b9ccaf7 Binary files /dev/null and b/assets/character/front-still.png differ diff --git a/assets/character/front-walk-1.png b/assets/character/front-walk-1.png new file mode 100644 index 0000000..102108c Binary files /dev/null and b/assets/character/front-walk-1.png differ diff --git a/assets/character/front-walk-2.png b/assets/character/front-walk-2.png new file mode 100644 index 0000000..b9ccaf7 Binary files /dev/null and b/assets/character/front-walk-2.png differ diff --git a/assets/character/left-still.png b/assets/character/left-still.png new file mode 100644 index 0000000..41c6397 Binary files /dev/null and b/assets/character/left-still.png differ diff --git a/assets/character/left-walk-1.png b/assets/character/left-walk-1.png new file mode 100644 index 0000000..9477a52 Binary files /dev/null and b/assets/character/left-walk-1.png differ diff --git a/assets/character/left-walk-2.png b/assets/character/left-walk-2.png new file mode 100644 index 0000000..41c6397 Binary files /dev/null and b/assets/character/left-walk-2.png differ diff --git a/assets/character/right-still.png b/assets/character/right-still.png new file mode 100644 index 0000000..358a93c Binary files /dev/null and b/assets/character/right-still.png differ diff --git a/assets/character/right-walk-1.png b/assets/character/right-walk-1.png new file mode 100644 index 0000000..acbf9d6 Binary files /dev/null and b/assets/character/right-walk-1.png differ diff --git a/assets/character/right-walk-2.png b/assets/character/right-walk-2.png new file mode 100644 index 0000000..358a93c Binary files /dev/null and b/assets/character/right-walk-2.png differ diff --git a/assets/map/house-a-1f.png b/assets/map/house-a-1f.png new file mode 100644 index 0000000..bdb9c89 Binary files /dev/null and b/assets/map/house-a-1f.png differ diff --git a/assets/map/lab.png b/assets/map/lab.png new file mode 100644 index 0000000..42ba608 Binary files /dev/null and b/assets/map/lab.png differ diff --git a/assets/map/pallet-town.png b/assets/map/pallet-town.png new file mode 100644 index 0000000..953ee35 Binary files /dev/null and b/assets/map/pallet-town.png differ diff --git a/assets/map/route-1.png b/assets/map/route-1.png new file mode 100644 index 0000000..05dc04f Binary files /dev/null and b/assets/map/route-1.png differ diff --git a/assets/npcs/a-down.png b/assets/npcs/a-down.png new file mode 100644 index 0000000..2c265da Binary files /dev/null and b/assets/npcs/a-down.png differ diff --git a/assets/npcs/a-left.png b/assets/npcs/a-left.png new file mode 100644 index 0000000..08378e5 Binary files /dev/null and b/assets/npcs/a-left.png differ diff --git a/assets/npcs/a-right.png b/assets/npcs/a-right.png new file mode 100644 index 0000000..c2b71d6 Binary files /dev/null and b/assets/npcs/a-right.png differ diff --git a/assets/npcs/a-up.png b/assets/npcs/a-up.png new file mode 100644 index 0000000..eb513da Binary files /dev/null and b/assets/npcs/a-up.png differ diff --git a/js/world/maps.js b/js/world/maps.js index 9f70a9a..79e8d99 100644 --- a/js/world/maps.js +++ b/js/world/maps.js @@ -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 }; diff --git a/js/world/sprites.js b/js/world/sprites.js index 9d9d701..f7ff5f7 100644 --- a/js/world/sprites.js +++ b/js/world/sprites.js @@ -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); } diff --git a/js/world/worldRenderer.js b/js/world/worldRenderer.js index c717d09..2b88320 100644 --- a/js/world/worldRenderer.js +++ b/js/world/worldRenderer.js @@ -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); diff --git a/js/world/worldState.js b/js/world/worldState.js index faf5de6..a56fca6 100644 --- a/js/world/worldState.js +++ b/js/world/worldState.js @@ -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';