Compare commits

..

13 Commits

Author SHA1 Message Date
Jose Luis
f8aa4e2eab fix: make spawn optional — only required for initial map
Spawn can now be deleted in the editor (click same tile with Spawn tool,
use Delete tool, or press Delete key). Interior maps no longer have
spawn objects. The editor shows "None" when no spawn is set, and the
generated maps.js omits the spawn field for maps without one.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 17:20:19 +01:00
Jose Luis
f740d96fc0 feat: bidirectional door system + editor bi-link tool
Replace spawn-based map transitions with explicit bidirectional door
links. Every exit now requires targetX/targetY — spawn is only used
for initial game start. Remove returnPoints stack from worldState.

Editor improvements:
- New "Bi-Link" tool creates paired exits on both maps at once
- Exit list shows target coordinates and warns if missing
- Canvas renders target info labels below exit tiles
- Properties panel handles game ID ↔ editor ID mapping

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 17:15:25 +01:00
Jose Luis
1d494d8ef3 feat: return-to-door system for map exits
When entering an interior (e.g. lab from town), the game saves the
player's current position as a return point. When exiting, if the
exit has no explicit targetX/targetY, the system pops the stored
return point and warps back to that exact position.

This means interior exits just need targetMap — the player always
returns to the specific door they entered from, not a hardcoded
position. Falls back to the destination map's spawn if no return
point is stored.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 17:02:51 +01:00
Jose Luis
b60edc49af fix: exits place player in front of door, not on it
When exiting the lab, appear one tile below the town's entrance door
instead of ON the door tile, which caused an infinite re-trigger loop.
Same pattern for all map transitions — land adjacent to the exit, not
on top of it.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 16:49:40 +01:00
Jose Luis
eee405d5d9 fix: editor varName broke on map IDs with digits after hyphens
house-a-1f generated 'houseA-1f' (invalid JS identifier) because
the regex only matched -[a-z], not -[0-9]. Changed to -(\w) to
handle all word characters after hyphens.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 16:42:38 +01:00
Jose Luis
943ba0b51c feat: Node.js server + editor save/load + keyboard controls
- Replace nginx with Node.js server that serves static files AND
  provides API endpoints for reading/writing maps.js directly
  (GET/PUT /api/maps). Creates .bak backup before each save.
- Editor: arrow keys to pan, +/- to zoom, Ctrl+S to save
- Editor: "Save" button writes maps.js directly on the server
- Editor: "Load" button reads and parses maps.js from server
- Editor: auto-loads from server on page open
- Dockerfile changed from nginx:alpine to node:20-alpine

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 16:37:12 +01:00
Jose Luis
71321e8e88 feat: add standalone level editor for visual map editing
Full-featured editor at /editor.html with:
- Visual wall painting (click/drag to paint/erase collision tiles)
- Entity placement: NPCs, exits, interactions, spawn point
- Properties panel for editing dialog, facing, target maps, etc.
- Zoom/pan with scroll wheel and right-click drag
- Tile coordinate overlay on the map PNG backgrounds
- Color-coded overlays matching the F3 debug view
- Export as JSON or as complete maps.js source code
- Import JSON to load/restore map data
- Keyboard shortcuts: 1-7 for tools, Delete to remove entities
- All 4 maps supported: lab, pallet-town, house, route-1

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 16:29:40 +01:00
Jose Luis
bf34879390 feat: add F3 debug overlay for collision visualization
Press F3 to toggle a debug overlay that shows:
- Red tiles: walls (collision)
- Green tiles: exits (map transitions)
- Yellow tiles: interactions (workshop, signs, doors)
- Purple tiles: NPCs
- Green border: current player tile
- Coordinate labels on nearby tiles
- Legend bar with player position and current map

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 16:23:09 +01:00
Jose Luis
9b2a25856e fix: player size, unplayable walls, NPC interaction, canvas sizing
- Player sprite: render 32x32 char at 1 tile (TILE_PX) instead of
  2x2 tiles (32*SCALE), matching NPC size on the game grid
