Compare commits
36 Commits
bc8823bcd4
...
feat/pokem
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c0ab2fc9b | ||
|
|
9ffd9c113e | ||
|
|
9d218c8728 | ||
|
|
06807801d0 | ||
|
|
bb72c58a15 | ||
|
|
f9492bff4c | ||
|
|
6ba3fa457a | ||
|
|
e7b18afd1a | ||
|
|
816a02aeb9 | ||
|
|
c6f5e19af5 | ||
|
|
b999fe855a | ||
|
|
f8aa4e2eab | ||
|
|
f740d96fc0 | ||
|
|
1d494d8ef3 | ||
|
|
b60edc49af | ||
|
|
eee405d5d9 | ||
|
|
943ba0b51c | ||
|
|
71321e8e88 | ||
|
|
bf34879390 | ||
|
|
9b2a25856e | ||
|
|
75001e10e7 | ||
|
|
bc9786ce49 | ||
|
|
c836ccbb21 | ||
|
|
e4cf35701e | ||
|
|
bbde11dfc7 | ||
|
|
a1cc631406 | ||
|
|
c116b6cf84 | ||
|
|
9ec3367253 | ||
|
|
12d7331d2c | ||
|
|
99f0fefe5c | ||
|
|
89d118f738 | ||
|
|
2fd22cc79d | ||
|
|
5bd157c059 | ||
|
|
eb22a5ff62 | ||
|
|
817dab43df | ||
|
|
1c45dc6104 |
15
Dockerfile
@@ -1,5 +1,12 @@
|
|||||||
FROM nginx:alpine
|
FROM node:20-alpine
|
||||||
COPY index.html /usr/share/nginx/html/
|
WORKDIR /app
|
||||||
COPY css/ /usr/share/nginx/html/css/
|
COPY server.js .
|
||||||
COPY js/ /usr/share/nginx/html/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
|
EXPOSE 80
|
||||||
|
CMD ["node", "server.js"]
|
||||||
|
|||||||
148
README.md
Normal 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
|
||||||
BIN
assets/character/back-still.png
Normal file
|
After Width: | Height: | Size: 8.2 KiB |
BIN
assets/character/back-walk-1.png
Normal file
|
After Width: | Height: | Size: 8.1 KiB |
BIN
assets/character/back-walk-2.png
Normal file
|
After Width: | Height: | Size: 8.2 KiB |
BIN
assets/character/front-still.png
Normal file
|
After Width: | Height: | Size: 8.2 KiB |
BIN
assets/character/front-walk-1.png
Normal file
|
After Width: | Height: | Size: 8.2 KiB |
BIN
assets/character/front-walk-2.png
Normal file
|
After Width: | Height: | Size: 8.2 KiB |
BIN
assets/character/left-still.png
Normal file
|
After Width: | Height: | Size: 8.2 KiB |
BIN
assets/character/left-walk-1.png
Normal file
|
After Width: | Height: | Size: 8.2 KiB |
BIN
assets/character/left-walk-2.png
Normal file
|
After Width: | Height: | Size: 8.2 KiB |
BIN
assets/character/right-still.png
Normal file
|
After Width: | Height: | Size: 8.2 KiB |
BIN
assets/character/right-walk-1.png
Normal file
|
After Width: | Height: | Size: 8.2 KiB |
BIN
assets/character/right-walk-2.png
Normal file
|
After Width: | Height: | Size: 8.2 KiB |
BIN
assets/map/house-a-1f.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
assets/map/lab.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
assets/map/pallet-town.png
Normal file
|
After Width: | Height: | Size: 8.6 KiB |
BIN
assets/map/route-1.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
assets/npcs/a-down.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
assets/npcs/a-left.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
assets/npcs/a-right.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
assets/npcs/a-up.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
1755
editor.html
Normal file
@@ -7,6 +7,10 @@
|
|||||||
<link rel="stylesheet" href="css/style.css">
|
<link rel="stylesheet" href="css/style.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<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">
|
<div id="toolbar">
|
||||||
<span class="logo">⚡ Logic Lab</span>
|
<span class="logo">⚡ Logic Lab</span>
|
||||||
|
|
||||||
|
|||||||
36
js/app.js
@@ -1,10 +1,40 @@
|
|||||||
// Entry point — initializes all modules
|
// Entry point — initializes game (world + workshop modes)
|
||||||
import { initRenderer } from './renderer.js';
|
import { initRenderer, resize } from './renderer.js';
|
||||||
import { initEvents } from './events.js';
|
import { initEvents } from './events.js';
|
||||||
import { initPuzzleUI } from './puzzleUI.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', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
// Register circuit editor init/destroy so gameMode can switch to workshop
|
||||||
|
registerCircuitEditor(
|
||||||
|
// init workshop
|
||||||
|
() => {
|
||||||
initRenderer();
|
initRenderer();
|
||||||
initEvents();
|
initEvents();
|
||||||
initPuzzleUI();
|
// initPuzzleUI(); // HIDDEN: puzzle mode disabled for now
|
||||||
|
if (loadFromStorage()) {
|
||||||
|
updateComponentButtons();
|
||||||
|
evaluateAll();
|
||||||
|
}
|
||||||
|
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();
|
||||||
});
|
});
|
||||||
|
|||||||
197
js/bus.js
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
// Bus system — shift+drag to cut wires and create paired bus terminals + cable
|
||||||
|
import { state } from './state.js';
|
||||||
|
import { getOutputPorts, getInputPorts, evaluateAll } from './gates.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sample a cubic bezier curve into discrete line segments.
|
||||||
|
*/
|
||||||
|
function sampleBezier(p0x, p0y, p1x, p1y, p2x, p2y, p3x, p3y, steps = 24) {
|
||||||
|
const points = [];
|
||||||
|
for (let i = 0; i <= steps; i++) {
|
||||||
|
const t = i / steps;
|
||||||
|
const mt = 1 - t;
|
||||||
|
const mt2 = mt * mt, mt3 = mt2 * mt;
|
||||||
|
const t2 = t * t, t3 = t2 * t;
|
||||||
|
points.push({
|
||||||
|
x: mt3 * p0x + 3 * mt2 * t * p1x + 3 * mt * t2 * p2x + t3 * p3x,
|
||||||
|
y: mt3 * p0y + 3 * mt2 * t * p1y + 3 * mt * t2 * p2y + t3 * p3y
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return points;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test intersection between two line segments.
|
||||||
|
*/
|
||||||
|
function segmentIntersect(ax1, ay1, ax2, ay2, bx1, by1, bx2, by2) {
|
||||||
|
const dx1 = ax2 - ax1, dy1 = ay2 - ay1;
|
||||||
|
const dx2 = bx2 - bx1, dy2 = by2 - by1;
|
||||||
|
const d = dx1 * dy2 - dy1 * dx2;
|
||||||
|
if (Math.abs(d) < 1e-10) return null;
|
||||||
|
|
||||||
|
const t = ((bx1 - ax1) * dy2 - (by1 - ay1) * dx2) / d;
|
||||||
|
const u = ((bx1 - ax1) * dy1 - (by1 - ay1) * dx1) / d;
|
||||||
|
|
||||||
|
if (t >= 0 && t <= 1 && u >= 0 && u <= 1) {
|
||||||
|
return { x: ax1 + t * dx1, y: ay1 + t * dy1, t };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get bezier control points for a connection (matching renderer.js drawConnection).
|
||||||
|
*/
|
||||||
|
function getConnectionBezier(conn) {
|
||||||
|
const fromGate = state.gates.find(g => g.id === conn.from);
|
||||||
|
const toGate = state.gates.find(g => g.id === conn.to);
|
||||||
|
if (!fromGate || !toGate) return null;
|
||||||
|
|
||||||
|
const fromPort = getOutputPorts(fromGate)[conn.fromPort];
|
||||||
|
const toPort = getInputPorts(toGate)[conn.toPort];
|
||||||
|
if (!fromPort || !toPort) return null;
|
||||||
|
|
||||||
|
const midX = (fromPort.x + toPort.x) / 2;
|
||||||
|
return {
|
||||||
|
p0x: fromPort.x, p0y: fromPort.y,
|
||||||
|
p1x: midX, p1y: fromPort.y,
|
||||||
|
p2x: midX, p2y: toPort.y,
|
||||||
|
p3x: toPort.x, p3y: toPort.y
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find all connections that intersect a cut line.
|
||||||
|
* Returns array of { conn, hitPoint } sorted by position along the cut line.
|
||||||
|
*/
|
||||||
|
export function findIntersectingConnections(startX, startY, endX, endY) {
|
||||||
|
const results = [];
|
||||||
|
|
||||||
|
for (const conn of state.connections) {
|
||||||
|
const bez = getConnectionBezier(conn);
|
||||||
|
if (!bez) continue;
|
||||||
|
|
||||||
|
const points = sampleBezier(
|
||||||
|
bez.p0x, bez.p0y, bez.p1x, bez.p1y,
|
||||||
|
bez.p2x, bez.p2y, bez.p3x, bez.p3y
|
||||||
|
);
|
||||||
|
|
||||||
|
for (let i = 0; i < points.length - 1; i++) {
|
||||||
|
const hit = segmentIntersect(
|
||||||
|
startX, startY, endX, endY,
|
||||||
|
points[i].x, points[i].y, points[i + 1].x, points[i + 1].y
|
||||||
|
);
|
||||||
|
if (hit) {
|
||||||
|
results.push({ conn, hitPoint: hit });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
results.sort((a, b) => a.hitPoint.t - b.hitPoint.t);
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create two paired BUS terminals from a cut line and rewire connections through them.
|
||||||
|
*
|
||||||
|
* Layout:
|
||||||
|
* Wire1 ──┐ ┌── Wire1
|
||||||
|
* Wire2 ──┤ ═══════ ├── Wire2
|
||||||
|
* Wire3 ──┘ └── Wire3
|
||||||
|
* Terminal IN Terminal OUT
|
||||||
|
*
|
||||||
|
* The thick cable between them is rendered by the renderer using busPairId.
|
||||||
|
*/
|
||||||
|
export function createBusFromCut() {
|
||||||
|
const cut = state.busCutting;
|
||||||
|
if (!cut) return;
|
||||||
|
|
||||||
|
const hits = findIntersectingConnections(cut.startX, cut.startY, cut.endX, cut.endY);
|
||||||
|
if (hits.length === 0) {
|
||||||
|
console.log('[bus] no connections intersected');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const n = hits.length;
|
||||||
|
console.log(`[bus] cut intersected ${n} connection(s)`);
|
||||||
|
|
||||||
|
// Calculate position from hit points
|
||||||
|
const avgX = hits.reduce((s, h) => s + h.hitPoint.x, 0) / n;
|
||||||
|
const avgY = hits.reduce((s, h) => s + h.hitPoint.y, 0) / n;
|
||||||
|
const busH = Math.max(40, (n + 1) * 22);
|
||||||
|
const gap = 120; // horizontal distance between the two terminals
|
||||||
|
|
||||||
|
// Reserve IDs
|
||||||
|
const busInId = state.nextId++;
|
||||||
|
const busOutId = state.nextId++;
|
||||||
|
|
||||||
|
// Create BUS_IN terminal (left — collects wires into bus, only input pins)
|
||||||
|
const busIn = {
|
||||||
|
id: busInId,
|
||||||
|
type: `BUS_IN:${n}`,
|
||||||
|
x: avgX - gap / 2 - 15,
|
||||||
|
y: avgY - busH / 2,
|
||||||
|
value: 0,
|
||||||
|
busValues: new Array(n).fill(0),
|
||||||
|
busPairId: busOutId
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create BUS_OUT terminal (right — distributes bus back to wires, only output pins)
|
||||||
|
const busOut = {
|
||||||
|
id: busOutId,
|
||||||
|
type: `BUS_OUT:${n}`,
|
||||||
|
x: avgX + gap / 2 - 15,
|
||||||
|
y: avgY - busH / 2,
|
||||||
|
value: 0,
|
||||||
|
outputValues: new Array(n).fill(0),
|
||||||
|
busPairId: busInId
|
||||||
|
};
|
||||||
|
|
||||||
|
state.gates.push(busIn, busOut);
|
||||||
|
|
||||||
|
// Rewire: source → BUS_IN input, BUS_OUT output → destination
|
||||||
|
// No internal connections — BUS_OUT reads from BUS_IN directly via busPairId
|
||||||
|
hits.forEach((hit, i) => {
|
||||||
|
const orig = hit.conn;
|
||||||
|
|
||||||
|
// Remove original connection
|
||||||
|
state.connections = state.connections.filter(c => c !== orig);
|
||||||
|
|
||||||
|
// Source → BUS_IN input[i]
|
||||||
|
state.connections.push({
|
||||||
|
from: orig.from,
|
||||||
|
fromPort: orig.fromPort,
|
||||||
|
to: busIn.id,
|
||||||
|
toPort: i
|
||||||
|
});
|
||||||
|
|
||||||
|
// BUS_OUT output[i] → original destination
|
||||||
|
state.connections.push({
|
||||||
|
from: busOut.id,
|
||||||
|
fromPort: i,
|
||||||
|
to: orig.to,
|
||||||
|
toPort: orig.toPort
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`[bus] created BUS_IN#${busIn.id} ↔ BUS_OUT#${busOut.id} with ${n} channels`);
|
||||||
|
evaluateAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all bus pairs for rendering the bus cables.
|
||||||
|
* Returns array of { inGate, outGate } for each pair.
|
||||||
|
*/
|
||||||
|
export function getBusPairs() {
|
||||||
|
const pairs = [];
|
||||||
|
const seen = new Set();
|
||||||
|
for (const gate of state.gates) {
|
||||||
|
if (!gate.type.startsWith('BUS_IN:') || !gate.busPairId || seen.has(gate.id)) continue;
|
||||||
|
const outGate = state.gates.find(g => g.id === gate.busPairId);
|
||||||
|
if (!outGate || !outGate.type.startsWith('BUS_OUT:')) continue;
|
||||||
|
seen.add(gate.id);
|
||||||
|
seen.add(outGate.id);
|
||||||
|
pairs.push({ inGate: gate, outGate });
|
||||||
|
}
|
||||||
|
return pairs;
|
||||||
|
}
|
||||||
252
js/components.js
@@ -18,15 +18,22 @@ export function saveComponentFromCircuit(name) {
|
|||||||
const outputGates = state.gates.filter(g => g.type === 'OUTPUT');
|
const outputGates = state.gates.filter(g => g.type === 'OUTPUT');
|
||||||
|
|
||||||
if (inputGates.length === 0 || outputGates.length === 0) {
|
if (inputGates.length === 0 || outputGates.length === 0) {
|
||||||
|
alert('Component must have at least one INPUT and one OUTPUT');
|
||||||
return { success: false, error: 'Component must have at least one INPUT and one OUTPUT' };
|
return { success: false, error: 'Component must have at least one INPUT and one OUTPUT' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Store the input/output gate IDs in order so we can map ports consistently
|
||||||
|
const inputIds = inputGates.map(g => g.id);
|
||||||
|
const outputIds = outputGates.map(g => g.id);
|
||||||
|
|
||||||
// Create component definition
|
// Create component definition
|
||||||
const component = {
|
const component = {
|
||||||
id: sanitizeComponentName(name),
|
id: sanitizeComponentName(name),
|
||||||
name,
|
name,
|
||||||
inputCount: inputGates.length,
|
inputCount: inputGates.length,
|
||||||
outputCount: outputGates.length,
|
outputCount: outputGates.length,
|
||||||
|
inputIds,
|
||||||
|
outputIds,
|
||||||
gates: JSON.parse(JSON.stringify(state.gates)),
|
gates: JSON.parse(JSON.stringify(state.gates)),
|
||||||
connections: JSON.parse(JSON.stringify(state.connections))
|
connections: JSON.parse(JSON.stringify(state.connections))
|
||||||
};
|
};
|
||||||
@@ -37,125 +44,114 @@ export function saveComponentFromCircuit(name) {
|
|||||||
}
|
}
|
||||||
state.customComponents[component.id] = component;
|
state.customComponents[component.id] = component;
|
||||||
|
|
||||||
|
console.log(`[component] saved "${name}" (${component.inputCount} in, ${component.outputCount} out)`,
|
||||||
|
`inputIds=${inputIds}`, `outputIds=${outputIds}`);
|
||||||
|
|
||||||
return { success: true, component };
|
return { success: true, component };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Instantiate a component on the canvas
|
* Evaluate a component instance.
|
||||||
*/
|
* Simulates the internal circuit and returns an array of output values.
|
||||||
export function instantiateComponent(componentId, x, y) {
|
* IMPORTANT: Uses persistent internal state so latches/flip-flops retain
|
||||||
if (!state.customComponents || !state.customComponents[componentId]) {
|
* their values between evaluations (just like the main circuit).
|
||||||
return { success: false, error: 'Component not found' };
|
|
||||||
}
|
|
||||||
|
|
||||||
const component = state.customComponents[componentId];
|
|
||||||
const instanceId = state.nextId++;
|
|
||||||
|
|
||||||
// Create a component instance gate
|
|
||||||
const gate = {
|
|
||||||
id: instanceId,
|
|
||||||
type: `COMPONENT:${componentId}`,
|
|
||||||
x,
|
|
||||||
y,
|
|
||||||
value: 0,
|
|
||||||
component
|
|
||||||
};
|
|
||||||
|
|
||||||
state.gates.push(gate);
|
|
||||||
|
|
||||||
return { success: true, gate };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Evaluate a component instance
|
|
||||||
* Simulates the internal circuit and returns output
|
|
||||||
*/
|
*/
|
||||||
export function evaluateComponent(gate, inputs) {
|
export function evaluateComponent(gate, inputs) {
|
||||||
if (!gate.component) return 0;
|
if (!gate.component) {
|
||||||
|
console.warn('[component] evaluateComponent called without component data', gate);
|
||||||
|
return [0];
|
||||||
|
}
|
||||||
|
|
||||||
const comp = gate.component;
|
const comp = gate.component;
|
||||||
const internalState = {
|
|
||||||
gates: JSON.parse(JSON.stringify(comp.gates)),
|
|
||||||
connections: JSON.parse(JSON.stringify(comp.connections)),
|
|
||||||
nextId: Math.max(...comp.gates.map(g => g.id), 0) + 1
|
|
||||||
};
|
|
||||||
|
|
||||||
// Set inputs
|
// Persist internal gate state on the gate instance so latches hold their value
|
||||||
const inputGates = internalState.gates.filter(g => g.type === 'INPUT');
|
if (!gate._internalGates) {
|
||||||
inputs.forEach((val, i) => {
|
gate._internalGates = JSON.parse(JSON.stringify(comp.gates));
|
||||||
if (inputGates[i]) inputGates[i].value = val;
|
}
|
||||||
});
|
const internalGates = gate._internalGates;
|
||||||
|
const internalConns = comp.connections; // read-only, no need to clone
|
||||||
|
|
||||||
// Evaluate internal circuit
|
// Map external inputs to internal INPUT gates using stored inputIds
|
||||||
evaluateInternalCircuit(internalState);
|
const inputIds = comp.inputIds || [];
|
||||||
|
for (let i = 0; i < inputs.length; i++) {
|
||||||
|
const targetId = inputIds[i];
|
||||||
|
const inputGate = targetId != null
|
||||||
|
? internalGates.find(g => g.id === targetId)
|
||||||
|
: internalGates.filter(g => g.type === 'INPUT')[i]; // fallback for old components
|
||||||
|
if (inputGate) {
|
||||||
|
inputGate.value = inputs[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Get outputs
|
// Iterative fixed-point evaluation (same approach as main evaluateAll)
|
||||||
const outputGates = internalState.gates.filter(g => g.type === 'OUTPUT');
|
const MAX_ITER = 20;
|
||||||
const outputs = outputGates.map(g => g.value || 0);
|
for (let iter = 0; iter < MAX_ITER; iter++) {
|
||||||
|
let changed = false;
|
||||||
|
for (const g of internalGates) {
|
||||||
|
if (g.type === 'INPUT' || g.type === 'CLOCK') continue;
|
||||||
|
|
||||||
|
const inCount = getGateInputCount(g.type);
|
||||||
|
const gInputs = [];
|
||||||
|
for (let j = 0; j < inCount; j++) {
|
||||||
|
const conn = internalConns.find(c => c.to === g.id && c.toPort === j);
|
||||||
|
if (conn) {
|
||||||
|
const src = internalGates.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read outputs using stored outputIds
|
||||||
|
const outputIds = comp.outputIds || [];
|
||||||
|
const outputs = [];
|
||||||
|
if (outputIds.length > 0) {
|
||||||
|
for (const outId of outputIds) {
|
||||||
|
const outGate = internalGates.find(g => g.id === outId);
|
||||||
|
outputs.push(outGate ? (outGate.value || 0) : 0);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Fallback for old components without outputIds
|
||||||
|
const outputGates = internalGates.filter(g => g.type === 'OUTPUT');
|
||||||
|
for (const g of outputGates) {
|
||||||
|
outputs.push(g.value || 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[component] eval "${comp.name}" inputs=[${inputs}] → outputs=[${outputs}]`,
|
||||||
|
`(internal state preserved: ${gate._internalGates ? 'yes' : 'no'})`);
|
||||||
return outputs;
|
return outputs;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper to evaluate internal circuit
|
* Get input count for a gate type
|
||||||
*/
|
|
||||||
function evaluateInternalCircuit(internalState) {
|
|
||||||
const { gates, connections } = internalState;
|
|
||||||
|
|
||||||
// Simple evaluation - may need optimization for complex circuits
|
|
||||||
for (let i = 0; i < 10; i++) {
|
|
||||||
for (const gate of gates) {
|
|
||||||
if (gate.type === 'INPUT' || gate.type === 'CLOCK') continue;
|
|
||||||
|
|
||||||
const inputCount = getGateInputCount(gate.type);
|
|
||||||
const inputs = [];
|
|
||||||
|
|
||||||
for (let j = 0; j < inputCount; j++) {
|
|
||||||
const conn = connections.find(c => c.to === gate.id && c.toPort === j);
|
|
||||||
if (conn) {
|
|
||||||
const srcGate = gates.find(g => g.id === conn.from);
|
|
||||||
inputs.push(srcGate ? srcGate.value || 0 : 0);
|
|
||||||
} else {
|
|
||||||
inputs.push(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Evaluate based on gate type
|
|
||||||
let result = 0;
|
|
||||||
if (gate.type === 'AND') result = (inputs[0] && inputs[1]) ? 1 : 0;
|
|
||||||
else if (gate.type === 'OR') result = (inputs[0] || inputs[1]) ? 1 : 0;
|
|
||||||
else if (gate.type === 'NOT') result = inputs[0] ? 0 : 1;
|
|
||||||
else if (gate.type === 'NAND') result = (inputs[0] && inputs[1]) ? 0 : 1;
|
|
||||||
else if (gate.type === 'NOR') result = (inputs[0] || inputs[1]) ? 0 : 1;
|
|
||||||
else if (gate.type === 'XOR') result = (inputs[0] !== inputs[1]) ? 1 : 0;
|
|
||||||
else if (gate.type === 'OUTPUT') result = inputs[0] || 0;
|
|
||||||
|
|
||||||
gate.value = result;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get input count for a gate type (includes component types)
|
|
||||||
*/
|
*/
|
||||||
function getGateInputCount(type) {
|
function getGateInputCount(type) {
|
||||||
if (type === 'CLOCK' || type === 'INPUT') return 0;
|
if (type === 'CLOCK' || type === 'INPUT') return 0;
|
||||||
if (type === 'NOT' || type === 'OUTPUT') return 1;
|
if (type === 'NOT' || type === 'OUTPUT') return 1;
|
||||||
if (type.startsWith('COMPONENT:')) {
|
|
||||||
// Return the component's input count
|
|
||||||
return 2; // Default for now, should lookup
|
|
||||||
}
|
|
||||||
return 2;
|
return 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get output count for a gate type
|
|
||||||
*/
|
|
||||||
function getGateOutputCount(type) {
|
|
||||||
if (type === 'OUTPUT') return 0;
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sanitize component name for use as ID
|
* Sanitize component name for use as ID
|
||||||
*/
|
*/
|
||||||
@@ -212,7 +208,7 @@ export function importComponent(data) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Enter component editor mode
|
* Enter component editor mode (new component)
|
||||||
*/
|
*/
|
||||||
export function enterComponentEditor() {
|
export function enterComponentEditor() {
|
||||||
// Save current main circuit
|
// Save current main circuit
|
||||||
@@ -227,6 +223,7 @@ export function enterComponentEditor() {
|
|||||||
state.connections = [];
|
state.connections = [];
|
||||||
state.nextId = 1;
|
state.nextId = 1;
|
||||||
state.componentEditorActive = true;
|
state.componentEditorActive = true;
|
||||||
|
state.editingComponentId = null; // new component, not editing existing
|
||||||
state.placingGate = null;
|
state.placingGate = null;
|
||||||
state.connecting = null;
|
state.connecting = null;
|
||||||
|
|
||||||
@@ -239,6 +236,42 @@ export function enterComponentEditor() {
|
|||||||
if (resizeCallback) resizeCallback();
|
if (resizeCallback) resizeCallback();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enter component editor to edit an existing component's blueprint.
|
||||||
|
* Loads the component's internal circuit for modification.
|
||||||
|
*/
|
||||||
|
export function editComponentBlueprint(gate) {
|
||||||
|
if (!gate.component) return;
|
||||||
|
const comp = gate.component;
|
||||||
|
|
||||||
|
// Save current main circuit
|
||||||
|
state.savedMainCircuit = {
|
||||||
|
gates: JSON.parse(JSON.stringify(state.gates)),
|
||||||
|
connections: JSON.parse(JSON.stringify(state.connections)),
|
||||||
|
nextId: state.nextId
|
||||||
|
};
|
||||||
|
|
||||||
|
// Load the component's internal circuit into the canvas
|
||||||
|
state.gates = JSON.parse(JSON.stringify(comp.gates));
|
||||||
|
state.connections = JSON.parse(JSON.stringify(comp.connections));
|
||||||
|
// Set nextId to max existing id + 1 so new gates don't collide
|
||||||
|
state.nextId = state.gates.reduce((max, g) => Math.max(max, g.id), 0) + 1;
|
||||||
|
state.componentEditorActive = true;
|
||||||
|
state.editingComponentId = comp.id; // track which component we're editing
|
||||||
|
state.placingGate = null;
|
||||||
|
state.connecting = null;
|
||||||
|
|
||||||
|
// Show editor overlay
|
||||||
|
const overlay = document.getElementById('component-editor-overlay');
|
||||||
|
overlay.style.display = 'flex';
|
||||||
|
document.getElementById('component-editor-title').textContent = `Editing Component: ${comp.name}`;
|
||||||
|
|
||||||
|
console.log(`[component] editing blueprint of "${comp.name}" (${comp.inputCount} in, ${comp.outputCount} out)`);
|
||||||
|
|
||||||
|
// Resize canvas to account for editor bar
|
||||||
|
if (resizeCallback) resizeCallback();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Exit component editor mode
|
* Exit component editor mode
|
||||||
*/
|
*/
|
||||||
@@ -246,9 +279,29 @@ export function exitComponentEditor(name, shouldSave) {
|
|||||||
const overlay = document.getElementById('component-editor-overlay');
|
const overlay = document.getElementById('component-editor-overlay');
|
||||||
overlay.style.display = 'none';
|
overlay.style.display = 'none';
|
||||||
|
|
||||||
|
const editingId = state.editingComponentId;
|
||||||
|
|
||||||
if (shouldSave && name) {
|
if (shouldSave && name) {
|
||||||
// Save the component
|
// Save the component (works for both new and edited)
|
||||||
saveComponentFromCircuit(name);
|
const result = saveComponentFromCircuit(name);
|
||||||
|
|
||||||
|
// Update all placed instances of this component in the main circuit.
|
||||||
|
// Handles both: editing existing component (editingId matches) AND
|
||||||
|
// creating a "new" component that overwrites an existing one (same sanitized name).
|
||||||
|
if (result.success && state.savedMainCircuit) {
|
||||||
|
const updatedComp = state.customComponents[result.component.id];
|
||||||
|
if (updatedComp) {
|
||||||
|
const matchId = editingId || result.component.id;
|
||||||
|
for (const gate of state.savedMainCircuit.gates) {
|
||||||
|
if (gate.component && gate.component.id === matchId) {
|
||||||
|
gate.component = updatedComp;
|
||||||
|
// Clear persisted internal state so it re-initializes from updated blueprint
|
||||||
|
delete gate._internalGates;
|
||||||
|
console.log(`[component] updated instance #${gate.id} with new blueprint`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restore main circuit
|
// Restore main circuit
|
||||||
@@ -260,6 +313,7 @@ export function exitComponentEditor(name, shouldSave) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
state.componentEditorActive = false;
|
state.componentEditorActive = false;
|
||||||
|
state.editingComponentId = null;
|
||||||
state.placingGate = null;
|
state.placingGate = null;
|
||||||
|
|
||||||
// Update component buttons to show newly saved component
|
// Update component buttons to show newly saved component
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ export const PORT_R = 7;
|
|||||||
export const GATE_COLORS = {
|
export const GATE_COLORS = {
|
||||||
AND: '#00e599', OR: '#3388ff', NOT: '#ff6644',
|
AND: '#00e599', OR: '#3388ff', NOT: '#ff6644',
|
||||||
NAND: '#e5cc00', NOR: '#cc44ff', XOR: '#ff44aa',
|
NAND: '#e5cc00', NOR: '#cc44ff', XOR: '#ff44aa',
|
||||||
INPUT: '#3388ff', CLOCK: '#ff44aa', OUTPUT: '#ff8833'
|
INPUT: '#3388ff', CLOCK: '#ff44aa', OUTPUT: '#ff8833',
|
||||||
|
BUS: '#44ddff'
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SIGNAL_COLORS = [
|
export const SIGNAL_COLORS = [
|
||||||
|
|||||||
173
js/events.js
@@ -8,8 +8,10 @@ import { resize, screenToWorld } from './renderer.js';
|
|||||||
import { puzzleMode, currentLevel, showLevelPanel } from './puzzleUI.js';
|
import { puzzleMode, currentLevel, showLevelPanel } from './puzzleUI.js';
|
||||||
import { getLevel } from './levels.js';
|
import { getLevel } from './levels.js';
|
||||||
import { saveState, loadState, exportAsFile, importFromFile } from './saveLoad.js';
|
import { saveState, loadState, exportAsFile, importFromFile } from './saveLoad.js';
|
||||||
import { enterComponentEditor, exitComponentEditor, updateComponentButtons, setResizeCallback } from './components.js';
|
import { enterComponentEditor, editComponentBlueprint, exitComponentEditor, updateComponentButtons, setResizeCallback } from './components.js';
|
||||||
import { getExampleList, loadExample } from './examples.js';
|
import { getExampleList, loadExample } from './examples.js';
|
||||||
|
import { createBusFromCut } from './bus.js';
|
||||||
|
import { isNamingActive, handleNamingInput } from './world/inventory.js';
|
||||||
|
|
||||||
const PAN_SPEED = 40;
|
const PAN_SPEED = 40;
|
||||||
|
|
||||||
@@ -31,6 +33,43 @@ export function initEvents() {
|
|||||||
|
|
||||||
// Convert to world coords for gate/port detection
|
// Convert to world coords for gate/port detection
|
||||||
const world = screenToWorld(e.offsetX, e.offsetY);
|
const world = screenToWorld(e.offsetX, e.offsetY);
|
||||||
|
|
||||||
|
// Update bus cut line endpoint
|
||||||
|
if (state.busCutting) {
|
||||||
|
state.busCutting.endX = world.x;
|
||||||
|
state.busCutting.endY = world.y;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update selection box
|
||||||
|
if (state.selectionBox) {
|
||||||
|
state.selectionBox.endX = world.x;
|
||||||
|
state.selectionBox.endY = world.y;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multi-drag selected gates
|
||||||
|
if (state.multiDrag) {
|
||||||
|
if (dragStartPos && !dragMoved) {
|
||||||
|
const dx = e.offsetX - dragStartPos.x;
|
||||||
|
const dy = e.offsetY - dragStartPos.y;
|
||||||
|
if (Math.abs(dx) > 4 || Math.abs(dy) > 4) dragMoved = true;
|
||||||
|
}
|
||||||
|
if (dragMoved) {
|
||||||
|
const dx = world.x - state.multiDrag.startX;
|
||||||
|
const dy = world.y - state.multiDrag.startY;
|
||||||
|
for (const orig of state.multiDrag.origins) {
|
||||||
|
const gate = state.gates.find(g => g.id === orig.id);
|
||||||
|
if (gate) {
|
||||||
|
gate.x = orig.x + dx;
|
||||||
|
gate.y = orig.y + dy;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
evaluateAll();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
state.hoveredPort = findPortAt(world.x, world.y);
|
state.hoveredPort = findPortAt(world.x, world.y);
|
||||||
state.hoveredGate = state.hoveredPort ? state.hoveredPort.gate : findGateAt(world.x, world.y);
|
state.hoveredGate = state.hoveredPort ? state.hoveredPort.gate : findGateAt(world.x, world.y);
|
||||||
|
|
||||||
@@ -51,6 +90,7 @@ export function initEvents() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
canvas.style.cursor = state.placingGate ? 'crosshair'
|
canvas.style.cursor = state.placingGate ? 'crosshair'
|
||||||
|
: state.selectionBox ? 'crosshair'
|
||||||
: state.hoveredPort ? 'pointer'
|
: state.hoveredPort ? 'pointer'
|
||||||
: state.hoveredGate ? 'grab'
|
: state.hoveredGate ? 'grab'
|
||||||
: 'default';
|
: 'default';
|
||||||
@@ -65,6 +105,19 @@ export function initEvents() {
|
|||||||
dragStartPos = { x: e.offsetX, y: e.offsetY };
|
dragStartPos = { x: e.offsetX, y: e.offsetY };
|
||||||
dragMoved = false;
|
dragMoved = false;
|
||||||
|
|
||||||
|
// Shift+click on empty space → start bus cut
|
||||||
|
if (e.shiftKey && !state.placingGate) {
|
||||||
|
const port = findPortAt(world.x, world.y);
|
||||||
|
const gate = findGateAt(world.x, world.y);
|
||||||
|
if (!port && !gate) {
|
||||||
|
state.busCutting = {
|
||||||
|
startX: world.x, startY: world.y,
|
||||||
|
endX: world.x, endY: world.y
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Placing a new gate
|
// Placing a new gate
|
||||||
if (state.placingGate) {
|
if (state.placingGate) {
|
||||||
let w = GATE_W, h = GATE_H;
|
let w = GATE_W, h = GATE_H;
|
||||||
@@ -90,7 +143,7 @@ export function initEvents() {
|
|||||||
}
|
}
|
||||||
state.gates.push(newGate);
|
state.gates.push(newGate);
|
||||||
evaluateAll();
|
evaluateAll();
|
||||||
state.placingGate = null;
|
// Keep placingGate active so user can place multiple — right-click to cancel
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,13 +178,78 @@ export function initEvents() {
|
|||||||
// Drag any gate (including INPUT/CLOCK)
|
// Drag any gate (including INPUT/CLOCK)
|
||||||
const gate = findGateAt(world.x, world.y);
|
const gate = findGateAt(world.x, world.y);
|
||||||
if (gate) {
|
if (gate) {
|
||||||
|
// If clicking a selected gate → multi-drag all selected
|
||||||
|
if (state.selectedGates.includes(gate.id)) {
|
||||||
|
state.multiDrag = {
|
||||||
|
startX: world.x,
|
||||||
|
startY: world.y,
|
||||||
|
origins: state.selectedGates.map(id => {
|
||||||
|
const g = state.gates.find(g => g.id === id);
|
||||||
|
return g ? { id: g.id, x: g.x, y: g.y } : null;
|
||||||
|
}).filter(Boolean)
|
||||||
|
};
|
||||||
|
canvas.style.cursor = 'grabbing';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Clicking an unselected gate → clear selection, drag just this one
|
||||||
|
state.selectedGates = [];
|
||||||
state.dragging = gate;
|
state.dragging = gate;
|
||||||
state.dragOffset = { x: world.x - gate.x, y: world.y - gate.y };
|
state.dragOffset = { x: world.x - gate.x, y: world.y - gate.y };
|
||||||
canvas.style.cursor = 'grabbing';
|
canvas.style.cursor = 'grabbing';
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Click on empty space → clear selection and start selection box
|
||||||
|
state.selectedGates = [];
|
||||||
|
state.selectionBox = {
|
||||||
|
startX: world.x, startY: world.y,
|
||||||
|
endX: world.x, endY: world.y
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
canvas.addEventListener('mouseup', e => {
|
canvas.addEventListener('mouseup', e => {
|
||||||
|
// Finish bus cut
|
||||||
|
if (state.busCutting) {
|
||||||
|
createBusFromCut();
|
||||||
|
state.busCutting = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finish selection box → select gates inside
|
||||||
|
if (state.selectionBox) {
|
||||||
|
const box = state.selectionBox;
|
||||||
|
const x1 = Math.min(box.startX, box.endX);
|
||||||
|
const y1 = Math.min(box.startY, box.endY);
|
||||||
|
const x2 = Math.max(box.startX, box.endX);
|
||||||
|
const y2 = Math.max(box.startY, box.endY);
|
||||||
|
|
||||||
|
// Only select if box is big enough (not just a click)
|
||||||
|
if (Math.abs(x2 - x1) > 5 || Math.abs(y2 - y1) > 5) {
|
||||||
|
state.selectedGates = state.gates
|
||||||
|
.filter(g => {
|
||||||
|
const isDynamic = g.type.startsWith('COMPONENT:') || g.type.startsWith('BUS_IN:') || g.type.startsWith('BUS_OUT:');
|
||||||
|
const gw = isDynamic ? getComponentWidth(g) : GATE_W;
|
||||||
|
const gh = isDynamic ? getComponentHeight(g) : GATE_H;
|
||||||
|
// Gate overlaps selection box
|
||||||
|
return g.x + gw > x1 && g.x < x2 && g.y + gh > y1 && g.y < y2;
|
||||||
|
})
|
||||||
|
.map(g => g.id);
|
||||||
|
if (state.selectedGates.length > 0) {
|
||||||
|
console.log(`[select] ${state.selectedGates.length} gate(s) selected`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.selectionBox = null;
|
||||||
|
dragStartPos = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finish multi-drag
|
||||||
|
if (state.multiDrag) {
|
||||||
|
state.multiDrag = null;
|
||||||
|
dragStartPos = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Toggle INPUT/CLOCK only on click (no drag movement)
|
// Toggle INPUT/CLOCK only on click (no drag movement)
|
||||||
if (state.dragging && !dragMoved) {
|
if (state.dragging && !dragMoved) {
|
||||||
const gate = state.dragging;
|
const gate = state.dragging;
|
||||||
@@ -139,7 +257,6 @@ export function initEvents() {
|
|||||||
gate.value = gate.value ? 0 : 1;
|
gate.value = gate.value ? 0 : 1;
|
||||||
console.log(`[toggle] ${gate.type}#${gate.id} → ${gate.value}`);
|
console.log(`[toggle] ${gate.type}#${gate.id} → ${gate.value}`);
|
||||||
evaluateAll(true); // record waveform on intentional toggle
|
evaluateAll(true); // record waveform on intentional toggle
|
||||||
// Log all gate values after evaluation
|
|
||||||
console.log('[state]', state.gates.map(g => `${g.type}#${g.id}=${g.value}`).join(', '));
|
console.log('[state]', state.gates.map(g => `${g.type}#${g.id}=${g.value}`).join(', '));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -147,11 +264,20 @@ export function initEvents() {
|
|||||||
dragStartPos = null;
|
dragStartPos = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Double-click to rename INPUT/OUTPUT/CLOCK gates
|
// Double-click to rename INPUT/OUTPUT/CLOCK gates, or edit component blueprint
|
||||||
canvas.addEventListener('dblclick', e => {
|
canvas.addEventListener('dblclick', e => {
|
||||||
const world = screenToWorld(e.offsetX, e.offsetY);
|
const world = screenToWorld(e.offsetX, e.offsetY);
|
||||||
const gate = findGateAt(world.x, world.y);
|
const gate = findGateAt(world.x, world.y);
|
||||||
if (gate && (gate.type === 'INPUT' || gate.type === 'OUTPUT' || gate.type === 'CLOCK')) {
|
if (!gate) return;
|
||||||
|
|
||||||
|
// Double-click on component gate → edit its blueprint
|
||||||
|
if (gate.type.startsWith('COMPONENT:') && gate.component) {
|
||||||
|
editComponentBlueprint(gate);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Double-click on I/O gates → rename (only inside component editor)
|
||||||
|
if (state.componentEditorActive && (gate.type === 'INPUT' || gate.type === 'OUTPUT' || gate.type === 'CLOCK')) {
|
||||||
const current = gate.label || '';
|
const current = gate.label || '';
|
||||||
const label = prompt(`Label for ${gate.type}#${gate.id}:`, current);
|
const label = prompt(`Label for ${gate.type}#${gate.id}:`, current);
|
||||||
if (label !== null) {
|
if (label !== null) {
|
||||||
@@ -163,6 +289,13 @@ export function initEvents() {
|
|||||||
|
|
||||||
canvas.addEventListener('contextmenu', e => {
|
canvas.addEventListener('contextmenu', e => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
|
// Right-click cancels placing mode
|
||||||
|
if (state.placingGate) {
|
||||||
|
state.placingGate = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const world = screenToWorld(e.offsetX, e.offsetY);
|
const world = screenToWorld(e.offsetX, e.offsetY);
|
||||||
const port = findPortAt(world.x, world.y);
|
const port = findPortAt(world.x, world.y);
|
||||||
if (port && port.type === 'input') {
|
if (port && port.type === 'input') {
|
||||||
@@ -193,11 +326,32 @@ export function initEvents() {
|
|||||||
const keysDown = new Set();
|
const keysDown = new Set();
|
||||||
|
|
||||||
document.addEventListener('keydown', e => {
|
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);
|
keysDown.add(e.key);
|
||||||
|
|
||||||
if (e.key === 'Delete' || e.key === 'Backspace') {
|
if (e.key === 'Delete' || e.key === 'Backspace') {
|
||||||
if (state.hoveredGate && document.activeElement === document.body) {
|
if (document.activeElement !== document.body) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
|
// Delete all selected gates
|
||||||
|
if (state.selectedGates.length > 0) {
|
||||||
|
for (const gateId of state.selectedGates) {
|
||||||
|
state.connections = state.connections.filter(c => c.from !== gateId && c.to !== gateId);
|
||||||
|
state.gates = state.gates.filter(g => g.id !== gateId);
|
||||||
|
delete state.waveData[gateId];
|
||||||
|
}
|
||||||
|
console.log(`[delete] removed ${state.selectedGates.length} gate(s)`);
|
||||||
|
state.selectedGates = [];
|
||||||
|
state.hoveredGate = null;
|
||||||
|
evaluateAll();
|
||||||
|
} else if (state.hoveredGate) {
|
||||||
|
// Delete single hovered gate
|
||||||
const gateId = state.hoveredGate.id;
|
const gateId = state.hoveredGate.id;
|
||||||
state.connections = state.connections.filter(c => c.from !== gateId && c.to !== gateId);
|
state.connections = state.connections.filter(c => c.from !== gateId && c.to !== gateId);
|
||||||
state.gates = state.gates.filter(g => g.id !== gateId);
|
state.gates = state.gates.filter(g => g.id !== gateId);
|
||||||
@@ -209,6 +363,7 @@ export function initEvents() {
|
|||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
state.placingGate = null;
|
state.placingGate = null;
|
||||||
state.connecting = null;
|
state.connecting = null;
|
||||||
|
state.selectedGates = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pan with arrow keys
|
// Pan with arrow keys
|
||||||
@@ -415,7 +570,11 @@ export function initEvents() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('component-editor-save').addEventListener('click', () => {
|
document.getElementById('component-editor-save').addEventListener('click', () => {
|
||||||
const name = prompt('Component name:', 'MyComponent');
|
// If editing existing, pre-fill with current name
|
||||||
|
const existingName = state.editingComponentId
|
||||||
|
? (state.customComponents[state.editingComponentId]?.name || 'MyComponent')
|
||||||
|
: 'MyComponent';
|
||||||
|
const name = prompt('Component name:', existingName);
|
||||||
if (name && name.trim()) {
|
if (name && name.trim()) {
|
||||||
exitComponentEditor(name.trim(), true);
|
exitComponentEditor(name.trim(), true);
|
||||||
}
|
}
|
||||||
|
|||||||
85
js/gates.js
@@ -4,13 +4,15 @@ import { state } from './state.js';
|
|||||||
import { recordSample, setEvaluateAll } from './waveform.js';
|
import { recordSample, setEvaluateAll } from './waveform.js';
|
||||||
import { evaluateComponent } from './components.js';
|
import { evaluateComponent } from './components.js';
|
||||||
|
|
||||||
// Wrappers that handle component types
|
// Wrappers that handle component and BUS types
|
||||||
export function gateInputCount(type) {
|
export function gateInputCount(type) {
|
||||||
if (type.startsWith('COMPONENT:')) {
|
if (type.startsWith('COMPONENT:')) {
|
||||||
const componentId = type.substring(10);
|
const componentId = type.substring(10);
|
||||||
const component = state.customComponents?.[componentId];
|
const component = state.customComponents?.[componentId];
|
||||||
return component ? component.inputCount : 0;
|
return component ? component.inputCount : 0;
|
||||||
}
|
}
|
||||||
|
if (type.startsWith('BUS_IN:')) return parseInt(type.substring(7)) || 0;
|
||||||
|
if (type.startsWith('BUS_OUT:')) return 0;
|
||||||
return baseGateInputCount(type);
|
return baseGateInputCount(type);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -20,10 +22,23 @@ export function gateOutputCount(type) {
|
|||||||
const component = state.customComponents?.[componentId];
|
const component = state.customComponents?.[componentId];
|
||||||
return component ? component.outputCount : 0;
|
return component ? component.outputCount : 0;
|
||||||
}
|
}
|
||||||
|
if (type.startsWith('BUS_IN:')) return 0;
|
||||||
|
if (type.startsWith('BUS_OUT:')) return parseInt(type.substring(8)) || 0;
|
||||||
return baseGateOutputCount(type);
|
return baseGateOutputCount(type);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isBusType(type) {
|
||||||
|
return type.startsWith('BUS_IN:') || type.startsWith('BUS_OUT:');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBusSize(type) {
|
||||||
|
if (type.startsWith('BUS_IN:')) return parseInt(type.substring(7)) || 1;
|
||||||
|
if (type.startsWith('BUS_OUT:')) return parseInt(type.substring(8)) || 1;
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
export function getComponentWidth(gate) {
|
export function getComponentWidth(gate) {
|
||||||
|
if (isBusType(gate.type)) return 30;
|
||||||
if (gate.type.startsWith('COMPONENT:')) {
|
if (gate.type.startsWith('COMPONENT:')) {
|
||||||
const count = Math.max(gate.component?.inputCount || 1, gate.component?.outputCount || 1);
|
const count = Math.max(gate.component?.inputCount || 1, gate.component?.outputCount || 1);
|
||||||
return Math.max(120, (count + 1) * 25);
|
return Math.max(120, (count + 1) * 25);
|
||||||
@@ -32,6 +47,10 @@ export function getComponentWidth(gate) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getComponentHeight(gate) {
|
export function getComponentHeight(gate) {
|
||||||
|
if (isBusType(gate.type)) {
|
||||||
|
const n = getBusSize(gate.type);
|
||||||
|
return Math.max(40, (n + 1) * 22);
|
||||||
|
}
|
||||||
if (gate.type.startsWith('COMPONENT:')) {
|
if (gate.type.startsWith('COMPONENT:')) {
|
||||||
const count = Math.max(gate.component?.inputCount || 1, gate.component?.outputCount || 1);
|
const count = Math.max(gate.component?.inputCount || 1, gate.component?.outputCount || 1);
|
||||||
return Math.max(60, (count + 1) * 25);
|
return Math.max(60, (count + 1) * 25);
|
||||||
@@ -42,8 +61,8 @@ export function getComponentHeight(gate) {
|
|||||||
export function getInputPorts(gate) {
|
export function getInputPorts(gate) {
|
||||||
const count = gateInputCount(gate.type);
|
const count = gateInputCount(gate.type);
|
||||||
const ports = [];
|
const ports = [];
|
||||||
const isComponent = gate.type.startsWith('COMPONENT:');
|
const isDynamic = gate.type.startsWith('COMPONENT:') || isBusType(gate.type);
|
||||||
const gateHeight = isComponent ? getComponentHeight(gate) : GATE_H;
|
const gateHeight = isDynamic ? getComponentHeight(gate) : GATE_H;
|
||||||
|
|
||||||
for (let i = 0; i < count; i++) {
|
for (let i = 0; i < count; i++) {
|
||||||
const spacing = gateHeight / (count + 1);
|
const spacing = gateHeight / (count + 1);
|
||||||
@@ -55,9 +74,9 @@ export function getInputPorts(gate) {
|
|||||||
export function getOutputPorts(gate) {
|
export function getOutputPorts(gate) {
|
||||||
const count = gateOutputCount(gate.type);
|
const count = gateOutputCount(gate.type);
|
||||||
const ports = [];
|
const ports = [];
|
||||||
const isComponent = gate.type.startsWith('COMPONENT:');
|
const isDynamic = gate.type.startsWith('COMPONENT:') || isBusType(gate.type);
|
||||||
const gateWidth = isComponent ? getComponentWidth(gate) : GATE_W;
|
const gateWidth = isDynamic ? getComponentWidth(gate) : GATE_W;
|
||||||
const gateHeight = isComponent ? getComponentHeight(gate) : GATE_H;
|
const gateHeight = isDynamic ? getComponentHeight(gate) : GATE_H;
|
||||||
|
|
||||||
for (let i = 0; i < count; i++) {
|
for (let i = 0; i < count; i++) {
|
||||||
const spacing = gateHeight / (count + 1);
|
const spacing = gateHeight / (count + 1);
|
||||||
@@ -66,9 +85,22 @@ export function getOutputPorts(gate) {
|
|||||||
return ports;
|
return ports;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the value from a source gate at a specific output port.
|
||||||
|
* For component gates with multiple outputs, reads from outputValues[].
|
||||||
|
* For normal gates (single output), reads gate.value.
|
||||||
|
*/
|
||||||
|
function readSourcePort(srcGate, fromPort) {
|
||||||
|
if (srcGate.outputValues && fromPort < srcGate.outputValues.length) {
|
||||||
|
return srcGate.outputValues[fromPort];
|
||||||
|
}
|
||||||
|
return srcGate.value || 0;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compute the output of a single gate given its current input values.
|
* Compute the output of a single gate given its current input values.
|
||||||
* Does NOT recurse — just reads source gate .value directly.
|
* Does NOT recurse — just reads source gate .value directly.
|
||||||
|
* For COMPONENT gates, evaluates internal circuit and stores all outputs.
|
||||||
*/
|
*/
|
||||||
function computeGate(gate) {
|
function computeGate(gate) {
|
||||||
if (gate.type === 'INPUT' || gate.type === 'CLOCK') return gate.value;
|
if (gate.type === 'INPUT' || gate.type === 'CLOCK') return gate.value;
|
||||||
@@ -79,7 +111,7 @@ function computeGate(gate) {
|
|||||||
const conn = state.connections.find(c => c.to === gate.id && c.toPort === i);
|
const conn = state.connections.find(c => c.to === gate.id && c.toPort === i);
|
||||||
if (conn) {
|
if (conn) {
|
||||||
const srcGate = state.gates.find(g => g.id === conn.from);
|
const srcGate = state.gates.find(g => g.id === conn.from);
|
||||||
inputs.push(srcGate ? (srcGate.value || 0) : 0);
|
inputs.push(srcGate ? readSourcePort(srcGate, conn.fromPort) : 0);
|
||||||
} else {
|
} else {
|
||||||
inputs.push(0);
|
inputs.push(0);
|
||||||
}
|
}
|
||||||
@@ -87,9 +119,28 @@ function computeGate(gate) {
|
|||||||
|
|
||||||
if (gate.type.startsWith('COMPONENT:')) {
|
if (gate.type.startsWith('COMPONENT:')) {
|
||||||
const outputs = evaluateComponent(gate, inputs);
|
const outputs = evaluateComponent(gate, inputs);
|
||||||
|
// Store all output values for multi-output components
|
||||||
|
gate.outputValues = outputs;
|
||||||
return outputs[0] || 0;
|
return outputs[0] || 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BUS_IN: collect input values and store them for the paired BUS_OUT
|
||||||
|
if (gate.type.startsWith('BUS_IN:')) {
|
||||||
|
gate.busValues = [...inputs];
|
||||||
|
gate.value = inputs[0] || 0;
|
||||||
|
return gate.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// BUS_OUT: read values from paired BUS_IN terminal
|
||||||
|
if (gate.type.startsWith('BUS_OUT:')) {
|
||||||
|
const pair = state.gates.find(g => g.id === gate.busPairId);
|
||||||
|
if (pair && pair.busValues) {
|
||||||
|
gate.outputValues = [...pair.busValues];
|
||||||
|
gate.value = gate.outputValues[0] || 0;
|
||||||
|
}
|
||||||
|
return gate.value || 0;
|
||||||
|
}
|
||||||
|
|
||||||
switch (gate.type) {
|
switch (gate.type) {
|
||||||
case 'AND': return (inputs[0] && inputs[1]) ? 1 : 0;
|
case 'AND': return (inputs[0] && inputs[1]) ? 1 : 0;
|
||||||
case 'OR': return (inputs[0] || inputs[1]) ? 1 : 0;
|
case 'OR': return (inputs[0] || inputs[1]) ? 1 : 0;
|
||||||
@@ -115,14 +166,25 @@ export function evaluateAll(recordWave = false) {
|
|||||||
let changed = false;
|
let changed = false;
|
||||||
for (const gate of state.gates) {
|
for (const gate of state.gates) {
|
||||||
if (gate.type === 'INPUT' || gate.type === 'CLOCK') continue;
|
if (gate.type === 'INPUT' || gate.type === 'CLOCK') continue;
|
||||||
|
const oldVal = gate.value;
|
||||||
|
const oldOutputs = gate.outputValues ? [...gate.outputValues] : null;
|
||||||
const newVal = computeGate(gate);
|
const newVal = computeGate(gate);
|
||||||
if (newVal !== gate.value) {
|
if (newVal !== oldVal) {
|
||||||
gate.value = newVal;
|
gate.value = newVal;
|
||||||
changed = true;
|
changed = true;
|
||||||
}
|
}
|
||||||
|
// Also check if outputValues changed (for multi-output components)
|
||||||
|
if (gate.outputValues && oldOutputs) {
|
||||||
|
for (let i = 0; i < gate.outputValues.length; i++) {
|
||||||
|
if (gate.outputValues[i] !== oldOutputs[i]) {
|
||||||
|
changed = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (!changed) {
|
if (!changed) {
|
||||||
console.log(`[eval] stable after ${iter + 1} iteration(s)`);
|
if (iter > 0) console.log(`[eval] stable after ${iter + 1} iteration(s)`);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if (iter === MAX_ITERATIONS - 1) {
|
if (iter === MAX_ITERATIONS - 1) {
|
||||||
@@ -142,8 +204,9 @@ setEvaluateAll(evaluateAll);
|
|||||||
|
|
||||||
export function findGateAt(x, y) {
|
export function findGateAt(x, y) {
|
||||||
return state.gates.find(g => {
|
return state.gates.find(g => {
|
||||||
const w = g.type.startsWith('COMPONENT:') ? getComponentWidth(g) : GATE_W;
|
const isDynamic = g.type.startsWith('COMPONENT:') || isBusType(g.type);
|
||||||
const h = g.type.startsWith('COMPONENT:') ? getComponentHeight(g) : GATE_H;
|
const w = isDynamic ? getComponentWidth(g) : GATE_W;
|
||||||
|
const h = isDynamic ? getComponentHeight(g) : GATE_H;
|
||||||
return x >= g.x && x <= g.x + w && y >= g.y && y <= g.y + h;
|
return x >= g.x && x <= g.x + w && y >= g.y && y <= g.y + h;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
364
js/renderer.js
@@ -3,15 +3,48 @@ import { GATE_W, GATE_H, COMP_W, PORT_R, GATE_COLORS } from './constants.js';
|
|||||||
import { state } from './state.js';
|
import { state } from './state.js';
|
||||||
import { getInputPorts, getOutputPorts, getComponentWidth, getComponentHeight } from './gates.js';
|
import { getInputPorts, getOutputPorts, getComponentWidth, getComponentHeight } from './gates.js';
|
||||||
import { getGateLabel, drawWaveLabels, drawWaveforms } from './waveform.js';
|
import { getGateLabel, drawWaveLabels, drawWaveforms } from './waveform.js';
|
||||||
|
import { getBusPairs } from './bus.js';
|
||||||
|
import { drawNamingScreen, drawNotification } from './world/inventory.js';
|
||||||
|
|
||||||
let canvas, ctx;
|
let canvas, ctx;
|
||||||
|
let circuitAnimFrameId = null;
|
||||||
|
let rendererInitialized = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the value arriving at an input port by looking up the source gate/port.
|
||||||
|
* Handles multi-output sources (BUS_OUT, COMPONENT) via outputValues[].
|
||||||
|
*/
|
||||||
|
function getSourcePortValue(conn) {
|
||||||
|
if (!conn) return 0;
|
||||||
|
const srcGate = state.gates.find(g => g.id === conn.from);
|
||||||
|
if (!srcGate) return 0;
|
||||||
|
if (srcGate.outputValues && conn.fromPort < srcGate.outputValues.length) {
|
||||||
|
return srcGate.outputValues[conn.fromPort] || 0;
|
||||||
|
}
|
||||||
|
return srcGate.value || 0;
|
||||||
|
}
|
||||||
|
|
||||||
export function initRenderer() {
|
export function initRenderer() {
|
||||||
canvas = document.getElementById('canvas');
|
canvas = document.getElementById('canvas');
|
||||||
ctx = canvas.getContext('2d');
|
ctx = canvas.getContext('2d');
|
||||||
resize();
|
resize();
|
||||||
|
if (!rendererInitialized) {
|
||||||
window.addEventListener('resize', resize);
|
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() {
|
export function resize() {
|
||||||
@@ -31,11 +64,29 @@ export function screenToWorld(sx, sy) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isBusType(type) {
|
||||||
|
return type.startsWith('BUS_IN:') || type.startsWith('BUS_OUT:');
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawSelectionHighlight(gate) {
|
||||||
|
if (!state.selectedGates.includes(gate.id)) return;
|
||||||
|
const isDynamic = gate.type.startsWith('COMPONENT:') || isBusType(gate.type);
|
||||||
|
const w = isDynamic ? getComponentWidth(gate) : GATE_W;
|
||||||
|
const h = isDynamic ? getComponentHeight(gate) : GATE_H;
|
||||||
|
const pad = 4;
|
||||||
|
ctx.strokeStyle = '#44ddff';
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.setLineDash([5, 3]);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.roundRect(gate.x - pad, gate.y - pad, w + pad * 2, h + pad * 2, 10);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.setLineDash([]);
|
||||||
|
}
|
||||||
|
|
||||||
function drawGate(gate) {
|
function drawGate(gate) {
|
||||||
// Component gates have different rendering
|
// Special gate types have different rendering
|
||||||
if (gate.type.startsWith('COMPONENT:')) {
|
if (isBusType(gate.type)) { drawBusGate(gate); drawSelectionHighlight(gate); return; }
|
||||||
return drawComponentGate(gate);
|
if (gate.type.startsWith('COMPONENT:')) { drawComponentGate(gate); drawSelectionHighlight(gate); return; }
|
||||||
}
|
|
||||||
|
|
||||||
const color = GATE_COLORS[gate.type];
|
const color = GATE_COLORS[gate.type];
|
||||||
const isHovered = state.hoveredGate === gate;
|
const isHovered = state.hoveredGate === gate;
|
||||||
@@ -98,7 +149,7 @@ function drawGate(gate) {
|
|||||||
state.hoveredPort.index === p.index &&
|
state.hoveredPort.index === p.index &&
|
||||||
state.hoveredPort.type === 'input';
|
state.hoveredPort.type === 'input';
|
||||||
const conn = state.connections.find(c => c.to === gate.id && c.toPort === p.index);
|
const conn = state.connections.find(c => c.to === gate.id && c.toPort === p.index);
|
||||||
const portActive = conn ? state.gates.find(g => g.id === conn.from)?.value : 0;
|
const portActive = getSourcePortValue(conn);
|
||||||
|
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.arc(p.x, p.y, PORT_R, 0, Math.PI * 2);
|
ctx.arc(p.x, p.y, PORT_R, 0, Math.PI * 2);
|
||||||
@@ -115,15 +166,159 @@ function drawGate(gate) {
|
|||||||
state.hoveredPort.gate === gate &&
|
state.hoveredPort.gate === gate &&
|
||||||
state.hoveredPort.index === p.index &&
|
state.hoveredPort.index === p.index &&
|
||||||
state.hoveredPort.type === 'output';
|
state.hoveredPort.type === 'output';
|
||||||
|
const portVal = gate.outputValues ? (gate.outputValues[p.index] || 0) : gate.value;
|
||||||
|
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.arc(p.x, p.y, PORT_R, 0, Math.PI * 2);
|
ctx.arc(p.x, p.y, PORT_R, 0, Math.PI * 2);
|
||||||
ctx.fillStyle = isPortHovered ? '#fff' : (gate.value ? '#00ff88' : '#1a1a2e');
|
ctx.fillStyle = isPortHovered ? '#fff' : (portVal ? '#00ff88' : '#1a1a2e');
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
ctx.strokeStyle = isPortHovered ? '#fff' : '#555';
|
ctx.strokeStyle = isPortHovered ? '#fff' : '#555';
|
||||||
ctx.lineWidth = 1.5;
|
ctx.lineWidth = 1.5;
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
drawSelectionHighlight(gate);
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawBusGate(gate) {
|
||||||
|
const isHovered = state.hoveredGate === gate;
|
||||||
|
const w = getComponentWidth(gate); // 30
|
||||||
|
const h = getComponentHeight(gate);
|
||||||
|
const color = '#44ddff';
|
||||||
|
const isIn = gate.type.startsWith('BUS_IN:');
|
||||||
|
const n = isIn
|
||||||
|
? parseInt(gate.type.substring(7)) || 1
|
||||||
|
: parseInt(gate.type.substring(8)) || 1;
|
||||||
|
|
||||||
|
// Check if any channel is active
|
||||||
|
const values = isIn ? gate.busValues : gate.outputValues;
|
||||||
|
const hasActive = values?.some(v => v === 1);
|
||||||
|
|
||||||
|
if (hasActive) {
|
||||||
|
ctx.shadowColor = color;
|
||||||
|
ctx.shadowBlur = 12 * state.zoom;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main bus bar
|
||||||
|
ctx.fillStyle = hasActive ? color + '22' : '#14141e';
|
||||||
|
ctx.strokeStyle = isHovered ? '#fff' : color;
|
||||||
|
ctx.lineWidth = isHovered ? 2.5 : 1.5;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.roundRect(gate.x, gate.y, w, h, 4);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.shadowBlur = 0;
|
||||||
|
|
||||||
|
// Thick center line (bus bar visual)
|
||||||
|
ctx.strokeStyle = isHovered ? '#fff' : color;
|
||||||
|
ctx.lineWidth = 3;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(gate.x + w / 2, gate.y + 6);
|
||||||
|
ctx.lineTo(gate.x + w / 2, gate.y + h - 6);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Bus size label + role indicator
|
||||||
|
ctx.font = 'bold 9px monospace';
|
||||||
|
ctx.fillStyle = color;
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
const roleIcon = isIn ? '▶' : '◀';
|
||||||
|
ctx.fillText(`${roleIcon} ${n}`, gate.x + w / 2, gate.y + h + 10);
|
||||||
|
|
||||||
|
// BUS_IN: only input ports (left side)
|
||||||
|
if (isIn) {
|
||||||
|
getInputPorts(gate).forEach(p => {
|
||||||
|
const isPortHovered = state.hoveredPort &&
|
||||||
|
state.hoveredPort.gate === gate &&
|
||||||
|
state.hoveredPort.index === p.index &&
|
||||||
|
state.hoveredPort.type === 'input';
|
||||||
|
const conn = state.connections.find(c => c.to === gate.id && c.toPort === p.index);
|
||||||
|
const portActive = getSourcePortValue(conn);
|
||||||
|
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(p.x, p.y, PORT_R - 1, 0, Math.PI * 2);
|
||||||
|
ctx.fillStyle = isPortHovered ? '#fff' : (portActive ? '#00ff88' : '#1a1a2e');
|
||||||
|
ctx.fill();
|
||||||
|
ctx.strokeStyle = isPortHovered ? '#fff' : '#555';
|
||||||
|
ctx.lineWidth = 1.5;
|
||||||
|
ctx.stroke();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// BUS_OUT: only output ports (right side)
|
||||||
|
if (!isIn) {
|
||||||
|
getOutputPorts(gate).forEach(p => {
|
||||||
|
const isPortHovered = state.hoveredPort &&
|
||||||
|
state.hoveredPort.gate === gate &&
|
||||||
|
state.hoveredPort.index === p.index &&
|
||||||
|
state.hoveredPort.type === 'output';
|
||||||
|
const portVal = gate.outputValues ? (gate.outputValues[p.index] || 0) : 0;
|
||||||
|
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(p.x, p.y, PORT_R - 1, 0, Math.PI * 2);
|
||||||
|
ctx.fillStyle = isPortHovered ? '#fff' : (portVal ? '#00ff88' : '#1a1a2e');
|
||||||
|
ctx.fill();
|
||||||
|
ctx.strokeStyle = isPortHovered ? '#fff' : '#555';
|
||||||
|
ctx.lineWidth = 1.5;
|
||||||
|
ctx.stroke();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawBusCables() {
|
||||||
|
const pairs = getBusPairs();
|
||||||
|
for (const { inGate, outGate } of pairs) {
|
||||||
|
const inW = getComponentWidth(inGate);
|
||||||
|
const inH = getComponentHeight(inGate);
|
||||||
|
const outH = getComponentHeight(outGate);
|
||||||
|
|
||||||
|
// Cable runs from right edge of inGate to left edge of outGate
|
||||||
|
const x1 = inGate.x + inW;
|
||||||
|
const y1 = inGate.y + inH / 2;
|
||||||
|
const x2 = outGate.x;
|
||||||
|
const y2 = outGate.y + outH / 2;
|
||||||
|
|
||||||
|
// Check if any channel is active
|
||||||
|
const hasActive = inGate.busValues?.some(v => v === 1);
|
||||||
|
const n = parseInt(inGate.type.substring(7)) || 1;
|
||||||
|
|
||||||
|
// Outer thick cable (bus background)
|
||||||
|
const cableWidth = Math.max(6, n * 2.5);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x1, y1);
|
||||||
|
const midX = (x1 + x2) / 2;
|
||||||
|
ctx.bezierCurveTo(midX, y1, midX, y2, x2, y2);
|
||||||
|
ctx.strokeStyle = hasActive ? '#44ddff33' : '#44ddff11';
|
||||||
|
ctx.lineWidth = cableWidth + 4;
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Inner cable
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x1, y1);
|
||||||
|
ctx.bezierCurveTo(midX, y1, midX, y2, x2, y2);
|
||||||
|
ctx.strokeStyle = hasActive ? '#44ddff' : '#44ddff66';
|
||||||
|
ctx.lineWidth = cableWidth;
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Channel count label at midpoint
|
||||||
|
const labelX = (x1 + x2) / 2;
|
||||||
|
const labelY = (y1 + y2) / 2 - cableWidth / 2 - 6;
|
||||||
|
ctx.font = 'bold 10px monospace';
|
||||||
|
ctx.fillStyle = '#44ddff';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
ctx.fillText(`/${n}`, labelX, labelY);
|
||||||
|
|
||||||
|
// Draw small diagonal slash across cable (bus notation)
|
||||||
|
const slashX = labelX;
|
||||||
|
const slashY = (y1 + y2) / 2;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(slashX - 6, slashY + 6);
|
||||||
|
ctx.lineTo(slashX + 6, slashY - 6);
|
||||||
|
ctx.strokeStyle = '#44ddff';
|
||||||
|
ctx.lineWidth = 1.5;
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawComponentGate(gate) {
|
function drawComponentGate(gate) {
|
||||||
@@ -161,6 +356,25 @@ function drawComponentGate(gate) {
|
|||||||
ctx.fillStyle = '#444';
|
ctx.fillStyle = '#444';
|
||||||
ctx.fillText(getGateLabel(gate), gate.x + w / 2, gate.y + h - 6);
|
ctx.fillText(getGateLabel(gate), gate.x + w / 2, gate.y + h - 6);
|
||||||
|
|
||||||
|
// Get port labels from the authoritative component definition (customComponents)
|
||||||
|
// This must match the source used by gateOutputCount/gateInputCount for port counts
|
||||||
|
const compId = gate.type.substring(10);
|
||||||
|
const comp = state.customComponents?.[compId] || gate.component;
|
||||||
|
const inputLabels = [];
|
||||||
|
const outputLabels = [];
|
||||||
|
if (comp) {
|
||||||
|
const inputIds = comp.inputIds || [];
|
||||||
|
const outputIds = comp.outputIds || [];
|
||||||
|
for (const id of inputIds) {
|
||||||
|
const g = comp.gates.find(g => g.id === id);
|
||||||
|
inputLabels.push(g?.label || '');
|
||||||
|
}
|
||||||
|
for (const id of outputIds) {
|
||||||
|
const g = comp.gates.find(g => g.id === id);
|
||||||
|
outputLabels.push(g?.label || '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Input ports
|
// Input ports
|
||||||
getInputPorts(gate).forEach(p => {
|
getInputPorts(gate).forEach(p => {
|
||||||
const isPortHovered = state.hoveredPort &&
|
const isPortHovered = state.hoveredPort &&
|
||||||
@@ -168,7 +382,7 @@ function drawComponentGate(gate) {
|
|||||||
state.hoveredPort.index === p.index &&
|
state.hoveredPort.index === p.index &&
|
||||||
state.hoveredPort.type === 'input';
|
state.hoveredPort.type === 'input';
|
||||||
const conn = state.connections.find(c => c.to === gate.id && c.toPort === p.index);
|
const conn = state.connections.find(c => c.to === gate.id && c.toPort === p.index);
|
||||||
const portActive = conn ? state.gates.find(g => g.id === conn.from)?.value : 0;
|
const portActive = getSourcePortValue(conn);
|
||||||
|
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.arc(p.x, p.y, PORT_R, 0, Math.PI * 2);
|
ctx.arc(p.x, p.y, PORT_R, 0, Math.PI * 2);
|
||||||
@@ -177,6 +391,16 @@ function drawComponentGate(gate) {
|
|||||||
ctx.strokeStyle = isPortHovered ? '#fff' : '#555';
|
ctx.strokeStyle = isPortHovered ? '#fff' : '#555';
|
||||||
ctx.lineWidth = 1.5;
|
ctx.lineWidth = 1.5;
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Port label (inside the gate, to the right of the port)
|
||||||
|
const label = inputLabels[p.index];
|
||||||
|
if (label) {
|
||||||
|
ctx.font = '9px "Segoe UI", system-ui, sans-serif';
|
||||||
|
ctx.fillStyle = '#aaa';
|
||||||
|
ctx.textAlign = 'left';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
ctx.fillText(label, p.x + PORT_R + 4, p.y);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Output ports
|
// Output ports
|
||||||
@@ -185,14 +409,25 @@ function drawComponentGate(gate) {
|
|||||||
state.hoveredPort.gate === gate &&
|
state.hoveredPort.gate === gate &&
|
||||||
state.hoveredPort.index === p.index &&
|
state.hoveredPort.index === p.index &&
|
||||||
state.hoveredPort.type === 'output';
|
state.hoveredPort.type === 'output';
|
||||||
|
const portVal = gate.outputValues ? (gate.outputValues[p.index] || 0) : gate.value;
|
||||||
|
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.arc(p.x, p.y, PORT_R, 0, Math.PI * 2);
|
ctx.arc(p.x, p.y, PORT_R, 0, Math.PI * 2);
|
||||||
ctx.fillStyle = isPortHovered ? '#fff' : (gate.value ? '#00ff88' : '#1a1a2e');
|
ctx.fillStyle = isPortHovered ? '#fff' : (portVal ? '#00ff88' : '#1a1a2e');
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
ctx.strokeStyle = isPortHovered ? '#fff' : '#555';
|
ctx.strokeStyle = isPortHovered ? '#fff' : '#555';
|
||||||
ctx.lineWidth = 1.5;
|
ctx.lineWidth = 1.5;
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Port label (inside the gate, to the left of the port)
|
||||||
|
const label = outputLabels[p.index];
|
||||||
|
if (label) {
|
||||||
|
ctx.font = '9px "Segoe UI", system-ui, sans-serif';
|
||||||
|
ctx.fillStyle = '#aaa';
|
||||||
|
ctx.textAlign = 'right';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
ctx.fillText(label, p.x - PORT_R - 4, p.y);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,7 +440,10 @@ function drawConnection(conn) {
|
|||||||
const toPort = getInputPorts(toGate)[conn.toPort];
|
const toPort = getInputPorts(toGate)[conn.toPort];
|
||||||
if (!fromPort || !toPort) return;
|
if (!fromPort || !toPort) return;
|
||||||
|
|
||||||
const active = fromGate.value === 1;
|
// Read correct output port value for multi-output gates (components)
|
||||||
|
const active = fromGate.outputValues
|
||||||
|
? (fromGate.outputValues[conn.fromPort] || 0) === 1
|
||||||
|
: fromGate.value === 1;
|
||||||
const midX = (fromPort.x + toPort.x) / 2;
|
const midX = (fromPort.x + toPort.x) / 2;
|
||||||
|
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
@@ -263,6 +501,103 @@ function drawConnectingWire() {
|
|||||||
ctx.setLineDash([]);
|
ctx.setLineDash([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function drawBusCutLine() {
|
||||||
|
if (!state.busCutting) return;
|
||||||
|
const cut = state.busCutting;
|
||||||
|
|
||||||
|
// Dashed cyan line showing the cut
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(cut.startX, cut.startY);
|
||||||
|
ctx.lineTo(cut.endX, cut.endY);
|
||||||
|
ctx.strokeStyle = '#44ddff';
|
||||||
|
ctx.lineWidth = 2.5;
|
||||||
|
ctx.setLineDash([8, 5]);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.setLineDash([]);
|
||||||
|
|
||||||
|
// Small circles at endpoints
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(cut.startX, cut.startY, 4, 0, Math.PI * 2);
|
||||||
|
ctx.fillStyle = '#44ddff';
|
||||||
|
ctx.fill();
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(cut.endX, cut.endY, 4, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Highlight intersecting wires
|
||||||
|
// Import is circular so we compute inline: sample bezier + test intersection
|
||||||
|
const hits = countCutIntersections(cut);
|
||||||
|
if (hits > 0) {
|
||||||
|
ctx.save();
|
||||||
|
ctx.resetTransform();
|
||||||
|
ctx.fillStyle = '#44ddff';
|
||||||
|
ctx.font = 'bold 12px "Segoe UI", system-ui, sans-serif';
|
||||||
|
ctx.textAlign = 'left';
|
||||||
|
ctx.fillText(`✂ ${hits} wire${hits > 1 ? 's' : ''}`, 10, canvas.height - 10);
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Quick inline intersection count for preview (avoids circular import from bus.js)
|
||||||
|
*/
|
||||||
|
function countCutIntersections(cut) {
|
||||||
|
let count = 0;
|
||||||
|
for (const conn of state.connections) {
|
||||||
|
const fromGate = state.gates.find(g => g.id === conn.from);
|
||||||
|
const toGate = state.gates.find(g => g.id === conn.to);
|
||||||
|
if (!fromGate || !toGate) continue;
|
||||||
|
const fp = getOutputPorts(fromGate)[conn.fromPort];
|
||||||
|
const tp = getInputPorts(toGate)[conn.toPort];
|
||||||
|
if (!fp || !tp) continue;
|
||||||
|
|
||||||
|
const midX = (fp.x + tp.x) / 2;
|
||||||
|
// Sample bezier at 16 points
|
||||||
|
for (let i = 0; i < 16; i++) {
|
||||||
|
const t1 = i / 16, t2 = (i + 1) / 16;
|
||||||
|
const bx1 = bezAt(fp.x, midX, midX, tp.x, t1);
|
||||||
|
const by1 = bezAt(fp.y, fp.y, tp.y, tp.y, t1);
|
||||||
|
const bx2 = bezAt(fp.x, midX, midX, tp.x, t2);
|
||||||
|
const by2 = bezAt(fp.y, fp.y, tp.y, tp.y, t2);
|
||||||
|
if (segsHit(cut.startX, cut.startY, cut.endX, cut.endY, bx1, by1, bx2, by2)) {
|
||||||
|
count++;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bezAt(p0, p1, p2, p3, t) {
|
||||||
|
const mt = 1 - t;
|
||||||
|
return mt * mt * mt * p0 + 3 * mt * mt * t * p1 + 3 * mt * t * t * p2 + t * t * t * p3;
|
||||||
|
}
|
||||||
|
|
||||||
|
function segsHit(ax1, ay1, ax2, ay2, bx1, by1, bx2, by2) {
|
||||||
|
const d = (ax2 - ax1) * (by2 - by1) - (ay2 - ay1) * (bx2 - bx1);
|
||||||
|
if (Math.abs(d) < 1e-10) return false;
|
||||||
|
const t = ((bx1 - ax1) * (by2 - by1) - (by1 - ay1) * (bx2 - bx1)) / d;
|
||||||
|
const u = ((bx1 - ax1) * (ay2 - ay1) - (by1 - ay1) * (ax2 - ax1)) / d;
|
||||||
|
return t >= 0 && t <= 1 && u >= 0 && u <= 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawSelectionBox() {
|
||||||
|
if (!state.selectionBox) return;
|
||||||
|
const box = state.selectionBox;
|
||||||
|
const x = Math.min(box.startX, box.endX);
|
||||||
|
const y = Math.min(box.startY, box.endY);
|
||||||
|
const w = Math.abs(box.endX - box.startX);
|
||||||
|
const h = Math.abs(box.endY - box.startY);
|
||||||
|
|
||||||
|
ctx.fillStyle = '#44ddff0a';
|
||||||
|
ctx.fillRect(x, y, w, h);
|
||||||
|
ctx.strokeStyle = '#44ddff88';
|
||||||
|
ctx.lineWidth = 1.5;
|
||||||
|
ctx.setLineDash([6, 3]);
|
||||||
|
ctx.strokeRect(x, y, w, h);
|
||||||
|
ctx.setLineDash([]);
|
||||||
|
}
|
||||||
|
|
||||||
function drawPlacingGhost() {
|
function drawPlacingGhost() {
|
||||||
if (!state.placingGate) return;
|
if (!state.placingGate) return;
|
||||||
ctx.globalAlpha = 0.5;
|
ctx.globalAlpha = 0.5;
|
||||||
@@ -312,8 +647,11 @@ function draw() {
|
|||||||
|
|
||||||
drawGrid();
|
drawGrid();
|
||||||
state.connections.forEach(drawConnection);
|
state.connections.forEach(drawConnection);
|
||||||
|
drawBusCables();
|
||||||
state.gates.forEach(drawGate);
|
state.gates.forEach(drawGate);
|
||||||
drawConnectingWire();
|
drawConnectingWire();
|
||||||
|
drawBusCutLine();
|
||||||
|
drawSelectionBox();
|
||||||
drawPlacingGhost();
|
drawPlacingGhost();
|
||||||
|
|
||||||
ctx.restore();
|
ctx.restore();
|
||||||
@@ -326,5 +664,9 @@ function draw() {
|
|||||||
drawWaveforms();
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
import { state } from './state.js';
|
import { state } from './state.js';
|
||||||
import { progress } from './levels.js';
|
import { progress } from './levels.js';
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'logiclab_state';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Save complete application state to JSON
|
* Save complete application state to JSON
|
||||||
*/
|
*/
|
||||||
@@ -54,6 +56,16 @@ export function loadState(data) {
|
|||||||
state.customComponents = JSON.parse(JSON.stringify(data.components));
|
state.customComponents = JSON.parse(JSON.stringify(data.components));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Re-link gate.component references to customComponents (authoritative source)
|
||||||
|
for (const gate of state.gates) {
|
||||||
|
if (gate.type.startsWith('COMPONENT:')) {
|
||||||
|
const compId = gate.type.substring(10);
|
||||||
|
if (state.customComponents[compId]) {
|
||||||
|
gate.component = state.customComponents[compId];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Load progress
|
// Load progress
|
||||||
if (data.progress) {
|
if (data.progress) {
|
||||||
progress.unlockedLevels = data.progress.unlockedLevels || ['buffer'];
|
progress.unlockedLevels = data.progress.unlockedLevels || ['buffer'];
|
||||||
@@ -147,3 +159,46 @@ export async function pasteFromClipboard() {
|
|||||||
return { success: false, error: e.message };
|
return { success: false, error: e.message };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save state to localStorage
|
||||||
|
*/
|
||||||
|
export function saveToStorage() {
|
||||||
|
try {
|
||||||
|
const data = saveState();
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[storage] failed to save:', e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load state from localStorage (returns true if state was restored)
|
||||||
|
*/
|
||||||
|
export function loadFromStorage() {
|
||||||
|
try {
|
||||||
|
const json = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (!json) return false;
|
||||||
|
const data = JSON.parse(json);
|
||||||
|
const result = loadState(data);
|
||||||
|
if (result.success) {
|
||||||
|
console.log('[storage] restored state from localStorage');
|
||||||
|
}
|
||||||
|
return result.success;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[storage] failed to load:', e.message);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start auto-saving to localStorage on an interval
|
||||||
|
*/
|
||||||
|
let autoSaveInterval = null;
|
||||||
|
export function startAutoSave(intervalMs = 3000) {
|
||||||
|
if (autoSaveInterval) clearInterval(autoSaveInterval);
|
||||||
|
autoSaveInterval = setInterval(saveToStorage, intervalMs);
|
||||||
|
// Also save on page unload
|
||||||
|
window.addEventListener('beforeunload', saveToStorage);
|
||||||
|
console.log(`[storage] auto-save enabled (every ${intervalMs}ms)`);
|
||||||
|
}
|
||||||
|
|||||||
11
js/state.js
@@ -41,5 +41,14 @@ export const state = {
|
|||||||
// Component Editor
|
// Component Editor
|
||||||
componentEditorActive: false,
|
componentEditorActive: false,
|
||||||
savedMainCircuit: null, // { gates, connections, nextId } saved before entering editor
|
savedMainCircuit: null, // { gates, connections, nextId } saved before entering editor
|
||||||
componentEditorName: ''
|
componentEditorName: '',
|
||||||
|
editingComponentId: null, // ID of component being edited (null = new component)
|
||||||
|
|
||||||
|
// Bus cutting (shift+drag)
|
||||||
|
busCutting: null, // { startX, startY, endX, endY } in world coords, or null
|
||||||
|
|
||||||
|
// Multi-selection
|
||||||
|
selectedGates: [], // array of gate IDs currently selected
|
||||||
|
selectionBox: null, // { startX, startY, endX, endY } in world coords while dragging
|
||||||
|
multiDrag: null // { startX, startY, origins: [{id, x, y}] } while dragging selected gates
|
||||||
};
|
};
|
||||||
|
|||||||
26
js/world/characterLoader.js
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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}`);
|
||||||
|
});
|
||||||