orbit/js/scenes/GameScene.js

3627 lines
160 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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

import Phaser from '../vendor/phaser.js';
import { config } from '../config/Config.js';
import { toColor, toCss } from '../utils/Color.js';
import { fontStack, themeColor } from '../utils/Theme.js';
import { Rng } from '../utils/Rng.js';
import { Galaxy } from '../galaxy/Galaxy.js';
import { formatSystemReport } from '../galaxy/SystemReport.js';
import { Discovery } from '../galaxy/Discovery.js';
import { Reputation, HOME_KEY } from '../reputation/Reputation.js';
import { ScrambleDecode, decodeDur } from '../utils/Decode.js';
import { canonicalPlanetName } from '../utils/WorldNames.js';
import { playSfxOn, sfxPlayingOn, stopSfxOn } from '../utils/Sfx.js';
import { gameTrackKey, startMusicShuffleOn, stopMusicShuffleOn } from '../utils/Music.js';
import { Ship } from '../entities/Ship.js';
import { 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 } from '../galaxy/JumpTravel.js';
import { Starfield } from '../visuals/Starfield.js';
import { DiscoveryCompass, circleInView } from '../ui/DiscoveryCompass.js';
import { ActionBar } from '../ui/ActionBar.js';
import { MineralHud } from '../ui/MineralHud.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 { 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 { 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, buildDefs } from '../research/ResearchModel.js';
import {
SYSTEM_CATEGORY,
MAP_NODE,
GATES_NODE,
systemNodeId,
systemIdOfGatesNode,
buildSystemTree,
isNavComplete,
activationKeys,
applyActivation,
} from '../research/SystemCategory.js';
import { BuildWindow } from '../ui/BuildWindow.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 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 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 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 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 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_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.events.on('sleep', () => {
this.stopGameMusic(); // no space hum on a world's surface
this.setMiningLoop(false); // …nor the mining hum
});
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();
// 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);
this.systemRecord = this.galaxy.currentSystem();
this.systemContent = this.galaxy.ensureContent(this.systemRecord.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;
}
// 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 02), 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 and waypoints): 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);
const spawn = this.planet
? this.planet.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);
// 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();
// 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);
}
// 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, two reserved, Menu. The slots are the seams
// for the player's loop — research (time-based, one at a time, see
// data/research.json) and scan (its behavior and panel come next).
this.actionBar = deckEnabled
? new ActionBar(this, {
onAction: (id) => this.deckAction(id),
})
: 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.
this.mineralHud = new MineralHud(this);
this.mineralHud.set(this.ship.minerals, this.ship.stats.mineralStorage);
// 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),
onEvent: (name, data) => this.onMiningEvent(name, data),
onOre: () => this.refreshMineralHud(), // live: the hold fills as the rock shrinks
});
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 {System} 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)),
},
),
});
// 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;
}
// 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;
// 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;
// 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.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;
}
// 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);
}
/**
* 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;
}
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 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}")`);
}
}
/**
* Top-left HUD: the current system's dossier — name, identity, and what
*'s ALREADY THERE: colonies, mining stations, cloud bases, stations adrift
* in open space (or "charted · unclaimed" when nobody's settled here).
* 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 (seed 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): what's already there ------------
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: '#8fa0c9', letterSpacing: 1 });
y += 2;
for (const s of report.settlements) {
line(s.text, { fontFamily: fam, fontSize: '13px', color: toCss(s.color, '#8fa0c9') });
}
for (const gt of report.gates ?? []) {
line(gt.text, { fontFamily: fam, fontSize: '13px', color: toCss(gt.color, '#5fd4ff') });
}
line(report.summary, { fontFamily: fam, fontSize: '12px', color: '#54608a' });
line(`seed ${this.galaxy.seed}`, { fontFamily: fam, fontSize: '11px', color: '#3d476b' });
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 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 (seed → … →
* 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
} 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 → … → seed) — the same decode as
* arrival, just without the name (it never left).
*/
startExpand(t) {
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, t + HUD_EXPAND_LEAD + i * HUD_STAGGER, decodeDur(ln.value.length)),
})),
};
}
/**
* Fold the details: the lines DECONSTRUCT in reverse build order (seed
* 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;
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.updateHud(_time); // the dossier: decode, caret, auto-fold, toggle
this.ship.update(_time, delta);
// 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).
const _researchDone = this.researchState?.tick(_time) ?? null;
if (_researchDone?.length) {
for (const d of _researchDone) this._completeResearch(d.category, d.id);
}
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._deckResearchBar(_time); // the RESEARCH slot's progress bar
// 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) {
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);
for (const st of this.systemStations) st.update(_time); // the ring turns, the beacon breathes
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.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)
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];
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 };
// 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 red (data/stations.json →
// compassColor) — the arrow + name tag pick it up.
color: config.get('stations.compassColor', '#ff4d5e'),
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) {
// 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;
}
// 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;
rec.systemName = dest.name;
rec.ship = {
x: arrival.x,
y: arrival.y,
heading: arrival.heading,
minerals: rec.ship?.minerals ?? 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);
// 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;
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
*/
commsTargetFor(obj) {
let key = null;
let settled = false;
let kindLabel = '';
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');
} else if (obj.settlement) {
// A free-space station — settled by definition; key = its id.
key = obj.settlement.id;
settled = true;
kindLabel = config.get(`settlements.kinds.${obj.kind}.label`, 'Station');
} 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) {
kindLabel = config.get(`planets.typeLabels.${rec.name}`, rec.name);
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;
}
}
}
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,
canLand: settled && rep > -4 && landingOn, // panel grays the button otherwise
kindLabel,
isPlanet,
frame: isPlanet ? (obj.sheetFrame ?? 0) : null, // planets.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
* 'land' — an unsettled world → land
* 'cancel' — just close (the console log is the ack)
*/
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 missing destination).
const gt = target.gate?.entity;
if (gt) this.jumpThroughGate(gt);
return;
}
if ((id === 'request-landing' || id === 'land') && target.isPlanet) {
this.startLanding(target);
return;
}
// 'request-landing' on a space station — no surface on a station yet;
// the click is acknowledged on the console for now.
console.info(`[orbit] comms: ${id}${target.name} (key: ${target.key ?? 'none'})`);
}
/**
* 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;
}
// 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).
const name = canonicalPlanetName(target.name, [
this.planet?.discoveryName,
...(this.systemPlanets ?? []).map((p) => p.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 index (`.frame` is the texture
// Frame OBJECT — Number() of it is NaN, and the video/music lookups
// need the number).
this.scene.launch('SurfaceScene', {
frame: Number(target.frame ?? target.sheetFrame ?? 0),
name,
type: target.kindLabel ?? '',
tetherLevel: this.tetherLevelFor(name),
});
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) ??
(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;
}
/**
* 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();
}
}
/**
* 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);
}
/**
* 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);
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,
});
}
showTargetMarker(x, y) {
const color = toColor(config.get('game.markerColor', '#41c7ff'));
const marker = this.add.circle(x, y, 10, color, 0.8).setDepth(5);
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;
},
});
}
/**
* 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 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 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}`);
}
/**
* 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: st.kind === 'waypoint' ? 'Waypoint' : '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;
return systemChartSnapshot(systemId, this.galaxy.contentOf(systemId), {
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;
}
/**
* 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()),
);
}
/**
* 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;
}
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 tree for a category: the SYSTEM category's live per-system tree,
* or the static file tree (data/research/<id>.json — null if missing). */
_treeFor(catId) {
if (this.systemTree && catId === this.systemTree.id) return this.systemTree;
return loadCategory(catId);
}
/** 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 {System} 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?.refresh();
}
/**
* 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);
} 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 (minerals are the build
// currency today — data/builds.json → cost)
const cost = Number(def.cost?.minerals ?? 0);
if (cost > 0) {
if (this.ship.minerals < cost) return { ok: false, reason: `NEED ${cost} MINERALS` };
this.ship.setMinerals(this.ship.minerals - cost);
this.refreshMineralHud();
}
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);
}
/**
* 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) ??
(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 {
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)),
});
}
}
}
/**
* 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
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.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 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) ??
(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 corner readout (upper right) was built with the FRESH hold (0)
// earlier in create() — push the restored value through it. No-op
// when the save held nothing (set() skips unchanged values — no
// count-up, no flash), so mineral-less saves stay quiet.
this.refreshMineralHud();
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.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');
}
shutdown() {
this.setMiningLoop(false); // the hum can't outlive the scene
this.stopGameMusic(); // neither can the soundtrack
this.starfield?.destroy();
this.compass?.destroy();
this.actionBar?.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.mineralHud?.destroy();
this.mining?.destroy();
this.scanPulse?.destroy();
this.signalCompass?.destroy();
this.tetherField?.destroy();
}
}