Decouple comms panel from fly-to and add double-click clip skip

- Clicking a planet or station now opens the comms panel without moving the ship; world clicks are no longer fly-here targets, keeping mining beams live during panel interaction
- Add double-click (350 ms) skip for landing and takeoff clips in SurfaceScene so players can cut to the deck or flight world immediately
- Push restored mineral hold through the upper-right HUD on load so the readout matches the saved value instead of staying at zero
- Replace removed Phaser v4 Container.removeChildren() with removeAll(true) in JumpGate and Station destroy paths
- Update landing.json shop entries for terran frames 2–3 and document the new skip behavior
- Add dev harnesses for surface-skip, hold-restore, scene-restart, container-probe, and import probes; extend comms-click and saves-ui tests to cover the new interactions
This commit is contained in:
Brian Fertig 2026-09-06 15:08:32 -06:00
parent 35a20416d1
commit cb82da4935
22 changed files with 677 additions and 55 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -18,6 +18,9 @@
"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).",
"A DOUBLE-CLICK (two quick presses) during the `land` or `takeoff`",
"clips skips the rest of the clip — the deck / the flight world comes",
"up immediately (no setting; the 350 ms window lives in SurfaceScene).",
"Set `enabled` to false to disable landing entirely (REQUEST LANDING is",
"greyed out in comms, same as a reputation ban)."
],
@ -41,8 +44,8 @@
},
"videos": [
{ "land": "terran-land-01.mp4", "surface": "terran-surface-01.mp4", "takeoff": "terran-takeoff-01.mp4", "shop": "terran-shop-01.mp4" },
{ "land": "terran-land-02.mp4", "surface": "terran-surface-02.mp4", "takeoff": "terran-takeoff-02.mp4", "shop": "terran-shop-01.mp4" },
{ "land": "terran-land-03.mp4", "surface": "terran-surface-03.mp4", "takeoff": "terran-takeoff-03.mp4", "shop": "terran-shop-01.mp4" },
{ "land": "terran-land-02.mp4", "surface": "terran-surface-02.mp4", "takeoff": "terran-takeoff-02.mp4", "shop": "terran-shop-02.mp4" },
{ "land": "terran-land-03.mp4", "surface": "terran-surface-03.mp4", "takeoff": "terran-takeoff-03.mp4", "shop": "terran-shop-03.mp4" },
{ "land": "gasgiant-land-01.mp4", "surface": "gasgiant-surface-01.mp4", "takeoff": "gasgiant-takeoff-01.mp4", "shop": "terran-shop-01.mp4" },
{ "land": "gasgiant-land-02.mp4", "surface": "gasgiant-surface-02.mp4", "takeoff": "gasgiant-takeoff-02.mp4", "shop": "terran-shop-01.mp4" },
{ "land": "gasgiant-land-03.mp4", "surface": "terran-surface-01.mp4", "takeoff": "gasgiant-takeoff-03.mp4", "shop": "terran-shop-01.mp4" },

View File

@ -1,6 +1,8 @@
/**
* Dev: real-input test for the CommsPanel interaction paths:
* 1. click an object panel opens + ship retargeted
* 1. click an object panel opens, ship does NOT fly (a planet
* click is not a fly-here it is the
* landing-request window, not a move)
* 2. click CANCEL closes
* 3. rep 4, click REQUEST LANDING inert (panel stays open)
* 4. click outside closes
@ -163,7 +165,7 @@ window.__CLICK_TEST__ = async () => {
s.cameraFollowShip = false;
};
// ---- 1. Real click ON the home world → fly + panel opens -------------
// ---- 1. Real click ON the home world → panel opens, ship stays put --
const hx = 0, hy = 0, hsx = 640, hsy = 300;
s.ship.x = 700; s.ship.y = 650;
frame(hx, hy, hsx, hsy);
@ -172,9 +174,9 @@ window.__CLICK_TEST__ = async () => {
clickAtClient(c1.clientX, c1.clientY);
await pump(s, () => s.commsPanel.isOpen && s.commsPanel.alpha >= 0.999 && s.commsPanel.nameDec === null, 4000);
step(
'click object → panel opens + ship retargeted',
s.commsPanel.isOpen && s.ship.target !== null && s.commsPanel.lastTarget?.name === s.planet.discoveryName.toUpperCase(),
{ shipTarget: s.ship.target, panelName: s.commsPanel._name },
'click object → panel opens + ship does NOT fly',
s.commsPanel.isOpen && s.ship.target === null && s.commsPanel._name === s.planet.discoveryName.toUpperCase(),
{ shipTarget: s.ship.target, panelName: s.commsPanel._name, planetName: s.planet.discoveryName },
);
// Button centers in panel-local y: btn1 = +23.5, btn2 = +76.5.
@ -230,23 +232,25 @@ window.__CLICK_TEST__ = async () => {
step('ESC → closes', !s.commsPanel.isOpen);
// ---- 6. Enabled REQUEST LANDING fires the seam + closes ---------------
// (Home is pinned +20 — canLand is true; the seam logs the action.)
let logged = '';
const orig = console.info;
console.info = (...a) => {
logged += ' ' + a.join(' ');
orig(...a);
};
// (Home is pinned +20 — canLand is true. For a planet target the seam is
// startLanding() — this harness registers ONLY GameScene (no
// SurfaceScene), so stub it and assert the seam fired with the
// planet's payload (the landing-click.mjs harness runs the real one).
let landingArgs = null;
const origLanding = s.startLanding;
s.startLanding = (t) => { landingArgs = t; };
s.openCommsPanel(s.planet, { worldX: hx, worldY: hy, x: 640, y: 300 });
await pump(s, () => s.commsPanel.isOpen && s.commsPanel.alpha >= 0.999, 4000);
const b6 = btn1();
clickAtClient(b6.clientX, b6.clientY);
await pump(s, () => !s.commsPanel.isOpen, 2500);
console.info = orig;
s.startLanding = origLanding;
step(
'click REQUEST LANDING (enabled) → seam fires + closes',
!s.commsPanel.isOpen && logged.includes('request-landing'),
{ logged: logged.trim() },
!s.commsPanel.isOpen &&
landingArgs?.name === s.planet.discoveryName &&
landingArgs?.isPlanet === true,
{ landingArgs: landingArgs && { name: landingArgs.name, isPlanet: landingArgs.isPlanet, frame: landingArgs.frame } },
);
out.allPass = out.steps.every((x) => x.pass);

2
dev/container-probe.html Normal file
View File

@ -0,0 +1,2 @@
<!doctype html><html><head><meta charset="utf-8"><base href="../"><script src="lib/phaser.min.js"></script></head>
<body><script type="module" src="dev/container-probe.mjs"></script></body></html>

22
dev/container-probe.mjs Normal file
View File

@ -0,0 +1,22 @@
import Phaser from '../js/vendor/phaser.js';
// Minimal headless-ish game just to get a scene/context.
const game = new Phaser.Game({ type: Phaser.HEADLESS, width: 100, height: 100,
scene: { create() { this.__done = true; } } });
window.__CP__ = null;
game.events.once('ready', () => {
const s = game.scene.getScenes(true)[0];
const out = { steps: [] };
const mk = () => {
const c = new Phaser.GameObjects.Container(s, 0, 0);
const ch = new Phaser.GameObjects.Sprite(s, 0, 0);
c.add(ch);
return { c, ch };
};
// 1. does destroy() destroy children?
{ const { c, ch } = mk(); c.destroy(); out.steps.push('destroy() -> child.isDestroyed=' + ch.isDestroyed + ' child.scene=' + (ch.scene === null)); }
// 2. removeAll(true)
{ const { c, ch } = mk(); c.removeAll(true); out.steps.push('removeAll(true) -> child.isDestroyed=' + ch.isDestroyed + ' stillInList=' + c.list.includes(ch)); }
// 3. removeAll(false) then destroy
{ const { c, ch } = mk(); c.removeAll(false); out.steps.push('removeAll(false) -> child.isDestroyed=' + ch.isDestroyed + ' inList=' + c.list.includes(ch)); c.destroy(); out.steps.push(' then destroy() -> child.isDestroyed=' + ch.isDestroyed); }
window.__CP__ = out;
});

26
dev/hold-restore.html Normal file
View File

@ -0,0 +1,26 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>orbit — hold across game entry (dev test)</title>
<!-- This page lives in /dev, but the game's relative asset paths are
rooted at the project root — resolve them against it. -->
<base href="../" />
<style>
html, body { margin: 0; padding: 0; height: 100%; background: #0a0e14; }
body { display: flex; align-items: center; justify-content: center; }
</style>
<script>
window.__BOOT_ERRORS__ = [];
window.addEventListener('error', (e) =>
window.__BOOT_ERRORS__.push(String(e.message) + ' @ ' + (e.filename || '') + ':' + e.lineno));
window.addEventListener('unhandledrejection', (e) =>
window.__BOOT_ERRORS__.push('reject: ' + ((e.reason && e.reason.stack) || e.reason)));
</script>
<script src="lib/phaser.min.js"></script>
</head>
<body>
<script type="module" src="dev/hold-restore.mjs"></script>
</body>
</html>

162
dev/hold-restore.mjs Normal file
View File

@ -0,0 +1,162 @@
/**
* The mineral HOLD across game entry points the upper-right readout
* (js/ui/MineralHud.js) must show the player's current inventory in all
* three cases the player can enter the game:
*
* 1. NEW GAME the hold is empty; the HUD reads 0 / capacity.
* 2. MINE minerals come aboard; the live seam (mining's onOre
* GameScene.refreshMineralHud) pushes them to the readout.
* 3. CONTINUE / LOAD the save carries the hold (captureState),
* prepareLoad stages it (the exact seam both menu buttons use),
* and the fresh GameScene's applyRestore must land it BOTH on the
* ship AND the upper-right HUD. (The regression this guards: the
* HUD was seeded with the fresh ship's 0 in create() and never
* followed the restored value the corner read 0 until the next
* mining run.)
*
* Two-scene harness: the real entry flow SWITCHES scenes (game menu
* game) and this Phaser build's scene.start is a no-op on the scene
* that is already active so a bare DummyMenu scene stands in for the
* real MenuScene. The data seams are driven directly:
* captureState prepareLoad scene.start('DummyMenu')
* scene.start('GameScene') consumeRestore applyRestore.
* (The full UI version of this flow lives in dev/saves-ui-test.mjs.)
*
* Served by dev/hold-restore.html; results land in
* `window.__HOLD_RESTORE__` for the CDP runner:
*
* python3 -m http.server 8080
* node dev/cdp-firefox.mjs http://localhost:8080/dev/hold-restore.html \
* 'return window.__HOLD_RESTORE__;'
*/
import Phaser from '../js/vendor/phaser.js';
import { ConfigLoader } from '../js/config/ConfigLoader.js';
import { config } from '../js/config/Config.js';
import { createGameConfig } from '../js/config/GameConfig.js';
import { GameScene } from '../js/scenes/GameScene.js';
import { captureState, prepareLoad } from '../js/save/SaveData.js';
// The real MenuScene's job in this flow is to be the OTHER side of the
// scene switch — a bare scene suffices (no art, no logic).
class DummyMenu extends Phaser.Scene {
constructor() { super('DummyMenu'); }
}
const data = await ConfigLoader.load();
config.init(data);
globalThis.__ORBIT_DEV_SEED = 'HOLDTEST'; // deterministic galaxy
const gameConfig = createGameConfig();
gameConfig.scene = [GameScene, DummyMenu]; // GameScene boots first
if (typeof Phaser !== 'undefined') Phaser.NoAudioContext = true; // quiet run
const game = new Phaser.Game(gameConfig);
window.game = game;
const results = [];
const check = (label, cond) => {
const pass = !!cond;
results.push({ label, pass });
console.log(`${pass ? '✔' : '✘ FAIL'} ${label}`);
};
const run = async () => {
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const waitUntil = async (fn, label, budgetMs = 180000) => {
const t = Date.now();
while (Date.now() - t < budgetMs) {
if (fn()) return true;
await sleep(250);
}
throw new Error(
`${label} (budget ${budgetMs}ms) — page errors: ${JSON.stringify((window.__BOOT_ERRORS__ || []).slice(0, 4))}`,
);
};
// Wait for boot — poll for the scene's UI to exist (throttling-proof;
// this box starves fixed timeouts).
let s = null;
await waitUntil(() => {
s = game.scene.getScene('GameScene');
return !!s && !!s.commsPanel && !!s.ship && !!s.mineralHud;
}, 'boot', 180000);
check('boots into GameScene with the HUD', !!s && !!s.ship && !!s.mineralHud);
if (!s || !s.ship) throw new Error('scene never came up');
// ---- 1. NEW GAME -----------------------------------------------------
const cap = s.ship.stats.mineralStorage;
check(`new game: the hold is empty (ship 0, HUD 0 / ${cap})`,
s.ship.minerals === 0 && s.mineralHud.value === 0 && s.mineralHud.max === cap);
// ---- 2. MINE ----------------------------------------------------------
// The live path: mining's onOre seam fires GameScene.refreshMineralHud
// — drive the same seam directly (the beam itself is covered by the
// mining tests).
s.ship.addMinerals(37);
s.refreshMineralHud();
await sleep(700); // the 420 ms count-up settles
check(`mined: the HUD reads the live hold (37 / ${cap})`,
s.ship.minerals === 37 && s.mineralHud.value === 37
&& s.mineralHud.label.text === s.mineralHud.format(37));
// ---- 3. CONTINUE / LOAD ----------------------------------------------
// captureState = what the Save panel writes to the bank; prepareLoad =
// the exact seam Continue (newest save) and Load Game (slot) both run;
// then the flow SWITCHES game → menu → game. Reproduce that switch
// through DummyMenu (the real MenuScene's role in the flow): scene
// instances are singletons, so the recreate re-runs GameScene.create()
// on the SAME object and consumeRestore() picks up the staged state.
// (The GameScene shutdown here also exercises the entity destroy path
// — JumpGate/Station — the v4 removeAll() migration.)
const rec = captureState(s);
check('the save record carries the hold (37)', rec.ship.minerals === 37);
prepareLoad(s.registry, rec);
game.scene.start('DummyMenu');
await waitUntil(
() => game.scene.isActive('DummyMenu') && !game.scene.isActive('GameScene'),
'switch to the menu (GameScene shutdown)',
);
game.scene.start('GameScene');
const gs = game.scene.getScene('GameScene');
await waitUntil(
() => game.scene.isActive('GameScene') && gs.ship && gs.mineralHud,
'recreate GameScene from the staged restore',
);
check('the game is back from the restore (GameScene recreated)',
game.scene.isActive('GameScene') && !!gs.ship);
check('the restored hold is on the ship (37)', gs.ship.minerals === 37);
check('the restored value landed on the HUD immediately (value 37)',
gs.mineralHud.value === 37); // set() is immediate; only the label animates
await sleep(700); // the count-up settles
check('the upper-right HUD reads the RESTORED hold (37 / capacity)',
gs.mineralHud.value === 37
&& gs.mineralHud.label.text === gs.mineralHud.format(37));
};
let done = false;
game.events.once('ready', async () => {
try {
await run();
} catch (err) {
console.error(err);
results.push({ label: `DRIVER CRASHED: ${err.message}`, pass: false });
}
const pass = results.length > 0 && results.every((r) => r.pass);
window.__HOLD_RESTORE__ = { pass, results };
done = true;
console.log(`HOLD-RESTORE ${pass ? 'PASS' : 'FAIL'} (${results.filter((r) => r.pass).length}/${results.length})`);
});
// Hard stop so a hung flow can't hang the runner.
const hardStop = (t0) => {
if (performance.now() - t0 >= 600000) {
if (!done) {
window.__HOLD_RESTORE__ = { pass: false, results: [...results, { label: 'TIMED OUT (600s)', pass: false }] };
console.log('HOLD-RESTORE FAIL (timed out)');
}
return;
}
requestAnimationFrame(() => hardStop(t0));
};
requestAnimationFrame(() => hardStop(performance.now()));

12
dev/probe-imports.html Normal file
View File

@ -0,0 +1,12 @@
<!doctype html><html><head><meta charset="utf-8"></head><body>
<script type="module">
window.__PROBE__ = 'start';
try {
await import('../js/vendor/phaser.js');
await import('../js/config/ConfigLoader.js');
await import('../js/config/GameConfig.js');
await import('../js/scenes/GameScene.js');
await import('../js/save/SaveData.js');
window.__PROBE__ = 'all imports OK';
} catch (e) { window.__PROBE__ = 'FAIL: ' + (e && e.message); }
</script></body></html>

View File

@ -12,7 +12,9 @@
* MenuScene: Load Game is live (the bank has a save) its own load
* pop-up LOAD confirm the staged restore rides into a
* fresh GameScene ship back where it was, the session
* time, the tether field.
* time, the tether field, and the hold: the minerals the
* save carried show on the ship AND the upper-right
* mineral readout (the load seam the HUD refresh guards).
*
* Served by dev/saves-ui-test.html; the results land in
* `window.__SAVES_UI__` for the CDP runner (dev/cdp-firefox.mjs):
@ -101,9 +103,33 @@ const press = (x, y) => {
const run = async () => {
const scene = game.scene.getScene('GameScene');
await wait(900); // boot: galaxy, system, deck, sub-bar, panel
// This box can starve the boot — poll until the scene is REALLY up
// (active + ship + HUD built) instead of trusting the fixed wait.
const bootT0 = Date.now();
while (!game.scene.isActive('GameScene') || !scene.ship || !scene.mineralHud) {
if (Date.now() - bootT0 > 90000) throw new Error('GameScene never came up');
await wait(200);
}
check('boots into GameScene', game.scene.isActive('GameScene'));
check('the sub-bar + panel exist', !!scene.menuSubBar && !!scene.savePanel);
// ---- the mineral hold (upper-right readout) ---------------------------
// FRESH GAME: the hold is empty and the HUD says exactly that (create()
// seeded the readout from the fresh ship's 0).
check('a fresh game: empty hold, HUD reads 0 / capacity',
scene.ship.minerals === 0 && scene.mineralHud.value === 0
&& scene.mineralHud.max === scene.ship.stats.mineralStorage
&& scene.mineralHud.label.text === scene.mineralHud.format(0));
// MINED: minerals aboard — the live seam (refreshMineralHud, what
// mining's onOre fires) pushes them into the readout, and the count-up
// (420 ms tween) settles on the exact value.
scene.ship.addMinerals(37);
scene.refreshMineralHud();
await wait(700);
check('the HUD reads the live hold (37 / capacity)',
scene.ship.minerals === 37 && scene.mineralHud.value === 37
&& scene.mineralHud.label.text === scene.mineralHud.format(37));
// ---- the sub-bar folds up out of MENU --------------------------------
scene.menuAction();
await wait(450);
@ -171,6 +197,7 @@ const run = async () => {
await wait(120);
const rec1 = scene.saveManager.get(1);
check('doSave(1) writes the bank', rec1 !== null && rec1.seed === scene.galaxy.seed && rec1.ship.x === scene.ship.x);
check('the bank captured the hold (37)', rec1.ship.minerals === 37);
// Capture fidelity: the bank's session time is the scene's at capture
// (a frame or two may have ticked since — small tolerance).
check('the bank captured the session time',
@ -225,6 +252,15 @@ const run = async () => {
check('the ship is back where the save parked it',
rec && Math.abs(gs.ship.x - rec.ship.x) < 0.01 && Math.abs(gs.ship.y - rec.ship.y) < 0.01);
check('the saved ship was where it had flown', Math.abs(rec.ship.x - shipX) < 0.01 && Math.abs(rec.ship.y - shipY) < 0.01);
// The HOLD rides the restore too — on the ship, and on the upper-right
// readout: applyRestore pushes the saved value through the HUD (the
// seam this test guards; without it the corner reads 0 until the next
// mining run). The count-up settles (420 ms) on the bank's value.
check('the restored hold is on the ship (37)', gs.ship.minerals === 37);
await wait(700);
check('the HUD reads the RESTORED hold (upper right)',
gs.mineralHud.value === 37
&& gs.mineralHud.label.text === gs.mineralHud.format(37));
// Restore fidelity: the restored value is the BANK's value (rec1), plus
// the session time accumulated since GameScene.create (the wait above +
// a few frames). update() caps per-frame accumulation at 100ms

View File

@ -0,0 +1,10 @@
<!doctype html><html><head><meta charset="utf-8"><base href="../">
<script>
window.__PAGE_ERRORS__ = [];
window.addEventListener('error', (e) => window.__PAGE_ERRORS__.push('error: ' + e.message + ' @ ' + (e.filename||'') + ':' + e.lineno));
window.addEventListener('unhandledrejection', (e) => window.__PAGE_ERRORS__.push('reject: ' + (e.reason && (e.reason.stack || e.reason) || e.reason)));
const oe = console.error.bind(console);
console.error = (...a) => { window.__PAGE_ERRORS__.push(a.map(String).join(' ').slice(0,800)); oe(...a); };
</script>
<script src="lib/phaser.min.js"></script></head>
<body><script type="module" src="dev/scene-restart-probe.mjs"></script></body></html>

View File

@ -0,0 +1,36 @@
import Phaser from '../js/vendor/phaser.js';
import { ConfigLoader } from '../js/config/ConfigLoader.js';
import { config } from '../js/config/Config.js';
import { createGameConfig } from '../js/config/GameConfig.js';
import { GameScene } from '../js/scenes/GameScene.js';
const data = await ConfigLoader.load();
config.init(data);
globalThis.__ORBIT_DEV_SEED = 'PROBE';
const gameConfig = createGameConfig();
gameConfig.scene = [GameScene];
if (typeof Phaser !== 'undefined') Phaser.NoAudioContext = true;
const game = new Phaser.Game(gameConfig);
const out = { steps: [] };
window.__PROBE__ = out;
const tick = (ms) => new Promise((r) => setTimeout(r, ms));
const until = async (fn, label, budgetMs) => {
const t0 = Date.now();
while (Date.now() - t0 < budgetMs) {
if (fn()) return true;
await tick(250);
}
out.steps.push(`TIMEOUT: ${label}`);
return false;
};
game.events.once('ready', async () => {
const s = game.scene.getScene('GameScene');
if (!await until(() => game.scene.isActive('GameScene') && s.ship && s.commsPanel, 'boot', 180000)) { window.__PROBE__ = out; return; }
out.steps.push('booted: ship=' + s.ship.x + ' gates=' + (s.systemContent?.gates?.length ?? 'n/a'));
s.scene.start('GameScene');
await until(() => !game.scene.isActive('GameScene'), 'shutdown', 120000);
out.steps.push('shutdown observed');
const up = await until(() => game.scene.isActive('GameScene') && s.ship && s.commsPanel && s.mineralHud, 'reboot', 240000);
out.steps.push('reboot ok=' + up + ' active=' + game.scene.isActive('GameScene') + ' ship=' + !!s.ship + ' hud=' + !!s.mineralHud + ' minerals=' + (s.ship && s.ship.minerals));
out.steps.push('pageErrors=' + JSON.stringify((window.__PAGE_ERRORS__ || []).slice(0, 6)));
window.__PROBE__ = out;
});

25
dev/surface-skip.html Normal file
View File

@ -0,0 +1,25 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Orbit — dev double-click skip 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>
window.__CAPTURED_ERRORS__ = [];
window.addEventListener('error', (e) => window.__CAPTURED_ERRORS__.push('window: ' + e.message + ' @ ' + (e.filename||'') + ':' + (e.lineno||'')));
window.addEventListener('unhandledrejection', (e) => window.__CAPTURED_ERRORS__.push('rejection: ' + String(e.reason && e.reason.stack || e.reason)));
</script>
<script src="lib/phaser.min.js"></script>
</head>
<body>
<div id="game"></div>
<script type="module" src="dev/surface-skip.mjs"></script>
</body>
</html>

216
dev/surface-skip.mjs Normal file
View File

@ -0,0 +1,216 @@
/**
* DOUBLE-CLICK SKIP (headless browser NOT a Node test).
*
* Drives the REAL input pipeline (dispatched mouse events on the canvas
* v4 pointer the scene's pointerdown handler) and checks:
*
* 1. a SINGLE click during the landing clip does NOT skip it
* (negative control one press is not a double-click)
* 2. a DOUBLE-click (two quick presses) during the landing clip skips
* the rest of the clip the deck comes up now, landing video gone
* 3. a DOUBLE-click during the takeoff clip leaves immediately
* SurfaceScene stopped, GameScene woken, the ship where it was
*
* PUMP-DRIVEN (like dev/landing-click.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 __SS_TICK__(), which steps the engine
* once and advances the stage machine one step. Real time between polls
* (the poll period) is the clock the double-click window is measured
* against, so the barrier stages gate on performance.now() (wall clock).
*
* python3 -m http.server 8123
* node dev/cdp-firefox.mjs http://127.0.0.1:8123/dev/surface-skip.html \
* 'window.__SS_TICK__(); return window.__SURFACE_SKIP__;' 240000 500
*/
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.__SURFACE_SKIP__ = out;
throw new Error(out.error);
}
};
const diag = () => {
let gs = null, ss = null;
try { gs = game.scene?.getScene('GameScene') ?? null; } catch { /* not up yet */ }
try { ss = game.scene?.getScene('SurfaceScene') ?? null; } catch { /* not up yet */ }
window.__SS_STATE__ = {
stage,
steps: out.steps.slice(),
error: out.error,
gs: gs ? { status: gs.sys.getStatus(), ship: !!gs.ship } : 'none',
ss: ss ? {
status: ss.sys.getStatus(),
phase: ss.phase,
landVideo: !!ss.landVideo,
surfaceVideo: !!ss.surfaceVideo,
takeoffVideo: !!ss.takeoffVideo,
deck: !!ss.actionBar,
lastClickT: ss.lastClickT ?? null,
} : 'none',
};
};
let stage = 0;
let negT = 0; // wall time of the negative-control click (barrier gate)
let takeoffT = 0; // wall time the takeoff clip went up (barrier gate)
const shipBefore = { x: null, y: null };
const canvas = () => game.canvas;
// A real mouse press at CANVAS screen coords (clickout-test pattern:
// v4 binds mousedown/mouseup on the canvas; FIT mode scales, so map
// canvas → client space with the rendered rect). `times` = the presses.
const press = (x, y, times = 1) => {
const canvasEl = canvas();
const r = canvasEl.getBoundingClientRect();
const zoom = r.width / canvasEl.width;
const mk = (type, buttons) => new MouseEvent(type, {
bubbles: true,
cancelable: true,
view: window,
button: 0,
buttons,
clientX: r.left + x * zoom,
clientY: r.top + y * zoom,
});
canvasEl.dispatchEvent(mk('mouseover', 0));
for (let i = 0; i < times; i++) {
canvasEl.dispatchEvent(mk('mousedown', 1));
canvasEl.dispatchEvent(mk('mouseup', 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 (landing-click rule).
if (!game.loop || !game.scene) return;
try {
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 });
shipBefore.x = gs.ship.x;
shipBefore.y = gs.ship.y;
gs.startLanding(gs.commsTargetFor(gs.planet));
stage = 1;
return;
}
case 1: { // the landing stage is up with its clip in flight
if (!(ss && ss.sys.isActive() && ss.phase === 'landing')) return;
step('landing-stage', true, { status: ss.sys.getStatus(), phase: ss.phase });
step('landing-clip', ss.landVideo !== null, { frame: ss.planetFrame, landKey: ss.landKey });
stage = 2;
return;
}
case 2: { // NEGATIVE: one press is not a double-click — no skip
press(640, 360, 1);
game.loop.step(performance.now()); // flush the queued pointerdown
step('single-click-no-skip', ss.phase === 'landing' && ss.landVideo !== null,
{ phase: ss.phase, landVideo: !!ss.landVideo });
negT = performance.now();
stage = 3;
return;
}
case 3: { // barrier: out of the double-click window before the real one
if (performance.now() - negT < 600) return;
stage = 4;
return;
}
case 4: { // DOUBLE-CLICK the landing clip — the rest of it is skipped
press(640, 360, 2);
game.loop.step(performance.now());
step('double-click-skips-landing',
ss.phase === 'surface' && ss.landVideo === null,
{ phase: ss.phase, landVideo: !!ss.landVideo });
step('deck-came-up', ss.actionBar !== null && ss.sys.isActive() === true,
{ deck: !!ss.actionBar, status: ss.sys.getStatus() });
// the skipped landing must not have stranded the surface stage:
const loopKey = `__surf_loop_${ss.planetFrame}`;
step('surface-stage', ss.phase === 'surface' && (ss.cache.video.has(loopKey) ? ss.surfaceVideo !== null : true),
{ loopKey, surfaceVideo: !!ss.surfaceVideo });
stage = 5;
return;
}
case 5: { // take off — the takeoff clip goes up (or leaves instantly)
ss.takeOff();
game.loop.step(performance.now());
if (ss.sys.isActive()) {
step('takeoff-clip', ss.takeoffVideo !== null, { frame: ss.planetFrame });
takeoffT = performance.now();
stage = 6;
} else {
step('no-takeoff-clip-left-instantly', gs.sys.isActive() === true, { gsStatus: gs.sys.getStatus() });
stage = 8;
}
return;
}
case 6: { // barrier: out of the double-click window before the real one
if (performance.now() - takeoffT < 600) return;
stage = 7;
return;
}
case 7: { // DOUBLE-CLICK the takeoff clip — the flight world wakes now
press(640, 360, 2);
game.loop.step(performance.now());
step('double-click-skips-takeoff',
ss.sys.isActive() === false && gs.sys.isActive() === true,
{ ssStatus: ss.sys.getStatus(), gsStatus: gs.sys.getStatus() });
// the run resumed exactly where the ship left it — shared check
/* falls through to case 8 */
}
case 8: { // the run resumed exactly where the ship left it
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 } });
out.pass = true;
window.__SURFACE_SKIP__ = out;
stage = 99;
return;
}
}
} catch (err) {
out.error = String(err?.message ?? err);
window.__SURFACE_SKIP__ = out;
stage = 99; // stop
}
}
window.__SS_TICK__ = tick;
// Hard timeout: a hung flow must not hang the driver forever.
const T0 = Date.now();
const arm = setTimeout(() => {
if (!out.pass) {
out.error = out.error ?? `TIMED OUT after ${Math.round((Date.now() - T0) / 1000)}s`;
window.__SURFACE_SKIP__ = out;
}
}, 300000);
arm.unref?.();

