Compare commits

24 Commits

Author SHA1 Message Date
Jose Luis
0c0ab2fc9b fix: rebuild props panel when interaction type changes
Without this, switching type to 'module' didn't show the moduleId,
ports, and Open IDE button until manually reselecting the entity.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 00:44:09 +01:00
Jose Luis
9ffd9c113e feat: character/NPC management system with spritesheet support
Add drag & drop spritesheet upload in editor, character registry in
sprites.js, character selector for NPCs, sprite rendering on editor
canvas, server API for character persistence, and game-side character
loading via characterLoader.js.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 21:15:28 +01:00
Jose Luis
9d218c8728 fix: replace verify textarea with Open IDE button + fix gutter line numbers
- Hide the inline verify textarea, show a clean "Open IDE" button instead
- Add white-space:pre to gutter div so line numbers render one per line
- Match gutter font-family with textarea for consistent alignment

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 18:08:21 +01:00
Jose Luis
06807801d0 feat: animated execution log with truth table + waveform viewer
When the player executes wiring, the panel transitions to a full
execution log showing:
- Animated truth table with rows revealing one by one (flash effect)
- Digital waveform viewer with square wave signals for each port
- Scanning cursor animation during evaluation
- Pulsing glow verdict (pass/fail) after all test cases complete
- Color-coded columns: orange for inputs, blue for outputs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 18:07:14 +01:00
Jose Luis
bb72c58a15 feat: fullscreen VSCode-style code editor for module verify logic
Add a dark-themed fullscreen code editor overlay in the level editor
for editing module verify JS. Features line numbers, cursor position
tracking, tab-to-spaces, Ctrl+Enter to apply, and syncs back to the
property panel on save.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 18:04:23 +01:00
Jose Luis
f9492bff4c feat: module interaction system with wiring panel
Add a new "module" interaction type where doors/devices define ports
(in/out) and a JS verify function. Players wire their gadget's I/O
to the module's ports via a canvas-rendered wiring panel, then execute
to verify the circuit logic.

- New wiringPanel.js: full wiring UI with keyboard nav, bezier wires,
  mini circuit evaluator, and verify execution
- AND-gate example door in Circuit Lab (tile 9,1)
- Editor support: module type with ports editor and JS verify textarea
- Integrated into gameMode, worldInput, worldRenderer

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 17:59:25 +01:00
Jose Luis
6ba3fa457a hide: disable puzzle mode from editor without removing code
Comment out initPuzzleUI() call and remove puzzle_door from
interaction type dropdown — all puzzle code remains intact for
future re-enablement.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 17:45:19 +01:00
Jose Luis
e7b18afd1a fix: move game buttons into toolbar-right to prevent overlap
Save Gadget and Back to World buttons were overlapping Export/Import.
Now they are dynamically inserted into .toolbar-right when entering
workshop mode, sitting inline with the other toolbar buttons.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 17:40:09 +01:00
Jose Luis
816a02aeb9 feat: replace browser dialogs with in-game naming screen + notifications
Remove prompt() and alert() calls that broke game immersion. Add:
- Pokemon-style naming screen with character grid + direct typing
- Canvas-rendered notification toasts (with fade-out animation)
- Both render on top of workshop AND world mode canvases
- Workshop keyboard handler yields to naming screen when active

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 17:36:31 +01:00
Jose Luis
c6f5e19af5 docs: add comprehensive README with full project overview
Covers game modes, controls, gadget system, maps, level editor,
project structure, dev setup, API, and technical details.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 17:31:33 +01:00
Jose Luis
b999fe855a feat: gadget backpack system — save circuits as items
Add Pokemon-style inventory where players save crafted circuits as
"gadgets" in a backpack. Gadgets can be used on puzzle doors to solve
them by testing their truth table against required outputs.

New files:
- js/world/inventory.js: gadget data model, backpack UI (list with
  scroll, action menu, detail panel), keyboard navigation

Changes:
- Workshop gets "Save as Gadget" button (pink, top-right)
- I key opens backpack overlay in world mode
- Puzzle doors open backpack to select a gadget to try
- HUD shows gadget count instead of old component count
- worldState gains gadgets[] array

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 17:30:30 +01:00
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
37 changed files with 5368 additions and 18 deletions

View File

@@ -1,5 +1,12 @@
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/
RUN mkdir -p public/data
EXPOSE 80
CMD ["node", "server.js"]

148
README.md Normal file
View File

@@ -0,0 +1,148 @@
# ⚡ Logic Gates — Circuit Simulator RPG
A logic gate circuit simulator combined with a Pokemon-style tile-based world. Players explore **Neon Town**, interact with NPCs, and craft logic circuits in a **Workshop** to solve puzzle doors and progress through the game.
**Live:** [logic.montlab.dev](https://logic.montlab.dev)
## 🎮 Game Modes
### World Mode (exploration)
- Walk around pixel-art maps rendered from PNG backgrounds
- Talk to NPCs, read signs, enter buildings through doors
- Find puzzle doors that require specific circuit outputs to unlock
- Open your **backpack** to manage saved gadgets
### Workshop Mode (circuit editor)
- Full-featured logic gate simulator with drag-and-drop
- Gates: AND, OR, NOT, NAND, NOR, XOR, INPUT, OUTPUT, CLOCK
- Wire gates together by dragging between ports
- Real-time simulation with GTKWave-style waveform viewer
- Save circuits as **custom components** for reuse
- Save circuits as **gadgets** to your backpack for use on puzzles
## 🕹️ Controls
### World
| Key | Action |
|-----|--------|
| WASD / Arrows | Move |
| E / Enter / Space | Interact with NPCs, signs, doors |
| I | Open Backpack |
| TAB | Open Workshop |
| F3 | Toggle debug collision overlay |
### Workshop
| Key | Action |
|-----|--------|
| Click + Drag | Place and connect gates |
| Shift + Drag | Cut wires / create bus connectors |
| Mouse wheel | Zoom |
| Arrows / +/- | Pan and zoom |
| 0 | Reset camera |
### Backpack
| Key | Action |
|-----|--------|
| ↑↓ / WS | Navigate gadget list |
| E / Enter | Select → Use / Toss |
| I / ESC / B | Close |
## 🎒 Gadget System
Circuits become portable items:
1. **Build** a circuit in the Workshop with INPUT and OUTPUT gates
2. **Save as Gadget** (pink button, top-right) — names it and stores in your backpack
3. **Use on puzzle doors** — the game runs your circuit's truth table against the puzzle's required outputs
4. If outputs match → puzzle solved, door unlocks!
## 🗺️ Maps
| Map | Size | Description |
|-----|------|-------------|
| Neon Town | 20×18 | Hub town with buildings, NPCs, signs |
| Circuit Lab | 10×12 | Professor's lab with workshop tables |
| House Interior | 8×8 | Generic house (WIP) |
| Route 1 | 20×36 | Path to next area (WIP) |
### Bidirectional Door System
Every door is a two-way link with explicit coordinates — entering a building through a specific door returns you to that exact door when you exit. `spawn` only exists on the starting map (Neon Town).
## 🛠️ Level Editor
Standalone editor at `/editor.html` for visually editing maps:
- **Wall painting** — click/drag to paint collision masks
- **Entity placement** — NPCs, exits, interactions, spawn points
- **🔗 Bi-Link tool** — create bidirectional door links between maps in one click
- **Properties panel** — edit entity properties (dialog, facing, target map/coords)
- **Zoom/pan** — mouse wheel, +/-, arrows, right-click drag
- **Server save/load** — saves directly to `maps.js` via API
- **Keyboard shortcuts** — 1-7 for tools, Ctrl+S to save, Delete to remove
## 📁 Project Structure
```
├── index.html # Main game page
├── editor.html # Standalone level editor
├── server.js # Node.js server (static + API)
├── Dockerfile # Docker build (node:20-alpine)
├── assets/
│ ├── map/ # PNG map backgrounds (lab, pallet-town, house, route-1)
│ └── character/ # Player sprites (32×32, 4 directions × 3 frames)
│ └── npcs/ # NPC sprites (16×16)
├── css/
│ └── style.css # Workshop UI styles
├── js/
│ ├── app.js # Entry point, wires everything together
│ ├── state.js # Circuit editor state
│ ├── gates.js # Gate creation and evaluation
│ ├── connections.js # Wire/connection logic
│ ├── renderer.js # Circuit editor canvas rendering
│ ├── events.js # Circuit editor mouse/keyboard events
│ ├── components.js # Custom component system (save/eval/edit)
│ ├── saveLoad.js # Circuit serialization
│ ├── levels.js # Puzzle level definitions
│ ├── constants.js # Shared constants
│ └── world/
│ ├── gameMode.js # Mode coordinator (world ↔ workshop)
│ ├── maps.js # Map definitions (walls, exits, NPCs, interactions)
│ ├── sprites.js # PNG asset loading and drawing
│ ├── worldRenderer.js# World canvas rendering (map, NPCs, player, HUD, debug)
│ ├── worldInput.js # World keyboard input (movement, interaction)
│ ├── worldState.js # World game state (player, dialog, puzzles, gadgets)
│ └── inventory.js # Gadget backpack (data model + full-screen UI)
```
## 🚀 Development
### Run locally
```bash
npm start # or: node server.js
# Open http://localhost:3000
```
### Deploy (Docker via Coolify)
```bash
docker build -t logic-gates .
docker run -p 3000:3000 logic-gates
```
Deployed automatically via Coolify on Hetzner at `logic.montlab.dev`.
### API Endpoints
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/maps` | Get current maps.js content |
| PUT | `/api/maps` | Update maps.js (creates .bak backup) |
## 🔧 Technical Details
- **Rendering**: Vanilla Canvas 2D, no frameworks
- **Modules**: ES6 modules (`type="module"`)
- **Tile system**: 16px native tiles, 3× scale = 48px on screen
- **Collision**: `Set<"x,y">` for O(1) wall lookups
- **Player sprite**: 32×32 native, drawn at 48×48 (same visual size as 16×16 NPC sprites at 48×48)
- **Camera**: Follows player, centered on screen
- **Movement**: Interpolated tile-based (0.15s per tile)
- **Circuit evaluation**: Fixed-point iteration (max 20 passes) — supports latches/flip-flops

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

1755
editor.html Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -7,6 +7,10 @@
<link rel="stylesheet" href="css/style.css">
</head>
<body>
<!-- Game buttons (hidden, placed outside toolbar — injected into toolbar-right by gameMode.js) -->
<button id="save-gadget-btn" class="action-btn" style="display:none; background:#ff44aa; color:#fff; border:none; border-radius:4px; font-weight:700; cursor:pointer; font-size:11px; padding:6px 12px;">🎒 Save Gadget</button>
<button id="back-to-world-btn" class="action-btn" style="display:none; background:#00e599; color:#000; border:none; border-radius:4px; font-weight:700; cursor:pointer; font-size:11px; padding:6px 12px;">◀ 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', () => {
// Register circuit editor init/destroy so gameMode can switch to workshop
registerCircuitEditor(
// init workshop
() => {
initRenderer();
initEvents();
initPuzzleUI();
// Restore previous session from localStorage
// initPuzzleUI(); // HIDDEN: puzzle mode disabled for now
if (loadFromStorage()) {
updateComponentButtons();
evaluateAll();
}
// Auto-save every 3 seconds + on page unload
startAutoSave(3000);
},
// destroy workshop (cleanup when switching back to world)
() => {
// Auto-save is fine to leave running
}
);
// Add back-to-world button handler
const backBtn = document.getElementById('back-to-world-btn');
if (backBtn) {
backBtn.addEventListener('click', () => {
enterWorldMode();
});
}
// Start the game in world mode
startGame();
});

View File

@@ -11,6 +11,7 @@ import { saveState, loadState, exportAsFile, importFromFile } from './saveLoad.j
import { enterComponentEditor, editComponentBlueprint, exitComponentEditor, updateComponentButtons, setResizeCallback } from './components.js';
import { getExampleList, loadExample } from './examples.js';
import { createBusFromCut } from './bus.js';
import { isNamingActive, handleNamingInput } from './world/inventory.js';
const PAN_SPEED = 40;
@@ -325,6 +326,13 @@ export function initEvents() {
const keysDown = new Set();
document.addEventListener('keydown', e => {
// In-game naming screen takes priority over circuit editor
if (isNamingActive()) {
e.preventDefault();
handleNamingInput(e.key);
return;
}
keysDown.add(e.key);
if (e.key === 'Delete' || e.key === 'Backspace') {

View File

@@ -4,8 +4,11 @@ import { state } from './state.js';
import { getInputPorts, getOutputPorts, getComponentWidth, getComponentHeight } from './gates.js';
import { getGateLabel, drawWaveLabels, drawWaveforms } from './waveform.js';
import { getBusPairs } from './bus.js';
import { drawNamingScreen, drawNotification } from './world/inventory.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 +28,23 @@ export function initRenderer() {
canvas = document.getElementById('canvas');
ctx = canvas.getContext('2d');
resize();
if (!rendererInitialized) {
window.addEventListener('resize', resize);
requestAnimationFrame(draw);
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 +664,9 @@ function draw() {
drawWaveforms();
}
requestAnimationFrame(draw);
// In-game overlays (naming screen, notifications) — render on top
drawNamingScreen(ctx, canvas.width, canvas.height);
drawNotification(ctx, canvas.width);
circuitAnimFrameId = requestAnimationFrame(draw);
}

View File

@@ -0,0 +1,26 @@
// characterLoader.js - Loads character spritesheets from server and registers them
import { registerCharacter } from './sprites.js';
/**
* Fetch character data from the server and register all characters
* with the sprite system so NPCs can use them.
* @returns {Promise<number>} number of characters loaded
*/
export async function loadCharacters() {
try {
const res = await fetch('/api/characters');
const data = await res.json();
if (!data.characters) return 0;
const entries = Object.entries(data.characters);
const promises = entries.map(([id, c]) =>
registerCharacter(id, c.name, c.spritesheet, c.frameW, c.frameH)
);
await Promise.all(promises);
console.log(`[characterLoader] loaded ${entries.length} character(s)`);
return entries.length;
} catch (e) {
console.warn('[characterLoader] failed to load characters:', e);
return 0;
}
}

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

@@ -0,0 +1,377 @@
// gameMode.js - Central coordinator: switches between World and Workshop modes
import { worldState, setPlayerPosition, warpToMap, isPuzzleSolved, solvePuzzle, startDialog } from './worldState.js';
import { initWorldRenderer, startWorldLoop, stopWorldLoop } from './worldRenderer.js';
import { initWorldInput, destroyWorldInput, setInteractionHandler } from './worldInput.js';
import { getMap } from './maps.js';
import { saveGadget, openBackpack, getGadgets, openNamingScreen, showNotification } from './inventory.js';
import { openWiringPanel } from './wiringPanel.js';
import { loadCharacters } from './characterLoader.js';
// Circuit editor stop function (to stop its render loop when switching modes)
import { stopCircuitLoop } from '../renderer.js';
import { state as circuitState } from '../state.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 async function startGame() {
// Load character spritesheets before entering world
await loadCharacters();
// Set spawn
const map = getMap(worldState.currentMap);
if (map && map.spawn) {
setPlayerPosition(map.spawn.x, map.spawn.y);
}
// Wire up interaction handler
setInteractionHandler(handleInteraction);
// Wire save-gadget button
const saveGadgetBtn = document.getElementById('save-gadget-btn');
if (saveGadgetBtn) {
saveGadgetBtn.addEventListener('click', handleSaveGadget);
}
// 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)) {
startDialog(['This door is already unlocked.'], 'System');
return;
}
// Open backpack to let player choose a gadget
const gadgets = getGadgets();
if (gadgets.length === 0) {
startDialog([
'This door requires a logic circuit to open.',
`Required output: [${inter.requiredOutputs.join(', ')}]`,
'Craft a circuit in your Workshop (TAB) and save it as a gadget!'
], 'System');
return;
}
// Open backpack with a "use" callback that tests the gadget
openBackpack((gadget) => {
const result = testGadgetOnPuzzle(gadget, inter);
if (result) {
solvePuzzle(inter.puzzleId);
startDialog([
`🎉 "${gadget.name}" solved the puzzle!`,
'The door unlocks with a satisfying click.'
], 'System');
} else {
startDialog([
`"${gadget.name}" didn't produce the right output.`,
`Required: [${inter.requiredOutputs.join(', ')}]`,
'Try a different gadget or tweak your circuit!'
], 'System');
}
});
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 'module': {
const inter = event.data;
// Already solved?
if (inter.moduleId && isPuzzleSolved(inter.moduleId)) {
startDialog(['This module is already unlocked.'], 'System');
return;
}
// Need gadgets
const mGadgets = getGadgets();
if (mGadgets.length === 0) {
const portDesc = (inter.ports || []).map(p => `${p.name} (${p.dir})`).join(', ');
startDialog([
`This module requires a gadget to operate.`,
`Ports: ${portDesc}`,
'Craft a circuit in your Workshop (TAB) and save it as a gadget!'
], 'System');
return;
}
// Open backpack → on "Use", open wiring panel
openBackpack((gadget) => {
openWiringPanel(inter, gadget);
});
break;
}
case 'openInventory':
// TODO: inventory UI
console.log('[gameMode] inventory:', worldState.inventory);
break;
}
}
// ==================== Save Gadget ====================
function handleSaveGadget() {
// Gather current circuit from the editor state
const gates = circuitState.gates;
const connections = circuitState.connections;
const inputGates = gates.filter(g => g.type === 'INPUT');
const outputGates = gates.filter(g => g.type === 'OUTPUT');
if (inputGates.length === 0 || outputGates.length === 0) {
showNotification('Need at least 1 INPUT and 1 OUTPUT!', '⚠️', '#ff5555');
return;
}
// Switch to world render temporarily to show the naming screen on canvas
// (workshop mode uses its own render loop, so we overlay on the canvas)
openNamingScreen(
'🎒 Name your gadget',
`Gadget ${getGadgets().length + 1}`,
(name) => {
if (!name) return; // cancelled
const component = {
id: name.toLowerCase().replace(/[^a-z0-9_]/g, '_').replace(/_+/g, '_'),
name,
inputCount: inputGates.length,
outputCount: outputGates.length,
inputIds: inputGates.map(g => g.id),
outputIds: outputGates.map(g => g.id),
gates: JSON.parse(JSON.stringify(gates)),
connections: JSON.parse(JSON.stringify(connections))
};
const result = saveGadget(component);
if (result.success) {
showNotification(`"${name}" saved to backpack!`, '🎒', '#ff44aa');
} else {
showNotification(result.error, '⚠️', '#ff5555');
}
}
);
}
// ==================== Puzzle testing ====================
/**
* Test a gadget against a puzzle door's required outputs.
* Runs the gadget's internal circuit with all possible input combos
* and checks if outputs match.
*/
function testGadgetOnPuzzle(gadget, puzzleInter) {
const required = puzzleInter.requiredOutputs;
if (!required || !gadget.gates || !gadget.connections) return false;
// Clone internal circuit for evaluation
const gates = JSON.parse(JSON.stringify(gadget.gates));
const conns = gadget.connections;
const inputIds = gadget.inputIds || [];
const outputIds = gadget.outputIds || [];
// Test with all inputs = 0, then all = 1, etc.
// For simplicity: test all 2^n input combos and collect outputs for each
const n = inputIds.length;
const allOutputs = [];
for (let combo = 0; combo < (1 << n); combo++) {
// Reset gates
const evalGates = JSON.parse(JSON.stringify(gates));
// Set inputs
for (let i = 0; i < n; i++) {
const inputGate = evalGates.find(g => g.id === inputIds[i]);
if (inputGate) inputGate.value = (combo >> i) & 1;
}
// Evaluate (fixed-point iteration)
for (let iter = 0; iter < 20; iter++) {
let changed = false;
for (const g of evalGates) {
if (g.type === 'INPUT' || g.type === 'CLOCK') continue;
const inCount = g.type === 'NOT' || g.type === 'OUTPUT' ? 1 : 2;
const gInputs = [];
for (let j = 0; j < inCount; j++) {
const conn = conns.find(c => c.to === g.id && c.toPort === j);
if (conn) {
const src = evalGates.find(s => s.id === conn.from);
gInputs.push(src ? (src.value || 0) : 0);
} else {
gInputs.push(0);
}
}
let result = 0;
switch (g.type) {
case 'AND': result = (gInputs[0] && gInputs[1]) ? 1 : 0; break;
case 'OR': result = (gInputs[0] || gInputs[1]) ? 1 : 0; break;
case 'NOT': result = gInputs[0] ? 0 : 1; break;
case 'NAND': result = (gInputs[0] && gInputs[1]) ? 0 : 1; break;
case 'NOR': result = (gInputs[0] || gInputs[1]) ? 0 : 1; break;
case 'XOR': result = (gInputs[0] !== gInputs[1]) ? 1 : 0; break;
case 'OUTPUT': result = gInputs[0] || 0; break;
default: result = 0;
}
if (result !== g.value) { g.value = result; changed = true; }
}
if (!changed) break;
}
// Collect outputs
for (const outId of outputIds) {
const outGate = evalGates.find(g => g.id === outId);
allOutputs.push(outGate ? (outGate.value || 0) : 0);
}
}
// Compare: the required outputs should match the output pattern
// Simple comparison: check if required matches any combo's outputs
// Or: required is the full truth table (outputs for combo 0, then combo 1, etc.)
if (allOutputs.length === required.length) {
return allOutputs.every((v, i) => v === required[i]);
}
// Fallback: check if any single combo's outputs match
const outPerCombo = outputIds.length;
for (let combo = 0; combo < (1 << n); combo++) {
const comboOutputs = allOutputs.slice(combo * outPerCombo, (combo + 1) * outPerCombo);
if (comboOutputs.length === required.length && comboOutputs.every((v, i) => v === required[i])) {
return true;
}
}
return false;
}
// ==================== 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';
}
// Hide game buttons (we're IN world, not workshop)
const backBtn = document.getElementById('back-to-world-btn');
if (backBtn) backBtn.style.display = 'none';
const saveBtn = document.getElementById('save-gadget-btn');
if (saveBtn) saveBtn.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';
}
// Move game buttons into toolbar-right so they sit inline with Export/Import
const toolbarRight = document.querySelector('.toolbar-right');
const saveBtn = document.getElementById('save-gadget-btn');
const backBtn = document.getElementById('back-to-world-btn');
if (toolbarRight && saveBtn) {
toolbarRight.insertBefore(saveBtn, toolbarRight.firstChild);
saveBtn.style.display = 'inline-block';
}
if (toolbarRight && backBtn) {
toolbarRight.insertBefore(backBtn, toolbarRight.firstChild);
backBtn.style.display = 'inline-block';
}
}
function hideWorkshopUI() {
const toolbar = document.getElementById('toolbar');
if (toolbar) toolbar.style.display = 'none';
// Hide game buttons and move them back out of toolbar
const backBtn = document.getElementById('back-to-world-btn');
if (backBtn) { backBtn.style.display = 'none'; document.body.insertBefore(backBtn, document.body.firstChild); }
const saveBtn = document.getElementById('save-gadget-btn');
if (saveBtn) { saveBtn.style.display = 'none'; document.body.insertBefore(saveBtn, document.body.firstChild); }
}

