Add SurfaceScene with landing video and surface deck

- New SurfaceScene overlays GameScene when the player requests landing:
  plays a one-shot landing clip, then a looping surface clip behind a
  dedicated command deck (Shop/Build/Ship + Take Off + Menu).
- Wire comms panel "Request Landing" / "Land" buttons to launch the
  surface; TAKE OFF stops SurfaceScene and wakes GameScene so the run
  resumes exactly where it left off.
- ActionBar accepts an optional `buttons` override so scenes can supply a
  different slot list while sharing layout/colors/animation.
- SavePanel gains a `stateScene` option so saves from the surface read
  state from the paused GameScene underneath.
- Add data/landing.json (per-frame video map) and four placeholder
  surface clips; CommsPanel now passes its full target payload through to
  onAction so landing metadata survives the round-trip.
- Add dev harnesses: headless-Firefox screenshot helper, click-path test
  for the real user flow, and a surface-flow assertion suite with parked
  screenshot pages.
This commit is contained in:
Brian Fertig 2026-09-04 21:25:44 -06:00
parent d933914ac3
commit dd7000638a
21 changed files with 986 additions and 24 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -19,6 +19,17 @@
{ "id": null, "label": null }, { "id": null, "label": null },
{ "id": "menu", "label": "Menu", "accent": "#ff2d6f" } { "id": "menu", "label": "Menu", "accent": "#ff2d6f" }
], ],
"surface": {
"_comment": "Deck layout for the surface (SurfaceScene): `research` is replaced by `shop` (no-op for now) and `takeoff` sits to the left of `menu`. SurfaceScene passes this list to ActionBar via its `buttons` option; everything else (height, colors, animation) is shared with the flight deck.",
"buttons": [
{ "id": "shop", "label": "Shop", "accent": "#00e5ff" },
{ "id": "build", "label": "Build", "accent": "#ffc94d" },
{ "id": "ship", "label": "Ship", "accent": "#7ce8a4" },
{ "id": null, "label": null },
{ "id": "takeoff", "label": "Take Off", "accent": "#7ce8a4" },
{ "id": "menu", "label": "Menu", "accent": "#ff2d6f" }
]
},
"colors": { "colors": {
"panelTop": "#101c36", "panelTop": "#101c36",
"panelBottom": "#04070e", "panelBottom": "#04070e",

30
data/landing.json Normal file
View File

@ -0,0 +1,30 @@
{
"_comment": [
"Landing + surface videos, keyed by planets.png SHEET FRAME (same index the",
"planet sprite uses — frames 0..2 are the terran worlds, 3..5 the gas",
"giants; see data/planets.json → frames).",
"",
"`land` — the full-screen one-shot clip played when the ship lands.",
"`surface` — the looping clip shown on the surface deck behind the action bar.",
"",
"Entries are stubbed with the existing videos where a specific clip is",
"missing: set `land`/`surface` to the real file name (in `videoBase`) when",
"it is authored — no code change needed. Gas-giant surface clips don't",
"exist yet, so they loop a terran surface clip as a placeholder.",
"An entry may set `land`/`surface` to null to skip that stage entirely",
"(land goes straight to the surface; a null surface shows the flat deck).",
"Set `enabled` to false to disable landing entirely (REQUEST LANDING is",
"greyed out in comms, same as a reputation ban)."
],
"enabled": true,
"videoBase": "assets/videos/",
"volume": 1,
"videos": [
{ "land": "terran-land-01.mp4", "surface": "terran-surface-01.mp4" },
{ "land": "terran-land-02.mp4", "surface": "terran-surface-01.mp4" },
{ "land": "terran-land-01.mp4", "surface": "terran-surface-01.mp4" },
{ "land": "gasgiant-land-01.mp4", "surface": "terran-surface-01.mp4" },
{ "land": "gasgiant-land-02.mp4", "surface": "terran-surface-01.mp4" },
{ "land": "gasgiant-land-01.mp4", "surface": "terran-surface-01.mp4" }
]
}

View File

@ -16,6 +16,7 @@
"research.json", "research.json",
"builds.json", "builds.json",
"actionbar.json", "actionbar.json",
"landing.json",
"sfx.json", "sfx.json",
"save.json" "save.json"
] ]

32
dev/landing-click.html Normal file
View File

@ -0,0 +1,32 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Orbit — dev landing click-path test</title>
<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>
<script src="lib/phaser.min.js"></script>
<script>
window.__PAGE_ERR__ = null;
window.addEventListener('error', (e) => {
window.__PAGE_ERR__ = { type: 'error', msg: String(e.message), src: String(e.filename).slice(-60) + ':' + e.lineno };
});
window.addEventListener('unhandledrejection', (e) => {
window.__PAGE_ERR__ = { type: 'rejection', msg: String(e.reason) };
});
const __CON = [];
for (const m of ['log', 'info', 'warn', 'error']) {
const orig = console[m].bind(console);
console[m] = (...a) => { try { __CON.push(a.map((x) => (typeof x === 'string' ? x : String(x))).join(' ').slice(0, 400)); } catch {} orig(...a); };
}
window.__CONSOLE__ = () => __CON.slice(-30);
</script>
</head>
<body>
<div id="game"></div>
<script type="module" src="dev/landing-click.mjs"></script>
</body>
</html>

143
dev/landing-click.mjs Normal file
View File

