Add take-off clip to surface scene before resuming flight

- Introduce a `takeoff` video entry in landing.json for each world; missing/
  null entries leave the surface instantly (no regression)
- SurfaceScene.takeOff now plays the one-shot take-off clip full-screen,
  hides the deck, and only then stops the scene and wakes GameScene
- Guard against a clip that never starts or runs past its duration so the
  player is never stranded; finishTakeoff is idempotent
- Update surface-flow/surface-shot dev harnesses to assert and screenshot
  the new take-off stage
- Re-map gas giant frames in planets.json from [0,1,2] to [3,4,5] to match
  their sheet positions
This commit is contained in:
Brian Fertig 2026-09-04 22:44:51 -06:00
parent 69875c1ff0
commit 1cbbf502df
5 changed files with 103 additions and 15 deletions

View File

@ -6,6 +6,8 @@
"", "",
"`land` — the full-screen one-shot clip played when the ship lands.", "`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.", "`surface` — the looping clip shown on the surface deck behind the action bar.",
"`takeoff` — the full-screen one-shot clip played on TAKE OFF, before the",
" flight world resumes. A null/missing entry takes off instantly.",
"", "",
"Entries are stubbed with the existing videos where a specific clip is", "Entries are stubbed with the existing videos where a specific clip is",
"missing: set `land`/`surface` to the real file name (in `videoBase`) when", "missing: set `land`/`surface` to the real file name (in `videoBase`) when",
@ -20,11 +22,11 @@
"videoBase": "assets/videos/", "videoBase": "assets/videos/",
"volume": 1, "volume": 1,
"videos": [ "videos": [
{ "land": "terran-land-01.mp4", "surface": "terran-surface-01.mp4" }, { "land": "terran-land-01.mp4", "surface": "terran-surface-01.mp4", "takeoff": "terran-takeoff-01.mp4" },
{ "land": "terran-land-02.mp4", "surface": "terran-surface-01.mp4" }, { "land": "terran-land-02.mp4", "surface": "terran-surface-01.mp4", "takeoff": "terran-takeoff-02.mp4" },
{ "land": "terran-land-01.mp4", "surface": "terran-surface-01.mp4" }, { "land": "terran-land-01.mp4", "surface": "terran-surface-01.mp4", "takeoff": "terran-takeoff-01.mp4" },
{ "land": "gasgiant-land-01.mp4", "surface": "terran-surface-01.mp4" }, { "land": "gasgiant-land-01.mp4", "surface": "terran-surface-01.mp4", "takeoff": "gasgiant-takeoff-01.mp4" },
{ "land": "gasgiant-land-02.mp4", "surface": "terran-surface-01.mp4" }, { "land": "gasgiant-land-02.mp4", "surface": "terran-surface-01.mp4", "takeoff": "gasgiant-takeoff-02.mp4" },
{ "land": "gasgiant-land-01.mp4", "surface": "terran-surface-01.mp4" } { "land": "gasgiant-land-01.mp4", "surface": "terran-surface-01.mp4", "takeoff": "gasgiant-takeoff-01.mp4" }
] ]
} }

View File

@ -7,7 +7,7 @@
"frames": { "frames": {
"terran": [0, 1, 2], "terran": [0, 1, 2],
"rocky": [0, 1, 2], "rocky": [0, 1, 2],
"gas": [0, 1, 2], "gas": [3, 4, 5],
"ice": [0, 1, 2], "ice": [0, 1, 2],
"lava": [0, 1, 2] "lava": [0, 1, 2]
}, },

View File

