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>
632 lines
20 KiB
JavaScript
632 lines
20 KiB
JavaScript
/**
|
||
* 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();
|
||
}
|