631
js/world/inventory.js Normal file
View File

@@ -0,0 +1,631 @@
/**
* inventory.js — Gadget backpack system
*
* A "gadget" is a saved circuit (gates + connections) the player crafted
* in the Workshop. Gadgets live in the backpack and can be used on
* puzzle doors to solve them.
*
* Inspired by the Pokemon item/bag menu but adapted for logic circuits.
*/
import { worldState } from './worldState.js';
import { TILE_PX } from './sprites.js';
// ==================== Gadget storage ====================
/**
* Gadget shape:
* {
* id: string, // sanitized unique ID
* name: string, // player-chosen display name
* inputCount: number,
* outputCount: number,
* gates: Array, // deep-cloned gate array
* connections: Array, // deep-cloned connection array
* inputIds: number[], // ordered input gate IDs
* outputIds: number[], // ordered output gate IDs
* icon: string, // emoji icon (auto-assigned)
* createdAt: number // Date.now()
* }
*/
const GADGET_ICONS = ['⚡', '🔌', '💡', '🔋', '📡', '🛠️', '⚙️', '🔩', '🧲', '💎', '🔮', '🧪'];
/** All saved gadgets — persisted in worldState.gadgets */
export function getGadgets() {
if (!worldState.gadgets) worldState.gadgets = [];
return worldState.gadgets;
}
/**
* Save a circuit as a gadget in the backpack
* @param {Object} component — component definition from components.js
* @returns {{ success: boolean, gadget?: Object, error?: string }}
*/
export function saveGadget(component) {
if (!component || !component.gates || !component.connections) {
return { success: false, error: 'Invalid circuit data' };
}
if (!component.inputIds?.length || !component.outputIds?.length) {
return { success: false, error: 'Circuit needs at least 1 INPUT and 1 OUTPUT' };
}
const gadgets = getGadgets();
// Check for duplicate — update if same id exists
const existingIdx = gadgets.findIndex(g => g.id === component.id);
const gadget = {
id: component.id,
name: component.name,
inputCount: component.inputCount,
outputCount: component.outputCount,
gates: JSON.parse(JSON.stringify(component.gates)),
connections: JSON.parse(JSON.stringify(component.connections)),
inputIds: [...component.inputIds],
outputIds: [...component.outputIds],
icon: GADGET_ICONS[gadgets.length % GADGET_ICONS.length],
createdAt: Date.now()
};
if (existingIdx >= 0) {
gadget.icon = gadgets[existingIdx].icon; // keep original icon
gadgets[existingIdx] = gadget;
} else {
gadgets.push(gadget);
}
console.log(`[inventory] saved gadget "${gadget.name}" (${gadget.inputCount}in/${gadget.outputCount}out)`);
return { success: true, gadget };
}
/**
* Remove a gadget from the backpack
*/
export function removeGadget(gadgetId) {
const gadgets = getGadgets();
const idx = gadgets.findIndex(g => g.id === gadgetId);
if (idx >= 0) {
gadgets.splice(idx, 1);
return true;
}
return false;
}
/**
* Get a gadget by ID
*/
export function getGadget(gadgetId) {
return getGadgets().find(g => g.id === gadgetId) || null;
}
// ==================== In-game naming screen ====================
let namingActive = false;
let namingText = '';
let namingCursorBlink = 0;
let namingCallback = null; // called with the final name string
let namingTitle = '';
let namingMaxLen = 16;
const CHAR_ROWS = [
'ABCDEFGHIJ',
'KLMNOPQRST',
'UVWXYZ ',
'abcdefghij',
'klmnopqrst',
'uvwxyz ',
'0123456789',
'-_.!? ⌫ ✓',
];
let charRow = 0, charCol = 0;
export function isNamingActive() { return namingActive; }
export function openNamingScreen(title, defaultText, callback) {
namingActive = true;
namingTitle = title || 'Enter name:';
namingText = defaultText || '';
namingCallback = callback;
namingCursorBlink = 0;
charRow = 0;
charCol = 0;
namingMaxLen = 16;
}
export function handleNamingInput(key) {
if (!namingActive) return false;
if (key === 'Escape') {
// Cancel
namingActive = false;
if (namingCallback) namingCallback(null);
return true;
}
if (key === 'Backspace') {
namingText = namingText.slice(0, -1);
return true;
}
// Navigate character grid
if (key === 'ArrowUp') { charRow = Math.max(0, charRow - 1); return true; }
if (key === 'ArrowDown') { charRow = Math.min(CHAR_ROWS.length - 1, charRow + 1); return true; }
if (key === 'ArrowLeft') { charCol = Math.max(0, charCol - 1); return true; }
if (key === 'ArrowRight') { charCol = Math.min(CHAR_ROWS[charRow].length - 1, charCol + 1); return true; }
// Select from grid
if (key === 'Enter' || key === ' ') {
const ch = CHAR_ROWS[charRow]?.[charCol];
if (ch === '✓' || (key === 'Enter' && (charRow === CHAR_ROWS.length - 1 && charCol >= 9))) {
// Confirm
if (namingText.trim()) {
namingActive = false;
if (namingCallback) namingCallback(namingText.trim());
}
} else if (ch === '⌫') {
namingText = namingText.slice(0, -1);
} else if (ch && ch !== ' ' && namingText.length < namingMaxLen) {
namingText += ch;
} else if (ch === ' ' && namingText.length < namingMaxLen && namingText.length > 0) {
namingText += ' ';
}
return true;
}
// Direct typing — any printable character
if (key.length === 1 && namingText.length < namingMaxLen) {
namingText += key;
return true;
}
return true;
}
export function drawNamingScreen(ctx, canvasW, canvasH) {
if (!namingActive) return;
namingCursorBlink = (namingCursorBlink + 1) % 60;
// Dim background
ctx.fillStyle = 'rgba(0, 0, 0, 0.8)';
ctx.fillRect(0, 0, canvasW, canvasH);
const pw = Math.min(380, canvasW - 40);
const ph = 340;
const px = (canvasW - pw) / 2;
const py = (canvasH - ph) / 2;
// Panel
ctx.fillStyle = '#181c2a';
ctx.strokeStyle = '#ff44aa';
ctx.lineWidth = 2;
roundRect(ctx, px, py, pw, ph, 8);
ctx.fill();
ctx.stroke();
// Title
ctx.fillStyle = '#ff44aa';
ctx.font = 'bold 14px "Segoe UI", system-ui, sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
ctx.fillText(namingTitle, canvasW / 2, py + 14);
// Text field
const tfx = px + 20, tfy = py + 42, tfw = pw - 40, tfh = 28;
ctx.fillStyle = '#0f1119';
ctx.strokeStyle = '#2a2f45';
ctx.lineWidth = 1;
roundRect(ctx, tfx, tfy, tfw, tfh, 4);
ctx.fill();
ctx.stroke();
ctx.fillStyle = '#fff';
ctx.font = 'bold 14px monospace';
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
const displayText = namingText + (namingCursorBlink < 30 ? '▌' : '');
ctx.fillText(displayText, tfx + 8, tfy + tfh / 2);
// Character count
ctx.fillStyle = '#555';
ctx.font = '10px monospace';
ctx.textAlign = 'right';
ctx.fillText(`${namingText.length}/${namingMaxLen}`, tfx + tfw - 4, tfy + tfh / 2);
// Character grid
const gridY = tfy + tfh + 16;
const cellW = 28, cellH = 24;
const gridW = 10 * cellW;
const gridX = (canvasW - gridW) / 2;
ctx.font = '12px monospace';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
for (let r = 0; r < CHAR_ROWS.length; r++) {
const row = CHAR_ROWS[r];
for (let c = 0; c < row.length; c++) {
const cx = gridX + c * cellW;
const cy = gridY + r * cellH;
const ch = row[c];
const isSel = r === charRow && c === charCol;
if (isSel) {
ctx.fillStyle = 'rgba(255, 68, 170, 0.25)';
roundRect(ctx, cx, cy, cellW - 2, cellH - 2, 3);
ctx.fill();
ctx.strokeStyle = '#ff44aa';
ctx.lineWidth = 1.5;
ctx.stroke();
}
if (ch === ' ') continue;
ctx.fillStyle = isSel ? '#ff44aa' : (ch === '✓' ? '#00e599' : ch === '⌫' ? '#ff5555' : '#c8cad0');
ctx.font = (ch === '✓' || ch === '⌫') ? 'bold 14px sans-serif' : '12px monospace';
ctx.fillText(ch, cx + cellW / 2 - 1, cy + cellH / 2);
}
}
// Hint
ctx.fillStyle = '#444';
ctx.font = '10px "Segoe UI", system-ui, sans-serif';
ctx.textAlign = 'center';
ctx.fillText('Type directly or use ↑↓←→ + Enter | ESC: Cancel', canvasW / 2, gridY + CHAR_ROWS.length * cellH + 10);
}
// ==================== In-game notification ====================
let notification = null; // { text, icon, timer, color }
export function showNotification(text, icon = '🎒', color = '#ff44aa') {
notification = { text, icon, timer: 150, color }; // ~2.5s at 60fps
}
export function drawNotification(ctx, canvasW) {
if (!notification) return;
notification.timer--;
if (notification.timer <= 0) { notification = null; return; }
const alpha = notification.timer < 20 ? notification.timer / 20 : 1;
const n = notification;
const text = `${n.icon} ${n.text}`;
ctx.save();
ctx.globalAlpha = alpha;
ctx.font = 'bold 13px "Segoe UI", system-ui, sans-serif';
const tw = ctx.measureText(text).width + 32;
const bx = (canvasW - tw) / 2;
const by = 50;
ctx.fillStyle = n.color;
roundRect(ctx, bx, by, tw, 32, 6);
ctx.fill();
ctx.fillStyle = '#fff';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(text, canvasW / 2, by + 16);
ctx.restore();
}
// ==================== Backpack UI state ====================
let backpackOpen = false;
let cursorIndex = 0;
let scrollOffset = 0;
let selectedGadget = null; // gadget currently inspected
let actionMenuOpen = false;
let actionCursor = 0;
let onUseCallback = null; // called when player selects "Use" on a gadget
const MAX_VISIBLE = 7; // items visible without scrolling
export function isBackpackOpen() { return backpackOpen; }
export function openBackpack(onUse) {
backpackOpen = true;
cursorIndex = 0;
scrollOffset = 0;
selectedGadget = null;
actionMenuOpen = false;
actionCursor = 0;
onUseCallback = onUse || null;
worldState.mode = 'inventory';
}
export function closeBackpack() {
backpackOpen = false;
selectedGadget = null;
actionMenuOpen = false;
worldState.mode = 'world';
}
// ==================== Backpack input ====================
export function handleBackpackInput(key) {
if (!backpackOpen) return false;
const gadgets = getGadgets();
// Action sub-menu open
if (actionMenuOpen) {
if (key === 'ArrowUp' || key === 'w' || key === 'W') {
actionCursor = Math.max(0, actionCursor - 1);
} else if (key === 'ArrowDown' || key === 's' || key === 'S') {
actionCursor = Math.min(1, actionCursor + 1); // 0=Use, 1=Toss
} else if (key === 'Enter' || key === ' ' || key === 'e' || key === 'E') {
if (actionCursor === 0 && selectedGadget) {
// USE
if (onUseCallback) {
const g = selectedGadget;
closeBackpack();
onUseCallback(g);
} else {
// Just close — no active puzzle
closeBackpack();
}
} else if (actionCursor === 1 && selectedGadget) {
// TOSS
removeGadget(selectedGadget.id);
actionMenuOpen = false;
selectedGadget = null;
if (cursorIndex >= gadgets.length) cursorIndex = Math.max(0, gadgets.length - 1);
}
} else if (key === 'Escape' || key === 'Backspace' || key === 'b' || key === 'B') {
actionMenuOpen = false;
selectedGadget = null;
}
return true;
}
// Main list navigation
if (key === 'ArrowUp' || key === 'w' || key === 'W') {
cursorIndex = Math.max(0, cursorIndex - 1);
if (cursorIndex < scrollOffset) scrollOffset = cursorIndex;
} else if (key === 'ArrowDown' || key === 's' || key === 'S') {
cursorIndex = Math.min(gadgets.length - 1, cursorIndex + 1);
if (cursorIndex >= scrollOffset + MAX_VISIBLE) scrollOffset = cursorIndex - MAX_VISIBLE + 1;
} else if (key === 'Enter' || key === ' ' || key === 'e' || key === 'E') {
if (gadgets.length > 0 && gadgets[cursorIndex]) {
selectedGadget = gadgets[cursorIndex];
actionMenuOpen = true;
actionCursor = 0;
}
} else if (key === 'Escape' || key === 'i' || key === 'I' || key === 'Backspace' || key === 'b' || key === 'B') {
closeBackpack();
}
return true;
}
// ==================== Backpack rendering ====================
/**
* Draw the full-screen backpack overlay on canvas
*/
export function drawBackpack(ctx, canvasW, canvasH) {
if (!backpackOpen) return;
const gadgets = getGadgets();
// Dim background
ctx.fillStyle = 'rgba(0, 0, 0, 0.75)';
ctx.fillRect(0, 0, canvasW, canvasH);
// Main panel
const panelW = Math.min(420, canvasW - 40);
const panelH = Math.min(440, canvasH - 40);
const px = (canvasW - panelW) / 2;
const py = (canvasH - panelH) / 2;
// Panel background
ctx.fillStyle = '#181c2a';
ctx.strokeStyle = '#00e599';
ctx.lineWidth = 2;
roundRect(ctx, px, py, panelW, panelH, 8);
ctx.fill();
ctx.stroke();
// Header
ctx.fillStyle = '#00e599';
ctx.font = 'bold 16px "Segoe UI", system-ui, sans-serif';
ctx.textAlign = 'left';
ctx.textBaseline = 'top';
ctx.fillText('🎒 Backpack', px + 16, py + 12);
ctx.fillStyle = '#555';
ctx.font = '11px "Segoe UI", system-ui, sans-serif';
ctx.textAlign = 'right';
ctx.fillText(`${gadgets.length} gadget${gadgets.length !== 1 ? 's' : ''}`, px + panelW - 16, py + 16);
// Divider
ctx.strokeStyle = '#2a2f45';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(px + 12, py + 38);
ctx.lineTo(px + panelW - 12, py + 38);
ctx.stroke();
// Empty state
if (gadgets.length === 0) {
ctx.fillStyle = '#555';
ctx.font = '13px "Segoe UI", system-ui, sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('No gadgets yet!', canvasW / 2, canvasH / 2 - 10);
ctx.fillStyle = '#444';
ctx.font = '11px "Segoe UI", system-ui, sans-serif';
ctx.fillText('Craft circuits in the Workshop (TAB)', canvasW / 2, canvasH / 2 + 14);
ctx.fillText('then save them as gadgets.', canvasW / 2, canvasH / 2 + 30);
// Close hint
drawCloseHint(ctx, px, py + panelH, panelW);
return;
}
// Item list
const listX = px + 12;
const listY = py + 46;
const itemH = 44;
const listH = MAX_VISIBLE * itemH;
// Clip list area
ctx.save();
ctx.beginPath();
ctx.rect(listX, listY, panelW - 24, listH);
ctx.clip();
const visibleGadgets = gadgets.slice(scrollOffset, scrollOffset + MAX_VISIBLE);
visibleGadgets.forEach((gadget, vi) => {
const i = vi + scrollOffset;
const iy = listY + vi * itemH;
const isSelected = i === cursorIndex;
// Selection highlight
if (isSelected) {
ctx.fillStyle = 'rgba(0, 229, 153, 0.12)';
roundRect(ctx, listX, iy, panelW - 24, itemH - 2, 4);
ctx.fill();
// Arrow cursor
ctx.fillStyle = '#00e599';
ctx.font = '14px sans-serif';
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillText('▸', listX + 4, iy + itemH / 2);
}
// Icon
ctx.font = '18px sans-serif';
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillText(gadget.icon, listX + 20, iy + itemH / 2);
// Name
ctx.fillStyle = isSelected ? '#fff' : '#c8cad0';
ctx.font = `${isSelected ? 'bold ' : ''}13px "Segoe UI", system-ui, sans-serif`;
ctx.textAlign = 'left';
ctx.fillText(gadget.name, listX + 46, iy + itemH / 2 - 8);
// Details
ctx.fillStyle = '#666';
ctx.font = '10px monospace';
ctx.fillText(`${gadget.inputCount} IN → ${gadget.outputCount} OUT | ${gadget.gates.length} gates`, listX + 46, iy + itemH / 2 + 10);
});
ctx.restore();
// Scroll indicators
if (scrollOffset > 0) {
ctx.fillStyle = '#00e599';
ctx.font = '12px sans-serif';
ctx.textAlign = 'center';
ctx.fillText('▲', px + panelW / 2, listY - 4);
}
if (scrollOffset + MAX_VISIBLE < gadgets.length) {
ctx.fillStyle = '#00e599';
ctx.font = '12px sans-serif';
ctx.textAlign = 'center';
ctx.fillText('▼', px + panelW / 2, listY + listH + 10);
}
// Detail panel (right side of selected item)
if (gadgets[cursorIndex]) {
const g = gadgets[cursorIndex];
const detailY = listY + listH + 16;
ctx.strokeStyle = '#2a2f45';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(px + 12, detailY - 6);
ctx.lineTo(px + panelW - 12, detailY - 6);
ctx.stroke();
// Mini circuit preview info
ctx.fillStyle = '#aaa';
ctx.font = '11px "Segoe UI", system-ui, sans-serif';
ctx.textAlign = 'left';
ctx.textBaseline = 'top';
const gateTypes = {};
g.gates.forEach(gate => {
if (gate.type !== 'INPUT' && gate.type !== 'OUTPUT') {
gateTypes[gate.type] = (gateTypes[gate.type] || 0) + 1;
}
});
const breakdown = Object.entries(gateTypes).map(([t, n]) => `${n}× ${t}`).join(', ');
ctx.fillText(`Components: ${breakdown || 'none'}`, px + 16, detailY);
ctx.fillStyle = '#666';
ctx.font = '10px "Segoe UI", system-ui, sans-serif';
const date = new Date(g.createdAt);
ctx.fillText(`Created: ${date.toLocaleDateString()} ${date.toLocaleTimeString()}`, px + 16, detailY + 18);
}
// Action sub-menu
if (actionMenuOpen && selectedGadget) {
drawActionMenu(ctx, px + panelW - 120, py + 50 + (cursorIndex - scrollOffset) * itemH);
}
// Close hint
drawCloseHint(ctx, px, py + panelH, panelW);
}
function drawActionMenu(ctx, x, y) {
const w = 100, itemH = 28;
const h = itemH * 2 + 8;
// Background
ctx.fillStyle = '#1e2235';
ctx.strokeStyle = '#00e599';
ctx.lineWidth = 1.5;
roundRect(ctx, x, y, w, h, 4);
ctx.fill();
ctx.stroke();
const actions = ['⚡ Use', '🗑️ Toss'];
actions.forEach((label, i) => {
const iy = y + 4 + i * itemH;
const isSel = i === actionCursor;
if (isSel) {
ctx.fillStyle = 'rgba(0, 229, 153, 0.15)';
ctx.fillRect(x + 2, iy, w - 4, itemH);
}
ctx.fillStyle = isSel ? '#00e599' : '#aaa';
ctx.font = `${isSel ? 'bold ' : ''}12px "Segoe UI", system-ui, sans-serif`;
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
if (isSel) ctx.fillText('▸', x + 6, iy + itemH / 2);
ctx.fillText(label, x + 20, iy + itemH / 2);
});
}
function drawCloseHint(ctx, x, y, w) {
ctx.fillStyle = '#444';
ctx.font = '10px "Segoe UI", system-ui, sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
ctx.fillText('I / ESC: Close | E: Select | ↑↓: Navigate', x + w / 2, y - 18);
}
function roundRect(ctx, x, y, w, h, r) {
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.lineTo(x + w - r, y);
ctx.quadraticCurveTo(x + w, y, x + w, y + r);
ctx.lineTo(x + w, y + h - r);
ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
ctx.lineTo(x + r, y + h);
ctx.quadraticCurveTo(x, y + h, x, y + h - r);
ctx.lineTo(x, y + r);
ctx.quadraticCurveTo(x, y, x + r, y);
ctx.closePath();
}

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