@ -0,0 +1,143 @@
/**
* THE USER'S EXACT PATH click the home planet, then click the
* REQUEST LANDING button in the comms panel (real pointer events, no
* direct scene calls). This is the path the headless flow test skipped
* (it called startLanding() directly) and it caught the lastTarget
* pruning bug where the panel's button payload lost `isPlanet`/`frame`.
*
* PUMP-DRIVEN (like dev/mining-test.mjs): this box's headless Firefox
* freezes rAF/timers after first paint, so the page is advanced by the
* runner's wake each poll calls __LC_TICK__(), which steps the Phaser
* loop once and advances the stage machine one step.
*
* python3 -m http.server 8123
* node dev/cdp-firefox.mjs http://127.0.0.1:8123/dev/landing-click.html \
* 'window.__LC_TICK__(); return window.__LANDED__;' 60000 30
*/
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 { GameScene } from '../js/scenes/GameScene.js';
import { SurfaceScene } from '../js/scenes/SurfaceScene.js';
const data = await ConfigLoader.load();
config.init(data);
const gameConfig = createGameConfig();
gameConfig.scene = [GameScene, SurfaceScene];
const game = new Phaser.Game(gameConfig);
window.game = game;
const out = { pass: false, steps: [], error: null };
const step = (label, cond, diag) => {
out.steps.push(label);
if (!cond) {
out.error = `${label}${diag ? ': ' + JSON.stringify(diag).slice(0, 300) : ''}`;
window.__LANDED__ = out;
throw new Error(out.error);
}
};
let stage = 0;
let pendingPoke = null; // { x, y } — a canvas pointer click to dispatch this tick
const diag = () => {
let gs = null, cp = null;
try { gs = game.scene?.getScene('GameScene') ?? null; } catch { /* not up yet */ }
if (gs?.commsPanel) cp = { open: gs.commsPanel.isOpen, state: gs.commsPanel.state };
window.__LC_STATE__ = {
stage, steps: out.steps.slice(), error: out.error,
gs: gs ? { status: gs.sys.getStatus(), ship: !!gs.ship, cp } : 'none',
ss: (() => { try { const s = game.scene.getScene('SurfaceScene'); return s ? { status: s.sys.getStatus(), phase: s.phase } : null; } catch { return null; } })(),
};
};
const canvas = () => game.canvas;
const poke = (sx, sy) => {
const opts = {
bubbles: true, cancelable: true, pointerId: 1, pointerType: 'mouse',
isPrimary: true, clientX: sx, clientY: sy, button: 0, buttons: 1,
};
canvas().dispatchEvent(new PointerEvent('pointerover', opts));
canvas().dispatchEvent(new PointerEvent('pointerdown', opts));
canvas().dispatchEvent(new PointerEvent('pointerup', { ...opts, buttons: 0 }));
};
const scene = (key) => { try { return game.scene?.getScene(key) ?? null; } catch { return null; } };
function tick() {
diag();
// v4: do NOT step the loop before boot completes — `game.scene` (the
// scene manager) is created during boot, and pumping loop.step() before
// that wedges the boot (the scene never comes up). The mining test's
// pump guards the same way (it only steps once g.loop is live).
if (!game.loop || !game.scene) return;
try {
// (a) dispatch this tick's click, (b) one engine frame, (c) advance.
if (pendingPoke) { poke(pendingPoke.x, pendingPoke.y); pendingPoke = null; }
game.loop.step(performance.now());
const gs = scene('GameScene');
const ss = scene('SurfaceScene');
switch (stage) {
case 0: // boot: the flight scene is live
if (!(gs && gs.ship)) return;
step('boot', true, { frame: gs.planet.sheetFrame });
{
// Click a VISIBLE point of the planet disc: the ship spawns on
// the rim (camera centered on the ship), so the disc rim facing
// the ship is the on-screen part — click just inside that rim.
const cam = gs.cameras.main;
const dx = gs.ship.x - gs.planet.x, dy = gs.ship.y - gs.planet.y;
const d = Math.hypot(dx, dy) || 1;
const wx = gs.planet.x + (dx / d) * gs.planet.radius * 0.98;
const wy = gs.planet.y + (dy / d) * gs.planet.radius * 0.98;
const px = wx - cam.scrollX, py = wy - cam.scrollY;
step('planet-on-screen', px > 8 && px < 1272 && py > 8 && py < 712, { px, py, planetRadius: gs.planet.radius });
pendingPoke = { x: px, y: py };
}
stage = 1;
return;
case 1: // the planet click opened the comms panel
if (!(gs.commsPanel && gs.commsPanel.isOpen)) return;
step('comms-opened', true);
step('button-enabled', gs.commsPanel.btn1.disabled === false,
{ label: gs.commsPanel.btn1.text?.text, disabled: gs.commsPanel.btn1.disabled });
{
const t = gs.commsPanel.lastTarget;
step('lastTarget-isPlanet', t?.isPlanet === true, { lastTarget: t });
step('lastTarget-frame', t?.frame === gs.planet.sheetFrame,
{ frame: t?.frame, sheetFrame: gs.planet.sheetFrame });
// Panel is origin (0, 0.5): local (0, H/2) sits at (cp.x, cp.y).
const cam = gs.cameras.main;
const cp = gs.commsPanel;
const bx = cp.x + cp.btn1.x - cam.scrollX;
const by = cp.y + cp.btn1.y - cp.H / 2 - cam.scrollY;
step('button-on-screen', bx > 8 && bx < 1272 && by > 8 && by < 712, { bx, by });
pendingPoke = { x: bx, y: by };
}
stage = 2;
return;
case 2: // the button press landed the ship
if (!(ss && ss.sys.isActive())) return;
step('panel-closed', gs.commsPanel.isOpen === false, { state: gs.commsPanel.state });
step('surface-running', true,
{ keys: game.scene.getScenes().map((s) => `${s.sys.id}:${s.sys.getStatus()}`) });
step('game-sleeping', gs.sys.isSleeping() === true, { gsStatus: gs.sys.getStatus() });
step('phase-landing', ss.phase === 'landing', { phase: ss.phase, landVideo: !!ss.landVideo });
step('handoff-frame', ss.planetFrame === gs.planet.sheetFrame,
{ ss: ss.planetFrame, planet: gs.planet.sheetFrame });
out.pass = true;
window.__LANDED__ = out;
stage = 3;
return;
}
} catch (err) {
out.error = String(err?.message ?? err);
window.__LANDED__ = out;
stage = 99; // stop
}
}
window.__LC_TICK__ = tick;

