Compare commits
2 Commits
f8aa4e2eab
...
c6f5e19af5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c6f5e19af5 | ||
|
|
b999fe855a |
148
README.md
Normal file
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
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
<body>
|
<body>
|
||||||
<!-- Back to world button (shown in workshop mode) -->
|
<!-- Back to world button (shown in workshop mode) -->
|
||||||
<button id="back-to-world-btn" style="display:none; position:fixed; top:12px; right:12px; z-index:200; padding:6px 14px; background:#00e599; border:none; border-radius:6px; color:#000; font-weight:700; cursor:pointer; font-size:12px;">◀ Back to World</button>
|
<button id="back-to-world-btn" style="display:none; position:fixed; top:12px; right:12px; z-index:200; padding:6px 14px; background:#00e599; border:none; border-radius:6px; color:#000; font-weight:700; cursor:pointer; font-size:12px;">◀ Back to World</button>
|
||||||
|
<button id="save-gadget-btn" style="display:none; position:fixed; top:12px; right:170px; z-index:200; padding:6px 14px; background:#ff44aa; border:none; border-radius:6px; color:#fff; font-weight:700; cursor:pointer; font-size:12px;">🎒 Save as Gadget</button>
|
||||||
|
|
||||||
<div id="toolbar">
|
<div id="toolbar">
|
||||||
<span class="logo">⚡ Logic Lab</span>
|
<span class="logo">⚡ Logic Lab</span>
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
// gameMode.js - Central coordinator: switches between World and Workshop modes
|
// gameMode.js - Central coordinator: switches between World and Workshop modes
|
||||||
import { worldState, setPlayerPosition, warpToMap, isPuzzleSolved } from './worldState.js';
|
import { worldState, setPlayerPosition, warpToMap, isPuzzleSolved, solvePuzzle, startDialog } from './worldState.js';
|
||||||
import { initWorldRenderer, startWorldLoop, stopWorldLoop } from './worldRenderer.js';
|
import { initWorldRenderer, startWorldLoop, stopWorldLoop } from './worldRenderer.js';
|
||||||
import { initWorldInput, destroyWorldInput, setInteractionHandler } from './worldInput.js';
|
import { initWorldInput, destroyWorldInput, setInteractionHandler } from './worldInput.js';
|
||||||
import { getMap } from './maps.js';
|
import { getMap } from './maps.js';
|
||||||
|
import { saveGadget, openBackpack, getGadgets } from './inventory.js';
|
||||||
|
|
||||||
// Circuit editor stop function (to stop its render loop when switching modes)
|
// Circuit editor stop function (to stop its render loop when switching modes)
|
||||||
import { stopCircuitLoop } from '../renderer.js';
|
import { stopCircuitLoop } from '../renderer.js';
|
||||||
|
import { state as circuitState } from '../state.js';
|
||||||
|
|
||||||
// Circuit editor modules (registered from app.js to avoid circular deps)
|
// Circuit editor modules (registered from app.js to avoid circular deps)
|
||||||
let circuitEditorInit = null;
|
let circuitEditorInit = null;
|
||||||
@@ -35,6 +37,12 @@ export function startGame() {
|
|||||||
// Wire up interaction handler
|
// Wire up interaction handler
|
||||||
setInteractionHandler(handleInteraction);
|
setInteractionHandler(handleInteraction);
|
||||||
|
|
||||||
|
// Wire save-gadget button
|
||||||
|
const saveGadgetBtn = document.getElementById('save-gadget-btn');
|
||||||
|
if (saveGadgetBtn) {
|
||||||
|
saveGadgetBtn.addEventListener('click', handleSaveGadget);
|
||||||
|
}
|
||||||
|
|
||||||
// Enter world mode
|
// Enter world mode
|
||||||
enterWorldMode();
|
enterWorldMode();
|
||||||
}
|
}
|
||||||
@@ -94,20 +102,36 @@ function handleInteraction(event) {
|
|||||||
case 'puzzleDoor': {
|
case 'puzzleDoor': {
|
||||||
const inter = event.data;
|
const inter = event.data;
|
||||||
if (isPuzzleSolved(inter.puzzleId)) {
|
if (isPuzzleSolved(inter.puzzleId)) {
|
||||||
// Already solved — could open door, show message, etc.
|
startDialog(['This door is already unlocked.'], 'System');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// For now, show a hint dialog. Later: open puzzle UI
|
// Open backpack to let player choose a gadget
|
||||||
worldState.dialog = {
|
const gadgets = getGadgets();
|
||||||
lines: [
|
if (gadgets.length === 0) {
|
||||||
|
startDialog([
|
||||||
'This door requires a logic circuit to open.',
|
'This door requires a logic circuit to open.',
|
||||||
`Required output pattern: [${inter.requiredOutputs.join(', ')}]`,
|
`Required output: [${inter.requiredOutputs.join(', ')}]`,
|
||||||
'Craft a component in your Workshop (TAB)!'
|
'Craft a circuit in your Workshop (TAB) and save it as a gadget!'
|
||||||
],
|
], 'System');
|
||||||
currentLine: 0,
|
return;
|
||||||
speakerName: 'System'
|
}
|
||||||
};
|
// Open backpack with a "use" callback that tests the gadget
|
||||||
worldState.mode = 'dialog';
|
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;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,6 +151,147 @@ function handleInteraction(event) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== 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) {
|
||||||
|
alert('Your circuit needs at least 1 INPUT and 1 OUTPUT to save as a gadget.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = prompt('Name your gadget:', `Gadget ${getGadgets().length + 1}`);
|
||||||
|
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) {
|
||||||
|
showToast(`🎒 "${name}" saved to backpack!`);
|
||||||
|
} else {
|
||||||
|
alert('Failed to save: ' + result.error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showToast(msg) {
|
||||||
|
// Simple floating toast
|
||||||
|
let toast = document.getElementById('game-toast');
|
||||||
|
if (!toast) {
|
||||||
|
toast = document.createElement('div');
|
||||||
|
toast.id = 'game-toast';
|
||||||
|
toast.style.cssText = 'position:fixed;top:60px;left:50%;transform:translateX(-50%);padding:10px 20px;background:#ff44aa;color:#fff;border-radius:8px;font-weight:700;font-size:13px;z-index:300;opacity:0;transition:opacity 0.3s;pointer-events:none;font-family:system-ui,sans-serif;';
|
||||||
|
document.body.appendChild(toast);
|
||||||
|
}
|
||||||
|
toast.textContent = msg;
|
||||||
|
toast.style.opacity = '1';
|
||||||
|
setTimeout(() => { toast.style.opacity = '0'; }, 2500);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 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 ====================
|
// ==================== UI visibility ====================
|
||||||
|
|
||||||
function showWorldUI() {
|
function showWorldUI() {
|
||||||
@@ -161,9 +326,11 @@ function showWorkshopUI() {
|
|||||||
canvas.style.cursor = 'default';
|
canvas.style.cursor = 'default';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show back-to-world button
|
// Show back-to-world button and save-gadget button
|
||||||
const backBtn = document.getElementById('back-to-world-btn');
|
const backBtn = document.getElementById('back-to-world-btn');
|
||||||
if (backBtn) backBtn.style.display = 'flex';
|
if (backBtn) backBtn.style.display = 'flex';
|
||||||
|
const saveBtn = document.getElementById('save-gadget-btn');
|
||||||
|
if (saveBtn) saveBtn.style.display = 'flex';
|
||||||
}
|
}
|
||||||
|
|
||||||
function hideWorkshopUI() {
|
function hideWorkshopUI() {
|
||||||
@@ -172,4 +339,6 @@ function hideWorkshopUI() {
|
|||||||
|
|
||||||
const backBtn = document.getElementById('back-to-world-btn');
|
const backBtn = document.getElementById('back-to-world-btn');
|
||||||
if (backBtn) backBtn.style.display = 'none';
|
if (backBtn) backBtn.style.display = 'none';
|
||||||
|
const saveBtn = document.getElementById('save-gadget-btn');
|
||||||
|
if (saveBtn) saveBtn.style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|||||||
419
js/world/inventory.js
Normal file
419
js/world/inventory.js
Normal file
@@ -0,0 +1,419 @@
|
|||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ==================== 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();
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
import { worldState, advanceDialog, startDialog } from './worldState.js';
|
import { worldState, advanceDialog, startDialog } from './worldState.js';
|
||||||
import { getMap, getInteraction, getNPC, getExit, isWalkable } from './maps.js';
|
import { getMap, getInteraction, getNPC, getExit, isWalkable } from './maps.js';
|
||||||
import { toggleDebug } from './worldRenderer.js';
|
import { toggleDebug } from './worldRenderer.js';
|
||||||
|
import { isBackpackOpen, openBackpack, handleBackpackInput } from './inventory.js';
|
||||||
|
|
||||||
const keysDown = new Set();
|
const keysDown = new Set();
|
||||||
let interactionHandler = null;
|
let interactionHandler = null;
|
||||||
@@ -25,6 +26,13 @@ function onKeyDown(e) {
|
|||||||
const key = e.key;
|
const key = e.key;
|
||||||
keysDown.add(key);
|
keysDown.add(key);
|
||||||
|
|
||||||
|
// Backpack open — route all input there
|
||||||
|
if (isBackpackOpen()) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleBackpackInput(key);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// During dialog: advance on action keys
|
// During dialog: advance on action keys
|
||||||
if (worldState.dialog) {
|
if (worldState.dialog) {
|
||||||
if (key === 'Enter' || key === ' ' || key === 'e' || key === 'E') {
|
if (key === 'Enter' || key === ' ' || key === 'e' || key === 'E') {
|
||||||
@@ -43,6 +51,13 @@ function onKeyDown(e) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Backpack toggle (I)
|
||||||
|
if (key === 'i' || key === 'I') {
|
||||||
|
e.preventDefault();
|
||||||
|
openBackpack(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Workshop shortcut (TAB)
|
// Workshop shortcut (TAB)
|
||||||
if (key === 'Tab') {
|
if (key === 'Tab') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
import { worldState } from './worldState.js';
|
import { worldState } from './worldState.js';
|
||||||
import { getMap, getInteraction, getNPC, getExit, isWall } from './maps.js';
|
import { getMap, getInteraction, getNPC, getExit, isWall } from './maps.js';
|
||||||
import { updateMovement } from './worldInput.js';
|
import { updateMovement } from './worldInput.js';
|
||||||
|
import { drawBackpack, getGadgets } from './inventory.js';
|
||||||
|
|
||||||
let canvas = null;
|
let canvas = null;
|
||||||
let ctx = null;
|
let ctx = null;
|
||||||
@@ -138,6 +139,11 @@ export function renderWorld(timestamp) {
|
|||||||
|
|
||||||
// === HUD ===
|
// === HUD ===
|
||||||
drawHUD(map);
|
drawHUD(map);
|
||||||
|
|
||||||
|
// === Layer 6: Backpack overlay (on top of everything) ===
|
||||||
|
if (worldState.mode === 'inventory') {
|
||||||
|
drawBackpack(ctx, canvas.width, canvas.height);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawHUD(map) {
|
function drawHUD(map) {
|
||||||
@@ -155,16 +161,17 @@ function drawHUD(map) {
|
|||||||
ctx.textAlign = 'left';
|
ctx.textAlign = 'left';
|
||||||
ctx.fillText(`📍 ${mapName}`, 12, 16);
|
ctx.fillText(`📍 ${mapName}`, 12, 16);
|
||||||
|
|
||||||
// Inventory
|
// Gadgets count
|
||||||
|
const gadgetCount = getGadgets().length;
|
||||||
ctx.fillStyle = '#ff44aa';
|
ctx.fillStyle = '#ff44aa';
|
||||||
ctx.textAlign = 'right';
|
ctx.textAlign = 'right';
|
||||||
ctx.fillText(`🔧 Components: ${worldState.inventory.length}`, canvas.width - 12, 16);
|
ctx.fillText(`🎒 Gadgets: ${gadgetCount}`, canvas.width - 12, 16);
|
||||||
|
|
||||||
// Controls hint
|
// Controls hint
|
||||||
ctx.fillStyle = '#555';
|
ctx.fillStyle = '#555';
|
||||||
ctx.textAlign = 'center';
|
ctx.textAlign = 'center';
|
||||||
ctx.font = '11px "Segoe UI", system-ui, sans-serif';
|
ctx.font = '11px "Segoe UI", system-ui, sans-serif';
|
||||||
ctx.fillText('WASD: Move | E: Interact | TAB: Workshop | F3: Debug', canvas.width / 2, 16);
|
ctx.fillText('WASD: Move | E: Interact | I: Backpack | TAB: Workshop | F3: Debug', canvas.width / 2, 16);
|
||||||
|
|
||||||
// Debug legend
|
// Debug legend
|
||||||
if (debugMode) {
|
if (debugMode) {
|
||||||
|
|||||||
@@ -33,8 +33,11 @@ export const worldState = {
|
|||||||
// Dialog
|
// Dialog
|
||||||
dialog: null, // { lines: [...], currentLine: 0, speakerName: '' } or null
|
dialog: null, // { lines: [...], currentLine: 0, speakerName: '' } or null
|
||||||
|
|
||||||
// Inventory of crafted components
|
// Inventory of crafted components (legacy — component IDs)
|
||||||
inventory: [], // array of component IDs from customComponents (stored in circuit editor)
|
inventory: [],
|
||||||
|
|
||||||
|
// Gadget backpack — saved circuits as reusable items
|
||||||
|
gadgets: [], // array of gadget objects (see inventory.js)
|
||||||
|
|
||||||
// Puzzle state
|
// Puzzle state
|
||||||
solvedPuzzles: [], // array of puzzleIds that have been solved
|
solvedPuzzles: [], // array of puzzleIds that have been solved
|
||||||
@@ -70,6 +73,7 @@ export function resetWorldState() {
|
|||||||
worldState.camera.y = 0;
|
worldState.camera.y = 0;
|
||||||
worldState.dialog = null;
|
worldState.dialog = null;
|
||||||
worldState.inventory = [];
|
worldState.inventory = [];
|
||||||
|
worldState.gadgets = [];
|
||||||
worldState.solvedPuzzles = [];
|
worldState.solvedPuzzles = [];
|
||||||
worldState.activePuzzle = null;
|
worldState.activePuzzle = null;
|
||||||
worldState.flags = {};
|
worldState.flags = {};
|
||||||
|
|||||||
Reference in New Issue
Block a user