@@ -0,0 +1,209 @@
/**
* 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."] },
// Module door example: requires AND(A, B) → C
{
x: 9, y: 1, type: 'module',
moduleId: 'lab_and_door',
label: 'AND Gate Door',
ports: [
{ name: 'A', dir: 'out', bits: 1 },
{ name: 'B', dir: 'out', bits: 1 },
{ name: 'C', dir: 'in', bits: 1 }
],
verify: `(test) => {
return test({A:0, B:0}).C === 0
&& test({A:0, B:1}).C === 0
&& test({A:1, B:0}).C === 0
&& test({A:1, B:1}).C === 1;
}`
},
]
};
// ==================== 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 };

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

@@ -0,0 +1,316 @@
// 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');
}
// ==================== Character Registry ====================
// Characters are stored as spritesheets: 3 cols (still, walk1, walk2) × 4 rows (down, up, left, right)
const characterRegistry = {};
/**
* Register a character from a spritesheet image (or base64 data URL).
* @param {string} charId - unique character ID
* @param {string} name - display name
* @param {string|HTMLImageElement} source - image URL, base64 data URL, or Image element
* @param {number} frameW - frame width in px (default 16)
* @param {number} frameH - frame height in px (default 16)
* @returns {Promise} resolves when character is loaded
*/
export function registerCharacter(charId, name, source, frameW = 16, frameH = 16) {
return new Promise((resolve) => {
const char = { id: charId, name, frameW, frameH, img: null };
if (source instanceof HTMLImageElement) {
char.img = source;
characterRegistry[charId] = char;
resolve(char);
} else {
const img = new Image();
img.onload = () => { char.img = img; characterRegistry[charId] = char; resolve(char); };
img.onerror = () => { console.warn(`[sprites] failed to load char: ${charId}`); resolve(null); };
img.src = source;
}
});
}
/** Get a registered character definition */
export function getCharacter(charId) { return characterRegistry[charId] || null; }
/** Get all registered characters */
export function getAllCharacters() { return { ...characterRegistry }; }
/** Remove a character from the registry */
export function removeCharacter(charId) { delete characterRegistry[charId]; }
// Direction → row index in spritesheet
const DIR_ROW = { down: 0, up: 1, left: 2, right: 3 };
/**
* Draw a character from the registry using its spritesheet.
* @param {CanvasRenderingContext2D} ctx
* @param {string} charId - character ID from registry
* @param {number} screenX - top-left X on screen
* @param {number} screenY - top-left Y on screen
* @param {string} facing - 'up'|'down'|'left'|'right'
* @param {number} walkFrame - 0=still, 1=walk-1, 2=walk-2
*/
export function drawCharacter(ctx, charId, screenX, screenY, facing, walkFrame = 0) {
const char = characterRegistry[charId];
if (!char || !char.img) {
// Fallback: magenta box
ctx.fillStyle = '#ff44aa';
ctx.fillRect(screenX, screenY, TILE_PX, TILE_PX);
return;
}
const row = DIR_ROW[facing] ?? 0;
const col = Math.min(walkFrame, 2);
const sx = col * char.frameW;
const sy = row * char.frameH;
ctx.imageSmoothingEnabled = false;
ctx.drawImage(char.img, sx, sy, char.frameW, char.frameH, screenX, screenY, TILE_PX, TILE_PX);
}
// ==================== 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 — if it has a charId, uses the character registry spritesheet.
* Otherwise falls back to the default hardcoded NPC sprites.
* @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'
* @param {string} [charId] - optional character ID from registry
* @param {number} [walkFrame=0] - animation frame (0=still, 1=walk-1, 2=walk-2)
*/
export function drawNPC(ctx, screenX, screenY, facing, charId, walkFrame = 0) {
// If a character is registered, use the spritesheet renderer
if (charId && characterRegistry[charId]) {
drawCharacter(ctx, charId, screenX, screenY, facing, walkFrame);
return;
}
// Fallback: legacy hardcoded sprites
const dir = DIR_TO_NPC[facing] || 'down';
const key = `npc:a-${dir}`;
const img = imageCache[key];
if (!img) {
ctx.fillStyle = '#ff44aa';
ctx.fillRect(screenX, screenY, TILE_PX, TILE_PX);
return;
}
ctx.imageSmoothingEnabled = false;
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);
}

