Files
logic-gates/js/world/inventory.js
Jose Luis 816a02aeb9 feat: replace browser dialogs with in-game naming screen + notifications
Remove prompt() and alert() calls that broke game immersion. Add:
- Pokemon-style naming screen with character grid + direct typing
- Canvas-rendered notification toasts (with fade-out animation)
- Both render on top of workshop AND world mode canvases
- Workshop keyboard handler yields to naming screen when active

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 17:36:31 +01:00

632 lines
20 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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();
}