- Wall data: completely rebuilt for both lab and town maps based on
  actual PNG layouts. Previous walls blocked spawn point, NPC access,
  and all exits. Now uses Set for O(1) collision lookups
- NPC dialog: was unreachable due to wall layout, causing player to
  interact with workshop tiles instead. Fixed by opening corridors
- Canvas: use window.innerWidth/Height directly instead of
  offsetWidth which gave wrong values before CSS recompute

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 16:19:49 +01:00
Jose Luis
75001e10e7 fix: add assets/ directory to Docker image
The game PNG assets (maps, characters, NPCs) were not being copied
into the nginx container, causing 404s in production.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 16:10:58 +01:00
Jose Luis
bc9786ce49 fix: resolve asset paths relative to document base URI
Assets were 404ing because relative paths resolved against the wrong
base when the page was served from a subdirectory.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 16:04:14 +01:00
Jose Luis
c836ccbb21 refactor: migrate world rendering from programmatic sprites to PNG assets
Replace pixel-art drawing with pre-rendered PNG map backgrounds and
character/NPC sprite images from pokemon-js reference. Maps now use
coordinate-based wall arrays instead of tile grids.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 16:02:44 +01:00
Jose Luis
e4cf35701e 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>
2026-03-20 15:52:13 +01:00
32 changed files with 2906 additions and 18 deletions

View File

@@ -1,5 +1,11 @@
FROM nginx:alpine
COPY index.html /usr/share/nginx/html/
COPY css/ /usr/share/nginx/html/css/
COPY js/ /usr/share/nginx/html/js/
FROM node:20-alpine
WORKDIR /app
COPY server.js .
RUN mkdir -p public
COPY index.html public/
COPY editor.html public/
COPY css/ public/css/
COPY js/ public/js/
COPY assets/ public/assets/
EXPOSE 80
CMD ["node", "server.js"]

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

BIN
assets/map/house-a-1f.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

BIN
assets/map/lab.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

BIN
assets/map/pallet-town.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

BIN
assets/map/route-1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

BIN
assets/npcs/a-down.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

BIN
assets/npcs/a-left.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

BIN
assets/npcs/a-right.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

BIN
assets/npcs/a-up.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

1309
editor.html Normal file

File diff suppressed because it is too large Load Diff

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

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

@@ -0,0 +1,175 @@
// gameMode.js - Central coordinator: switches between World and Workshop modes
import { worldState, setPlayerPosition, warpToMap, 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': {
// Every exit MUST have targetX/targetY — bidirectional door links.
// No spawn fallback. Spawn is only for the initial game start.
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';
}

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