View File

@ -813,8 +813,10 @@ The player holds a REPUTATION (standing) on each planet and space station:
(js/reputation/Reputation.js, data/reputation.json)
- [ ] Landing & exploration: settlements become points of interest you
can approach (the data — kind, anchor, population — is already there).
The comms panel is the door: clicking a planet or space station flies
the ship there AND opens a comms panel at the click — the name decodes
The comms panel is the door: clicking a planet or space station opens
a comms panel at the click (NOT a fly-here — the ship stays put; a
world click is the landing request, the compass autopilot is how the
ship flies to a world) — the name decodes
in, the standing bar draws (settled), REQUEST LANDING (gated at standing
4) or LAND on an unsettled world + CANCEL (`js/ui/CommsPanel.js`, a
rusty-metal frame around a scanlined green CRT). Free-space stations are

View File

@ -214,7 +214,10 @@ export class JumpGate extends Phaser.GameObjects.Container {
}
destroy() {
this.removeChildren();
// v4: Container no longer has removeChildren() — removeAll(true) is
// the equivalent (detach the art and destroy it); v4's super.destroy()
// would do the same for whatever was still attached.
this.removeAll(true);
super.destroy();
}
}

View File

@ -11,8 +11,8 @@ import { Planet } from './Planet.js';
* Solid like a planet (the ship keeps its clearance the same plain
* circle rule, GameScene.solids), discoverable (a compass arrow + the
* discovery toast, at the scale of its keepout), and a comms target
* click it and the comms panel opens there while the ship flies to its
* edge (GameScene.openCommsPanel).
* click it and the comms panel opens there (the click is NOT a
* fly-here the ship stays put; GameScene.openCommsPanel).
*
* update(time) turns the ring and breathes the beacon driven by
* GameScene.update, like the asteroid clusters.
@ -194,7 +194,10 @@ export class Station extends Phaser.GameObjects.Container {
}
destroy() {
this.removeChildren();
// v4: Container no longer has removeChildren() — removeAll(true) is
// the equivalent (detach the art and destroy it); v4's super.destroy()
// would do the same for whatever was still attached.
this.removeAll(true);
super.destroy();
}
}