@ -14,7 +14,8 @@
* 4. force the surface stage (skip the clip's runtime) * 4. force the surface stage (skip the clip's runtime)
* 5. assert deck slots (shop/takeoff/menu), save UI, surface clip * 5. assert deck slots (shop/takeoff/menu), save UI, surface clip
* 6. menuAction sub-bar opens; save panel opens * 6. menuAction sub-bar opens; save panel opens
* 7. takeOff SurfaceScene stopped, GameScene resumed, ship preserved * 7. takeOff take-off clip plays, then (forced complete) SurfaceScene
* stopped, GameScene resumed, ship preserved
* 8. land again takeOff again (repeat-launch/re-stop cycle) * 8. land again takeOff again (repeat-launch/re-stop cycle)
*/ */
import Phaser from '../js/vendor/phaser.js'; import Phaser from '../js/vendor/phaser.js';
@ -118,9 +119,20 @@ window.game = game;
ss.savePanel.close(); ss.savePanel.close();
ss.menuSubBar.dismiss(); ss.menuSubBar.dismiss();
// 7 — TAKE OFF: overlay stopped, flight scene woken, ship where it was. // 7 — TAKE OFF: the take-off clip plays first, then overlay stopped,
// flight scene woken, ship where it was.
const takeoffKey = `__surf_takeoff_${ss.planetFrame}`;
const takeoffCached = ss.cache.video.has(takeoffKey);
ss.takeOff(); ss.takeOff();
await sleep(250); await sleep(250);
if (takeoffCached) {
step('takeoff-clip', ss.takeoffVideo !== null && ss.sys.isActive() === true
&& ss.surfaceVideo === null && ss.actionBar?.visible === false,
{ takeoffKey, surfaceVideo: ss.surfaceVideo, deckVisible: ss.actionBar?.visible });
// the test does not wait out the 1520s clip — force its completion
ss.takeoffVideo.emit('complete', ss.takeoffVideo);
await sleep(250);
}
step('takeoff', ss.sys.isActive() === false && gs.sys.isActive() === true, step('takeoff', ss.sys.isActive() === false && gs.sys.isActive() === true,
{ ssStatus: ss.sys.getStatus(), gsStatus: gs.sys.getStatus() }); { ssStatus: ss.sys.getStatus(), gsStatus: gs.sys.getStatus() });
step('ship-preserved', Math.abs(gs.ship.x - shipBefore.x) < 1e-6 step('ship-preserved', Math.abs(gs.ship.x - shipBefore.x) < 1e-6
@ -137,6 +149,11 @@ window.game = game;
step('re-surface', ss2.phase === 'surface' && ss2.actionBar !== null); step('re-surface', ss2.phase === 'surface' && ss2.actionBar !== null);
ss2.takeOff(); ss2.takeOff();
await sleep(250); await sleep(250);
if (ss2.cache.video.has(`__surf_takeoff_${ss2.planetFrame}`) && ss2.takeoffVideo) {
step('re-takeoff-clip', ss2.takeoffVideo !== null && ss2.sys.isActive() === true);
ss2.takeoffVideo.emit('complete', ss2.takeoffVideo);
await sleep(250);
}
step('re-takeoff', ss2.sys.isActive() === false && gs.sys.isActive() === true); step('re-takeoff', ss2.sys.isActive() === false && gs.sys.isActive() === true);
window.__SURFACE_TEST__ = { pass: true, steps: window.__STEPS__ }; window.__SURFACE_TEST__ = { pass: true, steps: window.__STEPS__ };

View File

@ -3,6 +3,7 @@
// driver can grab a screenshot at exactly the moment we want: // driver can grab a screenshot at exactly the moment we want:
// __SHOT__ = 'landing' — the one-shot landing clip, full screen // __SHOT__ = 'landing' — the one-shot landing clip, full screen
// __SHOT__ = 'surface' — looping surface clip + the surface deck // __SHOT__ = 'surface' — looping surface clip + the surface deck
// __SHOT__ = 'takeoff' — the one-shot take-off clip (deck hidden)
// (dev/surface-flow.mjs is the assertion suite; this one just parks.) // (dev/surface-flow.mjs is the assertion suite; this one just parks.)
import Phaser from '../js/vendor/phaser.js'; import Phaser from '../js/vendor/phaser.js';
import { config } from '../js/config/Config.js'; import { config } from '../js/config/Config.js';
@ -47,6 +48,14 @@ import { SurfaceScene } from '../js/scenes/SurfaceScene.js';
ss.surfaceVideo.video.play().catch(() => {}); ss.surfaceVideo.video.play().catch(() => {});
} }
await sleep(1200); await sleep(1200);
window.__SHOT__ = 'surface'; // ← screenshot #2 (loop + deck), stays here window.__SHOT__ = 'surface'; // ← screenshot #2 (loop + deck)
await sleep(600);
ss.takeOff(); // the take-off clip owns the screen now
if (ss.takeoffVideo && ss.takeoffVideo.video) {
ss.takeoffVideo.video.muted = true;
ss.takeoffVideo.video.play().catch(() => {});
}
await sleep(1200);
window.__SHOT__ = 'takeoff'; // ← screenshot #3 (take-off clip), stays here
window.__SURFACE_TEST__ = { pass: true, parked: true }; window.__SURFACE_TEST__ = { pass: true, parked: true };
})().catch((err) => { window.__SHOT__ = 'FAILED: ' + String(err); window.__SURFACE_TEST__ = { pass: false, error: String(err) }; }); })().catch((err) => { window.__SHOT__ = 'FAILED: ' + String(err); window.__SURFACE_TEST__ = { pass: false, error: String(err) }; });

View File

