Spaces:
Paused
Paused
Claude
Claude Sonnet 5
T-012: port the single-player client onto session + loopback
42b40d2 unverified Download boot.js from Almaatla/SpaceCities: direct link, hf CLI and curl.
- Browser
- Download file 52.7 kB
-
https://huggingface.co/spaces/Almaatla/SpaceCities/resolve/main/boot.js
- Command line
-
hf download hf://spaces/Almaatla/SpaceCities/boot.js
-
curl -L -o boot.js https://huggingface.co/spaces/Almaatla/SpaceCities/resolve/main/boot.js
52.7 kB
| /* ============================================================ | |
| The running game session: startGame (fresh) and bootState (fresh OR loaded) | |
| wire a state to input, the camera, the fixed-timestep loop and the HUD; the | |
| render loop also pumps this frame's sim events into sound + visual effects and | |
| the under-attack alert. Split out of main.js β `state`/`input` now live on the | |
| shared session (session.js); the loop reads them live so a restart is picked | |
| up automatically (the previous loop is always stopped first). | |
| ============================================================ */ | |
| ; | |
| import { game } from "./session.js"; | |
| import { canvas, ctx, minimapCtx, mapSelectEl, gameOverEl, underAttackEl, pauseBtn, MINIMAP_W, MINIMAP_H } from "./dom.js"; | |
| import { mulberry32 } from "./engine/rng.js"; | |
| import { createLoop } from "./engine/loop.js"; | |
| import { tick } from "./engine/sim.js"; | |
| // ADR-0003/ADR-0004 (T-012): an ordinary skirmish, a live competition fixture, and a loaded | |
| // skirmish save now run their sim behind a real server/session.js session, reached over | |
| // net/loopback.js exactly like multiplayer will reach it over a socket β see startGame and | |
| // startCompetitionMatch below. Odyssey, a scenario/raider/bounty, and a spectated match are | |
| // deliberately NOT ported by this pass (TASKS.md T-012's own scoping: they keep ticking on | |
| // their existing stepGalaxy/tickSelfPlay/tick path); createDirectTransport is what still lets | |
| // their OWN input handling and HUD buttons call transport.submitCommand(cmd) uniformly, same as | |
| // every other boot path, without a session behind them. | |
| import { createSession } from "./server/session.js"; | |
| import { createLoopbackTransport } from "./net/loopback.js"; | |
| import { createDirectTransport } from "./net/directTransport.js"; | |
| // The self-play core (docs/competitions-and-elo.md Phase 0 made it browser-importable): the ONE | |
| // way a spectated match gets both of its seats driven by the real AI β createSelfPlayState builds | |
| // the state with a second controller for owner "player", tickSelfPlay runs it before the ordinary | |
| // tick() that already drives owner "ai". | |
| import { createSelfPlayState, tickSelfPlay, SELFPLAY_HZ } from "./tools/selfplay.js"; | |
| import { archetypeFor } from "./engine/aiArchetypes.js"; | |
| import { isVisibleAt } from "./engine/fog.js"; | |
| import { drawFrame, resetFacing, snapshotPositions } from "./render.js"; | |
| import { drawMinimap } from "./minimap.js"; | |
| import { clampCamera } from "./camera.js"; | |
| import { attachInput } from "./input.js"; | |
| import { addTracer, addDeathFlash, addUnderAttackPing, addFireworks, addExplosion, addFuseWarning, activePings, resetEffects, DEATH_BASE_RADIUS } from "./effects.js"; | |
| import { UNITS, BUILDINGS } from "./engine/entities.js"; | |
| import { renderHUD, resetPanelSignature } from "./hud.js"; | |
| import { observedState, exitObserverMode, enterObserverMode } from "./observer.js"; | |
| import { renderObserverPanel } from "./observerPanel.js"; | |
| import { showObjectives, hideObjectives, showSeedChip, showFactionChip, showGameOver, showScenarioEnd, showGalaxyToast } from "./overlays.js"; | |
| import { renderMapSelect, setup, DIFFICULTY_OPTIONS } from "./setup.js"; | |
| import { captureCompetitionResult, spectatedGameOverBlock } from "./competition.js"; | |
| import { setupEscort, setupRaider, setupBounty } from "./engine/scenarios.js"; | |
| import { createGalaxy, activeState, jumpCapital, sweepColonies, stepGalaxy, surrenderGalaxy, DOMINATION_TARGET, playerSpaceports, previewPlanet, canJump, canJumpTo, jumpCost } from "./engine/galaxy.js"; | |
| import { openLandingPicker } from "./landingPicker.js"; | |
| import { TECHS } from "./engine/techtree.js"; | |
| import { planetName, COM } from "./data.js"; | |
| import * as sound from "./sound.js"; | |
| const UNDER_ATTACK_THROTTLE_MS = 4000; | |
| const UNDER_ATTACK_BANNER_MS = 2500; | |
| // Difficulty β the two AI dials, looked up in setup.js's DIFFICULTY_OPTIONS β the | |
| // single list that also drives the Easy/Medium/Hard picker, so a difficulty key | |
| // can't exist in one list and not the other (previously boot.js kept its own | |
| // separate easy/medium/hard map; if the two ever drifted, an unrecognised key | |
| // silently fell back to Medium instead of erroring). Computed lazily (not at | |
| // module top level) because setup.js and boot.js import each other β by the | |
| // time this runs, both modules have finished loading. | |
| function difficultyDials(key) { | |
| const opt = DIFFICULTY_OPTIONS.find(o => o.mult === key) || DIFFICULTY_OPTIONS.find(o => o.mult === "medium"); | |
| return { aiApm: opt.aiApm, aiMicro: opt.aiMicro }; | |
| } | |
| // Resolve the seed to actually run with: the setup screen's explicit pick, or a | |
| // fresh random one (Math.random is fine here β it's not the sim; everything | |
| // downstream flows from the seeded mulberry32, so same seed => same world), coerced | |
| // to an unsigned 32-bit int either way. One helper for every start* path (skirmish, | |
| // the three scenarios, Odyssey) so they can never disagree on how a seed is drawn. | |
| function resolveSeed(cfg) { | |
| return (cfg.seed != null ? cfg.seed : Math.floor(Math.random() * 0x100000000)) >>> 0; | |
| } | |
| // Runtime bookkeeping β module-local because only the loop / frame-event pump | |
| // touch them (state + input live on the shared session instead). | |
| let loop, announced, lastHud, lastUnderAttackAt, underAttackTimer; | |
| // Highest Antimatter Gate charge milestone (25/50/75/100%) already announced, so the | |
| // wonderCharging event (which fires every tick) toasts once per threshold, not per frame. | |
| let gateMilestone = 0; | |
| // Where the last under-attack alert fired lives on game.lastAttackAt (session.js, next to | |
| // supplyBlockedUntil) rather than a module-local var β input.js's Backspace "jump to last | |
| // alert" key reads it too, not just the banner click below. | |
| if (underAttackEl) underAttackEl.addEventListener("click", () => { | |
| if (!game.lastAttackAt || !game.input || !game.state) return; | |
| const cam = game.input.getCamera(); | |
| cam.x = game.lastAttackAt.x; | |
| cam.y = game.lastAttackAt.y; | |
| clampCamera(cam, game.state.map, canvas.clientWidth, canvas.clientHeight); | |
| }); | |
| // --- pause ------------------------------------------------------------------- | |
| // The sim pauses while a blocking overlay is up (Help, the Home-confirm modal) and on a | |
| // manual P toggle. Reasons are refcounted so closing Help doesn't resume a game the player | |
| // ALSO paused with P. Pausing just gates the update() callback β render keeps drawing the | |
| // frozen frame (and the camera still pans, so you can look around) with overlays on top. | |
| // The loop's own backlog-drop (engine/loop.js: `if (acc > dtFixed) acc = 0`) bounds the | |
| // accumulator while update() is skipped, so no sim time is lost or spiralled on resume and | |
| // the fixed-dt tick sequence β hence replay determinism β is untouched. The PAUSED banner | |
| // (style.css body.paused) shows only for the MANUAL pause; Help/Home carry their own UI. | |
| const pauseReasons = new Set(); | |
| function syncPause() { | |
| const manual = pauseReasons.has("manual"); | |
| document.body.classList.toggle("paused", manual); // the centered PAUSED banner (style.css) β manual only | |
| if (pauseBtn) pauseBtn.textContent = manual ? "βΆ Resume" : "βΈ Pause"; // the topbar affordance (touch has no P key) | |
| } | |
| function clearPause() { pauseReasons.clear(); syncPause(); } | |
| export function pauseLoop(reason = "manual") { pauseReasons.add(reason); syncPause(); } | |
| export function resumeLoop(reason = "manual") { pauseReasons.delete(reason); syncPause(); } | |
| export function togglePause() { | |
| if (!game.state || game.state.over) return; // nothing to pause on the menu or after the match has ended | |
| if (pauseReasons.has("manual")) resumeLoop("manual"); else pauseLoop("manual"); | |
| } | |
| if (pauseBtn) pauseBtn.addEventListener("click", togglePause); // touch-reachable pause (mirrors the P key) | |
| export function startGame(planetId) { | |
| // Seed the sim so the match is reproducible: a player can note the seed and | |
| // re-enter it to replay the exact same map. The seed itself is drawn from the | |
| // UI layer (Math.random is fine here β it's not the sim); everything downstream | |
| // flows from the seeded mulberry32, so same seed β same world. | |
| const seed = resolveSeed(setup); | |
| const diff = difficultyDials(setup.difficulty); | |
| // The player picks their faction; the AI's comes from this world's archetype | |
| // (aiArchetypes.js), so the opponent's identity is part of the world's character. | |
| const aiFaction = archetypeFor(planetId).faction || "neutral"; | |
| // ADR-0003/ADR-0004 (T-012): an ordinary skirmish runs behind a real session now, reached | |
| // through net/loopback.js exactly as multiplayer will reach one over a socket β see boot.js's | |
| // own header comment. session.getState() is the SAME object bootState hands to game.state | |
| // (no copy), so every render/HUD read downstream is unchanged. | |
| const session = createSession({ planetId, seed, rng: mulberry32(seed), | |
| aiApm: diff.aiApm, aiMicro: diff.aiMicro, aiStrategy: setup.aiStrategy, difficulty: setup.difficulty, | |
| sizeMult: setup.sizeMult, resourceMult: setup.resourceMult, swapAsym: setup.swapAsym, | |
| matchTimeLimit: setup.matchTimeLimit, popCap: setup.popCap, | |
| playerFaction: setup.faction, aiFaction }); | |
| bootState(session.getState(), { intro: true, transport: createLoopbackTransport(session) }); | |
| } | |
| // Start one COMPETITION fixture as a real, live skirmish (docs/competitions-and-elo.md Phase 4 β | |
| // the Gauntlet's "Play Next Match"). Deliberately a near-copy of startGame above rather than a | |
| // second code path: same resolveSeed/difficultyDials/createGameState/bootState chain, same loop, | |
| // same HUD β this IS an ordinary skirmish, it just knows which fixture it belongs to. Nothing about | |
| // the running game changes; only `game.competition` (session.js) is set, which the game-over hook | |
| // below reads to route the result into the ledger. | |
| // | |
| // EVERYTHING IS TAKEN FROM THE FIXTURE, not from the setup screen: the world, the exact seed (off | |
| // the gauntlet's own schedule β never a fresh random one, or a resumed run would replay a different | |
| // map, D6), the pinned difficulty, the opponent's own strategy/archetype, the match length, and | |
| // this fixture's swapAsym parity. The human plays owner "player" with their chosen faction, exactly | |
| // as in a normal skirmish (D4: there is no other seat available to them). | |
| // | |
| // The three map dials are PINNED, not read off `setup`: a duel's own createSelfPlayState leaves | |
| // sizeMult/resourceMult/popCap at engine defaults, so every rating already in the ladder was earned | |
| // on that map shape. Letting a player's leftover skirmish preferences (Gigantic, Abundant) shape | |
| // the matches that move THEIR rating in the same bracket would make the two numbers incomparable. | |
| // | |
| // BUT PINNING THOSE DOES NOT MAKE A HUMAN MATCH THE SAME SHAPE AS A SIMULATED ONE, and it would be | |
| // dishonest to imply it does. Three differences remain, by construction, and none of them can be | |
| // closed from here: | |
| // β’ ONE match, not a worlds x seeds x 2 sweep β a human cannot play forty games (Phase 4's | |
| // founding constraint), so a human rating rests on far fewer, noisier samples. | |
| // β’ NO side-swap, because the human can only ever hold owner "player" (D4). That is the seat the | |
| // 13%-of-think-cycles edge is measured AGAINST, and it is the deviation the UI discloses. | |
| // β’ The human HAS a faction and their simulated opponents do not β a self-play match has no | |
| // faction dial at all (see aiFaction below), so a faction's passive traits appear on exactly | |
| // one side of a human match and neither side of every other rated match in the bracket. | |
| // The honest framing, and the one the screen states: a human rating is a rating in the SAME table, | |
| // earned under a documented and bounded set of differences β not a number produced by an identical | |
| // process. Do not add a compensating fudge for any of this; see D4 on why a correction nobody can | |
| // derive is worse than a difference everybody can read. | |
| export function startCompetitionMatch(fixture) { | |
| const { world, seed, difficulty, matchTimeLimit, swapAsym, aiStrategy, aiArchetype, playerFaction, competition } = fixture; | |
| sound.unlockAudio(); // a real user gesture (the Play button), same point setup.js's map cards unlock audio | |
| const resolved = resolveSeed({ seed }); | |
| const diff = difficultyDials(difficulty); | |
| // The opponent's faction comes from the world's archetype, exactly as in a normal skirmish β a | |
| // roster entry's own faction stays what Phase 2 made it (flavour on the row), because a self-play | |
| // match has no faction dial at all, so every AI-vs-AI rating in this bracket was earned without | |
| // one. Giving the human's opponents a faction edge here alone would break that comparison. | |
| const aiFaction = archetypeFor(world).faction || "neutral"; | |
| // Same session/loopback path startGame builds above β this IS an ordinary skirmish (see this | |
| // function's own header comment) sharing the exact same ADR-0003/ADR-0004 machinery. | |
| const session = createSession({ | |
| planetId: world, seed: resolved, rng: mulberry32(resolved), | |
| aiApm: diff.aiApm, aiMicro: diff.aiMicro, aiStrategy, difficulty, aiArchetype, | |
| sizeMult: 1, resourceMult: 1, popCap: null, | |
| swapAsym: !!swapAsym, matchTimeLimit, | |
| playerFaction: playerFaction || setup.faction, aiFaction, | |
| }); | |
| bootState(session.getState(), { intro: true, transport: createLoopbackTransport(session) }); | |
| game.competition = competition; // after bootState, which clears it (see its own line) | |
| } | |
| // WATCH one AI-vs-AI match live (docs/competitions-and-elo.md Phase 5 β competition.js's Quick | |
| // Duel "Watch" button is the only caller). Same relationship to startGame that | |
| // startCompetitionMatch above has: one bootState, one loop, one HUD β what makes this a SPECTATED | |
| // match rather than a played one is two facts parked on the session afterwards. | |
| // | |
| // 1. THE STATE COMES FROM createSelfPlayState, not createGameState. That is what gives owner | |
| // "player" a second AI controller (state.playerAi), and it is the SAME constructor | |
| // tools/duelCore.js's runDuelMatch feeds for every simulated, rated match β so what you watch | |
| // is configured identically to what the Worker would have run: same world, same derived seed, | |
| // same swapAsym parity, the same ONE pinned difficulty dial set on both seats, each entrant's | |
| // own strategy/archetype on its own seat. competition.js's buildWatchConfig shapes that config | |
| // and is unit-tested against the worker's own seed derivation. | |
| // 2. `game.spectateMatch` then makes the loop below call tickSelfPlay instead of tick (so owner | |
| // "player" is actually driven), lets Observer Mode be entered without an Odyssey, and marks the | |
| // match un-checkpointable (saveShape.js's resumableMode). | |
| // | |
| // Observer Mode is entered immediately and deliberately: it is what reveals the fog, gives the | |
| // spectator its own free camera, and β the part that matters most β makes input.js refuse to issue | |
| // a single order (every mouse/wheel/key path already early-returns into observer.js while | |
| // game.observerMode is on). The human watches; they do not play. | |
| // | |
| // EXHIBITION ONLY: nothing here writes to the ledger. `game.competition` stays null, so the | |
| // game-over hook's captureCompetitionResult returns null and no rating moves β see the exhibition | |
| // argument at competition.js's EXHIBITION_NOTE, and the honest game-over copy it feeds. | |
| export function startSpectatedMatch(cfg) { | |
| const { world, seed, swapAsym, matchTimeLimit, ai, playerAi, aName, bName, difficulty, onLeave, recorded } = cfg; | |
| sound.unlockAudio(); // a real user gesture (the Watch button), same point startCompetitionMatch unlocks audio | |
| const fresh = createSelfPlayState({ planetId: world, seed, swapAsym, matchTimeLimit, ai, playerAi }); | |
| // `selfPlay: true` is what makes this the same simulation the Worker runs, not merely the same | |
| // configuration: the loop steps at tools/selfplay.js's own fixed step. It is what a REPLAY needs | |
| // to reproduce its recorded row, and the watched path shares it so "what you watch is what the | |
| // Worker would have run" is true of the run and not just of the config. | |
| bootState(fresh, { intro: false, selfPlay: true }); // no objectives strip: that checklist is a PLAYER's to-do list | |
| // After bootState, which clears both (see its own lines) β exactly the startOdyssey/ | |
| // startCompetitionMatch pattern. | |
| // `recorded` (present only for a REPLAY) is the outcome this re-run is expected to reproduce β | |
| // carried so the spectate bar can say what is being replayed and the game-over screen can state, | |
| // honestly, whether the determinism claim held this time. It is read-only display data; nothing | |
| // in the sim ever sees it. | |
| game.spectateMatch = { aName, bName, world, seed, difficulty, matchTimeLimit, onLeave, recorded: recorded || null }; | |
| game.spectateSpeed = 1; | |
| enterObserverMode(); | |
| // bootState's own renderHUD() ran a frame ago, while the flag above was still null β so the | |
| // selection panel and the topbar chips were built for an ordinary skirmish and their signature | |
| // guard (hudPanelSignature.js) would keep them frozen that way, offering "Select Army" over an | |
| // AI's army. The flag is set exactly once per game, right here, so one forced repaint is the | |
| // whole fix; nothing downstream has to re-check it every tick. | |
| resetPanelSignature(); | |
| renderHUD(); | |
| } | |
| // Start a Convoy Escort scenario on `planetId` at the chosen difficulty. Shares | |
| // the seed/boot machinery with a skirmish; the scenario state carries its own | |
| // objective (engine/scenarios.js), so bootState wires it the same way. | |
| export function startScenario(planetId) { | |
| const seed = resolveSeed(setup); | |
| const fresh = setupEscort({ planetId, seed, difficulty: setup.difficulty, sizeMult: setup.sizeMult }); | |
| bootState(fresh, { intro: false }); | |
| } | |
| // Start a Pirate Raider scenario on `planetId` β the mirror of Escort (you raid | |
| // the AI convoy). Same boot machinery; the scenario carries its own objective. | |
| export function startRaider(planetId) { | |
| const seed = resolveSeed(setup); | |
| const fresh = setupRaider({ planetId, seed, difficulty: setup.difficulty, sizeMult: setup.sizeMult }); | |
| bootState(fresh, { intro: false }); | |
| } | |
| // Start a Bounty Marshal scenario on `planetId` β hunt scattered pirate camps | |
| // across the sector against a clock. Same boot machinery. | |
| export function startBounty(planetId) { | |
| const seed = resolveSeed(setup); | |
| const fresh = setupBounty({ planetId, seed, difficulty: setup.difficulty, sizeMult: setup.sizeMult }); | |
| bootState(fresh, { intro: false }); | |
| } | |
| // Start an Odyssey β the open-world campaign. Builds a galaxy (Phase 1: one | |
| // randomly-chosen starting world), boots its active planet, and parks the galaxy | |
| // on the session so the HUD/credits and the later jump machinery can reach it. | |
| export function startOdyssey() { | |
| const seed = resolveSeed(setup); | |
| const diff = difficultyDials(setup.difficulty); | |
| bootGalaxy(createGalaxy({ | |
| seed, difficulty: setup.difficulty, sizeMult: setup.sizeMult, resourceMult: setup.resourceMult, | |
| playerFaction: setup.faction, aiApm: diff.aiApm, aiMicro: diff.aiMicro, aiStrategy: setup.aiStrategy, | |
| startId: setup.startWorld, popCap: setup.popCap, | |
| }), { intro: true }); | |
| } | |
| // Boot a galaxy (fresh from startOdyssey, or rehydrated from a save by | |
| // saveload.js). bootState clears game.galaxy and rewires input/camera/HUD to the | |
| // active world; the loop reads game.galaxy live, so the background worlds resume | |
| // on their own once it's set right after. | |
| export function bootGalaxy(galaxy, { intro = false } = {}) { | |
| bootState(activeState(galaxy), { intro }); | |
| game.galaxy = galaxy; | |
| } | |
| // Launch an interplanetary jump to `destId` β relocate the capital + staged | |
| // units (engine/galaxy.js), then repoint the running loop at the new world. The | |
| // loop keeps running and keeps ticking the world you left (now a background | |
| // colony), so this only swaps what's rendered and controlled. `landingPoint` | |
| // (world coords) is only ever consulted by jumpCapital when the destination has | |
| // no player Spaceport of its own β see initiateJump below, the picker's own | |
| // caller. | |
| export function performJump(destId, landingPoint) { | |
| if (!game.galaxy) return null; | |
| const result = jumpCapital(game.galaxy, destId, landingPoint ? { landingPoint } : undefined); | |
| if (!result) return null; // couldn't launch (no Spaceport here, or too poor for a new world) | |
| focusActivePlanet(); | |
| return result; | |
| } | |
| // The entry point every "Jump βΈ world" affordance should call (starmap.js, hudSelection.js's | |
| // Spaceport panel, notifyColony's reinforce/retake toasts below) β NOT performJump directly. | |
| // Decides whether the destination needs a player-chosen landing site first: engine/galaxy.js's | |
| // landingZone lands at the player's own Spaceport when one already stands on `destId` (no pick | |
| // needed β it already knows where "home" is there), and otherwise falls back to a point the | |
| // player chooses on a blind minimap (landingPicker.js) β there's no beacon to home in on, and no | |
| // scouted intel to show while picking. A Spaceport-less ORIGIN never needs a pick at all: with | |
| // nothing to load, that's a pure control-switch hop (jumpCapital's header comment) that moves no | |
| // units, so there's nowhere for a landing site to matter. | |
| // | |
| // Runs the same afford/reachability checks jumpCapital itself re-validates at launch (mirroring | |
| // the pre-check every existing call site already did before calling performJump), so a jump that | |
| // plain can't happen still fails fast, synchronously, with no picker ever shown. Returns null on | |
| // that failure, the jump's result object if it launched immediately, or `true` if a picker opened | |
| // (the jump completes later, from the picker's own Confirm button, once the player commits to a | |
| // spot). | |
| export function initiateJump(destId) { | |
| // A real jump changes galaxy.activeId β Observer Mode's whole guarantee is that spectating | |
| // never touches that (see observer.js's header comment), so it's refused here at the single | |
| // choke point every real-jump trigger funnels through (the starmap, a Spaceport's Jump | |
| // button, a colony-alert toast's "jump to defend"), not just the starmap click this file | |
| // also special-cases into a free spectateWorld instead. | |
| if (game.observerMode) return null; | |
| const g = game.galaxy; | |
| if (!g || !canJumpTo(g, destId)) return null; | |
| if (g.credits < jumpCost(g, destId)) return null; | |
| // The destination may still be DORMANT β only a seeded handful of worlds are alive from turn | |
| // one (engine/galaxy.js BACKGROUND_WORLDS), and a first jump to an unvisited world is exactly | |
| // the case this picker exists for. previewPlanet builds what the jump WOULD arrive at without | |
| // adding it to the live set, so opening this modal (or backing out of it) never wakes a world; | |
| // jumpCapital builds the identical state for real once the player commits. | |
| const dest = previewPlanet(g, destId); | |
| const needsPick = canJump(activeState(g)) && playerSpaceports(dest).length === 0; | |
| if (!needsPick) return performJump(destId); | |
| // A right-click-drag (or box-select, or an armed attack-move) started on the origin's canvas | |
| // just before this modal opens is otherwise still "live" β see input.js's cancelGesture β and | |
| // its eventual mouseup, landing anywhere on the picker, would read as a move order on the | |
| // current planet instead of a landing pick. Cancel it before the picker can steal that release. | |
| if (game.input) game.input.cancelGesture(); | |
| pauseLoop("landing-pick"); | |
| openLandingPicker(dest, planetName(destId), { | |
| onPick: point => { resumeLoop("landing-pick"); performJump(destId, point); }, | |
| onCancel: () => resumeLoop("landing-pick"), | |
| }); | |
| return true; | |
| } | |
| // Voluntarily end the Odyssey β the ONLY way it ends (a wipeout just sends relief). Marks the | |
| // active seat over; the render loop's over-poll then shows the game-over (surrender) screen. | |
| export function surrenderOdyssey() { | |
| if (!game.galaxy) return; | |
| surrenderGalaxy(game.galaxy); | |
| } | |
| // Per-world UI bookkeeping that must NOT carry across a world change (a fresh boot or a | |
| // jump): the Gate-progress high-water mark, the remembered under-attack hit + banner, and | |
| // the supply-warning window. Shared by bootState and focusActivePlanet so the next field | |
| // added can't drift between them β the bug this fixes was focusActivePlanet resetting none | |
| // of it, so after a jump the old world's Gate% swallowed the new world's toasts and the | |
| // under-attack click panned to stale coordinates on the wrong map. | |
| function resetWorldUiBookkeeping() { | |
| gateMilestone = 0; | |
| game.lastAttackAt = null; | |
| lastUnderAttackAt = -Infinity; | |
| game.supplyBlockedUntil = 0; | |
| underAttackEl.classList.add("hidden"); | |
| clearTimeout(underAttackTimer); | |
| } | |
| // Repoint the view/input at the galaxy's active planet without restarting the | |
| // loop (used after a jump). Mirrors bootState's per-state wiring, minus creating | |
| // the loop and minus touching game.galaxy. | |
| function focusActivePlanet() { | |
| const state = activeState(game.galaxy); | |
| game.state = state; | |
| // A fresh directTransport per jump: each planet in the galaxy owns its own `state` object, and | |
| // this adapter just closes over one (see net/directTransport.js's header) β it has no session | |
| // to carry across a jump, unlike the loopback path startGame/startCompetitionMatch build below. | |
| game.transport = createDirectTransport(state); | |
| if (game.input) game.input.destroy(); | |
| game.input = attachInput(canvas, state, game.transport, () => renderHUD()); | |
| const cc = [...state.buildings.values()].find(b => b.owner === "player" && b.type === "command"); | |
| const openAt = cc || state.map.bases.player; | |
| const cam = game.input.getCamera(); | |
| cam.x = openAt.x; | |
| cam.y = openAt.y; | |
| clampCamera(cam, state.map, canvas.clientWidth, canvas.clientHeight); | |
| resetEffects(); | |
| resetFacing(); | |
| resetPanelSignature(); | |
| resetWorldUiBookkeeping(); // don't carry the previous world's Gate%, under-attack hit, or supply window | |
| // The player is physically here now β any starmap alert badge for THIS world is stale. Only | |
| // this one entry: game.colonyAlerts tracks every held colony at once (unlike the per-world | |
| // bookkeeping resetWorldUiBookkeeping just cleared above), so a jump here must never blow away | |
| // what's still burning on a world the player hasn't looked at yet. | |
| delete game.colonyAlerts[state.planetId]; | |
| showSeedChip(state.seed); | |
| showFactionChip(state); | |
| renderHUD(); | |
| } | |
| // Re-open the map-select screen (the game-over "choose another battlefield" | |
| // button, passed into overlays' showGameOver so that module needn't import setup; | |
| // also the topbar Home button via saveload.js). Stops the running loop AND tears the | |
| // session down: without clearing game.input/state/galaxy the old game stayed live behind | |
| // the menu β its window-level hotkeys kept firing, the M key reopened the now-dead | |
| // Odyssey starmap, and the timer/beforeunload autosave kept writing (so "Exit without | |
| // Saving" saved anyway). destroy() aborts the input listeners; nulling the session makes | |
| // snapshot() a no-op and the M-key/hotkey gates false. Every other game.state reader | |
| // (minimap handlers, under-attack click, save/repair buttons) already null-guards. Callers | |
| // that need to persist first (Save & Exit) run autoSave BEFORE this. Idempotent. | |
| export function restartToMapSelect() { | |
| if (loop) loop.stop(); | |
| if (game.input) { game.input.destroy(); game.input = null; } | |
| exitObserverMode(); // a dangling spectateId into a galaxy that's about to be nulled would wedge the next game's input guards | |
| game.state = null; | |
| game.galaxy = null; | |
| // No game, no fixture in play. Every path that leaves a LIVE competition match has already | |
| // settled it before reaching here β the game-over hook records its result, saveload.js's Home | |
| // confirm forfeits it β so this is the belt-and-suspenders clear, not the one that decides. | |
| game.competition = null; | |
| // β¦and no watched match either. Nothing to settle for a spectated one (it is exhibition-only and | |
| // writes nothing), but leaving it set would tell the NEXT game's loop to drive owner "player" | |
| // with an AI controller that state doesn't have, and would keep offering Observer Mode in an | |
| // ordinary skirmish. Same dangling-session hazard as the spectateId exitObserverMode clears above. | |
| game.spectateMatch = null; | |
| game.spectateSpeed = 1; | |
| // Repaint the observer UI once, now that all of the above is false: the banner, the spectate bar | |
| // and the stats panel are only ever hidden by this function, and the render loop that normally | |
| // calls it has just been stopped. Without this they survive as stale elements β covered by the | |
| // full-screen map-select, but still live, and still there under the NEXT game until its first | |
| // HUD tick happens to hide them. The same "don't leave the session half torn down" reasoning as | |
| // the exitObserverMode call above. | |
| renderObserverPanel(); | |
| clearPause(); // leaving a game clears any pause + the PAUSED banner | |
| pauseBtn.classList.add("hidden"); // β¦and the topbar pause control (no game to pause) | |
| renderMapSelect(); | |
| mapSelectEl.classList.remove("hidden"); | |
| } | |
| // The sim rate ordinary play has always run at β engine/loop.js's own default, restated here | |
| // because bootState now chooses between two rates and a caller reading this file should see both | |
| // numbers side by side rather than one of them hiding in a default parameter. | |
| const PLAY_HZ = 20; | |
| // Wire a state β freshly created OR loaded from a save β to input, camera, the | |
| // fixed-timestep loop, and the HUD. The single boot path both startGame and | |
| // loadGame funnel through. | |
| // | |
| // `selfPlay` (docs/competitions-and-elo.md Phase 5) runs the loop at tools/selfplay.js's own fixed | |
| // step instead of PLAY_HZ. It is set for the one kind of game whose tick sequence has to MATCH | |
| // something already simulated β a watched or replayed AI-vs-AI match β because a fixed step is the | |
| // simulation, not a tuning knob: same seed, different step, different game (see SELFPLAY_DT's own | |
| // comment for the measured proof). Every other boot path is untouched and still runs at PLAY_HZ. | |
| // `transport` (T-012): the real session-backed loopback transport, when the caller already built | |
| // one from a server/session.js session (startGame, startCompetitionMatch, and saveload.js's | |
| // skirmish load β see each). Every OTHER boot path (Odyssey's own bootGalaxy->bootState, a | |
| // scenario/raider/bounty, a spectated match) leaves this null: bootState then falls back to | |
| // wrapping `newState` in a directTransport for input/HUD routing only, and β critically β the | |
| // loop below keeps ticking `newState` on its OWN existing path (galaxy/spectate/tick) instead of | |
| // calling transport.tick(dt), because a directTransport owns no ticking at all (net/directTransport.js). | |
| export function bootState(newState, { intro, selfPlay = false, transport = null }) { | |
| if (loop) loop.stop(); | |
| if (game.input) game.input.destroy(); | |
| exitObserverMode(); // fresh/loaded game β fresh session, same reasoning as game.groups/colonyAlerts below | |
| mapSelectEl.classList.add("hidden"); | |
| gameOverEl.classList.add("hidden"); | |
| underAttackEl.classList.add("hidden"); | |
| clearTimeout(underAttackTimer); | |
| hideObjectives(); | |
| game.galaxy = null; // cleared by default; startOdyssey re-sets it right after this returns | |
| game.competition = null; // β¦and likewise: startCompetitionMatch re-sets it right after this returns | |
| game.spectateMatch = null; game.spectateSpeed = 1; // β¦and likewise: startSpectatedMatch re-sets them right after this returns | |
| game.groups = {}; // fresh game β fresh control groups (entity ids reset per game, so stale groups would mis-select) | |
| game.colonyAlerts = {}; // fresh game β fresh starmap alert ledger (a previous game's background-colony alerts are meaningless here) | |
| game.state = newState; | |
| // Every input/HUD command site calls game.transport.submitCommand(cmd) unconditionally now | |
| // (T-012) β give it the real one when the caller built a session, else a directTransport | |
| // wrapping this same newState (see this function's own header comment above). | |
| game.transport = transport || createDirectTransport(newState); | |
| const state = newState; // alias for the synchronous setup below (identical to the original) | |
| // A scenario shows the scenario bar at the top-center; the body class drops the | |
| // under-attack banner below it (style.css) so a raid alert isn't hidden behind the bar. | |
| document.body.classList.toggle("scenario", !!state.scenario); | |
| showSeedChip(state.seed); | |
| showFactionChip(state); | |
| if (intro) showObjectives(state.endless); | |
| game.input = attachInput(canvas, state, game.transport, () => renderHUD()); | |
| const input = game.input; | |
| // Open on the player's own ships β the escort/convoy start station, the raider | |
| // fleet's ambush point, or the player's base in a skirmish β never the map | |
| // centre, which on a big map is empty space. | |
| const openAt = state.scenario ? (state.scenario.playerStart || state.scenario.route[0]) : state.map.bases.player; | |
| const cam = input.getCamera(); | |
| cam.x = openAt.x; | |
| cam.y = openAt.y; | |
| clampCamera(cam, state.map, canvas.clientWidth, canvas.clientHeight); | |
| resetEffects(); | |
| resetFacing(); | |
| announced = false; | |
| lastHud = 0; | |
| resetPanelSignature(); | |
| resetWorldUiBookkeeping(); | |
| clearPause(); // a fresh game is never born paused (and clears a stale PAUSED banner) | |
| let lastFrame = performance.now(); | |
| loop = createLoop({ | |
| // The fixed step (see this function's own `selfPlay` note). Read ONCE, at construction, and | |
| // deliberately not a live getter like `speed` below: the timestep must not move mid-match, or | |
| // the run stops being the deterministic thing a replay is comparing against. | |
| hz: selfPlay ? SELFPLAY_HZ : PLAY_HZ, | |
| // Spectate speed (docs/competitions-and-elo.md Phase 5), read LIVE so the on-screen | |
| // 1x/2x/4x/8x control takes effect on the very next frame without rebuilding the loop. Pinned | |
| // to 1 whenever no match is being watched, so an ordinary game can never inherit a leftover | |
| // multiplier β and engine/loop.js scales the SIM TIME a real second buys, never the fixed | |
| // timestep, so the tick sequence (and replay determinism) is exactly what it always was. | |
| speed: () => (game.spectateMatch ? game.spectateSpeed : 1), | |
| // Odyssey advances every world in the galaxy each tick (only the active one | |
| // is rendered), so the colonies you left keep evolving; otherwise just the | |
| // one match state ticks. | |
| update: dt => { | |
| if (pauseReasons.size) return; // paused: skip the sim; render still draws the frozen frame + overlays | |
| snapshotPositions(game.state); // interpolation baseline: positions BEFORE this tick moves them (render.js) | |
| if (game.galaxy) { | |
| stepGalaxy(game.galaxy, dt); // active world full-rate, colonies on a coarser schedule | |
| for (const n of sweepColonies(game.galaxy, dt)) notifyColony(n); | |
| // Conquest progress: a freshly-razed neighbour capital β a toast + a small firework. | |
| if (game.galaxy.pacifyNotes.length) { | |
| for (const id of game.galaxy.pacifyNotes) { | |
| showGalaxyToast(`Conquered ${planetName(id)} β ${game.galaxy.pacified.size}/${DOMINATION_TARGET} worlds`, "good"); | |
| addFireworks(3); | |
| } | |
| game.galaxy.pacifyNotes.length = 0; | |
| } | |
| // Progress milestones (engine/galaxy.js): a firework show + toast for each, in place of a | |
| // victory screen β the Odyssey is a play-forever sandbox, so you keep going. | |
| if (game.galaxy.milestones.length) { | |
| for (const m of game.galaxy.milestones) celebrateMilestone(m); | |
| game.galaxy.milestones.length = 0; | |
| } | |
| // Relief: a total wipeout is never a defeat β a fresh colony ship is dispatched so you | |
| // can re-found. Announce it so the player finds the ship at their landing zone. | |
| if (game.galaxy.reliefNote) { | |
| game.galaxy.reliefNote = false; | |
| showGalaxyToast("A relief colony ship has arrived at your landing zone β re-found your Odyssey.", "warn"); | |
| } | |
| } else if (game.spectateMatch) { | |
| // A WATCHED match: owner "player" is driven by state.playerAi here, then tickSelfPlay's own | |
| // tick() drives owner "ai" exactly as the branch below does. Deliberately the same | |
| // tools/selfplay.js entry point every simulated duel already runs through, so a match you | |
| // watch and the identical match the Worker would have simulated advance the same way. | |
| tickSelfPlay(game.state, dt); | |
| } else if (transport) { | |
| // The ADR-0003/ADR-0004 path (T-012): transport.tick(dt) is net/loopback.js's own | |
| // session.tick(dt) β aiSeats (empty for an ordinary human-played match) then | |
| // engine/sim.js's tick(state, dt), the SAME call the plain branch below makes, on the | |
| // SAME game.state object (session.getState() returned it by reference at boot) β so this | |
| // is a routing change, not a behavior change. Bound to the `transport` OPTION, not | |
| // game.transport: only a caller that actually built a session (startGame, | |
| // startCompetitionMatch, a loaded skirmish) reaches here; every other boot path's | |
| // game.transport is a directTransport with no tick() of its own, and keeps ticking below. | |
| transport.tick(dt); | |
| } else tick(game.state, dt); | |
| }, | |
| render: (alpha) => { | |
| const now = performance.now(); | |
| game.input.tickCamera((now - lastFrame) / 1000); | |
| lastFrame = now; | |
| // While paused no tick runs, so the leftover fraction wobbles with the accumulator β | |
| // pin alpha to 1 (settled/live positions) so paused units sit still instead of jittering. | |
| const a = pauseReasons.size ? 1 : alpha; | |
| // Observer Mode draws observedState() (possibly a different, backgrounded world) through | |
| // its own camera instead of the real game.state/game.input camera β see observer.js's | |
| // header comment. Neither is touched by the other: normal play resumes exactly where it | |
| // was left the moment observerMode goes back off. | |
| const viewState = game.observerMode ? observedState() : game.state; | |
| const viewCamera = game.observerMode ? game.observerCamera : game.input.getCamera(); | |
| drawFrame(ctx, viewState, viewCamera, canvas.clientWidth, canvas.clientHeight, game.input.getDragBox(), game.input.getBuildGhost(), a, game.observerMode); | |
| drawMinimap(minimapCtx, viewState, viewCamera, canvas.clientWidth, canvas.clientHeight, MINIMAP_W, MINIMAP_H, activePings(), game.observerMode); | |
| processFrameEvents(); | |
| if (now - lastHud > 150) { lastHud = now; renderHUD(); renderObserverPanel(); } | |
| if (game.state.over && !announced) { | |
| announced = true; | |
| loop.stop(); | |
| // A WATCHED match's own ending, computed BEFORE Observer Mode is torn down (it reads | |
| // game.spectateMatch). Then leave Observer Mode and repaint its UI once, so the spectate | |
| // bar and stats panel β which carry explicit z-indexes and would otherwise sit ON TOP of | |
| // the game-over overlay β are gone before that overlay goes up. | |
| const spectate = game.spectateMatch ? spectatedGameOverBlock(game.state, game.spectateMatch) : null; | |
| if (spectate) { exitObserverMode(); renderObserverPanel(); } | |
| if (game.state.scenario) showScenarioEnd(game.state, restartToMapSelect); | |
| else showGameOver(game.state.winner, game.state.seed, restartToMapSelect, | |
| { odyssey: !!game.galaxy, wonBy: game.galaxy?.wonBy, surrendered: !!game.galaxy?.surrendered, | |
| // A WATCHED match's ending (Phase 5, computed just above). Neither seat is the | |
| // player's, so the ordinary "Victory β the enemy's last Command Center is destroyed" | |
| // copy would be a lie in both directions; competition.js names the entrant that | |
| // actually won and restates that the result was exhibition-only. null for every other | |
| // game, leaving that screen untouched. | |
| spectate, | |
| // winReason (engine/victory.js finish) + the state itself, so showGameOver can branch | |
| // its copy honestly and, for a score decision, show the bank/army/structures breakdown. | |
| winReason: game.state.winReason, state: game.state, | |
| // A competition fixture's result is recorded HERE, at the one point the match is | |
| // genuinely over (docs/competitions-and-elo.md Phase 4) β competition.js owns the write | |
| // (it holds the live ledger) and hands back the block this screen shows: the rating | |
| // change, the seat disclosure, and what the next fixture is with a button to play it. | |
| // null for every ordinary skirmish, which leaves this screen byte-identical to before. | |
| competition: game.competition ? captureCompetitionResult(game.state) : null }); | |
| } | |
| }, | |
| }); | |
| loop.start(); | |
| renderHUD(); | |
| } | |
| // Background-colony notifications from galaxy.sweepColonies. "Under attack" is | |
| // throttled per planet so a sustained raid pings occasionally rather than every | |
| // tick; "lost" fires once per loss (sweepColonies only reports each transition once β | |
| // retaking and losing a world again re-arms it). Both toasts are clickable β clicking | |
| // jumps straight to that world to defend or retake it (a free hop, since it's a world | |
| // you've held). If no Spaceport stands on the world you're currently on, the jump can't | |
| // launch, so the click explains why instead. | |
| // | |
| // Every notification here ALSO lands on game.colonyAlerts[planetId] = {type, at} | |
| // (session.js) β the starmap's live ledger (starmap.js renderStarmap reads it for a badge | |
| // + garrison line, P6 "Starmap live colony ledger"), so a multi-colony empire can see | |
| // "where's the fire" on the map itself instead of from memory of whichever toast scrolled | |
| // by. Written unconditionally for all three types, independent of the toast throttle below | |
| // β the badge should track the freshest real event even while the TOAST itself stays quiet | |
| // mid-siege β and cleared per-world by boot.js's focusActivePlanet the instant the player | |
| // actually arrives there. The throttle itself is untouched: it still only ever compares | |
| // against THIS world's own previous ATTACKED alert (never a hostile/lost one that might | |
| // have just landed on the same record), so a hostile-declaration toast can never suppress | |
| // the very next under-attack toast. | |
| const COLONY_NOTE_THROTTLE_MS = 9000; | |
| export function notifyColony(n) { | |
| const name = planetName(n.planetId); | |
| const jumpThere = () => { | |
| if (!initiateJump(n.planetId)) | |
| showGalaxyToast(`Build a Spaceport on your current world to jump to ${name}.`, "warn"); | |
| }; | |
| const now = performance.now(); | |
| const prevAlert = game.colonyAlerts[n.planetId]; | |
| if (n.type === "lost") { | |
| game.colonyAlerts[n.planetId] = { type: n.type, at: now }; | |
| showGalaxyToast(`β Your colony on ${name} has fallen β click to retake βΈ`, "bad", jumpThere); | |
| return; | |
| } | |
| // A background world's neighbour has just declared war (fires once β diplomacy latches it). | |
| // Surface it so the first warning isn't the colony already dying; clicking jumps to reinforce. | |
| if (n.type === "hostile") { | |
| game.colonyAlerts[n.planetId] = { type: n.type, at: now }; | |
| showGalaxyToast(`β The neighbour on ${name} has turned hostile β click to reinforce βΈ`, "warn", jumpThere); | |
| return; | |
| } | |
| // "attacked": throttle against this world's own previous ATTACKED record specifically β NOT | |
| // whatever prevAlert holds if the last write here was actually the hostile/lost branch above. | |
| const lastAttackAt = prevAlert && prevAlert.type === "attacked" ? prevAlert.at : undefined; | |
| game.colonyAlerts[n.planetId] = { type: n.type, at: now }; | |
| if (lastAttackAt !== undefined && now - lastAttackAt < COLONY_NOTE_THROTTLE_MS) return; // undefined β first alert always fires | |
| showGalaxyToast(`β Your colony on ${name} is under attack β click to defend βΈ`, "warn", jumpThere); | |
| } | |
| // A reached progress milestone (engine/galaxy.js checkGalaxyProgress / checkDomination) β | |
| // a firework show + a celebratory toast. The Odyssey has no victory screen any more | |
| // (play-forever); these mark how far you've come instead. The two grand milestones β the | |
| // Antimatter Gate coming online and conquering the galaxy β get a bigger show. | |
| function celebrateMilestone(id) { | |
| const [kind, arg] = id.split(":"); | |
| const dominAll = kind === "domination" && arg === "all"; // every world pacified β the maximal feat | |
| const grand = kind === "gate" || kind === "domination"; | |
| const msg = | |
| kind === "world" ? (arg === "1" ? "β First colony founded β your Odyssey begins!" | |
| : `β Colony #${arg} established β your reach grows.`) | |
| : kind === "capital" ? "β Capital fortified β your anchor world stands strong." | |
| : kind === "gate" ? "β Antimatter Gate online β a triumph of industry!" | |
| : dominAll ? "β Every world pacified β the galaxy is yours!" | |
| : kind === "domination" ? `β ${DOMINATION_TARGET} worlds conquered β the galaxy trembles before your fleet!` | |
| : "β Milestone reached!"; | |
| addFireworks(dominAll ? 12 : grand ? 8 : 5); | |
| showGalaxyToast(msg, "good"); | |
| sound.playBuildingComplete(); | |
| } | |
| // Stereo pan (-1..1) for a world-x, relative to the camera: a fight off the | |
| // left edge of the view is heard on the left. Clamped, and flattened toward | |
| // center for things near the middle so it isn't distractingly hard-panned. | |
| function panFor(worldX) { | |
| const { state, input } = game; | |
| if (!state || !input) return 0; | |
| const cam = input.getCamera(); | |
| const halfW = canvas.clientWidth / (2 * cam.zoom) || 1; | |
| return Math.max(-1, Math.min(1, (worldX - cam.x) / halfW)) * 0.85; | |
| } | |
| // A sim event plays a sound (and spawns a matching visual effect β see | |
| // effects.js) if it's the player's own, or if it happened somewhere | |
| // currently visible β same "you can hear what you can see" rule as fog | |
| // of war applies to rendering. Every AI-only skirmish happening off in | |
| // the fogged dark stays silent. An attackHit whose attacker is the AI | |
| // necessarily means the target is the player's (only two sides exist), | |
| // so that's also the under-attack alert's trigger. | |
| function processFrameEvents() { | |
| const { state } = game; | |
| for (const ev of state.events) { | |
| if (ev.owner !== "player" && !isVisibleAt(state.fog, ev.x, ev.y)) continue; | |
| const pan = panFor(ev.x); // stereo-place the sound by where it happened on screen | |
| switch (ev.type) { | |
| case "unitSpawned": | |
| sound.playUnitSpawned(pan); | |
| break; | |
| case "attackHit": | |
| (ev.heavy ? sound.playHeavyHit : sound.playAttackHit)(pan); | |
| addTracer(ev.fromX, ev.fromY, ev.x, ev.y, ev.unitType, ev.bonus, ev.splashRadius); | |
| if (ev.owner === "ai") triggerUnderAttack(ev.x, ev.y); | |
| break; | |
| // Tiered destruction (docs/improvement-proposals.md): the event's unitType/kind (engine/ | |
| // combat.js/engine/bomb.js) let the death ring/sound scale by what actually died, instead | |
| // of every kill playing the identical 280ms ring + tone. Falls back to the def-less | |
| // baseline for an event somehow missing them (shouldn't happen post-this-change, but keeps | |
| // this handler from ever crashing on a stray/older-shaped event). | |
| case "entityKilled": { | |
| const def = ev.kind === "building" ? BUILDINGS[ev.unitType] : UNITS[ev.unitType]; | |
| const radius = def?.radius || DEATH_BASE_RADIUS; | |
| sound.playEntityKilled(pan, radius / DEATH_BASE_RADIUS); | |
| addDeathFlash(ev.x, ev.y, radius, ev.kind || "unit"); | |
| break; | |
| } | |
| // A Helium Bomb's fuse just lit (engine/bomb.js) β proximity or the player's own | |
| // "Detonate Now" command. The real blast (bombDetonated below) doesn't land for | |
| // another ev.delay sim-seconds; this is the warning that it's now inevitable. | |
| case "bombFused": | |
| sound.playFuseLit(pan); | |
| addFuseWarning(ev.x, ev.y, ev.delay * 1000); | |
| if (ev.owner === "ai") triggerUnderAttack(ev.x, ev.y); | |
| break; | |
| // The Helium Bomb's detonation (engine/bomb.js) β one event for the blast itself, | |
| // alongside the individual entityKilled events for everything the falloff-damaged | |
| // blast actually killed outright. | |
| case "bombDetonated": | |
| sound.playExplosion(pan); | |
| addExplosion(ev.x, ev.y, ev.radius, ev.coreRadius); | |
| if (ev.owner === "ai") triggerUnderAttack(ev.x, ev.y); | |
| break; | |
| // A Helium Bomb crater (engine/bomb.js) finished terraforming β the payoff for the | |
| // detonation, easy to miss if it's not called out (the deposit can land anywhere the | |
| // bomb went off, not necessarily somewhere the player is still looking). | |
| case "craterMatured": | |
| sound.playBuildingComplete(pan); | |
| showGalaxyToast(`${COM[ev.com]?.ico || "β"} A ${COM[ev.com]?.name || ev.com} deposit has formed in the blast crater`, "good"); | |
| break; | |
| // Battle wreckage (engine/wreckage.js) finished settling into real deposits β one | |
| // node per commodity it accumulated, so the toast lists all of them at once rather | |
| // than firing once per node. Same payoff-callout reasoning as craterMatured above: | |
| // easy to miss otherwise if the fight's moved on since it happened. | |
| case "wreckMatured": { | |
| sound.playBuildingComplete(pan); | |
| const goods = (ev.coms || []).map(com => `${COM[com]?.ico || "β"} ${COM[com]?.name || com}`).join(", "); | |
| showGalaxyToast(`Battle wreckage has settled into a deposit: ${goods}`, "good"); | |
| break; | |
| } | |
| case "buildingComplete": | |
| sound.playBuildingComplete(pan); | |
| break; | |
| // Only the player's own supply block beeps and flashes β a visible | |
| // enemy stalling on supply is their problem, not a HUD alert of ours. | |
| case "productionBlocked": | |
| if (ev.owner === "player") { | |
| sound.playProductionBlocked(); | |
| game.supplyBlockedUntil = performance.now() + 800; | |
| } | |
| break; | |
| // Odyssey research finishing was previously silent β announce the unlock so | |
| // the player connects the wait to the reward (and notices new build options). | |
| case "researchComplete": | |
| sound.playBuildingComplete(pan); | |
| showGalaxyToast(`Researched ${TECHS[ev.techId]?.name || ev.techId}`, "good"); | |
| break; | |
| // The neighbour just crossed from peace into war β a one-time heads-up so the | |
| // first raid doesn't land unannounced (diplomacy.js fires this once per world). | |
| case "neighbourHostile": | |
| sound.playProductionBlocked(); | |
| showGalaxyToast("β Your neighbour has turned hostile β expect raids. Ready your defence.", "bad"); | |
| break; | |
| // The Antimatter Gate charge (fires every tick) β toast once per 25% so the | |
| // multi-minute climb to the galaxy win is visible without selecting the Gate. | |
| case "wonderCharging": { | |
| const pct = Math.floor((ev.charge || 0) * 4) * 25; | |
| if (pct >= 25 && pct > gateMilestone) { | |
| gateMilestone = pct; | |
| showGalaxyToast(`Antimatter Gate charging β ${pct}%`, pct >= 75 ? "bad" : "warn"); | |
| } | |
| break; | |
| } | |
| } | |
| } | |
| state.events.length = 0; | |
| } | |
| // Throttled independently of sound.js's own internal throttle (which | |
| // only governs the alarm tone) so the banner and the minimap/world ping | |
| // stay in lockstep with each other during a sustained siege instead of | |
| // re-flashing on every single hit. | |
| function triggerUnderAttack(x, y) { | |
| // Nobody's base is under attack in a WATCHED match β both sides are AI entrants, and "β Under | |
| // Attack" over a match the human isn't playing is simply false. (The alarm fires off an | |
| // owner-"ai" attackHit, which in a spectated duel just means one entrant hit the other.) | |
| if (game.spectateMatch) return; | |
| game.lastAttackAt = { x, y }; // remembered even while throttled, so a click/Backspace always jumps to the freshest hit | |
| const now = performance.now(); | |
| if (now - lastUnderAttackAt < UNDER_ATTACK_THROTTLE_MS) return; | |
| lastUnderAttackAt = now; | |
| sound.playUnderAttack(); | |
| addUnderAttackPing(x, y); | |
| underAttackEl.classList.remove("hidden"); | |
| clearTimeout(underAttackTimer); | |
| underAttackTimer = setTimeout(() => underAttackEl.classList.add("hidden"), UNDER_ATTACK_BANNER_MS); | |
| } | |