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:
parent
ff557f87ba
commit
507d00f818
Binary file not shown.
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Reference in New Issue