@@ -0,0 +1,192 @@
/**
* maps.js - PNG-based world maps (auto-generated by Level Editor)
*/
function buildWallSet(wallData) {
const set = new Set();
for (const [row, cols] of Object.entries(wallData)) {
for (const col of cols) set.add(col + ',' + row);
}
return set;
}
function r(a, b) { const arr = []; for (let i = a; i <= b; i++) arr.push(i); return arr; }
// ==================== Circuit Lab ====================
const labWalls = {
0: [...r(0,9)],
1: [0,1,2,3,6,7,8,9],
3: [6,7,8],
6: [0,1,2,3,6,7,8,9],
7: [0,1,2,3,6,7,8,9],
};
const labMap = {
id: 'lab',
name: 'Circuit Lab',
image: 'map:lab',
widthTiles: 10,
heightTiles: 12,
// No spawn — player enters via door from town
wallSet: buildWallSet(labWalls),
exits: [
// Bidirectional: these doors return to the specific town door (12,12 = tile in front of lab entrance)
{ x: 4, y: 11, targetMap: 'town', targetX: 12, targetY: 12 },
{ x: 5, y: 11, targetMap: 'town', targetX: 12, targetY: 12 },
],
npcs: [
{ id: 'professor', x: 5, y: 1, facing: 'down', dialog: ["Welcome to the Circuit Lab!","I\"m the Professor. We study logic gates here.","Use the workshop tables to design circuits.","Press TAB to open the Workshop anytime!"] },
],
interactions: [
{ x: 7, y: 3, type: 'workshop', label: 'Workshop Table' },
{ x: 8, y: 3, type: 'workshop', label: 'Workshop Table' },
{ x: 6, y: 3, type: 'workshop', label: 'Workshop Table' },
{ x: 1, y: 7, type: 'sign', label: 'Bookshelf', dialog: ["A collection of logic circuit manuals."] },
{ x: 7, y: 7, type: 'sign', label: 'Bookshelf', dialog: ["Advanced boolean algebra textbooks."] },
{ x: 0, y: 1, type: 'terminal', label: 'Terminal', dialog: ["Circuit analysis terminal.","Connect components to solve puzzles."] },
]
};
// ==================== Neon Town ====================
const palletTownWalls = {
0: [...r(0,19)],
1: [0,1,2,3,4,5,6,7,8,9,10,11,18,19],
2: [0,19],
3: [0,4,5,6,7,12,13,14,15,19],
4: [0,4,5,6,7,12,13,14,15,19],
5: [0,3,4,5,6,7,11,12,13,14,15,19],
6: [0,19],
7: [0,19],
8: [0,10,11,12,13,14,15,19],
9: [0,4,5,6,7,10,11,12,13,14,15,19],
10: [0,10,11,12,13,14,15,19],
11: [0,10,11,13,14,15,19],
12: [0,19],
13: [0,10,11,12,13,14,15,19],
14: [0,19],
15: [0,19],
16: [0,19],
17: [0,1,8,9,10,11,12,13,14,15,16,17,18,19],
};
const palletTownMap = {
id: 'town',
name: 'Neon Town',
image: 'map:pallet-town',
widthTiles: 20,
heightTiles: 18,
spawn: { x: 12, y: 12 },
wallSet: buildWallSet(palletTownWalls),
exits: [
{ x: 12, y: 11, targetMap: 'lab', targetX: 4, targetY: 10 },
],
npcs: [
{ id: 'merchant', x: 8, y: 10, facing: 'right', dialog: ["Welcome to Neon Town!","I trade in rare logic components."] },
{ id: 'guide', x: 11, y: 12, facing: 'down', dialog: ["The Circuit Lab is in the big building up north.","Press TAB anytime to open your Workshop."] },
],
interactions: [
{ x: 3, y: 5, type: 'door', label: 'House', dialog: ["The door is locked."] },
{ x: 7, y: 9, type: 'sign', label: 'Sign', dialog: ["Welcome to Neon Town!","Circuit Lab ↑"] },
{ x: 11, y: 5, type: 'sign', label: 'Sign', dialog: ["CIRCUIT LAB","Open for research!"] },
]
};
// ==================== House Interior ====================
const houseA1fWalls = {
};
const houseA1fMap = {
id: 'house-a-1f',
name: 'House Interior',
image: 'map:house-a-1f',
widthTiles: 8,
heightTiles: 8,
wallSet: buildWallSet(houseA1fWalls),
exits: [
],
npcs: [
],
interactions: [
]
};
// ==================== Route 1 ====================
const route1Walls = {
};
const route1Map = {
id: 'route-1',
name: 'Route 1',
image: 'map:route-1',
widthTiles: 20,
heightTiles: 36,
wallSet: buildWallSet(route1Walls),
exits: [
],
npcs: [
],
interactions: [
]
};
// ==================== Registry ====================
const maps = {
'lab': labMap,
'town': palletTownMap,
'house-a-1f': houseA1fMap,
'route-1': route1Map,
};
export function getMap(id) { return maps[id] || null; }
export function isWall(mapId, x, y) {
const map = maps[mapId];
if (!map) return true;
if (x < 0 || x >= map.widthTiles || y < 0 || y >= map.heightTiles) return true;
return map.wallSet.has(x + ',' + y);
}
export function isWalkable(mapId, x, y) {
if (isWall(mapId, x, y)) return false;
if (getNPC(mapId, x, y)) return false;
return true;
}
export function getInteraction(mapId, x, y) {
const map = maps[mapId];
if (!map) return null;
return map.interactions.find(i => i.x === x && i.y === y) || null;
}
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;
}
export function getExit(mapId, x, y) {
const map = maps[mapId];
if (!map) return null;
return map.exits.find(e => e.x === x && e.y === y) || null;
}
export function getTile(mapId, x, y) { return isWall(mapId, x, y) ? 1 : 0; }
export { maps };

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

