Compare commits

..

45 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 17:30:30 +01:00
Jose Luis
f8aa4e2eab fix: make spawn optional — only required for initial map
Spawn can now be deleted in the editor (click same tile with Spawn tool,
use Delete tool, or press Delete key). Interior maps no longer have
spawn objects. The editor shows "None" when no spawn is set, and the
generated maps.js omits the spawn field for maps without one.

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 16:02:44 +01:00
Jose Luis
e4cf35701e feat: add Pokemon-style world mode with workshop integration
Two-mode game: explore a tile-based cyberpunk world, then enter
Workshop mode (the existing circuit editor) to craft components.

New modules (js/world/):
- sprites.js: programmatic pixel-art renderer (16x16 tiles, 3x scale)
- maps.js: tile-based map definitions (lab + town)
- worldState.js: player position, inventory, dialog, puzzle state
- worldRenderer.js: camera-following world renderer on shared canvas
- worldInput.js: WASD movement, E interaction, dialog system
- gameMode.js: central mode switcher (world ↔ workshop)

Changes to existing code:
- app.js: boots into world mode, registers circuit editor for workshop
- renderer.js: circuit draw loop now stoppable (start/stopCircuitLoop)
- index.html: added "Back to World" button for workshop mode

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 15:52:13 +01:00
Jose Luis
bbde11dfc7 fix: BUS_OUT port and cable visual state reading wrong output value
Input port rendering on standard gates and components read srcGate.value
to determine active state, but for multi-output gates (BUS_OUT, COMPONENT)
.value only holds port 0's value. Connections from port N>0 always showed
port 0's state visually despite working correctly at the logic level.

Extract getSourcePortValue() helper that checks outputValues[fromPort]
for multi-output sources, and apply it consistently across all three
input port renderers (drawGate, drawBusGate, drawComponentGate).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 14:10:53 +01:00
Jose Luis
a1cc631406 fix: component port labels showing wrong name after editing blueprint
The label lookup in drawComponentGate read from gate.component (potentially
stale copy) while gateOutputCount read from state.customComponents (updated
definition), causing a mismatch — fewer ports but old outputIds, so the
first (deleted) output's label was shown instead of the surviving one.

Three fixes:
- renderer: use customComponents as authoritative source for label lookup
- saveLoad: re-link gate.component refs to customComponents after loading
- components: update existing instances even when a "new" component
  overwrites an existing definition with the same name

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 14:04:42 +01:00
Jose Luis
c116b6cf84 refactor: bus terminals with single-sided pins only
BUS_IN has input pins only (left side), BUS_OUT has output pins
only (right side). No internal connections between them — BUS_OUT
reads values directly from its paired BUS_IN via busPairId. The
bus cable between them is purely visual, representing the grouped
signal bundle.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 04:50:42 +01:00
Jose Luis
9ec3367253 feat: drag selection box to select, move, and delete multiple gates
Click and drag on empty space to draw a selection rectangle. Gates
inside the box get selected (cyan dashed outline). Drag any selected
gate to move all of them together. Delete/Backspace removes all
selected gates and their connections. Escape clears the selection.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 04:47:34 +01:00
Jose Luis
12d7331d2c refactor: bus now spawns two paired terminals with a bus cable
Instead of a single pass-through gate, shift+drag now creates two
BUS terminals (IN and OUT) connected by a thick bus cable. Internal
connections between terminals are hidden and rendered as a single
cable with /N notation and a diagonal slash. Each terminal is a
thin cyan bar that can be moved independently.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 04:43:30 +01:00
Jose Luis
99f0fefe5c feat: shift+drag to cut wires and create bus connectors
Hold Shift and drag across wires to create a BUS gate that groups
them together. The cut line shows a live preview with wire count.
BUS gates are pass-through (each input maps to its output) and
render as a thin cyan bar with ports on each side.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 04:39:00 +01:00
Jose Luis
89d118f738 feat: keep placing mode active for multiple placements
Selected gate type stays active after placing, allowing multiple
gates of the same type without re-selecting. Right-click or Escape
to cancel placing mode.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 04:28:10 +01:00
Jose Luis
2fd22cc79d feat: persist circuit and components to localStorage
Auto-saves every 3 seconds and on page unload. Restores the full
state (circuit, camera, custom components) on page load.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 04:25:28 +01:00
Jose Luis
5bd157c059 fix: only show port label prompt inside component editor
Double-click rename for INPUT/OUTPUT/CLOCK gates now only triggers
when inside the component editor, not in the main circuit.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 04:24:39 +01:00
Jose Luis
eb22a5ff62 feat: double-click component gates to edit their blueprint
Opens the component editor with the internal circuit loaded for
modification. On save, updates the component definition and all
existing instances in the main circuit.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 04:22:45 +01:00
Jose Luis
817dab43df feat: port labels on component gates + persistent internal state
Show input/output labels next to ports on custom component chips,
and persist internal gate state between evaluations so latches and
flip-flops retain their values correctly.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 04:15:37 +01:00
Jose Luis
1c45dc6104 fix: complete rewrite of component evaluation system
Major fixes for custom components when used in the main circuit:

- Add outputValues[] array for multi-output component gates, so each
  output port carries its own independent value
- readSourcePort() reads the correct port value from source gates
  instead of always reading gate.value
- evaluateComponent() now uses iterative fixed-point evaluation
  (matching main evaluateAll) instead of a simple 10-pass loop
- Store inputIds/outputIds in component definition for consistent
  port-to-gate mapping across save/load
- Renderer reads per-port values for connection color and port glow
- Added debug logs for component save and evaluation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 04:10:28 +01:00
Jose Luis
bc8823bcd4 feat: editable labels for INPUT/OUTPUT/CLOCK gates
Double-click any INPUT, OUTPUT, or CLOCK gate to assign a custom label.
Labels are shown inside the gate and used in waveform viewer instead of
generic IN_0/OUT_0 names. Example circuits now ship with meaningful
labels (S, R, D, EN, Q, Q̅, CLK).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 04:06:50 +01:00
Jose Luis
6cb3f091d4 fix: dropdown menus render above component editor overlay
Lower component editor overlay z-index from 105 to 90 so toolbar
dropdown menus (z-index 150) appear on top of it.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 04:05:24 +01:00
Jose Luis
a4292b42cf fix: iterative evaluation for sequential circuits + debug logs
Replace single-pass recursive evaluation with iterative fixed-point
evaluation that runs multiple passes until all gate values stabilize.
Crucially, gate values are NO LONGER reset to 0 before evaluation,
which preserves latch/flip-flop memory state.

Add console logs for toggle, wire, and evaluation stability debugging.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 03:59:26 +01:00
Jose Luis
2384c489b9 feat: add Examples dropdown with pre-built circuits
Add 4 example circuits accessible from a new toolbar dropdown:
- SR Flip-Flop (NOR) — basic set-reset latch
- SR Flip-Flop (NAND) — active-low variant
- D Latch (1-bit Memory) — gated latch with enable
- D Flip-Flop (Master-Slave) — edge-triggered with CLK

Each example shows name + description in the dropdown and loads
the full circuit with proper gate placement and wiring.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 03:54:04 +01:00
Jose Luis
d78b45841c fix: INPUT toggle no longer disrupts CLK timing in waveform
recordSample() (triggered by user INPUT toggle) now only advances
timeStep when the simulation is stopped. When sim is running, it
records at the current timeStep without advancing it, so the clock's
regular tick cadence is never stolen by manual interactions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 03:19:28 +01:00
Jose Luis
f0f3516efa fix: use requestAnimationFrame + real timestamps for clock simulation
Replace setInterval with requestAnimationFrame loop that tracks elapsed
time via performance.now(). Clock ticks now fire based on real time
rather than assuming perfect interval spacing, so user interactions
(toggling inputs, dragging gates) no longer cause the clock to stutter
or pause.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 03:16:26 +01:00
Jose Luis
2a58ad372e feat: toolbar sections as dropdown menus
Replace horizontal toolbar sections with dropdown buttons (I/O, Gates,
Components). Each opens a dropdown menu on click, keeping the toolbar
clean and compact. Dropdowns close on outside click or after selecting
a gate.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 03:14:43 +01:00
Jose Luis
53d600fcb0 fix: horizontal toolbar layout + fix component button placement
Redesign toolbar sections to use horizontal button rows instead of
vertical stacking. Fix component placement by attaching click handlers
directly to dynamically created buttons and passing correct gate object
shape to getComponentWidth/Height.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 03:06:23 +01:00
Jose Luis
268013d053 feat: sectioned toolbar + custom component editor
- Redesigned toolbar with I/O, Gates, and Components sections
- Component editor: sub-canvas mode to design reusable chips
  - Save/Cancel with main circuit state preservation
  - Components persist in localStorage
- Custom components render as purple chips with dynamic I/O ports
- Component evaluation simulates internal circuit as black box
- Toolbar height increased to 56px for section labels
- All height references updated consistently

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 02:54:04 +01:00
47 changed files with 7637 additions and 441 deletions

View File

@@ -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
View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

