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>
This commit is contained in:
Jose Luis
2026-03-20 17:30:30 +01:00
parent f8aa4e2eab
commit b999fe855a
6 changed files with 633 additions and 18 deletions

View File

@@ -1,11 +1,13 @@
// gameMode.js - Central coordinator: switches between World and Workshop modes
import { worldState, setPlayerPosition, warpToMap, isPuzzleSolved } from './worldState.js';
import { worldState, setPlayerPosition, warpToMap, isPuzzleSolved, solvePuzzle, startDialog } from './worldState.js';
import { initWorldRenderer, startWorldLoop, stopWorldLoop } from './worldRenderer.js';
import { initWorldInput, destroyWorldInput, setInteractionHandler } from './worldInput.js';
import { getMap } from './maps.js';
import { saveGadget, openBackpack, getGadgets } from './inventory.js';
// Circuit editor stop function (to stop its render loop when switching modes)
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;
@@ -35,6 +37,12 @@ export function startGame() {
// 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();
}
@@ -94,20 +102,36 @@ function handleInteraction(event) {
case 'puzzleDoor': {
const inter = event.data;
if (isPuzzleSolved(inter.puzzleId)) {
// Already solved — could open door, show message, etc.
startDialog(['This door is already unlocked.'], 'System');
return;
}
// For now, show a hint dialog. Later: open puzzle UI
worldState.dialog = {
lines: [
// 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 pattern: [${inter.requiredOutputs.join(', ')}]`,
'Craft a component in your Workshop (TAB)!'
],
currentLine: 0,
speakerName: 'System'
};
worldState.mode = 'dialog';
`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;
}
@@ -127,6 +151,147 @@ function handleInteraction(event) {
}
}
// ==================== Save Gadget ====================
function handleSaveGadget() {
// Gather current circuit from the editor state
const gates = circuitState.gates;
const connections = circuitState.connections;
const inputGates = gates.filter(g => g.type === 'INPUT');
const outputGates = gates.filter(g => g.type === 'OUTPUT');
if (inputGates.length === 0 || outputGates.length === 0) {
alert('Your circuit needs at least 1 INPUT and 1 OUTPUT to save as a gadget.');
return;
}
const name = prompt('Name your gadget:', `Gadget ${getGadgets().length + 1}`);
if (!name) return; // cancelled
const component = {
id: name.toLowerCase().replace(/[^a-z0-9_]/g, '_').replace(/_+/g, '_'),
name,
inputCount: inputGates.length,
outputCount: outputGates.length,
inputIds: inputGates.map(g => g.id),
outputIds: outputGates.map(g => g.id),
gates: JSON.parse(JSON.stringify(gates)),
connections: JSON.parse(JSON.stringify(connections))
};
const result = saveGadget(component);
if (result.success) {
showToast(`🎒 "${name}" saved to backpack!`);
} else {
alert('Failed to save: ' + result.error);
}
}
function showToast(msg) {
// Simple floating toast
let toast = document.getElementById('game-toast');
if (!toast) {
toast = document.createElement('div');
toast.id = 'game-toast';
toast.style.cssText = 'position:fixed;top:60px;left:50%;transform:translateX(-50%);padding:10px 20px;background:#ff44aa;color:#fff;border-radius:8px;font-weight:700;font-size:13px;z-index:300;opacity:0;transition:opacity 0.3s;pointer-events:none;font-family:system-ui,sans-serif;';
document.body.appendChild(toast);
}
toast.textContent = msg;
toast.style.opacity = '1';
setTimeout(() => { toast.style.opacity = '0'; }, 2500);
}
// ==================== Puzzle testing ====================
/**
* Test a gadget against a puzzle door's required outputs.
* Runs the gadget's internal circuit with all possible input combos
* and checks if outputs match.
*/
function testGadgetOnPuzzle(gadget, puzzleInter) {
const required = puzzleInter.requiredOutputs;
if (!required || !gadget.gates || !gadget.connections) return false;
// Clone internal circuit for evaluation
const gates = JSON.parse(JSON.stringify(gadget.gates));
const conns = gadget.connections;
const inputIds = gadget.inputIds || [];
const outputIds = gadget.outputIds || [];
// Test with all inputs = 0, then all = 1, etc.
// For simplicity: test all 2^n input combos and collect outputs for each
const n = inputIds.length;
const allOutputs = [];
for (let combo = 0; combo < (1 << n); combo++) {
// Reset gates
const evalGates = JSON.parse(JSON.stringify(gates));
// Set inputs
for (let i = 0; i < n; i++) {
const inputGate = evalGates.find(g => g.id === inputIds[i]);
if (inputGate) inputGate.value = (combo >> i) & 1;
}
// Evaluate (fixed-point iteration)
for (let iter = 0; iter < 20; iter++) {
let changed = false;
for (const g of evalGates) {
if (g.type === 'INPUT' || g.type === 'CLOCK') continue;
const inCount = g.type === 'NOT' || g.type === 'OUTPUT' ? 1 : 2;
const gInputs = [];
for (let j = 0; j < inCount; j++) {
const conn = conns.find(c => c.to === g.id && c.toPort === j);
if (conn) {
const src = evalGates.find(s => s.id === conn.from);
gInputs.push(src ? (src.value || 0) : 0);
} else {
gInputs.push(0);
}
}
let result = 0;
switch (g.type) {
case 'AND': result = (gInputs[0] && gInputs[1]) ? 1 : 0; break;
case 'OR': result = (gInputs[0] || gInputs[1]) ? 1 : 0; break;
case 'NOT': result = gInputs[0] ? 0 : 1; break;
case 'NAND': result = (gInputs[0] && gInputs[1]) ? 0 : 1; break;
case 'NOR': result = (gInputs[0] || gInputs[1]) ? 0 : 1; break;
case 'XOR': result = (gInputs[0] !== gInputs[1]) ? 1 : 0; break;
case 'OUTPUT': result = gInputs[0] || 0; break;
default: result = 0;
}
if (result !== g.value) { g.value = result; changed = true; }
}
if (!changed) break;
}
// Collect outputs
for (const outId of outputIds) {
const outGate = evalGates.find(g => g.id === outId);
allOutputs.push(outGate ? (outGate.value || 0) : 0);
}
}
// Compare: the required outputs should match the output pattern
// Simple comparison: check if required matches any combo's outputs
// Or: required is the full truth table (outputs for combo 0, then combo 1, etc.)
if (allOutputs.length === required.length) {
return allOutputs.every((v, i) => v === required[i]);
}
// Fallback: check if any single combo's outputs match
const outPerCombo = outputIds.length;
for (let combo = 0; combo < (1 << n); combo++) {
const comboOutputs = allOutputs.slice(combo * outPerCombo, (combo + 1) * outPerCombo);
if (comboOutputs.length === required.length && comboOutputs.every((v, i) => v === required[i])) {
return true;
}
}
return false;
}
// ==================== UI visibility ====================
function showWorldUI() {
@@ -161,9 +326,11 @@ function showWorkshopUI() {
canvas.style.cursor = 'default';
}
// Show back-to-world button
// Show back-to-world button and save-gadget button
const backBtn = document.getElementById('back-to-world-btn');
if (backBtn) backBtn.style.display = 'flex';
const saveBtn = document.getElementById('save-gadget-btn');
if (saveBtn) saveBtn.style.display = 'flex';
}
function hideWorkshopUI() {
@@ -172,4 +339,6 @@ function hideWorkshopUI() {
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';
}