809
js/world/wiringPanel.js Normal file
View File

@@ -0,0 +1,809 @@
// wiringPanel.js — Wiring panel for connecting gadget ports to module ports
// The player wires a gadget's I/O to a module door's ports, then executes
// to verify the circuit satisfies the module's logic.
// After execution, shows an animated truth table + digital waveform viewer.
import { worldState, solvePuzzle, isPuzzleSolved, startDialog } from './worldState.js';
import { showNotification } from './inventory.js';
// ==================== State ====================
let panelOpen = false;
let moduleInter = null; // the full interaction object from the map
let gadget = null; // the selected gadget from backpack
let wires = []; // [{ moduleIdx, gadgetIdx }]
let cursor = { side: 'module', index: 0 };
let selectedModule = null; // index or null (first port of a pending wire)
let selectedGadget = null;
let result = null; // { message, color } or null
let resultTimer = 0;
// Execution log state
let execLog = null; // { rows, portNames, passed, startTime, revealedRows }
const ROW_REVEAL_MS = 300; // ms between each row appearing
// ==================== Public API ====================
export function isWiringOpen() { return panelOpen; }
export function openWiringPanel(inter, gad) {
panelOpen = true;
moduleInter = inter;
gadget = gad;
wires = [];
cursor = { side: 'module', index: 0 };
selectedModule = null;
selectedGadget = null;
result = null;
execLog = null;
worldState.mode = 'wiring';
console.log(`[wiring] opened for module "${inter.label}" with gadget "${gad.name}"`);
}
export function closeWiringPanel() {
panelOpen = false;
moduleInter = null;
gadget = null;
wires = [];
execLog = null;
worldState.mode = 'world';
}
// ==================== Gadget port helpers ====================
function getGadgetPorts() {
if (!gadget) return [];
const ports = [];
const inputIds = gadget.inputIds || [];
const outputIds = gadget.outputIds || [];
for (let i = 0; i < inputIds.length; i++) {
ports.push({ name: `In ${i + 1}`, dir: 'in', gateId: inputIds[i] });
}
for (let i = 0; i < outputIds.length; i++) {
ports.push({ name: `Out ${i + 1}`, dir: 'out', gateId: outputIds[i] });
}
return ports;
}
// ==================== Input handling ====================
export function handleWiringInput(key) {
if (!panelOpen) return false;
// If execution log is showing, ESC or Enter dismisses it
if (execLog) {
if (key === 'Escape' || key === 'Enter') {
if (execLog.passed) {
closeWiringPanel();
showNotification('Module unlocked!', '⚡', '#00ff88');
startDialog([
`⚡ "${gadget.name}" passed the verification!`,
'The module hums to life and the door unlocks.'
], 'System');
} else {
execLog = null; // dismiss log, back to wiring
}
}
return true;
}
const mPorts = moduleInter.ports || [];
const gPorts = getGadgetPorts();
switch (key) {
case 'ArrowUp': case 'w': case 'W':
cursor.index = Math.max(0, cursor.index - 1);
break;
case 'ArrowDown': case 's': case 'S': {
const max = cursor.side === 'module' ? mPorts.length - 1 : gPorts.length - 1;
cursor.index = Math.min(Math.max(max, 0), cursor.index + 1);
break;
}
case 'ArrowLeft': case 'ArrowRight': case 'a': case 'A': case 'd': case 'D':
cursor.side = cursor.side === 'module' ? 'gadget' : 'module';
{ const max = cursor.side === 'module' ? mPorts.length - 1 : gPorts.length - 1;
cursor.index = Math.min(cursor.index, Math.max(max, 0)); }
break;
case 'e': case 'E': case ' ':
selectPort();
break;
case 'Enter':
executeWiring();
break;
case 'Backspace': case 'Delete': case 'x': case 'X':
removeWireAtCursor();
break;
case 'Escape':
closeWiringPanel();
break;
default:
return false;
}
return true;
}
// ==================== Wire management ====================
function selectPort() {
if (cursor.side === 'module') {
selectedModule = cursor.index;
if (selectedGadget !== null) tryCreateWire();
} else {
selectedGadget = cursor.index;
if (selectedModule !== null) tryCreateWire();
}
}
function tryCreateWire() {
const mPorts = moduleInter.ports || [];
const gPorts = getGadgetPorts();
const mPort = mPorts[selectedModule];
const gPort = gPorts[selectedGadget];
if (!mPort || !gPort) {
selectedModule = null;
selectedGadget = null;
return;
}
const valid =
(mPort.dir === 'out' && gPort.dir === 'in') ||
(mPort.dir === 'in' && gPort.dir === 'out');
if (!valid) {
result = { message: '✗ Invalid! Wire out→in only', color: '#ff4444' };
resultTimer = Date.now();
selectedModule = null;
selectedGadget = null;
return;
}
wires = wires.filter(w => w.moduleIdx !== selectedModule && w.gadgetIdx !== selectedGadget);
wires.push({ moduleIdx: selectedModule, gadgetIdx: selectedGadget });
result = { message: `✓ Wired ${mPort.name}${gPort.name}`, color: '#00e599' };
resultTimer = Date.now();
selectedModule = null;
selectedGadget = null;
}
function removeWireAtCursor() {
const key = cursor.side === 'module' ? 'moduleIdx' : 'gadgetIdx';
const before = wires.length;
wires = wires.filter(w => w[key] !== cursor.index);
if (wires.length < before) {
result = { message: 'Wire removed', color: '#ffaa00' };
resultTimer = Date.now();
}
}
// ==================== Circuit evaluation ====================
function evaluateGadgetCircuit(gates, connections, inputValues) {
const evalGates = JSON.parse(JSON.stringify(gates));
for (const g of evalGates) {
if (g.type === 'INPUT' && inputValues[g.id] !== undefined) {
g.value = inputValues[g.id];
}
}
for (let iter = 0; iter < 20; iter++) {
let changed = false;
for (const g of evalGates) {
if (g.type === 'INPUT' || g.type === 'CLOCK') continue;
const inCount = (g.type === 'NOT' || g.type === 'OUTPUT') ? 1 : 2;
const ins = [];
for (let p = 0; p < inCount; p++) {
const conn = connections.find(c => c.to === g.id && c.toPort === p);
if (conn) {
const src = evalGates.find(s => s.id === conn.from);
ins.push(src ? (src.value || 0) : 0);
} else { ins.push(0); }
}
let val = 0;
switch (g.type) {
case 'AND': val = (ins[0] && ins[1]) ? 1 : 0; break;
case 'OR': val = (ins[0] || ins[1]) ? 1 : 0; break;
case 'NOT': val = ins[0] ? 0 : 1; break;
case 'NAND': val = (ins[0] && ins[1]) ? 0 : 1; break;
case 'NOR': val = (ins[0] || ins[1]) ? 0 : 1; break;
case 'XOR': val = (ins[0] !== ins[1]) ? 1 : 0; break;
case 'OUTPUT': val = ins[0] || 0; break;
default: val = g.value || 0;
}
if (val !== g.value) { g.value = val; changed = true; }
}
if (!changed) break;
}
return evalGates;
}
/**
* Build the test function + run all combos to produce the truth table,
* then pass it to the verify function. Store results for the execution log.
*/
function executeWiring() {
const mPorts = moduleInter.ports || [];
const gPorts = getGadgetPorts();
// Identify module out ports (inputs to the gadget) and in ports (outputs from gadget)
const outPorts = mPorts.filter(p => p.dir === 'out');
const inPorts = mPorts.filter(p => p.dir === 'in');
const n = outPorts.length;
// Collect truth table rows: for each input combo, run the circuit
const rows = [];
function testOnce(moduleOutputs) {
const inputValues = {};
for (const wire of wires) {
const mp = mPorts[wire.moduleIdx];
const gp = gPorts[wire.gadgetIdx];
if (mp.dir === 'out' && gp.dir === 'in') {
inputValues[gp.gateId] = moduleOutputs[mp.name] || 0;
}
}
const evaluated = evaluateGadgetCircuit(gadget.gates, gadget.connections, inputValues);
const moduleInputs = {};
for (const wire of wires) {
const mp = mPorts[wire.moduleIdx];
const gp = gPorts[wire.gadgetIdx];
if (mp.dir === 'in' && gp.dir === 'out') {
const outGate = evaluated.find(g => g.id === gp.gateId);
moduleInputs[mp.name] = outGate ? (outGate.value || 0) : 0;
}
}
return moduleInputs;
}
// Generate all input combos and record results
for (let combo = 0; combo < (1 << n); combo++) {
const inputs = {};
for (let i = 0; i < n; i++) {
inputs[outPorts[i].name] = (combo >> i) & 1;
}
const outputs = testOnce(inputs);
rows.push({ inputs: { ...inputs }, outputs: { ...outputs } });
}
// Build test function that the verify code calls (using our precomputed results)
function test(moduleOutputs) {
const inputValues = {};
for (const wire of wires) {
const mp = mPorts[wire.moduleIdx];
const gp = gPorts[wire.gadgetIdx];
if (mp.dir === 'out' && gp.dir === 'in') {
inputValues[gp.gateId] = moduleOutputs[mp.name] || 0;
}
}
const evaluated = evaluateGadgetCircuit(gadget.gates, gadget.connections, inputValues);
const moduleInputs = {};
for (const wire of wires) {
const mp = mPorts[wire.moduleIdx];
const gp = gPorts[wire.gadgetIdx];
if (mp.dir === 'in' && gp.dir === 'out') {
const outGate = evaluated.find(g => g.id === gp.gateId);
moduleInputs[mp.name] = outGate ? (outGate.value || 0) : 0;
}
}
return moduleInputs;
}
// Run verify
try {
const verifyFn = new Function('return ' + moduleInter.verify)();
const passed = verifyFn(test);
// Build port name lists for display
const portNames = {
inputs: outPorts.map(p => p.name),
outputs: inPorts.map(p => p.name)
};
// Set up execution log with animated reveal
execLog = {
rows,
portNames,
passed,
startTime: Date.now(),
totalRows: rows.length
};
if (passed && moduleInter.moduleId) {
solvePuzzle(moduleInter.moduleId);
}
} catch (e) {
result = { message: `Error: ${e.message}`, color: '#ff4444' };
resultTimer = Date.now();
console.error('[wiring] verify error:', e);
}
}
// ==================== Rendering ====================
const PANEL_BG = 'rgba(10, 12, 20, 0.95)';
const PANEL_BORDER = '#00e599';
const PORT_OUT_COLOR = '#ff6644';
const PORT_IN_COLOR = '#44aaff';
const WIRE_COLOR = '#ffdd44';
const SELECTED_COLOR = '#ffffff';
const CURSOR_COLOR = '#00ffcc';
// Waveform/log colors
const WAVE_HIGH = '#00e599';
const WAVE_LOW = '#334';
const WAVE_GRID = '#1a1d2e';
const TABLE_HEADER_BG = '#141828';
const TABLE_ROW_BG = '#0d1018';
const TABLE_ROW_ALT = '#111520';
const PASS_COLOR = '#00ff88';
const FAIL_COLOR = '#ff4444';
export function drawWiringPanel(ctx, canvasW, canvasH) {
if (!panelOpen || !moduleInter || !gadget) return;
// If execution log is active, draw that instead
if (execLog) {
drawExecutionLog(ctx, canvasW, canvasH);
return;
}
const mPorts = moduleInter.ports || [];
const gPorts = getGadgetPorts();
// Panel dimensions
const pw = Math.min(640, canvasW - 40);
const ph = Math.min(480, canvasH - 40);
const px = (canvasW - pw) / 2;
const py = (canvasH - ph) / 2;
// Dim background
ctx.fillStyle = 'rgba(0, 0, 0, 0.7)';
ctx.fillRect(0, 0, canvasW, canvasH);
// Panel background
ctx.fillStyle = PANEL_BG;
ctx.strokeStyle = PANEL_BORDER;
ctx.lineWidth = 2;
roundRect(ctx, px, py, pw, ph, 8);
ctx.fill();
ctx.stroke();
// Title
ctx.fillStyle = '#00e599';
ctx.font = 'bold 16px "Segoe UI", system-ui, sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
ctx.fillText(`⚡ WIRING PANEL — ${moduleInter.label || 'Module'}`, px + pw / 2, py + 12);
// Gadget name
ctx.fillStyle = '#ff44aa';
ctx.font = '12px "Segoe UI", system-ui, sans-serif';
ctx.fillText(`Gadget: ${gadget.icon || '🔧'} ${gadget.name}`, px + pw / 2, py + 34);
// Column headers
const colY = py + 60;
const leftX = px + 30;
const rightX = px + pw - 30;
const portStartY = colY + 30;
const portSpacing = 40;
ctx.font = 'bold 13px "Segoe UI", system-ui, sans-serif';
ctx.textAlign = 'center';
ctx.fillStyle = '#aaa';
ctx.fillText('MODULE PORTS', leftX + 80, colY);
ctx.fillText('GADGET PORTS', rightX - 80, colY);
// Column separator
ctx.strokeStyle = '#333';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(px + pw / 2, colY + 15);
ctx.lineTo(px + pw / 2, py + ph - 50);
ctx.stroke();
const modulePortPositions = [];
const gadgetPortPositions = [];
// Draw module ports
for (let i = 0; i < mPorts.length; i++) {
const port = mPorts[i];
const yPos = portStartY + i * portSpacing;
const isOut = port.dir === 'out';
const dotX = leftX;
const wireX = leftX + 170;
modulePortPositions.push({ x: wireX, y: yPos + 6 });
const isCursor = cursor.side === 'module' && cursor.index === i;
const isSelected = selectedModule === i;
if (isCursor) {
ctx.fillStyle = 'rgba(0, 255, 200, 0.1)';
ctx.fillRect(leftX - 10, yPos - 6, 190, portSpacing - 8);
}
ctx.beginPath();
ctx.arc(dotX, yPos + 6, 8, 0, Math.PI * 2);
ctx.fillStyle = isSelected ? SELECTED_COLOR : (isOut ? PORT_OUT_COLOR : PORT_IN_COLOR);
ctx.fill();
if (isCursor) { ctx.strokeStyle = CURSOR_COLOR; ctx.lineWidth = 2; ctx.stroke(); }
ctx.fillStyle = '#000'; ctx.font = 'bold 10px monospace';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText(isOut ? '→' : '←', dotX, yPos + 6);
ctx.textAlign = 'left'; ctx.textBaseline = 'top';
ctx.font = '13px "Segoe UI", system-ui, sans-serif';
ctx.fillStyle = isCursor ? CURSOR_COLOR : '#ddd';
ctx.fillText(port.name, dotX + 14, yPos);
ctx.font = '10px monospace';
ctx.fillStyle = isOut ? PORT_OUT_COLOR : PORT_IN_COLOR;
ctx.fillText(isOut ? 'OUT' : 'IN', dotX + 14, yPos + 16);
if (wires.find(w => w.moduleIdx === i)) {
ctx.fillStyle = WIRE_COLOR; ctx.fillText('⚡', dotX + 50, yPos + 16);
}
}
// Draw gadget ports
for (let i = 0; i < gPorts.length; i++) {
const port = gPorts[i];
const yPos = portStartY + i * portSpacing;
const isOut = port.dir === 'out';
const dotX = rightX;
const wireX = rightX - 170;
gadgetPortPositions.push({ x: wireX, y: yPos + 6 });
const isCursor = cursor.side === 'gadget' && cursor.index === i;
const isSelected = selectedGadget === i;
if (isCursor) {
ctx.fillStyle = 'rgba(0, 255, 200, 0.1)';
ctx.fillRect(rightX - 180, yPos - 6, 190, portSpacing - 8);
}
ctx.beginPath();
ctx.arc(dotX, yPos + 6, 8, 0, Math.PI * 2);
ctx.fillStyle = isSelected ? SELECTED_COLOR : (isOut ? PORT_OUT_COLOR : PORT_IN_COLOR);
ctx.fill();
if (isCursor) { ctx.strokeStyle = CURSOR_COLOR; ctx.lineWidth = 2; ctx.stroke(); }
ctx.fillStyle = '#000'; ctx.font = 'bold 10px monospace';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText(isOut ? '→' : '←', dotX, yPos + 6);
ctx.textAlign = 'right'; ctx.textBaseline = 'top';
ctx.font = '13px "Segoe UI", system-ui, sans-serif';
ctx.fillStyle = isCursor ? CURSOR_COLOR : '#ddd';
ctx.fillText(port.name, dotX - 14, yPos);
ctx.font = '10px monospace';
ctx.fillStyle = isOut ? PORT_OUT_COLOR : PORT_IN_COLOR;
ctx.fillText(isOut ? 'OUT' : 'IN', dotX - 14, yPos + 16);
if (wires.find(w => w.gadgetIdx === i)) {
ctx.fillStyle = WIRE_COLOR; ctx.fillText('⚡', dotX - 60, yPos + 16);
}
}
// Draw wires (bezier)
ctx.strokeStyle = WIRE_COLOR; ctx.lineWidth = 2; ctx.setLineDash([6, 4]);
for (const wire of wires) {
const mp = modulePortPositions[wire.moduleIdx];
const gp = gadgetPortPositions[wire.gadgetIdx];
if (mp && gp) {
ctx.beginPath(); ctx.moveTo(mp.x, mp.y);
const midX = (mp.x + gp.x) / 2;
ctx.bezierCurveTo(midX, mp.y, midX, gp.y, gp.x, gp.y);
ctx.stroke();
}
}
ctx.setLineDash([]);
// Pending wire indicator
if (selectedModule !== null && selectedGadget === null) {
const mp = modulePortPositions[selectedModule];
if (mp) { ctx.beginPath(); ctx.arc(mp.x, mp.y, 5, 0, Math.PI * 2); ctx.fillStyle = SELECTED_COLOR; ctx.fill(); }
}
if (selectedGadget !== null && selectedModule === null) {
const gp = gadgetPortPositions[selectedGadget];
if (gp) { ctx.beginPath(); ctx.arc(gp.x, gp.y, 5, 0, Math.PI * 2); ctx.fillStyle = SELECTED_COLOR; ctx.fill(); }
}
// Result message (transient)
if (result && (Date.now() - resultTimer < 3000)) {
ctx.fillStyle = result.color;
ctx.font = 'bold 14px "Segoe UI", system-ui, sans-serif';
ctx.textAlign = 'center'; ctx.textBaseline = 'bottom';
ctx.fillText(result.message, px + pw / 2, py + ph - 35);
}
// Controls hint
ctx.fillStyle = '#555'; ctx.font = '11px "Segoe UI", system-ui, sans-serif';
ctx.textAlign = 'center'; ctx.textBaseline = 'bottom';
ctx.fillText('↑↓←→: Navigate | E: Wire | X: Remove | Enter: Execute | ESC: Close', px + pw / 2, py + ph - 10);
}
// ==================== Execution Log ====================
function drawExecutionLog(ctx, canvasW, canvasH) {
const { rows, portNames, passed, startTime, totalRows } = execLog;
const elapsed = Date.now() - startTime;
const revealedRows = Math.min(totalRows, Math.floor(elapsed / ROW_REVEAL_MS));
const allRevealed = revealedRows >= totalRows;
// Show verdict after all rows + a small delay
const showVerdict = allRevealed && (elapsed > totalRows * ROW_REVEAL_MS + 400);
const allPortNames = [...portNames.inputs, ...portNames.outputs];
const numInputs = portNames.inputs.length;
const numOutputs = portNames.outputs.length;
const numCols = allPortNames.length;
// Panel sizing — full-width execution log
const pw = Math.min(720, canvasW - 40);
const tableH = 28 * (totalRows + 1) + 8; // header + rows
const waveH = 40 * numCols + 20; // waveform area
const verdictH = showVerdict ? 60 : 0;
const ph = Math.min(canvasH - 40, 70 + tableH + 20 + waveH + verdictH + 50);
const px = (canvasW - pw) / 2;
const py = (canvasH - ph) / 2;
// Dim background
ctx.fillStyle = 'rgba(0, 0, 0, 0.8)';
ctx.fillRect(0, 0, canvasW, canvasH);
// Panel
ctx.fillStyle = PANEL_BG;
ctx.strokeStyle = passed && showVerdict ? PASS_COLOR : PANEL_BORDER;
ctx.lineWidth = 2;
roundRect(ctx, px, py, pw, ph, 8);
ctx.fill();
ctx.stroke();
// Glow effect if passed
if (passed && showVerdict) {
const glowIntensity = 0.15 + 0.1 * Math.sin(Date.now() / 300);
ctx.shadowColor = PASS_COLOR;
ctx.shadowBlur = 20;
ctx.strokeStyle = PASS_COLOR;
ctx.lineWidth = 2;
roundRect(ctx, px, py, pw, ph, 8);
ctx.stroke();
ctx.shadowBlur = 0;
}
// Title
ctx.fillStyle = '#00e599';
ctx.font = 'bold 15px "Segoe UI", system-ui, sans-serif';
ctx.textAlign = 'center'; ctx.textBaseline = 'top';
ctx.fillText('⚡ EXECUTION LOG', px + pw / 2, py + 12);
// Subtitle — scanning animation
const dots = '.'.repeat((Math.floor(elapsed / 400) % 4));
const subtitle = allRevealed ? `All ${totalRows} test cases evaluated` : `Running test cases${dots}`;
ctx.fillStyle = '#888'; ctx.font = '11px "Segoe UI", system-ui, sans-serif';
ctx.fillText(subtitle, px + pw / 2, py + 32);
let curY = py + 52;
// ==================== Truth Table ====================
const colW = Math.min(70, (pw - 60) / numCols);
const tableX = px + (pw - colW * numCols) / 2;
const rowH = 28;
// Header background
ctx.fillStyle = TABLE_HEADER_BG;
roundRect(ctx, tableX - 8, curY, colW * numCols + 16, rowH, 4);
ctx.fill();
// Header labels
ctx.font = 'bold 12px "Cascadia Code", "Fira Code", monospace';
ctx.textBaseline = 'middle';
for (let c = 0; c < numCols; c++) {
const cx = tableX + c * colW + colW / 2;
const isInput = c < numInputs;
ctx.fillStyle = isInput ? PORT_OUT_COLOR : PORT_IN_COLOR;
ctx.textAlign = 'center';
ctx.fillText(allPortNames[c], cx, curY + rowH / 2);
}
// Separator line under header
curY += rowH;
ctx.strokeStyle = '#333'; ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(tableX - 4, curY);
ctx.lineTo(tableX + colW * numCols + 4, curY);
ctx.stroke();
// Vertical separator between inputs and outputs
if (numInputs > 0 && numOutputs > 0) {
const sepX = tableX + numInputs * colW;
ctx.strokeStyle = '#444'; ctx.lineWidth = 1;
ctx.setLineDash([3, 3]);
ctx.beginPath();
ctx.moveTo(sepX, py + 52);
ctx.lineTo(sepX, curY + rowH * Math.min(revealedRows, totalRows) + 4);
ctx.stroke();
ctx.setLineDash([]);
}
// Data rows (animated reveal)
for (let r = 0; r < revealedRows; r++) {
const row = rows[r];
const rowY = curY + r * rowH;
const rowAge = elapsed - r * ROW_REVEAL_MS;
const alpha = Math.min(1, rowAge / 200); // fade in
// Row background
ctx.fillStyle = r % 2 === 0 ? TABLE_ROW_BG : TABLE_ROW_ALT;
ctx.globalAlpha = alpha;
ctx.fillRect(tableX - 8, rowY, colW * numCols + 16, rowH);
// Flash effect on new row
if (rowAge < 250) {
const flash = 1 - rowAge / 250;
ctx.fillStyle = `rgba(0, 229, 153, ${flash * 0.15})`;
ctx.fillRect(tableX - 8, rowY, colW * numCols + 16, rowH);
}
// Values
ctx.font = '13px "Cascadia Code", "Fira Code", monospace';
ctx.textBaseline = 'middle';
for (let c = 0; c < numCols; c++) {
const cx = tableX + c * colW + colW / 2;
const isInput = c < numInputs;
const portName = allPortNames[c];
const val = isInput ? row.inputs[portName] : row.outputs[portName];
// Color: high = bright, low = dim
ctx.fillStyle = val ? WAVE_HIGH : '#555';
ctx.textAlign = 'center';
ctx.fillText(val !== undefined ? String(val) : '?', cx, rowY + rowH / 2);
}
ctx.globalAlpha = 1;
}
curY += totalRows * rowH + 12;
// ==================== Waveform Viewer ====================
if (revealedRows > 0) {
const waveX = tableX;
const waveW = colW * numCols + 16;
const sigH = 28;
const sigSpacing = 38;
const labelW = 36;
const sigAreaW = waveW - labelW - 8;
// Section header
ctx.fillStyle = '#666'; ctx.font = 'bold 10px "Segoe UI", system-ui, sans-serif';
ctx.textAlign = 'left'; ctx.textBaseline = 'top';
ctx.fillText('SIGNAL VIEW', waveX, curY);
curY += 16;
// Background
ctx.fillStyle = '#0a0c14';
roundRect(ctx, waveX - 8, curY - 4, waveW, sigSpacing * numCols + 12, 4);
ctx.fill();
// Grid lines (vertical, one per test case)
ctx.strokeStyle = WAVE_GRID; ctx.lineWidth = 1;
const stepW = sigAreaW / Math.max(totalRows, 1);
for (let i = 0; i <= totalRows; i++) {
const gx = waveX + labelW + i * stepW;
ctx.beginPath();
ctx.moveTo(gx, curY);
ctx.lineTo(gx, curY + sigSpacing * numCols);
ctx.stroke();
}
// Draw each signal
for (let s = 0; s < numCols; s++) {
const sigY = curY + s * sigSpacing;
const isInput = s < numInputs;
const portName = allPortNames[s];
const color = isInput ? PORT_OUT_COLOR : PORT_IN_COLOR;
// Label
ctx.fillStyle = color;
ctx.font = 'bold 11px "Cascadia Code", "Fira Code", monospace';
ctx.textAlign = 'left'; ctx.textBaseline = 'middle';
ctx.fillText(portName, waveX, sigY + sigH / 2);
// Draw square wave
ctx.strokeStyle = color;
ctx.lineWidth = 2;
ctx.beginPath();
let prevVal = null;
for (let r = 0; r < revealedRows; r++) {
const row = rows[r];
const val = isInput ? (row.inputs[portName] || 0) : (row.outputs[portName] || 0);
const x1 = waveX + labelW + r * stepW;
const x2 = x1 + stepW;
const yHigh = sigY + 3;
const yLow = sigY + sigH - 3;
const yVal = val ? yHigh : yLow;
if (r === 0) {
ctx.moveTo(x1, yVal);
} else if (prevVal !== val) {
// Transition — vertical edge
ctx.lineTo(x1, yVal);
}
ctx.lineTo(x2, yVal);
// Fill area under high signals
if (val) {
ctx.save();
ctx.fillStyle = color.replace(')', ', 0.08)').replace('rgb', 'rgba');
if (color.startsWith('#')) {
const r2 = parseInt(color.slice(1,3), 16);
const g = parseInt(color.slice(3,5), 16);
const b = parseInt(color.slice(5,7), 16);
ctx.fillStyle = `rgba(${r2},${g},${b},0.1)`;
}
ctx.fillRect(x1, yHigh, stepW, yLow - yHigh);
ctx.restore();
}
prevVal = val;
}
ctx.stroke();
// Scanline animation (moving cursor)
if (!allRevealed) {
const scanX = waveX + labelW + revealedRows * stepW;
ctx.strokeStyle = '#00e599';
ctx.lineWidth = 1;
ctx.setLineDash([2, 2]);
ctx.beginPath();
ctx.moveTo(scanX, sigY);
ctx.lineTo(scanX, sigY + sigH);
ctx.stroke();
ctx.setLineDash([]);
}
}
curY += sigSpacing * numCols + 16;
}
// ==================== Verdict ====================
if (showVerdict) {
const verdictY = curY;
const pulse = 0.8 + 0.2 * Math.sin(Date.now() / 200);
if (passed) {
ctx.fillStyle = PASS_COLOR;
ctx.font = `bold ${18 * pulse}px "Segoe UI", system-ui, sans-serif`;
ctx.textAlign = 'center'; ctx.textBaseline = 'top';
ctx.fillText('⚡ VERIFICATION PASSED ⚡', px + pw / 2, verdictY);
ctx.fillStyle = '#aaa'; ctx.font = '12px "Segoe UI", system-ui, sans-serif';
ctx.fillText('Press Enter to unlock', px + pw / 2, verdictY + 26);
} else {
ctx.fillStyle = FAIL_COLOR;
ctx.font = 'bold 18px "Segoe UI", system-ui, sans-serif';
ctx.textAlign = 'center'; ctx.textBaseline = 'top';
ctx.fillText('✗ VERIFICATION FAILED', px + pw / 2, verdictY);
ctx.fillStyle = '#888'; ctx.font = '12px "Segoe UI", system-ui, sans-serif';
ctx.fillText('Press Esc to go back and adjust your wiring', px + pw / 2, verdictY + 26);
}
}
// Footer hint
ctx.fillStyle = '#444'; ctx.font = '10px "Segoe UI", system-ui, sans-serif';
ctx.textAlign = 'center'; ctx.textBaseline = 'bottom';
const hint = showVerdict
? (passed ? 'Enter: Continue | Esc: Close' : 'Esc: Back to wiring')
: 'Evaluating...';
ctx.fillText(hint, px + pw / 2, py + ph - 8);
}
// ==================== Helpers ====================
function roundRect(ctx, x, y, w, h, r) {
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.lineTo(x + w - r, y);
ctx.quadraticCurveTo(x + w, y, x + w, y + r);
ctx.lineTo(x + w, y + h - r);
ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
ctx.lineTo(x + r, y + h);
ctx.quadraticCurveTo(x, y + h, x, y + h - r);
ctx.lineTo(x, y + r);
ctx.quadraticCurveTo(x, y, x + r, y);
ctx.closePath();
}

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

