Spaces:
Paused
Paused
File size: 48,961 Bytes
d32ddf2 a3867b9 d32ddf2 450f6c1 090c049 d32ddf2 f894a44 d32ddf2 d213ade d32ddf2 450f6c1 6bd2c72 11037b3 ed2403c 450f6c1 3a48732 b88cc9c d1cc6d6 e46bcce 5330c74 d32ddf2 ed2403c d32ddf2 e46bcce 18ec8e7 cce54f5 18ec8e7 d32ddf2 18ec8e7 d32ddf2 1ad681c a3867b9 1ad681c a3867b9 1ad681c a3867b9 1ad681c d32ddf2 ed2403c d32ddf2 53ce1fb 2fa2e12 dadc0b2 d32ddf2 1ff6f70 6fec449 1ff6f70 450f6c1 090c049 450f6c1 090c049 450f6c1 090c049 450f6c1 dcbdeac ed2403c 8c580c4 dcbdeac 11d7a22 ed2403c 8c580c4 11d7a22 3a48732 ed2403c 8c580c4 3a48732 cae0952 ed2403c 7e2a606 cae0952 be1f1f9 dadc0b2 7e2a606 cae0952 5df8767 d1cc6d6 ba43c95 d1cc6d6 ba43c95 5df8767 ba43c95 5df8767 d1cc6d6 6bd2c72 d1cc6d6 b88cc9c d1cc6d6 7aab638 d1cc6d6 76e5854 e692ca2 18ec8e7 e692ca2 5df8767 e692ca2 25e82b6 5df8767 d32ddf2 cf3fc9b a4bbde9 cf3fc9b a4bbde9 6bd2c72 a4bbde9 1ff6f70 450f6c1 1ad681c a3867b9 d32ddf2 090c049 d32ddf2 090c049 d32ddf2 6bd2c72 d32ddf2 cae0952 1ff6f70 450f6c1 1ad681c 25e82b6 d32ddf2 1ad681c d32ddf2 cae0952 d32ddf2 11d7a22 d32ddf2 dcbdeac d32ddf2 e692ca2 1ad681c d32ddf2 090c049 450f6c1 5df8767 1ad681c f894a44 11037b3 7e2a606 b1db01d 947d9db 7acc706 947d9db 7acc706 947d9db 7acc706 947d9db 76e5854 450f6c1 11037b3 5df8767 f894a44 d32ddf2 f894a44 6bd2c72 d32ddf2 6bd2c72 dcbdeac 450f6c1 dcbdeac 76e5854 5bffdf0 450f6c1 5bffdf0 1ff6f70 dcbdeac d32ddf2 11037b3 25e82b6 11037b3 25e82b6 7e2a606 ba43c95 d1cc6d6 ba43c95 25e82b6 e692ca2 25e82b6 ba43c95 11037b3 947d9db e692ca2 947d9db e692ca2 947d9db e692ca2 947d9db d32ddf2 8f2921c d32ddf2 d213ade d32ddf2 d213ade c1ba8ce 53d4247 33fe16b 53d4247 33fe16b 53d4247 5330c74 6e6bac2 d32ddf2 e46bcce 820fa12 e46bcce d32ddf2 450f6c1 18ec8e7 d32ddf2 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 | /* ============================================================
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).
============================================================ */
"use strict";
import { game } from "./session.js";
import { canvas, ctx, minimapCtx, mapSelectEl, gameOverEl, underAttackEl, pauseBtn, MINIMAP_W, MINIMAP_H } from "./dom.js";
import { createGameState } from "./engine/state.js";
import { mulberry32 } from "./engine/rng.js";
import { createLoop } from "./engine/loop.js";
import { tick } from "./engine/sim.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";
const fresh = createGameState({ 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(fresh, { intro: true });
}
// 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";
const fresh = createGameState({
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(fresh, { intro: true });
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;
if (game.input) game.input.destroy();
game.input = attachInput(canvas, state, () => 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.
export function bootState(newState, { intro, selfPlay = false }) {
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;
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, () => 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 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);
}
|