3162 lines
137 KiB
JavaScript
3162 lines
137 KiB
JavaScript
import Phaser from '../vendor/phaser.js';
|
||
import { config } from '../config/Config.js';
|
||
import { toColor, toCss } from '../utils/Color.js';
|
||
import { fontStack, themeColor } from '../utils/Theme.js';
|
||
import { Rng } from '../utils/Rng.js';
|
||
import { Galaxy } from '../galaxy/Galaxy.js';
|
||
import { formatSystemReport } from '../galaxy/SystemReport.js';
|
||
import { Discovery } from '../galaxy/Discovery.js';
|
||
import { Reputation, HOME_KEY } from '../reputation/Reputation.js';
|
||
import { ScrambleDecode, decodeDur } from '../utils/Decode.js';
|
||
import { canonicalPlanetName } from '../utils/WorldNames.js';
|
||
import { playSfxOn, sfxPlayingOn, stopSfxOn } from '../utils/Sfx.js';
|
||
import { gameTrackKey, startMusicShuffleOn, stopMusicShuffleOn } from '../utils/Music.js';
|
||
import { Ship } from '../entities/Ship.js';
|
||
import { Planet } from '../entities/Planet.js';
|
||
import { Star } from '../entities/Star.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 } from '../galaxy/SystemChart.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);
|
||
}
|
||
for (const j of this.systemContent.jumps ?? []) {
|
||
if (j && this.activatedGates.has(`${this.systemRecord.id}>${j.to}`)) j.active = true;
|
||
}
|
||
// The CENTRAL BODY — one per system, always at the origin, always the
|
||
// system's 'home' NAV point (js/research/SystemCategory.js → navPoints):
|
||
// in the STARTING system it is the player's home world (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); in every OTHER system it is
|
||
// the system's STAR (content.star — name + spectral class). The home
|
||
// world is the ONLY central body that is a world: only here is it a
|
||
// comms target (settled, landable) and only here does it read "Home
|
||
// World" — a star is not a comms object, and never "home".
|
||
// 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 planet — 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;
|
||
} else {
|
||
// The system's star — the central body of every other system
|
||
// (content.star from the generator: name + spectral class).
|
||
const star = this.systemContent.star ?? {};
|
||
this.planet = new Star(this, 0, 0, star);
|
||
this.planet.discoveryName = star.name ?? 'Star';
|
||
}
|
||
this.planet.setDepth(5); // above the starfield (depths 0–2), below the ship (10)
|
||
|
||
// 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));
|
||
const tint = config.get(`planets.classTint.${kind}`);
|
||
const p = new Planet(this, rec.x, rec.y, frame, kind, {
|
||
scale: rec.scale ?? 1,
|
||
tint: tint === undefined ? undefined : toColor(tint),
|
||
});
|
||
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.
|
||
// Ship constraint, click-to-fly clamping and autopilot all run
|
||
// against this list.
|
||
this.solids = [this.planet, ...this.systemPlanets, ...this.asteroidClusters, ...this.systemStations, ...this.systemGates];
|
||
// The central body's discovery id is 'home' in EVERY system — it is
|
||
// the chart's central NAV point (SystemCategory.navPoints); its NAME
|
||
// differs: the home world in the starting system, the star elsewhere.
|
||
this.planet.discoveryId = 'home';
|
||
|
||
// The ship — a short hop (~150 px, edge-to-edge) from the home world's
|
||
// rim, in a seed-derived direction: same galaxy ⇒ same start.
|
||
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.edgePoint(
|
||
Rng.derive(this.galaxy.seed, 'spawn', 'ship').range(0, Math.PI * 2),
|
||
config.get('planets.spawnDistanceFromEdge', 150),
|
||
this.ship.radius,
|
||
);
|
||
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. 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. 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)
|
||
});
|
||
this.tetherField.add(
|
||
config.get('tether.homeId', 'home'),
|
||
0, 0, // the central body's center (home world or star — the system origin)
|
||
config.get('tether.homeLevel', 1),
|
||
config.get('tether.homeLabel', '') || (this.isHomeSystem ? this.homeWorldName : this.planet.discoveryName),
|
||
);
|
||
// 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 + the system chart — 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 on the chart plots the course (the same
|
||
// autopilot as a world click). The window is a pure view — it polls
|
||
// mapChartSnapshot() while open and repaints on any change.
|
||
this.mapWindow = new MapWindow(this, {
|
||
getChart: () => this.mapChartSnapshot(),
|
||
getShip: () => (this.ship ? { x: this.ship.x, y: this.ship.y, heading: this.ship.rotation } : null),
|
||
onSelect: (objectId) => this.autopilotTo(objectId),
|
||
onLocked: () =>
|
||
this.consoleToast(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.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 discovered 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 };
|
||
const offscreen = [];
|
||
for (const o of objects) {
|
||
if (this.discovery.isDiscovered(sysId, o.id) && !circleInView(o.x, o.y, o.radius, view)) {
|
||
offscreen.push(o);
|
||
}
|
||
}
|
||
// 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 player's home world in the starting system,
|
||
// the system's star in every other one (discovery id 'home' either
|
||
// way: the chart's central NAV point).
|
||
out.push({
|
||
id: 'home',
|
||
x: this.planet.x,
|
||
y: this.planet.y,
|
||
radius: this.planet.radius,
|
||
typeLabel: this.isHomeSystem
|
||
? config.get('planets.homeTypeLabel', 'Home World')
|
||
: config.get(`planets.starTypeLabels.${this.systemContent.star?.class}`, 'Star'),
|
||
// The compass accent: the home world is a PLANET (green —
|
||
// data/planets.json → compassColor, like the system's worlds); the
|
||
// star isn't a planet and keeps the compass's default neon cyan.
|
||
color: this.isHomeSystem ? config.get('planets.compassColor', '#3dff88') : undefined,
|
||
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;
|
||
}
|
||
const o = this.discoverableObjects().find((v) => v.id === id);
|
||
if (!o) return;
|
||
// The solid body behind this discovery entry (a world or a cluster).
|
||
const solid = 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). One-way shortcut
|
||
* jumps have no return gate — they land on the destination's star,
|
||
* inside its home-tether zone.
|
||
*
|
||
* 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): the destination's
|
||
// star, inside its home tether (added unconditionally in create()).
|
||
const destContent = this.galaxy.ensureContent(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}`);
|
||
}
|
||
|
||
// ------------------------------------------------------------ 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, else the star
|
||
const starClass = String(content.star?.class ?? '').toUpperCase();
|
||
const central = this.isHomeSystem
|
||
? {
|
||
name: this.homeWorldName ?? 'Terra',
|
||
isHome: true,
|
||
radius: this.planet?.radius ?? 200,
|
||
typeLabel: config.get('planets.homeTypeLabel', 'Home World'),
|
||
}
|
||
: {
|
||
name: this.planet?.discoveryName ?? 'Star',
|
||
isHome: 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,
|
||
stats: {
|
||
nav: navDiscoveryStats(disc, sysId, content),
|
||
res: resourceStats(disc, sysId, content),
|
||
},
|
||
};
|
||
}
|
||
|
||
// ------------------------------------------------------------ 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 + top edge. */
|
||
menuAnchor() {
|
||
const menuSlot = this.actionBar?.slots?.find((s) => s.id === 'menu');
|
||
if (menuSlot) {
|
||
return {
|
||
x: menuSlot.slot.x,
|
||
y: menuSlot.slot.y - this.actionBar.style.bh / 2,
|
||
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();
|
||
}
|
||
}
|