View File

@ -276,7 +276,7 @@ export class GameScene extends Phaser.Scene {
// the void, rendered as world objects (js/entities/Station.js).
// Solid (the ship keeps its clearance), discoverable (compass +
// toast), and comms targets — a click opens the comms panel there
// (openCommsPanel below) and the ship flies to the rim.
// (openCommsPanel below) — it is NOT a fly-here (the ship stays put).
this.systemStations = [];
if (config.get('stations.enabled', true) !== false) {
for (const s of this.systemContent.settlements ?? []) {
@ -562,14 +562,15 @@ export class GameScene extends Phaser.Scene {
// the caret beside it) toggles the dossier open/closed — none of
// those is a fly-here. A click on an ASTEROID opens the mining
// pop-up (a context menu — its buttons own their clicks; any click
// elsewhere closes it and is consumed). Any OTHER click moves the
// ship — which ends the mining state (the beam retracts as the
// ship goes; a mid-reach arm aborts).
// A click inside a planet clamps to that planet's keep-out rim — the
// ship can stop at the clearance, never inside. (Worlds don't
// overlap, so sequential clamping is exact.) A click BEYOND the
// player's tether range clamps to the union boundary — the target
// marker lands on the barrier line itself.
// elsewhere closes it and is consumed). A click on a PLANET or
// STATION opens the comms panel — the landing-request window — and
// does nothing else: it is NOT a fly-here (the ship stays put), and
// it does not touch the mining state (no move ⇒ a live beam keeps
// running). Any OTHER click moves the ship — which ends the mining
// state (the beam retracts as the ship goes; a mid-reach arm
// aborts). A click BEYOND the player's tether range clamps to the
// union boundary — the target marker lands on the barrier line
// itself.
this.input.on('pointerdown', (pointer) => {
// The save pop-up is MODAL — while it's up it owns all input
// (its scrim / cards / dialog eat the click; the world stays put).
@ -626,8 +627,9 @@ export class GameScene extends Phaser.Scene {
// mining menu: a click on one of its buttons is the button's (its
// own pointerdown listener); a click INSIDE it is swallowed (no
// fly-here); a click on ANOTHER planet/station moves the panel
// there (and the ship flies to it — comms business, not a world
// click); any other click closes it and that click is consumed.
// there (the ship does not fly — the click is the panel's, not a
// world move); any other click closes it and that click is
// consumed.
if (this.commsPanel && this.commsPanel.isOpen) {
if (this.commsPanel.contains(pointer.worldX, pointer.worldY)) return;
const obj = this.worldObjectAt(pointer.worldX, pointer.worldY);
@ -661,11 +663,18 @@ export class GameScene extends Phaser.Scene {
}
// A PLANET or STATION under the cursor (a rock click is the mining
// flow above): the ship still flies — to its keep-out rim — AND the
// comms panel opens at the click (js/ui/CommsPanel.js).
// flow above): the click opens the comms panel (the landing-request
// window, js/ui/CommsPanel.js) and NOTHING ELSE — it is not a
// fly-here (the ship stays put), and it does not end the mining
// state (no move, so a live beam keeps running).
const obj = this.worldObjectAt(pointer.worldX, pointer.worldY);
if (obj) {
this.hideHint();
this.openCommsPanel(obj, pointer);
return;
}
// Any other click MOVES the ship — which ends the mining state
// Any OTHER click MOVES the ship — which ends the mining state
// (the beam retracts as the ship goes; a mid-reach arm aborts).
// The state exit is signalled via ship.onStateChange above.
if (this.mining.isActive) this.mining.stop();
@ -678,7 +687,6 @@ export class GameScene extends Phaser.Scene {
this.showTargetMarker(aim.x, aim.y);
this.ship.setTarget(aim.x, aim.y);
this.hideHint();
if (obj) this.openCommsPanel(obj, pointer); // comms: the panel opens at the click
});
// ESC: the topmost open thing closes — the confirm dialog, then the
@ -1410,12 +1418,12 @@ export class GameScene extends Phaser.Scene {
}
/**
* A planet or station was clicked (the ship is already flying to its
* rim the click-to-fly path did that): the comms panel opens at the
* click the name decodes in, the reputation bar draws (settled),
* the landing buttons wait. The panel is world-anchored at the click
* point and picks a side (up/down/left/right) that keeps it fully on
* screen (CommsPanel.pickSideAndPlace).
* A planet or station was clicked: the comms panel opens at the click
* the name decodes in, the reputation bar draws (settled), the
* landing buttons wait. The click is NOT a fly-here the ship stays
* exactly where it is. The panel is world-anchored at the click point
* and picks a side (up/down/left/right) that keeps it fully on screen
* (CommsPanel.pickSideAndPlace).
*/
openCommsPanel(obj, pointer) {
const t = this.commsTargetFor(obj);
@ -2154,10 +2162,11 @@ export class GameScene extends Phaser.Scene {
/**
* Apply a staged restore (js/save/SaveData.js prepareLoad parked it):
* the ship back where it was and with its hold full or empty as saved
* (ship.minerals) the saved tether field (the level-1 home tether is
* just a saved entry the field is rebuilt from the record), and the
* saved session time. Discovery + galaxy are already restored in the
* registry (this scene's create() read them).
* (ship.minerals the upper-right mineral readout follows) the saved
* tether field (the level-1 home tether is just a saved entry the
* field is rebuilt from the record), and the saved session time.
* Discovery + galaxy are already restored in the registry (this scene's
* create() read them).
*/
/**
* The `starting` installs (data/builds.json `starting`): rule-level
@ -2186,6 +2195,11 @@ export class GameScene extends Phaser.Scene {
// setMinerals clamps to the ship's capacity.
if (typeof r.ship.minerals === 'number') this.ship.setMinerals(r.ship.minerals);
}
// The corner readout (upper right) was built with the FRESH hold (0)
// earlier in create() — push the restored value through it. No-op
// when the save held nothing (set() skips unchanged values — no
// count-up, no flash), so mineral-less saves stay quiet.
this.refreshMineralHud();
if (Array.isArray(r.tethers) && r.tethers.length > 0) {
for (const t of [...this.tetherField.tethers]) this.tetherField.remove(t.id);
for (const t of r.tethers) {

View File

@ -12,13 +12,22 @@ import { SaveManager } from '../save/SaveManager.js';
import { BuildWindow } from '../ui/BuildWindow.js';
import { buildDefs } from '../research/ResearchModel.js';
/**
* The double-click window (ms): two presses this close together during a
* one-shot flight clip (the landing or the takeoff) skip the rest of the
* clip (onPointerDown skipFlightClip). On the deck itself there is no
* clip in flight, so the skip is a no-op there.
*/
const DOUBLE_CLICK_MS = 350;
/**
* 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.
* plate. No UI during the flight down. A double-click (two quick
* presses) skips the rest of the clip the deck comes up now.
* 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 (swaps the loop for the world's `shop` clip
@ -33,7 +42,8 @@ import { buildDefs } from '../research/ResearchModel.js';
* 3. TAKE OFF the world's one-shot take-off clip (`takeoff`) plays,
* then this scene stops the paused GameScene resumes exactly where
* it was (ship, tethers, discovery, standing, everything). A world
* with no `takeoff` clip leaves immediately.
* with no `takeoff` clip leaves immediately. A double-click skips
* the rest of the clip the flight world wakes now.
*
* 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
@ -55,6 +65,7 @@ export class SurfaceScene extends Phaser.Scene {
this.planetType = String(data.type ?? ''); // e.g. "Rocky World" (planets.typeLabels)
this.tetherLevel = Math.max(0, Math.round(Number(data.tetherLevel ?? 0)));
this.phase = 'landing'; // 'landing' → 'surface'
this.lastClickT = 0; // last pointerdown (performance.now(), ms) — the double-click skip
this.musicSpec = 'music.frames.' + this.planetFrame; // the surface loop (data/music.json)
this.gameScene = null; // the paused GameScene beneath us (save state lives there)
this.backdrop = null;
@ -127,6 +138,13 @@ export class SurfaceScene extends Phaser.Scene {
// hum's stop is also hooked on the event (Return to Menu path).
this.events.once('shutdown', () => this.setSurfaceMusic(false));
// Pointer input — bound once per visit from the LANDING stage on
// (the scene's input plugin drops its listeners on shutdown, v4, so
// this is fresh every launch; buildDeck no longer re-binds it):
// a double-click skips the in-flight clip (skipFlightClip), and on
// the deck the handler owns the sub-bar / outside-click behavior.
this.input.on('pointerdown', (pointer) => this.onPointerDown(pointer));
if (this.hasVideo(this.landKey)) {
this.playLanding();
} else {
@ -506,11 +524,9 @@ export class SurfaceScene extends Phaser.Scene {
: null;
// ESC — topmost open thing first (mirror of GameScene.escAction).
// (The pointerdown binding lives in create() — it must also be
// live on the landing stage, before the deck exists.)
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));
}
// ------------------------------------------------------------------
@ -723,6 +739,15 @@ export class SurfaceScene extends Phaser.Scene {
}
onPointerDown(pointer) {
// Double-click (two presses inside the window) — the rest of the
// in-flight one-shot clip is skipped: a landing in flight lands now,
// a takeoff in flight leaves now (a no-op on the deck, where no clip
// is in flight). Checked before the UI ownership rules — a flight
// clip owns the whole screen anyway (no deck up during one).
const now = performance.now();
if (now - this.lastClickT <= DOUBLE_CLICK_MS) this.skipFlightClip();
this.lastClickT = now;
if (this.buildWindow?.isOpen) return; // the console owns the click
if (this.savePanel?.isOpen) return; // the modal scrim owns the click
if (this.menuSubBar?.isOpen) {
@ -734,6 +759,27 @@ export class SurfaceScene extends Phaser.Scene {
// A click on the surface itself — the seam for surface interactions.
}
/**
* The double-click skip the remainder of the in-flight one-shot
* clip, dropped: a landing in flight lands now (startSurface the
* clip's complete/error guards see the phase change and stand down),
* a takeoff in flight leaves now (finishTakeoff idempotent). The
* tick plays before the cut, so a skip always answers with a sound.
* A no-op on the surface (no clip in flight) the window keeps
* ticking there, but the skip never fires.
*/
skipFlightClip() {
if (this.phase === 'landing' && this.landVideo) {
this.playSfx('ui_click');
this.startSurface();
return;
}
if (this.takeoffVideo) {
this.playSfx('ui_click');
this.finishTakeoff();
}
}
// ------------------------------------------------------------------
// Plumbing
// ------------------------------------------------------------------