diff --git a/assets/videos/jump.mp4 b/assets/videos/jump.mp4 new file mode 100644 index 0000000..cb22bf6 Binary files /dev/null and b/assets/videos/jump.mp4 differ diff --git a/data/gates.json b/data/gates.json index 45b138c..69fb43a 100644 --- a/data/gates.json +++ b/data/gates.json @@ -29,9 +29,11 @@ } }, "jump": { - "_comment": "THE JUMP (GameScene.jumpThroughGate): a click on an ACTIVE gate transports the run to the connected system — the save pipeline in miniature (captureState → swap in the destination system + arrival position → prepareLoad → scene restart), so discovery/research/builds/minerals/playtime/activatedGates all carry over. ARRIVAL: the ship materialises near the destination's RETURN gate — the gate in the destination whose destination is the system just left (returnGateFor, js/galaxy/JumpTravel.js) — offset back along its facing (the gate faces the star we came from) by radius + shipClearance + ship radius + `arrivalGap`, i.e. just clear of its keepout, inside its activated-gate tether (per ACTIVITY above). One-way SHORTCUT jumps have no return gate (JumpNetwork: tree edges run both ways, shortcuts don't) — those land on the destination's star (its home-tether origin). A click on a DORMANT gate is a console note + the ordinary fly-here (the ship drifts up to the gate's rim). `enabled` is the feature switch; the toasts are templated ({dest}/{system}).", + "_comment": "THE JUMP (GameScene.jumpThroughGate): a click on an ACTIVE gate transports the run to the connected system — the save pipeline in miniature (captureState → swap in the destination system + arrival position → prepareLoad → scene restart), so discovery/research/builds/minerals/playtime/activatedGates all carry over. ARRIVAL: the ship materialises near the destination's RETURN gate — the gate in the destination whose destination is the system just left (returnGateFor, js/galaxy/JumpTravel.js) — offset back along its facing (the gate faces the star we came from) by radius + shipClearance + ship radius + `arrivalGap`, i.e. just clear of its keepout, inside its activated-gate tether (per ACTIVITY above). One-way SHORTCUT jumps have no return gate (JumpNetwork: tree edges run both ways, shortcuts don't) — those land on the destination's star (its home-tether origin). A click on a DORMANT gate is a console note + the ordinary fly-here (the ship drifts up to the gate's rim). The jump plays a full-screen one-shot clip (`video`, cover-scaled over the theme background) between the two systems — the destination is already staged behind it, so the scene restarts the moment the clip ends (or errors, or stalls); `videoVolume` is 0..1 (0 = silent). A DOUBLE-CLICK (two quick presses) skips the rest of the clip, same as the landing/takeoff clips. If `video` is empty/missing the jump falls back to the short `jumpDelayMs` cut. `enabled` is the feature switch; the toasts are templated ({dest}/{system}).", "enabled": true, "arrivalGap": 128, + "video": "assets/videos/jump.mp4", + "videoVolume": 1, "jumpDelayMs": 420, "toast": "JUMP — {dest}", "dormantToast": "JUMP GATE DORMANT — CHART {system} TO ACTIVATE ITS JUMP GATES", diff --git a/docs/PROJECT_NOTES.md b/docs/PROJECT_NOTES.md index fb08b8e..bf7ba84 100644 --- a/docs/PROJECT_NOTES.md +++ b/docs/PROJECT_NOTES.md @@ -313,8 +313,22 @@ collide). **Barren systems** (no anchors — the `objectCount` → 0 stops) direction; one-way SHORTCUT jumps have no return gate (JumpNetwork: tree edges run both ways, shortcuts don't — ~⅓ of a typical galaxy's gates) and land on the destination's star, inside its home-tether - zone. A mid-jump guard (`_jumping`) swallows input for the cut - (`jumpDelayMs`); mining blocks the jump (console nudge). + zone. THE CLIP: the jump plays a full-screen one-shot between the two + systems (`jump.video`, cover-scaled over an opaque theme backdrop — the + source system must not show through; `jump.videoVolume` 0..1, 0 = + silent). The clip + backdrop are SCREEN-pinned (`scrollFactor(0)`) — + this scene's camera follows the ship and scrolls, so a world-space clip + (the default `scrollFactor 1`) renders off-screen and you'd hear it + without seeing it (the same reason every screen UI here pins itself). + The destination is ALREADY staged behind it (`prepareLoad` ran first), + so the scene restarts the moment the clip ends — or errors, or stalls (a STALL guard advances after a grace period with no playback + progress; a CAP does so at duration + margin), whichever fires first + (`_playJumpClip`/`_finishJump`, both idempotent). A DOUBLE-CLICK (two + quick presses, the 350 ms window shared with the landing/takeoff skip) + skips the rest of the clip; a single press is swallowed. If `video` is + empty/missing the jump falls back to the short `jumpDelayMs` cut. + A mid-jump guard (`_jumping`) swallows input for the whole clip; + mining blocks the jump (console nudge). - **Determinism** — same seed ⇒ same network, same gates, same placements, same `active` flags, same jump arrivals. Verified: `dev/jumps.test.mjs` (network invariants, placement invariants, barren diff --git a/js/scenes/GameScene.js b/js/scenes/GameScene.js index 0f351cb..a63e31c 100644 --- a/js/scenes/GameScene.js +++ b/js/scenes/GameScene.js @@ -53,6 +53,13 @@ const FONT_FALLBACK = "'Segoe UI', 'Helvetica Neue', Arial, sans-serif"; const HEADER_FONT = () => fontStack('header', FONT_FALLBACK); const BODY_FONT = () => fontStack('body', FONT_FALLBACK); +// The jump clip (data/gates.json → jump.video) — the full-screen one-shot +// played between the two systems on a gate jump. Its cache key + the +// double-click skip window (ms; two presses this close skip the rest of the +// clip — the same window as SurfaceScene's landing/takeoff skip). +const JUMP_VIDEO_KEY = 'jump_warp'; +const JUMP_DOUBLE_CLICK_MS = 350; + /** * The dossier's decode pacing (the shared decode effect, per line): * a beat of arrival, then each line starts a little after the last. @@ -155,6 +162,20 @@ export class GameScene extends Phaser.Scene { } } + // The jump clip (data/gates.json → jump.video): a full-screen one-shot + // played between the two systems on a gate jump (jumpThroughGate → + // _playJumpClip). A missing/empty file just leaves the short-cut + // fallback — the jump still works. + { + const jumpVideo = String(config.get('gates.jump.video') ?? ''); + if (jumpVideo) { + const url = /^(https?:)?\/\//.test(jumpVideo) || jumpVideo.startsWith('assets/') + ? jumpVideo + : `assets/videos/${jumpVideo}`; + this.load.video(JUMP_VIDEO_KEY, url); + } + } + // Sound effects (data/sfx.json → enabled). Skipped entirely when the // master switch is off — no load cost, no files fetched. if (config.get('sfx.enabled', true)) { @@ -183,6 +204,14 @@ export class GameScene extends Phaser.Scene { // A jump's cut (jumpThroughGate) sets this on the dying scene — the // restart lands here, so clear it: input unlocks again. this._jumping = false; + // The jump clip's in-flight state (the full-screen one-shot between the + // two systems — _playJumpClip). The clip is created on the dying scene + // and torn down in _finishJump before the restart, so these are fresh on + // every entry regardless. + this._jumpVideo = null; + this._jumpBackdrop = null; + this._jumpFinished = false; + this._jumpLastClickT = 0; // Camera: smoothly follows the ship (updateCamera below). This motion // is what drives the parallax starfield — ship flies, view trails. @@ -635,8 +664,17 @@ export class GameScene extends Phaser.Scene { // union boundary — the target marker lands on the barrier line // itself. this.input.on('pointerdown', (pointer) => { - // A jump is in flight (the cut's 400 ms or so): the old scene is - // already gone — swallow anything that lands in the gap. + // A jump clip is in flight — it owns the whole screen. A DOUBLE-CLICK + // (two quick presses) skips the rest of the clip (the same window as + // SurfaceScene's landing/takeoff skip); a single press is swallowed. + if (this._jumping && this._jumpVideo) { + const now = performance.now(); + if (now - this._jumpLastClickT <= JUMP_DOUBLE_CLICK_MS) this._finishJump(); + this._jumpLastClickT = now; + return; + } + // A jump is in flight (between the clip's end and the restart): the + // old scene is already gone — swallow anything that lands in the gap. if (this._jumping) return; // 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). @@ -1541,10 +1579,119 @@ export class GameScene extends Phaser.Scene { { glyph: '⌁', glyphColor: toCss(themeColor('neon', 0x00e5ff)), durationMs: 1200 }, ); this.playSfx('discovery'); - this.time.delayedCall( - Number(config.get('gates.jump.jumpDelayMs', 420)) || 420, - () => this.scene.restart(), - ); + this._playJumpClip(); + } + + /** + * The jump clip — a full-screen one-shot played between the two systems + * (data/gates.json → jump.video). The destination is ALREADY staged behind + * it (prepareLoad ran in jumpThroughGate), so the moment the clip ends we + * restart the scene onto it. Mirrors SurfaceScene's landing clip: play to + * completion, with a STALL guard (a clip that never starts must not hold + * the jump) and a CAP (duration + margin), both advancing anyway. A + * missing/empty clip falls back to the short jumpDelayMs cut. + */ + _playJumpClip() { + this._jumpFinished = false; + this._jumpLastClickT = 0; + if (!this.hasVideo(JUMP_VIDEO_KEY)) { + // No clip (disabled or file missing) — the old short cut. + this.time.delayedCall( + Number(config.get('gates.jump.jumpDelayMs', 420)) || 420, + () => this._finishJump(), + ); + return; + } + const W = this.scale.width; + const H = this.scale.height; + // Opaque backdrop: the source system must not show through the clip (or + // its first-frame decode gap). scrollFactor(0) pins it to the SCREEN — + // this scene's camera follows the ship and scrolls, so a world-space + // overlay (the default scrollFactor 1) would land off-screen; the same + // reason every screen-pinned UI here (toast, action bar, HUD) does it. + this._jumpBackdrop = this.add + .rectangle(W / 2, H / 2, W, H, toColor(themeColor('bg', 0x04060d))) + .setScrollFactor(0) + .setDepth(60); + // v4: add.video(x, y, key) — the key is the LAST argument (it loads the + // cached clip and attaches the