BIN
assets/map/lab.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@@ -41,7 +41,7 @@ body {
#toolbar { #toolbar {
position: fixed; position: fixed;
top: 0; left: 0; right: 0; top: 0; left: 0; right: 0;
height: 48px; height: 56px;
background: #12121a; background: #12121a;
border-bottom: 1px solid #2a2a3a; border-bottom: 1px solid #2a2a3a;
display: flex; display: flex;
@@ -78,7 +78,21 @@ body {
.gate-btn.output-btn { border-color: #ff8833; } .gate-btn.output-btn { border-color: #ff8833; }
.gate-btn.output-btn:hover { border-color: #ffaa55; } .gate-btn.output-btn:hover { border-color: #ffaa55; }
.separator { width: 1px; height: 24px; background: #2a2a3a; margin: 0 6px; } .separator { width: 1px; height: 24px; background: #2a2a3a; margin: 0 4px; }
.create-component-btn {
padding: 4px 10px;
background: #1a1a2e;
border: 1px solid #9900ff;
border-radius: 6px;
color: #9900ff;
cursor: pointer;
font-size: 11px;
font-weight: 600;
transition: all 0.15s;
user-select: none;
}
.create-component-btn:hover { background: #252540; border-color: #cc66ff; color: #cc66ff; }
.toolbar-right { margin-left: auto; display: flex; gap: 6px; align-items: center; } .toolbar-right { margin-left: auto; display: flex; gap: 6px; align-items: center; }
.action-btn { .action-btn {
@@ -97,10 +111,157 @@ body {
.action-btn.sim-btn:hover { background: #ff44aa22; } .action-btn.sim-btn:hover { background: #ff44aa22; }
.action-btn.sim-btn.active { background: #ff44aa33; border-color: #ff66cc; color: #ff66cc; } .action-btn.sim-btn.active { background: #ff44aa33; border-color: #ff66cc; color: #ff66cc; }
/* ==================== Toolbar Dropdowns ==================== */
.toolbar-dropdown {
position: relative;
}
.dropdown-toggle {
padding: 6px 14px;
background: #1a1a2e;
border: 1px solid #2a2a3a;
border-radius: 6px;
color: #ccc;
cursor: pointer;
font-size: 12px;
font-weight: 600;
transition: all 0.15s;
user-select: none;
display: flex;
align-items: center;
gap: 6px;
}
.dropdown-toggle:hover {
background: #252540;
border-color: #00e599;
color: #fff;
}
.toolbar-dropdown.open .dropdown-toggle {
background: #252540;
border-color: #00e599;
color: #00e599;
}
.dropdown-arrow {
font-size: 10px;
opacity: 0.6;
transition: transform 0.15s;
}
.toolbar-dropdown.open .dropdown-arrow {
transform: rotate(180deg);
opacity: 1;
}
.dropdown-menu {
display: none;
position: absolute;
top: calc(100% + 6px);
left: 0;
min-width: 140px;
background: #14141e;
border: 1px solid #2a2a3a;
border-radius: 8px;
padding: 4px;
z-index: 150;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
flex-direction: column;
gap: 2px;
}
.toolbar-dropdown.open .dropdown-menu {
display: flex;
}
.dropdown-menu .gate-btn {
width: 100%;
text-align: left;
padding: 7px 12px;
border-radius: 5px;
font-size: 12px;
}
.dropdown-menu .create-component-btn {
width: 100%;
text-align: left;
padding: 7px 12px;
border-radius: 5px;
font-size: 12px;
}
.dropdown-menu #saved-components {
display: flex;
flex-direction: column;
gap: 2px;
}
.dropdown-menu .component-btn {
width: 100%;
text-align: left;
padding: 7px 12px;
background: #1a1a2e;
border: 1px solid #2a2a3a;
border-radius: 5px;
color: #9900ff;
cursor: pointer;
font-size: 12px;
font-weight: 600;
transition: all 0.15s;
}
.dropdown-menu .component-btn:hover {
border-color: #9900ff;
color: #cc66ff;
background: #252540;
}
/* Example buttons */
.example-btn {
width: 100%;
text-align: left;
padding: 8px 12px;
background: transparent;
border: 1px solid transparent;
border-radius: 5px;
color: #ccc;
cursor: pointer;
transition: all 0.15s;
display: flex;
flex-direction: column;
gap: 2px;
}
.example-btn:hover {
background: #1a1a3a;
border-color: #00e599;
}
.example-name {
font-size: 12px;
font-weight: 600;
color: #00e599;
}
.example-btn:hover .example-name {
color: #00ff99;
}
.example-desc {
font-size: 10px;
color: #666;
line-height: 1.3;
}
#examples-menu {
min-width: 260px;
}
/* ==================== Canvas ==================== */ /* ==================== Canvas ==================== */
#canvas { #canvas {
position: fixed; position: fixed;
top: 48px; left: 0; right: 0; bottom: 0; top: 56px; left: 0; right: 0; bottom: 0;
cursor: default; cursor: default;
transition: left 0.2s ease; transition: left 0.2s ease;
} }
@@ -273,14 +434,79 @@ body {
color: #00e599; color: #00e599;
} }
/* ==================== Component Editor Overlay ==================== */
#component-editor-overlay {
position: fixed;
top: 56px;
left: 0;
right: 0;
height: 44px;
background: #1a1a2e;
border-bottom: 2px solid #9900ff;
z-index: 90;
display: flex;
align-items: center;
padding: 0 12px;
gap: 12px;
}
#component-editor-bar {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
}
#component-editor-title {
color: #9900ff;
font-weight: 600;
font-size: 13px;
flex: 1;
}
#component-editor-save, #component-editor-cancel {
padding: 4px 12px;
border-radius: 4px;
border: none;
cursor: pointer;
font-size: 12px;
font-weight: 600;
transition: all 0.15s;
}
#component-editor-save {
background: #00e599;
color: #000;
}
#component-editor-save:hover {
background: #00ff99;
box-shadow: 0 0 10px #00e59944;
}
#component-editor-cancel {
background: transparent;
border: 1px solid #ff4444;
color: #ff4444;
}
#component-editor-cancel:hover {
background: #ff444422;
}
/* Shift canvas when editor is active */
#component-editor-overlay:not([style*="display: none"]) ~ #canvas {
top: calc(56px + 44px);
}
/* ==================== Puzzle Panels ==================== */ /* ==================== Puzzle Panels ==================== */
.puzzle-panel { .puzzle-panel {
display: none; display: none;
position: fixed; position: fixed;
top: 48px; top: 56px;
left: 0; left: 0;
width: 340px; width: 340px;
height: calc(100vh - 48px); height: calc(100vh - 56px);
background: #12121a; background: #12121a;
border-right: 1px solid #2a2a3a; border-right: 1px solid #2a2a3a;
z-index: 95; z-index: 95;
@@ -293,7 +519,7 @@ body {
} }
.puzzle-panel.puzzle-info { .puzzle-panel.puzzle-info {
top: 48px; top: 56px;
width: 340px; width: 340px;
} }

1755
editor.html Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -7,19 +7,51 @@
<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>
<!-- I/O Dropdown -->
<div class="toolbar-dropdown">
<button class="dropdown-toggle">I/O <span class="dropdown-arrow"></span></button>
<div class="dropdown-menu">
<button class="gate-btn input-btn" data-gate="INPUT">INPUT</button> <button class="gate-btn input-btn" data-gate="INPUT">INPUT</button>
<button class="gate-btn clock-btn" data-gate="CLOCK">CLOCK</button> <button class="gate-btn clock-btn" data-gate="CLOCK">CLOCK</button>
<button class="gate-btn output-btn" data-gate="OUTPUT">OUTPUT</button> <button class="gate-btn output-btn" data-gate="OUTPUT">OUTPUT</button>
<div class="separator"></div> </div>
</div>
<!-- Gates Dropdown -->
<div class="toolbar-dropdown">
<button class="dropdown-toggle">Gates <span class="dropdown-arrow"></span></button>
<div class="dropdown-menu">
<button class="gate-btn" data-gate="AND">AND</button> <button class="gate-btn" data-gate="AND">AND</button>
<button class="gate-btn" data-gate="OR">OR</button> <button class="gate-btn" data-gate="OR">OR</button>
<button class="gate-btn" data-gate="NOT">NOT</button> <button class="gate-btn" data-gate="NOT">NOT</button>
<button class="gate-btn" data-gate="NAND">NAND</button> <button class="gate-btn" data-gate="NAND">NAND</button>
<button class="gate-btn" data-gate="NOR">NOR</button> <button class="gate-btn" data-gate="NOR">NOR</button>
<button class="gate-btn" data-gate="XOR">XOR</button> <button class="gate-btn" data-gate="XOR">XOR</button>
<div class="separator"></div> </div>
</div>
<!-- Components Dropdown -->
<div class="toolbar-dropdown" id="components-section">
<button class="dropdown-toggle">Components <span class="dropdown-arrow"></span></button>
<div class="dropdown-menu" id="components-menu">
<button class="create-component-btn" id="create-component-btn" title="Create custom component">✚ Create</button>
<div id="saved-components"></div>
</div>
</div>
<!-- Examples Dropdown -->
<div class="toolbar-dropdown" id="examples-section">
<button class="dropdown-toggle">Examples <span class="dropdown-arrow"></span></button>
<div class="dropdown-menu" id="examples-menu"></div>
</div>
<button class="action-btn sim-btn" id="sim-btn">Waveform</button> <button class="action-btn sim-btn" id="sim-btn">Waveform</button>
<div class="toolbar-right"> <div class="toolbar-right">
<button class="action-btn export-btn" id="export-btn" title="Export circuit">↓ Export</button> <button class="action-btn export-btn" id="export-btn" title="Export circuit">↓ Export</button>
@@ -32,6 +64,15 @@
<!-- Hidden file input for import --> <!-- Hidden file input for import -->
<input type="file" id="import-file" accept=".json" style="display:none"> <input type="file" id="import-file" accept=".json" style="display:none">
<!-- Component Editor Overlay -->
<div id="component-editor-overlay" style="display:none;">
<div id="component-editor-bar">
<span id="component-editor-title">Editing Component: </span>
<button id="component-editor-save">Save</button>
<button id="component-editor-cancel">Cancel</button>
</div>
</div>
<!-- Mode toggle will be inserted here by puzzleUI.js --> <!-- Mode toggle will be inserted here by puzzleUI.js -->
<canvas id="canvas"></canvas> <canvas id="canvas"></canvas>

View File

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

View File

@@ -2,6 +2,12 @@
import { state } from './state.js'; import { state } from './state.js';
import { GATE_W, GATE_H } from './constants.js'; import { GATE_W, GATE_H } from './constants.js';
// Avoid circular imports - resize will be called from events.js
let resizeCallback = null;
export function setResizeCallback(fn) {
resizeCallback = fn;
}
/** /**
* Save current circuit as a reusable component * Save current circuit as a reusable component
* Returns the component ID if successful * Returns the component ID if successful
@@ -12,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))
}; };
@@ -31,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
*/ */
@@ -204,3 +206,143 @@ export function importComponent(data) {
state.customComponents[data.id] = data; state.customComponents[data.id] = data;
return { success: true, component: data }; return { success: true, component: data };
} }
/**
* Enter component editor mode (new component)
*/
export function enterComponentEditor() {
// Save current main circuit
state.savedMainCircuit = {
gates: JSON.parse(JSON.stringify(state.gates)),
connections: JSON.parse(JSON.stringify(state.connections)),
nextId: state.nextId
};
// Clear canvas for sub-circuit design
state.gates = [];
state.connections = [];
state.nextId = 1;
state.componentEditorActive = true;
state.editingComponentId = null; // new component, not editing existing
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: (New)';
// Resize canvas to account for editor bar
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
*/
export function exitComponentEditor(name, shouldSave) {
const overlay = document.getElementById('component-editor-overlay');
overlay.style.display = 'none';
const editingId = state.editingComponentId;
if (shouldSave && name) {
// Save the component (works for both new and edited)
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
if (state.savedMainCircuit) {
state.gates = state.savedMainCircuit.gates;
state.connections = state.savedMainCircuit.connections;
state.nextId = state.savedMainCircuit.nextId;
state.savedMainCircuit = null;
}
state.componentEditorActive = false;
state.editingComponentId = null;
state.placingGate = null;
// Update component buttons to show newly saved component
updateComponentButtons();
// Resize canvas via callback
if (resizeCallback) resizeCallback();
}
/**
* Update component buttons in toolbar
*/
export function updateComponentButtons() {
const container = document.getElementById('saved-components');
container.innerHTML = '';
const components = getAllComponents();
Object.values(components).forEach(comp => {
const btn = document.createElement('button');
btn.className = 'component-btn';
btn.dataset.componentId = comp.id;
btn.textContent = comp.name;
btn.title = `${comp.inputCount} input(s), ${comp.outputCount} output(s)`;
btn.addEventListener('click', (e) => {
e.stopPropagation();
state.placingGate = `COMPONENT:${comp.id}`;
// Close dropdown
document.querySelectorAll('.toolbar-dropdown.open').forEach(d => d.classList.remove('open'));
});
container.appendChild(btn);
});
}

View File

@@ -1,12 +1,14 @@
// Gate dimensions and rendering constants // Gate dimensions and rendering constants
export const GATE_W = 100; export const GATE_W = 100;
export const GATE_H = 60; export const GATE_H = 60;
export const COMP_W = 120;
export const PORT_R = 7; 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 = [
@@ -18,9 +20,18 @@ export const SIGNAL_COLORS = [
export function gateInputCount(type) { export function gateInputCount(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:')) {
// Component types look up their input count from state
return 0; // Will be overridden by lookup in gates.js
}
return 2; return 2;
} }
export function gateOutputCount(type) { export function gateOutputCount(type) {
return type === 'OUTPUT' ? 0 : 1; if (type === 'OUTPUT') return 0;
if (type.startsWith('COMPONENT:')) {
// Component types look up their output count from state
return 0; // Will be overridden by lookup in gates.js
}
return 1;
} }

View File

@@ -1,13 +1,17 @@
// Event handlers — mouse, keyboard, toolbar, waveform controls // Event handlers — mouse, keyboard, toolbar, waveform controls
import { GATE_W, GATE_H } from './constants.js'; import { GATE_W, GATE_H, COMP_W } from './constants.js';
import { state } from './state.js'; import { state } from './state.js';
import { evaluateAll, findGateAt, findPortAt } from './gates.js'; import { evaluateAll, findGateAt, findPortAt, getComponentWidth, getComponentHeight } from './gates.js';
import { manualStep, clearWaveData } from './waveform.js'; import { manualStep, clearWaveData } from './waveform.js';
import { startSim, stopSim, adjustSpeed } from './simulation.js'; import { startSim, stopSim, adjustSpeed } from './simulation.js';
import { resize, screenToWorld } from './renderer.js'; 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, editComponentBlueprint, exitComponentEditor, updateComponentButtons, setResizeCallback } from './components.js';
import { getExampleList, loadExample } from './examples.js';
import { createBusFromCut } from './bus.js';
import { isNamingActive, handleNamingInput } from './world/inventory.js';
const PAN_SPEED = 40; const PAN_SPEED = 40;
@@ -19,6 +23,9 @@ function updateWaveZoomLabel() {
export function initEvents() { export function initEvents() {
const canvas = document.getElementById('canvas'); const canvas = document.getElementById('canvas');
// Set up resize callback for component editor
setResizeCallback(resize);
// ==================== CANVAS MOUSE ==================== // ==================== CANVAS MOUSE ====================
canvas.addEventListener('mousemove', e => { canvas.addEventListener('mousemove', e => {
state.mouseX = e.offsetX; state.mouseX = e.offsetX;
@@ -26,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);
@@ -46,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';
@@ -60,17 +105,45 @@ 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) {
state.gates.push({ let w = GATE_W, h = GATE_H;
if (state.placingGate.startsWith('COMPONENT:')) {
const componentId = state.placingGate.substring(10);
const component = state.customComponents?.[componentId];
if (component) {
const fakeGate = { type: state.placingGate, component };
w = getComponentWidth(fakeGate);
h = getComponentHeight(fakeGate);
}
}
const newGate = {
id: state.nextId++, id: state.nextId++,
type: state.placingGate, type: state.placingGate,
x: world.x - GATE_W / 2, x: world.x - w / 2,
y: world.y - GATE_H / 2, y: world.y - h / 2,
value: 0 value: 0
}); };
if (state.placingGate.startsWith('COMPONENT:')) {
newGate.component = state.customComponents[state.placingGate.substring(10)];
}
state.gates.push(newGate);
evaluateAll(); evaluateAll();
state.placingGate = null; // Keep placingGate active so user can place multiple — right-click to cancel
return; return;
} }
@@ -80,12 +153,18 @@ export function initEvents() {
if (state.connecting) { if (state.connecting) {
if (state.connecting.portType === 'output' && port.type === 'input') { if (state.connecting.portType === 'output' && port.type === 'input') {
state.connections = state.connections.filter(c => !(c.to === port.gate.id && c.toPort === port.index)); state.connections = state.connections.filter(c => !(c.to === port.gate.id && c.toPort === port.index));
state.connections.push({ from: state.connecting.gate.id, fromPort: state.connecting.portIndex, to: port.gate.id, toPort: port.index }); const conn = { from: state.connecting.gate.id, fromPort: state.connecting.portIndex, to: port.gate.id, toPort: port.index };
state.connections.push(conn);
console.log(`[wire] ${conn.from}:${conn.fromPort}${conn.to}:${conn.toPort}`);
evaluateAll(); evaluateAll();
console.log('[state]', state.gates.map(g => `${g.type}#${g.id}=${g.value}`).join(', '));
} else if (state.connecting.portType === 'input' && port.type === 'output') { } else if (state.connecting.portType === 'input' && port.type === 'output') {
state.connections = state.connections.filter(c => !(c.to === state.connecting.gate.id && c.toPort === state.connecting.portIndex)); state.connections = state.connections.filter(c => !(c.to === state.connecting.gate.id && c.toPort === state.connecting.portIndex));
state.connections.push({ from: port.gate.id, fromPort: port.index, to: state.connecting.gate.id, toPort: state.connecting.portIndex }); const conn = { from: port.gate.id, fromPort: port.index, to: state.connecting.gate.id, toPort: state.connecting.portIndex };
state.connections.push(conn);
console.log(`[wire] ${conn.from}:${conn.fromPort}${conn.to}:${conn.toPort}`);
evaluateAll(); evaluateAll();
console.log('[state]', state.gates.map(g => `${g.type}#${g.id}=${g.value}`).join(', '));
} }
state.connecting = null; state.connecting = null;
} else { } else {
@@ -99,27 +178,124 @@ 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;
if (gate.type === 'INPUT' || gate.type === 'CLOCK') { if (gate.type === 'INPUT' || gate.type === 'CLOCK') {
gate.value = gate.value ? 0 : 1; gate.value = gate.value ? 0 : 1;
console.log(`[toggle] ${gate.type}#${gate.id}${gate.value}`);
evaluateAll(true); // record waveform on intentional toggle evaluateAll(true); // record waveform on intentional toggle
console.log('[state]', state.gates.map(g => `${g.type}#${g.id}=${g.value}`).join(', '));
} }
} }
state.dragging = null; state.dragging = null;
dragStartPos = null; dragStartPos = null;
}); });
// Double-click to rename INPUT/OUTPUT/CLOCK gates, or edit component blueprint
canvas.addEventListener('dblclick', e => {
const world = screenToWorld(e.offsetX, e.offsetY);
const gate = findGateAt(world.x, world.y);
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 label = prompt(`Label for ${gate.type}#${gate.id}:`, current);
if (label !== null) {
gate.label = label.trim() || undefined;
console.log(`[label] ${gate.type}#${gate.id} → "${gate.label || ''}"`);
}
}
});
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') {
@@ -150,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);
@@ -166,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
@@ -196,9 +394,32 @@ export function initEvents() {
keysDown.delete(e.key); keysDown.delete(e.key);
}); });
// ==================== TOOLBAR ==================== // ==================== TOOLBAR DROPDOWNS ====================
document.querySelectorAll('.gate-btn').forEach(btn => { function closeAllDropdowns() {
btn.addEventListener('click', () => { document.querySelectorAll('.toolbar-dropdown.open').forEach(d => d.classList.remove('open'));
}
document.querySelectorAll('.dropdown-toggle').forEach(toggle => {
toggle.addEventListener('click', (e) => {
e.stopPropagation();
const dropdown = toggle.parentElement;
const wasOpen = dropdown.classList.contains('open');
closeAllDropdowns();
if (!wasOpen) dropdown.classList.add('open');
});
});
// Close dropdowns when clicking outside
document.addEventListener('click', (e) => {
if (!e.target.closest('.toolbar-dropdown')) {
closeAllDropdowns();
}
});
// Gate buttons inside dropdowns
document.querySelectorAll('.dropdown-menu .gate-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const gateName = btn.dataset.gate; const gateName = btn.dataset.gate;
// In puzzle mode, check if gate is allowed // In puzzle mode, check if gate is allowed
if (puzzleMode && currentLevel) { if (puzzleMode && currentLevel) {
@@ -209,9 +430,38 @@ export function initEvents() {
} }
} }
state.placingGate = gateName; state.placingGate = gateName;
closeAllDropdowns();
}); });
}); });
// ==================== EXAMPLES ====================
const examplesMenu = document.getElementById('examples-menu');
getExampleList().forEach((ex, i) => {
const btn = document.createElement('button');
btn.className = 'example-btn';
btn.innerHTML = `<span class="example-name">${ex.name}</span><span class="example-desc">${ex.description}</span>`;
btn.addEventListener('click', (e) => {
e.stopPropagation();
const hasGates = state.gates.length > 0;
if (hasGates && !confirm('Load example? This will replace your current circuit.')) return;
const data = loadExample(i);
if (data) {
state.gates = data.circuit.gates;
state.connections = data.circuit.connections;
state.nextId = data.circuit.nextId;
if (data.camera) {
state.camX = data.camera.camX;
state.camY = data.camera.camY;
state.zoom = data.camera.zoom;
}
clearWaveData();
evaluateAll();
}
closeAllDropdowns();
});
examplesMenu.appendChild(btn);
});
document.getElementById('clear-btn').addEventListener('click', () => { document.getElementById('clear-btn').addEventListener('click', () => {
if (state.gates.length === 0 || confirm('Clear all gates and connections?')) { if (state.gates.length === 0 || confirm('Clear all gates and connections?')) {
state.gates = []; state.gates = [];
@@ -311,4 +561,31 @@ export function initEvents() {
} }
}); });
document.addEventListener('mouseup', () => { state.resizingWave = false; }); document.addEventListener('mouseup', () => { state.resizingWave = false; });
// ==================== COMPONENT EDITOR ====================
document.getElementById('create-component-btn').addEventListener('click', (e) => {
e.stopPropagation();
closeAllDropdowns();
enterComponentEditor();
});
document.getElementById('component-editor-save').addEventListener('click', () => {
// 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()) {
exitComponentEditor(name.trim(), true);
}
});
document.getElementById('component-editor-cancel').addEventListener('click', () => {
if (confirm('Discard component without saving?')) {
exitComponentEditor('', false);
}
});
// Update component buttons initially
updateComponentButtons();
} }

