diff --git a/index.html b/index.html
index f4a3e00..da43b1f 100644
--- a/index.html
+++ b/index.html
@@ -7,6 +7,9 @@
+
+
+
⚡ Logic Lab
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)));
+}