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>
This commit is contained in:
Jose Luis
2026-03-20 17:59:25 +01:00
parent 6ba3fa457a
commit f9492bff4c
6 changed files with 671 additions and 4 deletions

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

@@ -0,0 +1,577 @@
// 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.
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;
// ==================== Public API ====================
export function isWiringOpen() { return panelOpen; }
/**
* Open the wiring panel for a module interaction with a chosen gadget.
* @param {Object} inter - The module interaction from the map
* @param {Object} gad - The gadget from inventory
*/
export function openWiringPanel(inter, gad) {
panelOpen = true;
moduleInter = inter;
gadget = gad;
wires = [];
cursor = { side: 'module', index: 0 };
selectedModule = null;
selectedGadget = null;
result = 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 = [];
worldState.mode = 'world';
}
// ==================== Gadget port helpers ====================
/** Build a flat list of gadget ports (inputs first, then outputs) */
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;
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;
}
// Validation: module-out → gadget-in, OR gadget-out → module-in
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;
}
// Remove any existing wire on either port
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 ====================
/**
* Mini circuit evaluator — runs a cloned gadget circuit with given input values.
* Returns an object mapping OUTPUT gate IDs → computed values.
*/
function evaluateGadgetCircuit(gates, connections, inputValues) {
// inputValues = { gateId: 0|1 }
const evalGates = JSON.parse(JSON.stringify(gates));
// Set input gate values
for (const g of evalGates) {
if (g.type === 'INPUT' && inputValues[g.id] !== undefined) {
g.value = inputValues[g.id];
}
}
// Fixed-point iteration (max 20 passes)
for (let iter = 0; iter < 20; iter++) {
let changed = false;
for (const g of evalGates) {
if (g.type === 'INPUT' || g.type === 'CLOCK') continue;
// Gather inputs from connections
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;
}
/**
* Execute the wiring — build a test() function from the wires,
* then run the module's verify function to check if the gadget passes.
*/
function executeWiring() {
const mPorts = moduleInter.ports || [];
const gPorts = getGadgetPorts();
// Build the test function that the verify code will call.
// test(moduleOutputValues) → moduleInputValues
function test(moduleOutputs) {
// moduleOutputs = { A: 0, B: 1, ... } — values for module's "out" ports
// Map module output values → gadget input gates via wires
const inputValues = {};
for (const wire of wires) {
const mp = mPorts[wire.moduleIdx];
const gp = gPorts[wire.gadgetIdx];
if (mp.dir === 'out' && gp.dir === 'in') {
// Module provides value → gadget receives it
inputValues[gp.gateId] = moduleOutputs[mp.name] || 0;
}
}
// Evaluate the gadget circuit
const evaluated = evaluateGadgetCircuit(gadget.gates, gadget.connections, inputValues);
// Read gadget output gates → map back to module input ports via wires
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 the verify function
try {
const verifyFn = new Function('return ' + moduleInter.verify)();
const passed = verifyFn(test);
if (passed) {
result = { message: '⚡ VERIFIED! Module unlocked!', color: '#00ff88' };
resultTimer = Date.now();
// Mark as solved if it has an ID
if (moduleInter.moduleId) {
solvePuzzle(moduleInter.moduleId);
}
// Close after a brief delay and show dialog
setTimeout(() => {
closeWiringPanel();
showNotification('Module unlocked!', '⚡', '#00ff88');
startDialog([
`⚡ "${gadget.name}" passed the verification!`,
'The module hums to life and the door unlocks.'
], 'System');
}, 800);
} else {
result = { message: '✗ Verification failed — wrong logic', color: '#ff4444' };
resultTimer = Date.now();
}
} 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';
export function drawWiringPanel(ctx, canvasW, canvasH) {
if (!panelOpen || !moduleInter || !gadget) 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';
// Module ports header
ctx.fillStyle = '#aaa';
ctx.fillText('MODULE PORTS', leftX + 80, colY);
// Gadget ports header
ctx.fillText('GADGET PORTS', rightX - 80, colY);
// Draw 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();
// Port positions for wire drawing
const modulePortPositions = [];
const gadgetPortPositions = [];
// Draw module ports
ctx.font = '13px "Segoe UI", system-ui, sans-serif';
ctx.textAlign = 'left';
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 dotRadius = 8;
// Port dot position (for wires)
const wireX = leftX + 170;
modulePortPositions.push({ x: wireX, y: yPos + 6 });
// Highlight if cursor is here
const isCursor = cursor.side === 'module' && cursor.index === i;
const isSelected = selectedModule === i;
// Background highlight
if (isCursor) {
ctx.fillStyle = 'rgba(0, 255, 200, 0.1)';
ctx.fillRect(leftX - 10, yPos - 6, 190, portSpacing - 8);
}
// Port dot
ctx.beginPath();
ctx.arc(dotX, yPos + 6, dotRadius, 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();
}
// Direction arrow inside dot
ctx.fillStyle = '#000';
ctx.font = 'bold 10px monospace';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(isOut ? '→' : '←', dotX, yPos + 6);
// Port label
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);
// Direction tag
ctx.font = '10px monospace';
ctx.fillStyle = isOut ? PORT_OUT_COLOR : PORT_IN_COLOR;
ctx.fillText(isOut ? 'OUT' : 'IN', dotX + 14, yPos + 16);
// Wire connected indicator
const wire = wires.find(w => w.moduleIdx === i);
if (wire) {
ctx.fillStyle = WIRE_COLOR;
ctx.fillText('⚡', dotX + 50, yPos + 16);
}
}
// Draw gadget ports
ctx.textAlign = 'right';
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 dotRadius = 8;
const wireX = rightX - 170;
gadgetPortPositions.push({ x: wireX, y: yPos + 6 });
const isCursor = cursor.side === 'gadget' && cursor.index === i;
const isSelected = selectedGadget === i;
// Background highlight
if (isCursor) {
ctx.fillStyle = 'rgba(0, 255, 200, 0.1)';
ctx.fillRect(rightX - 180, yPos - 6, 190, portSpacing - 8);
}
// Port dot
ctx.beginPath();
ctx.arc(dotX, yPos + 6, dotRadius, 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();
}
// Direction arrow
ctx.fillStyle = '#000';
ctx.font = 'bold 10px monospace';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(isOut ? '→' : '←', dotX, yPos + 6);
// Port label
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);
// Direction tag
ctx.font = '10px monospace';
ctx.fillStyle = isOut ? PORT_OUT_COLOR : PORT_IN_COLOR;
ctx.fillText(isOut ? 'OUT' : 'IN', dotX - 14, yPos + 16);
// Wire connected indicator
const wire = wires.find(w => w.gadgetIdx === i);
if (wire) {
ctx.fillStyle = WIRE_COLOR;
ctx.fillText('⚡', dotX - 60, yPos + 16);
}
}
// Draw wires
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);
// Bezier curve for nice look
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 (half-selected)
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
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: Select/Wire | X: Remove | Enter: Execute | ESC: Close', px + pw / 2, py + ph - 10);
}
// ==================== 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();
}