@@ -0,0 +1,242 @@
// sprites.js - PNG image-based sprite system
// Uses pre-rendered assets from assets/ directory
// 16px native tile size, 3x scale for screen rendering
export const TILE = 16;
export const SCALE = 3;
export const TILE_PX = TILE * SCALE; // 48px on screen
// Also export as TILE_SIZE for backward compat
export const TILE_SIZE = TILE;
// ==================== Image cache ====================
const imageCache = {};
let assetsLoaded = false;
let onAssetsReady = null;
function loadImage(key, src) {
return new Promise((resolve, reject) => {
if (imageCache[key]) { resolve(imageCache[key]); return; }
const img = new Image();
img.onload = () => { imageCache[key] = img; resolve(img); };
img.onerror = () => { console.warn(`[sprites] failed to load: ${src}`); resolve(null); };
img.src = src;
});
}
export function getImage(key) {
return imageCache[key] || null;
}
/**
* Preload all game assets. Returns a promise that resolves when done.
*/
export async function preloadAssets() {
if (assetsLoaded) return;
const loads = [];
// Resolve asset base path relative to the HTML document
const base = new URL('.', document.baseURI).href;
// Map backgrounds
loads.push(loadImage('map:lab', `${base}assets/map/lab.png`));
loads.push(loadImage('map:pallet-town', `${base}assets/map/pallet-town.png`));
loads.push(loadImage('map:house-a-1f', `${base}assets/map/house-a-1f.png`));
loads.push(loadImage('map:route-1', `${base}assets/map/route-1.png`));
// Character sprites (32x32 each)
const dirs = ['front', 'back', 'left', 'right'];
const frames = ['still', 'walk-1', 'walk-2'];
for (const dir of dirs) {
for (const frame of frames) {
const key = `char:${dir}-${frame}`;
loads.push(loadImage(key, `${base}assets/character/${dir}-${frame}.png`));
}
}
// NPC sprites (16x16 each)
const npcDirs = ['down', 'up', 'left', 'right'];
for (const d of npcDirs) {
loads.push(loadImage(`npc:a-${d}`, `${base}assets/npcs/a-${d}.png`));
}
await Promise.all(loads);
assetsLoaded = true;
console.log('[sprites] all assets loaded');
}
// ==================== Direction mapping ====================
// Map game direction to character sprite prefix
const DIR_TO_SPRITE = {
down: 'front',
up: 'back',
left: 'left',
right: 'right'
};
// Map game direction to NPC sprite suffix
const DIR_TO_NPC = {
down: 'down',
up: 'up',
left: 'left',
right: 'right'
};
// ==================== Drawing functions ====================
/**
* Draw a map background image
* @param {CanvasRenderingContext2D} ctx
* @param {string} mapImageKey - key in imageCache (e.g. 'map:lab')
* @param {number} offsetX - pixel offset for camera
* @param {number} offsetY - pixel offset for camera
*/
export function drawMapImage(ctx, mapImageKey, offsetX, offsetY) {
const img = imageCache[mapImageKey];
if (!img) return;
// Draw scaled: native pixels * SCALE
ctx.imageSmoothingEnabled = false;
ctx.drawImage(img, offsetX, offsetY, img.width * SCALE, img.height * SCALE);
}
/**
* Draw the player character
* @param {CanvasRenderingContext2D} ctx
* @param {number} screenX - top-left X on screen
* @param {number} screenY - top-left Y on screen
* @param {string} direction - 'up'|'down'|'left'|'right'
* @param {number} walkFrame - 0=still, 1=walk-1, 2=walk-2
*/
export function drawPlayer(ctx, screenX, screenY, direction, walkFrame) {
const spriteDir = DIR_TO_SPRITE[direction] || 'front';
const frameName = walkFrame === 0 ? 'still' : walkFrame === 1 ? 'walk-1' : 'walk-2';
const key = `char:${spriteDir}-${frameName}`;
const img = imageCache[key];
if (!img) {
// Fallback: colored rectangle
ctx.fillStyle = '#00e599';
ctx.fillRect(screenX, screenY, TILE_PX, TILE_PX * 2);
return;
}
ctx.imageSmoothingEnabled = false;
// Character is 32x32 native but represents a 1-tile-wide, 2-tile-tall entity
// Draw at TILE_PX wide x TILE_PX tall (square, matching NPC size on grid)
ctx.drawImage(img, screenX, screenY, TILE_PX, TILE_PX);
}
/**
* Draw an NPC
* @param {CanvasRenderingContext2D} ctx
* @param {number} screenX - top-left X on screen
* @param {number} screenY - top-left Y on screen
* @param {string} facing - 'up'|'down'|'left'|'right'
*/
export function drawNPC(ctx, screenX, screenY, facing) {
const dir = DIR_TO_NPC[facing] || 'down';
const key = `npc:a-${dir}`;
const img = imageCache[key];
if (!img) {
// Fallback
ctx.fillStyle = '#ff44aa';
ctx.fillRect(screenX, screenY, TILE_PX, TILE_PX);
return;
}
ctx.imageSmoothingEnabled = false;
// NPC is 16x16 native = 1 tile
ctx.drawImage(img, screenX, screenY, TILE_PX, TILE_PX);
}
/**
* Draw the interaction prompt (E button hint) above a tile
*/
export function drawInteractionPrompt(ctx, screenX, screenY) {
const cx = screenX + TILE_PX / 2;
const cy = screenY - 12;
// Bubble background
ctx.fillStyle = 'rgba(0, 0, 0, 0.7)';
ctx.beginPath();
ctx.roundRect(cx - 18, cy - 12, 36, 22, 6);
ctx.fill();
// Border
ctx.strokeStyle = '#00e599';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.roundRect(cx - 18, cy - 12, 36, 22, 6);
ctx.stroke();
// Text
ctx.fillStyle = '#ffffff';
ctx.font = 'bold 12px "Segoe UI", system-ui, sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('[E]', cx, cy);
}
/**
* Draw the dialog box at the bottom of the screen
*/
export function drawDialogBox(ctx, canvasW, canvasH, text, speakerName) {
const boxH = 100;
const boxY = canvasH - boxH - 16;
const boxX = 32;
const boxW = canvasW - 64;
// Background
ctx.fillStyle = 'rgba(10, 14, 39, 0.92)';
ctx.beginPath();
ctx.roundRect(boxX, boxY, boxW, boxH, 10);
ctx.fill();
// Border
ctx.strokeStyle = '#00e599';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.roundRect(boxX, boxY, boxW, boxH, 10);
ctx.stroke();
// Speaker name
if (speakerName) {
ctx.fillStyle = '#00e599';
ctx.font = 'bold 14px "Segoe UI", system-ui, sans-serif';
ctx.textAlign = 'left';
ctx.textBaseline = 'top';
ctx.fillText(speakerName, boxX + 16, boxY + 12);
}
// Text
ctx.fillStyle = '#ffffff';
ctx.font = '14px "Segoe UI", system-ui, sans-serif';
ctx.textAlign = 'left';
ctx.textBaseline = 'top';
const textY = speakerName ? boxY + 34 : boxY + 16;
// Simple word wrap
wrapText(ctx, text, boxX + 16, textY, boxW - 32, 20);
// Continue prompt
ctx.fillStyle = '#555';
ctx.font = '11px "Segoe UI", system-ui, sans-serif';
ctx.textAlign = 'right';
ctx.fillText('Press E to continue ▶', boxX + boxW - 16, boxY + boxH - 16);
}
function wrapText(ctx, text, x, y, maxWidth, lineHeight) {
const words = text.split(' ');
let line = '';
let currentY = y;
for (const word of words) {
const test = line + (line ? ' ' : '') + word;
if (ctx.measureText(test).width > maxWidth && line) {
ctx.fillText(line, x, currentY);
line = word;
currentY += lineHeight;
} else {
line = test;
}
}
if (line) ctx.fillText(line, x, currentY);
}

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