121
dev/shot-firefox.mjs Normal file
View File

@ -0,0 +1,121 @@
/**
* Headless-Firefox screenshot helper (W3C WebDriver via geckodriver):
*
* node dev/shot-firefox.mjs <url> <pollExpr> <outPng> [timeoutMs=25000]
*
* <pollExpr> is evaluated with execute/sync and polled until it returns
* a non-null value (the page should set the state first), then the
* session's screenshot is saved to <outPng>.
*/
import { spawn, execFileSync } from 'node:child_process';
import { existsSync, readdirSync, writeFileSync } from 'node:fs';
const [url, expr, outPng, timeoutMs = '30000'] = process.argv.slice(2);
if (!url || !expr || !outPng) {
console.error('usage: node dev/shot-firefox.mjs <url> <pollExpr> <outPng> [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 firefox + 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 { /* no /snap/firefox */ }
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) => {
try { driver.kill('SIGKILL'); } catch { /* dead */ }
console.error(msg);
process.exit(1);
};
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,
},
},
},
});
break;
} catch (err) {
lastErr = String(err?.message ?? err);
}
}
if (!sess) fail(`no firefox candidate accepted: ${lastErr}`);
const sid = sess.value?.sessionId;
await wd('POST', `/session/${sid}/window/size`, { width: 1280, height: 720 }).catch(() => {});
await wd('POST', `/session/${sid}/url`, { url });
const deadline = Date.now() + Number(timeoutMs);
let pollErr = null;
while (Date.now() < deadline) {
await sleep(300);
try {
const r = await wd('POST', `/session/${sid}/execute/sync`, { script: expr, args: [] });
if (r.value !== undefined && r.value !== null) break;
} catch (err) {
pollErr = String(err?.message ?? err);
}
}
await sleep(400); // let the last frame paint
const shot = await wd('GET', `/session/${sid}/screenshot`);
writeFileSync(outPng, Buffer.from(shot.value, 'base64'));
console.log(`saved ${outPng}${pollErr ? ` (last poll error: ${pollErr})` : ''}`);
await wd('DELETE', `/session/${sid}`).catch(() => {});
} finally {
try { driver.kill('SIGKILL'); } catch { /* dead */ }
}

144
dev/surface-flow.mjs Normal file
View File

