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>
This commit is contained in:
Jose Luis
2026-03-20 17:36:31 +01:00
parent c6f5e19af5
commit 816a02aeb9
6 changed files with 267 additions and 36 deletions

View File

@@ -11,6 +11,7 @@ import { saveState, loadState, exportAsFile, importFromFile } from './saveLoad.j
import { enterComponentEditor, editComponentBlueprint, exitComponentEditor, updateComponentButtons, setResizeCallback } from './components.js';
import { getExampleList, loadExample } from './examples.js';
import { createBusFromCut } from './bus.js';
import { isNamingActive, handleNamingInput } from './world/inventory.js';
const PAN_SPEED = 40;
@@ -325,6 +326,13 @@ export function initEvents() {
const keysDown = new Set();
document.addEventListener('keydown', e => {
// In-game naming screen takes priority over circuit editor
if (isNamingActive()) {
e.preventDefault();
handleNamingInput(e.key);
return;
}
keysDown.add(e.key);
if (e.key === 'Delete' || e.key === 'Backspace') {

View File

@@ -4,6 +4,7 @@ import { state } from './state.js';
import { getInputPorts, getOutputPorts, getComponentWidth, getComponentHeight } from './gates.js';
import { getGateLabel, drawWaveLabels, drawWaveforms } from './waveform.js';
import { getBusPairs } from './bus.js';
import { drawNamingScreen, drawNotification } from './world/inventory.js';
let canvas, ctx;
let circuitAnimFrameId = null;
@@ -663,5 +664,9 @@ function draw() {
drawWaveforms();
}
// In-game overlays (naming screen, notifications) — render on top
drawNamingScreen(ctx, canvas.width, canvas.height);
drawNotification(ctx, canvas.width);
circuitAnimFrameId = requestAnimationFrame(draw);
}

View File

@@ -3,7 +3,7 @@ import { worldState, setPlayerPosition, warpToMap, isPuzzleSolved, solvePuzzle,
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';
import { saveGadget, openBackpack, getGadgets, openNamingScreen, showNotification } from './inventory.js';
// Circuit editor stop function (to stop its render loop when switching modes)
import { stopCircuitLoop } from '../renderer.js';
@@ -162,44 +162,37 @@ function handleSaveGadget() {
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.');
showNotification('Need at least 1 INPUT and 1 OUTPUT!', '⚠️', '#ff5555');
return;
}
const name = prompt('Name your gadget:', `Gadget ${getGadgets().length + 1}`);
if (!name) return; // cancelled
// Switch to world render temporarily to show the naming screen on canvas
// (workshop mode uses its own render loop, so we overlay on the canvas)
openNamingScreen(
'🎒 Name your gadget',
`Gadget ${getGadgets().length + 1}`,
(name) => {
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 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);
const result = saveGadget(component);
if (result.success) {
showNotification(`"${name}" saved to backpack!`, '🎒', '#ff44aa');
} else {
showNotification(result.error, '⚠️', '#ff5555');
}
}
);
}
// ==================== Puzzle testing ====================

View File

@@ -100,6 +100,218 @@ export function getGadget(gadgetId) {
}
// ==================== 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;

View File

@@ -2,7 +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';
import { isBackpackOpen, openBackpack, handleBackpackInput, isNamingActive, handleNamingInput } from './inventory.js';
const keysDown = new Set();
let interactionHandler = null;
@@ -26,6 +26,13 @@ function onKeyDown(e) {
const key = e.key;
keysDown.add(key);
// Naming screen — route all input there
if (isNamingActive()) {
e.preventDefault();
handleNamingInput(key);
return;
}
// Backpack open — route all input there
if (isBackpackOpen()) {
e.preventDefault();

View File

@@ -6,7 +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';
import { drawBackpack, getGadgets, drawNamingScreen, drawNotification } from './inventory.js';
let canvas = null;
let ctx = null;
@@ -144,6 +144,12 @@ export function renderWorld(timestamp) {
if (worldState.mode === 'inventory') {
drawBackpack(ctx, canvas.width, canvas.height);
}
// === Layer 7: Naming screen (on top of everything including backpack) ===
drawNamingScreen(ctx, canvas.width, canvas.height);
// === Layer 8: Notification toast ===
drawNotification(ctx, canvas.width);
}
function drawHUD(map) {