diff --git a/assets/fx/discovery.mp3 b/assets/fx/discovery.mp3 index 0c43ece..34f2fc4 100644 Binary files a/assets/fx/discovery.mp3 and b/assets/fx/discovery.mp3 differ diff --git a/assets/fx/system-scan.mp3 b/assets/fx/system-scan.mp3 index df65215..a49fded 100644 Binary files a/assets/fx/system-scan.mp3 and b/assets/fx/system-scan.mp3 differ diff --git a/assets/fx/type-construct.mp3 b/assets/fx/type-construct.mp3 index 4f02823..b0664ad 100644 Binary files a/assets/fx/type-construct.mp3 and b/assets/fx/type-construct.mp3 differ diff --git a/assets/fx/type-deconstruct.mp3 b/assets/fx/type-deconstruct.mp3 index c669266..f9c0a7f 100644 Binary files a/assets/fx/type-deconstruct.mp3 and b/assets/fx/type-deconstruct.mp3 differ diff --git a/assets/fx/ui-click.mp3 b/assets/fx/ui-click.mp3 new file mode 100644 index 0000000..219cda4 Binary files /dev/null and b/assets/fx/ui-click.mp3 differ diff --git a/assets/fx/ui-hover.mp3 b/assets/fx/ui-hover.mp3 new file mode 100644 index 0000000..7159164 Binary files /dev/null and b/assets/fx/ui-hover.mp3 differ diff --git a/assets/images/originals/gasgiant-planet-02.png b/assets/images/originals/gasgiant-planet-02.png new file mode 100644 index 0000000..3d80ad8 Binary files /dev/null and b/assets/images/originals/gasgiant-planet-02.png differ diff --git a/data/manifest.json b/data/manifest.json index fb3c98a..5dfcec1 100644 --- a/data/manifest.json +++ b/data/manifest.json @@ -14,6 +14,7 @@ "research.json", "builds.json", "actionbar.json", - "sfx.json" + "sfx.json", + "save.json" ] } diff --git a/data/menu.json b/data/menu.json index 20cba6d..300e65d 100644 --- a/data/menu.json +++ b/data/menu.json @@ -14,6 +14,13 @@ "fontSize": 26, "paddingX": 46, "paddingY": 18 + }, + "loadGame": { + "label": "Load Game", + "position": { "x": 0.5, "y": 0.675 }, + "fontSize": 17, + "paddingX": 32, + "paddingY": 11 } }, "seed": { diff --git a/data/save.json b/data/save.json new file mode 100644 index 0000000..7617fb4 --- /dev/null +++ b/data/save.json @@ -0,0 +1,96 @@ +{ + "_comment": "SAVE SYSTEM — 10 slots of saved games in localStorage (js/save/SaveManager.js). The MENU button on the command deck folds up a sub-bar (js/ui/MenuSubBar.js): Save Game / Load Game (grayed out while no saves exist) / Return to Main Menu. Both save verbs open the slot pop-up (js/ui/SavePanel.js) — 10 slots, overwrite + load confirmations, and a bulk download of every saved game. All copy/colors/timing for the three pieces live here.", + "slots": 10, + "storageKey": "orbit.saves.v1", + "format": 1, + "subBar": { + "_comment": "The sub-bar that folds up from the deck's Menu button. `items` is the ordered button list (id fired via onAction, label, accent). animation: openMs/closeMs = the fold duration; staggerMs = between each button's flicker-in; microBurst = the ambient glitch the open bar keeps up (a faint slice + RGB pulse now and then).", + "enabled": true, + "height": 58, + "padding": 16, + "gap": 12, + "button": { "fontSize": 13, "letterSpacing": 2, "paddingX": 18, "paddingY": 9 }, + "items": [ + { "id": "save", "label": "Save Game", "accent": "#00e5ff" }, + { "id": "load", "label": "Load Game", "accent": "#ffc94d" }, + { "id": "mainMenu", "label": "Return to Main Menu", "accent": "#ff2d6f" } + ], + "animation": { + "openMs": 340, + "closeMs": 200, + "ghostStart": 12, + "staggerMs": 70, + "buttonStart": 0.34, + "microBurst": { "enabled": true, "intervalMs": [2800, 6800] } + } + }, + "panel": { + "_comment": "The save/load pop-up (the MEMORY BANK). slotW/slotH = one slot card, cols = cards per row (10 slots total). downloadLabel = the bulk-export button in the footer. animation: openMs/closeMs = the split-open duration, staggerMs between slot flicker-ins.", + "slotW": 138, + "slotH": 98, + "cols": 5, + "gap": 10, + "padding": 22, + "headerH": 56, + "footerH": 44, + "title": { + "save": "SAVE GAME", + "load": "LOAD GAME" + }, + "subtitle": { + "save": "SELECT A SLOT TO WRITE THE CURRENT SIGNAL", + "load": "SELECT A SLOT TO RESTORE THE SIGNAL" + }, + "slotEmpty": "NO SIGNAL", + "downloadLabel": "DOWNLOAD ALL SAVED GAMES", + "animation": { + "openMs": 300, + "closeMs": 190, + "staggerMs": 55, + "ghostStart": 10 + } + }, + "confirm": { + "_comment": "The confirm dialog inside the pop-up. overwrite = replacing an occupied slot; load = restoring a slot (replaces the live game). accent per context; body lines are the detail lines under the title.", + "width": 430, + "height": 168, + "overwrite": { + "title": "OVERWRITE SLOT {slot}?", + "accent": "#ff2d6f", + "body": [ + "{detail}", + "THE SIGNAL IN THIS SLOT WILL BE DESTROYED" + ], + "confirmLabel": "OVERWRITE", + "cancelLabel": "CANCEL" + }, + "load": { + "title": "LOAD SLOT {slot}?", + "accent": "#ffc94d", + "body": [ + "{detail}", + "THE LIVE GAME WILL BE REPLACED BY THIS SIGNAL" + ], + "confirmLabel": "LOAD", + "cancelLabel": "CANCEL" + }, + "animation": { "openMs": 170, "closeMs": 130 } + }, + "colors": { + "panelTop": "#0e1930", + "panelBottom": "#050912", + "cardBg": "#0a1222", + "cardBorder": "#1e3050", + "ink": "#eaf6ff", + "dim": "#7d92c4", + "faint": "#3d4c74", + "neon": "#00e5ff", + "amber": "#ffc94d", + "magenta": "#ff2d6f" + }, + "toast": { + "ok": { "color": "#00e5ff", "durationMs": 2600 }, + "warn": { "color": "#ffc94d", "durationMs": 2600 }, + "error": { "color": "#ff9b9b", "durationMs": 3200 } + } +} diff --git a/dev/cdp-firefox.mjs b/dev/cdp-firefox.mjs new file mode 100644 index 0000000..77afa31 --- /dev/null +++ b/dev/cdp-firefox.mjs @@ -0,0 +1,151 @@ +/** + * Run a page in headless Firefox (via geckodriver, W3C WebDriver) and + * print a result object. + * + * node dev/cdp-firefox.mjs [timeoutMs=25000] [pollMs=250] + * + * is an arrow-less script evaluated with + * `execute/sync` (WebDriver classic) — e.g. + * `return window.__SAVES_UI__;` — polled until it returns non-null, + * then printed as JSON. The page under test is expected to set that + * global when its flow finishes (success or failure). + * + * Needs: firefox + geckodriver on PATH, a static server for the page. + */ +import { spawn, execFileSync } from 'node:child_process'; +import { existsSync, readdirSync } from 'node:fs'; + +const [url, expr, timeoutMs = '25000', pollMs = '250'] = process.argv.slice(2); +if (!url || !expr) { + console.error('usage: node dev/cdp-firefox.mjs [timeoutMs] [pollMs]'); + process.exit(2); +} + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +// --- find firefox + geckodriver ------------------------------------------ +let geckodriver; +try { + geckodriver = execFileSync('sh', ['-c', 'command -v geckodriver'], { encoding: 'utf8' }).trim(); +} catch { + console.error('need firefox + geckodriver on PATH'); + process.exit(2); +} + +// The firefox binary. On snap systems `command -v firefox` is a shell +// wrapper (geckodriver rejects non-ELF), so also try the real binary +// inside the snap revision directories. +const firefoxCandidates = []; +try { + const ff = execFileSync('sh', ['-c', 'command -v firefox'], { encoding: 'utf8' }).trim(); + if (ff) firefoxCandidates.push(ff); +} catch { /* not on PATH */ } +try { + const snapRev = readdirSync('/snap/firefox', { withFileTypes: true }).map((d) => d.name); + for (const rev of [null, ...snapRev]) { + const base = rev ? `/snap/firefox/${rev}` : '/snap/firefox/current'; + const p = `${base}/usr/lib/firefox/firefox`; + if (existsSync(p)) firefoxCandidates.push(p); + } +} catch { /* no /snap/firefox (non-snap install) */ } +if (firefoxCandidates.length === 0) { + console.error('no firefox binary found (PATH or /snap/firefox)'); + process.exit(2); +} +const PORT = 4444 + (process.pid % 100); +const udd = `/tmp/wd-firefox-${process.pid}-${Date.now()}`; +const driver = spawn(geckodriver, ['--port', String(PORT), '--log', 'fatal'], { stdio: 'ignore' }); +const base = `http://127.0.0.1:${PORT}`; + +const fail = (msg, code = 1) => { + try { driver.kill('SIGKILL'); } catch { /* already dead */ } + console.error(msg); + process.exit(code); +}; + +const wd = async (method, path, body) => { + const res = await fetch(base + path, { + method, + headers: { 'Content-Type': 'application/json' }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + const text = await res.text(); + let data = null; + try { data = JSON.parse(text); } catch { /* empty body (DELETE) */ } + if (!res.ok) throw new Error(`WebDriver ${method} ${path} → ${res.status}: ${text.slice(0, 300)}`); + return data; +}; + +try { + // --- wait for the driver ------------------------------------------------ + let up = false; + for (let i = 0; i < 60; i++) { + try { + const v = await (await fetch(`${base}/status`)).json(); + if (v?.value?.ready || v?.status === 'ok' || v?.value) { up = true; break; } + } catch { /* not up yet */ } + await sleep(250); + } + if (!up) fail('geckodriver never came up'); + + // --- a headless session (try each firefox candidate; geckodriver + // rejects wrapper scripts) ------------------------------------------- + let sess; + let lastSessErr = null; + for (const ff of firefoxCandidates) { + try { + sess = await wd('POST', '/session', { + capabilities: { + alwaysMatch: { + browserName: 'firefox', + 'moz:firefoxOptions': { + args: ['-headless'], + binary: ff, + }, + }, + }, + }); + break; + } catch (err) { + lastSessErr = err; + // A rejected binary leaves no session; other errors are fatal-ish, + // but keep trying the next candidate. + } + } + if (!sess) fail(`no firefox candidate accepted: ${String(lastSessErr?.message ?? lastSessErr)}`); + const sid = sess.value?.sessionId; + if (!sid) fail(`no session id in ${JSON.stringify(sess).slice(0, 200)}`); + + // --- navigate ------------------------------------------------------------- + await wd('POST', `/session/${sid}/url`, { url }); + + // --- poll the expression until it yields a value --------------------------- + const deadline = Date.now() + Number(timeoutMs); + let out; + let lastErr = null; + while (Date.now() < deadline) { + await sleep(Number(pollMs)); + try { + const r = await wd('POST', `/session/${sid}/execute/sync`, { + script: expr, + args: [], + }); + if (r.value !== undefined && r.value !== null) { + out = r.value; + break; + } + } catch (err) { + lastErr = String(err?.message ?? err); + } + } + + await wd('DELETE', `/session/${sid}`).catch(() => {}); + + if (out === undefined) { + fail(`timeout waiting for a value: ${lastErr ?? 'the expression kept returning null'}`); + } + console.log(typeof out === 'string' ? out : JSON.stringify(out, null, 2)); + if (out && typeof out === 'object' && out.pass === false) process.exitCode = 1; +} finally { + try { driver.kill('SIGKILL'); } catch { /* already dead */ } +} diff --git a/dev/saves-ui-test.html b/dev/saves-ui-test.html new file mode 100644 index 0000000..6178dd2 --- /dev/null +++ b/dev/saves-ui-test.html @@ -0,0 +1,31 @@ + + + + + Orbit — dev save-system UI test + + + + + + + + +
+ + + diff --git a/dev/saves-ui-test.mjs b/dev/saves-ui-test.mjs new file mode 100644 index 0000000..2328317 --- /dev/null +++ b/dev/saves-ui-test.mjs @@ -0,0 +1,224 @@ +/** + * Save-system UI integration driver (headless browser — NOT a Node test). + * + * Drives the REAL game through the full save flow and checks every seam: + * + * GameScene: MENU button → sub-bar folds up → Load grayed (empty bank) + * → Save Game → the 10-slot pop-up (save mode) + * → doSave(1) writes the bank + * → overwrite confirm opens, CANCEL leaves the save intact + * → the panel reopens in LOAD mode, empty slots inert + * → LOAD confirm → the restore stages → cut to the menu + * MenuScene: Load Game is live (the bank has a save) → its own load + * pop-up → LOAD confirm → the staged restore rides into a + * fresh GameScene — ship back where it was, the session + * time, the tether field. + * + * Served by dev/saves-ui-test.html; the results land in + * `window.__SAVES_UI__` for the CDP runner (dev/cdp-firefox.mjs): + * + * python3 -m http.server 8080 + * node dev/cdp-firefox.mjs http://localhost:8080/dev/saves-ui-test.html + */ +import Phaser from '../js/vendor/phaser.js'; +import { config } from '../js/config/Config.js'; +import { ConfigLoader } from '../js/config/ConfigLoader.js'; +import { createGameConfig } from '../js/config/GameConfig.js'; +import { MenuScene } from '../js/scenes/MenuScene.js'; +import { GameScene } from '../js/scenes/GameScene.js'; + +const data = await ConfigLoader.load(); +config.init(data); + +// A deterministic galaxy (same system every run) + a quiet run (no audio +// files to fetch in headless). +globalThis.__ORBIT_DEV_SEED = 'SAVETEST'; +const gameConfig = createGameConfig(); +gameConfig.scene = [GameScene, MenuScene]; // GameScene boots first +if (typeof Phaser !== 'undefined') Phaser.NoAudioContext = true; + +const game = new Phaser.Game(gameConfig); +window.game = game; + +const results = []; +const check = (label, cond) => { + const pass = !!cond; + results.push({ label, pass }); + console.log(`${pass ? '✔' : '✘ FAIL'} ${label}`); +}; + +// WAIT — throttling-proof. This headless box starves setTimeout and +// stretches its compositor clock, so the only honest barrier is the +// GAME'S OWN CLOCK: Phaser v4 sets scene time.now from the engine loop +// time (shared by all scenes, monotonic across scene transitions). We +// poll it on rAF until it has advanced past the base. +const gameClock = () => { + try { + const s = window.game.scene.getScenes(true)[0]; + if (s && typeof s.time.now === 'number') return s.time.now; + } catch {} + return null; +}; +const wait = (ms) => new Promise((resolve) => { + const base = gameClock(); + if (base === null) { // pre-boot: fall back to wall clock + const start = performance.now(); + setTimeout(() => resolve(), ms); + return; + } + const poll = () => { + const now = gameClock(); + if (now !== null && now - base >= ms) return resolve(); + requestAnimationFrame(poll); + }; + requestAnimationFrame(poll); +}); + + +const run = async () => { + const scene = game.scene.getScene('GameScene'); + await wait(900); // boot: galaxy, system, deck, sub-bar, panel + check('boots into GameScene', game.scene.isActive('GameScene')); + check('the sub-bar + panel exist', !!scene.menuSubBar && !!scene.savePanel); + + // ---- the sub-bar folds up out of MENU -------------------------------- + scene.menuAction(); + await wait(450); + check('MENU folds the sub-bar up', scene.menuSubBar.isOpen); + // Every button must sit INSIDE the bar (and the canvas) — the body is + // drawn about the container origin, so the row must be centered on it. + check('all sub-bar buttons are inside the bar and the canvas', + scene.menuSubBar.buttons.every((s) => { + const bar = scene.menuSubBar; + const wx = bar.x + s.btn.x; + const half = s.btn.style.width / 2; + return Math.abs(s.btn.x) <= bar.W / 2 + 1 + && wx - half >= -1 && wx + half <= scene.scale.width + 1; + })); + check('Load Game is grayed while the bank is empty', + scene.menuSubBar.buttons.find((b) => b.id === 'load').btn.disabled === true); + + // ---- the pop-up: SAVE mode -------------------------------------------- + scene.subBarAction('save'); + await wait(450); + check('Save Game opens the 10-slot pop-up (save mode)', + scene.savePanel.isOpen && scene.savePanel.mode === 'save' && scene.savePanel.cards.length === 10); + // Geometry sanity: the panel and every card/footer button must sit at + // FINITE positions inside the canvas (NaN y = the use-before-assign bug). + check('panel + cards + footer have finite positions inside the canvas', + Number.isFinite(scene.savePanel.W) && Number.isFinite(scene.savePanel.H) + && scene.savePanel.cards.every((c) => { + const x = scene.savePanel.x + c.x; + const y = scene.savePanel.y + c.y; + return Number.isFinite(x) && Number.isFinite(y) + && x >= -1 && x <= scene.scale.width + 1 && y >= -1 && y <= scene.scale.height + 1; + }) + && [scene.savePanel.cancelBtn, scene.savePanel.downloadBtn].every((b) => + Number.isFinite(scene.savePanel.x + b.x) && Number.isFinite(scene.savePanel.y + b.y))); + + // A direct write to slot 1 (the same path the confirm dialog uses). + scene.savePanel.doSave(1); + await wait(120); + const rec1 = scene.saveManager.get(1); + check('doSave(1) writes the bank', rec1 !== null && rec1.seed === scene.galaxy.seed && rec1.ship.x === scene.ship.x); + // Capture fidelity: the bank's session time is the scene's at capture + // (a frame or two may have ticked since — small tolerance). + check('the bank captured the session time', + rec1.playTimeMs > 0 && rec1.playTimeMs <= Math.round(scene.playTimeMs) + 200 && rec1.playTimeMs >= Math.round(scene.playTimeMs) - 400); + + // ---- overwrite confirm: open, CANCEL, the save is intact -------------- + scene.savePanel.confirmOverwrite(1, rec1); + await wait(300); + check('overwrite raises the confirm dialog', scene.savePanel.confirm.isOpen); + const before = JSON.stringify(scene.saveManager.get(1)); + scene.savePanel.confirm.cancel(); + await wait(300); + check('CANCEL closes the dialog', scene.savePanel.confirm.isOpen === false); + check('CANCEL left the slot intact', JSON.stringify(scene.saveManager.get(1)) === before); + + // ---- the pop-up: LOAD mode -------------------------------------------- + scene.savePanel.close(); + await wait(350); + scene.savePanel.show('load'); + await wait(450); + check('the panel reopens in LOAD mode', scene.savePanel.isOpen && scene.savePanel.mode === 'load'); + check('empty slots are inert in load mode', + scene.savePanel.cards[1].disabled === true && scene.savePanel.cards[0].disabled === false); + + // ---- load confirm: stage the restore, cut to the menu ----------------- + const shipX = scene.ship.x; + const shipY = scene.ship.y; + scene.savePanel.confirmLoad(1, scene.saveManager.get(1)); + await wait(300); + scene.savePanel.confirm.fireConfirm(); + await wait(700); // close anim → onLoadComplete → scene.start + check('a confirmed LOAD cuts to the main menu', game.scene.isActive('MenuScene')); + + // ---- the menu side ------------------------------------------------------ + const menu = game.scene.getScene('MenuScene'); + await wait(400); + check('the menu shows a (live) Load Game button', + !!menu.loadGameBtn && menu.loadGameBtn.disabled === false); + + menu.openLoadPanel(); + await wait(450); + check('the menu opens the load pop-up', menu.savePanel.isOpen && menu.savePanel.mode === 'load'); + + menu.savePanel.confirmLoad(1, menu.saveManager.get(1)); + await wait(300); + menu.savePanel.confirm.fireConfirm(); + await wait(1100); // close anim → cut → GameScene.create (galaxy + restore) + check('the game is back from the menu', game.scene.isActive('GameScene')); + + const gs = game.scene.getScene('GameScene'); + const rec = gs.saveManager.get(1); + check('the ship is back where the save parked it', + rec && Math.abs(gs.ship.x - rec.ship.x) < 0.01 && Math.abs(gs.ship.y - rec.ship.y) < 0.01); + check('the saved ship was where it had flown', Math.abs(rec.ship.x - shipX) < 0.01 && Math.abs(rec.ship.y - shipY) < 0.01); + // Restore fidelity: the restored value is the BANK's value (rec1), plus + // the session time accumulated since GameScene.create (the wait above + + // a few frames). update() caps per-frame accumulation at 100ms + // (anti-fast-forward), which matters on throttled headless frames — so + // the upper bound is generous; the lower bound is exact (restore is a + // hard set, accumulation only adds). + const restoredT = gs.playTimeMs; + check(`the session time restored (bank=${rec1.playTimeMs}ms, now=${Math.round(restoredT)}ms)`, + restoredT >= rec1.playTimeMs && restoredT <= rec1.playTimeMs + 4000); + check('the tether field restored', gs.tetherField.tethers.length === (rec.tethers ?? []).length && gs.tetherField.tethers.length >= 1); + check('discovery survived (registry hand-off)', gs.discovery instanceof Object && gs.discovery.distance > 0); + + // The sub-bar again after the load: Load Game is live now. + scene.menuAction?.call(gs); + await wait(450); + check('after the load, Load Game is live in the sub-bar', + gs.menuSubBar.isOpen && gs.menuSubBar.buttons.find((b) => b.id === 'load').btn.disabled === false); +}; + +let done = false; +game.events.once('ready', async () => { + try { + await run(); + } catch (err) { + console.error(err); + results.push({ label: `DRIVER CRASHED: ${err.message}`, pass: false }); + } + const pass = results.length > 0 && results.every((r) => r.pass); + window.__SAVES_UI__ = { pass, results }; + done = true; + console.log(`SAVES-UI ${pass ? 'PASS' : 'FAIL'} (${results.filter((r) => r.pass).length}/${results.length})`); +}); + +// Hard stop: if the flow never finishes, the runner will report the +// in-flight results as a failure. (rAF + monotonic clock — the same +// throttling-proof primitive as wait().) +const hardStop = (t0) => { + if (performance.now() - t0 >= 300000) { + if (!done) { + window.__SAVES_UI__ = { pass: false, results: [...results, { label: 'TIMED OUT (300s)', pass: false }] }; + console.log('SAVES-UI FAIL (timed out)'); + } + return; + } + requestAnimationFrame(() => hardStop(t0)); +}; +requestAnimationFrame(() => hardStop(performance.now())); diff --git a/dev/saves.test.mjs b/dev/saves.test.mjs new file mode 100644 index 0000000..03a5516 --- /dev/null +++ b/dev/saves.test.mjs @@ -0,0 +1,223 @@ +/** + * Save system test (dev tool, run with Node — no browser): + * + * node dev/saves.test.mjs + * + * Covers the storage seam + state capture (js/save/): + * - SaveManager: the 10-slot localStorage bank — list/get/put/clear, + * hasAny/filledCount, corrupt-bank recovery, failed-write surfacing, + * and exportAll (the bulk download payload); + * - SaveManager.validateRecord: the shape a save must have; + * - SaveData.captureState: a live scene → a record (round-trips); + * - SaveData.prepareLoad/consumeRestore/resetRunState: the registry + * hand-off the two scenes use (galaxy from seed, discovery restored, + * the pending restore consumed exactly once). + */ +import { pathToFileURL } from 'node:url'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const file = (p) => pathToFileURL(join(__dirname, '../js', p)).href; + +// --- Load the real config (data/*.json) into the config singleton -------- +const { config } = await import(file('config/Config.js')); +const fs = await import('node:fs'); +const dataDir = join(__dirname, '../data'); +const configData = {}; +for (const f of fs.readdirSync(dataDir)) { + if (!f.endsWith('.json') || f === 'manifest.json') continue; + configData[f.replace(/\.json$/i, '')] = JSON.parse(fs.readFileSync(join(dataDir, f), 'utf8')); +} +config.init(configData); + +const { SaveManager, SAVE_FORMAT } = await import(file('save/SaveManager.js')); +const { + captureState, + prepareLoad, + consumeRestore, + resetRunState, + PENDING_RESTORE_KEY, +} = await import(file('save/SaveData.js')); +const { Galaxy } = await import(file('galaxy/Galaxy.js')); +const { Discovery } = await import(file('galaxy/Discovery.js')); + +let failures = 0; +const check = (label, cond) => { + console.log(`${cond ? '✔' : '✘ FAIL'} ${label}`); + if (!cond) failures++; +}; + +// A small deterministic galaxy (fast) + a fake "scene" for captureState. +const SEED = 'SAVETEST'; +const galaxy = Galaxy.create(SEED, { systemCount: 8 }); +const system = galaxy.byId.get(galaxy.currentSystemId); // the starting system + +const fakeScene = () => ({ + registry: { map: new Map(), set(k, v) { this.map.set(k, v); }, get(k) { return this.map.get(k); } }, + galaxy, + systemRecord: { name: system.name }, + ship: { x: 123.5, y: -77, rotation: 0.72 }, + discovery: new Discovery(540), + tetherField: { + tethers: [ + { id: 'home', x: 0, y: 0, level: 1, label: 'Terra' }, + { id: 'st:42', x: 300, y: 250, level: 2, label: 'Outpost' }, + ], + }, + playTimeMs: 123456, +}); + +const makeRec = (over = {}) => ({ + app: 'orbit', + format: 1, + savedAt: '2026-07-15T00:00:00.000Z', + seed: SEED, + galaxyName: galaxy.name, + currentSystemId: system.id, + systemName: system.name, + ship: { x: 123.5, y: -77, heading: 0.72 }, + discovery: { distance: 540, bySystem: {} }, + tethers: [{ id: 'home', x: 0, y: 0, level: 1, label: 'Terra' }], + playTimeMs: 123456, + ...over, +}); + +// --- a fake localStorage (in-memory, with failure modes) ------------------- +const makeStorage = (fail = false) => { + const m = new Map(); + return { + map: m, + fail, + getItem(k) { return m.has(k) ? m.get(k) : null; }, + setItem(k, v) { if (this.fail) throw new Error('QuotaExceeded'); m.set(k, String(v)); }, + removeItem(k) { m.delete(k); }, + }; +}; + +// --------------------------------------------------------------------------- +// SaveManager — the bank +// --------------------------------------------------------------------------- +{ + const storage = makeStorage(); + const sm = new SaveManager(storage); + + check('fresh bank: hasAny() is false', sm.hasAny() === false); + check('fresh bank: filledCount() is 0', sm.filledCount() === 0); + + const list0 = sm.listSlots(); + check('listSlots(): 10 slots, all empty', list0.length === 10 && list0.every((s) => s.record === null)); + check('listSlots(): sequential ids 1..10', list0[0].slot === 1 && list0[9].slot === 10); + + const rec = makeRec(); + sm.put(3, rec); + check('put(3) → get(3) round-trips', JSON.stringify(sm.get(3)) === JSON.stringify(rec)); + check('put(3) → hasAny()', sm.hasAny() === true); + check('put(3) → filledCount() 1', sm.filledCount() === 1); + check('put(3) → the slot shows in listSlots', JSON.stringify(sm.listSlots()[2].record) === JSON.stringify(rec)); + + const rec2 = makeRec({ savedAt: '2026-07-16T00:00:00.000Z' }); + sm.put(7, rec2); + check('put(7) → filledCount() 2', sm.filledCount() === 2); + + sm.put(3, makeRec({ seed: SEED, ship: { x: 1, y: 2, heading: 0 } })); + check('put(3) again overwrites the slot', sm.get(3).ship.x === 1 && sm.filledCount() === 2); + + sm.clear(7); + check('clear(7) empties the slot', sm.get(7) === null && sm.filledCount() === 1); + + // The bank lives in storage — a second manager on the same storage sees it. + const sm2 = new SaveManager(storage); + check('the bank persists in storage (new manager sees slot 3)', sm2.get(3).seed === SEED); + + // Corrupt bank → treated as empty (the UI then offers an overwrite). + storage.setItem(sm.key, '{definitely not json'); + const sm3 = new SaveManager(storage); + check('corrupt bank → readBank() is empty', sm3.hasAny() === false && sm3.filledCount() === 0); + sm3.put(1, rec); + check('writing into a corrupt bank recovers it', sm3.get(1).seed === SEED && sm3.get(3) === null); + + // Failed write (quota) → surfaced, the old value untouched. + storage.fail = true; + let threw = false; + try { sm3.put(2, rec); } catch { threw = true; } + check('failed write throws (the UI toasts it)', threw === true); + storage.fail = false; + + // exportAll — the bulk download payload: every filled slot, nothing else. + sm3.put(10, rec2); + const json = sm3.exportAll(); + let parsed = null; + try { parsed = JSON.parse(json); } catch { /* checked below */ } + check('exportAll(): parses as JSON', parsed !== null); + check('exportAll(): app/format present', parsed?.app === 'orbit' && parsed.format === SAVE_FORMAT); + check('exportAll(): exactly the filled slots', parsed && Object.keys(parsed.slots).length === 2); + check('exportAll(): slot records intact', parsed?.slots['1']?.seed === SEED && parsed?.slots['10']?.seed === SEED); + + // validateRecord — the shape contract. + check('validateRecord: null rejected', SaveManager.validateRecord(null) !== null); + check('validateRecord: no seed rejected', SaveManager.validateRecord(makeRec({ seed: null })) !== null); + check('validateRecord: bad ship rejected', SaveManager.validateRecord(makeRec({ ship: { x: 1 } })) !== null); + check('validateRecord: good record accepted', SaveManager.validateRecord(rec) === null); + check('validateRecord: tolerates missing tethers/discovery', SaveManager.validateRecord(makeRec({ tethers: undefined, discovery: undefined })) === null); +} + +// --------------------------------------------------------------------------- +// SaveData — capture → (travel) → prepare/consume, over a fake registry +// --------------------------------------------------------------------------- +{ + const scene = fakeScene(); + // Discover one object so the round-trip is non-trivial. + scene.discovery.check(system.id, 0, 0, [{ id: 'pl:0', x: 500, y: 0, radius: 12 }]); + check('setup: discovery has a hit', scene.discovery.isDiscovered(system.id, 'pl:0') === true); + + const rec = captureState(scene); + check('capture: seed carried', rec.seed === SEED); + check('capture: system identity carried', rec.currentSystemId === system.id && rec.systemName === system.name); + check('capture: ship carried', rec.ship.x === 123.5 && rec.ship.y === -77 && rec.ship.heading === 0.72); + check('capture: discovery carried', (rec.discovery.bySystem[system.id] ?? []).includes('pl:0')); + check('capture: tethers carried (both)', rec.tethers.length === 2 && rec.tethers[1].label === 'Outpost'); + check('capture: playtime carried', rec.playTimeMs === 123456); + + // A FRESH registry = a new browser session: prepareLoad rebuilds the + // galaxy from the seed and restores the discovery state. + const reg = { map: new Map(), set(k, v) { this.map.set(k, v); }, get(k) { return this.map.get(k); } }; + prepareLoad(reg, rec); + const staged = reg.get(PENDING_RESTORE_KEY); + + check('prepare: galaxy rebuilt from the seed', reg.get('galaxy') instanceof Galaxy && reg.get('galaxy').seed === SEED); + check('prepare: the rebuilt galaxy is the SAME galaxy', reg.get('galaxy').name === galaxy.name + && reg.get('galaxy').byId.get(system.id)?.name === system.name); + check('prepare: discovery restored', reg.get('discovery').isDiscovered(system.id, 'pl:0') === true); + check('prepare: a NEW undiscovered object stays undiscovered', + reg.get('discovery').isDiscovered(system.id, 'pl:999999') === false); + check('prepare: the live state is staged under the pending key', + staged !== null && staged.ship.x === 123.5 && staged.playTimeMs === 123456); + + // consumeRestore: exactly once. + const first = consumeRestore(reg); + const second = consumeRestore(reg); + check('consume: first read returns the staged state', first !== null && first.ship.y === -77); + check('consume: second read returns null (consumed)', second === null); + + // resetRunState: New Game clears the run-state seams. + reg.set('discovery', reg.get('discovery')); + reg.set(PENDING_RESTORE_KEY, { seed: SEED, ship: { x: 0, y: 0, heading: 0 } }); + resetRunState(reg); + check('reset: discovery cleared (fresh run)', reg.get('discovery') === null); + check('reset: staged restore cleared', reg.get(PENDING_RESTORE_KEY) === null); +} + +// prepareLoad must reject a record it can't trust. +{ + const reg = { map: new Map(), set(k, v) { this.map.set(k, v); }, get(k) { return this.map.get(k); } }; + let threw = false; + try { prepareLoad(reg, makeRec({ seed: null })); } catch { threw = true; } + check('prepare: invalid record throws', threw === true); +} + +if (failures > 0) { + console.error(`\n${failures} save-system test(s) FAILED`); + process.exit(1); +} +console.log('\nsave-system: all checks passed'); diff --git a/dev/system-hud.test.mjs b/dev/system-hud.test.mjs index 748b141..2b0a9ed 100644 --- a/dev/system-hud.test.mjs +++ b/dev/system-hud.test.mjs @@ -219,7 +219,7 @@ check('arrival timeline: name first, then the detail lines', ys.push(y); // seed y += 20; check('y layout unchanged by the decode', [scene.hudTitle, ...scene.hudDetail.map((d) => d.text)].every((t, i) => t.y === ys[i])); - check('hudEndY unchanged (tether readout keeps its slot)', scene.hudEndY === y + 6); + check('hudEndY tracks the dossier bottom', scene.hudEndY === y + 6); } // The toggle hit-area: the name plus the caret beside it — and nothing else. @@ -372,9 +372,10 @@ check('a manual toggle cancels the auto-fold — still OPEN past 10 s', check('and the auto-fold stays cancelled', scene2.autoCollapseArmed === false); // =========================================================================== -// 5) Tether readout: bottom-right above the MENU button, deck-aligned +// 5) Tether readout: UPPER-RIGHT of the screen, right-aligned, stacks DOWN // =========================================================================== { + scene.scale = { width: 1280, height: 720 }; scene.tetherHudTexts = []; scene.tetherField = { tethers: [ @@ -382,57 +383,39 @@ check('and the auto-fold stays cancelled', scene2.autoCollapseArmed === false); { level: 2, radius: 10240, label: 'Kethral' }, ], }; - const BW = 190; - const BH = 66; - const barX = 20; - const barW = 1160; // 20..1180 - const barH = 92; - const barY = 708; // 800 - 12 - 92 (the deck's strip) - const pad = 16; - const slotW = (barW - pad * 2) / 6; - const menuX = barX + pad + slotW * 5.5; // sixth slot centre (the MENU button) - const menu = { id: 'menu', slot: { x: menuX, y: barY + barH / 2 } }; - scene.actionBar = { - rect: { x: barX, y: barY, w: barW, h: barH }, // the deck panel strip - style: { bw: BW, bh: BH }, - slots: [menu], - }; GameScene.prototype.refreshTetherHud.call(scene); - const right = barX + barW; // the deck panel's right edge - const buttonTop = barY + barH / 2 - BH / 2; const lines = scene.tetherHudTexts; + const right = scene.scale.width - 16; // 16 px margin from the right edge check('one line per tether', lines.length === 2); check('the lines read level · range · anchor', lines[0].text === 'TETHER LV 1 · RANGE 5.1K PX · TERRA' && lines[1].text === 'TETHER LV 2 · RANGE 10.2K PX · KETHRAL'); - check('right-aligned to the deck (bottom bar) right edge', - lines.every((t) => Math.abs(t.x + t.width - right) < 0.001)); - check('the block sits above the button with 20 px of padding over its top', - Math.abs(lines[1].y + lines[1].height - (buttonTop - 20)) < 0.001); - check('lines stack UPWARD from the button (bottom-anchored)', - lines[0].y < lines[1].y && lines[1].y < buttonTop && lines[0].y < buttonTop); + check('right-aligned to the screen edge (16 px margin)', + lines.every((t) => t.x === right)); + check('top-anchored 16 px from the top, stacking DOWNWARD', + Math.abs(lines[0].y - 16) < 0.001 && + Math.abs(lines[1].y - (16 + 19 + 4)) < 0.001); - // A third tether appears: the block stays anchored to the button — the - // new line takes the bottom slot (closest to the button) and the old - // lines step up by exactly one line (19 px fake height + 2 px gap). - const before = lines.map((t) => ({ x: t.x, y: t.y })); + // A third tether appears: the block stays pinned to the top-right corner — + // the new line takes the bottom slot, the old lines keep their place. scene.tetherField.tethers.push({ level: 1, radius: 2560, label: null }); GameScene.prototype.refreshTetherHud.call(scene); const after = scene.tetherHudTexts; - check('added tether: new line takes the bottom slot, old lines step up', + check('added tether: new line takes the bottom slot, old lines keep place', after.length === 3 && - Math.abs(after[2].y + after[2].height - (buttonTop - 20)) < 0.001 && - Math.abs(after[0].y - (before[0].y - 21)) < 0.001 && - Math.abs(after[1].y - (before[1].y - 21)) < 0.001 && - Math.abs(after[2].x + after[2].width - right) < 0.001 && + Math.abs(after[0].y - 16) < 0.001 && + Math.abs(after[1].y - 39) < 0.001 && + Math.abs(after[2].y - 62) < 0.001 && + after.every((t) => t.x === right) && after[2].text === 'TETHER LV 1 · RANGE 2.6K PX'); - // No deck (disabled) → the old left-column fallback under the dossier. + // No deck (disabled): the readout still lives in the upper-right corner — + // it no longer needs the deck as an anchor. scene.actionBar = null; GameScene.prototype.refreshTetherHud.call(scene); - check('no deck → falls back to the left column under the dossier', - scene.tetherHudTexts[0].x === 16 && scene.tetherHudTexts[0].y === scene.hudEndY); + check('no deck → still in the upper-right corner', + scene.tetherHudTexts[0].x === right && Math.abs(scene.tetherHudTexts[0].y - 16) < 0.001); } // =========================================================================== diff --git a/dev/wdshot.mjs b/dev/wdshot.mjs new file mode 100644 index 0000000..a0d86a8 --- /dev/null +++ b/dev/wdshot.mjs @@ -0,0 +1,131 @@ +// One-shot headless-Firefox screenshot via geckodriver (W3C WebDriver). +// +// node dev/wdshot.mjs [setupExpression] [timeoutMs=20000] +// +// The setup expression (optional) runs right before the capture — it may +// return a Promise; the runner waits for it to settle (so scene state / +// animations can be driven first). WebDriver's /screenshot endpoint is +// used (not canvas.toDataURL — a WebGL canvas without +// preserveDrawingBuffer captures black). +import { spawn, execFileSync } from 'node:child_process'; +import { existsSync, readdirSync, writeFileSync } from 'node:fs'; + +const [url, out, setup, timeoutMs = '20000'] = process.argv.slice(2); +if (!url || !out) { + console.error('usage: node dev/wdshot.mjs [setupExpression] [timeoutMs]'); + process.exit(2); +} + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +let geckodriver; +try { + geckodriver = execFileSync('sh', ['-c', 'command -v geckodriver'], { encoding: 'utf8' }).trim(); +} catch { + console.error('need geckodriver on PATH'); + process.exit(2); +} +const firefoxCandidates = []; +try { + const ff = execFileSync('sh', ['-c', 'command -v firefox'], { encoding: 'utf8' }).trim(); + if (ff) firefoxCandidates.push(ff); +} catch { /* not on PATH */ } +try { + const snapRev = readdirSync('/snap/firefox', { withFileTypes: true }).map((d) => d.name); + for (const rev of [null, ...snapRev]) { + const base = rev ? `/snap/firefox/${rev}` : '/snap/firefox/current'; + const p = `${base}/usr/lib/firefox/firefox`; + if (existsSync(p)) firefoxCandidates.push(p); + } +} catch { /* non-snap install */ } +if (firefoxCandidates.length === 0) { + console.error('no firefox binary found'); + process.exit(2); +} +const PORT = 4444 + (process.pid % 100); +const driver = spawn(geckodriver, ['--port', String(PORT), '--log', 'fatal'], { stdio: 'ignore' }); +const base = `http://127.0.0.1:${PORT}`; + +const fail = (msg, code = 1) => { + try { driver.kill('SIGKILL'); } catch { /* dead */ } + console.error(msg); + process.exit(code); +}; +const wd = async (method, path, body) => { + const res = await fetch(base + path, { + method, + headers: { 'Content-Type': 'application/json' }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + const text = await res.text(); + let data = null; + try { data = JSON.parse(text); } catch { /* empty */ } + if (!res.ok) throw new Error(`WebDriver ${method} ${path} → ${res.status}: ${text.slice(0, 300)}`); + return data; +}; + +try { + let up = false; + for (let i = 0; i < 60; i++) { + try { + const v = await (await fetch(`${base}/status`)).json(); + if (v?.value?.ready || v?.status === 'ok' || v?.value) { up = true; break; } + } catch { /* not up */ } + await sleep(250); + } + if (!up) fail('geckodriver never came up'); + + let sess; + let lastErr = null; + for (const ff of firefoxCandidates) { + try { + sess = await wd('POST', '/session', { + capabilities: { + alwaysMatch: { + browserName: 'firefox', + 'moz:firefoxOptions': { args: ['-headless'], binary: ff }, + // This box is CPU-starved: boot + animation can outlive the + // driver's 30 s default. Raise the script budget. + timeouts: { script: 180000 }, + }, + }, + }); + break; + } catch (err) { lastErr = String(err?.message ?? err); } + } + if (!sess) fail(`no firefox candidate accepted: ${lastErr}`); + const sid = sess.value?.sessionId; + if (!sid) fail('no session id'); + + // Belt & braces: also set the session script timeout explicitly. + await wd('POST', `/session/${sid}/timeouts`, { script: 180000 }).catch((e) => { + console.error('timeouts: ' + String(e?.message ?? e).slice(0, 120)); + }); + + await wd('POST', `/session/${sid}/url`, { url }); + + if (setup) { + // Multiple scripts, each held as a pending Promise under execute/sync + // (geckodriver honours the session script timeout set above — 3 min). + // setup may be a single script string or a JSON array of them. + let steps = [setup]; + try { const parsed = JSON.parse(setup); if (Array.isArray(parsed)) steps = parsed; } catch { /* single script */ } + for (let i = 0; i < steps.length; i++) { + const script = `return (async () => { ${steps[i]} })();`; + try { + await wd('POST', `/session/${sid}/execute/sync`, { script, args: [] }); + } catch (err) { + console.error(`setup[${i}]: ` + String(err?.message ?? err).slice(0, 200)); + } + } + } + + const shot = await wd('GET', `/session/${sid}/screenshot`); + if (!shot?.value) fail('no screenshot in response'); + const b64 = String(shot.value).replace(/^data:image\/\w+;base64,/, ''); + writeFileSync(out, Buffer.from(b64, 'base64')); + console.log('saved ' + out); + + await wd('DELETE', `/session/${sid}`).catch(() => {}); +} finally { + try { driver.kill('SIGKILL'); } catch { /* dead */ } +} diff --git a/js/save/SaveData.js b/js/save/SaveData.js new file mode 100644 index 0000000..51b953c --- /dev/null +++ b/js/save/SaveData.js @@ -0,0 +1,126 @@ +/** + * SaveData — a save as DATA: what a game IS, and how a record becomes + * a game again. + * + * A record is small because the galaxy regenerates from its seed + * (PROJECT_NOTES: "a save = seed + player state"): + * + * { app, format, savedAt, + * seed, galaxyName, currentSystemId, systemName, + * ship: { x, y, heading }, + * discovery: Discovery.toJSON(), + * tethers: [{ id, x, y, level, label }], + * playTimeMs } + * + * captureState(scene) — GameScene → record (the Save panel calls it) + * prepareLoad(registry, r) — record → shared registry: rebuilds the + * galaxy from the seed, restores discovery, + * and parks the ship/tether/playtime state + * under 'orbit.pendingRestore' + * consumeRestore(registry) — GameScene.create() picks the parked state + * off the registry (and clears it) + * resetRunState(registry) — "New Game" from the menu: a fresh run must + * not inherit the previous one's discovery + * + * Pure module (no Phaser) — the registry is just {get,set}, so Node + * tests can drive it (dev/saves.test.mjs). + */ +import { config } from '../config/Config.js'; +import { Galaxy } from '../galaxy/Galaxy.js'; +import { Discovery } from '../galaxy/Discovery.js'; +import { SAVE_FORMAT, SaveManager } from './SaveManager.js'; + +export const PENDING_RESTORE_KEY = 'orbit.pendingRestore'; + +/** + * Snapshot the live game into a save record. + * + * @param {object} scene the GameScene (reads: registry, galaxy, + * systemRecord, ship, discovery, tetherField, playTimeMs) + * @returns {object} the record (ready for SaveManager.put) + */ +export function captureState(scene) { + const galaxy = scene.galaxy; + if (!galaxy) throw new Error('no galaxy to save'); + const rec = { + app: 'orbit', + format: SAVE_FORMAT, + savedAt: new Date().toISOString(), + seed: scene.registry.get('seed') ?? galaxy.seed, + galaxyName: galaxy.name, + currentSystemId: galaxy.currentSystemId, + systemName: scene.systemRecord?.name ?? null, + ship: { + x: Number(scene.ship.x), + y: Number(scene.ship.y), + heading: Number(scene.ship.rotation), + }, + discovery: scene.discovery ? scene.discovery.toJSON() : { distance: 540, bySystem: {} }, + tethers: (scene.tetherField?.tethers ?? []).map((t) => ({ + id: t.id, + x: t.x, + y: t.y, + level: t.level, + label: t.label ?? '', + })), + playTimeMs: Math.round(scene.playTimeMs ?? 0), + }; + const err = SaveManager.validateRecord(rec); + if (err) throw new Error(err); + return rec; +} + +/** + * Stage a record for play: rebuild the galaxy from its seed, restore the + * discovery state, and park the live-state (ship/tethers/playtime) in the + * registry — the next GameScene.create() consumes it via + * consumeRestore(). + * + * @throws {Error} when the record fails validation — the caller toasts it. + */ +export function prepareLoad(registry, record) { + const err = SaveManager.validateRecord(record); + if (err) throw new Error(err); + + const seed = String(record.seed).trim(); + const galaxy = Galaxy.create(seed); + // Honour the saved current system when the roster knows it (it should — + // same seed ⇒ same roster). + if (typeof record.currentSystemId === 'string' && galaxy.byId.has(record.currentSystemId)) { + galaxy.currentSystemId = record.currentSystemId; + } + + registry.set('galaxy', galaxy); + registry.set('seed', seed); + registry.set('discovery', Discovery.fromJSON(record.discovery ?? { distance: 540, bySystem: {} })); + registry.set(PENDING_RESTORE_KEY, { + ship: record.ship, + tethers: Array.isArray(record.tethers) ? record.tethers : [], + playTimeMs: Number(record.playTimeMs) || 0, + }); +} + +/** + * GameScene.create() seam: take the parked restore state (if any) off the + * registry and clear the key. Returns null for a fresh run. + */ +export function consumeRestore(registry) { + const r = registry.get(PENDING_RESTORE_KEY); + if (r === undefined || r === null) return null; + registry.set(PENDING_RESTORE_KEY, null); + return r; +} + +/** + * "New Game" from the main menu: a fresh run must not inherit the + * previous one's discovery state or a half-staged restore. + */ +export function resetRunState(registry) { + registry.set('discovery', null); + registry.set(PENDING_RESTORE_KEY, null); +} + +/** Distance used when a save predates discovery (defensive fallback). */ +export function defaultDiscoveryDistance() { + return config.get('game.discovery.distance', 540); +} diff --git a/js/save/SaveManager.js b/js/save/SaveManager.js new file mode 100644 index 0000000..a10c55e --- /dev/null +++ b/js/save/SaveManager.js @@ -0,0 +1,179 @@ +/** + * SaveManager — the localStorage bank of saved games. + * + * One blob per browser (data/save.json → storageKey): the whole bank is + * a single JSON document + * + * { "format": 1, "slots": { "3": { …save record… }, "7": { … } } } + * + * keyed 1..`save.slots` (10 by default) — exactly what the pop-up shows. + * Everything about the RECORD's shape is owned by js/save/SaveData.js + * (captureState/prepareLoad); this class only owns the BLOB: read, + * write, clear, list, export (the "download all" button). + * + * Pure (no Phaser, no DOM): the storage is injected (defaults to + * window.localStorage), so it's Node-testable (dev/saves.test.mjs) and + * trivially swappable for IndexedDB later. + * + * const sm = new SaveManager(); + * sm.hasAny(); // → is Load Game enabled? + * sm.listSlots(); // → [{slot:1, record:null}, …] ×10 + * sm.put(3, record); sm.get(3); sm.clear(3); + * sm.exportAll(); // → pretty JSON of every slot (download) + * + * Errors: storage that throws (private mode / quota) propagates — the + * UI layer (SavePanel) catches and toasts it; nothing here crashes the + * game. + */ +import { config } from '../config/Config.js'; + +const DEFAULT_KEY = 'orbit.saves.v1'; +export const SAVE_FORMAT = 1; + +export class SaveManager { + /** + * @param {Storage|null} [storage] a localStorage-like ({getItem,setItem}) + * — defaults to globalThis.localStorage when present. + * @param {string} [key] the storage key — defaults to save.storageKey. + */ + constructor(storage = null, key = null) { + this.storage = storage !== null + ? storage + : (typeof globalThis.localStorage !== 'undefined' ? globalThis.localStorage : null); + this.key = key ?? config.get('save.storageKey', DEFAULT_KEY); + } + + /** Is there any storage to live in at all (headless dev = no)? */ + get available() { + return !!this.storage; + } + + // ------------------------------------------------------------------ + // The blob + // ------------------------------------------------------------------ + + /** @returns {{format:number, slots:Record}} — empty bank on any failure */ + readBank() { + if (!this.storage) return { format: SAVE_FORMAT, slots: {} }; + let raw = null; + try { + raw = this.storage.getItem(this.key); + } catch { + return { format: SAVE_FORMAT, slots: {} }; + } + if (!raw) return { format: SAVE_FORMAT, slots: {} }; + try { + const data = JSON.parse(raw); + const slots = (data && data.slots && typeof data.slots === 'object' && !Array.isArray(data.slots)) + ? data.slots + : {}; + return { format: typeof data?.format === 'number' ? data.format : SAVE_FORMAT, slots }; + } catch { + // Corrupt blob — treat as empty (the UI offers a fresh write). + return { format: SAVE_FORMAT, slots: {} }; + } + } + + writeBank(bank) { + if (!this.storage) throw new Error('no storage available'); + this.storage.setItem(this.key, JSON.stringify(bank)); + } + + // ------------------------------------------------------------------ + // Slots + // ------------------------------------------------------------------ + + /** How many slots the game has (data/save.json → slots). */ + slotCount() { + return Math.max(1, Math.min(50, Math.round(config.get('save.slots', 10)))); + } + + /** @returns {boolean} true if at least one slot holds a save */ + hasAny() { + const bank = this.readBank(); + for (const v of Object.values(bank.slots)) if (v) return true; + return false; + } + + /** @returns {Array<{slot:number, record:object|null}>} all slots, in order */ + listSlots() { + const bank = this.readBank(); + const out = []; + for (let i = 1; i <= this.slotCount(); i++) { + const record = bank.slots[String(i)]; + out.push({ slot: i, record: record && typeof record === 'object' ? record : null }); + } + return out; + } + + /** @returns {object|null} the save record in `slot` (1-based) */ + get(slot) { + const bank = this.readBank(); + const rec = bank.slots[String(slot)]; + return rec && typeof rec === 'object' ? rec : null; + } + + /** Write `record` to `slot` (1-based). Throws on storage failure. */ + put(slot, record) { + if (!Number.isInteger(slot) || slot < 1) throw new Error(`invalid slot ${slot}`); + const bank = this.readBank(); + bank.slots[String(slot)] = record; + bank.format = SAVE_FORMAT; + this.writeBank(bank); + } + + /** Empty `slot` (1-based). */ + clear(slot) { + const bank = this.readBank(); + delete bank.slots[String(slot)]; + this.writeBank(bank); + } + + /** How many slots are filled. */ + filledCount() { + return this.listSlots().filter((s) => s.record !== null).length; + } + + // ------------------------------------------------------------------ + // Export — the "download a local copy of ALL saved games" button. + // ------------------------------------------------------------------ + + /** + * Every saved game, one pretty-printed JSON document: + * + * { "app":"orbit", "format":1, "exportedAt":"…", "slots":{ "1":{…} } } + * + * @returns {string} JSON (empty slots are omitted) + */ + exportAll() { + const out = { + app: 'orbit', + format: SAVE_FORMAT, + exportedAt: new Date().toISOString(), + slots: {}, + }; + for (const { slot, record } of this.listSlots()) { + if (record) out.slots[String(slot)] = record; + } + return JSON.stringify(out, null, 2); + } + + // ------------------------------------------------------------------ + // Records + // ------------------------------------------------------------------ + + /** + * Sanity-check a save record before it's trusted (write or load): + * it must be an object with a seed and a ship position. + * + * @returns {string|null} an error message, or null when the record passes + */ + static validateRecord(rec) { + if (!rec || typeof rec !== 'object') return 'corrupt save data'; + if (!String(rec.seed ?? '').trim()) return 'save is missing its galaxy seed'; + if (!rec.ship || typeof rec.ship.x !== 'number' || typeof rec.ship.y !== 'number') { + return 'save is missing its ship position'; + } + return null; + } +} diff --git a/js/scenes/GameScene.js b/js/scenes/GameScene.js index 1d6461b..01e2147 100644 --- a/js/scenes/GameScene.js +++ b/js/scenes/GameScene.js @@ -13,6 +13,10 @@ import { AsteroidCluster } from '../entities/AsteroidCluster.js'; import { Starfield } from '../visuals/Starfield.js'; import { DiscoveryCompass, circleInView } from '../ui/DiscoveryCompass.js'; import { ActionBar } from '../ui/ActionBar.js'; +import { MenuSubBar } from '../ui/MenuSubBar.js'; +import { SavePanel } from '../ui/SavePanel.js'; +import { SaveManager } from '../save/SaveManager.js'; +import { consumeRestore } from '../save/SaveData.js'; import { TetherField } from '../tether/TetherField.js'; const FONT_FALLBACK = "'Segoe UI', 'Helvetica Neue', Arial, sans-serif"; @@ -116,10 +120,18 @@ export class GameScene extends Phaser.Scene { this.cameraFollowShip = config.get('game.camera.followShip', true); this.cameraFollowRate = config.get('game.camera.followRate', 3.0); // 1/s + // Session time (the save's playTimeMs). Accumulates in update(); + // a LOAD replaces it with the saved value (applyRestore). + this.playTimeMs = 0; + // We are, after all, in a system. Establish the galaxy (shared // registry), the current system record, and its lazy contents — the // dossier below and the world's other planets both read them. this.ensureGalaxy(); + // A LOAD from the save panel staged its live state (ship / tethers / + // playtime) in the registry (js/save/SaveData.js) — the ship and the + // tether field don't exist yet, so apply it once they do (below). + this._pendingRestore = consumeRestore(this.registry); this.systemRecord = this.galaxy.currentSystem(); this.systemContent = this.galaxy.ensureContent(this.systemRecord.id); // The home world's name — dealt from the planet name bank by the @@ -225,6 +237,13 @@ export class GameScene extends Phaser.Scene { this.tetherToastAt = null; this.refreshTetherHud(); + // The staged restore (if this run was LOADED): ship back where it + // was, the saved tether field, the saved session time. + if (this._pendingRestore) { + this.applyRestore(this._pendingRestore); + this._pendingRestore = null; + } + // Discovery: which objects the player has found (within discovery // distance of an edge — data/game.json), tracked per system. Kept in // the shared registry so it survives scene restarts; serializable for @@ -257,14 +276,27 @@ export class GameScene extends Phaser.Scene { // data/builds.json) — whose behavior and panels come next. this.actionBar = deckEnabled ? new ActionBar(this, { - onAction: (id) => { - // TODO(command deck): 'research' → research panel, 'build' → build - // panel, 'ship' → ship screen, 'menu' → main menu. - console.info(`[orbit] command deck: ${id}`); - }, + onAction: (id) => this.deckAction(id), }) : null; + // ---- The SAVE SYSTEM — the deck's Menu sub-bar + the slot pop-up -- + // The sub-bar folds up out of the Menu button (Save Game / Load Game + // [grayed while no saves exist] / Return to Main Menu); both save + // verbs open the 10-slot pop-up, which owns the overwrite + load + // confirmations and the bulk download (js/ui/MenuSubBar.js + + // js/ui/SavePanel.js, config in data/save.json). + this.saveManager = new SaveManager(); // the localStorage bank + this.menuSubBar = new MenuSubBar(this, { + anchor: this.menuAnchor(), + onAction: (id) => this.subBarAction(id), + }); + this.savePanel = new SavePanel(this, { + // Load confirmed: the restore is staged — hand back to the menu + // (its New Game is the "start" the staged restore rides on). + onLoadComplete: () => this.returnToMenu(), + }); + // The tether readout anchors above the deck's MENU button — re-render // it now that the deck exists (the boot pass had nothing to anchor to). this.refreshTetherHud(); @@ -291,6 +323,11 @@ export class GameScene extends Phaser.Scene { // player's tether range clamps to the union boundary — the target // marker lands on the barrier line itself. this.input.on('pointerdown', (pointer) => { + // The save pop-up is MODAL — while it's up it owns all input + // (its scrim / cards / dialog eat the click; the world stays put). + if (this.savePanel && this.savePanel.isOpen) return; + // A click ON the sub-bar is the sub-bar's (its buttons live there). + if (this.menuSubBar && this.menuSubBar.isOpen && this.menuSubBar.contains(pointer.x, pointer.y)) return; if (this.actionBar && this.actionBar.contains(pointer.x, pointer.y)) return; if (this.compass.contains(pointer.x, pointer.y)) return; if (this.hudTitleContains(pointer.x, pointer.y)) { @@ -307,6 +344,10 @@ export class GameScene extends Phaser.Scene { this.hideHint(); }); + // ESC: the topmost open thing closes — the confirm dialog, then the + // save pop-up, then the sub-bar. + this.input.keyboard?.on('keydown-ESC', () => this.escAction()); + // The solid keep-outs run AFTER the arcade world has integrated the // ship's motion (the scene's 'postupdate' fires after the physics step // has moved the sprite). Constrained in update() instead, the body @@ -448,7 +489,7 @@ export class GameScene extends Phaser.Scene { line(report.summary, { fontFamily: fam, fontSize: '12px', color: '#54608a' }); line(`seed ${this.galaxy.seed}`, { fontFamily: fam, fontSize: '11px', color: '#3d476b' }); this.hudDetail = detail; - this.hudEndY = y + 6; // bottom of the dossier block (tether readout's fallback anchor) + this.hudEndY = y + 6; // bottom of the dossier block // --- State + the arrival timeline (anchored on the first frame) ----- this.hudPhase = 'constructing'; // 'constructing' | 'expanded' | 'collapsing' | 'collapsed' @@ -614,12 +655,9 @@ export class GameScene extends Phaser.Scene { * whenever the field changes (TetherField.onChange), so a future * "tether upgrade" build shows up here for free. * - * Sits in the BOTTOM-RIGHT, stacked just above the command deck's MENU - * button, right-aligned to the DECK's right edge. The block is - * bottom-anchored, so when more tethers are added they grow UPWARD, away - * from the button. If the deck is disabled there's no button to anchor - * to — the readout falls back to the left column under the dossier - * (hudEndY). + * Sits in the UPPER-RIGHT of the screen, right-aligned, stacking + * downward — clear of the dossier (upper-left) and the toasts + * (top-centre). Multiple tethers grow downward from the top margin. */ refreshTetherHud() { if (!this.tetherField) return; @@ -628,7 +666,8 @@ export class GameScene extends Phaser.Scene { const fam = BODY_FONT(); const neon = toCss(themeColor('neon', 0x00e5ff)); - // Build first so the real widths/heights are known, then place. + const pad = 16; // margin from the screen edge + const made = this.tetherField.tethers.map((t) => { const label = t.label ? ` · ${String(t.label).toUpperCase()}` : ''; return this.add @@ -638,35 +677,17 @@ export class GameScene extends Phaser.Scene { color: neon, letterSpacing: 1, }) - .setOrigin(0, 0) + .setOrigin(1, 0) // top-right anchor: right edge sticks to the margin .setScrollFactor(0) // UI — pinned to the screen .setDepth(30); }); - // The deck's MENU button (last slot by default) is the anchor. - const bar = this.actionBar; - const menu = - bar && Array.isArray(bar.slots) && bar.slots.length > 0 - ? bar.slots.find((s) => s.id === 'menu') ?? bar.slots[bar.slots.length - 1] - : null; - if (menu) { - // Right edge on the DECK panel's, closest line's bottom a little - // above the button's top (a few px of padding over the bar). - const right = bar.rect.x + bar.rect.w; - let y = menu.slot.y - bar.style.bh / 2 - 20; - for (let i = made.length - 1; i >= 0; i--) { - const t = made[i]; - y -= t.height; - t.setPosition(right - t.width, y); - y -= 2; // the line above - } - } else { - // No deck (disabled): the old left-column spot under the dossier. - let y = this.hudEndY; - for (const t of made) { - t.setPosition(16, y); - y += 18; - } + // Upper-right corner, lines stack downward. + const right = this.scale.width - pad; + let y = pad; + for (const t of made) { + t.setPosition(right, y); + y += t.height + 4; } this.tetherHudTexts = made; } @@ -679,6 +700,13 @@ export class GameScene extends Phaser.Scene { this.tweens.update(); this.updateHud(_time); // the dossier: decode, caret, auto-fold, toggle this.ship.update(_time, delta); + // Session time (saved with the game) — capped so a backgrounded tab + // can't fast-forward it. + this.playTimeMs = (this.playTimeMs ?? 0) + Math.min(delta, 100); + // The save UI (the sub-bar + the pop-up) drive their own per-frame + // state (folds, decodes, toasts, the confirm dialog). + this.menuSubBar?.update(_time, delta); + this.savePanel?.update(_time); // The clusters are ALIVE: each rock tumbles, the loose group drifts, // the dust orbits. (The keep-out constraint runs in onPostUpdate, // after the physics step has moved the ship.) @@ -967,10 +995,122 @@ export class GameScene extends Phaser.Scene { }); } + /** + * The deck's button presses (js/ui/ActionBar.js → onAction). + * 'menu' is the save system's door (menuAction below); the other + * slots are still seams for the player's loop. + */ + deckAction(id) { + if (id === 'menu') { + this.menuAction(); + return; + } + console.info(`[orbit] command deck: ${id}`); + } + + /** + * The MENU button: fold the sub-bar up / fold it back down. + * (While the pop-up is open, the button closes the pop-up instead.) + */ + menuAction() { + if (config.get('save.subBar.enabled', true) !== true) return; + if (this.savePanel && this.savePanel.isOpen) { + this.savePanel.close(); + return; + } + if (this.menuSubBar && this.menuSubBar.isOpen) { + this.menuSubBar.close(); + return; + } + // Load Game is live only when the bank holds at least one save. + this.menuSubBar.setDisabled('load', !this.saveManager.hasAny()); + this.menuSubBar.open(); + } + + /** The sub-bar's button presses (js/ui/MenuSubBar.js → onAction). */ + subBarAction(id) { + if (id === 'save') { + this.savePanel.show('save'); + return; + } + if (id === 'load') { + if (this.saveManager.hasAny()) this.savePanel.show('load'); + return; + } + if (id === 'mainMenu') { + this.returnToMenu(); + return; + } + } + + /** ESC: topmost open thing first — confirm dialog → pop-up → sub-bar. */ + escAction() { + if (this.savePanel) { + if (this.savePanel.confirm.isOpen) { + this.savePanel.confirm.cancel(); + return; + } + if (this.savePanel.isOpen) { + this.savePanel.close(); + return; + } + } + if (this.menuSubBar && this.menuSubBar.isOpen) this.menuSubBar.close(); + } + + /** Return to the main menu (the sub-bar's last button). */ + returnToMenu() { + this.savePanel?.close(); + this.menuSubBar?.dismiss(); + this.scene.start('MenuScene'); + } + + /** The sub-bar's anchor: the MENU button's center + top edge. */ + menuAnchor() { + const menuSlot = this.actionBar?.slots?.find((s) => s.id === 'menu'); + if (menuSlot) { + return { + x: menuSlot.slot.x, + y: menuSlot.slot.y - this.actionBar.style.bh / 2, + w: this.actionBar.style.bw, + }; + } + // Deck disabled (dev): a virtual button at the bottom right. + return { x: this.scale.width - 96, y: this.scale.height - 58, w: 120 }; + } + + /** + * Apply a staged restore (js/save/SaveData.js → prepareLoad parked it): + * the ship back where it was, the saved tether field (the level-1 home + * tether is just a saved entry — the field is rebuilt from the record), + * and the saved session time. Discovery + galaxy are already restored + * in the registry (this scene's create() read them). + */ + applyRestore(r) { + if (r.ship) { + this.ship.setPosition(Number(r.ship.x) || 0, Number(r.ship.y) || 0); + if (typeof r.ship.heading === 'number') this.ship.rotation = r.ship.heading; + } + if (Array.isArray(r.tethers) && r.tethers.length > 0) { + for (const t of [...this.tetherField.tethers]) this.tetherField.remove(t.id); + for (const t of r.tethers) { + if (!t || typeof t.id !== 'string') continue; + this.tetherField.add(t.id, Number(t.x) || 0, Number(t.y) || 0, t.level ?? 1, t.label ?? ''); + } + this.refreshTetherHud(); + } + this.playTimeMs = Number(r.playTimeMs) || 0; + // The camera was centered on the spawn — recentre on the restored ship. + this.cameras.main.setScroll(this.ship.x - this.scale.width / 2, this.ship.y - this.scale.height / 2); + this.playSfx('construct'); + } + shutdown() { this.starfield?.destroy(); this.compass?.destroy(); this.actionBar?.destroy(); + this.menuSubBar?.destroy(); + this.savePanel?.destroy(); this.tetherField?.destroy(); } } diff --git a/js/scenes/MenuScene.js b/js/scenes/MenuScene.js index 0f7f352..57fb9ec 100644 --- a/js/scenes/MenuScene.js +++ b/js/scenes/MenuScene.js @@ -11,6 +11,9 @@ import { Rng } from '../utils/Rng.js'; import { NameGenerator } from '../utils/NameGenerator.js'; import { ScrambleDecode, DECODE_DURATION } from '../utils/Decode.js'; import { Galaxy } from '../galaxy/Galaxy.js'; +import { SaveManager } from '../save/SaveManager.js'; +import { resetRunState } from '../save/SaveData.js'; +import { SavePanel } from '../ui/SavePanel.js'; const FONT_FALLBACK = "'Segoe UI', 'Helvetica Neue', Arial, sans-serif"; const SEED_STORAGE_KEY = 'orbit.galaxySeed'; @@ -142,6 +145,28 @@ export class MenuScene extends Phaser.Scene { ); this.newGameBtn.setAlpha(0); + // ---- Load Game — resumes a saved run (the same 10-slot bank as the + // in-game panel, LOAD mode only; js/ui/SavePanel.js). Grayed out while + // the bank is empty — the feature is visible, the door is locked. + const lb = menu.buttons?.loadGame ?? {}; + this.saveManager = new SaveManager(); + this.loadGameBtn = new MenuButton( + this, + (lb.position?.x ?? 0.5) * w, + (lb.position?.y ?? 0.675) * h, + lb.label ?? 'Load Game', + () => this.openLoadPanel(), + { + fontSize: lb.fontSize ?? 17, + paddingX: lb.paddingX ?? 32, + paddingY: lb.paddingY ?? 11, + textColor: menu.colors?.buttonText ?? '#eaf6ff', + borderColor: toCss('#7d92c4'), + }, + ); + this.loadGameBtn.setAlpha(0); + if (!this.saveManager.hasAny()) this.loadGameBtn.setDisabled(true); + // ---- Galaxy seed panel this.createSeedPanel(menu, headerFont, bodyFont, cx, h); const panelFade = this.seedIntroTargets; @@ -186,6 +211,9 @@ export class MenuScene extends Phaser.Scene { this.intro(340, () => { this.tweens.add({ targets: this.newGameBtn, alpha: 1, y: this.newGameBtn.y - 12, duration: 420, ease: 'Sine.easeOut' }); }); + this.intro(460, () => { + this.tweens.add({ targets: this.loadGameBtn, alpha: 1, duration: 340, ease: 'Sine.easeOut' }); + }); this.intro(500, () => { const targets = [...this.seedIntroTargets, this.rerollBtn]; this.tweens.add({ targets, alpha: 1, duration: 420, ease: 'Sine.easeOut' }); @@ -195,6 +223,8 @@ export class MenuScene extends Phaser.Scene { // ---- input: typing goes to the seed field; a click elsewhere blurs it this.input.keyboard.on('keydown', (e) => this.onSeedKey(e)); + // ESC with the load pop-up open closes the topmost thing first. + this.input.keyboard.on('keydown-ESC', () => this.escLoadPanel()); this.input.on('pointerdown', (pointer) => { // v4 (Giedi) quirk: no Pointer.over() — the scene input plugin's // hitTestPointer() returns the hit game objects (or []). @@ -423,6 +453,9 @@ export class MenuScene extends Phaser.Scene { this.dead = true; // no further input while we cut away this.setSeedFocus(false); this.drawSeed(); + // A fresh run must not inherit the previous one's discovery state or + // a half-staged restore (js/save/SaveData.js). + resetRunState(this.registry); const galaxy = Galaxy.create(seed); this.registry.set('galaxy', galaxy); this.registry.set('seed', seed); @@ -448,6 +481,42 @@ export class MenuScene extends Phaser.Scene { } } + // ------------------------------------------------------------------ + // Load Game — the same slot bank, from the menu side + // ------------------------------------------------------------------ + + /** + * The menu's Load Game button: the 10-slot pop-up in LOAD mode (no + * captureState needed here — a menu load only reads the bank and stages + * the restore in the registry; GameScene.create() consumes it). + */ + openLoadPanel() { + if (this.dead || !this.saveManager.hasAny()) return; + if (!this.savePanel) { + this.savePanel = new SavePanel(this, { + onLoadComplete: () => { + if (this.dead) return; + this.dead = true; // no further input while we cut away + // The same glitch-out cut as New Game. + this.overlay?.trigger(240, 1); + this.title?.setBurst(1); + this.time.delayedCall(200, () => this.scene.start('GameScene')); + }, + }); + } + this.savePanel.show('load'); + } + + /** ESC with the load pop-up open: topmost thing first (confirm → panel). */ + escLoadPanel() { + if (!this.savePanel) return; + if (this.savePanel.confirm.isOpen) { + this.savePanel.confirm.cancel(); + return; + } + if (this.savePanel.isOpen) this.savePanel.close(); + } + /** Per-frame: overlay, title glitch, status dot, seed decode. */ update(time, delta) { // Phaser v4 (Giedi) quirk: the engine calls the scene's update() but never @@ -463,6 +532,7 @@ export class MenuScene extends Phaser.Scene { this.overlay.update(time, delta); this.title.update(time, delta); this.updateDecode(time); + this.savePanel?.update(time); // the load pop-up (folds, decodes, confirm) if (this.statusDot) { this.statusDot.setAlpha(0.35 + 0.5 * (0.5 + 0.5 * Math.sin(time * 0.004))); } @@ -474,6 +544,7 @@ export class MenuScene extends Phaser.Scene { this.titleBloom?.destroy(); this.overlay?.destroy(); this.title?.destroy(); + this.savePanel?.destroy(); } loadSavedSeed() { diff --git a/js/ui/ConfirmOverlay.js b/js/ui/ConfirmOverlay.js new file mode 100644 index 0000000..39ab07e --- /dev/null +++ b/js/ui/ConfirmOverlay.js @@ -0,0 +1,337 @@ +import Phaser from '../vendor/phaser.js'; +import { config } from '../config/Config.js'; +import { toColor, toCss } from '../utils/Color.js'; +import { fontStack } from '../utils/Theme.js'; +import { CyberShape } from './CyberShape.js'; +import { MenuButton } from './MenuButton.js'; +import { ScrambleDecode } from '../utils/Decode.js'; + +const FONT_FALLBACK = "'Segoe UI', 'Helvetica Neue', Arial, sans-serif"; + +/** + * The CONFIRM dialog — the second beat before anything destructive in + * the save pop-up: overwriting a slot, or loading one (which replaces + * the live game). + * + * Split-open: the panel splits from a 2px hot line down the middle + * (top and bottom edges fly apart), the RGB channel ghosts converge as + * the panel locks in, the title decodes, and the buttons flicker up — + * the same console language as the sub-bar and the pop-up, one step + * louder. Cancel = the line swallows the panel back down. + * + * While shown it blocks the modal behind it (SavePanel checks + * `contains`), and ESC / the CANCEL button both close it. + * + * const cf = new ConfirmOverlay(scene, cx, cy, 430, 168, { areaW, areaH }); + * cf.show({ title, body: […], accent, onConfirm, onCancel, time }); + * cf.cancel(); cf.escape(); cf.contains(px, py); cf.update(time); + */ +export class ConfirmOverlay extends Phaser.GameObjects.Container { + /** + * @param {Phaser.Scene} scene + * @param {object} [o] { areaW?, areaH? — the modal area the scrim + * darkens (defaults to this dialog's own size + margin) } + */ + constructor(scene, x, y, w, h, o = {}) { + super(scene, x, y); + this.scene.add.existing(this); + this.setScrollFactor(0); // screen-fixed + this.setSize(w, h); + + const c = config.section('save.colors', {}); + this.w = w; + this.h = h; + this.areaW = o.areaW ?? w + 40; + this.areaH = o.areaH ?? h + 40; + + this.state = 'hidden'; // hidden | opening | shown | closing + this.t0 = 0; + this.onConfirm = null; + this.onCancel = null; + this.accent = toColor(c.neon ?? '#00e5ff'); + + // Scrim over the whole modal (below the panel) — the world behind + // goes quiet. Clicking outside the dialog = cancel (standard). + this.scrim = scene.add.rectangle(0, 0, this.areaW, this.areaH, 0x02040a, 0).setScrollFactor(0); + this.scrim.setInteractive({ + useHandCursor: false, + hitArea: new Phaser.Geom.Rectangle(-this.areaW / 2, -this.areaH / 2, this.areaW, this.areaH), + hitAreaCallback: () => true, + }); + this.scrim.on('pointerdown', () => this.cancel()); + this.scrim.ignorePointer = true; // armed while shown only + this.add(this.scrim); + + this.panelG = scene.add.graphics().setScrollFactor(0); + this.add(this.panelG); + + // RGB channel ghosts (additive strokes) — converge on open, diverge on close. + this.ghostC = scene.add.graphics().setScrollFactor(0).setBlendMode(Phaser.BlendModes.ADD).setAlpha(0); + this.ghostM = scene.add.graphics().setScrollFactor(0).setBlendMode(Phaser.BlendModes.ADD).setAlpha(0); + this.add([this.ghostC, this.ghostM]); + + // Title (decoded) + body lines. + const famHeader = fontStack('header', FONT_FALLBACK); + const famBody = fontStack('body', FONT_FALLBACK); + this.title = scene.add.text(0, -this.h / 2 + 24, '', { + fontFamily: famHeader, + fontSize: '16px', + color: toCss(c.ink ?? '#eaf6ff'), + letterSpacing: 3, + }).setOrigin(0.5, 0).setScrollFactor(0); + this.add(this.title); + + this.bodyTexts = []; + for (let i = 0; i < 2; i++) { + const t = scene.add + .text(0, -this.h / 2 + 52 + i * 17, '', { + fontFamily: famBody, + fontSize: '11px', + color: toCss(c.dim ?? '#7d92c4'), + letterSpacing: 1.5, + }) + .setOrigin(0.5, 0) + .setScrollFactor(0); + this.bodyTexts.push(t); + this.add(t); + } + + // Buttons — the confirm button is built wide enough for the longest + // label ("OVERWRITE"); show() swaps the actual label in. + this.confirmBtn = new MenuButton( + scene, + w / 2 - 84, + this.h / 2 - 24, + 'CONFIRM', + () => this.fireConfirm(), + { screenFixed: true, fontSize: 12, paddingX: 18, paddingY: 8, width: 148, textColor: c.ink ?? '#eaf6ff', upper: true }, + ); + this.cancelBtn = new MenuButton( + scene, + -w / 2 + 78, + this.h / 2 - 24, + 'CANCEL', + () => this.cancel(), + { screenFixed: true, fontSize: 12, paddingX: 18, paddingY: 8, width: 104, textColor: c.dim ?? '#7d92c4', upper: true }, + ); + this.confirmBtn.setAlpha(0); + this.cancelBtn.setAlpha(0); + this.add([this.cancelBtn, this.confirmBtn]); + + this._titleDec = null; + this._btnsUp = false; + this._confirmUp = false; + } + // ------------------------------------------------------------------ + // Showing + // ------------------------------------------------------------------ + + /** + * spec: { + * title, body: string[], accent?: number, + * confirmLabel?, cancelLabel?, + * onConfirm?, onCancel? — fired after the close animation lands + * time — the engine time (for the open animation) + * } + */ + show(spec) { + this.accent = toColor(spec.accent ?? this.accent); + this.onConfirm = typeof spec.onConfirm === 'function' ? spec.onConfirm : null; + this.onCancel = typeof spec.onCancel === 'function' ? spec.onCancel : null; + + const setBtn = (btn, label) => { + const s = String(label ?? '').toUpperCase(); + btn.labelText.setText(s); + btn.ghostCyan.setText(s); + btn.ghostMagenta.setText(s); + }; + setBtn(this.confirmBtn, spec.confirmLabel ?? 'CONFIRM'); + setBtn(this.cancelBtn, spec.cancelLabel ?? 'CANCEL'); + // Accent the confirm button's edge + hover glow. + this.confirmBtn.style.stroke = this.accent; + this.confirmBtn.style.neon = this.accent; + this.confirmBtn.setDisabled(false); + this.cancelBtn.setDisabled(false); + + this.title.setColor(toCss(this.accent)); + this.bodyTexts.forEach((t, i) => t.setText(spec.body?.[i] ?? '')); + + const t = spec.time ?? this.scene.time.now; + this.state = 'opening'; + this.t0 = t; + this._titleDec = new ScrambleDecode(String(spec.title ?? ''), t, 420); + this.title.setText(''); + this.confirmBtn.setAlpha(0); + this.cancelBtn.setAlpha(0); + this._btnsUp = false; + this._confirmUp = false; + this.scrim.ignorePointer = false; + this.confirmBtn.panel.ignorePointer = false; + this.cancelBtn.panel.ignorePointer = false; + + const cfg = config.section('save.confirm.animation', {}); + this.openDur = cfg.openMs ?? 170; + this.closeDur = cfg.closeMs ?? 130; + + // The scene plays the SFX (one voice, one place). + this.scene.playSfx?.('construct'); + } + + /** Close + fire onCancel (after the close animation). */ + cancel() { + if (this.state === 'hidden' || this.state === 'closing') return; + this.startClose(); + this._fired = 'cancel'; + } + + fireConfirm() { + if (this.state !== 'shown' && this.state !== 'opening') return; + this.startClose(); + this._fired = 'confirm'; + const fn = this.onConfirm; + this.onConfirm = null; + // Fire when the collapse has landed (or immediately if it's done). + if (this.state === 'hidden') { + fn?.(); + } else { + this._pending = fn; + } + } + + startClose() { + if (this.state === 'hidden' || this.state === 'closing') return; + this.state = 'closing'; + this.t0 = this.lastTime ?? this.scene.time.now; + this.scene.playSfx?.('deconstruct'); + } + + /** @returns {boolean} true while the dialog is up (blocks the modal behind) */ + contains(px, py) { + if (this.state === 'hidden') return false; + const dx = Math.abs(px - this.x); + const dy = Math.abs(py - this.y); + return dx <= this.areaW / 2 && dy <= this.areaH / 2; + } + + get isOpen() { + return this.state !== 'hidden'; + } + + // ------------------------------------------------------------------ + // Per-frame (driven by SavePanel.update) + // ------------------------------------------------------------------ + + update(time) { + this.lastTime = time; + if (this.state === 'hidden') return; + + const p = Phaser.Math.Clamp((time - this.t0) / (this.state === 'opening' ? this.openDur : this.closeDur), 0, 1); + const e = p * p * (3 - 2 * p); // smoothstep + const h = this.state === 'opening' ? Math.max(2, e * this.h) : Math.max(2, (1 - e) * this.h); + this.drawPanel(h); + // The world behind goes quiet — the scrim rides the same curve. + this.scrim.setAlpha(0.72 * (this.state === 'opening' ? e : 1 - e)); + + // RGB channels: converge on open (±start → 0, α .5 → 0), diverge on close. + const start = 10; + if (this.state === 'opening') { + const off = start * (1 - e); + const a = 0.5 * (1 - e); + this.ghostC.setAlpha(a).setPosition(off * 0.85, -off * 0.3); + this.ghostM.setAlpha(a).setPosition(-off, off * 0.5); + } else { + const off = start * e; + const a = 0.45 * e; + this.ghostC.setAlpha(a).setPosition(off * 0.85, -off * 0.3); + this.ghostM.setAlpha(a).setPosition(-off, off * 0.5); + } + + // Title decode. + if (this._titleDec) { + if (this._titleDec.finished(time)) { + this.title.setText(this._titleDec.value); + this._titleDec = null; + } else if (this._titleDec.started(time)) { + this.title.setText(this._titleDec.display(time)); + } + } + + // Buttons flicker up on open (as the panel crosses their band). + if (this.state === 'opening' && p > 0.45) { + if (!this._btnsUp) { + this._btnsUp = true; + this.scene.tweens.add({ targets: this.cancelBtn, alpha: 1, duration: 130, ease: 'Sine.easeOut' }); + } + if (p > 0.6 && !this._confirmUp) { + this._confirmUp = true; + this.scene.tweens.add({ targets: this.confirmBtn, alpha: 1, duration: 130, ease: 'Sine.easeOut' }); + } + } + + // The open lands — the dialog is up and waiting. + if (this.state === 'opening' && p >= 1) this.state = 'shown'; + + if (this.state === 'closing' && p >= 1) { + this.setAlpha(1); + this.scrim.setAlpha(0); + this.panelG.clear(); + this.ghostC.setAlpha(0); + this.ghostM.setAlpha(0); + this.confirmBtn.setAlpha(0); + this.cancelBtn.setAlpha(0); + this.title.setText(''); + this.bodyTexts.forEach((t) => t.setText('')); + this.state = 'hidden'; + this.scrim.ignorePointer = true; + this.confirmBtn.panel.ignorePointer = true; + this.cancelBtn.panel.ignorePointer = true; + const fired = this._fired; + this._fired = null; + const pending = this._pending; + this._pending = null; + this.onConfirm = null; + this.onCancel = null; + if (fired === 'confirm') pending?.(); + else if (fired === 'cancel') this.onCancel?.(); + } + } + + /** The panel body at the current split height h (grows from the center line). */ + drawPanel(h) { + const c = config.section('save.colors', {}); + const g = this.panelG; + g.clear(); + const w = this.w; + const notch = Math.min(14, h * 0.22, w * 0.1); + + // Body: deep panel gradient feel (flat fill + edge glow — cheap). + CyberShape.draw(g, w, h, { + notch, + fill: toColor(c.panelTop ?? '#0e1930'), + fillAlpha: 0.97, + stroke: toColor(c.cardBorder ?? '#1e3050'), + strokeAlpha: 0.9, + lineWidth: 1.5, + glow: this.accent, + glowAlpha: 0.22, + }); + // The split edges — while animating they're the hot lines; at rest + // (h = full) they read as the panel's top/bottom rails. + const edgeA = 0.5 + 0.3 * Math.min(1, h / this.h); + g.fillStyle(toColor(c.neon ?? '#00e5ff'), edgeA * 0.8); + g.fillRect(-w / 2 + notch, -h / 2, w - notch * 2, 1.5); + g.fillStyle(toColor(c.magenta ?? '#ff2d6f'), edgeA * 0.5); + g.fillRect(-w / 2 + notch, h / 2 - 1.5, w - notch * 2, 1.5); + + // Ghost strokes at full size (the channels being pulled apart). + for (const [gg, color] of [[this.ghostC, c.neon ?? '#00e5ff'], [this.ghostM, c.magenta ?? '#ff2d6f']]) { + gg.clear(); + CyberShape.draw(gg, w, this.h, { notch: Math.min(14, this.h * 0.22), stroke: toColor(color), strokeAlpha: 1, lineWidth: 1.5 }); + } + } + + destroy() { + // The MenuButtons are children — the container's destroy takes them. + super.destroy(); + } +} diff --git a/js/ui/MenuButton.js b/js/ui/MenuButton.js index 1030bde..72225dc 100644 --- a/js/ui/MenuButton.js +++ b/js/ui/MenuButton.js @@ -17,6 +17,15 @@ const FONT_FALLBACK = "'Segoe UI', 'Helvetica Neue', Arial, sans-serif"; * * Colors/sizes come from data/menu.json (menu.colors + per-button * overrides) with the palette in data/theme.json underneath. + * + * o.screenFixed (default false): the button lives in a scene whose camera + * MOVES (the game world). Screen-fixed UI must pin EVERY child to the + * screen (scrollFactor 0 — the v4 input quirk, see ActionBar), so this + * flag is passed by the in-game UI (MenuSubBar, SavePanel, …). The main + * menu's camera never scrolls, so it keeps the default. + * + * setDisabled(on): the grayed-out state (Load Game with no saves) — + * dim paint, no hover/press, clicks swallowed. */ export class MenuButton extends Phaser.GameObjects.Container { constructor(scene, x, y, label, onClick, o = {}) { @@ -36,6 +45,10 @@ export class MenuButton extends Phaser.GameObjects.Container { const neon = themeColor('neon', 0x00e5ff); const neon2 = themeColor('neon2', 0xff2d6f); const ink = toColor(o.textColor ?? menuColors.buttonText ?? '#eaf6ff'); + // In-game (scrolling camera) UI pins every child to the screen — the + // v4 per-child scrollFactor quirk (ActionBar's note). Menu default 1 + // leaves the existing menu buttons exactly as they were. + const sf = o.screenFixed === true ? 0 : 1; // v4 quirk: Text styles go straight to canvas fillStyle — must be CSS // strings, or the text renders black (see toCss in utils/Color.js). @@ -56,25 +69,28 @@ export class MenuButton extends Phaser.GameObjects.Container { const textH = w0.height; w0.destroy(); - const width = textW + paddingX * 2; + // o.width (optional): a fixed panel width (the label is centred in it) — + // used by ConfirmOverlay so the confirm button fits its longest label. + const width = o.width ? Math.max(o.width, textW + 20) : textW + paddingX * 2; const height = textH + paddingY * 2; const notch = Math.min(12, height * 0.3); // Panel (redrawn on state changes — that's the whole styling system). - this.panel = scene.add.graphics().setPosition(0, 0); + this.panel = scene.add.graphics().setPosition(0, 0).setScrollFactor(sf); // RGB-split ghosts behind the label (additive), revealed on hover. this.ghostCyan = scene.add.text(0, 0, labelText, { ...textStyle, color: neonCss }) - .setOrigin(0.5).setAlpha(0).setBlendMode(Phaser.BlendModes.ADD); + .setOrigin(0.5).setAlpha(0).setBlendMode(Phaser.BlendModes.ADD).setScrollFactor(sf); this.ghostMagenta = scene.add.text(0, 0, labelText, { ...textStyle, color: neon2Css }) - .setOrigin(0.5).setAlpha(0).setBlendMode(Phaser.BlendModes.ADD); + .setOrigin(0.5).setAlpha(0).setBlendMode(Phaser.BlendModes.ADD).setScrollFactor(sf); // Light streak that sweeps across the panel on hover. - this.sweep = scene.add.rectangle(0, 0, 26, Math.max(6, height - 10), neon, 0).setOrigin(0.5); + this.sweep = scene.add.rectangle(0, 0, 26, Math.max(6, height - 10), neon, 0).setOrigin(0.5).setScrollFactor(sf); - this.labelText = scene.add.text(0, 0, labelText, textStyle).setOrigin(0.5); + this.labelText = scene.add.text(0, 0, labelText, textStyle).setOrigin(0.5).setScrollFactor(sf); this.add([this.panel, this.ghostCyan, this.ghostMagenta, this.sweep, this.labelText]); this.setSize(width, height); + this.setScrollFactor(sf); // Phaser v4: a directly-constructed GameObject is NOT added to the scene's // display list (the scene.add.* factories do that) — register it here or it @@ -84,6 +100,7 @@ export class MenuButton extends Phaser.GameObjects.Container { this.style = { width, height, notch, fill, hoverFill, stroke, neon, neon2, ink, inkCss, labelText, textStyle }; this.hoverOn = false; this.pressing = false; + this.disabled = false; this._sweepTween = null; this.paint('base'); @@ -98,14 +115,41 @@ export class MenuButton extends Phaser.GameObjects.Container { this.panel.on('pointerover', () => this.hover(true)); this.panel.on('pointerout', () => this.hover(false)); this.panel.on('pointerdown', () => { + if (this.disabled) return; this.press(); if (typeof onClick === 'function') onClick(this); }); } - /** Repaint the panel for a visual state: 'base' | 'hover'. */ + /** + * The grayed-out state (Load Game while no saves exist): dim paint, no + * fringe, no hover/press, clicks swallowed. + */ + setDisabled(on) { + this.disabled = !!on; + if (on) this.hoverOn = false; + this.paint(on ? 'disabled' : (this.hoverOn ? 'hover' : 'base')); + } + + /** Repaint the panel for a visual state: 'base' | 'hover' | 'disabled'. */ paint(state) { const s = this.style; + if (state === 'disabled') { + this.panel.clear(); + CyberShape.draw(this.panel, s.width, s.height, { + notch: s.notch, + fill: 0x0a0e16, + fillAlpha: 0.6, + stroke: 0x2a3242, + strokeAlpha: 0.5, + lineWidth: 1.5, + }); + this.ghostCyan.setAlpha(0); + this.ghostMagenta.setAlpha(0); + // v4: Text colors are CSS strings (see toCss). + this.labelText.setColor('#5a6478'); + return; + } const hover = state === 'hover'; this.panel.clear(); CyberShape.draw(this.panel, s.width, s.height, { @@ -127,6 +171,7 @@ export class MenuButton extends Phaser.GameObjects.Container { } hover(on) { + if (this.disabled) return; this.hoverOn = on; this.paint(on ? 'hover' : 'base'); @@ -151,7 +196,7 @@ export class MenuButton extends Phaser.GameObjects.Container { /** Click feedback: white flash + scale punch, then restore. */ press() { - if (this.pressing) return; + if (this.pressing || this.disabled) return; this.pressing = true; const s = this.style; this.panel.clear(); @@ -166,7 +211,7 @@ export class MenuButton extends Phaser.GameObjects.Container { this.scene.tweens.add({ targets: this, scale: 0.965, duration: 70, yoyo: true, ease: 'Sine.easeOut' }); this.scene.time.delayedCall(120, () => { this.pressing = false; - if (this.active !== false) this.paint(this.hoverOn ? 'hover' : 'base'); + if (this.active !== false && !this.disabled) this.paint(this.hoverOn ? 'hover' : 'base'); }); } } diff --git a/js/ui/MenuSubBar.js b/js/ui/MenuSubBar.js new file mode 100644 index 0000000..7cea124 --- /dev/null +++ b/js/ui/MenuSubBar.js @@ -0,0 +1,537 @@ +import Phaser from '../vendor/phaser.js'; +import { config } from '../config/Config.js'; +import { toColor, toCss } from '../utils/Color.js'; +import { canvasTexture } from '../utils/Textures.js'; +import { MenuButton } from './MenuButton.js'; +import { ScrambleDecode, decodeDur } from '../utils/Decode.js'; + +/** + * The MENU sub-bar — the drawer that folds up out of the command deck's + * Menu button when it's pressed (js/ui/ActionBar.js, last slot). + * + * Three buttons (data/save.json → subBar.items): + * SAVE GAME · LOAD GAME (grayed while no saves exist) · RETURN TO MAIN MENU + * + * THE OPEN — a signal unfolding out of the seam (bottom-up, ~340 ms): + * t=0 the deck fires a glitch burst (slice bars + its panel's RGB + * pull-apart — the scene triggers it), the camera kicks, and a + * hot seam line flares along the button's top edge; + * 0→H a bright UNFOLD EDGE climbs the panel — the body is redrawn + * taller every frame behind it (the deck grows out of the + * button), a cyan→magenta rail rides the edge, the scanlines + * fill in under it; + * mid as the edge crosses the button band they flicker up one by + * one (Steps, left→right) and their labels DECODE in — the + * console pulling words out of static; + * lock the panel's RGB channel ghosts (magenta/cyan outlines, + * ±12 px apart) converge to zero as the bar settles — the + * channels locking onto the signal; slice bars tear once and + * die; the seam line settles into a faint breathing power rail. + * + * THE CLOSE is the same language in reverse at ⅓ the length: the edge + * folds back down, the labels deconstruct, the channels drift apart and + * cut. + * + * While open it keeps a low-level life: a breathing seam rail, an + * occasional faint micro-burst (ghost pulse + a slice or two). + * + * Screen-space: the container AND every child are scrollFactor 0 (the + * v4 per-child input quirk — ActionBar's note). The scene calls + * update(time, delta) and owns the trigger (its onAction('menu')). + * + * const bar = new MenuSubBar(scene, { anchor: {x, y, w}, onAction }); + * bar.open(); bar.close(); bar.setDisabled('load', true); + * bar.contains(px, py); bar.update(time, delta); bar.destroy(); + */ +export class MenuSubBar extends Phaser.GameObjects.Container { + /** + * @param {Phaser.Scene} scene + * @param {object} [o] { + * anchor: { x, y, w } — the menu button's center (x, y) + width (screen px) + * onAction?: (id) => void + * } + */ + constructor(scene, o = {}) { + super(scene, 0, 0); + this.scene.add.existing(this); + this.setScrollFactor(0); // screen-fixed UI + + const cfg = config.section('save.subBar', {}); + const anim = cfg.animation ?? {}; + const H = cfg.height ?? 58; + this.H = H; + this.pad = cfg.padding ?? 16; + this.gap = cfg.gap ?? 12; + this.onAction = typeof o.onAction === 'function' ? o.onAction : null; + + const c = config.section('save.colors', {}); + this.panelTop = c.panelTop ?? '#0e1930'; + this.panelBottom = c.panelBottom ?? '#050912'; + this.neon = toColor(c.neon ?? '#00e5ff'); + this.magenta = toColor(c.magenta ?? '#ff2d6f'); + this.ink = toColor(c.ink ?? '#eaf6ff'); + this.dim = toColor(c.dim ?? '#7d92c4'); + + // ---- the body graphics FIRST: v4 paints children in list order, so + // the opaque panel fill must come before the buttons or it would + // cover them (the body is redrawn every frame while it unfolds). + // The button row width is measured up-front (throw-away texts) so the + // scan tile can be sized correctly from the start. + const items = Array.isArray(cfg.items) && cfg.items.length + ? cfg.items + : [ + { id: 'save', label: 'Save Game', accent: '#00e5ff' }, + { id: 'load', label: 'Load Game', accent: '#ffc94d' }, + { id: 'mainMenu', label: 'Return to Main Menu', accent: '#ff2d6f' }, + ]; + const btnCfg = cfg.button ?? {}; + const sceneW = scene.scale.width; + + // ---- create the buttons first (they measure their own label) so the + // row layout uses the REAL button widths — no second, divergent + // text-measurement pass. + const mkBtn = (item) => { + const btn = new MenuButton( + scene, + 0, + -H / 2, + String(item.label ?? item.id), + () => this.fire(item.id), + { + screenFixed: true, + fontSize: btnCfg.fontSize ?? 13, + paddingX: btnCfg.paddingX ?? 18, + paddingY: btnCfg.paddingY ?? 9, + letterSpacing: btnCfg.letterSpacing ?? 2, + upper: true, + textColor: c.ink ?? '#eaf6ff', + bgColor: c.cardBg ?? '#0a1222', + hoverColor: c.cardBg ?? '#0a1222', + borderColor: toCss(item.accent ?? c.neon ?? '#00e5ff'), + }, + ); + // The accent edge: the button's stroke + hover glow take the item's accent. + btn.style.stroke = toColor(item.accent ?? this.neon); + btn.style.neon = toColor(item.accent ?? this.neon); + btn.setAlpha(0); + return btn; + }; + + const btns = items.map((it) => mkBtn(it)); + const widths = btns.map((b) => b.style.width); + let W = this.pad * 2 + widths.reduce((a, b) => a + b, 0) + this.gap * (widths.length - 1); + W = Math.min(W, sceneW - 24); + this.W = W; + + // ---- the body (redrawn each frame while it unfolds) --------------- + this.bodyG = scene.add.graphics().setScrollFactor(0); + this.add(this.bodyG); + + // The unfold edge (bright line + glow trail) — the moving top of the + // revealed body. Additive. + this.edgeG = scene.add.graphics().setScrollFactor(0).setBlendMode(Phaser.BlendModes.ADD); + this.add(this.edgeG); + + // RGB channel ghosts — full-size outlines that converge on open, + // diverge on close (the GlitchText technique, applied to the panel). + this.ghostC = scene.add.graphics().setScrollFactor(0).setBlendMode(Phaser.BlendModes.ADD).setAlpha(0); + this.ghostM = scene.add.graphics().setScrollFactor(0).setBlendMode(Phaser.BlendModes.ADD).setAlpha(0); + this.add([this.ghostC, this.ghostM]); + + // Scanlines over the revealed body (the deck's tile, sized to it). + const sl = cfg.scanline ?? {}; + const pitch = Math.max(2, Math.round(sl.pitch ?? 3)); + const scanKey = `__subbar_scan_${pitch}`; + canvasTexture(scene, scanKey, 1, pitch, (ctx) => { + ctx.fillStyle = 'rgba(0,0,0,0.5)'; + ctx.fillRect(0, pitch - 1, 1, 1); + }); + this.scan = scene.add.tileSprite(0, -H / 2, this.W, 0, scanKey).setScrollFactor(0).setAlpha(0); + this.add(this.scan); + + // The seam line — the power connection button↔bar (additive, animated). + this.seamG = scene.add.graphics().setScrollFactor(0).setBlendMode(Phaser.BlendModes.ADD); + this.add(this.seamG); + + // ---- add the buttons LAST so they paint above the body; center the + // row about the origin (-W/2..W/2) — left-origin coords (0..W) would + // push the last button W/2 past the bar's right edge. + let bx = -this.W / 2 + this.pad + widths[0] / 2; + this.buttons = items.map((it, i) => { + const btn = btns[i]; + btn.setX(bx); + this.add(btn); + const slot = { id: it.id, item: it, btn, finalLabel: String(it.label ?? it.id).toUpperCase(), _dec: null, up: false }; + // center → center: this button's half + gap + next button's half. + bx += widths[i] / 2 + this.gap + (i < items.length - 1 ? widths[i + 1] / 2 : 0); + return slot; + }); + // ---- the anchor: right-aligned over the menu button, seam on its top edge + const a = o.anchor ?? { x: sceneW / 2, y: scene.scale.height - 60, w: 120 }; + let cx = a.x + a.w / 2 - this.W / 2; // bar's right edge on the button's right edge + cx = Phaser.Math.Clamp(cx, this.W / 2 + 12, sceneW - this.W / 2 - 12); + const seamY = a.y - 0; // the bar's bottom edge sits ON the button (seam = button top) + this.seamY = seamY; + this.setPosition(cx, seamY); + this.rect = { x: cx - this.W / 2, y: seamY - H, w: this.W, h: H }; + + this.slices = []; // { g, die } + this.state = 'closed'; // closed | opening | open | closing + this.t0 = 0; + this.lastTime = null; + this.openDur = anim.openMs ?? 340; + this.closeDur = anim.closeMs ?? 200; + this.ghostStart = anim.ghostStart ?? 12; + this.buttonStart = anim.buttonStart ?? 0.34; + this.staggerFrac = (anim.staggerMs ?? 70) / Math.max(1, this.openDur); + this.micro = { enabled: (anim.microBurst?.enabled !== false), every: anim.microBurst?.intervalMs ?? [2800, 6800] }; + this.nextMicroAt = null; + this.level = 0; // ambient micro-burst level (drives ghost pulses) + + this.drawGhosts(); + } + + // ------------------------------------------------------------------ + // Public + // ------------------------------------------------------------------ + + get isOpen() { + return this.state === 'open' || this.state === 'opening'; + } + + open() { + if (this.state !== 'closed' || this.dead) return; + this.state = 'opening'; + this.t0 = this.lastTime ?? this.scene.time.now; + this.buttons.forEach((s) => { + s.up = false; + s._dec = null; + s.btn.setAlpha(0); + s.btn.labelText.setText(''); + // NOTE: the disabled (grayed) state is the SCENE's (menuAction sets it + // right before open) — never reset it here, or Load Game un-grays. + }); + // The seam flares button-width → bar-width as the signal leaves the button. + this.seamFlash = { t0: this.t0, dur: 260, from: this.rect.w * 0.001, to: this.W }; + this.spawnSlices(1); + this.scene.playSfx?.('construct'); + } + + close() { + if (this.state !== 'open' || this.dead) return; + this.state = 'closing'; + this.t0 = this.lastTime ?? this.scene.time.now; + // Labels deconstruct (the reverse decode) while the edge folds down. + this.buttons.forEach((s, i) => { + s._dec = new ScrambleDecode(s.finalLabel, this.t0, 150, true); + if (i > 0) s._dec.t0 = this.t0 + i * 25; + }); + this.spawnSlices(0.8); + this.scene.playSfx?.('deconstruct'); + } + + /** Instantly kill the bar (scene transitions). */ + dismiss() { + this.state = 'closed'; + this.bodyG.clear(); + this.edgeG.clear(); + this.ghostC.setAlpha(0); + this.ghostM.setAlpha(0); + this.seamG.clear(); + this.scan.setAlpha(0).setSize(this.W, 0); + this.buttons.forEach((s) => s.btn.setAlpha(0)); + for (const sl of this.slices) sl.g.destroy(); + this.slices.length = 0; + } + + /** Gray a sub-bar button out (Load Game while no saves exist). */ + setDisabled(id, on) { + const s = this.buttons.find((b) => b.id === id); + if (s) s.btn.setDisabled(!!on); + } + + /** Is (px, py) — screen coords — over the bar? */ + contains(px, py) { + const r = this.rect; + return px >= r.x && px <= r.x + r.w && py >= r.y && py <= r.y + r.h; + } + + fire(id) { + if (typeof this.onAction === 'function') { + try { + this.onAction(id); + } catch (err) { + console.error('[subbar] onAction failed', err); + } + } + } + + // ------------------------------------------------------------------ + // Drawing + // ------------------------------------------------------------------ + + /** + * The panel body at revealed height h (0..H), bottom edge on the seam + * (local y=0), top corners cut. The top edge is the UNFOLD EDGE — a + * bright cyan→magenta rail; at h=H it's the bar's final top rail. + */ + drawBody(h) { + const g = this.bodyG; + g.clear(); + const W = this.W; + h = Math.max(0, Math.min(this.H, h)); + if (h < 1) return; + const cut = Math.max(3, Math.min(12, h * 0.18)); + + const pts = [ + { x: -W / 2 + cut, y: -h }, + { x: W / 2 - cut, y: -h }, + { x: W / 2, y: -h + cut }, + { x: W / 2, y: 0 }, + { x: -W / 2, y: 0 }, + { x: -W / 2, y: -h + cut }, + ]; + + // Body fill: deep console blue, brighter toward the (moving) top edge. + g.fillStyle(toColor(this.panelBottom), 1); + g.fillPoints(pts, true); + g.fillStyle(toColor(this.panelTop), Math.min(1, h / this.H) * 0.55); + g.fillPoints(pts, true); + // Side glows (cyan left, magenta right — the deck's language). + g.fillStyle(this.neon, 0.05); + g.fillRect(-W / 2, -h, 26, h); + g.fillStyle(this.magenta, 0.06); + g.fillRect(W / 2 - 26, -h, 26, h); + + // The unfold edge — the signature line, riding the revealed top. + const rail = 2; + g.fillStyle(this.neon, 0.9); + g.fillRect(-W / 2 + cut, -h, W - cut * 2, rail); + g.fillStyle(this.ink, 0.8); + g.fillRect(-W / 2 + cut, -h + 0.5, W - cut * 2, 0.8); + // A magenta counter-edge 1px below (RGB separation on the seam of the bar). + g.fillStyle(this.magenta, 0.5); + g.fillRect(-W / 2 + cut, -h + rail, W - cut * 2, 1); + + // The seam (bottom) — a faint magenta floor line, always. + g.fillStyle(this.magenta, 0.35); + g.fillRect(-W / 2, -1.5, W, 1.5); + + // Viewfinder brackets on the two top corners (HUD chrome). + if (h > this.H * 0.5) { + g.lineStyle(2, this.neon, 0.4); + const L = 10; + g.lineBetween(-W / 2 + 1, -h + L, -W / 2 + 1, -h + 1); + g.lineBetween(-W / 2 + 1, -h + 1, -W / 2 + L, -h + 1); + g.lineBetween(W / 2 - 1, -h + L, W / 2 - 1, -h + 1); + g.lineBetween(W / 2 - 1, -h + 1, W / 2 - L, -h + 1); + } + } + + /** The bright unfold edge (line + glow trail) at revealed height h. */ + drawEdge(h, alpha) { + const g = this.edgeG; + g.clear(); + if (alpha <= 0.01 || h < 2) return; + const W = this.W; + g.fillStyle(this.ink, 0.85 * alpha); + g.fillRect(-W / 2, -h, W, 2); + g.fillStyle(this.neon, 0.3 * alpha); + g.fillRect(-W / 2, -h - 5, W, 5); + g.fillStyle(this.neon, 0.12 * alpha); + g.fillRect(-W / 2, -h - 12, W, 7); + } + + /** The two channel ghosts (full-size outlines, additive). */ + drawGhosts() { + const cut = Math.min(12, this.H * 0.18); + const pts = [ + { x: -this.W / 2 + cut, y: -this.H }, + { x: this.W / 2 - cut, y: -this.H }, + { x: this.W / 2, y: -this.H + cut }, + { x: this.W / 2, y: 0 }, + { x: -this.W / 2, y: 0 }, + { x: -this.W / 2, y: -this.H + cut }, + ]; + const stroke = (gg, color) => { + gg.clear(); + gg.lineStyle(1.5, color, 1); + gg.strokePoints(pts, true); + }; + stroke(this.ghostC, this.neon); + stroke(this.ghostM, this.magenta); + } + + /** The seam power line (on the button's top edge), width animated. */ + drawSeam(width, alpha) { + const g = this.seamG; + g.clear(); + if (alpha <= 0.01) return; + g.fillStyle(this.neon, alpha); + g.fillRect(-width / 2, -1, width, 2); + g.fillStyle(this.ink, alpha * 0.8); + g.fillRect(-width / 2, -0.5, width, 1); + } + + /** Slice bars across the bar (the signal tearing), intensity 0..1. */ + spawnSlices(intensity = 1) { + const g = this.scene.add.graphics().setScrollFactor(0); + g.setBlendMode(Phaser.BlendModes.ADD); + const n = 3 + Math.floor(Math.random() * 3); + for (let i = 0; i < n; i++) { + const yy = -Math.random() * this.H; + const bh = 1 + Math.random() * 7; + const dx = (Math.random() * 2 - 1) * 10; + const palette = [this.neon, this.magenta, 0xeaf6ff, 0x04060d]; + g.fillStyle(palette[(Math.random() * palette.length) | 0], (0.06 + Math.random() * 0.14) * intensity); + g.fillRect(-this.W / 2 - 10 + dx, yy, this.W + 20, bh); + } + const die = (this.lastTime ?? this.scene.time.now) + 240; + this.slices.push({ g, die }); + } + + // ------------------------------------------------------------------ + // Per-frame (scene.update drives it) + // ------------------------------------------------------------------ + + update(time, delta) { + if (this.dead) return; + this.lastTime = time; + const t = time * 0.001; + + // Reap slices. + if (this.slices.length) { + this.slices = this.slices.filter((s) => { + if (time >= s.die) { + s.g.destroy(); + return false; + } + return true; + }); + } + + // Micro-burst scheduling (open only) + level decay. + this.level *= Math.exp(-(delta / 140)); + if (this.state === 'open') { + if (this.micro.enabled) { + if (this.nextMicroAt === null) this.nextMicroAt = time + this.range(this.micro.every[0], this.micro.every[1]); + if (time >= this.nextMicroAt) { + this.level = Math.max(this.level, 0.5); + this.nextMicroAt = time + this.range(this.micro.every[0], this.micro.every[1]); + this.spawnSlices(0.45); + } + } + } + + if (this.state === 'opening') { + const p = Phaser.Math.Clamp((time - this.t0) / this.openDur, 0, 1); + const e = 1 - Math.pow(1 - p, 3); // ease-out cubic + const h = Math.max(2, e * this.H); + this.drawBody(h); + this.drawEdge(h, 0.9); + this.scan.setSize(this.W, h).setPosition(0, -h / 2).setAlpha(0.14); + // The channels converge as the bar locks in. + const off = this.ghostStart * (1 - e); + this.ghostC.setAlpha(0.5 * (1 - e)).setPosition(off * 0.85, -off * 0.3); + this.ghostM.setAlpha(0.5 * (1 - e)).setPosition(-off, off * 0.4); + // The seam line grows button-width → bar-width, then fades to a rail. + if (this.seamFlash) { + const sp = Phaser.Math.Clamp((time - this.seamFlash.t0) / this.seamFlash.dur, 0, 1); + const w = Phaser.Math.Linear(this.seamFlash.from, this.seamFlash.to, 1 - Math.pow(1 - sp, 2)); + this.drawSeam(w, 0.7 * (1 - sp * 0.4)); + } + // Buttons flicker up as the edge crosses their band. + this.buttons.forEach((s, i) => { + if (s.up) return; + const ap = this.buttonStart + i * this.staggerFrac; + if (p >= ap) { + s.up = true; + this.scene.tweens.add({ targets: s.btn, alpha: 1, duration: 140, ease: 'Sine.easeOut' }); + s._dec = new ScrambleDecode(s.finalLabel, time + 70, decodeDur(s.finalLabel.length)); + } + }); + this.driveDecodes(time); + if (p >= 1) { + this.state = 'open'; + this.drawBody(this.H); + this.edgeG.clear(); + this.nextMicroAt = null; + // The top rail energizes — a quick settle flash. + this.scene.tweens.add({ + targets: this.bodyG, + alpha: 1, + duration: 120, + ease: 'Sine.easeOut', + }); + this.bodyG.setAlpha(0.6); + } + } else if (this.state === 'open') { + // The seam power rail breathes (button↔bar connection). + this.drawSeam(this.W * 0.5, 0.1 + 0.06 * (0.5 + 0.5 * Math.sin(t * 2.1))); + // Ambient micro-burst: the channel ghosts pulse faintly. + if (this.level > 0.04) { + const jx = this.level * 5 * Math.sin(time * 0.06); + this.ghostC.setAlpha(this.level * 0.2).setPosition(jx, -this.level * 1.5); + this.ghostM.setAlpha(this.level * 0.2).setPosition(-jx, this.level); + } else { + this.ghostC.setAlpha(0); + this.ghostM.setAlpha(0); + } + this.driveDecodes(time); + } else if (this.state === 'closing') { + const p = Phaser.Math.Clamp((time - this.t0) / this.closeDur, 0, 1); + const e = p * p * (3 - 2 * p); + const h = Math.max(0, (1 - e) * this.H); + this.drawBody(h); + this.drawEdge(h, 0.8); + this.scan.setSize(this.W, h).setPosition(0, -h / 2).setAlpha(0.14 * (1 - e)); + // The channels drift apart and cut — the signal dropping. + const off = this.ghostStart * e; + this.ghostC.setAlpha(0.4 * e).setPosition(off * 0.85, -off * 0.3); + this.ghostM.setAlpha(0.4 * e).setPosition(-off, off * 0.4); + this.drawSeam(this.W * 0.4 * (1 - e), 0.5 * (1 - e)); + this.buttons.forEach((s) => { + s.btn.setAlpha(1 - e); + }); + this.driveDecodes(time); + if (p >= 1) { + this.state = 'closed'; + this.bodyG.clear(); + this.edgeG.clear(); + this.ghostC.setAlpha(0); + this.ghostM.setAlpha(0); + this.seamG.clear(); + this.scan.setAlpha(0).setSize(this.W, 0); + this.buttons.forEach((s) => { + s.btn.setAlpha(0); + s.up = false; + s._dec = null; + }); + } + } + } + + /** Drive any label decodes (open-in or close-out). */ + driveDecodes(time) { + for (const s of this.buttons) { + const d = s._dec; + if (!d) continue; + if (d.finished(time)) { + s.btn.labelText.setText(d.reverse ? '' : d.value); + s._dec = null; + continue; + } + if (d.started(time)) { + s.btn.labelText.setText(d.display(time)); + } + } + } + + range(lo, hi) { + return lo + Math.random() * (hi - lo); + } + + get dead() { + return this.scene === null || this.active === false; + } +} diff --git a/js/ui/SavePanel.js b/js/ui/SavePanel.js new file mode 100644 index 0000000..c0d5a74 --- /dev/null +++ b/js/ui/SavePanel.js @@ -0,0 +1,673 @@ +import Phaser from '../vendor/phaser.js'; +import { config } from '../config/Config.js'; +import { toColor, toCss } from '../utils/Color.js'; +import { canvasTexture } from '../utils/Textures.js'; +import { fontStack } from '../utils/Theme.js'; +import { CyberShape } from './CyberShape.js'; +import { MenuButton } from './MenuButton.js'; +import { SlotCard } from './SlotCard.js'; +import { ConfirmOverlay } from './ConfirmOverlay.js'; +import { Toast } from './Toast.js'; +import { SaveManager } from '../save/SaveManager.js'; +import { captureState, prepareLoad } from '../save/SaveData.js'; +import { ScrambleDecode, decodeDur } from '../utils/Decode.js'; + +const FONT_FALLBACK = "'Segoe UI', 'Helvetica Neue', Arial, sans-serif"; + +/** + * The SAVE PANEL — the MEMORY BANK pop-up (10 slots), opened by the + * sub-bar's Save Game / Load Game (js/ui/MenuSubBar.js). + * + * ONE PANEL, TWO MODES: + * save — accent neon; every slot is a write target; + * occupied slots ask for an OVERWRITE confirmation first. + * load — accent amber; empty slots are inert (NO SIGNAL); + * filled slots ask for a LOAD confirmation first (it replaces + * the live game). + * + * THE OPEN — the vault cracks open (data/save.json → panel.animation): + * a hot seam line runs the full width, the panel body grows out of it + * (top and bottom halves fly apart, redrawn taller every frame), the + * RGB channel ghosts converge as the plate locks in, the title + + * subtitle decode, the ten slot cards flicker up one by one, a bright + * scanline sweeps across, and the footer (CANCEL · DOWNLOAD ALL) + * rises. The CLOSE folds the panel back into the seam at ~⅔ length. + * + * Inside: ten SlotCards (5×2, js/ui/SlotCard.js), the ConfirmOverlay + * (js/ui/ConfirmOverlay.js) for overwrite + load, and a Toast for + * feedback. The footer's DOWNLOAD ALL writes every saved game to a + * JSON file the player can keep (SaveManager.exportAll → Blob download). + * + * The scene owns the keys: ESC (close panel → cancel confirm, in + * reverse order) and the world-input block while isOpen. + * + * const panel = new SavePanel(scene, { onLoadComplete: (slot, rec) => … }); + * panel.show('save' | 'load'); panel.close(onDone?); + * panel.update(time); panel.isOpen; panel.destroy(); + */ +export class SavePanel extends Phaser.GameObjects.Container { + /** + * @param {Phaser.Scene} scene the GameScene (it has registry, galaxy, + * ship, discovery, tetherField, playTimeMs — what captureState reads) + * @param {object} [o] { + * onLoadComplete?: (slot, record) => void — called after the + * load-confirm lands and the panel has closed (the scene starts + * the menu; the menu's New Game picks the staged restore up) + * } + */ + constructor(scene, o = {}) { + super(scene, 0, 0); + this.scene.add.existing(this); + this.setScrollFactor(0); // screen-fixed UI + + const cfg = config.section('save.panel', {}); + const cols = cfg.cols ?? 5; + const slotW = cfg.slotW ?? 138; + const slotH = cfg.slotH ?? 98; + const gap = cfg.gap ?? 10; + const pad = cfg.padding ?? 22; + const headerH = cfg.headerH ?? 56; + const footerH = cfg.footerH ?? 44; + this.sm = new SaveManager(); // the localStorage bank (js/save/SaveManager.js) + this.slotCount = this.sm.slotCount(); + const rows = Math.ceil(this.slotCount / cols); + this.W = pad * 2 + cols * slotW + (cols - 1) * gap; + this.H = pad + headerH + rows * slotH + (rows - 1) * gap + footerH + pad; + this.cfg = { cols, slotW, slotH, gap, pad, headerH, footerH }; + this.rows = rows; + + const c = config.section('save.colors', {}); + this.saveAccent = toColor(c.neon ?? '#00e5ff'); + this.loadAccent = toColor(c.amber ?? '#ffc94d'); + this.magenta = toColor(c.magenta ?? '#ff2d6f'); + this.accent = this.saveAccent; + this.onLoadComplete = typeof o.onLoadComplete === 'function' ? o.onLoadComplete : null; + + + const sw = scene.scale.width; + const sh = scene.scale.height; + this.setPosition(sw / 2, sh / 2); + + // ---- scrim: the world goes dark (click = close) ------------------- + this.scrim = scene.add.rectangle(sw / 2, sh / 2, sw, sh, 0x02040a, 0); + this.scrim.setInteractive({ + useHandCursor: false, + hitArea: new Phaser.Geom.Rectangle(0, 0, sw, sh), + hitAreaCallback: () => true, + }); + this.scrim.on('pointerdown', () => this.close()); + this.scrim.ignorePointer = true; // off while hidden + this.add(this.scrim); + + // ---- the plate (redrawn taller every frame while it cracks open) -- + this.bodyG = scene.add.graphics().setScrollFactor(0); + this.add(this.bodyG); + + // The hot seam (the crack) — a bright full-width line at center. + this.seamG = scene.add.graphics().setScrollFactor(0).setBlendMode(Phaser.BlendModes.ADD); + this.add(this.seamG); + + // RGB channel ghosts — full-size outlines, converge on open. + this.ghostC = scene.add.graphics().setScrollFactor(0).setBlendMode(Phaser.BlendModes.ADD).setAlpha(0); + this.ghostM = scene.add.graphics().setScrollFactor(0).setBlendMode(Phaser.BlendModes.ADD).setAlpha(0); + this.add([this.ghostC, this.ghostM]); + + // Scanlines over the plate (sized to the revealed height). + const pitch = 3; + const scanKey = `__panel_scan_${pitch}`; + canvasTexture(scene, scanKey, 1, pitch, (ctx) => { + ctx.fillStyle = 'rgba(0,0,0,0.5)'; + ctx.fillRect(0, pitch - 1, 1, 1); + }); + this.scan = scene.add.tileSprite(0, 0, this.W, 0, scanKey).setScrollFactor(0).setAlpha(0); + this.add(this.scan); + + // The one-shot scan sweep (a bright line crossing the plate). + this.sweep = scene.add + .rectangle(-this.W / 2, 0, 3, this.H, 0xeaf6ff, 0) + .setOrigin(0.5) + .setBlendMode(Phaser.BlendModes.ADD) + .setScrollFactor(0); + this.add(this.sweep); + + // ---- header: title (decoded) + subtitle ---------------------------- + const famHeader = fontStack('header', FONT_FALLBACK); + const famBody = fontStack('body', FONT_FALLBACK); + this.title = scene.add + .text(0, -this.H / 2 + pad + 16, '', { + fontFamily: famHeader, + fontSize: '20px', + color: toCss(c.ink ?? '#eaf6ff'), + letterSpacing: 4, + }) + .setOrigin(0.5, 0) + .setScrollFactor(0); + this.subtitle = scene.add + .text(0, -this.H / 2 + pad + 44, '', { + fontFamily: famBody, + fontSize: '10px', + color: toCss(c.dim ?? '#7d92c4'), + letterSpacing: 2, + }) + .setOrigin(0.5, 0) + .setScrollFactor(0); + this.add([this.title, this.subtitle]); + + // ---- the ten slot cards (5 × 2) ------------------------------------ + this.cards = []; + const cardTop = -this.H / 2 + pad + headerH; + for (let i = 0; i < this.slotCount; i++) { + const col = i % cols; + const row = Math.floor(i / cols); + const x = -this.W / 2 + pad + slotW / 2 + col * (slotW + gap); + const y = cardTop + slotH / 2 + row * (slotH + gap); + const card = new SlotCard(scene, x, y, slotW, slotH, { + accent: this.accent, + onClick: (slot) => this.onCard(slot, card), + }); + card.setNumber(i + 1); + card.setRecord(null); + card.setAlpha(0); + card._up = false; + card._locked = true; + this.cards.push(card); + this.add(card); + } + + // ---- footer: CANCEL · DOWNLOAD ALL --------------------------------- + const copy = cfg; + this.cancelBtn = new MenuButton( + scene, + -this.W / 2 + pad + 62, + this.H / 2 - pad - footerH / 2, + config.get('save.panel.cancelLabel', 'CANCEL'), + () => this.close(), + { + screenFixed: true, + fontSize: 12, + paddingX: 18, + paddingY: 8, + width: 104, + textColor: c.dim ?? '#7d92c4', + upper: true, + }, + ); + this.downloadBtn = new MenuButton( + scene, + this.W / 2 - pad - 100, + this.H / 2 - pad - footerH / 2, + config.get('save.panel.downloadLabel', 'DOWNLOAD ALL SAVED GAMES'), + () => this.downloadAll(), + { + screenFixed: true, + fontSize: 12, + paddingX: 18, + paddingY: 8, + width: 210, + textColor: c.ink ?? '#eaf6ff', + borderColor: toCss(c.neon ?? '#00e5ff'), + upper: true, + }, + ); + this.cancelBtn.setAlpha(0); + this.downloadBtn.setAlpha(0); + this.cancelBtn.ignorePointer = true; + this.downloadBtn.ignorePointer = true; + this.add([this.cancelBtn, this.downloadBtn]); + + // ---- the confirm dialog + toast ------------------------------------ + this.confirm = new ConfirmOverlay( + scene, + 0, + 0, + config.get('save.confirm.width', 430), + config.get('save.confirm.height', 168), + { areaW: this.W - 20, areaH: this.H - 20 }, + ); + this.add(this.confirm); + this.toast = new Toast(scene, 0, -this.H / 2 - 26); + this.toast.setBaseY(-this.H / 2 - 26); + this.add(this.toast); + + this.state = 'hidden'; // hidden | opening | shown | closing + this.t0 = 0; + this.openDur = cfg.animation?.openMs ?? 300; + this.closeDur = cfg.animation?.closeMs ?? 190; + this.ghostStart = cfg.animation?.ghostStart ?? 10; + this.cardStaggerMs = cfg.animation?.staggerMs ?? 55; + this.cardStartDelayMs = 90; + this.mode = 'save'; + this._titleDec = null; + this._subDec = null; + this.onDone = null; + + this.drawGhosts(); + } + + // ------------------------------------------------------------------ + // Public + // ------------------------------------------------------------------ + + get isOpen() { + return this.state !== 'hidden'; + } + + /** + * @param {'save'|'load'} mode + */ + show(mode) { + if (this.state !== 'hidden' || this.dead) return; + this.mode = mode === 'load' ? 'load' : 'save'; + this.accent = this.mode === 'save' ? this.saveAccent : this.loadAccent; + + // Header copy (decoded in — the console pulls the words out of static). + const title = String(config.get(`save.panel.title.${this.mode}`, this.mode === 'save' ? 'SAVE GAME' : 'LOAD GAME')); + const sub = String(config.get(`save.panel.subtitle.${this.mode}`, '')); + this._titleFinal = title.toUpperCase(); + this._subFinal = sub.toUpperCase(); + this.title.setColor(toCss(this.accent)); + const t0 = this.scene.time.now; + this._titleDec = new ScrambleDecode(this._titleFinal, t0 + 40, decodeDur(this._titleFinal.length)); + this._subDec = new ScrambleDecode(this._subFinal, t0 + 120, decodeDur(this._subFinal.length)); + this.title.setText(''); + this.subtitle.setText(''); + + // Cards: records + mode rules. + const records = this.sm.listSlots().map((s) => s.record); + this.cards.forEach((card, i) => { + card.setAccent(this.accent); + card._up = false; + card._locked = false; + card.panel.ignorePointer = false; + card.setRecord(records[i]); + card.setDisabled(this.mode === 'load' && records[i] === null); + card.setAlpha(0); + card.setY(card.y + 5); + }); + + // Input back on. + this.scrim.ignorePointer = false; + this.cancelBtn.panel.ignorePointer = false; + this.downloadBtn.panel.ignorePointer = false; + this.cancelBtn.setAlpha(0); + this.downloadBtn.setAlpha(0); + + // The crack. + const t = this.scene.time.now; + this.state = 'opening'; + this.t0 = t; + this.scrim.setAlpha(0); + this.scene.tweens.add({ targets: this.scrim, alpha: 0.82, duration: 200, ease: 'Sine.easeOut' }); + this.sweep.setX(-this.W / 2 + 2).setAlpha(0); + this.scene.tweens.add({ + targets: this.sweep, + x: this.W / 2 - 2, + duration: this.openDur + 60, + ease: 'Sine.easeIn', + onStart: () => this.sweep.setAlpha(0.5), + onComplete: () => this.sweep.setAlpha(0), + }); + // The deck kicks. + this.scene.cameras?.main?.shake(80, 0.0022); + this.scene.playSfx?.('construct'); + this.toast.state = 'hidden'; + } + + /** + * Fold the panel back into the seam. + * @param {Function} [onDone] — fired once the close animation lands + */ + close(onDone) { + if (this.state === 'hidden' || this.state === 'closing' || this.dead) return; + this.state = 'closing'; + this.t0 = this.scene.time.now; + this.onDone = typeof onDone === 'function' ? onDone : null; + // Cards cut fast (no stagger on the way out). + this.cards.forEach((c) => { + c._up = true; + c._dec = null; + c.panel.ignorePointer = true; + }); + this.scene.tweens.add({ targets: this.cards, alpha: 0, duration: 110, ease: 'Sine.easeIn' }); + this.scene.tweens.add({ targets: [this.cancelBtn, this.downloadBtn], alpha: 0, duration: 90 }); + this.scene.tweens.add({ targets: this.scrim, alpha: 0, duration: this.closeDur * 0.8, ease: 'Sine.easeIn' }); + this.scrim.ignorePointer = true; + this.cancelBtn.panel.ignorePointer = true; + this.downloadBtn.panel.ignorePointer = true; + this.scene.playSfx?.('deconstruct'); + } + + // ------------------------------------------------------------------ + // The ten slots + // ------------------------------------------------------------------ + + onCard(slot, card) { + const rec = this.sm.get(slot); + if (this.mode === 'save') { + if (!rec) { + this.doSave(slot); + return; + } + this.confirmOverwrite(slot, rec); + } else { + if (!rec) return; // inert in load mode + this.confirmLoad(slot, rec); + } + } + + /** Confirmation beat: replacing an occupied slot. */ + confirmOverwrite(slot, rec) { + const tmpl = config.section('save.confirm.overwrite', {}); + this.confirm.show({ + title: this.fillTemplate(String(tmpl.title ?? 'OVERWRITE SLOT {slot}?'), slot, rec, true), + body: (tmpl.body ?? []).map((l) => this.fillTemplate(String(l), slot, rec, true)).filter((l) => l.length), + accent: toColor(tmpl.accent ?? '#ff2d6f'), + confirmLabel: tmpl.confirmLabel ?? 'OVERWRITE', + cancelLabel: tmpl.cancelLabel ?? 'CANCEL', + onConfirm: () => this.doSave(slot), + time: this.scene.time.now, + }); + } + + /** Confirmation beat: loading replaces the live game. */ + confirmLoad(slot, rec) { + const tmpl = config.section('save.confirm.load', {}); + this.confirm.show({ + title: this.fillTemplate(String(tmpl.title ?? 'LOAD SLOT {slot}?'), slot, rec, false), + body: (tmpl.body ?? []).map((l) => this.fillTemplate(String(l), slot, rec, false)).filter((l) => l.length), + accent: toColor(tmpl.accent ?? '#ffc94d'), + confirmLabel: tmpl.confirmLabel ?? 'LOAD', + cancelLabel: tmpl.cancelLabel ?? 'CANCEL', + onConfirm: () => this.doLoad(slot), + time: this.scene.time.now, + }); + } + + /** {slot} → zero-padded number, {detail} → the record's identity line. */ + fillTemplate(line, slot, rec, isOverwrite) { + const slotStr = String(slot).padStart(2, '0'); + const galaxy = String(rec?.galaxyName ?? '').toUpperCase() || 'UNKNOWN'; + const system = String(rec?.systemName ?? '').toUpperCase(); + const date = SavePanel.dateStr(rec?.savedAt); + const detail = isOverwrite + ? `SLOT ${slotStr} · ${galaxy} · ${date}` + : `SLOT ${slotStr} · ${galaxy}${system ? ` · IN ${system}` : ''} · ${date}`; + return line + .replace(/\{slot\}/g, slotStr) + .replace(/\{detail\}/g, detail) + .replace(/\{galaxy\}/g, galaxy); + } + + static dateStr(savedAt) { + const t = Date.parse(savedAt); + if (!Number.isFinite(t)) return '—'; + const d = new Date(t); + const pad = (n) => String(n).padStart(2, '0'); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`; + } + + // ------------------------------------------------------------------ + // The verbs + // ------------------------------------------------------------------ + + doSave(slot) { + try { + const rec = captureState(this.scene); + this.sm.put(slot, rec); + this.toast.show(`${config.get('save.toast.saved', 'GAME SAVED')} · SLOT ${String(slot).padStart(2, '0')}`, { kind: 'ok' }); + const records = this.sm.listSlots().map((s) => s.record); + const card = this.cards[slot - 1]; + if (card) { + card.setRecord(records[slot - 1], { decodeFrom: this.scene.time.now }); + card.setAccent(this.accent); + } + this.scene.playSfx?.('construct'); + } catch (err) { + console.error('[save]', err); + this.toast.show(`${config.get('save.toast.saveFail', 'SAVE FAILED')} · ${(err.message ?? String(err)).toUpperCase()}`, { kind: 'error' }); + } + } + + doLoad(slot) { + try { + const rec = this.sm.get(slot); + if (!rec) throw new Error('EMPTY SLOT'); + prepareLoad(this.scene.registry, rec); + this.toast.show(`${config.get('save.toast.loaded', 'SIGNAL LOCKED')} · SLOT ${String(slot).padStart(2, '0')}`, { kind: 'ok' }); + const done = this.onLoadComplete; + this.close(() => { + if (done) { + try { + done(slot, rec); + } catch (err) { + console.error('[load]', err); + } + } + }); + } catch (err) { + console.error('[load]', err); + this.toast.show(`${config.get('save.toast.loadFail', 'LOAD FAILED')} · ${(err.message ?? String(err)).toUpperCase()}`, { kind: 'error' }); + } + } + + /** Bulk export: every saved game, one JSON download. */ + downloadAll() { + try { + const filled = this.sm.filledCount(); + if (!filled) { + this.toast.show(config.get('save.toast.noSaves', 'NO SAVED GAMES YET'), { kind: 'warn' }); + return; + } + const json = this.sm.exportAll(); // pretty JSON of the whole bank + const blob = new Blob([json], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `orbit-saves-${new Date().toISOString().replace(/[:.]/g, '-')}.json`; + document.body.appendChild(a); + a.click(); + a.remove(); + setTimeout(() => URL.revokeObjectURL(url), 4000); + this.toast.show(`${config.get('save.toast.exported', 'ALL SAVES EXPORTED')} · ${filled} SLOT${filled === 1 ? '' : 'S'}`, { kind: 'ok' }); + this.scene.playSfx?.('construct'); + } catch (err) { + console.error('[export]', err); + this.toast.show(`EXPORT FAILED · ${(err.message ?? String(err)).toUpperCase()}`, { kind: 'error' }); + } + } + + // ------------------------------------------------------------------ + // Drawing + // ------------------------------------------------------------------ + + /** + * The plate at revealed height h (grows out of the center seam, + * all four corners cut) — the same console language as the sub-bar + * body, mirrored about the seam. + */ + drawBody(h) { + const c = config.section('save.colors', {}); + const g = this.bodyG; + g.clear(); + const W = this.W; + h = Math.max(0, Math.min(this.H, h)); + if (h < 1) return; + const cut = Math.max(4, Math.min(16, h * 0.08, W * 0.03)); + const top = -h / 2; + const bot = h / 2; + const pts = [ + { x: -W / 2 + cut, y: top }, + { x: W / 2 - cut, y: top }, + { x: W / 2, y: top + cut }, + { x: W / 2, y: bot - cut }, + { x: W / 2 - cut, y: bot }, + { x: -W / 2 + cut, y: bot }, + { x: -W / 2, y: bot - cut }, + { x: -W / 2, y: top + cut }, + ]; + + g.fillStyle(toColor(c.panelBottom ?? '#050912'), 1); + g.fillPoints(pts, true); + g.fillStyle(toColor(c.panelTop ?? '#0e1930'), 0.55); + g.fillPoints(pts, true); + // Side glows (cyan left / magenta right — the deck's language). + g.fillStyle(toColor(c.neon ?? '#00e5ff'), 0.05); + g.fillRect(-W / 2, top, 30, h); + g.fillStyle(toColor(c.magenta ?? '#ff2d6f'), 0.06); + g.fillRect(W / 2 - 30, top, 30, h); + + // Rails: the top and bottom edges of the plate (mode accent + magenta). + g.fillStyle(this.accent, 0.8); + g.fillRect(-W / 2 + cut, top, W - cut * 2, 2); + g.fillStyle(toColor(c.magenta ?? '#ff2d6f'), 0.45); + g.fillRect(-W / 2 + cut, bot - 2, W - cut * 2, 2); + + // Corner brackets (HUD chrome) once the plate is mostly there. + if (h > this.H * 0.6) { + g.lineStyle(2, this.accent, 0.4); + const L = 12; + g.lineBetween(-W / 2 + 1, top + L, -W / 2 + 1, top + 1); + g.lineBetween(-W / 2 + 1, top + 1, -W / 2 + L, top + 1); + g.lineBetween(W / 2 - 1, top + L, W / 2 - 1, top + 1); + g.lineBetween(W / 2 - 1, top + 1, W / 2 - L, top + 1); + g.lineBetween(-W / 2 + 1, bot - L, -W / 2 + 1, bot - 1); + g.lineBetween(-W / 2 + 1, bot - 1, -W / 2 + L, bot - 1); + g.lineBetween(W / 2 - 1, bot - L, W / 2 - 1, bot - 1); + g.lineBetween(W / 2 - 1, bot - 1, W / 2 - L, bot - 1); + } + } + + /** The hot seam line at center (the crack the plate grows out of). */ + drawSeam(width, alpha) { + const g = this.seamG; + g.clear(); + if (alpha <= 0.01) return; + g.fillStyle(0xeaf6ff, 0.9 * alpha); + g.fillRect(-width / 2, -1, width, 2); + g.fillStyle(this.accent, 0.35 * alpha); + g.fillRect(-width / 2, -4, width, 8); + } + + /** The two channel ghosts (full-size plate outlines, additive). */ + drawGhosts() { + const W = this.W; + const H = this.H; + const cut = Math.min(16, H * 0.08, W * 0.03); + const top = -H / 2; + const bot = H / 2; + const pts = [ + { x: -W / 2 + cut, y: top }, + { x: W / 2 - cut, y: top }, + { x: W / 2, y: top + cut }, + { x: W / 2, y: bot - cut }, + { x: W / 2 - cut, y: bot }, + { x: -W / 2 + cut, y: bot }, + { x: -W / 2, y: bot - cut }, + { x: -W / 2, y: top + cut }, + ]; + const c = config.section('save.colors', {}); + for (const [gg, color] of [ + [this.ghostC, toColor(c.neon ?? '#00e5ff')], + [this.ghostM, toColor(c.magenta ?? '#ff2d6f')], + ]) { + gg.clear(); + gg.lineStyle(1.5, color, 1); + gg.strokePoints(pts, true); + } + } + + // ------------------------------------------------------------------ + // Per-frame + // ------------------------------------------------------------------ + + update(time) { + if (this.dead) return; + + // Card decode reveals + the overlay + the toast always tick. + for (const card of this.cards) card.update(time); + this.confirm.update(time); + this.toast.update(time); + + if (this.state === 'opening') { + const p = Phaser.Math.Clamp((time - this.t0) / this.openDur, 0, 1); + const e = 1 - Math.pow(1 - p, 3); + const h = Math.max(2, e * this.H); + this.drawBody(h); + this.drawSeam(this.W, 0.85 * (1 - e)); + this.scan.setSize(this.W, h).setPosition(0, 0).setAlpha(0.13); + // The channels converge as the plate locks in. + const off = this.ghostStart * (1 - e); + this.ghostC.setAlpha(0.5 * (1 - e)).setPosition(off * 0.85, -off * 0.35); + this.ghostM.setAlpha(0.5 * (1 - e)).setPosition(-off, off * 0.45); + // Header decodes. + this.driveDecode(this._titleDec, this.title, time, this._titleFinal); + this.driveDecode(this._subDec, this.subtitle, time, this._subFinal); + // The slot cards flicker up one by one. + this.cards.forEach((card, i) => { + if (card._up) return; + if (time - this.t0 >= this.cardStartDelayMs + i * this.cardStaggerMs) { + card._up = true; + this.scene.tweens.add({ targets: card, alpha: 1, duration: 150, ease: 'Sine.easeOut' }); + this.scene.tweens.add({ targets: card, y: card.y - 5, duration: 150, ease: 'Sine.easeOut' }); + } + }); + // The footer rises near the end. + if (p > 0.6) { + this.cancelBtn.setAlpha(0); + this.scene.tweens.add({ targets: this.cancelBtn, alpha: 1, duration: 130, ease: 'Sine.easeOut' }); + this.downloadBtn.setAlpha(0); + this.scene.tweens.add({ targets: this.downloadBtn, alpha: 1, duration: 130, ease: 'Sine.easeOut', delay: 40 }); + } + if (p >= 1) { + this.state = 'shown'; + this.drawBody(this.H); + this.seamG.clear(); + this.scan.setAlpha(0.1); + // A single quiet settle flash. + this.bodyG.setAlpha(0.7); + this.scene.tweens.add({ targets: this.bodyG, alpha: 1, duration: 140, ease: 'Sine.easeOut' }); + } + } else if (this.state === 'closing') { + const p = Phaser.Math.Clamp((time - this.t0) / this.closeDur, 0, 1); + const e = p * p * (3 - 2 * p); + const h = Math.max(0, (1 - e) * this.H); + this.drawBody(h); + this.drawSeam(this.W * 0.7, 0.6 * (1 - e)); + this.scan.setSize(this.W, h).setPosition(0, 0).setAlpha(0.1 * (1 - e)); + const off = this.ghostStart * e; + this.ghostC.setAlpha(0.4 * e).setPosition(off * 0.85, -off * 0.35); + this.ghostM.setAlpha(0.4 * e).setPosition(-off, off * 0.45); + if (p >= 1) { + this.state = 'hidden'; + this.bodyG.clear(); + this.seamG.clear(); + this.ghostC.setAlpha(0); + this.ghostM.setAlpha(0); + this.scan.setAlpha(0).setSize(this.W, 0); + this.title.setText(''); + this.subtitle.setText(''); + const done = this.onDone; + this.onDone = null; + if (done) done(); + } + } + } + + /** Advance one decode (title/subtitle). */ + driveDecode(dec, textObj, time, finalValue) { + if (!dec) return; + if (dec.finished(time)) { + textObj.setText(finalValue); + if (textObj === this.title) this._titleDec = null; + else this._subDec = null; + return; + } + if (dec.started(time)) { + textObj.setText(dec.display(time)); + } + } + + get dead() { + return this.scene === null || this.active === false; + } +} diff --git a/js/ui/SlotCard.js b/js/ui/SlotCard.js new file mode 100644 index 0000000..4d819e3 --- /dev/null +++ b/js/ui/SlotCard.js @@ -0,0 +1,374 @@ +import Phaser from '../vendor/phaser.js'; +import { config } from '../config/Config.js'; +import { toColor, toCss } from '../utils/Color.js'; +import { fontStack } from '../utils/Theme.js'; +import { CyberShape } from './CyberShape.js'; +import { ScrambleDecode } from '../utils/Decode.js'; + +const FONT_FALLBACK = "'Segoe UI', 'Helvetica Neue', Arial, sans-serif"; + +/** + * A SLOT CARD — one of the ten slots in the save/load pop-up + * (js/ui/SavePanel.js). + * + * The cut-corner card with its console text: + * + * ┌ SLOT 04 ────────────┐ + * │ KESSARIAN VOID │ ← galaxy name (header font) + * │ Kepler-9 │ ← current system + * │ 2026-09-04 · 10:12 │ ← savedAt · playtime + * └──────────────────────┘ + * + * or, when empty: "NO SIGNAL" — dim, inert in load mode, a fresh write + * target in save mode. + * + * Hover = accent edge + light sweep + label fringe (the MenuButton + * language); filled cards can REVEAL their text with the shared decode + * scramble (revealFrom) — a just-written slot types itself in. + * + * Screen-fixed (scrollFactor 0 on every child — the v4 input quirk, see + * MenuButton/ActionBar) because the pop-up lives in the scrolling + * game world. + */ +export class SlotCard extends Phaser.GameObjects.Container { + /** + * @param {Phaser.Scene} scene + * @param {object} [o] { + * accent?: number — the mode accent (save = neon, load = amber) + * onClick?(slot) — fired on press + * } + */ + constructor(scene, x, y, w, h, o = {}) { + super(scene, x, y); + // v4 quirk: a directly-constructed GameObject is NOT added to the + // display list — register it or it never renders. + this.scene.add.existing(this); + this.setScrollFactor(0); // screen-fixed UI + this.setSize(w, h); + + const c = config.section('save.colors', {}); + this.w = w; + this.h = h; + this.accent = toColor(o.accent ?? c.neon ?? '#00e5ff'); + this.onClick = typeof o.onClick === 'function' ? o.onClick : null; + + const famHeader = fontStack('header', FONT_FALLBACK); + const famBody = fontStack('body', FONT_FALLBACK); + + this.panel = scene.add.graphics().setScrollFactor(0); + this.add(this.panel); + + // Slot number — the card's constant identity (top-left). + this.number = scene.add + .text(-w / 2 + 12, -h / 2 + 7, '', { + fontFamily: famBody, + fontSize: '10px', + color: toCss(c.faint ?? '#3d4c74'), + letterSpacing: 2, + }) + .setOrigin(0, 0) + .setScrollFactor(0); + this.add(this.number); + + // A small status pip top-right: filled = accent, empty = dim. + this.pip = scene.add.circle(w / 2 - 13, -h / 2 + 11, 2.4, toColor(c.faint ?? '#3d4c74'), 0.8).setScrollFactor(0); + this.add(this.pip); + + // Content lines (centered block) — sized for the ~98 px card. + const inkCss = toCss(c.ink ?? '#eaf6ff'); + const dimCss = toCss(c.dim ?? '#7d92c4'); + const faintCss = toCss(c.faint ?? '#3d4c74'); + this.line1 = scene.add.text(0, -14, '', { + fontFamily: famHeader, + fontSize: '13px', + color: inkCss, + letterSpacing: 1.5, + }).setOrigin(0.5, 0).setScrollFactor(0); + this.line2 = scene.add.text(0, 7, '', { + fontFamily: famBody, + fontSize: '11px', + color: dimCss, + letterSpacing: 1, + }).setOrigin(0.5, 0).setScrollFactor(0); + this.line3 = scene.add.text(0, 27, '', { + fontFamily: famBody, + fontSize: '10px', + color: faintCss, + letterSpacing: 1, + }).setOrigin(0.5, 0).setScrollFactor(0); + this.add([this.line1, this.line2, this.line3]); + + // Hover fringe ghosts (additive), on line1 only — the label's RGB pull. + this.ghostCyan = scene.add + .text(0, -14, '', { fontFamily: famHeader, fontSize: '13px', color: toCss('#00e5ff'), letterSpacing: 1.5 }) + .setOrigin(0.5, 0).setAlpha(0).setBlendMode(Phaser.BlendModes.ADD).setScrollFactor(0); + this.ghostMagenta = scene.add + .text(0, -14, '', { fontFamily: famHeader, fontSize: '13px', color: toCss('#ff2d6f'), letterSpacing: 1.5 }) + .setOrigin(0.5, 0).setAlpha(0).setBlendMode(Phaser.BlendModes.ADD).setScrollFactor(0); + this.sweep = scene.add.rectangle(0, 0, 18, h - 12, this.accent, 0).setOrigin(0.5).setBlendMode(Phaser.BlendModes.ADD).setScrollFactor(0); + this.add([this.ghostCyan, this.ghostMagenta, this.sweep]); + + this.hoverOn = false; + this.disabled = false; + this.locked = true; // the panel unlocks input when it opens + this.filled = false; + this._sweepTween = null; + this._decodes = null; // { line1?, line3?, t0 } while a reveal is running + + // Hit-test the whole card rect (independent of the Graphics' draw + // state — repainting never breaks input). + this.panel.setInteractive({ + useHandCursor: true, + hitArea: new Phaser.Geom.Rectangle(-w / 2, -h / 2, w, h), + hitAreaCallback: (p, px, py) => Phaser.Geom.Rectangle.Contains(p, px, py), + }); + this.panel.on('pointerover', () => this.setHover(true)); + this.panel.on('pointerout', () => this.setHover(false)); + this.panel.on('pointerdown', () => this.press()); + + this.paint('empty'); + } + + // ------------------------------------------------------------------ + // Content + // ------------------------------------------------------------------ + + /** @param {number} n 1-based slot number */ + setNumber(n) { + this.slot = n; + this.number.setText(`SLOT ${String(n).padStart(2, '0')}`); + } + + /** + * Fill the card from a save record (or empty it). + * + * @param {object|null} record — the save record (null = empty slot) + * @param {object} [o] { decodeFrom?: engine time — type the new text in + * with the shared decode (a just-written slot) } + */ + setRecord(record, o = {}) { + this.filled = record !== null; + this.pip.setFillStyle(record ? this.accent : toColor(config.get('save.colors.faint', '#3d4c74')), record ? 0.95 : 0.55); + + if (!record) { + const empty = config.get('save.panel.slotEmpty', 'NO SIGNAL'); + this.line1.setText(empty); + this.line2.setText(''); + this.line3.setText(''); + this.ghostCyan.setText(empty); + this.ghostMagenta.setText(empty); + this._decodes = null; + this.paint(this.hoverOn ? 'hover' : (this.disabled ? 'disabled' : 'empty')); + return; + } + + const galaxy = String(record.galaxyName ?? 'UNKNOWN GALAXY').toUpperCase(); + const system = String(record.systemName ?? '').toUpperCase() ? `IN ${String(record.systemName).toUpperCase()}` : ''; + this.line1.setText(galaxy); + this.line2.setText(system); + this.line3.setText(SlotCard.metaLine(record)); + this.ghostCyan.setText(galaxy); + this.ghostMagenta.setText(galaxy); + + if (typeof o.decodeFrom === 'number') { + this._decodes = { + t0: o.decodeFrom, + line1: new ScrambleDecode(galaxy, o.decodeFrom, 520), + line2: system ? new ScrambleDecode(system, o.decodeFrom + 90, 420) : null, + line3: new ScrambleDecode(SlotCard.metaLine(record), o.decodeFrom + 140, 460), + }; + this.line1.setText(''); + if (this._decodes.line2) this.line2.setText(''); + this.line3.setText(''); + } + this.paint(this.hoverOn ? 'hover' : (this.disabled ? 'disabled' : 'filled')); + } + + /** "2026-09-04 · 10:12 · 42M" — the card's detail line. */ + static metaLine(record) { + const parts = []; + const savedAt = Date.parse(record.savedAt); + if (Number.isFinite(savedAt)) { + const d = new Date(savedAt); + const pad = (n) => String(n).padStart(2, '0'); + parts.push(`${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} · ${pad(d.getHours())}:${pad(d.getMinutes())}`); + } + const ms = Number(record.playTimeMs) || 0; + if (ms > 0) { + const m = Math.floor(ms / 60000); + if (m >= 60) parts.push(`${Math.floor(m / 60)}H ${m % 60}M`); + else if (m >= 1) parts.push(`${m}M`); + else parts.push(`${Math.max(1, Math.floor(ms / 1000))}S`); + } + return parts.join(' · '); + } + + // ------------------------------------------------------------------ + // States + // ------------------------------------------------------------------ + + /** 'empty' | 'filled' | 'hover' | 'disabled' */ + paint(state) { + const c = config.section('save.colors', {}); + const g = this.panel; + g.clear(); + const notch = Math.min(12, this.h * 0.24); + + if (state === 'disabled') { + CyberShape.draw(g, this.w, this.h, { + notch, + fill: toColor(c.cardBg ?? '#0a1222'), + fillAlpha: 0.4, + stroke: toColor(c.cardBorder ?? '#1e3050'), + strokeAlpha: 0.3, + lineWidth: 1.5, + }); + this.line1.setColor(toCss(c.faint ?? '#3d4c74')); + this.line2.setColor(toCss(c.faint ?? '#3d4c74')); + this.line3.setColor(toCss(c.faint ?? '#3d4c74')); + this.ghostCyan.setAlpha(0); + this.ghostMagenta.setAlpha(0); + return; + } + + if (state === 'empty') { + CyberShape.draw(g, this.w, this.h, { + notch, + fill: toColor(c.cardBg ?? '#0a1222'), + fillAlpha: 0.62, + stroke: toColor(c.cardBorder ?? '#1e3050'), + strokeAlpha: 0.75, + lineWidth: 1.5, + }); + // A faint dashed "empty socket" rail along the top. + g.fillStyle(toColor(c.cardBorder ?? '#1e3050'), 0.5); + for (let tx = -this.w / 2 + 10; tx < this.w / 2 - 14; tx += 9) { + g.fillRect(tx, -this.h / 2 + 6, 5, 1.5); + } + this.line1.setColor(toCss(c.faint ?? '#3d4c74')); + this.line2.setColor(toCss(c.faint ?? '#3d4c74')); + this.line3.setColor(toCss(c.faint ?? '#3d4c74')); + this.ghostCyan.setAlpha(0); + this.ghostMagenta.setAlpha(0); + return; + } + + const hover = state === 'hover'; + CyberShape.draw(g, this.w, this.h, { + notch, + fill: hover ? mixColor(toColor(c.cardBg ?? '#0a1222'), this.accent, 0.14) : toColor(c.cardBg ?? '#0a1222'), + fillAlpha: hover ? 0.94 : 0.85, + stroke: hover ? this.accent : toColor(c.cardBorder ?? '#1e3050'), + strokeAlpha: hover ? 1 : 0.8, + lineWidth: 1.5, + glow: hover ? this.accent : undefined, + glowAlpha: 0.28, + }); + // Accent rail along the top edge (the card's signature line). + g.fillStyle(this.accent, hover ? 1 : 0.55); + g.fillRect(-this.w / 2 + notch, -this.h / 2 + 1.5, this.w - notch * 2, 2); + this.line1.setColor(hover ? '#ffffff' : toCss(c.ink ?? '#eaf6ff')); + this.line2.setColor(toCss(c.dim ?? '#7d92c4')); + this.line3.setColor(hover ? toCss(c.dim ?? '#7d92c4') : toCss(c.faint ?? '#3d4c74')); + if (this.filled) { + this.ghostCyan.setAlpha(hover ? 0.7 : 0).setPosition(-2, 0); + this.ghostMagenta.setAlpha(hover ? 0.7 : 0).setPosition(2, 0); + } + } + + setHover(on) { + if (this.disabled) return; + this.hoverOn = on; + this.paint(on ? 'hover' : (this.filled ? 'filled' : 'empty')); + if (this._sweepTween) { + this._sweepTween.remove(); + this._sweepTween = null; + } + if (on) { + const half = this.w / 2 - 12; + this.sweep.setX(-half).setAlpha(0.45); + this._sweepTween = this.scene.tweens.add({ + targets: this.sweep, + x: half, + duration: 380, + ease: 'Sine.easeOut', + onComplete: () => this.sweep.setAlpha(0), + }); + } else { + this.sweep.setAlpha(0); + } + } + + press() { + if (this.disabled || this.dead || this.locked) return; + // Punch + fire — the MenuButton's click language. + this.scene.tweens.add({ targets: this, scale: 0.965, duration: 70, yoyo: true, ease: 'Sine.easeOut' }); + if (typeof this.onClick === 'function') { + try { + this.onClick(this.slot, this); + } catch (err) { + console.error('[slotcard] onClick failed', err); + } + } + } + + /** Inert (empty slots in load mode): dim, no hover, no click. */ + setDisabled(on) { + this.disabled = !!on; + if (on) this.hoverOn = false; + this.paint(on ? 'disabled' : (this.hoverOn ? 'hover' : (this.filled ? 'filled' : 'empty'))); + } + + /** Re-point the accent (mode color: save = neon, load = amber). */ + setAccent(a) { + this.accent = toColor(a); + this.sweep.setFillStyle(this.accent, 0); + this.pip.setFillStyle( + this.filled ? this.accent : toColor(config.get('save.colors.faint', '#3d4c74')), + this.filled ? 0.95 : 0.55, + ); + this.paint(this.disabled ? 'disabled' : (this.hoverOn ? 'hover' : (this.filled ? 'filled' : 'empty'))); + } + + // ------------------------------------------------------------------ + // Per-frame + // ------------------------------------------------------------------ + + /** Drive a running decode (called by SavePanel.update). */ + update(time) { + if (!this._decodes) return; + const d = this._decodes; + let done = true; + const drive = (textObj, dec) => { + if (!textObj || !dec) return; + if (dec.finished(time)) return; + if (!dec.started(time)) { + done = false; + return; + } + textObj.setText(dec.display(time)); + if (!dec.finished(time)) done = false; + }; + drive(this.line1, d.line1); + drive(this.line2, d.line2); + drive(this.line3, d.line3); + if (done) { + this.line1.setText(d.line1.value); + if (d.line2) this.line2.setText(d.line2.value); + if (d.line3) this.line3.setText(d.line3.value); + this._decodes = null; + } + } + + get dead() { + return this.scene === null || this.active === false; + } +} + +/** Blend two color ints toward each other (like ActionBar's mixColor). */ +function mixColor(a, b, t) { + const r = Math.round(((a >> 16) & 255) + (((b >> 16) & 255) - ((a >> 16) & 255)) * t); + const g = Math.round(((a >> 8) & 255) + (((b >> 8) & 255) - ((a >> 8) & 255)) * t); + const bl = Math.round((a & 255) + ((b & 255) - (a & 255)) * t); + return (r << 16) | (g << 8) | bl; +} diff --git a/js/ui/Toast.js b/js/ui/Toast.js new file mode 100644 index 0000000..7946ae5 --- /dev/null +++ b/js/ui/Toast.js @@ -0,0 +1,118 @@ +import Phaser from '../vendor/phaser.js'; +import { config } from '../config/Config.js'; +import { toColor, toCss } from '../utils/Color.js'; +import { fontStack } from '../utils/Theme.js'; +import { CyberShape } from './CyberShape.js'; + +const FONT_FALLBACK = "'Segoe UI', 'Helvetica Neue', Arial, sans-serif"; + +/** + * A TOAST — the save pop-up's feedback line ("GAME SAVED · SLOT 03", + * "SAVE FAILED · STORAGE FULL"), a slim cut-corner strip that slides in + * above the panel, holds, and dissolves. + * + * Kinds (data/save.json → toast): ok (neon) · warn (amber) · error (red) — + * each with its own color + duration. + * + * const toast = new Toast(scene, x, y); + * toast.show('GAME SAVED · SLOT 03', { kind: 'ok' }); + * toast.update(time); // called by the panel + * + * Screen-fixed (scrollFactor 0 on every child — the v4 quirk). + */ +export class Toast extends Phaser.GameObjects.Container { + constructor(scene, x, y) { + super(scene, x, y); + this.scene.add.existing(this); + this.setScrollFactor(0); + this.setSize(280, 30); + + const def = config.get('save.toast.ok', {}); + this.accent = toColor(def.color ?? '#00e5ff'); + this.holdMs = def.durationMs ?? 2400; + + this.panelG = scene.add.graphics().setScrollFactor(0); + this.add(this.panelG); + this.text = scene.add + .text(0, 1, '', { + fontFamily: fontStack('body', FONT_FALLBACK), + fontSize: '11px', + color: toCss(config.get('save.colors.ink', '#eaf6ff')), + letterSpacing: 2, + }) + .setOrigin(0.5) + .setScrollFactor(0); + this.add(this.text); + + this.state = 'hidden'; // hidden | in | hold | out + this.t0 = 0; + } + + /** + * @param {string} msg + * @param {object} [o] { kind?: 'ok'|'warn'|'error', time? } + */ + show(msg, o = {}) { + const kind = o.kind ?? 'ok'; + const def = config.get(`save.toast.${kind}`, { color: '#00e5ff', durationMs: 2400 }) ?? {}; + this.accent = toColor(def.color ?? '#00e5ff'); + this.holdMs = def.durationMs ?? 2400; + this.text.setText(String(msg ?? '').toUpperCase()); + // Size the panel to the message (measure, then paint). + const w = Math.max(120, this.text.width + 34); + this.setSize(w, 30); + this.panelG.clear(); + CyberShape.draw(this.panelG, w, 30, { + notch: 8, + fill: 0x0a1322, + fillAlpha: 0.94, + stroke: this.accent, + strokeAlpha: 0.65, + lineWidth: 1.5, + }); + // Accent tick on the left (the toast's signature). + this.panelG.fillStyle(this.accent, 0.9); + this.panelG.fillRect(-w / 2 + 3, -9, 3, 18); + + const t = o.time ?? this.scene.time.now; + this.state = 'in'; + this.t0 = t; + this.setAlpha(0); + this.setY((this.baseY ?? this.y) + 6); + } + + get isOpen() { + return this.state !== 'hidden'; + } + + /** Driven by the panel's update(). */ + update(time) { + if (this.state === 'hidden') return; + const baseY = this.baseY ?? this.y; + const p = Phaser.Math.Clamp((time - this.t0) / 170, 0, 1); + if (this.state === 'in') { + this.setAlpha(1 - (1 - p) * (1 - p)); + this.setY(baseY + 6 * (1 - p)); + if (p >= 1) { + this.state = 'hold'; + this.t0 = time; + } + } else if (this.state === 'hold') { + if (time - this.t0 >= this.holdMs) { + this.state = 'out'; + this.t0 = time; + } + } else if (this.state === 'out') { + const q = Phaser.Math.Clamp((time - this.t0) / 200, 0, 1); + this.setAlpha(1 - q); + this.setY(baseY - 4 * q); + if (q >= 1) this.state = 'hidden'; + } + } + + /** Park the toast at a new spot (above the panel). */ + setBaseY(y) { + this.baseY = y; + if (this.state === 'hidden') this.setY(y); + } +}