@@ -0,0 +1,199 @@
// worldInput.js - Keyboard input for world mode
import { worldState, advanceDialog, startDialog } from './worldState.js';
import { getMap, getInteraction, getNPC, getExit, isWalkable } from './maps.js';
import { toggleDebug } from './worldRenderer.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;
}
// Debug overlay toggle (F3)
if (key === 'F3') {
e.preventDefault();
toggleDebug();
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 }
});
}
}

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

@@ -0,0 +1,289 @@
// worldRenderer.js - Renders PNG-based game world on canvas
import {
drawMapImage, drawPlayer, drawNPC, drawInteractionPrompt,
drawDialogBox, preloadAssets, TILE_PX, SCALE
} from './sprites.js';
import { worldState } from './worldState.js';
import { getMap, getInteraction, getNPC, getExit, isWall } from './maps.js';
import { updateMovement } from './worldInput.js';
let canvas = null;
let ctx = null;
let animFrameId = null;
let lastTime = 0;
let debugMode = false;
export function toggleDebug() {
debugMode = !debugMode;
console.log(`[debug] collision overlay ${debugMode ? 'ON' : 'OFF'}`);
return debugMode;
}
export function initWorldRenderer() {
canvas = document.getElementById('canvas');
ctx = canvas.getContext('2d');
resizeCanvas();
window.addEventListener('resize', resizeCanvas);
}
function resizeCanvas() {
if (!canvas) return;
// Always use full window size in world mode — don't rely on offsetWidth
// because CSS layout may not have recomputed yet on initial load
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
}
// ==================== Camera ====================
/** Get the pixel offset to draw the map so the player is centered */
function getCameraOffset() {
const p = worldState.player;
const playerWorldX = (p.x + p.px) * TILE_PX;
const playerWorldY = (p.y + p.py) * TILE_PX;
return {
x: canvas.width / 2 - playerWorldX - TILE_PX / 2,
y: canvas.height / 2 - playerWorldY - TILE_PX / 2
};
}
/** Convert tile position to screen position */
function tileToScreen(tileX, tileY) {
const cam = getCameraOffset();
return {
x: tileX * TILE_PX + cam.x,
y: tileY * TILE_PX + cam.y
};
}
// ==================== Facing tile ====================
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 ====================
export function renderWorld(timestamp) {
const dt = (timestamp - lastTime) / 1000;
lastTime = timestamp;
// Update movement
updateMovement(dt);
// Resize check
if (canvas.width !== window.innerWidth || canvas.height !== window.innerHeight) {
resizeCanvas();
}
// Clear
ctx.fillStyle = '#0a0a0f';
ctx.fillRect(0, 0, canvas.width, canvas.height);
const map = getMap(worldState.currentMap);
if (!map) return;
const cam = getCameraOffset();
// === Layer 1: Map background (PNG) ===
drawMapImage(ctx, map.image, cam.x, cam.y);
// === Debug overlay (between map and entities) ===
if (debugMode) drawDebugOverlay(ctx, map, cam);
// === Layer 2: NPCs ===
if (map.npcs) {
for (const npc of map.npcs) {
const pos = tileToScreen(npc.x, npc.y);
drawNPC(ctx, pos.x, pos.y, npc.facing || 'down');
}
}
// === Layer 3: Player ===
const playerScreen = tileToScreen(
worldState.player.x + worldState.player.px,
worldState.player.y + worldState.player.py
);
const playerDrawX = playerScreen.x;
const playerDrawY = playerScreen.y;
const walkFrame = worldState.player.moving
? (Math.floor(Date.now() / 150) % 2) + 1 // alternates 1, 2
: 0;
drawPlayer(ctx, playerDrawX, playerDrawY, worldState.player.direction, walkFrame);
// === 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 = tileToScreen(ft.x, ft.y);
drawInteractionPrompt(ctx, pos.x, pos.y);
}
}
// === Layer 5: Dialog ===
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(map);
}
function drawHUD(map) {
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 | F3: Debug', canvas.width / 2, 16);
// Debug legend
if (debugMode) {
const legendY = 40;
ctx.font = '11px monospace';
ctx.textAlign = 'left';
ctx.textBaseline = 'top';
const items = [
['rgba(255, 50, 50, 0.6)', 'Wall'],
['rgba(50, 255, 50, 0.6)', 'Exit'],
['rgba(255, 255, 0, 0.6)', 'Interaction'],
['rgba(200, 50, 255, 0.6)', 'NPC'],
['#00e599', 'Player tile']
];
let lx = 12;
for (const [color, label] of items) {
ctx.fillStyle = color;
ctx.fillRect(lx, legendY, 12, 12);
ctx.strokeStyle = '#fff';
ctx.lineWidth = 0.5;
ctx.strokeRect(lx, legendY, 12, 12);
ctx.fillStyle = '#ccc';
ctx.fillText(label, lx + 16, legendY + 1);
lx += ctx.measureText(label).width + 28;
}
// Player coords
const p = worldState.player;
ctx.fillStyle = '#00e599';
ctx.fillText(`Pos: (${p.x}, ${p.y}) Map: ${worldState.currentMap}`, 12, legendY + 18);
}
}
// ==================== Debug overlay ====================
function drawDebugOverlay(ctx, map, cam) {
const mapId = worldState.currentMap;
const w = map.widthTiles;
const h = map.heightTiles;
ctx.save();
for (let ty = 0; ty < h; ty++) {
for (let tx = 0; tx < w; tx++) {
const sx = tx * TILE_PX + cam.x;
const sy = ty * TILE_PX + cam.y;
// Skip tiles entirely off-screen
if (sx + TILE_PX < 0 || sx > canvas.width || sy + TILE_PX < 0 || sy > canvas.height) continue;
const wall = isWall(mapId, tx, ty);
const exit = getExit(mapId, tx, ty);
const inter = getInteraction(mapId, tx, ty);
const npc = getNPC(mapId, tx, ty);
// Wall = red, Exit = green, Interaction = yellow, NPC = purple, walkable = no fill
if (wall) {
ctx.fillStyle = 'rgba(255, 50, 50, 0.35)';
ctx.fillRect(sx, sy, TILE_PX, TILE_PX);
} else if (exit) {
ctx.fillStyle = 'rgba(50, 255, 50, 0.4)';
ctx.fillRect(sx, sy, TILE_PX, TILE_PX);
}
if (inter) {
ctx.fillStyle = 'rgba(255, 255, 0, 0.35)';
ctx.fillRect(sx, sy, TILE_PX, TILE_PX);
}
if (npc) {
ctx.fillStyle = 'rgba(200, 50, 255, 0.4)';
ctx.fillRect(sx, sy, TILE_PX, TILE_PX);
}
// Grid lines
ctx.strokeStyle = 'rgba(255, 255, 255, 0.12)';
ctx.lineWidth = 0.5;
ctx.strokeRect(sx, sy, TILE_PX, TILE_PX);
// Coordinate labels (only near player to avoid clutter)
const p = worldState.player;
if (Math.abs(tx - p.x) <= 6 && Math.abs(ty - p.y) <= 5) {
ctx.fillStyle = 'rgba(255, 255, 255, 0.5)';
ctx.font = '9px monospace';
ctx.textAlign = 'left';
ctx.textBaseline = 'top';
ctx.fillText(`${tx},${ty}`, sx + 2, sy + 1);
}
}
}
// Player tile highlight
const px = worldState.player.x * TILE_PX + cam.x;
const py = worldState.player.y * TILE_PX + cam.y;
ctx.strokeStyle = '#00e599';
ctx.lineWidth = 2;
ctx.strokeRect(px, py, TILE_PX, TILE_PX);
ctx.restore();
}
// ==================== Loop control ====================
export async function startWorldLoop() {
// Ensure assets are loaded before starting
await preloadAssets();
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: 4,
y: 10, // 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 = 4;
worldState.player.y = 10;
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)));
}