@ -0,0 +1,144 @@
/**
* Dev-only SURFACE FLOW test: boots straight into the GameScene (smoke
* style), then drives the landing flow end-to-end and reports a result
* object for dev/cdp-firefox.mjs to poll:
*
* python3 -m http.server 8123
* node dev/cdp-firefox.mjs http://127.0.0.1:8123/dev/surface-test.html \
* 'return window.__SURFACE_TEST__;'
*
* Steps:
* 1. wait for the GameScene to be ready
* 2. GameScene.startLanding(commsTargetFor(home world)) the real entry
* 3. assert SurfaceScene launched, GameScene paused, landing clip present
* 4. force the surface stage (skip the clip's runtime)
* 5. assert deck slots (shop/takeoff/menu), save UI, surface clip
* 6. menuAction sub-bar opens; save panel opens
* 7. takeOff SurfaceScene stopped, GameScene resumed, ship preserved
* 8. land again takeOff again (repeat-launch/re-stop cycle)
*/
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 { GameScene } from '../js/scenes/GameScene.js';
import { SurfaceScene } from '../js/scenes/SurfaceScene.js';
const fail = (msg, extra = {}) => {
const gs = window.game?.scene?.getScene?.('GameScene');
const ss = window.game?.scene?.getScene?.('SurfaceScene');
window.__SURFACE_TEST__ = {
pass: false,
error: msg,
steps: window.__STEPS__ ?? [],
diag: {
sceneKeys: Array.isArray(window.game?.scene?.keys)
? window.game.scene.keys.slice()
: Object.keys(window.game?.scene?.keys ?? {}),
gs: gs ? { status: gs.sys.getStatus(), hasShip: !!gs.ship } : null,
ss: ss ? { status: ss.sys.getStatus(), phase: ss.phase } : 'missing',
...extra,
},
};
console.error('[surface-flow] FAIL:', msg, extra);
};
window.__FLOW_STARTED__ = true; // module graph loaded — for dev/cdp-firefox.mjs debugging
const step = (name, cond, extra = {}) => {
if (!cond) throw new Error(`${name}: ${JSON.stringify(extra).slice(0, 300)}`);
window.__STEPS__ = (window.__STEPS__ ?? []).concat(name);
};
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const data = await ConfigLoader.load();
config.init(data);
const gameConfig = createGameConfig();
gameConfig.scene = [GameScene, SurfaceScene];
const game = new Phaser.Game(gameConfig);
window.game = game;
(async () => {
// 1 — wait for the flight scene to be live (v4 boots asynchronously
// after `new Phaser.Game`).
let gs = null;
for (let i = 0; i < 300; i++) {
gs = game.scene?.getScene('GameScene') ?? null;
if (gs && gs.ship) break;
await sleep(100);
}
if (!gs || !gs.ship) throw new Error('GameScene never came up (gs=' + String(gs) + ')');
step('boot', true, { frame: gs.planet.sheetFrame });
const shipBefore = { x: gs.ship.x, y: gs.ship.y };
const target = gs.commsTargetFor(gs.planet);
step('comms-target', target.isPlanet === true && target.canLand === true, target);
// 2 — the real entry point (v4: launch + sleep the flight world).
gs.startLanding(target);
await sleep(250);
const ss = game.scene.getScene('SurfaceScene');
step('launched', ss.sys.isActive() === true,
{ ssStatus: ss.sys.getStatus(), gsStatus: gs.sys.getStatus() });
step('game-sleeping', gs.sys.isSleeping() === true, { gsStatus: gs.sys.getStatus() });
step('frame', Number.isInteger(ss.planetFrame) && ss.planetFrame === Number(target.frame),
{ ssFrame: ss.planetFrame, targetFrame: target.frame });
// 3 — the landing clip was queued + attached (or legitimately absent).
const landKey = `__surf_land_${ss.planetFrame}`;
const landCached = ss.cache.video.has(landKey);
step('landing-clip', landCached ? ss.landVideo !== null : true, { landCached, landKey });
// 4 — force the surface stage (the test does not wait out the clip).
ss.startSurface();
await sleep(300);
step('surface-stage', ss.phase === 'surface');
// 5 — the deck.
const ids = (ss.actionBar?.slots ?? []).map((s) => s.id);
step('deck-slots',
ids.length === 6 && ids[0] === 'shop' && ids[4] === 'takeoff' && ids[5] === 'menu'
&& ids[1] === 'build' && ids[2] === 'ship' && ids[3] === null,
{ ids });
step('save-ui', !!ss.menuSubBar && !!ss.savePanel && !!ss.saveManager,
{ subBar: !!ss.menuSubBar, panel: !!ss.savePanel });
const loopKey = `__surf_loop_${ss.planetFrame}`;
const loopCached = ss.cache.video.has(loopKey);
step('surface-clip', loopCached ? ss.surfaceVideo !== null : true, { loopCached });
// 6 — the menu door (sub-bar → save pop-up) works on the surface.
ss.menuAction();
await sleep(120);
step('subbar-open', ss.menuSubBar.isOpen === true);
ss.subBarAction('save');
await sleep(120);
step('savepanel-open', ss.savePanel.isOpen === true);
// A real save round-trip THROUGH the paused GameScene (stateScene):
// slot 1 is empty → onCard saves straight (no confirm beat).
ss.savePanel.onCard(1);
step('save-from-surface', ss.saveManager.hasAny() === true, { hasAny: ss.saveManager.hasAny() });
ss.savePanel.close();
ss.menuSubBar.dismiss();
// 7 — TAKE OFF: overlay stopped, flight scene woken, ship where it was.
ss.takeOff();
await sleep(250);
step('takeoff', ss.sys.isActive() === false && gs.sys.isActive() === true,
{ ssStatus: ss.sys.getStatus(), gsStatus: gs.sys.getStatus() });
step('ship-preserved', Math.abs(gs.ship.x - shipBefore.x) < 1e-6
&& Math.abs(gs.ship.y - shipBefore.y) < 1e-6,
{ before: shipBefore, after: { x: gs.ship.x, y: gs.ship.y } });
// 8 — land again + take off again (the re-launch cycle must stay clean).
gs.startLanding(gs.commsTargetFor(gs.planet));
await sleep(250);
const ss2 = game.scene.getScene('SurfaceScene');
step('re-land', ss2.sys.isActive() === true);
ss2.startSurface();
await sleep(120);
step('re-surface', ss2.phase === 'surface' && ss2.actionBar !== null);
ss2.takeOff();
await sleep(250);
step('re-takeoff', ss2.sys.isActive() === false && gs.sys.isActive() === true);
window.__SURFACE_TEST__ = { pass: true, steps: window.__STEPS__ };
console.info('[surface-flow] PASS:', (window.__STEPS__ ?? []).join(' → '));
})().catch((err) => fail(String(err && err.message || err)));

17
dev/surface-shot.html Normal file
View File

@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Orbit — dev surface screenshot page</title>
<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>
<script src="lib/phaser.min.js"></script>
</head>
<body>
<div id="game"></div>
<script type="module" src="dev/surface-shot.mjs"></script>
</body>
</html>

52
dev/surface-shot.mjs Normal file
View File

@ -0,0 +1,52 @@
// Visual probe for dev/surface-test.html screenshots (headless geckodriver):
// boots the real scenes, lands, and HOLDS — one marker per stage so the
// driver can grab a screenshot at exactly the moment we want:
// __SHOT__ = 'landing' — the one-shot landing clip, full screen
// __SHOT__ = 'surface' — looping surface clip + the surface deck
// (dev/surface-flow.mjs is the assertion suite; this one just parks.)
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 { GameScene } from '../js/scenes/GameScene.js';
import { SurfaceScene } from '../js/scenes/SurfaceScene.js';
(async () => {
const data = await ConfigLoader.load();
config.init(data);
const gameConfig = createGameConfig();
gameConfig.scene = [GameScene, SurfaceScene];
const game = new Phaser.Game(gameConfig);
window.game = game;
window.__SHOT__ = 'booting';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
let gs = null;
for (let i = 0; i < 300; i++) {
gs = game.scene?.getScene?.('GameScene') ?? null;
if (gs && gs.ship) break;
await sleep(100);
}
if (!gs || !gs.ship) { window.__SHOT__ = 'FAILED: no GameScene'; return; }
gs.startLanding(gs.commsTargetFor(gs.planet));
await sleep(900);
const ss = game.scene.getScene('SurfaceScene');
if (!ss || !ss.sys.isActive()) { window.__SHOT__ = 'FAILED: no surface'; return; }
// Headless autoplay policy: force the clip visible (muted) for the shot.
if (ss.landVideo && ss.landVideo.video) {
ss.landVideo.video.muted = true;
ss.landVideo.video.play().catch(() => {});
}
await sleep(700);
window.__SHOT__ = 'landing'; // ← screenshot #1 (landing clip)
await sleep(800);
ss.startSurface(); // force the loop stage (don't wait the clip out)
if (ss.surfaceVideo && ss.surfaceVideo.video) {
ss.surfaceVideo.video.muted = true;
ss.surfaceVideo.video.play().catch(() => {});
}
await sleep(1200);
window.__SHOT__ = 'surface'; // ← screenshot #2 (loop + deck), stays here
window.__SURFACE_TEST__ = { pass: true, parked: true };
})().catch((err) => { window.__SHOT__ = 'FAILED: ' + String(err); window.__SURFACE_TEST__ = { pass: false, error: String(err) }; });

