6491 lines
305 KiB
JavaScript
6491 lines
305 KiB
JavaScript
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();
|
||
}
|
||
}
|