1510 lines
62 KiB
JavaScript
1510 lines
62 KiB
JavaScript
import Phaser from '../vendor/phaser.js';
|
||
import { config } from '../config/Config.js';
|
||
import { toColor, toCss } from '../utils/Color.js';
|
||
import { fontStack, themeColor } from '../utils/Theme.js';
|
||
import { Rng } from '../utils/Rng.js';
|
||
import { Galaxy } from '../galaxy/Galaxy.js';
|
||
import { formatSystemReport } from '../galaxy/SystemReport.js';
|
||
import { Discovery } from '../galaxy/Discovery.js';
|
||
import { Reputation, HOME_KEY } from '../reputation/Reputation.js';
|
||
import { ScrambleDecode, decodeDur } from '../utils/Decode.js';
|
||
import { 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 { 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'));
|
||
}
|
||
}
|
||
|
||
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 0–2), below the ship (10)
|
||
|
||
// The rest of the solar system — the generated worlds, placed by the
|
||
// generator (data/planets.json → solarSystem) on orbits around the
|
||
// 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, 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 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 buttons are seams for
|
||
// now — the landing sequence wires into commsAction() once it
|
||
// exists.
|
||
this.commsPanel = new CommsPanel(this, {
|
||
onAction: (id, target) => this.commsAction(id, target),
|
||
});
|
||
|
||
// 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)
|
||
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;
|
||
return {
|
||
name: obj.discoveryName,
|
||
settled,
|
||
key,
|
||
reputation: rep,
|
||
canLand: settled && rep > -4,
|
||
kindLabel,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 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
|
||
this.commsPanel.open(pointer.worldX, pointer.worldY, pointer.x, pointer.y, {
|
||
name: t.name,
|
||
settled: t.settled,
|
||
reputation: t.reputation,
|
||
litMarks: this.reputation.marksFor(t.reputation),
|
||
canLand: t.canLand,
|
||
key: t.key,
|
||
kindLabel: t.kindLabel,
|
||
});
|
||
}
|
||
|
||
/**
|
||
* The panel's button presses (CommsPanel → onAction). The panel is
|
||
* already closed by the time this runs — the actions are SEAMS for
|
||
* now; the landing sequence lands here once it exists:
|
||
* 'request-landing' — a settled world, standing > −4
|
||
* 'land' — an unsettled world
|
||
* 'cancel' — just close (done)
|
||
*/
|
||
commsAction(id, target) {
|
||
this.commsPanel.close(); // the buttons close the panel (like miningAction)
|
||
if (!target) return;
|
||
// TODO(landing): wire the landing sequence — for now the click is
|
||
// acknowledged on the console (the seam the landing logic plugs
|
||
// into).
|
||
console.info(`[orbit] comms: ${id} — ${target.name} (key: ${target.key ?? 'none'})`);
|
||
}
|
||
|
||
/**
|
||
* 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;
|
||
}
|
||
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;
|
||
}
|
||
// 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.tetherField?.destroy();
|
||
}
|
||
}
|