From f9492bff4c5214ab5076b50008b2b7788def7847 Mon Sep 17 00:00:00 2001 From: Jose Luis Date: Fri, 20 Mar 2026 17:59:25 +0100 Subject: [PATCH] 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 --- editor.html | 36 ++- js/world/gameMode.js | 26 ++ js/world/maps.js | 17 ++ js/world/wiringPanel.js | 577 ++++++++++++++++++++++++++++++++++++++ js/world/worldInput.js | 11 + js/world/worldRenderer.js | 8 +- 6 files changed, 671 insertions(+), 4 deletions(-) create mode 100644 js/world/wiringPanel.js diff --git a/editor.html b/editor.html index b05f1f2..9498cc6 100644 --- a/editor.html +++ b/editor.html @@ -467,7 +467,7 @@ function render() { ctx.strokeStyle = selectedEntity?.type === 'interaction' && selectedEntity.index === i ? '#fff' : '#ffdd44'; ctx.lineWidth = selectedEntity?.type === 'interaction' && selectedEntity.index === i ? 2.5 : 1.5; ctx.strokeRect(inter.x * TILE_PX, inter.y * TILE_PX, TILE_PX, TILE_PX); - const icon = inter.type === 'workshop' ? 'πŸ”§' : inter.type === 'puzzle_door' ? 'πŸ”’' : inter.type === 'terminal' ? 'πŸ’»' : 'πŸ“‹'; + const icon = inter.type === 'workshop' ? 'πŸ”§' : inter.type === 'puzzle_door' ? 'πŸ”’' : inter.type === 'module' ? '⚑' : inter.type === 'terminal' ? 'πŸ’»' : 'πŸ“‹'; drawLabel(ctx, icon, inter.x, inter.y); }); @@ -856,13 +856,23 @@ function updateProps() { html += '
⚠️ Set target X/Y! Every exit needs explicit coordinates.
'; } } else if (t === 'interaction') { - html += propSelect('Type', 'type', ent.type, ['sign','workshop','terminal','door']); // puzzle_door hidden for now + html += propSelect('Type', 'type', ent.type, ['sign','workshop','terminal','door','module']); // puzzle_door hidden for now html += propText('Label', 'label', ent.label || ''); html += propTextarea('Dialog', 'dialog', (ent.dialog || []).join('\n')); if (ent.type === 'puzzle_door') { html += propText('Puzzle ID', 'puzzleId', ent.puzzleId || ''); html += propText('Req. Outputs', 'requiredOutputs', (ent.requiredOutputs || []).join(',')); } + if (ent.type === 'module') { + html += propText('Module ID', 'moduleId', ent.moduleId || ''); + // Ports editor β€” compact format: "A:out, B:out, C:in" + const portsStr = (ent.ports || []).map(p => `${p.name}:${p.dir}`).join(', '); + html += propText('Ports', 'ports', portsStr); + html += `
Format: A:out, B:out, C:in
`; + // Verify code editor + const verifyCode = ent.verify || `(test) => {\n return test({A:0, B:0}).C === 0\n && test({A:1, B:1}).C === 1;\n}`; + html += `
`; + } } container.innerHTML = html; @@ -899,6 +909,14 @@ function applyPropChange(prop, value, inputType) { ent.dialog = value.split('\n').filter(l => l.trim()); } else if (prop === 'requiredOutputs') { ent.requiredOutputs = value.split(',').map(Number); + } else if (prop === 'ports') { + // Parse "A:out, B:out, C:in" format + ent.ports = value.split(',').map(s => s.trim()).filter(Boolean).map(s => { + const [name, dir] = s.split(':').map(p => p.trim()); + return { name: name || '?', dir: dir || 'out', bits: 1 }; + }); + } else if (prop === 'verify') { + ent.verify = value; } else if (prop === 'targetMap') { // Store as game ID (pallet-town β†’ town) ent.targetMap = value === 'pallet-town' ? 'town' : value; @@ -998,7 +1016,19 @@ function generateMapsJS() { if (inter.dialog) line += `, dialog: ${JSON.stringify(inter.dialog)}`; if (inter.puzzleId) line += `, puzzleId: '${inter.puzzleId}'`; if (inter.requiredOutputs) line += `, requiredOutputs: [${inter.requiredOutputs}]`; - line += ` },\n`; + if (inter.moduleId) line += `,\n moduleId: '${inter.moduleId}'`; + if (inter.ports && inter.ports.length > 0) { + line += `,\n ports: [\n`; + for (const p of inter.ports) { + line += ` { name: '${p.name}', dir: '${p.dir}', bits: ${p.bits || 1} },\n`; + } + line += ` ]`; + } + if (inter.verify) { + // Output verify as raw JS (not a string) so it's executable + line += `,\n verify: \`${inter.verify.replace(/`/g, '\\`')}\``; + } + line += `\n },\n`; out += line; } out += ` ]\n};\n\n`; diff --git a/js/world/gameMode.js b/js/world/gameMode.js index adeb6ed..f056d32 100644 --- a/js/world/gameMode.js +++ b/js/world/gameMode.js @@ -4,6 +4,7 @@ import { initWorldRenderer, startWorldLoop, stopWorldLoop } from './worldRendere 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'; // Circuit editor stop function (to stop its render loop when switching modes) import { stopCircuitLoop } from '../renderer.js'; @@ -144,6 +145,31 @@ function handleInteraction(event) { 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); diff --git a/js/world/maps.js b/js/world/maps.js index 33ef076..96a6c60 100644 --- a/js/world/maps.js +++ b/js/world/maps.js @@ -48,6 +48,23 @@ const labMap = { { 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; + }` + }, ] }; diff --git a/js/world/wiringPanel.js b/js/world/wiringPanel.js new file mode 100644 index 0000000..4be2541 --- /dev/null +++ b/js/world/wiringPanel.js @@ -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(); +} diff --git a/js/world/worldInput.js b/js/world/worldInput.js index cb2c384..b0a23d1 100644 --- a/js/world/worldInput.js +++ b/js/world/worldInput.js @@ -3,6 +3,7 @@ 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; @@ -33,6 +34,13 @@ function onKeyDown(e) { return; } + // Wiring panel β€” route all input there + if (isWiringOpen()) { + e.preventDefault(); + handleWiringInput(key); + return; + } + // Backpack open β€” route all input there if (isBackpackOpen()) { e.preventDefault(); @@ -201,6 +209,9 @@ function performInteraction() { 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; diff --git a/js/world/worldRenderer.js b/js/world/worldRenderer.js index e6db135..0f0bc00 100644 --- a/js/world/worldRenderer.js +++ b/js/world/worldRenderer.js @@ -7,6 +7,7 @@ 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; @@ -145,7 +146,12 @@ export function renderWorld(timestamp) { drawBackpack(ctx, canvas.width, canvas.height); } - // === Layer 7: Naming screen (on top of everything including backpack) === + // === 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 ===