orbit/js/scenes/SurfaceScene.js

840 lines
33 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import Phaser from '../vendor/phaser.js';
import { config } from '../config/Config.js';
import { toCss } from '../utils/Color.js';
import { fontStack, themeColor } from '../utils/Theme.js';
import { ScrambleDecode, decodeDur } from '../utils/Decode.js';
import { playSfxOn } from '../utils/Sfx.js';
import { playMusicOn, musicKey, stopMusicOn } from '../utils/Music.js';
import { ActionBar } from '../ui/ActionBar.js';
import { MenuSubBar } from '../ui/MenuSubBar.js';
import { SavePanel } from '../ui/SavePanel.js';
import { SaveManager } from '../save/SaveManager.js';
import { BuildWindow } from '../ui/BuildWindow.js';
import { buildDefs } from '../research/ResearchModel.js';
/**
* SURFACE — the planet's surface, started ON TOP of the sleeping
* GameScene (GameScene.startLanding → launch('SurfaceScene') + sleep):
*
* 1. LANDING — the full-screen one-shot clip (data/landing.json →
* videos[planets.png frame] → `land`), cover-scaled over an opaque
* plate. No UI during the flight down.
* 2. SURFACE — the clip is swapped for the looping world clip (`surface`),
* and the command deck appears: the GameScene deck with Research
* replaced by SHOP (swaps the loop for the world's `shop` clip —
* e.g. terran-shop-01.mp4 — press again to swap back) and TAKE OFF
* left of MENU (data/actionbar.json → surface.buttons). Menu keeps the GameScene's
* save sub-bar — Save/Load operate on the paused GameScene's state
* (SavePanel `stateScene`). While the clip loops, the planet's name
* decodes in at the upper-left (Ethnocentric, the menu title's shade,
* black-stroked) with its type + tether level beneath (Centauri) —
* see buildHud(); the HUD dies with the loop clip.
*
* 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
* giants, per data/planets.json → frames), so a planet always lands with
* the clip matching the art it is drawn with. Missing clips fall back to
* the existing stubs in data/landing.json; a broken/missing file skips
* straight to the surface instead of stranding the player. SHOP swaps the
* surface loop for the frame's `shop` clip (null on worlds without one —
* a console note instead).
*/
export class SurfaceScene extends Phaser.Scene {
constructor() {
super('SurfaceScene');
}
init(data = {}) {
this.planetFrame = Number(data.frame ?? 0); // planets.png frame (video key)
this.planetName = String(data.name ?? '');
this.planetType = String(data.type ?? ''); // e.g. "Rocky World" (planets.typeLabels)
this.tetherLevel = Math.max(0, Math.round(Number(data.tetherLevel ?? 0)));
this.phase = 'landing'; // 'landing' → 'surface'
this.musicSpec = 'music.frames.' + this.planetFrame; // the surface loop (data/music.json)
this.gameScene = null; // the paused GameScene beneath us (save state lives there)
this.backdrop = null;
this.landVideo = null;
this.surfaceVideo = null;
this.hudName = null; // upper-left: the planet's name (decodes in)
this.hudLine = null; // upper-left: type + tether level (decodes in)
this.hudLines = null; // the HUD's decode timeline (updateHud)
this.hudT0 = null;
this.actionBar = null;
this.menuSubBar = null;
this.savePanel = null;
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)
this.shopKey = null;
this.shopVideo = null; // the SHOP clip (created on first press, then paused/resumed)
this.shopMode = false; // true while the shop clip is on screen (SHOP toggles it)
this.noteG = null; // console-note glyphs (consoleNote)
this.buildWindow = null; // the BUILD console (deck slot) — a view over gameScene.buildState
}
/** Pick this world's clips (data/landing.json) and queue them. */
preload() {
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.shopKey = `__surf_shop_${this.planetFrame}`;
this.landUrl = this.resolveUrl(entry.land ?? null);
this.surfaceUrl = this.resolveUrl(entry.surface ?? null);
this.takeoffUrl = this.resolveUrl(entry.takeoff ?? null);
this.shopUrl = this.resolveUrl(entry.shop ?? 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);
if (this.shopUrl) this.load.video(this.shopKey, this.shopUrl);
// The surface's music loop (data/music.json → frames[planets.png frame])
// — the same key as the videos, so a world hums the track matching the
// art it is drawn with. A frame with no entry is silent.
if (config.get('music.enabled', true)) {
const track = config.get(this.musicSpec);
if (typeof track === 'string') {
this.load.audio(musicKey(this.musicSpec), track);
}
}
}
create() {
// The run's state (ship, tethers, discovery, …) stays parked in the
// paused GameScene — the save panel reads/writes it through there.
this.gameScene = this.scene.get('GameScene') ?? null;
const W = this.scale.width;
const H = this.scale.height;
// Launched over the flight world — the first thing painted must be an
// opaque full-screen plate, or GameScene bleeds through the seams.
this.backdrop = this.add.rectangle(W / 2, H / 2, W, H, 0x04060d).setScrollFactor(0).setDepth(0);
// The surface hum — on from the moment the landing sequence starts
// (landing clip, or straight to the deck when there is no clip) until
// the takeoff clip ends (finishTakeoff) or the scene leaves otherwise.
this.setSurfaceMusic(true);
// v4 (Giedi) quirk: scene transitions stop us via sys.shutdown and emit
// the 'shutdown' EVENT — they never call our shutdown() method — so the
// hum's stop is also hooked on the event (Return to Menu path).
this.events.once('shutdown', () => this.setSurfaceMusic(false));
if (this.hasVideo(this.landKey)) {
this.playLanding();
} else {
this.startSurface(); // no landing clip for this frame — straight to the deck
}
}
// ------------------------------------------------------------------
// Stage 1 — the one-shot landing clip
// ------------------------------------------------------------------
playLanding() {
// v4: add.video(x, y, key) — the key is the LAST argument (it loads the
// cached clip and attaches the <video> element).
const v = this.add.video(0, 0, this.landKey).setOrigin(0.5).setDepth(10);
this.attachClip(v);
this.landVideo = v;
// Fire it straight away. If the browser's autoplay policy locks it
// (no gesture yet), v4's Video retries internally every ~500ms until
// it's allowed — the click that sent the ship down counts, so this
// normally starts on the first attempt. 'complete'/'error' both land
// the player on the deck either way.
v.play();
v.on('complete', () => this.startSurface()); // played once → surface
v.on('error', () => this.startSurface()); // broken clip → don't strand the player
const el = v.video;
// The clip must play to its END before the surface stage (loop clip +
// deck), so no fixed "8s" valve — the current clips are ~15s and a
// constant would cut the landing in half. Two targeted guards instead:
// 1. STALLED — a clip that never starts moving (autoplay locked and
// never unlocked, decode failure, …) must not hold the deck: after
// a grace period with no playback progress, advance anyway.
// 2. CAP — 'ended' is expected by duration + margin; if the element
// goes silent before it, advance anyway.
const playing = () => !!el && (el.currentTime > 0.1 || !el.paused);
this.time.delayedCall(5000, () => {
if (this.landVideo === v && this.phase === 'landing' && !playing()) {
this.startSurface();
}
});
const durS = Number(el && el.duration);
if (Number.isFinite(durS) && durS > 0) {
this.time.delayedCall(durS * 1000 + 5000, () => {
if (this.landVideo === v && this.phase === 'landing') this.startSurface();
});
}
}
// ------------------------------------------------------------------
// Stage 2 — the looping surface clip + the deck
// ------------------------------------------------------------------
startSurface() {
if (this.phase === 'surface') return;
this.phase = 'surface';
if (this.landVideo) {
this.destroyVideo(this.landVideo);
this.landVideo = null;
}
if (this.hasVideo(this.surfaceKey)) {
const s = this.add.video(0, 0, this.surfaceKey).setOrigin(0.5).setDepth(10);
this.attachClip(s);
s.setLoop(true);
s.play();
this.surfaceVideo = s;
// A decode failure on the loop clip keeps the plate — the deck
// still works, the world just stays dark.
this.buildHud(); // the name + type/tether — only while the clip loops
}
this.buildDeck();
}
// ------------------------------------------------------------------
// Stage 2b — the shop clip (SHOP swaps the loop, and back)
// ------------------------------------------------------------------
/**
* SHOP — swap the looping surface clip for this world's shop clip
* (data/landing.json → videos[frame].shop, e.g. terran-shop-01.mp4);
* the next press swaps back. The surface clip is PAUSED, not
* destroyed, so it resumes exactly where it left off; the shop clip
* is created on the first press (the file was preloaded) and then
* just paused/resumed, and the deck's SHOP slot relabels to
* `labelActive` ("Leave Shop", data/actionbar.json → surface.buttons)
* and back. The upper-left HUD stays up in both states — the shop is
* on the same world — and the surface hum keeps humming.
* A world whose frame has no shop clip (data/landing.json → shop null)
* gets a console note instead.
*/
toggleShop() {
if (this.phase !== 'surface') return; // no deck, no shop
if (this.shopMode) {
this.leaveShop();
return;
}
if (!this.hasVideo(this.shopKey)) {
this.consoleNote('NO SHOP ON THIS WORLD');
return;
}
if (!this.shopVideo) {
// First press of this stay: bring the clip up (only the <video>
// element is new — the file was preloaded).
const s = this.add.video(0, 0, this.shopKey).setOrigin(0.5).setDepth(10);
this.attachClip(s);
s.setLoop(true);
this.shopVideo = s;
s.on('error', () => {
// Broken clip — drop back to the surface loop, don't strand the view.
this.shopMode = false;
this.destroyVideo(this.shopVideo);
this.shopVideo = null;
this.surfaceVideo?.resume(); // v4 quirk: play() no-ops a clip that has played before
this.actionBar?.setLabel('shop', this.shopLabel(false)); // the slot is back to SHOP
this.consoleNote('SHOP CLIP UNAVAILABLE');
});
}
this.shopVideo.setVisible(true); // may be hidden from a previous visit
this.surfaceVideo?.pause(); // the loop pauses where it is (resume() brings it back)
// resume(), not play(): the first start is a no-op-safe superset (it
// falls through to the first-play path), and on a RE-entry the clip
// has played before, where play() would silently no-op.
this.shopVideo.resume();
this.shopMode = true;
this.actionBar?.setLabel('shop', this.shopLabel(true)); // the slot now reads LEAVE SHOP
}
/**
* The second SHOP press — back to the looping surface clip.
* v4 quirk: for a clip that has already played once, `play()` is a
* no-op (its createPlayPromise gate only fires on the first start) —
* `resume()` (setPaused(false)) is what actually restarts it. And a
* paused <video> keeps showing its last frame, so the shop clip —
* painted over the surface loop — must be hidden, not just paused.
*/
leaveShop() {
this.shopMode = false;
this.shopVideo?.pause();
this.shopVideo?.setVisible(false); // its frozen last frame would cover the surface
this.surfaceVideo?.resume(); // resumes where it paused (no surface clip → the plate)
this.actionBar?.setLabel('shop', this.shopLabel(false)); // the slot is back to SHOP
}
/**
* The SHOP slot's label for the given state (data/actionbar.json →
* surface.buttons: `label` out, `labelActive` in), with plain fallbacks
* if the config entry is missing.
*/
shopLabel(inShop) {
const buttons = config.get('actionbar.surface.buttons');
const b = Array.isArray(buttons) ? buttons.find((x) => x && x.id === 'shop') : null;
const active = b?.labelActive ?? 'Leave Shop';
const base = b?.label ?? 'Shop';
return inShop ? String(active) : String(base);
}
/**
* The console note — this scene's small cousin of GameScene.consoleToast:
* an accent glyph + console-caps line, top-center, fading in, holding,
* fading out. One at a time (a new note replaces an old one). SHOP
* feedback lives here.
*/
consoleNote(label, { glyph = '\u25b8', glyphColor, durationMs = 2400 } = {}) {
if (Array.isArray(this.noteG)) {
for (const g of this.noteG) g.destroy();
this.noteG = null;
}
const fam = fontStack('body');
const g1 = this.add
.text(0, 0, glyph, {
fontFamily: fam,
fontSize: '13px',
color: glyphColor ?? toCss(themeColor('neon', 0x00e5ff)),
})
.setOrigin(0, 0.5)
.setScrollFactor(0); // UI — pinned to the screen
const g2 = this.add
.text(0, 0, String(label ?? '').toUpperCase(), {
fontFamily: fam,
fontSize: '11px',
color: toCss(themeColor('dim', 0x7d92c4)),
letterSpacing: 2,
})
.setOrigin(0, 0.5)
.setScrollFactor(0); // UI — pinned to the screen
const total = g1.width + 10 + g2.width;
const x0 = this.scale.width / 2 - total / 2;
const yy = this.scale.height / 2 - 75; // the flight world's console slot
g1.setPosition(x0, yy).setDepth(60).setAlpha(0);
g2.setPosition(x0 + g1.width + 10, yy).setDepth(60).setAlpha(0);
this.noteG = [g1, g2];
this.tweens.add({ targets: this.noteG, alpha: 1, duration: 180, ease: 'Sine.easeOut' });
this.time.delayedCall(durationMs, () => {
if (!Array.isArray(this.noteG)) return;
const [a, b] = this.noteG;
this.noteG = null;
this.tweens.add({
targets: [a, b],
alpha: 0,
duration: 350,
onComplete: () => {
a.destroy();
b.destroy();
},
});
});
}
// ------------------------------------------------------------------
// HUD (upper-left) — the planet's name + type + tether level
// ------------------------------------------------------------------
/**
* The upper-left HUD, built with the looping surface clip and alive
* exactly as long as that clip runs (destroyHud on take-off/shutdown):
*
* ESHKAELURA ← the planet's name — Ethnocentric
* Home World, Tether Level 1 ← the body face (Centauri)
*
* The name takes the SAME shade the menu's game title uses
* (data/menu.json → colors.title, falling back to the theme's ink) with
* a black stroke behind it, so it holds over any clip regardless of
* what the video shows beneath it. Both lines decode in with the
* shared scramble (js/utils/Decode.js) — the name first, the line
* beneath it after (the dossier's pacing, GameScene.createSystemHud).
*/
buildHud() {
const hud = config.section('landing.hud', {});
const X = hud.marginX ?? 24;
const Y = hud.marginY ?? 20;
const name = String(this.planetName ?? '').toUpperCase();
this.hudName = this.add
.text(X, Y, '', {
fontFamily: fontStack('header'), // Ethnocentric — the theme's header face
fontSize: `${hud.nameFontSize ?? 32}px`,
color: toCss(config.get('menu.colors.title') ?? themeColor('ink', 0xeaf6ff)),
stroke: toCss(hud.nameStroke ?? '#000000'),
strokeThickness: hud.nameStrokeWidth ?? 4,
letterSpacing: hud.nameLetterSpacing ?? 2,
})
.setOrigin(0, 0)
.setScrollFactor(0) // UI: pinned to the screen
.setDepth(60); // above the deck (50) / sub-bar — it never overlaps them
const parts = [];
if (this.planetType) parts.push(this.planetType);
parts.push(`Tether Level ${this.tetherLevel}`);
const line = parts.join(', ');
this.hudLine = this.add
.text(X, Y + this.hudName.height + (hud.lineGap ?? 10), '', {
fontFamily: fontStack('body'), // Centauri — the theme's body face
fontSize: `${hud.lineFontSize ?? 16}px`,
color: toCss(hud.lineColor ?? themeColor('dim', 0x7d92c4)),
stroke: toCss(hud.lineStroke ?? '#000000'),
strokeThickness: hud.lineStrokeWidth ?? 2,
letterSpacing: hud.lineLetterSpacing ?? 1,
})
.setOrigin(0, 0)
.setScrollFactor(0) // UI: pinned to the screen
.setDepth(60);
// The decode timeline — updateHud() drives it from the scene's
// update() (the engine loop time, same base as the dossier's).
this.hudLines = [
{ text: this.hudName, value: name, delay: 140, dur: decodeDur(name.length), settled: false },
{ text: this.hudLine, value: line, delay: 420, dur: decodeDur(line.length), settled: false },
];
this.hudT0 = null; // anchored on the first update frame (the clock is stale in create())
}
/** Per-frame: the HUD's decode (driven from update() with the loop time). */
updateHud(time) {
if (!this.hudLines) return;
if (this.hudT0 === null) {
this.hudT0 = time;
for (const ln of this.hudLines) ln.dec = new ScrambleDecode(ln.value, time + ln.delay, ln.dur);
}
let done = true;
for (const ln of this.hudLines) {
if (ln.settled) continue;
if (!ln.dec.started(time)) {
done = false;
continue;
}
ln.text.setText(ln.dec.display(time));
if (ln.dec.finished(time)) ln.settled = true;
else done = false;
}
if (done) this.hudLines = null; // the text holds — stop the per-frame churn
}
/** The HUD belongs to the surface stage — the take-off clip owns the screen. */
destroyHud() {
this.hudName?.destroy();
this.hudLine?.destroy();
this.hudName = null;
this.hudLine = null;
this.hudLines = null;
this.hudT0 = null;
}
buildDeck() {
// The surface deck: same bar, different slots — Shop for Research,
// Take Off left of Menu (data/actionbar.json → surface.buttons).
const deckButtons = config.get('actionbar.surface.buttons');
this.actionBar = new ActionBar(this, {
onAction: (id) => this.deckAction(id),
buttons: Array.isArray(deckButtons) ? deckButtons : undefined,
});
// The MENU button keeps the GameScene's save system. The run's state
// is in the paused GameScene beneath us — SavePanel captures from it
// (stateScene) and a confirmed load stages the restore there.
this.saveManager = new SaveManager(); // the localStorage bank
this.menuSubBar = new MenuSubBar(this, {
anchor: this.menuAnchor(),
onAction: (id) => this.subBarAction(id),
});
this.savePanel = new SavePanel(this, {
// Load confirmed: the restore is staged — hand back to the menu
// (its New Game is the "start" the staged restore rides on).
onLoadComplete: () => this.returnToMenu(),
stateScene: this.gameScene ?? this,
});
// The BUILD console (the deck's BUILD slot) — the passive view over
// gameScene.buildState (the run's build records + the single
// in-progress build): the same split as the research console (state
// + effects on the GameScene, the view here). This scene TICKS the
// state in update() — the GameScene sleeps while we own the loop, so
// this clock is the build's time base. A completion applies its
// effects on the game scene (the planet's tether range — world state
// that outlives this stay).
this.buildWindow = this.gameScene?.buildState
? new BuildWindow(this, {
state: this.gameScene.buildState,
planetName: this.planetName,
minerals: () => this.gameScene?.ship?.minerals ?? 0,
tetherLevel: () => this.gameScene?.tetherLevelFor(this.planetName) ?? 0,
researchUnlocked: (cat, node) =>
this.gameScene?.researchState?.isUnlocked(cat, node) ?? false,
onBuild: (buildId) => this.beginSurfaceBuild(buildId),
})
: null;
// ESC — topmost open thing first (mirror of GameScene.escAction).
this.input.keyboard?.on('keydown-ESC', () => this.escAction());
// Clicks: outside the sub-bar, close it. (No click-to-fly on the
// surface — the world is a backdrop.)
this.input.on('pointerdown', (pointer) => this.onPointerDown(pointer));
}
// ------------------------------------------------------------------
// Deck behavior
// ------------------------------------------------------------------
/**
* The deck's button presses (ActionBar → onAction). SHOP swaps the
* looping surface clip for the world's shop clip (and back); BUILD
* opens the build console (the world's buildable items —
* data/builds.json); SHIP is a seam for the surface economy; TAKE OFF
* goes back to the ship; MENU is the save door.
*/
deckAction(id) {
// While a build runs, the deck is locked (one at a time —
// data/builds.json → maxConcurrent). The BUILD slot stays open — it
// shows the in-progress build and its progress.
if (this.gameScene?.buildState?.getActive() && id !== 'build') {
this.consoleNote('BUILD IN PROGRESS — COMMAND DECK LOCKED');
this.playSfx('ui_close');
return;
}
if (id === 'build') {
this.buildWindow?.open();
return;
}
if (id === 'shop') {
this.toggleShop();
return;
}
if (id === 'menu') {
this.menuAction();
return;
}
if (id === 'takeoff') {
this.takeOff();
return;
}
console.info(`[orbit] surface deck: ${id}${this.planetName}`);
}
/** The MENU button: fold the sub-bar up / fold it back down. */
menuAction() {
if (config.get('save.subBar.enabled', true) !== true) return;
if (this.savePanel && this.savePanel.isOpen) {
this.savePanel.close();
return;
}
if (this.menuSubBar && this.menuSubBar.isOpen) {
this.menuSubBar.close();
return;
}
// Load Game is live only when the bank holds at least one save.
this.menuSubBar.setDisabled('load', !this.saveManager.hasAny());
this.menuSubBar.open();
}
/** The sub-bar's button presses (MenuSubBar → onAction). */
subBarAction(id) {
if (id === 'save') {
this.savePanel.show('save');
return;
}
if (id === 'load') {
if (this.saveManager.hasAny()) this.savePanel.show('load');
return;
}
if (id === 'mainMenu') {
this.returnToMenu();
return;
}
}
/**
* The BUILD button → the scene-side enforcement on the GameScene
* (rules, research + planet gates, the mineral cost —
* GameScene.beginBuild). The window only offers the button when the
* rules allow it; this is the belt-and-braces pass (the same shape as
* GameScene.beginResearch). A refusal is a console note.
*/
beginSurfaceBuild(buildId) {
const g = this.gameScene;
if (!g) return;
// The build's time base is the GAME LOOP clock (game.loop.now) — the
// global monotonic ms that advances while ANY scene is awake, so it
// stays right across the surface → space transition if the player
// takes off mid-build (a scene's own clock is per-scene and freezes
// while that scene sleeps — the loop clock is the build's
// authoritative base).
const res = g.beginBuild(this.planetName, buildId, this.game.loop.now);
if (!res.ok) {
this.consoleNote(res.reason ?? 'BUILD REFUSED');
this.playSfx('ui_close');
return;
}
// (GameScene.beginBuild plays the 'construct' power-up tick — the
// research voice — the same single-voice rule as beginResearch.)
this.buildWindow?.refresh(); // IN PROGRESS — the button + row repaint
}
/** The scene's SFX voice (ActionBar / SavePanel call `playSfx?.(name)`). */
playSfx(name, o = {}) {
playSfxOn(this, name, o);
}
/** ESC: build console → confirm dialog → save pop-up → sub-bar. */
escAction() {
if (this.buildWindow?.isOpen) {
this.buildWindow.close();
return;
}
if (this.savePanel) {
if (this.savePanel.confirm.isOpen) {
this.savePanel.confirm.cancel();
return;
}
if (this.savePanel.isOpen) {
this.savePanel.close();
return;
}
}
if (this.menuSubBar && this.menuSubBar.isOpen) this.menuSubBar.close();
}
/**
* 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 + HUD belong to the surface stage — the clip
// owns the screen (same "no UI mid-flight" rule as the landing stage).
this.actionBar?.setVisible(false);
this.destroyHud();
if (this.surfaceVideo) {
this.destroyVideo(this.surfaceVideo); // the opaque clip hides it — free the decoder
this.surfaceVideo = null;
}
if (this.shopVideo) {
this.destroyVideo(this.shopVideo); // same — the take-off clip owns the screen
this.shopVideo = null;
}
this.shopMode = false;
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();
});
}
}
/**
* The surface's music (data/music.json → frames[this.planetFrame]): a
* loop that runs from the start of the landing sequence to the end of
* the takeoff clip. One loop per world (the shared voice's isPlaying
* guard); no-ops when music is disabled or the frame has no track.
*/
setSurfaceMusic(on) {
if (on) playMusicOn(this, this.musicSpec);
else stopMusicOn(this, this.musicSpec);
}
/** Take-off clip done (or bailed out) — back to the flight world. */
finishTakeoff() {
if (this.takeoffDone) return;
this.takeoffDone = true;
this.setSurfaceMusic(false); // the takeoff clip ends — the hum ends with it
this.destroyVideo(this.takeoffVideo);
this.takeoffVideo = null;
this.scene.stop('SurfaceScene');
this.scene.wake('GameScene');
}
/** Return to the main menu (the sub-bar's last button). */
returnToMenu() {
this.savePanel?.close();
this.menuSubBar?.dismiss();
this.scene.start('MenuScene');
}
onPointerDown(pointer) {
if (this.buildWindow?.isOpen) return; // the console owns the click
if (this.savePanel?.isOpen) return; // the modal scrim owns the click
if (this.menuSubBar?.isOpen) {
if (this.menuSubBar.contains(pointer.x, pointer.y)) return;
this.menuSubBar.close();
return;
}
if (this.actionBar?.contains(pointer.x, pointer.y)) return;
// A click on the surface itself — the seam for surface interactions.
}
// ------------------------------------------------------------------
// Plumbing
// ------------------------------------------------------------------
/** The sub-bar's anchor: the MENU button's center + top edge. */
menuAnchor() {
const menuSlot = this.actionBar?.slots?.find((s) => s.id === 'menu');
if (menuSlot) {
return {
x: menuSlot.slot.x,
y: menuSlot.slot.y - this.actionBar.style.bh / 2,
w: this.actionBar.style.bw,
};
}
// Deck missing (dev): a virtual button at the bottom right.
return { x: this.scale.width - 96, y: this.scale.height - 58, w: 120 };
}
hasVideo(key) {
const c = this.cache?.video;
return !!(c && typeof c.has === 'function' && c.has(key));
}
/**
* Cover-fit a clip to the screen and keep it that way.
*
* v4 quirk: the video object's bookkeeping size (width/height, and even
* frame.realWidth) is a placeholder until the first presented frame
* lands — sizing against it (setDisplaySize divides by that width) ends
* up ~3.4× too big, because the frame swap preserves the SCALE, not the
* size. That's how the clips rendered heavily zoomed in. So:
* - fit now with the best known dimensions (a placeholder-free guess),
* - re-fit on 'created', which fires on the first presented frame and
* carries the clip's true (w, h) — after that, frame × scale is
* authoritative and stable for the clip's whole life.
*/
attachClip(v) {
v.setVolume(this.clipVolume());
this.fitCover(v);
v.on('created', (vv, w, h) => {
if (vv === v) this.fitCover(vv, w, h);
});
}
/** Cover-scale: the clip fills the screen, cropping whatever overflows. */
fitCover(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);
}
clipVolume() {
return Math.max(0, Math.min(1, Number(config.get('landing.volume', 1))));
}
resolveUrl(file) {
if (!file) return null;
if (/^(https?:)?\/\//.test(file)) return file; // absolute — use as-is
const base = String(config.get('landing.videoBase', 'assets/videos/'));
return (base.endsWith('/') ? base : `${base}/`) + file;
}
destroyVideo(v) {
if (!v) return;
try {
v.off();
v.stop(false);
v.destroy();
} catch {
/* already gone */
}
}
update(time, delta) {
this.time.update(time, delta);
this.tweens.update();
this.actionBar?.update(time, delta);
this.menuSubBar?.update(time, delta);
this.savePanel?.update(time);
this.updateHud(time); // the upper-left name/type decode (surface stage only)
// The build console: tick the single in-progress build (one at a
// time) on the game-loop clock (game.loop.now — global, monotonic,
// survives scene restarts; see beginSurfaceBuild). A completion
// applies its effects on the game scene (the planet's tether range —
// world state that outlives this stay).
if (this.gameScene?.buildState) {
const done = this.gameScene.buildState.tick(this.game.loop.now);
if (done.length) {
for (const c of done) this.gameScene.completeBuild(c.planet, c.build);
const def = buildDefs()[done[0].build];
this.consoleNote(`BUILD COMPLETE — ${String(def?.label ?? done[0].build).toUpperCase()}`);
this.playSfx('discovery'); // the 'something new is here' voice
}
}
this.buildWindow?.update(time);
}
shutdown() {
this.setSurfaceMusic(false); // the hum can't outlive the scene
this.destroyVideo(this.landVideo);
this.destroyVideo(this.surfaceVideo);
this.destroyVideo(this.takeoffVideo);
this.destroyVideo(this.shopVideo);
this.landVideo = null;
this.surfaceVideo = null;
this.takeoffVideo = null;
this.shopVideo = null;
this.shopMode = false;
if (Array.isArray(this.noteG)) {
for (const g of this.noteG) g.destroy();
this.noteG = null;
}
this.destroyHud();
this.buildWindow?.destroy();
this.buildWindow = null;
this.actionBar?.destroy();
this.menuSubBar?.destroy();
this.savePanel?.destroy();
this.actionBar = null;
this.menuSubBar = null;
this.savePanel = null;
this.backdrop?.destroy();
this.backdrop = null;
}
}