orbit/js/scenes/SurfaceScene.js

2060 lines
92 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 { VignetteHub } from '../vignettes/VignetteHub.js';
import { resolveItems, worldContext } from '../ui/ExploreBar.js';
import { SavePanel } from '../ui/SavePanel.js';
import { SaveManager } from '../save/SaveManager.js';
import { BuildWindow } from '../ui/BuildWindow.js';
import { JobBoardWindow } from '../ui/JobBoardWindow.js';
import { QuestWindow } from '../ui/QuestWindow.js';
import { ConfirmOverlay } from '../ui/ConfirmOverlay.js';
import { planRoute } from '../galaxy/Route.js';
import { ShopView } from '../ui/shop/ShopView.js';
import { CreditsHud } from '../ui/CreditsHud.js';
import { MineralHud } from '../ui/MineralHud.js';
import * as Economy from '../economy/Economy.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; on the neutral
* capital the EXPLORE drawer's BAR slot does the same with the world's
* `bar` clip — capital-bar-neutral.mp4 — LEAVE BAR to swap back, and
* while the bar clip loops it is the ONLY live button on the surface)
* 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 — the only free-space kind
// that lands.)
// 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.exploreSubBar = null; // the EXPLORE drawer (the deck's full-width service bar)
this.vignetteHub = null; // the planet's dialogue scenes (data/vignettes.json)
this.vignetteLocation = null; // the location a scene is holding (bar | shop)
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.barKey = null;
this.barVideo = null; // the BAR clip (EXPLORE → Bar, neutral capital; created on first press, then paused/resumed)
this.barMode = false; // true while the bar clip is on screen (BAR/LEAVE BAR toggles it)
this.shopView = null; // the buy/sell UI over the clip (js/ui/shop/ShopView.js); null when out of the shop
this.shopHud = null; // the buy/sell screen's corner readout (upper right): this scene's CreditsHud + MineralHud pair
this.noteG = null; // console-note glyphs (consoleNote)
this.buildWindow = null; // the BUILD console (deck slot) — a view over gameScene.buildState
this.jobBoardWindow = null; // the JOB BOARD console (Explore drawer's Job Board slot)
this.jobBoardMode = false; // true while the job board console is up (the JOB BOARD press or the ✕ toggles it)
this._setDestDialog = null; // the shop's "SET DESTINATION" confirm (ConfirmOverlay, container-hosted)
this._setDestHost = null; // the centered container hosting that dialog (SavePanel pattern)
this._setDest = null; // the pending { exit, hops } behind that dialog
}
/** 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.barKey = `${this.clipKeyPrefix}bar_${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);
this.barUrl = this.resolveUrl(entry.bar ?? 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);
if (this.barUrl) this.load.video(this.barKey, this.barUrl);
// 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
// The SHOP button is always live — the shop's sub-bar opens on every
// world. The TRADE gate (GameScene.isTradeUnlocked — the story ledger's
// capitalLanded, 'Special Delivery' step 1) sits inside the shop on the
// Buy/Sell tab (ShopView reads it, so a save/load can't bypass it).
if (this.shopMode) {
this.leaveShop();
return;
}
if (this.vignetteHub?.busy) {
// A scene is pending or open (the bar's) — the shop's entry waits
// for its DONE (the scene plays before the player can touch
// buy/sell, and one location at a time).
this.consoleNote('ONE THING AT A TIME');
this.playSfx('ui_close');
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.setDeckLocked(false); // the deck was locked to the shop — release it
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
this.setDeckLocked(true); // the shop is up — the deck's other slots go standby
// The SHOP's scene, if one is due (data/vignettes.json, location
// 'shop'): it plays BEFORE the player can touch buy/sell — the trade
// view defers to the scene's completion (_vignetteComplete raises it
// then). No scene due → the trade view comes up now (the current
// behavior).
if (this._tryStartVignette('shop')) return;
this.buildShopView(); // the buy/sell UI over the clip (js/ui/shop/ShopView.js)
}
/**
* 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() {
if (this.vignetteHub?.active) return; // the scene holds the shop — DONE is the only way out
this.shopMode = false;
this.destroyShopView(); // tear down the buy/sell UI before restoring the surface
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
this.setDeckLocked(false); // the shop is down — the deck's other slots come back
// The scene, if armed-but-not-yet-open (the player left before the
// opening beat), is cancelled — it re-arms on the next visit to the
// shop (the played ledger marks on completion only, so nothing is
// lost).
this.vignetteHub?.cancelPending();
}
/**
* DECK LOCK — while the shop clip is up, the deck's slots go standby
* (dim, inert: no hover, no press) EXCEPT the SHOP slot — LEAVE SHOP
* is the only live button on the deck, the same one-live-control
* language as the 'ack' comm's hold (ActionBar.setEnabled). Re-enables
* everything on unlock; the shop slot is untouched in both directions
* (it's the toggle that owns the lock). Idempotent — safe on re-entry.
*/
setDeckLocked(locked) {
const bar = this.actionBar;
if (!bar || !Array.isArray(bar.slots)) return;
for (const s of bar.slots) {
if (!s.live || s.id === 'shop') continue; // SHOP is the way out
bar.setSlotEnabled(s.id, !locked);
}
}
/**
* 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);
}
// ------------------------------------------------------------------
// Stage 2c — the bar clip (EXPLORE → Bar swaps the loop, and back)
// ------------------------------------------------------------------
/**
* BAR — the neutral capital's service point (the EXPLORE drawer's BAR
* slot, data/actionbar.json → surface.explore.items): swap the looping
* surface clip for this world's bar clip (data/landing.json →
* videos[frame].bar — capital-bar-neutral.mp4 on the neutral capital);
* the next press (LEAVE BAR) swaps back. The same shape as the shop
* toggle: the surface clip is PAUSED, not destroyed (it resumes exactly
* where it left off), the bar clip is created on the first press (the
* file was preloaded) and then just paused/resumed, and the drawer's BAR
* slot relabels to `labelActive` ("Leave Bar") and back. The difference
* is the LOCK: the bar's way-out lives on the SECONDARY bar, so while
* the clip is up EVERY button on the primary deck and every OTHER
* drawer item goes standby — LEAVE BAR is the only live control on the
* surface (setBarLocked). The drawer itself stays up — it is the home
* of the way-out — and outside-clicks / ESC can't fold it down (see
* onPointerDown / escAction). A world whose frame has no bar clip
* (data/landing.json → bar null) gets a console note instead.
*/
toggleBar() {
if (this.phase !== 'surface') return; // no deck, no drawer, no bar
if (this.barMode) {
this.leaveBar();
return;
}
if (this.vignetteHub?.busy) {
// A scene is pending or open (the shop's) — the bar's entry waits
// for its DONE (one location at a time).
this.consoleNote('ONE THING AT A TIME');
this.playSfx('ui_close');
return;
}
if (!this.hasVideo(this.barKey)) {
this.consoleNote('NO BAR ON THIS WORLD');
return;
}
if (!this.barVideo) {
// 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.barKey).setOrigin(0.5).setDepth(10);
this.attachClip(s);
s.setLoop(true);
this.barVideo = s;
s.on('error', () => {
// Broken clip — drop back to the surface loop, don't strand the view.
this.barMode = false;
this.destroyVideo(this.barVideo);
this.barVideo = null;
this.surfaceVideo?.resume(); // v4 quirk: play() no-ops a clip that has played before
this.exploreSubBar?.setLabel('bar', this.barLabel(false)); // the slot is back to BAR
this.setBarLocked(false); // the deck + drawer were locked to the bar — release
this.consoleNote('BAR CLIP UNAVAILABLE');
});
}
this.barVideo.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.barVideo.resume();
this.barMode = true;
this.exploreSubBar?.setLabel('bar', this.barLabel(true)); // the slot now reads LEAVE BAR
this.setBarLocked(true); // the bar is up — every OTHER button goes standby
// The BAR's scene, if one is due (data/vignettes.json, location
// 'bar'): the bar's clip reads first, the window opens over it after
// the opening beat. While the scene holds, the deck + drawer stand
// down (the window's NEXT/DONE is the only live control); when it
// completes, the bar's own lock returns (LEAVE BAR live again) — the
// scene's ledger mark being the step's witness.
this._tryStartVignette('bar');
}
/**
* The second BAR 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 bar clip —
* painted over the surface loop — must be hidden, not just paused.
*/
leaveBar() {
if (this.vignetteHub?.active) return; // the scene holds the bar — DONE is the only way out
this.barMode = false;
this.barVideo?.pause();
this.barVideo?.setVisible(false); // its frozen last frame would cover the surface
this.surfaceVideo?.resume(); // resumes where it paused (no surface clip → the plate)
this.exploreSubBar?.setLabel('bar', this.barLabel(false)); // the slot is back to BAR
this.setBarLocked(false); // the bar is down — the deck + drawer come back
// The scene, if armed-but-not-yet-open (the player left before the
// opening beat), is cancelled — it re-arms on the next visit to the
// bar (the played ledger marks on completion only, so nothing is
// lost).
this.vignetteHub?.cancelPending();
}
/**
* BAR LOCK — while the bar clip is up, EVERY button on the primary deck
* (all six live slots — the way-out is not on the deck) and every item
* on the secondary drawer EXCEPT BAR go standby (dim, inert: no hover,
* no press — the deck's per-slot language, ActionBar.setSlotEnabled;
* the drawer's, MenuSubBar.setDisabled). LEAVE BAR is the only live
* control on the surface — the same one-live-control language as the
* shop's lock (the one difference: over there the way-out IS the shop
* slot, over here it is the drawer's bar slot). The shop slot re-arms
* with the rest (its TRADE gate — GameScene.isTradeUnlocked — lives on
* the shop's Buy/Sell tab, not on this slot). The bar slot itself is
* untouched in both directions (it's the toggle that owns the lock). Idempotent — safe on re-entry.
*/
setBarLocked(locked) {
this.setServiceLocked(locked, 'bar');
}
/**
* SURFACE LOCK — while a service point owns the surface (the bar clip,
* the job board console), EVERY button on the primary deck (all six
* live slots — the way-out is not on the deck) and every item on the
* secondary drawer EXCEPT the service's slot go standby (dim, inert —
* no hover, no press — the deck's per-slot language,
* ActionBar.setSlotEnabled; the drawer's, MenuSubBar.setDisabled).
* `escapeId` names the way-out slot (the bar's 'bar', the board's
* 'jobBoard') — it stays live. The shop slot re-arms with the rest (its
* TRADE gate — GameScene.isTradeUnlocked — lives on the shop's Buy/Sell
* tab, not on this slot). Idempotent — safe on re-entry.
*/
setServiceLocked(locked, escapeId) {
const deck = this.actionBar;
if (deck && Array.isArray(deck.slots)) {
for (const s of deck.slots) {
if (!s.live) continue;
deck.setSlotEnabled(s.id, !locked);
}
}
const drawer = this.exploreSubBar;
if (drawer && Array.isArray(drawer.buttons)) {
for (const b of drawer.buttons) {
if (b.id === escapeId) continue; // the way-out slot stays live
drawer.setDisabled(b.id, locked);
}
}
}
/**
* The BAR slot's label for the given state (data/actionbar.json →
* surface.explore.items: `label` out, `labelActive` in), with plain
* fallbacks if the config entry is missing.
*/
barLabel(inBar) {
const items = config.get('actionbar.surface.explore.items');
const it = Array.isArray(items) ? items.find((x) => x && x.id === 'bar') : null;
const active = it?.labelActive ?? 'Leave Bar';
const base = it?.label ?? 'Bar';
return inBar ? String(active) : String(base);
}
// ------------------------------------------------------------------
// Stage 2c — the job board (EXPLORE → Job Board opens the console, and back)
// ------------------------------------------------------------------
/**
* JOB BOARD — the drawer's Job Board slot (on every world EXCEPT the
* player's home system — safe space): open the board's console (the
* build screen's structure — js/ui/JobBoardWindow.js, nearly
* full-screen, over the deck + drawer + the upper-left HUD); the next
* press — or the console's ✕ — closes it. While it's up the deck +
* drawer stand down (the slot's own press stays live as the second
* door — setServiceLocked). The surface clip pauses under the console
* and resumes where it left off (v4 quirk — resume(), not play();
* see leaveJobBoard).
*/
toggleJobBoard() {
if (this.phase !== 'surface') return; // no deck, no drawer, no board
if (this.jobBoardMode) {
this.leaveJobBoard();
return;
}
if (this.vignetteHub?.busy) {
// A scene is pending or open (the shop's) — the board's entry waits
// for its DONE (one location at a time).
this.consoleNote('ONE THING AT A TIME');
this.playSfx('ui_close');
return;
}
if (this.jobBoardWindow?.isOpen) {
this.leaveJobBoard(); // out of sync (e.g. a close in flight) — land it
return;
}
this.jobBoardMode = true;
this.surfaceVideo?.pause(); // the console covers the loop (resume() brings it back)
this.jobBoardWindow?.open();
this.setServiceLocked(true, 'jobBoard'); // the board is up — every OTHER button stands down
}
/** The second press (JOB BOARD) — or the console's ✕ — back to the looping surface clip. */
leaveJobBoard() {
if (!this.jobBoardMode) return;
this.jobBoardMode = false;
this.jobBoardWindow?.close();
this.setServiceLocked(false, 'jobBoard'); // the board is down — the deck + drawer come back
// resume(), not play(): v4's play() is a no-op after pause() (its
// _playCalled flag stays true, so the native play() is never
// re-issued) — the surface clip would stay frozen after board → back.
this.surfaceVideo?.resume();
}
// ------------------------------------------------------------------
// Stage 2d — the planet's VIGNETTES (data/vignettes.json — the
// dialogue scenes that play on the surface: the window over the
// location's looping clip, NEXT/DONE, the quest-step witness)
// ------------------------------------------------------------------
/**
* Arm the vignette due for a location (the scene's hub — it loads the
* scene's assets on demand and opens the window after the opening
* beat). Returns true when a scene now holds the surface: the caller
* defers the location's own UI (the shop's buy/sell view) until the
* scene completes (_vignetteComplete).
*/
_tryStartVignette(location) {
const hub = this.vignetteHub;
if (!hub || !hub.enabled || hub.busy) return false;
this.vignetteLocation = location;
return hub.tryPlay(location);
}
/**
* The gate's facts (the hub asks; the model applies them): the scene's
* quest, read from the sleeping GameScene — granted, unclaimed, and
* the named step still open. A gate the scene can't read (no ledger,
* no such step) stays CLOSED — a miswired gate must not open a scene.
*/
_vignetteGate(vig) {
if (!vig.quest) return null;
// Resolve lazily (the create-time capture can predate the scene
// manager's registration on a slow boot — the click is what matters).
const g = this.gameScene ?? this.scene?.get?.('GameScene') ?? null;
if (!g?.questState || typeof g.questSnapshot !== 'function') return null;
const closed = { granted: false, claimed: false, stepExists: false, stepDone: false };
if (g.questState.isGranted(vig.quest) !== true) return closed;
const quest = (g.questSnapshot().quests ?? []).find((q) => q.id === vig.quest);
if (!quest) return closed;
const step = Array.isArray(quest.checks) ? quest.checks[vig.step ?? 0] : null;
return {
granted: true,
claimed: quest.claimed === true,
stepExists: step != null,
stepDone: step?.done === true,
};
}
/**
* The deck's lock while a scene holds (the hub's onLock): the window's
* NEXT/DONE is the only live control on the surface — every deck slot
* (including the location's way-out) and every drawer item go standby.
* On completion the location's own lock returns (LEAVE BAR / LEAVE
* SHOP live again) — the scene played ON TOP of the location, not
* instead of it. The way-out comes back FIRST on the restore: the
* `on` path disabled it along with everything else, and the location
* restore below intentionally SKIPS it (it owns the location's lock —
* setBarLocked/setDeckLocked never touch their own way-out) — so
* without this line LEAVE BAR / LEAVE SHOP would stay dead after DONE.
*/
_vignetteLock(on) {
const deck = this.actionBar;
const drawer = this.exploreSubBar;
if (on) {
this.menuSubBar?.close?.(); // one drawer at a time — the window's up now
if (deck && Array.isArray(deck.slots)) {
for (const s of deck.slots) {
if (s.live) deck.setSlotEnabled(s.id, false);
}
}
if (drawer && Array.isArray(drawer.buttons)) {
for (const b of drawer.buttons) drawer.setDisabled(b.id, true);
}
} else {
// The way-out first (see the doc above) — LEAVE BAR over the bar,
// LEAVE SHOP over the shop — so it is live when the location's own
// lock returns.
if (this.barMode && drawer && Array.isArray(drawer.buttons)) {
if (drawer.buttons.some((b) => b.id === 'bar')) drawer.setDisabled('bar', false);
} else if (this.shopMode && deck && Array.isArray(deck.slots)) {
const shop = deck.slots.find((s) => s.id === 'shop');
if (shop && shop.live) deck.setSlotEnabled('shop', true);
}
if (this.barMode) this.setBarLocked(true);
else if (this.shopMode) this.setDeckLocked(true);
else {
this.setBarLocked(false);
this.setDeckLocked(false);
if (drawer && Array.isArray(drawer.buttons)) {
for (const b of drawer.buttons) drawer.setDisabled(b.id, false);
}
}
}
}
/**
* The scene completed (the hub's onComplete — the played ledger is
* already marked, which is the step's 'vignettePlayed' witness): the
* location resumes. The scene's STORY BEAT runs first (GameScene — the
* ledger's owner): the hold is built out (the scene's effects — the
* run's ship starts with cargoHold 0, data/ship.json), THEN the
* scene's quest's package joins the hold (it must fit — and its
* presence is the 'cargoUpgraded' step's witness, so both steps stand
* done at the DONE press), and — the 'Special Delivery' bar scene,
* while the quest is held — the HOME WORLD becomes the destination
* (the delivery's route: the bar conversation is the trigger). The
* surface calls the beats out in its console slot (the flight HUD
* sits behind the opaque plate for the whole stay — the game scene's
* own toasts would sit frozen on it; the scout's SET DESTINATION note
* is the same recipe). The bar is the loop again; the shop raises its
* buy/sell view now — it deferred from entry while the scene held
* (the scene plays before the player can touch buy/sell).
*/
_vignetteComplete(vig) {
const g = this.gameScene ?? this.scene?.get?.('GameScene') ?? null;
const beat = typeof g?.onVignetteComplete === 'function' ? g.onVignetteComplete(vig) : null;
if (beat?.upgradedTo != null) {
this.consoleNote(`CARGO HOLD UPGRADED — ${beat.upgradedTo} BAYS`, {
glyph: '\u25b2',
glyphColor: toCss(themeColor('amber', 0xffc94d)),
durationMs: 3600,
});
}
if (beat?.handoff === true) {
this.consoleNote('SPECIAL PACKAGE SECURED IN THE CARGO HOLD', {
glyph: '\u2726',
glyphColor: toCss(themeColor('amber', 0xffc94d)),
durationMs: 3600,
});
}
if (beat?.routed === true) {
// The delivery route (onVignetteComplete's beat 3 — the bar
// conversation just set the destination to the home world): the
// surface calls it out here (the game scene's own toast sits on
// the sleeping scene), the map's SET DESTINATION toast in shape.
// Staggered behind the package note (the slot is one-at-a-time —
// a new note replaces the old): the beats read in order, each for
// a full hold, and a take-off before the delay simply drops the
// echo (the destination is set either way — the compass shows it).
const homeId = g?.galaxy?.homeSystemId;
const plan = homeId ? planRoute(g.galaxy, g.systemRecord?.id, homeId) : null;
const dest = String(g?.homeWorldName ?? 'HOME WORLD').toUpperCase();
const label = `DESTINATION SET — ${dest}` + (plan?.hops != null ? ` \u00b7 ${plan.hops} HOP(S)` : '');
const note = () => this.consoleNote(label, {
glyph: '\u25c6',
glyphColor: toCss(config.get('gates.route.compassColor', '#ff8c1a')),
durationMs: 3600,
});
if (beat?.handoff === true) this.time.delayedCall(3200, note); // behind the package note
else note(); // no package beat to read first — land it now
}
if (this.shopMode) this.buildShopView();
this.vignetteLocation = null;
}
/**
* The BUY/SELL UI over the shop clip (js/ui/shop/ShopView.js). Built once
* per shop visit (a fresh session each time you land — the session-local
* Buyback state resets with it), and destroyed by leaveShop(). It reads the
* paused GameScene for the ledger (ship.credits / ship.cargo) + the economy
* (galaxy, this system) and the shop's economic class (this world's nature).
* The screen also carries the main game's corner readout (upper right —
* buildShopHud), so the player sees the same credits/minerals lines they
* have in flight while trading.
*/
buildShopView() {
if (this.shopView) return this.shopView; // already up this visit
const g = this.gameScene;
if (!g?.ship || !g?.galaxy) return null; // no state beneath (shouldn't happen)
const systemId = g.systemRecord?.id ?? g.galaxy.currentSystemId ?? null;
this.shopView = new ShopView(this, {
ship: g.ship,
galaxy: g.galaxy,
cls: this._shopClass(),
systemId,
// The delivery gate (the 'Special Delivery' package is saleable ONLY
// on the home world, at zero credits — the sale is the quest's step
// 3): the shop's home check runs against THIS world's name.
worldName: this.planetName,
gameScene: g,
onLeave: () => this.leaveShop(),
anchor: this.shopAnchor(),
topY: this.shopTopY(),
});
this.buildShopHud(); // the upper-right ledger (credits + minerals), this scene
return this.shopView;
}
/**
* The buy/sell screen's CORNER READOUT — the main game's own pair
* (js/ui/CreditsHud.js + js/ui/MineralHud.js) built in THIS scene, so it
* renders over the shop clip: CREDITS on top, MINERALS + the hold bar
* stacked below (upper right, the same recipe as GameScene's corner —
* same data/*.json config, same count-up). It reads the very ship the
* shop trades against and dies with the shop view (leaveShop/shutdown).
*/
buildShopHud() {
if (this.shopHud) return this.shopHud;
const ship = this.gameScene?.ship;
if (!ship) return null;
const creditsHud = new CreditsHud(this);
creditsHud.set(ship.credits);
const mineralHud = new MineralHud(this, { topOffset: creditsHud.height + creditsHud.gap });
mineralHud.set(ship.minerals, ship.stats?.mineralStorage);
this.shopHud = { creditsHud, mineralHud };
return this.shopHud;
}
/**
* Mirror the ship's ledger into the corner readout (after a trade —
* ShopView.refreshHud — or any other change while the shop is up).
* No-op when the shop (and its HUD) is not on screen.
*/
refreshShopHud() {
const hud = this.shopHud;
const ship = this.gameScene?.ship;
if (!hud || !ship) return;
hud.creditsHud?.set?.(ship.credits);
hud.mineralHud?.set?.(ship.minerals, ship.stats?.mineralStorage);
}
/**
* The trade panel's top edge — just under the surface HUD's planet text
* (the name + the type/tether sub-line), with a little padding. The HUD is
* upper-left, so the panel hangs below it (near the top of the screen).
* Falls back to a small top margin if the HUD isn't up (dev).
*/
shopTopY() {
const hud = config.section('landing.hud', {});
const pad = hud.shopPanelGap ?? 18; // a little padding under the planet text
const line = this.hudLine;
if (line && Number.isFinite(line.y) && Number.isFinite(line.height)) {
return line.y + line.height + pad;
}
const name = this.hudName;
if (name && Number.isFinite(name.y) && Number.isFinite(name.height)) {
return name.y + name.height + pad;
}
return (this.scale.height ?? 720) * 0.16;
}
/**
* The shop tab sub-bar's anchor — the deck's "Shop" button, the same
* recipe as menuAnchor(): the bar rests ABOVE the deck (bottom edge on its
* top rail), right-aligned to the Shop button. Falls back to bottom-center
* if the deck is missing (dev).
*/
shopAnchor() {
const bar = this.actionBar;
const slot = bar?.slots?.find((s) => s.id === 'shop');
// The bar spans the FULL DECK — left edge on the deck's left, right
// edge on its right (the main action bar's own rect), resting on its
// top rail. Falls back to a bottom-center span if the deck is missing.
const left = bar?.rect ? bar.rect.x : this.scale.width * 0.06;
const right = bar?.rect ? bar.rect.x + bar.rect.w : this.scale.width * 0.94;
if (slot) {
return { x: slot.slot.x, y: bar.rect.y, w: bar.style.bw, left, right };
}
return { x: this.scale.width / 2, y: this.scale.height - 58, w: 120, left, right };
}
destroyShopView() {
if (!this.shopView) return;
this.shopView.destroy?.();
this.shopView = null;
this.destroyShopHud(); // the corner readout belongs to the shop visit
}
/** Tear the corner readout down (leaving the shop / scene shutdown). */
destroyShopHud() {
if (!this.shopHud) return;
this.shopHud.creditsHud?.destroy?.();
this.shopHud.mineralHud?.destroy?.();
this.shopHud = null;
}
/**
* A BEST-EXIT row on the trade scout was tapped (ScoutPanel → onExitTap
* → ShopView): the small "SET DESTINATION" popup — the same confirm the
* map's SET DESTINATION uses (js/ui/ConfirmOverlay.js), centered over the
* shop screen. Confirming records the destination on the sleeping
* GameScene (its stable seam — the exit's system + its WORLD's object:
* the planet of the exit's class, the station, or the home world —
* null → the system-level fallback; the route's final stop is that
* object) and toasts it here on the surface (the game scene's own toast
* would sit frozen on a sleeping scene).
*/
askSetDestination(exit) {
if (!exit || typeof exit.systemId !== 'string') return;
const gs = this.gameScene;
if (!gs || typeof gs.setDestination !== 'function') {
this.consoleNote('NO NAV LINK — SET IT FROM THE MAP', { glyph: '✕', glyphColor: toCss(themeColor('neon2', 0xff9b9b)) });
return;
}
// The local system is always in the exit list (charted, 0 hops) —
// pointing the ship at itself just bounces back, so say so instead.
if (gs.systemRecord?.id === exit.systemId) {
this.consoleNote('ALREADY AT THAT SYSTEM', { glyph: '✕', glyphColor: toCss(themeColor('neon2', 0xff9b9b)) });
return;
}
const name = String(exit.systemName ?? exit.systemId).toUpperCase();
const obj = exit.objectName ? String(exit.objectName).toUpperCase() : null;
const cls = exit.clsLabel ? `${exit.clsLabel} · ` : '';
const sign = exit.profit >= 0 ? '+' : '−';
const plan = planRoute(gs.galaxy, gs.systemRecord?.id, exit.systemId);
this._setDest = { exit, hops: plan?.hops ?? null };
this._ensureSetDestDialog().show({
title: 'SET DESTINATION',
body: [
`DESTINATION — ${obj ? `${obj} · ${name}` : name}`,
`${cls}SELLS ${exit.price} CR/UNIT · ${sign}${Math.abs(exit.profit)}/UNIT`,
],
accent: toCss(themeColor('neon', 0x00e5ff)),
confirmLabel: 'SET DESTINATION',
onConfirm: () => {
const d = this._setDest;
this._setDest = null;
if (!d) return;
// A CARGO delivery — the scout's run (kind 'cargo': the compass
// mark reads BROWN, unless it is the held story quest's delivery —
// GameScene.setDestination's kind guard).
gs.setDestination(d.exit.systemId, d.exit.objectId ?? null, 'cargo');
const dest = String(d.exit.objectName ?? d.exit.systemName ?? d.exit.systemId).toUpperCase();
const label = `DESTINATION SET — ${dest}` + (d.hops == null ? '' : ` · ${d.hops} HOP(S)`);
// The note's glyph follows the mark's color (the scene resolves it
// — the story's delivery reads the route's orange, a free run brown).
const glyphColor = typeof gs._routeMarkColor === 'function'
? toCss(gs._routeMarkColor())
: toCss(config.get('gates.route.compassColor', '#ff8c1a'));
this.consoleNote(label, { glyph: '◆', glyphColor });
},
time: this.time.now,
});
}
/** The shared confirm dialog (built once, sized like the map's). */
_ensureSetDestDialog() {
if (this._setDestDialog) return this._setDestDialog;
const ds = config.get('shopui.scout.setDestination', {});
const w = Number(ds?.width) > 0 ? Number(ds.width) : 520;
const h = Number(ds?.height) > 0 ? Number(ds.height) : 176;
// Host it the way SavePanel/MapWindow host their confirm dialogs:
// `new ConfirmOverlay(scene, 0, 0, …)` inside a centered container
// (`new Container` + `add.existing` — not `scene.add.container`, which
// this v4 build doesn't put on the display list).
// Note: in the headless-Brave probe the plate Graphics doesn't paint in
// the SurfaceScene context (the same happens to SavePanel's own confirm
// there); the dialog renders correctly in a real browser — see
// dev/map-dest-shot.* (the identical component over the map, painted
// fully in headless).
this._setDestHost = new Phaser.GameObjects.Container(
this, this.scale.width / 2, this.scale.height / 2,
);
this.add.existing(this._setDestHost);
// Above EVERYTHING in the surface scene — the shop's panels live in
// ShopView's root container at depth 100 (js/ui/shop/ShopView.js),
// above the deck (50), console note (60), SavePanel (70), MapWindow
// (80). 110 puts the dialog + its scrim over all of it.
this._setDestHost.setDepth(110);
this._setDestDialog = new ConfirmOverlay(
this,
0, 0,
w, h,
{ areaW: this.scale.width, areaH: this.scale.height },
);
this._setDestHost.add(this._setDestDialog);
return this._setDestDialog;
}
/**
* This world's economic class (data/economy.json classes) for the shop:
* match the surface world to a planet (js/entities/Planet.js, carries
* `.class`) or a station settlement, and ask Economy.economicClass. A
* capital world resolves to 'capital' (the universal buyer); a deep-space
* station to 'station'. Falls back to the system's first recorded class.
*/
_shopClass() {
const g = this.gameScene;
const name = this.planetName;
const match = (list) => (Array.isArray(list) ? list : []).find(
(o) => o && (o.discoveryName === name || o.name === name),
);
const p = match(g?.systemPlanets) ?? (g?.planet?.discoveryName === name ? g.planet : null);
if (p && p.class) return Economy.economicClass(p);
const st = match(g?.systemStations);
if (st) return 'station'; // free-space stations are the 'station' class
const sysId = g?.systemRecord?.id;
const raw = g?.galaxy?.economy?.classBySystem?.get?.(sysId);
const classes = raw ? [...raw] : [];
return (classes.length ? classes[0] : 'rocky');
}
/**
* 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;
this.playSfx('construct'); // the planet info starts typing in (data/sfx.json → construct)
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 SHOP slot is always live — the shop's sub-bar opens on every
// world. The TRADE gate (GameScene.isTradeUnlocked — the story
// ledger's capitalLanded, 'Special Delivery' step 1) sits on the
// shop's Buy/Sell tab (ShopView reads it from the GameScene's ledger,
// not this scene, so a save/load can't bypass it).
// 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 in the shared registry (the
// run's GameScene is the stateScene — same registry). Leave the
// surface and restart the game scene so its fresh create() picks the
// staged restore up — a confirmed LOAD drops the run back where it
// was saved, not out to the menu.
onLoadComplete: () => this.reloadRun(),
stateScene: this.gameScene ?? this,
});
// The EXPLORE drawer (the deck's EXPLORE slot — where SHIP used to
// sit): the full-width secondary bar (the shop tab bar's span — the
// deck's own left→right edges, resting on its top rail) the same
// MenuSubBar drawer carries in span mode, with this world's service
// buttons: data/actionbar.json → surface.explore.items, each item's
// optional `where` clause (world / planetClass / faction / isHomeSystem)
// resolved against THIS world by js/ui/ExploreBar.js — Job Board on
// every planet and station EXCEPT the player's home system (safe space),
// Bar on the neutral capital, and whatever the
// config adds per class / faction later. The buttons' behavior lands
// later (exploreAction is the seam — BAR is live, the rest land later). A
// world with no matching items
// gets no drawer (the slot is a no-op with a console note).
const deck = this.actionBar;
const exploreItems = resolveItems(
worldContext({ isStation: this.isStation, name: this.planetName }, this.gameScene),
);
if (deck && exploreItems.length > 0) {
const exploreSlot = deck.slots.find((s) => s.id === 'explore');
this.exploreSubBar = new MenuSubBar(this, {
anchor: exploreSlot
? { x: exploreSlot.slot.x, y: deck.rect.y, w: deck.style.bw }
: { x: this.scale.width / 2, y: this.scale.height - 58, w: 120 },
span: deck.rect ? { left: deck.rect.x, right: deck.rect.x + deck.rect.w } : null,
items: exploreItems,
onAction: (id) => this.exploreAction(id),
});
}
// The VIGNETTE hub (data/vignettes.json — the planet's dialogue
// scenes, the comms hub's surface twin): it arms the scene due for a
// location when the player enters one (toggleBar 'bar' / toggleShop
// 'shop'), loads the scene's assets on demand, opens the window over
// the location's looping clip after the opening beat, and hands the
// surface back on DONE (the scene's ledger mark is the step's
// witness). It dies with this scene — the played ledger lives in the
// shared registry, so the next visit remembers.
this.vignetteHub = new VignetteHub(this, {
gate: (vig) => this._vignetteGate(vig),
onLock: (on) => this._vignetteLock(on),
onComplete: (vig) => this._vignetteComplete(vig),
});
// 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,
credits: () => this.gameScene?.ship?.credits ?? 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 JOB BOARD console (the Explore drawer's Job Board slot — on
// every world EXCEPT the player's home system — safe space): the
// build screen's structure (js/ui/JobBoardWindow.js) on the surface
// clip's depth layer (10 — the bar clip's), so the deck (50) + drawer
// (60) stay live on top — the drawer's slot is the way-out (JOB BOARD
// ↔ LEAVE; toggleJobBoard owns the relabel + lock). The left feed is
// assets/videos/jobboard.mp4 (GameScene's preload, shared cache, muted
// loop). Right: the AVAILABLE JOBS list (the current system's live
// postings — the SLEEPING GameScene's seam, the QuestWindow
// precedent: state + effects there, the view here) + the JOB DETAILS
// contract readout, with the ACCEPT door onto GameScene.
// acceptSideQuest (the ledger + sfx) — the note is ours.
this.jobBoardWindow = new JobBoardWindow(this, {
planetName: this.planetName,
onClose: () => this.leaveJobBoard(),
postings: () => this.gameScene?.sideQuestPostings?.() ?? [],
isAccepted: (id) => this.gameScene?.sideQuestState?.isAccepted?.(id) ?? false,
isCompleted: (id) => this.gameScene?.sideQuestState?.isCompleted?.(id) ?? false,
isActive: (id) => this.gameScene?.sideQuestState?.isActive?.(id) ?? false,
isClaimed: (id) => this.gameScene?.sideQuestState?.isClaimed?.(id) ?? false,
routeProgress: (id) => this.gameScene?.patrolProgress?.(id) ?? null,
onAccept: (id) => this.acceptSurfaceJob(id),
onActivate: (id) => this.activateSurfaceJob(id),
onClaim: (id) => this.claimSurfaceJob(id),
});
// 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),
onSetPriority: (id) => this.setPrioritySurfaceQuest(id),
getPriority: () => this.gameScene?.questState?.priority ?? null,
// The FRESH open (the deck's QUESTS button) lands on the run's
// PRIORITY quest — the one the tracker HUD features (the same seam
// as the flight deck's window, over the sleeping GameScene's ledger).
resolvePriority: (ids) => this.gameScene?.questState?.resolvePriority(ids) ?? null,
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); EXPLORE folds up the full-width service drawer
* (data/actionbar.json → surface.explore.items); 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 === 'explore') {
this.toggleExplore();
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;
}
this.exploreSubBar?.close?.(); // one drawer at a time (the menu's is opening)
// Load Game is live only when the bank holds at least one save.
this.menuSubBar.setDisabled('load', !this.saveManager.hasAny());
this.menuSubBar.open();
}
/**
* The EXPLORE button: fold the full-width service drawer up / fold it
* back down (the shop tab bar's span, the same MenuSubBar drawer in
* span mode). One drawer at a time — the menu's folds down if it is up.
* While the shop clip is up the slot is deck-locked anyway (see
* setDeckLocked), so the two never fight.
*/
toggleExplore() {
const bar = this.exploreSubBar;
if (!bar) {
this.consoleNote('NOTHING TO EXPLORE HERE');
return;
}
if (bar.isOpen) {
bar.close();
return;
}
if (bar.closing) return; // let the fold finish — the press can re-open
this.menuSubBar?.close?.(); // one drawer at a time
bar.open();
}
/**
* The EXPLORE drawer's button presses (MenuSubBar → onAction). The
* items are the world's service points (data/actionbar.json →
* surface.explore.items — Job Board everywhere except the home system,
* Bar on the neutral capital). BAR swaps the surface loop for the world's bar clip and
* back (toggleBar — the drawer relabels to LEAVE BAR, and every other
* button on the deck + drawer goes standby until pressed). While the
* bar clip is up a press on any OTHER item is refused (belt-and-braces
* — the lock already stands the items down); unhandled items are still
* acknowledged on the console (the deck's unhandled-action voice) while
* their behavior lands.
*/
exploreAction(id) {
if (this.barMode && id !== 'bar') {
this.consoleNote('LEAVE THE BAR FIRST');
this.playSfx('ui_close');
return;
}
if (id === 'bar') {
this.toggleBar();
return;
}
if (this.jobBoardMode && id !== 'jobBoard') {
// Belt-and-braces — the board's lock already stands the other drawer
// items down (their clicks never reach here).
this.consoleNote('LEAVE THE JOB BOARD FIRST');
this.playSfx('ui_close');
return;
}
if (id === 'jobBoard') {
this.toggleJobBoard();
return;
}
console.info(`[orbit] surface explore: ${id} — ${this.planetName}`);
}
/** 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).
* SIDE QUESTS (the board's accepted contracts — the sq- ids) ride the
* same door: the scene's claimQuest dispatches them to the side
* ledger's payout + idempotency, and the note stands on the side
* ledger's claim mark + the contract's payout (the route record's
* credits — fixed at the accept).
*/
claimSurfaceQuest(id) {
const g = this.gameScene;
if (!g?.claimQuest) return;
const isSide = typeof id === 'string' && id.startsWith('sq-');
const claimedOf = (gid) =>
isSide ? gid.sideQuestState?.isClaimed?.(id) ?? false : gid.questState?.isClaimed?.(id) ?? false;
const before = claimedOf(g);
g.claimQuest(id); // the scene's seam (the side dispatch for sq- ids)
const now = claimedOf(g);
if (!before && now) {
const t = isSide ? config.get('sidequests.ui.claimToast', {}) : config.get('quests.claimToast', {});
if (isSide) {
// The SIDE contract's payout — the route's credits + its XP
// (the claim's second reward — the experience worth of the
// patrol's defeated contacts, fixed at the accept).
const route = g.sideQuestState?.routeFor?.(id);
const xp = route && Number.isFinite(route.xp) ? Math.max(0, route.xp) : 0;
const tpl = xp > 0 ? String(t.textXp ?? t.text ?? 'REWARD CLAIMED — +{reward} CR · +{xp} XP') : String(t.text ?? 'REWARD CLAIMED');
this.consoleNote(
tpl.replace('{reward}', `+${route?.credits ?? 0} CR`).replace('{xp}', String(xp)),
{ glyph: t.glyph ?? '✓', glyphColor: t.color ? toCss(t.color) : undefined },
);
return;
}
const label = g.questSnapshot?.()?.quests?.find((q) => q.id === id)?.reward ?? '';
let text = String(t.text ?? 'REWARD CLAIMED');
if (label) text = text.replace('{reward}', String(label).toUpperCase());
this.consoleNote(text, {
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 = isSide ? config.get('sidequests.ui.claimDenyToast', {}) : config.get('quests.claimDenyToast', {});
this.consoleNote(String(t.text ?? 'NOTHING TO CLAIM'), {
glyph: t.glyph ?? '✗', glyphColor: t.color ? toCss(t.color) : undefined,
});
}
}
/**
* The JOB BOARD's ACCEPT (the console's button → here — the
* claimSurfaceQuest precedent, over the sleeping GameScene's ledger):
* the scene takes the contract onto its SideQuestState (the idempotent
* accept + the sfx) and hands back the posting; the NOTE is ours (we
* are the scene the player reads — the game scene's console toast
* would land behind the sleeping deck), and the console repaints its
* row (the contract's ACCEPTED state) off the scene's fresh ledger.
*/
acceptSurfaceJob(jobId) {
const g = this.gameScene;
if (!g?.acceptSideQuest) return;
const res = g.acceptSideQuest(jobId);
const t = config.get('sidequests.ui.acceptToast', {});
if (res?.ok) {
this.consoleNote(
String(t.text ?? 'CONTRACT ACCEPTED — {title}').replace('{title}', String(res.posting.title).toUpperCase()),
{ glyph: t.glyph ?? '◆', glyphColor: t.color ? toCss(t.color) : undefined },
);
} else if (res?.locked) {
// The LEVEL GATE's refusal (a BOUNTY HUNT under categories.bounty.minLevel
// — data → ui.bountyAcceptDenyToast): the board greyed the row, so this is
// the line that says WHY, in the level's own number off the posting. The
// game scene's own toast lands behind this sleeping one.
const d = config.get('sidequests.ui.bountyAcceptDenyToast', {});
const level = Number.isFinite(Number(res?.posting?.minLevel)) ? Math.floor(Number(res.posting.minLevel)) : 2;
this.consoleNote(String(d.text ?? 'COMMISSION REFUSED — LEVEL {level} REQUIRED').replace(/\{level\}/g, String(level)), {
glyph: d.glyph ?? '✗',
glyphColor: d.color ? toCss(d.color) : undefined,
});
} else {
const d = config.get('sidequests.ui.acceptDenyToast', {});
this.consoleNote(String(d.text ?? 'COMMISSION DENIED'), {
glyph: d.glyph ?? '✗',
glyphColor: d.color ? toCss(d.color) : undefined,
});
}
this.jobBoardWindow?.refresh();
}
/**
* SET ACTIVE from the surface console — the ledger's ONE ACTIVE slot
* (GameScene.setActiveSideQuest — the scene's state; the note is
* ours, the same shape as acceptSurfaceJob). The active state IS the
* in-flight face: the sleeping GameScene's compass reads
* sideQuestState.activeId (the yellow next-stop arrow — the gate
* toward the route's system when it's another system, the nearest
* unvisited waypoint when it's this one) and paints the route's
* in-world beacons.
*/
activateSurfaceJob(jobId) {
const g = this.gameScene;
if (!g?.setActiveSideQuest) return;
if (!g.setActiveSideQuest(jobId)) return; // not held, done, or claimed
const t = config.get('sidequests.ui.activateToast', {});
// The note names the contract (the posting's title off the scene's
// live board — the acceptToast precedent).
const posting = (g.sideQuestPostings?.() ?? []).find((p) => p.id === jobId) ?? null;
const text = String(t.text ?? 'PATROL ACTIVE').replace('{title}', String(posting?.title ?? '').toUpperCase());
this.consoleNote(text, {
glyph: t.glyph ?? '▸',
glyphColor: t.color ? toCss(t.color) : undefined,
});
this.jobBoardWindow?.refresh();
}
/**
* CLAIM REWARD from the surface console (GameScene.claimSideQuest —
* the scene's state + the payout, the claimSurfaceQuest precedent):
* the contract's credits land exactly once (the ledger's claim is
* idempotent); the note is ours (we are the scene the player sees).
*/
claimSurfaceJob(jobId) {
const g = this.gameScene;
if (!g?.claimSideQuest) return;
const res = g.claimSideQuest(jobId);
if (res?.ok) {
const t = config.get('sidequests.ui.claimToast', {});
const xp = Number.isFinite(res?.xp) ? res.xp : 0; // the claim's second payout (the XP)
const tpl = xp > 0 ? String(t.textXp ?? t.text ?? 'REWARD CLAIMED — +{reward} CR · +{xp} XP') : String(t.text ?? 'REWARD CLAIMED — +{reward} CR');
this.consoleNote(
tpl.replace('{reward}', String(res.credits ?? 0)).replace('{xp}', String(xp)),
{ glyph: t.glyph ?? '✓', glyphColor: t.color ? toCss(t.color) : undefined },
);
} else {
const d = config.get('sidequests.ui.claimDenyToast', {});
this.consoleNote(String(d.text ?? 'NOTHING TO CLAIM'), {
glyph: d.glyph ?? '✗',
glyphColor: d.color ? toCss(d.color) : undefined,
});
}
this.jobBoardWindow?.refresh();
}
/**
* SET PRIORITY from the surface console — the ledger lives on the
* sleeping GameScene (the claimSurfaceQuest precedent), and the
* console note is ours (we are the scene the player sees).
* SIDE QUESTS ride the same door: the choice is the side ledger's
* ACTIVE slot (the contract in flight) featured through the tracker's
* seam (the story ledger's priority — the scene owns both ledgers).
*/
setPrioritySurfaceQuest(id) {
const g = this.gameScene;
if (!g?.questState) return;
const isSide = typeof id === 'string' && id.startsWith('sq-');
let ok = false;
if (isSide) {
ok = g.sideQuestState?.setActive(id) ?? false; // not held, done, or claimed
if (ok) {
g.questState.priorityCleared = false; // the explicit set re-arms (a clear sticks until then)
g.questState.resolvePriority([id]); // the featured slot (stores the side id)
}
} else {
ok = g.questState.setPriority(id); // not held, claimed, or already the priority
}
if (!ok) return;
let title = id;
try {
title = g.questSnapshot?.()?.quests?.find((q) => q.id === id)?.title ?? id;
} catch {
/* keep the id */
}
const t = config.get('quests.tracker.priorityToast', {});
this.consoleNote(
String(t.text ?? 'PRIORITY SET — {title}').replace('{title}', String(title).toUpperCase()),
{ glyph: t.glyph ?? '◆', glyphColor: t.color ? toCss(t.color) : undefined },
);
this.questWindow?.refresh();
}
/** ESC: build console → quests console → set-destination → confirm dialog → save pop-up → explore drawer → sub-bar. */
escAction() {
// A scene holding the surface owns ESC too — NEXT/DONE is the only
// way out (the scene plays before the player can touch buy/sell).
if (this.vignetteHub?.active) return;
if (this.barMode) return; // the drawer's LEAVE BAR is the only way out of the bar
if (this.buildWindow?.isOpen) {
this.buildWindow.close();
return;
}
if (this.questWindow?.isOpen) {
this.questWindow.close();
return;
}
if (this._setDestDialog?.isOpen) {
this._setDestDialog.cancel();
return;
}
if (this.savePanel) {
if (this.savePanel.confirm.isOpen) {
this.savePanel.confirm.cancel();
return;
}
if (this.savePanel.isOpen) {
this.savePanel.close();
return;
}
}
if (this.exploreSubBar && this.exploreSubBar.isOpen) this.exploreSubBar.close();
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();
this.exploreSubBar?.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;
if (this.barVideo) {
this.destroyVideo(this.barVideo); // same — the take-off clip owns the screen
this.barVideo = null;
}
this.barMode = 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;
// The 'Special Delivery' departure seam (GameScene.onSurfaceDeparture):
// a launch OFF the neutral capital records the delivery's trail
// (launchedFromCapital — the story ledger) and, once the package's
// home-world sale stands, arms the 'deliveredLaunch' payoff chain.
// It no longer sets the destination — the delivery's route is owned
// by the bar conversation itself (the quest's bar scene DONE press,
// GameScene.onVignetteComplete, while the quest is held), so a
// post-quest departure (a 'Pirate Problems' return leg) leaves the
// player's destination alone. Runs now, while the GameScene still
// owns the run: the wake back to the flight world is the beat the
// quest's step 3 waits on.
this.gameScene?.onSurfaceDeparture?.(this.planetName);
// The comms beat (data/comms.json: tetherL2Launch — oac-relaunch →
// oac-explore): the player is launching back into space with the
// level-2 tether installed on the HOME world (the run's build record
// is the witness — BuildState outlives this stay and saves with the
// run). Armed now, while the GameScene is still paused: the hub only
// queues (due off the scene clock, which resumes with the wake), and
// the FIFO plays the pair once the flight world is back — the comm's
// once-ledger makes any later take-off a no-op.
const home = this.gameScene?.homeWorldName;
if (home && this.gameScene.buildState?.isBuilt(home, 'tether-l2')) {
this.gameScene.comms?.trigger('tetherL2Launch');
}
this.scene.stop('SurfaceScene');
this.scene.wake('GameScene');
}
/**
* Confirmed surface load: the restore is staged in the shared registry
* (SavePanel.doLoad → prepareLoad, stateScene = the run's GameScene) and
* the panel has closed. Stop this scene and start the game scene (v4
* scene.start on a sleeping scene shuts it down and re-creates it), so
* the fresh GameScene.create() consumes the staged restore; the
* take-off story seam stays untouched (a load is not a departure).
*/
reloadRun() {
this.menuSubBar?.dismiss();
this.scene.start('GameScene'); // stop this scene + start the game scene
}
/** Return to the main menu (the sub-bar's last button). */
returnToMenu() {
this.savePanel?.close();
this.menuSubBar?.dismiss();
this.scene.start('MenuScene');
}
onPointerDown(pointer) {
// A scene holding the surface (the window open) owns the clicks —
// NEXT/DONE is the only live control; the surface's own click
// bookkeeping (the drawers' dismiss, the double-click skip) stands
// down (the window's button handles its own press).
if (this.vignetteHub?.active) return;
// 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.exploreSubBar?.isOpen) {
if (this.exploreSubBar.contains(pointer.x, pointer.y)) return;
if (this.barMode) return; // the bar holds the drawer up — LEAVE BAR is the way out
if (this.jobBoardMode) return; // the board holds the drawer up — the ✕ (or the JOB BOARD press) is the way out
this.exploreSubBar.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.exploreSubBar?.update(time, delta);
this.savePanel?.update(time);
this._setDestDialog?.update(time); // the SET DESTINATION confirm
this.vignetteHub?.update(time); // a scene's opening beat / decode / pulse
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.jobBoardWindow?.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.destroyVideo(this.barVideo);
this.landVideo = null;
this.surfaceVideo = null;
this.takeoffVideo = null;
this.shopVideo = null;
this.barVideo = null;
this.shopMode = false;
this.barMode = false;
if (Array.isArray(this.noteG)) {
for (const g of this.noteG) g.destroy();
this.noteG = null;
}
this.destroyHud();
this.destroyShopHud();
this._setDestDialog?.destroy?.();
this._setDestDialog = null;
this._setDestHost?.destroy?.();
this._setDestHost = null;
this._setDest = null;
this.buildWindow?.destroy();
this.buildWindow = null;
this.jobBoardWindow?.destroy();
this.jobBoardWindow = null;
this.jobBoardMode = false;
this.vignetteHub?.destroy(); // the scene + its pending beat die with the surface
this.vignetteHub = null;
this.vignetteLocation = null;
this.actionBar?.destroy();
this.menuSubBar?.destroy();
this.exploreSubBar?.destroy();
this.savePanel?.destroy();
this.actionBar = null;
this.menuSubBar = null;
this.exploreSubBar = null;
this.savePanel = null;
this.backdrop?.destroy();
this.backdrop = null;
}
}