diff --git a/index.html b/index.html
index da43b1f..f4a1e80 100644
--- a/index.html
+++ b/index.html
@@ -9,6 +9,7 @@
+
โก Logic Lab
diff --git a/js/world/gameMode.js b/js/world/gameMode.js
index a6119e0..6a580a7 100644
--- a/js/world/gameMode.js
+++ b/js/world/gameMode.js
@@ -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';
}
diff --git a/js/world/inventory.js b/js/world/inventory.js
new file mode 100644
index 0000000..3fe2a61
--- /dev/null
+++ b/js/world/inventory.js
@@ -0,0 +1,419 @@
+/**
+ * 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;
+}
+
+
+// ==================== 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();
+}
diff --git a/js/world/worldInput.js b/js/world/worldInput.js
index 959fd23..3886623 100644
--- a/js/world/worldInput.js
+++ b/js/world/worldInput.js
@@ -2,6 +2,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 } from './inventory.js';
const keysDown = new Set();
let interactionHandler = null;
@@ -25,6 +26,13 @@ function onKeyDown(e) {
const key = e.key;
keysDown.add(key);
+ // 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') {
@@ -43,6 +51,13 @@ function onKeyDown(e) {
return;
}
+ // Backpack toggle (I)
+ if (key === 'i' || key === 'I') {
+ e.preventDefault();
+ openBackpack(null);
+ return;
+ }
+
// Workshop shortcut (TAB)
if (key === 'Tab') {
e.preventDefault();
diff --git a/js/world/worldRenderer.js b/js/world/worldRenderer.js
index 7afe27e..909468a 100644
--- a/js/world/worldRenderer.js
+++ b/js/world/worldRenderer.js
@@ -6,6 +6,7 @@ import {
import { worldState } from './worldState.js';
import { getMap, getInteraction, getNPC, getExit, isWall } from './maps.js';
import { updateMovement } from './worldInput.js';
+import { drawBackpack, getGadgets } from './inventory.js';
let canvas = null;
let ctx = null;
@@ -138,6 +139,11 @@ export function renderWorld(timestamp) {
// === HUD ===
drawHUD(map);
+
+ // === Layer 6: Backpack overlay (on top of everything) ===
+ if (worldState.mode === 'inventory') {
+ drawBackpack(ctx, canvas.width, canvas.height);
+ }
}
function drawHUD(map) {
@@ -155,16 +161,17 @@ function drawHUD(map) {
ctx.textAlign = 'left';
ctx.fillText(`๐ ${mapName}`, 12, 16);
- // Inventory
+ // Gadgets count
+ const gadgetCount = getGadgets().length;
ctx.fillStyle = '#ff44aa';
ctx.textAlign = 'right';
- ctx.fillText(`๐ง Components: ${worldState.inventory.length}`, canvas.width - 12, 16);
+ 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 | TAB: Workshop | F3: Debug', canvas.width / 2, 16);
+ ctx.fillText('WASD: Move | E: Interact | I: Backpack | TAB: Workshop | F3: Debug', canvas.width / 2, 16);
// Debug legend
if (debugMode) {
diff --git a/js/world/worldState.js b/js/world/worldState.js
index a56fca6..fa50660 100644
--- a/js/world/worldState.js
+++ b/js/world/worldState.js
@@ -33,8 +33,11 @@ export const worldState = {
// Dialog
dialog: null, // { lines: [...], currentLine: 0, speakerName: '' } or null
- // Inventory of crafted components
- inventory: [], // array of component IDs from customComponents (stored in circuit editor)
+ // 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
@@ -70,6 +73,7 @@ export function resetWorldState() {
worldState.camera.y = 0;
worldState.dialog = null;
worldState.inventory = [];
+ worldState.gadgets = [];
worldState.solvedPuzzles = [];
worldState.activePuzzle = null;
worldState.flags = {};