diff --git a/js/events.js b/js/events.js index c33990d..b25a579 100644 --- a/js/events.js +++ b/js/events.js @@ -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') { diff --git a/js/renderer.js b/js/renderer.js index aaddf5b..747cdec 100644 --- a/js/renderer.js +++ b/js/renderer.js @@ -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); } diff --git a/js/world/gameMode.js b/js/world/gameMode.js index 6a580a7..c4f25b4 100644 --- a/js/world/gameMode.js +++ b/js/world/gameMode.js @@ -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 ==================== diff --git a/js/world/inventory.js b/js/world/inventory.js index 3fe2a61..856d564 100644 --- a/js/world/inventory.js +++ b/js/world/inventory.js @@ -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; diff --git a/js/world/worldInput.js b/js/world/worldInput.js index 3886623..cb2c384 100644 --- a/js/world/worldInput.js +++ b/js/world/worldInput.js @@ -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(); diff --git a/js/world/worldRenderer.js b/js/world/worldRenderer.js index 909468a..e6db135 100644 --- a/js/world/worldRenderer.js +++ b/js/world/worldRenderer.js @@ -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) {