orbit/js/scenes/GameScene.js

6491 lines
305 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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

import Phaser from '../vendor/phaser.js';
import { config } from '../config/Config.js';
import { toColor, toCss } from '../utils/Color.js';
import { fontStack, themeColor } from '../utils/Theme.js';
import { Rng } from '../utils/Rng.js';
import { Galaxy } from '../galaxy/Galaxy.js';
import { formatSystemReport } from '../galaxy/SystemReport.js';
import { Discovery } from '../galaxy/Discovery.js';
import { Reputation, HOME_KEY } from '../reputation/Reputation.js';
import { ScrambleDecode, decodeDur } from '../utils/Decode.js';
import { canonicalPlanetName } from '../utils/WorldNames.js';
import { playSfxOn, sfxPlayingOn, stopSfxOn } from '../utils/Sfx.js';
import { gameTrackKey, startMusicShuffleOn, stopMusicShuffleOn } from '../utils/Music.js';
import { Ship } from '../entities/Ship.js';
import { EnemyShip } from '../combat/EnemyShip.js';
import { enemySheetList } from '../combat/EnemyTypes.js';
import { Planet } from '../entities/Planet.js';
import { AsteroidCluster } from '../entities/AsteroidCluster.js';
import { Station } from '../entities/Station.js';
import { JumpGate } from '../entities/JumpGate.js';
import { jumpArrival, returnGateFor, ambushTriggerDistance } from '../galaxy/JumpTravel.js';
import { Starfield } from '../visuals/Starfield.js';
import { NebulaAtmosphere } from '../visuals/NebulaAtmosphere.js';
import { NebulaLightning } from '../visuals/NebulaLightning.js';
import { EmberField } from '../visuals/EmberField.js';
import { EngineFlare } from '../visuals/EngineFlare.js';
import { WandererStar } from '../visuals/WandererStar.js';
import { RedHaze } from '../visuals/RedHaze.js';
import { VoidBase } from '../visuals/VoidBase.js';
import { StormLayer } from '../visuals/StormLayer.js';
import { SystemEffects } from '../visuals/SystemEffects.js';
import { assignUi, assignWorld } from '../visuals/UiCameras.js';
import { pickFxSystem } from '../galaxy/FxSystems.js';
import { parseTypeSpec, pickPreviewSystem } from '../galaxy/TypeSystems.js';
import { DiscoveryCompass, circleInView } from '../ui/DiscoveryCompass.js';
import { ActionBar } from '../ui/ActionBar.js';
import { CombatBar } from '../ui/CombatBar.js';
import { CombatSystem } from '../combat/CombatSystem.js';
import { CombatHud } from '../combat/CombatHud.js';
import { MineralHud } from '../ui/MineralHud.js';
import { CreditsHud } from '../ui/CreditsHud.js';
import { GOODS_TEXTURE_KEY } from '../economy/Economy.js';
import * as Stock from '../economy/Stock.js';
import { MenuSubBar } from '../ui/MenuSubBar.js';
import { SavePanel } from '../ui/SavePanel.js';
import { SaveManager } from '../save/SaveManager.js';
import { consumeRestore, captureState, prepareLoad } from '../save/SaveData.js';
import { CommsHub } from '../comms/CommsHub.js';
import { commsForTrigger } from '../comms/CommsModel.js';
import { TetherField } from '../tether/TetherField.js';
import { Mining } from '../mining/Mining.js';
import { MiningPopup } from '../ui/MiningPopup.js';
import { ScanPulse } from '../scan/ScanPulse.js';
import { SignalCompass, signalAlpha } from '../ui/SignalCompass.js';
import { CommsPanel } from '../ui/CommsPanel.js';
import { ResearchWindow } from '../ui/ResearchWindow.js';
import { MapWindow } from '../ui/MapWindow.js';
import { QuestWindow } from '../ui/QuestWindow.js';
import { QuestTrackerHud } from '../ui/QuestTrackerHud.js';
import { QuestState, questCheckKey, questDef, questDefs, starterQuestIds } from '../quests/QuestState.js';
import { tutorialTarget } from '../quests/TutorialGuide.js';
import { navDiscoveryStats, resourceStats, systemChartSnapshot } from '../galaxy/SystemChart.js';
import { buildGalaxySnapshot, edgeKey } from '../galaxy/GalaxyChart.js';
import { planRoute } from '../galaxy/Route.js';
import { ResearchState } from '../research/ResearchState.js';
import { categories, loadCategory, isAvailable, hasAvailable, buildDefs, attachQuestGates, attachBuildGates } from '../research/ResearchModel.js';
import {
SYSTEM_CATEGORY,
MAP_NODE,
GATES_NODE,
systemNodeId,
systemIdOfGatesNode,
buildSystemTree,
isNavComplete,
navChart,
activationKeys,
applyActivation,
} from '../research/SystemCategory.js';
import { BuildWindow } from '../ui/BuildWindow.js';
import { JobBoardWindow } from '../ui/JobBoardWindow.js';
import { BuildState } from '../build/BuildState.js';
import { startingPairs, defById, isShipScoped } from '../build/BuildModel.js';
const FONT_FALLBACK = "'Segoe UI', 'Helvetica Neue', Arial, sans-serif";
const HEADER_FONT = () => fontStack('header', FONT_FALLBACK);
const BODY_FONT = () => fontStack('body', FONT_FALLBACK);
// The jump clip (data/gates.json → jump.video) — the full-screen one-shot
// played between the two systems on a gate jump. Its cache key + the
// double-click skip window (ms; two presses this close skip the rest of the
// clip — the same window as SurfaceScene's landing/takeoff skip).
const JUMP_VIDEO_KEY = 'jump_warp';
const JUMP_DOUBLE_CLICK_MS = 350;
/**
* The dossier's decode pacing (the shared decode effect, per line):
* a beat of arrival, then each line starts a little after the last.
*/
const HUD_LEAD_IN = 250; // ms before the dossier starts typing in
const HUD_STAGGER = 140; // ms between the start of each line
const HUD_EXPAND_LEAD = 120; // ms before re-opened details start streaming in
/** The open-by-default dossier folds itself 10 s after arrival (unless the player toggles it first). */
const HUD_AUTO_COLLAPSE_MS = 10000;
/**
* The game world (v0.3: the current system — the central body at the
* origin (the player's home world in the starting system, the system's
* star in every other one) plus the system's other worlds scattered
* around it, in open space). Click anywhere to fly there — holding
* SHIFT on the click THROTTLE-LOCKS the ship: it commits to that heading
* and keeps flying the way until you steer it somewhere else, or it runs
* into something (a world, a rock, the tether rim) and stops there.
*
* Discovery: come within discovery distance (data/game.json) of a world's
* edge and it is DISCOVERED (state in this.discovery). Discovered worlds
* that are off-screen get a themed compass arrow on the screen edge
* (this.compass) pointing the way back — and clicking its name tag
* autopilots the ship there (GameScene.autopilotTo: it targets the
* keep-out rim on the side the ship is approaching from).
*
* The system dossier (top-left) opens by default on arrival and folds
* itself after 10 s; clicking the system NAME (or the caret beside it)
* toggles the detail lines open/closed — see createSystemHud().
*/
export class GameScene extends Phaser.Scene {
constructor() {
super({ key: 'GameScene' });
}
preload() {
// The planet spritesheet: frameWidth×frameHeight frames; the first
// frames are Terran worlds (see data/planets.json → frames).
this.load.spritesheet(
Planet.TEXTURE_KEY,
config.get('planets.texture', 'assets/images/planets.png'),
{
frameWidth: config.get('planets.frameWidth', 1024),
frameHeight: config.get('planets.frameHeight', 1024),
},
);
// The player's ship spritesheet (data/ship.json → texture). If it
// isn't configured or the file is missing, Ship falls back to its
// built-in procedural dart (a console note says so in create()).
const shipTexture = config.get('ship.texture', '');
if (shipTexture) {
this.load.spritesheet(
Ship.TEXTURE_KEY,
shipTexture,
{
frameWidth: config.get('ship.frameWidth', 256),
frameHeight: config.get('ship.frameHeight', 256),
},
);
}
// The ENEMY spritesheets (data/combat.json → enemy.texture + the
// enemyTypes' own textures — EnemyTypes.enemySheetList is the seam,
// one key per source file, EnemyShip.textureKey). Loaded here too —
// the art is available in the main game for the WAVE RUNS
// (js/combat/Waves.js → CombatSystem.startWaves); the sandbox is
// where the numbers get tuned.
for (const { src, frameWidth, frameHeight } of enemySheetList()) {
this.load.spritesheet(EnemyShip.textureKey(src), src, { frameWidth, frameHeight });
}
// The asteroid spritesheet (data/asteroids.json → texture): 128×128
// rock frames, one per cluster member (the generator picks frames).
const asteroidTexture = config.get('asteroids.texture', '');
if (asteroidTexture && config.get('asteroids.enabled', true) !== false) {
this.load.spritesheet(
AsteroidCluster.TEXTURE_KEY,
asteroidTexture,
{
frameWidth: config.get('asteroids.frameWidth', 128),
frameHeight: config.get('asteroids.frameHeight', 128),
},
);
}
// The space-station spritesheet (data/stations.json → texture): the
// deep-space station variants (variants 0..2 today — the first three
// 256×256 frames). Station falls back to its procedural hardware if
// the sheet is missing.
const stationTexture = config.get('stations.texture', '');
if (stationTexture && config.get('stations.enabled', true) !== false) {
this.load.spritesheet(
Station.TEXTURE_KEY,
stationTexture,
{
frameWidth: config.get('stations.frameWidth', 256),
frameHeight: config.get('stations.frameHeight', 256),
},
);
}
// The jump gate spritesheet (data/gates.json → texture): frame 0 =
// the gate body, frame 1 = the active swirl. If it isn't configured
// or the file is missing, JumpGate falls back to its built-in
// procedural gate (a console note says so in build()).
const gateTexture = config.get('gates.texture', '');
if (gateTexture) {
this.load.spritesheet(
JumpGate.TEXTURE_KEY,
gateTexture,
{
frameWidth: config.get('gates.frameWidth', 256),
frameHeight: config.get('gates.frameHeight', 256),
},
);
}
// The goods spritesheet (data/goods.json → sheet): 128×128 icon frames
// (12 across, 120 cells) — one per trade good, in goods.json order
// (frame N = goods[N]). Loaded now so the icons are ready when the
// shop UI lands (trading is locked until the third story quest); an
// empty/blank file is harmless (120 transparent frames).
const goodsTexture = config.get('goods.sheet.texture', '');
if (goodsTexture) {
this.load.spritesheet(
GOODS_TEXTURE_KEY,
goodsTexture,
{
frameWidth: config.get('goods.sheet.frameWidth', 128),
frameHeight: config.get('goods.sheet.frameHeight', 128),
},
);
}
// The research console's archive feed (data/research.json → video):
// a muted 2:3 loop behind the RESEARCH window. A missing file just
// leaves the window's NO SIGNAL plate up — the console still works.
if (config.get('research.enabled', true)) {
const researchVideo = String(config.get('research.video.file') ?? '');
if (researchVideo) {
// The config stores the full relative path (like the sfx paths);
// a bare filename still works (→ assets/videos/<name>).
const url = /^(https?:)?\/\//.test(researchVideo) || researchVideo.startsWith('assets/')
? researchVideo
: `assets/videos/${researchVideo}`;
this.load.video(ResearchWindow.VIDEO_KEY, url);
}
}
// The map console's cartography feed (data/map.json → video): a muted
// 2:3 loop behind the MAP window. Same contract as the research feed —
// a missing file just leaves the window's NO SIGNAL plate up.
if (config.get('map.enabled', true)) {
const mapVideo = String(config.get('map.video.file') ?? '');
if (mapVideo) {
const url = /^(https?:)?\/\//.test(mapVideo) || mapVideo.startsWith('assets/')
? mapVideo
: `assets/videos/${mapVideo}`;
this.load.video(MapWindow.VIDEO_KEY, url);
}
}
// The quests console's mission feed (data/quests.json → video): a muted
// 2:3 loop behind the QUESTS window — the same dimensions as the
// research feed. Same contract — a missing file just leaves the
// window's NO SIGNAL plate up.
if (config.get('quests.enabled', true)) {
const questVideo = String(config.get('quests.video.file') ?? '');
if (questVideo) {
const url = /^(https?:)?\/\//.test(questVideo) || questVideo.startsWith('assets/')
? questVideo
: `assets/videos/${questVideo}`;
this.load.video(QuestWindow.VIDEO_KEY, url);
}
}
// The build console's feed (data/builds.json → video): a muted 2:3
// loop behind the BUILD window on a planet surface (SurfaceScene's
// BuildWindow reads the shared cache). A missing file just leaves the
// window's NO SIGNAL plate up — the console still works.
if (config.get('builds.enabled', true)) {
const buildVideo = String(config.get('builds.video.file') ?? '');
if (buildVideo) {
const url = /^(https?:)?\/\//.test(buildVideo) || buildVideo.startsWith('assets/')
? buildVideo
: `assets/videos/${buildVideo}`;
this.load.video(BuildWindow.VIDEO_KEY, url);
}
}
// The job board's feed (data/actionbar.json → surface.explore.video):
// a muted 2:3 loop behind the JOB BOARD console on a surface
// (SurfaceScene's JobBoardWindow reads the shared cache). A missing
// file just leaves the window's NO SIGNAL plate up — the board still
// works.
{
const jobVideo = String(config.get('actionbar.surface.explore.video.file') ?? '');
if (jobVideo) {
const url = /^(https?:)?\/\//.test(jobVideo) || jobVideo.startsWith('assets/')
? jobVideo
: `assets/videos/${jobVideo}`;
this.load.video(JobBoardWindow.VIDEO_KEY, url);
}
}
// The jump clip (data/gates.json → jump.video): a full-screen one-shot
// played between the two systems on a gate jump (jumpThroughGate →
// _playJumpClip). A missing/empty file just leaves the short-cut
// fallback — the jump still works.
{
const jumpVideo = String(config.get('gates.jump.video') ?? '');
if (jumpVideo) {
const url = /^(https?:)?\/\//.test(jumpVideo) || jumpVideo.startsWith('assets/')
? jumpVideo
: `assets/videos/${jumpVideo}`;
this.load.video(JUMP_VIDEO_KEY, url);
}
}
// Sound effects (data/sfx.json → enabled). Skipped entirely when the
// master switch is off — no load cost, no files fetched.
if (config.get('sfx.enabled', true)) {
this.load.audio('sfx_construct', config.get('sfx.construct', 'assets/fx/type-construct.mp3'));
this.load.audio('sfx_deconstruct', config.get('sfx.deconstruct', 'assets/fx/type-deconstruct.mp3'));
this.load.audio('sfx_discovery', config.get('sfx.discovery', 'assets/fx/discovery.mp3'));
this.load.audio('sfx_mining', config.get('sfx.mining', 'assets/fx/system-scan.mp3'));
this.load.audio('sfx_mining_loop', config.get('sfx.mining_loop', 'assets/fx/mining-01.mp3'));
this.load.audio('sfx_mining_split', config.get('sfx.mining_split', 'assets/fx/mining-02.mp3'));
this.load.audio('sfx_scan', config.get('sfx.scan', 'assets/fx/scan-01.mp3'));
this.load.audio('sfx_solar_buzz', config.get('sfx.solar_buzz', 'assets/fx/solar-buzz.mp3'));
this.load.audio('sfx_solar_flare_01', config.get('sfx.solar_flare_01', 'assets/fx/solar-flare-01.mp3'));
this.load.audio('sfx_solar_flare_02', config.get('sfx.solar_flare_02', 'assets/fx/solar-flare-02.mp3'));
this.load.audio('sfx_thunder_01', config.get('sfx.thunder_01', 'assets/fx/thunder-01.mp3'));
this.load.audio('sfx_thunder_02', config.get('sfx.thunder_02', 'assets/fx/thunder-02.mp3'));
this.load.audio('sfx_thunder_03', config.get('sfx.thunder_03', 'assets/fx/thunder-03.mp3'));
this.load.audio('sfx_thunder_04', config.get('sfx.thunder_04', 'assets/fx/thunder-04.mp3'));
this.load.audio('sfx_ui_hover', config.get('sfx.ui_hover', 'assets/fx/ui-hover.mp3'));
this.load.audio('sfx_ui_click', config.get('sfx.ui_click', 'assets/fx/ui-click.mp3'));
this.load.audio('sfx_ui_window', config.get('sfx.ui_window', 'assets/fx/ui-window.mp3'));
this.load.audio('sfx_ui_close', config.get('sfx.ui_close', 'assets/fx/ui-close.mp3'));
}
// The deep-space soundtrack (data/music.json → game) — the shuffled
// playlist. Each file queues under its derived key (music_deepspace_01…).
const gameTracks = config.get('music.game', []);
if (config.get('music.enabled', true) && Array.isArray(gameTracks)) {
for (const f of gameTracks) this.load.audio(gameTrackKey(f), f);
}
}
create() {
// A jump's cut (jumpThroughGate) sets this on the dying scene — the
// restart lands here, so clear it: input unlocks again.
this._jumping = false;
// The jump clip's in-flight state (the full-screen one-shot between the
// two systems — _playJumpClip). The clip is created on the dying scene
// and torn down in _finishJump before the restart, so these are fresh on
// every entry regardless.
this._jumpVideo = null;
this._jumpBackdrop = null;
this._jumpFinished = false;
this._jumpLastClickT = 0;
// Camera: smoothly follows the ship (updateCamera below). This motion
// is what drives the parallax starfield — ship flies, view trails.
this.cameraFollowShip = config.get('game.camera.followShip', true);
this.cameraFollowRate = config.get('game.camera.followRate', 3.0); // 1/s
// Session time (the save's playTimeMs). Accumulates in update();
// a LOAD replaces it with the saved value (applyRestore).
this.playTimeMs = 0;
// The deep-space soundtrack — the data/music.json (game) playlist,
// shuffled: one track at a time, next picked when one ends.
// LANDING does not transition us — it LAUNCHES SurfaceScene on top
// and SLEEPS this scene (v4: `launch` doesn't freeze the caller, and
// a sleeping scene never emits 'shutdown') — so the soundtrack and
// the mining hum stop on 'sleep' and come back on 'wake' (Take Off
// wakes the scene). 'shutdown' still covers return-to-menu and game
// destroy (the shutdown() method stays as the destroy safety net).
this.startGameMusic();
this.events.once('shutdown', () => {
this.stopGameMusic();
this.setMiningLoop(false); // the hum can't outlive the scene
this.systemEffects?.release(); // the effect can't outlive its scene
});
this.events.on('sleep', () => {
this.stopGameMusic(); // no space hum on a world's surface
this.setMiningLoop(false); // …nor the mining hum
this.engineFx?.sleep(); // no ghost exhaust waiting on the pad
});
this.events.on('wake', () => {
this.startGameMusic(); // back in space — the shuffle starts fresh
if (this.mining?.state === 'mining') this.setMiningLoop(true); // the beam is still live
});
// We are, after all, in a system. Establish the galaxy (shared
// registry), the current system record, and its lazy contents — the
// dossier below and the world's other planets both read them.
this.ensureGalaxy();
// THE ORIGIN WORLDS' DAILY BATCHES (model v2 — the economy's one
// stateful piece, the time-gated BUY side: each origin world's
// signature good sells a batch per day and regenerates on the day
// clock; js/economy/Stock.js). Registry-backed run state like
// depletedClusters (save/restore in js/save/SaveData.js); a fresh run
// starts full everywhere (the empty map reads as "full batch for
// today"), and spendStock() below swaps the reference in place.
this.economyStock = this.registry.get('economyStock') ?? null;
if (!this.economyStock) {
this.economyStock = {};
this.registry.set('economyStock', this.economyStock);
}
// THE EFFECTS DEMO (?fx=<type> — js/visuals/SystemEffects.js): drop
// the run into the richest system of that archetype (ranked in
// js/galaxy/FxSystems.js), fully charted, all gates live — a clean
// minute of flying to judge the star's character. It retargets the
// CURRENT SYSTEM only (the seed and the galaxy are untouched), so
// everything downstream — the record, the contents, the worlds,
// the gates, the tethers — builds for the demo system for free.
//
// THE WORLD PREVIEW (?type=<class>[-NN] / ss[-NN] — js/galaxy/
// TypeSystems.js): drop the run onto the richest system holding a
// world/station of the requested kind (a specific SHEET FACE when
// -NN names one — the face its landing clips + surface loop wear),
// and spawn the ship right beside it (below), so a landing puts the
// player on its surface for the videos/music. ?type= wins the system
// pick; when BOTH are given the world must live in a system of the
// ?fx= type (a preview under a chosen star's character), and a
// ?type= that finds nothing there falls back to the plain ?fx= demo.
this.fxSystemType = this._readFxSystemType();
this.typeSpec = this._readTypeSpec();
this.typeSpecRaw = this._typeSpecRaw;
this.typeTarget = null;
if (this.typeSpec) {
const pick = pickPreviewSystem(this.galaxy, this.typeSpec, this.fxSystemType);
if (pick) {
this.galaxy.currentSystemId = pick.record.id;
this.typeTarget = pick; // { record, ordinal|station, reskin }
if (pick.reskin) {
console.info(
`[orbit] ?type=${this.typeSpecRaw} — no world wears that exact face in this galaxy; ` +
`previewing it on ${pick.record.id}'s closest match (reskinned for the demo).`,
);
}
} else if (this.fxSystemType) {
console.warn(
`[orbit] ?type=${this.typeSpecRaw} — no world of that kind in a ${this.fxSystemType}-type system; falling back to the ?fx= demo.`,
);
const rec = pickFxSystem(this.galaxy, this.fxSystemType);
if (rec) this.galaxy.currentSystemId = rec.id;
else {
console.warn(`[orbit] ?fx=${this.fxSystemType} — no system of that type in this galaxy; starting as usual.`);
this.fxSystemType = null;
}
} else {
console.warn(`[orbit] ?type=${this.typeSpecRaw} — no world of that kind in this galaxy; starting as usual.`);
this.typeSpec = null;
}
} else if (this.fxSystemType) {
const rec = pickFxSystem(this.galaxy, this.fxSystemType);
if (rec) this.galaxy.currentSystemId = rec.id;
else {
console.warn(`[orbit] ?fx=${this.fxSystemType} — no system of that type in this galaxy; starting as usual.`);
this.fxSystemType = null;
}
}
// A LOAD from the save panel staged its live state (ship / tethers /
// playtime) in the registry (js/save/SaveData.js) — the ship and the
// tether field don't exist yet, so apply it once they do (below).
this._pendingRestore = consumeRestore(this.registry);
// The last JUMP's source system (jumpThroughGate stages it into the
// restore record; captureState carries it through a panel save too):
// the 'Pirate Problems' ambush measures its midpoint from the gate
// the player came in through — returnGateFor(content, lastJumpFrom)
// (GameScene._pirateAmbushEntryGate). Null on a fresh run or a save
// predating the field — the ambush's nearest-gate fallback stands
// in for it.
this.lastJumpFromSystem =
this._pendingRestore && typeof this._pendingRestore.lastJumpFrom === 'string'
? this._pendingRestore.lastJumpFrom
: null;
// A FRESH run (no staged restore) is what the 'newGame' comms fire
// for (the welcome plays on a new game, never on a load) — and a
// jump-cut restart (scene.restart, no resetRunState) is NOT one: its
// played ledger (the registry) already holds the welcome's id, so
// the trigger below re-arms nothing (the model's played filter).
this._freshRun = this._pendingRestore === null;
this.systemRecord = this.galaxy.currentSystem();
this.systemContent = this.galaxy.ensureContent(this.systemRecord.id);
// WORLD PREVIEW RESKIN (?type=<class>-NN whose face the galaxy wears
// nowhere — pick.reskin): paint the requested sheet frame on the
// target BEFORE its entities build (below) — the demo world wears
// the face, and its landing/surface clips + surface loop follow
// (data/landing.json / data/music.json index off the drawn frame).
if (this.typeTarget?.reskin && typeof this.typeSpec?.frame === 'number') {
const frame = this.typeSpec.frame;
if (this.typeTarget.station) {
const s = (this.systemContent.settlements ?? []).find((x) => x?.kind === 'deepSpaceStation');
if (s) s.stationFrame = frame;
} else if (this.typeTarget.ordinal === 0) {
this.systemContent.homeFrame = frame; // the home world (starting system)
} else {
const p = (this.systemContent.planets ?? [])[this.typeTarget.ordinal - 1];
if (p) p.frame = frame;
}
}
// DEPLETED FIELDS — asteroid clusters the run has mined to nothing:
// per-run world state, registry-backed like discovery/research
// (js/save/SaveData.js). The galaxy's content regenerates from the
// seed on every load, so the run's depleted ids are spliced OUT of
// the content BEFORE the cluster entities build (below) — a mined-out
// field stays gone across a save, and it is off the map (the chart +
// resource stats read content.asteroids) with the entities gone.
this.depletedClusters = this.registry.get('depletedClusters') ?? null;
if (!this.depletedClusters) {
this.depletedClusters = new Set();
this.registry.set('depletedClusters', this.depletedClusters);
}
if (this.depletedClusters.size && Array.isArray(this.systemContent?.asteroids)) {
const sysId = this.systemRecord.id;
this.systemContent.asteroids = this.systemContent.asteroids.filter(
(c) => !this.depletedClusters.has(`${sysId}:${c?.id}`),
);
}
// JUMP GATE ACTIVATION (the SYSTEM research category —
// js/research/SystemCategory.js): the run's activation keys are
// per-run world state — registry-backed (like discovery: New Game
// clears it, a load restores it; js/save/SaveData.js). A gate the
// run activated — or whose RETURN the run activated from a linked
// system — flips on here, BEFORE the gate entities below read
// `active` (the dormant look is baked at construction); the gate
// tethers (data/gates.json → ACTIVITY) attach after the field exists
// (below) and save with the run's tether list.
this.activatedGates = this.registry.get('activatedGates') ?? null;
if (!this.activatedGates) {
this.activatedGates = new Set();
this.registry.set('activatedGates', this.activatedGates);
}
// THE RUN'S GALAXY FOOTPRINT (the MAP console's GALAXY tab —
// js/ui/GalaxyView.js): visitedSystems = the systems the run has
// ENTERED (chart the region), usedGates = the jump lanes the run has
// TRAVELED (edgeKey(a,b), undirected — the lane glows brighter). Both
// are per-run world state — registry-backed (like activatedGates):
// New Game clears them, a load restores them (js/save/SaveData.js).
this.visitedSystems = this.registry.get('visitedSystems') ?? null;
if (!this.visitedSystems) {
this.visitedSystems = new Set();
this.registry.set('visitedSystems', this.visitedSystems);
}
this.usedGates = this.registry.get('usedGates') ?? null;
if (!this.usedGates) {
this.usedGates = new Set();
this.registry.set('usedGates', this.usedGates);
}
// THE PLOTTED DESTINATION (the MAP console's SYSTEM tab — js/galaxy/
// Route.js): where the run is headed. Persisted as { systemId,
// objectId } — the ROUTE itself is derived on demand (planRoute from
// wherever the ship is now to destination.systemId), so following it
// just shortens it and a detour re-plots it (no progress counter to
// desync). Null until the player sets one; cleared on arrival.
this.destination = this.registry.get('destination') ?? null;
// A ROUTE NOTICE parked by the jump we just completed (Route._routeFor
// Jump → _queueRouteNotice): the destination-REACHED / route-RE-PLOTTED
// message. The jump's own toast + full-screen clip already played on
// the source scene, so surface this one here, a beat after spawn, so
// it reads cleanly on the destination system.
{
const notice = this.registry.get('routeNotice');
if (notice && typeof notice.text === 'string') {
this.registry.set('routeNotice', null);
this.time.delayedCall(900, () =>
this.consoleToast(notice.text, {
glyph: notice.glyph ?? '◆',
glyphColor: notice.glyphColor ?? toCss(this._routeColor()),
durationMs: notice.durationMs ?? 3400,
}),
);
}
}
this.visitedSystems.add(this.systemRecord.id); // we start here — it's charted
for (const j of this.systemContent.jumps ?? []) {
if (j && this.activatedGates.has(`${this.systemRecord.id}>${j.to}`)) j.active = true;
}
if (this.fxSystemType) {
// The demo wants the whole system LIVE: every gate on (its tether
// is the flight range — the union of them is the playable space),
// so a jump out and back is one click away.
for (const j of this.systemContent.jumps ?? []) {
j.active = true;
this.activatedGates.add(`${this.systemRecord.id}>${j.to}`);
}
}
// The CENTRAL BODY — the home world, present ONLY in the STARTING
// system, at the world origin (the generator deals its name —
// content.homeName — so it reads as a real place rather than a generic
// "Terra"; the fallback keeps the old configured name if the bank is
// ever unavailable). It is the only central body in the galaxy: in
// every other system the center is EMPTY (the star is invisible flavor
// — content.star name/class feeds the dossier and gate names, never
// rendered) and is the ONLY system where the origin is a comms target
// (settled, landable) and reads "Home World".
// NOTE: home is the STARTING system (galaxy.homeSystemId) — stable
// across jumps. Comparing against galaxy.currentSystemId would be
// wrong: that pointer tracks where the player IS NOW, so every
// freshly-arrived system would read as home.
this.isHomeSystem = this.galaxy.isHomeSystem(this.systemRecord.id);
this.homeWorldName = this.isHomeSystem
? (this.systemContent.homeName || config.get('planets.homeName', 'Terra'))
: null;
this.createSystemHud();
if (this.isHomeSystem) {
// The home world — the player's Terran world, present ONLY in the
// system the player starts in. It sits at the world origin. Which
// Terran face it shows comes from the galaxy-wide frame pass
// (content.homeFrame — the same (class, face) spreading as the
// planets), falling back to a seed-deterministic pool pick, so the
// same galaxy always yields the same home world.
const homeName = config.get('planets.homePlanet', 'terran');
const homeRng = Rng.derive(this.galaxy.seed, 'planet', 'home');
const homeFrame =
typeof this.systemContent.homeFrame === 'number'
? this.systemContent.homeFrame
: Planet.frameFor(homeName, homeRng);
this.planet = new Planet(this, 0, 0, homeFrame, homeName);
this.planet.discoveryName = this.homeWorldName;
this.planet.setDepth(5); // above the starfield (depths 0–2), below the ship (10)
} else {
// Every other system has NO central body: the star is INVISIBLE
// flavor (content.star — name/class/binary — feeds the dossier and
// gate names, but is never rendered). The origin is empty space;
// the gate tethers are the player's anchors here.
this.planet = null;
}
// The rest of the solar system — the generated worlds, placed by the
// generator (data/planets.json → solarSystem) on orbits around the
// central body. Same solid-disc rules as the worlds: fly close,
// never through.
this.systemPlanets = [];
if (config.get('planets.solarSystem.enabled', true)) {
for (const rec of this.systemContent.planets ?? []) {
if (typeof rec.x !== 'number' || typeof rec.y !== 'number') continue;
const kind = rec.class || 'rocky';
// The galaxy-wide frame pass (js/galaxy/PlanetFrames.js) stamped
// `rec.frame` to spread each (class, face) across the galaxy; fall
// back to a random pool pick for content that predates the pass.
const frame =
typeof rec.frame === 'number'
? rec.frame
: Planet.frameFor(kind, Rng.derive(this.galaxy.seed, 'planet', rec.name));
// (PLANET TINT — REMOVED FOR NOW: per-class canvas tints used to be
// applied here from data/planets.json → classTint, i.e.
// `tint: toColor(config.get(`planets.classTint.${kind}`))`. The
// Planet still accepts the option — pass it back when we want it.)
const p = new Planet(this, rec.x, rec.y, frame, kind, {
scale: rec.scale ?? 1,
});
p.setDepth(5);
p.discoveryId = rec.name; // unique within the system
p.discoveryName = rec.name;
this.systemPlanets.push(p);
}
}
// Asteroid clusters — loose groups of slowly tumbling rocks scattered
// through the system's void (data/asteroids.json). Every cluster is a
// DISCOVERABLE object (the compass knows it once found), a SOLID one
// (the ship can park at a rock's rim, never fly through it) — the same
// contract as the worlds, at rock scale — and a MINABLE one: a click
// on a rock opens the mining menu (see the Mining wiring below).
this.asteroidClusters = [];
if (config.get('asteroids.enabled', true) !== false) {
for (const rec of this.systemContent.asteroids ?? []) {
this.asteroidClusters.push(new AsteroidCluster(this, rec, { depth: 5 }));
}
}
// Free-space stations (settlements with `anchor.type === 'space'`
// — deep-space stations): settlements that float in
// the void, rendered as world objects (js/entities/Station.js).
// Solid (the ship keeps its clearance), discoverable (compass +
// toast), and comms targets — a click opens the comms panel there
// (openCommsPanel below) — it is NOT a fly-here (the ship stays put).
this.systemStations = [];
if (config.get('stations.enabled', true) !== false) {
for (const s of this.systemContent.settlements ?? []) {
if (s.anchor?.type !== 'space' || typeof s.x !== 'number' || typeof s.y !== 'number') continue;
this.systemStations.push(new Station(this, s, { depth: 5 }));
}
}
// Jump gates (content.jumps — one per destination in the system's
// gate network, js/galaxy/JumpNetwork.js): the system's exits.
// Solid (the ship keeps its clearance), discoverable (compass +
// toast, in their own cyan), and each one faces its destination
// star on the map (entity.rotation). A click on a gate opens the
// GATE COMM WINDOW (openGateCommsPanel — the shared comms panel with
// a LINK status line; REQUEST JUMP initiates
// GameScene.jumpThroughGate, the transport) — never a fly-here.
// worldObjectAt deliberately excludes them (the panel opens via
// gateAt(), the gates' own hit test, not the comms target set).
this.systemGates = [];
if (config.get('gates.enabled', true) !== false) {
for (const j of this.systemContent.jumps ?? []) {
if (typeof j.x !== 'number' || typeof j.y !== 'number') continue;
this.systemGates.push(new JumpGate(this, j, { depth: 5 }));
}
}
// Every solid in the system — worlds first (their keep-out circles are
// disjoint), then the clusters, then the stations, then the gates. The
// central body (the home world — the starting system only; other
// systems have no star) leads when it exists.
// Ship constraint, click-to-fly clamping and autopilot all run
// against this list.
this.solids = [
...(this.planet ? [this.planet] : []),
...this.systemPlanets, ...this.asteroidClusters, ...this.systemStations, ...this.systemGates,
];
// The central body's discovery id is 'home' — the chart's central NAV
// point (SystemCategory.navPoints) — in the STARTING SYSTEM ONLY (the
// home world). Every other system's center is empty: no 'home' NAV
// point there (its star is invisible flavor, not a chart waypoint).
if (this.planet) this.planet.discoveryId = 'home';
// The ship — in the starting system, a short hop (~150 px,
// edge-to-edge) from the home world's rim, in a seed-derived
// direction: same galaxy ⇒ same start. Everywhere else the ship's
// position comes from the jump/save restore (the arrival gate), so
// the origin (an empty center — no star) is only a defensive
// fallback.
if (config.get('ship.texture', '') && !this.textures.exists(Ship.TEXTURE_KEY)) {
console.warn(
`[orbit] ship spritesheet "${config.get('ship.texture')}" did not load — using the built-in dart.`,
);
}
this.ship = new Ship(this, 0, 0);
this.ship.setDepth(10);
// ENGINE FX — flare-up + exhaust trail on the ship's side pods
// (js/visuals/EngineFlare.js). Created per create() because a jump
// restarts this scene in place: the display list (emitters, glows)
// is destroyed with it, while our ref survives — same contract as
// the effect layers below. It reads the ship's throttle per frame
// (update) and cleans up on landing (sleep).
this.engineFx?.destroy();
this.engineFx = new EngineFlare(this, this.ship);
// WORLD PREVIEW (?type=…): the ship starts beside the TARGET world
// (this._previewEntity — the planet/station the pick named), not
// the home world — the point is to land on it and judge its clips
// and surface loop. Same edge-to-edge hop as the home spawn, same
// seed-derived direction (same galaxy ⇒ same start).
const spawnBody = (this.typeTarget ? this._previewEntity() : null) ?? this.planet;
const spawn = spawnBody
? spawnBody.edgePoint(
Rng.derive(this.galaxy.seed, 'spawn', 'ship').range(0, Math.PI * 2),
config.get('planets.spawnDistanceFromEdge', 150),
this.ship.radius,
)
: { x: 0, y: 0 };
this.ship.setPosition(spawn.x, spawn.y);
// DEV QA handle for the world preview (?type=…): what the boot named
// and where the ship was placed — inspect in the console.
if (typeof window !== 'undefined') {
window.__ORBIT_PREVIEW__ = {
typeSpec: this.typeSpec,
systemId: this.systemRecord?.id,
spawnBody: spawnBody ? spawnBody.discoveryName ?? spawnBody.label ?? null : null,
spawn: { x: Math.round(spawn.x), y: Math.round(spawn.y) },
ship: { x: Math.round(this.ship.x), y: Math.round(this.ship.y) },
};
}
// Center the camera on the ship from the very first frame.
this.cameras.main.setScroll(
this.ship.x - this.scale.width / 2,
this.ship.y - this.scale.height / 2,
);
// Background (stars are placed around the current camera view).
this.starfield = new Starfield(this);
this.starfield.create();
// A JUMP restarts this scene IN PLACE (v4: the same instance — the
// 'shutdown' EVENT tears down the display list, but the engine never
// calls our shutdown() method, and our own refs survive; see the
// MenuScene note on the v4 quirk). The per-system EFFECT LAYERS below
// are conditional, so a layer the LAST entry built would keep ticking
// — and talking — in this system: a habitable storm's update() would
// keep striking, and keep THUNDERING, on a starless sky. Drop whatever
// the last entry built; each conditional below re-adds its layer or
// leaves the ref null.
this._teardownEffectLayers();
// NEBULA GAS — soft coloured clouds in TWO layers, tinted with the
// system's own atmosphere color (assigned at random at galaxy
// generation — SystemGenerator): a BACK layer behind the art (depth 3,
// base color) and a FRONT layer above the ship (depth 11, a derived
// accent hue) that drifts past you — flying THROUGH the nebula.
// Nebula systems only. See js/visuals/NebulaAtmosphere.js.
if (
this.systemRecord?.type === 'nebula' &&
this.systemContent?.atmosphere?.color
) {
this.nebula = new NebulaAtmosphere(
this,
this.systemContent.atmosphere.color,
config.get('game.nebula', {}),
);
this.nebula.create();
}
// Per-system CONTENT effects (world-space sprites, Canvas-safe):
// - redDwarf ember dust (effect.particles) → EmberField (depth 3)
// - redDwarf red haze (effect.haze) → RedHaze (depth -1)
// - binary void base (effect.void) → VoidBase (depth -1)
// - binary companion star (effect.wanderer) → WandererStar (depth 3)
// (The camera FILTERS — ripple + grade — are SystemEffects' job.)
const _fx = config.get(`systems.types.${this.systemRecord?.type}.effect`, null);
if (_fx?.haze) {
this.haze = new RedHaze(this, _fx.haze, this.systemContent?.fx ?? {});
this.haze.create();
}
if (_fx?.void) {
this.voidBase = new VoidBase(this, _fx.void, this.systemContent?.fx ?? {});
this.voidBase.create();
}
if (_fx?.storm) {
this.storm = new StormLayer(this, _fx.storm, this.systemContent?.fx ?? {});
this.storm.create();
}
if (_fx?.particles) {
this.embers = new EmberField(this, _fx.particles.color, _fx.particles, this.systemContent?.fx ?? {});
this.embers.create();
}
if (_fx?.wanderer) {
this.wanderer = new WandererStar(this, _fx.wanderer.color, _fx.wanderer, this.systemContent?.fx ?? {});
this.wanderer.create();
}
if (_fx?.lightning && _fx.lightning.enabled !== false) {
// OVERT far-sky lightning (nebula): the bolt wears the system's GAS
// colors — the base atmosphere + the derived accent (the front gas's
// tint), alternating per strike. See js/visuals/NebulaLightning.js.
const baseCol = this.systemContent?.atmosphere?.color ?? '#5fd4d0';
const accentCol = typeof this.nebula?.frontTint === 'number' ? this.nebula.frontTint : baseCol;
this.nebulaLightning = new NebulaLightning(this, baseCol, accentCol, _fx.lightning, this.systemContent?.fx ?? {});
this.nebulaLightning.create();
}
// The TETHER — the player's range. In the STARTING system it starts
// as one level-1 tether anchored on the home world (data/tether.json):
// the ship may fly anywhere within its rim (level 1 = 5120 px from the
// planet's center); the rim is a hard barrier drawn as a thick
// glitchy dotted line. In every OTHER system there is no native
// tether — the center is empty — the player's range is the activated
// GATE tethers (the arrival gate anchors a level-1 tether on landing,
// data/gates.json → ACTIVITY). The field owns the tether list
// (add/remove/setLevel are the seam the build system will use later);
// where multiple tethers' zones overlap there is no line and no wall
// — the union is the player's space.
this.tetherField = new TetherField(this, {
depth: 6, // above planets (5), below the ship (10)
});
if (this.isHomeSystem) {
this.tetherField.add(
config.get('tether.homeId', 'home'),
0, 0, // the home world's center (the system origin)
config.get('tether.homeLevel', 1),
config.get('tether.homeLabel', '') || this.homeWorldName,
);
}
// Each activated gate anchors its tether (data/gates.json → ACTIVITY:
// an active gate is a TETHER ANCHOR in its own right — the room to
// move in a barren system). Idempotent: add() replaces by id, and a
// save's tether list restores to the same ids.
const gateTetherLevel = Math.max(1, Math.floor(Number(config.get('gates.activation.tetherLevel', 1)) || 1));
for (const j of this.systemContent.jumps ?? []) {
if (j && j.active) this.tetherField.add(`gate:${j.id}`, j.x, j.y, gateTetherLevel, j.name ?? '');
}
this.tetherToastAt = null;
// Discovery: which objects the player has found (within discovery
// distance of an edge — data/game.json), tracked per system. Kept in
// the shared registry so it survives scene restarts; serializable for
// saves later (Discovery.toJSON). The compass turns the discovered set
// into screen-edge arrows for the objects currently out of sight.
this.discovery = this.registry.get('discovery') ?? null;
if (!this.discovery) {
this.discovery = new Discovery(config.get('game.discovery.distance', 540));
this.registry.set('discovery', this.discovery);
}
if (this.fxSystemType) {
// The demo's "fully charted": the system is already discovered —
// the compass rests, the dossier reads complete, no pings compete
// with the effect for your eye. (check() can't do this — its
// radius is capped at the configured discovery distance — so the
// demo marks the roster directly.)
this.discovery.markAllDiscovered(this.systemRecord.id, this.discoverableObjects());
}
// REPUTATION — the player's standing on each planet and space station
// (js/reputation/Reputation.js): −20…+20 (data/reputation.json),
// neutral 0 everywhere the player has no standing, the home world
// pinned at +20. The faction check (settlements' reserved `owner`
// seam) is a placeholder until factions land. Nothing the player can
// see or influence yet — this is the data layer + the save seam the
// influence mechanics plug into. Like discovery it lives in the shared
// registry (survives scene restarts; New Game resets it).
this.reputation = this.registry.get('reputation') ?? null;
if (!this.reputation) {
this.reputation = new Reputation();
this.registry.set('reputation', this.reputation);
}
// The command deck's bottom strip (config: data/actionbar.json) — the
// compass lays out its arrows/name tags ABOVE it so a tag is never
// buried under the deck, and the hint sits above it too.
const deckEnabled = config.get('actionbar.enabled', true) === true;
const deckReserve = deckEnabled
? config.get('actionbar.height', 92) + config.get('actionbar.margin.bottom', 12)
: 0;
this.compass = new DiscoveryCompass(this, {
// Autopilot: clicking a compass name tag sends the ship to that
// discovered object (GameScene.autopilotTo below).
onSelect: (id) => this.autopilotTo(id),
reserveBottom: deckReserve,
});
// The command deck — the cyberpunk action bar across the bottom of the
// screen (config: data/actionbar.json). Six evenly spaced slots:
// Research, Scan, Ship, Quests, Map, Menu. The slots are the seams
// for the player's loop — research (time-based, one at a time, see
// data/research.json), scan, the ship, the quest dossier (see
// data/quests.json) and the star map.
this.actionBar = deckEnabled
? new ActionBar(this, {
onAction: (id) => this.deckAction(id),
})
: null;
// ---- COMBAT — the hostile pack (the ?combat boot) + the combat bar --
// COMBAT IS LIVE IN THE MAIN GAME: the 'Pirate Problems' quest
// (data/quests.json → pirate_problems) ambushes the player at the
// MIDPOINT of the entry-jumpgate → Neutral-Capital-world stretch —
// the enter lands the ship at the gate (a full stretch out); the
// wave starts when it reaches half that distance from the world —
// GameScene.
// _maybeStartPirateAmbush fires the pirate_ambush wave run (data/
// combat.json → waves.scripts.pirate_ambush — one rolling wave of six
// frame-0 raiders, materializing off-screen around the player from a
// random direction each and streaming in on their chase AI), and the
// deck swap,
// the readability layer, the auto-fire, and the armor are all the
// same shared parts (the ?combat sandbox is where the numbers get
// tuned). data/combat.json (game.*): the ?combat boot adds the hostile
// pack — spawnCount hostiles in a ring around the ship (spawnRadius px).
// The mouse steers; while a hostile is in range the ship's cannon
// AUTO-FIRES at the nearest one (game.autoFire — the cannon's numbers
// are combat.player.weapon, shared with the range). While combat is
// LIVE the deck steps aside for the combat bar (js/ui/CombatBar.js):
// shields + hull left, ENERGY right, eight skill seats (keys 1-8) in
// the middle — the skills land later. Built for every scene (an idle
// system with no enemies and no wave run costs nothing in update()),
// the bar hidden until a wave makes combat live.
this.combat = null;
this.combatBar = null;
this.combatHud = null;
this._combatSettleFrames = 0;
if (config.get('combat.enabled', true) !== false && this.ship) {
this.combatBar = new CombatBar(this, {
onCast: (i) => this.combat?.cast(i),
});
this.combatBar.setVisible(false);
this.combat = new CombatSystem(this, {
ship: this.ship,
onActiveChange: (active) => this._setCombatBar(active),
// The readability layer's bookkeeping (js/combat/CombatHud.js —
// the same shared layer the range runs): a kill lands in the
// feed, and a hit flashes the bar's gauge that took it.
onKill: () => this.combatHud?.kill('HOSTILE DESTROYED'),
onPlayerDamage: (n, a) => {
if (!this.combatHud || !a) return;
if (a.shieldHit > 0) this.combatHud.flash('shield');
if (a.hullHit > 0) this.combatHud.flash('hull');
},
});
// ?combat boot (the tuning sandbox): the hostile pack around the
// ship. The main game's combat arrives with the quest's ambush
// (the wave run above) — the boot just adds the practice pack.
// NOTE — the player's home system is SAFE SPACE: enemies should
// NEVER be placed there. This dev boot is the one place they could
// land in it, so don't run ?combat at home, and keep that rule for
// any new enemy placement (the quest ambush already targets the
// Neutral Capital, never home).
if (this._wantsCombatDemo()) {
this.combat.spawnPack(this.ship.x, this.ship.y);
// SETTLE WINDOW — the ship's start position is finalized AFTER
// create() (a fresh run's home rim comes from the tether barrier;
// a load's from the save), so the pack — spawned around the
// create-time position — is re-anchored around the ship for the
// first few frames (update() → CombatSystem.resyncAroundShip).
this._combatSettleFrames = 30;
console.info(
'[orbit] ?combat — hostile pack live (data/combat.json, js/combat/CombatSystem.js)',
);
}
// The READABILITY LAYER (shared with the range): the lock on the
// auto-fire target, the threat markers, the off-screen arrows
// (the camera follows the ship — hostiles can sit outside it),
// the kill feed, and the combat bar's gauge flash on player hits.
this.combatHud = new CombatHud(this, {
getEnemies: () => (this.combat ? this.combat.enemies : []),
getTarget: () => this.combat?.currentTarget() ?? null,
getCamera: () => this.cameras?.main ?? null,
worldToScreen: (x, y) => {
const c = this.cameras?.main;
const ox = c?.worldView?.x ?? c?.x ?? 0;
const oy = c?.worldView?.y ?? c?.y ?? 0;
const z = c?.zoom ?? 1;
return { x: (x - ox) * z, y: (y - oy) * z };
},
gaugeShape: (kind) => this.combatBar?.gaugeShape(kind) ?? null,
});
if (typeof window !== 'undefined') {
// Tuning handle: poke the pack / the armor / the bar from the console.
window.__ORBIT_COMBAT__ = {
system: this.combat,
bar: this.combatBar,
player: () => (this.combat ? this.combat.stats() : null),
kills: () => this.combat?.kills ?? 0,
spawn: (n = 1) => {
if (!this.combat || !this.ship) return;
const r = this.combat._offscreenRadius();
for (let i = 0; i < n; i++) {
const a = Math.random() * Math.PI * 2;
this.combat.spawnEnemy(
this.ship.x + Math.cos(a) * r,
this.ship.y + Math.sin(a) * r,
100 + i,
);
}
},
damagePlayer: (n = 10) => this.combat?.damagePlayer(n),
cast: (i) => this.combat?.cast(i),
// WAVE RUNS (js/combat/Waves.js — the quest-step combat): start a
// script (a combat.waves.scripts NAME or a raw wave list), watch
// it, cancel it. The quest's own hook is live (the 'Pirate
// Problems' ambush — GameScene._maybeStartPirateAmbush); this
// seam is the sandbox's entry point.
waves: {
start: (script, opts) => this.combat?.startWaves(script, opts) ?? null,
cancel: (destroy = false) => this.combat?.cancelWaves(destroy) ?? null,
status: () => this.combat?.waveStatus() ?? null,
demo: (name = 'raid') =>
this.combat?.startWaves(name, {
onWaveStart: (i) => console.info(`[orbit] wave ${i + 1} in`),
onWaveClear: (i) => console.info(`[orbit] wave ${i + 1} cleared`),
onComplete: (s) => console.info('[orbit] combat complete', s),
}) ?? null,
},
};
}
}
// ---- The SAVE SYSTEM — the deck's Menu sub-bar + the slot pop-up --
// The sub-bar folds up out of the Menu button (Save Game / Load Game
// [grayed while no saves exist] / Return to Main Menu); both save
// verbs open the 10-slot pop-up, which owns the overwrite + load
// confirmations and the bulk download (js/ui/MenuSubBar.js +
// js/ui/SavePanel.js, config in data/save.json).
this.saveManager = new SaveManager(); // the localStorage bank
this.menuSubBar = new MenuSubBar(this, {
anchor: this.menuAnchor(),
onAction: (id) => this.subBarAction(id),
});
this.savePanel = new SavePanel(this, {
// Load confirmed: the restore is staged — hand back to the menu
// (its New Game is the "start" the staged restore rides on).
onLoadComplete: () => this.returnToMenu(),
});
// The MINERALS readout — upper right (the corner the old tether stack
// used to occupy): the hold's fill as a bar + counting number.
// The CREDITS readout stacks ABOVE it (the trade currency — see
// js/economy/Economy.js + data/creditshud.json); the minerals HUD is
// shifted down by the credits line's height so the two read as one
// console, and the comms box (anchored to the minerals bar's bottom)
// keeps clear of both.
this.creditsHud = new CreditsHud(this);
this.creditsHud.set(this.ship.credits);
this.mineralHud = new MineralHud(this, { topOffset: this.creditsHud.height + this.creditsHud.gap });
this.mineralHud.set(this.ship.minerals, this.ship.stats.mineralStorage);
// ---- COMMS (the characters speak to the player — data/comms.json) --
// The hub (js/comms/CommsHub.js) owns the comms' lives: the queue
// (events arm them; pump() below plays the next due one), the
// DEFERRED asset loads (the comm's character clip + speech file load
// when the comm is due — never in preload, so a big comm roster
// doesn't bloat the initial load), the voice (with the music ducked
// under it), and the ACK pause contract (below: update() gates the
// action on comms.paused; the input seams gate on it too). The box
// itself (js/ui/CommsBox.js) docks just below the mineral bar — the
// hud's lower-right is the anchor.
this.comms = new CommsHub(this, {
// The scene isn't free while any of these owns the screen — the
// comm waits (re-pumped each frame) until the screen is clear.
// The hub passes the comm it's about to start (def.type):
// - a MODAL owns the screen (a window/panel is up, or a jump is
// in flight) — no comm of ANY type starts until it's clear;
// - an ACTION is in progress (mining live, the scan sweep out) —
// the box comms (ack/visual: they pause the action or take the
// screen) wait for it to settle, but an audio-only comm is
// voice with no box, no pause, no input — it rides over the
// action (the research-complete sign-off lands mid-mining).
busy: (type) => {
const screenOwned =
(this.savePanel && this.savePanel.isOpen) ||
(this.researchWindow && this.researchWindow.isOpen) ||
(this.mapWindow && this.mapWindow.isOpen) ||
(this.questWindow && this.questWindow.isOpen) ||
(this.commsPanel && this.commsPanel.isOpen) ||
(this.menuSubBar && this.menuSubBar.isOpen) ||
!!this._jumping;
if (screenOwned) return true;
if (type === 'audio') return false; // voice over the action — allowed
return (
(this.mining && this.mining.isActive) ||
(this.miningPopup && this.miningPopup.isOpen) ||
(this.scanPulse && this.scanPulse.busy)
);
},
anchor: () => {
const hud = this.mineralHud;
if (!hud) return null;
return { right: this.scale.width - hud.pad, top: hud.barY + hud.barH };
},
// The ack pause's scene-side effects: the action holds — the ship
// parks dead (target + throttle cleared, velocity zeroed) and the
// mining arm drops. Resuming needs no work (the ship stays parked;
// the player's next click steers it again).
onPause: (on) => {
if (!on) return;
this.ship.stop();
if (this.mining && this.mining.isActive) this.mining.stop();
},
// The comm has STARTED (its id is on the run's played-ledger): the
// 'Leaving the Nest' quest's Trestle witnesses flip on exactly this
// moment — all three briefs in the ledger = 'Talk to Trestle' done,
// the arrival in it = 'Meet Trestle' done (GameScene._trestleCheck
// reads the ledger live). Refresh the quest ledger so the checklist
// row ticks over and, if the quest just completed, the READY moment
// (toast + chime) carries it when the tracker is slid off.
onStart: (def) => {
if (def && def.from === 'trestle') this._questRefresh();
},
// The comm was ACKNOWLEDGED (the 'ack' comm's press itself — fired
// BEFORE onEnd, so the end-side pass already sees the mark): the
// run's comms ACKED-ledger (registry 'commsAcked' —
// GameScene._commAcked) records the id. 'Pirate Problems' (data/
// quests.json → pirate_problems.unlocks.acked) stands on this:
// its grant waits for the 'Special Delivery' payoff chain to be
// ACKed all the way through (SaveData captures it under
// record.comms.acked).
onAck: (def) => this._commAcked(def),
// The comm ENDED (for 'ack' comms: the ACKNOWLEDGE press): the
// scene's ack-side bookkeeping — Trestle's last brief ('trestle
// sends the coordinates to your computer right now') sets the
// player's destination to the neutral capital on acknowledgment
// (GameScene._commEnded).
onEnd: (def) => this._commEnded(def),
});
// The run's FIRST rock field (the 'firstCluster' comm beat — see
// celebrateDiscovery): a scene-local one-shot, so the event fires
// exactly once per scene life. The comm's own once-ledger is the
// authoritative dedupe across saves (a flag reset on load can never
// re-play a comm the ledger already holds).
this._firstClusterSeen = false;
this._firstWorldSeen = false; // the first DISCOVERED WORLD (Trestle's intro — the quest's own witness)
// The systems whose NAV chart COMPLETED this scene life (the
// 'systemComplete' milestone — oac-system-complete, see
// celebrateDiscovery): one fire per system. A later asteroid-cluster
// discovery in an already-charted system still passes
// isSystemNavComplete() (clusters aren't NAV points), so the set —
// not the chart state — is the arming gate.
this._systemCompleteSeen = new Set();
// The run's FIRST crossing of the 200-mineral line (the 'minerals200'
// comm beat — see onOre): same one-shot shape; the threshold check
// also runs at boot (create, below) so a loaded run that already
// crossed it still gets the beat.
this._minerals200Armed = false;
// The tutorial's pointing finger (data/quests.json → tutorial, the beat
// table in js/quests/TutorialGuide.js): the 'press SCAN' beat is past
// the moment the player presses SCAN — the scene-local one-shot (the
// comm's played-ledger marks oac-upgrade the moment it starts, and is
// the authoritative witness from then on).
this._tutScanPressed = false;
// Hint (pinned just ABOVE the command deck, not under it)
this.hint = this.add
.text(this.scale.width / 2, this.scale.height - deckReserve - (deckReserve ? 20 : 26), config.get('game.hintText', ''), {
fontFamily: BODY_FONT(),
fontSize: '14px',
color: '#54608a',
letterSpacing: 1,
})
.setOrigin(0.5)
.setScrollFactor(0); // UI: pinned to the screen, not the world
// ---- MINING (the clusters are now verbs, not just rocks) -----------
// The state machine (js/mining/Mining.js): idle → extending (~1.5 s,
// the ship holds station, "Extending Mining Arm..." in the console
// slot) → mining (the beam is live — js/mining/MiningBeam.js)
// → retracting. The pop-up (js/ui/MiningPopup.js) is the context
// menu a rock click opens, world-anchored right where the player
// clicked.
this.mining = new Mining(this, {
onPhase: (p) => this.onMiningPhase(p),
onDepleted: (c) => this.onClusterDepleted(c),
onEvent: (name, data) => this.onMiningEvent(name, data),
onOre: (gained) => {
this.refreshMineralHud(); // live: the hold fills as the rock shrinks
// Lifetime asteroid mining — the quest ledger's input (the
// 'Mine 200 Minerals from asteroids' requirement counts this,
// not the hold — the hold is cargo, this is the record).
this.totalMined = (this.totalMined ?? 0) + Math.max(0, Number(gained) || 0);
this._questRefresh();
// The comms beat (data/comms.json: minerals200 — oac-tether2):
// OAC confirms the moment the run's lifetime mining first crosses
// 200 — the 'Mine 200 Minerals' quest line (data/quests.json) and
// the tether-l2 build cost (data/builds.json). The scene flag fires
// the event once per scene life; the comm's once-ledger is the
// authoritative dedupe across saves.
if (!this._minerals200Armed && this.totalMined >= 200) {
this._minerals200Armed = true;
this.comms?.trigger('minerals200');
}
},
});
this.miningPopup = new MiningPopup(this, {
onAction: (id, rock) => this.miningAction(id, rock),
});
// COMM PANEL (js/ui/CommsPanel.js) — the starship comms console that
// opens at the player's click on a planet or space station: a rusty
// metal case around a green phosphor readout. The name decodes at
// the top; a SETTLED object (a world hosting a colony / mining
// station / cloud base, or a free-space station) shows a reputation
// bar — 41 marks, −20…+20, red → green, lit up to the standing —
// plus REQUEST LANDING (grayed at standing ≤ −4) and CANCEL; an
// UNSETTLED world shows LAND + CANCEL. The landing buttons launch
// SurfaceScene (startLanding) — a one-shot landing video over a
// looping surface clip with its own deck (data/landing.json).
this.commsPanel = new CommsPanel(this, {
onAction: (id, target) => this.commsAction(id, target),
});
// ---- RESEARCH CONSOLE (the deck's RESEARCH button) ---------------------
// The rules live in data/research.json (+ one file per category under
// data/research/); the progress in ResearchState; the view in
// ResearchWindow (js/ui/ResearchWindow.js, depth 80). This scene owns
// the effects: tether level-ups, capability flags, toasts, the deck
// progress bar, and the save data (record.research).
this.researchState = new ResearchState();
for (const cat of categories()) {
const tree = loadCategory(cat.id);
if (!tree) continue;
for (const id of tree.starting) this.researchState.unlock(cat.id, id);
}
// The SYSTEM category's live tree — built for the system the player is
// in (categories flagged `dynamic` in research.json have no data file):
// the '{System} Map' (granted on entry — _onEnterSystem below) and
// 'Unlock Jumpgates' (chart complete → researchable → the
// gates + the linked systems' return gates go live). js/research/
// SystemCategory.js; copy + duration in data/gates.json → activation.
const sysCat = categories().find((c) => c.id === SYSTEM_CATEGORY);
this.systemTree = buildSystemTree({
systemId: this.systemRecord.id,
systemName: this.systemRecord.name,
accent: sysCat?.accent,
isComplete: () => this.isSystemNavComplete(),
});
this.researchWindow = new ResearchWindow(this, {
state: this.researchState,
systemTree: this.systemTree,
onResearch: (catId, id) => this.beginResearch(catId, id),
});
// ---- MAP CONSOLE (the deck's MAP button, right of SHIP) ----
// The cartography window (data/map.json, js/ui/MapWindow.js, depth 80):
// left cartography feed + THREE tabs — CURRENT SYSTEM (discovered
// objects on a padded frame, the tether union boundary, the fog of
// what the tether doesn't cover yet, the system discovery / resources
// readout — clicking a discovered object plots the course, the same
// autopilot as a world click), SYSTEM (a CHARTED star's chart — the
// same painter for another system, no ship/tether; armed until the
// player picks that star on the GALAXY tab, then clicking an object
// asks SET DESTINATION), and GALAXY (js/ui/GalaxyView.js:
// the whole-galaxy chart — glowing, pulsing stars per archetype, the
// jump-lane web with traveled lanes bright + flow packets, the
// charted region's outline, hover readouts, and a CHARTED star's
// chart in the SYSTEM tab).
// The window is a pure view — it polls mapChartSnapshot() /
// systemChartSnapshotFor() / galaxySnapshot() while open and repaints
// on any change.
this.mapWindow = new MapWindow(this, {
getChart: () => this.mapChartSnapshot(),
getSystemChart: (systemId) => this.systemChartSnapshotFor(systemId),
getGalaxy: () => this.galaxySnapshot(),
getShip: () => (this.ship ? { x: this.ship.x, y: this.ship.y, heading: this.ship.rotation } : null),
onSelect: (objectId) => this.autopilotTo(objectId),
onSetDestination: (systemId, objectId) => this.setDestination(systemId, objectId),
onLocked: (tabId) =>
this.consoleToast(
tabId === 'system'
? config.get('map.systemLockedToast', 'NO SYSTEM TARGET — PICK A CHARTED STAR ON THE GALAXY MAP')
: config.get('map.galaxyLockedToast', 'GALAXY MAP OFFLINE — SECTOR DATA NOT ACQUIRED'),
{
glyph: '✕',
glyphColor: toCss(themeColor('amber', 0xffc94d)),
},
),
});
// ---- QUESTS CONSOLE (the deck's QUESTS button, right of SHIP) ----
// The mission dossier (data/quests.json, js/ui/QuestWindow.js, depth 80):
// left the 2:3 mission feed (assets/videos/quests.mp4 — the same
// dimensions as the research feed), right the MAIN STORY / SIDE QUESTS
// tabs (side is a STANDBY socket — side quests are deferred by design),
// a THIN list of the active quests, and the big detail plate below:
// name, issuer, description, the requirement checklist, and the reward
// with the CLAIM action. The surface deck's QUESTS slot opens the same
// window over the paused GameScene's state (SurfaceScene.buildDeck).
// A quest's progress is evaluated LIVE (questSnapshot below — research,
// lifetime asteroid mining, the home tether's standing, the discovery
// ledger) and, once a step reads DONE, it is RECORDED on the ledger
// (QuestState's completed set — monotonic: the step keeps standing
// after the live read can no longer see it, e.g. the home tether's
// standing once the ship has left the home system). The ledger
// itself — which quests are held, which rewards have been claimed,
// and which steps have stood — is the QuestState in the shared
// registry (like discovery/research) and rides the saves
// (record.quests, plus the lifetime counter record.totalMined the
// mining requirement reads).
this.questState = this.registry.get('quests') ?? null;
if (!this.questState) {
this.questState = new QuestState();
this.registry.set('quests', this.questState);
}
this._questAnnounced = new Set(); // the 'QUEST READY' toasts + chime, once each
this.questWindow = new QuestWindow(this, {
getSnapshot: () => this.questSnapshot(),
onClaim: (id) => this.claimQuest(id),
onSetPriority: (id) => this.setPriorityQuest(id),
getPriority: () => this.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 story
// quest the player is actively engaged in): the tracker's own seam.
resolvePriority: (ids) => this.questState?.resolvePriority(ids) ?? null,
onLocked: (tabId) =>
this.consoleToast(
tabId === 'side'
? config.get('quests.sideStandbyNote', 'SIDE QUESTS OFFLINE — NO CONTRACTS ON FILE YET')
: 'NO SIGNAL',
{ glyph: '✕', glyphColor: toCss(themeColor('amber', 0xffc94d)) },
),
});
// Lifetime minerals mined from asteroids (the 'Mine 200 Minerals'
// requirement's input — the hold is cargo, this is the ledger). Registry
// -backed like discovery: a load stages the saved value (prepareLoad),
// a fresh run starts at zero, and the New Game reset clears it.
this.totalMined = Math.max(0, Math.round(Number(this.registry.get('totalMined')) || 0));
// The run's STORY ledger (data/quests.json → 'Special Delivery' — the
// neutral capital's bar, the package handoff, the delivery leg — and
// the surface TRADE gate: ShopView reads isTradeUnlocked before the
// shop's Buy/Sell tab is allowed). Registry-backed like totalMined:
// a load stages the saved record (SaveData.prepareLoad), a fresh run
// starts blank, and the New Game reset clears it (resetRunState).
const storyRaw = this.registry.get('story') ?? null;
this.story = {
capitalLanded: storyRaw?.capitalLanded === true, // the bar (the trade gate lifts with it)
launchedFromCapital: storyRaw?.launchedFromCapital === true, // the delivery leg's start
lastLandedWorld:
typeof storyRaw?.lastLandedWorld === 'string' && storyRaw.lastLandedWorld
? storyRaw.lastLandedWorld
: null, // the last world landed on
delivered: storyRaw?.delivered === true, // step 3's proof (the home-world SALE of the package — monotonic)
// 'Pirate Problems' step 3 — the return ARRIVAL at the capital
// world (GameScene._maybeWitnessCapitalReturn set it: the ship
// within the world's keep-out rim while the quest is held +
// unclaimed — monotonic; the 'capitalReturned' check reads it).
capitalReturned: storyRaw?.capitalReturned === true,
};
this.registry.set('story', this.story);
// deck progress bar — drawn over the RESEARCH button while a project runs
this._researchDeckBar = {
g: this.add.graphics().setScrollFactor(0).setDepth(51).setVisible(false),
txt: this.add
.text(0, 0, '', {
fontFamily: fontStack('body'),
fontSize: '10px',
color: toCss(themeColor('dim', 0x7d92c4)),
letterSpacing: 2,
align: 'center',
})
.setOrigin(0.5, 1)
.setScrollFactor(0)
.setDepth(52)
.setVisible(false),
};
// ---- BUILD CONSOLE (the deck's BUILD button, on a planet surface) ----
// The rules live in data/builds.json (js/build/BuildModel.js); the
// progress in BuildState — the run's build records (which builds are
// installed on which planets) plus the single in-progress build (one
// at a time). The SurfaceScene is the passive view (BuildWindow,
// depth 80) and TICKS the state in its update() (this scene sleeps
// while the surface is active). The build's time base is the game-
// LOOP clock (game.loop.now) — global + monotonic — so a build keeps
// running if the player takes off mid-build (then we tick it here).
// This scene owns the effects (the tether field is world state, so it
// outlives the stay) and the save data (record.builds).
this.buildState = new BuildState();
this._seedStartingBuilds();
// The staged restore (if this run was LOADED): ship back where it
// was, the saved tether field, the saved session time — and the saved
// research + build state. AFTER the state objects above exist: the
// restore mutates them (applyRestore), so it must come last in the
// creation order.
if (this._pendingRestore) {
this.applyRestore(this._pendingRestore);
this._pendingRestore = null;
}
// WORLD PREVIEW (?type=…): the ship is parked at the TARGET world —
// anchor that world's first-landing tether (data/builds.json →
// tether-l1: free + instant, "the moment the player lands on a
// world") so the player's room to move is the world's rim — exactly
// the state a first landing leaves. Without it the ship would sit
// outside every tether (the other systems' gate tethers don't
// necessarily reach the target world) and the rim constraint would
// drag it back. Idempotent: the home world already hosts its home
// tether, and ensureLandingTether adopts existing fields.
if (this.typeTarget) {
const previewEntity = this._previewEntity();
if (previewEntity?.discoveryName) this.ensureLandingTether(previewEntity.discoveryName);
}
// The starter quest — granted ONCE onto a fresh ledger (a loaded save's
// ledger already holds whatever was granted back then — and give() is
// idempotent, so a jump-cut restart that rebuilds this scene off the
// registry's ledger stays silent). It is the run's opening arc: the
// checklist (data/quests.json) is the first loop — research, mine,
// build, discover.
if (config.get('quests.enabled', true) !== false) {
for (const id of starterQuestIds()) {
if (this.questState.give(id)) {
const def = questDef(id);
this.playSfx('discovery'); // the 'something new is here' voice
// The toast lands AFTER the opening 'DISCOVERED — HOME WORLD'
// toast (one toast slot — the later call replaces the earlier):
// ~2.4s is that toast's life, so +0.3s keeps the order readable.
this.time.delayedCall(2700, () => {
this.consoleToast(
String(config.get('quests.starterToast.text', 'NEW QUEST — {title}'))
.replace('{title}', String(def?.title ?? id).toUpperCase()),
{
glyph: config.get('quests.starterToast.glyph', '◆'),
glyphColor: toCss(themeColor('amber', 0xffc94d)),
durationMs: 3200,
},
);
});
}
}
}
// The RUN's chain (data/quests.json → quests[].unlocks): the quests
// whose unlock requirement (unlocks.claimed — the reward CLAIMED) is
// met land on the ledger now. On a fresh run the starter is unclaimed
// — a no-op; on a LOADED save whose starter is already paid out,
// 'Leaving the Nest' grants here (a fresh run's later claim grants it
// via claimQuest → the same method; give() is idempotent either way).
this._grantUnlockedQuests();
// The field's native set (home + the activated gates) is above; the
// tethers the player INSTALLED on this system's other worlds are
// world state (built records) — re-anchor them on their worlds, since
// a jump cut / scene restart rebuilds the field to the native set
// only (idempotent: existing tethers are only ever strengthened).
this._rematerializeBuiltTethers();
// The ship's MINING upgrades are derived the same way (build records
// are the source of truth — the save carries them, not the stats):
// a load / jump-cut re-asserts the arm rate + hold cap the player
// installed (idempotent; a fresh run has none).
this._restoreMiningUpgrades();
// Entering a system charts it: the SYSTEM category's '{System} Map'
// tech (duration 0) is owned on arrival — a new run gets the starting
// system's, a load re-asserts the saved system's (the restore above
// replaced the unlocked set), and a jump (jumpThroughGate →
// scene.restart → this create) grants the destination's.
this._onEnterSystem();
// ---- DEEP SCAN (the deck's SCAN button) -------------------------------
// The ship's sonar pulse (js/scan/ScanPulse.js): a charge at the hull,
// then an omnidirectional wavefront expanding across the TETHER REGION
// (clipped to the union boundary — absorbed at the rim). Objects in the
// region ring as the front crosses them (updateScanHits), the starfield
// ripples as the wave passes, the camera thumps + rolls with it, and the
// barrier shivers where the wave is swallowed (TetherField.excite).
// The RESULTS are a seam for now — finishScan logs the in-region objects;
// the next instruction decides what a scan yields.
this.scanPulse = new ScanPulse(this);
this.scanObjects = null;
this.scanObjectScale = config.get('scan.ripple.objectScale', 0.045);
// SIGNAL COMPASS (js/ui/SignalCompass.js) — the secondary compass:
// a faint ring (radius px) around the ship. When a scan COMPLETES,
// every undiscovered in-region object lights up a soft "radio signal"
// on that ring at its bearing (full 20 s, then a 5 s fade). A signal
// drops the instant its object is discovered; re-scanning re-emits the
// set with a fresh clock. `this.scanReveal` = the current emission
// ({ bornAt, ids }) — the clock + which objects it covers.
this.signalCompass = new SignalCompass(this);
this.scanReveal = null;
this.signalFullMs = config.get('signalCompass.lifetime.fullMs', 20000);
this.signalFadeMs = config.get('signalCompass.lifetime.fadeMs', 5000);
// SHIP STATE (js/entities/Ship.js): 'normal' is the default — the
// ship is free; 'mining' — the arm's sequence owns the ship. The
// mining visuals live and die with that state: ANY change out of it
// (the player moving the ship — a world click or the compass
// autopilot — a cancel, or a future state) ends the mining sequence.
this.ship.onStateChange = (next, prev) => {
if (prev === 'mining' && next !== 'mining') this.mining.stop();
};
// Input: click = fly there. A click ON the command deck is deck
// business, a click on a compass name tag is an autopilot (it
// already retargeted the ship), and a click on the system NAME (or
// the caret beside it) toggles the dossier open/closed — none of
// those is a fly-here. A click on an ASTEROID opens the mining
// pop-up (a context menu — its buttons own their clicks; any click
// elsewhere closes it and is consumed). A click on a PLANET or
// STATION opens the comms panel — the landing-request window — and
// does nothing else: it is NOT a fly-here (the ship stays put), and
// it does not touch the mining state (no move ⇒ a live beam keeps
// running). Any OTHER click moves the ship — which ends the mining
// state (the beam retracts as the ship goes; a mid-reach arm
// aborts). Holding SHIFT on that click = THROTTLE LOCK: the ship
// commits to the heading toward the click point and keeps flying it
// — past the point, full throttle — until the player steers it
// somewhere else (a plain click flies there and stops; another
// Shift+click re-aims the heading) — or until it runs into a solid /
// the tether rim, where the scene stops it dead (onPostUpdate). A
// click BEYOND the player's tether range clamps to the union boundary
// — the target marker lands on the barrier line itself (and while
// throttle-locked, the ship runs straight into the line and stops
// there instead of resting on it).
this.input.on('pointerdown', (pointer) => {
// A jump clip is in flight — it owns the whole screen. A DOUBLE-CLICK
// (two quick presses) skips the rest of the clip (the same window as
// SurfaceScene's landing/takeoff skip); a single press is swallowed.
if (this._jumping && this._jumpVideo) {
const now = performance.now();
if (now - this._jumpLastClickT <= JUMP_DOUBLE_CLICK_MS) this._finishJump();
this._jumpLastClickT = now;
return;
}
// A jump is in flight (between the clip's end and the restart): the
// old scene is already gone — swallow anything that lands in the gap.
if (this._jumping) return;
// An ACK comm is up — it owns the whole screen (the box + its
// ACKNOWLEDGE button press itself; the world holds still — the
// CommsHub's pause contract; no skip, the player reads + clicks).
// _commInputBlocked also swallows the click that ACKNOWLEDGED (the
// endedWithin race window) — that click is the comm's own.
if (this._commInputBlocked()) return;
// The save pop-up is MODAL — while it's up it owns all input
// (its scrim / cards / dialog eat the click; the world stays put).
if (this.savePanel && this.savePanel.isOpen) return;
// The research console (depth 80) is full-screen — it owns all input
// (its own buttons, the close, ESC); a world click never lands behind it.
if (this.researchWindow && this.researchWindow.isOpen) return;
// The map console (depth 80) — the same full-screen contract.
if (this.mapWindow && this.mapWindow.isOpen) return;
// The quests console (depth 80) — the same full-screen contract.
if (this.questWindow && this.questWindow.isOpen) return;
// The quests console (depth 80) — the same full-screen contract.
if (this.questWindow && this.questWindow.isOpen) return;
// Sub-bar OPEN: a click INSIDE it is its own (the panel or one of
// its buttons — the buttons fire on their own pointerdown). ANY
// click OUTSIDE — world, deck, HUD, compass — folds it back down
// and that click is done (no fly-here, no dossier toggle, no
// autopilot). Deck buttons still get their own press from the
// deck's handler; the Menu button's toggle is safe either order
// because open() is a no-op while the bar is still 'closing'.
if (this.menuSubBar && this.menuSubBar.isOpen) {
if (this.menuSubBar.contains(pointer.x, pointer.y)) return;
this.menuSubBar.close();
return;
}
if (this.actionBar && this.actionBar.visible && this.actionBar.contains(pointer.x, pointer.y)) return;
// The combat bar (in the deck's place while combat is live) — a
// press on it is the bar's (a skill slot); no fly-here behind it.
if (this.combatBar && this.combatBar.visible && this.combatBar.contains(pointer.x, pointer.y)) return;
if (this.compass.contains(pointer.x, pointer.y)) return;
if (this.hudTitleContains(pointer.x, pointer.y)) {
this.toggleHud(); // the system name toggles the dossier details
return;
}
// The quest tracker (below the dossier, depth 30) — the mining-
// menu contract: while its row popup is up, a click inside the
// popup is a button's (its own pointerdown); the same-frame click
// on the row that OPENED it is swallowed; any other click closes
// the popup and is consumed. With the popup down, a click on the
// tracker's footprint is the tracker's (a row's own pointerdown
// opens the popup) — never a fly-here; a click its popup buttons
// just handled (they close it from their side first) is that
// click, never a world click.
if (this.questTracker && this.questTracker.shown) {
const tx = pointer.x;
const ty = pointer.y;
if (this.questTracker.popupOpen) {
if (this.questTracker.popupContains(tx, ty)) return;
if (this.questTracker.sourceContains(tx, ty)) {
if (this.questTracker.popupOpenedWithin(40)) return; // the opening click
this.questTracker.closePopup(); // a later deliberate re-click — fold it
return;
}
this.questTracker.closePopup();
return;
}
if (this.questTracker.closedPopupAt(tx, ty)) return;
if (this.questTracker.contains(tx, ty)) return;
}
// Mining pop-up OPEN: a click on one of its buttons is the
// button's (its own pointerdown listener); a click ANYWHERE else
// closes the menu and that click is consumed — no fly-here (the
// same contract as the sub-bar).
if (this.miningPopup && this.miningPopup.isOpen) {
if (this.miningPopup.contains(pointer.worldX, pointer.worldY)) return;
this.miningPopup.close();
return;
}
// A click the pop-up's OWN buttons just handled (they close it from
// their side first — the two handlers race on event order): it is
// the menu's click, never a fly-here. The button and this handler
// fire on the SAME input pass (same frame — this.time.now is
// identical), so "same frame AND inside the panel's footprint"
// isolates that exact click without swallowing the player's next
// deliberate one (a click on the rock, to re-open the menu).
if (
this.miningPopup &&
this.miningPopup.closedByButtonAt !== null &&
this.time.now - this.miningPopup.closedByButtonAt < 20 &&
this.miningPopup.containsScreen(pointer.x, pointer.y)
) {
return;
}
// COMM PANEL OPEN (js/ui/CommsPanel.js) — the same contract as the
// mining menu: a click on one of its buttons is the button's (its
// own pointerdown listener); a click INSIDE it is swallowed (no
// fly-here); a click on ANOTHER planet/station moves the panel
// there (the ship does not fly — the click is the panel's, not a
// world move); any other click closes it and that click is
// consumed.
if (this.commsPanel && this.commsPanel.isOpen) {
if (this.commsPanel.contains(pointer.worldX, pointer.worldY)) return;
const obj = this.worldObjectAt(pointer.worldX, pointer.worldY);
if (obj) {
this.openCommsPanel(obj, pointer);
return;
}
// A gate click re-anchors the gate window on that gate (the same
// "another world moves the panel" contract).
const gt = this.gateAt(pointer.worldX, pointer.worldY);
if (gt) {
this.openGateCommsPanel(gt, pointer);
return;
}
this.commsPanel.close();
return;
}
// A click the panel's OWN buttons just handled (they close it from
// their side first — the two handlers race on event order): it is
// the panel's click, never a fly-here (the same same-frame test as
// the mining menu's button guard below it).
if (
this.commsPanel &&
this.commsPanel.closedByButtonAt !== null &&
this.time.now - this.commsPanel.closedByButtonAt < 20 &&
this.commsPanel.containsScreen(pointer.x, pointer.y)
) {
return;
}
// A rock under the cursor: clicking an asteroid opens the mining
// menu (or the stop menu, when this cluster is the beam's target)
// — this click does not fly the ship.
const rock = this.rockAt(pointer.worldX, pointer.worldY);
if (rock) {
this.openMiningMenu(rock, pointer);
return;
}
// A JUMP GATE under the cursor (solid — the ship can't pass
// through it): the click opens the GATE COMM WINDOW
// (openGateCommsPanel) — the LINK line reads ACTIVE / DORMANT,
// REQUEST JUMP initiates the transport (GameScene.jumpThroughGate,
// data/gates.json → jump; a grayed ghost while the gate is
// dormant) and CANCEL closes the window. Like a planet click it
// is NOT a fly-here — the ship stays put.
const gateObj = this.gateAt(pointer.worldX, pointer.worldY);
if (gateObj) {
this.hideHint();
this.openGateCommsPanel(gateObj, pointer);
return;
}
// A PLANET or STATION under the cursor (a rock click is the mining
// flow above): the click opens the comms panel (the landing-request
// window, js/ui/CommsPanel.js) and NOTHING ELSE — it is not a
// fly-here (the ship stays put), and it does not end the mining
// state (no move, so a live beam keeps running).
const obj = this.worldObjectAt(pointer.worldX, pointer.worldY);
if (obj) {
this.hideHint();
this.openCommsPanel(obj, pointer);
return;
}
// Any OTHER click MOVES the ship — which ends the mining state
// (the beam retracts as the ship goes; a mid-reach arm aborts).
// The state exit is signalled via ship.onStateChange above.
if (this.mining.isActive) this.mining.stop();
// The stop point a plain click would have taken: off every solid's
// keep-out rim, clamped to the player's tether range.
let aim = { x: pointer.worldX, y: pointer.worldY };
for (const s of this.solids) {
aim = s.aimPoint(aim.x, aim.y, this.ship.radius);
}
aim = this.tetherField.clampPoint(aim.x, aim.y);
// SHIFT HELD = THROTTLE LOCK: the ship commits to the heading
// toward the click point (raw — the direction the player pointed,
// not the stop point) and keeps flying that way — past the point
// and on, full throttle — until the player steers it somewhere
// else. The world still owns the motion: solids and the tether
// rim hold it back as it drives into them (postupdate constraint).
// (pointer.event is the DOM pointerdown — the press that just
// landed, with its modifier keys.)
if (pointer.event?.shiftKey) {
this.showTargetMarker(aim.x, aim.y); // the aim feedback
this.ship.thrustToward(pointer.worldX, pointer.worldY);
this.hideHint();
return;
}
this.showTargetMarker(aim.x, aim.y);
this.ship.setTarget(aim.x, aim.y);
this.hideHint();
});
// ESC: the topmost open thing closes — the confirm dialog, then the
// save pop-up, then the sub-bar.
this.input.keyboard?.on('keydown-ESC', () => this.escAction());
// The solid keep-outs run AFTER the arcade world has integrated the
// ship's motion (the scene's 'postupdate' fires after the physics step
// has moved the sprite). Constrained in update() instead, the body
// re-applies this frame's inward velocity right after the push-out —
// the ship would dip a frame-deep into every rim it meets at speed.
this.events.on('postupdate', this.onPostUpdate, this);
// SYSTEM EFFECT (the star's character — js/visuals/SystemEffects.js):
// data-driven per type (data/systems.json → effect). "none" (the
// common case) attaches nothing — the scene renders exactly as
// before. A live effect (nebula's ripple) splits the cameras first
// (world pass carries the filter; the UI pass draws the HUD on top,
// unfiltered — js/visuals/UiCameras.js), then attaches the filter
// to the world camera.
this.systemEffects = new SystemEffects(this);
this.systemEffects.apply(this.systemRecord.type);
// ---- COMMS: the fresh-run welcome ----------------------------------
// The 'newGame' trigger (data/comms.json) arms the run's opening
// comm — OAC introducing the ship (ack: the action pauses, the box
// holds, the player reads + clicks ACKNOWLEDGE). It fires on a FRESH
// start only (above: _freshRun) — a LOAD's played ledger already
// holds its id, so a resumed run doesn't re-hear the welcome. The
// hub's delayMs beats the opening toast, and its deferred load pulls
// OAC's clip + speech on demand (the scene's preload stays lean).
if (this._freshRun) this.comms.trigger('newGame');
// A loaded run that ALREADY crossed the 200-mineral line (a save from
// before the 'minerals200' beat existed) arms it here too — on a fresh
// run totalMined is zero so this is a no-op; the hub's ledger /
// in-flight guards keep the beat a one-shot either way.
if ((this.totalMined ?? 0) >= 200) this.comms.trigger('minerals200');
// A loaded run that ALREADY holds discovered worlds (a save from
// before the 'firstWorld' beat existed) arms it here too — on a fresh
// run the count is zero so this is a no-op; the hub's ledger /
// in-flight guards keep the pair a one-shot either way.
if ((this.discoveredWorldCount?.() ?? 0) >= 1) this.comms.trigger('firstWorld');
// The 'Leaving the Nest' beats (Trestle's briefing + arrival —
// data/comms.json: trestle-brief-1/2/3 / trestle-arrive-1/2): the
// scene-create arm covers the loaded-save case — a save parked at
// the capital world (the arrival's witness) or one whose home gates
// were already online (the briefing's) still gets its beats.
// _trestleBeats is the guard (quest held + unclaimed) and the comm's
// once-ledger dedupes the rest (a mid-sequence quit re-arms only the
// unplayed beats).
this._trestleBeats();
// The 'Pirate Problems' warning (data/comms.json: trestle-q4-1/2/3,
// trigger 'trestleQ4') — the same arm for a loaded save that holds
// the quest unclaimed: the comm's once-ledger dedupes the already-
// heard beats (a mid-sequence quit re-arms only the unplayed ones),
// and the quest guard (_trestleQ4Beats) keeps the beats off the
// player before the grant.
this._trestleQ4Beats();
// The 'Pirate Problems' AMBUSH — the ENTER of the Neutral Capital's
// system (the scene was just built for it): the ship is at the entry
// gate (a full gate→capital stretch from the world), so the wave
// starts here only when the spawn already sits within the MIDPOINT
// (a degenerate short stretch) — otherwise update() arms it the
// frame the flight crosses the halfway point (the same method's
// guards are no-op-gated either way). The third arm is the grant
// itself (_questStartBeats — a save whose grant landed while the
// player was already IN the system, past the midpoint or not).
// When it fires, its ships materialize OFF-SCREEN around the player,
// each from a random direction (CombatSystem._spawnWaveEnemy), and
// converge on their own chase AI.
this._ambushSpawn = this.ship ? { x: this.ship.x, y: this.ship.y } : null;
this._maybeStartPirateAmbush();
}
/**
* Post-physics constraints: every solid in the system (worlds AND
* asteroid clusters) holds the ship back at its clearance — fly close,
* never through — and the tether holds the player's range (clamped to
* the union boundary, the line shudders where it was hit).
*
* THROTTLE LOCK (Shift+click): a thrusting ship is the one mover that
* deliberately drives INTO those constraints (it never brakes on its
* own) — so the first frame any constraint actually touches it, the
* ship stops dead right there: the hold ends, the world wins.
*/
onPostUpdate(_time, delta) {
let touched = false;
for (const s of this.solids) {
if (s.constrainShip(this.ship, this.ship.radius)) touched = true;
// The combat pack (the ?combat demo) keeps the same keep-out as the
// ship — the solids push the hostiles off their rims too.
if (this.combat) {
for (const e of this.combat.enemies) {
if (!e.dead) s.constrainShip(e, e.hitRadius);
}
}
}
const tether = this.tetherField.constrainShip(this.ship);
if (tether) {
touched = true;
this.onTetherContact(tether); // the line shudders where it was hit
}
if (touched && this.ship.state === 'thrust') {
this.ship.stop(); // brake dead at the obstacle
this.ship.setState('normal', 'contact'); // the hold is over — back to normal
}
}
/**
* The ?combat boot (a dev URL parameter, like ?fx= / ?type=): a NORMAL
* game — but with the combat demo live from the start (the pack spawns
* when the player starts, see create()). The standalone range is
* combat.html / the dev server's /combat.
* @returns {boolean}
*/
_wantsCombatDemo() {
if (config.get('combat.enabled', true) === false) return false;
if (typeof location === 'undefined') return false;
return new URLSearchParams(location.search).has('combat');
}
/**
* The action-deck swap (combat mode, Diablo 4 style — one bar in the
* same place, depending on the mode): combat LIVE → the combat bar up
* and the deck down; combat over → back to the deck.
* The COMBAT COMMS ride this same flip (data/comms.json →
* oac-enemies-detected / oac-enemies-defeated, both visual, both
* once:false): live → OAC's contact call (the wave run's lead — before
* the first hostile lands — or the ?combat pack's spawn); over (the
* last hostile down) → OAC's sign-off (its delayMs, 1 s, lands it
* after the final explosion's read). The hub's in-flight guard + the
* played ledger keep each flip to one voice.
* @param {boolean} active
*/
_setCombatBar(active) {
if (this.actionBar) this.actionBar.setVisible(!active);
if (this.combatBar) this.combatBar.setVisible(active);
if (active) this.comms?.trigger('combatStart');
else this.comms?.trigger('combatEnd');
}
/**
* The galaxy comes from the shared registry (built by the menu from the
* chosen seed). Dev boots that skip the menu (dev/test-game.html) get a
* fresh dev galaxy so the scene always works standalone — with a
* deterministic seed when one is injected (`globalThis.__ORBIT_DEV_SEED`,
* set by dev/smoke-game.mjs from ?seed=…).
*/
ensureGalaxy() {
this.galaxy = this.registry.get('galaxy') ?? null;
if (!this.galaxy) {
const devSeed = typeof globalThis !== 'undefined' ? globalThis.__ORBIT_DEV_SEED : null;
const seed = devSeed && String(devSeed).length > 0 ? String(devSeed) : Rng.randomSeedString(8);
this.galaxy = Galaxy.create(seed);
this.registry.set('galaxy', this.galaxy);
this.registry.set('seed', seed);
if (!devSeed) console.warn(`[orbit] no galaxy in the registry — generated a dev galaxy (seed "${seed}")`);
}
}
/**
* The effects demo's archetype (js/visuals/SystemEffects.js): read the
* `?fx=` URL parameter (browser only — no-op in Node/tests) and keep it
* only if it names a known system type (data/systems.json → types),
* matched case-insensitively (so `?fx=redDwarf` resolves to the camelCase
* `redDwarf` key). `?fx` alone defaults to `nebula`. The live families
* today: nebula, redDwarf, binary.
* Example: http://localhost:8080/?fx=redDwarf
*
* @returns {string | null} a systems.types key, or null
*/
_readFxSystemType() {
const loc = typeof globalThis !== 'undefined' ? globalThis.location : null;
if (!loc?.search) return null;
try {
const raw = (new URLSearchParams(loc.search).get('fx') ?? '').trim() || 'nebula';
const types = config.section('systems.types', {});
if (!types || typeof types !== 'object') return null;
// Case-insensitive match on the config keys, so `?fx=redDwarf`,
// `?fx=reddwarf`, `?fx=REDWARf` all resolve to the `redDwarf` type.
const wanted = raw.toLowerCase();
for (const key of Object.keys(types)) {
if (key.toLowerCase() === wanted) return key;
}
return null;
} catch {
return null;
}
}
/**
* The world preview's spec (js/galaxy/TypeSystems.js): read the
* `?type=` URL parameter (browser only — no-op in Node/tests) and
* parse it into a { kind, cls?, frame? } spec — a planet class
* (terran/rocky/gas/ice/lava) with an optional 1-based face ordinal
* (terran-02 = the 2nd face of the class's pool), or a station
* (ss / ss-NN variant). Absent → null (no preview); malformed →
* null (with a note — `this._typeSpecRaw` keeps the raw value for
* the message). Case-insensitive throughout.
*
* Examples: http://localhost:8080/?type=terran-01 …or ?type=ss-02 …or ?type=ice-03
*
* @returns {object | null} a parseTypeSpec() result, or null
*/
_readTypeSpec() {
this._typeSpecRaw = null;
const loc = typeof globalThis !== 'undefined' ? globalThis.location : null;
if (!loc?.search) return null;
try {
const raw = new URLSearchParams(loc.search).get('type');
if (raw === null) return null;
this._typeSpecRaw = raw;
const spec = parseTypeSpec(raw);
if (!spec) {
const classes = Object.keys(config.get('planets.frames', {})).join('/');
console.warn(`[orbit] ?type=${raw} — unknown world (try ${classes} or -NN faces) or station (ss / ss-NN).`);
}
return spec;
} catch {
return null;
}
}
/**
* The world preview's TARGET entity (the planet/station the ship
* spawns beside) — resolved from this.typeTarget now that the
* system's entities exist. null = no preview target (or the entity
* is missing — the home-world spawn fallback applies).
*/
_previewEntity() {
const t = this.typeTarget;
if (!t) return null;
if (t.station) {
// Up to two deep-space stations per system — match the entity
// to the settlement record by id (name-safe).
const rec = (this.systemContent?.settlements ?? []).find((s) => s?.kind === 'deepSpaceStation');
if (!rec) return null;
return (this.systemStations ?? []).find((s) => s.settlement?.id === rec.id) ?? null;
}
if (t.ordinal === 0) return this.planet ?? null; // the home world
const rec = (this.systemContent?.planets ?? [])[t.ordinal - 1];
if (!rec) return null;
return (this.systemPlanets ?? []).find((p) => p.discoveryName === rec.name) ?? null;
}
/**
* Top-left HUD: the current system's dossier — the system name, its
* identity ("<Type> system · star <class>"), and its standing: faction
* control (Neutral for now) + total population ("Unclaimed" when none).
* Deliberately compact — the per-settlement and gate detail lives
* elsewhere (star chart, map, the content itself).
* Formatted by the pure SystemReport helper; this method only renders.
*
* The dossier types itself in with the menu's decode scramble
* (js/utils/Decode.js): the system name first, then each data line below
* it, staggered — the console acquiring a signal as the ship arrives.
* A small caret sits to the RIGHT of the name — pointing DOWN while the
* details are open, RIGHT while they're folded — and the name (or the
* caret) is the click target that toggles them (toggleHud):
*
* arrive ──► OPEN by default (name decodes, caret fades in below it…
* beside it), details type in
* │ after 10 s (HUD_AUTO_COLLAPSE_MS) — OR a click on the name
* ▼
* details DECONSTRUCT in reverse build order (status line
* first … subtitle last), then the caret swings down→right
* │
* ▼ a click on the name (or the caret)
* the caret swings right→down, the details type back in
*
* Any manual toggle cancels the one-shot auto-fold — the player took
* control of the dossier. updateHud drives it all (anchored to the
* first frame after create — the scene's TimeClock is still stale in
* create()). It runs every time the scene is created: the first start
* and every new solar system.
*
* The system's CONTENTS were ensured in create() (ensureGalaxy +
* ensureContent) — the first touch of the lazy level-2 generation.
* (Roster/positions were fixed at the menu's New Game click; this is
* just the "build the room" part.)
*/
createSystemHud() {
const report = formatSystemReport(this.systemContent);
const fam = BODY_FONT();
const famHeader = HEADER_FONT();
const current = this.systemRecord;
const X = 16;
let y = 14;
// --- The name: the dossier's anchor and its toggle -----------------
const titleStyle = {
fontFamily: famHeader,
fontSize: '17px',
// v4 quirk: text colors must be CSS strings (see toCss, utils/Color.js).
color: toCss(this.galaxy.typeDefs?.[current.type]?.theme?.color ?? '#9fb4e8'),
letterSpacing: 2,
};
const title = this.add
.text(X, y, report.title, titleStyle)
.setOrigin(0, 0)
.setScrollFactor(0) // UI: pinned to the screen, not the world
.setDepth(30);
const titleW = title.width; // measure the FINISHED name once…
const titleH = title.height;
title.setText(''); // …then let the decode type it out
this.hudTitle = title;
// The state caret — a small triangle to the right of the name:
// pointing down (▾) = details open, right = folded. It fades in once
// the name has landed and swings with the state (rotateCaret).
const caret = this.add
.text(X + titleW + 10, y + titleH / 2, '\u25be', {
fontFamily: fam,
fontSize: '14px',
color: '#8fa0c9',
})
.setOrigin(0.5)
.setScrollFactor(0) // UI: pinned to the screen, not the world
.setDepth(30)
.setAlpha(0);
this.hudCaret = caret;
this.hudCaretShown = false;
// Click target: the name plus the caret beside it (screen space — the
// dossier is pinned UI, so local space == screen space).
this.hudTitleRect = { x: X - 4, y: y - 4, w: titleW + 24, h: titleH + 8 };
y += 26;
// --- The details (open by default) ----------------------------------
const detail = [];
const line = (value, style) => {
const t = this.add
.text(X, y, '', style)
.setOrigin(0, 0)
.setScrollFactor(0) // UI: pinned to the screen, not the world
.setDepth(30);
detail.push({ text: t, value });
y += 20;
};
line(report.subtitle, { fontFamily: fam, fontSize: '12px', color: '#ffffff', letterSpacing: 1 });
y += 2;
line(report.status, { fontFamily: fam, fontSize: '13px', color: toCss(report.statusColor, '#8fa0c9') });
this.hudDetail = detail;
this.hudEndY = y + 6; // bottom of the dossier block
// --- State + the arrival timeline (anchored on the first frame) -----
this.hudPhase = 'constructing'; // 'constructing' | 'expanded' | 'collapsing' | 'collapsed'
this.autoCollapseArmed = true; // the one-shot 10 s auto-fold
this.hudArrivalT0 = null;
this.hudTimeline = {
mode: 'arrive', // the name first, then the data below it
t0: null,
lines: [
{ text: title, value: report.title, isTitle: true },
...detail,
].map((ln, i) => ({
...ln,
delay: HUD_LEAD_IN + i * HUD_STAGGER,
dur: decodeDur(ln.value.length),
settled: false,
})),
};
// ---- The QUEST TRACKER — the thin quest list right below the name --
// Slides in from offscreen-left the moment the dossier has FOLDED
// (the one-shot auto-fold, or the player's manual fold — the first
// fold arms it; visible ⇔ the dossier is folded). It is a VIEW over
// the same live split as the console (questSnapshot — the checklist
// ticks over as research/mining/builds/discovery complete) and the
// same ledger (questState — the ONE priority quest). updateHud shows
// it when a collapse lands; startExpand slides it out first (make
// room); GameScene.update drives its per-frame poll.
this.questTracker = new QuestTrackerHud(this, {
anchorX: X,
anchorY: 40, // just below the dossier title (where the details start)
getSnapshot: () => this.questSnapshot(),
resolvePriority: (ids) => this.questState.resolvePriority(ids),
onSetPriority: (id) => this.setPriorityQuest(id),
onClearPriority: () => this.clearPriorityQuest(),
onViewQuest: (id) => this.openQuestAt(id),
onShowMore: (id) => this.openQuestAt(id),
onClaim: (id) => this.claimQuest(id),
});
}
/**
* The dossier's lifecycle, driven every frame (updateHud is called from
* update() with the engine's loop time — the scene's TimeClock is the
* only clock that runs, see the v4 quirk in update()):
* - the active timeline (arrive / expand / collapse) reveals or
* erases its lines one by one with the shared decode scramble;
* - on arrival the name lands first and the state caret fades in
* beside it;
* - on a collapse the LAST line built erases first (status →
* subtitle), then the caret swings down→right;
* - the one-shot 10 s auto-fold fires once (cancelled by any manual
* toggle).
*/
updateHud(time) {
if (this.hudArrivalT0 === null) {
// First frame after create(): anchor the arrival timeline here
// (this is the same time base update() receives; the scene's
// time.now is still stale).
this.hudArrivalT0 = time;
this.playSfx('construct'); // the dossier starts typing in
const tl = this.hudTimeline;
tl.t0 = time;
for (const ln of tl.lines) ln.dec = new ScrambleDecode(ln.value, time + ln.delay, ln.dur);
}
const tl = this.hudTimeline;
if (tl) {
let done = true;
for (const ln of tl.lines) {
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;
if (ln.isTitle) this.showCaret(); // the name has landed — the state is legible
} else done = false;
}
if (done) {
this.hudTimeline = null;
if (tl.mode === 'collapse') {
this.hudPhase = 'collapsed';
this.rotateCaret(true); // details gone → caret swings to point right
// The dossier has folded — the tracker slides in from offscreen
// left (the first fold arms it; a later fold re-shows it after
// startExpand slid it out to make room).
this.questTracker?.show(this.time.now);
} else {
this.hudPhase = 'expanded';
}
}
}
// The open-by-default dossier folds itself 10 s after arrival —
// unless the player already toggled it (autoCollapseArmed).
if (
this.autoCollapseArmed &&
this.hudPhase === 'expanded' &&
!this.hudTimeline &&
time >= this.hudArrivalT0 + HUD_AUTO_COLLAPSE_MS
) {
this.autoCollapseArmed = false;
this.startCollapse(time);
}
}
/** The state caret fades in once the name has landed (arrival only). */
showCaret() {
if (this.hudCaretShown) return;
this.hudCaretShown = true;
this.tweens.add({ targets: this.hudCaret, alpha: 1, duration: 160, ease: 'Sine.easeOut' });
}
/** The caret swings with the state: down (0) = open, right (−90°) = folded. */
rotateCaret(toRight) {
this.tweens.add({
targets: this.hudCaret,
angle: toRight ? -90 : 0,
duration: 220,
ease: 'Sine.easeInOut',
});
}
/**
* Re-open the details: the caret swings back down first, then the lines
* type back in in build order (subtitle → status) — the same decode as
* arrival, just without the name (it never left).
*/
startExpand(t) {
const begin = (tt) => {
this.hudPhase = 'constructing';
this.playSfx('construct'); // the details type back in
this.rotateCaret(false);
this.hudTimeline = {
mode: 'expand',
lines: this.hudDetail.map((ln, i) => ({
text: ln.text,
value: ln.value,
settled: false,
dec: new ScrambleDecode(ln.value, tt + HUD_EXPAND_LEAD + i * HUD_STAGGER, decodeDur(ln.value.length)),
})),
};
};
const trk = this.questTracker;
if (trk && (trk.isBusy() || trk.shown)) {
// Make room FIRST: the tracker slides offscreen-left, then the
// dossier unfolds (its lines would paint straight over the list).
trk.hide(this.time.now, () => begin(this.time.now));
} else {
begin(t);
}
}
/**
* Fold the details: the lines DECONSTRUCT in reverse build order (status
* line erases first … subtitle last) — the same scramble played
* backwards — and the caret swings down→right once the last one is gone
* (updateHud, when the timeline completes).
*/
startCollapse(t) {
this.hudPhase = 'collapsing';
this.playSfx('deconstruct'); // the details deconstruct
this.hudTimeline = {
mode: 'collapse',
lines: [...this.hudDetail].reverse().map((ln, i) => ({
text: ln.text,
value: ln.value,
settled: false,
// reverse = true: value → shrinking prefix → static → ''
dec: new ScrambleDecode(ln.value, t + i * HUD_STAGGER, decodeDur(ln.value.length), true),
})),
};
}
/**
* The player clicked the system name (or its caret): toggle the details.
* Ignored mid-animation — let the current construct/deconstruct finish.
* A manual toggle also cancels the one-shot auto-fold.
*/
toggleHud() {
if (this.hudTimeline) return;
if (this.questTracker && this.questTracker.isBusy()) return; // a slide is in flight — let it land
this.autoCollapseArmed = false;
if (this.hudPhase === 'expanded') this.startCollapse(this.time.now);
else if (this.hudPhase === 'collapsed') this.startExpand(this.time.now);
}
/** Is (px,py) on the dossier's toggle target — the name or the caret beside it? */
hudTitleContains(px, py) {
const r = this.hudTitleRect;
return !!r && px >= r.x && px <= r.x + r.w && py >= r.y && py <= r.y + r.h;
}
update(_time, delta) {
// Phaser v4 (Giedi): the engine does not step the scene's TimeClock or
// TweenManager — drive them here or delayedCall/addEvent/tweens never
// fire. (Same quirk as MenuScene.update; verified Sept 2026.)
this.time.update(_time, delta);
this.tweens.update();
this.comms?.pump(_time); // comms: queue → play (and its pause contract)
// The ACK pause contract (the CommsHub froze the action): the ship
// holds station, the research/build ticks wait (their deadlines are
// time-based — a project that finishes while the box holds completes
// on the first tick after, its toast landing when the action resumes),
// and discovery is off (the ship isn't moving). The world KEEPS
// breathing — starfield, tether, the comm's own clip — only the
// player's action is paused.
const _commsPaused = this.comms?.paused === true;
// The deck goes to STANDBY while an 'ack' comm holds (the pause
// contract's face): the slots dim and stop hovering/pressing, so the
// pulsing ACKNOWLEDGE button is the only live control on screen.
// setEnabled is a no-op when the state is unchanged (per-frame free),
// and re-arms the frame the comm ends.
this.actionBar?.setEnabled(!_commsPaused);
this.updateHud(_time); // the dossier: decode, caret, auto-fold, toggle
this.systemEffects?.update(_time); // the star's character (wave phase + star's screen UV)
this.questTracker?.update(_time); // the tracker: live checklist + priority
if (!_commsPaused) this.ship.update(_time, delta);
// COMBAT (the ?combat demo): the pack's AI + lances, the ship's
// auto-fire, the tracers, the rams, the player's armor — frozen by an
// ACK comm's pause, like the ship.
if (this.combat) {
// The settle window (above): one-shot re-anchor of the pack while
// the ship's start position settles; a no-op once anchored.
if (this._combatSettleFrames > 0) {
this._combatSettleFrames--;
if (this.combat.resyncAroundShip()) this._combatSettleFrames = 0;
}
if (!_commsPaused) {
this.combat.update(_time, delta);
this.combatBar?.setStats(this.combat);
this.combatHud?.update(_time); // the readability layer (lock/threat/feed/flash)
}
}
this.engineFx?.update(_time, delta); // burn fades even through a pause
// Session time (saved with the game) — capped so a backgrounded tab
// can't fast-forward it.
this.playTimeMs = (this.playTimeMs ?? 0) + Math.min(delta, 100);
// The save UI (the sub-bar + the pop-up) drive their own per-frame
// state (folds, decodes, toasts, the confirm dialog).
this.menuSubBar?.update(_time, delta);
this.savePanel?.update(_time);
this.commsPanel?.update(_time); // the name decode, the bar draw-in, the cursor blink, the flicker
// Research: tick the in-flight project (time-based; tick() reports any
// completion and already unlocked it — the scene applies effects/SFX).
// Paused by an ACK comm (above) — the tick resumes after, and the
// completed project's effects land then (the toast, the deck bar).
const _researchDone = !_commsPaused && this.researchState ? this.researchState.tick(_time) : null;
if (_researchDone?.length) {
for (const d of _researchDone) this._completeResearch(d.category, d.id);
}
// The starter quest's pointing finger — applied before the surfaces
// draw (their update loops paint the rim's pulse from the target).
this._applyTutorialHighlight();
this.researchWindow?.update(_time); // the console's living details (open state)
this.mapWindow?.update(_time); // the map console: reveal, decodes, sweep, ship marker, chart poll
this.questWindow?.update(_time); // the quests console: reveal, decodes, sweep, dossier poll
this._deckResearchBar(_time); // the RESEARCH slot's progress bar
this._applyResearchCue(); // the RESEARCH slot's ready-shimmer (the deck's half)
// Builds: a build started on a surface keeps running in space if the
// player took off mid-build (game-loop clock — see beginBuild); when
// its deadline passes HERE we apply the effect (the world change is
// world state: the tether range outlives the stay).
if (this.buildState && !_commsPaused) {
const _buildDone = this.buildState.tick(this.game.loop.now);
if (_buildDone.length) {
for (const c of _buildDone) this.completeBuild(c.planet, c.build);
this.playSfx('discovery'); // the 'something new is here' voice
}
}
// The clusters are ALIVE: each rock tumbles, the loose group drifts,
// the dust orbits. (The keep-out constraint runs in onPostUpdate,
// after the physics step has moved the ship.)
for (const c of this.asteroidClusters) c.update(_time);
// The route's NEXT STOP in this system wears the orange route pulse
// (js/entities/RoutePulse.js — the in-world "your destination is
// HERE" mark, visible the moment the target is on screen; the
// compass's orange arrow is the off-screen half): in an intermediate
// system it is the system's JUMP GATE toward the destination
// (routeNextGate — the FULL pulse, route.pulse); in the DESTINATION
// system it is the destination OBJECT (a planet or a space station —
// the subdued pulse, route.objectPulse; _routeDestinationEntity).
// Every other object drops it. Both marks are null when no
// destination is set or the route is done.
const _routeGate = this.routeNextGate();
const _routeDest = this._routeDestinationEntity();
if (this.planet) this.planet.setRouteTarget(this.planet === _routeDest);
for (const p of this.systemPlanets) p.setRouteTarget(p === _routeDest);
for (const st of this.systemStations) st.setRouteTarget(st === _routeDest);
for (const gt of this.systemGates) gt.setRouteTarget(gt === _routeGate);
for (const p of this.systemPlanets) p.update(_time); // the route pulse (a no-op when unmarked)
for (const st of this.systemStations) st.update(_time); // the ring turns, the beacon breathes, the route pulse
for (const gt of this.systemGates) gt.update(_time); // the swirl spins + breathes (active gates); fallback art drifts
this.mining.update(_time, delta); // the arm: extending → beam (tracks the drifting rocks)
if (this.scanObjects) this.updateScanHits(_time); // the front crossing objects → ring
this.scanPulse.update(_time, delta); // the pulse: charge → front → absorbed → settle
this.updateSignalCompass(_time); // the secondary compass: bearing signals on the ring
this.updateCamera(delta);
this.starfield.update(); // after the camera, so it sees this frame's motion
this.nebula?.update(); // the gas: parallax + slow self-swirl
this.haze?.update(_time, delta); // redDwarf red haze: follow the camera
this.voidBase?.update(_time, delta); // binary void base: follow the camera
this.storm?.update(_time, delta); // habitable storm: parallax + charge/strike/afterglow
this.nebulaLightning?.update(_time, delta); // nebula: the far-sky bolts (strobe + draw/clear)
this.embers?.update(_time, delta); // redDwarf ember dust: drift + parallax
this.wanderer?.update(_time, delta); // binary companion star: slow wander
this.tetherField.tick(_time, delta); // glitch/pulse lifecycle
this.tetherField.draw(_time); // the barrier (on-screen dots only)
this.actionBar?.update(_time, delta); // the deck's living details
this._checkDestinationReached(); // last leg done? clear the destination (REACHED)
// 'Pirate Problems' step 2 — the AMBUSH's arm: with the quest live
// and its combat step open, the pirate wave starts the moment the
// ship is within HALF the entry-gate → capital-world stretch — the
// conflict begins halfway to the world from the jumpgate (the
// method is no-op-gated, so the per-frame cost is a few ledger
// reads + one distance check).
this._maybeStartPirateAmbush();
// 'Pirate Problems' step 3 — the RETURN's witness: the ship at the
// capital world's rim with the quest held + unclaimed sets
// story.capitalReturned (monotonic; _maybeWitnessCapitalReturn is a
// no-op once it stands, so the per-frame cost is a ledger read).
this._maybeWitnessCapitalReturn();
if (!_commsPaused) this.updateDiscovery(_time, delta); // last: sees this frame's final camera view
}
/**
* The ship met the tether boundary: a small camera kick (impact), and a
* console warning — throttled so a held push against the line doesn't
* spam it. The line itself already shudders locally (TetherField pulse).
*/
onTetherContact(contact) {
const cam = this.cameras.main;
if (typeof cam.shake === 'function') {
cam.shake(80, 0.003 + Math.min(0.004, (contact.vn ?? 0) * 0.00002));
}
const now = this.time.now;
const cooldown = config.get('tether.contact.toastCooldown', 4000);
if (this.tetherToastAt !== null && now - this.tetherToastAt < cooldown) return;
this.tetherToastAt = now;
this.tetherToast();
}
/**
* The console note — the game's top-center signal slot. Discovery toasts,
* the tether warning, and the mining calls all live here: a small accent
* glyph + a console-caps label, fading in, holding, fading out. One at a
* time — a new note replaces any old one.
*
* @param {string} label the console line
* @param {object} [o] { glyph, glyphColor, durationMs, y (screen px; default ~75 above center) }
*/
consoleToast(label, { glyph = '\u25b8', glyphColor = '#00e5ff', durationMs = 2400, y } = {}) {
if (Array.isArray(this.consoleToastG)) {
for (const g of this.consoleToastG) g.destroy();
this.consoleToastG = null;
}
const fam = BODY_FONT();
const g1 = this.add
.text(0, 0, glyph, { fontFamily: fam, fontSize: '13px', color: toCss(glyphColor) })
.setOrigin(0, 0.5)
.setScrollFactor(0); // UI — pinned to the screen, not the world
const g2 = this.add
.text(0, 0, label, {
fontFamily: fam,
fontSize: '11px',
color: toCss(themeColor('dim', 0x7d92c4)),
letterSpacing: 2,
})
.setOrigin(0, 0.5)
.setScrollFactor(0); // UI — pinned to the screen, not the world
const total = g1.width + 10 + g2.width;
const x0 = this.scale.width / 2 - total / 2;
const yy = y === undefined ? this.scale.height / 2 - 75 : y; // ~75 px above the screen center
g1.setPosition(x0, yy).setDepth(45).setAlpha(0);
g2.setPosition(x0 + g1.width + 10, yy).setDepth(45).setAlpha(0);
this.consoleToastG = [g1, g2];
assignUi(this, g1); // the UI pass (no double-draw while the split is live)
assignUi(this, g2);
this.tweens.add({ targets: this.consoleToastG, alpha: 1, duration: 180, ease: 'Sine.easeOut' });
this.time.delayedCall(durationMs, () => {
if (!Array.isArray(this.consoleToastG)) return;
const [a, b] = this.consoleToastG;
this.consoleToastG = null;
this.tweens.add({
targets: [a, b],
alpha: 0,
duration: 350,
onComplete: () => {
a.destroy();
b.destroy();
},
});
});
}
/** Top-center console warning (magenta), high up, clear of the signal slot. */
tetherToast() {
this.consoleToast('TETHER BARRIER — RANGE LIMIT · UPGRADE THE TETHER TO EXTEND IT', {
glyph: '\u26a0',
glyphColor: toCss(themeColor('neon2', 0xff2d6f)),
durationMs: 2600,
y: 54,
});
}
/**
* The camera chases the ship with a frame-rate-independent ease:
* scroll += (shipCenter - scroll) * (1 - e^(-rate·dt))
* While the ship flies, the camera trails it and the starfield streams
* past in the opposite direction (real parallax). When the ship stops,
* the camera keeps easing until it is centered on the ship again — a
* slow recenter (≈1.5 s at followRate 3) with the stars parallaxing
* along for the ride.
*/
updateCamera(delta) {
if (!this.cameraFollowShip || !this.ship) return;
const rate = this.cameraFollowRate;
if (!(rate > 0)) return;
const cam = this.cameras.main;
const k = 1 - Math.exp(-rate * (Math.min(delta, 64) / 1000));
cam.setScroll(
Phaser.Math.Linear(cam.scrollX, this.ship.x - this.scale.width / 2, k),
Phaser.Math.Linear(cam.scrollY, this.ship.y - this.scale.height / 2, k),
);
}
/**
* Discovery, every frame (a handful of distance tests — cheap):
* 1. Ship within discovery distance of a world's edge → DISCOVERED
* (once — state lives in this.discovery), with a rim ping + toast.
* 2. Discovered worlds that are off-screen feed the compass, which
* draws the themed screen-edge arrows pointing back to them.
*/
updateDiscovery(time, delta) {
const objects = this.discoverableObjects();
const sysId = this.systemRecord.id;
const fresh = this.discovery.check(sysId, this.ship.x, this.ship.y, objects);
for (const o of fresh) this.celebrateDiscovery(o);
// Which objects are NOT on screen right now? (The camera never
// zooms, so the world view is scroll + canvas size.)
const cam = this.cameras.main;
const view = { left: cam.scrollX, top: cam.scrollY, w: this.scale.width, h: this.scale.height };
// COMBAT LIVE (a hostile up, or a wave run in flight —
// this.combat.active, the same predicate that raises the combat bar):
// the edge ARROWS and name BOXES yield to the fight — the combat
// chrome (threat markers, off-screen hostile arrows, the kill feed)
// owns the screen edge while the player is shooting, and the nav
// chips would only compete with it. refresh([]) retires the entries;
// the first non-combat frame re-feeds them — the target set is
// recomputed here every frame, so nothing is lost (a mid-fight
// discovery still pings above; only the compass display steps aside).
if (this.combat?.active) {
this.compass.refresh([], view, this.scale.width, this.scale.height, time, delta, this.ship);
return;
}
// The active route's NEXT STOP. In an intermediate system it is that
// system's jump gate toward the destination (routeNextGate); in the
// DESTINATION system it is the destination OBJECT itself (the last hop
// is across the system to the world — routeDestinationObject). It
// claims a compass slot in ORANGE (data/gates.json → route.compassColor)
// so the player always knows where to fly next — and is shown EVEN while
// still undiscovered (it is the thing to find). That object's ordinary
// (cyan/green) entry is suppressed so the two don't stack; when it is on
// screen there is no arrow at all (the player can see it) and the
// suppress is moot. The destination object stays orange until the ship
// actually reaches it (_checkDestinationReached).
const routeGate = this.routeNextGate();
const routeGateId = routeGate?.discoveryId ?? null;
const routeOffscreen = !!(
routeGate && !circleInView(routeGate.x, routeGate.y, routeGate.bound, view)
);
// The final stop: only meaningful in the destination system (no next
// gate). It is the object the player set as the destination.
const destObj = routeGate ? null : this.routeDestinationObject();
const destObjId = destObj?.id ?? null;
const destOffscreen = !!(destObj && !circleInView(destObj.x, destObj.y, destObj.radius, view));
const offscreen = [];
for (const o of objects) {
if (o.id === routeGateId) continue; // the route arrow takes this slot
if (o.id === destObjId) continue; // the destination arrow takes this slot
if (this.discovery.isDiscovered(sysId, o.id) && !circleInView(o.x, o.y, o.radius, view)) {
offscreen.push(o);
}
}
if (routeOffscreen) {
offscreen.push({
id: `route:${routeGate.discoveryId}`,
x: routeGate.x,
y: routeGate.y,
radius: routeGate.bound,
typeLabel: config.get('gates.route.typeLabel', 'Route Gate'),
color: config.get('gates.route.compassColor', '#ff8c1a'),
name: routeGate.discoveryName,
// The "next gate" is the one thing the player must find — keep it at
// the full readout (arrow at scale 1 + type + name) no matter how far
// it sits, and resolvable by autopilotTo (the `route:` id).
alwaysFull: true,
});
}
if (destOffscreen) {
offscreen.push({
id: `dest:${destObj.id}`,
x: destObj.x,
y: destObj.y,
radius: destObj.radius,
typeLabel: config.get('gates.route.destTypeLabel', 'Destination'),
color: config.get('gates.route.compassColor', '#ff8c1a'),
name: destObj.name,
// The final stop — same always-on treatment as the next gate.
alwaysFull: true,
});
}
// The ship's position drives the compass's FAR display (targets
// beyond game.discovery.compass.farDistance fold to their compact
// chip + shrunken arrow until hovered — js/ui/DiscoveryCompass.js).
this.compass.refresh(offscreen, view, this.scale.width, this.scale.height, time, delta, this.ship);
}
/** The discoverable objects of the system, with compass metadata. */
discoverableObjects() {
const out = [];
// The central body — the home world in the STARTING SYSTEM ONLY
// (discovery id 'home': the chart's central NAV point). Every other
// system's center is empty — its star is invisible flavor (content.star
// feeds the dossier, not the chart) — so no central NAV point there.
if (this.planet) {
out.push({
id: 'home',
x: this.planet.x,
y: this.planet.y,
radius: this.planet.radius,
typeLabel: config.get('planets.homeTypeLabel', 'Home World'),
// The compass accent: the home world is a PLANET (green —
// data/planets.json → compassColor, like the system's worlds).
color: config.get('planets.compassColor', '#3dff88'),
name: this.planet.discoveryName,
});
}
for (const p of this.systemPlanets) {
out.push({
id: p.discoveryId,
x: p.x,
y: p.y,
radius: p.radius,
typeLabel: config.get(`planets.typeLabels.${p.name}`, p.name),
// The compass shows planets in green (data/planets.json →
// compassColor) — the arrow + name tag pick it up.
color: config.get('planets.compassColor', '#3dff88'),
name: p.discoveryName,
});
}
// Asteroid clusters count as objects too: discoverable, compass arrows,
// autopilot — at the scale of their extent (bound).
for (const c of this.asteroidClusters) {
out.push({
id: c.discoveryId,
x: c.x,
y: c.y,
radius: c.bound,
typeLabel: config.get('asteroids.typeLabel', 'Asteroid Cluster'),
// The compass shows clusters in their own light gray (their own
// accent — data/asteroids.json → compassColor) — the arrow +
// name tag pick it up.
color: config.get('asteroids.compassColor', '#c8d2e0'),
name: c.discoveryName,
});
}
// Space stations are objects too: discoverable, compass arrows,
// autopilot — and comms targets — at the scale of their keepout.
for (const st of this.systemStations) {
out.push({
id: st.discoveryId,
x: st.x,
y: st.y,
radius: st.bound,
typeLabel: config.get(`settlements.kinds.${st.kind}.label`, 'Station'),
// The compass shows stations in bright purple (data/stations.json →
// compassColor) — the arrow + name tag pick it up.
color: config.get('stations.compassColor', '#c84dff'),
name: st.discoveryName,
});
}
// Jump gates are objects too: discoverable, compass arrows, autopilot —
// in their own cyan (the exits, so easy to spot on the compass).
for (const gt of this.systemGates) {
out.push({
id: gt.discoveryId,
x: gt.x,
y: gt.y,
radius: gt.bound,
typeLabel: config.get('gates.typeLabel', 'Jump Gate'),
color: config.get('gates.theme.color', '#5fd4ff'),
name: gt.discoveryName,
});
}
return out;
}
/**
* Autopilot — the player clicked a compass name tag (any UI could wire
* to this): send the ship to a discovered object. It flies to the
* keep-out rim — the clearance point on the side the ship is coming
* from — and arrives to a stop, exactly like a click-to-fly onto the
* rim. Works for worlds AND asteroid clusters (the solid behind the
* discovery entry). Clicking elsewhere retargets the same way (last
* click wins).
*/
autopilotTo(id) {
if (this._commInputBlocked()) return; // the comm owns the screen (the ship holds)
// While the sub-bar is open (or this very click just closed it — the
// scene's handler and the chip's own listener race on event order),
// the chip's click is the menu-dismiss, not a navigation command.
const bar = this.menuSubBar;
if (bar && (bar.isOpen || bar.closing)) {
if (bar.isOpen) bar.close();
return;
}
// A ROUTE compass entry carries a prefixed id (see updateDiscovery):
// `route:<gateId>` for the orange next-gate, `dest:<objectId>` for the
// orange final stop. Resolve either to the object's real discovery id so
// both can be autopiloted like any other off-screen object.
let effectiveId = id;
if (typeof id === 'string') {
if (id.startsWith('route:')) effectiveId = id.slice('route:'.length);
else if (id.startsWith('dest:')) effectiveId = id.slice('dest:'.length);
}
const o =
this.discoverableObjects().find((v) => v.id === effectiveId) ??
this.discoverableObjects().find((v) => v.id === id);
if (!o) return;
// The solid body behind this discovery entry (a world, a cluster, or
// for a ROUTE gate — the gate itself). Must use `effectiveId`, not the
// raw `route:<id>` — otherwise the lookup misses and the aim falls back
// to the central body, so the ship flew at the STAR's rim in the gate's
// direction instead of to the gate (the "wrong direction" the player hit).
const solid =
this.solids.find((s) => s.discoveryId === effectiveId) ??
this.solids.find((s) => s.discoveryId === id) ??
this.planet;
// Approach point: on the rim (clearance + hull), on the side the ship
// is approaching from (center → ship direction) — it ends up facing
// the object. If the object is outside the player's tether range, the
// aim clamps to the union boundary — the ship flies as far as its
// tether lets it (and rests on the line) until the tether grows.
const dx = this.ship.x - o.x;
const dy = this.ship.y - o.y;
let aim = solid.edgePoint(Math.atan2(dy, dx), solid.clearance, this.ship.radius);
// Belt and braces: no other solid may own this point either.
for (const s of this.solids) aim = s.aimPoint(aim.x, aim.y, this.ship.radius);
aim = this.tetherField.clampPoint(aim.x, aim.y);
this.showTargetMarker(aim.x, aim.y);
this.ship.setTarget(aim.x, aim.y);
this.hideHint();
}
// ==================================================================
// Mining — the ship's energy arm (js/mining/Mining.js, MiningBeam.js)
// ==================================================================
/**
* Which rock (if any) sits under a WORLD point — the click-to-mine
* hit test. Nearest rock wins (clusters are far apart; members may
* slightly overlap). A few px of slop so smaller rocks are easy to hit.
*/
rockAt(wx, wy) {
let best = null;
let bestD = Infinity;
for (const c of this.asteroidClusters) {
for (const m of c.members) {
const d = Math.hypot(wx - m.wx, wy - m.wy);
if (d <= m.radius + 8 && d < bestD) {
best = { cluster: c, member: m, x: wx, y: wy };
bestD = d;
}
}
}
return best;
}
/**
* A rock click: the context menu unfolds right where the player
* clicked (js/ui/MiningPopup.js — world-anchored on the asteroid).
* The primary button is "Mine Asteroids" — or "Stop Mining" when
* this cluster is already the beam's target.
*/
openMiningMenu(rock, pointer) {
const isTarget = this.mining.state === 'mining' && this.mining.cluster === rock.cluster;
// The pop-up plays its own ui_window whoosh (MiningPopup.open).
this.miningPopup.open(rock.x, rock.y, pointer.y, {
name: rock.cluster.discoveryName,
primaryId: isTarget ? 'stop' : 'mine',
primaryLabel: isTarget ? 'Stop Mining' : 'Mine Asteroids',
target: rock,
});
this.hideHint();
}
/** The pop-up's button presses (MiningPopup → onAction). */
miningAction(id, rock) {
this.miningPopup.close();
if (!rock) return;
if (id === 'mine') {
if (this.mining.state === 'extending') return; // the arm is committed
this.mining.begin(rock.cluster, rock.member);
} else if (id === 'stop') {
this.mining.stop();
}
// 'cancel' — just close.
}
// ==================================================================
// Comms — the planet / station comms panel (js/ui/CommsPanel.js)
// ==================================================================
/**
* Which comms object (if any) sits under a WORLD point — the home
* world, a system planet, or a space station — hit-tested at the
* scale of its keepout circle (radius + clearance, the flyable rim).
* Nearest wins (they don't overlap). Rocks are NOT comms objects —
* they are the mining flow's (rockAt above).
*/
worldObjectAt(wx, wy) {
let best = null;
let bestD = Infinity;
// The central body is a comms target only when it IS the home world —
// the star (every other system) is not a comms object (like a gate).
for (const s of [...(this.isHomeSystem ? [this.planet] : []), ...this.systemPlanets, ...this.systemStations]) {
const r = s.radius + (s.clearance ?? 0);
const d = Math.hypot(wx - s.x, wy - s.y);
if (d <= r && d < bestD) {
best = s;
bestD = d;
}
}
return best;
}
/**
* The gate under (wx, wy), closest wins — the hit circle is its
* keepout (gate radius + ship clearance) plus a touch of pad, the
* same spirit as rockAt. (Gates are deliberately absent from
* worldObjectAt — they are NOT comms targets.)
*/
gateAt(wx, wy) {
let best = null;
let bestD = Infinity;
for (const gt of this.systemGates ?? []) {
const r = (gt.radius ?? 0) + (gt.clearance ?? 0) + 8;
const d = Math.hypot(wx - gt.x, wy - gt.y);
if (d <= r && d < bestD) {
best = gt;
bestD = d;
}
}
return best;
}
/**
* THE JUMP (data/gates.json → jump): click an ACTIVE gate and the
* run moves to the connected system, arriving near that system's
* RETURN gate (the one pointing back — activated with the gate per
* the ACTIVITY rule, its tether anchoring the ship's room to move;
* the pure geometry is js/galaxy/JumpTravel.js). With shortcuts OFF
* the network is a pure spanning tree, so every jump has a return
* gate; the origin fallback below is defensive only (the destination
* center is empty — its star is invisible flavor).
*
* HOW: the save pipeline, in miniature. captureState() snapshots the
* WHOLE run (discovery, reputation, research, builds, minerals,
* playtime, activatedGates); the destination system + arrival
* position are swapped in; prepareLoad() re-stages the shared state
* (the galaxy rebuilt from the seed — deterministic, same names and
* placements — with the new current system); scene.restart() rebuilds
* this scene onto it: the destination's native tethers (home + its
* activated gates) re-form in create(), its dossier/HUDs rebuild,
* and _onEnterSystem() grants its map tech.
*
* @param {object} gt the gate entity (JumpGate — .gate record, .x/.y,
* .radius, .clearance, .active)
*/
jumpThroughGate(gt) {
if (this._jumping) return;
const from = this.systemRecord;
const destId = gt?.gate?.to;
const dest = destId ? this.galaxy.byId.get(destId) : null;
if (!dest) {
this.consoleToast('NO LINK — GATE DESTINATION UNKNOWN', {
glyph: '⌁',
glyphColor: toCss(themeColor('neon2', 0xff9b9b)),
});
return;
}
if (this.mining?.isActive) {
this.consoleToast(
String(config.get('gates.jump.miningToast', 'CANNOT JUMP WHILE MINING')),
{ glyph: '⌁', glyphColor: toCss(themeColor('neon2', 0xff9b9b)) },
);
return;
}
// COMBAT — a live fight (CombatSystem.active — a wave run in flight,
// the 'Pirate Problems' ambush included, or hostiles up) holds the
// ship in the system: a jump mid-wave would carry the run out of its
// own fight (the wave run is scene-local — the enemies and the
// fight's momentum would die with the scene, the run would simply
// vanish with the threat). Same flat denial shape as the mining
// guard above.
if (this.combat?.active) {
this.consoleToast(
String(config.get('gates.jump.combatToast', 'CANNOT JUMP WHILE UNDER ATTACK')),
{ glyph: '⌁', glyphColor: toCss(themeColor('neon2', 0xff9b9b)) },
);
return;
}
// Where we arrive: just past the return gate's keepout (JumpTravel).
// Fallback — no return gate (a one-way shortcut, shortcuts are OFF in
// the current config): the destination's origin (its center is empty —
// the star is invisible flavor, so this is just open space).
const destContent = this.galaxy.ensureContent(destId);
// ROUTE BOOKKEEPING — resolve what this hop means for the active route
// BEFORE captureState below snapshots the run (so a destination we've
// just reached is cleared in the saved state, not resurrected):
// arrived-at-destination → clear; on-course → silent; detour → toast.
this._routeForJump(from.id, destId);
// THE RUN'S FOOTPRINT — record the lane we just traveled + the system
// we are entering (the GALAXY tab's brighter lane + charted region;
// captured into `rec` right below, so the save carries it).
this.usedGates.add(edgeKey(from.id, destId));
this.visitedSystems.add(destId);
const arrival =
jumpArrival(destContent, from.id, {
radius: gt.radius ?? 0,
clearance: gt.clearance ?? 0,
shipRadius: this.ship?.radius ?? 0,
gap: Number(config.get('gates.jump.arrivalGap', 128)) || 0,
}) ?? { x: 0, y: 0, heading: 0, gateId: null };
const rec = captureState(this, this.time.now);
rec.currentSystemId = destId;
// The AMBUSH's bookkeeping — which gate the player came in through:
// the new scene resolves its entry gate as
// returnGateFor(content, lastJumpFrom) and measures the 'Pirate
// Problems' midpoint from it (GameScene._pirateAmbushEntryGate).
// A panel save carries the field through (captureState), so a
// reload keeps the same midpoint; a save predating it simply lacks
// the field (the nearest-gate fallback).
rec.lastJumpFrom = from.id;
rec.systemName = dest.name;
rec.ship = {
x: arrival.x,
y: arrival.y,
heading: arrival.heading,
minerals: rec.ship?.minerals ?? 0,
// The credit ledger + trade-goods hold ride the jump too (the ship
// keeps them — only the position + the system change hands). Without
// these the restore below would re-seed 0 credits and an empty hold.
credits: rec.ship?.credits ?? 0,
cargo: rec.ship?.cargo ?? [],
// The unlocked cargo bays ride the jump too (the bar scene's DONE
// beat — effects.cargoHold — or a cargo-bay build). Without this the
// restore would re-seed the fresh ship's 0 and shed the goods
// aboard (the 4-bay hold would die on the first jump).
cargoHold: rec.ship?.cargoHold ?? 0,
};
rec.tethers = []; // the destination's native set re-forms in create()
prepareLoad(this.registry, rec);
// The cut: the console line + the sfx, then the restart — short
// enough to read, long enough to hear (the scene's TimeClock drives
// the delay; update() steps it, see the v4 quirk note there).
this._jumping = true;
this.ship.stop(); // no steering across the cut
this.hideHint();
this.consoleToast(
String(config.get('gates.jump.toast', 'JUMP — {dest}'))
.replace('{dest}', String(dest.name).toUpperCase()),
{ glyph: '⌁', glyphColor: toCss(themeColor('neon', 0x00e5ff)), durationMs: 1200 },
);
this.playSfx('discovery');
this._playJumpClip();
}
/**
* The jump clip — a full-screen one-shot played between the two systems
* (data/gates.json → jump.video). The destination is ALREADY staged behind
* it (prepareLoad ran in jumpThroughGate), so the moment the clip ends we
* restart the scene onto it. Mirrors SurfaceScene's landing clip: play to
* completion, with a STALL guard (a clip that never starts must not hold
* the jump) and a CAP (duration + margin), both advancing anyway. A
* missing/empty clip falls back to the short jumpDelayMs cut.
*/
_playJumpClip() {
this._jumpFinished = false;
this._jumpLastClickT = 0;
if (!this.hasVideo(JUMP_VIDEO_KEY)) {
// No clip (disabled or file missing) — the old short cut.
this.time.delayedCall(
Number(config.get('gates.jump.jumpDelayMs', 420)) || 420,
() => this._finishJump(),
);
return;
}
const W = this.scale.width;
const H = this.scale.height;
// Opaque backdrop: the source system must not show through the clip (or
// its first-frame decode gap). scrollFactor(0) pins it to the SCREEN —
// this scene's camera follows the ship and scrolls, so a world-space
// overlay (the default scrollFactor 1) would land off-screen; the same
// reason every screen-pinned UI here (toast, action bar, HUD) does it.
this._jumpBackdrop = this.add
.rectangle(W / 2, H / 2, W, H, toColor(themeColor('bg', 0x04060d)))
.setScrollFactor(0)
.setDepth(60);
assignUi(this, this._jumpBackdrop); // the UI pass (no double-draw)
// v4: add.video(x, y, key) — the key is the LAST argument (it loads the
// cached clip and attaches the <video> element). scrollFactor(0) keeps
// the clip pinned to the screen as the camera scrolls (see above).
const v = this.add.video(0, 0, JUMP_VIDEO_KEY).setOrigin(0.5).setScrollFactor(0).setDepth(61);
this._jumpVideo = v;
assignUi(this, v); // the UI pass (no double-draw)
v.setVolume(Math.max(0, Math.min(1, Number(config.get('gates.jump.videoVolume', 1)))));
this._fitJumpClip(v);
// Re-fit on the first presented frame (v4: the bookkeeping size is a
// placeholder until then — see SurfaceScene.attachClip).
v.on('created', (vv, w, h) => {
if (vv === v) this._fitJumpClip(vv, w, h);
});
v.play();
v.on('complete', () => this._finishJump());
v.on('error', () => this._finishJump());
const el = v.video;
const playing = () => !!el && (el.currentTime > 0.1 || !el.paused);
// STALL — no progress after a grace period (autoplay locked, decode
// failure, …): advance anyway.
this.time.delayedCall(6000, () => {
if (this._jumpVideo === v && !this._jumpFinished && !playing()) this._finishJump();
});
// CAP — expected to end by duration + margin; if the element goes silent
// before it, advance anyway.
const durS = Number(el && el.duration);
if (Number.isFinite(durS) && durS > 0) {
this.time.delayedCall(durS * 1000 + 5000, () => {
if (this._jumpVideo === v && !this._jumpFinished) this._finishJump();
});
}
}
/** Cover-scale the jump clip to the screen, cropping whatever overflows. */
_fitJumpClip(v, iw = 0, ih = 0) {
const el = v.video;
const vw =
iw || (el && (el.videoWidth || el.width)) || (v.frame && v.frame.realWidth) || 864;
const vh =
ih || (el && (el.videoHeight || el.height)) || (v.frame && v.frame.realHeight) || 480;
const W = this.scale.width;
const H = this.scale.height;
const s = Math.max(W / vw, H / vh);
v.setPosition(W / 2, H / 2);
v.setScale(s);
}
hasVideo(key) {
const c = this.cache?.video;
return !!(c && typeof c.has === 'function' && c.has(key));
}
/**
* The jump's cut — idempotent. Tears down the clip (free the decoder, drop
* the listeners) and restarts the scene onto the staged destination.
* scene.restart() destroys the scene's objects anyway; this is explicit and
* safe (the complete/error/stall/cap guards never double-restart).
*/
_finishJump() {
if (this._jumpFinished) return;
this._jumpFinished = true;
if (this._jumpVideo) {
try {
this._jumpVideo.off();
this._jumpVideo.stop(false);
this._jumpVideo.destroy();
} catch {
/* already gone */
}
this._jumpVideo = null;
}
if (this._jumpBackdrop) {
try {
this._jumpBackdrop.destroy();
} catch {
/* already gone */
}
this._jumpBackdrop = null;
}
this.scene.restart();
}
/**
* What the comms panel shows for a planet or station:
* - settled — it hosts a settlement (a colony / mining station /
* cloud base anchored to the planet), or IS one (a
* free-space station); the home world is settled by
* definition (the player's homestead)
* - key — the reputation standing key (the anchored planet's
* FIRST settlement id — the stable `<system>-s<n>` id;
* HOME_KEY for home; the station's own id)
* - reputation — the standing on that key (neutral 0 when none)
* - canLand — standing > −4 (the panel grays the button at ≤ −4)
* - kindLabel — a short flavor line under the name
* - isPlanet / isStation / frame — the landing handoff (startLanding →
* SurfaceScene): a PLANTED planet or a deep-space station
* (both wear a spritesheet frame — the planet's class
* frame / the station's variant frame — and land with
* the matching clips: data/landing.json → videos /
* stationVideos).
*/
commsTargetFor(obj) {
let key = null;
let settled = false;
let kindLabel = '';
let isStation = false;
let frame = null;
if (obj === this.planet && this.isHomeSystem) {
// The home world: standing pinned at the scale's top.
key = HOME_KEY;
settled = true;
kindLabel = config.get('planets.homeTypeLabel', 'Home World');
frame = this.planet?.sheetFrame ?? 0;
} else if (obj.settlement) {
// A free-space station — settled by definition; key = its id.
key = obj.settlement.id;
settled = true;
// Deep-space stations wear a spacestations.png variant (the
// galaxy-wide spread pass — js/galaxy/StationFrames.js) and label
// themselves by it (data/stations.json → typeLabels — the fallback
// is the kind's label).
isStation = obj.kind === 'deepSpaceStation';
const variantLabel = typeof obj.sheetFrame === 'number'
? config.get(`stations.typeLabels.${obj.sheetFrame}`)
: null;
kindLabel = variantLabel || config.get(`settlements.kinds.${obj.kind}.label`, 'Station');
frame = isStation ? (obj.sheetFrame ?? 0) : null;
} else {
// A system planet: settled when a settlement is anchored to it
// (anchor.type 'planet', anchor.ordinal = the planet's ordinal).
const rec = (this.systemContent.planets ?? []).find((p) => p.name === obj.discoveryName);
if (rec) {
// typeLabels is keyed by the planet's CLASS (rec.class —
// 'rocky'/'gas'/…), not its proper name (rec.name) — a name
// lookup would miss and fall back to the name, so the comms
// panel and the surface HUD would show the name twice.
kindLabel = config.get(`planets.typeLabels.${rec.class}`, rec.class);
const anchored = (this.systemContent.settlements ?? []).filter(
(s) => s.anchor?.type === 'planet' && s.anchor?.ordinal === rec.ordinal,
);
if (anchored.length > 0) {
settled = true;
key = anchored[0].id;
}
}
frame = obj.sheetFrame ?? 0;
}
const rep = settled ? (this.reputation.standingFor(key) ?? 0) : 0;
const isPlanet = !obj.settlement; // home world + system planets vs free-space stations
const landingOn = config.get('landing.enabled', true) === true; // landing.json kill-switch
return {
name: obj.discoveryName,
settled,
key,
reputation: rep,
// Landing = the surface sequence: a PLANTED world (population > 0)
// or a deep-space station (always settled) with enough goodwill.
// The home world's button is hidden as a UI fact (the comms panel).
canLand: settled && rep > -4 && landingOn && (isPlanet || isStation),
kindLabel,
isPlanet,
isStation,
frame, // planets.png / spacestations.png frame → landing.json videos
};
}
/**
* A planet or station was clicked: the comms panel opens at the click
* — the name decodes in, the reputation bar draws (settled), the
* landing buttons wait. The click is NOT a fly-here — the ship stays
* exactly where it is. The panel is world-anchored at the click point
* and picks a side (up/down/left/right) that keeps it fully on screen
* (CommsPanel.pickSideAndPlace).
*/
openCommsPanel(obj, pointer) {
const t = this.commsTargetFor(obj);
// The FULL target goes through the panel — lastTarget comes back to
// commsAction() and needs `isPlanet`/`frame` (the landing handoff),
// not just the display fields.
this.commsPanel.open(pointer.worldX, pointer.worldY, pointer.x, pointer.y, {
...t,
litMarks: this.reputation.marksFor(t.reputation),
});
}
/**
* A JUMP GATE was clicked: the comm window opens at the click — the
* name decodes in, the LINK line reads ACTIVE / DORMANT, and the
* buttons wait (REQUEST JUMP is a grayed ghost while the gate is
* dormant or gates.jump.enabled is off). Same contract as a planet
* click: NOT a fly-here (the ship stays put), world-anchored at the
* click, side-picked to stay on screen (CommsPanel.pickSideAndPlace).
*/
openGateCommsPanel(gt, pointer) {
this.commsPanel.open(pointer.worldX, pointer.worldY, pointer.x, pointer.y, {
name: gt.discoveryName,
kindLabel: String(config.get('gates.typeLabel', 'Jump Gate')),
gate: {
active: gt.active,
jumpEnabled: config.get('gates.jump.enabled', true) !== false,
// The JumpGate entity — commsAction hands it to jumpThroughGate.
entity: gt,
},
});
}
/**
* The panel's button presses (CommsPanel → onAction). The panel is
* already closed by the time this runs:
* 'request-jump' — the gate window, REQUEST JUMP → the JUMP
* 'request-landing' — a settled world, standing > −4 → land (the
* capital world: startLanding's claim gate denies
* until 'Leaving the Nest' is claimed)
* 'land' — an unsettled world → land (same gate for the
* capital world, were it ever unsettled)
* 'cancel' — just close (the console log is the ack)
* Both transports keep their OWN combat guard on top of the panel's
* (GameScene.jumpThroughGate / startLanding — a live fight,
* CombatSystem.active, denies the jump and the landing: the run can't
* leave the system it's fighting in).
*/
commsAction(id, target) {
this.commsPanel.close(); // the buttons close the panel (like miningAction)
if (!target) return;
if (id === 'request-jump') {
// The panel only offers this when the gate is active and
// gates.jump.enabled — jumpThroughGate keeps its own guards
// (mining in progress, a live combat, a missing destination).
const gt = target.gate?.entity;
if (gt) this.jumpThroughGate(gt);
return;
}
if ((id === 'request-landing' || id === 'land') && (target.isPlanet || target.isStation)) {
this.startLanding(target);
return;
}
// A landing press on a non-port target (defensive — the panel only
// offers it on planets + stations) — acknowledged on the console.
console.info(`[orbit] comms: ${id} — ${target.name} (key: ${target.key ?? 'none'})`);
}
/**
* The capital world's landing gate (data/quests.json →
* capitalDenyToast): true when the comms target is the NEUTRAL CAPITAL
* WORLD — the 'Leaving the Nest' quest's destination planet (the
* neutralCapitalTarget's world name, matched case-insensitively; the
* comms panel only opens on this system's objects, so a name hit IS the
* capital) — and the quest's reward is NOT CLAIMED yet
* (QuestState.isClaimed). Quests off (the config kill-switch) = the
* gate is off too: with no quest there is no claim to wait for.
* @param {object} target the comms panel's landing target (commsTargetFor)
* @returns {boolean} landing on this target is blocked
*/
_capitalLandingBlocked(target) {
if (config.get('quests.enabled', true) === false) return false;
if (this.questState?.isClaimed?.('leaving_the_nest')) return false;
const cap = String(this.galaxy?.neutralCapitalTarget?.()?.objectId ?? '').trim().toLowerCase();
const name = String(target?.name ?? '').trim().toLowerCase();
return cap !== '' && cap === name;
}
/**
* LAND (commsAction) — the surface sequence: SurfaceScene starts ON
* TOP of this scene, handed the world's planets.png sheet frame so it
* can pick the landing/surface videos from data/landing.json, plus
* the world's type label and tether level for its upper-left HUD.
* v4 note: `launch` does not freeze the caller, so sleep the flight
* world explicitly — Take Off (SurfaceScene) wakes it back, with the
* ship, tethers and discovery exactly where they were.
*/
startLanding(target) {
if (!config.get('landing.enabled', true)) {
console.info(`[orbit] comms: landing disabled for ${target.name}`);
return;
}
// COMBAT — a live fight (CombatSystem.active — a wave run in flight,
// the 'Pirate Problems' ambush included, or hostiles up) holds the
// ship in the air: landing would SLEEP the flight scene under the
// wave — the player would hide on the surface while the raiders
// wait, and the fight's momentum (the wave run is scene-local) would
// die with the sleep. The fight ends where it started, in the air.
// Same flat denial shape as the capital's claim gate below.
if (this.combat?.active) {
const t = config.get('landing.combatDenyToast', {});
this.playSfx('ui_close');
this.consoleToast(
String(t.text ?? 'CANNOT LAND WHILE UNDER ATTACK'),
{
glyph: t.glyph ?? '✗',
glyphColor: t.color ? toCss(t.color) : toCss(themeColor('red', 0xff5c77)),
durationMs: 3200,
},
);
return;
}
// The CAPITAL WORLD holds its door until the 'Leaving the Nest'
// reward is CLAIMED (the quest's 200 credits — the quest itself
// completes when the ship arrives at this planet; the claim is the
// last step before the world opens). A request before the claim is
// a flat denial on the console (the comms panel already closed —
// the toast is the ack).
if (this._capitalLandingBlocked(target)) {
const t = config.get('quests.capitalDenyToast', {});
this.playSfx('ui_close');
this.consoleToast(
String(t.text ?? 'REQUEST DENIED. PLEASE CLAIM YOUR QUEST REWARD FIRST.'),
{
glyph: t.glyph ?? '✗',
glyphColor: t.color ? toCss(t.color) : toCss(themeColor('red', 0xff5c77)),
durationMs: 3200,
},
);
return;
}
// Name-keyed world state (build records, tether labels) is keyed by
// the world's CANONICAL spelling — resolve whatever casing the
// target arrived in (UI display paths uppercase) to that spelling
// before it crosses into the surface (js/utils/WorldNames.js).
// Station names are in the list too — a deep-space station is a
// world to the surface deck, so its name keys the deck's lookups
// just like a planet's.
const name = canonicalPlanetName(target.name, [
this.planet?.discoveryName,
...(this.systemPlanets ?? []).map((p) => p.discoveryName),
...(this.systemStations ?? []).map((s) => s.discoveryName),
...(this.tetherField?.tethers ?? []).map((t) => t.label),
]);
// A live scan would leave the camera roll/zoom mid-wobble — the scene
// sleeps (or shuts down) and never restores it. Cut it, and drop the
// compass emission (a flight-scene navigation aid — no use on the
// surface, and it shouldn't resurface stale on the way back up).
if (this.scanPulse?.busy) {
this.scanPulse.cancel();
this.scanObjects = null;
}
this.scanReveal = null;
this.hideHint();
// A previous surface (left sleeping by an earlier Take Off) is shut
// down + restarted by this — the fresh init gets this world's frame.
// sheetFrame is the raw planets.png / spacestations.png index
// (`.frame` is the texture Frame OBJECT — Number() of it is NaN, and
// the video/music lookups need the number). `station` tells the
// surface deck which clip set the frame indexes into (data/landing.json
// → videos for planets, stationVideos for station variants).
// FIRST LANDING (data/builds.json → tether-l1): the world hosts its
// level-1 tether the moment the player lands — free + instant, so no
// build-console step (planets and deep-space stations alike). Before
// the launch, so the surface HUD's tetherLevel snapshot already
// reads 1.
this.ensureLandingTether(name);
// 'Special Delivery' bookkeeping (a real landing, post-guards): the
// story ledger's lastLandedWorld, and on the NEUTRAL CAPITAL — the
// bar: the trade gate lifts + the package lands in the hold.
this._recordLanding(name);
this.scene.launch('SurfaceScene', {
frame: Number(target.frame ?? target.sheetFrame ?? 0),
name,
type: target.kindLabel ?? '',
tetherLevel: this.tetherLevelFor(name),
station: target.isStation === true,
});
this.scene.sleep();
}
/**
* The tether LEVEL anchored on this world (the surface HUD's second
* line — "Tether Level N"): the tether whose label is the world's
* name, or whose anchor sits on the world's center (the home tether
* is anchored at the origin with the home world's name). 0 = no
* tether on this world yet.
*/
tetherLevelFor(name) {
if (!name || !this.tetherField) return 0;
const obj =
this.systemPlanets.find((p) => p.discoveryName === name) ??
this.systemStations.find((s) => s.discoveryName === name) ??
(name === this.planet?.discoveryName ? this.planet : null);
if (!obj) return 0;
const t = this.tetherField.tethers.find(
(tt) => tt.label === name || (tt.x === obj.x && tt.y === obj.y),
);
return t ? t.level : 0;
}
/**
* FIRST-LANDING INSTALL (data/builds.json → tether-l1): the moment the
* player lands on a world — a planet or a deep-space station — the
* world hosts its level-1 tether (5120 px, data/tether.json), with no
* build-console step: the build is free + instant (cost {}, duration
* 0), so the landing simply completes it. The record then reads BUILT
* in the console, and the level-2 build's planet gate (a world holding
* a level-1 tether) is met. Idempotent — the home world's starting
* seed (tether-l1.starting) already records it, and a world that
* already hosts a tether is adopted, never anchored twice.
*/
ensureLandingTether(worldName) {
const state = this.buildState;
if (!worldName || !state) return;
if (state.isBuilt(worldName, 'tether-l1')) return;
if (this.tetherLevelFor(worldName) >= 1) {
// The world already hosts a tether without a build record — adopt
// it (the console reads BUILT, the L2 gate is met) instead of
// anchoring a second field.
state.markBuilt(worldName, 'tether-l1');
return;
}
const def = defById('tether-l1');
if (!def?.effects) return; // build system off / the data is gone
this._applyBuildEffects(worldName, def.effects); // anchors + the toast
state.markBuilt(worldName, 'tether-l1');
this.playSfx('construct'); // the power-up tick — the manual build's voice
this._questRefresh(); // a tether requirement may have just landed
}
// --------------------------------------------------------------- 'Special Delivery'
// The run's story beats around the neutral capital (data/quests.json →
// special_delivery — granted when 'Leaving the Nest' is claimed). The
// quest's checklist is LIVE (_questCheckEval); these seams own the
// WITNESSES the checks read, recorded in this.story (the registry's
// 'story' — SaveData carries it to record.story and back):
// · _recordLanding — startLanding (a real arrival): the last
// world landed on; the capital landing
// (the bar — the trade gate lifts). Landing
// on the home world is NO LONGER the
// delivery's proof (the sale is — below);
// · onVignetteComplete — the bar scene's DONE press: the hold is
// built out (the scene's effects) and the
// special package lands in the hold;
// · onSurfaceDeparture — SurfaceScene.finishTakeoff (a real
// launch): after the package's sale it
// arms the 'deliveredLaunch' comms chain
// (Carol ×3 → OAC's sign-off, the payoff);
// the capital launch sets the destination
// back to the HOME WORLD (the delivery's
// route — the compass points it);
// · onQuestPackageSold — the shop's CONFIRM of the package's sale:
// saleable on the home world ONLY (TradeGrid's
// gate), at zero credits — that sale is
// step 3's proof (the parcel in Carol's
// hands), recorded monotonic.
// The witnesses are persistent, so a save/reload keeps the trade gate
// lifted, the package aboard (the cargo step's witness), and the
// delivery leg in flight.
// ---------------------------------------------------------------
/** Is this world the NEUTRAL CAPITAL (the neutralCapitalTarget's world —
* matched case-insensitively, the way _capitalLandingBlocked does)? */
isCapitalWorld(worldName) {
const cap = String(this.galaxy?.neutralCapitalTarget?.()?.objectId ?? '').trim();
const name = String(worldName ?? '').trim();
return cap !== '' && cap.toLowerCase() === name.toLowerCase();
}
/**
* The surface TRADE gate (the shop's Buy/Sell tab — the shop's sub-bar
* stays live everywhere, but trading is dark until the player reaches
* + LANDS on the neutral capital): true once the story ledger holds the
* capital landing. Read by the shop's sub-bar (js/ui/shop/ShopView.js —
* the Buy/Sell tab's disabled state; the shop button itself is always on).
*/
isTradeUnlocked() {
return this.story?.capitalLanded === true;
}
/** Is this world the HOME WORLD (the run's origin — name-matched against
* this.homeWorldName, case-insensitively)? */
isHomeWorld(worldName) {
const home = String(this.homeWorldName ?? '').trim();
const name = String(worldName ?? '').trim();
return home !== '' && home.toLowerCase() === name.toLowerCase();
}
/**
* 'Special Delivery' bookkeeping on a world landing (startLanding —
* after the guards, so this is a real arrival, not a refused one):
* · the ledger's lastLandedWorld (the run's arrival trail);
* · the NEUTRAL CAPITAL landing (the quest's bar beat): the trade gate
* lifts (isTradeUnlocked). The package handoff does NOT run here —
* it rides the bar scene's DONE press (onVignetteComplete: the
* hold's built out first, the package second — the ship starts
* with cargoHold 0 and can't take the parcel until then).
* Landing on the HOME world no longer completes the quest on its own:
* the delivery's proof is the package's SALE there (onQuestPackageSold
* — the parcel in Carol's hands, not the ship at the gate).
* Pure bookkeeping — the checklist itself is live, so _questRefresh
* announces a READY quest if the landing was the last piece (the bar,
* on the capital's arrival).
*/
_recordLanding(name) {
if (!this.story) return;
this.story.lastLandedWorld = String(name ?? '');
if (this.isCapitalWorld(name)) {
this.story.capitalLanded = true; // the trade gate lifts (the bar's scene runs its own beat)
}
this._questRefresh(); // a step may have just landed (the bar, on the capital)
}
/**
* The package handoff (the quest's packageGood — data/quests.json →
* special_delivery.packageGood, the 'special-package' in
* data/goods.json, sheet frame 114): the SPECIAL PACKAGE joins the
* hold. ONE per ship — a repeat is a no-op while it is aboard; a full
* hold makes room by dropping the OLDEST ordinary good (the package is
* the quest, cargo is trade — defensive: the scene's hold is fresh and
* empty when this runs). Called by onVignetteComplete AFTER the
* hold's upgrade (the parcel must fit — the ship starts with 0 bays).
* The player-facing beat is the SurfaceScene's console note (the
* completion carries it out — the flight HUD sits behind the surface's
* opaque plate for the whole stay).
* @returns {boolean} true when the package was JUST placed (the beat is due)
*/
_handoffSpecialPackage(goodId = null) {
const ship = this.ship;
if (!ship?.cargo) return false;
const id = String(goodId ?? questDef('special_delivery')?.packageGood ?? '').trim();
if (!id) return false; // the quest data is gone — the handoff is off too
if (ship.cargo.has(id)) return false; // already aboard — idempotent
if (ship.cargo.room <= 0) {
const i = ship.cargo.items.findIndex((x) => x !== id);
if (i < 0) return false; // a hold of nothing but the package (it can't be)
ship.cargo.items.splice(i, 1); // make room — the package outranks trade
}
return ship.cargo.add(id) > 0;
}
/**
* A VIGNETTE scene's DONE press (SurfaceScene._vignetteComplete → here,
* the ledger's owner): the scene's story beat, in order —
* 1. the scene's EFFECTS (data/vignettes.json → effects): the
* cargo-hold build-out (effects.cargoHold, MAX-wins — the run's
* ship starts with cargoHold 0, data/ship.json; Carol's crew is
* the labor, the scene's lines are the truth);
* 2. the scene's QUEST's package (data/quests.json → packageGood):
* the 'special-package' joins the hold — AFTER the upgrade (the
* hold must fit it). Its presence is the 'cargoUpgraded' step's
* witness (the check's id) — the parcel aboard proves the ship
* can hold cargo.
* _questRefresh then stands the just-made steps (steps 1 + 2) DONE.
* @returns {{upgradedTo: number|null, handoff: boolean}} the beats that
* fired (the SurfaceScene calls them out in its console slot)
*/
onVignetteComplete(vig) {
const out = { upgradedTo: null, handoff: false };
if (!vig) return out;
// 1 — the hold's built out (the scene's effects — pure data the
// model kept): MAX-wins, never a downgrade (a later hold — a
// cargo-bay build, 8/12/16 — is never stepped down by this).
const hold = Math.floor(Number(vig.effects?.cargoHold) || 0);
if (hold > 0 && typeof this.ship?.setCargoHold === 'function') {
const cur = Number(this.ship.stats?.cargoHold) || 0;
if (hold > cur) {
out.upgradedTo = this.ship.setCargoHold(hold);
this.refreshCreditsHud(); // the hold is now worth more — keep the readout honest
}
}
// 2 — the scene's quest's package (the handoff): the parcel joins the
// hold NOW — its presence is the 'cargoUpgraded' witness.
const questId = String(vig.quest ?? '').trim();
if (questId !== '') {
const goodId = questDef(questId)?.packageGood;
if (goodId && this._handoffSpecialPackage(goodId)) out.handoff = true;
}
if (out.upgradedTo != null || out.handoff) {
this._questRefresh(); // steps 1 + 2 may stand done now
}
return out;
}
/**
* The surface TAKE OFF seam (SurfaceScene.finishTakeoff → here).
* Two beats ride on it:
*
* 1 — the 'Special Delivery' PAYOFF (data/comms.json →
* 'deliveredLaunch'): the launch back into space AFTER the
* package's home-world sale — Carol's bar line set it up ('After
* you've done that, launch back into space and I'll contact you',
* data/vignettes.json → special_delivery_bar_meeting). Witnessed by
* the run's story ledger (story.delivered — set by the shop's
* CONFIRM of the package's sale, onQuestPackageSold), armed here
* on any departure once it stands; the comms' once-ledger keeps
* the four-beat chain (Carol ×3 → OAC's sign-off) a one-shot and
* any later re-arm a no-op. Queued while this scene still owns the
* run — the hub plays the chain once the flight world is back
* (the FIFO, each ack holding until its own ACKNOWLEDGE).
*
* 2 — 'Special Delivery' step 3's leg: launching off the NEUTRAL
* CAPITAL (the bar is done, the package is aboard) records the
* launch in the story ledger and sets the run's destination back
* to the HOME WORLD (the delivery's route — the compass marks it,
* and the REACHED toast fires on the home world's arrival via
* _checkDestinationReached). A launch off any other world is a
* no-op for this leg — the quest's auto-course earns itself only
* from the capital's departure.
*/
onSurfaceDeparture(worldName) {
if (this.story?.delivered === true) {
this.comms?.trigger('deliveredLaunch'); // the payoff chain — the once-ledger dedupes
}
if (!this.story || !this.isCapitalWorld(worldName)) return;
this.story.launchedFromCapital = true; // the leg is made
this._questRefresh(); // the delivery leg may have just completed
const homeId = this.galaxy?.homeSystemId;
if (!homeId || this.systemRecord?.id === homeId) return; // already home (defensive)
this.setDestination(homeId, 'home'); // the compass points home
}
/**
* The 'Special Delivery' step 3's seam (ShopView.confirm — the shop's
* CONFIRM of a sale that included the quest's package). The package is
* the run's story, not a commodity: the ONE floor that takes it is the
* HOME WORLD's (TradeGrid's saleable gate — every other world's grid
* refuses the bay, the tooltip says NOT FOR SALE), and it prices at
* zero (the good's `sellFor`, data/goods.json). THAT sale is the
* delivery — Carol gets her parcel — and it sets the witness the
* 'packageDelivered' check reads (this.story.delivered), recorded
* MONOTONIC: once it stands, a later landing elsewhere can't take it
* back (the quest can be CLAIMED and stay claimed; the cargo step's
* witness — the package aboard — is already on the ledger's completed
* set by then, so it stays done too).
* A sale off the home world is a no-op (the grid's gate makes it
* unreachable — belt and braces).
* @param {string} worldName the world the sale ran on (the surface's planet)
* @returns {boolean} true when the delivery stood NOW (not before)
*/
onQuestPackageSold(worldName) {
if (!this.story) return false;
if (!this.isHomeWorld(worldName)) return false; // not the home world — no delivery here
if (this.story.delivered === true) return false; // already stands (monotonic)
this.story.delivered = true; // step 3 made — the parcel is in Carol's hands
this._questRefresh(); // the checklist may now read READY (all three steps)
return true;
}
/**
* Mining phase changes (Mining → onPhase): the scene's share of the
* sequence — the ship's STATE, the ship lock, the console calls, the sfx.
*/
onMiningPhase(phase) {
if (phase === 'extending') {
this.ship.stop(); // hold station — the arm needs a steady hull
this.ship.setState('mining', 'mining'); // the arm's sequence owns the ship
this.playSfx('mining'); // the arm powers up
this.hideHint();
// The console call, in the same slot as the discovery toasts —
// it holds for the whole reach, then fades as the beam fires.
this.consoleToast('Extending Mining Arm...', {
glyph: '\u2316',
glyphColor: toCss(themeColor('neon', 0x00e5ff)),
durationMs: this.mining.armExtendMs,
});
} else if (phase === 'mining') {
this.setMiningLoop(true); // the beam is live — the hum runs until the sequence ends
} else if (phase === 'stopped') {
this.setMiningLoop(false); // the arm pulls back — the hum ends with it (no one-shot sound)
// Back to normal — but ONLY when the ship is still in its mining
// state (the beam-out / full-hold ending: the ship is parked). When
// the player moved the ship out of 'mining' (a click-to-fly, or a
// Shift+click throttle lock), that movement already owns the
// ship's state ('normal' or 'thrust') — don't stomp it.
if (this.ship.state === 'mining') this.ship.setState('normal', 'mining-ended');
}
}
/**
* Mining events (Mining → onEvent): the ore's scene share — the crack
* sfx (the new mining-02.mp3, once per break) and the console calls.
* 'split' the rock broke (data: kind 'absorb' = shattered into
* pieces that fly to the ship, 'divide' = split in half)
* 'absorbed' the shattered pieces hit the hull (data.gained minerals)
* 'storageFull' the hold is full — the run ended (mining stops so the
* rock isn't ground away for nothing)
*/
onMiningEvent(name, data = {}) {
const neon = toCss(themeColor('neon', 0x00e5ff));
const magenta = toCss(themeColor('neon2', 0xff2d6f));
if (name === 'split') {
this.playSfx('mining_split'); // the crack — assets/fx/mining-02.mp3, once per break
this.consoleToast(
data.kind === 'absorb'
? `SHATTERED — ${data.pieces} × ${data.pieceSize} px PIECES`
: `SPLIT — ${data.pieces} × ${data.pieceSize} px ROCKS`,
{ glyph: '\u25c6', glyphColor: neon },
);
} else if (name === 'absorbed') {
this.consoleToast(`+${data.gained} MINERALS ABOARD`, { glyph: '\u2295', glyphColor: neon });
this.refreshMineralHud(); // belt & braces — the landings already fed it via onOre
} else if (name === 'storageFull') {
this.consoleToast('MINERAL STORAGE FULL', { glyph: '!', glyphColor: magenta });
this.refreshMineralHud();
}
}
/**
* A cluster's LAST rock was consumed (Mining → onDepleted): the field is
* DEPLETED. It drops out of the WORLD (this.asteroidClusters — compass
* arrows, rock hit tests — and this.solids, so the ship may now fly
* through the spot) and out of the CONTENT RECORD (the map's chart +
* resource stats read content.asteroids), and its id joins the run's
* depleted list (a save/load keeps it gone — the content regenerates
* from the seed; js/save/SaveData.js). The shattered pieces already
* flying home are scene images, not cluster children, so they finish
* the ride after destroy(). The field's menu (if still open) closes —
* it would float over the void.
*/
onClusterDepleted(cluster) {
const i = this.asteroidClusters.indexOf(cluster);
if (i !== -1) this.asteroidClusters.splice(i, 1);
const j = this.solids.indexOf(cluster);
if (j !== -1) this.solids.splice(j, 1);
const arr = this.systemContent?.asteroids;
if (Array.isArray(arr)) {
const k = arr.findIndex((c) => c && c.id === cluster.discoveryId);
if (k !== -1) arr.splice(k, 1);
}
this.depletedClusters.add(`${this.systemRecord.id}:${cluster.discoveryId}`);
if (this.miningPopup?.isOpen && this.miningPopup.lastTarget?.cluster === cluster) {
this.miningPopup.close();
}
cluster.destroy();
this.consoleToast(`${cluster.discoveryName.toUpperCase()} FIELD DEPLETED`, {
glyph: '\u25cc',
glyphColor: toCss(themeColor('neon', 0x00e5ff)),
});
}
/**
* Mirror the ship's hold into the upper-right readout (js/ui/MineralHud.js).
* A no-op there when the value is unchanged (no re-tween, no re-flash),
* so this is safe to call from every ore seam (per extraction tick, per
* fragment landing, per event).
*/
refreshMineralHud() {
if (!this.mineralHud || !this.ship) return;
this.mineralHud.set(this.ship.minerals, this.ship.stats.mineralStorage);
}
/**
* Mirror the ship's credit ledger into the upper-right readout
* (js/ui/CreditsHud.js) — the line stacked above the minerals HUD. A
* no-op when unchanged (no re-count), so it's safe to call from every
* credit seam (quest claims, selling goods, …).
*/
refreshCreditsHud() {
if (!this.creditsHud || !this.ship) return;
this.creditsHud.set(this.ship.credits);
}
/**
* THE DAILY BATCH SPEND (model v2 — the economy's one stateful seam).
* After a confirmed trade, the ORIGIN system's remaining batch drops by
* the units the player bought of its signature good (the buy side is
* the only stock-limited side — the sell side is unlimited, the market
* is a sink). Pure math in js/economy/Stock.js (spend returns a NEW map
* — the save-safe reference swap); this just owns the reference.
* ShopView.confirm() calls it for each batch-limited good bought (its
* remaining() is finite only when THIS system is that good's origin).
* A dry day is recorded as { day, left: 0 } — an ABSENT entry would
* read as a full batch for the day.
*/
spendStock(systemId, qty) {
if (!systemId) return this;
this.economyStock = Stock.spend(this.economyStock, systemId, qty);
this.registry.set('economyStock', this.economyStock);
return this;
}
/**
* The mining hum (data/sfx.json → mining_loop, assets/fx/mining-01.mp3):
* a loop that starts when the beam goes live (phase 'mining') and
* stops when the sequence ends (phase 'stopped'). A retarget re-fires
* 'mining' without a 'stopped' in between — keep ONE hum across
* retargets (the isPlaying guard). No-ops when SFX are disabled or the
* asset is missing (the shared voice's guards, js/utils/Sfx.js).
*/
setMiningLoop(on) {
if (on) {
if (!config.get('sfx.enabled', true)) return;
if (sfxPlayingOn(this, 'mining_loop')) return; // already humming (a retarget)
playSfxOn(this, 'mining_loop', { loop: true });
} else {
stopSfxOn(this, 'mining_loop');
}
}
/**
* Play one of the configured sound effects (data/sfx.json).
* Silently no-ops when SFX are disabled, the sound manager isn't
* available (headless/test), or the asset never loaded — the game
* never blocks or warns on sound. (Shared voice: js/utils/Sfx.js.)
*/
playSfx(name) {
playSfxOn(this, name);
}
/** The deep-space soundtrack (data/music.json → game): shuffled tracks,
* one at a time, advancing when a track ends. Clean to re-fire — a
* restart stops the old track and its timer first. */
startGameMusic() {
startMusicShuffleOn(this, config.get('music.game', []));
}
stopGameMusic() {
stopMusicShuffleOn(this);
}
/** The "new object" moment: a rim ping at the world + a HUD toast. */
celebrateDiscovery(o) {
const neon = themeColor('neon', 0x00e5ff);
this.playSfx('discovery'); // the signal that something new is here
// Expanding ring at the world's rim (world space).
const ring = this.add.circle(o.x, o.y, o.radius, 0, 0).setStrokeStyle(2, neon, 0.9).setDepth(6);
assignWorld(this, ring); // world-anchored — keep it off the UI pass
this.tweens.add({
targets: ring,
scale: 1.12,
alpha: 0,
duration: 750,
ease: 'Sine.easeOut',
onComplete: () => ring.destroy(),
});
// Center HUD toast (~75 px above screen center), in the console language.
const type = (o.typeLabel ?? 'OBJECT').toUpperCase();
const name = o.name ? String(o.name).toUpperCase() : null;
const label = name ? `DISCOVERED — ${name} · ${type}` : `DISCOVERED — ${type}`;
this.consoleToast(label, {
glyph: '\u25b8',
glyphColor: toCss(neon),
durationMs: 2400,
});
this._questRefresh(); // a 'Discover another world' requirement may have just landed
// The comms beat (data/comms.json: firstCluster — oac-first-cluster):
// OAC celebrates the FIRST rock field the player finds. The scene
// flag fires the event exactly once; the comm's once-ledger is the
// authoritative dedupe across saves.
if (!this._firstClusterSeen) {
const isCluster = (this.asteroidClusters ?? []).some((c) => c.discoveryId === o.id);
if (isCluster) {
this._firstClusterSeen = true;
this.comms?.trigger('firstCluster');
}
}
// The comms beat (data/comms.json: firstWorld — the trestle pair):
// Trestle meets the player the moment the FIRST world is discovered.
// The witness is the QUEST's own (discoveredWorldCount — the
// 'Discover another planet or space station' check: any planet or
// space station, the home world never counts, in any system), so the
// comm and the quest can never drift apart. The scene flag fires the
// event once; the comm's once-ledger dedupes across saves.
if (!this._firstWorldSeen && (this.discoveredWorldCount() ?? 0) >= 1) {
this._firstWorldSeen = true;
this.comms?.trigger('firstWorld');
}
// The system-complete milestone (data/comms.json: systemComplete —
// oac-system-complete): the moment this system's NAV chart COMPLETES —
// the discovery that makes its 'Unlock Jumpgates' research AVAILABLE
// (the SYSTEM tree's available hook, js/research/SystemCategory.js —
// the node flips live in the research console), NOT the research's
// completion. OAC reads the milestone; the voice rolls one of the
// three discovery-complete clips (audioPool); delayMs lets the
// discovery chime + toast land first. One fire per system — the
// arming set in create(), because a later cluster discovery in a
// charted system still passes isSystemNavComplete() (clusters aren't
// NAV points).
const sysId = this.systemRecord?.id;
if (sysId && this.isSystemNavComplete() && !this._systemCompleteSeen.has(sysId)) {
this._systemCompleteSeen.add(sysId);
this.comms?.trigger('systemComplete');
}
}
showTargetMarker(x, y) {
const color = toColor(config.get('game.markerColor', '#41c7ff'));
const marker = this.add.circle(x, y, 10, color, 0.8).setDepth(5);
assignWorld(this, marker); // world-anchored — keep it off the UI pass
this.tweens.add({
targets: marker,
scale: 2.4,
alpha: 0,
duration: 450,
ease: 'Sine.easeOut',
onComplete: () => marker.destroy(),
});
}
hideHint() {
if (!this.hint || !this.hint.active) return;
if (this._hintClosing) return; // a fade-out is already running
this._hintClosing = true;
this.tweens.add({
targets: this.hint,
alpha: 0,
duration: 400,
onComplete: () => {
this._hintClosing = false;
if (this.hint) this.hint.destroy();
this.hint = null;
},
});
}
/**
* True while the ACK comm owns the input — it's up (paused), or it just
* ended on a click (the endedWithin race window, so the click that
* ACKNOWLEDGED never leaks into a world/deck/ESC action). The scene's
* input seams gate on this; the pause's action-freeze gates on
* comms.paused alone (strict).
*/
_commInputBlocked() {
const c = this.comms;
return !!(c && (c.paused || c.endedWithin(200)));
}
/**
* The deck's button presses (js/ui/ActionBar.js → onAction).
* 'menu' is the save system's door (menuAction below); the other
* slots are still seams for the player's loop.
*/
deckAction(id) {
// The ACK comm owns the input (no deck while the box holds — and the
// click that ACKNOWLEDGED is the comm's, never the deck's).
if (this._commInputBlocked()) return;
// The research console (full-screen, depth 80) — the deck's main feature.
if (id === 'research') {
if (this.researchWindow && this.researchWindow.isOpen) {
this.researchWindow.close();
return;
}
if (this.menuSubBar && this.menuSubBar.isOpen) this.menuSubBar.close();
if (this.commsPanel && this.commsPanel.isOpen) this.commsPanel.close();
this.researchWindow?.open();
return;
}
// The quests console (full-screen, depth 80) — the deck's third console.
if (id === 'quests') {
if (this.questWindow && this.questWindow.isOpen) {
this.questWindow.close();
return;
}
if (this.menuSubBar && this.menuSubBar.isOpen) this.menuSubBar.close();
if (this.commsPanel && this.commsPanel.isOpen) this.commsPanel.close();
if (this.researchWindow && this.researchWindow.isOpen) this.researchWindow.close();
this.questWindow?.open();
return;
}
// The map console (full-screen, depth 80) — the deck's other console.
if (id === 'map') {
if (this.mapWindow && this.mapWindow.isOpen) {
this.mapWindow.close();
return;
}
if (this.menuSubBar && this.menuSubBar.isOpen) this.menuSubBar.close();
if (this.commsPanel && this.commsPanel.isOpen) this.commsPanel.close();
if (this.researchWindow && this.researchWindow.isOpen) this.researchWindow.close();
this.mapWindow?.open();
return;
}
if (id === 'menu') {
this.menuAction();
return;
}
if (id === 'scan') {
this.startScan();
return;
}
console.info(`[orbit] command deck: ${id}`);
}
// ------------------------------------------------------------ tutorial
/**
* The starter quest's POINTING FINGER (data/quests.json → tutorial —
* the targets' names ride on the data): which control is asking for
* the player's attention right now. The beat table is pure and
* node-tested in js/quests/TutorialGuide.js; this feeds it the run's
* live records:
* null the comms still own the screen, or the arc's goal (the
* Tether Level 2 research) is in flight / done — rest;
* 'scan' the welcome sequence is acknowledged (oac-intro →
* oac-tether → oac-scan all on the run's comms played-ledger)
* and SCAN hasn't been pressed yet → the deck's SCAN slot;
* 'research' OAC's 'research a Tether Level 2 upgrade' line
* (oac-upgrade) is on the ledger and the console is closed →
* the deck's RESEARCH slot;
* 'console' the same beat with the console open → the console's
* category tab, and — once the player has clicked it — the
* node's plate (ResearchWindow paints the right half from
* its own active category, so the click completes the beat).
* Derived from the records only — nothing extra is saved; a loaded run
* picks the same beat back up.
*/
_tutorialTarget() {
if (config.get('quests.enabled', true) !== true) return null;
const tut = config.section('quests.tutorial', {});
if (tut.enabled === false) return null;
const played = this.comms?.played;
if (!(played instanceof Set)) return null;
const cat = tut.console?.category ?? 'exploration';
const node = tut.console?.node ?? 'tether_l2';
const run = this.researchState?.getActive();
const resolving =
this.researchState?.isUnlocked(cat, node) === true ||
(run && run.category === cat && run.id === node);
return tutorialTarget({
starterGranted: this.questState?.isGranted('start_your_journey') === true,
hasPlayed: (id) => played.has(id),
scanPressed: this._tutScanPressed === true,
consoleOpen: this.researchWindow?.isOpen === true,
nodeResolving: resolving,
});
}
/**
* Apply the pointing finger to whichever surface owns it (per frame,
* update() — before the surfaces draw, so their update() paints the
* pulse from the target). Both setters are idempotent and no-ops for
* their current value, so re-applying every frame is cheap; the
* ActionBar hides the rim under the deck's standby (the 'ack' hold's
* contract — the ACKNOWLEDGE button is the only live control then),
* and the window hides it while closed.
*/
_applyTutorialHighlight() {
const target = this._tutorialTarget();
const tut = config.section('quests.tutorial', {});
this.actionBar?.setHighlight(
target === 'scan'
? tut.scan?.slot ?? 'scan'
: target === 'research'
? tut.research?.slot ?? 'research'
: null,
);
const con = this.researchWindow;
if (con) {
if (target === 'console') {
con.setHighlight(tut.console?.category ?? 'exploration', tut.console?.node ?? 'tether_l2');
} else {
con.setHighlight(null, null);
}
}
}
// ------------------------------------------------------------ quests
/**
* Has the named quest's reward been CLAIMED — the ledger's paid-out set
* (the same witness the run's chain uses to grant the NEXT quest,
* QuestState.grantUnlocked)? The research trees' STORY-QUEST gate
* (data/research/<id>.json → node. `quest`; today mining/premium_arm +
* mining/advanced_storage wait on 'special_delivery') asks this through
* attachQuestGates' live predicate. Claim (reward collected) is the bar
* — not checklist completion alone: the tech opens the moment the player
* presses CLAIM on the dossier and stays closed until then.
* @param {string} id a quest id (data/quests.json → quests[].id)
* @returns {boolean}
*/
questClaimed(id) {
return !!this.questState && this.questState.isClaimed(id);
}
/**
* The live dossier snapshot (QuestWindow polls it while open):
* the categories (tabs) + every HELD quest with its checklist
* evaluated against the run RIGHT NOW — research unlocks, lifetime
* asteroid mining, the home tether's standing, the discovery ledger —
* and made MONOTONIC by the ledger's completed set: a step that has
* read DONE is recorded and stays DONE (the witness rides the save),
* even where the live read can no longer see it (the home tether's
* standing once the ship has left the home system). The window is a
* pure view over this; the scene owns the effects (claimQuest) — the
* same split as the research/map consoles.
* @returns {{categories: Array<object>, quests: Array<object>}|null}
*/
questSnapshot() {
if (!this.questState) return null;
const tabs = config.get('quests.tabs', []);
const categories = (Array.isArray(tabs) ? tabs : []).map((c) => ({
id: String(c?.id ?? ''),
label: String(c?.label ?? c?.id ?? ''),
standby: c?.standby === true,
accent: c?.accent ?? null,
}));
const quests = [];
for (const def of questDefs()) {
if (!def || !this.questState.isGranted(def.id)) continue;
const checks = (Array.isArray(def.checks) ? def.checks : [])
.map((c) => this._questCheckView(def.id, c));
const doneCount = checks.filter((c) => c.done).length;
const complete = checks.length > 0 && doneCount === checks.length;
quests.push({
id: def.id,
category: def.category ?? 'main',
title: def.title ?? def.id,
description: def.description ?? '',
issuer: def.issuer ?? '',
icon: def.icon ?? 'diamond',
checks,
reward: this._questRewardLabel(def),
complete,
claimed: this.questState.isClaimed(def.id),
progress: `${doneCount}/${checks.length}`,
});
}
return { categories, quests };
}
/**
* One checklist row for the dossier: the step's LIVE evaluation
* (_questCheckEval) made MONOTONIC by the quest ledger's completed
* set (QuestState). A step that reads DONE is recorded on the ledger
* (markCheck — with its detail line, refreshed while the live read
* still holds it) and from then on the RECORD is its source of truth:
* the check stands even where the live read can no longer see it (the
* home world's tether standing once the ship has left the home
* system), and a completed step can never read open again. The ledger
* is the run's — it rides the saves (record.quests), so the standing
* survives scene restarts + loads.
* @returns {{label: string, done: boolean, detail: string}}
*/
_questCheckView(questId, check) {
const live = this._questCheckEval(check);
const key = questCheckKey(check);
if (live.done) {
this.questState.markCheck(questId, key, live.detail); // the witness (idempotent)
return live;
}
if (this.questState.isCheckDone(questId, key)) {
return {
label: live.label,
done: true,
detail: this.questState.checkDetail(questId, key) ?? live.detail,
};
}
return live;
}
/**
* One checklist requirement, evaluated LIVE (data/quests.json →
* quests[].checks[].type). Pure reads — no effects here.
* research — the node is unlocked (data/research/<cat>.json)
* mineralsMined — lifetime asteroid mining >= amount (this.totalMined)
* tetherBuilt — the world's tether standing >= level ('home' = the home world)
* discoveredWorld — discovered planets/stations (not the home world) >= count
* systemCharted — the named system's NAV chart is complete (all its
* planets/stations/jumpgates discovered)
* gatesActivated — the named system's jumpgates are live
* trestleTalked — Trestle's three brief comms played (the comms ledger)
* trestleMet — Trestle's arrival pair played (the comms ledger)
* capitalLanded — the run landed on the neutral capital (this.story —
* the 'Special Delivery' bar beat + the trade gate)
* cargoUpgraded — the quest's package is ABOARD (check.id — the
* 'special-package', data/goods.json): it only fits
* once the hold is upgraded (the run's ship starts
* with cargoHold 0, data/ship.json), and the bar
* scene's DONE press is what upgrades + loads it
* (onVignetteComplete) — the parcel aboard is the
* upgrade's proof (recorded on the ledger's
* completed set the moment it reads DONE, so the
* step STAYS done even after the package is sold)
* packageDelivered— the delivery's proof holds (this.story.delivered —
* the package's HOME-WORLD SALE at zero credits;
* onQuestPackageSold sets it — landing alone no
* longer completes the step)
* vignettePlayed — the named VIGNETTE scene completed (registry
* 'vignettesPlayed' — VignetteHub marks it when the
* scene ENDS; data/vignettes.json's scene is the step
* this check is the witness of)
*/
_questCheckEval(check) {
const label = String(check?.label ?? '');
const type = String(check?.type ?? '');
if (type === 'research') {
const cat = String(check.category ?? '');
const node = String(check.node ?? '');
const done = !!this.researchState?.isUnlocked?.(cat, node);
return { label, done, detail: done ? 'RESEARCHED' : 'PENDING' };
}
if (type === 'mineralsMined') {
const amount = Math.max(0, Math.floor(Number(check.amount) || 0));
const got = Math.min(amount, Math.floor(this.totalMined ?? 0));
const done = amount > 0 && got >= amount;
return { label, done, detail: `${got}/${amount}` };
}
if (type === 'tetherBuilt') {
const level = Math.max(1, Math.floor(Number(check.level) || 1));
const name = check.planet === 'home' ? this.homeWorldName : String(check.planet ?? '');
const have = Math.max(0, this.tetherLevelFor(name) ?? 0);
const done = have >= level;
return { label, done, detail: `L${have}/L${level}` };
}
if (type === 'discoveredWorld') {
const count = Math.max(1, Math.floor(Number(check.count) || 1));
const have = Math.min(count, this.discoveredWorldCount());
const done = have >= count;
return { label, done, detail: `${have}/${count}` };
}
if (type === 'systemCharted') {
// EVERY NAV point of the named system ('home' = the home system)
// — the same discoverable set that gates the jumpgate research
// (navChart: planets, stations, gates — the central body is
// implicit, clusters don't count).
const sysId = this._questSystemId(check);
const content = sysId && this.galaxy && typeof this.galaxy.contentOf === 'function'
? (() => { try { return this.galaxy.contentOf(sysId); } catch { return null; } })()
: null;
const chart = sysId && content ? navChart(this.discovery, sysId, content) : null;
const done = !!chart && chart.complete;
return { label, done, detail: chart ? `${chart.discovered}/${chart.total}` : '??' };
}
if (type === 'gatesActivated') {
// The named system's jumpgates are LIVE (the activateGates effect's
// keys — the same set _activateSystemJumpgates writes).
const sysId = this._questSystemId(check);
const keys = sysId ? activationKeys(this.galaxy, sysId) : [];
const have = keys.filter((k) => this.activatedGates?.has?.(k)).length;
const done = keys.length > 0 && have === keys.length;
return { label, done, detail: `${have}/${keys.length}` };
}
if (type === 'trestleTalked') {
// A TRESTLE SEQUENCE (data/comms.json, trigger check.trigger —
// default 'trestleBrief', the 'Leaving the Nest' gates-online
// briefing; 'Pirate Problems' points at 'trestleQ4', the grant's
// three-beat warning): the run's comms played-ledger (registry
// 'commsPlayed') is the witness — all of them heard = the player
// talked to him (the check's trigger parameter keeps the two
// quests' Trestle lines apart, the played-ledger shared).
return this._trestleCheck(String(check.trigger ?? 'trestleBrief'), label, 'TALKED');
}
if (type === 'trestleMet') {
// His ARRIVAL PAIR (trigger 'trestleArrival' — the player arrives
// at the destination planet, the neutral capital world) in the
// ledger = met.
return this._trestleCheck('trestleArrival', label, 'MET');
}
// 'Special Delivery' (data/quests.json → special_delivery) — the
// witnesses are this.story's ledger (_recordLanding +
// onSurfaceDeparture own the writes; SaveData carries them to
// record.story and back, so the progress survives a load).
if (type === 'capitalLanded') {
// Step 1 — the bar: a real landing on the neutral capital (Trestle
// and Carol). The same world _capitalLandingBlocked gates — the
// arrival that is allowed is the arrival that counts.
const done = this.story?.capitalLanded === true;
return { label, done, detail: done ? '1/1' : '0/1' };
}
if (type === 'cargoUpgraded') {
// Step 2 — the ship can HOLD CARGO: the witness is the quest's
// package ABOARD (check.id — the 'special-package' in
// data/goods.json; the quest's packageGood is the fallback). It
// only fits once the hold is upgraded (the run's ship starts with
// cargoHold 0 — data/ship.json), and the bar scene's DONE press is
// what does the upgrade + the load (onVignetteComplete), so the
// parcel's presence IS the upgrade's proof — the step stands done
// the moment the package joins the hold (the scene's lines are the
// truth: Carol's crew built out the bays).
const goodId =
String(check.id ?? '').trim() ||
String(questDef('special_delivery')?.packageGood ?? '').trim();
const done = goodId !== '' && this.ship?.cargo?.has?.(goodId) === true;
return { label, done, detail: done ? '1/1' : '0/1' };
}
if (type === 'packageDelivered') {
// Step 3 — the parcel in Carol's hands on the home world: the proof
// is the package's HOME-WORLD SALE (zero credits — the delivery,
// not a commodity; onQuestPackageSold set it when the shop's
// Confirm ran it, and the grid only offers the sale there, so the
// sale IS the delivery — landing alone no longer completes it).
// Monotonic by design: once the delivery stands, it stands (the
// quest can be CLAIMED and stay claimed, the run's record is the
// record).
const done = this.story?.delivered === true;
return { label, done, detail: done ? '1/1' : '0/1' };
}
if (type === 'vignettePlayed') {
// A VIGNETTE scene (data/vignettes.json) completed: the run's
// vignette played-ledger (registry 'vignettesPlayed' — VignetteHub
// marks it when the scene ENDS; SaveData saves/restores it) is the
// witness — the scene named by check.id is the one the step is
// about (the bar's Trestle + Carol meeting for 'Special Delivery').
// The scene plays over the location's looping clip (the window is
// the moment; the clip keeps moving beneath it) and its DONE press
// is what sets this — the conversation IS the step.
const id = String(check.id ?? '').trim();
const played = this._vignettesPlayed();
const done = id !== '' && played.has(id);
return { label, done, detail: done ? 'SEEN' : 'PENDING' };
}
// 'Pirate Problems' (data/quests.json → pirate_problems) — the run's
// first COMBAT step + the return leg.
if (type === 'combatCleared') {
// The pirate wave CLEARED (check.script — data/combat.json →
// waves.scripts' name, 'pirate_ambush'): the run's combat done-
// ledger (registry 'combatDone' — CombatSystem's onComplete marks
// it via GameScene._recordCombatDone; SaveData captures it under
// record.combat.done) is the monotonic witness — the wave can be
// re-fired on a load (its in-flight state is not saved) but once
// it has FINISHED the step stands, no re-fight required. The ambush
// only starts in the capital's system (GameScene.
// _maybeStartPirateAmbush), so the step opens with the quest.
const script = String(check.script ?? '').trim();
const done = script !== '' && this._combatDone().has(script);
return { label, done, detail: done ? 'CLEARED' : 'PENDING' };
}
if (type === 'capitalReturned') {
// The RETURN arrival (the quest's destination, the Neutral Capital
// world): the story ledger's capitalReturned flag — set the moment
// the ship reaches the world's keep-out rim with the quest held +
// unclaimed (GameScene._maybeWitnessCapitalReturn, called from
// update()'s destination pass; monotonic, SaveData-captured under
// record.story). 'I'll add more speech' is a later layer — the
// arrival IS the step (the bar scene lands on it).
const done = this.story?.capitalReturned === true;
return { label, done, detail: done ? '1/1' : '0/1' };
}
return { label, done: false, detail: '??' };
}
/**
* A Trestle story-beat check (the 'Leaving the Nest' quest's two
* social lines): every comm on `trigger` (data/comms.json) in the
* run's comms played-ledger (registry 'commsPlayed' — CommsHub marks
* it when a comm STARTS; SaveData saves/restores it) = done. Reading
* the trigger's roster data-driven keeps the check and the comms
* from drifting (a beat added to the sequence completes the check
* only once it too has been heard), and a quit mid-sequence can't
* fake it — the unplayed beats re-arm on the next load and the
* ledger knows.
* @param {string} trigger the comm trigger the witness rides on
* @param {string} label the checklist line
* @param {string} doneWord the detail once done
*/
_trestleCheck(trigger, label, doneWord) {
const ids = commsForTrigger(config.section('comms', {}), trigger).map((c) => c.id);
const played = this._commsPlayed();
const have = ids.filter((id) => played.has(id)).length;
const done = ids.length > 0 && have === ids.length;
return { label, done, detail: done ? doneWord : `${have}/${ids.length}` };
}
/**
* The run's comms played-ledger (registry 'commsPlayed' — CommsHub
* owns it; SaveData captures/restores it under record.comms.played).
* Empty set when the run hasn't armed a comm yet (headless harnesses
* included — the checks just read zero).
* @returns {Set<string>}
*/
_commsPlayed() {
const cur = this.registry?.get?.('commsPlayed');
return cur instanceof Set ? cur : new Set();
}
/**
* The run's vignette played-ledger (registry 'vignettesPlayed' —
* VignetteHub owns it, SaveData captures/restores it under
* record.vignettes.played). Vignettes mark on COMPLETION (the opposite
* of comms' mark-on-start — a scene left unfinished re-arms), so this
* is also the 'vignettePlayed' check's progress record.
* @returns {Set<string>}
*/
_vignettesPlayed() {
const cur = this.registry?.get?.('vignettesPlayed');
return cur instanceof Set ? cur : new Set();
}
/**
* A quest check's system: 'home' → the home system (the nest); a
* literal id otherwise (future quests may name a specific system).
* @returns {string|null}
*/
_questSystemId(check) {
const sys = check?.system;
if (sys === 'home') return this.galaxy?.homeSystemId ?? null;
return typeof sys === 'string' && sys ? sys : null;
}
/**
* How many DISCOVERED worlds are planets or space stations (the
* 'Discover another planet or space station' requirement). The home
* world never counts (it is the origin, not a discovery), and neither
* do asteroid clusters / jump gates (the requirement is a WORLD or a
* STATION). The ledger is per-system (Discovery), so the count walks
* every system the player has found something in: the current system's
* objects are the scene's own entities; other systems resolve their
* ids against the generated content (galaxy.contentOf — deterministic,
* cached once generated).
*/
discoveredWorldCount() {
const disc = this.discovery;
if (!disc || !disc.bySystem) return 0;
const cur = this.systemRecord?.id ?? null;
let n = 0;
for (const [sysId, ids] of disc.bySystem) {
let planetIds = null;
let stationIds = null;
if (sysId === cur) {
planetIds = new Set((this.systemPlanets ?? []).map((p) => p.discoveryId));
stationIds = new Set((this.systemStations ?? []).map((st) => st.discoveryId));
} else if (this.galaxy && typeof this.galaxy.contentOf === 'function') {
let content = null;
try {
content = this.galaxy.contentOf(sysId);
} catch {
content = null;
}
planetIds = new Set(((content?.planets ?? []).map((r) => r?.name)).filter(Boolean));
stationIds = new Set(
((content?.settlements ?? []).filter((s) => s?.anchor?.type === 'space').map((s) => s?.id)).filter(Boolean),
);
}
if (!planetIds && !stationIds) continue;
for (const id of ids) {
if (!id || id === 'home') continue;
if (planetIds.has(id) || stationIds.has(id)) n++;
}
}
return n;
}
/** The reward line for a quest def (data/quests.json → reward object). */
_questRewardLabel(def) {
const rw = def?.reward;
if (rw && typeof rw === 'object') {
const parts = [];
if (typeof rw.minerals === 'number') parts.push(`${rw.minerals} MINERALS`);
if (typeof rw.credits === 'number') parts.push(`${rw.credits} CREDITS`);
return parts.length ? parts.join(' · ') : '';
}
return String(rw ?? '');
}
/**
* The CLAIM REWARD action (QuestWindow's button → here). The window
* only offers the button when the checklist reads complete; this is
* the belt-and-braces pass — re-evaluates the live checklist and pays
* the reward exactly once (the ledger's claim() is idempotent, so a
* double-press or a re-open can never pay twice). The scene owns the
* effect (the minerals land in the hold); the ledger owns the record.
*/
claimQuest(id) {
if (!this.questState || !this.questState.isGranted(id)) return;
if (this.questState.isClaimed(id)) {
this.questWindow?.refresh();
return;
}
const def = questDef(id);
if (!def) return;
const snap = this.questSnapshot();
const q = snap?.quests?.find((x) => x.id === id);
if (!q || !q.complete) {
// Requirements still open — a refusal, in the console's voice.
const missing = (q?.checks ?? []).filter((c) => !c.done).map((c) => c.label.toUpperCase());
const t = config.get('quests.claimDenyToast', {});
this.consoleToast(
String(t.text ?? 'REQUIREMENTS INCOMPLETE — {missing}').replace('{missing}', (missing.slice(0, 2).join(' · ') || 'REQUIREMENTS')),
{
glyph: t.glyph ?? '✗',
glyphColor: t.color ? toCss(t.color) : toCss(themeColor('red', 0xff5c77)),
},
);
this.questWindow?.refresh();
return;
}
if (!this.questState.claim(id)) return; // someone beat us to it — idempotent
// The reward: minerals into the hold (clamped to the ship's storage —
// addMinerals hands back what fit, so a full hold just wastes the
// excess, the game's rule for mined ore) + credits onto the ledger
// (data/quests.json → reward.credits; none of the first-pass quests pay
// credits yet, but the seam is live for the quest rewards that will).
const rw = def.reward ?? {};
const rewardParts = [];
if (typeof rw.minerals === 'number' && this.ship) {
this.ship.addMinerals(rw.minerals);
this.refreshMineralHud();
rewardParts.push(`+${rw.minerals} MINERALS`);
}
if (typeof rw.credits === 'number' && this.ship) {
this.ship.addCredits(rw.credits);
this.refreshCreditsHud();
rewardParts.push(`+${rw.credits} CREDITS`);
}
this.playSfx('construct'); // the power-up tick — the reward lands
const t = config.get('quests.claimToast', {});
this.consoleToast(
String(t.text ?? 'REWARD CLAIMED — {reward}').replace('{reward}', rewardParts.join(' · ') || 'REWARD'),
{
glyph: t.glyph ?? '✓',
glyphColor: t.color ? toCss(t.color) : toCss(themeColor('green', 0x67e863)),
},
);
this.questWindow?.refresh();
// The RUN's chain — the claim that pays this quest is what arms the
// next (data/quests.json → leaving_the_nest.unlocks.claimed): the
// follow-up grants ONTO the claim (sfx + NEW QUEST toast + the OAC's
// quest-grant voice), queued after this claim's own toast.
this._grantUnlockedQuests();
}
/**
* Grant every not-yet-held quest whose unlock requirement is met
* (QuestState.grantUnlocked — pure ledger work; the scene supplies the
* ACKED half of the gate via this._commsAcked() — 'Pirate Problems'
* waits for the 'Special Delivery' payoff chain to be ACKed all the
* way through) and announce each new grant in the console's voice: the
* 'something new is here' SFX, the OAC's quest-grant voice (data/comms.json:
* questNew — once:false, re-armed on every grant), and the NEW QUEST
* toast (the starter's own toast style — quests.starterToast, one toast
* slot: the 2.7 s delay lands it after the claim toast that preceded
* it). Then the quest's START beats (_questStartBeats — 'Pirate
* Problems': the destination set + Trestle's warning + the ambush's
* grant-side arm). A fresh run's starter boot and a loaded save's boot
* both call this (the create() arm); claimQuest calls it on the claim;
* _commEnded calls it on the ACK that completes the last gate.
* Idempotent — give() is, and the announcement is per-NEW-grant.
* @returns {string[]} the newly granted ids
*/
_grantUnlockedQuests() {
if (config.get('quests.enabled', true) === false || !this.questState) return [];
const fresh = this.questState.grantUnlocked({ acked: this._commsAcked() });
for (const id of fresh) {
const def = questDef(id);
this.playSfx('discovery'); // the 'something new is here' voice
this.comms?.trigger('questNew'); // the OAC's quest-grant voice (audio-only)
this.time.delayedCall(2700, () => {
this.consoleToast(
String(config.get('quests.starterToast.text', 'NEW QUEST — {title}'))
.replace('{title}', String(def?.title ?? id).toUpperCase()),
{
glyph: config.get('quests.starterToast.glyph', '◆'),
glyphColor: toCss(themeColor('amber', 0xffc94d)),
durationMs: 3200,
},
);
});
}
if (fresh.length) {
this._questRefresh(); // a grant may have landed already-complete
this._questStartBeats(fresh); // the per-quest start beats (destination + comms)
this._trestleBeats(); // the gates may already be live (the claim was the last piece)
}
return fresh;
}
/**
* A quest's START beats — the one-shot work its grant owns (the
* 'the run turns' moment; runs exactly once per grant, on the scene
* that saw it). 'Pirate Problems' (data/quests.json →
* pirate_problems): the player's DESTINATION becomes the Neutral
* Capital world (the 'get back here' — set BEFORE the comms, so the
* warning answers the plotted route) and Trestle's warning sequence
* arms (trigger 'trestleQ4' — data/comms.json: trestle-q4-1/2/3);
* then the ambush's grant-side arm (a grant that lands while the
* player is already IN the capital's system). Other quests carry no
* start beats today; the seam is the shape for the ones that will.
* @param {string[]} fresh the newly granted ids (from _grantUnlockedQuests)
*/
_questStartBeats(fresh) {
if (!Array.isArray(fresh) || !fresh.length) return;
if (!fresh.includes('pirate_problems')) return;
const t = this.galaxy?.neutralCapitalTarget?.();
if (t?.systemId) {
this.setDestination(t.systemId, t.objectId);
}
this._trestleQ4Beats();
// The AMBUSH's other arm: the grant may have landed while the player
// was already IN the capital's system (a late claim out there) — if
// the ship is already within the midpoint the wave starts here; if
// it's still gate-side, update()'s arm arms it once the flight
// crosses the halfway point. _maybeStartPirateAmbush is no-op-gated
// (quest live + step open + system match + not already running/done
// + within the midpoint trigger), so re-calling it here is safe.
this._maybeStartPirateAmbush();
}
/**
* Is the ship AT the NEUTRAL CAPITAL WORLD (the quest's destination
* planet — the outermost world of the designated neutral-capital
* system, Galaxy.neutralCapitalTarget, the destination trestle-brief-3's
* acknowledgment sends the player to)? The test is the same keep-out
* rim _checkDestinationReached uses for 'arrived at this object': the
* ship within rim + 40 px of the world's center. Returns the capital
* world's entity when at it, null otherwise (no galaxy / no target /
* another system / still in transit).
* @returns {object|null} the capital world entity, or null
*/
_atCapitalWorld() {
const t = this.galaxy?.neutralCapitalTarget?.();
if (!t?.objectId || t.systemId !== this.systemRecord?.id) return null;
const cap = (this.systemPlanets ?? []).find((p) => p.discoveryId === t.objectId);
if (!cap || !this.ship) return null;
const keepout =
cap.minCenterDistance?.(this.ship.radius) ??
(cap.radius + (cap.clearance ?? 0) + this.ship.radius);
const dist = Math.hypot(this.ship.x - cap.x, this.ship.y - cap.y);
return dist <= keepout + 40 ? cap : null;
}
/**
* The 'Leaving the Nest' quest's Trestle beats (data/comms.json:
* the trestle-brief-1/2/3 sequence + the trestle-arrive-1/2 pair) —
* armed from
* every point where their preconditions could have become true while
* the quest is held and unclaimed:
* - the BRIEFING (three back-to-back ack comms), when the home
* system's jumpgates are LIVE — the moment the player can jump
* out of the nest (the activateGates effect — _applyResearchEffects,
* or already-live when the quest grants / a save loads);
* - the ARRIVAL, when the player is AT the neutral capital world —
* the quest's destination planet: the quest completes on the
* PLANET, not on entering the neighboring system (the live flow
* earns it on the arrival moment, GameScene._checkDestinationReached;
* this scene-create arm covers a loaded save parked at the
* capital's rim).
* The comm's once-ledger + the hub's in-flight guard keep each beat a
* one-shot; the QUEST CHECKS are the authoritative witnesses (the
* played-ledger, read by _trestleCheck), so a beat the player never
* heard can't fake the check — and a quit mid-sequence re-arms only
* the unplayed beats here, on the next load.
*/
_trestleBeats() {
if (config.get('quests.enabled', true) === false || !this.questState) return;
const qid = 'leaving_the_nest';
if (!this.questState.isGranted(qid) || this.questState.isClaimed(qid)) return;
const homeId = this.galaxy?.homeSystemId;
if (!homeId) return;
// The BRIEFING — home's gates are live (the quest's own 'Activate
// System Jump Gates' check holds): Trestle walks the player to the
// gate (one trigger arms all three comms; the hub's FIFO plays them
// back-to-back).
const homeKeys = activationKeys(this.galaxy, homeId);
if (homeKeys.length && homeKeys.every((k) => this.activatedGates?.has?.(k))) {
this.comms?.trigger('trestleBrief');
}
// The ARRIVAL — the player is AT the capital world (Trestle's side
// of the gate, ON the planet he promised). Only arms when the ship
// is actually there — a jump-in landing in the system (scene create,
// gate-side) does not complete the quest; the arrival moment does
// (GameScene._checkDestinationReached).
if (this._atCapitalWorld()) this.comms?.trigger('trestleArrival');
}
/**
* A comm was ACKNOWLEDGED (the CommsHub's onAck hook — the press
* itself, fired BEFORE onEnd): the run's comms ACKED-ledger mark.
* The played-ledger (registry 'commsPlayed' — GameScene._commPlayed) marks
* a comm when it STARTS; the acked-ledger (registry 'commsAcked')
* marks it when the ACKNOWLEDGE is pressed — 'heard all the way
* through'. 'Pirate Problems' (data/quests.json →
* pirate_problems.unlocks.acked) stands on this: its grant waits for
* the 'Special Delivery' payoff chain (carol-delivered-1/2/3 +
* oac-delivered-1) to be ACKed before it turns the run, and the mark
* must already stand when _commEnded's grant pass reads it (hence the
* onAck-before-onEnd order). SaveData captures the ledger under
* record.comms.acked; New Game clears it (resetRunState).
* @param {object} def the comm def that was acknowledged
*/
_commAcked(def) {
if (!def?.id) return;
const set = this._commsAcked();
if (!set.has(def.id)) set.add(def.id);
}
/**
* The run's comms ACKED-ledger (registry 'commsAcked') — the Set of
* comm ids acknowledged this run (_commAcked adds; SaveData restores
* it on load; resetRunState clears it on New Game). Created on first
* read, so old saves and new runs both find a live ledger.
* @returns {Set<string>} the acked ids
*/
_commsAcked() {
if (!this.registry) return new Set(); // a harness scene without a registry: nothing acked
let set = this.registry.get('commsAcked');
if (!(set instanceof Set)) {
set = new Set();
this.registry.set('commsAcked', set);
}
return set;
}
/**
* The run's combat done-ledger (registry 'combatDone') — the Set of
* wave-script names that have FINISHED this run (_recordCombatDone
* adds on the run's onComplete; SaveData captures it under
* record.combat.done and restores it on load; resetRunState clears it
* on New Game). The 'Pirate Problems' step's monotonic witness:
* the wave run's IN-FLIGHT state is scene-local (a load re-arms the
* ambush if it was mid-fight) but once it has cleared, the step
* stands — no re-fight.
* @returns {Set<string>} the cleared script names
*/
_combatDone() {
if (!this.registry) return new Set(); // a harness scene without a registry: nothing cleared
let set = this.registry.get('combatDone');
if (!(set instanceof Set)) {
set = new Set();
this.registry.set('combatDone', set);
}
return set;
}
/**
* A wave run FINISHED (its onComplete — the run's force is spent): the
* step's witness lands on the combat done-ledger (monotonic — a later
* re-fire of the same script, or a re-fire on load, cannot undo it)
* and the checklist row ticks over ('Defend your ship from pirates' —
* the 'Pirate Problems' step). The bar's departure (the combat going
* idle) is the player-facing cue; the console feed marks it too.
* @param {string} script the wave-script name (combat.waves.scripts key)
*/
_recordCombatDone(script) {
const id = String(script ?? '').trim();
if (!id) return;
const set = this._combatDone();
if (!set.has(id)) set.add(id);
this.combatHud?.kill('PIRATE AMBUSH CLEARED'); // the feed's moment
this._questRefresh(); // the checklist row may tick over (READY/READY)
}
/**
* The 'Pirate Problems' (data/quests.json → pirate_problems) grant's
* WARNING — Trestle's three beats (data/comms.json: trestle-q4-1/2/3,
* trigger 'trestleQ4', type 'ack': the player acknowledges each):
* the pirates' attention, the cannon + the upgrades he's seen the
* player earn, and the 'be on the lookout' that closes it. Armed the
* moment the quest is HELD + UNCLAIMED (the trigger's comm guard,
* GameScene._commTriggerGate — data/comms.json), so the beats ride
* the grant's announcement and never land before it; the comm's
* once-ledger + the hub's in-flight guard keep each beat a one-shot
* across the scene's life and across saves (a mid-sequence quit re-
* arms only the unplayed beats — the same contract as _trestleBeats).
* Arms from the grant (_questStartBeats) and the scene-create pass
* (create()'s tail — a loaded save that holds the quest unclaimed).
*/
_trestleQ4Beats() {
if (config.get('quests.enabled', true) === false || !this.questState) return;
const qid = 'pirate_problems';
if (!this.questState.isGranted(qid) || this.questState.isClaimed(qid)) return;
this.comms?.trigger('trestleQ4');
}
/**
* The 'Pirate Problems' AMBUSH — the quest's 'Defend your ship from
* pirates' step: with the quest live (held + unclaimed) and its
* combat step still open, the pirate wave starts the moment the ship
* reaches the MIDPOINT of the entry-gate → capital-world stretch —
* HALF the jumpgate-to-world distance from the capital (the quest's
* destination is the capital, so the run's flight crosses it), NOT
* on the system's enter (the gate-side spawn is a full stretch out).
* ONE rolling wave of six frame-0 raiders (data/combat.json →
* waves.scripts.pirate_ambush), three at a time, each kill feeding a
* replacement until all six are down. The wave ships materialize
* OFF-SCREEN around the player, each from a random direction
* (CombatSystem._spawnWaveEnemy), and stream in on their own chase
* AI. Three arms call it (all no-op-gated by the guards below):
* · update() — the per-frame arm: the flight crosses the midpoint;
* · create()'s tail — the ENTER, when the spawn already sits
* within the midpoint (a degenerate short stretch);
* · _questStartBeats — a grant that lands while the player is
* already in the system (a late claim out there).
* Guards (all must hold, in order): combat enabled + built; the
* quest held + unclaimed; the combat step not yet cleared (the
* monotonic done-ledger — a cleared ambush never re-fires); no wave
* run in flight (a live fight is the fight); the capital target
* resolves in THIS system (the ambush lives where Trestle is); the
* midpoint resolves (entry gate + capital world both found) and the
* ship is within it of the capital world.
* A mid-fight quit re-arms the ambush on load (the in-flight run is
* scene-local) — the step's witness is the completion, not the
* attempt, so the re-fire is the fair read.
*/
_maybeStartPirateAmbush() {
if (config.get('combat.enabled', true) === false) return;
if (!this.combat) return;
if (config.get('quests.enabled', true) === false || !this.questState) return;
const qid = 'pirate_problems';
if (!this.questState.isGranted(qid) || this.questState.isClaimed(qid)) return;
const qd = questDef('pirate_problems');
const combatStep = (qd?.checks ?? []).find((c) => c?.type === 'combatCleared');
const script = String(combatStep?.script ?? '').trim() || 'pirate_ambush';
if (this._combatDone().has(script)) return; // cleared once — the step stands
const run = this.combat.waveRun;
if (run && run.state !== 'done' && run.state !== 'cancelled') return; // in flight
const t = this.galaxy?.neutralCapitalTarget?.();
if (!t?.objectId || t.systemId !== this.systemRecord?.id) return; // not this system
// The MIDPOINT TRIGGER — half the entry-gate → capital stretch:
// the raiders materialise at the halfway point, not at the gate.
const cap = (this.systemPlanets ?? []).find((p) => p.discoveryId === t.objectId);
const trigger = ambushTriggerDistance(this._pirateAmbushEntryGate(), cap);
if (trigger == null || !this.ship) return; // no stretch to halve
if (Math.hypot(this.ship.x - cap.x, this.ship.y - cap.y) > trigger) return; // not halfway yet
this.combat.startWaves(script, {
onComplete: () => this._recordCombatDone(script),
});
}
/**
* The AMBUSH's entry gate — the gate the player CAME IN through (the
* midpoint is measured from it): the destination's return gate for
* the last jump's source system first (jumpThroughGate stages
* lastJumpFrom into the restore record — exact, and stable across a
* save parked mid-stretch), then the system's gate NEAREST the spawn
* point (a jump materialises the ship just past its return gate's
* keepout, so the nearest gate is the one they used — the fallback
* for a fresh run or a save that predates lastJumpFrom), and
* finally the spawn point itself (a gate-less system — the spawn IS
* where the ship came from).
* @returns {{x:number,y:number}|null} the gate record, or null
*/
_pirateAmbushEntryGate() {
if (typeof this.lastJumpFromSystem === 'string') {
const g = returnGateFor(this.systemContent, this.lastJumpFromSystem);
if (g && typeof g.x === 'number' && typeof g.y === 'number') return g;
}
const anchor = this._ambushSpawn ?? this.ship;
if (anchor && Array.isArray(this.systemGates)) {
let best = null;
let bestD = Infinity;
for (const gt of this.systemGates) {
const d = Math.hypot(gt.x - anchor.x, gt.y - anchor.y);
if (d < bestD) {
bestD = d;
best = gt;
}
}
if (best) return best;
}
return anchor && typeof anchor.x === 'number' && typeof anchor.y === 'number'
? { x: anchor.x, y: anchor.y }
: null;
}
/**
* The 'Pirate Problems' RETURN witness — the step's 'Return to the
* Neutral Capital World': the moment the ship is at the capital
* world's keep-out rim (the same _atCapitalWorld rim the 'Leaving
* the Nest' arrival beat reads) with the quest held + unclaimed,
* the story ledger's capitalReturned flag stands — monotonic, and
* the 'capitalReturned' check (GameScene._questCheckEval) reads it,
* so the step completes on ARRIVAL (in-space, the player hasn't even
* landed) regardless of how the player got here (the quest set the
* destination, but the witness is the position, not the route). The
* bar scene's own 'I'll add more speech' layer lands on top of this
* moment later. Called from update()'s destination pass (a no-op once
* the flag stands — the per-frame cost is a ledger read + a distance
* check) and from create()'s tail (a loaded save parked at the rim —
* the same arm shape as the 'Leaving the Nest' arrival beat).
*/
_maybeWitnessCapitalReturn() {
if (this.story?.capitalReturned) return; // monotonic — the step stands
if (config.get('quests.enabled', true) === false || !this.questState) return;
const qid = 'pirate_problems';
if (!this.questState.isGranted(qid) || this.questState.isClaimed(qid)) return;
if (!this._atCapitalWorld()) return; // not at the capital world's rim
this.story.capitalReturned = true;
this._questRefresh(); // the checklist row may tick over (READY)
}
/**
* A comm ENDED (the CommsHub's onEnd hook — for 'ack' comms this is
* the ACKNOWLEDGE press, for 'audio'/'visual' comms the moment the
* voice finishes): the scene's end-side bookkeeping.
*
* THE RESEARCH-COMPLETE SIGN-OFF (data/comms.json:
* oac-research-complete, audio-only — onEnd fires the moment the voice
* finishes): a completed tech may have OPENED a new one; if any category
* still holds a ready-to-research tech (researchAnyAvailable), OAC's
* 'new research' line answers the sign-off (oac-research-new — the
* hub's FIFO plays it right behind; see the in-line note).
*
* TRESTLE'S LAST BRIEF (data/comms.json: trestle-brief-3) — he is
* "in the system right next to you in this star cluster's capital" and
* "sending the coordinates to your computer right now": the moment the
* player acknowledges it, the NEUTRAL CAPITAL (the roster's faction
* pass — js/galaxy/Galaxy.js → _assignFactions; its seat is that
* system's outermost world) becomes the player's destination.
* setDestination does the rest: the route is derived from it, the
* orange compass marker points at the gate to fly, and the map's
* route lane lights up — no extra input from the player. Arriving is
* 'Meet Trestle' — the trestle-arrive-1/2 pair plays when the ship
* reaches the capital world (GameScene._checkDestinationReached).
*/
_commEnded(def) {
// The ACK that just landed is ALREADY in the acked ledger (CommsHub
// fires onAck BEFORE onEnd — GameScene._commAcked), so this end-side
// pass can read it. GRANT PASS: a comm ending can complete the last
// gate of a quest unlock — 'Pirate Problems' (data/quests.json →
// pirate_problems.unlocks) waits on the 'Special Delivery' payoff
// chain to be ACKed all the way through, and THIS press may be the
// last piece. _grantUnlockedQuests is a no-op when nothing is newly
// due (grantUnlocked reports only the new ones; give() is idempotent),
// so the pass runs on every comm end.
this._grantUnlockedQuests();
// THE RESEARCH-COMPLETE SIGN-OFF (data/comms.json: oac-research-complete,
// audio-only — onEnd fires the moment the voice finishes): the moment
// it has spoken, a completed tech may have OPENED a new one. If any
// category still holds a ready-to-research tech (researchAnyAvailable
// — nothing in flight, a node whose requires are met, the SYSTEM
// chart's gate included), arm OAC's 'new research' line
// (oac-research-new, data/comms.json → trigger 'researchNew') — the
// hub is idle again, so its FIFO starts it right now, and the voice
// rides on the comm system (music ducked, the same contract as the
// sign-off it answers). The same beat the shimmer cue lights
// (GameScene._applyResearchCue / ResearchWindow._paintCues), as voice.
// A leaf tech's completion opens nothing → the silence is the answer.
if (def && def.id === 'oac-research-complete') {
if (typeof this.researchAnyAvailable === 'function' && this.researchAnyAvailable()) {
this.comms?.trigger('researchNew');
}
return;
}
if (!def || def.id !== 'trestle-brief-3') return;
const t = this.galaxy?.neutralCapitalTarget?.();
if (!t) {
// The faction pass always designates one on a real galaxy — this
// is the degenerate-galaxy guard (no branches at all).
this.consoleToast('NO CAPITAL ON FILE — COORDS LOST IN THE STATIC', {
glyph: '✕',
glyphColor: toCss(themeColor('neon2', 0xff9b9b)),
});
return;
}
this.setDestination(t.systemId, t.objectId);
}
/**
* SET PRIORITY — the tracker HUD's featured quest (the row popup's
* button, or the console's SET PRIORITY). The ledger is the source of
* truth (setPriority is idempotent + persists); the tracker and the
* console repaint on their next poll — the tracker's checklist moves
* to the newly featured quest LIVE.
*/
setPriorityQuest(id) {
if (!this.questState) return;
if (!this.questState.setPriority(id)) return; // not held, claimed, or already the priority
const def = questDef(id);
const t = config.get('quests.tracker.priorityToast', {});
this.consoleToast(
String(t.text ?? 'PRIORITY SET — {title}').replace('{title}', String(def?.title ?? id).toUpperCase()),
{ glyph: t.glyph ?? '◆', glyphColor: t.color ? toCss(t.color) : toCss(themeColor('green', 0x67e863)) },
);
this.questWindow?.refresh();
}
/**
* CLEAR PRIORITY — the player's "no priority right now" (the row
* popup's CLEAR PRIORITY). It STICKS on the ledger: the tracker shows
* plain titles again, and the auto-assignment re-arms only when a new
* quest is granted or one is claimed (the active set changed).
*/
clearPriorityQuest() {
if (!this.questState) return;
if (!this.questState.clearPriority()) return; // there was nothing to clear
const t = config.get('quests.tracker.clearToast', {});
this.consoleToast(String(t.text ?? 'PRIORITY CLEARED'), {
glyph: t.glyph ?? '·',
glyphColor: t.color ? toCss(t.color) : toCss(themeColor('dim', 0x7d92c4)),
});
this.questWindow?.refresh();
}
/**
* Open the QUESTS console ON A QUEST (the tracker's SHOW MORE — the
* first hidden quest — and VIEW QUEST): the deckAction('quests') close
* contract first, then the window lands on that quest (its tab active,
* its plate selected — QuestWindow.openAt).
*/
openQuestAt(questId) {
if (!this.questWindow) return;
if (this.questWindow.isOpen) {
this.questWindow.openAt(questId);
return;
}
if (this.menuSubBar && this.menuSubBar.isOpen) this.menuSubBar.close();
if (this.commsPanel && this.commsPanel.isOpen) this.commsPanel.close();
if (this.researchWindow && this.researchWindow.isOpen) this.researchWindow.close();
if (this.mapWindow && this.mapWindow.isOpen) this.mapWindow.close();
this.questWindow.openAt(questId);
this.questWindow.open();
}
/**
* A quest-relevant event happened (research completed, ore mined, a
* build finished, a world discovered, a reward claimed). Announce each
* quest that just became claimable ONCE (the console's voice), and kick
* the open console's poll so it repaints without waiting the interval.
*/
_questRefresh() {
if (!this.questState) return;
const snap = this.questSnapshot();
if (snap) {
for (const q of snap.quests) {
if (q.complete && !q.claimed && !this._questAnnounced.has(q.id)) {
this._questAnnounced.add(q.id);
this.playSfx('discovery'); // the READY chime — the 'something new is here' voice, once per quest
const t = config.get('quests.readyToast', {});
this.consoleToast(
String(t.text ?? 'QUEST READY — {title} · CLAIM ITS REWARD')
.replace('{title}', String(q.title).toUpperCase()),
{
glyph: t.glyph ?? '◆',
glyphColor: t.color ? toCss(t.color) : toCss(themeColor('amber', 0xffc94d)),
durationMs: 3200,
},
);
}
}
}
this.questWindow?.refresh();
}
/**
* The GALAXY tab's live snapshot (js/ui/GalaxyView.js renders it, polled
* the same 500 ms the system chart is — js/ui/MapWindow.js). Pure data
* from the shared Galaxy + the run's footprint (js/galaxy/GalaxyChart.js):
* every system (type + archetype color), the jump lanes (used /
* frontier / live), the charted region's stats. Per-system `faction`
* stays `null` — the FACTIONS seam (planned, not yet implemented).
*/
galaxySnapshot() {
if (!this.galaxy || !this.systemRecord) return null;
return buildGalaxySnapshot({
galaxy: this.galaxy,
visited: this.visitedSystems,
used: this.usedGates,
live: this.activatedGates,
currentSystemId: this.systemRecord.id,
// The active route (data/gates.json → route): its lanes drawn orange
// and the destination star circled (js/ui/GalaxyView.js). Absent when
// no destination is set (the route is derived — routePlan is null).
route: this.routeEdgeKeys(),
destinationId: this.destination?.systemId ?? null,
});
}
// ------------------------------------------------------------ map console
/**
* The MAP console's chart snapshot (js/ui/MapWindow.js polls this while
* open; js/galaxy/SystemChart.js shapes the stats).
*
* The frame covers EVERY object of the system — discovered or not (the
* map must always show the full extent of the solar system) — plus the
* player's tether reach; the chart DRAWS only the discovered ones.
*/
mapChartSnapshot() {
const sysId = this.systemRecord?.id;
const content = this.systemContent;
if (!sysId || !content) return null;
const disc = this.discovery;
const isDisc = (id) => (disc ? disc.isDiscovered(sysId, id) : true);
const objects = [];
for (const p of this.systemPlanets) {
objects.push({
id: p.discoveryId,
kind: 'planet',
x: p.x,
y: p.y,
radius: p.radius,
name: p.discoveryName,
typeLabel: config.get(`planets.typeLabels.${p.name}`, p.name),
discovered: isDisc(p.discoveryId),
tint: toCss(config.get(`planets.classTint.${p.name}`, '#9fb6d8')),
});
}
for (const st of this.systemStations) {
objects.push({
id: st.discoveryId,
kind: 'station',
x: st.x,
y: st.y,
radius: st.bound ?? st.size,
name: st.discoveryName,
typeLabel: 'Station',
discovered: isDisc(st.discoveryId),
});
}
for (const gt of this.systemGates) {
objects.push({
id: gt.discoveryId,
kind: 'gate',
x: gt.x,
y: gt.y,
radius: gt.bound ?? gt.size,
name: gt.discoveryName,
typeLabel: gt.toName ? `Gate → ${gt.toName}` : 'Jump Gate',
discovered: isDisc(gt.discoveryId),
rotation: gt.rotation,
active: gt.active,
});
}
for (const c of this.asteroidClusters) {
objects.push({
id: c.discoveryId,
kind: 'cluster',
x: c.x,
y: c.y,
radius: c.bound,
name: c.discoveryName,
typeLabel: 'Rock Field',
discovered: isDisc(c.discoveryId),
rocks: (c.members ?? []).map((m) => ({
dx: m.lx * 1, // local offset — the plate transform is a uniform scale
dy: m.ly,
r: Math.max(1, m.radius),
seed: (m.lx * 7919 + m.ly * 104729) % 1000 / 1000,
})),
});
}
// the player's tether zones — the explored region (union boundary + fog)
const tethers = (this.tetherField?.tethers ?? []).map((t) => ({
id: t.id,
x: t.x,
y: t.y,
radius: t.radius,
level: t.level,
label: t.label ?? '',
}));
// central body: the home world in the starting system; every other
// system's center is EMPTY — the star is invisible dossier flavor
// (name/class feed the plate tag, never a rendered disc: `visible`
// is false for the map's central-body draw)
const starClass = String(content.star?.class ?? '').toUpperCase();
const central = this.isHomeSystem
? {
name: this.homeWorldName ?? 'Terra',
isHome: true,
visible: true,
radius: this.planet?.radius ?? 200,
typeLabel: config.get('planets.homeTypeLabel', 'Home World'),
}
: {
name: this.planet?.discoveryName ?? 'Star',
isHome: false,
visible: false,
radius: 240,
typeLabel: config.get(`planets.starTypeLabels.${starClass}`, 'Star'),
color: toCss(config.get(`planets.star.classColor.${starClass}`, '#ffe9b0')),
};
return {
systemId: sysId,
systemName: this.systemRecord?.name ?? sysId,
isHome: this.isHomeSystem === true,
central,
objects,
tethers,
ship: this.ship ? { x: this.ship.x, y: this.ship.y, heading: this.ship.rotation } : null,
// The DESTINATION object (if the player is in the destination system
// and the destination names one) — the chart circles it in the route's
// orange (data/gates.json → route.compassColor) so the final stop is
// unmistakable. Null otherwise (no destination, or the destination is
// in another system — then the route is shown on the GALAXY tab).
destinationId: this.destination?.objectId && this.destination.systemId === sysId
? this.destination.objectId
: null,
// The ROUTE EXIT GATE — the jump gate in THIS system that leads toward
// the destination (routeNextGate). Shown when the destination is OUTSIDE
// the current system (the player needs to leave this system to reach
// it): the chart circles it in orange + draws an orange line from the
// ship to it ("path out of the system"). Null when the destination is
// in this system (then destinationId is set instead) or there is no
// active route.
routeExitGate: (() => {
const d = this.destination;
if (!d?.systemId || d.systemId === sysId) return null;
const gate = this.routeNextGate();
if (!gate) return null;
return {
id: gate.discoveryId,
x: gate.x,
y: gate.y,
name: gate.discoveryName ?? 'Gate',
};
})(),
stats: {
nav: navDiscoveryStats(disc, sysId, content),
res: resourceStats(disc, sysId, content),
},
};
}
/**
* The SYSTEM tab's chart snapshot (js/ui/MapWindow.js polls this while
* that tab is open): a CHARTED star picked on the GALAXY tab, drawn by
* the same painter as the CURRENT SYSTEM tab. The snapshot itself is
* pure — js/galaxy/SystemChart.js → systemChartSnapshot() — built
* straight from the generated content (no live entities; the ship and
* tethers exist only in the CURRENT system), so it is safe to poll
* with nothing of the system loaded. Discovered flags are the run's
* Discovery footprint.
*/
systemChartSnapshotFor(systemId) {
if (!systemId || !this.galaxy || systemId === this.systemRecord?.id) return null;
const record = this.galaxy.byId.get(systemId);
if (!record) return null;
const content = this.galaxy.contentOf(systemId);
// A field the run depleted stays off the chart: the content may have
// been regenerated since the mining (a load rebuilds the galaxy), so
// the splice is applied here too (the CURRENT system is spliced in
// create() — and is excluded above).
if (content && Array.isArray(content.asteroids) && this.depletedClusters?.size) {
content.asteroids = content.asteroids.filter(
(c) => !this.depletedClusters.has(`${systemId}:${c?.id}`),
);
}
return systemChartSnapshot(systemId, content, {
discovery: this.discovery,
isDiscovered: (id) => this.discovery?.isDiscovered(systemId, id),
// The DESTINATION object (if this is the destination system and the
// destination names one) — the chart circles it in the route's orange.
destinationId: this.destination?.objectId && this.destination.systemId === systemId
? this.destination.objectId
: null,
});
}
/**
* The SYSTEM tab's SET DESTINATION CONFIRM (js/ui/MapWindow.js →
* onSetDestination): the player marked an object on a CHARTED star's
* chart as their destination. The destination model is still being
* defined — for now the pick is acknowledged (the seam is stable:
* systemId + objectId).
*/
/**
* SET DESTINATION (the MAP console's SYSTEM tab — MapWindow._askObject):
* record the target system and plot the route to it. The route's FIRST
* STEP is what the player flies now — the current system's jump gate
* toward the destination (routeNextGate) — and it is marked ORANGE on
* the compass (updateDiscovery) so the player always knows where to go
* next. The MAP window closes itself once this returns (MapWindow's
* confirm handler calls this.close()).
*
* The ROUTE IS DERIVED (js/galaxy/Route.js): only the DESTINATION
* (systemId + the object within it) is stored — the path from wherever
* the ship is NOW to that system is computed on demand. Following the
* route (a jump to its next system) just shortens it; a DETOUR (a jump
* anywhere else) re-plots it from the detour (_routeForJump toasts the
* re-plot); arriving at the destination CLEARS it.
*/
setDestination(systemId, objectId) {
const sys = this.galaxy?.byId?.get(systemId);
if (!sys) {
this.consoleToast('UNKNOWN DESTINATION — NO SUCH SYSTEM', {
glyph: '✕',
glyphColor: toCss(themeColor('neon2', 0xff9b9b)),
});
return;
}
if (systemId === this.systemRecord.id) {
this.consoleToast('ALREADY AT THAT SYSTEM', {
glyph: '✕',
glyphColor: toCss(themeColor('neon2', 0xff9b9b)),
});
return;
}
// Store the destination (the ROUTE itself is derived from it). The
// object within the destination system is carried along for the
// arrival handling (autopilot / the REACHED toast).
this.destination = { systemId, objectId: objectId ?? null };
this.registry.set('destination', this.destination);
const plan = planRoute(this.galaxy, this.systemRecord.id, systemId);
const hops = plan?.hops ?? 0;
this.consoleToast(
String(config.get('gates.route.setToast', 'DESTINATION SET — {dest} · {hops} HOP(S)'))
.replace('{dest}', String(sys.name).toUpperCase())
.replace('{hops}', String(hops)),
{ glyph: '◆', glyphColor: toCss(this._routeColor()) },
);
}
/** The route's accent — orange (data/gates.json → route.compassColor). */
_routeColor() {
return toColor(config.get('gates.route.compassColor', '#ff8c1a'), 0xff8c1a);
}
/**
* The CURRENT route: the path from this system to the destination
* (planRoute), or null when no destination is set.
*/
routePlan() {
if (!this.destination?.systemId) return null;
return planRoute(this.galaxy, this.systemRecord.id, this.destination.systemId);
}
/**
* The lanes on the active route — the `edgeKey`s of the segments of the
* planRoute path (consecutive system pairs). The galaxy plate uses this
* to draw the route in orange (data/gates.json → route.compassColor):
* the "where am I going" path across the map. Empty when no destination.
* @returns {Array<string>} edge keys (js/galaxy/GalaxyChart.js → edgeKey)
*/
routeEdgeKeys() {
const plan = this.routePlan();
if (!plan?.path || plan.path.length < 2) return [];
const out = [];
for (let i = 0; i < plan.path.length - 1; i++) out.push(edgeKey(plan.path[i], plan.path[i + 1]));
return out;
}
/**
* The route's NEXT POINT — the jump gate in the CURRENT system that
* leads toward the destination (its gate.to === the route's next
* system). This is what the compass marks in orange and what the
* player flies to. null when there is no active route, the route is
* complete (we are at the destination), or the current system holds no
* gate toward the next system (defensive — the network is connected,
* so a next system always has a gate here).
*/
routeNextGate() {
const plan = this.routePlan();
if (!plan?.next) return null;
for (const gt of this.systemGates ?? []) if (gt.gate?.to === plan.next) return gt;
return null;
}
/**
* The destination OBJECT in the CURRENT system — the route's FINAL stop.
* Non-null only when the player is already in the destination system AND
* the destination names a specific object (objectId) that exists here.
* This is the last hop: across the system to the world itself. The
* compass marks it in ORANGE (updateDiscovery, `dest:` id) and it stays
* orange until the ship actually reaches it (_checkDestinationReached),
* so the "where to fly next" marker persists right up to the arrival.
*/
routeDestinationObject() {
const d = this.destination;
if (!d?.objectId || d.systemId !== this.systemRecord.id) return null;
return this.discoverableObjects().find((o) => o.id === d.objectId) ?? null;
}
/**
* The route's FINAL stop as a WORLD entity: the destination OBJECT in
* this system (routeDestinationObject) resolved to the entity that
* wears the subdued route pulse (js/entities/RoutePulse.js) — a
* planet (the home world included) or a space station. Null when the
* route is complete (no next stop in this system), the destination
* names no object, or the object is a kind that doesn't wear the pulse
* (gates/asteroids — the compass still marks them).
*/
_routeDestinationEntity() {
const o = this.routeDestinationObject();
if (!o) return null;
if (o.id === 'home') return this.planet ?? null;
for (const p of this.systemPlanets) if (p.discoveryId === o.id) return p;
for (const st of this.systemStations) if (st.discoveryId === o.id) return st;
return null;
}
/**
* Arrived at the FINAL stop? Called each frame (update, just before
* updateDiscovery). When the player is in the destination system and the
* ship is within the destination object's keep-out rim, the trip is done:
* clear the tracked destination (the compass orange drops, the SYSTEM tab
* unlocks) and fire the REACHED toast. (Fired here — not queued like a
* jump notice — because the player is already in this scene; there is no
* jump cut to ride out.) A destination with no specific object is handled
* at the jump instead (_routeForJump clears it on system entry).
*/
_checkDestinationReached() {
const d = this.destination;
if (!d?.objectId || d.systemId !== this.systemRecord.id) return;
if (!this.ship) return;
const solid =
this.solids.find((s) => s.discoveryId === d.objectId) ??
this.discoverableObjects().find((o) => o.id === d.objectId);
if (!solid) return;
// Keep-out rim: the distance the ship rests at when autopiloted there.
const keepout =
solid.minCenterDistance?.(this.ship.radius) ??
(solid.radius + (solid.clearance ?? 0) + this.ship.radius);
const dist = Math.hypot(this.ship.x - solid.x, this.ship.y - solid.y);
// A little grace past the rim so a settling ship counts, but not so
// much that flying by on the far side does.
if (dist > keepout + 40) return;
const destName = this.galaxy?.byId?.get(d.systemId)?.name ?? d.systemId;
this.destination = null;
this.registry.set('destination', null);
this.consoleToast(
String(config.get('gates.route.reachedToast', 'DESTINATION REACHED — {dest}'))
.replace('{dest}', String(destName).toUpperCase()),
);
// The 'Leaving the Nest' ARRIVAL (data/comms.json: trestle-arrive-1/2,
// trigger 'trestleArrival') — the quest's last beat plays on the
// CAPITAL WORLD (the neutral capital — the destination trestle-brief-3's
// acknowledgment set), not on entering the system: this is Trestle's
// 'you made it' moment. The comm's once-ledger + _trestleBeats' quest
// guard keep it a one-shot; both in the played-ledger are the quest's
// 'Meet Trestle' witness (GameScene._questCheckEval).
if (this._atCapitalWorld()) this.comms?.trigger('trestleArrival');
}
/**
* Route bookkeeping at a JUMP (called from jumpThroughGate with the
* system we are LEAVING and the one we are ENTERING). Derives what the
* hop means for the active route and QUEUES a notice (routeNotice — the
* toast must appear AFTER the jump cut, on the destination scene: the
* jump itself fires its own toast + full-screen clip, which would
* swallow an immediate one):
* · entering the DESTINATION → route complete — clear it (REACHED);
* · entering the route's NEXT → on course (the derived route simply
* shortens — no notice, the compass already pointed there);
* · entering anything else → a DETOUR — re-plot from the detour
* (RE-PLOTTED). Because the route is derived, the re-plot is
* automatic; this only announces it.
* The state change (clearing the destination on arrival) happens NOW —
* before captureState snapshots the run — so the saved state is right;
* only the player-facing message is deferred.
*/
_routeForJump(fromId, destId) {
if (!this.destination?.systemId) return; // no active route
const destId2 = this.destination.systemId;
const destName = this.galaxy?.byId?.get(destId2)?.name ?? destId2;
if (destId === destId2) {
// Arrived at the destination SYSTEM. If the destination names a
// specific OBJECT in that system, the trip is NOT over — the player
// still has to fly to it. Keep the destination active: the compass
// now points at that object (updateDiscovery) and it clears when the
// ship actually reaches it (_checkDestinationReached fires REACHED).
// No toast yet — the arrival is the last leg, not the destination.
if (this.destination.objectId) return;
// No specific object — reaching the system is the whole trip. Clear
// the tracked destination (the compass orange drops, the SYSTEM tab
// unlocks for the next target) and queue the REACHED notice for the
// new scene.
this.destination = null;
this.registry.set('destination', null);
this._queueRouteNotice(
String(config.get('gates.route.reachedToast', 'DESTINATION REACHED — {dest}'))
.replace('{dest}', String(destName).toUpperCase()),
);
return;
}
// Not the destination — were we on the route, or off it?
const expected = planRoute(this.galaxy, fromId, destId2)?.next ?? null;
if (expected === destId) return; // on course — the route just shortened
const plan = planRoute(this.galaxy, destId, destId2);
const hops = plan?.hops ?? 0;
this._queueRouteNotice(
String(config.get('gates.route.replotToast', 'ROUTE RE-PLOTTED — {hops} HOP(S) TO {dest}'))
.replace('{hops}', String(hops))
.replace('{dest}', String(destName).toUpperCase()),
);
}
/**
* Park a route notice (data/gates.json → route.* toast) in the shared
* registry so the DESTINATION scene's create() can surface it after the
* jump cut (the jump's own toast + clip would swallow an immediate one).
* Consumed (and cleared) exactly once by the next GameScene.create().
*/
_queueRouteNotice(text) {
this.registry.set('routeNotice', {
text,
glyph: '◆',
glyphColor: toCss(this._routeColor()),
durationMs: 3400,
});
}
// ------------------------------------------------------------ research
/**
* Begin a research project (ResearchWindow's RESEARCH button → here).
* The window only offers the button when the rules allow it; this is the
* scene-side enforcement — one project at a time, available tech only —
* then the state runs the clock (time-based, data/research.json rules).
*/
beginResearch(catId, id) {
const state = this.researchState;
if (!state) return;
if (state.getActive()) {
this.consoleToast('A RESEARCH PROJECT IS ALREADY IN PROGRESS', {
glyph: '◆',
glyphColor: toCss(themeColor('amber', 0xffc94d)),
});
return;
}
const tree = this._treeFor(catId);
const node = tree?.nodes?.[id];
if (!node) return;
if (!isAvailable(tree, state, id)) {
this.consoleToast('THAT TECH IS NOT AVAILABLE YET', {
glyph: '✕',
glyphColor: toCss(themeColor('amber', 0xffc94d)),
});
return;
}
// The comms reaction (data/comms.json: tetherResearch — oac-minerals
// → oac-fly-mine): OAC reacts the moment the player commits to the
// Tether Level 2 research. The node is the canonical 'tether_l2'
// (data/research/exploration.json — the opening quest's 'research'
// check references the same). Queued now; the busy() contract holds
// the pair until the research console closes.
if (catId === 'exploration' && id === 'tether_l2') {
this.comms?.trigger('tetherResearch');
}
const durMs = Math.max(0, Number(node.duration ?? 0) * 1000);
if (durMs <= 0) {
// Instant tech (duration 0) — applies the moment it's requested.
state.unlock(catId, id);
this._applyResearchEffects(node.effects, node, id);
this._completeResearchFx(catId, id, node);
return;
}
// The in-flight project rides the next explicit save (captureState
// includes record.research — the game's save model is player-chosen).
state.start(catId, id, durMs, this.time.now);
this.consoleToast(`RESEARCH INITIATED — ${String(node.label ?? id).toUpperCase()}`, {
glyph: '◆',
});
this.playSfx('construct'); // the power-up tick (data/sfx.json key 'construct')
this.researchWindow?.refresh();
}
/** A project's clock ran out (update's tick) — apply + celebrate. */
_completeResearch(catId, id) {
const state = this.researchState;
if (!state) return;
state.unlock(catId, id);
const node = this._treeFor(catId)?.nodes?.[id];
if (node?.effects) this._applyResearchEffects(node.effects, node, id);
this._completeResearchFx(catId, id, node);
// The comms sign-off (data/comms.json: researchComplete —
// oac-research-complete, audio-only, once:false — it plays on EVERY
// completion). It arms now, whatever the console is doing: the
// busy() contract holds it while the research window is still open
// (and the FIFO behind any comm already playing), and it rides the
// action once the screen is free — the clock can't expire while an
// ACK comm pauses the scene, so it always lands on a live run.
this.comms?.trigger('researchComplete');
}
/** The tree for a category: the SYSTEM category's live per-system tree,
* or the static file tree (data/research/<id>.json — null if missing).
* The static tree's nodes may carry gates on top of `requires`: a
* STORY-QUEST gate (node. `quest` — 'Special Delivery' reward CLAIMED;
* attachQuestGates wires this scene's live quest check) and a
* BUILD-COMPLETION gate (node. `build` — the named build BUILT;
* attachBuildGates wires this scene's BuildState built set — today the
* cargo 12/16 Bays techs wait on the Improved/Advanced Mining Storage
* builds). Both land in the tree's available/lockNote hooks, so
* beginResearch's isAvailable pass and the READY-RESEARCH cues see them. */
_treeFor(catId) {
if (this.systemTree && catId === this.systemTree.id) return this.systemTree;
const tree = loadCategory(catId);
if (!tree) return tree;
attachBuildGates(tree, (bid) => (this.buildState?.isBuiltAnywhere(bid) ?? false) === true);
return attachQuestGates(tree, (qid) => this.questClaimed(qid));
}
/**
* Any tech in ANY category ready to research right now — none in
* flight (maxConcurrent 1 — nothing can START while a project runs)
* and at least one node with its requires met (the SYSTEM chart's
* extra gate included — the tree's availability hook says so).
* Drives the READY-RESEARCH cue: the deck's RESEARCH slot's shimmer
* (this scene) and the console's per-tab shimmers + gates pulse
* (ResearchWindow._paintCues).
*/
researchAnyAvailable() {
const state = this.researchState;
if (!state || typeof state.getActive !== 'function' || state.getActive()) return false;
for (const cat of categories()) {
const tree = this._treeFor(cat.id);
if (tree && hasAvailable(tree, state)) return true;
}
return false;
}
/**
* The READY-RESEARCH cue's scene-side switch (per frame, update): one
* cue on screen at a time —
* - console CLOSED: the deck's RESEARCH slot wears the periodic
* yellow shimmer (face sweep → edge run, js/ui/ShimmerCue.js);
* - console OPEN: the console wears it instead (its tabs' edge
* shimmers + the jump-gate tech's ACK pulse — _paintCues), and
* the deck's slot rests.
* setShimmer is idempotent, so the per-frame call is a no-op when
* the beat is unchanged. The cue also rests while a project runs
* (researchAnyAvailable) and under the deck's standby (the ActionBar
* owns that hold — the 'ack' comm) and over the tutorial's finger
* (same slot — the finger already owns the eyes).
*/
_applyResearchCue() {
const bar = this.actionBar;
if (!bar || typeof bar.setShimmer !== 'function') return;
const on = this.researchAnyAvailable() && this.researchWindow?.isOpen !== true;
bar.setShimmer(on ? 'research' : null);
}
/** Entering a system begins its NAV chart: the SYSTEM category's
* '{System} Map' tech is granted (duration 0 — never researched via the
* console). Idempotent — create() calls it for the starting system (and
* again after a load, whose restore replaced the unlocked set), and a
* jump's scene restart runs it for the destination. */
_onEnterSystem() {
if (!this.researchState || !this.systemRecord) return;
this.researchState.unlock(SYSTEM_CATEGORY, systemNodeId(this.systemRecord.id, MAP_NODE));
// Invariant: if this system's gates tech is researched, its gates are
// active. Reconcile on entry — idempotent (silent when already online),
// and it recovers any system whose activation was missed at completion
// without a re-research (the node.id bug) or a save round-trip.
const gatesId = systemNodeId(this.systemRecord.id, GATES_NODE);
if (this.researchState.isUnlocked(SYSTEM_CATEGORY, gatesId)) {
this._activateSystemJumpgates(this.systemRecord.id);
}
this.researchWindow?.refresh();
}
/** The SYSTEM category's chart gate: every NAV point of the current
* system (central body, planets, space stations, jump gates — the
* discoverable set minus the asteroid clusters) discovered. */
isSystemNavComplete() {
return isNavComplete(this.discovery, this.systemRecord?.id, this.systemContent);
}
/**
* 'Unlock Jumpgates' completed (SYSTEM category): the system's
* gates AND the return gates in the systems they jump to go ACTIVE.
*
* The activation keys are per-run world state (the registry-backed set —
* New Game clears, a load restores). The current system's gate entities
* wake up here and, per data/gates.json → ACTIVITY, each anchored gate
* carries a level-1 tether at its own position (the room to move — in a
* barren system, the whole room). The linked systems' return gates flip
* their content record now (or on entry — the create() pass); their
* entities + tethers materialise when the player jumps there (the
* JUMP re-stages the scene onto the destination — jumpThroughGate).
* Idempotent throughout.
*/
_activateSystemJumpgates(sysId) {
const level = Math.max(1, Math.floor(Number(config.get('gates.activation.tetherLevel', 1)) || 1));
const keys = activationKeys(this.galaxy, sysId);
// Idempotent: only the NOT-yet-active keys count as new. Re-entering a
// system (a jump, a load, a refresh) re-runs this — the early return
// keeps it silent when everything is already online.
const fresh = keys.filter((k) => !this.activatedGates.has(k));
for (const k of keys) this.activatedGates.add(k);
if (fresh.length === 0) return;
// Current system — the gate entities wake + anchor their tethers.
for (const gt of this.systemGates ?? []) {
if (!gt.gate) continue;
if (!this.activatedGates.has(`${sysId}>${gt.gate.to}`)) continue;
gt.activate();
this.tetherField.add(`gate:${gt.gate.id}`, gt.x, gt.y, level, gt.gate.name ?? '');
}
// Linked systems — flip the cached content (ungenerated systems flip
// in the create() pass on entry; both paths are idempotent).
for (const [sid, content] of this.galaxy.contentCache) applyActivation(content, sid, this.activatedGates);
this.consoleToast(`JUMP GATES ONLINE — ${String(this.systemRecord?.name ?? sysId).toUpperCase()}`, {
glyph: '⌁',
glyphColor: toCss(themeColor('neon', 0x00e5ff)),
});
}
/** The completion ceremony: SFX, toast, the window repaint. */
_completeResearchFx(catId, id, node) {
this.playSfx('discovery'); // the 'something new is here' voice (data/sfx.json)
this.consoleToast(`RESEARCH COMPLETE — ${String(node?.label ?? id).toUpperCase()}`, {
glyph: '✓',
glyphColor: toCss(themeColor('neon', 0x00e5ff)),
});
this.researchWindow?.celebrate(catId, id); // the console's ignition (ring / pulse / flare) — queues if it's closed
this.researchWindow?.refresh();
this._questRefresh(); // a 'Research …' requirement may have just landed
}
/**
* Apply a tech's effects (node.effects, data/research/<cat>.json — or
* the SYSTEM category's live tree). `id` is the node's id (the key in the
* tree's `nodes` map — the node object itself carries no `id` field).
* Known shapes:
* tether { level: N } → the home world's tether field strengthens
* capability "flag" → a scene capability set (future systems read it)
* activateGates true → the SYSTEM category: the system's jump gates
* + the linked systems' return gates go ACTIVE
* Unknown shapes are logged and skipped — data can lead code a step.
*/
_applyResearchEffects(effects, node, id) {
if (!effects || typeof effects !== 'object') return;
for (const [type, spec] of Object.entries(effects)) {
if (type === 'tether' && spec && Number.isFinite(Number(spec.level))) {
const lvl = Math.max(1, Math.floor(Number(spec.level)));
const homeId = this.tetherField.tethers[0]?.id ?? 'home';
this.tetherField.setLevel(homeId, lvl);
this.consoleToast(`TETHER FIELD STRENGTHENED — LEVEL ${lvl}`, {
glyph: '⌖',
glyphColor: toCss(themeColor('neon', 0x00e5ff)),
});
} else if (type === 'capability' && typeof spec === 'string') {
this.researchCapabilities = this.researchCapabilities ?? new Set();
this.researchCapabilities.add(spec);
} else if (type === 'activateGates' && spec) {
// SYSTEM category — the node id is "<systemId>_gates". The node object
// carries no `id` field (it's the key in the tree's `nodes` map), so
// `id` (passed in) is the source of truth — NOT `node.id`.
const sysId = systemIdOfGatesNode(id);
if (sysId) this._activateSystemJumpgates(sysId);
// (The 'systemComplete' milestone beat moved to
// celebrateDiscovery — it fires when this research BECOMES
// AVAILABLE, the chart-completing discovery, not on completion.)
// The 'Leaving the Nest' quest's BRIEFING (data/comms.json:
// trestle-brief-1/2/3 — the three back-to-back ack comms) — the
// HOME system's gates are the run's door out of the nest: the
// moment the player can jump out, Trestle keeps his starter-beat
// promise ('I'll ping you once you're ready'). It lands in front
// of the generic 'researchComplete' sign-off _completeResearch
// queues after the effects — the hub's FIFO plays the specific
// character beat first, then the system sign-off. _trestleBeats
// guards it (quest held + unclaimed) and the comm's once-ledger
// dedupes.
if (sysId && sysId === this.galaxy?.homeSystemId) {
this._trestleBeats();
}
} else {
console.warn(`[orbit] research: unknown effect ${type}`, spec, id);
}
}
}
// ------------------------------------------------------------ builds
/**
* Begin a build on the surface planet (the BUILD button → SurfaceScene
* → here). The BuildWindow only offers the button when the rules allow
* it; this is the scene-side enforcement — one build at a time, not
* already installed, research + planet gates, the full cost paid up
* front (minerals) — then the state runs the clock. The build's time
* base is the game-loop clock passed as `now` (global + monotonic, so
* a build keeps running if the player takes off mid-build).
*
* @param {string} planetName the surface planet (BuildState's record key)
* @param {string} buildId the build (data/builds.json → builds)
* @param {number} now the game-loop clock (ms)
* @returns {{ ok: boolean, reason?: string }} the refusal, if any
*/
beginBuild(planetName, buildId, now) {
const state = this.buildState;
const def = buildDefs()[buildId];
if (!state || !def) return { ok: false, reason: 'BUILD SYSTEM OFFLINE' };
if (state.getActive()) return { ok: false, reason: 'A BUILD IS ALREADY IN PROGRESS' };
// SHIP-scoped builds (targets: ["ship"] — the mining arms/storage) are
// installed ON THE SHIP: already built on ANY planet counts (the
// console shows them BUILT everywhere — no re-buy).
if (isShipScoped(def)) {
if (state.isBuiltAnywhere(buildId)) {
return { ok: false, reason: 'ALREADY INSTALLED ON THE SHIP' };
}
} else if (state.isBuilt(planetName, buildId)) {
return { ok: false, reason: 'ALREADY INSTALLED ON THIS WORLD' };
}
// research gate — the blueprint must be researched (the authoritative
// side; the research tree's unlocks.builds is the declaration side).
for (const req of def.requires ?? []) {
const i = req.indexOf('/');
if (i < 0) continue;
const cat = req.slice(0, i);
const node = req.slice(i + 1);
if (!this.researchState?.isUnlocked(cat, node)) {
return { ok: false, reason: `REQUIRES RESEARCH — ${String(node).toUpperCase()}` };
}
}
// planet gate — the world must hold the tether level the build needs
const needTether = def.planetRequires?.tetherLevel;
if (typeof needTether === 'number' && this.tetherLevelFor(planetName) < needTether) {
return { ok: false, reason: `REQUIRES A LEVEL-${needTether} TETHER ON THIS WORLD` };
}
// cost — the full amount, paid up front. The cost is a { res: amount }
// map (data/builds.json → cost): the planet builds pay MINERALS, the
// cargo bays pay CREDITS + MINERALS (trade income + the mining hold buy
// the expansion). Each line is checked + debited from its own wallet
// (ship.minerals / ship.credits).
const cost = def.cost ?? null;
if (cost && typeof cost === 'object') {
const mineralsCost = Number(cost.minerals ?? 0);
const creditsCost = Number(cost.credits ?? 0);
if (mineralsCost > 0) {
if (this.ship.minerals < mineralsCost) return { ok: false, reason: `NEED ${mineralsCost} MINERALS` };
this.ship.setMinerals(this.ship.minerals - mineralsCost);
this.refreshMineralHud();
}
if (creditsCost > 0) {
if (!this.ship.spendCredits(creditsCost)) return { ok: false, reason: `NEED ${creditsCost} CREDITS` };
this.refreshCreditsHud();
}
}
const durMs = Math.max(1, Number(def.duration ?? 0) * 1000);
state.start(planetName, buildId, durMs, now ?? this.time.now);
this.playSfx('construct'); // the power-up tick (the research voice)
return { ok: true };
}
/**
* A build's clock ran out (the SurfaceScene's tick → here): apply its
* effect to the world. The world change outlives the surface stay — a
* level-2 tether on the way back to space means a level-2 range.
*/
completeBuild(planetName, buildId) {
const def = buildDefs()[buildId];
if (def?.effects) this._applyBuildEffects(planetName, def.effects);
this._questRefresh(); // a 'Build a Level 2 Tether' requirement may have just landed
}
/**
* Apply a build's effects (data/builds.json → builds.<id>.effects).
* tether { level: N } → the world's tether strengthens to at least N
* (never a downgrade) — the ship's travel range widens with it
* capability "flag" → a scene capability set (future systems read it)
* Unknown shapes are logged and skipped — data can lead code a step.
*/
_applyBuildEffects(planetName, effects) {
if (!effects || typeof effects !== 'object') return;
for (const [type, spec] of Object.entries(effects)) {
if (type === 'tether' && spec && Number.isFinite(Number(spec.level))) {
const lvl = Math.max(1, Math.floor(Number(spec.level)));
// The tether anchored on this world — the same lookup as
// tetherLevelFor (by label, or by anchor sitting on the world's
// center — the home tether is anchored at the origin with the
// home world's name).
const obj =
this.systemPlanets.find((p) => p.discoveryName === planetName) ??
this.systemStations.find((s) => s.discoveryName === planetName) ??
(planetName === this.planet?.discoveryName ? this.planet : null);
const t = this.tetherField?.tethers.find(
(tt) => tt.label === planetName ||
(obj && tt.x === obj.x && tt.y === obj.y),
);
if (t) {
if (t.level < lvl) {
this.tetherField.setLevel(t.id, lvl);
this.consoleToast(`TETHER FIELD STRENGTHENED — LEVEL ${lvl}`, {
glyph: '⌖',
glyphColor: toCss(themeColor('neon', 0x00e5ff)),
});
}
} else if (obj && this.tetherField) {
// The world's FIRST tether (the tether-l1 build on a newly
// discovered world): anchor one on the world's center — the
// player-owned anchor model (the field is the player's, the
// world hosts it). Label = the world's name, so
// tetherLevelFor / the surface HUD / the build gates see it,
// and the save's tether list carries it with the run.
this.tetherField.add(`planet:${planetName}`, obj.x, obj.y, lvl, planetName);
this.consoleToast(`TETHER ANCHORED — LEVEL ${lvl}`, {
glyph: '⌖',
glyphColor: toCss(themeColor('neon', 0x00e5ff)),
});
} else {
console.warn(`[orbit] build: world "${planetName}" not found — cannot anchor its tether`, spec);
}
} else if (type === 'capability' && typeof spec === 'string') {
this.researchCapabilities = this.researchCapabilities ?? new Set();
this.researchCapabilities.add(spec);
} else if (type === 'mining' && spec && typeof spec === 'object') {
// SHIP upgrades (the mining arms/storage): effects.mining.rate =
// the ship's mining speed (minerals/s), effects.mining.capacity =
// the mineral hold's cap. MAX wins — never a downgrade.
this._applyMiningUpgrade(spec);
} else if (type === 'cargo' && spec && typeof spec === 'object') {
// SHIP upgrade (the cargo bay): effects.cargo.bays = the hold's bay
// count (the trade capacity). MAX wins — 4 → 8 → 12 → 16, never a
// downgrade.
this._applyCargoUpgrade(spec);
} else {
console.warn(`[orbit] build: unknown effect ${type}`, spec);
}
}
}
/**
* Apply a mining SHIP upgrade (a build's effects.mining, data/builds.json):
* `rate` → stats.miningSpeed (minerals/s — Mining multiplies the base
* economy rate by it), `capacity` → stats.mineralStorage (the hold's
* cap — ship.storageRoom / the corner HUD read n / cap). MAX wins: a
* slower arm or a smaller hold is never installed over a better one
* (build the advanced arm, then the improved — the ship keeps 2.0/s).
*/
_applyMiningUpgrade(spec) {
const ship = this.ship;
if (!ship?.stats) return;
if (Number.isFinite(Number(spec.rate))) {
const rate = Math.max(0, Number(spec.rate));
if (rate > (Number(ship.stats.miningSpeed) || 0)) {
ship.stats.miningSpeed = rate;
this.consoleToast(`MINING ARM UPGRADED — ${rate} PER SECOND`, {
glyph: '▲',
glyphColor: toCss(themeColor('amber', 0xffc94d)),
});
}
}
if (Number.isFinite(Number(spec.capacity))) {
const cap = Math.max(0, Math.round(Number(spec.capacity)));
if (cap > (Number(ship.stats.mineralStorage) || 0)) {
ship.stats.mineralStorage = cap;
this.refreshMineralHud(); // the corner readout is n / CAP — push the new cap
this.consoleToast(`MINERAL STORAGE EXPANDED — ${cap} CAPACITY`, {
glyph: '▲',
glyphColor: toCss(themeColor('amber', 0xffc94d)),
});
}
}
}
/**
* Apply a cargo SHIP upgrade (a build's effects.cargo,
* data/builds.json): `bays` = the hold's bay count (the trade capacity).
* MAX wins — a smaller hold is never installed over a bigger one (build
* the 16-bay, then a 12 — the ship keeps 16). Toasts the expansion.
*/
_applyCargoUpgrade(spec) {
const ship = this.ship;
if (!ship || typeof ship.setCargoHold !== 'function') return;
if (Number.isFinite(Number(spec.bays))) {
const bays = Math.max(0, Math.round(Number(spec.bays)));
if (bays > (Number(ship.stats?.cargoHold) || 0)) {
ship.setCargoHold(bays);
this.refreshCreditsHud(); // the hold is now worth more — keep the readout honest
this.consoleToast(`CARGO HOLD EXPANDED — ${bays} BAYS`, {
glyph: '▲',
glyphColor: toCss(themeColor('amber', 0xffc94d)),
});
}
}
}
/**
* Re-derive the ship's mining upgrades from the build RECORDS (the
* save carries the records, not the ship's derived stats): the MAX
* effects.mining.rate / capacity across every installed build (any
* planet). Idempotent + MAX-guarded — a fresh run has no mining
* records and keeps its base stats; a load / jump-cut restore re-asserts
* them. Runs in create(), right after the built tethers are
* re-anchored (same seam: world/ship state re-derived from the
* outliving build records).
*/
_restoreMiningUpgrades() {
if (!this.buildState || !this.ship?.stats) return;
let rate = 0;
let capacity = 0;
for (const ids of this.buildState.built.values()) {
for (const id of ids) {
const spec = defById(id)?.effects?.mining;
if (!spec) continue;
if (Number.isFinite(Number(spec.rate))) rate = Math.max(rate, Number(spec.rate));
if (Number.isFinite(Number(spec.capacity))) capacity = Math.max(capacity, Number(spec.capacity));
}
}
if (rate > (Number(this.ship.stats.miningSpeed) || 0)) this.ship.stats.miningSpeed = rate;
if (capacity > (Number(this.ship.stats.mineralStorage) || 0)) this.ship.stats.mineralStorage = capacity;
if (rate > 0 || capacity > 0) this.refreshMineralHud(); // the HUD was pushed with the FRESH hold earlier in create()
}
/** The RESEARCH slot's progress bar (update() calls it every frame). */
_deckResearchBar(time) {
const bar = this._researchDeckBar;
if (!bar) return;
const p = this.researchState?.progress(time);
const slot = this.actionBar?.slots?.find((s) => s.id === 'research');
if (!p || !slot) {
if (bar.g.visible) bar.g.setVisible(false);
if (bar.txt.visible) bar.txt.setVisible(false);
return;
}
const style = this.actionBar.style;
const bw = style.bw;
const bh = style.bh;
const x = slot.slot.x - bw / 2;
const y = slot.slot.y - bh / 2;
const accent = toColor(themeColor('neon', 0x00e5ff));
const g = bar.g;
g.setVisible(true);
g.clear();
g.fillStyle(0x060a10, 0.85);
g.fillRect(x + 8, y - 11, bw - 16, 5);
g.fillStyle(accent, 0.95);
g.fillRect(x + 8, y - 11, (bw - 16) * p.fraction, 5);
const txt = bar.txt;
txt.setVisible(true);
txt.setText(`RESEARCHING ${Math.round(p.fraction * 100)}%`);
txt.setColor(toCss(accent));
txt.setPosition(slot.slot.x, y - 15);
}
/**
* The SCAN button: fire the deep-scan pulse from the ship across the
* tether region. The sweep's TARGET SET is the discoverable objects
* inside the tether union (the wave is absorbed at the union boundary,
* so nothing beyond it is in range). One sweep at a time (busy guard);
* `enabled: false` in data/scan.json makes the button a no-op.
*/
startScan() {
if (!config.get('scan.enabled', true)) {
console.info('[orbit] scan disabled (data/scan.json)');
return;
}
if (this.scanPulse.busy) return; // a sweep is already out
this._tutScanPressed = true; // the tutorial's finger moves off SCAN now
// The comms reaction (data/comms.json: firstScan — oac-upgrade): the
// comm's own delayMs is the beat after this click; the busy() contract
// below (scanPulse.busy) holds it until the sweep settles.
this.comms?.trigger('firstScan');
const ox = this.ship.x;
const oy = this.ship.y;
const tethers = this.tetherField.tethers.map((t) => ({ x: t.x, y: t.y, radius: t.radius }));
this.scanObjects = this.discoverableObjects()
.filter((o) => this.tetherField.contains(o.x, o.y))
.map((o) => ({ ...o, dist: Math.hypot(o.x - ox, o.y - oy), hit: false }));
this.scanPulse.begin(ox, oy, tethers, this.time.now, {
onEmit: () => {
this.playSfx('scan'); // the ping goes out
this.consoleToast('DEEP SCAN — SWEEPING TETHER REGION', {
glyph: '\u25c8',
glyphColor: toCss(themeColor('neon', 0x00e5ff)),
durationMs: this.scanPulse.durationMs + 500,
});
},
onAbsorb: () => this.tetherField.excite(1.15), // the barrier shivers as the wave is swallowed
onComplete: () => this.finishScan(),
});
this.hideHint();
}
/** Per-frame: ring every in-region object the moment the front crosses it. */
updateScanHits(time) {
const r = this.scanPulse.radiusAt(time);
if (r <= 0) return;
for (const o of this.scanObjects) {
if (o.hit || r < o.dist) continue;
o.hit = true;
this.scanRippleObject(o);
}
}
/** The hit: the solid pulses in scale (sonar echo) + a shock ring at it. */
scanRippleObject(o) {
const solid = this.solids.find((s) => s.discoveryId === o.id) ?? this.planet;
const s0 = solid.scale;
this.tweens.add({
targets: solid,
scale: s0 * (1 + this.scanObjectScale),
duration: 150,
yoyo: true,
repeat: 1,
ease: 'Sine.easeOut',
});
this.scanPulse.ripple(o.x, o.y, o.radius, this.time.now);
}
/**
* The sweep finished — the seam for SCAN RESULTS (what a scan yields is
* the next instruction's call). For now: log the in-region objects and
* report the count in the console slot.
*/
/**
* The sweep finished — the scan's OUTPUT. For now this is the seam the
* results will grow out of (what a scan YIELDS is a later instruction's
* call): log the in-region objects + report the count, and — the part
* the player can act on — light up the SECONDARY COMPASS: every
* in-region object becomes a faint bearing signal on the ring around the
* ship (the compass itself drops any already discovered, and drops them
* the instant they get discovered). A fresh scan re-emits the set with a
* fresh clock (a re-ping).
*/
finishScan() {
const objs = this.scanObjects ?? [];
const hits = objs.filter((o) => o.hit).length;
console.info(`[orbit] scan complete — ${hits}/${objs.length} objects in tether range`, objs);
// The compass emission (fullMs + fadeMs of life; see updateSignalCompass).
this.scanReveal = { bornAt: this.time.now, ids: objs.map((o) => o.id) };
this.consoleToast(`SCAN COMPLETE — ${hits} OBJECT${hits === 1 ? '' : 'S'} DETECTED IN TETHER RANGE`, {
glyph: '\u25c8',
glyphColor: toCss(themeColor('neon', 0x00e5ff)),
durationMs: 2600,
});
this.scanObjects = null;
}
/**
* The SECONDARY COMPASS, per frame (js/ui/SignalCompass.js): resolve the
* current scan emission into live bearing signals. A signal exists per
* in-region object that is still UNDISCOVERED; its strength follows the
* full→fade envelope (signalAlpha) from the emission's birth. Positions
* are resolved LIVE, so the bearings track both the ship and any drifting
* objects. Expired (fullMs + fadeMs) or discovered ⇒ dropped.
*/
updateSignalCompass(time) {
const compass = this.signalCompass;
if (!compass) return;
const sx = this.ship.x, sy = this.ship.y;
const reveal = this.scanReveal;
if (!reveal || !Array.isArray(reveal.ids)) {
compass.refresh([], time, sx, sy);
return;
}
const age = time - reveal.bornAt;
if (age >= this.signalFullMs + this.signalFadeMs) {
this.scanReveal = null; // the emission has fully faded
compass.refresh([], time, sx, sy);
return;
}
const alpha = signalAlpha(age, this.signalFullMs, this.signalFadeMs);
const sysId = this.systemRecord.id;
const objects = this.discoverableObjects(); // one build per frame
const signals = [];
for (const id of reveal.ids) {
if (this.discovery.isDiscovered(sysId, id)) continue; // found it → its signal is done
const o = objects.find((v) => v.id === id);
if (!o) continue;
signals.push({ id, x: o.x, y: o.y, alpha, color: o.color });
}
compass.refresh(signals, time, sx, sy);
}
/**
* The MENU button: fold the sub-bar up / fold it back down.
* (While the pop-up is open, the button closes the pop-up instead.)
*/
menuAction() {
if (config.get('save.subBar.enabled', true) !== true) return;
if (this.savePanel && this.savePanel.isOpen) {
this.savePanel.close();
return;
}
if (this.researchWindow && this.researchWindow.isOpen) return; // the console owns input
if (this.menuSubBar && this.menuSubBar.isOpen) {
this.menuSubBar.close();
return;
}
// Load Game is live only when the bank holds at least one save.
this.menuSubBar.setDisabled('load', !this.saveManager.hasAny());
this.menuSubBar.open();
}
/** The sub-bar's button presses (js/ui/MenuSubBar.js → 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;
}
}
/** ESC: topmost open thing first — confirm dialog → pop-up → research → mining → sub-bar. */
escAction() {
if (this._commInputBlocked()) return; // the comm owns the input (no skip)
if (this.savePanel) {
if (this.savePanel.confirm.isOpen) {
this.savePanel.confirm.cancel();
return;
}
if (this.savePanel.isOpen) {
this.savePanel.close();
return;
}
}
// The research console (depth 80) — above everything else but the save UI.
if (this.researchWindow && this.researchWindow.isOpen) {
this.researchWindow.close();
return;
}
// The map console (depth 80) — the same contract. Its autopilot
// confirm sits on top: ESC cancels the dialog, not the map.
if (this.mapWindow && this.mapWindow.isOpen) {
if (this.mapWindow.dialog && this.mapWindow.dialog.isOpen) {
this.mapWindow.dialog.cancel();
return;
}
this.mapWindow.close();
return;
}
// The quests console (depth 80) — the same contract.
if (this.questWindow && this.questWindow.isOpen) {
this.questWindow.close();
return;
}
// The mining context menu is the topmost open thing after the save UI.
if (this.miningPopup && this.miningPopup.isOpen) {
this.miningPopup.close();
return;
}
// The comms panel (a planet/station click opened it).
if (this.commsPanel && this.commsPanel.isOpen) {
this.commsPanel.close();
return;
}
// Beam live (or arm extending) → break the mining (the beam retracts;
// the ship is free again — no fly).
if (this.mining && (this.mining.state === 'mining' || this.mining.state === 'extending')) {
this.mining.stop();
return;
}
if (this.menuSubBar && this.menuSubBar.isOpen) this.menuSubBar.close();
}
/** Return to the main menu (the sub-bar's last button). */
returnToMenu() {
this.savePanel?.close();
this.menuSubBar?.dismiss();
this.scene.start('MenuScene');
}
/** The sub-bar's anchor: the MENU button's center x + width, and the
* deck's TOP EDGE as the resting line — the sub-bar rests on the bottom
* bar (the deck), not on the button (the buttons are inset below the
* bar's top rail, so anchoring to the button made the sub-bar spill
* over the deck's top edge). */
menuAnchor() {
const menuSlot = this.actionBar?.slots?.find((s) => s.id === 'menu');
if (menuSlot) {
return {
x: menuSlot.slot.x,
y: this.actionBar.rect.y, // deck top edge — the sub-bar's bottom edge sits on it
w: this.actionBar.style.bw,
};
}
// Deck disabled (dev): a virtual button at the bottom right.
return { x: this.scale.width - 96, y: this.scale.height - 58, w: 120 };
}
/**
* Apply a staged restore (js/save/SaveData.js → prepareLoad parked it):
* the ship back where it was — and with its hold full or empty as saved
* (ship.minerals — the upper-right mineral readout follows) — the saved
* tether field (the level-1 home tether is just a saved entry — the
* field is rebuilt from the record), and the saved session time.
* Discovery + galaxy are already restored in the registry (this scene's
* create() read them).
*/
/**
* The `starting` installs (data/builds.json → `starting`): rule-level
* pre-builds the player owns from the first frame — the home world's
* level-1 tether ("every world starts with its tether already in
* place"). 'home' is the seed-independent home key; resolve it to the
* home world's name (BuildState keys records by planet name — the same
* key the tether field and the surface scene use). Idempotent: create()
* seeds a fresh state, and applyRestore() re-seeds AFTER a load — a save
* captured before the seed existed (or by an older iteration) replaces
* the seeded state, and must not un-install the home world's starting
* tether. A player resuming a run always has L1 on home.
*/
_seedStartingBuilds() {
for (const [planet, build] of startingPairs()) {
// 'home' is the player's home world — the starting system's central
// body only. In every other system the central body is the star,
// whose origin tether is the scene's native set (added in create),
// not a build — so the seed does not apply there.
const name = planet === 'home'
? (this.isHomeSystem ? this.planet?.discoveryName : null)
: planet;
if (name) this.buildState.markBuilt(name, build);
}
}
/**
* Re-anchor the tethers the player has INSTALLED on this system's
* worlds (a build's `effects.tether`, data/builds.json) whenever the
* field is rebuilt to its native set — a jump cut stages an empty
* tether list (rec.tethers = []) and the scene restart re-forms home +
* the activated gates only, while the installed tethers (and their
* built records) outlive the cut. Idempotent: the installed level is
* the MAX of the world's tether builds (level-1 + level-2 ⇒ level 2);
* an existing tether is only ever strengthened, never created twice or
* downgraded.
*/
_rematerializeBuiltTethers() {
if (!this.buildState || !this.tetherField) return;
for (const [planetName, builds] of this.buildState.built.entries()) {
const world =
this.systemPlanets.find((p) => p.discoveryName === planetName) ??
this.systemStations.find((s) => s.discoveryName === planetName) ??
(planetName === this.planet?.discoveryName ? this.planet : null);
if (!world) continue; // another system's world — re-anchors on the way back
let level = 0;
for (const id of builds) {
const lvl = Number(defById(id)?.effects?.tether?.level);
if (Number.isFinite(lvl) && lvl > level) level = lvl;
}
if (!level) continue;
const t = this.tetherField.tethers.find(
(tt) => tt.label === planetName || (tt.x === world.x && tt.y === world.y),
);
if (!t) {
this.tetherField.add(`planet:${planetName}`, world.x, world.y, level, planetName);
} else if (t.level < level) {
this.tetherField.setLevel(t.id, level);
}
}
}
applyRestore(r) {
if (r.ship) {
this.ship.setPosition(Number(r.ship.x) || 0, Number(r.ship.y) || 0);
if (typeof r.ship.heading === 'number') this.ship.rotation = r.ship.heading;
// The hold — saves that predate minerals have no field (stay at 0);
// setMinerals clamps to the ship's capacity.
if (typeof r.ship.minerals === 'number') this.ship.setMinerals(r.ship.minerals);
// The credit ledger (data/economy.json) — a save predating credits
// has no field; setCredits floors a missing/invalid value at 0.
this.ship.setCredits(r.ship.credits ?? 0);
// The unlocked cargo bays (ship.stats.cargoHold) — the run's ship
// starts with 0 (data/ship.json) and the bar scene's DONE press is
// what builds it out (onVignetteComplete); the hold is ship state,
// so the save carries it and the load restores it BEFORE the goods
// go back in. A save predating the field is the old-story era (the
// package landed on the capital landing and the ship's base hold
// was 4): fall back to "enough bays for the goods it carried" so a
// mid-delivery save's package survives the load.
if (typeof this.ship.setCargoHold === 'function') {
const carried = Array.isArray(r.ship.cargo) ? r.ship.cargo.length : 0;
const hold = typeof r.ship.cargoHold === 'number' ? r.ship.cargoHold : carried;
if (hold > (Number(this.ship.stats?.cargoHold) || 0)) this.ship.setCargoHold(hold);
}
// The trade-goods hold (data/economy.json → cargo) — a save predating
// cargo has no field; set() degrades to an empty hold (old saves load).
// Capacity is the restored stat above (0, 4, or an upgraded hold).
if (this.ship.cargo) this.ship.cargo.set(r.ship.cargo ?? []);
}
// The corner readouts (upper right) were built with the FRESH hold (0)
// earlier in create() — push the restored values through them. No-op
// when the save held nothing (set() skips unchanged values — no
// count-up, no flash), so mineral-less saves stay quiet.
this.refreshMineralHud();
this.refreshCreditsHud(); // same seam — the ledger line was set to the fresh 0 at build
if (Array.isArray(r.tethers) && r.tethers.length > 0) {
for (const t of [...this.tetherField.tethers]) this.tetherField.remove(t.id);
for (const t of r.tethers) {
if (!t || typeof t.id !== 'string') continue;
this.tetherField.add(t.id, Number(t.x) || 0, Number(t.y) || 0, t.level ?? 1, t.label ?? '');
}
}
this.playTimeMs = Number(r.playTimeMs) || 0;
// Research — the unlocked set + the in-flight project (fresh clock).
if (r.research && this.researchState) {
try {
const fresh = ResearchState.fromJSON(r.research);
this.researchState.reset();
this.researchState.unlocked = fresh.unlocked;
this.researchState.restoreActive(r.research.active ?? null, this.time.now);
this.researchWindow?.refresh();
} catch (e) {
console.warn('[orbit] restore: research skipped', e);
}
}
// Builds — the installed set + the in-flight build (fresh clock — the
// game-loop clock: the build's time base, shared across scenes).
if (r.builds && this.buildState) {
try {
const fresh = new BuildState().fromJSON(r.builds);
// World state is keyed by the world's CANONICAL spelling. Saves
// written while the comms panel leaked its display casing into
// the landing handoff can carry uppercased planet keys —
// re-key them (merging into the canonical entry) so the surface
// and the tether field see the same world again.
const known = [
this.planet?.discoveryName,
...(this.systemPlanets ?? []).map((p) => p.discoveryName),
...(this.systemStations ?? []).map((s) => s.discoveryName),
...(this.tetherField?.tethers ?? []).map((t) => t.label),
];
for (const [k, v] of [...fresh.built.entries()]) {
const c = canonicalPlanetName(k, known);
if (c === k) continue;
fresh.built.delete(k);
const existing = fresh.built.get(c);
fresh.built.set(c, existing ? new Set([...existing, ...v]) : v);
}
this.buildState.reset();
this.buildState.built = fresh.built;
const activeSpec =
r.builds.active && typeof r.builds.active.planet === 'string'
? { ...r.builds.active, planet: canonicalPlanetName(r.builds.active.planet, known) }
: (r.builds.active ?? null);
this.buildState.restoreActive(activeSpec, this.game.loop.now);
} catch (e) {
console.warn('[orbit] restore: builds skipped', e);
}
}
// The starting installs are a RULE (data/builds.json → `starting`),
// not save data — the load above just replaced the seeded state, so
// re-assert them: the home world's level-1 tether is installed from
// the first frame, even on a run resumed from a save that predates
// the seed (or captured before it was recorded).
if (this.buildState) this._seedStartingBuilds();
// The camera was centered on the spawn — recentre on the restored ship.
this.cameras.main.setScroll(this.ship.x - this.scale.width / 2, this.ship.y - this.scale.height / 2);
this.playSfx('construct');
}
/**
* Drop the LAST entry's per-system effect layers (nebula / haze / void /
* storm / embers / wanderer) — the conditional ones, created just below
* in create(). A jump's cut is a scene RESTART that reuses this scene
* instance (v4: sys.shutdown + the 'shutdown' EVENT — the display list
* destroys its children, but this scene's refs + its update() calls
* survive, and our shutdown() method is never called), so without this
* a previous system's layers would keep running — and the habitable
* storm would keep playing thunder in a system with no storm. Safe to
* run over null refs (first boot) and over already-destroyed layers
* (the layers' destroy()s are idempotent, as is the shutdown() net
* below). create() calls this before building this system's set.
*/
_teardownEffectLayers() {
this.nebula?.destroy();
this.nebula = null;
this.haze?.destroy();
this.haze = null;
this.voidBase?.destroy();
this.voidBase = null;
this.storm?.destroy();
this.storm = null;
this.embers?.destroy();
this.embers = null;
this.wanderer?.destroy();
this.wanderer = null;
this.nebulaLightning?.destroy();
this.nebulaLightning = null;
}
shutdown() {
this.setMiningLoop(false); // the hum can't outlive the scene
this.stopGameMusic(); // neither can the soundtrack
this.starfield?.destroy();
this.engineFx?.destroy(); // the exhaust can't outlive the scene
this.nebula?.destroy();
this.haze?.destroy();
this.voidBase?.destroy();
this.storm?.destroy();
this.embers?.destroy();
this.wanderer?.destroy();
this.nebulaLightning?.destroy();
this.compass?.destroy();
this.actionBar?.destroy();
this.combat?.destroy();
this.combatBar?.destroy();
this.menuSubBar?.destroy();
this.savePanel?.destroy();
this.miningPopup?.destroy();
this.commsPanel?.destroy();
this.researchWindow?.destroy(); // the console (video + UI)
this.mapWindow?.destroy(); // the map console (video + chart canvas)
this.creditsHud?.destroy();
this.mineralHud?.destroy();
this.mining?.destroy();
this.scanPulse?.destroy();
this.signalCompass?.destroy();
this.tetherField?.destroy();
}
}