1001 lines
41 KiB
JavaScript
1001 lines
41 KiB
JavaScript
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 { QuestWindow } from '../ui/QuestWindow.js';
|
||
import { buildDefs } from '../research/ResearchModel.js';
|
||
|
||
/**
|
||
* The double-click window (ms): two presses this close together during a
|
||
* one-shot flight clip (the landing or the takeoff) skip the rest of the
|
||
* clip (onPointerDown → skipFlightClip). On the deck itself there is no
|
||
* clip in flight, so the skip is a no-op there.
|
||
*/
|
||
const DOUBLE_CLICK_MS = 350;
|
||
|
||
/**
|
||
* 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. A double-click (two quick
|
||
* presses) skips the rest of the clip — the deck comes up now.
|
||
* 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. A double-click skips
|
||
* the rest of the clip — the flight world wakes now.
|
||
*
|
||
* Video selection is by the world's SHEET FRAME (the same index its
|
||
* sprite uses — a planet's planets.png class frame: frames 0..2 the
|
||
* terran worlds, 3..5 the gas giants, per data/planets.json → frames; a
|
||
* deep-space station's spacestations.png VARIANT frame: 0..2, per
|
||
* data/stations.json → variants), so a world always lands with the clip
|
||
* matching the art it is drawn with. Planet entries live in
|
||
* data/landing.json → videos; station variant entries in
|
||
* → stationVideos (same sheet-frame key; surface/shop are null until
|
||
* authored). 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); // the world's sheet frame (video key)
|
||
this.planetName = String(data.name ?? '');
|
||
this.planetType = String(data.type ?? ''); // e.g. "Rocky World" / "Smuggler Outpost"
|
||
this.tetherLevel = Math.max(0, Math.round(Number(data.tetherLevel ?? 0)));
|
||
// A deep-space STATION surface? (GameScene.startLanding hands over
|
||
// `station: true` for a deepSpaceStation — waypoints never land.)
|
||
// Same deck, same flow; the frame indexes a different clip set
|
||
// (data/landing.json → stationVideos) and a different music key
|
||
// (data/music.json → stationFrames). Surface/shop clips are authored
|
||
// later — null slots degrade exactly like an unset planet slot.
|
||
this.isStation = data.station === true;
|
||
this.phase = 'landing'; // 'landing' → 'surface'
|
||
this.lastClickT = 0; // last pointerdown (performance.now(), ms) — the double-click skip
|
||
this.musicSpec = (this.isStation ? 'music.stationFrames.' : 'music.frames.') + this.planetFrame; // the surface loop (data/music.json)
|
||
this.clipKeyPrefix = this.isStation ? '__surf_st_' : '__surf_'; // cache keys (planet/station frames overlap)
|
||
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() {
|
||
// The entry keyed by this world's sheet frame: a planet's class frame
|
||
// (→ landing.videos) or a deep-space station's variant frame (→
|
||
// landing.stationVideos) — { land, surface, takeoff, shop }.
|
||
const videos = this.isStation
|
||
? (config.get('landing.stationVideos') ?? [])
|
||
: (config.get('landing.videos') ?? []);
|
||
const entry = videos[this.planetFrame] ?? {};
|
||
this.landKey = `${this.clipKeyPrefix}land_${this.planetFrame}`;
|
||
this.surfaceKey = `${this.clipKeyPrefix}loop_${this.planetFrame}`;
|
||
this.takeoffKey = `${this.clipKeyPrefix}takeoff_${this.planetFrame}`;
|
||
this.shopKey = `${this.clipKeyPrefix}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]
|
||
// for a planet, → stationFrames[spacestations.png frame] for a station)
|
||
// — 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));
|
||
|
||
// Pointer input — bound once per visit from the LANDING stage on
|
||
// (the scene's input plugin drops its listeners on shutdown, v4, so
|
||
// this is fresh every launch; buildDeck no longer re-binds it):
|
||
// a double-click skips the in-flight clip (skipFlightClip), and on
|
||
// the deck the handler owns the sub-bar / outside-click behavior.
|
||
this.input.on('pointerdown', (pointer) => this.onPointerDown(pointer));
|
||
|
||
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 world's tether level changed under us (a tether build completed
|
||
* on this world): the HUD's second line was a snapshot from landing,
|
||
* so refresh it — "Tether Level N" re-decodes to the new level. A
|
||
* no-op when the level is unchanged or the HUD is not up (take-off
|
||
* stage, no surface clip).
|
||
*/
|
||
refreshHudTetherLevel(time) {
|
||
const level = Math.max(0, Math.round(Number(this.gameScene?.tetherLevelFor(this.planetName) ?? 0)));
|
||
if (level === this.tetherLevel) return;
|
||
this.tetherLevel = level;
|
||
if (!this.hudLine) return;
|
||
const parts = [];
|
||
if (this.planetType) parts.push(this.planetType);
|
||
parts.push(`Tether Level ${this.tetherLevel}`);
|
||
const line = parts.join(', ');
|
||
const idx = this.hudLines?.findIndex((h) => h.text === this.hudLine) ?? -1;
|
||
if (idx >= 0) {
|
||
// Still decoding: retarget the in-flight scramble at the new line.
|
||
const ln = this.hudLines[idx];
|
||
ln.value = line;
|
||
ln.dec = new ScrambleDecode(line, time, ln.dur);
|
||
ln.settled = false;
|
||
} else {
|
||
this.hudLine.setText(line); // settled — swap the held text in place
|
||
}
|
||
}
|
||
|
||
/** 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;
|
||
|
||
// The QUESTS console (the deck's QUESTS slot — right of SHIP): the
|
||
// SAME window as the flight deck's (js/ui/QuestWindow.js), opened
|
||
// over the SLEEPING GameScene's dossier — the same split as the build
|
||
// console (state + effects on the GameScene, the view here). The
|
||
// snapshot/claim delegates to the game scene; the console note is
|
||
// ours (we are the scene the player sees).
|
||
this.questWindow = new QuestWindow(this, {
|
||
getSnapshot: () => this.gameScene?.questSnapshot?.() ?? null,
|
||
onClaim: (id) => this.claimSurfaceQuest(id),
|
||
onLocked: (tabId) =>
|
||
this.consoleNote(
|
||
tabId === 'side'
|
||
? config.get('quests.sideStandbyNote', 'SIDE QUESTS OFFLINE — NO CONTRACTS ON FILE YET')
|
||
: 'NO SIGNAL',
|
||
{ glyph: '✕', glyphColor: toCss(themeColor('amber', 0xffc94d)) },
|
||
),
|
||
});
|
||
|
||
// ESC — topmost open thing first (mirror of GameScene.escAction).
|
||
// (The pointerdown binding lives in create() — it must also be
|
||
// live on the landing stage, before the deck exists.)
|
||
this.input.keyboard?.on('keydown-ESC', () => this.escAction());
|
||
}
|
||
|
||
// ------------------------------------------------------------------
|
||
// 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 === 'quests') {
|
||
if (this.questWindow?.isOpen) {
|
||
this.questWindow.close();
|
||
} else {
|
||
this.questWindow?.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);
|
||
}
|
||
|
||
/**
|
||
* The CLAIM REWARD action from the surface console — the effect lands
|
||
* on the SLEEPING GameScene (the quest ledger + the ship's hold are
|
||
* its; the beginBuild precedent), and the console note is ours (we are
|
||
* the scene the player sees — a sleeping scene's toasts don't animate).
|
||
*/
|
||
claimSurfaceQuest(id) {
|
||
const g = this.gameScene;
|
||
if (!g?.claimQuest) return;
|
||
const before = g.questState?.isClaimed(id) ?? false;
|
||
g.claimQuest(id);
|
||
const now = g.questState?.isClaimed(id) ?? false;
|
||
if (!before && now) {
|
||
const t = config.get('quests.claimToast', {});
|
||
this.consoleNote(String(t.text ?? 'REWARD CLAIMED'), {
|
||
glyph: t.glyph ?? '✓',
|
||
glyphColor: t.color ? toCss(t.color) : undefined,
|
||
});
|
||
} else if (before !== now) {
|
||
// Still open — the refusal, in the console's voice (the game
|
||
// scene's own toast is behind the sleeping scene — this is the one
|
||
// the player reads).
|
||
const t = config.get('quests.claimDenyToast', {});
|
||
this.consoleNote(
|
||
String(t.text ?? 'REQUIREMENTS INCOMPLETE').replace('{missing}', 'REQUIREMENTS'),
|
||
{ glyph: t.glyph ?? '✗', glyphColor: t.color ? toCss(t.color) : undefined },
|
||
);
|
||
}
|
||
}
|
||
|
||
/** ESC: build console → quests console → confirm dialog → save pop-up → sub-bar. */
|
||
escAction() {
|
||
if (this.buildWindow?.isOpen) {
|
||
this.buildWindow.close();
|
||
return;
|
||
}
|
||
if (this.questWindow?.isOpen) {
|
||
this.questWindow.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) {
|
||
// Double-click (two presses inside the window) — the rest of the
|
||
// in-flight one-shot clip is skipped: a landing in flight lands now,
|
||
// a takeoff in flight leaves now (a no-op on the deck, where no clip
|
||
// is in flight). Checked before the UI ownership rules — a flight
|
||
// clip owns the whole screen anyway (no deck up during one).
|
||
const now = performance.now();
|
||
if (now - this.lastClickT <= DOUBLE_CLICK_MS) this.skipFlightClip();
|
||
this.lastClickT = now;
|
||
|
||
if (this.buildWindow?.isOpen) return; // the console owns the click
|
||
if (this.questWindow?.isOpen) return; // the quests 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.
|
||
}
|
||
|
||
/**
|
||
* The double-click skip — the remainder of the in-flight one-shot
|
||
* clip, dropped: a landing in flight lands now (startSurface — the
|
||
* clip's complete/error guards see the phase change and stand down),
|
||
* a takeoff in flight leaves now (finishTakeoff — idempotent). The
|
||
* tick plays before the cut, so a skip always answers with a sound.
|
||
* A no-op on the surface (no clip in flight) — the window keeps
|
||
* ticking there, but the skip never fires.
|
||
*/
|
||
skipFlightClip() {
|
||
if (this.phase === 'landing' && this.landVideo) {
|
||
this.playSfx('ui_click');
|
||
this.startSurface();
|
||
return;
|
||
}
|
||
if (this.takeoffVideo) {
|
||
this.playSfx('ui_click');
|
||
this.finishTakeoff();
|
||
}
|
||
}
|
||
|
||
// ------------------------------------------------------------------
|
||
// 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,
|
||
// Deck top edge — the sub-bar rests on the bottom bar (the deck),
|
||
// not on the button (buttons are inset below the top rail).
|
||
y: this.actionBar.rect.y,
|
||
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.refreshHudTetherLevel(time); // the HUD's level line is a landing snapshot
|
||
this.buildWindow?.refresh(); // rows repaint — the world's tether level changed, so the next tier's gate may be open now
|
||
}
|
||
}
|
||
this.buildWindow?.update(time);
|
||
this.questWindow?.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;
|
||
}
|
||
}
|