131
server.js Normal file
View File

@@ -0,0 +1,131 @@
// Lightweight static file server + editor API for saving maps.js
// Used in production Docker container
const http = require('http');
const fs = require('fs');
const path = require('path');
const PORT = process.env.PORT || 80;
const STATIC_DIR = path.join(__dirname, 'public');
const MAPS_FILE = path.join(STATIC_DIR, 'js', 'world', 'maps.js');
const MIME = {
'.html': 'text/html',
'.css': 'text/css',
'.js': 'application/javascript',
'.json': 'application/json',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.svg': 'image/svg+xml',
'.ico': 'image/x-icon',
'.woff2': 'font/woff2',
};
const server = http.createServer((req, res) => {
// CORS headers for editor
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
if (req.method === 'OPTIONS') {
res.writeHead(204);
res.end();
return;
}
// === API: GET /api/maps — read maps.js source ===
if (req.method === 'GET' && req.url === '/api/maps') {
fs.readFile(MAPS_FILE, 'utf-8', (err, data) => {
if (err) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Failed to read maps.js' }));
return;
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ content: data }));
});
return;
}
// === API: PUT /api/maps — write maps.js source ===
if (req.method === 'PUT' && req.url === '/api/maps') {
let body = '';
req.on('data', chunk => { body += chunk; });
req.on('end', () => {
try {
const { content } = JSON.parse(body);
if (!content || typeof content !== 'string') {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Missing content field' }));
return;
}
// Backup before overwrite
const backup = MAPS_FILE + '.bak';
if (fs.existsSync(MAPS_FILE)) {
fs.copyFileSync(MAPS_FILE, backup);
}
fs.writeFileSync(MAPS_FILE, content, 'utf-8');
console.log(`[server] maps.js saved (${content.length} bytes)`);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true, bytes: content.length }));
} catch (e) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: e.message }));
}
});
return;
}
// === API: GET /api/maps/json — parse current map data as JSON ===
if (req.method === 'GET' && req.url === '/api/maps/json') {
fs.readFile(MAPS_FILE, 'utf-8', (err, data) => {
if (err) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Failed to read maps.js' }));
return;
}
// Extract JSON-serializable data from the JS source
// This is a best-effort parser for the generated maps.js format
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ source: data }));
});
return;
}
// === Static file serving ===
let filePath = path.join(STATIC_DIR, req.url === '/' ? 'index.html' : req.url);
// Prevent directory traversal
if (!filePath.startsWith(STATIC_DIR)) {
res.writeHead(403);
res.end('Forbidden');
return;
}
const ext = path.extname(filePath).toLowerCase();
const contentType = MIME[ext] || 'application/octet-stream';
fs.readFile(filePath, (err, data) => {
if (err) {
if (err.code === 'ENOENT') {
res.writeHead(404);
res.end('Not found');
} else {
res.writeHead(500);
res.end('Server error');
}
return;
}
// Cache static assets
if (ext === '.png' || ext === '.jpg' || ext === '.woff2') {
res.setHeader('Cache-Control', 'public, max-age=86400');
}
res.writeHead(200, { 'Content-Type': contentType });
res.end(data);
});
});
server.listen(PORT, () => {
console.log(`[server] Logic Gates running on port ${PORT}`);
console.log(`[server] Static: ${STATIC_DIR}`);
console.log(`[server] Maps file: ${MAPS_FILE}`);
});