@@ -0,0 +1,232 @@
// 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';
import { isBackpackOpen, openBackpack, handleBackpackInput, isNamingActive, handleNamingInput } from './inventory.js';
import { isWiringOpen, handleWiringInput } from './wiringPanel.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);
// Naming screen — route all input there
if (isNamingActive()) {
e.preventDefault();
handleNamingInput(key);
return;
}
// Wiring panel — route all input there
if (isWiringOpen()) {
e.preventDefault();
handleWiringInput(key);
return;
}
// Backpack open — route all input there
if (isBackpackOpen()) {
e.preventDefault();
handleBackpackInput(key);
return;
}
// 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;
}
// Backpack toggle (I)
if (key === 'i' || key === 'I') {
e.preventDefault();
openBackpack(null);
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;
case 'module':
if (interactionHandler) interactionHandler({ type: 'module', 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 }
});
}
}

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

@@ -0,0 +1,308 @@
// 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';
import { drawBackpack, getGadgets, drawNamingScreen, drawNotification } from './inventory.js';
import { isWiringOpen, drawWiringPanel } from './wiringPanel.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', npc.charId || null);
}
}
// === 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);
// === Layer 6: Backpack overlay (on top of everything) ===
if (worldState.mode === 'inventory') {
drawBackpack(ctx, canvas.width, canvas.height);
}
// === Layer 7: Wiring panel overlay ===
if (isWiringOpen()) {
drawWiringPanel(ctx, canvas.width, canvas.height);
}
// === Layer 8: Naming screen (on top of everything) ===
drawNamingScreen(ctx, canvas.width, canvas.height);
// === Layer 8: Notification toast ===
drawNotification(ctx, canvas.width);
}
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);
// Gadgets count
const gadgetCount = getGadgets().length;
ctx.fillStyle = '#ff44aa';
ctx.textAlign = 'right';
ctx.fillText(`🎒 Gadgets: ${gadgetCount}`, 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 | I: Backpack | 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;
}
}

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

