diff --git a/index.html b/index.html index da43b1f..f4a1e80 100644 --- a/index.html +++ b/index.html @@ -9,6 +9,7 @@ +
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 = {};