From e4cf35701e0f71247dc1920cb55110615b5605cc Mon Sep 17 00:00:00 2001 From: Jose Luis Date: Fri, 20 Mar 2026 15:52:13 +0100 Subject: [PATCH] feat: add Pokemon-style world mode with workshop integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two-mode game: explore a tile-based cyberpunk world, then enter Workshop mode (the existing circuit editor) to craft components. New modules (js/world/): - sprites.js: programmatic pixel-art renderer (16x16 tiles, 3x scale) - maps.js: tile-based map definitions (lab + town) - worldState.js: player position, inventory, dialog, puzzle state - worldRenderer.js: camera-following world renderer on shared canvas - worldInput.js: WASD movement, E interaction, dialog system - gameMode.js: central mode switcher (world ↔ workshop) Changes to existing code: - app.js: boots into world mode, registers circuit editor for workshop - renderer.js: circuit draw loop now stoppable (start/stopCircuitLoop) - index.html: added "Back to World" button for workshop mode Co-Authored-By: Claude Opus 4.6 --- index.html | 3 + js/app.js | 40 ++- js/renderer.js | 23 +- js/world/gameMode.js | 173 ++++++++++ js/world/maps.js | 305 +++++++++++++++++ js/world/sprites.js | 694 ++++++++++++++++++++++++++++++++++++++ js/world/worldInput.js | 191 +++++++++++ js/world/worldRenderer.js | 181 ++++++++++ js/world/worldState.js | 307 +++++++++++++++++ 9 files changed, 1903 insertions(+), 14 deletions(-) create mode 100644 js/world/gameMode.js create mode 100644 js/world/maps.js create mode 100644 js/world/sprites.js create mode 100644 js/world/worldInput.js create mode 100644 js/world/worldRenderer.js create mode 100644 js/world/worldState.js diff --git a/index.html b/index.html index f4a3e00..da43b1f 100644 --- a/index.html +++ b/index.html @@ -7,6 +7,9 @@ + + +
diff --git a/js/app.js b/js/app.js index fca7861..c9a9adc 100644 --- a/js/app.js +++ b/js/app.js @@ -1,22 +1,40 @@ -// Entry point — initializes all modules -import { initRenderer } from './renderer.js'; +// Entry point — initializes game (world + workshop modes) +import { initRenderer, resize } from './renderer.js'; import { initEvents } from './events.js'; import { initPuzzleUI } from './puzzleUI.js'; import { loadFromStorage, startAutoSave } from './saveLoad.js'; import { updateComponentButtons } from './components.js'; import { evaluateAll } from './gates.js'; +import { startGame, registerCircuitEditor, enterWorldMode } from './world/gameMode.js'; document.addEventListener('DOMContentLoaded', () => { - initRenderer(); - initEvents(); - initPuzzleUI(); + // Register circuit editor init/destroy so gameMode can switch to workshop + registerCircuitEditor( + // init workshop + () => { + initRenderer(); + initEvents(); + initPuzzleUI(); + if (loadFromStorage()) { + updateComponentButtons(); + evaluateAll(); + } + startAutoSave(3000); + }, + // destroy workshop (cleanup when switching back to world) + () => { + // Auto-save is fine to leave running + } + ); - // Restore previous session from localStorage - if (loadFromStorage()) { - updateComponentButtons(); - evaluateAll(); + // Add back-to-world button handler + const backBtn = document.getElementById('back-to-world-btn'); + if (backBtn) { + backBtn.addEventListener('click', () => { + enterWorldMode(); + }); } - // Auto-save every 3 seconds + on page unload - startAutoSave(3000); + // Start the game in world mode + startGame(); }); diff --git a/js/renderer.js b/js/renderer.js index 71b12f6..aaddf5b 100644 --- a/js/renderer.js +++ b/js/renderer.js @@ -6,6 +6,8 @@ import { getGateLabel, drawWaveLabels, drawWaveforms } from './waveform.js'; import { getBusPairs } from './bus.js'; let canvas, ctx; +let circuitAnimFrameId = null; +let rendererInitialized = false; /** * Read the value arriving at an input port by looking up the source gate/port. @@ -25,8 +27,23 @@ export function initRenderer() { canvas = document.getElementById('canvas'); ctx = canvas.getContext('2d'); resize(); - window.addEventListener('resize', resize); - requestAnimationFrame(draw); + if (!rendererInitialized) { + window.addEventListener('resize', resize); + rendererInitialized = true; + } + startCircuitLoop(); +} + +export function startCircuitLoop() { + if (circuitAnimFrameId) return; // already running + circuitAnimFrameId = requestAnimationFrame(draw); +} + +export function stopCircuitLoop() { + if (circuitAnimFrameId) { + cancelAnimationFrame(circuitAnimFrameId); + circuitAnimFrameId = null; + } } export function resize() { @@ -646,5 +663,5 @@ function draw() { drawWaveforms(); } - requestAnimationFrame(draw); + circuitAnimFrameId = requestAnimationFrame(draw); } diff --git a/js/world/gameMode.js b/js/world/gameMode.js new file mode 100644 index 0000000..bfc1049 --- /dev/null +++ b/js/world/gameMode.js @@ -0,0 +1,173 @@ +// gameMode.js - Central coordinator: switches between World and Workshop modes +import { worldState, setPlayerPosition, warpToMap, solvePuzzle, isPuzzleSolved } from './worldState.js'; +import { initWorldRenderer, startWorldLoop, stopWorldLoop } from './worldRenderer.js'; +import { initWorldInput, destroyWorldInput, setInteractionHandler } from './worldInput.js'; +import { getMap } from './maps.js'; + +// Circuit editor stop function (to stop its render loop when switching modes) +import { stopCircuitLoop } from '../renderer.js'; + +// Circuit editor modules (registered from app.js to avoid circular deps) +let circuitEditorInit = null; +let circuitEditorDestroy = null; + +let currentMode = 'none'; // 'world' | 'workshop' + +/** + * Register the circuit editor's init/destroy functions. + * Called from app.js so we don't create circular imports. + */ +export function registerCircuitEditor(initFn, destroyFn) { + circuitEditorInit = initFn; + circuitEditorDestroy = destroyFn; +} + +/** + * Boot the game — start in world mode + */ +export function startGame() { + // Set spawn + const map = getMap(worldState.currentMap); + if (map && map.spawn) { + setPlayerPosition(map.spawn.x, map.spawn.y); + } + + // Wire up interaction handler + setInteractionHandler(handleInteraction); + + // Enter world mode + enterWorldMode(); +} + +// ==================== Mode switching ==================== + +export function enterWorldMode() { + if (currentMode === 'world') return; + + // Tear down workshop if active + if (currentMode === 'workshop') { + stopCircuitLoop(); + if (circuitEditorDestroy) circuitEditorDestroy(); + hideWorkshopUI(); + } + + currentMode = 'world'; + worldState.mode = 'world'; + + showWorldUI(); + initWorldRenderer(); + initWorldInput(); + startWorldLoop(); + + console.log('[gameMode] entered world mode'); +} + +export function enterWorkshopMode() { + if (currentMode === 'workshop') return; + + // Tear down world + if (currentMode === 'world') { + stopWorldLoop(); + destroyWorldInput(); + hideWorldUI(); + } + + currentMode = 'workshop'; + worldState.mode = 'workshop'; + + showWorkshopUI(); + if (circuitEditorInit) circuitEditorInit(); + + console.log('[gameMode] entered workshop mode'); +} + +export function getCurrentMode() { return currentMode; } + +// ==================== Interaction handler ==================== + +function handleInteraction(event) { + switch (event.type) { + case 'enterWorkshop': + enterWorkshopMode(); + break; + + case 'puzzleDoor': { + const inter = event.data; + if (isPuzzleSolved(inter.puzzleId)) { + // Already solved — could open door, show message, etc. + return; + } + // For now, show a hint dialog. Later: open puzzle UI + worldState.dialog = { + lines: [ + 'This door requires a logic circuit to open.', + `Required output pattern: [${inter.requiredOutputs.join(', ')}]`, + 'Craft a component in your Workshop (TAB)!' + ], + currentLine: 0, + speakerName: 'System' + }; + worldState.mode = 'dialog'; + break; + } + + case 'mapExit': { + const { targetMap, targetX, targetY } = event.data; + warpToMap(targetMap, targetX, targetY); + console.log(`[gameMode] warped to ${targetMap} (${targetX}, ${targetY})`); + break; + } + + case 'openInventory': + // TODO: inventory UI + console.log('[gameMode] inventory:', worldState.inventory); + break; + } +} + +// ==================== UI visibility ==================== + +function showWorldUI() { + // Hide workshop-specific elements + const toolbar = document.getElementById('toolbar'); + const wavePanel = document.getElementById('waveform-panel'); + const canvas = document.getElementById('canvas'); + + if (toolbar) toolbar.style.display = 'none'; + if (wavePanel) wavePanel.style.display = 'none'; + if (canvas) { + canvas.style.top = '0'; + canvas.style.cursor = 'default'; + } + + // Show back-to-world button (hidden since we're IN world) + const backBtn = document.getElementById('back-to-world-btn'); + if (backBtn) backBtn.style.display = 'none'; +} + +function hideWorldUI() { + // Nothing special to hide — canvas stays +} + +function showWorkshopUI() { + const toolbar = document.getElementById('toolbar'); + const canvas = document.getElementById('canvas'); + + if (toolbar) toolbar.style.display = 'flex'; + if (canvas) { + canvas.style.top = '56px'; + canvas.style.cursor = 'default'; + } + + // Show back-to-world button + const backBtn = document.getElementById('back-to-world-btn'); + if (backBtn) backBtn.style.display = 'flex'; +} + +function hideWorkshopUI() { + const toolbar = document.getElementById('toolbar'); + if (toolbar) toolbar.style.display = 'none'; + + const backBtn = document.getElementById('back-to-world-btn'); + if (backBtn) backBtn.style.display = 'none'; +} diff --git a/js/world/maps.js b/js/world/maps.js new file mode 100644 index 0000000..9f70a9a --- /dev/null +++ b/js/world/maps.js @@ -0,0 +1,305 @@ +/** + * maps.js - Tile-based world map definitions + * + * 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 + */ + +// 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 1: CIRCUIT LAB (20×15) + * Indoor tech lab with workstations, terminals, and a puzzle door + */ +function createLabMap() { + const width = 20; + const height = 15; + + // Start with all floor + const tiles = Array(height).fill(null).map(() => Array(width).fill(0)); + + // 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 + } + + // Internal wall structure - create lab rooms + // Vertical divider wall down the middle area + vline(tiles, 13, 1, 10, 1); + + // 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() +}; + +/** + * Get a complete map by ID + */ +export function getMap(id) { + return maps[id] || null; +} + +/** + * Get tile type at position + */ +export function getTile(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]; +} + +/** + * Get interaction at position (if any) + */ +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; +} + +/** + * Get NPC at position (if any) + */ +export function getNPC(mapId, x, y) { + const map = maps[mapId]; + if (!map) return null; + return map.npcs.find(npc => npc.x === x && npc.y === y) || null; +} + +/** + * Get exit at position (if any) + */ +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; +} + +/** + * 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; +} + +export { maps }; diff --git a/js/world/sprites.js b/js/world/sprites.js new file mode 100644 index 0000000..9d9d701 --- /dev/null +++ b/js/world/sprites.js @@ -0,0 +1,694 @@ +// Cyberpunk pixel-art sprite system +// All sprites drawn on canvas, no image assets +// 16x16 tile size with 3x scaling for screen rendering + +export const TILE_SIZE = 16; +export const SCALE = 3; + +// Color palette +const COLORS = { + // Neon palette + neonGreen: '#00e599', + neonPink: '#ff44aa', + neonPurple: '#9900ff', + neonCyan: '#44ddff', + neonOrange: '#ff8844', + + // Dark palette + black: '#0a0e27', + darkGray: '#1a1f3a', + gray: '#3a3f5a', + lightGray: '#5a5f7a', + + // Skin tones & details + skinLight: '#d4a574', + skinMid: '#c89860', + skinDark: '#a0704c', + + // Material colors + metalDark: '#2a2f4a', + metalLight: '#4a4f6a', + copper: '#b87333', + blue: '#4488dd', + red: '#ee4444', + green: '#44aa44', + white: '#ffffff', +}; + +/** + * 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); +} + +/** + * Draw a filled rectangle in tile space + */ +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); +} + +/** + * 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) + */ +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; + } + } + + 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); +} diff --git a/js/world/worldInput.js b/js/world/worldInput.js new file mode 100644 index 0000000..8481787 --- /dev/null +++ b/js/world/worldInput.js @@ -0,0 +1,191 @@ +// worldInput.js - Keyboard input for world mode +import { worldState, advanceDialog, startDialog } from './worldState.js'; +import { getMap, getInteraction, getNPC, getExit, isWalkable } from './maps.js'; + +const keysDown = new Set(); +let interactionHandler = null; + +export function setInteractionHandler(fn) { interactionHandler = fn; } + +export function initWorldInput() { + document.addEventListener('keydown', onKeyDown); + document.addEventListener('keyup', onKeyUp); +} + +export function destroyWorldInput() { + document.removeEventListener('keydown', onKeyDown); + document.removeEventListener('keyup', onKeyUp); + keysDown.clear(); +} + +// ---- Key handlers ---- + +function onKeyDown(e) { + const key = e.key; + keysDown.add(key); + + // During dialog: advance on action keys + if (worldState.dialog) { + if (key === 'Enter' || key === ' ' || key === 'e' || key === 'E') { + e.preventDefault(); + if (!advanceDialog()) { + // Dialog ended + } + } + return; + } + + // Workshop shortcut (TAB) + if (key === 'Tab') { + e.preventDefault(); + if (interactionHandler) interactionHandler({ type: 'enterWorkshop' }); + return; + } + + // Interaction (E / Enter / Space) + if (key === 'e' || key === 'E' || key === 'Enter' || key === ' ') { + e.preventDefault(); + performInteraction(); + return; + } + + // Movement (handled in updateMovement via keysDown) + const dir = keyToDir(key); + if (dir) e.preventDefault(); +} + +function onKeyUp(e) { + keysDown.delete(e.key); +} + +// ---- Direction mapping ---- + +function keyToDir(key) { + if (key === 'ArrowUp' || key === 'w' || key === 'W') return 'up'; + if (key === 'ArrowDown' || key === 's' || key === 'S') return 'down'; + if (key === 'ArrowLeft' || key === 'a' || key === 'A') return 'left'; + if (key === 'ArrowRight' || key === 'd' || key === 'D') return 'right'; + return null; +} + +/** Get the currently pressed direction (prioritizes most recent) */ +function getHeldDirection() { + // Check in order of specificity + for (const key of keysDown) { + const dir = keyToDir(key); + if (dir) return dir; + } + return null; +} + +// ---- Movement ---- + +const MOVE_DURATION = 0.15; // seconds per tile + +/** + * Called each frame by the renderer. + * Handles movement interpolation and starting new moves. + */ +export function updateMovement(dt) { + const p = worldState.player; + + if (p.moving) { + // Advance interpolation + p._moveProgress = (p._moveProgress || 0) + dt / MOVE_DURATION; + + if (p._moveProgress >= 1) { + // Snap to target + p.x = p._targetX; + p.y = p._targetY; + p.px = 0; + p.py = 0; + p.moving = false; + p._moveProgress = 0; + + // Check map exit + checkMapExit(); + + // Continue moving if key held + const dir = getHeldDirection(); + if (dir) tryMove(dir); + } else { + // Interpolate + p.px = (p._targetX - p._startX) * p._moveProgress; + p.py = (p._targetY - p._startY) * p._moveProgress; + } + } else { + // Not moving — check if direction key is held + const dir = getHeldDirection(); + if (dir) tryMove(dir); + } +} + +function tryMove(direction) { + const p = worldState.player; + p.direction = direction; + + let tx = p.x, ty = p.y; + if (direction === 'up') ty--; + else if (direction === 'down') ty++; + else if (direction === 'left') tx--; + else if (direction === 'right') tx++; + + if (!isWalkable(worldState.currentMap, tx, ty)) return; + + // Start movement + p._startX = p.x; + p._startY = p.y; + p._targetX = tx; + p._targetY = ty; + p._moveProgress = 0; + p.moving = true; +} + +// ---- Interaction ---- + +function performInteraction() { + if (worldState.player.moving) return; + + const p = worldState.player; + let fx = p.x, fy = p.y; + if (p.direction === 'up') fy--; + else if (p.direction === 'down') fy++; + else if (p.direction === 'left') fx--; + else if (p.direction === 'right') fx++; + + // NPC? + const npc = getNPC(worldState.currentMap, fx, fy); + if (npc && npc.dialog) { + startDialog(npc.dialog, npc.id); + return; + } + + // Interaction tile? + const inter = getInteraction(worldState.currentMap, fx, fy); + if (!inter) return; + + switch (inter.type) { + case 'workshop': + if (interactionHandler) interactionHandler({ type: 'enterWorkshop', data: inter }); + break; + case 'puzzle_door': + if (interactionHandler) interactionHandler({ type: 'puzzleDoor', data: inter }); + break; + default: + if (inter.dialog) startDialog(inter.dialog, ''); + break; + } +} + +// ---- Map transitions ---- + +function checkMapExit() { + const p = worldState.player; + const exit = getExit(worldState.currentMap, p.x, p.y); + if (exit && interactionHandler) { + interactionHandler({ + type: 'mapExit', + data: { targetMap: exit.targetMap, targetX: exit.targetX, targetY: exit.targetY } + }); + } +} diff --git a/js/world/worldRenderer.js b/js/world/worldRenderer.js new file mode 100644 index 0000000..c717d09 --- /dev/null +++ b/js/world/worldRenderer.js @@ -0,0 +1,181 @@ +// worldRenderer.js - Renders the tile-based game world on canvas +import { drawPlayer, drawTile, drawNPC, drawInteractionPrompt, drawDialogBox, TILE_SIZE, SCALE } from './sprites.js'; +import { worldState } from './worldState.js'; +import { getMap, getTile, getInteraction, getNPC } from './maps.js'; +import { updateMovement } from './worldInput.js'; + +let canvas = null; +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() { + if (!canvas) return; + canvas.width = canvas.offsetWidth || window.innerWidth; + canvas.height = canvas.offsetHeight || window.innerHeight; +} + +/** Convert tile coords → screen pixel coords (camera-relative) */ +export function worldToScreen(tileX, tileY) { + 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; + return { + x: cx + (tileX * TILE_PX - pwx), + y: cy + (tileY * TILE_PX - pwy) + }; +} + +/** 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; + 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 + }; +} + +/** Tile the player is facing */ +function getFacingTile() { + const p = worldState.player; + let x = p.x, y = p.y; + if (p.direction === 'up') y--; + else if (p.direction === 'down') y++; + else if (p.direction === 'left') x--; + else if (p.direction === 'right') x++; + return { x, y }; +} + +/** Main render frame */ +export function renderWorld(timestamp) { + const dt = (timestamp - lastTime) / 1000; + lastTime = timestamp; + + // Update movement interpolation + updateMovement(dt); + + // Resize check + if (canvas.width !== canvas.offsetWidth || canvas.height !== canvas.offsetHeight) { + resizeCanvas(); + } + + // Clear + ctx.fillStyle = '#0a0a0f'; + ctx.fillRect(0, 0, canvas.width, canvas.height); + + const map = getMap(worldState.currentMap); + if (!map) return; + + const bounds = getVisibleBounds(); + + // === 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 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); + } + } + + // === 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); + + // === Layer 4: Interaction prompt === + if (!worldState.dialog && !worldState.player.moving) { + const ft = getFacingTile(); + 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); + drawInteractionPrompt(ctx, pos.x, pos.y); + } + } + + // === Layer 5: Dialog box === + if (worldState.dialog) { + const line = worldState.dialog.lines[worldState.dialog.currentLine] || ''; + const speaker = worldState.dialog.speakerName || ''; + drawDialogBox(ctx, canvas.width, canvas.height, line, speaker); + } + + // === HUD === + drawHUD(); +} + +function drawHUD() { + const map = getMap(worldState.currentMap); + const mapName = map ? map.name : worldState.currentMap; + + // Background bar + ctx.fillStyle = 'rgba(10, 10, 15, 0.75)'; + ctx.fillRect(0, 0, canvas.width, 32); + + ctx.font = 'bold 13px "Segoe UI", system-ui, sans-serif'; + ctx.textBaseline = 'middle'; + + // Map name + ctx.fillStyle = '#00e599'; + ctx.textAlign = 'left'; + ctx.fillText(`📍 ${mapName}`, 12, 16); + + // Inventory + ctx.fillStyle = '#ff44aa'; + ctx.textAlign = 'right'; + ctx.fillText(`🔧 Components: ${worldState.inventory.length}`, canvas.width - 12, 16); + + // Controls hint + ctx.fillStyle = '#555'; + ctx.textAlign = 'center'; + ctx.font = '11px "Segoe UI", system-ui, sans-serif'; + ctx.fillText('WASD: Move | E: Interact | TAB: Workshop', canvas.width / 2, 16); +} + +export function startWorldLoop() { + lastTime = performance.now(); + function loop(ts) { + renderWorld(ts); + animFrameId = requestAnimationFrame(loop); + } + animFrameId = requestAnimationFrame(loop); +} + +export function stopWorldLoop() { + if (animFrameId !== null) { + cancelAnimationFrame(animFrameId); + animFrameId = null; + } +} diff --git a/js/world/worldState.js b/js/world/worldState.js new file mode 100644 index 0000000..faf5de6 --- /dev/null +++ b/js/world/worldState.js @@ -0,0 +1,307 @@ +/** + * worldState.js - World game state management + * + * Tracks player position, current map, dialog, inventory, puzzles, and other game state + */ + +// Default/initial world state +export const worldState = { + // Current mode + mode: 'world', // 'world' | 'workshop' | 'dialog' | 'puzzle' + + // Player + player: { + x: 10, + y: 12, // tile position in current map + px: 0, + py: 0, // pixel offset for smooth movement (interpolation) + direction: 'down', // 'up' | 'down' | 'left' | 'right' + moving: false, + frame: 0, // animation frame (0-3 for walking cycles) + speed: 150 // milliseconds per tile movement + }, + + // Map + currentMap: 'lab', + + // Camera + camera: { + x: 0, + y: 0 + }, + + // Dialog + dialog: null, // { lines: [...], currentLine: 0, speakerName: '' } or null + + // Inventory of crafted components + inventory: [], // array of component IDs from customComponents (stored in circuit editor) + + // Puzzle state + solvedPuzzles: [], // array of puzzleIds that have been solved + activePuzzle: null, // { puzzleId, requiredOutputs, doorX, doorY } or null when no puzzle active + + // Game flags + flags: { + // Examples: + // 'met_professor': false, + // 'guard_talked': false, + // 'merchant_met': false + }, + + // Timing + lastMoveTime: 0, + animTimer: 0 +}; + +/** + * Reset world state to initial defaults + */ +export function resetWorldState() { + worldState.mode = 'world'; + worldState.player.x = 10; + worldState.player.y = 12; + worldState.player.px = 0; + worldState.player.py = 0; + worldState.player.direction = 'down'; + worldState.player.moving = false; + worldState.player.frame = 0; + worldState.currentMap = 'lab'; + worldState.camera.x = 0; + worldState.camera.y = 0; + worldState.dialog = null; + worldState.inventory = []; + worldState.solvedPuzzles = []; + worldState.activePuzzle = null; + worldState.flags = {}; + worldState.lastMoveTime = 0; + worldState.animTimer = 0; +} + +/** + * Check if player is currently in movement animation + */ +export function isPlayerMoving() { + return worldState.player.moving; +} + +/** + * Set player position and reset movement state + */ +export function setPlayerPosition(x, y) { + worldState.player.x = x; + worldState.player.y = y; + worldState.player.px = 0; + worldState.player.py = 0; + worldState.player.moving = false; + worldState.player.frame = 0; +} + +/** + * Start a dialog sequence + */ +export function startDialog(lines, speakerName = '') { + worldState.dialog = { + lines: Array.isArray(lines) ? lines : [lines], + currentLine: 0, + speakerName: speakerName + }; + worldState.mode = 'dialog'; +} + +/** + * Advance dialog to next line + * Returns false when dialog sequence ends and should be closed + */ +export function advanceDialog() { + if (!worldState.dialog) return false; + + worldState.dialog.currentLine++; + + // Dialog finished + if (worldState.dialog.currentLine >= worldState.dialog.lines.length) { + worldState.dialog = null; + worldState.mode = 'world'; + return false; + } + + return true; +} + +/** + * Get current dialog line text + */ +export function getCurrentDialogLine() { + if (!worldState.dialog) return ''; + return worldState.dialog.lines[worldState.dialog.currentLine] || ''; +} + +/** + * Add component to inventory + */ +export function addToInventory(componentId) { + if (!worldState.inventory.includes(componentId)) { + worldState.inventory.push(componentId); + } +} + +/** + * Remove component from inventory + */ +export function removeFromInventory(componentId) { + const idx = worldState.inventory.indexOf(componentId); + if (idx !== -1) { + worldState.inventory.splice(idx, 1); + } +} + +/** + * Check if component is in inventory + */ +export function hasInInventory(componentId) { + return worldState.inventory.includes(componentId); +} + +/** + * Mark a puzzle as solved + */ +export function solvePuzzle(puzzleId) { + if (!worldState.solvedPuzzles.includes(puzzleId)) { + worldState.solvedPuzzles.push(puzzleId); + } +} + +/** + * Check if a puzzle has been solved + */ +export function isPuzzleSolved(puzzleId) { + return worldState.solvedPuzzles.includes(puzzleId); +} + +/** + * Set the active puzzle that player is attempting + */ +export function setActivePuzzle(puzzleId, requiredOutputs, doorX, doorY) { + worldState.activePuzzle = { + puzzleId: puzzleId, + requiredOutputs: requiredOutputs, + doorX: doorX, + doorY: doorY + }; + worldState.mode = 'puzzle'; +} + +/** + * Clear the active puzzle + */ +export function clearActivePuzzle() { + worldState.activePuzzle = null; + worldState.mode = 'world'; +} + +/** + * Get the active puzzle + */ +export function getActivePuzzle() { + return worldState.activePuzzle; +} + +/** + * Set a game flag + */ +export function setFlag(key, value) { + worldState.flags[key] = value; +} + +/** + * Get a game flag + */ +export function getFlag(key, defaultValue = false) { + return worldState.flags[key] !== undefined ? worldState.flags[key] : defaultValue; +} + +/** + * Check if a flag is true + */ +export function isFlagSet(key) { + return getFlag(key) === true; +} + +/** + * Move player by tile offset (for movement updates) + * Returns true if movement started, false if blocked + */ +export function movePlayer(dx, dy, isWalkable) { + if (worldState.player.moving) return false; + + const newX = worldState.player.x + dx; + const newY = worldState.player.y + dy; + + // Check if new position is walkable + if (!isWalkable(newX, newY)) { + return false; + } + + // Update direction + if (dx > 0) worldState.player.direction = 'right'; + if (dx < 0) worldState.player.direction = 'left'; + if (dy > 0) worldState.player.direction = 'down'; + if (dy < 0) worldState.player.direction = 'up'; + + // Start movement animation + worldState.player.x = newX; + worldState.player.y = newY; + worldState.player.moving = true; + worldState.player.frame = 0; + worldState.lastMoveTime = Date.now(); + + return true; +} + +/** + * Update player movement animation + * Call this in game loop, delta is time elapsed in ms + */ +export function updatePlayerAnimation(delta) { + if (!worldState.player.moving) return; + + const elapsed = Date.now() - worldState.lastMoveTime; + const progress = Math.min(elapsed / worldState.player.speed, 1); + + // Update pixel offset for smooth movement + const tileSize = 32; // Assuming 32x32 tiles + worldState.player.px = (worldState.player.direction === 'right' ? 1 : worldState.player.direction === 'left' ? -1 : 0) * tileSize * progress; + worldState.player.py = (worldState.player.direction === 'down' ? 1 : worldState.player.direction === 'up' ? -1 : 0) * tileSize * progress; + + // Update animation frame + worldState.player.frame = Math.floor(progress * 4) % 4; + + // Movement complete + if (progress >= 1) { + worldState.player.moving = false; + worldState.player.px = 0; + worldState.player.py = 0; + worldState.player.frame = 0; + } +} + +/** + * Warp player to a new map and position + */ +export function warpToMap(mapId, x, y) { + worldState.currentMap = mapId; + setPlayerPosition(x, y); +} + +/** + * Get complete world state snapshot (for debugging/saving) + */ +export function getWorldStateSnapshot() { + return JSON.parse(JSON.stringify(worldState)); +} + +/** + * Load world state from snapshot + */ +export function loadWorldStateSnapshot(snapshot) { + Object.assign(worldState, JSON.parse(JSON.stringify(snapshot))); +}