import Phaser from '../vendor/phaser.js'; import { config } from '../config/Config.js'; import { toColor, toCss } from '../utils/Color.js'; import { fontStack, themeColor } from '../utils/Theme.js'; import { Rng } from '../utils/Rng.js'; import { Galaxy } from '../galaxy/Galaxy.js'; import { formatSystemReport } from '../galaxy/SystemReport.js'; import { Discovery } from '../galaxy/Discovery.js'; import { Reputation, HOME_KEY } from '../reputation/Reputation.js'; import { ScrambleDecode, decodeDur } from '../utils/Decode.js'; import { canonicalPlanetName } from '../utils/WorldNames.js'; import { playSfxOn, sfxPlayingOn, stopSfxOn } from '../utils/Sfx.js'; import { gameTrackKey, startMusicShuffleOn, stopMusicShuffleOn } from '../utils/Music.js'; import { Ship } from '../entities/Ship.js'; import { Planet } from '../entities/Planet.js'; import { AsteroidCluster } from '../entities/AsteroidCluster.js'; import { Station } from '../entities/Station.js'; import { JumpGate } from '../entities/JumpGate.js'; import { jumpArrival } from '../galaxy/JumpTravel.js'; import { Starfield } from '../visuals/Starfield.js'; import { DiscoveryCompass, circleInView } from '../ui/DiscoveryCompass.js'; import { ActionBar } from '../ui/ActionBar.js'; import { MineralHud } from '../ui/MineralHud.js'; import { MenuSubBar } from '../ui/MenuSubBar.js'; import { SavePanel } from '../ui/SavePanel.js'; import { SaveManager } from '../save/SaveManager.js'; import { consumeRestore, captureState, prepareLoad } from '../save/SaveData.js'; import { TetherField } from '../tether/TetherField.js'; import { Mining } from '../mining/Mining.js'; import { MiningPopup } from '../ui/MiningPopup.js'; import { ScanPulse } from '../scan/ScanPulse.js'; import { SignalCompass, signalAlpha } from '../ui/SignalCompass.js'; import { CommsPanel } from '../ui/CommsPanel.js'; import { ResearchWindow } from '../ui/ResearchWindow.js'; import { ResearchState } from '../research/ResearchState.js'; import { categories, loadCategory, isAvailable, buildDefs } from '../research/ResearchModel.js'; import { SYSTEM_CATEGORY, MAP_NODE, systemNodeId, buildSystemTree, isNavComplete, activationKeys, applyActivation, } from '../research/SystemCategory.js'; import { BuildWindow } from '../ui/BuildWindow.js'; import { BuildState } from '../build/BuildState.js'; import { startingPairs } from '../build/BuildModel.js'; const FONT_FALLBACK = "'Segoe UI', 'Helvetica Neue', Arial, sans-serif"; const HEADER_FONT = () => fontStack('header', FONT_FALLBACK); const BODY_FONT = () => fontStack('body', FONT_FALLBACK); /** * The dossier's decode pacing (the shared decode effect, per line): * a beat of arrival, then each line starts a little after the last. */ const HUD_LEAD_IN = 250; // ms before the dossier starts typing in const HUD_STAGGER = 140; // ms between the start of each line const HUD_EXPAND_LEAD = 120; // ms before re-opened details start streaming in /** The open-by-default dossier folds itself 10 s after arrival (unless the player toggles it first). */ const HUD_AUTO_COLLAPSE_MS = 10000; /** * The game world (v0.3: the current system — the 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), }, ); } // The research console's archive feed (data/research.json → video): // a muted 2:3 loop behind the RESEARCH window. A missing file just // leaves the window's NO SIGNAL plate up — the console still works. if (config.get('research.enabled', true)) { const researchVideo = String(config.get('research.video.file') ?? ''); if (researchVideo) { // The config stores the full relative path (like the sfx paths); // a bare filename still works (→ assets/videos/). const url = /^(https?:)?\/\//.test(researchVideo) || researchVideo.startsWith('assets/') ? researchVideo : `assets/videos/${researchVideo}`; this.load.video(ResearchWindow.VIDEO_KEY, url); } } // The build console's feed (data/builds.json → video): a muted 2:3 // loop behind the BUILD window on a planet surface (SurfaceScene's // BuildWindow reads the shared cache). A missing file just leaves the // window's NO SIGNAL plate up — the console still works. if (config.get('builds.enabled', true)) { const buildVideo = String(config.get('builds.video.file') ?? ''); if (buildVideo) { const url = /^(https?:)?\/\//.test(buildVideo) || buildVideo.startsWith('assets/') ? buildVideo : `assets/videos/${buildVideo}`; this.load.video(BuildWindow.VIDEO_KEY, url); } } // Sound effects (data/sfx.json → enabled). Skipped entirely when the // master switch is off — no load cost, no files fetched. if (config.get('sfx.enabled', true)) { this.load.audio('sfx_construct', config.get('sfx.construct', 'assets/fx/type-construct.mp3')); this.load.audio('sfx_deconstruct', config.get('sfx.deconstruct', 'assets/fx/type-deconstruct.mp3')); this.load.audio('sfx_discovery', config.get('sfx.discovery', 'assets/fx/discovery.mp3')); this.load.audio('sfx_mining', config.get('sfx.mining', 'assets/fx/system-scan.mp3')); this.load.audio('sfx_mining_loop', config.get('sfx.mining_loop', 'assets/fx/mining-01.mp3')); this.load.audio('sfx_mining_split', config.get('sfx.mining_split', 'assets/fx/mining-02.mp3')); this.load.audio('sfx_scan', config.get('sfx.scan', 'assets/fx/scan-01.mp3')); this.load.audio('sfx_ui_hover', config.get('sfx.ui_hover', 'assets/fx/ui-hover.mp3')); this.load.audio('sfx_ui_click', config.get('sfx.ui_click', 'assets/fx/ui-click.mp3')); this.load.audio('sfx_ui_window', config.get('sfx.ui_window', 'assets/fx/ui-window.mp3')); this.load.audio('sfx_ui_close', config.get('sfx.ui_close', 'assets/fx/ui-close.mp3')); } // The deep-space soundtrack (data/music.json → game) — the shuffled // playlist. Each file queues under its derived key (music_deepspace_01…). const gameTracks = config.get('music.game', []); if (config.get('music.enabled', true) && Array.isArray(gameTracks)) { for (const f of gameTracks) this.load.audio(gameTrackKey(f), f); } } create() { // A jump's cut (jumpThroughGate) sets this on the dying scene — the // restart lands here, so clear it: input unlocks again. this._jumping = false; // Camera: smoothly follows the ship (updateCamera below). This motion // is what drives the parallax starfield — ship flies, view trails. this.cameraFollowShip = config.get('game.camera.followShip', true); this.cameraFollowRate = config.get('game.camera.followRate', 3.0); // 1/s // Session time (the save's playTimeMs). Accumulates in update(); // a LOAD replaces it with the saved value (applyRestore). this.playTimeMs = 0; // The deep-space soundtrack — the data/music.json (game) playlist, // shuffled: one track at a time, next picked when one ends. // LANDING does not transition us — it LAUNCHES SurfaceScene on top // and SLEEPS this scene (v4: `launch` doesn't freeze the caller, and // a sleeping scene never emits 'shutdown') — so the soundtrack and // the mining hum stop on 'sleep' and come back on 'wake' (Take Off // wakes the scene). 'shutdown' still covers return-to-menu and game // destroy (the shutdown() method stays as the destroy safety net). this.startGameMusic(); this.events.once('shutdown', () => { this.stopGameMusic(); this.setMiningLoop(false); // the hum can't outlive the scene }); this.events.on('sleep', () => { this.stopGameMusic(); // no space hum on a world's surface this.setMiningLoop(false); // …nor the mining hum }); this.events.on('wake', () => { this.startGameMusic(); // back in space — the shuffle starts fresh if (this.mining?.state === 'mining') this.setMiningLoop(true); // the beam is still live }); // We are, after all, in a system. Establish the galaxy (shared // registry), the current system record, and its lazy contents — the // dossier below and the world's other planets both read them. this.ensureGalaxy(); // A LOAD from the save panel staged its live state (ship / tethers / // playtime) in the registry (js/save/SaveData.js) — the ship and the // tether field don't exist yet, so apply it once they do (below). this._pendingRestore = consumeRestore(this.registry); this.systemRecord = this.galaxy.currentSystem(); this.systemContent = this.galaxy.ensureContent(this.systemRecord.id); // JUMP GATE ACTIVATION (the SYSTEM research category — // js/research/SystemCategory.js): the run's activation keys are // per-run world state — registry-backed (like discovery: New Game // clears it, a load restores it; js/save/SaveData.js). A gate the // run activated — or whose RETURN the run activated from a linked // system — flips on here, BEFORE the gate entities below read // `active` (the dormant look is baked at construction); the gate // tethers (data/gates.json → ACTIVITY) attach after the field exists // (below) and save with the run's tether list. this.activatedGates = this.registry.get('activatedGates') ?? null; if (!this.activatedGates) { this.activatedGates = new Set(); this.registry.set('activatedGates', this.activatedGates); } for (const j of this.systemContent.jumps ?? []) { if (j && this.activatedGates.has(`${this.systemRecord.id}>${j.to}`)) j.active = true; } // The 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 comes from the galaxy-wide frame pass (content.homeFrame — // the same (class, face) spreading as the planets), falling back to a // seed-deterministic pool pick, so the same galaxy always yields the // same home world. const homeName = config.get('planets.homePlanet', 'terran'); const homeRng = Rng.derive(this.galaxy.seed, 'planet', 'home'); const homeFrame = typeof this.systemContent.homeFrame === 'number' ? this.systemContent.homeFrame : Planet.frameFor(homeName, homeRng); this.planet = new Planet(this, 0, 0, homeFrame, homeName); this.planet.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'; // The galaxy-wide frame pass (js/galaxy/PlanetFrames.js) stamped // `rec.frame` to spread each (class, face) across the galaxy; fall // back to a random pool pick for content that predates the pass. const frame = typeof rec.frame === 'number' ? rec.frame : Planet.frameFor(kind, Rng.derive(this.galaxy.seed, 'planet', rec.name)); const tint = config.get(`planets.classTint.${kind}`); const p = new Planet(this, rec.x, rec.y, frame, kind, { scale: rec.scale ?? 1, tint: tint === undefined ? undefined : toColor(tint), }); p.setDepth(5); p.discoveryId = rec.name; // unique within the system p.discoveryName = rec.name; this.systemPlanets.push(p); } } // Asteroid clusters — loose groups of slowly tumbling rocks scattered // through the system's void (data/asteroids.json). Every cluster is a // DISCOVERABLE object (the compass knows it once found), a SOLID one // (the ship can park at a rock's rim, never fly through it) — the same // contract as the worlds, at rock scale — and a MINABLE one: a click // on a rock opens the mining menu (see the Mining wiring below). this.asteroidClusters = []; if (config.get('asteroids.enabled', true) !== false) { for (const rec of this.systemContent.asteroids ?? []) { this.asteroidClusters.push(new AsteroidCluster(this, rec, { depth: 5 })); } } // Free-space stations (settlements with `anchor.type === 'space'` // — deep-space stations and waypoints): settlements that float in // the void, rendered as world objects (js/entities/Station.js). // Solid (the ship keeps its clearance), discoverable (compass + // toast), and comms targets — a click opens the comms panel there // (openCommsPanel below) — it is NOT a fly-here (the ship stays put). this.systemStations = []; if (config.get('stations.enabled', true) !== false) { for (const s of this.systemContent.settlements ?? []) { if (s.anchor?.type !== 'space' || typeof s.x !== 'number' || typeof s.y !== 'number') continue; this.systemStations.push(new Station(this, s, { depth: 5 })); } } // Jump gates (content.jumps — one per destination in the system's // gate network, js/galaxy/JumpNetwork.js): the system's exits. // Solid (the ship keeps its clearance), discoverable (compass + // toast, in their own cyan), and each one faces its destination // star on the map (entity.rotation). They are NOT comms targets — // a click on an ACTIVE gate is the JUMP (GameScene.jumpThroughGate // — the transport); a dormant one is the ordinary fly-here. // worldObjectAt deliberately excludes them (no comms panel for a // gate); gateAt() is their own hit test. this.systemGates = []; if (config.get('gates.enabled', true) !== false) { for (const j of this.systemContent.jumps ?? []) { if (typeof j.x !== 'number' || typeof j.y !== 'number') continue; this.systemGates.push(new JumpGate(this, j, { depth: 5 })); } } // Every solid in the system — worlds first (their keep-out circles are // disjoint), then the clusters, then the stations, then the gates. // Ship constraint, click-to-fly clamping and autopilot all run // against this list. this.solids = [this.planet, ...this.systemPlanets, ...this.asteroidClusters, ...this.systemStations, ...this.systemGates]; 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.tetherField = new TetherField(this, { depth: 6, // above planets (5), below the ship (10) }); 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, ); // Each activated gate anchors its tether (data/gates.json → ACTIVITY: // an active gate is a TETHER ANCHOR in its own right — the room to // move in a barren system). Idempotent: add() replaces by id, and a // save's tether list restores to the same ids. const gateTetherLevel = Math.max(1, Math.floor(Number(config.get('gates.activation.tetherLevel', 1)) || 1)); for (const j of this.systemContent.jumps ?? []) { if (j && j.active) this.tetherField.add(`gate:${j.id}`, j.x, j.y, gateTetherLevel, j.name ?? ''); } this.tetherToastAt = null; // Discovery: which objects the player has found (within discovery // distance of an edge — data/game.json), tracked per system. Kept in // the shared registry so it survives scene restarts; serializable for // saves later (Discovery.toJSON). The compass turns the discovered set // into screen-edge arrows for the objects currently out of sight. this.discovery = this.registry.get('discovery') ?? null; if (!this.discovery) { this.discovery = new Discovery(config.get('game.discovery.distance', 540)); this.registry.set('discovery', this.discovery); } // REPUTATION — the player's standing on each planet and space station // (js/reputation/Reputation.js): −20…+20 (data/reputation.json), // neutral 0 everywhere the player has no standing, the home world // pinned at +20. The faction check (settlements' reserved `owner` // seam) is a placeholder until factions land. Nothing the player can // see or influence yet — this is the data layer + the save seam the // influence mechanics plug into. Like discovery it lives in the shared // registry (survives scene restarts; New Game resets it). this.reputation = this.registry.get('reputation') ?? null; if (!this.reputation) { this.reputation = new Reputation(); this.registry.set('reputation', this.reputation); } // The command deck's bottom strip (config: data/actionbar.json) — the // compass lays out its arrows/name tags ABOVE it so a tag is never // buried under the deck, and the hint sits above it too. const deckEnabled = config.get('actionbar.enabled', true) === true; const deckReserve = deckEnabled ? config.get('actionbar.height', 92) + config.get('actionbar.margin.bottom', 12) : 0; this.compass = new DiscoveryCompass(this, { // Autopilot: clicking a compass name tag sends the ship to that // discovered object (GameScene.autopilotTo below). onSelect: (id) => this.autopilotTo(id), reserveBottom: deckReserve, }); // The command deck — the cyberpunk action bar across the bottom of the // screen (config: data/actionbar.json). Six evenly spaced slots: // Research, Scan, Ship, two reserved, Menu. The slots are the seams // for the player's loop — research (time-based, one at a time, see // data/research.json) and scan (its behavior and panel come next). this.actionBar = deckEnabled ? new ActionBar(this, { onAction: (id) => this.deckAction(id), }) : null; // ---- The SAVE SYSTEM — the deck's Menu sub-bar + the slot pop-up -- // The sub-bar folds up out of the Menu button (Save Game / Load Game // [grayed while no saves exist] / Return to Main Menu); both save // verbs open the 10-slot pop-up, which owns the overwrite + load // confirmations and the bulk download (js/ui/MenuSubBar.js + // js/ui/SavePanel.js, config in data/save.json). this.saveManager = new SaveManager(); // the localStorage bank this.menuSubBar = new MenuSubBar(this, { anchor: this.menuAnchor(), onAction: (id) => this.subBarAction(id), }); this.savePanel = new SavePanel(this, { // Load confirmed: the restore is staged — hand back to the menu // (its New Game is the "start" the staged restore rides on). onLoadComplete: () => this.returnToMenu(), }); // The MINERALS readout — upper right (the corner the old tether stack // used to occupy): the hold's fill as a bar + counting number. this.mineralHud = new MineralHud(this); this.mineralHud.set(this.ship.minerals, this.ship.stats.mineralStorage); // Hint (pinned just ABOVE the command deck, not under it) this.hint = this.add .text(this.scale.width / 2, this.scale.height - deckReserve - (deckReserve ? 20 : 26), config.get('game.hintText', ''), { fontFamily: BODY_FONT(), fontSize: '14px', color: '#54608a', letterSpacing: 1, }) .setOrigin(0.5) .setScrollFactor(0); // UI: pinned to the screen, not the world // ---- MINING (the clusters are now verbs, not just rocks) ----------- // The state machine (js/mining/Mining.js): idle → extending (~1.5 s, // the ship holds station, "Extending Mining Arm..." in the console // slot) → mining (the beam is live — js/mining/MiningBeam.js) // → retracting. The pop-up (js/ui/MiningPopup.js) is the context // menu a rock click opens, world-anchored right where the player // clicked. this.mining = new Mining(this, { onPhase: (p) => this.onMiningPhase(p), onEvent: (name, data) => this.onMiningEvent(name, data), onOre: () => this.refreshMineralHud(), // live: the hold fills as the rock shrinks }); this.miningPopup = new MiningPopup(this, { onAction: (id, rock) => this.miningAction(id, rock), }); // COMM PANEL (js/ui/CommsPanel.js) — the starship comms console that // opens at the player's click on a planet or space station: a rusty // metal case around a green phosphor readout. The name decodes at // the top; a SETTLED object (a world hosting a colony / mining // station / cloud base, or a free-space station) shows a reputation // bar — 41 marks, −20…+20, red → green, lit up to the standing — // plus REQUEST LANDING (grayed at standing ≤ −4) and CANCEL; an // UNSETTLED world shows LAND + CANCEL. The landing buttons launch // SurfaceScene (startLanding) — a one-shot landing video over a // looping surface clip with its own deck (data/landing.json). this.commsPanel = new CommsPanel(this, { onAction: (id, target) => this.commsAction(id, target), }); // ---- RESEARCH CONSOLE (the deck's RESEARCH button) --------------------- // The rules live in data/research.json (+ one file per category under // data/research/); the progress in ResearchState; the view in // ResearchWindow (js/ui/ResearchWindow.js, depth 80). This scene owns // the effects: tether level-ups, capability flags, toasts, the deck // progress bar, and the save data (record.research). this.researchState = new ResearchState(); for (const cat of categories()) { const tree = loadCategory(cat.id); if (!tree) continue; for (const id of tree.starting) this.researchState.unlock(cat.id, id); } // The SYSTEM category's live tree — built for the system the player is // in (categories flagged `dynamic` in research.json have no data file): // the '{System} Map' (granted on entry — _onEnterSystem below) and // 'Unlock {System} Jumpgates' (chart complete → researchable → the // gates + the linked systems' return gates go live). js/research/ // SystemCategory.js; copy + duration in data/gates.json → activation. const sysCat = categories().find((c) => c.id === SYSTEM_CATEGORY); this.systemTree = buildSystemTree({ systemId: this.systemRecord.id, systemName: this.systemRecord.name, accent: sysCat?.accent, isComplete: () => this.isSystemNavComplete(), }); this.researchWindow = new ResearchWindow(this, { state: this.researchState, systemTree: this.systemTree, onResearch: (catId, id) => this.beginResearch(catId, id), }); // deck progress bar — drawn over the RESEARCH button while a project runs this._researchDeckBar = { g: this.add.graphics().setScrollFactor(0).setDepth(51).setVisible(false), txt: this.add .text(0, 0, '', { fontFamily: fontStack('body'), fontSize: '10px', color: toCss(themeColor('dim', 0x7d92c4)), letterSpacing: 2, align: 'center', }) .setOrigin(0.5, 1) .setScrollFactor(0) .setDepth(52) .setVisible(false), }; // ---- BUILD CONSOLE (the deck's BUILD button, on a planet surface) ---- // The rules live in data/builds.json (js/build/BuildModel.js); the // progress in BuildState — the run's build records (which builds are // installed on which planets) plus the single in-progress build (one // at a time). The SurfaceScene is the passive view (BuildWindow, // depth 80) and TICKS the state in its update() (this scene sleeps // while the surface is active). The build's time base is the game- // LOOP clock (game.loop.now) — global + monotonic — so a build keeps // running if the player takes off mid-build (then we tick it here). // This scene owns the effects (the tether field is world state, so it // outlives the stay) and the save data (record.builds). this.buildState = new BuildState(); this._seedStartingBuilds(); // The staged restore (if this run was LOADED): ship back where it // was, the saved tether field, the saved session time — and the saved // research + build state. AFTER the state objects above exist: the // restore mutates them (applyRestore), so it must come last in the // creation order. if (this._pendingRestore) { this.applyRestore(this._pendingRestore); this._pendingRestore = null; } // Entering a system charts it: the SYSTEM category's '{System} Map' // tech (duration 0) is owned on arrival — a new run gets the starting // system's, a load re-asserts the saved system's (the restore above // replaced the unlocked set), and a jump (jumpThroughGate → // scene.restart → this create) grants the destination's. this._onEnterSystem(); // ---- DEEP SCAN (the deck's SCAN button) ------------------------------- // The ship's sonar pulse (js/scan/ScanPulse.js): a charge at the hull, // then an omnidirectional wavefront expanding across the TETHER REGION // (clipped to the union boundary — absorbed at the rim). Objects in the // region ring as the front crosses them (updateScanHits), the starfield // ripples as the wave passes, the camera thumps + rolls with it, and the // barrier shivers where the wave is swallowed (TetherField.excite). // The RESULTS are a seam for now — finishScan logs the in-region objects; // the next instruction decides what a scan yields. this.scanPulse = new ScanPulse(this); this.scanObjects = null; this.scanObjectScale = config.get('scan.ripple.objectScale', 0.045); // SIGNAL COMPASS (js/ui/SignalCompass.js) — the secondary compass: // a faint ring (radius px) around the ship. When a scan COMPLETES, // every undiscovered in-region object lights up a soft "radio signal" // on that ring at its bearing (full 20 s, then a 5 s fade). A signal // drops the instant its object is discovered; re-scanning re-emits the // set with a fresh clock. `this.scanReveal` = the current emission // ({ bornAt, ids }) — the clock + which objects it covers. this.signalCompass = new SignalCompass(this); this.scanReveal = null; this.signalFullMs = config.get('signalCompass.lifetime.fullMs', 20000); this.signalFadeMs = config.get('signalCompass.lifetime.fadeMs', 5000); // SHIP STATE (js/entities/Ship.js): 'normal' is the default — the // ship is free; 'mining' — the arm's sequence owns the ship. The // mining visuals live and die with that state: ANY change out of it // (the player moving the ship — a world click or the compass // autopilot — a cancel, or a future state) ends the mining sequence. this.ship.onStateChange = (next, prev) => { if (prev === 'mining' && next !== 'mining') this.mining.stop(); }; // Input: click = fly there. A click ON the command deck is deck // business, a click on a compass name tag is an autopilot (it // already retargeted the ship), and a click on the system NAME (or // the caret beside it) toggles the dossier open/closed — none of // those is a fly-here. A click on an ASTEROID opens the mining // pop-up (a context menu — its buttons own their clicks; any click // elsewhere closes it and is consumed). A click on a PLANET or // STATION opens the comms panel — the landing-request window — and // does nothing else: it is NOT a fly-here (the ship stays put), and // it does not touch the mining state (no move ⇒ a live beam keeps // running). Any OTHER click moves the ship — which ends the mining // state (the beam retracts as the ship goes; a mid-reach arm // aborts). 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) => { // A jump is in flight (the cut's 400 ms or so): the old scene is // already gone — swallow anything that lands in the gap. if (this._jumping) return; // The save pop-up is MODAL — while it's up it owns all input // (its scrim / cards / dialog eat the click; the world stays put). if (this.savePanel && this.savePanel.isOpen) return; // The research console (depth 80) is full-screen — it owns all input // (its own buttons, the close, ESC); a world click never lands behind it. if (this.researchWindow && this.researchWindow.isOpen) return; // Sub-bar OPEN: a click INSIDE it is its own (the panel or one of // its buttons — the buttons fire on their own pointerdown). ANY // click OUTSIDE — world, deck, HUD, compass — folds it back down // and that click is done (no fly-here, no dossier toggle, no // autopilot). Deck buttons still get their own press from the // deck's handler; the Menu button's toggle is safe either order // because open() is a no-op while the bar is still 'closing'. if (this.menuSubBar && this.menuSubBar.isOpen) { if (this.menuSubBar.contains(pointer.x, pointer.y)) return; this.menuSubBar.close(); return; } if (this.actionBar && this.actionBar.contains(pointer.x, pointer.y)) return; if (this.compass.contains(pointer.x, pointer.y)) return; if (this.hudTitleContains(pointer.x, pointer.y)) { this.toggleHud(); // the system name toggles the dossier details return; } // Mining pop-up OPEN: a click on one of its buttons is the // button's (its own pointerdown listener); a click ANYWHERE else // closes the menu and that click is consumed — no fly-here (the // same contract as the sub-bar). if (this.miningPopup && this.miningPopup.isOpen) { if (this.miningPopup.contains(pointer.worldX, pointer.worldY)) return; this.miningPopup.close(); return; } // A click the pop-up's OWN buttons just handled (they close it from // their side first — the two handlers race on event order): it is // the menu's click, never a fly-here. The button and this handler // fire on the SAME input pass (same frame — this.time.now is // identical), so "same frame AND inside the panel's footprint" // isolates that exact click without swallowing the player's next // deliberate one (a click on the rock, to re-open the menu). if ( this.miningPopup && this.miningPopup.closedByButtonAt !== null && this.time.now - this.miningPopup.closedByButtonAt < 20 && this.miningPopup.containsScreen(pointer.x, pointer.y) ) { return; } // COMM PANEL OPEN (js/ui/CommsPanel.js) — the same contract as the // mining menu: a click on one of its buttons is the button's (its // own pointerdown listener); a click INSIDE it is swallowed (no // fly-here); a click on ANOTHER planet/station moves the panel // there (the ship does not fly — the click is the panel's, not a // world move); any other click closes it and that click is // consumed. if (this.commsPanel && this.commsPanel.isOpen) { if (this.commsPanel.contains(pointer.worldX, pointer.worldY)) return; const obj = this.worldObjectAt(pointer.worldX, pointer.worldY); if (obj) { this.openCommsPanel(obj, pointer); return; } this.commsPanel.close(); return; } // A click the panel's OWN buttons just handled (they close it from // their side first — the two handlers race on event order): it is // the panel's click, never a fly-here (the same same-frame test as // the mining menu's button guard below it). if ( this.commsPanel && this.commsPanel.closedByButtonAt !== null && this.time.now - this.commsPanel.closedByButtonAt < 20 && this.commsPanel.containsScreen(pointer.x, pointer.y) ) { return; } // A rock under the cursor: clicking an asteroid opens the mining // menu (or the stop menu, when this cluster is the beam's target) // — this click does not fly the ship. const rock = this.rockAt(pointer.worldX, pointer.worldY); if (rock) { this.openMiningMenu(rock, pointer); return; } // A JUMP GATE under the cursor (solid — the ship can't pass // through it): ACTIVE → the JUMP itself (the transport — // GameScene.jumpThroughGate, data/gates.json → jump); DORMANT → // a console note, and the click falls through to the usual // fly-here (the ship drifts up to the gate's rim and waits). const gateObj = this.gateAt(pointer.worldX, pointer.worldY); if (gateObj) { if (gateObj.active && config.get('gates.jump.enabled', true)) { this.jumpThroughGate(gateObj); return; } this.consoleToast( String(config.get('gates.jump.dormantToast', 'JUMP GATE DORMANT')) .replace('{system}', (this.systemRecord?.name ?? 'THIS SYSTEM').toUpperCase()), { glyph: '⌁', glyphColor: toCss(themeColor('neon2', 0xffc94d)), durationMs: 3200 }, ); } // A PLANET or STATION under the cursor (a rock click is the mining // flow above): the click opens the comms panel (the landing-request // window, js/ui/CommsPanel.js) and NOTHING ELSE — it is not a // fly-here (the ship stays put), and it does not end the mining // state (no move, so a live beam keeps running). const obj = this.worldObjectAt(pointer.worldX, pointer.worldY); if (obj) { this.hideHint(); this.openCommsPanel(obj, pointer); return; } // Any OTHER click MOVES the ship — which ends the mining state // (the beam retracts as the ship goes; a mid-reach arm aborts). // The state exit is signalled via ship.onStateChange above. if (this.mining.isActive) this.mining.stop(); 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') }); } for (const gt of report.gates ?? []) { line(gt.text, { fontFamily: fam, fontSize: '13px', color: toCss(gt.color, '#5fd4ff') }); } line(report.summary, { fontFamily: fam, fontSize: '12px', color: '#54608a' }); line(`seed ${this.galaxy.seed}`, { fontFamily: fam, fontSize: '11px', color: '#3d476b' }); this.hudDetail = detail; this.hudEndY = y + 6; // bottom of the dossier block // --- State + the arrival timeline (anchored on the first frame) ----- this.hudPhase = 'constructing'; // 'constructing' | 'expanded' | 'collapsing' | 'collapsed' this.autoCollapseArmed = true; // the one-shot 10 s auto-fold this.hudArrivalT0 = null; this.hudTimeline = { mode: 'arrive', // the name first, then the data below it t0: null, lines: [ { text: title, value: report.title, isTitle: true }, ...detail, ].map((ln, i) => ({ ...ln, delay: HUD_LEAD_IN + i * HUD_STAGGER, dur: decodeDur(ln.value.length), settled: false, })), }; } /** * The dossier's lifecycle, driven every frame (updateHud is called from * update() with the engine's loop time — the scene's TimeClock is the * only clock that runs, see the v4 quirk in update()): * - the active timeline (arrive / expand / collapse) reveals or * erases its lines one by one with the shared decode scramble; * - on arrival the name lands first and the state caret fades in * beside it; * - on a collapse the LAST line built erases first (seed → … → * subtitle), then the caret swings down→right; * - the one-shot 10 s auto-fold fires once (cancelled by any manual * toggle). */ updateHud(time) { if (this.hudArrivalT0 === null) { // First frame after create(): anchor the arrival timeline here // (this is the same time base update() receives; the scene's // time.now is still stale). this.hudArrivalT0 = time; this.playSfx('construct'); // the dossier starts typing in const tl = this.hudTimeline; tl.t0 = time; for (const ln of tl.lines) ln.dec = new ScrambleDecode(ln.value, time + ln.delay, ln.dur); } const tl = this.hudTimeline; if (tl) { let done = true; for (const ln of tl.lines) { if (ln.settled) continue; if (!ln.dec.started(time)) { done = false; continue; } ln.text.setText(ln.dec.display(time)); if (ln.dec.finished(time)) { ln.settled = true; if (ln.isTitle) this.showCaret(); // the name has landed — the state is legible } else done = false; } if (done) { this.hudTimeline = null; if (tl.mode === 'collapse') { this.hudPhase = 'collapsed'; this.rotateCaret(true); // details gone → caret swings to point right } else { this.hudPhase = 'expanded'; } } } // The open-by-default dossier folds itself 10 s after arrival — // unless the player already toggled it (autoCollapseArmed). if ( this.autoCollapseArmed && this.hudPhase === 'expanded' && !this.hudTimeline && time >= this.hudArrivalT0 + HUD_AUTO_COLLAPSE_MS ) { this.autoCollapseArmed = false; this.startCollapse(time); } } /** The state caret fades in once the name has landed (arrival only). */ showCaret() { if (this.hudCaretShown) return; this.hudCaretShown = true; this.tweens.add({ targets: this.hudCaret, alpha: 1, duration: 160, ease: 'Sine.easeOut' }); } /** The caret swings with the state: down (0) = open, right (−90°) = folded. */ rotateCaret(toRight) { this.tweens.add({ targets: this.hudCaret, angle: toRight ? -90 : 0, duration: 220, ease: 'Sine.easeInOut', }); } /** * Re-open the details: the caret swings back down first, then the lines * type back in in build order (subtitle → … → seed) — the same decode as * arrival, just without the name (it never left). */ startExpand(t) { this.hudPhase = 'constructing'; this.playSfx('construct'); // the details type back in this.rotateCaret(false); this.hudTimeline = { mode: 'expand', lines: this.hudDetail.map((ln, i) => ({ text: ln.text, value: ln.value, settled: false, dec: new ScrambleDecode(ln.value, t + HUD_EXPAND_LEAD + i * HUD_STAGGER, decodeDur(ln.value.length)), })), }; } /** * Fold the details: the lines DECONSTRUCT in reverse build order (seed * line erases first … subtitle last) — the same scramble played * backwards — and the caret swings down→right once the last one is gone * (updateHud, when the timeline completes). */ startCollapse(t) { this.hudPhase = 'collapsing'; this.playSfx('deconstruct'); // the details deconstruct this.hudTimeline = { mode: 'collapse', lines: [...this.hudDetail].reverse().map((ln, i) => ({ text: ln.text, value: ln.value, settled: false, // reverse = true: value → shrinking prefix → static → '' dec: new ScrambleDecode(ln.value, t + i * HUD_STAGGER, decodeDur(ln.value.length), true), })), }; } /** * The player clicked the system name (or its caret): toggle the details. * Ignored mid-animation — let the current construct/deconstruct finish. * A manual toggle also cancels the one-shot auto-fold. */ toggleHud() { if (this.hudTimeline) return; this.autoCollapseArmed = false; if (this.hudPhase === 'expanded') this.startCollapse(this.time.now); else if (this.hudPhase === 'collapsed') this.startExpand(this.time.now); } /** Is (px,py) on the dossier's toggle target — the name or the caret beside it? */ hudTitleContains(px, py) { const r = this.hudTitleRect; return !!r && px >= r.x && px <= r.x + r.w && py >= r.y && py <= r.y + r.h; } update(_time, delta) { // Phaser v4 (Giedi): the engine does not step the scene's TimeClock or // TweenManager — drive them here or delayedCall/addEvent/tweens never // fire. (Same quirk as MenuScene.update; verified Sept 2026.) this.time.update(_time, delta); this.tweens.update(); this.updateHud(_time); // the dossier: decode, caret, auto-fold, toggle this.ship.update(_time, delta); // Session time (saved with the game) — capped so a backgrounded tab // can't fast-forward it. this.playTimeMs = (this.playTimeMs ?? 0) + Math.min(delta, 100); // The save UI (the sub-bar + the pop-up) drive their own per-frame // state (folds, decodes, toasts, the confirm dialog). this.menuSubBar?.update(_time, delta); this.savePanel?.update(_time); this.commsPanel?.update(_time); // the name decode, the bar draw-in, the cursor blink, the flicker // Research: tick the in-flight project (time-based; tick() reports any // completion and already unlocked it — the scene applies effects/SFX). const _researchDone = this.researchState?.tick(_time) ?? null; if (_researchDone?.length) { for (const d of _researchDone) this._completeResearch(d.category, d.id); } this.researchWindow?.update(_time); // the console's living details (open state) this._deckResearchBar(_time); // the RESEARCH slot's progress bar // Builds: a build started on a surface keeps running in space if the // player took off mid-build (game-loop clock — see beginBuild); when // its deadline passes HERE we apply the effect (the world change is // world state: the tether range outlives the stay). if (this.buildState) { const _buildDone = this.buildState.tick(this.game.loop.now); if (_buildDone.length) { for (const c of _buildDone) this.completeBuild(c.planet, c.build); this.playSfx('discovery'); // the 'something new is here' voice } } // The clusters are ALIVE: each rock tumbles, the loose group drifts, // the dust orbits. (The keep-out constraint runs in onPostUpdate, // after the physics step has moved the ship.) for (const c of this.asteroidClusters) c.update(_time); for (const st of this.systemStations) st.update(_time); // the ring turns, the beacon breathes for (const gt of this.systemGates) gt.update(_time); // the field breathes, the ring drifts this.mining.update(_time, delta); // the arm: extending → beam (tracks the drifting rocks) if (this.scanObjects) this.updateScanHits(_time); // the front crossing objects → ring this.scanPulse.update(_time, delta); // the pulse: charge → front → absorbed → settle this.updateSignalCompass(_time); // the secondary compass: bearing signals on the ring this.updateCamera(delta); this.starfield.update(); // after the camera, so it sees this frame's motion this.tetherField.tick(_time, delta); // glitch/pulse lifecycle this.tetherField.draw(_time); // the barrier (on-screen dots only) this.actionBar?.update(_time, delta); // the deck's living details this.updateDiscovery(_time, delta); // last: sees this frame's final camera view } /** * The ship met the tether boundary: a small camera kick (impact), and a * console warning — throttled so a held push against the line doesn't * spam it. The line itself already shudders locally (TetherField pulse). */ onTetherContact(contact) { const cam = this.cameras.main; if (typeof cam.shake === 'function') { cam.shake(80, 0.003 + Math.min(0.004, (contact.vn ?? 0) * 0.00002)); } const now = this.time.now; const cooldown = config.get('tether.contact.toastCooldown', 4000); if (this.tetherToastAt !== null && now - this.tetherToastAt < cooldown) return; this.tetherToastAt = now; this.tetherToast(); } /** * The console note — the game's top-center signal slot. Discovery toasts, * the tether warning, and the mining calls all live here: a small accent * glyph + a console-caps label, fading in, holding, fading out. One at a * time — a new note replaces any old one. * * @param {string} label the console line * @param {object} [o] { glyph, glyphColor, durationMs, y (screen px; default ~75 above center) } */ consoleToast(label, { glyph = '\u25b8', glyphColor = '#00e5ff', durationMs = 2400, y } = {}) { if (Array.isArray(this.consoleToastG)) { for (const g of this.consoleToastG) g.destroy(); this.consoleToastG = null; } const fam = BODY_FONT(); const g1 = this.add .text(0, 0, glyph, { fontFamily: fam, fontSize: '13px', color: toCss(glyphColor) }) .setOrigin(0, 0.5) .setScrollFactor(0); // UI — pinned to the screen, not the world const g2 = this.add .text(0, 0, label, { fontFamily: fam, fontSize: '11px', color: toCss(themeColor('dim', 0x7d92c4)), letterSpacing: 2, }) .setOrigin(0, 0.5) .setScrollFactor(0); // UI — pinned to the screen, not the world const total = g1.width + 10 + g2.width; const x0 = this.scale.width / 2 - total / 2; const yy = y === undefined ? this.scale.height / 2 - 75 : y; // ~75 px above the screen center g1.setPosition(x0, yy).setDepth(45).setAlpha(0); g2.setPosition(x0 + g1.width + 10, yy).setDepth(45).setAlpha(0); this.consoleToastG = [g1, g2]; this.tweens.add({ targets: this.consoleToastG, alpha: 1, duration: 180, ease: 'Sine.easeOut' }); this.time.delayedCall(durationMs, () => { if (!Array.isArray(this.consoleToastG)) return; const [a, b] = this.consoleToastG; this.consoleToastG = null; this.tweens.add({ targets: [a, b], alpha: 0, duration: 350, onComplete: () => { a.destroy(); b.destroy(); }, }); }); } /** Top-center console warning (magenta), high up, clear of the signal slot. */ tetherToast() { this.consoleToast('TETHER BARRIER — RANGE LIMIT · UPGRADE THE TETHER TO EXTEND IT', { glyph: '\u26a0', glyphColor: toCss(themeColor('neon2', 0xff2d6f)), durationMs: 2600, y: 54, }); } /** * The camera chases the ship with a frame-rate-independent ease: * scroll += (shipCenter - scroll) * (1 - e^(-rate·dt)) * While the ship flies, the camera trails it and the starfield streams * past in the opposite direction (real parallax). When the ship stops, * the camera keeps easing until it is centered on the ship again — a * slow recenter (≈1.5 s at followRate 3) with the stars parallaxing * along for the ride. */ updateCamera(delta) { if (!this.cameraFollowShip || !this.ship) return; const rate = this.cameraFollowRate; if (!(rate > 0)) return; const cam = this.cameras.main; const k = 1 - Math.exp(-rate * (Math.min(delta, 64) / 1000)); cam.setScroll( Phaser.Math.Linear(cam.scrollX, this.ship.x - this.scale.width / 2, k), Phaser.Math.Linear(cam.scrollY, this.ship.y - this.scale.height / 2, k), ); } /** * Discovery, every frame (a handful of distance tests — cheap): * 1. Ship within discovery distance of a world's edge → DISCOVERED * (once — state lives in this.discovery), with a rim ping + toast. * 2. Discovered worlds that are off-screen feed the compass, which * draws the themed screen-edge arrows pointing back to them. */ updateDiscovery(time, delta) { const objects = this.discoverableObjects(); const sysId = this.systemRecord.id; const fresh = this.discovery.check(sysId, this.ship.x, this.ship.y, objects); for (const o of fresh) this.celebrateDiscovery(o); // Which discovered objects are NOT on screen right now? (The camera // never zooms, so the world view is scroll + canvas size.) const cam = this.cameras.main; const view = { left: cam.scrollX, top: cam.scrollY, w: this.scale.width, h: this.scale.height }; const offscreen = []; for (const o of objects) { if (this.discovery.isDiscovered(sysId, o.id) && !circleInView(o.x, o.y, o.radius, view)) { offscreen.push(o); } } 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, }); } // Jump gates are objects too: discoverable, compass arrows, autopilot — // in their own cyan (the exits, so easy to spot on the compass). for (const gt of this.systemGates) { out.push({ id: gt.discoveryId, x: gt.x, y: gt.y, radius: gt.bound, typeLabel: config.get('gates.typeLabel', 'Jump Gate'), color: config.get('gates.theme.color', '#5fd4ff'), name: gt.discoveryName, }); } return out; } /** * Autopilot — the player clicked a compass name tag (any UI could wire * to this): send the ship to a discovered object. It flies to the * keep-out rim — the clearance point on the side the ship is coming * from — and arrives to a stop, exactly like a click-to-fly onto the * rim. Works for worlds AND asteroid clusters (the solid behind the * discovery entry). Clicking elsewhere retargets the same way (last * click wins). */ autopilotTo(id) { // While the sub-bar is open (or this very click just closed it — the // scene's handler and the chip's own listener race on event order), // the chip's click is the menu-dismiss, not a navigation command. const bar = this.menuSubBar; if (bar && (bar.isOpen || bar.closing)) { if (bar.isOpen) bar.close(); return; } const o = this.discoverableObjects().find((v) => v.id === id); if (!o) return; // The solid body behind this discovery entry (a world or a cluster). const solid = this.solids.find((s) => s.discoveryId === id) ?? this.planet; // Approach point: on the rim (clearance + hull), on the side the ship // is approaching from (center → ship direction) — it ends up facing // the object. If the object is outside the player's tether range, the // aim clamps to the union boundary — the ship flies as far as its // tether lets it (and rests on the line) until the tether grows. const dx = this.ship.x - o.x; const dy = this.ship.y - o.y; let aim = solid.edgePoint(Math.atan2(dy, dx), solid.clearance, this.ship.radius); // Belt and braces: no other solid may own this point either. for (const s of this.solids) aim = s.aimPoint(aim.x, aim.y, this.ship.radius); aim = this.tetherField.clampPoint(aim.x, aim.y); this.showTargetMarker(aim.x, aim.y); this.ship.setTarget(aim.x, aim.y); this.hideHint(); } // ================================================================== // Mining — the ship's energy arm (js/mining/Mining.js, MiningBeam.js) // ================================================================== /** * Which rock (if any) sits under a WORLD point — the click-to-mine * hit test. Nearest rock wins (clusters are far apart; members may * slightly overlap). A few px of slop so smaller rocks are easy to hit. */ rockAt(wx, wy) { let best = null; let bestD = Infinity; for (const c of this.asteroidClusters) { for (const m of c.members) { const d = Math.hypot(wx - m.wx, wy - m.wy); if (d <= m.radius + 8 && d < bestD) { best = { cluster: c, member: m, x: wx, y: wy }; bestD = d; } } } return best; } /** * A rock click: the context menu unfolds right where the player * clicked (js/ui/MiningPopup.js — world-anchored on the asteroid). * The primary button is "Mine Asteroids" — or "Stop Mining" when * this cluster is already the beam's target. */ openMiningMenu(rock, pointer) { const isTarget = this.mining.state === 'mining' && this.mining.cluster === rock.cluster; // The pop-up plays its own ui_window whoosh (MiningPopup.open). this.miningPopup.open(rock.x, rock.y, pointer.y, { name: rock.cluster.discoveryName, primaryId: isTarget ? 'stop' : 'mine', primaryLabel: isTarget ? 'Stop Mining' : 'Mine Asteroids', target: rock, }); this.hideHint(); } /** The pop-up's button presses (MiningPopup → onAction). */ miningAction(id, rock) { this.miningPopup.close(); if (!rock) return; if (id === 'mine') { if (this.mining.state === 'extending') return; // the arm is committed this.mining.begin(rock.cluster, rock.member); } else if (id === 'stop') { this.mining.stop(); } // 'cancel' — just close. } // ================================================================== // Comms — the planet / station comms panel (js/ui/CommsPanel.js) // ================================================================== /** * Which comms object (if any) sits under a WORLD point — the home * world, a system planet, or a space station — hit-tested at the * scale of its keepout circle (radius + clearance, the flyable rim). * Nearest wins (they don't overlap). Rocks are NOT comms objects — * they are the mining flow's (rockAt above). */ worldObjectAt(wx, wy) { let best = null; let bestD = Infinity; 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; } /** * The gate under (wx, wy), closest wins — the hit circle is its * keepout (gate radius + ship clearance) plus a touch of pad, the * same spirit as rockAt. (Gates are deliberately absent from * worldObjectAt — they are NOT comms targets.) */ gateAt(wx, wy) { let best = null; let bestD = Infinity; for (const gt of this.systemGates ?? []) { const r = (gt.radius ?? 0) + (gt.clearance ?? 0) + 8; const d = Math.hypot(wx - gt.x, wy - gt.y); if (d <= r && d < bestD) { best = gt; bestD = d; } } return best; } /** * THE JUMP (data/gates.json → jump): click an ACTIVE gate and the * run moves to the connected system, arriving near that system's * RETURN gate (the one pointing back — activated with the gate per * the ACTIVITY rule, its tether anchoring the ship's room to move; * the pure geometry is js/galaxy/JumpTravel.js). One-way shortcut * jumps have no return gate — they land on the destination's star, * inside its home-tether zone. * * HOW: the save pipeline, in miniature. captureState() snapshots the * WHOLE run (discovery, reputation, research, builds, minerals, * playtime, activatedGates); the destination system + arrival * position are swapped in; prepareLoad() re-stages the shared state * (the galaxy rebuilt from the seed — deterministic, same names and * placements — with the new current system); scene.restart() rebuilds * this scene onto it: the destination's native tethers (home + its * activated gates) re-form in create(), its dossier/HUDs rebuild, * and _onEnterSystem() grants its map tech. * * @param {object} gt the gate entity (JumpGate — .gate record, .x/.y, * .radius, .clearance, .active) */ jumpThroughGate(gt) { if (this._jumping) return; const from = this.systemRecord; const destId = gt?.gate?.to; const dest = destId ? this.galaxy.byId.get(destId) : null; if (!dest) { this.consoleToast('NO LINK — GATE DESTINATION UNKNOWN', { glyph: '⌁', glyphColor: toCss(themeColor('neon2', 0xff9b9b)), }); return; } if (this.mining?.isActive) { this.consoleToast( String(config.get('gates.jump.miningToast', 'CANNOT JUMP WHILE MINING')), { glyph: '⌁', glyphColor: toCss(themeColor('neon2', 0xff9b9b)) }, ); return; } // Where we arrive: just past the return gate's keepout (JumpTravel). // Fallback — no return gate (a one-way shortcut): the destination's // star, inside its home tether (added unconditionally in create()). const destContent = this.galaxy.ensureContent(destId); const arrival = jumpArrival(destContent, from.id, { radius: gt.radius ?? 0, clearance: gt.clearance ?? 0, shipRadius: this.ship?.radius ?? 0, gap: Number(config.get('gates.jump.arrivalGap', 128)) || 0, }) ?? { x: 0, y: 0, heading: 0, gateId: null }; const rec = captureState(this, this.time.now); rec.currentSystemId = destId; rec.systemName = dest.name; rec.ship = { x: arrival.x, y: arrival.y, heading: arrival.heading, minerals: rec.ship?.minerals ?? 0, }; rec.tethers = []; // the destination's native set re-forms in create() prepareLoad(this.registry, rec); // The cut: the console line + the sfx, then the restart — short // enough to read, long enough to hear (the scene's TimeClock drives // the delay; update() steps it, see the v4 quirk note there). this._jumping = true; this.ship.stop(); // no steering across the cut this.hideHint(); this.consoleToast( String(config.get('gates.jump.toast', 'JUMP — {dest}')) .replace('{dest}', String(dest.name).toUpperCase()), { glyph: '⌁', glyphColor: toCss(themeColor('neon', 0x00e5ff)), durationMs: 1200 }, ); this.playSfx('discovery'); this.time.delayedCall( Number(config.get('gates.jump.jumpDelayMs', 420)) || 420, () => this.scene.restart(), ); } /** * What the comms panel shows for a planet or station: * - settled — it hosts a settlement (a colony / mining station / * cloud base anchored to the planet), or IS one (a * free-space station); the home world is settled by * definition (the player's homestead) * - key — the reputation standing key (the anchored planet's * FIRST settlement id — the stable `-s` id; * HOME_KEY for home; the station's own id) * - reputation — the standing on that key (neutral 0 when none) * - canLand — standing > −4 (the panel grays the button at ≤ −4) * - kindLabel — a short flavor line under the name */ commsTargetFor(obj) { let key = null; let settled = false; let kindLabel = ''; if (obj === this.planet) { // The home world: standing pinned at the scale's top. key = HOME_KEY; settled = true; kindLabel = config.get('planets.homeTypeLabel', 'Home World'); } else if (obj.settlement) { // A free-space station — settled by definition; key = its id. key = obj.settlement.id; settled = true; kindLabel = config.get(`settlements.kinds.${obj.kind}.label`, 'Station'); } else { // A system planet: settled when a settlement is anchored to it // (anchor.type 'planet', anchor.ordinal = the planet's ordinal). const rec = (this.systemContent.planets ?? []).find((p) => p.name === obj.discoveryName); if (rec) { kindLabel = config.get(`planets.typeLabels.${rec.name}`, rec.name); const anchored = (this.systemContent.settlements ?? []).filter( (s) => s.anchor?.type === 'planet' && s.anchor?.ordinal === rec.ordinal, ); if (anchored.length > 0) { settled = true; key = anchored[0].id; } } } const rep = settled ? (this.reputation.standingFor(key) ?? 0) : 0; const isPlanet = !obj.settlement; // home world + system planets vs free-space stations const landingOn = config.get('landing.enabled', true) === true; // landing.json kill-switch return { name: obj.discoveryName, settled, key, reputation: rep, canLand: settled && rep > -4 && landingOn, // panel grays the button otherwise kindLabel, isPlanet, frame: isPlanet ? (obj.sheetFrame ?? 0) : null, // planets.png frame → landing.json videos }; } /** * A planet or station was clicked: the comms panel opens at the click * — the name decodes in, the reputation bar draws (settled), the * landing buttons wait. The click is NOT a fly-here — the ship stays * exactly where it is. The panel is world-anchored at the click point * and picks a side (up/down/left/right) that keeps it fully on screen * (CommsPanel.pickSideAndPlace). */ openCommsPanel(obj, pointer) { const t = this.commsTargetFor(obj); // The FULL target goes through the panel — lastTarget comes back to // commsAction() and needs `isPlanet`/`frame` (the landing handoff), // not just the display fields. this.commsPanel.open(pointer.worldX, pointer.worldY, pointer.x, pointer.y, { ...t, litMarks: this.reputation.marksFor(t.reputation), }); } /** * The panel's button presses (CommsPanel → onAction). The panel is * already closed by the time this runs: * 'request-landing' — a settled world, standing > −4 → land * 'land' — an unsettled world → land * 'cancel' — just close (the console log is the ack) */ commsAction(id, target) { this.commsPanel.close(); // the buttons close the panel (like miningAction) if (!target) return; if ((id === 'request-landing' || id === 'land') && target.isPlanet) { this.startLanding(target); return; } // 'request-landing' on a space station — no surface on a station yet; // the click is acknowledged on the console for now. console.info(`[orbit] comms: ${id} — ${target.name} (key: ${target.key ?? 'none'})`); } /** * LAND (commsAction) — the surface sequence: SurfaceScene starts ON * TOP of this scene, handed the world's planets.png sheet frame so it * can pick the landing/surface videos from data/landing.json, plus * the world's type label and tether level for its upper-left HUD. * v4 note: `launch` does not freeze the caller, so sleep the flight * world explicitly — Take Off (SurfaceScene) wakes it back, with the * ship, tethers and discovery exactly where they were. */ startLanding(target) { if (!config.get('landing.enabled', true)) { console.info(`[orbit] comms: landing disabled for ${target.name}`); return; } // Name-keyed world state (build records, tether labels) is keyed by // the world's CANONICAL spelling — resolve whatever casing the // target arrived in (UI display paths uppercase) to that spelling // before it crosses into the surface (js/utils/WorldNames.js). const name = canonicalPlanetName(target.name, [ this.planet?.discoveryName, ...(this.systemPlanets ?? []).map((p) => p.discoveryName), ...(this.tetherField?.tethers ?? []).map((t) => t.label), ]); // A live scan would leave the camera roll/zoom mid-wobble — the scene // sleeps (or shuts down) and never restores it. Cut it, and drop the // compass emission (a flight-scene navigation aid — no use on the // surface, and it shouldn't resurface stale on the way back up). if (this.scanPulse?.busy) { this.scanPulse.cancel(); this.scanObjects = null; } this.scanReveal = null; this.hideHint(); // A previous surface (left sleeping by an earlier Take Off) is shut // down + restarted by this — the fresh init gets this world's frame. // sheetFrame is the raw planets.png index (`.frame` is the texture // Frame OBJECT — Number() of it is NaN, and the video/music lookups // need the number). this.scene.launch('SurfaceScene', { frame: Number(target.frame ?? target.sheetFrame ?? 0), name, type: target.kindLabel ?? '', tetherLevel: this.tetherLevelFor(name), }); this.scene.sleep(); } /** * The tether LEVEL anchored on this world (the surface HUD's second * line — "Tether Level N"): the tether whose label is the world's * name, or whose anchor sits on the world's center (the home tether * is anchored at the origin with the home world's name). 0 = no * tether on this world yet. */ tetherLevelFor(name) { if (!name || !this.tetherField) return 0; const obj = this.systemPlanets.find((p) => p.discoveryName === name) ?? (name === this.planet?.discoveryName ? this.planet : null); if (!obj) return 0; const t = this.tetherField.tethers.find( (tt) => tt.label === name || (tt.x === obj.x && tt.y === obj.y), ); return t ? t.level : 0; } /** * Mining phase changes (Mining → onPhase): the scene's share of the * sequence — the ship's STATE, the ship lock, the console calls, the sfx. */ onMiningPhase(phase) { if (phase === 'extending') { this.ship.stop(); // hold station — the arm needs a steady hull this.ship.setState('mining', 'mining'); // the arm's sequence owns the ship this.playSfx('mining'); // the arm powers up this.hideHint(); // The console call, in the same slot as the discovery toasts — // it holds for the whole reach, then fades as the beam fires. this.consoleToast('Extending Mining Arm...', { glyph: '\u2316', glyphColor: toCss(themeColor('neon', 0x00e5ff)), durationMs: this.mining.armExtendMs, }); } else if (phase === 'mining') { this.setMiningLoop(true); // the beam is live — the hum runs until the sequence ends } else if (phase === 'stopped') { this.setMiningLoop(false); // the arm pulls back — the hum ends with it (no one-shot sound) // Back to normal — 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 events (Mining → onEvent): the ore's scene share — the crack * sfx (the new mining-02.mp3, once per break) and the console calls. * 'split' the rock broke (data: kind 'absorb' = shattered into * pieces that fly to the ship, 'divide' = split in half) * 'absorbed' the shattered pieces hit the hull (data.gained minerals) * 'storageFull' the hold is full — the run ended (mining stops so the * rock isn't ground away for nothing) */ onMiningEvent(name, data = {}) { const neon = toCss(themeColor('neon', 0x00e5ff)); const magenta = toCss(themeColor('neon2', 0xff2d6f)); if (name === 'split') { this.playSfx('mining_split'); // the crack — assets/fx/mining-02.mp3, once per break this.consoleToast( data.kind === 'absorb' ? `SHATTERED — ${data.pieces} × ${data.pieceSize} px PIECES` : `SPLIT — ${data.pieces} × ${data.pieceSize} px ROCKS`, { glyph: '\u25c6', glyphColor: neon }, ); } else if (name === 'absorbed') { this.consoleToast(`+${data.gained} MINERALS ABOARD`, { glyph: '\u2295', glyphColor: neon }); this.refreshMineralHud(); // belt & braces — the landings already fed it via onOre } else if (name === 'storageFull') { this.consoleToast('MINERAL STORAGE FULL', { glyph: '!', glyphColor: magenta }); this.refreshMineralHud(); } } /** * Mirror the ship's hold into the upper-right readout (js/ui/MineralHud.js). * A no-op there when the value is unchanged (no re-tween, no re-flash), * so this is safe to call from every ore seam (per extraction tick, per * fragment landing, per event). */ refreshMineralHud() { if (!this.mineralHud || !this.ship) return; this.mineralHud.set(this.ship.minerals, this.ship.stats.mineralStorage); } /** * The mining hum (data/sfx.json → mining_loop, assets/fx/mining-01.mp3): * a loop that starts when the beam goes live (phase 'mining') and * stops when the sequence ends (phase 'stopped'). A retarget re-fires * 'mining' without a 'stopped' in between — keep ONE hum across * retargets (the isPlaying guard). No-ops when SFX are disabled or the * asset is missing (the shared voice's guards, js/utils/Sfx.js). */ setMiningLoop(on) { if (on) { if (!config.get('sfx.enabled', true)) return; if (sfxPlayingOn(this, 'mining_loop')) return; // already humming (a retarget) playSfxOn(this, 'mining_loop', { loop: true }); } else { stopSfxOn(this, 'mining_loop'); } } /** * Play one of the configured sound effects (data/sfx.json). * Silently no-ops when SFX are disabled, the sound manager isn't * available (headless/test), or the asset never loaded — the game * never blocks or warns on sound. (Shared voice: js/utils/Sfx.js.) */ playSfx(name) { playSfxOn(this, name); } /** The deep-space soundtrack (data/music.json → game): shuffled tracks, * one at a time, advancing when a track ends. Clean to re-fire — a * restart stops the old track and its timer first. */ startGameMusic() { startMusicShuffleOn(this, config.get('music.game', [])); } stopGameMusic() { stopMusicShuffleOn(this); } /** The "new object" moment: a rim ping at the world + a HUD toast. */ celebrateDiscovery(o) { const neon = themeColor('neon', 0x00e5ff); this.playSfx('discovery'); // the signal that something new is here // Expanding ring at the world's rim (world space). const ring = this.add.circle(o.x, o.y, o.radius, 0, 0).setStrokeStyle(2, neon, 0.9).setDepth(6); this.tweens.add({ targets: ring, scale: 1.12, alpha: 0, duration: 750, ease: 'Sine.easeOut', onComplete: () => ring.destroy(), }); // Center HUD toast (~75 px above screen center), in the console language. const type = (o.typeLabel ?? 'OBJECT').toUpperCase(); const name = o.name ? String(o.name).toUpperCase() : null; const label = name ? `DISCOVERED — ${name} · ${type}` : `DISCOVERED — ${type}`; this.consoleToast(label, { glyph: '\u25b8', glyphColor: toCss(neon), durationMs: 2400, }); } showTargetMarker(x, y) { const color = toColor(config.get('game.markerColor', '#41c7ff')); const marker = this.add.circle(x, y, 10, color, 0.8).setDepth(5); this.tweens.add({ targets: marker, scale: 2.4, alpha: 0, duration: 450, ease: 'Sine.easeOut', onComplete: () => marker.destroy(), }); } hideHint() { if (!this.hint || !this.hint.active) return; if (this._hintClosing) return; // a fade-out is already running this._hintClosing = true; this.tweens.add({ targets: this.hint, alpha: 0, duration: 400, onComplete: () => { this._hintClosing = false; if (this.hint) this.hint.destroy(); this.hint = null; }, }); } /** * The deck's button presses (js/ui/ActionBar.js → onAction). * 'menu' is the save system's door (menuAction below); the other * slots are still seams for the player's loop. */ deckAction(id) { // The research console (full-screen, depth 80) — the deck's main feature. if (id === 'research') { if (this.researchWindow && this.researchWindow.isOpen) { this.researchWindow.close(); return; } if (this.menuSubBar && this.menuSubBar.isOpen) this.menuSubBar.close(); if (this.commsPanel && this.commsPanel.isOpen) this.commsPanel.close(); this.researchWindow?.open(); return; } if (id === 'menu') { this.menuAction(); return; } if (id === 'scan') { this.startScan(); return; } console.info(`[orbit] command deck: ${id}`); } // ------------------------------------------------------------ research /** * Begin a research project (ResearchWindow's RESEARCH button → here). * The window only offers the button when the rules allow it; this is the * scene-side enforcement — one project at a time, available tech only — * then the state runs the clock (time-based, data/research.json rules). */ beginResearch(catId, id) { const state = this.researchState; if (!state) return; if (state.getActive()) { this.consoleToast('A RESEARCH PROJECT IS ALREADY IN PROGRESS', { glyph: '◆', glyphColor: toCss(themeColor('amber', 0xffc94d)), }); return; } const tree = this._treeFor(catId); const node = tree?.nodes?.[id]; if (!node) return; if (!isAvailable(tree, state, id)) { this.consoleToast('THAT TECH IS NOT AVAILABLE YET', { glyph: '✕', glyphColor: toCss(themeColor('amber', 0xffc94d)), }); return; } const durMs = Math.max(0, Number(node.duration ?? 0) * 1000); if (durMs <= 0) { // Instant tech (duration 0) — applies the moment it's requested. state.unlock(catId, id); this._applyResearchEffects(node.effects, node); this._completeResearchFx(catId, id, node); return; } // The in-flight project rides the next explicit save (captureState // includes record.research — the game's save model is player-chosen). state.start(catId, id, durMs, this.time.now); this.consoleToast(`RESEARCH INITIATED — ${String(node.label ?? id).toUpperCase()}`, { glyph: '◆', }); this.playSfx('construct'); // the power-up tick (data/sfx.json key 'construct') this.researchWindow?.refresh(); } /** A project's clock ran out (update's tick) — apply + celebrate. */ _completeResearch(catId, id) { const state = this.researchState; if (!state) return; state.unlock(catId, id); const node = this._treeFor(catId)?.nodes?.[id]; if (node?.effects) this._applyResearchEffects(node.effects, node); this._completeResearchFx(catId, id, node); } /** The tree for a category: the SYSTEM category's live per-system tree, * or the static file tree (data/research/.json — null if missing). */ _treeFor(catId) { if (this.systemTree && catId === this.systemTree.id) return this.systemTree; return loadCategory(catId); } /** Entering a system begins its NAV chart: the SYSTEM category's * '{System} Map' tech is granted (duration 0 — never researched via the * console). Idempotent — create() calls it for the starting system (and * again after a load, whose restore replaced the unlocked set), and a * jump's scene restart runs it for the destination. */ _onEnterSystem() { if (!this.researchState || !this.systemRecord) return; this.researchState.unlock(SYSTEM_CATEGORY, systemNodeId(this.systemRecord.id, MAP_NODE)); this.researchWindow?.refresh(); } /** The SYSTEM category's chart gate: every NAV point of the current * system (central body, planets, space stations, jump gates — the * discoverable set minus the asteroid clusters) discovered. */ isSystemNavComplete() { return isNavComplete(this.discovery, this.systemRecord?.id, this.systemContent); } /** * 'Unlock {System} Jumpgates' completed (SYSTEM category): the system's * gates AND the return gates in the systems they jump to go ACTIVE. * * The activation keys are per-run world state (the registry-backed set — * New Game clears, a load restores). The current system's gate entities * wake up here and, per data/gates.json → ACTIVITY, each anchored gate * carries a level-1 tether at its own position (the room to move — in a * barren system, the whole room). The linked systems' return gates flip * their content record now (or on entry — the create() pass); their * entities + tethers materialise when the player jumps there (the * JUMP re-stages the scene onto the destination — jumpThroughGate). * Idempotent throughout. */ _activateSystemJumpgates(sysId) { const level = Math.max(1, Math.floor(Number(config.get('gates.activation.tetherLevel', 1)) || 1)); const keys = activationKeys(this.galaxy, sysId); for (const k of keys) this.activatedGates.add(k); // Current system — the gate entities wake + anchor their tethers. for (const gt of this.systemGates ?? []) { if (!gt.gate) continue; if (!this.activatedGates.has(`${sysId}>${gt.gate.to}`)) continue; gt.activate(); this.tetherField.add(`gate:${gt.gate.id}`, gt.x, gt.y, level, gt.gate.name ?? ''); } // Linked systems — flip the cached content (ungenerated systems flip // in the create() pass on entry; both paths are idempotent). for (const [sid, content] of this.galaxy.contentCache) applyActivation(content, sid, this.activatedGates); this.consoleToast(`JUMP GATES ONLINE — ${String(this.systemRecord?.name ?? sysId).toUpperCase()}`, { glyph: '⌁', glyphColor: toCss(themeColor('neon', 0x00e5ff)), }); } /** The completion ceremony: SFX, toast, the window repaint. */ _completeResearchFx(catId, id, node) { this.playSfx('discovery'); // the 'something new is here' voice (data/sfx.json) this.consoleToast(`RESEARCH COMPLETE — ${String(node?.label ?? id).toUpperCase()}`, { glyph: '✓', glyphColor: toCss(themeColor('neon', 0x00e5ff)), }); this.researchWindow?.refresh(); } /** * Apply a tech's effects (node.effects, data/research/.json — or * the SYSTEM category's live tree). Known shapes: * tether { level: N } → the home world's tether field strengthens * capability "flag" → a scene capability set (future systems read it) * activateGates true → the SYSTEM category: the system's jump gates * + the linked systems' return gates go ACTIVE * Unknown shapes are logged and skipped — data can lead code a step. */ _applyResearchEffects(effects, node) { if (!effects || typeof effects !== 'object') return; for (const [type, spec] of Object.entries(effects)) { if (type === 'tether' && spec && Number.isFinite(Number(spec.level))) { const lvl = Math.max(1, Math.floor(Number(spec.level))); const homeId = this.tetherField.tethers[0]?.id ?? 'home'; this.tetherField.setLevel(homeId, lvl); this.consoleToast(`TETHER FIELD STRENGTHENED — LEVEL ${lvl}`, { glyph: '⌖', glyphColor: toCss(themeColor('neon', 0x00e5ff)), }); } else if (type === 'capability' && typeof spec === 'string') { this.researchCapabilities = this.researchCapabilities ?? new Set(); this.researchCapabilities.add(spec); } else if (type === 'activateGates' && spec) { // SYSTEM category — the node id is "_gates". const sysId = String(node?.id ?? '').replace(/_gates$/, ''); if (sysId) this._activateSystemJumpgates(sysId); } else { console.warn(`[orbit] research: unknown effect ${type}`, spec, node?.id); } } } // ------------------------------------------------------------ builds /** * Begin a build on the surface planet (the BUILD button → SurfaceScene * → here). The BuildWindow only offers the button when the rules allow * it; this is the scene-side enforcement — one build at a time, not * already installed, research + planet gates, the full cost paid up * front (minerals) — then the state runs the clock. The build's time * base is the game-loop clock passed as `now` (global + monotonic, so * a build keeps running if the player takes off mid-build). * * @param {string} planetName the surface planet (BuildState's record key) * @param {string} buildId the build (data/builds.json → builds) * @param {number} now the game-loop clock (ms) * @returns {{ ok: boolean, reason?: string }} the refusal, if any */ beginBuild(planetName, buildId, now) { const state = this.buildState; const def = buildDefs()[buildId]; if (!state || !def) return { ok: false, reason: 'BUILD SYSTEM OFFLINE' }; if (state.getActive()) return { ok: false, reason: 'A BUILD IS ALREADY IN PROGRESS' }; if (state.isBuilt(planetName, buildId)) { return { ok: false, reason: 'ALREADY INSTALLED ON THIS WORLD' }; } // research gate — the blueprint must be researched (the authoritative // side; the research tree's unlocks.builds is the declaration side). for (const req of def.requires ?? []) { const i = req.indexOf('/'); if (i < 0) continue; const cat = req.slice(0, i); const node = req.slice(i + 1); if (!this.researchState?.isUnlocked(cat, node)) { return { ok: false, reason: `REQUIRES RESEARCH — ${String(node).toUpperCase()}` }; } } // planet gate — the world must hold the tether level the build needs const needTether = def.planetRequires?.tetherLevel; if (typeof needTether === 'number' && this.tetherLevelFor(planetName) < needTether) { return { ok: false, reason: `REQUIRES A LEVEL-${needTether} TETHER ON THIS WORLD` }; } // cost — the full amount, paid up front (minerals are the build // currency today — data/builds.json → cost) const cost = Number(def.cost?.minerals ?? 0); if (cost > 0) { if (this.ship.minerals < cost) return { ok: false, reason: `NEED ${cost} MINERALS` }; this.ship.setMinerals(this.ship.minerals - cost); this.refreshMineralHud(); } const durMs = Math.max(1, Number(def.duration ?? 0) * 1000); state.start(planetName, buildId, durMs, now ?? this.time.now); this.playSfx('construct'); // the power-up tick (the research voice) return { ok: true }; } /** * A build's clock ran out (the SurfaceScene's tick → here): apply its * effect to the world. The world change outlives the surface stay — a * level-2 tether on the way back to space means a level-2 range. */ completeBuild(planetName, buildId) { const def = buildDefs()[buildId]; if (def?.effects) this._applyBuildEffects(planetName, def.effects); } /** * Apply a build's effects (data/builds.json → builds..effects). * tether { level: N } → the world's tether strengthens to at least N * (never a downgrade) — the ship's travel range widens with it * capability "flag" → a scene capability set (future systems read it) * Unknown shapes are logged and skipped — data can lead code a step. */ _applyBuildEffects(planetName, effects) { if (!effects || typeof effects !== 'object') return; for (const [type, spec] of Object.entries(effects)) { if (type === 'tether' && spec && Number.isFinite(Number(spec.level))) { const lvl = Math.max(1, Math.floor(Number(spec.level))); // The tether anchored on this world — the same lookup as // tetherLevelFor (by label, or by anchor sitting on the world's // center — the home tether is anchored at the origin with the // home world's name). const obj = this.systemPlanets.find((p) => p.discoveryName === planetName) ?? (planetName === this.planet?.discoveryName ? this.planet : null); const t = this.tetherField?.tethers.find( (tt) => tt.label === planetName || (obj && tt.x === obj.x && tt.y === obj.y), ); if (t) { if (t.level < lvl) { this.tetherField.setLevel(t.id, lvl); this.consoleToast(`TETHER FIELD STRENGTHENED — LEVEL ${lvl}`, { glyph: '⌖', glyphColor: toCss(themeColor('neon', 0x00e5ff)), }); } } else { console.warn(`[orbit] build: no tether on "${planetName}" to strengthen`, spec); } } else if (type === 'capability' && typeof spec === 'string') { this.researchCapabilities = this.researchCapabilities ?? new Set(); this.researchCapabilities.add(spec); } else { console.warn(`[orbit] build: unknown effect ${type}`, spec); } } } /** The RESEARCH slot's progress bar (update() calls it every frame). */ _deckResearchBar(time) { const bar = this._researchDeckBar; if (!bar) return; const p = this.researchState?.progress(time); const slot = this.actionBar?.slots?.find((s) => s.id === 'research'); if (!p || !slot) { if (bar.g.visible) bar.g.setVisible(false); if (bar.txt.visible) bar.txt.setVisible(false); return; } const style = this.actionBar.style; const bw = style.bw; const bh = style.bh; const x = slot.slot.x - bw / 2; const y = slot.slot.y - bh / 2; const accent = toColor(themeColor('neon', 0x00e5ff)); const g = bar.g; g.setVisible(true); g.clear(); g.fillStyle(0x060a10, 0.85); g.fillRect(x + 8, y - 11, bw - 16, 5); g.fillStyle(accent, 0.95); g.fillRect(x + 8, y - 11, (bw - 16) * p.fraction, 5); const txt = bar.txt; txt.setVisible(true); txt.setText(`RESEARCHING ${Math.round(p.fraction * 100)}%`); txt.setColor(toCss(accent)); txt.setPosition(slot.slot.x, y - 15); } /** * The SCAN button: fire the deep-scan pulse from the ship across the * tether region. The sweep's TARGET SET is the discoverable objects * inside the tether union (the wave is absorbed at the union boundary, * so nothing beyond it is in range). One sweep at a time (busy guard); * `enabled: false` in data/scan.json makes the button a no-op. */ startScan() { if (!config.get('scan.enabled', true)) { console.info('[orbit] scan disabled (data/scan.json)'); return; } if (this.scanPulse.busy) return; // a sweep is already out const ox = this.ship.x; const oy = this.ship.y; const tethers = this.tetherField.tethers.map((t) => ({ x: t.x, y: t.y, radius: t.radius })); this.scanObjects = this.discoverableObjects() .filter((o) => this.tetherField.contains(o.x, o.y)) .map((o) => ({ ...o, dist: Math.hypot(o.x - ox, o.y - oy), hit: false })); this.scanPulse.begin(ox, oy, tethers, this.time.now, { onEmit: () => { this.playSfx('scan'); // the ping goes out this.consoleToast('DEEP SCAN — SWEEPING TETHER REGION', { glyph: '\u25c8', glyphColor: toCss(themeColor('neon', 0x00e5ff)), durationMs: this.scanPulse.durationMs + 500, }); }, onAbsorb: () => this.tetherField.excite(1.15), // the barrier shivers as the wave is swallowed onComplete: () => this.finishScan(), }); this.hideHint(); } /** Per-frame: ring every in-region object the moment the front crosses it. */ updateScanHits(time) { const r = this.scanPulse.radiusAt(time); if (r <= 0) return; for (const o of this.scanObjects) { if (o.hit || r < o.dist) continue; o.hit = true; this.scanRippleObject(o); } } /** The hit: the solid pulses in scale (sonar echo) + a shock ring at it. */ scanRippleObject(o) { const solid = this.solids.find((s) => s.discoveryId === o.id) ?? this.planet; const s0 = solid.scale; this.tweens.add({ targets: solid, scale: s0 * (1 + this.scanObjectScale), duration: 150, yoyo: true, repeat: 1, ease: 'Sine.easeOut', }); this.scanPulse.ripple(o.x, o.y, o.radius, this.time.now); } /** * The sweep finished — the seam for SCAN RESULTS (what a scan yields is * the next instruction's call). For now: log the in-region objects and * report the count in the console slot. */ /** * The sweep finished — the scan's OUTPUT. For now this is the seam the * results will grow out of (what a scan YIELDS is a later instruction's * call): log the in-region objects + report the count, and — the part * the player can act on — light up the SECONDARY COMPASS: every * in-region object becomes a faint bearing signal on the ring around the * ship (the compass itself drops any already discovered, and drops them * the instant they get discovered). A fresh scan re-emits the set with a * fresh clock (a re-ping). */ finishScan() { const objs = this.scanObjects ?? []; const hits = objs.filter((o) => o.hit).length; console.info(`[orbit] scan complete — ${hits}/${objs.length} objects in tether range`, objs); // The compass emission (fullMs + fadeMs of life; see updateSignalCompass). this.scanReveal = { bornAt: this.time.now, ids: objs.map((o) => o.id) }; this.consoleToast(`SCAN COMPLETE — ${hits} OBJECT${hits === 1 ? '' : 'S'} DETECTED IN TETHER RANGE`, { glyph: '\u25c8', glyphColor: toCss(themeColor('neon', 0x00e5ff)), durationMs: 2600, }); this.scanObjects = null; } /** * The SECONDARY COMPASS, per frame (js/ui/SignalCompass.js): resolve the * current scan emission into live bearing signals. A signal exists per * in-region object that is still UNDISCOVERED; its strength follows the * full→fade envelope (signalAlpha) from the emission's birth. Positions * are resolved LIVE, so the bearings track both the ship and any drifting * objects. Expired (fullMs + fadeMs) or discovered ⇒ dropped. */ updateSignalCompass(time) { const compass = this.signalCompass; if (!compass) return; const sx = this.ship.x, sy = this.ship.y; const reveal = this.scanReveal; if (!reveal || !Array.isArray(reveal.ids)) { compass.refresh([], time, sx, sy); return; } const age = time - reveal.bornAt; if (age >= this.signalFullMs + this.signalFadeMs) { this.scanReveal = null; // the emission has fully faded compass.refresh([], time, sx, sy); return; } const alpha = signalAlpha(age, this.signalFullMs, this.signalFadeMs); const sysId = this.systemRecord.id; const objects = this.discoverableObjects(); // one build per frame const signals = []; for (const id of reveal.ids) { if (this.discovery.isDiscovered(sysId, id)) continue; // found it → its signal is done const o = objects.find((v) => v.id === id); if (!o) continue; signals.push({ id, x: o.x, y: o.y, alpha, color: o.color }); } compass.refresh(signals, time, sx, sy); } /** * The MENU button: fold the sub-bar up / fold it back down. * (While the pop-up is open, the button closes the pop-up instead.) */ menuAction() { if (config.get('save.subBar.enabled', true) !== true) return; if (this.savePanel && this.savePanel.isOpen) { this.savePanel.close(); return; } if (this.researchWindow && this.researchWindow.isOpen) return; // the console owns input if (this.menuSubBar && this.menuSubBar.isOpen) { this.menuSubBar.close(); return; } // Load Game is live only when the bank holds at least one save. this.menuSubBar.setDisabled('load', !this.saveManager.hasAny()); this.menuSubBar.open(); } /** The sub-bar's button presses (js/ui/MenuSubBar.js → onAction). */ subBarAction(id) { if (id === 'save') { this.savePanel.show('save'); return; } if (id === 'load') { if (this.saveManager.hasAny()) this.savePanel.show('load'); return; } if (id === 'mainMenu') { this.returnToMenu(); return; } } /** ESC: topmost open thing first — confirm dialog → pop-up → research → mining → sub-bar. */ escAction() { if (this.savePanel) { if (this.savePanel.confirm.isOpen) { this.savePanel.confirm.cancel(); return; } if (this.savePanel.isOpen) { this.savePanel.close(); return; } } // The research console (depth 80) — above everything else but the save UI. if (this.researchWindow && this.researchWindow.isOpen) { this.researchWindow.close(); return; } // The mining context menu is the topmost open thing after the save UI. if (this.miningPopup && this.miningPopup.isOpen) { this.miningPopup.close(); return; } // The comms panel (a planet/station click opened it). if (this.commsPanel && this.commsPanel.isOpen) { this.commsPanel.close(); return; } // Beam live (or arm extending) → break the mining (the beam retracts; // the ship is free again — no fly). if (this.mining && (this.mining.state === 'mining' || this.mining.state === 'extending')) { this.mining.stop(); return; } if (this.menuSubBar && this.menuSubBar.isOpen) this.menuSubBar.close(); } /** Return to the main menu (the sub-bar's last button). */ returnToMenu() { this.savePanel?.close(); this.menuSubBar?.dismiss(); this.scene.start('MenuScene'); } /** The sub-bar's anchor: the MENU button's center + top edge. */ menuAnchor() { const menuSlot = this.actionBar?.slots?.find((s) => s.id === 'menu'); if (menuSlot) { return { x: menuSlot.slot.x, y: menuSlot.slot.y - this.actionBar.style.bh / 2, w: this.actionBar.style.bw, }; } // Deck disabled (dev): a virtual button at the bottom right. return { x: this.scale.width - 96, y: this.scale.height - 58, w: 120 }; } /** * Apply a staged restore (js/save/SaveData.js → prepareLoad parked it): * the ship back where it was — and with its hold full or empty as saved * (ship.minerals — the upper-right mineral readout follows) — the saved * tether field (the level-1 home tether is just a saved entry — the * field is rebuilt from the record), and the saved session time. * Discovery + galaxy are already restored in the registry (this scene's * create() read them). */ /** * The `starting` installs (data/builds.json → `starting`): rule-level * pre-builds the player owns from the first frame — the home world's * level-1 tether ("every world starts with its tether already in * place"). 'home' is the seed-independent home key; resolve it to the * home world's name (BuildState keys records by planet name — the same * key the tether field and the surface scene use). Idempotent: create() * seeds a fresh state, and applyRestore() re-seeds AFTER a load — a save * captured before the seed existed (or by an older iteration) replaces * the seeded state, and must not un-install the home world's starting * tether. A player resuming a run always has L1 on home. */ _seedStartingBuilds() { for (const [planet, build] of startingPairs()) { const name = planet === 'home' ? this.planet?.discoveryName : planet; if (name) this.buildState.markBuilt(name, build); } } applyRestore(r) { if (r.ship) { this.ship.setPosition(Number(r.ship.x) || 0, Number(r.ship.y) || 0); if (typeof r.ship.heading === 'number') this.ship.rotation = r.ship.heading; // The hold — saves that predate minerals have no field (stay at 0); // setMinerals clamps to the ship's capacity. if (typeof r.ship.minerals === 'number') this.ship.setMinerals(r.ship.minerals); } // The corner readout (upper right) was built with the FRESH hold (0) // earlier in create() — push the restored value through it. No-op // when the save held nothing (set() skips unchanged values — no // count-up, no flash), so mineral-less saves stay quiet. this.refreshMineralHud(); if (Array.isArray(r.tethers) && r.tethers.length > 0) { for (const t of [...this.tetherField.tethers]) this.tetherField.remove(t.id); for (const t of r.tethers) { if (!t || typeof t.id !== 'string') continue; this.tetherField.add(t.id, Number(t.x) || 0, Number(t.y) || 0, t.level ?? 1, t.label ?? ''); } } this.playTimeMs = Number(r.playTimeMs) || 0; // Research — the unlocked set + the in-flight project (fresh clock). if (r.research && this.researchState) { try { const fresh = ResearchState.fromJSON(r.research); this.researchState.reset(); this.researchState.unlocked = fresh.unlocked; this.researchState.restoreActive(r.research.active ?? null, this.time.now); this.researchWindow?.refresh(); } catch (e) { console.warn('[orbit] restore: research skipped', e); } } // Builds — the installed set + the in-flight build (fresh clock — the // game-loop clock: the build's time base, shared across scenes). if (r.builds && this.buildState) { try { const fresh = new BuildState().fromJSON(r.builds); // World state is keyed by the world's CANONICAL spelling. Saves // written while the comms panel leaked its display casing into // the landing handoff can carry uppercased planet keys — // re-key them (merging into the canonical entry) so the surface // and the tether field see the same world again. const known = [ this.planet?.discoveryName, ...(this.systemPlanets ?? []).map((p) => p.discoveryName), ...(this.tetherField?.tethers ?? []).map((t) => t.label), ]; for (const [k, v] of [...fresh.built.entries()]) { const c = canonicalPlanetName(k, known); if (c === k) continue; fresh.built.delete(k); const existing = fresh.built.get(c); fresh.built.set(c, existing ? new Set([...existing, ...v]) : v); } this.buildState.reset(); this.buildState.built = fresh.built; const activeSpec = r.builds.active && typeof r.builds.active.planet === 'string' ? { ...r.builds.active, planet: canonicalPlanetName(r.builds.active.planet, known) } : (r.builds.active ?? null); this.buildState.restoreActive(activeSpec, this.game.loop.now); } catch (e) { console.warn('[orbit] restore: builds skipped', e); } } // The starting installs are a RULE (data/builds.json → `starting`), // not save data — the load above just replaced the seeded state, so // re-assert them: the home world's level-1 tether is installed from // the first frame, even on a run resumed from a save that predates // the seed (or captured before it was recorded). if (this.buildState) this._seedStartingBuilds(); // The camera was centered on the spawn — recentre on the restored ship. this.cameras.main.setScroll(this.ship.x - this.scale.width / 2, this.ship.y - this.scale.height / 2); this.playSfx('construct'); } shutdown() { this.setMiningLoop(false); // the hum can't outlive the scene this.stopGameMusic(); // neither can the soundtrack this.starfield?.destroy(); this.compass?.destroy(); this.actionBar?.destroy(); this.menuSubBar?.destroy(); this.savePanel?.destroy(); this.miningPopup?.destroy(); this.commsPanel?.destroy(); this.researchWindow?.destroy(); // the console (video + UI) this.mineralHud?.destroy(); this.mining?.destroy(); this.scanPulse?.destroy(); this.signalCompass?.destroy(); this.tetherField?.destroy(); } }