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

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