2060 lines
92 KiB
JavaScript
2060 lines
92 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 { 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;
|
||
}
|
||
}
|