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 ===