@@ -0,0 +1,311 @@
/**
* 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 (legacy — component IDs)
inventory: [],
// Gadget backpack — saved circuits as reusable items
gadgets: [], // array of gadget objects (see inventory.js)
// 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.gadgets = [];
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)));
}

169
server.js Normal file
View File

@@ -0,0 +1,169 @@
// 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 CHARS_FILE = path.join(STATIC_DIR, 'data', 'characters.json');
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;
}
// === API: GET /api/characters — read character data ===
if (req.method === 'GET' && req.url === '/api/characters') {
fs.readFile(CHARS_FILE, 'utf-8', (err, data) => {
if (err) {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ characters: {} }));
return;
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(data);
});
return;
}
// === API: PUT /api/characters — write character data ===
if (req.method === 'PUT' && req.url === '/api/characters') {
let body = '';
req.on('data', chunk => { body += chunk; });
req.on('end', () => {
try {
const parsed = JSON.parse(body);
// Ensure data dir exists
const dataDir = path.dirname(CHARS_FILE);
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
fs.writeFileSync(CHARS_FILE, JSON.stringify(parsed, null, 2), 'utf-8');
const count = Object.keys(parsed.characters || {}).length;
console.log(`[server] characters.json saved (${count} characters)`);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true, count }));
} catch (e) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: e.message }));
}
});
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}`);
});