300
js/examples.js Normal file
View File

@@ -0,0 +1,300 @@
// Pre-built example circuits
// Each example is a { gates, connections, nextId, camera } object
const W = 100; // GATE_W
const H = 60; // GATE_H
const GAP_X = 160;
const GAP_Y = 100;
/**
* SR Flip-Flop using cross-coupled NOR gates
*
* S ──┐
* NOR─── Q
* ┌──┘ │
* │ │
* └──┐ │
* NOR─── Q̅
* R ──┘
*/
function srFlipFlop() {
const gates = [
// Inputs
{ id: 1, type: 'INPUT', x: 50, y: 80, value: 0, label: 'S' },
{ id: 2, type: 'INPUT', x: 50, y: 280, value: 0, label: 'R' },
// Cross-coupled NOR gates
{ id: 3, type: 'NOR', x: 300, y: 80, value: 0 },
{ id: 4, type: 'NOR', x: 300, y: 280, value: 0 },
// Outputs
{ id: 5, type: 'OUTPUT', x: 550, y: 80, value: 0, label: 'Q' },
{ id: 6, type: 'OUTPUT', x: 550, y: 280, value: 0, label: 'Q̅' }
];
const connections = [
// S → Top NOR input 0
{ from: 1, fromPort: 0, to: 3, toPort: 0 },
// R → Bottom NOR input 1
{ from: 2, fromPort: 0, to: 4, toPort: 1 },
// Top NOR (Q) → Bottom NOR input 0 (cross-couple)
{ from: 3, fromPort: 0, to: 4, toPort: 0 },
// Bottom NOR (Q̅) → Top NOR input 1 (cross-couple)
{ from: 4, fromPort: 0, to: 3, toPort: 1 },
// NOR outputs → visible outputs
{ from: 3, fromPort: 0, to: 5, toPort: 0 },
{ from: 4, fromPort: 0, to: 6, toPort: 0 }
];
return {
name: 'SR Flip-Flop (NOR)',
description: 'Set-Reset latch using cross-coupled NOR gates. Toggle S to set Q=1, toggle R to reset Q=0.',
gates,
connections,
nextId: 7,
camera: { camX: 0, camY: 0, zoom: 1 }
};
}
/**
* SR Flip-Flop using cross-coupled NAND gates
*
* S̅ ──┐
* NAND── Q
* ┌───┘ │
* │ │
* └───┐ │
* NAND── Q̅
* R̅ ──┘
*/
function srFlipFlopNand() {
const gates = [
// Inputs (active low for NAND SR)
{ id: 1, type: 'INPUT', x: 50, y: 80, value: 1, label: 'S̅' },
{ id: 2, type: 'INPUT', x: 50, y: 280, value: 1, label: 'R̅' },
// Cross-coupled NAND gates
{ id: 3, type: 'NAND', x: 300, y: 80, value: 0 },
{ id: 4, type: 'NAND', x: 300, y: 280, value: 0 },
// Outputs
{ id: 5, type: 'OUTPUT', x: 550, y: 80, value: 0, label: 'Q' },
{ id: 6, type: 'OUTPUT', x: 550, y: 280, value: 0, label: 'Q̅' }
];
const connections = [
// S̅ → Top NAND input 0
{ from: 1, fromPort: 0, to: 3, toPort: 0 },
// R̅ → Bottom NAND input 1
{ from: 2, fromPort: 0, to: 4, toPort: 1 },
// Top NAND (Q) → Bottom NAND input 0 (cross-couple)
{ from: 3, fromPort: 0, to: 4, toPort: 0 },
// Bottom NAND (Q̅) → Top NAND input 1 (cross-couple)
{ from: 4, fromPort: 0, to: 3, toPort: 1 },
// Outputs
{ from: 3, fromPort: 0, to: 5, toPort: 0 },
{ from: 4, fromPort: 0, to: 6, toPort: 0 }
];
return {
name: 'SR Flip-Flop (NAND)',
description: 'Set-Reset latch using cross-coupled NAND gates. Inputs are active-low: set S̅=0 to set, R̅=0 to reset.',
gates,
connections,
nextId: 7,
camera: { camX: 0, camY: 0, zoom: 1 }
};
}
/**
* Gated D Latch (1-bit memory)
*
* Uses an SR flip-flop with gating logic:
* D ──AND──┐
* E ──┤ NOR── Q
* │ │ ┌──┘│
* │ │ │ │
* │ │ └──┐│
* E ──┤ NOR── Q̅
* D─NOT─AND┘
*
* When Enable=1, Q follows D.
* When Enable=0, Q holds its value.
*/
function dLatch() {
const gates = [
// Inputs
{ id: 1, type: 'INPUT', x: 50, y: 100, value: 0, label: 'D' },
{ id: 2, type: 'INPUT', x: 50, y: 280, value: 0, label: 'EN' },
// NOT gate to invert D
{ id: 3, type: 'NOT', x: 200, y: 340, value: 0 },
// AND gates for gating
{ id: 4, type: 'AND', x: 350, y: 60, value: 0 }, // D AND E → S
{ id: 5, type: 'AND', x: 350, y: 340, value: 0 }, // NOT(D) AND E → R
// Cross-coupled NOR gates (SR latch core)
{ id: 6, type: 'NOR', x: 550, y: 60, value: 0 }, // → Q
{ id: 7, type: 'NOR', x: 550, y: 340, value: 0 }, // → Q̅
// Outputs
{ id: 8, type: 'OUTPUT', x: 750, y: 60, value: 0, label: 'Q' },
{ id: 9, type: 'OUTPUT', x: 750, y: 340, value: 0, label: 'Q̅' }
];
const connections = [
// D → AND top input, and D → NOT
{ from: 1, fromPort: 0, to: 4, toPort: 0 },
{ from: 1, fromPort: 0, to: 3, toPort: 0 },
// E → both AND gates
{ from: 2, fromPort: 0, to: 4, toPort: 1 },
{ from: 2, fromPort: 0, to: 5, toPort: 1 },
// NOT(D) → bottom AND
{ from: 3, fromPort: 0, to: 5, toPort: 0 },
// AND outputs → NOR inputs (S and R)
{ from: 4, fromPort: 0, to: 6, toPort: 0 },
{ from: 5, fromPort: 0, to: 7, toPort: 1 },
// Cross-coupling
{ from: 6, fromPort: 0, to: 7, toPort: 0 },
{ from: 7, fromPort: 0, to: 6, toPort: 1 },
// Outputs
{ from: 6, fromPort: 0, to: 8, toPort: 0 },
{ from: 7, fromPort: 0, to: 9, toPort: 0 }
];
return {
name: 'D Latch (1-bit Memory)',
description: 'Gated D latch — a 1-bit memory cell. When Enable=1, output Q follows input D. When Enable=0, Q holds its last value.',
gates,
connections,
nextId: 10,
camera: { camX: 0, camY: 0, zoom: 1 }
};
}
/**
* D Flip-Flop (edge-triggered, master-slave)
*
* Two D latches in series with inverted enable:
* - Master latch captures D when CLK=0
* - Slave latch outputs when CLK=1
* This creates rising-edge triggered behavior.
*/
function dFlipFlop() {
const gates = [
// Inputs
{ id: 1, type: 'INPUT', x: 30, y: 100, value: 0, label: 'D' },
{ id: 2, type: 'CLOCK', x: 30, y: 350, value: 0, label: 'CLK' },
// CLK inverter (for master latch)
{ id: 3, type: 'NOT', x: 170, y: 350, value: 0 },
// === MASTER LATCH (enabled when CLK=0, i.e. NOT CLK=1) ===
{ id: 4, type: 'NOT', x: 170, y: 200, value: 0 }, // NOT D for master
{ id: 5, type: 'AND', x: 300, y: 60, value: 0 }, // D AND !CLK
{ id: 6, type: 'AND', x: 300, y: 240, value: 0 }, // !D AND !CLK
{ id: 7, type: 'NOR', x: 450, y: 60, value: 0 }, // Master Q
{ id: 8, type: 'NOR', x: 450, y: 240, value: 0 }, // Master Q̅
// === SLAVE LATCH (enabled when CLK=1) ===
{ id: 9, type: 'NOT', x: 570, y: 200, value: 0 }, // NOT master Q for slave
{ id: 10, type: 'AND', x: 680, y: 60, value: 0 }, // masterQ AND CLK
{ id: 11, type: 'AND', x: 680, y: 240, value: 0 }, // !masterQ AND CLK
{ id: 12, type: 'NOR', x: 830, y: 60, value: 0 }, // Slave Q
{ id: 13, type: 'NOR', x: 830, y: 240, value: 0 }, // Slave Q̅
// Outputs
{ id: 14, type: 'OUTPUT', x: 1010, y: 60, value: 0, label: 'Q' },
{ id: 15, type: 'OUTPUT', x: 1010, y: 240, value: 0, label: 'Q̅' }
];
const connections = [
// D → master AND, D → master NOT
{ from: 1, fromPort: 0, to: 5, toPort: 0 },
{ from: 1, fromPort: 0, to: 4, toPort: 0 },
// CLK → NOT (invert for master)
{ from: 2, fromPort: 0, to: 3, toPort: 0 },
// !CLK → master AND gates (enable)
{ from: 3, fromPort: 0, to: 5, toPort: 1 },
{ from: 3, fromPort: 0, to: 6, toPort: 1 },
// !D → master bottom AND
{ from: 4, fromPort: 0, to: 6, toPort: 0 },
// Master AND outputs → Master NOR (SR latch)
{ from: 5, fromPort: 0, to: 7, toPort: 0 },
{ from: 6, fromPort: 0, to: 8, toPort: 1 },
// Master cross-coupling
{ from: 7, fromPort: 0, to: 8, toPort: 0 },
{ from: 8, fromPort: 0, to: 7, toPort: 1 },
// Master Q → slave AND, Master Q → slave NOT
{ from: 7, fromPort: 0, to: 10, toPort: 0 },
{ from: 7, fromPort: 0, to: 9, toPort: 0 },
// CLK → slave AND gates (enable, direct CLK)
{ from: 2, fromPort: 0, to: 10, toPort: 1 },
{ from: 2, fromPort: 0, to: 11, toPort: 1 },
// !masterQ → slave bottom AND
{ from: 9, fromPort: 0, to: 11, toPort: 0 },
// Slave AND outputs → Slave NOR (SR latch)
{ from: 10, fromPort: 0, to: 12, toPort: 0 },
{ from: 11, fromPort: 0, to: 13, toPort: 1 },
// Slave cross-coupling
{ from: 12, fromPort: 0, to: 13, toPort: 0 },
{ from: 13, fromPort: 0, to: 12, toPort: 1 },
// Outputs
{ from: 12, fromPort: 0, to: 14, toPort: 0 },
{ from: 13, fromPort: 0, to: 15, toPort: 0 }
];
return {
name: 'D Flip-Flop (Master-Slave)',
description: 'Edge-triggered D flip-flop built from two D latches. Captures D on the rising edge of CLK. Use with the clock simulation to see it in action.',
gates,
connections,
nextId: 16,
camera: { camX: 0, camY: -50, zoom: 0.9 }
};
}
// Export all examples as a list
export const examples = [
srFlipFlop,
srFlipFlopNand,
dLatch,
dFlipFlop
];
export function getExampleList() {
return examples.map((fn, i) => {
const ex = fn();
return { id: i, name: ex.name, description: ex.description };
});
}
export function loadExample(index) {
if (index < 0 || index >= examples.length) return null;
const ex = examples[index]();
return {
circuit: {
gates: JSON.parse(JSON.stringify(ex.gates)),
connections: JSON.parse(JSON.stringify(ex.connections)),
nextId: ex.nextId
},
camera: ex.camera
};
}

