Add save/load system with 10-slot localStorage bank and UI
- SaveManager: 10-slot localStorage bank (put/get/clear/list/export), corrupt-bank recovery, failed-write surfacing, bulk JSON export - SaveData: captureState / prepareLoad / consumeRestore / resetRunState — seed + player state round-trip through a shared registry - MenuSubBar: fold-up drawer off the deck's MENU button (Save Game / Load Game / Return to Main Menu) with animated open/close - SavePanel: 10-slot pop-up in save or load mode; overwrite + load confirm dialogs; "download all" bulk export; toast feedback - ConfirmOverlay: split-open confirm dialog for destructive actions - SlotCard, Toast: per-slot cards and inline feedback strips - MenuScene: Load Game button (grayed while bank is empty) opens the same panel in load mode - GameScene: menu sub-bar + save panel wired into deck action, ESC handling, input blocking while modal, applyRestore on scene boot - Tether HUD moved from above the MENU button to upper-right corner, stacking downward - MenuButton: disabled (grayed) state and screenFixed option for scrolling-camera UI - New SFX assets (ui-click, ui-hover) + updated existing FX; gasgiant planet image - Dev tooling: cdp-firefox.mjs, wdshot.mjs (headless Firefox via geckodriver), saves-ui-test.html/.mjs (integration driver), saves.test.mjs (Node unit tests), system-hud test updates
This commit is contained in:
parent
2d1265d592
commit
217ba6bedb
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 2.3 MiB |
|
|
@ -14,6 +14,7 @@
|
|||
"research.json",
|
||||
"builds.json",
|
||||
"actionbar.json",
|
||||
"sfx.json"
|
||||
"sfx.json",
|
||||
"save.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": {
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,151 @@
|
|||
/**
|
||||
* Run a page in headless Firefox (via geckodriver, W3C WebDriver) and
|
||||
* print a result object.
|
||||
*
|
||||
* node dev/cdp-firefox.mjs <url> <expression> [timeoutMs=25000] [pollMs=250]
|
||||
*
|
||||
* <expression> 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 <url> <expression> [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 */ }
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Orbit — dev save-system UI test</title>
|
||||
<!-- This page lives in /dev, but the game's relative asset paths are
|
||||
rooted at the project root — resolve them against it. -->
|
||||
<base href="../" />
|
||||
<style>
|
||||
html, body { margin: 0; height: 100%; background: #04060d; overflow: hidden; }
|
||||
#game { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; }
|
||||
</style>
|
||||
<!-- The CDP runner (dev/cdp-firefox.mjs) reads window.__CAPTURED_ERRORS__
|
||||
to surface any page-level failure the driver itself can't see. -->
|
||||
<script>
|
||||
window.__CAPTURED_ERRORS__ = [];
|
||||
window.__CAPTURED_LOGS__ = [];
|
||||
const __oe = console.error.bind(console);
|
||||
const __ol = console.log.bind(console);
|
||||
console.error = (...a) => { window.__CAPTURED_ERRORS__.push(a.map(String).join(' ').slice(0, 600)); __oe(...a); };
|
||||
console.log = (...a) => { window.__CAPTURED_LOGS__.push(a.map(String).join(' ').slice(0, 300)); __ol(...a); };
|
||||
window.addEventListener('error', (e) => window.__CAPTURED_ERRORS__.push('window: ' + e.message + ' @ ' + (e.filename||'') + ':' + (e.lineno||'')));
|
||||
window.addEventListener('unhandledrejection', (e) => window.__CAPTURED_ERRORS__.push('rejection: ' + String(e.reason && e.reason.stack || e.reason)));
|
||||
</script>
|
||||
<script src="lib/phaser.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="game"></div>
|
||||
<script type="module" src="dev/saves-ui-test.mjs"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -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()));
|
||||
|
|
@ -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');
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
|
|
|
|||
|
|
@ -0,0 +1,131 @@
|
|||
// One-shot headless-Firefox screenshot via geckodriver (W3C WebDriver).
|
||||
//
|
||||
// node dev/wdshot.mjs <url> <out.png> [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 <url> <out.png> [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 */ }
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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<string,object>}} — 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
// Upper-right corner, lines stack downward.
|
||||
const right = this.scale.width - pad;
|
||||
let y = pad;
|
||||
for (const t of made) {
|
||||
t.setPosition(16, y);
|
||||
y += 18;
|
||||
}
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue