feat: add Pokemon-style world mode with workshop integration

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 <noreply@anthropic.com>
This commit is contained in:
Jose Luis
2026-03-20 15:52:13 +01:00
parent bbde11dfc7
commit e4cf35701e
9 changed files with 1903 additions and 14 deletions

View File

@@ -7,6 +7,9 @@
<link rel="stylesheet" href="css/style.css">
</head>
<body>
<!-- Back to world button (shown in workshop mode) -->
<button id="back-to-world-btn" style="display:none; position:fixed; top:12px; right:12px; z-index:200; padding:6px 14px; background:#00e599; border:none; border-radius:6px; color:#000; font-weight:700; cursor:pointer; font-size:12px;">◀ Back to World</button>
<div id="toolbar">
<span class="logo">⚡ Logic Lab</span>

View File

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

View File

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

173
js/world/gameMode.js Normal file
View File

@@ -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';
}

305
js/world/maps.js Normal file
View File

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

694
js/world/sprites.js Normal file
View File

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

191
js/world/worldInput.js Normal file
View File

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

181
js/world/worldRenderer.js Normal file
View File

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

307
js/world/worldState.js Normal file
View File

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