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:
parent
d933914ac3
commit
dd7000638a
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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 */ }
|
||||||
|
}
|
||||||
|
|
@ -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)));
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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) }; });
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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(() => {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue