/** * 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; } // ==================== In-game naming screen ==================== let namingActive = false; let namingText = ''; let namingCursorBlink = 0; let namingCallback = null; // called with the final name string let namingTitle = ''; let namingMaxLen = 16; const CHAR_ROWS = [ 'ABCDEFGHIJ', 'KLMNOPQRST', 'UVWXYZ ', 'abcdefghij', 'klmnopqrst', 'uvwxyz ', '0123456789', '-_.!? โŒซ โœ“', ]; let charRow = 0, charCol = 0; export function isNamingActive() { return namingActive; } export function openNamingScreen(title, defaultText, callback) { namingActive = true; namingTitle = title || 'Enter name:'; namingText = defaultText || ''; namingCallback = callback; namingCursorBlink = 0; charRow = 0; charCol = 0; namingMaxLen = 16; } export function handleNamingInput(key) { if (!namingActive) return false; if (key === 'Escape') { // Cancel namingActive = false; if (namingCallback) namingCallback(null); return true; } if (key === 'Backspace') { namingText = namingText.slice(0, -1); return true; } // Navigate character grid if (key === 'ArrowUp') { charRow = Math.max(0, charRow - 1); return true; } if (key === 'ArrowDown') { charRow = Math.min(CHAR_ROWS.length - 1, charRow + 1); return true; } if (key === 'ArrowLeft') { charCol = Math.max(0, charCol - 1); return true; } if (key === 'ArrowRight') { charCol = Math.min(CHAR_ROWS[charRow].length - 1, charCol + 1); return true; } // Select from grid if (key === 'Enter' || key === ' ') { const ch = CHAR_ROWS[charRow]?.[charCol]; if (ch === 'โœ“' || (key === 'Enter' && (charRow === CHAR_ROWS.length - 1 && charCol >= 9))) { // Confirm if (namingText.trim()) { namingActive = false; if (namingCallback) namingCallback(namingText.trim()); } } else if (ch === 'โŒซ') { namingText = namingText.slice(0, -1); } else if (ch && ch !== ' ' && namingText.length < namingMaxLen) { namingText += ch; } else if (ch === ' ' && namingText.length < namingMaxLen && namingText.length > 0) { namingText += ' '; } return true; } // Direct typing โ€” any printable character if (key.length === 1 && namingText.length < namingMaxLen) { namingText += key; return true; } return true; } export function drawNamingScreen(ctx, canvasW, canvasH) { if (!namingActive) return; namingCursorBlink = (namingCursorBlink + 1) % 60; // Dim background ctx.fillStyle = 'rgba(0, 0, 0, 0.8)'; ctx.fillRect(0, 0, canvasW, canvasH); const pw = Math.min(380, canvasW - 40); const ph = 340; const px = (canvasW - pw) / 2; const py = (canvasH - ph) / 2; // Panel ctx.fillStyle = '#181c2a'; ctx.strokeStyle = '#ff44aa'; ctx.lineWidth = 2; roundRect(ctx, px, py, pw, ph, 8); ctx.fill(); ctx.stroke(); // Title ctx.fillStyle = '#ff44aa'; ctx.font = 'bold 14px "Segoe UI", system-ui, sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'top'; ctx.fillText(namingTitle, canvasW / 2, py + 14); // Text field const tfx = px + 20, tfy = py + 42, tfw = pw - 40, tfh = 28; ctx.fillStyle = '#0f1119'; ctx.strokeStyle = '#2a2f45'; ctx.lineWidth = 1; roundRect(ctx, tfx, tfy, tfw, tfh, 4); ctx.fill(); ctx.stroke(); ctx.fillStyle = '#fff'; ctx.font = 'bold 14px monospace'; ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; const displayText = namingText + (namingCursorBlink < 30 ? 'โ–Œ' : ''); ctx.fillText(displayText, tfx + 8, tfy + tfh / 2); // Character count ctx.fillStyle = '#555'; ctx.font = '10px monospace'; ctx.textAlign = 'right'; ctx.fillText(`${namingText.length}/${namingMaxLen}`, tfx + tfw - 4, tfy + tfh / 2); // Character grid const gridY = tfy + tfh + 16; const cellW = 28, cellH = 24; const gridW = 10 * cellW; const gridX = (canvasW - gridW) / 2; ctx.font = '12px monospace'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; for (let r = 0; r < CHAR_ROWS.length; r++) { const row = CHAR_ROWS[r]; for (let c = 0; c < row.length; c++) { const cx = gridX + c * cellW; const cy = gridY + r * cellH; const ch = row[c]; const isSel = r === charRow && c === charCol; if (isSel) { ctx.fillStyle = 'rgba(255, 68, 170, 0.25)'; roundRect(ctx, cx, cy, cellW - 2, cellH - 2, 3); ctx.fill(); ctx.strokeStyle = '#ff44aa'; ctx.lineWidth = 1.5; ctx.stroke(); } if (ch === ' ') continue; ctx.fillStyle = isSel ? '#ff44aa' : (ch === 'โœ“' ? '#00e599' : ch === 'โŒซ' ? '#ff5555' : '#c8cad0'); ctx.font = (ch === 'โœ“' || ch === 'โŒซ') ? 'bold 14px sans-serif' : '12px monospace'; ctx.fillText(ch, cx + cellW / 2 - 1, cy + cellH / 2); } } // Hint ctx.fillStyle = '#444'; ctx.font = '10px "Segoe UI", system-ui, sans-serif'; ctx.textAlign = 'center'; ctx.fillText('Type directly or use โ†‘โ†“โ†โ†’ + Enter | ESC: Cancel', canvasW / 2, gridY + CHAR_ROWS.length * cellH + 10); } // ==================== In-game notification ==================== let notification = null; // { text, icon, timer, color } export function showNotification(text, icon = '๐ŸŽ’', color = '#ff44aa') { notification = { text, icon, timer: 150, color }; // ~2.5s at 60fps } export function drawNotification(ctx, canvasW) { if (!notification) return; notification.timer--; if (notification.timer <= 0) { notification = null; return; } const alpha = notification.timer < 20 ? notification.timer / 20 : 1; const n = notification; const text = `${n.icon} ${n.text}`; ctx.save(); ctx.globalAlpha = alpha; ctx.font = 'bold 13px "Segoe UI", system-ui, sans-serif'; const tw = ctx.measureText(text).width + 32; const bx = (canvasW - tw) / 2; const by = 50; ctx.fillStyle = n.color; roundRect(ctx, bx, by, tw, 32, 6); ctx.fill(); ctx.fillStyle = '#fff'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(text, canvasW / 2, by + 16); ctx.restore(); } // ==================== 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(); }