20
dev/surface-test.html Normal file
View File

@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Orbit — dev surface flow test</title>
<!-- This page lives in /dev, but the game's relative asset paths are rooted
at the project root — resolve them against it. ("../" keeps this
working even if the project is served from a subdirectory.) -->
<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>
<script src="lib/phaser.min.js"></script>
</head>
<body>
<div id="game"></div>
<script type="module" src="dev/surface-flow.mjs"></script>
</body>
</html>

View File

@ -50,6 +50,7 @@ export class Planet extends Phaser.GameObjects.Sprite {
super(scene, x, y, Planet.TEXTURE_KEY, frame); super(scene, x, y, Planet.TEXTURE_KEY, frame);
scene.add.existing(this); scene.add.existing(this);
this.name = name; this.name = name;
this.sheetFrame = frame; // raw planets.png frame — picks the landing/surface videos
const scale = config.get('planets.scale', 1) * (o.scale ?? 1); const scale = config.get('planets.scale', 1) * (o.scale ?? 1);
this.setScale(scale); this.setScale(scale);

View File

@ -4,6 +4,7 @@ import { ConfigLoader } from './config/ConfigLoader.js';
import { createGameConfig } from './config/GameConfig.js'; import { createGameConfig } from './config/GameConfig.js';
import { MenuScene } from './scenes/MenuScene.js'; import { MenuScene } from './scenes/MenuScene.js';
import { GameScene } from './scenes/GameScene.js'; import { GameScene } from './scenes/GameScene.js';
import { SurfaceScene } from './scenes/SurfaceScene.js';
/** /**
* Orbit entry point. * Orbit entry point.
@ -40,7 +41,7 @@ async function boot() {
console.info('orbit — fonts ready (or timed out)'); console.info('orbit — fonts ready (or timed out)');
const gameConfig = createGameConfig(); const gameConfig = createGameConfig();
gameConfig.scene = [MenuScene, GameScene]; gameConfig.scene = [MenuScene, GameScene, SurfaceScene];
const game = new Phaser.Game(gameConfig); const game = new Phaser.Game(gameConfig);

View File

@ -368,9 +368,9 @@ export class GameScene extends Phaser.Scene {
// station / cloud base, or a free-space station) shows a reputation // station / cloud base, or a free-space station) shows a reputation
// bar — 41 marks, 20…+20, red → green, lit up to the standing — // bar — 41 marks, 20…+20, red → green, lit up to the standing —
// plus REQUEST LANDING (grayed at standing ≤ 4) and CANCEL; an // plus REQUEST LANDING (grayed at standing ≤ 4) and CANCEL; an
// UNSETTLED world shows LAND + CANCEL. The buttons are seams for // UNSETTLED world shows LAND + CANCEL. The landing buttons launch
// now — the landing sequence wires into commsAction() once it // SurfaceScene (startLanding) — a one-shot landing video over a
// exists. // looping surface clip with its own deck (data/landing.json).
this.commsPanel = new CommsPanel(this, { this.commsPanel = new CommsPanel(this, {
onAction: (id, target) => this.commsAction(id, target), onAction: (id, target) => this.commsAction(id, target),
}); });
@ -1223,13 +1223,17 @@ export class GameScene extends Phaser.Scene {
} }
} }
const rep = settled ? (this.reputation.standingFor(key) ?? 0) : 0; const rep = settled ? (this.reputation.standingFor(key) ?? 0) : 0;
const isPlanet = !obj.settlement; // home world + system planets vs free-space stations
const landingOn = config.get('landing.enabled', true) === true; // landing.json kill-switch
return { return {
name: obj.discoveryName, name: obj.discoveryName,
settled, settled,
key, key,
reputation: rep, reputation: rep,
canLand: settled && rep > -4, canLand: settled && rep > -4 && landingOn, // panel grays the button otherwise
kindLabel, kindLabel,
isPlanet,
frame: isPlanet ? (obj.sheetFrame ?? 0) : null, // planets.png frame → landing.json videos
}; };
} }
@ -1244,34 +1248,57 @@ export class GameScene extends Phaser.Scene {
openCommsPanel(obj, pointer) { openCommsPanel(obj, pointer) {
const t = this.commsTargetFor(obj); const t = this.commsTargetFor(obj);
this.playSfx('construct'); // the panel decodes in this.playSfx('construct'); // the panel decodes in
// The FULL target goes through the panel — lastTarget comes back to
// commsAction() and needs `isPlanet`/`frame` (the landing handoff),
// not just the display fields.
this.commsPanel.open(pointer.worldX, pointer.worldY, pointer.x, pointer.y, { this.commsPanel.open(pointer.worldX, pointer.worldY, pointer.x, pointer.y, {
name: t.name, ...t,
settled: t.settled,
reputation: t.reputation,
litMarks: this.reputation.marksFor(t.reputation), litMarks: this.reputation.marksFor(t.reputation),
canLand: t.canLand,
key: t.key,
kindLabel: t.kindLabel,
}); });
} }
/** /**
* The panel's button presses (CommsPanel onAction). The panel is * The panel's button presses (CommsPanel onAction). The panel is
* already closed by the time this runs the actions are SEAMS for * already closed by the time this runs:
* now; the landing sequence lands here once it exists: * 'request-landing' a settled world, standing > 4 land
* 'request-landing' a settled world, standing > 4 * 'land' an unsettled world land
* 'land' an unsettled world * 'cancel' just close (the console log is the ack)
* 'cancel' just close (done)
*/ */
commsAction(id, target) { commsAction(id, target) {
this.commsPanel.close(); // the buttons close the panel (like miningAction) this.commsPanel.close(); // the buttons close the panel (like miningAction)
if (!target) return; if (!target) return;
// TODO(landing): wire the landing sequence — for now the click is if ((id === 'request-landing' || id === 'land') && target.isPlanet) {
// acknowledged on the console (the seam the landing logic plugs this.startLanding(target);
// into). return;
}
// 'request-landing' on a space station — no surface on a station yet;
// the click is acknowledged on the console for now.
console.info(`[orbit] comms: ${id}${target.name} (key: ${target.key ?? 'none'})`); console.info(`[orbit] comms: ${id}${target.name} (key: ${target.key ?? 'none'})`);
} }
/**
* LAND (commsAction) the surface sequence: SurfaceScene starts ON
* TOP of this scene, handed the world's planets.png sheet frame so it
* can pick the landing/surface videos from data/landing.json. v4
* note: `launch` does not freeze the caller, so sleep the flight
* world explicitly Take Off (SurfaceScene) wakes it back, with the
* ship, tethers and discovery exactly where they were.
*/
startLanding(target) {
if (!config.get('landing.enabled', true)) {
console.info(`[orbit] comms: landing disabled for ${target.name}`);
return;
}
this.hideHint();
// A previous surface (left sleeping by an earlier Take Off) is shut
// down + restarted by this — the fresh init gets this world's frame.
this.scene.launch('SurfaceScene', {
frame: Number(target.frame ?? 0),
name: target.name,
});
this.scene.sleep();
}
/** /**
* Mining phase changes (Mining onPhase): the scene's share of the * Mining phase changes (Mining onPhase): the scene's share of the
* sequence the ship's STATE, the ship lock, the console calls, the sfx. * sequence the ship's STATE, the ship lock, the console calls, the sfx.

344
js/scenes/SurfaceScene.js Normal file
View File

@ -0,0 +1,344 @@
import Phaser from '../vendor/phaser.js';
import { config } from '../config/Config.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';
/**
* SURFACE the planet's surface, started ON TOP of the sleeping
* GameScene (GameScene.startLanding launch('SurfaceScene') + sleep):
*
* 1. LANDING the full-screen one-shot clip (data/landing.json
* videos[planets.png frame] `land`), cover-scaled over an opaque
* plate. No UI during the flight down.
* 2. SURFACE the clip is swapped for the looping world clip (`surface`),
* and the command deck appears: the GameScene deck with Research
* replaced by SHOP (a seam for now) and TAKE OFF left of MENU
* (data/actionbar.json surface.buttons). Menu keeps the GameScene's
* save sub-bar Save/Load operate on the paused GameScene's state
* (SavePanel `stateScene`).
*
* TAKE OFF stops this scene the paused GameScene resumes exactly
* where it was (ship, tethers, discovery, standing, everything).
*
* Video selection is by the planet's planets.png SHEET FRAME (the same
* index its sprite uses frames 0..2 the terran worlds, 3..5 the gas
* giants, per data/planets.json frames), so a planet always lands with
* the clip matching the art it is drawn with. Missing clips fall back to
* the existing stubs in data/landing.json; a broken/missing file skips
* straight to the surface instead of stranding the player.
*/
export class SurfaceScene extends Phaser.Scene {
constructor() {
super('SurfaceScene');
}
init(data = {}) {
this.planetFrame = Number(data.frame ?? 0); // planets.png frame (video key)
this.planetName = String(data.name ?? '');
this.phase = 'landing'; // 'landing' → 'surface'
this.gameScene = null; // the paused GameScene beneath us (save state lives there)
this.backdrop = null;
this.landVideo = null;
this.surfaceVideo = null;
this.actionBar = null;
this.menuSubBar = null;
this.savePanel = null;
this.saveManager = null;
this.landKey = null;
this.surfaceKey = null;
}
/** Pick this world's clips (data/landing.json) and queue them. */
preload() {
const entry = (config.get('landing.videos') ?? [])[this.planetFrame] ?? {};
this.landKey = `__surf_land_${this.planetFrame}`;
this.surfaceKey = `__surf_loop_${this.planetFrame}`;
this.landUrl = this.resolveUrl(entry.land ?? null);
this.surfaceUrl = this.resolveUrl(entry.surface ?? null);
if (this.landUrl) this.load.video(this.landKey, this.landUrl);
if (this.surfaceUrl) this.load.video(this.surfaceKey, this.surfaceUrl);
}
create() {
// The run's state (ship, tethers, discovery, …) stays parked in the
// paused GameScene — the save panel reads/writes it through there.
this.gameScene = this.scene.get('GameScene') ?? null;
const W = this.scale.width;
const H = this.scale.height;
// Launched over the flight world — the first thing painted must be an
// opaque full-screen plate, or GameScene bleeds through the seams.
this.backdrop = this.add.rectangle(W / 2, H / 2, W, H, 0x04060d).setScrollFactor(0).setDepth(0);
if (this.hasVideo(this.landKey)) {
this.playLanding();
} else {
this.startSurface(); // no landing clip for this frame — straight to the deck
}
}
// ------------------------------------------------------------------
// Stage 1 — the one-shot landing clip
// ------------------------------------------------------------------
playLanding() {
// v4: add.video(x, y, key) — the key is the LAST argument (it loads the
// cached clip and attaches the <video> element).
const v = this.add.video(0, 0, this.landKey).setOrigin(0.5).setDepth(10);
this.fitCover(v);
v.setVolume(this.clipVolume());
this.landVideo = v;
// Fire it straight away. If the browser's autoplay policy locks it
// (no gesture yet), v4's Video retries internally every ~500ms until
// it's allowed — the click that sent the ship down counts, so this
// normally starts on the first attempt. 'complete'/'error' both land
// the player on the deck either way.
v.play();
v.on('complete', () => this.startSurface()); // played once → surface
v.on('error', () => this.startSurface()); // broken clip → don't strand the player
// Safety valve: a stalled element (neither complete nor error) still
// gets the player to the deck.
this.time.delayedCall(8000, () => {
if (this.landVideo === v && this.phase === 'landing') this.startSurface();
});
}
// ------------------------------------------------------------------
// Stage 2 — the looping surface clip + the deck
// ------------------------------------------------------------------
startSurface() {
if (this.phase === 'surface') return;
this.phase = 'surface';
if (this.landVideo) {
this.destroyVideo(this.landVideo);
this.landVideo = null;
}
if (this.hasVideo(this.surfaceKey)) {
const s = this.add.video(0, 0, this.surfaceKey).setOrigin(0.5).setDepth(10);
this.fitCover(s);
s.setVolume(this.clipVolume());
s.setLoop(true);
s.play();
this.surfaceVideo = s;
// A decode failure on the loop clip keeps the plate — the deck
// still works, the world just stays dark.
}
this.buildDeck();
}
buildDeck() {
// The surface deck: same bar, different slots — Shop for Research,
// Take Off left of Menu (data/actionbar.json → surface.buttons).
const deckButtons = config.get('actionbar.surface.buttons');
this.actionBar = new ActionBar(this, {
onAction: (id) => this.deckAction(id),
buttons: Array.isArray(deckButtons) ? deckButtons : undefined,
});
// The MENU button keeps the GameScene's save system. The run's state
// is in the paused GameScene beneath us — SavePanel captures from it
// (stateScene) and a confirmed load stages the restore there.
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(),
stateScene: this.gameScene ?? this,
});
// ESC — topmost open thing first (mirror of GameScene.escAction).
this.input.keyboard?.on('keydown-ESC', () => this.escAction());
// Clicks: outside the sub-bar, close it. (No click-to-fly on the
// surface — the world is a backdrop.)
this.input.on('pointerdown', (pointer) => this.onPointerDown(pointer));
}
// ------------------------------------------------------------------
// Deck behavior
// ------------------------------------------------------------------
/**
* The deck's button presses (ActionBar onAction). SHOP / BUILD /
* SHIP are seams for the surface economy (data/builds.json plugs in
* later); TAKE OFF goes back to the ship; MENU is the save door.
*/
deckAction(id) {
if (id === 'menu') {
this.menuAction();
return;
}
if (id === 'takeoff') {
this.takeOff();
return;
}
console.info(`[orbit] surface deck: ${id}${this.planetName}`);
}
/** The MENU button: fold the sub-bar up / fold it back down. */
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 (MenuSubBar → 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: confirm dialog → save 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();
}
/**
* TAKE OFF leave the surface: stop this scene (the shutdown hook
* cleans the deck + clips) and wake the sleeping GameScene the run
* resumes exactly where the ship left it.
*/
takeOff() {
this.savePanel?.close();
this.menuSubBar?.dismiss();
this.scene.stop('SurfaceScene');
this.scene.wake('GameScene');
}
/** Return to the main menu (the sub-bar's last button). */
returnToMenu() {
this.savePanel?.close();
this.menuSubBar?.dismiss();
this.scene.start('MenuScene');
}
onPointerDown(pointer) {
if (this.savePanel?.isOpen) return; // the modal scrim owns the click
if (this.menuSubBar?.isOpen) {
if (this.menuSubBar.contains(pointer.x, pointer.y)) return;
this.menuSubBar.close();
return;
}
if (this.actionBar?.contains(pointer.x, pointer.y)) return;
// A click on the surface itself — the seam for surface interactions.
}
// ------------------------------------------------------------------
// Plumbing
// ------------------------------------------------------------------
/** 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 missing (dev): a virtual button at the bottom right.
return { x: this.scale.width - 96, y: this.scale.height - 58, w: 120 };
}
hasVideo(key) {
const c = this.cache?.video;
return !!(c && typeof c.has === 'function' && c.has(key));
}
/** Cover-scale: the clip fills the screen, cropping whatever overflows. */
fitCover(v) {
const vw = (v.video && v.video.width) || v.width || 864;
const vh = (v.video && v.video.height) || v.height || 480;
const W = this.scale.width;
const H = this.scale.height;
const s = Math.max(W / vw, H / vh);
v.setPosition(W / 2, H / 2);
v.setDisplaySize(vw * s, vh * s);
}
clipVolume() {
return Math.max(0, Math.min(1, Number(config.get('landing.volume', 1))));
}
resolveUrl(file) {
if (!file) return null;
if (/^(https?:)?\/\//.test(file)) return file; // absolute — use as-is
const base = String(config.get('landing.videoBase', 'assets/videos/'));
return (base.endsWith('/') ? base : `${base}/`) + file;
}
destroyVideo(v) {
if (!v) return;
try {
v.off();
v.stop(false);
v.destroy();
} catch {
/* already gone */
}
}
update(time, delta) {
this.time.update(time, delta);
this.tweens.update();
this.actionBar?.update(time, delta);
this.menuSubBar?.update(time, delta);
this.savePanel?.update(time);
}
shutdown() {
this.destroyVideo(this.landVideo);
this.destroyVideo(this.surfaceVideo);
this.landVideo = null;
this.surfaceVideo = null;
this.actionBar?.destroy();
this.menuSubBar?.destroy();
this.savePanel?.destroy();
this.actionBar = null;
this.menuSubBar = null;
this.savePanel = null;
this.backdrop?.destroy();
this.backdrop = null;
}
}