@ -19,8 +19,10 @@ import { SaveManager } from '../save/SaveManager.js';
* save sub-bar Save/Load operate on the paused GameScene's state * save sub-bar Save/Load operate on the paused GameScene's state
* (SavePanel `stateScene`). * (SavePanel `stateScene`).
* *
* TAKE OFF stops this scene the paused GameScene resumes exactly * 3. TAKE OFF the world's one-shot take-off clip (`takeoff`) plays,
* where it was (ship, tethers, discovery, standing, everything). * 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.
* *
* Video selection is by the planet's planets.png SHEET FRAME (the same * 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 * index its sprite uses frames 0..2 the terran worlds, 3..5 the gas
@ -48,6 +50,9 @@ export class SurfaceScene extends Phaser.Scene {
this.saveManager = null; this.saveManager = null;
this.landKey = null; this.landKey = null;
this.surfaceKey = null; this.surfaceKey = null;
this.takeoffKey = null;
this.takeoffVideo = null;
this.takeoffDone = false; // finishTakeoff idempotency ('complete' + a guard can both fire)
} }
/** Pick this world's clips (data/landing.json) and queue them. */ /** Pick this world's clips (data/landing.json) and queue them. */
@ -55,10 +60,13 @@ export class SurfaceScene extends Phaser.Scene {
const entry = (config.get('landing.videos') ?? [])[this.planetFrame] ?? {}; const entry = (config.get('landing.videos') ?? [])[this.planetFrame] ?? {};
this.landKey = `__surf_land_${this.planetFrame}`; this.landKey = `__surf_land_${this.planetFrame}`;
this.surfaceKey = `__surf_loop_${this.planetFrame}`; this.surfaceKey = `__surf_loop_${this.planetFrame}`;
this.takeoffKey = `__surf_takeoff_${this.planetFrame}`;
this.landUrl = this.resolveUrl(entry.land ?? null); this.landUrl = this.resolveUrl(entry.land ?? null);
this.surfaceUrl = this.resolveUrl(entry.surface ?? null); this.surfaceUrl = this.resolveUrl(entry.surface ?? null);
this.takeoffUrl = this.resolveUrl(entry.takeoff ?? null);
if (this.landUrl) this.load.video(this.landKey, this.landUrl); if (this.landUrl) this.load.video(this.landKey, this.landUrl);
if (this.surfaceUrl) this.load.video(this.surfaceKey, this.surfaceUrl); if (this.surfaceUrl) this.load.video(this.surfaceKey, this.surfaceUrl);
if (this.takeoffUrl) this.load.video(this.takeoffKey, this.takeoffUrl);
} }
create() { create() {
@ -249,13 +257,63 @@ export class SurfaceScene extends Phaser.Scene {
} }
/** /**
* TAKE OFF leave the surface: stop this scene (the shutdown hook * TAKE OFF leave the surface. The world's one-shot take-off clip
* cleans the deck + clips) and wake the sleeping GameScene the run * (`takeoff`) plays first, then the scene stops and the sleeping
* resumes exactly where the ship left it. * GameScene wakes the run resumes exactly where the ship left it.
* No clip for this world leave immediately.
*/ */
takeOff() { takeOff() {
this.savePanel?.close(); this.savePanel?.close();
this.menuSubBar?.dismiss(); this.menuSubBar?.dismiss();
if (this.takeoffVideo) return; // the take-off clip is already in flight
if (this.hasVideo(this.takeoffKey)) {
this.playTakeoff();
return;
}
this.finishTakeoff();
}
/**
* Stage 3 the one-shot take-off clip, then back to the flight world.
* Same shape as the landing stage (full-screen clip, no deck over it),
* and the same two guards: a clip that never starts, or goes silent
* past duration + margin, must not hold the run.
*/
playTakeoff() {
// The deck + loop clip belong to the surface stage — the clip owns
// the screen (same "no UI mid-flight" rule as the landing stage).
this.actionBar?.setVisible(false);
if (this.surfaceVideo) {
this.destroyVideo(this.surfaceVideo); // the opaque clip hides it — free the decoder
this.surfaceVideo = null;
}
const v = this.add.video(0, 0, this.takeoffKey).setOrigin(0.5).setDepth(20);
this.attachClip(v);
this.takeoffVideo = v;
v.play();
v.on('complete', () => this.finishTakeoff()); // played once → back to the ship
v.on('error', () => this.finishTakeoff()); // broken clip → don't strand the player
const el = v.video;
const playing = () => !!el && (el.currentTime > 0.1 || !el.paused);
this.time.delayedCall(5000, () => {
if (this.takeoffVideo === v && !playing()) this.finishTakeoff();
});
const durS = Number(el && el.duration);
if (Number.isFinite(durS) && durS > 0) {
this.time.delayedCall(durS * 1000 + 5000, () => {
if (this.takeoffVideo === v) this.finishTakeoff();
});
}
}
/** Take-off clip done (or bailed out) — back to the flight world. */
finishTakeoff() {
if (this.takeoffDone) return;
this.takeoffDone = true;
this.destroyVideo(this.takeoffVideo);
this.takeoffVideo = null;
this.scene.stop('SurfaceScene'); this.scene.stop('SurfaceScene');
this.scene.wake('GameScene'); this.scene.wake('GameScene');
} }
@ -367,8 +425,10 @@ export class SurfaceScene extends Phaser.Scene {
shutdown() { shutdown() {
this.destroyVideo(this.landVideo); this.destroyVideo(this.landVideo);
this.destroyVideo(this.surfaceVideo); this.destroyVideo(this.surfaceVideo);
this.destroyVideo(this.takeoffVideo);
this.landVideo = null; this.landVideo = null;
this.surfaceVideo = null; this.surfaceVideo = null;
this.takeoffVideo = null;
this.actionBar?.destroy(); this.actionBar?.destroy();
this.menuSubBar?.destroy(); this.menuSubBar?.destroy();
this.savePanel?.destroy(); this.savePanel?.destroy();