orbit/js/scenes/GameScene.js

1273 lines
51 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 { 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 { 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';
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'));
}
}
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 }));
}
}
// Every solid in the system — worlds first (their keep-out circles are
// disjoint), then the clusters. Ship constraint, click-to-fly clamping
// and autopilot all run against this list.
this.solids = [this.planet, ...this.systemPlanets, ...this.asteroidClusters];
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);
}
// 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, Build, 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 building (credits + minerals, see
// data/builds.json) — whose behavior and panels 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 is locked, "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),
});
// 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). While the beam is live the
// ship is LOCKED: a world click breaks the beam first, then flies.
// 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.
if (
this.miningPopup &&
this.miningPopup.closedByButtonAt !== null &&
this.time.now - this.miningPopup.closedByButtonAt < 300
) {
return;
}
// The arm is mid-extension: the ship is committed to the rock for
// the ~1.5 s reach — the world holds (the toast says why).
if (this.mining.state === 'extending') 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;
}
// Beam live and the click is OFF the rock: break the beam (it
// retracts while the ship goes) and fly to the click.
if (this.mining.state === 'mining') 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();
});
// 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);
// 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);
this.mining.update(_time, delta); // the arm: extending → beam (tracks the drifting rocks)
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,
});
}
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.
}
/**
* Mining phase changes (Mining → onPhase): the scene's share of the
* sequence — 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.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
}
// '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;
this.tweens.add({
targets: this.hint,
alpha: 0,
duration: 400,
onComplete: () => {
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;
}
console.info(`[orbit] command deck: ${id}`);
}
/**
* 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;
}
// 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.mining?.destroy();
this.tetherField?.destroy();
}
}