/** * 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(); }