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 { EnemyShip } from '../combat/EnemyShip.js'; import { enemySheetList, enemyTypeIds, neutralTypeIds, factionIds, factionTypeIds, transportSheet } from '../combat/EnemyTypes.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, returnGateFor, ambushTriggerDistance, factionGateLocked } from '../galaxy/JumpTravel.js'; import { Starfield } from '../visuals/Starfield.js'; import { NebulaAtmosphere } from '../visuals/NebulaAtmosphere.js'; import { NebulaLightning } from '../visuals/NebulaLightning.js'; import { EmberField } from '../visuals/EmberField.js'; import { EngineFlare } from '../visuals/EngineFlare.js'; import { WandererStar } from '../visuals/WandererStar.js'; import { RedHaze } from '../visuals/RedHaze.js'; import { VoidBase } from '../visuals/VoidBase.js'; import { StormLayer } from '../visuals/StormLayer.js'; import { SystemEffects } from '../visuals/SystemEffects.js'; import { assignUi, assignWorld } from '../visuals/UiCameras.js'; import { pickFxSystem } from '../galaxy/FxSystems.js'; import { parseTypeSpec, pickPreviewSystem } from '../galaxy/TypeSystems.js'; import { DiscoveryCompass, circleInView } from '../ui/DiscoveryCompass.js'; import { ActionBar } from '../ui/ActionBar.js'; import { CombatBar } from '../ui/CombatBar.js'; import { LevelUpMarquee } from '../ui/LevelUpMarquee.js'; import { CombatSystem } from '../combat/CombatSystem.js'; import { XpManager } from '../combat/XpManager.js'; import { EncounterDirector, encounterAllowed } from '../combat/Encounters.js'; import { CombatHud } from '../combat/CombatHud.js'; import { DuelPicker } from '../combat/DuelPicker.js'; import { MineralHud } from '../ui/MineralHud.js'; import { CreditsHud } from '../ui/CreditsHud.js'; import { GOODS_TEXTURE_KEY } from '../economy/Economy.js'; import { scoutGood } from '../economy/TradeScout.js'; import * as Stock from '../economy/Stock.js'; import { MenuSubBar } from '../ui/MenuSubBar.js'; import { GameOverOverlay } from '../ui/GameOverOverlay.js'; import { SavePanel } from '../ui/SavePanel.js'; import { SaveManager } from '../save/SaveManager.js'; import { consumeRestore, captureState, prepareLoad } from '../save/SaveData.js'; import { CommsHub } from '../comms/CommsHub.js'; import { commsForTrigger } from '../comms/CommsModel.js'; import { Tether } from '../tether/Tether.js'; import { TetherField } from '../tether/TetherField.js'; import { Mining } from '../mining/Mining.js'; import { MiningPopup } from '../ui/MiningPopup.js'; import { ScanPulse } from '../scan/ScanPulse.js'; import { SignalCompass, signalAlpha } from '../ui/SignalCompass.js'; import { CommsPanel } from '../ui/CommsPanel.js'; import { ResearchWindow } from '../ui/ResearchWindow.js'; import { MapWindow } from '../ui/MapWindow.js'; import { ShipWindow } from '../ui/ShipWindow.js'; import { QuestWindow } from '../ui/QuestWindow.js'; import { QuestTrackerHud } from '../ui/QuestTrackerHud.js'; import { QuestState, questCheckKey, questDef, questDefs, starterQuestIds } from '../quests/QuestState.js'; import { SideQuestState, sideQuestPostingsFor, parseSideQuestId, buildPatrolRoute, derivePatrolEncounters, patrolEncounterScript, patrolEncounterXp, patrolRouteComplete, sideQuestAcceptable, bountyDenyCopy, gateLockCopy, deriveBountyPlan, bountySkirmishScript, bountyEngagementScript, bountyHuntXp, nextBountyWaypoint, bountyRouteComplete, bountyText, escortDestinationFor, escortFriendlyDestination, escortEncounterXp, escortWaveRoster, } from '../quests/SideQuests.js'; import { tutorialTarget } from '../quests/TutorialGuide.js'; import { navDiscoveryStats, resourceStats, systemChartSnapshot } from '../galaxy/SystemChart.js'; import { buildGalaxySnapshot, edgeKey } from '../galaxy/GalaxyChart.js'; import { planRoute } from '../galaxy/Route.js'; import { ResearchState } from '../research/ResearchState.js'; import { categories, loadCategory, isAvailable, hasAvailable, buildDefs, attachQuestGates, attachBuildGates } from '../research/ResearchModel.js'; import { SYSTEM_CATEGORY, MAP_NODE, GATES_NODE, systemNodeId, systemIdOfGatesNode, buildSystemTree, isNavComplete, navChart, activationKeys, applyActivation, } from '../research/SystemCategory.js'; import { BuildWindow } from '../ui/BuildWindow.js'; import { JobBoardWindow } from '../ui/JobBoardWindow.js'; import { BuildState } from '../build/BuildState.js'; import { startingPairs, defById, isShipScoped } from '../build/BuildModel.js'; import { ClassWindow, loadClassMedia } from '../ui/ClassWindow.js'; import { ClassState } from '../player/ClassState.js'; import { SkillState } from '../skills/SkillState.js'; import { SkillWindow } from '../ui/SkillWindow.js'; import { SKILL_ICON_KEY, barSpec as skillBarSpec, fillTemplate as fillSkillTemplate, hasTree as skillHasTree, iconSheet as skillIconSheet, skillList as skillListOf, treeClassIds as skillTreeClassIds, windowSpec as skillWindowSpec, } from '../skills/SkillModel.js'; import { CLASS_ICON_KEY, classAt as classLevelAt, classDef, classesEnabled, perkNames, successMessage, windowSpec as classWindowSpec, } from '../player/ClassModel.js'; const FONT_FALLBACK = "'Segoe UI', 'Helvetica Neue', Arial, sans-serif"; const HEADER_FONT = () => fontStack('header', FONT_FALLBACK); const BODY_FONT = () => fontStack('body', FONT_FALLBACK); // The jump clip (data/gates.json → jump.video) — the full-screen one-shot // played between the two systems on a gate jump. Its cache key + the // double-click skip window (ms; two presses this close skip the rest of the // clip — the same window as SurfaceScene's landing/takeoff skip). const JUMP_VIDEO_KEY = 'jump_warp'; const JUMP_DOUBLE_CLICK_MS = 350; /** * The dossier's decode pacing (the shared decode effect, per line): * a beat of arrival, then each line starts a little after the last. */ const HUD_LEAD_IN = 250; // ms before the dossier starts typing in const HUD_STAGGER = 140; // ms between the start of each line const HUD_EXPAND_LEAD = 120; // ms before re-opened details start streaming in /** The open-by-default dossier folds itself 10 s after arrival (unless the player toggles it first). */ const HUD_AUTO_COLLAPSE_MS = 10000; /** * The game world (v0.3: the current system — the central body at the * origin (the player's home world in the starting system, the system's * star in every other one) plus the system's other worlds scattered * around it, in open space). Click anywhere to fly there — holding * SHIFT on the click THROTTLE-LOCKS the ship: it commits to that heading * and keeps flying the way until you steer it somewhere else, or it runs * into something (a world, a rock, the tether rim) and stops there. * * Discovery: come within discovery distance (data/game.json) of a world's * edge and it is DISCOVERED (state in this.discovery). Discovered worlds * that are off-screen get a themed compass arrow on the screen edge * (this.compass) pointing the way back — and clicking its name tag * autopilots the ship there (GameScene.autopilotTo: it targets the * keep-out rim on the side the ship is approaching from). * * The system dossier (top-left) opens by default on arrival and folds * itself after 10 s; clicking the system NAME (or the caret beside it) * toggles the detail lines open/closed — see createSystemHud(). */ export class GameScene extends Phaser.Scene { constructor() { super({ key: 'GameScene' }); } preload() { // The planet spritesheet: frameWidth×frameHeight frames; the first // frames are Terran worlds (see data/planets.json → frames). this.load.spritesheet( Planet.TEXTURE_KEY, config.get('planets.texture', 'assets/images/planets.png'), { frameWidth: config.get('planets.frameWidth', 1024), frameHeight: config.get('planets.frameHeight', 1024), }, ); // The player's ship spritesheet (data/ship.json → texture). If it // isn't configured or the file is missing, Ship falls back to its // built-in procedural dart (a console note says so in create()). const shipTexture = config.get('ship.texture', ''); if (shipTexture) { this.load.spritesheet( Ship.TEXTURE_KEY, shipTexture, { frameWidth: config.get('ship.frameWidth', 256), frameHeight: config.get('ship.frameHeight', 256), }, ); } // The ENEMY spritesheets (data/combat.json → enemy.texture + the // enemyTypes' own textures — EnemyTypes.enemySheetList is the seam, // one key per source file, EnemyShip.textureKey). Loaded here too — // the art is available in the main game for the WAVE RUNS // (js/combat/Waves.js → CombatSystem.startWaves); the sandbox is // where the numbers get tuned. for (const { src, frameWidth, frameHeight } of enemySheetList()) { this.load.spritesheet(EnemyShip.textureKey(src), src, { frameWidth, frameHeight }); } // The ESCORT's TRANSPORT sheet (data/combat.json → transport.texture — // assets/images/ships-transport.png, frames 0–3 at 180×320): a distinct // file from the enemy sheet, loaded under its own key (the escort // contract's freighter — js/combat/TransportShip.js). const transportSheetInfo = transportSheet(); if (transportSheetInfo) { this.load.spritesheet( EnemyShip.textureKey(transportSheetInfo.src), transportSheetInfo.src, { frameWidth: transportSheetInfo.frameWidth, frameHeight: transportSheetInfo.frameHeight }, ); } // 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 space-station spritesheet (data/stations.json → texture): the // deep-space station variants (variants 0..2 today — the first three // 256×256 frames). Station falls back to its procedural hardware if // the sheet is missing. const stationTexture = config.get('stations.texture', ''); if (stationTexture && config.get('stations.enabled', true) !== false) { this.load.spritesheet( Station.TEXTURE_KEY, stationTexture, { frameWidth: config.get('stations.frameWidth', 256), frameHeight: config.get('stations.frameHeight', 256), }, ); } // The jump gate spritesheet (data/gates.json → texture): frame 0 = // the gate body, frame 1 = the active swirl. If it isn't configured // or the file is missing, JumpGate falls back to its built-in // procedural gate (a console note says so in build()). const gateTexture = config.get('gates.texture', ''); if (gateTexture) { this.load.spritesheet( JumpGate.TEXTURE_KEY, gateTexture, { frameWidth: config.get('gates.frameWidth', 256), frameHeight: config.get('gates.frameHeight', 256), }, ); } // The goods spritesheet (data/goods.json → sheet): 128×128 icon frames // (12 across, 120 cells) — one per trade good, in goods.json order // (frame N = goods[N]). Loaded now so the icons are ready when the // shop UI lands (trading is locked until the third story quest); an // empty/blank file is harmless (120 transparent frames). const goodsTexture = config.get('goods.sheet.texture', ''); if (goodsTexture) { this.load.spritesheet( GOODS_TEXTURE_KEY, goodsTexture, { frameWidth: config.get('goods.sheet.frameWidth', 128), frameHeight: config.get('goods.sheet.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 map console's cartography feed (data/map.json → video): a muted // 2:3 loop behind the MAP window. Same contract as the research feed — // a missing file just leaves the window's NO SIGNAL plate up. if (config.get('map.enabled', true)) { const mapVideo = String(config.get('map.video.file') ?? ''); if (mapVideo) { const url = /^(https?:)?\/\//.test(mapVideo) || mapVideo.startsWith('assets/') ? mapVideo : `assets/videos/${mapVideo}`; this.load.video(MapWindow.VIDEO_KEY, url); } } // The quests console's mission feed (data/quests.json → video): a muted // 2:3 loop behind the QUESTS window — the same dimensions as the // research feed. Same contract — a missing file just leaves the // window's NO SIGNAL plate up. if (config.get('quests.enabled', true)) { const questVideo = String(config.get('quests.video.file') ?? ''); if (questVideo) { const url = /^(https?:)?\/\//.test(questVideo) || questVideo.startsWith('assets/') ? questVideo : `assets/videos/${questVideo}`; this.load.video(QuestWindow.VIDEO_KEY, url); } } // The ship console's bay camera (data/shipScreen.json → video): a muted // 2:3 loop behind the SHIP window. Same contract — the clip is planned // but not authored yet (assets/videos/ship.mp4 is not in the repo), and a // missing file just leaves the window's procedural NO SIGNAL plate up. if (config.get('shipScreen.enabled', true)) { const shipVideo = String(config.get('shipScreen.video.file') ?? ''); if (shipVideo) { const url = /^(https?:)?\/\//.test(shipVideo) || shipVideo.startsWith('assets/') ? shipVideo : `assets/videos/${shipVideo}`; this.load.video(ShipWindow.VIDEO_KEY, url); } } // The build console's feed (data/builds.json → video): a muted 2:3 // loop behind the BUILD window on a planet surface (SurfaceScene's // BuildWindow reads the shared cache). A missing file just leaves the // window's NO SIGNAL plate up — the console still works. if (config.get('builds.enabled', true)) { const buildVideo = String(config.get('builds.video.file') ?? ''); if (buildVideo) { const url = /^(https?:)?\/\//.test(buildVideo) || buildVideo.startsWith('assets/') ? buildVideo : `assets/videos/${buildVideo}`; this.load.video(BuildWindow.VIDEO_KEY, url); } } // The job board's feed (data/actionbar.json → surface.explore.video): // a muted 2:3 loop behind the JOB BOARD console on a surface // (SurfaceScene's JobBoardWindow reads the shared cache). A missing // file just leaves the window's NO SIGNAL plate up — the board still // works. { const jobVideo = String(config.get('actionbar.surface.explore.video.file') ?? ''); if (jobVideo) { const url = /^(https?:)?\/\//.test(jobVideo) || jobVideo.startsWith('assets/') ? jobVideo : `assets/videos/${jobVideo}`; this.load.video(JobBoardWindow.VIDEO_KEY, url); } } // The jump clip (data/gates.json → jump.video): a full-screen one-shot // played between the two systems on a gate jump (jumpThroughGate → // _playJumpClip). A missing/empty file just leaves the short-cut // fallback — the jump still works. { const jumpVideo = String(config.get('gates.jump.video') ?? ''); if (jumpVideo) { const url = /^(https?:)?\/\//.test(jumpVideo) || jumpVideo.startsWith('assets/') ? jumpVideo : `assets/videos/${jumpVideo}`; this.load.video(JUMP_VIDEO_KEY, url); } } // Sound effects (data/sfx.json → enabled). Skipped entirely when the // master switch is off — no load cost, no files fetched. if (config.get('sfx.enabled', true)) { this.load.audio('sfx_construct', config.get('sfx.construct', 'assets/fx/type-construct.mp3')); this.load.audio('sfx_deconstruct', config.get('sfx.deconstruct', 'assets/fx/type-deconstruct.mp3')); this.load.audio('sfx_discovery', config.get('sfx.discovery', 'assets/fx/discovery.mp3')); this.load.audio('sfx_laser', config.get('sfx.laser', 'assets/fx/combat-laser.mp3')); this.load.audio('sfx_mass_cannon', config.get('sfx.mass_cannon', 'assets/fx/combat-mass-cannon.mp3')); this.load.audio('sfx_mass_cannon_hit', config.get('sfx.mass_cannon_hit', 'assets/fx/combat-mass-cannon-hit.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_solar_buzz', config.get('sfx.solar_buzz', 'assets/fx/solar-buzz.mp3')); this.load.audio('sfx_solar_flare_01', config.get('sfx.solar_flare_01', 'assets/fx/solar-flare-01.mp3')); this.load.audio('sfx_solar_flare_02', config.get('sfx.solar_flare_02', 'assets/fx/solar-flare-02.mp3')); this.load.audio('sfx_thunder_01', config.get('sfx.thunder_01', 'assets/fx/thunder-01.mp3')); this.load.audio('sfx_thunder_02', config.get('sfx.thunder_02', 'assets/fx/thunder-02.mp3')); this.load.audio('sfx_thunder_03', config.get('sfx.thunder_03', 'assets/fx/thunder-03.mp3')); this.load.audio('sfx_thunder_04', config.get('sfx.thunder_04', 'assets/fx/thunder-04.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); } // The COMBAT soundtrack (data/music.json → combat) — the shuffled // playlist that swaps in the moment the fight goes live (setCombatMusic, // on the onActiveChange seam). Same derived-key rule (music_combat_01…). const combatTracks = config.get('music.combat', []); if (config.get('music.enabled', true) && Array.isArray(combatTracks)) { for (const f of combatTracks) this.load.audio(gameTrackKey(f), f); } // The NEUTRAL-PILOT soundtrack (data/music.json → neutral) — the // neutral-only encounter's hum (setCombatMusic, active + neutral — the // onActiveChange seam's second argument). Same derived-key rule // (music_neutral_01…). const neutralTracks = config.get('music.neutral', []); if (config.get('music.enabled', true) && Array.isArray(neutralTracks)) { for (const f of neutralTracks) this.load.audio(gameTrackKey(f), f); } } create() { // A jump's cut (jumpThroughGate) sets this on the dying scene — the // restart lands here, so clear it: input unlocks again. this._jumping = false; // The jump clip's in-flight state (the full-screen one-shot between the // two systems — _playJumpClip). The clip is created on the dying scene // and torn down in _finishJump before the restart, so these are fresh on // every entry regardless. this._jumpVideo = null; this._jumpBackdrop = null; this._jumpFinished = false; this._jumpLastClickT = 0; // Camera: smoothly follows the ship (updateCamera below). This motion // is what drives the parallax starfield — ship flies, view trails. this.cameraFollowShip = config.get('game.camera.followShip', true); this.cameraFollowRate = config.get('game.camera.followRate', 3.0); // 1/s // Session time (the save's playTimeMs). Accumulates in update(); // a LOAD replaces it with the saved value (applyRestore). this.playTimeMs = 0; // The deep-space soundtrack — the data/music.json (game) playlist, // shuffled: one track at a time, next picked when one ends. The COMBAT // soundtrack (→ combat) swaps in on the onActiveChange seam (see // setCombatMusic): the deep-space one stops, the combat one starts, and // they swap back the moment the fight 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._asleep = false; // surface state (the sleep/wake events flip it) — setCombatMusic's seam this._combatWasNeutral = false; // the last live combat's neutral-only flag — the seam's memory this.startGameMusic(); this.events.once('shutdown', () => { this.stopGameMusic(); this.setMiningLoop(false); // the hum can't outlive the scene this.systemEffects?.release(); // the effect can't outlive its scene this.levelMarquee?.destroy(); // the marquee can't outlive its scene }); this.events.on('sleep', () => { this._asleep = true; // on a world's surface — the surface owns the silence this.stopGameMusic(); // no space hum on a world's surface (combat or deep-space) this.setMiningLoop(false); // …nor the mining hum this.engineFx?.sleep(); // no ghost exhaust waiting on the pad }); this.events.on('wake', () => { this._asleep = false; // Back in space — resume the RIGHT context: the combat shuffle if the // fight is still live (landing is denied mid-fight, so this is the // defensive branch), the deep-space hum otherwise. if (this.combat?.active) (this._combatWasNeutral ? this.startNeutralMusic() : this.startCombatMusic()); else this.startGameMusic(); // 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(); // THE ORIGIN WORLDS' DAILY BATCHES (model v2 — the economy's one // stateful piece, the time-gated BUY side: each origin world's // signature good sells a batch per day and regenerates on the day // clock; js/economy/Stock.js). Registry-backed run state like // depletedClusters (save/restore in js/save/SaveData.js); a fresh run // starts full everywhere (the empty map reads as "full batch for // today"), and spendStock() below swaps the reference in place. this.economyStock = this.registry.get('economyStock') ?? null; if (!this.economyStock) { this.economyStock = {}; this.registry.set('economyStock', this.economyStock); } // THE EFFECTS DEMO (?fx= — js/visuals/SystemEffects.js): drop // the run into the richest system of that archetype (ranked in // js/galaxy/FxSystems.js), fully charted, all gates live — a clean // minute of flying to judge the star's character. It retargets the // CURRENT SYSTEM only (the seed and the galaxy are untouched), so // everything downstream — the record, the contents, the worlds, // the gates, the tethers — builds for the demo system for free. // // THE WORLD PREVIEW (?type=[-NN] / ss[-NN] — js/galaxy/ // TypeSystems.js): drop the run onto the richest system holding a // world/station of the requested kind (a specific SHEET FACE when // -NN names one — the face its landing clips + surface loop wear), // and spawn the ship right beside it (below), so a landing puts the // player on its surface for the videos/music. ?type= wins the system // pick; when BOTH are given the world must live in a system of the // ?fx= type (a preview under a chosen star's character), and a // ?type= that finds nothing there falls back to the plain ?fx= demo. this.fxSystemType = this._readFxSystemType(); this.typeSpec = this._readTypeSpec(); this.typeSpecRaw = this._typeSpecRaw; this.typeTarget = null; if (this.typeSpec) { const pick = pickPreviewSystem(this.galaxy, this.typeSpec, this.fxSystemType); if (pick) { this.galaxy.currentSystemId = pick.record.id; this.typeTarget = pick; // { record, ordinal|station, reskin } if (pick.reskin) { console.info( `[orbit] ?type=${this.typeSpecRaw} — no world wears that exact face in this galaxy; ` + `previewing it on ${pick.record.id}'s closest match (reskinned for the demo).`, ); } } else if (this.fxSystemType) { console.warn( `[orbit] ?type=${this.typeSpecRaw} — no world of that kind in a ${this.fxSystemType}-type system; falling back to the ?fx= demo.`, ); const rec = pickFxSystem(this.galaxy, this.fxSystemType); if (rec) this.galaxy.currentSystemId = rec.id; else { console.warn(`[orbit] ?fx=${this.fxSystemType} — no system of that type in this galaxy; starting as usual.`); this.fxSystemType = null; } } else { console.warn(`[orbit] ?type=${this.typeSpecRaw} — no world of that kind in this galaxy; starting as usual.`); this.typeSpec = null; } } else if (this.fxSystemType) { const rec = pickFxSystem(this.galaxy, this.fxSystemType); if (rec) this.galaxy.currentSystemId = rec.id; else { console.warn(`[orbit] ?fx=${this.fxSystemType} — no system of that type in this galaxy; starting as usual.`); this.fxSystemType = null; } } // 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); // The last JUMP's source system (jumpThroughGate stages it into the // restore record; captureState carries it through a panel save too): // the 'Pirate Problems' ambush measures its midpoint from the gate // the player came in through — returnGateFor(content, lastJumpFrom) // (GameScene._pirateAmbushEntryGate). Null on a fresh run or a save // predating the field — the ambush's nearest-gate fallback stands // in for it. this.lastJumpFromSystem = this._pendingRestore && typeof this._pendingRestore.lastJumpFrom === 'string' ? this._pendingRestore.lastJumpFrom : null; // A FRESH run (no staged restore) is what the 'newGame' comms fire // for (the welcome plays on a new game, never on a load) — and a // jump-cut restart (scene.restart, no resetRunState) is NOT one: its // played ledger (the registry) already holds the welcome's id, so // the trigger below re-arms nothing (the model's played filter). this._freshRun = this._pendingRestore === null; this.systemRecord = this.galaxy.currentSystem(); this.systemContent = this.galaxy.ensureContent(this.systemRecord.id); // WORLD PREVIEW RESKIN (?type=-NN whose face the galaxy wears // nowhere — pick.reskin): paint the requested sheet frame on the // target BEFORE its entities build (below) — the demo world wears // the face, and its landing/surface clips + surface loop follow // (data/landing.json / data/music.json index off the drawn frame). if (this.typeTarget?.reskin && typeof this.typeSpec?.frame === 'number') { const frame = this.typeSpec.frame; if (this.typeTarget.station) { const s = (this.systemContent.settlements ?? []).find((x) => x?.kind === 'deepSpaceStation'); if (s) s.stationFrame = frame; } else if (this.typeTarget.ordinal === 0) { this.systemContent.homeFrame = frame; // the home world (starting system) } else { const p = (this.systemContent.planets ?? [])[this.typeTarget.ordinal - 1]; if (p) p.frame = frame; } } // DEPLETED FIELDS — asteroid clusters the run has mined to nothing: // per-run world state, registry-backed like discovery/research // (js/save/SaveData.js). The galaxy's content regenerates from the // seed on every load, so the run's depleted ids are spliced OUT of // the content BEFORE the cluster entities build (below) — a mined-out // field stays gone across a save, and it is off the map (the chart + // resource stats read content.asteroids) with the entities gone. this.depletedClusters = this.registry.get('depletedClusters') ?? null; if (!this.depletedClusters) { this.depletedClusters = new Set(); this.registry.set('depletedClusters', this.depletedClusters); } if (this.depletedClusters.size && Array.isArray(this.systemContent?.asteroids)) { const sysId = this.systemRecord.id; this.systemContent.asteroids = this.systemContent.asteroids.filter( (c) => !this.depletedClusters.has(`${sysId}:${c?.id}`), ); } // PIRATE KILL TALLIES — the per-system count of the pirates the run // has put down (data/reputation.json → bonuses.pirateKills — every // `every` kills in a system, every place in it gains `bonus` // standing; GameScene.recordPirateKill pays it out). Per-run world // state — registry-backed (like depletedClusters): New Game clears // it, a load restores it (js/save/SaveData.js). this.pirateKillsBySystem = this.registry.get('pirateKillsBySystem') ?? null; if (!this.pirateKillsBySystem) { this.pirateKillsBySystem = {}; this.registry.set('pirateKillsBySystem', this.pirateKillsBySystem); } // 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); } // THE RUN'S GALAXY FOOTPRINT (the MAP console's GALAXY tab — // js/ui/GalaxyView.js): visitedSystems = the systems the run has // ENTERED (chart the region), usedGates = the jump lanes the run has // TRAVELED (edgeKey(a,b), undirected — the lane glows brighter). Both // are per-run world state — registry-backed (like activatedGates): // New Game clears them, a load restores them (js/save/SaveData.js). this.visitedSystems = this.registry.get('visitedSystems') ?? null; if (!this.visitedSystems) { this.visitedSystems = new Set(); this.registry.set('visitedSystems', this.visitedSystems); } this.usedGates = this.registry.get('usedGates') ?? null; if (!this.usedGates) { this.usedGates = new Set(); this.registry.set('usedGates', this.usedGates); } // THE PLOTTED DESTINATION (the MAP console's SYSTEM tab — js/galaxy/ // Route.js): where the run is headed. Persisted as { systemId, // objectId } — the ROUTE itself is derived on demand (planRoute from // wherever the ship is now to destination.systemId), so following it // just shortens it and a detour re-plots it (no progress counter to // desync). Null until the player sets one; cleared on arrival. this.destination = this.registry.get('destination') ?? null; // A ROUTE NOTICE parked by the jump we just completed (Route._routeFor // Jump → _queueRouteNotice): the destination-REACHED / route-RE-PLOTTED // message. The jump's own toast + full-screen clip already played on // the source scene, so surface this one here, a beat after spawn, so // it reads cleanly on the destination system. { const notice = this.registry.get('routeNotice'); if (notice && typeof notice.text === 'string') { this.registry.set('routeNotice', null); this.time.delayedCall(900, () => this.consoleToast(notice.text, { glyph: notice.glyph ?? '◆', glyphColor: notice.glyphColor ?? toCss(this._routeColor()), durationMs: notice.durationMs ?? 3400, }), ); } } this.visitedSystems.add(this.systemRecord.id); // we start here — it's charted for (const j of this.systemContent.jumps ?? []) { if (j && this.activatedGates.has(`${this.systemRecord.id}>${j.to}`)) j.active = true; } if (this.fxSystemType) { // The demo wants the whole system LIVE: every gate on (its tether // is the flight range — the union of them is the playable space), // so a jump out and back is one click away. for (const j of this.systemContent.jumps ?? []) { j.active = true; this.activatedGates.add(`${this.systemRecord.id}>${j.to}`); } } // The CENTRAL BODY — the home world, present ONLY in the STARTING // system, at the world origin (the generator deals its name — // content.homeName — so it reads as a real place rather than a generic // "Terra"; the fallback keeps the old configured name if the bank is // ever unavailable). It is the only central body in the galaxy: in // every other system the center is EMPTY (the star is invisible flavor // — content.star name/class feeds the dossier and gate names, never // rendered) and is the ONLY system where the origin is a comms target // (settled, landable) and reads "Home World". // NOTE: home is the STARTING system (galaxy.homeSystemId) — stable // across jumps. Comparing against galaxy.currentSystemId would be // wrong: that pointer tracks where the player IS NOW, so every // freshly-arrived system would read as home. this.isHomeSystem = this.galaxy.isHomeSystem(this.systemRecord.id); this.homeWorldName = this.isHomeSystem ? (this.systemContent.homeName || config.get('planets.homeName', 'Terra')) : null; this.createSystemHud(); if (this.isHomeSystem) { // The home world — the player's Terran world, present ONLY in the // system the player starts in. It sits at the world origin. Which // Terran face it shows comes from the galaxy-wide frame pass // (content.homeFrame — the same (class, face) spreading as the // planets), falling back to a seed-deterministic pool pick, so the // same galaxy always yields the same home world. const homeName = config.get('planets.homePlanet', 'terran'); const homeRng = Rng.derive(this.galaxy.seed, 'planet', 'home'); const homeFrame = typeof this.systemContent.homeFrame === 'number' ? this.systemContent.homeFrame : Planet.frameFor(homeName, homeRng); this.planet = new Planet(this, 0, 0, homeFrame, homeName); this.planet.discoveryName = this.homeWorldName; this.planet.setDepth(5); // above the starfield (depths 0–2), below the ship (10) } else { // Every other system has NO central body: the star is INVISIBLE // flavor (content.star — name/class/binary — feeds the dossier and // gate names, but is never rendered). The origin is empty space; // the gate tethers are the player's anchors here. this.planet = null; } // The rest of the solar system — the generated worlds, placed by the // generator (data/planets.json → solarSystem) on orbits around the // central body. Same solid-disc rules as the worlds: fly close, // never through. this.systemPlanets = []; if (config.get('planets.solarSystem.enabled', true)) { for (const rec of this.systemContent.planets ?? []) { if (typeof rec.x !== 'number' || typeof rec.y !== 'number') continue; const kind = rec.class || 'rocky'; // The galaxy-wide frame pass (js/galaxy/PlanetFrames.js) stamped // `rec.frame` to spread each (class, face) across the galaxy; fall // back to a random pool pick for content that predates the pass. const frame = typeof rec.frame === 'number' ? rec.frame : Planet.frameFor(kind, Rng.derive(this.galaxy.seed, 'planet', rec.name)); // (PLANET TINT — REMOVED FOR NOW: per-class canvas tints used to be // applied here from data/planets.json → classTint, i.e. // `tint: toColor(config.get(`planets.classTint.${kind}`))`. The // Planet still accepts the option — pass it back when we want it.) const p = new Planet(this, rec.x, rec.y, frame, kind, { scale: rec.scale ?? 1, }); 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): settlements that float in // the void, rendered as world objects (js/entities/Station.js). // Solid (the ship keeps its clearance), discoverable (compass + // toast), and comms targets — a click opens the comms panel there // (openCommsPanel below) — it is NOT a fly-here (the ship stays put). this.systemStations = []; if (config.get('stations.enabled', true) !== false) { for (const s of this.systemContent.settlements ?? []) { if (s.anchor?.type !== 'space' || typeof s.x !== 'number' || typeof s.y !== 'number') continue; this.systemStations.push(new Station(this, s, { depth: 5 })); } } // Jump gates (content.jumps — one per destination in the system's // gate network, js/galaxy/JumpNetwork.js): the system's exits. // Solid (the ship keeps its clearance), discoverable (compass + // toast, in their own cyan), and each one faces its destination // star on the map (entity.rotation). A click on a gate opens the // GATE COMM WINDOW (openGateCommsPanel — the shared comms panel with // a LINK status line; REQUEST JUMP initiates // GameScene.jumpThroughGate, the transport) — never a fly-here. // worldObjectAt deliberately excludes them (the panel opens via // gateAt(), the gates' own hit test, not the comms target set). this.systemGates = []; if (config.get('gates.enabled', true) !== false) { for (const j of this.systemContent.jumps ?? []) { if (typeof j.x !== 'number' || typeof j.y !== 'number') continue; this.systemGates.push(new JumpGate(this, j, { depth: 5 })); } } // FACTION GATE LOCK — the swirl tint (the in-world tell): a gate // into faction-controlled space, below the player's level-5 lock, // swirls in that faction's hue (gateFactionTintFor). With combat on // the XP ledger isn't stood up yet (it lands below with the save's // level), so _refreshGateFactionTints() re-resolves once the ledger // exists; a level-up that lifts the lock clears it again. this._refreshGateFactionTints(); // Every solid in the system — worlds first (their keep-out circles are // disjoint), then the clusters, then the stations, then the gates. The // central body (the home world — the starting system only; other // systems have no star) leads when it exists. // Ship constraint, click-to-fly clamping and autopilot all run // against this list. this.solids = [ ...(this.planet ? [this.planet] : []), ...this.systemPlanets, ...this.asteroidClusters, ...this.systemStations, ...this.systemGates, ]; // The central body's discovery id is 'home' — the chart's central NAV // point (SystemCategory.navPoints) — in the STARTING SYSTEM ONLY (the // home world). Every other system's center is empty: no 'home' NAV // point there (its star is invisible flavor, not a chart waypoint). if (this.planet) this.planet.discoveryId = 'home'; // The ship — in the starting system, a short hop (~150 px, // edge-to-edge) from the home world's rim, in a seed-derived // direction: same galaxy ⇒ same start. Everywhere else the ship's // position comes from the jump/save restore (the arrival gate), so // the origin (an empty center — no star) is only a defensive // fallback. 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); // ENGINE FX — flare-up + exhaust trail on the ship's side pods // (js/visuals/EngineFlare.js). Created per create() because a jump // restarts this scene in place: the display list (emitters, glows) // is destroyed with it, while our ref survives — same contract as // the effect layers below. It reads the ship's throttle per frame // (update) and cleans up on landing (sleep). this.engineFx?.destroy(); this.engineFx = new EngineFlare(this, this.ship); // WORLD PREVIEW (?type=…): the ship starts beside the TARGET world // (this._previewEntity — the planet/station the pick named), not // the home world — the point is to land on it and judge its clips // and surface loop. Same edge-to-edge hop as the home spawn, same // seed-derived direction (same galaxy ⇒ same start). const spawnBody = (this.typeTarget ? this._previewEntity() : null) ?? this.planet; const spawn = spawnBody ? spawnBody.edgePoint( Rng.derive(this.galaxy.seed, 'spawn', 'ship').range(0, Math.PI * 2), config.get('planets.spawnDistanceFromEdge', 150), this.ship.radius, ) : { x: 0, y: 0 }; this.ship.setPosition(spawn.x, spawn.y); // DEV QA handle for the world preview (?type=…): what the boot named // and where the ship was placed — inspect in the console. if (typeof window !== 'undefined') { window.__ORBIT_PREVIEW__ = { typeSpec: this.typeSpec, systemId: this.systemRecord?.id, spawnBody: spawnBody ? spawnBody.discoveryName ?? spawnBody.label ?? null : null, spawn: { x: Math.round(spawn.x), y: Math.round(spawn.y) }, ship: { x: Math.round(this.ship.x), y: Math.round(this.ship.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(); // A JUMP restarts this scene IN PLACE (v4: the same instance — the // 'shutdown' EVENT tears down the display list, but the engine never // calls our shutdown() method, and our own refs survive; see the // MenuScene note on the v4 quirk). The per-system EFFECT LAYERS below // are conditional, so a layer the LAST entry built would keep ticking // — and talking — in this system: a habitable storm's update() would // keep striking, and keep THUNDERING, on a starless sky. Drop whatever // the last entry built; each conditional below re-adds its layer or // leaves the ref null. this._teardownEffectLayers(); // NEBULA GAS — soft coloured clouds in TWO layers, tinted with the // system's own atmosphere color (assigned at random at galaxy // generation — SystemGenerator): a BACK layer behind the art (depth 3, // base color) and a FRONT layer above the ship (depth 11, a derived // accent hue) that drifts past you — flying THROUGH the nebula. // Nebula systems only. See js/visuals/NebulaAtmosphere.js. if ( this.systemRecord?.type === 'nebula' && this.systemContent?.atmosphere?.color ) { this.nebula = new NebulaAtmosphere( this, this.systemContent.atmosphere.color, config.get('game.nebula', {}), ); this.nebula.create(); } // Per-system CONTENT effects (world-space sprites, Canvas-safe): // - redDwarf ember dust (effect.particles) → EmberField (depth 3) // - redDwarf red haze (effect.haze) → RedHaze (depth -1) // - binary void base (effect.void) → VoidBase (depth -1) // - binary companion star (effect.wanderer) → WandererStar (depth 3) // (The camera FILTERS — ripple + grade — are SystemEffects' job.) const _fx = config.get(`systems.types.${this.systemRecord?.type}.effect`, null); if (_fx?.haze) { this.haze = new RedHaze(this, _fx.haze, this.systemContent?.fx ?? {}); this.haze.create(); } if (_fx?.void) { this.voidBase = new VoidBase(this, _fx.void, this.systemContent?.fx ?? {}); this.voidBase.create(); } if (_fx?.storm) { this.storm = new StormLayer(this, _fx.storm, this.systemContent?.fx ?? {}); this.storm.create(); } if (_fx?.particles) { this.embers = new EmberField(this, _fx.particles.color, _fx.particles, this.systemContent?.fx ?? {}); this.embers.create(); } if (_fx?.wanderer) { this.wanderer = new WandererStar(this, _fx.wanderer.color, _fx.wanderer, this.systemContent?.fx ?? {}); this.wanderer.create(); } if (_fx?.lightning && _fx.lightning.enabled !== false) { // OVERT far-sky lightning (nebula): the bolt wears the system's GAS // colors — the base atmosphere + the derived accent (the front gas's // tint), alternating per strike. See js/visuals/NebulaLightning.js. const baseCol = this.systemContent?.atmosphere?.color ?? '#5fd4d0'; const accentCol = typeof this.nebula?.frontTint === 'number' ? this.nebula.frontTint : baseCol; this.nebulaLightning = new NebulaLightning(this, baseCol, accentCol, _fx.lightning, this.systemContent?.fx ?? {}); this.nebulaLightning.create(); } // The TETHER — the player's range. In the STARTING system it 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. In every OTHER system there is no native // tether — the center is empty — the player's range is the activated // GATE tethers (the arrival gate anchors a level-1 tether on landing, // data/gates.json → ACTIVITY). 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) }); if (this.isHomeSystem) { this.tetherField.add( config.get('tether.homeId', 'home'), 0, 0, // the home world's center (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; // The active PATROL's in-world WAYPOINT beacons (data/sidequests.json // → patrol.marker): a diamond + ring + soft glow per waypoint (the // yellow pulse on the NEXT waypoint — the route is a path flown in // order — the dim done mark once visited) — plain // coordinates, not world objects (the route's shape), repainted per // frame (js/quests/SideQuests.js's route record on the scene's // sideQuestState). Depth 9: above the tether line (6) + the route // pulse, below the ship (10). this.patrolG = this.add.graphics().setDepth(9); // world coordinates (the ship + tether scroll with it) // 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); } if (this.fxSystemType) { // The demo's "fully charted": the system is already discovered — // the compass rests, the dossier reads complete, no pings compete // with the effect for your eye. (check() can't do this — its // radius is capped at the configured discovery distance — so the // demo marks the roster directly.) this.discovery.markAllDiscovered(this.systemRecord.id, this.discoverableObjects()); } // 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, Quests, Map, Menu. The slots are the seams // for the player's loop — research (time-based, one at a time, see // data/research.json), scan, the ship, the quest dossier (see // data/quests.json) and the star map. this.actionBar = deckEnabled ? new ActionBar(this, { onAction: (id) => this.deckAction(id), }) : null; // ---- COMBAT — the hostile pack (the ?combat boot) + the combat bar -- // COMBAT IS LIVE IN THE MAIN GAME: the 'Pirate Problems' quest // (data/quests.json → pirate_problems) ambushes the player at the // MIDPOINT of the entry-jumpgate → Neutral-Capital-world stretch — // the enter lands the ship at the gate (a full stretch out); the // wave starts when it reaches half that distance from the world — // GameScene. // _maybeStartPirateAmbush fires the pirate_ambush wave run (data/ // combat.json → waves.scripts.pirate_ambush — one rolling wave of six // frame-0 raiders, materializing off-screen around the player from a // random direction each and streaming in on their chase AI), and the // deck swap, // the readability layer, the auto-fire, and the armor are all the // same shared parts (the ?combat sandbox is where the numbers get // tuned). data/combat.json (game.*): the ?combat boot adds the hostile // pack — spawnCount hostiles in a ring around the ship (spawnRadius px). // The mouse steers; while a hostile is in range the ship's cannon // AUTO-FIRES at the nearest one (game.autoFire — the cannon's numbers // are combat.player.weapon, shared with the range). While combat is // LIVE the deck steps aside for the combat bar (js/ui/CombatBar.js): // shields + hull left, ENERGY right, eight skill seats (keys 1-8) in // the middle — the skills land later. Built for every scene (an idle // system with no enemies and no wave run costs nothing in update()), // the bar hidden until a wave makes combat live. this.combat = null; this.combatBar = null; this.combatHud = null; this._combatSettleFrames = 0; if (config.get('combat.enabled', true) !== false && this.ship) { this.combatBar = new CombatBar(this, { onCast: (i) => this.castSkillSeat(i), // The ASSIGNMENT hand (the skill tree clicks a skill, then a seat) and the // right-press that clears one to make room. Both land in the ledger — // js/skills/SkillState.js — which is what the save writes. onSeatPick: (i) => this.pickSkillSeat(i), // (the console's ASSIGN SEAT press puts the skill in the same hand) onSeatUnbind: (i) => this.clearSkillSeat(i), }); this.combatBar.setVisible(false); // The seats' icons (one sheet, data/skills.json → icons). Loaded here rather // than in preload(): a run that never fights — and every scene that has no // combat bar at all — does not pay for the sheet. this._ensureSkillIcons(); // The run's COMBAT XP ledger (js/combat/XpManager.js — the curve is // data/progression.json; each hostile's type carries its own `xp`, // data/combat.json → enemy.xp / enemyTypes). A save predating the // ledger has no field → the run starts at level 0 (old saves load). const savedXp = this.registry.get('combatXp'); // THE SHIP'S VITALS (hull/shields/energy): a LOAD restores the // damage the run was flying with (record.combat → prepareLoad's // 'combatVitals'); a JUMP's arrival is a SYSTEM LANDING — the // 'combatRepair' flag (staged in jumpThroughGate's record) heals // the ship full instead; a fresh run (neither staged) is a fresh // ship (full armor). const arrivalRepair = this.registry.get('combatRepair') === true; this.registry.set('combatRepair', false); this.combat = new CombatSystem(this, { ship: this.ship, xp: savedXp ? XpManager.fromJSON(savedXp) : new XpManager(), vitals: arrivalRepair ? null : (this.registry.get('combatVitals') ?? null), onActiveChange: (active, neutral) => this._setCombatBar(active, neutral), // The readability layer's bookkeeping (js/combat/CombatHud.js — // the same shared layer the range runs): a kill lands in the feed, // and a hit flashes the bar's gauge that took it. The enemy rides // along for the BOUNTY HUNT's witness (handleCombatKill — the mark // is the only elite the game deploys, so its death IS the contract). onKill: (e) => { this.combatHud?.kill((e.name ? `${e.name.toUpperCase()} — ` : '') + 'DESTROYED'); this.recordPirateKill(e); this.handleCombatKill(e); }, // THE PIRATE'S COMM LINES (data/combat.json → enemyTypes.raider.comms // rolls in CombatSystem — 50% on-screen / 20% rolling-arrival for the // attack, 20% for the defeat; the comms themselves are data/comms.json's // pirate-attack / pirate-defeat — the clip + the random voice + the // line): arm the hub's trigger (js/comms — the box plays the clip // muted with the speech and the message). onComm: (trigger) => this.comms?.trigger(trigger), // A combat level landed (the system fires its own burst + voice + // the armor bump on the manager's 'levelup' event — the skill // layer will subscribe to that same event later): the feed's line. onLevelUp: (level) => this.combatHud?.kill(`LEVEL ${level} — XP`), onPlayerDamage: (n, a) => { if (!this.combatHud || !a) return; if (a.shieldHit > 0) this.combatHud.flash('shield'); if (a.hullHit > 0) this.combatHud.flash('hull'); }, // THE DEATH FLOW (the real one): the hull broke → the scene raises // the Game Over beat (js/ui/GameOverOverlay.js) instead of the // placeholder respawn (that flag is set on the skill layer's seam, // below — _playerDeathRespawns). onPlayerDown: () => this._playerWentDown(), }); // THE LEVEL-UP MARQUEE (js/ui/LevelUpMarquee.js) — the big beat on a // combat level landing: the world freezes and dims, the old level // shatters into falling shards, the new one decodes in digit by // digit, and a neon shutter peels the frame away. Subscribed to the // ledger's 'levelup' event (the armor bump + the level-landing voice // still ride CombatSystem's own subscription); the scene's update() // gates the ship, the combat, and the time-based ticks on its // `active` — the same freeze contract as the comms' ack pause. this.levelMarquee = new LevelUpMarquee(this); this.levelMarquee.bind(this.combat.xp); // FACTION GATE LOCK — the swirl tint follows the level: the ledger // now stands (the save's level restored), so re-resolve the gates' // tints (a level that already lifted the lock clears them), and // every level-up after re-resolves too (the lock lifts at minLevel). this._refreshGateFactionTints(); this.combat.xp.on('levelup', () => this._refreshGateFactionTints()); // THE ENCOUNTER DIRECTOR (js/combat/Encounters.js — the pure clock; // the numbers are data/combat.json → encounters): while flying in // neutral wild space (not home, no wave run in flight, the player // upright) it rolls a random encounter on the interval schedule — // pirates, neutrals, or a mix, sized off the combat level. It owns // no ships: on a hit it returns a composition and _spawnEncounter // materializes the pack off-screen (the existing spawn path; their // AI streams them in). this.encounters = new EncounterDirector(config.get('combat.encounters', {})); // THE ESCORT LANE'S DIRECTOR (data/sidequests.json → escort.encounters — // a HOT lane: a short 20–30 s roll interval at a middling chance, a // 15–30 s quiet after each fight). It owns no ships either: // on a hit it's just a "go", and updateEscort fires the escort's mixed-fighters // wave (escortWaveRoster's roster, bigger concurrent counts). One at a // time (the wave run's completion is the quiet's gate — the patrol's law). this.escortDirector = new EncounterDirector(config.get('sidequests.escort.encounters', {})); this._escortTransport = null; // the escort contract's transport (the scene-local convoy reference) this._encounterSerial = 10000; // off the dev console's 100+i band // ?combat boot (the tuning sandbox): the SEVEN-TYPE DEMO around the // ship (data/combat.json → waves.scripts.combat_demo) — a sequence // of rolling waves, one per new hostile type in difficulty order // (bomber → warden, frames 1–7), six of each, three on screen at a // time, each kill sending in a replacement (js/combat/Waves.js → // WaveRunner, driven by CombatSystem._waveTick — wave 1 lands after // the script's 1.5 s lead; the bar is up from the start). // The main game's combat still arrives with the quest's ambush // (its own wave run) — the boot just adds the showcase. // NOTE — the player's home system is SAFE SPACE: enemies should // NEVER be placed there. This dev boot is the one place they could // land in it, so don't run ?combat at home, and keep that rule for // any new enemy placement (the quest ambush already targets the // Neutral Capital, never home). const boot = this._combatBootMode(); // 'demo' | a combat.factions id | null if (boot === 'demo') { this.combat.startWaves('combat_demo'); // SETTLE WINDOW — the ship's start position is finalized AFTER // create() (a fresh run's home rim comes from the tether barrier; // a load's from the save), so the run ships — spawned around the // create-time position — are re-anchored around the ship for the // first few frames (update() → CombatSystem.resyncAroundShip). this._combatSettleFrames = 30; console.info( '[orbit] ?combat — the seven-type demo is live: 7 rolling waves, bomber → warden (data/combat.json → waves.scripts.combat_demo)', ); } // THE FACTION DUEL ARENA (the ?combat=apex / ?combat=mindweave / // ?combat=zenith boots — data/combat.json → combat.factions): the // APEX (frames 30–37 — cloaked ambushes, flanking strikes, forward // firepower), the MINDWEAVE (frames 40–47 — blinks, phantoms, // telekinetic tides, spiralling barrages) and the ZENITH (frames // 50–57 — wing volleys of slow seekers, arcs that bloom into // electrified fields) duel arena: a picker panel (js/combat/ // DuelPicker.js) offers the faction's eight fighters (keys 1–8 / a // click), and '0' sends the whole pack. One fighter at a time — // the factions run 2× the pirate's damage and 3.5× its armor // (data/combat.json → enemyTypes, the $factions_note / the // _zenith_note's balance rule), with the faction's SKILL LAYER // (js/combat/EnemyAbilities.js) on top of its weapon pattern. When // one goes down the picker re-shows for the next choice. These // rosters are NOT in any quest/galaxy content — the arena is their // only home (same safe-space caveat as the demo: don't run it at // home, where hostiles never go). if (factionIds().includes(boot)) { this.combat.startDuel(boot); this.duelPicker = new DuelPicker(this, this.combat, boot); console.info( `[orbit] ?combat=${boot} — the faction duel arena is live: pick a fighter (keys 1–8, 0 = the whole pack)`, ); } // The ?neutral boot (the dev stand-in for the NEUTRAL-ONLY // encounter): two pilots stream in from off-screen on a SHORT escort // window (8 s — the data's band is 10–15 s), so the whole beat plays // in one sitting: the neutral hum in (setCombatMusic, neutral), the // pilot's SCAN comm as one flies on-screen, the break-off, the // SCAN-COMPLETE comm, and the deep-space hum back. Player-side ships // only — safe to run even at home (where hostiles never go). if (this._wantsNeutralDemo()) { const ship = this.ship; const nids = neutralTypeIds(); const r = this.combat._offscreenRadius() * (0.9 + Math.random() * 0.2); for (let i = 0; i < 2; i++) { const a = Math.PI * (i + 0.5) + (Math.random() - 0.5) * 0.6; this.combat.spawnAlly( ship.x + Math.cos(a) * r, ship.y + Math.sin(a) * r, 90000 + this._encounterSerial++, nids.length ? nids[(Math.random() * nids.length) | 0] : null, { escortMs: 8000 }, // short window — the break-off lands fast ); } console.info( '[orbit] ?neutral — neutral-only encounter live: two pilots, 8 s window (the neutral hum + the scan / scan-complete comms)', ); } // The READABILITY LAYER (shared with the range): the lock on the // auto-fire target, the threat markers, the off-screen arrows // (the camera follows the ship — hostiles can sit outside it), // the kill feed, and the combat bar's gauge flash on player hits. this.combatHud = new CombatHud(this, { getEnemies: () => (this.combat ? this.combat.enemies : []), getTarget: () => this.combat?.currentTarget() ?? null, getCamera: () => this.cameras?.main ?? null, worldToScreen: (x, y) => { const c = this.cameras?.main; const ox = c?.worldView?.x ?? c?.x ?? 0; const oy = c?.worldView?.y ?? c?.y ?? 0; const z = c?.zoom ?? 1; return { x: (x - ox) * z, y: (y - oy) * z }; }, gaugeShape: (kind) => this.combatBar?.gaugeShape(kind) ?? null, }); if (typeof window !== 'undefined') { // Tuning handle: poke the pack / the armor / the bar from the console. window.__ORBIT_COMBAT__ = { system: this.combat, bar: this.combatBar, player: () => (this.combat ? this.combat.stats() : null), kills: () => this.combat?.kills ?? 0, spawn: (n = 1) => { if (!this.combat || !this.ship) return; const r = this.combat._offscreenRadius(); for (let i = 0; i < n; i++) { const a = Math.random() * Math.PI * 2; this.combat.spawnEnemy( this.ship.x + Math.cos(a) * r, this.ship.y + Math.sin(a) * r, 100 + i, ); } }, damagePlayer: (n = 10) => this.combat?.damagePlayer(n), cast: (i) => this.combat?.cast(i), // COMBAT XP (js/combat/XpManager.js): read the bank (level, the // xp toward the next, the cap), or feed it straight to test the // curve / the level-up beat (source 'dev' — the ledger tags it). xp: () => this.combat?.xp?.stats() ?? null, grantXp: (n = 100) => this.combat?.xp?.grant(n, 'dev') ?? null, // The LEVEL-UP MARQUEE (js/ui/LevelUpMarquee.js): preview the // full beat without a kill (from → to; +N LEVELS for a jump). marquee: (from = 7, to = 9) => (this.levelMarquee?.play({ from, to, levelsGained: Math.max(1, to - from) }) ?? null), // WAVE RUNS (js/combat/Waves.js — the quest-step combat): start a // script (a combat.waves.scripts NAME or a raw wave list), watch // it, cancel it. The quest's own hook is live (the 'Pirate // Problems' ambush — GameScene._maybeStartPirateAmbush); this // seam is the sandbox's entry point. waves: { start: (script, opts) => this.combat?.startWaves(script, opts) ?? null, cancel: (destroy = false) => this.combat?.cancelWaves(destroy) ?? null, status: () => this.combat?.waveStatus() ?? null, demo: (name = 'raid') => this.combat?.startWaves(name, { onWaveStart: (i) => console.info(`[orbit] wave ${i + 1} in`), onWaveClear: (i) => console.info(`[orbit] wave ${i + 1} cleared`), onComplete: (s) => console.info('[orbit] combat complete', s), }) ?? 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 in the shared registry — // restart this scene in place (the jump cut's seam) and let the // fresh create() pick it up. A confirmed LOAD drops the run back // where it was saved; it is not a detour through the menu (the // menu's New Game resets the run state, so it never carried a // staged restore). onLoadComplete: () => this.reloadRun(), }); // The MINERALS readout — upper right (the corner the old tether stack // used to occupy): the hold's fill as a bar + counting number. // The CREDITS readout stacks ABOVE it (the trade currency — see // js/economy/Economy.js + data/creditshud.json); the minerals HUD is // shifted down by the credits line's height so the two read as one // console, and the comms box (anchored to the minerals bar's bottom) // keeps clear of both. this.creditsHud = new CreditsHud(this); this.creditsHud.set(this.ship.credits); this.mineralHud = new MineralHud(this, { topOffset: this.creditsHud.height + this.creditsHud.gap }); this.mineralHud.set(this.ship.minerals, this.ship.stats.mineralStorage); // ---- COMMS (the characters speak to the player — data/comms.json) -- // The hub (js/comms/CommsHub.js) owns the comms' lives: the queue // (events arm them; pump() below plays the next due one), the // DEFERRED asset loads (the comm's character clip + speech file load // when the comm is due — never in preload, so a big comm roster // doesn't bloat the initial load), the voice (with the music ducked // under it), and the ACK pause contract (below: update() gates the // action on comms.paused; the input seams gate on it too). The box // itself (js/ui/CommsBox.js) docks just below the mineral bar — the // hud's lower-right is the anchor. this.comms = new CommsHub(this, { // The scene isn't free while any of these owns the screen — the // comm waits (re-pumped each frame) until the screen is clear. // The hub passes the comm it's about to start (def.type): // - a MODAL owns the screen (a window/panel is up, or a jump is // in flight) — no comm of ANY type starts until it's clear; // - an ACTION is in progress (mining live, the scan sweep out) — // the box comms (ack/visual: they pause the action or take the // screen) wait for it to settle, but an audio-only comm is // voice with no box, no pause, no input — it rides over the // action (the research-complete sign-off lands mid-mining). busy: (type) => { const screenOwned = (this.savePanel && this.savePanel.isOpen) || (this.researchWindow && this.researchWindow.isOpen) || (this.mapWindow && this.mapWindow.isOpen) || (this.shipWindow && this.shipWindow.isOpen) || (this.questWindow && this.questWindow.isOpen) || (this.commsPanel && this.commsPanel.isOpen) || (this.menuSubBar && this.menuSubBar.isOpen) || !!this._jumping; if (screenOwned) return true; if (type === 'audio') return false; // voice over the action — allowed return ( (this.mining && this.mining.isActive) || (this.miningPopup && this.miningPopup.isOpen) || (this.scanPulse && this.scanPulse.busy) ); }, anchor: () => { const hud = this.mineralHud; if (!hud) return null; return { right: this.scale.width - hud.pad, top: hud.barY + hud.barH }; }, // IN-COMBAT 'visual' comms take the compact form: the box's small // video-only plate hugging the right edge of the screen (2/3-size // clip, no text) — the battlefield stays clear while the video and // the voice still play. 'ack' comms keep the full box (they need // the words + the ACKNOWLEDGE button). compact: (def) => def?.type === 'visual' && !!(this.combat && this.combat.active), // The ack pause's scene-side effects: the action holds — the ship // parks dead (target + throttle cleared, velocity zeroed) and the // mining arm drops. Resuming needs no work (the ship stays parked; // the player's next click steers it again). onPause: (on) => { if (!on) return; this.ship.stop(); if (this.mining && this.mining.isActive) this.mining.stop(); }, // The comm has STARTED (its id is on the run's played-ledger): the // 'Leaving the Nest' quest's Trestle witnesses flip on exactly this // moment — all three briefs in the ledger = 'Talk to Trestle' done, // the arrival in it = 'Meet Trestle' done (GameScene._trestleCheck // reads the ledger live). Refresh the quest ledger so the checklist // row ticks over and, if the quest just completed, the READY moment // (toast + chime) carries it when the tracker is slid off. onStart: (def) => { if (def && def.from === 'trestle') this._questRefresh(); }, // The comm was ACKNOWLEDGED (the 'ack' comm's press itself — fired // BEFORE onEnd, so the end-side pass already sees the mark): the // run's comms ACKED-ledger (registry 'commsAcked' — // GameScene._commAcked) records the id. 'Pirate Problems' (data/ // quests.json → pirate_problems.unlocks.acked) stands on this: // its grant waits for the 'Special Delivery' payoff chain to be // ACKed all the way through (SaveData captures it under // record.comms.acked). onAck: (def) => this._commAcked(def), // The comm ENDED (for 'ack' comms: the ACKNOWLEDGE press): the // scene's ack-side bookkeeping — Trestle's last brief ('trestle // sends the coordinates to your computer right now') sets the // player's destination to the neutral capital on acknowledgment // (GameScene._commEnded). onEnd: (def) => this._commEnded(def), }); // The run's FIRST rock field (the 'firstCluster' comm beat — see // celebrateDiscovery): a scene-local one-shot, so the event fires // exactly once per scene life. The comm's own once-ledger is the // authoritative dedupe across saves (a flag reset on load can never // re-play a comm the ledger already holds). this._firstClusterSeen = false; this._firstWorldSeen = false; // the first DISCOVERED WORLD (Trestle's intro — the quest's own witness) // The systems whose NAV chart COMPLETED this scene life (the // 'systemComplete' milestone — oac-system-complete, see // celebrateDiscovery): one fire per system. A later asteroid-cluster // discovery in an already-charted system still passes // isSystemNavComplete() (clusters aren't NAV points), so the set — // not the chart state — is the arming gate. this._systemCompleteSeen = new Set(); // The run's FIRST crossing of the 200-mineral line (the 'minerals200' // comm beat — see onOre): same one-shot shape; the threshold check // also runs at boot (create, below) so a loaded run that already // crossed it still gets the beat. this._minerals200Armed = false; // The tutorial's pointing finger (data/quests.json → tutorial, the beat // table in js/quests/TutorialGuide.js): the 'press SCAN' beat is past // the moment the player presses SCAN — the scene-local one-shot (the // comm's played-ledger marks oac-upgrade the moment it starts, and is // the authoritative witness from then on). this._tutScanPressed = false; // 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), onDepleted: (c) => this.onClusterDepleted(c), onEvent: (name, data) => this.onMiningEvent(name, data), onOre: (gained) => { this.refreshMineralHud(); // live: the hold fills as the rock shrinks // Lifetime asteroid mining — the quest ledger's input (the // 'Mine 200 Minerals from asteroids' requirement counts this, // not the hold — the hold is cargo, this is the record). this.totalMined = (this.totalMined ?? 0) + Math.max(0, Number(gained) || 0); this._questRefresh(); // The comms beat (data/comms.json: minerals200 — oac-tether2): // OAC confirms the moment the run's lifetime mining first crosses // 200 — the 'Mine 200 Minerals' quest line (data/quests.json) and // the tether-l2 build cost (data/builds.json). The scene flag fires // the event once per scene life; the comm's once-ledger is the // authoritative dedupe across saves. if (!this._minerals200Armed && this.totalMined >= 200) { this._minerals200Armed = true; this.comms?.trigger('minerals200'); } }, }); 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 Jumpgates' (chart complete → researchable → the // gates + the linked systems' return gates go live). js/research/ // SystemCategory.js; copy + duration in data/gates.json → activation. const sysCat = categories().find((c) => c.id === SYSTEM_CATEGORY); this.systemTree = buildSystemTree({ systemId: this.systemRecord.id, systemName: this.systemRecord.name, accent: sysCat?.accent, isComplete: () => this.isSystemNavComplete(), }); this.researchWindow = new ResearchWindow(this, { state: this.researchState, systemTree: this.systemTree, onResearch: (catId, id) => this.beginResearch(catId, id), }); // ---- MAP CONSOLE (the deck's MAP button, right of SHIP) ---- // The cartography window (data/map.json, js/ui/MapWindow.js, depth 80): // left cartography feed + THREE tabs — CURRENT SYSTEM (discovered // objects on a padded frame, the tether union boundary, the fog of // what the tether doesn't cover yet, the system discovery / resources // readout — clicking a discovered object plots the course, the same // autopilot as a world click), SYSTEM (a CHARTED star's chart — the // same painter for another system, no ship/tether; armed until the // player picks that star on the GALAXY tab, then clicking an object // asks SET DESTINATION), and GALAXY (js/ui/GalaxyView.js: // the whole-galaxy chart — glowing, pulsing stars per archetype, the // jump-lane web with traveled lanes bright + flow packets, the // charted region's outline, hover readouts, and a CHARTED star's // chart in the SYSTEM tab). // The window is a pure view — it polls mapChartSnapshot() / // systemChartSnapshotFor() / galaxySnapshot() while open and repaints // on any change. this.mapWindow = new MapWindow(this, { getChart: () => this.mapChartSnapshot(), getSystemChart: (systemId) => this.systemChartSnapshotFor(systemId), getGalaxy: () => this.galaxySnapshot(), getShip: () => (this.ship ? { x: this.ship.x, y: this.ship.y, heading: this.ship.rotation } : null), onSelect: (objectId) => this.autopilotTo(objectId), onSetDestination: (systemId, objectId) => this.setDestination(systemId, objectId), onLocked: (tabId) => this.consoleToast( tabId === 'system' ? config.get('map.systemLockedToast', 'NO SYSTEM TARGET — PICK A CHARTED STAR ON THE GALAXY MAP') : config.get('map.galaxyLockedToast', 'GALAXY MAP OFFLINE — SECTOR DATA NOT ACQUIRED'), { glyph: '✕', glyphColor: toCss(themeColor('amber', 0xffc94d)), }, ), }); // ---- SHIP CONSOLE (the deck's SHIP button) ------------------------ // The hull & hardware console (data/shipScreen.json, js/ui/ShipWindow.js, // depth 80): left the bay camera (assets/videos/ship.mp4 — a muted 2:3 // loop; until the clip is authored the window's procedural NO SIGNAL // plate stands in), right the SHIP STATUS / SKILL TREE / CARGO HOLDS tabs // over one plate. Ship Status reads the run through the data-driven // source table (data/shipScreen.json → status.rows, resolved by the pure // js/ship/ShipScreenModel.js — ship.stats, the live combat session's // armor, the cannon's cooldown→rate, the hold's bays) and shows the // XP ledger (js/combat/XpManager.js) as a bar + the level 0→5 journey, // which gives way to CHOOSE YOUR CLASS at the class level (the class // layer's seam — onChooseClass). Skill Tree is a STANDBY socket until the // class layer lands: pressing it shakes the tab and toasts. Cargo Holds // reads js/economy/CargoHold.js (one good per bay) + the aggregate // mineral load, and a filled bay opens its manifest with the charted // exits (js/economy/TradeScout.js — the planet shop's own report). this.shipWindow = new ShipWindow(this, { getSnapshot: () => this.shipScreenSnapshot(), // The bay manifest's report is passed the bay's RECORDED purchase cost // (CargoHold.costs), so its exits compare against what the player // actually paid for that bay. getScoutReport: (goodId, cost) => this.shipScreenScoutReport(goodId, cost), // A BEST-EXIT row on the bay manifest was CONFIRMED (the console's own // SET DESTINATION dialog, js/ui/ConfirmOverlay.js): the exit's system + // its WORLD become the route target through the map's seam. The console // closes on the way out (what MapWindow does), so the DESTINATION SET // toast and the orange compass are actually on screen for it. onSetDestination: (systemId, objectId) => { this.shipWindow?.close(); // A CARGO delivery — the manifest's run (kind 'cargo' — the mark // reads BROWN, unless it is a held story quest's delivery — see // setDestination's kind guard). this.setDestination(systemId, objectId, 'cargo'); }, // The row for the system the ship is IN can never be a destination — the // manifest marks it HERE and leaves it inert. isLocalSystem: (systemId) => !!systemId && systemId === this.systemRecord?.id, onLocked: () => this.consoleToast(String(config.get('shipScreen.skillsLockedToast', 'SKILL TREE OFFLINE')), { glyph: '✕', glyphColor: toCss(themeColor('amber', 0xffc94d)), }), // The level-5 door: it opens the SELECT YOUR CLASS console over the // parked world (js/ui/ClassWindow.js) — and it is where a DEFERRED pick // comes back for. With the class layer switched off // (data/classes.json → enabled: false) it stays a voice, never a click. onChooseClass: () => this.openClassWindow({ source: 'shipConsole' }), // The SKILL TREE door (js/ui/SkillWindow.js). The tab is lit by the // snapshot's skillDoor flag below — a class with no tree keeps the socket. onSkills: () => this.openSkillWindow({ source: 'shipConsole' }), }); // ---- THE CLASS LAYER (data/classes.json, js/player/ClassState.js) ---- // The run's class LEDGER — the class locked in at combat level 5, the perks // its initial bonus paid (the combat code's gates: dualCannon / // targetingComputer) and the skill points banked with them. Registry-backed // like discovery/quests: a load stages the saved record // (SaveData.prepareLoad → record.playerClass), a fresh run starts empty, and // New Game clears it. The CONSOLE itself is built on the first open — its // media (~2 MB of feed) loads with js/vignettes/VignetteHub.js's deferred // rule, so a run that never reaches level 5 never downloads it. this.classState = this.registry.get('playerClass') ?? null; if (!(this.classState instanceof ClassState)) this.classState = new ClassState(); this.registry.set('playerClass', this.classState); // The door is spent once a class is on file (a loaded run is never asked // again) and once the auto-prompt has been made (closing it DEFERS — the // ship console's door re-opens it, but the game does not nag). this._classPrompted = this.classState.hasClass; this._classAutoShown = false; // the auto prompt has been made this session this._classAutoOpen = false; // the console is up FROM the auto trigger this._classMediaStarted = false; this._classMediaReady = false; this._classPendingOpen = null; // an open request waiting on the media pass this.classWindow = null; // A LOADED run already wears its class — hand the ledger to the combat // system so its perks (dual cannon / targeting computer) are live from the // first frame, exactly as they are after a fresh lock-in. if (this.combat) this.combat.classState = this.classState; // ---- THE SKILL LAYER (data/skills.json, js/skills/SkillState.js) ---- // The run's skill LEDGER — the points spent in the class's tree and which // active skills sit on the combat bar's eight seats. THE BANK IS NOT HERE: // points live on the class ledger above (the lock-in banks them), this one // only ever spends them. Registry-backed like the class layer: a load stages // the saved record (SaveData.prepareLoad → record.playerSkills), a fresh run // starts empty, and New Game clears it. A loaded/older ledger with a class on // file but no bindings takes the data's default seats (data/skills.json → // classes..skills[].seat). this.skillState = this.registry.get('playerSkills') ?? null; if (!(this.skillState instanceof SkillState)) this.skillState = new SkillState(this.classState); else this.skillState.classState = this.classState; if (this.classState.hasClass) this.skillState.applyDefaultSeats(); this.registry.set('playerSkills', this.skillState); // THE COMBAT SEAM: the fight reads the ledger — its seats resolve through // CombatSystem.cast(), and a hull passive owns the armor's max there. A // LOADED run re-rolls the armor so its ranks are live from frame one (the // multiplier is rolled from base, so this is idempotent — see refresh). if (this.combat) { this.combat.skillState = this.skillState; this.combat.refreshSkillPassives(); // THE DEATH FLOW's respawn gate (the seam reads the LIVE ledger, so a // skill that overrides death stays in sync as ranks change): this.combat.playerRespawn = this._playerDeathRespawns(); } // The assignment hand: {id, from} while a skill is being placed on a seat // (js/ui/CombatBar.js waits on it), null the rest of the time. this._skillAssign = null; this._ensureSkillIcons(); // The dev door (?skill — see _skillDevMode): files the class, funds or maxes // its tree and can open the console, so the layer is testable without 5 levels // of grinding. No parameter, nothing happens. this._applySkillDevDoor(); // THE SKILL CONSOLE (js/ui/SkillWindow.js, depth 80) — the ship console's // SKILL TREE door. It is a pure VIEW of the ledger: every press goes back out // through the scene's own doors (rankUpSkill / beginSkillAssign / clearSkillSeat) // so points, seats, registry stamps, passives and toasts behave exactly as they // do when a KEY does the same thing. Built once per scene life (no media). this.skillWindow = new SkillWindow(this, { getSnapshot: () => this.skillWindowSnapshot(), onRankUp: (id) => { this.rankUpSkill(id); this.skillWindow?.refresh(); }, onAssign: (id) => this.beginSkillAssign(id), onClearSeat: (index) => { this.clearSkillSeat(index); this.skillWindow?.refresh(); }, // THE SESSION DOOR: give back what THIS OPENING of the console ranked, and // only what can be given back without stranding a committed point — the // ledger works the cascade and says when it cannot (SkillState.resetPlan). onReset: (id) => { this.resetSkillPending(id); this.skillWindow?.refresh(); }, // CLOSING IS COMMITTING. The one close that is not is the seat hand-off, which // steps the console aside for a moment and takes it straight back — committing // there would take the undo out of the player's hand mid-transfer. onClose: ({ reason } = {}) => { if (reason !== 'assign') this.commitSkillSession(); }, }); // ---- QUESTS CONSOLE (the deck's QUESTS button, right of SHIP) ---- // The mission dossier (data/quests.json, js/ui/QuestWindow.js, depth 80): // left the 2:3 mission feed (assets/videos/quests.mp4 — the same // dimensions as the research feed), right the MAIN STORY / SIDE QUESTS // tabs (side is a STANDBY socket — side quests are deferred by design), // a THIN list of the active quests, and the big detail plate below: // name, issuer, description, the requirement checklist, and the reward // with the CLAIM action. The surface deck's QUESTS slot opens the same // window over the paused GameScene's state (SurfaceScene.buildDeck). // A quest's progress is evaluated LIVE (questSnapshot below — research, // lifetime asteroid mining, the home tether's standing, the discovery // ledger) and, once a step reads DONE, it is RECORDED on the ledger // (QuestState's completed set — monotonic: the step keeps standing // after the live read can no longer see it, e.g. the home tether's // standing once the ship has left the home system). The ledger // itself — which quests are held, which rewards have been claimed, // and which steps have stood — is the QuestState in the shared // registry (like discovery/research) and rides the saves // (record.quests, plus the lifetime counter record.totalMined the // mining requirement reads). this.questState = this.registry.get('quests') ?? null; if (!this.questState) { this.questState = new QuestState(); this.registry.set('quests', this.questState); } // ---- SIDE QUESTS (the JOB BOARD's ledger — data/sidequests.json) ---- // The contracts the run has taken on the job board (every WORLD // except home — the Explore drawer's own gate): accepted + completed // ids (the postings themselves are seed derivations — a save = seed + // player state, js/quests/SideQuests.js). The board is PER SURFACE // WORLD (the postings stand on the world the surface stands on — // sideQuestPostings(worldName, isStation) below), and the window // renders them over the SLEEPING scene (the QuestWindow precedent — // state + effects here, the view on the surface); the ACCEPT lands // here (acceptSideQuest below). this.sideQuestState = this.registry.get('sidequests') ?? null; if (!this.sideQuestState) { this.sideQuestState = new SideQuestState(); this.registry.set('sidequests', this.sideQuestState); } this._questAnnounced = new Set(); // the 'QUEST READY' toasts + chime, once each this.questWindow = new QuestWindow(this, { getSnapshot: () => this.questSnapshot(), onClaim: (id) => this.claimQuest(id), onSetPriority: (id) => this.setPriorityQuest(id), getPriority: () => this.questState?.priority ?? null, // The FRESH open (the deck's QUESTS button) lands on the run's // PRIORITY quest — the one the tracker HUD features (the story // quest the player is actively engaged in): the tracker's own seam. resolvePriority: (ids) => this.questState?.resolvePriority(ids) ?? null, onLocked: (tabId) => this.consoleToast( tabId === 'side' ? config.get('quests.sideStandbyNote', 'SIDE QUESTS OFFLINE — NO CONTRACTS ON FILE YET') : 'NO SIGNAL', { glyph: '✕', glyphColor: toCss(themeColor('amber', 0xffc94d)) }, ), }); // Lifetime minerals mined from asteroids (the 'Mine 200 Minerals' // requirement's input — the hold is cargo, this is the ledger). Registry // -backed like discovery: a load stages the saved value (prepareLoad), // a fresh run starts at zero, and the New Game reset clears it. this.totalMined = Math.max(0, Math.round(Number(this.registry.get('totalMined')) || 0)); // The run's STORY ledger (data/quests.json → 'Special Delivery' — the // neutral capital's bar, the package handoff, the delivery leg — and // the surface TRADE gate: ShopView reads isTradeUnlocked before the // shop's Buy/Sell tab is allowed). Registry-backed like totalMined: // a load stages the saved record (SaveData.prepareLoad), a fresh run // starts blank, and the New Game reset clears it (resetRunState). const storyRaw = this.registry.get('story') ?? null; this.story = { capitalLanded: storyRaw?.capitalLanded === true, // the bar (the trade gate lifts with it) launchedFromCapital: storyRaw?.launchedFromCapital === true, // the delivery leg's start lastLandedWorld: typeof storyRaw?.lastLandedWorld === 'string' && storyRaw.lastLandedWorld ? storyRaw.lastLandedWorld : null, // the last world landed on delivered: storyRaw?.delivered === true, // step 3's proof (the home-world SALE of the package — monotonic) // 'Pirate Problems' step 3 — the return ARRIVAL at the capital // world (GameScene._maybeWitnessCapitalReturn set it: the ship // within the world's keep-out rim while the quest is held + // unclaimed — monotonic; the 'capitalReturned' check reads it). capitalReturned: storyRaw?.capitalReturned === true, }; this.registry.set('story', this.story); // 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; } // WORLD PREVIEW (?type=…): the ship is parked at the TARGET world — // anchor that world's first-landing tether (data/builds.json → // tether-l1: free + instant, "the moment the player lands on a // world") so the player's room to move is the world's rim — exactly // the state a first landing leaves. Without it the ship would sit // outside every tether (the other systems' gate tethers don't // necessarily reach the target world) and the rim constraint would // drag it back. Idempotent: the home world already hosts its home // tether, and ensureLandingTether adopts existing fields. if (this.typeTarget) { const previewEntity = this._previewEntity(); if (previewEntity?.discoveryName) this.ensureLandingTether(previewEntity.discoveryName); } // The starter quest — granted ONCE onto a fresh ledger (a loaded save's // ledger already holds whatever was granted back then — and give() is // idempotent, so a jump-cut restart that rebuilds this scene off the // registry's ledger stays silent). It is the run's opening arc: the // checklist (data/quests.json) is the first loop — research, mine, // build, discover. if (config.get('quests.enabled', true) !== false) { for (const id of starterQuestIds()) { if (this.questState.give(id)) { const def = questDef(id); this.playSfx('discovery'); // the 'something new is here' voice // The toast lands AFTER the opening 'DISCOVERED — HOME WORLD' // toast (one toast slot — the later call replaces the earlier): // ~2.4s is that toast's life, so +0.3s keeps the order readable. this.time.delayedCall(2700, () => { this.consoleToast( String(config.get('quests.starterToast.text', 'NEW QUEST — {title}')) .replace('{title}', String(def?.title ?? id).toUpperCase()), { glyph: config.get('quests.starterToast.glyph', '◆'), glyphColor: toCss(themeColor('amber', 0xffc94d)), durationMs: 3200, }, ); }); } } } // The RUN's chain (data/quests.json → quests[].unlocks): the quests // whose unlock requirement (unlocks.claimed — the reward CLAIMED) is // met land on the ledger now. On a fresh run the starter is unclaimed // — a no-op; on a LOADED save whose starter is already paid out, // 'Leaving the Nest' grants here (a fresh run's later claim grants it // via claimQuest → the same method; give() is idempotent either way). this._grantUnlockedQuests(); // The field's native set (home + the activated gates) is above; the // tethers the player INSTALLED on this system's other worlds are // world state (built records) — re-anchor them on their worlds, since // a jump cut / scene restart rebuilds the field to the native set // only (idempotent: existing tethers are only ever strengthened). this._rematerializeBuiltTethers(); // The ship's MINING upgrades are derived the same way (build records // are the source of truth — the save carries them, not the stats): // a load / jump-cut re-asserts the arm rate + hold cap the player // installed (idempotent; a fresh run has none). this._restoreMiningUpgrades(); // Entering a system charts it: the SYSTEM category's '{System} Map' // tech (duration 0) is owned on arrival — a new run gets the starting // system's, a load re-asserts the saved system's (the restore above // replaced the unlocked set), and a jump (jumpThroughGate → // scene.restart → this create) grants the destination's. this._onEnterSystem(); // ---- DEEP SCAN (the deck's SCAN button) ------------------------------- // The ship's sonar pulse (js/scan/ScanPulse.js): a charge at the hull, // then an omnidirectional wavefront expanding across the TETHER REGION // (clipped to the union boundary — absorbed at the rim). Objects in the // region ring as the front crosses them (updateScanHits), the starfield // ripples as the wave passes, the camera thumps + rolls with it, and the // barrier shivers where the wave is swallowed (TetherField.excite). // The RESULTS are a seam for now — finishScan logs the in-region objects; // the next instruction decides what a scan yields. this.scanPulse = new ScanPulse(this); this.scanObjects = null; this.scanObjectScale = config.get('scan.ripple.objectScale', 0.045); // SIGNAL COMPASS (js/ui/SignalCompass.js) — the secondary compass: // a faint ring (radius px) around the ship. When a scan COMPLETES, // every undiscovered in-region object lights up a soft "radio signal" // on that ring at its bearing (full 20 s, then a 5 s fade). A signal // drops the instant its object is discovered; re-scanning re-emits the // set with a fresh clock. `this.scanReveal` = the current emission // ({ bornAt, ids }) — the clock + which objects it covers. this.signalCompass = new SignalCompass(this); this.scanReveal = null; this.signalFullMs = config.get('signalCompass.lifetime.fullMs', 20000); this.signalFadeMs = config.get('signalCompass.lifetime.fadeMs', 5000); // SHIP STATE (js/entities/Ship.js): 'normal' is the default — the // ship is free; 'mining' — the arm's sequence owns the ship. The // mining visuals live and die with that state: ANY change out of it // (the player moving the ship — a world click or the compass // autopilot — a cancel, or a future state) ends the mining sequence. this.ship.onStateChange = (next, prev) => { if (prev === 'mining' && next !== 'mining') this.mining.stop(); }; // Input: click = fly there. A click ON the command deck is deck // business, a click on a compass name tag is an autopilot (it // already retargeted the ship), and a click on the system NAME (or // the caret beside it) toggles the dossier open/closed — none of // those is a fly-here. A click on an ASTEROID opens the mining // pop-up (a context menu — its buttons own their clicks; any click // elsewhere closes it and is consumed). A click on a PLANET or // STATION opens the comms panel — the landing-request window — and // does nothing else: it is NOT a fly-here (the ship stays put), and // it does not touch the mining state (no move ⇒ a live beam keeps // running). Any OTHER click moves the ship — which ends the mining // state (the beam retracts as the ship goes; a mid-reach arm // aborts). Holding SHIFT on that click = THROTTLE LOCK: the ship // commits to the heading toward the click point and keeps flying it // — past the point, full throttle — until the player steers it // somewhere else (a plain click flies there and stops; another // Shift+click re-aims the heading) — or until it runs into a solid / // the tether rim, where the scene stops it dead (onPostUpdate). A // click BEYOND the player's tether range clamps to the union boundary // — the target marker lands on the barrier line itself (and while // throttle-locked, the ship runs straight into the line and stops // there instead of resting on it). this.input.on('pointerdown', (pointer) => { // The Game Over beat is up — it owns the whole screen (its two buttons // fire on their own pointerdown; a world click never lands behind it, // so a button press never doubles as a fly-here for the downed ship). if (this._gameOver === true) return; // A jump clip is in flight — it owns the whole screen. A DOUBLE-CLICK // (two quick presses) skips the rest of the clip (the same window as // SurfaceScene's landing/takeoff skip); a single press is swallowed. if (this._jumping && this._jumpVideo) { const now = performance.now(); if (now - this._jumpLastClickT <= JUMP_DOUBLE_CLICK_MS) this._finishJump(); this._jumpLastClickT = now; return; } // A jump is in flight (between the clip's end and the restart): the // old scene is already gone — swallow anything that lands in the gap. if (this._jumping) return; // An ACK comm is up — it owns the whole screen (the box + its // ACKNOWLEDGE button press itself; the world holds still — the // CommsHub's pause contract; no skip, the player reads + clicks). // _commInputBlocked also swallows the click that ACKNOWLEDGED (the // endedWithin race window) — that click is the comm's own. if (this._commInputBlocked()) return; // The save pop-up is MODAL — while it's up it owns all input // (its scrim / cards / dialog eat the click; the world stays put). if (this.savePanel && this.savePanel.isOpen) return; // The research console (depth 80) is full-screen — it owns all input // (its own buttons, the close, ESC); a world click never lands behind it. if (this.researchWindow && this.researchWindow.isOpen) return; // The map console (depth 80) — the same full-screen contract. if (this.mapWindow && this.mapWindow.isOpen) return; // The quests console (depth 80) — the same full-screen contract. if (this.questWindow && this.questWindow.isOpen) return; // The ship console (depth 80) — the same full-screen contract. if (this.shipWindow && this.shipWindow.isOpen) return; // The skill tree console (depth 80) — the same full-screen contract. if (this.skillWindow && this.skillWindow.isOpen) return; // A click the console's OWN ✕ just handled (the two handlers race on // event order — close() lands first, so the guard above can miss it): // it is the console's click, never a fly-here. Same-frame test, the // mining menu's and the comms panel's contract. The console is full- // screen, so no footprint check — any click that frame is its own. if ( this.skillWindow && this.skillWindow.closedByButtonAt !== null && this.time.now - this.skillWindow.closedByButtonAt < 20 ) { 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.visible && this.actionBar.contains(pointer.x, pointer.y)) return; // The combat bar (in the deck's place while combat is live) — a // press on it is the bar's (a skill slot); no fly-here behind it. if (this.combatBar && this.combatBar.visible && this.combatBar.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; } // The quest tracker (below the dossier, depth 30) — the mining- // menu contract: while its row popup is up, a click inside the // popup is a button's (its own pointerdown); the same-frame click // on the row that OPENED it is swallowed; any other click closes // the popup and is consumed. With the popup down, a click on the // tracker's footprint is the tracker's (a row's own pointerdown // opens the popup) — never a fly-here; a click its popup buttons // just handled (they close it from their side first) is that // click, never a world click. if (this.questTracker && this.questTracker.shown) { const tx = pointer.x; const ty = pointer.y; if (this.questTracker.popupOpen) { if (this.questTracker.popupContains(tx, ty)) return; if (this.questTracker.sourceContains(tx, ty)) { if (this.questTracker.popupOpenedWithin(40)) return; // the opening click this.questTracker.closePopup(); // a later deliberate re-click — fold it return; } this.questTracker.closePopup(); return; } if (this.questTracker.closedPopupAt(tx, ty)) return; if (this.questTracker.contains(tx, ty)) return; } // Mining pop-up OPEN: a click on one of its buttons is the // button's (its own pointerdown listener); a click ANYWHERE else // closes the menu and that click is consumed — no fly-here (the // same contract as the sub-bar). if (this.miningPopup && this.miningPopup.isOpen) { if (this.miningPopup.contains(pointer.worldX, pointer.worldY)) return; this.miningPopup.close(); return; } // A click the pop-up's OWN buttons just handled (they close it from // their side first — the two handlers race on event order): it is // the menu's click, never a fly-here. The button and this handler // fire on the SAME input pass (same frame — this.time.now is // identical), so "same frame AND inside the panel's footprint" // isolates that exact click without swallowing the player's next // deliberate one (a click on the rock, to re-open the menu). if ( this.miningPopup && this.miningPopup.closedByButtonAt !== null && this.time.now - this.miningPopup.closedByButtonAt < 20 && this.miningPopup.containsScreen(pointer.x, pointer.y) ) { return; } // COMM PANEL OPEN (js/ui/CommsPanel.js) — the same contract as the // mining menu: a click on one of its buttons is the button's (its // own pointerdown listener); a click INSIDE it is swallowed (no // fly-here); a click on ANOTHER planet/station moves the panel // there (the ship does not fly — the click is the panel's, not a // world move); any other click closes it and that click is // consumed. if (this.commsPanel && this.commsPanel.isOpen) { if (this.commsPanel.contains(pointer.worldX, pointer.worldY)) return; const obj = this.worldObjectAt(pointer.worldX, pointer.worldY); if (obj) { this.openCommsPanel(obj, pointer); return; } // A gate click re-anchors the gate window on that gate (the same // "another world moves the panel" contract). const gt = this.gateAt(pointer.worldX, pointer.worldY); if (gt) { this.openGateCommsPanel(gt, pointer); return; } this.commsPanel.close(); return; } // A click the panel's OWN buttons just handled (they close it from // their side first — the two handlers race on event order): it is // the panel's click, never a fly-here (the same same-frame test as // the mining menu's button guard below it). if ( this.commsPanel && this.commsPanel.closedByButtonAt !== null && this.time.now - this.commsPanel.closedByButtonAt < 20 && this.commsPanel.containsScreen(pointer.x, pointer.y) ) { return; } // A rock under the cursor: clicking an asteroid opens the mining // menu (or the stop menu, when this cluster is the beam's target) // — this click does not fly the ship. const rock = this.rockAt(pointer.worldX, pointer.worldY); if (rock) { this.openMiningMenu(rock, pointer); return; } // A JUMP GATE under the cursor (solid — the ship can't pass // through it): the click opens the GATE COMM WINDOW // (openGateCommsPanel) — the LINK line reads ACTIVE / DORMANT (or // the FACTION LOCK's message — the gate is from a neutral system // into a faction-controlled one and the player is below the lock's // minLevel — data/gates.json → jump.factionLock), REQUEST JUMP // initiates the transport (GameScene.jumpThroughGate, // data/gates.json → jump; a grayed ghost while the gate is // dormant or locked) and CANCEL closes the window. Like a planet // click it is NOT a fly-here — the ship stays put. const gateObj = this.gateAt(pointer.worldX, pointer.worldY); if (gateObj) { this.hideHint(); this.openGateCommsPanel(gateObj, pointer); return; } // A PLANET or STATION under the cursor (a rock click is the mining // flow above): the click opens the comms panel (the landing-request // window, js/ui/CommsPanel.js) and NOTHING ELSE — it is not a // fly-here (the ship stays put), and it does not end the mining // state (no move, so a live beam keeps running). const obj = this.worldObjectAt(pointer.worldX, pointer.worldY); if (obj) { this.hideHint(); this.openCommsPanel(obj, pointer); return; } // Any OTHER click MOVES the ship — which ends the mining state // (the beam retracts as the ship goes; a mid-reach arm aborts). // The state exit is signalled via ship.onStateChange above. if (this.mining.isActive) this.mining.stop(); // The stop point a plain click would have taken: off every solid's // keep-out rim, clamped to the player's tether range. let aim = { x: pointer.worldX, y: pointer.worldY }; for (const s of this.solids) { aim = s.aimPoint(aim.x, aim.y, this.ship.radius); } aim = this.tetherField.clampPoint(aim.x, aim.y); // SHIFT HELD = THROTTLE LOCK: the ship commits to the heading // toward the click point (raw — the direction the player pointed, // not the stop point) and keeps flying that way — past the point // and on, full throttle — until the player steers it somewhere // else. The world still owns the motion: solids and the tether // rim hold it back as it drives into them (postupdate constraint). // (pointer.event is the DOM pointerdown — the press that just // landed, with its modifier keys.) if (pointer.event?.shiftKey) { this.showTargetMarker(aim.x, aim.y); // the aim feedback this.ship.thrustToward(pointer.worldX, pointer.worldY); this.hideHint(); return; } this.showTargetMarker(aim.x, aim.y); this.ship.setTarget(aim.x, aim.y); this.hideHint(); }); // ESC: the topmost open thing closes — the confirm dialog, then the // save pop-up, then the sub-bar. this.input.keyboard?.on('keydown-ESC', () => this.escAction()); // KEYS 1-8 — THE SKILL SEATS. The bar's seats are the same eight addresses, so // the key press runs the SAME door as a click (castSkillSeat → the fight's // cast(), which answers with a reason the console reads out), and while the // skill tree is holding a skill up to be placed the digits PLACE it instead. this.input.keyboard?.on('keydown', (e) => this._skillKeyAction(e)); // 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); // SYSTEM EFFECT (the star's character — js/visuals/SystemEffects.js): // data-driven per type (data/systems.json → effect). "none" (the // common case) attaches nothing — the scene renders exactly as // before. A live effect (nebula's ripple) splits the cameras first // (world pass carries the filter; the UI pass draws the HUD on top, // unfiltered — js/visuals/UiCameras.js), then attaches the filter // to the world camera. this.systemEffects = new SystemEffects(this); this.systemEffects.apply(this.systemRecord.type); // ---- COMMS: the fresh-run welcome ---------------------------------- // The 'newGame' trigger (data/comms.json) arms the run's opening // comm — OAC introducing the ship (ack: the action pauses, the box // holds, the player reads + clicks ACKNOWLEDGE). It fires on a FRESH // start only (above: _freshRun) — a LOAD's played ledger already // holds its id, so a resumed run doesn't re-hear the welcome. The // hub's delayMs beats the opening toast, and its deferred load pulls // OAC's clip + speech on demand (the scene's preload stays lean). if (this._freshRun) this.comms.trigger('newGame'); // A loaded run that ALREADY crossed the 200-mineral line (a save from // before the 'minerals200' beat existed) arms it here too — on a fresh // run totalMined is zero so this is a no-op; the hub's ledger / // in-flight guards keep the beat a one-shot either way. if ((this.totalMined ?? 0) >= 200) this.comms.trigger('minerals200'); // A loaded run that ALREADY holds discovered worlds (a save from // before the 'firstWorld' beat existed) arms it here too — on a fresh // run the count is zero so this is a no-op; the hub's ledger / // in-flight guards keep the pair a one-shot either way. if ((this.discoveredWorldCount?.() ?? 0) >= 1) this.comms.trigger('firstWorld'); // The 'Leaving the Nest' beats (Trestle's briefing + arrival — // data/comms.json: trestle-brief-1/2/3 / trestle-arrive-1/2): the // scene-create arm covers the loaded-save case — a save parked at // the capital world (the arrival's witness) or one whose home gates // were already online (the briefing's) still gets its beats. // _trestleBeats is the guard (quest held + unclaimed) and the comm's // once-ledger dedupes the rest (a mid-sequence quit re-arms only the // unplayed beats). this._trestleBeats(); // The 'Pirate Problems' warning (data/comms.json: trestle-q4-1/2/3, // trigger 'trestleQ4') — the same arm for a loaded save that holds // the quest unclaimed: the comm's once-ledger dedupes the already- // heard beats (a mid-sequence quit re-arms only the unplayed ones), // and the quest guard (_trestleQ4Beats) keeps the beats off the // player before the grant. this._trestleQ4Beats(); // The 'Pirate Problems' AMBUSH — the ENTER of the Neutral Capital's // system (the scene was just built for it): the ship is at the entry // gate (a full gate→capital stretch from the world), so the wave // starts here only when the spawn already sits within the MIDPOINT // (a degenerate short stretch) — otherwise update() arms it the // frame the flight crosses the halfway point (the same method's // guards are no-op-gated either way). The third arm is the grant // itself (_questStartBeats — a save whose grant landed while the // player was already IN the system, past the midpoint or not). // When it fires, its ships materialize OFF-SCREEN around the player, // each from a random direction (CombatSystem._spawnWaveEnemy), and // converge on their own chase AI. this._ambushSpawn = this.ship ? { x: this.ship.x, y: this.ship.y } : null; this._maybeStartPirateAmbush(); } /** * Post-physics constraints: every solid in the system (worlds AND * asteroid clusters) holds the ship back at its clearance — fly close, * never through — and the tether holds the player's range (clamped to * the union boundary, the line shudders where it was hit). * * THROTTLE LOCK (Shift+click): a thrusting ship is the one mover that * deliberately drives INTO those constraints (it never brakes on its * own) — so the first frame any constraint actually touches it, the * ship stops dead right there: the hold ends, the world wins. */ onPostUpdate(_time, delta) { let touched = false; for (const s of this.solids) { if (s.constrainShip(this.ship, this.ship.radius)) touched = true; // The combat pack (the ?combat demo) keeps the same keep-out as the // ship — the solids push the hostiles off their rims too. if (this.combat) { for (const e of this.combat.enemies) { if (!e.dead) s.constrainShip(e, e.hitRadius); } } } const tether = this.tetherField.constrainShip(this.ship); if (tether) { touched = true; this.onTetherContact(tether); // the line shudders where it was hit } if (touched && this.ship.state === 'thrust') { this.ship.stop(); // brake dead at the obstacle this.ship.setState('normal', 'contact'); // the hold is over — back to normal } } /** * The ?combat boot MODE (a dev URL parameter, like ?fx= / ?type=): * ?combat → 'demo' (the seven-type showcase waves) * ?combat=apex → the APEX duel arena (frames 30–37) * ?combat=mindweave → the MINDWEAVE duel arena (frames 40–47) * ?combat=zenith → the ZENITH duel arena (frames 50–57) * Any id present in data/combat.json → combat.factions is a valid * faction boot (the roster is data — a new faction needs no code * change here); a bare ?combat is the classic boot, and anything * unknown is treated as the demo so a typo still shows combat. * @returns {string|null} 'demo' | a combat.factions id | null */ _combatBootMode() { if (config.get('combat.enabled', true) === false) return null; if (typeof location === 'undefined') return null; const v = new URLSearchParams(location.search).get('combat'); if (v === null) return null; if (factionIds().includes(v)) return v; return 'demo'; } /** * The ?neutral boot (a dev URL parameter, like ?combat): a NORMAL game * with a NEUTRAL-ONLY encounter live from the start (two pilots, a short * escort window) — the test stand for the neutral hum + the pilot's scan * / scan-complete comms (see the block in create()). * @returns {boolean} */ _wantsNeutralDemo() { if (config.get('combat.enabled', true) === false) return false; if (typeof location === 'undefined') return false; return new URLSearchParams(location.search).has('neutral'); } /** * The action-deck swap (combat mode, Diablo 4 style — one bar in the * same place, depending on the mode): combat LIVE → the combat bar up * and the deck down; combat over → back to the deck. * The COMBAT COMMS ride this same flip (data/comms.json → * oac-enemies-detected / oac-enemies-defeated, both visual, both * once:false): live → OAC's contact call (the wave run's lead — before * the first hostile lands — or the ?combat pack's spawn); over (the * last hostile down) → OAC's sign-off (its delayMs, 1 s, lands it * after the final explosion's read). The hub's in-flight guard + the * played ledger keep each flip to one voice. A NEUTRAL-ONLY encounter * (neutral — the seam's second argument: escorts in, zero hostiles) * swaps the soundtrack for the neutral hum INSTEAD, and OAC's * hostile-signature beats stay silent (there's no hostile signature — * the pilot's own scan comms carry the encounter). * @param {boolean} active * @param {boolean} [neutral] the live roster is a neutral-only encounter */ _setCombatBar(active, neutral = false) { if (this.actionBar) this.actionBar.setVisible(!active); if (this.combatBar) this.combatBar.setVisible(active); this.setCombatMusic(active, neutral); // the soundtrack swap (combat/neutral ↔ deep-space) if (active) { this._combatWasNeutral = neutral; if (!neutral) this.comms?.trigger('combatStart'); } else { if (!this._combatWasNeutral) this.comms?.trigger('combatEnd'); this._combatWasNeutral = false; } } /** * 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}")`); } } /** * The effects demo's archetype (js/visuals/SystemEffects.js): read the * `?fx=` URL parameter (browser only — no-op in Node/tests) and keep it * only if it names a known system type (data/systems.json → types), * matched case-insensitively (so `?fx=redDwarf` resolves to the camelCase * `redDwarf` key). `?fx` alone defaults to `nebula`. The live families * today: nebula, redDwarf, binary. * Example: http://localhost:8080/?fx=redDwarf * * @returns {string | null} a systems.types key, or null */ _readFxSystemType() { const loc = typeof globalThis !== 'undefined' ? globalThis.location : null; if (!loc?.search) return null; try { const raw = (new URLSearchParams(loc.search).get('fx') ?? '').trim() || 'nebula'; const types = config.section('systems.types', {}); if (!types || typeof types !== 'object') return null; // Case-insensitive match on the config keys, so `?fx=redDwarf`, // `?fx=reddwarf`, `?fx=REDWARf` all resolve to the `redDwarf` type. const wanted = raw.toLowerCase(); for (const key of Object.keys(types)) { if (key.toLowerCase() === wanted) return key; } return null; } catch { return null; } } /** * The world preview's spec (js/galaxy/TypeSystems.js): read the * `?type=` URL parameter (browser only — no-op in Node/tests) and * parse it into a { kind, cls?, frame? } spec — a planet class * (terran/rocky/gas/ice/lava) with an optional 1-based face ordinal * (terran-02 = the 2nd face of the class's pool), or a station * (ss / ss-NN variant). Absent → null (no preview); malformed → * null (with a note — `this._typeSpecRaw` keeps the raw value for * the message). Case-insensitive throughout. * * Examples: http://localhost:8080/?type=terran-01 …or ?type=ss-02 …or ?type=ice-03 * * @returns {object | null} a parseTypeSpec() result, or null */ _readTypeSpec() { this._typeSpecRaw = null; const loc = typeof globalThis !== 'undefined' ? globalThis.location : null; if (!loc?.search) return null; try { const raw = new URLSearchParams(loc.search).get('type'); if (raw === null) return null; this._typeSpecRaw = raw; const spec = parseTypeSpec(raw); if (!spec) { const classes = Object.keys(config.get('planets.frames', {})).join('/'); console.warn(`[orbit] ?type=${raw} — unknown world (try ${classes} or -NN faces) or station (ss / ss-NN).`); } return spec; } catch { return null; } } /** * The world preview's TARGET entity (the planet/station the ship * spawns beside) — resolved from this.typeTarget now that the * system's entities exist. null = no preview target (or the entity * is missing — the home-world spawn fallback applies). */ _previewEntity() { const t = this.typeTarget; if (!t) return null; if (t.station) { // Up to two deep-space stations per system — match the entity // to the settlement record by id (name-safe). const rec = (this.systemContent?.settlements ?? []).find((s) => s?.kind === 'deepSpaceStation'); if (!rec) return null; return (this.systemStations ?? []).find((s) => s.settlement?.id === rec.id) ?? null; } if (t.ordinal === 0) return this.planet ?? null; // the home world const rec = (this.systemContent?.planets ?? [])[t.ordinal - 1]; if (!rec) return null; return (this.systemPlanets ?? []).find((p) => p.discoveryName === rec.name) ?? null; } /** * Top-left HUD: the current system's dossier — the system name, its * identity (" system · star "), and its standing: faction * control (Neutral for now) + total population ("Unclaimed" when none). * Deliberately compact — the per-settlement and gate detail lives * elsewhere (star chart, map, the content itself). * 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 (status 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) ---------------------------------- 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: '#ffffff', letterSpacing: 1 }); y += 2; line(report.status, { fontFamily: fam, fontSize: '13px', color: toCss(report.statusColor, '#8fa0c9') }); 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 QUEST TRACKER — the thin quest list right below the name -- // Slides in from offscreen-left the moment the dossier has FOLDED // (the one-shot auto-fold, or the player's manual fold — the first // fold arms it; visible ⇔ the dossier is folded). It is a VIEW over // the same live split as the console (questSnapshot — the checklist // ticks over as research/mining/builds/discovery complete) and the // same ledger (questState — the ONE priority quest). updateHud shows // it when a collapse lands; startExpand slides it out first (make // room); GameScene.update drives its per-frame poll. this.questTracker = new QuestTrackerHud(this, { anchorX: X, anchorY: 40, // just below the dossier title (where the details start) getSnapshot: () => this.questSnapshot(), resolvePriority: (ids) => this.questState.resolvePriority(ids), onSetPriority: (id) => this.setPriorityQuest(id), onClearPriority: () => this.clearPriorityQuest(), onViewQuest: (id) => this.openQuestAt(id), onShowMore: (id) => this.openQuestAt(id), onClaim: (id) => this.claimQuest(id), }); } /** * 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 (status → * 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 // The dossier has folded — the tracker slides in from offscreen // left (the first fold arms it; a later fold re-shows it after // startExpand slid it out to make room). this.questTracker?.show(this.time.now); } 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 → status) — the same decode as * arrival, just without the name (it never left). */ startExpand(t) { const begin = (tt) => { 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, tt + HUD_EXPAND_LEAD + i * HUD_STAGGER, decodeDur(ln.value.length)), })), }; }; const trk = this.questTracker; if (trk && (trk.isBusy() || trk.shown)) { // Make room FIRST: the tracker slides offscreen-left, then the // dossier unfolds (its lines would paint straight over the list). trk.hide(this.time.now, () => begin(this.time.now)); } else { begin(t); } } /** * Fold the details: the lines DECONSTRUCT in reverse build order (status * 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; if (this.questTracker && this.questTracker.isBusy()) return; // a slide is in flight — let it land 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.comms?.pump(_time); // comms: queue → play (and its pause contract) this.levelMarquee?.update(_time, delta); // the level-up beat (drives its own timeline) // The ACK pause contract (the CommsHub froze the action): the ship // holds station, the research/build ticks wait (their deadlines are // time-based — a project that finishes while the box holds completes // on the first tick after, its toast landing when the action resumes), // and discovery is off (the ship isn't moving). The world KEEPS // breathing — starfield, tether, the comm's own clip — only the // player's action is paused. const _commsPaused = this.comms?.paused === true; // The level-up marquee's freeze (js/ui/LevelUpMarquee.js): the beat // owns the screen, so the action holds while it plays — the same // rule as the ack pause, one frame of the world standing still. // The JUMP CLIP's freeze (GameScene._playJumpClip): the transport // owns the screen (opaque backdrop + the gate video) for the clip's // length, so the action holds the same way — the ship parks, the // fight waits, the ticks stand down — and restart() lands the // destination the moment it ends (the deadlines are time-based, so // a project that finished mid-clip completes on the first tick // after, exactly like the ack pause's completion path). // The CLASS CONSOLE's freeze (js/ui/ClassWindow.js): picking a class is a // decision, not an overlay on top of the fight — the world holds exactly // the way it does for a comm's ACK and the level-up marquee. // The SKILL TREE console's freeze (js/ui/SkillWindow.js): ranking skills // and binding seats is a decision the same way — the world holds behind // it (the cabinet idiom), and its clicks never steer the ship. const _frozen = _commsPaused || this.levelMarquee?.active === true || !!this._jumping || this.classWindow?.isOpen === true || this.skillWindow?.isOpen === true || this._gameOver === true; // the Game Over beat owns the screen (the run is over) // The freeze's PARKING (the ack pause's onPause is the same contract // for its own freeze): while ANY freeze holds, the action is parked // dead EVERY frame — the ship (target + throttle cleared, velocity // and acceleration zeroed) and the combat actors' bodies. The Arcade // world keeps stepping through a freeze (only the drivers — // ship.update / combat.update — stand down), so anything that still // injects motion (a coasting body, a collision response, a body that // was set after the freeze began) is killed the very next frame. // Resuming a freeze needs no work (the ship stays parked; the next // input steers it again — the ack pause's same rule). // The skill console repaints on its own signature (points spent, a seat // bound) — including while the world is parked behind it. if (this.skillWindow?.isOpen) this.skillWindow.refresh(); if (_frozen) this._parkForFreeze(); // The deck goes to STANDBY while an 'ack' comm holds (the pause // contract's face): the slots dim and stop hovering/pressing, so the // pulsing ACKNOWLEDGE button is the only live control on screen. // setEnabled is a no-op when the state is unchanged (per-frame free), // and re-arms the frame the comm ends. this.actionBar?.setEnabled(!_frozen); this.updateHud(_time); // the dossier: decode, caret, auto-fold, toggle this.systemEffects?.update(_time); // the star's character (wave phase + star's screen UV) this.questTracker?.update(_time); // the tracker: live checklist + priority if (!_frozen) this.ship.update(_time, delta); // COMBAT (the ?combat demo): the pack's AI + lances, the ship's // auto-fire, the tracers, the rams, the player's armor — frozen by an // ACK comm's pause, like the ship. if (this.combat) { // The settle window (above): one-shot re-anchor of the pack while // the ship's start position settles; a no-op once anchored. if (this._combatSettleFrames > 0) { this._combatSettleFrames--; if (this.combat.resyncAroundShip()) this._combatSettleFrames = 0; } if (!_frozen) { this.combat.update(_time, delta); this.duelPicker?.update(_time); // the faction duel's picker (hidden while its fighter is up) this.combatBar?.setStats(this.combat); this.combatHud?.update(_time); // the readability layer (lock/threat/feed/flash) // THE ENCOUNTER DIRECTOR (data/combat.json → encounters): a hit // returns the composition to spawn — off-screen, streamed in by // their own AI (the clock is pure; the scene stays the spawner). // FACTION SPACE: the context (the local faction + its rivals, // with their rosters) turns the roll into the local faction AND // one opposing faction — never pirates or neutrals — and // ctx.faction takes the encounters.faction timers (the tighter // cadence in territory). See _spawnFactionEncounter. const fctx = this._encounterContext(); const comp = this.encounters.update(_time, { allowed: encounterAllowed({ ship: this.ship, isHomeSystem: this.isHomeSystem, faction: this.systemRecord ? this.systemRecord.faction : null, factionContext: fctx, waveRunLive: !!this.combat.waveRun && this.combat.waveRun.state !== 'done' && this.combat.waveRun.state !== 'cancelled', playerDown: this.combat.playerDown, }) && !this._encountersLockedByQuest(), // the 'Special Delivery' hold context: fctx, faction: fctx != null, live: this.combat.active, xpLevel: this.combat.xp.level, }); if (comp) this._spawnEncounter(comp); } } this.engineFx?.update(_time, delta); // burn fades even through a pause // 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). // Paused by an ACK comm (above) — the tick resumes after, and the // completed project's effects land then (the toast, the deck bar). const _researchDone = !_frozen && this.researchState ? this.researchState.tick(_time) : null; if (_researchDone?.length) { for (const d of _researchDone) this._completeResearch(d.category, d.id); } // The starter quest's pointing finger — applied before the surfaces // draw (their update loops paint the rim's pulse from the target). this._applyTutorialHighlight(); this.researchWindow?.update(_time); // the console's living details (open state) this.mapWindow?.update(_time); // the map console: reveal, decodes, sweep, ship marker, chart poll this.questWindow?.update(_time); // the quests console: reveal, decodes, sweep, dossier poll this.shipWindow?.update(_time); // the ship console: reveal, decodes, sweep, telemetry poll this.classWindow?.update(_time); // the class console: the brief's typing, feed sweep, decodes this._classBeat(); // THE LEVEL-5 DOOR: opens when combat goes quiet this._skillDoorBeat(); // ?skilltree asks for the console once the deck is calm this._deckResearchBar(_time); // the RESEARCH slot's progress bar this._applyResearchCue(); // the RESEARCH slot's ready-shimmer (the deck's half) // 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 && !_frozen) { 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); // The route's NEXT STOP in this system wears the route pulse // (js/entities/RoutePulse.js — the in-world "your destination is // HERE" mark, visible the moment the target is on screen; the // compass's arrow is the off-screen half), in the mark's color — // the route's ORANGE, or BROWN for the player's own cargo delivery // (_routeMarkColor): in an intermediate // system it is the system's JUMP GATE toward the destination // (routeNextGate — the FULL pulse, route.pulse); in the DESTINATION // system it is the destination OBJECT (a planet or a space station — // the subdued pulse, route.objectPulse; _routeDestinationEntity). // Every other object drops it. Both marks are null when no // destination is set or the route is done. const _routeGate = this.routeNextGate(); const _routeDest = this._routeDestinationEntity(); const _markColor = this._routeMarkColor(); // the pulse follows the mark (cargo brown / route orange) if (this.planet) this.planet.setRouteTarget(this.planet === _routeDest, _markColor); for (const p of this.systemPlanets) p.setRouteTarget(p === _routeDest, _markColor); for (const st of this.systemStations) st.setRouteTarget(st === _routeDest, _markColor); for (const gt of this.systemGates) gt.setRouteTarget(gt === _routeGate, _markColor); for (const p of this.systemPlanets) p.update(_time); // the route pulse (a no-op when unmarked) for (const st of this.systemStations) st.update(_time); // the ring turns, the beacon breathes, the route pulse for (const gt of this.systemGates) gt.update(_time); // the swirl spins + breathes (active gates); fallback art drifts this.mining.update(_time, delta); // the arm: extending → beam (tracks the drifting rocks) if (this.scanObjects) this.updateScanHits(_time); // the front crossing objects → ring this.scanPulse.update(_time, delta); // the pulse: charge → front → absorbed → settle this.updateSignalCompass(_time); // the secondary compass: bearing signals on the ring this.updateCamera(delta); this.starfield.update(); // after the camera, so it sees this frame's motion this.nebula?.update(); // the gas: parallax + slow self-swirl this.haze?.update(_time, delta); // redDwarf red haze: follow the camera this.voidBase?.update(_time, delta); // binary void base: follow the camera this.storm?.update(_time, delta); // habitable storm: parallax + charge/strike/afterglow this.nebulaLightning?.update(_time, delta); // nebula: the far-sky bolts (strobe + draw/clear) this.embers?.update(_time, delta); // redDwarf ember dust: drift + parallax this.wanderer?.update(_time, delta); // binary companion star: slow wander 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._checkDestinationReached(); // last leg done? clear the destination (REACHED) // 'Pirate Problems' step 2 — the AMBUSH's arm: with the quest live // and its combat step open, the pirate wave starts the moment the // ship is within HALF the entry-gate → capital-world stretch — the // conflict begins halfway to the world from the jumpgate (the // method is no-op-gated, so the per-frame cost is a few ledger // reads + one distance check). this._maybeStartPirateAmbush(); // 'Pirate Problems' step 3 — the RETURN's witness: the ship at the // capital world's rim with the quest held + unclaimed sets // story.capitalReturned (monotonic; _maybeWitnessCapitalReturn is a // no-op once it stands, so the per-frame cost is a ledger read). this._maybeWitnessCapitalReturn(); this._drawSideQuestMarkers(_time); // the active contract's beacons (patrol or hunt — the route record's kind picks) this.updateSideQuestRoute(); // its check-ins + engagements (the compass + beacons read the fresh ledger this frame) if (!_frozen) this.updateDiscovery(_time, delta); // last: sees this frame's final camera view } /** * Park the action for a frozen frame (the level-up beat, the jump * clip, the ack comm's pause — all three gate on _frozen in update * above, and all three need this): the ship dead (stop() clears its * target + throttle and zeros velocity/acceleration), and the combat * actors' bodies zeroed too. The Arcade world still steps while the * drivers (ship.update / combat.update) stand down, so un-parked * bodies would keep gliding through the beat/clip — the visible * "the action isn't actually paused" drift. Cheap + idempotent, so it * runs every frozen frame (a velocity set mid-freeze dies next frame); * resuming a freeze needs no work (the next input steers again). */ _parkForFreeze() { try { this.ship?.stop?.(); } catch { /* the ship is optional (a scene mid-setup) — the park stands or falls with it */ } const c = this.combat; if (!c) return; for (const list of [c.enemies, c.allies]) { for (const a of list ?? []) { const b = a?.body; if (!b) continue; if (typeof b.setVelocity === 'function') b.setVelocity(0, 0); if (typeof b.setAcceleration === 'function') b.setAcceleration(0, 0); } } } /** * 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]; assignUi(this, g1); // the UI pass (no double-draw while the split is live) assignUi(this, 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 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 }; // COMBAT LIVE (a hostile up, or a wave run in flight — // this.combat.active, the same predicate that raises the combat bar): // the edge ARROWS and name BOXES yield to the fight — the combat // chrome (threat markers, off-screen hostile arrows, the kill feed) // owns the screen edge while the player is shooting, and the nav // chips would only compete with it. refresh([]) retires the entries; // the first non-combat frame re-feeds them — the target set is // recomputed here every frame, so nothing is lost (a mid-fight // discovery still pings above; only the compass display steps aside). if (this.combat?.active) { this.compass.refresh([], view, this.scale.width, this.scale.height, time, delta, this.ship); return; } // The active route's NEXT STOP. In an intermediate system it is that // system's jump gate toward the destination (routeNextGate); in the // DESTINATION system it is the destination OBJECT itself (the last hop // is across the system to the world — routeDestinationObject). It // claims a compass slot in the mark's color — the route's ORANGE // (data/gates.json → route.compassColor), or BROWN (route.cargoColor) // when the destination is the player's own cargo delivery — so the // player always knows where to fly next — and is shown EVEN while // still undiscovered (it is the thing to find). That object's ordinary // (cyan/green) entry is suppressed so the two don't stack; when it is on // screen there is no arrow at all (the player can see it) and the // suppress is moot. The destination object stays the mark's color // until the ship actually reaches it (_checkDestinationReached). const routeColor = this._routeMarkColor(); // the mark's accent (cargo = brown, else the route's orange) const routeGate = this.routeNextGate(); const routeGateId = routeGate?.discoveryId ?? null; const routeOffscreen = !!( routeGate && !circleInView(routeGate.x, routeGate.y, routeGate.bound, view) ); // The final stop: only meaningful in the destination system (no next // gate). It is the object the player set as the destination. const destObj = routeGate ? null : this.routeDestinationObject(); const destObjId = destObj?.id ?? null; const destOffscreen = !!(destObj && !circleInView(destObj.x, destObj.y, destObj.radius, view)); // The active PATROL's next stop (data/sidequests.json → patrol.compass — // the YELLOW arrow, its own color off the route gate's orange): // the NEXT unvisited waypoint (the route is a path — 1, 2, 3, …). // In the route's own system it is the // waypoint itself (plain coordinates — no discovery entry to // suppress, and only off-screen: on screen the beacon is visible); // in another system it is the jumpgate that jumps there — the yellow // takes the gate's slot, so the gate's ordinary cyan entry is // suppressed (the route gate's rule, a different color). const patrolT = this.activeRouteCompassTarget(); const patrolGateId = patrolT?.kind === 'gate' ? patrolT.gate?.discoveryId : null; // The ESCORT's destination world (its magenta arrow takes the world's slot — // the route gate's suppression rule). const patrolDestId = patrolT?.kind === 'destination' ? patrolT.objectId : null; const offscreen = []; for (const o of objects) { if (o.id === routeGateId) continue; // the route arrow takes this slot if (o.id === destObjId) continue; // the destination arrow takes this slot if (o.id === patrolGateId) continue; // the patrol arrow takes this slot if (o.id === patrolDestId) continue; // the escort destination arrow takes this slot if (this.discovery.isDiscovered(sysId, o.id) && !circleInView(o.x, o.y, o.radius, view)) { offscreen.push(o); } } if (routeOffscreen) { offscreen.push({ id: `route:${routeGate.discoveryId}`, x: routeGate.x, y: routeGate.y, radius: routeGate.bound, typeLabel: config.get('gates.route.typeLabel', 'Route Gate'), color: routeColor, name: routeGate.discoveryName, // The "next gate" is the one thing the player must find — keep it at // the full readout (arrow at scale 1 + type + name) no matter how far // it sits, and resolvable by autopilotTo (the `route:` id). alwaysFull: true, }); } if (destOffscreen) { offscreen.push({ id: `dest:${destObj.id}`, x: destObj.x, y: destObj.y, radius: destObj.radius, typeLabel: config.get('gates.route.destTypeLabel', 'Destination'), color: routeColor, name: destObj.name, // The final stop — same always-on treatment as the next gate. alwaysFull: true, }); } if (patrolT) { if (patrolT.kind === 'gate' && patrolT.gate && !circleInView(patrolT.gate.x, patrolT.gate.y, patrolT.gate.bound, view)) { offscreen.push({ id: `patrol:gate:${patrolT.gate.discoveryId}`, x: patrolT.gate.x, y: patrolT.gate.y, radius: patrolT.gate.bound, // The target carries its OWN colors when it stands (a hunt's arrow is // the data's crimson, a patrol's the yellow); the patrol keys are the // fallback, so an old route reads exactly as it always did. typeLabel: patrolT.typeLabel ?? config.get('sidequests.patrol.compass.typeLabel', 'Patrol'), color: patrolT.color ?? config.get('sidequests.patrol.compass.color', '#ffd60a'), name: patrolT.gate.discoveryName, // The patrol's next gate — the full readout (like the route gate), // autopilot-resolvable (the `patrol:gate:` id's own branch). alwaysFull: true, }); } else if (patrolT.kind === 'destination' && patrolT.objectId && !circleInView(patrolT.x, patrolT.y, patrolT.radius ?? 40, view)) { offscreen.push({ id: `escort:obj:${patrolT.objectId}`, x: patrolT.x, y: patrolT.y, radius: patrolT.radius ?? 40, typeLabel: patrolT.typeLabel ?? config.get('sidequests.escort.compass.typeLabel', 'Escort'), color: patrolT.color ?? config.get('sidequests.escort.compass.color', '#e87cf5'), name: patrolT.name ?? 'DESTINATION', // The escort's final stop — the full readout (like the route's // destination), autopilot-resolvable (the `escort:obj:` id's own // branch: the world's real discovery id, not a waypoint index). alwaysFull: true, }); } else if (patrolT.kind === 'waypoint' && !circleInView(patrolT.x, patrolT.y, 32, view)) { offscreen.push({ id: `patrol:wp:${patrolT.idx}`, x: patrolT.x, y: patrolT.y, radius: 32, typeLabel: patrolT.typeLabel ?? config.get('sidequests.patrol.compass.typeLabel', 'Patrol'), color: patrolT.color ?? config.get('sidequests.patrol.compass.color', '#ffd60a'), name: `WAYPOINT ${patrolT.idx + 1}`, // The patrol's next point — the full readout (the thing to fly), // autopilot-resolvable (the `patrol:wp:` id's own branch). alwaysFull: true, }); } } // The ship's position drives the compass's FAR display (targets // beyond game.discovery.compass.farDistance fold to their compact // chip + shrunken arrow until hovered — js/ui/DiscoveryCompass.js). this.compass.refresh(offscreen, view, this.scale.width, this.scale.height, time, delta, this.ship); } /** The discoverable objects of the system, with compass metadata. */ discoverableObjects() { const out = []; // The central body — the home world in the STARTING SYSTEM ONLY // (discovery id 'home': the chart's central NAV point). Every other // system's center is empty — its star is invisible flavor (content.star // feeds the dossier, not the chart) — so no central NAV point there. if (this.planet) { out.push({ id: 'home', x: this.planet.x, y: this.planet.y, radius: this.planet.radius, typeLabel: config.get('planets.homeTypeLabel', 'Home World'), // The compass accent: the home world is a PLANET (green — // data/planets.json → compassColor, like the system's worlds). color: config.get('planets.compassColor', '#3dff88'), name: this.planet.discoveryName, }); } for (const p of this.systemPlanets) { out.push({ id: p.discoveryId, x: p.x, y: p.y, radius: p.radius, typeLabel: config.get(`planets.typeLabels.${p.name}`, p.name), // The compass shows planets in green (data/planets.json → // compassColor) — the arrow + name tag pick it up. color: config.get('planets.compassColor', '#3dff88'), name: p.discoveryName, }); } // Asteroid clusters count as objects too: discoverable, compass arrows, // autopilot — at the scale of their extent (bound). for (const c of this.asteroidClusters) { out.push({ id: c.discoveryId, x: c.x, y: c.y, radius: c.bound, typeLabel: config.get('asteroids.typeLabel', 'Asteroid Cluster'), // The compass shows clusters in their own light gray (their own // accent — data/asteroids.json → compassColor) — the arrow + // name tag pick it up. color: config.get('asteroids.compassColor', '#c8d2e0'), name: c.discoveryName, }); } // Space stations are objects too: discoverable, compass arrows, // autopilot — and comms targets — at the scale of their keepout. for (const st of this.systemStations) { out.push({ id: st.discoveryId, x: st.x, y: st.y, radius: st.bound, typeLabel: config.get(`settlements.kinds.${st.kind}.label`, 'Station'), // The compass shows stations in bright purple (data/stations.json → // compassColor) — the arrow + name tag pick it up. color: config.get('stations.compassColor', '#c84dff'), 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) { if (this._commInputBlocked()) return; // the comm owns the screen (the ship holds) // 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; } // A ROUTE compass entry carries a prefixed id (see updateDiscovery): // `route:` for the orange next-gate, `dest:` for the // orange final stop. Resolve either to the object's real discovery id so // both can be autopiloted like any other off-screen object. let effectiveId = id; if (typeof id === 'string') { if (id.startsWith('route:')) effectiveId = id.slice('route:'.length); else if (id.startsWith('dest:')) effectiveId = id.slice('dest:'.length); else if (id.startsWith('escort:')) { // The ESCORT's destination world (`escort:obj:` — the // world's real discovery id, resolved like `route:`/`dest:`): // autopiloting it by the `patrol:wp:` index shape would have aimed // the ship at the escort's RENDEZVOUS waypoint instead. A payload // that is a content RECORD id (an entry stood before the // discoveryId fix, or a stale session) is mapped onto the world's // discovery entry here — without it the lookup would miss and the // click would silently no-op. const payload = id.slice('escort:'.length); if (payload.startsWith('obj:')) { const oid = payload.slice('obj:'.length); const objs = this.discoverableObjects(); const recs = [ ...(Array.isArray(this.systemContent?.planets) ? this.systemContent.planets : []), ...(Array.isArray(this.systemContent?.settlements) ? this.systemContent.settlements : []), ]; const rec = recs.find((r) => r && String(r.id) === oid); const hit = objs.find((v) => v.id === oid) ?? (rec ? objs.find((v) => v.name === rec.name) : undefined); effectiveId = hit ? hit.id : oid; // no hit → the generic lookup below misses → no-op } } else if (id.startsWith('patrol:')) { // The active PATROL's compass entry (see updateDiscovery): // `patrol:gate:` — the yellow route gate in ANOTHER // system (the gate's own autopilot flies it — the orange route // gate's rule); `patrol:wp:` — the yellow next-waypoint (plain // coordinates, NOT a discovery object: the aim clamps to the // tether union — the ship flies as far as its tether lets it, // the waypoint's radius is generous enough to land the check-in). const payload = id.slice('patrol:'.length); if (payload.startsWith('gate:')) { this.autopilotTo(payload.slice('gate:'.length)); return; } if (payload.startsWith('wp:')) { const idx = Number.parseInt(payload.slice('wp:'.length), 10); const st = this.sideQuestState; const route = st?.activeId ? st.routeFor(st.activeId) : null; const wp = Number.isInteger(idx) && route && Array.isArray(route.waypoints) ? route.waypoints[idx] : null; if (wp && Number.isFinite(wp[0]) && Number.isFinite(wp[1])) { const aim = this.tetherField.clampPoint(wp[0], wp[1]); this.showTargetMarker(aim.x, aim.y); // the aim feedback this.ship.setTarget(aim.x, aim.y); this.hideHint(); } return; } return; } } const o = this.discoverableObjects().find((v) => v.id === effectiveId) ?? this.discoverableObjects().find((v) => v.id === id); if (!o) return; // The solid body behind this discovery entry (a world, a cluster, or // for a ROUTE gate — the gate itself). Must use `effectiveId`, not the // raw `route:` — otherwise the lookup misses and the aim falls back // to the central body, so the ship flew at the STAR's rim in the gate's // direction instead of to the gate (the "wrong direction" the player hit). const solid = this.solids.find((s) => s.discoveryId === effectiveId) ?? this.solids.find((s) => s.discoveryId === id) ?? this.planet; // Approach point: on the rim (clearance + hull), on the side the ship // is approaching from (center → ship direction) — it ends up facing // the object. If the object is outside the player's tether range, the // aim clamps to the union boundary — the ship flies as far as its // tether lets it (and rests on the line) until the tether grows. const dx = this.ship.x - o.x; const dy = this.ship.y - o.y; let aim = solid.edgePoint(Math.atan2(dy, dx), solid.clearance, this.ship.radius); // Belt and braces: no other solid may own this point either. for (const s of this.solids) aim = s.aimPoint(aim.x, aim.y, this.ship.radius); aim = this.tetherField.clampPoint(aim.x, aim.y); this.showTargetMarker(aim.x, aim.y); this.ship.setTarget(aim.x, aim.y); this.hideHint(); } // ================================================================== // Mining — the ship's energy arm (js/mining/Mining.js, MiningBeam.js) // ================================================================== /** * Which rock (if any) sits under a WORLD point — the click-to-mine * hit test. Nearest rock wins (clusters are far apart; members may * slightly overlap). A few px of slop so smaller rocks are easy to hit. */ rockAt(wx, wy) { let best = null; let bestD = Infinity; for (const c of this.asteroidClusters) { for (const m of c.members) { const d = Math.hypot(wx - m.wx, wy - m.wy); if (d <= m.radius + 8 && d < bestD) { best = { cluster: c, member: m, x: wx, y: wy }; bestD = d; } } } return best; } /** * A rock click: the context menu unfolds right where the player * clicked (js/ui/MiningPopup.js — world-anchored on the asteroid). * The primary button is "Mine Asteroids" — or "Stop Mining" when * this cluster is already the beam's target. */ openMiningMenu(rock, pointer) { const isTarget = this.mining.state === 'mining' && this.mining.cluster === rock.cluster; // The pop-up plays its own ui_window whoosh (MiningPopup.open). this.miningPopup.open(rock.x, rock.y, pointer.y, { name: rock.cluster.discoveryName, primaryId: isTarget ? 'stop' : 'mine', primaryLabel: isTarget ? 'Stop Mining' : 'Mine Asteroids', target: rock, }); this.hideHint(); } /** The pop-up's button presses (MiningPopup → onAction). */ miningAction(id, rock) { this.miningPopup.close(); if (!rock) return; if (id === 'mine') { if (this.mining.state === 'extending') return; // the arm is committed this.mining.begin(rock.cluster, rock.member); } else if (id === 'stop') { this.mining.stop(); } // 'cancel' — just close. } // ================================================================== // Comms — the planet / station comms panel (js/ui/CommsPanel.js) // ================================================================== /** * Which comms object (if any) sits under a WORLD point — the home * world, a system planet, or a space station — hit-tested at the * scale of its keepout circle (radius + clearance, the flyable rim). * Nearest wins (they don't overlap). Rocks are NOT comms objects — * they are the mining flow's (rockAt above). */ worldObjectAt(wx, wy) { let best = null; let bestD = Infinity; // The central body is a comms target only when it IS the home world — // the star (every other system) is not a comms object (like a gate). for (const s of [...(this.isHomeSystem ? [this.planet] : []), ...this.systemPlanets, ...this.systemStations]) { const r = s.radius + (s.clearance ?? 0); const d = Math.hypot(wx - s.x, wy - s.y); if (d <= r && d < bestD) { best = s; bestD = d; } } return best; } /** * The gate under (wx, wy), closest wins — the hit circle is its * keepout (gate radius + ship clearance) plus a touch of pad, the * same spirit as rockAt. (Gates are deliberately absent from * worldObjectAt — they are NOT comms targets.) */ gateAt(wx, wy) { let best = null; let bestD = Infinity; for (const gt of this.systemGates ?? []) { const r = (gt.radius ?? 0) + (gt.clearance ?? 0) + 8; const d = Math.hypot(wx - gt.x, wy - gt.y); if (d <= r && d < bestD) { best = gt; bestD = d; } } return best; } /** * THE JUMP (data/gates.json → jump): click an ACTIVE gate and the * run moves to the connected system, arriving near that system's * RETURN gate (the one pointing back — activated with the gate per * the ACTIVITY rule, its tether anchoring the ship's room to move; * the pure geometry is js/galaxy/JumpTravel.js). With shortcuts OFF * the network is a pure spanning tree, so every jump has a return * gate; the origin fallback below is defensive only (the destination * center is empty — its star is invisible flavor). * * 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; } // COMBAT — a live fight (CombatSystem.active — a wave run in flight, // the 'Pirate Problems' ambush included, or hostiles up) holds the // ship in the system: a jump mid-wave would carry the run out of its // own fight (the wave run is scene-local — the enemies and the // fight's momentum would die with the scene, the run would simply // vanish with the threat). Same flat denial shape as the mining // guard above. if (this.combat?.active) { this.consoleToast( String(config.get('gates.jump.combatToast', 'CANNOT JUMP WHILE UNDER ATTACK')), { glyph: '⌁', glyphColor: toCss(themeColor('neon2', 0xff9b9b)) }, ); return; } // ESCORT CONVOY (data/sidequests.json → escort) — while the convoy is // linked ('transit') or setting down ('delivering'), the jump HOLDS: // the player must have the convoy — the transport + its surviving // escorts — AND this gate ON SCREEN (the convoy is witnessed, not // merely left behind: a jump re-stages the scene and would break the // convoy off mid-contract). 'delivering' holds until the touchdown // closes. Same flat-denial shape as the guards above. { const hold = this._escortJumpHold(gt); if (hold) { const t = config.get( hold === 'delivering' ? 'sidequests.ui.escortDeliveringToast' : 'sidequests.ui.escortJumpHoldToast', {}, ); const fallback = hold === 'delivering' ? 'CONVOY SETTING DOWN — HOLD POSITION UNTIL TOUCHDOWN' : 'CONVOY OUT OF VIEW — BRING THE TRANSPORT, ITS ESCORTS AND THE GATE INTO VIEW TO JUMP'; this.comms?.trigger('escortDeny'); // OAC's denial beat (data/comms.json — the escort-wait voice) this.consoleToast(String(t.text ?? fallback), { glyph: t.glyph ?? '⚓', glyphColor: t.color ? toCss(t.color) : undefined, }); return; } } // FACTION GATE LOCK (data/gates.json → jump.factionLock) — the gate // comm window's grayed REQUEST JUMP + the "under Faction Control" // message are the visible half; this is the scene's own flat denial // on top (the same shape as the mining/combat guards above): a gate // from a neutral system into a faction-controlled one, below the // lock's minLevel. if (this.factionGateLockedFor(gt)) { const t = config.get('gates.jump.factionLock', {}); this.consoleToast( String(t.message ?? 'JUMP GATE IS UNDER FACTION CONTROL. STATUS: LOCKED'), { 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, shortcuts are OFF in // the current config): the destination's origin (its center is empty — // the star is invisible flavor, so this is just open space). const destContent = this.galaxy.ensureContent(destId); // ROUTE BOOKKEEPING — resolve what this hop means for the active route // BEFORE captureState below snapshots the run (so a destination we've // just reached is cleared in the saved state, not resurrected): // arrived-at-destination → clear; on-course → silent; detour → toast. this._routeForJump(from.id, destId); // THE RUN'S FOOTPRINT — record the lane we just traveled + the system // we are entering (the GALAXY tab's brighter lane + charted region; // captured into `rec` right below, so the save carries it). this.usedGates.add(edgeKey(from.id, destId)); this.visitedSystems.add(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; // The AMBUSH's bookkeeping — which gate the player came in through: // the new scene resolves its entry gate as // returnGateFor(content, lastJumpFrom) and measures the 'Pirate // Problems' midpoint from it (GameScene._pirateAmbushEntryGate). // A panel save carries the field through (captureState), so a // reload keeps the same midpoint; a save predating it simply lacks // the field (the nearest-gate fallback). rec.lastJumpFrom = from.id; rec.systemName = dest.name; // SYSTEM ARRIVAL = LANDING — the destination's dock repairs the ship // (hull/shields/energy full) the moment the cut lands. A panel LOAD // never carries the key, so a save's damage survives its load. rec.combatRepair = true; rec.ship = { x: arrival.x, y: arrival.y, heading: arrival.heading, minerals: rec.ship?.minerals ?? 0, // The credit ledger + trade-goods hold ride the jump too (the ship // keeps them — only the position + the system change hands). Without // these the restore below would re-seed 0 credits and an empty hold. credits: rec.ship?.credits ?? 0, cargo: rec.ship?.cargo ?? [], // The unlocked cargo bays ride the jump too (the bar scene's DONE // beat — effects.cargoHold — or a cargo-bay build). Without this the // restore would re-seed the fresh ship's 0 and shed the goods // aboard (the 4-bay hold would die on the first jump). cargoHold: rec.ship?.cargoHold ?? 0, // The per-bay cost ledger rides the jump too — the manifest prices // exits against what was ACTUALLY paid per bay; dropping it would // re-price every bay at the catalog's own value on the way down. cargoCosts: rec.ship?.cargoCosts ?? null, }; rec.tethers = []; // the destination's native set re-forms in create() prepareLoad(this.registry, rec); // The cut: the console line + the sfx, then the restart — short // enough to read, long enough to hear (the scene's TimeClock drives // the delay; update() steps it, see the v4 quirk note there). this._jumping = true; this.ship.stop(); // no steering across the cut this.hideHint(); this.consoleToast( String(config.get('gates.jump.toast', 'JUMP — {dest}')) .replace('{dest}', String(dest.name).toUpperCase()), { glyph: '⌁', glyphColor: toCss(themeColor('neon', 0x00e5ff)), durationMs: 1200 }, ); this.playSfx('discovery'); this._playJumpClip(); } /** * The jump clip — a full-screen one-shot played between the two systems * (data/gates.json → jump.video). The destination is ALREADY staged behind * it (prepareLoad ran in jumpThroughGate), so the moment the clip ends we * restart the scene onto it. Mirrors SurfaceScene's landing clip: play to * completion, with a STALL guard (a clip that never starts must not hold * the jump) and a CAP (duration + margin), both advancing anyway. A * missing/empty clip falls back to the short jumpDelayMs cut. */ _playJumpClip() { this._jumpFinished = false; this._jumpLastClickT = 0; if (!this.hasVideo(JUMP_VIDEO_KEY)) { // No clip (disabled or file missing) — the old short cut. this.time.delayedCall( Number(config.get('gates.jump.jumpDelayMs', 420)) || 420, () => this._finishJump(), ); return; } const W = this.scale.width; const H = this.scale.height; // Opaque backdrop: the source system must not show through the clip (or // its first-frame decode gap). scrollFactor(0) pins it to the SCREEN — // this scene's camera follows the ship and scrolls, so a world-space // overlay (the default scrollFactor 1) would land off-screen; the same // reason every screen-pinned UI here (toast, action bar, HUD) does it. this._jumpBackdrop = this.add .rectangle(W / 2, H / 2, W, H, toColor(themeColor('bg', 0x04060d))) .setScrollFactor(0) .setDepth(60); assignUi(this, this._jumpBackdrop); // the UI pass (no double-draw) // v4: add.video(x, y, key) — the key is the LAST argument (it loads the // cached clip and attaches the