diff --git a/data/landing.json b/data/landing.json index 72bdb6e..869a1fc 100644 --- a/data/landing.json +++ b/data/landing.json @@ -6,6 +6,8 @@ "", "`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.", + "`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", "missing: set `land`/`surface` to the real file name (in `videoBase`) when", @@ -20,11 +22,11 @@ "videoBase": "assets/videos/", "volume": 1, "videos": [ - { "land": "terran-land-01.mp4", "surface": "terran-surface-01.mp4" }, - { "land": "terran-land-02.mp4", "surface": "terran-surface-01.mp4" }, - { "land": "terran-land-01.mp4", "surface": "terran-surface-01.mp4" }, - { "land": "gasgiant-land-01.mp4", "surface": "terran-surface-01.mp4" }, - { "land": "gasgiant-land-02.mp4", "surface": "terran-surface-01.mp4" }, - { "land": "gasgiant-land-01.mp4", "surface": "terran-surface-01.mp4" } + { "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", "takeoff": "terran-takeoff-02.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", "takeoff": "gasgiant-takeoff-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", "takeoff": "gasgiant-takeoff-01.mp4" } ] } diff --git a/data/planets.json b/data/planets.json index 801294e..292ed54 100644 --- a/data/planets.json +++ b/data/planets.json @@ -7,7 +7,7 @@ "frames": { "terran": [0, 1, 2], "rocky": [0, 1, 2], - "gas": [0, 1, 2], + "gas": [3, 4, 5], "ice": [0, 1, 2], "lava": [0, 1, 2] }, diff --git a/dev/surface-flow.mjs b/dev/surface-flow.mjs index b54344a..b60382f 100644 --- a/dev/surface-flow.mjs +++ b/dev/surface-flow.mjs @@ -14,7 +14,8 @@ * 4. force the surface stage (skip the clip's runtime) * 5. assert deck slots (shop/takeoff/menu), save UI, surface clip * 6. menuAction → sub-bar opens; save panel opens - * 7. takeOff → SurfaceScene stopped, GameScene resumed, ship preserved + * 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) */ import Phaser from '../js/vendor/phaser.js'; @@ -118,9 +119,20 @@ window.game = game; ss.savePanel.close(); 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(); 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 15–20s clip — force its completion + ss.takeoffVideo.emit('complete', ss.takeoffVideo); + await sleep(250); + } step('takeoff', ss.sys.isActive() === false && gs.sys.isActive() === true, { ssStatus: ss.sys.getStatus(), gsStatus: gs.sys.getStatus() }); step('ship-preserved', Math.abs(gs.ship.x - shipBefore.x) < 1e-6 @@ -137,6 +149,11 @@ window.game = game; step('re-surface', ss2.phase === 'surface' && ss2.actionBar !== null); ss2.takeOff(); 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); window.__SURFACE_TEST__ = { pass: true, steps: window.__STEPS__ }; diff --git a/dev/surface-shot.mjs b/dev/surface-shot.mjs index c84bf83..6c1a494 100644 --- a/dev/surface-shot.mjs +++ b/dev/surface-shot.mjs @@ -3,6 +3,7 @@ // driver can grab a screenshot at exactly the moment we want: // __SHOT__ = 'landing' — the one-shot landing clip, full screen // __SHOT__ = 'surface' — looping surface clip + the surface deck +// __SHOT__ = 'takeoff' — the one-shot take-off clip (deck hidden) // (dev/surface-flow.mjs is the assertion suite; this one just parks.) import Phaser from '../js/vendor/phaser.js'; import { config } from '../js/config/Config.js'; @@ -47,6 +48,14 @@ import { SurfaceScene } from '../js/scenes/SurfaceScene.js'; ss.surfaceVideo.video.play().catch(() => {}); } 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 }; })().catch((err) => { window.__SHOT__ = 'FAILED: ' + String(err); window.__SURFACE_TEST__ = { pass: false, error: String(err) }; }); diff --git a/js/scenes/SurfaceScene.js b/js/scenes/SurfaceScene.js index 7c3db85..d26a891 100644 --- a/js/scenes/SurfaceScene.js +++ b/js/scenes/SurfaceScene.js @@ -19,8 +19,10 @@ import { SaveManager } from '../save/SaveManager.js'; * save sub-bar — Save/Load operate on the paused GameScene's state * (SavePanel `stateScene`). * - * TAKE OFF stops this scene — the paused GameScene resumes exactly - * where it was (ship, tethers, discovery, standing, everything). + * 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. * * 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 @@ -48,6 +50,9 @@ export class SurfaceScene extends Phaser.Scene { this.saveManager = null; this.landKey = 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. */ @@ -55,10 +60,13 @@ export class SurfaceScene extends Phaser.Scene { const entry = (config.get('landing.videos') ?? [])[this.planetFrame] ?? {}; this.landKey = `__surf_land_${this.planetFrame}`; this.surfaceKey = `__surf_loop_${this.planetFrame}`; + this.takeoffKey = `__surf_takeoff_${this.planetFrame}`; this.landUrl = this.resolveUrl(entry.land ?? 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.surfaceUrl) this.load.video(this.surfaceKey, this.surfaceUrl); + if (this.takeoffUrl) this.load.video(this.takeoffKey, this.takeoffUrl); } create() { @@ -249,13 +257,63 @@ export class SurfaceScene extends Phaser.Scene { } /** - * TAKE OFF — leave the surface: stop this scene (the shutdown hook - * cleans the deck + clips) and wake the sleeping GameScene — the run - * resumes exactly where the ship left it. + * TAKE OFF — leave the surface. The world's one-shot take-off clip + * (`takeoff`) plays first, then the scene stops and the sleeping + * GameScene wakes — the run resumes exactly where the ship left it. + * No clip for this world → leave immediately. */ takeOff() { this.savePanel?.close(); 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.wake('GameScene'); } @@ -367,8 +425,10 @@ export class SurfaceScene extends Phaser.Scene { shutdown() { this.destroyVideo(this.landVideo); this.destroyVideo(this.surfaceVideo); + this.destroyVideo(this.takeoffVideo); this.landVideo = null; this.surfaceVideo = null; + this.takeoffVideo = null; this.actionBar?.destroy(); this.menuSubBar?.destroy(); this.savePanel?.destroy();