diff --git a/assets/images/originals/gasgiant-shop-01.png b/assets/images/originals/gasgiant-shop-01.png new file mode 100644 index 0000000..1b9abf0 Binary files /dev/null and b/assets/images/originals/gasgiant-shop-01.png differ diff --git a/assets/images/originals/terran-shop-03.png b/assets/images/originals/terran-shop-03.png new file mode 100644 index 0000000..3e48989 Binary files /dev/null and b/assets/images/originals/terran-shop-03.png differ diff --git a/assets/videos/gasgiant-shop-01.mp4 b/assets/videos/gasgiant-shop-01.mp4 new file mode 100644 index 0000000..c2baf5b Binary files /dev/null and b/assets/videos/gasgiant-shop-01.mp4 differ diff --git a/assets/videos/terran-shop-02.mp4 b/assets/videos/terran-shop-02.mp4 new file mode 100644 index 0000000..4859672 Binary files /dev/null and b/assets/videos/terran-shop-02.mp4 differ diff --git a/assets/videos/terran-shop-03.mp4 b/assets/videos/terran-shop-03.mp4 new file mode 100644 index 0000000..226a77b Binary files /dev/null and b/assets/videos/terran-shop-03.mp4 differ diff --git a/data/landing.json b/data/landing.json index 631014f..9e59105 100644 --- a/data/landing.json +++ b/data/landing.json @@ -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" }, diff --git a/dev/comms-click.mjs b/dev/comms-click.mjs index 5081ab0..8e4bcf7 100644 --- a/dev/comms-click.mjs +++ b/dev/comms-click.mjs @@ -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); diff --git a/dev/container-probe.html b/dev/container-probe.html new file mode 100644 index 0000000..9f454f4 --- /dev/null +++ b/dev/container-probe.html @@ -0,0 +1,2 @@ + + diff --git a/dev/container-probe.mjs b/dev/container-probe.mjs new file mode 100644 index 0000000..f06613b --- /dev/null +++ b/dev/container-probe.mjs @@ -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; +}); diff --git a/dev/hold-restore.html b/dev/hold-restore.html new file mode 100644 index 0000000..23d3d85 --- /dev/null +++ b/dev/hold-restore.html @@ -0,0 +1,26 @@ + + + + + + orbit — hold across game entry (dev test) + + + + + + + + + + diff --git a/dev/hold-restore.mjs b/dev/hold-restore.mjs new file mode 100644 index 0000000..ae9b09b --- /dev/null +++ b/dev/hold-restore.mjs @@ -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())); diff --git a/dev/probe-imports.html b/dev/probe-imports.html new file mode 100644 index 0000000..f90e292 --- /dev/null +++ b/dev/probe-imports.html @@ -0,0 +1,12 @@ + + diff --git a/dev/saves-ui-test.mjs b/dev/saves-ui-test.mjs index b6057a5..966d64b 100644 --- a/dev/saves-ui-test.mjs +++ b/dev/saves-ui-test.mjs @@ -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 diff --git a/dev/scene-restart-probe.html b/dev/scene-restart-probe.html new file mode 100644 index 0000000..21161c0 --- /dev/null +++ b/dev/scene-restart-probe.html @@ -0,0 +1,10 @@ + + + + diff --git a/dev/scene-restart-probe.mjs b/dev/scene-restart-probe.mjs new file mode 100644 index 0000000..6af5153 --- /dev/null +++ b/dev/scene-restart-probe.mjs @@ -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; +}); diff --git a/dev/surface-skip.html b/dev/surface-skip.html new file mode 100644 index 0000000..e1e26ed --- /dev/null +++ b/dev/surface-skip.html @@ -0,0 +1,25 @@ + + + + + Orbit — dev double-click skip test + + + + + + + +
+ + + diff --git a/dev/surface-skip.mjs b/dev/surface-skip.mjs new file mode 100644 index 0000000..beebaf9 --- /dev/null +++ b/dev/surface-skip.mjs @@ -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?.(); diff --git a/docs/PROJECT_NOTES.md b/docs/PROJECT_NOTES.md index 21d3430..7c7e7d5 100644 --- a/docs/PROJECT_NOTES.md +++ b/docs/PROJECT_NOTES.md @@ -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 diff --git a/js/entities/JumpGate.js b/js/entities/JumpGate.js index 7cd6067..0162af2 100644 --- a/js/entities/JumpGate.js +++ b/js/entities/JumpGate.js @@ -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(); } } diff --git a/js/entities/Station.js b/js/entities/Station.js index 7490cc0..cd52e6b 100644 --- a/js/entities/Station.js +++ b/js/entities/Station.js @@ -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(); } } diff --git a/js/scenes/GameScene.js b/js/scenes/GameScene.js index 7b8dfc5..5fce233 100644 --- a/js/scenes/GameScene.js +++ b/js/scenes/GameScene.js @@ -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) { diff --git a/js/scenes/SurfaceScene.js b/js/scenes/SurfaceScene.js index 7913536..513da19 100644 --- a/js/scenes/SurfaceScene.js +++ b/js/scenes/SurfaceScene.js @@ -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 // ------------------------------------------------------------------