orbit/js/scenes/GameScene.js

1729 lines
72 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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

import Phaser from '../vendor/phaser.js';
import { config } from '../config/Config.js';
import { toColor, toCss } from '../utils/Color.js';
import { fontStack, themeColor } from '../utils/Theme.js';
import { Rng } from '../utils/Rng.js';
import { Galaxy } from '../galaxy/Galaxy.js';
import { formatSystemReport } from '../galaxy/SystemReport.js';
import { Discovery } from '../galaxy/Discovery.js';
import { Reputation, HOME_KEY } from '../reputation/Reputation.js';
import { ScrambleDecode, decodeDur } from '../utils/Decode.js';
import { Ship } from '../entities/Ship.js';
import { Planet } from '../entities/Planet.js';
import { AsteroidCluster } from '../entities/AsteroidCluster.js';
import { Station } from '../entities/Station.js';
import { Starfield } from '../visuals/Starfield.js';
import { DiscoveryCompass, circleInView } from '../ui/DiscoveryCompass.js';
import { ActionBar } from '../ui/ActionBar.js';
import { MenuSubBar } from '../ui/MenuSubBar.js';
import { SavePanel } from '../ui/SavePanel.js';
import { SaveManager } from '../save/SaveManager.js';
import { consumeRestore } 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';
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 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;
/** 5120 → "5.1K", 640 → "640" — compact range readout for the HUD. */
function fmtRange(v) {
if (v >= 1000) {
const k = (v / 1000).toFixed(1).replace(/\.0$/, '');
return `${k}K`;
}
return String(Math.round(v));
}
/**
* The game world (v0.3: the current system — the player's home world at the
* origin plus the system's other worlds scattered around it, in open space).
* Click anywhere to fly 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),
},
);
}
// 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_scan', config.get('sfx.scan', 'assets/fx/system-scan.mp3'));
}
}
create() {
// 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;
// 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);
// The home world's name — dealt from the planet name bank by the
// generator (the starting system is the only one with a home world), so
// it reads as a real place rather than a generic "Terra". Fallback keeps
// the old configured name if the bank is ever unavailable.
this.homeWorldName = this.systemContent.homeName || config.get('planets.homeName', 'Terra');
this.createSystemHud();
// The home planet — the player's Terran world, always present in the
// system they start in. It sits at the world origin. Which Terran face
// it shows is a seed-deterministic pick from the terran pool, 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');
this.planet = new Planet(this, 0, 0, Planet.frameFor(homeName, homeRng), homeName);
this.planet.setDepth(5); // above the starfield (depths 02), 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
// home world. Same solid-disc rules as home: 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';
const 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) and the ship flies to the rim.
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 }));
}
}
// Every solid in the system — worlds first (their keep-out circles are
// disjoint), then the clusters, then the stations. Ship constraint,
// click-to-fly clamping and autopilot all run against this list.
this.solids = [this.planet, ...this.systemPlanets, ...this.asteroidClusters, ...this.systemStations];
this.planet.discoveryId = 'home';
this.planet.discoveryName = this.homeWorldName;
// 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.tetherHudTexts = [];
this.tetherField = new TetherField(this, {
depth: 6, // above planets (5), below the ship (10)
onChange: () => this.refreshTetherHud(),
});
this.tetherField.add(
config.get('tether.homeId', 'home'),
0, 0, // home world's center (it sits at the system origin)
config.get('tether.homeLevel', 1),
config.get('tether.homeLabel', '') || this.homeWorldName,
);
this.tetherToastAt = null;
this.refreshTetherHud();
// The staged restore (if this run was LOADED): ship back where it
// was, the saved tether field, the saved session time.
if (this._pendingRestore) {
this.applyRestore(this._pendingRestore);
this._pendingRestore = 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 tether readout anchors above the deck's MENU button — re-render
// it now that the deck exists (the boot pass had nothing to anchor to).
this.refreshTetherHud();
// 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),
});
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),
});
// ---- 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). Any OTHER click moves the
// ship — which ends the mining state (the beam retracts as the
// ship goes; a mid-reach arm aborts).
// A click inside a planet clamps to that planet's keep-out rim — the
// ship can stop at the clearance, never inside. (Worlds don't
// overlap, so sequential clamping is exact.) A click BEYOND the
// player's tether range clamps to the union boundary — the target
// marker lands on the barrier line itself.
this.input.on('pointerdown', (pointer) => {
// 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;
// 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 (and the ship flies to it — comms business, not a world
// click); 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;
}
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 PLANET or STATION under the cursor (a rock click is the mining
// flow above): the ship still flies — to its keep-out rim — AND the
// comms panel opens at the click (js/ui/CommsPanel.js).
const obj = this.worldObjectAt(pointer.worldX, pointer.worldY);
// 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();
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);
this.showTargetMarker(aim.x, aim.y);
this.ship.setTarget(aim.x, aim.y);
this.hideHint();
if (obj) this.openCommsPanel(obj, pointer); // comms: the panel opens at the click
});
// 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).
*/
onPostUpdate(_time, delta) {
for (const s of this.solids) {
s.constrainShip(this.ship, this.ship.radius);
}
const contact = this.tetherField.constrainShip(this.ship);
if (contact) this.onTetherContact(contact);
}
/**
* 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') });
}
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;
}
/**
* Tether readout: one line per tether — level, range, anchor. Rebuilt
* whenever the field changes (TetherField.onChange), so a future
* "tether upgrade" build shows up here for free.
*
* Sits in the UPPER-RIGHT of the screen, right-aligned, stacking
* downward — clear of the dossier (upper-left) and the toasts
* (top-centre). Multiple tethers grow downward from the top margin.
*/
refreshTetherHud() {
if (!this.tetherField) return;
for (const g of this.tetherHudTexts) g.destroy();
this.tetherHudTexts = [];
const fam = BODY_FONT();
const neon = toCss(themeColor('neon', 0x00e5ff));
const pad = 16; // margin from the screen edge
const made = this.tetherField.tethers.map((t) => {
const label = t.label ? ` · ${String(t.label).toUpperCase()}` : '';
return this.add
.text(0, 0, `TETHER LV ${t.level} · RANGE ${fmtRange(t.radius)} PX${label}`, {
fontFamily: fam,
fontSize: '12px',
color: neon,
letterSpacing: 1,
})
.setOrigin(1, 0) // top-right anchor: right edge sticks to the margin
.setScrollFactor(0) // UI — pinned to the screen
.setDepth(30);
});
// Upper-right corner, lines stack downward.
const right = this.scale.width - pad;
let y = pad;
for (const t of made) {
t.setPosition(right, y);
y += t.height + 4;
}
this.tetherHudTexts = made;
}
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
// 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
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);
}
}
this.compass.refresh(offscreen, view, this.scale.width, this.scale.height, time, delta);
}
/** The discoverable objects of the system, with compass metadata. */
discoverableObjects() {
const out = [];
out.push({
id: 'home',
x: this.planet.x,
y: this.planet.y,
radius: this.planet.radius,
typeLabel: config.get('planets.homeTypeLabel', 'Home World'),
name: this.homeWorldName,
});
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),
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 (not the
// worlds' neon cyan) — 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'),
name: st.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;
this.playSfx('construct'); // the menu decodes in
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;
for (const s of [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;
}
/**
* 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) {
// 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 ship is already flying to its
* rim — the click-to-fly path did that): the comms panel opens at the
* click — the name decodes in, the reputation bar draws (settled),
* the landing buttons wait. 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);
this.playSfx('construct'); // the panel decodes in
// 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),
});
}
/**
* The panel's button presses (CommsPanel → onAction). The panel is
* already closed by the time this runs:
* '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-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;
}
// 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.
this.scene.launch('SurfaceScene', {
frame: Number(target.frame ?? 0),
name: target.name,
type: target.kindLabel ?? '',
tetherLevel: this.tetherLevelFor(target.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 === 'stopped') {
this.playSfx('deconstruct'); // the arm pulls back
// Back to normal — a no-op when the ship already left 'mining'
// itself (the player moved it, which ended the sequence).
this.ship.setState('normal', 'mining-ended');
}
// 'mining' = the beam is live — the visual speaks for itself.
}
/**
* 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.
*/
playSfx(name) {
if (!config.get('sfx.enabled', true)) return;
const snd = this.sound;
if (!snd || typeof snd.play !== 'function') return;
const key = `sfx_${name}`;
if (this.cache && typeof this.cache.hasAudio === 'function' && !this.cache.hasAudio(key)) return;
snd.play(key, { volume: config.get('sfx.volume', 0.55) });
}
/** 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) {
if (id === 'menu') {
this.menuAction();
return;
}
if (id === 'scan') {
this.startScan();
return;
}
console.info(`[orbit] command deck: ${id}`);
}
/**
* 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.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 → 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 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, 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).
*/
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;
}
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.refreshTetherHud();
}
this.playTimeMs = Number(r.playTimeMs) || 0;
// 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.starfield?.destroy();
this.compass?.destroy();
this.actionBar?.destroy();
this.menuSubBar?.destroy();
this.savePanel?.destroy();
this.miningPopup?.destroy();
this.commsPanel?.destroy();
this.mining?.destroy();
this.scanPulse?.destroy();
this.signalCompass?.destroy();
this.tetherField?.destroy();
}
}