Add full-screen warp clip to gate jumps

- Play a cover-scaled video between systems on jump, with an opaque backdrop so the source doesn't show through
- Double-click skips the remaining clip; single press is swallowed while it plays
- Fall back to the short jumpDelayMs cut when no video is configured or missing
- Guard against stalled/overlong playback with a grace and duration cap before restarting
- Add assets/videos/jump.mp4 and document the new behavior in gates.json and PROJECT_NOTES.md
This commit is contained in:
Brian Fertig 2026-09-06 18:08:29 -06:00
parent ff557f87ba
commit 507d00f818
4 changed files with 172 additions and 9 deletions

BIN
assets/videos/jump.mp4 Normal file

Binary file not shown.

View File

@ -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",

View File

@ -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

View File

@ -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._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.scene.restart(),
() => 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 <video> element). scrollFactor(0) keeps
// the clip pinned to the screen as the camera scrolls (see above).
const v = this.add.video(0, 0, JUMP_VIDEO_KEY).setOrigin(0.5).setScrollFactor(0).setDepth(61);
this._jumpVideo = v;
v.setVolume(Math.max(0, Math.min(1, Number(config.get('gates.jump.videoVolume', 1)))));
this._fitJumpClip(v);
// Re-fit on the first presented frame (v4: the bookkeeping size is a
// placeholder until then — see SurfaceScene.attachClip).
v.on('created', (vv, w, h) => {
if (vv === v) this._fitJumpClip(vv, w, h);
});
v.play();
v.on('complete', () => this._finishJump());
v.on('error', () => this._finishJump());
const el = v.video;
const playing = () => !!el && (el.currentTime > 0.1 || !el.paused);
// STALL — no progress after a grace period (autoplay locked, decode
// failure, …): advance anyway.
this.time.delayedCall(6000, () => {
if (this._jumpVideo === v && !this._jumpFinished && !playing()) this._finishJump();
});
// CAP — expected to end by duration + margin; if the element goes silent
// before it, advance anyway.
const durS = Number(el && el.duration);
if (Number.isFinite(durS) && durS > 0) {
this.time.delayedCall(durS * 1000 + 5000, () => {
if (this._jumpVideo === v && !this._jumpFinished) this._finishJump();
});
}
}
/** Cover-scale the jump clip to the screen, cropping whatever overflows. */
_fitJumpClip(v, iw = 0, ih = 0) {
const el = v.video;
const vw =
iw || (el && (el.videoWidth || el.width)) || (v.frame && v.frame.realWidth) || 864;
const vh =
ih || (el && (el.videoHeight || el.height)) || (v.frame && v.frame.realHeight) || 480;
const W = this.scale.width;
const H = this.scale.height;
const s = Math.max(W / vw, H / vh);
v.setPosition(W / 2, H / 2);
v.setScale(s);
}
hasVideo(key) {
const c = this.cache?.video;
return !!(c && typeof c.has === 'function' && c.has(key));
}
/**
* The jump's cut idempotent. Tears down the clip (free the decoder, drop
* the listeners) and restarts the scene onto the staged destination.
* scene.restart() destroys the scene's objects anyway; this is explicit and
* safe (the complete/error/stall/cap guards never double-restart).
*/
_finishJump() {
if (this._jumpFinished) return;
this._jumpFinished = true;
if (this._jumpVideo) {
try {
this._jumpVideo.off();
this._jumpVideo.stop(false);
this._jumpVideo.destroy();
} catch {
/* already gone */
}
this._jumpVideo = null;
}
if (this._jumpBackdrop) {
try {
this._jumpBackdrop.destroy();
} catch {
/* already gone */
}
this._jumpBackdrop = null;
}
this.scene.restart();
}
/**