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

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