View File

@ -27,6 +27,11 @@ const FONT_FALLBACK = "'Segoe UI', 'Helvetica Neue', Arial, sans-serif";
* update(time, delta) { this.actionBar.update(time, delta); } * update(time, delta) { this.actionBar.update(time, delta); }
* shutdown() { this.actionBar.destroy(); } * shutdown() { this.actionBar.destroy(); }
* *
* A scene may pass its own slot list as `buttons` (an ordered array exactly
* like `actionbar.buttons`) e.g. SurfaceScene's deck swaps Research for
* Shop and adds Take Off left of Menu. Layout, colors and animations stay
* shared from data/actionbar.json.
*
* Live slots fire `onAction(id, slot)` on press behavior is the * Live slots fire `onAction(id, slot)` on press behavior is the
* scene's job (the Research/Build/Ship/Menu panels come next). * scene's job (the Research/Build/Ship/Menu panels come next).
* `bar.contains(px, py)` (screen coords) lets a scene keep its own * `bar.contains(px, py)` (screen coords) lets a scene keep its own
@ -35,7 +40,9 @@ const FONT_FALLBACK = "'Segoe UI', 'Helvetica Neue', Arial, sans-serif";
export class ActionBar extends Phaser.GameObjects.Container { export class ActionBar extends Phaser.GameObjects.Container {
/** /**
* @param {Phaser.Scene} scene * @param {Phaser.Scene} scene
* @param {object} [o] { onAction?(id, slot) } * @param {object} [o] { onAction?(id, slot), buttons? } `buttons` is an
* optional slot-list override (same shape as actionbar.json buttons),
* for decks that differ per scene (SurfaceScene: shop/takeoff).
*/ */
constructor(scene, o = {}) { constructor(scene, o = {}) {
super(scene, 0, 0); super(scene, 0, 0);
@ -46,7 +53,9 @@ export class ActionBar extends Phaser.GameObjects.Container {
this.setScrollFactor(0); // UI — pinned to the screen, not the world this.setScrollFactor(0); // UI — pinned to the screen, not the world
this.setDepth(50); // above the HUD dossier (30) / compass (40) / toast (45) this.setDepth(50); // above the HUD dossier (30) / compass (40) / toast (45)
const ab = config.section('actionbar', {}); const ab = Array.isArray(o.buttons)
? { ...config.section('actionbar', {}), buttons: o.buttons }
: config.section('actionbar', {});
this.dead = false; this.dead = false;
this.onAction = typeof o.onAction === 'function' ? o.onAction : null; this.onAction = typeof o.onAction === 'function' ? o.onAction : null;

View File

@ -329,7 +329,11 @@ export class CommsPanel extends Phaser.GameObjects.Container {
this.rep = Math.round(o.reputation ?? 0); this.rep = Math.round(o.reputation ?? 0);
this.canLand = o.canLand !== false; this.canLand = o.canLand !== false;
this._name = name; this._name = name;
// Carry the FULL payload the scene handed us (it may include landing
// data like `isPlanet`/`frame`) — the scene's onAction gets this back
// verbatim, so nothing the scene needs may be pruned here.
this.lastTarget = { this.lastTarget = {
...o,
name, name,
settled: this.settled, settled: this.settled,
key: o.key ?? null, key: o.key ?? null,

View File

@ -53,7 +53,11 @@ export class SavePanel extends Phaser.GameObjects.Container {
* @param {object} [o] { * @param {object} [o] {
* onLoadComplete?: (slot, record) => void called after the * onLoadComplete?: (slot, record) => void called after the
* load-confirm lands and the panel has closed (the scene starts * load-confirm lands and the panel has closed (the scene starts
* the menu; the menu's New Game picks the staged restore up) * the menu; the menu's New Game picks the staged restore up),
* stateScene?: Phaser.Scene where captureState/prepareLoad read
* the run's state. Defaults to `scene`. SurfaceScene passes the
* paused GameScene (the surface is a launched overlay; the run
* stays parked there)
* } * }
*/ */
constructor(scene, o = {}) { constructor(scene, o = {}) {
@ -88,6 +92,7 @@ export class SavePanel extends Phaser.GameObjects.Container {
this.magenta = toColor(c.magenta ?? '#ff2d6f'); this.magenta = toColor(c.magenta ?? '#ff2d6f');
this.accent = this.saveAccent; this.accent = this.saveAccent;
this.onLoadComplete = typeof o.onLoadComplete === 'function' ? o.onLoadComplete : null; this.onLoadComplete = typeof o.onLoadComplete === 'function' ? o.onLoadComplete : null;
this.stateScene = o.stateScene ?? scene; // where captureState/prepareLoad read the run
const sw = scene.scale.width; const sw = scene.scale.width;
@ -431,7 +436,7 @@ export class SavePanel extends Phaser.GameObjects.Container {
doSave(slot) { doSave(slot) {
try { try {
const rec = captureState(this.scene); const rec = captureState(this.stateScene);
this.sm.put(slot, rec); this.sm.put(slot, rec);
this.toast.show(`${config.get('save.toast.saved', 'GAME SAVED')} · SLOT ${String(slot).padStart(2, '0')}`, { kind: 'ok' }); 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 records = this.sm.listSlots().map((s) => s.record);
@ -451,7 +456,7 @@ export class SavePanel extends Phaser.GameObjects.Container {
try { try {
const rec = this.sm.get(slot); const rec = this.sm.get(slot);
if (!rec) throw new Error('EMPTY SLOT'); if (!rec) throw new Error('EMPTY SLOT');
prepareLoad(this.scene.registry, rec); prepareLoad(this.stateScene.registry, rec);
this.toast.show(`${config.get('save.toast.loaded', 'SIGNAL LOCKED')} · SLOT ${String(slot).padStart(2, '0')}`, { kind: 'ok' }); this.toast.show(`${config.get('save.toast.loaded', 'SIGNAL LOCKED')} · SLOT ${String(slot).padStart(2, '0')}`, { kind: 'ok' });
const done = this.onLoadComplete; const done = this.onLoadComplete;
this.close(() => { this.close(() => {