View File

@@ -1,13 +1,71 @@
// Gate evaluation and port geometry // Gate evaluation and port geometry
import { GATE_W, GATE_H, PORT_R, gateInputCount, gateOutputCount } from './constants.js'; import { GATE_W, GATE_H, COMP_W, PORT_R, gateInputCount as baseGateInputCount, gateOutputCount as baseGateOutputCount } from './constants.js';
import { state } from './state.js'; import { state } from './state.js';
import { recordSample, setEvaluateAll } from './waveform.js'; import { recordSample, setEvaluateAll } from './waveform.js';
import { evaluateComponent } from './components.js';
// Wrappers that handle component and BUS types
export function gateInputCount(type) {
if (type.startsWith('COMPONENT:')) {
const componentId = type.substring(10);
const component = state.customComponents?.[componentId];
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);
}
export function gateOutputCount(type) {
if (type.startsWith('COMPONENT:')) {
const componentId = type.substring(10);
const component = state.customComponents?.[componentId];
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);
}
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) {
if (isBusType(gate.type)) return 30;
if (gate.type.startsWith('COMPONENT:')) {
const count = Math.max(gate.component?.inputCount || 1, gate.component?.outputCount || 1);
return Math.max(120, (count + 1) * 25);
}
return GATE_W;
}
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:')) {
const count = Math.max(gate.component?.inputCount || 1, gate.component?.outputCount || 1);
return Math.max(60, (count + 1) * 25);
}
return GATE_H;
}
export function getInputPorts(gate) { export function getInputPorts(gate) {
const count = gateInputCount(gate.type); const count = gateInputCount(gate.type);
const ports = []; const ports = [];
const isDynamic = gate.type.startsWith('COMPONENT:') || isBusType(gate.type);
const gateHeight = isDynamic ? getComponentHeight(gate) : GATE_H;
for (let i = 0; i < count; i++) { for (let i = 0; i < count; i++) {
const spacing = GATE_H / (count + 1); const spacing = gateHeight / (count + 1);
ports.push({ x: gate.x, y: gate.y + spacing * (i + 1), index: i, type: 'input' }); ports.push({ x: gate.x, y: gate.y + spacing * (i + 1), index: i, type: 'input' });
} }
return ports; return ports;
@@ -16,15 +74,35 @@ 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 isDynamic = gate.type.startsWith('COMPONENT:') || isBusType(gate.type);
const gateWidth = isDynamic ? getComponentWidth(gate) : GATE_W;
const gateHeight = isDynamic ? getComponentHeight(gate) : GATE_H;
for (let i = 0; i < count; i++) { for (let i = 0; i < count; i++) {
ports.push({ x: gate.x + GATE_W, y: gate.y + GATE_H / 2, index: i, type: 'output' }); const spacing = gateHeight / (count + 1);
ports.push({ x: gate.x + gateWidth, y: gate.y + spacing * (i + 1), index: i, type: 'output' });
} }
return ports; return ports;
} }
export function evaluate(gate, visited = new Set()) { /**
if (visited.has(gate.id)) return gate.value || 0; * Read the value from a source gate at a specific output port.
visited.add(gate.id); * 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.
* Does NOT recurse — just reads source gate .value directly.
* For COMPONENT gates, evaluates internal circuit and stores all outputs.
*/
function computeGate(gate) {
if (gate.type === 'INPUT' || gate.type === 'CLOCK') return gate.value; if (gate.type === 'INPUT' || gate.type === 'CLOCK') return gate.value;
const inputCount = gateInputCount(gate.type); const inputCount = gateInputCount(gate.type);
@@ -33,39 +111,104 @@ export function evaluate(gate, visited = new Set()) {
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 ? evaluate(srcGate, visited) : 0); inputs.push(srcGate ? readSourcePort(srcGate, conn.fromPort) : 0);
} else { } else {
inputs.push(0); inputs.push(0);
} }
} }
let result = 0; if (gate.type.startsWith('COMPONENT:')) {
switch (gate.type) { const outputs = evaluateComponent(gate, inputs);
case 'AND': result = (inputs[0] && inputs[1]) ? 1 : 0; break; // Store all output values for multi-output components
case 'OR': result = (inputs[0] || inputs[1]) ? 1 : 0; break; gate.outputValues = outputs;
case 'NOT': result = inputs[0] ? 0 : 1; break; return outputs[0] || 0;
case 'NAND': result = (inputs[0] && inputs[1]) ? 0 : 1; break; }
case 'NOR': result = (inputs[0] || inputs[1]) ? 0 : 1; break;
case 'XOR': result = (inputs[0] !== inputs[1]) ? 1 : 0; break; // BUS_IN: collect input values and store them for the paired BUS_OUT
case 'OUTPUT': result = inputs[0] || 0; break; 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) {
case 'AND': return (inputs[0] && inputs[1]) ? 1 : 0;
case 'OR': return (inputs[0] || inputs[1]) ? 1 : 0;
case 'NOT': return inputs[0] ? 0 : 1;
case 'NAND': return (inputs[0] && inputs[1]) ? 0 : 1;
case 'NOR': return (inputs[0] || inputs[1]) ? 0 : 1;
case 'XOR': return (inputs[0] !== inputs[1]) ? 1 : 0;
case 'OUTPUT': return inputs[0] || 0;
default: return 0;
} }
gate.value = result;
return result;
} }
/**
* Iterative fixed-point evaluation.
* Runs multiple passes over all gates until no values change (stable)
* or a max iteration limit is reached. Does NOT reset gate values,
* preserving latch/flip-flop state across evaluations.
*/
const MAX_ITERATIONS = 20;
export function evaluateAll(recordWave = false) { export function evaluateAll(recordWave = false) {
state.gates.forEach(g => { for (let iter = 0; iter < MAX_ITERATIONS; iter++) {
if (g.type !== 'INPUT' && g.type !== 'CLOCK') g.value = 0; let changed = false;
}); for (const gate of state.gates) {
state.gates.forEach(g => evaluate(g)); if (gate.type === 'INPUT' || gate.type === 'CLOCK') continue;
const oldVal = gate.value;
const oldOutputs = gate.outputValues ? [...gate.outputValues] : null;
const newVal = computeGate(gate);
if (newVal !== oldVal) {
gate.value = newVal;
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 (iter > 0) console.log(`[eval] stable after ${iter + 1} iteration(s)`);
break;
}
if (iter === MAX_ITERATIONS - 1) {
console.warn(`[eval] did not stabilize after ${MAX_ITERATIONS} iterations (oscillation?)`);
}
}
if (recordWave && state.recording && state.waveformVisible) recordSample(); if (recordWave && state.recording && state.waveformVisible) recordSample();
} }
// Keep legacy export name for components.js internal use
export function evaluate(gate) {
return computeGate(gate);
}
// Register evaluateAll in waveform to break circular dependency // Register evaluateAll in waveform to break circular dependency
setEvaluateAll(evaluateAll); setEvaluateAll(evaluateAll);
export function findGateAt(x, y) { export function findGateAt(x, y) {
return state.gates.find(g => x >= g.x && x <= g.x + GATE_W && y >= g.y && y <= g.y + GATE_H); return state.gates.find(g => {
const isDynamic = g.type.startsWith('COMPONENT:') || isBusType(g.type);
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;
});
} }
export function findPortAt(x, y) { export function findPortAt(x, y) {

View File

@@ -1,17 +1,50 @@
// Canvas rendering — gates, connections, grid // Canvas rendering — gates, connections, grid
import { GATE_W, GATE_H, PORT_R, GATE_COLORS } from './constants.js'; 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 } 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() {
@@ -19,7 +52,8 @@ export function resize() {
const sidebarW = sidebarOpen ? 340 : 0; const sidebarW = sidebarOpen ? 340 : 0;
canvas.width = window.innerWidth - sidebarW; canvas.width = window.innerWidth - sidebarW;
const waveH = state.waveformVisible ? state.waveformHeight : 0; const waveH = state.waveformVisible ? state.waveformHeight : 0;
canvas.height = window.innerHeight - 48 - waveH; const editorH = state.componentEditorActive ? 44 : 0;
canvas.height = window.innerHeight - 56 - editorH - waveH;
} }
// Convert screen coords to world coords (accounting for pan/zoom) // Convert screen coords to world coords (accounting for pan/zoom)
@@ -30,7 +64,30 @@ 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) {
// Special gate types have different rendering
if (isBusType(gate.type)) { drawBusGate(gate); drawSelectionHighlight(gate); return; }
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;
const isActive = gate.value === 1; const isActive = gate.value === 1;
@@ -57,8 +114,18 @@ function drawGate(gate) {
ctx.textBaseline = 'middle'; ctx.textBaseline = 'middle';
const isIOType = gate.type === 'INPUT' || gate.type === 'OUTPUT' || gate.type === 'CLOCK'; const isIOType = gate.type === 'INPUT' || gate.type === 'OUTPUT' || gate.type === 'CLOCK';
// Show custom label above the gate if it has one
if (gate.label && isIOType) {
ctx.font = 'bold 10px "Segoe UI", system-ui, sans-serif';
ctx.fillStyle = '#888';
ctx.fillText(gate.label, gate.x + GATE_W / 2, gate.y - 8);
}
ctx.font = `bold 14px "Segoe UI", system-ui, sans-serif`;
ctx.fillStyle = isActive ? '#fff' : color;
ctx.fillText( ctx.fillText(
gate.type === 'CLOCK' ? '⏱ CLK' : gate.type, gate.label && isIOType ? gate.label : (gate.type === 'CLOCK' ? '⏱ CLK' : gate.type),
gate.x + GATE_W / 2, gate.x + GATE_W / 2,
gate.y + GATE_H / 2 - (isIOType ? 8 : 0) gate.y + GATE_H / 2 - (isIOType ? 8 : 0)
); );
@@ -82,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);
@@ -99,15 +166,269 @@ 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) {
const isHovered = state.hoveredGate === gate;
const isActive = gate.value === 1;
const w = getComponentWidth(gate);
const h = getComponentHeight(gate);
const color = '#9900ff';
if (isActive) {
ctx.shadowColor = color;
ctx.shadowBlur = 20 * state.zoom;
}
ctx.fillStyle = isActive ? 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, 8);
ctx.fill();
ctx.stroke();
ctx.shadowBlur = 0;
// Component name label
ctx.fillStyle = isActive ? '#fff' : color;
ctx.font = `bold 12px "Segoe UI", system-ui, sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
const componentName = gate.component?.name || 'Component';
ctx.fillText(componentName, gate.x + w / 2, gate.y + h / 2);
// Small ID label
ctx.font = '9px monospace';
ctx.fillStyle = '#444';
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
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, 0, Math.PI * 2);
ctx.fillStyle = isPortHovered ? '#fff' : (portActive ? '#00ff88' : '#1a1a2e');
ctx.fill();
ctx.strokeStyle = isPortHovered ? '#fff' : '#555';
ctx.lineWidth = 1.5;
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
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) : gate.value;
ctx.beginPath();
ctx.arc(p.x, p.y, PORT_R, 0, Math.PI * 2);
ctx.fillStyle = isPortHovered ? '#fff' : (portVal ? '#00ff88' : '#1a1a2e');
ctx.fill();
ctx.strokeStyle = isPortHovered ? '#fff' : '#555';
ctx.lineWidth = 1.5;
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);
}
});
} }
function drawConnection(conn) { function drawConnection(conn) {
@@ -119,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();
@@ -177,16 +501,126 @@ 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;
const world = screenToWorld(state.mouseX, state.mouseY); const world = screenToWorld(state.mouseX, state.mouseY);
let w = GATE_W, h = GATE_H;
if (state.placingGate.startsWith('COMPONENT:')) {
const componentId = state.placingGate.substring(10);
const component = state.customComponents?.[componentId];
if (component) {
const count = Math.max(component.inputCount, component.outputCount);
w = 120;
h = Math.max(60, (count + 1) * 25);
}
}
const ghost = { const ghost = {
x: world.x - GATE_W / 2, x: world.x - w / 2,
y: world.y - GATE_H / 2, y: world.y - h / 2,
type: state.placingGate, type: state.placingGate,
value: 0, value: 0,
id: -1 id: -1,
component: state.customComponents?.[state.placingGate.substring(10)]
}; };
drawGate(ghost); drawGate(ghost);
ctx.globalAlpha = 1; ctx.globalAlpha = 1;
@@ -213,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();
@@ -227,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);
} }

View File

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

View File

@@ -1,9 +1,18 @@
// Clock simulation engine // Clock simulation engine — uses real timestamps for consistent timing
import { state } from './state.js'; import { state } from './state.js';
import { evaluateAll } from './gates.js'; import { evaluateAll } from './gates.js';
import { forceRecordSample } from './waveform.js'; import { forceRecordSample } from './waveform.js';
export function simTick() { let lastTickTime = 0;
let animFrameId = null;
function simLoop(now) {
if (!state.simRunning) return;
// Fire ticks that are due based on real elapsed time
while (now - lastTickTime >= state.simSpeed) {
lastTickTime += state.simSpeed;
// Toggle all CLOCK gates // Toggle all CLOCK gates
state.gates.forEach(g => { state.gates.forEach(g => {
if (g.type === 'CLOCK') { if (g.type === 'CLOCK') {
@@ -11,10 +20,12 @@ export function simTick() {
} }
}); });
evaluateAll(); evaluateAll();
// Force record even if evaluateAll didn't detect change
if (state.recording && state.waveformVisible) { if (state.recording && state.waveformVisible) {
forceRecordSample(); forceRecordSample();
} }
}
animFrameId = requestAnimationFrame(simLoop);
} }
export function startSim() { export function startSim() {
@@ -29,18 +40,18 @@ export function startSim() {
state.waveformVisible = true; state.waveformVisible = true;
document.getElementById('waveform-panel').classList.add('visible'); document.getElementById('waveform-panel').classList.add('visible');
document.getElementById('sim-btn').classList.add('active'); document.getElementById('sim-btn').classList.add('active');
// Trigger resize via event so renderer picks it up
window.dispatchEvent(new Event('resize')); window.dispatchEvent(new Event('resize'));
} }
state.simInterval = setInterval(simTick, state.simSpeed); lastTickTime = performance.now();
animFrameId = requestAnimationFrame(simLoop);
updateSimUI(); updateSimUI();
} }
export function stopSim() { export function stopSim() {
state.simRunning = false; state.simRunning = false;
if (state.simInterval) clearInterval(state.simInterval); if (animFrameId) cancelAnimationFrame(animFrameId);
state.simInterval = null; animFrameId = null;
updateSimUI(); updateSimUI();
} }
@@ -57,10 +68,8 @@ export function updateSimUI() {
} }
export function adjustSpeed(delta) { export function adjustSpeed(delta) {
const wasRunning = state.simRunning;
state.simSpeed = Math.max(50, Math.min(2000, state.simSpeed + delta)); state.simSpeed = Math.max(50, Math.min(2000, state.simSpeed + delta));
if (state.simRunning) { // No need to restart — the loop reads simSpeed dynamically
clearInterval(state.simInterval);
state.simInterval = setInterval(simTick, state.simSpeed);
}
updateSimUI(); updateSimUI();
} }

View File

@@ -36,5 +36,19 @@ export const state = {
simSpeed: 500, // ms per tick simSpeed: 500, // ms per tick
// Puzzle/Components // Puzzle/Components
customComponents: {} // { id -> component definition } customComponents: {}, // { id -> component definition }
// Component Editor
componentEditorActive: false,
savedMainCircuit: null, // { gates, connections, nextId } saved before entering editor
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
}; };

View File

@@ -12,6 +12,7 @@ export function getTrackedGates() {
} }
export function getGateLabel(gate) { export function getGateLabel(gate) {
if (gate.label) return gate.label;
const sameType = state.gates.filter(g => g.type === gate.type); const sameType = state.gates.filter(g => g.type === gate.type);
const idx = sameType.indexOf(gate); const idx = sameType.indexOf(gate);
if (gate.type === 'CLOCK') return `CLK_${idx}`; if (gate.type === 'CLOCK') return `CLK_${idx}`;
@@ -20,6 +21,11 @@ export function getGateLabel(gate) {
return `${gate.type}_${idx}`; return `${gate.type}_${idx}`;
} }
/**
* Record a sample triggered by user interaction (INPUT toggle).
* When sim is running, records at current timeStep WITHOUT advancing it.
* When sim is stopped, advances timeStep first.
*/
export function recordSample() { export function recordSample() {
const { gates, waveData } = state; const { gates, waveData } = state;
@@ -31,8 +37,11 @@ export function recordSample() {
if (!changed && state.timeStep > 0) return; if (!changed && state.timeStep > 0) return;
// Manual toggles advance by simSpeed too for consistency // Only advance time if sim is NOT running (manual mode)
if (!state.simRunning) {
state.timeStep += state.simSpeed; state.timeStep += state.simSpeed;
}
gates.forEach(g => { gates.forEach(g => {
if (!waveData[g.id]) waveData[g.id] = []; if (!waveData[g.id]) waveData[g.id] = [];
const arr = waveData[g.id]; const arr = waveData[g.id];
@@ -43,8 +52,11 @@ export function recordSample() {
updateWaveInfo(); updateWaveInfo();
} }
/**
* Record a sample from the simulation tick.
* Always advances timeStep — this is the ONLY source of time when sim is running.
*/
export function forceRecordSample() { export function forceRecordSample() {
// Advance time by the current simSpeed (in ms) to reflect real time
state.timeStep += state.simSpeed; state.timeStep += state.simSpeed;
state.gates.forEach(g => { state.gates.forEach(g => {
if (!state.waveData[g.id]) state.waveData[g.id] = []; if (!state.waveData[g.id]) state.waveData[g.id] = [];

View File

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

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

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

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

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

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

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

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

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

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

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

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

@@ -0,0 +1,232 @@
// worldInput.js - Keyboard input for world mode
import { worldState, advanceDialog, startDialog } from './worldState.js';
import { getMap, getInteraction, getNPC, getExit, isWalkable } from './maps.js';
import { toggleDebug } from './worldRenderer.js';
import { isBackpackOpen, openBackpack, handleBackpackInput, isNamingActive, handleNamingInput } from './inventory.js';
import { isWiringOpen, handleWiringInput } from './wiringPanel.js';
const keysDown = new Set();
let interactionHandler = null;
export function setInteractionHandler(fn) { interactionHandler = fn; }
export function initWorldInput() {
document.addEventListener('keydown', onKeyDown);
document.addEventListener('keyup', onKeyUp);
}
export function destroyWorldInput() {
document.removeEventListener('keydown', onKeyDown);
document.removeEventListener('keyup', onKeyUp);
keysDown.clear();
}
// ---- Key handlers ----
function onKeyDown(e) {
const key = e.key;
keysDown.add(key);
// Naming screen — route all input there
if (isNamingActive()) {
e.preventDefault();
handleNamingInput(key);
return;
}
// Wiring panel — route all input there
if (isWiringOpen()) {
e.preventDefault();
handleWiringInput(key);
return;
}
// Backpack open — route all input there
if (isBackpackOpen()) {
e.preventDefault();
handleBackpackInput(key);
return;
}
// During dialog: advance on action keys
if (worldState.dialog) {
if (key === 'Enter' || key === ' ' || key === 'e' || key === 'E') {
e.preventDefault();
if (!advanceDialog()) {
// Dialog ended
}
}
return;
}
// Debug overlay toggle (F3)
if (key === 'F3') {
e.preventDefault();
toggleDebug();
return;
}
// Backpack toggle (I)
if (key === 'i' || key === 'I') {
e.preventDefault();
openBackpack(null);
return;
}
// Workshop shortcut (TAB)
if (key === 'Tab') {
e.preventDefault();
if (interactionHandler) interactionHandler({ type: 'enterWorkshop' });
return;
}
// Interaction (E / Enter / Space)
if (key === 'e' || key === 'E' || key === 'Enter' || key === ' ') {
e.preventDefault();
performInteraction();
return;
}
// Movement (handled in updateMovement via keysDown)
const dir = keyToDir(key);
if (dir) e.preventDefault();
}
function onKeyUp(e) {
keysDown.delete(e.key);
}
// ---- Direction mapping ----
function keyToDir(key) {
if (key === 'ArrowUp' || key === 'w' || key === 'W') return 'up';
if (key === 'ArrowDown' || key === 's' || key === 'S') return 'down';
if (key === 'ArrowLeft' || key === 'a' || key === 'A') return 'left';
if (key === 'ArrowRight' || key === 'd' || key === 'D') return 'right';
return null;
}
/** Get the currently pressed direction (prioritizes most recent) */
function getHeldDirection() {
// Check in order of specificity
for (const key of keysDown) {
const dir = keyToDir(key);
if (dir) return dir;
}
return null;
}
// ---- Movement ----
const MOVE_DURATION = 0.15; // seconds per tile
/**
* Called each frame by the renderer.
* Handles movement interpolation and starting new moves.
*/
export function updateMovement(dt) {
const p = worldState.player;
if (p.moving) {
// Advance interpolation
p._moveProgress = (p._moveProgress || 0) + dt / MOVE_DURATION;
if (p._moveProgress >= 1) {
// Snap to target
p.x = p._targetX;
p.y = p._targetY;
p.px = 0;
p.py = 0;
p.moving = false;
p._moveProgress = 0;
// Check map exit
checkMapExit();
// Continue moving if key held
const dir = getHeldDirection();
if (dir) tryMove(dir);
} else {
// Interpolate
p.px = (p._targetX - p._startX) * p._moveProgress;
p.py = (p._targetY - p._startY) * p._moveProgress;
}
} else {
// Not moving — check if direction key is held
const dir = getHeldDirection();
if (dir) tryMove(dir);
}
}
function tryMove(direction) {
const p = worldState.player;
p.direction = direction;
let tx = p.x, ty = p.y;
if (direction === 'up') ty--;
else if (direction === 'down') ty++;
else if (direction === 'left') tx--;
else if (direction === 'right') tx++;
if (!isWalkable(worldState.currentMap, tx, ty)) return;
// Start movement
p._startX = p.x;
p._startY = p.y;
p._targetX = tx;
p._targetY = ty;
p._moveProgress = 0;
p.moving = true;
}
// ---- Interaction ----
function performInteraction() {
if (worldState.player.moving) return;
const p = worldState.player;
let fx = p.x, fy = p.y;
if (p.direction === 'up') fy--;
else if (p.direction === 'down') fy++;
else if (p.direction === 'left') fx--;
else if (p.direction === 'right') fx++;
// NPC?
const npc = getNPC(worldState.currentMap, fx, fy);
if (npc && npc.dialog) {
startDialog(npc.dialog, npc.id);
return;
}
// Interaction tile?
const inter = getInteraction(worldState.currentMap, fx, fy);
if (!inter) return;
switch (inter.type) {
case 'workshop':
if (interactionHandler) interactionHandler({ type: 'enterWorkshop', data: inter });
break;
case 'puzzle_door':
if (interactionHandler) interactionHandler({ type: 'puzzleDoor', data: inter });
break;
case 'module':
if (interactionHandler) interactionHandler({ type: 'module', data: inter });
break;
default:
if (inter.dialog) startDialog(inter.dialog, '');
break;
}
}
// ---- Map transitions ----
function checkMapExit() {
const p = worldState.player;
const exit = getExit(worldState.currentMap, p.x, p.y);
if (exit && interactionHandler) {
interactionHandler({
type: 'mapExit',
data: { targetMap: exit.targetMap, targetX: exit.targetX, targetY: exit.targetY }
});
}
}

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

@@ -0,0 +1,308 @@
// worldRenderer.js - Renders PNG-based game world on canvas
import {
drawMapImage, drawPlayer, drawNPC, drawInteractionPrompt,
drawDialogBox, preloadAssets, TILE_PX, SCALE
} from './sprites.js';
import { worldState } from './worldState.js';
import { getMap, getInteraction, getNPC, getExit, isWall } from './maps.js';
import { updateMovement } from './worldInput.js';
import { drawBackpack, getGadgets, drawNamingScreen, drawNotification } from './inventory.js';
import { isWiringOpen, drawWiringPanel } from './wiringPanel.js';
let canvas = null;
let ctx = null;
let animFrameId = null;
let lastTime = 0;
let debugMode = false;
export function toggleDebug() {
debugMode = !debugMode;
console.log(`[debug] collision overlay ${debugMode ? 'ON' : 'OFF'}`);
return debugMode;
}
export function initWorldRenderer() {
canvas = document.getElementById('canvas');
ctx = canvas.getContext('2d');
resizeCanvas();
window.addEventListener('resize', resizeCanvas);
}
function resizeCanvas() {
if (!canvas) return;
// Always use full window size in world mode — don't rely on offsetWidth
// because CSS layout may not have recomputed yet on initial load
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
}
// ==================== Camera ====================
/** Get the pixel offset to draw the map so the player is centered */
function getCameraOffset() {
const p = worldState.player;
const playerWorldX = (p.x + p.px) * TILE_PX;
const playerWorldY = (p.y + p.py) * TILE_PX;
return {
x: canvas.width / 2 - playerWorldX - TILE_PX / 2,
y: canvas.height / 2 - playerWorldY - TILE_PX / 2
};
}
/** Convert tile position to screen position */
function tileToScreen(tileX, tileY) {
const cam = getCameraOffset();
return {
x: tileX * TILE_PX + cam.x,
y: tileY * TILE_PX + cam.y
};
}
// ==================== Facing tile ====================
function getFacingTile() {
const p = worldState.player;
let x = p.x, y = p.y;
if (p.direction === 'up') y--;
else if (p.direction === 'down') y++;
else if (p.direction === 'left') x--;
else if (p.direction === 'right') x++;
return { x, y };
}
// ==================== Main render ====================
export function renderWorld(timestamp) {
const dt = (timestamp - lastTime) / 1000;
lastTime = timestamp;
// Update movement
updateMovement(dt);
// Resize check
if (canvas.width !== window.innerWidth || canvas.height !== window.innerHeight) {
resizeCanvas();
}
// Clear
ctx.fillStyle = '#0a0a0f';
ctx.fillRect(0, 0, canvas.width, canvas.height);
const map = getMap(worldState.currentMap);
if (!map) return;
const cam = getCameraOffset();
// === Layer 1: Map background (PNG) ===
drawMapImage(ctx, map.image, cam.x, cam.y);
// === Debug overlay (between map and entities) ===
if (debugMode) drawDebugOverlay(ctx, map, cam);
// === Layer 2: NPCs ===
if (map.npcs) {
for (const npc of map.npcs) {
const pos = tileToScreen(npc.x, npc.y);
drawNPC(ctx, pos.x, pos.y, npc.facing || 'down', npc.charId || null);
}
}
// === Layer 3: Player ===
const playerScreen = tileToScreen(
worldState.player.x + worldState.player.px,
worldState.player.y + worldState.player.py
);
const playerDrawX = playerScreen.x;
const playerDrawY = playerScreen.y;
const walkFrame = worldState.player.moving
? (Math.floor(Date.now() / 150) % 2) + 1 // alternates 1, 2
: 0;
drawPlayer(ctx, playerDrawX, playerDrawY, worldState.player.direction, walkFrame);
// === Layer 4: Interaction prompt ===
if (!worldState.dialog && !worldState.player.moving) {
const ft = getFacingTile();
const inter = getInteraction(worldState.currentMap, ft.x, ft.y);
const npc = getNPC(worldState.currentMap, ft.x, ft.y);
if (inter || npc) {
const pos = tileToScreen(ft.x, ft.y);
drawInteractionPrompt(ctx, pos.x, pos.y);
}
}
// === Layer 5: Dialog ===
if (worldState.dialog) {
const line = worldState.dialog.lines[worldState.dialog.currentLine] || '';
const speaker = worldState.dialog.speakerName || '';
drawDialogBox(ctx, canvas.width, canvas.height, line, speaker);
}
// === HUD ===
drawHUD(map);
// === Layer 6: Backpack overlay (on top of everything) ===
if (worldState.mode === 'inventory') {
drawBackpack(ctx, canvas.width, canvas.height);
}
// === Layer 7: Wiring panel overlay ===
if (isWiringOpen()) {
drawWiringPanel(ctx, canvas.width, canvas.height);
}
// === Layer 8: Naming screen (on top of everything) ===
drawNamingScreen(ctx, canvas.width, canvas.height);
// === Layer 8: Notification toast ===
drawNotification(ctx, canvas.width);
}
function drawHUD(map) {
const mapName = map ? map.name : worldState.currentMap;
// Background bar
ctx.fillStyle = 'rgba(10, 10, 15, 0.75)';
ctx.fillRect(0, 0, canvas.width, 32);
ctx.font = 'bold 13px "Segoe UI", system-ui, sans-serif';
ctx.textBaseline = 'middle';
// Map name
ctx.fillStyle = '#00e599';
ctx.textAlign = 'left';
ctx.fillText(`📍 ${mapName}`, 12, 16);
// Gadgets count
const gadgetCount = getGadgets().length;
ctx.fillStyle = '#ff44aa';
ctx.textAlign = 'right';
ctx.fillText(`🎒 Gadgets: ${gadgetCount}`, canvas.width - 12, 16);
// Controls hint
ctx.fillStyle = '#555';
ctx.textAlign = 'center';
ctx.font = '11px "Segoe UI", system-ui, sans-serif';
ctx.fillText('WASD: Move | E: Interact | I: Backpack | TAB: Workshop | F3: Debug', canvas.width / 2, 16);
// Debug legend
if (debugMode) {
const legendY = 40;
ctx.font = '11px monospace';
ctx.textAlign = 'left';
ctx.textBaseline = 'top';
const items = [
['rgba(255, 50, 50, 0.6)', 'Wall'],
['rgba(50, 255, 50, 0.6)', 'Exit'],
['rgba(255, 255, 0, 0.6)', 'Interaction'],
['rgba(200, 50, 255, 0.6)', 'NPC'],
['#00e599', 'Player tile']
];
let lx = 12;
for (const [color, label] of items) {
ctx.fillStyle = color;
ctx.fillRect(lx, legendY, 12, 12);
ctx.strokeStyle = '#fff';
ctx.lineWidth = 0.5;
ctx.strokeRect(lx, legendY, 12, 12);
ctx.fillStyle = '#ccc';
ctx.fillText(label, lx + 16, legendY + 1);
lx += ctx.measureText(label).width + 28;
}
// Player coords
const p = worldState.player;
ctx.fillStyle = '#00e599';
ctx.fillText(`Pos: (${p.x}, ${p.y}) Map: ${worldState.currentMap}`, 12, legendY + 18);
}
}
// ==================== Debug overlay ====================
function drawDebugOverlay(ctx, map, cam) {
const mapId = worldState.currentMap;
const w = map.widthTiles;
const h = map.heightTiles;
ctx.save();
for (let ty = 0; ty < h; ty++) {
for (let tx = 0; tx < w; tx++) {
const sx = tx * TILE_PX + cam.x;
const sy = ty * TILE_PX + cam.y;
// Skip tiles entirely off-screen
if (sx + TILE_PX < 0 || sx > canvas.width || sy + TILE_PX < 0 || sy > canvas.height) continue;
const wall = isWall(mapId, tx, ty);
const exit = getExit(mapId, tx, ty);
const inter = getInteraction(mapId, tx, ty);
const npc = getNPC(mapId, tx, ty);
// Wall = red, Exit = green, Interaction = yellow, NPC = purple, walkable = no fill
if (wall) {
ctx.fillStyle = 'rgba(255, 50, 50, 0.35)';
ctx.fillRect(sx, sy, TILE_PX, TILE_PX);
} else if (exit) {
ctx.fillStyle = 'rgba(50, 255, 50, 0.4)';
ctx.fillRect(sx, sy, TILE_PX, TILE_PX);
}
if (inter) {
ctx.fillStyle = 'rgba(255, 255, 0, 0.35)';
ctx.fillRect(sx, sy, TILE_PX, TILE_PX);
}
if (npc) {
ctx.fillStyle = 'rgba(200, 50, 255, 0.4)';
ctx.fillRect(sx, sy, TILE_PX, TILE_PX);
}
// Grid lines
ctx.strokeStyle = 'rgba(255, 255, 255, 0.12)';
ctx.lineWidth = 0.5;
ctx.strokeRect(sx, sy, TILE_PX, TILE_PX);
// Coordinate labels (only near player to avoid clutter)
const p = worldState.player;
if (Math.abs(tx - p.x) <= 6 && Math.abs(ty - p.y) <= 5) {
ctx.fillStyle = 'rgba(255, 255, 255, 0.5)';
ctx.font = '9px monospace';
ctx.textAlign = 'left';
ctx.textBaseline = 'top';
ctx.fillText(`${tx},${ty}`, sx + 2, sy + 1);
}
}
}
// Player tile highlight
const px = worldState.player.x * TILE_PX + cam.x;
const py = worldState.player.y * TILE_PX + cam.y;
ctx.strokeStyle = '#00e599';
ctx.lineWidth = 2;
ctx.strokeRect(px, py, TILE_PX, TILE_PX);
ctx.restore();
}
// ==================== Loop control ====================
export async function startWorldLoop() {
// Ensure assets are loaded before starting
await preloadAssets();
lastTime = performance.now();
function loop(ts) {
renderWorld(ts);
animFrameId = requestAnimationFrame(loop);
}
animFrameId = requestAnimationFrame(loop);
}
export function stopWorldLoop() {
if (animFrameId !== null) {
cancelAnimationFrame(animFrameId);
animFrameId = null;
}
}

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

@@ -0,0 +1,311 @@
/**
* worldState.js - World game state management
*
* Tracks player position, current map, dialog, inventory, puzzles, and other game state
*/
// Default/initial world state
export const worldState = {
// Current mode
mode: 'world', // 'world' | 'workshop' | 'dialog' | 'puzzle'
// Player
player: {
x: 4,
y: 10, // tile position in current map
px: 0,
py: 0, // pixel offset for smooth movement (interpolation)
direction: 'down', // 'up' | 'down' | 'left' | 'right'
moving: false,
frame: 0, // animation frame (0-3 for walking cycles)
speed: 150 // milliseconds per tile movement
},
// Map
currentMap: 'lab',
// Camera
camera: {
x: 0,
y: 0
},
// Dialog
dialog: null, // { lines: [...], currentLine: 0, speakerName: '' } or null
// Inventory of crafted components (legacy — component IDs)
inventory: [],
// Gadget backpack — saved circuits as reusable items
gadgets: [], // array of gadget objects (see inventory.js)
// Puzzle state
solvedPuzzles: [], // array of puzzleIds that have been solved
activePuzzle: null, // { puzzleId, requiredOutputs, doorX, doorY } or null when no puzzle active
// Game flags
flags: {
// Examples:
// 'met_professor': false,
// 'guard_talked': false,
// 'merchant_met': false
},
// Timing
lastMoveTime: 0,
animTimer: 0
};
/**
* Reset world state to initial defaults
*/
export function resetWorldState() {
worldState.mode = 'world';
worldState.player.x = 4;
worldState.player.y = 10;
worldState.player.px = 0;
worldState.player.py = 0;
worldState.player.direction = 'down';
worldState.player.moving = false;
worldState.player.frame = 0;
worldState.currentMap = 'lab';
worldState.camera.x = 0;
worldState.camera.y = 0;
worldState.dialog = null;
worldState.inventory = [];
worldState.gadgets = [];
worldState.solvedPuzzles = [];
worldState.activePuzzle = null;
worldState.flags = {};
worldState.lastMoveTime = 0;
worldState.animTimer = 0;
}
/**
* Check if player is currently in movement animation
*/
export function isPlayerMoving() {
return worldState.player.moving;
}
/**
* Set player position and reset movement state
*/
export function setPlayerPosition(x, y) {
worldState.player.x = x;
worldState.player.y = y;
worldState.player.px = 0;
worldState.player.py = 0;
worldState.player.moving = false;
worldState.player.frame = 0;
}
/**
* Start a dialog sequence
*/
export function startDialog(lines, speakerName = '') {
worldState.dialog = {
lines: Array.isArray(lines) ? lines : [lines],
currentLine: 0,
speakerName: speakerName
};
worldState.mode = 'dialog';
}
/**
* Advance dialog to next line
* Returns false when dialog sequence ends and should be closed
*/
export function advanceDialog() {
if (!worldState.dialog) return false;
worldState.dialog.currentLine++;
// Dialog finished
if (worldState.dialog.currentLine >= worldState.dialog.lines.length) {
worldState.dialog = null;
worldState.mode = 'world';
return false;
}
return true;
}
/**
* Get current dialog line text
*/
export function getCurrentDialogLine() {
if (!worldState.dialog) return '';
return worldState.dialog.lines[worldState.dialog.currentLine] || '';
}
/**
* Add component to inventory
*/
export function addToInventory(componentId) {
if (!worldState.inventory.includes(componentId)) {
worldState.inventory.push(componentId);
}
}
/**
* Remove component from inventory
*/
export function removeFromInventory(componentId) {
const idx = worldState.inventory.indexOf(componentId);
if (idx !== -1) {
worldState.inventory.splice(idx, 1);
}
}
/**
* Check if component is in inventory
*/
export function hasInInventory(componentId) {
return worldState.inventory.includes(componentId);
}
/**
* Mark a puzzle as solved
*/
export function solvePuzzle(puzzleId) {
if (!worldState.solvedPuzzles.includes(puzzleId)) {
worldState.solvedPuzzles.push(puzzleId);
}
}
/**
* Check if a puzzle has been solved
*/
export function isPuzzleSolved(puzzleId) {
return worldState.solvedPuzzles.includes(puzzleId);
}
/**
* Set the active puzzle that player is attempting
*/
export function setActivePuzzle(puzzleId, requiredOutputs, doorX, doorY) {
worldState.activePuzzle = {
puzzleId: puzzleId,
requiredOutputs: requiredOutputs,
doorX: doorX,
doorY: doorY
};
worldState.mode = 'puzzle';
}
/**
* Clear the active puzzle
*/
export function clearActivePuzzle() {
worldState.activePuzzle = null;
worldState.mode = 'world';
}
/**
* Get the active puzzle
*/
export function getActivePuzzle() {
return worldState.activePuzzle;
}
/**
* Set a game flag
*/
export function setFlag(key, value) {
worldState.flags[key] = value;
}
/**
* Get a game flag
*/
export function getFlag(key, defaultValue = false) {
return worldState.flags[key] !== undefined ? worldState.flags[key] : defaultValue;
}
/**
* Check if a flag is true
*/
export function isFlagSet(key) {
return getFlag(key) === true;
}
/**
* Move player by tile offset (for movement updates)
* Returns true if movement started, false if blocked
*/
export function movePlayer(dx, dy, isWalkable) {
if (worldState.player.moving) return false;
const newX = worldState.player.x + dx;
const newY = worldState.player.y + dy;
// Check if new position is walkable
if (!isWalkable(newX, newY)) {
return false;
}
// Update direction
if (dx > 0) worldState.player.direction = 'right';
if (dx < 0) worldState.player.direction = 'left';
if (dy > 0) worldState.player.direction = 'down';
if (dy < 0) worldState.player.direction = 'up';
// Start movement animation
worldState.player.x = newX;
worldState.player.y = newY;
worldState.player.moving = true;
worldState.player.frame = 0;
worldState.lastMoveTime = Date.now();
return true;
}
/**
* Update player movement animation
* Call this in game loop, delta is time elapsed in ms
*/
export function updatePlayerAnimation(delta) {
if (!worldState.player.moving) return;
const elapsed = Date.now() - worldState.lastMoveTime;
const progress = Math.min(elapsed / worldState.player.speed, 1);
// Update pixel offset for smooth movement
const tileSize = 32; // Assuming 32x32 tiles
worldState.player.px = (worldState.player.direction === 'right' ? 1 : worldState.player.direction === 'left' ? -1 : 0) * tileSize * progress;
worldState.player.py = (worldState.player.direction === 'down' ? 1 : worldState.player.direction === 'up' ? -1 : 0) * tileSize * progress;
// Update animation frame
worldState.player.frame = Math.floor(progress * 4) % 4;
// Movement complete
if (progress >= 1) {
worldState.player.moving = false;
worldState.player.px = 0;
worldState.player.py = 0;
worldState.player.frame = 0;
}
}
/**
* Warp player to a new map and position
*/
export function warpToMap(mapId, x, y) {
worldState.currentMap = mapId;
setPlayerPosition(x, y);
}
/**
* Get complete world state snapshot (for debugging/saving)
*/
export function getWorldStateSnapshot() {
return JSON.parse(JSON.stringify(worldState));
}
/**
* Load world state from snapshot
*/
export function loadWorldStateSnapshot(snapshot) {
Object.assign(worldState, JSON.parse(JSON.stringify(snapshot)));
}

169
server.js Normal file
View File

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