mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-07 23:00:38 +00:00
22c873cf55
## Problem
Every 100 ms the main thread's worker `onmessage` callback processes a
full game tick (`gameView.update` → `webglBuilder.update` →
`renderer.tick`). At 60 fps this competes with the 16.7 ms frame budget,
and on the Giant World Map it takes several ms — frame drops on low-end
hardware.
## Harness (`npm run perf:client-tick`)
Headless-Chromium harness that times every worker→main `game_update`
dispatch on the main thread, with structured-clone deserialization
measured separately from the handler body (via a
`Worker.prototype.addEventListener` wrapper installed as a page init
script — no product-code changes). It reports windowed distributions,
captures `.cpuprofile` files at chosen ticks, writes raw samples and an
end-of-run screenshot. `AnalyzeCpuProfile.ts` breaks a profile down by
inclusive time under the dispatch subtree.
Init scripts are passed as **strings**: tsx compiles function-form init
scripts with esbuild `keepNames`, whose injected `__name` helper doesn't
exist in-page and silently kills the game worker setup.
## Baseline (Giant World Map, 400 bots, headless)
Dispatch handler ms — cost **grows with game progression**:
| window | mean | p50 | p95 | max |
|---|---|---|---|---|
| tick 506 | 2.22 | 2.20 | 3.40 | 5.00 |
| tick 1506 | 2.60 | 2.00 | 7.00 | 10.40 |
| tick 2000 | 2.67 | 1.90 | **8.70** | **12.70** |
Deserialization is negligible (0.12 ms mean). CPU profiles attributed
the growing tail to the leaderboard's once-per-second refresh: its
Max-troops column calls `config().maxTroops(p)` for **all ~508
players**, and `PlayerView.units()` scanned **every unit in the game**
per call — O(players × units), growing as units accumulate.
## Round 1 — algorithmic fixes
- **GameView**: new `unitsOwnedBy(smallID)` — an active-units-by-owner
index built lazily at most once per tick. `PlayerView.units()` reads its
own units from it: O(own units) instead of O(all units). Also speeds up
unit display, player panel, and buildables queries.
- **NamePass.updateNames**: reads player state directly from the
caller's map by smallID instead of rebuilding three lookup maps per
tick; skips the slot-assignment sweep once every player has a slot.
After (same map, same spawn tile):
| window | mean | p50 | p95 | max |
|---|---|---|---|---|
| tick 506 | 2.12 | 2.00 | 3.10 | 5.20 |
| tick 1506 | 1.86 | 1.80 | 2.90 | 4.30 |
| tick 2000 | 1.74 | 1.60 | **2.40** | **4.70** |
Late-game p95 −65% (8.7 → 2.4 ms), worst dispatch −63% (12.7 → 4.7 ms),
and per-dispatch cost no longer grows with game progression. The
leaderboard disappeared from the dispatch profile entirely.
## Round 2 — allocation churn + time slicing
Aimed at GC pauses and low-end CPUs; measures flat vs round 1 on a fast
machine, as expected:
- **`FrameData.changedTiles`** is now the plain tile-ref array GameView
already builds instead of a per-tile `{ref, state}` object copy — heavy
battle ticks allocated tens of thousands of objects per tick for a
`state` field that was always 0. `TilePair` removed; `TerritoryPass`
buckets refs synchronously, so the live reference is safe.
- **`UnitView.lastPos`** is only re-sliced when a move actually appended
a position — the unconditional `slice(-1)` allocated an identical
1-element array per unit per tick, including for structures that never
move.
- **`NamePass.updateNames`** refreshes slots round-robin, a quarter per
tick — the full per-player diff pass spreads over ~400 ms, under the
existing 500 ms troop-text cadence; positions lerp continuously. Unnamed
slots and snap passes (seeks) are always processed so nothing pops in
late. Dispatch share: 17% → 13%.
Not sliced on purpose: tile ingest and frame upload need a consistent
per-tick snapshot (stale `GameMap` reads would leak into hover queries,
minimap, attack targeting) — a correctness risk not worth ~1 ms while
the worst dispatch already fits in a quarter of the frame budget.
## Verification
- `npx tsc --noEmit`, eslint clean; full suite green (1929 tests)
- 6 new GameView tests cover the owner index (grouping, inactive
exclusion, ownership capture, death, type filtering, copy semantics);
changedTiles tests updated to the ref-array contract
- Headless end-of-run screenshots verified after each round: leaderboard
Max-troops values, map names + troop counts + flags all render correctly
(including with name slicing active)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1271 lines
41 KiB
TypeScript
1271 lines
41 KiB
TypeScript
import { Config } from "../../core/configuration/Config";
|
||
import {
|
||
Cell,
|
||
GameUpdates,
|
||
PlayerID,
|
||
TerrainType,
|
||
TerraNullius,
|
||
Tick,
|
||
Unit,
|
||
UnitInfo,
|
||
UnitType,
|
||
} from "../../core/game/Game";
|
||
import { GameMap, TileRef } from "../../core/game/GameMap";
|
||
import {
|
||
GameUpdateType,
|
||
GameUpdateViewData,
|
||
SpawnPhaseEndUpdate,
|
||
} from "../../core/game/GameUpdates";
|
||
import { ATTACK_DELTA_OUTGOING } from "../../core/game/GameUpdateUtils";
|
||
import {
|
||
MotionPlanRecord,
|
||
unpackMotionPlans,
|
||
} from "../../core/game/MotionPlans";
|
||
import { TerrainMapData } from "../../core/game/TerrainMapLoader";
|
||
import { TerraNulliusImpl } from "../../core/game/TerraNulliusImpl";
|
||
import { UnitGrid, UnitPredicate } from "../../core/game/UnitGrid";
|
||
import { ClientID, GameID, Player, PlayerCosmetics } from "../../core/Schemas";
|
||
import { formatPlayerDisplayName } from "../../core/Util";
|
||
import { WorkerClient } from "../../core/worker/WorkerClient";
|
||
import { computeAllianceClusters } from "../render/frame/derive/AllianceClusters";
|
||
import { extractAttackRings } from "../render/frame/derive/AttackRings";
|
||
import { extractNukeTelegraphs } from "../render/frame/derive/NukeTelegraphs";
|
||
import { computePlayerStatus } from "../render/frame/derive/PlayerStatus";
|
||
import { buildRelationMatrix } from "../render/frame/derive/RelationMatrix";
|
||
import { RailroadCache } from "../render/frame/RailroadCache";
|
||
import { TrailManager } from "../render/frame/TrailManager";
|
||
import type { FrameData, NameEntry } from "../render/types";
|
||
import { STRUCTURE_TYPES } from "../render/types";
|
||
import { PlayerView } from "./PlayerView";
|
||
import { UnitView } from "./UnitView";
|
||
|
||
const TRAIL_TYPES: ReadonlySet<UnitType> = new Set<UnitType>([
|
||
UnitType.TransportShip,
|
||
UnitType.AtomBomb,
|
||
UnitType.HydrogenBomb,
|
||
UnitType.MIRV,
|
||
UnitType.MIRVWarhead,
|
||
]);
|
||
|
||
type TrainPlanState = {
|
||
planId: number;
|
||
startTick: number;
|
||
speed: number;
|
||
spacing: number;
|
||
carUnitIds: Uint32Array;
|
||
path: Uint32Array;
|
||
cursor: number;
|
||
usedTilesBuf: Uint32Array;
|
||
usedHead: number;
|
||
usedLen: number;
|
||
lastAdvancedTick: Tick;
|
||
};
|
||
|
||
export class GameView implements GameMap {
|
||
private lastUpdate: GameUpdateViewData | null;
|
||
private startTick: Tick | null = null;
|
||
private smallIDToID = new Map<number, PlayerID>();
|
||
private _players = new Map<PlayerID, PlayerView>();
|
||
private _units = new Map<number, UnitView>();
|
||
/**
|
||
* Long-lived state maps (renderer's plain-object shape). Each entry shares
|
||
* its identity with the corresponding PlayerView.state / UnitView.state, so
|
||
* mutations through either path are visible everywhere.
|
||
*/
|
||
private _playerStates = new Map<
|
||
number,
|
||
import("../render/types").PlayerState
|
||
>();
|
||
private _unitStates = new Map<number, import("../render/types").UnitState>();
|
||
/** smallID → team, for the renderer's relation matrix (team games). */
|
||
private _teams = new Map<number, string>();
|
||
private updatedTiles: TileRef[] = [];
|
||
private updatedTerrainTiles: TileRef[] = [];
|
||
/**
|
||
* Active units grouped by owner smallID, built lazily at most once per
|
||
* tick. Keeps per-player unit queries (PlayerView.units) at O(own units)
|
||
* instead of O(all units) — the leaderboard asks for every player's units
|
||
* in one pass, which is O(players × units) without this.
|
||
*/
|
||
private _unitsByOwner = new Map<number, UnitView[]>();
|
||
private _unitsByOwnerStale = true;
|
||
|
||
// ── FrameData accumulators (renderer-bound state) ─────────────────────
|
||
private trailManager!: TrailManager;
|
||
private railroadCache!: RailroadCache;
|
||
/** Long-lived NameEntry map for the renderer's NamePass. */
|
||
private _names = new Map<string, NameEntry>();
|
||
/** Reusable scratch buffers for per-tick deltas. */
|
||
private readonly _trailIdsScratch: number[] = [];
|
||
/**
|
||
* The single long-lived FrameData object. Fields are mutated in place each
|
||
* tick by update(). Renderer reads this each frame via frameData().
|
||
*/
|
||
private _frame: FrameData;
|
||
private _structuresDirty = false;
|
||
/** True until first populateFrame() — controls full-vs-delta tile upload. */
|
||
private _firstPopulate = true;
|
||
|
||
private _myPlayer: PlayerView | null = null;
|
||
|
||
// ── populateFrame dirty flags ──────────────────────────────────────────
|
||
// The derived structures below only depend on rarely-changing player
|
||
// fields, so they're rebuilt only when one of their inputs arrived this
|
||
// tick (PlayerUpdates are partial — field presence means "changed").
|
||
/** Names: nameData record applied, or a player was added. */
|
||
private _namesDirty = true;
|
||
/** Relation matrix: allies/embargoes changed, or a player was added. */
|
||
private _relationsDirty = true;
|
||
/** Alliance clusters: allies changed, or a player was added. */
|
||
private _clustersDirty = true;
|
||
|
||
private unitGrid: UnitGrid;
|
||
private unitMotionPlans = new Map<
|
||
number,
|
||
{
|
||
planId: number;
|
||
startTick: number;
|
||
ticksPerStep: number;
|
||
path: Uint32Array;
|
||
}
|
||
>();
|
||
private trainMotionPlans = new Map<number, TrainPlanState>();
|
||
private trainUnitToEngine = new Map<number, number>();
|
||
|
||
private toDelete = new Set<number>();
|
||
|
||
private _cosmetics: Map<string, PlayerCosmetics> = new Map();
|
||
|
||
private _map: GameMap;
|
||
|
||
constructor(
|
||
public worker: WorkerClient,
|
||
private _config: Config,
|
||
private _mapData: TerrainMapData,
|
||
private _myClientID: ClientID | undefined,
|
||
private _myUsername: string,
|
||
private _myClanTag: string | null,
|
||
private _gameID: GameID,
|
||
humans: Player[],
|
||
) {
|
||
this._map = this._mapData.gameMap;
|
||
this.lastUpdate = null;
|
||
this.unitGrid = new UnitGrid(this._map);
|
||
this._cosmetics = new Map(
|
||
humans.map((h) => [h.clientID, h.cosmetics ?? {}]),
|
||
);
|
||
for (const nation of this._mapData.nations) {
|
||
// Nations don't have client ids, so we use their name as the key instead.
|
||
this._cosmetics.set(nation.name, {
|
||
flag: nation.flag ? `/flags/${nation.flag}.svg` : undefined,
|
||
} satisfies PlayerCosmetics);
|
||
}
|
||
for (const extra of this._mapData.additionalNations) {
|
||
// Only set if not already provided by a manifest nation with the same name.
|
||
if (this._cosmetics.has(extra.name)) continue;
|
||
this._cosmetics.set(extra.name, {
|
||
flag: extra.flag ? `/flags/${extra.flag}.svg` : undefined,
|
||
} satisfies PlayerCosmetics);
|
||
}
|
||
|
||
const mapW = this._map.width();
|
||
const mapH = this._map.height();
|
||
this.trailManager = new TrailManager(mapW, mapH);
|
||
this.railroadCache = new RailroadCache(mapW, mapH);
|
||
|
||
// Long-lived FrameData. Most fields are mutable references to long-lived
|
||
// buffers (tileState, trailState, etc.); changedTiles points at this
|
||
// tick's updatedTiles. Properties marked `readonly` on FrameData only
|
||
// prevent reassignment, not mutation through the reference.
|
||
// events: fresh arrays we own; cleared and repopulated each tick.
|
||
this._frame = {
|
||
tick: 0,
|
||
inSpawnPhase: true,
|
||
tileState: this._map.tileStateBuffer(),
|
||
trailState: this.trailManager.getTrailState(),
|
||
railroadState: this.railroadCache.railroadState,
|
||
units: this._unitStates,
|
||
players: this._playerStates,
|
||
names: this._names,
|
||
events: {
|
||
deadUnits: [],
|
||
conquestEvents: [],
|
||
bonusEvents: [],
|
||
},
|
||
changedTiles: null,
|
||
railroadDirty: false,
|
||
revealedRailTiles: this.railroadCache.revealedRailTiles,
|
||
trailDirtyRowMin: 0,
|
||
trailDirtyRowMax: -1,
|
||
// Derived data — populated each tick by populateFrame(). Empty defaults
|
||
// here so the type is satisfied before the first update().
|
||
playerStatus: new Map(),
|
||
relationMatrix: new Uint8Array(0),
|
||
relationSize: 0,
|
||
relationsDirty: false,
|
||
allianceClusters: new Map(),
|
||
nukeTelegraphs: [],
|
||
attackRings: [],
|
||
structuresDirty: false,
|
||
};
|
||
}
|
||
|
||
isOnEdgeOfMap(ref: TileRef): boolean {
|
||
return this._map.isOnEdgeOfMap(ref);
|
||
}
|
||
|
||
public updatesSinceLastTick(): GameUpdates | null {
|
||
return this.lastUpdate?.updates ?? null;
|
||
}
|
||
|
||
public motionPlans(): ReadonlyMap<
|
||
number,
|
||
{
|
||
planId: number;
|
||
startTick: number;
|
||
ticksPerStep: number;
|
||
path: Uint32Array;
|
||
}
|
||
> {
|
||
return this.unitMotionPlans;
|
||
}
|
||
|
||
private motionPlannedUnitIdsCache: number[] = [];
|
||
private motionPlannedUnitIdsDirty = true;
|
||
|
||
private markMotionPlannedUnitIdsDirty(): void {
|
||
this.motionPlannedUnitIdsDirty = true;
|
||
}
|
||
|
||
private rebuildMotionPlannedUnitIdsCacheIfDirty(): void {
|
||
if (!this.motionPlannedUnitIdsDirty) {
|
||
return;
|
||
}
|
||
this.motionPlannedUnitIdsDirty = false;
|
||
|
||
const out = this.motionPlannedUnitIdsCache;
|
||
out.length = 0;
|
||
|
||
for (const unitId of this.unitMotionPlans.keys()) {
|
||
out.push(unitId);
|
||
}
|
||
for (const [engineId, plan] of this.trainMotionPlans) {
|
||
out.push(engineId);
|
||
for (let i = 0; i < plan.carUnitIds.length; i++) {
|
||
const id = plan.carUnitIds[i] >>> 0;
|
||
if (id !== 0) out.push(id);
|
||
}
|
||
}
|
||
}
|
||
|
||
public motionPlannedUnitIds(): number[] {
|
||
this.rebuildMotionPlannedUnitIdsCacheIfDirty();
|
||
return this.motionPlannedUnitIdsCache;
|
||
}
|
||
|
||
public isCatchingUp(): boolean {
|
||
return (this.lastUpdate?.pendingTurns ?? 0) > 1;
|
||
}
|
||
|
||
public update(gu: GameUpdateViewData) {
|
||
// Unit set/ownership changes below; rebuild the owner index on demand.
|
||
this._unitsByOwnerStale = true;
|
||
|
||
this.toDelete.forEach((id) => {
|
||
this._units.delete(id);
|
||
this._unitStates.delete(id);
|
||
});
|
||
this.toDelete.clear();
|
||
|
||
this.lastUpdate = gu;
|
||
|
||
this.updatedTiles = [];
|
||
this.updatedTerrainTiles = [];
|
||
const packed = this.lastUpdate.packedTileUpdates;
|
||
for (let i = 0; i + 1 < packed.length; i += 2) {
|
||
const tile = packed[i];
|
||
const state = packed[i + 1];
|
||
const terrainChanged = this.updateTile(tile, state);
|
||
this.updatedTiles.push(tile);
|
||
if (terrainChanged) {
|
||
this.updatedTerrainTiles.push(tile);
|
||
}
|
||
}
|
||
|
||
if (gu.packedMotionPlans) {
|
||
const records = unpackMotionPlans(gu.packedMotionPlans);
|
||
this.applyMotionPlanRecords(records);
|
||
}
|
||
|
||
if (gu.updates === null) {
|
||
throw new Error("lastUpdate.updates not initialized");
|
||
}
|
||
|
||
const spawnPhaseEndUpdate = gu.updates[GameUpdateType.SpawnPhaseEnd][0] as
|
||
| SpawnPhaseEndUpdate
|
||
| undefined;
|
||
if (spawnPhaseEndUpdate) {
|
||
this.startTick = spawnPhaseEndUpdate.startTick;
|
||
}
|
||
|
||
const myDisplayName = formatPlayerDisplayName(
|
||
this._myUsername,
|
||
this._myClanTag,
|
||
);
|
||
|
||
// Name placements arrive only on ticks where the worker recomputed them
|
||
// (see GameUpdateViewData.playerNameViewData). Apply to existing alive
|
||
// players here; dead players keep their last placement (names freeze at
|
||
// death), and new players get theirs via the PlayerView constructor in
|
||
// pass 1 below.
|
||
if (gu.playerNameViewData !== undefined) {
|
||
for (const id in gu.playerNameViewData) {
|
||
const pv = this._players.get(id);
|
||
if (pv !== undefined && pv.state.isAlive) {
|
||
pv.nameData = gu.playerNameViewData[id];
|
||
}
|
||
}
|
||
this._namesDirty = true;
|
||
}
|
||
|
||
// Pass 1: ensure every player exists with up-to-date PlayerState. We need
|
||
// all smallIDs registered before pass 2 can translate embargo PlayerIDs.
|
||
// PlayerUpdate is now partial: only `id` is guaranteed; everything else
|
||
// is present only when its value changed since the last emission.
|
||
gu.updates[GameUpdateType.Player].forEach((pu) => {
|
||
// First-emission (new player) — must have all static fields populated.
|
||
// Subsequent emissions for an existing player carry only changed fields.
|
||
const existing = this._players.get(pu.id);
|
||
|
||
// Replace the local player's name/displayName with their own stored values.
|
||
// This way the user does not know they are being censored. clientID is
|
||
// static — present only on first emission — so this branch only runs once.
|
||
if (pu.clientID !== undefined && pu.clientID === this._myClientID) {
|
||
pu.name = this._myUsername;
|
||
pu.displayName = myDisplayName;
|
||
}
|
||
|
||
if (pu.smallID !== undefined) {
|
||
this.smallIDToID.set(pu.smallID, pu.id);
|
||
}
|
||
|
||
// Derived-data dirty tracking: field presence on a partial update
|
||
// means the field changed this tick.
|
||
if (pu.allies !== undefined) {
|
||
this._relationsDirty = true;
|
||
this._clustersDirty = true;
|
||
}
|
||
if (pu.embargoes !== undefined) {
|
||
this._relationsDirty = true;
|
||
}
|
||
|
||
if (existing !== undefined) {
|
||
existing.applyUpdate(pu);
|
||
const nextNameData = gu.playerNameViewData?.[pu.id];
|
||
if (nextNameData !== undefined) {
|
||
existing.nameData = nextNameData;
|
||
}
|
||
} else {
|
||
const player = new PlayerView(
|
||
this,
|
||
pu,
|
||
gu.playerNameViewData?.[pu.id],
|
||
// First check human by clientID, then check nation by name.
|
||
this._cosmetics.get(pu.clientID ?? "") ??
|
||
this._cosmetics.get(pu.name!) ??
|
||
{},
|
||
);
|
||
this._players.set(pu.id, player);
|
||
this._playerStates.set(pu.smallID!, player.state);
|
||
const team = player.team();
|
||
if (team !== null) {
|
||
this._teams.set(pu.smallID!, team);
|
||
}
|
||
this._namesDirty = true;
|
||
this._relationsDirty = true;
|
||
this._clustersDirty = true;
|
||
}
|
||
});
|
||
|
||
// Pass 2: translate engine embargoes (Set<PlayerID>) → renderer-format
|
||
// smallIDs. Only re-translate when embargoes changed (field present);
|
||
// unchanged sets stay at the previously-computed renderer-format list.
|
||
gu.updates[GameUpdateType.Player].forEach((pu) => {
|
||
if (pu.embargoes === undefined) return;
|
||
const player = this._players.get(pu.id);
|
||
if (player === undefined) return;
|
||
const smallIDs: number[] = [];
|
||
for (const otherPlayerID of pu.embargoes) {
|
||
const otherPV = this._players.get(otherPlayerID);
|
||
if (otherPV !== undefined) {
|
||
smallIDs.push(otherPV.smallID());
|
||
}
|
||
}
|
||
player.setEmbargoSmallIDs(smallIDs);
|
||
});
|
||
|
||
// Packed per-player stats: [smallID, tilesOwned, gold, troops] quads for
|
||
// every player whose stats changed this tick (the per-tick churn that no
|
||
// longer travels in PlayerUpdate objects). Applied after pass 1 so
|
||
// first-emission players exist; their quad carries the same values as
|
||
// the full update, so double-applying is harmless.
|
||
const packedStats = gu.packedPlayerUpdates;
|
||
if (packedStats !== undefined) {
|
||
for (let i = 0; i + 3 < packedStats.length; i += 4) {
|
||
const state = this._playerStates.get(packedStats[i]);
|
||
if (state === undefined) continue;
|
||
state.tilesOwned = packedStats[i + 1];
|
||
state.gold = packedStats[i + 2];
|
||
state.troops = packedStats[i + 3];
|
||
}
|
||
}
|
||
|
||
// Packed attack troop counts: [ownerSmallID, direction, index, troops]
|
||
// quads. The attack arrays themselves are only resent when membership/
|
||
// order changes, which is also what keeps these indexes valid — a tick
|
||
// either resends an array (fresh troops included) or patches it, never
|
||
// both. See packAttackTroopDeltas.
|
||
const packedAttacks = gu.packedAttackUpdates;
|
||
if (packedAttacks !== undefined) {
|
||
for (let i = 0; i + 3 < packedAttacks.length; i += 4) {
|
||
const state = this._playerStates.get(packedAttacks[i]);
|
||
if (state === undefined) continue;
|
||
const attacks =
|
||
packedAttacks[i + 1] === ATTACK_DELTA_OUTGOING
|
||
? state.outgoingAttacks
|
||
: state.incomingAttacks;
|
||
const attack = attacks[packedAttacks[i + 2]];
|
||
if (attack !== undefined) {
|
||
attack.troops = packedAttacks[i + 3];
|
||
}
|
||
}
|
||
}
|
||
|
||
if (this._myClientID) {
|
||
this._myPlayer ??= this.playerByClientID(this._myClientID);
|
||
}
|
||
|
||
for (const unit of this._units.values()) {
|
||
unit._wasUpdated = false;
|
||
// Only trim when a move appended a position — slicing a ≤1-element
|
||
// array would allocate an identical array per unit per tick, and most
|
||
// units (structures) never move.
|
||
if (unit.lastPos.length > 1) {
|
||
unit.lastPos = unit.lastPos.slice(-1);
|
||
}
|
||
}
|
||
gu.updates[GameUpdateType.Unit].forEach((update) => {
|
||
let unit = this._units.get(update.id);
|
||
const isStructure = STRUCTURE_TYPES.has(update.unitType);
|
||
if (unit !== undefined) {
|
||
// Structure changes that affect rendering: owner changed (captured),
|
||
// level changed, became inactive, or finished construction
|
||
// (underConstruction → !underConstruction).
|
||
if (
|
||
isStructure &&
|
||
(unit.state.ownerID !== update.ownerID ||
|
||
unit.state.level !== update.level ||
|
||
unit.state.isActive !== update.isActive ||
|
||
(unit.state.underConstruction &&
|
||
!(update.underConstruction ?? false)))
|
||
) {
|
||
this._structuresDirty = true;
|
||
}
|
||
unit.update(update);
|
||
} else {
|
||
unit = new UnitView(this, update);
|
||
this._units.set(update.id, unit);
|
||
this._unitStates.set(update.id, unit.state);
|
||
this.unitGrid.addUnit(unit);
|
||
if (isStructure) this._structuresDirty = true;
|
||
}
|
||
if (!update.isActive) {
|
||
this.unitGrid.removeUnit(unit);
|
||
} else if (unit.tile() !== unit.lastTile()) {
|
||
this.unitGrid.updateUnitCell(unit);
|
||
}
|
||
if (!unit.isActive()) {
|
||
// Wait until next tick to delete the unit.
|
||
this.toDelete.add(unit.id());
|
||
if (this.unitMotionPlans.delete(unit.id())) {
|
||
this.markMotionPlannedUnitIdsDirty();
|
||
}
|
||
this.clearTrainPlanForUnit(unit.id());
|
||
}
|
||
});
|
||
|
||
this.advanceMotionPlannedUnits(gu.tick);
|
||
this.rebuildMotionPlannedUnitIdsCacheIfDirty();
|
||
|
||
this.populateFrame(gu);
|
||
}
|
||
|
||
// ── FrameData population ────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Populate the long-lived FrameData from this tick's updates and current
|
||
* state. Runs at the end of update() once all engine-driven mutations are
|
||
* complete. Mutates _frame fields in place; never reassigns them.
|
||
*/
|
||
private populateFrame(gu: GameUpdateViewData): void {
|
||
// Reset trail dirty markers for this tick. The trailManager.update() pass
|
||
// below repaints rows and re-sets these as it goes.
|
||
this.trailManager.clearDirtyRows();
|
||
|
||
// Railroad events accumulate into the cache; revealedRailTiles is cleared
|
||
// at the start of apply().
|
||
this.railroadCache.apply(gu);
|
||
|
||
// Trail update: walk active trail-type units and stamp/decay.
|
||
this._trailIdsScratch.length = 0;
|
||
for (const u of this._units.values()) {
|
||
if (u.isActive() && TRAIL_TYPES.has(u.type())) {
|
||
this._trailIdsScratch.push(u.id());
|
||
}
|
||
}
|
||
this.trailManager.update(
|
||
this._unitStates as Map<number, import("../render/types").UnitState>,
|
||
this._trailIdsScratch,
|
||
);
|
||
|
||
// Names map — rebuilt only when a placement record arrived or a player
|
||
// was added (nameData values cannot change between those ticks). Entry
|
||
// order is irrelevant for the renderer.
|
||
if (this._namesDirty) {
|
||
this._namesDirty = false;
|
||
this._names.clear();
|
||
for (const p of this._players.values()) {
|
||
this._names.set(p.id(), {
|
||
playerID: p.id(),
|
||
x: p.nameData?.x ?? 0,
|
||
y: p.nameData?.y ?? 0,
|
||
size: p.nameData?.size ?? 0,
|
||
});
|
||
}
|
||
}
|
||
|
||
// FrameEvents — clear arrays, then re-populate from this tick's updates.
|
||
this.buildFrameEvents(gu);
|
||
|
||
// Update FrameData fields. Derived data is computed once per tick and
|
||
// stored directly on _frame (no intermediate copy). The renderer's
|
||
// `readonly` modifier on FrameData is just an external API hint —
|
||
// not enforced at runtime; we cast off to assign here.
|
||
const f = this._frame as {
|
||
-readonly [K in keyof FrameData]: FrameData[K];
|
||
};
|
||
f.tick = gu.tick;
|
||
f.inSpawnPhase = this.startTick === null;
|
||
f.railroadDirty = this.railroadCache.railroadDirty;
|
||
f.trailDirtyRowMin = this.trailManager.dirtyRowMin;
|
||
f.trailDirtyRowMax = this.trailManager.dirtyRowMax;
|
||
|
||
f.playerStatus = computePlayerStatus(this._playerStates, this._unitStates, {
|
||
localPlayerSmallID: this._myPlayer?.smallID() ?? 0,
|
||
localPlayerID: this._myPlayer?.id() ?? "",
|
||
tileState: this._map.tileStateBuffer(),
|
||
tick: gu.tick,
|
||
allianceDuration: this._config.allianceDuration(),
|
||
isTransitiveTarget: (sid) =>
|
||
this._myPlayer?.hasTransitiveTarget(sid) ?? false,
|
||
doomsdayClockWarnTicks:
|
||
this._config.doomsdayClockConfig().warnSeconds * 10,
|
||
});
|
||
// Relations + clusters depend only on allies/embargoes/teams, which
|
||
// change rarely (teams only when a player is added) — recompute only
|
||
// when one of those inputs arrived this tick. buildRelationMatrix
|
||
// writes into a reusable module-level buffer, so skipping the call
|
||
// leaves f.relationMatrix's contents intact. f.relationsDirty lets the
|
||
// upload layer skip the GPU push (and the full-map border recompute it
|
||
// triggers) on unchanged ticks.
|
||
if (this._relationsDirty) {
|
||
this._relationsDirty = false;
|
||
const rel = buildRelationMatrix(this._playerStates, this._teams);
|
||
f.relationMatrix = rel.matrix;
|
||
f.relationSize = rel.size;
|
||
f.relationsDirty = true;
|
||
} else {
|
||
f.relationsDirty = false;
|
||
}
|
||
if (this._clustersDirty) {
|
||
this._clustersDirty = false;
|
||
f.allianceClusters = computeAllianceClusters(this._playerStates);
|
||
}
|
||
f.nukeTelegraphs = extractNukeTelegraphs(
|
||
this._unitStates,
|
||
this._map.width(),
|
||
this._myPlayer?.smallID() ?? 0,
|
||
// The latest relation matrix — recomputed above when dirty, otherwise
|
||
// carried over on the frame from the last rebuild.
|
||
f.relationMatrix,
|
||
f.relationSize,
|
||
);
|
||
f.attackRings = this._myPlayer
|
||
? extractAttackRings(
|
||
this._unitStates,
|
||
this._map.width(),
|
||
this._myPlayer.smallID(),
|
||
)
|
||
: [];
|
||
f.structuresDirty = this._structuresDirty;
|
||
|
||
// First populate: signal "full upload required" by nulling changedTiles.
|
||
// uploadFrameData() treats null as "no delta info; do a full tile+trail
|
||
// upload" — needed because the renderer's GPU buffers are empty.
|
||
if (this._firstPopulate) {
|
||
f.changedTiles = null;
|
||
f.structuresDirty = true; // force initial structure upload
|
||
this._firstPopulate = false;
|
||
} else {
|
||
// Live reference to this tick's changed refs — consumers copy what
|
||
// they keep (TerritoryPass buckets them synchronously in the upload).
|
||
f.changedTiles = this.updatedTiles;
|
||
}
|
||
|
||
// Reset transient flags for next tick.
|
||
this.railroadCache.clearDirty();
|
||
this._structuresDirty = false;
|
||
}
|
||
|
||
/** Clear and repopulate _frame.events arrays from this tick's gu.updates. */
|
||
private buildFrameEvents(gu: GameUpdateViewData): void {
|
||
const ev = this._frame.events;
|
||
ev.deadUnits.length = 0;
|
||
ev.conquestEvents.length = 0;
|
||
ev.bonusEvents.length = 0;
|
||
|
||
for (const u of gu.updates[GameUpdateType.Unit] ?? []) {
|
||
if (u.isActive) continue;
|
||
ev.deadUnits.push({
|
||
unitType: u.unitType,
|
||
pos: u.pos,
|
||
reachedTarget: u.reachedTarget,
|
||
ownerSmallID: u.ownerID,
|
||
});
|
||
}
|
||
const myID = this._myPlayer?.id();
|
||
for (const c of gu.updates[GameUpdateType.ConquestEvent] ?? []) {
|
||
if (c.conquerorId !== myID) continue;
|
||
const conquered = this._players.get(c.conqueredId);
|
||
if (conquered === undefined) continue;
|
||
const loc = conquered.nameLocation();
|
||
if (loc === undefined) continue;
|
||
ev.conquestEvents.push({
|
||
x: loc.x,
|
||
y: loc.y,
|
||
gold: Number(c.gold),
|
||
});
|
||
}
|
||
for (const b of gu.updates[GameUpdateType.BonusEvent] ?? []) {
|
||
const player = this._players.get(b.player);
|
||
if (player === undefined) continue;
|
||
ev.bonusEvents.push({
|
||
playerID: b.player,
|
||
smallID: player.smallID(),
|
||
tile: b.tile,
|
||
gold: Number(b.gold),
|
||
troops: b.troops,
|
||
});
|
||
}
|
||
}
|
||
|
||
/** Public accessor: the renderer reads this and uploads to the GPU. */
|
||
frameData(): FrameData {
|
||
return this._frame;
|
||
}
|
||
|
||
private advanceMotionPlannedUnits(currentTick: Tick): void {
|
||
for (const [unitId, plan] of this.unitMotionPlans) {
|
||
const unit = this._units.get(unitId);
|
||
if (!unit || !unit.isActive()) {
|
||
if (this.unitMotionPlans.delete(unitId)) {
|
||
this.markMotionPlannedUnitIdsDirty();
|
||
}
|
||
continue;
|
||
}
|
||
|
||
const oldTile = unit.tile();
|
||
const dt = currentTick - plan.startTick;
|
||
const stepIndex =
|
||
dt <= 0 ? 0 : Math.floor(dt / Math.max(1, plan.ticksPerStep));
|
||
const lastIndex = plan.path.length - 1;
|
||
const idx = Math.max(0, Math.min(lastIndex, stepIndex));
|
||
const newTile = plan.path[idx] as TileRef;
|
||
|
||
if (newTile !== oldTile) {
|
||
unit.applyDerivedPosition(newTile);
|
||
this.unitGrid.updateUnitCell(unit);
|
||
continue;
|
||
}
|
||
|
||
unit.applyDerivedRest();
|
||
|
||
// Once a plan is past its final step, `newTile` remains clamped to the last path tile.
|
||
// Drop finished plans to avoid repeatedly marking static units as updated each tick.
|
||
if (dt > 0 && stepIndex >= lastIndex) {
|
||
if (this.unitMotionPlans.delete(unitId)) {
|
||
this.markMotionPlannedUnitIdsDirty();
|
||
}
|
||
}
|
||
}
|
||
|
||
this.advanceTrainMotionPlannedUnits(currentTick);
|
||
}
|
||
|
||
private clearTrainPlanForUnit(unitId: number): void {
|
||
const engineId =
|
||
this.trainUnitToEngine.get(unitId) ??
|
||
(this.trainMotionPlans.has(unitId) ? unitId : null);
|
||
if (engineId === null) {
|
||
return;
|
||
}
|
||
const plan = this.trainMotionPlans.get(engineId);
|
||
if (!plan) {
|
||
this.trainUnitToEngine.delete(unitId);
|
||
return;
|
||
}
|
||
if (this.trainMotionPlans.delete(engineId)) {
|
||
this.markMotionPlannedUnitIdsDirty();
|
||
}
|
||
this.trainUnitToEngine.delete(engineId);
|
||
for (let i = 0; i < plan.carUnitIds.length; i++) {
|
||
const id = plan.carUnitIds[i] >>> 0;
|
||
if (id !== 0) this.trainUnitToEngine.delete(id);
|
||
}
|
||
}
|
||
|
||
private advanceTrainMotionPlannedUnits(currentTick: Tick): void {
|
||
const staleEngineIds: number[] = [];
|
||
for (const [engineId, plan] of this.trainMotionPlans) {
|
||
const engine = this._units.get(engineId);
|
||
if (!engine || !engine.isActive()) {
|
||
staleEngineIds.push(engineId);
|
||
continue;
|
||
}
|
||
|
||
const steps = currentTick - plan.lastAdvancedTick;
|
||
if (steps <= 0) {
|
||
continue;
|
||
}
|
||
|
||
const path = plan.path;
|
||
const lastIndex = path.length - 1;
|
||
const cap = plan.usedTilesBuf.length;
|
||
|
||
const pushUsed = (tile: TileRef) => {
|
||
if (cap === 0) return;
|
||
if (plan.usedLen < cap) {
|
||
const idx = (plan.usedHead + plan.usedLen) % cap;
|
||
plan.usedTilesBuf[idx] = tile >>> 0;
|
||
plan.usedLen++;
|
||
} else {
|
||
plan.usedTilesBuf[plan.usedHead] = tile >>> 0;
|
||
plan.usedHead = (plan.usedHead + 1) % cap;
|
||
plan.usedLen = cap;
|
||
}
|
||
};
|
||
|
||
const usedGet = (index: number): TileRef | null => {
|
||
if (index < 0 || index >= plan.usedLen || cap === 0) return null;
|
||
const idx = (plan.usedHead + index) % cap;
|
||
return plan.usedTilesBuf[idx] as TileRef;
|
||
};
|
||
|
||
let didMove = false;
|
||
for (let step = 0; step < steps; step++) {
|
||
const cursor = plan.cursor;
|
||
if (cursor >= lastIndex) {
|
||
break;
|
||
}
|
||
for (let i = 0; i < plan.speed && cursor + i < path.length; i++) {
|
||
pushUsed(path[cursor + i] as TileRef);
|
||
}
|
||
|
||
plan.cursor = Math.min(lastIndex, cursor + plan.speed);
|
||
|
||
for (let i = plan.carUnitIds.length - 1; i >= 0; --i) {
|
||
const carId = plan.carUnitIds[i] >>> 0;
|
||
if (carId === 0) continue;
|
||
const car = this._units.get(carId);
|
||
if (!car || !car.isActive()) {
|
||
continue;
|
||
}
|
||
const carTileIndex = (i + 1) * plan.spacing + 2;
|
||
const tile = usedGet(carTileIndex);
|
||
if (tile !== null) {
|
||
const oldTile = car.tile();
|
||
if (tile !== oldTile) {
|
||
car.applyDerivedPosition(tile);
|
||
this.unitGrid.updateUnitCell(car);
|
||
didMove = true;
|
||
}
|
||
}
|
||
}
|
||
|
||
const newEngineTile = path[plan.cursor] as TileRef;
|
||
const oldEngineTile = engine.tile();
|
||
if (newEngineTile !== oldEngineTile) {
|
||
engine.applyDerivedPosition(newEngineTile);
|
||
this.unitGrid.updateUnitCell(engine);
|
||
didMove = true;
|
||
}
|
||
}
|
||
|
||
plan.lastAdvancedTick = currentTick;
|
||
|
||
// Preserve the final-step redraw (plan remains for the tick where motion ends),
|
||
// then clear once the train has settled and no longer moves.
|
||
// Note: trains are currently deleted at the end of TrainExecution, and the ensuing
|
||
// `Unit` update (isActive=false) also clears any associated motion plan records.
|
||
// This expiry is defensive to avoid keeping stale plans around if that behavior changes.
|
||
if (!didMove && plan.cursor >= lastIndex) {
|
||
staleEngineIds.push(engineId);
|
||
}
|
||
}
|
||
|
||
for (const engineId of staleEngineIds) {
|
||
this.clearTrainPlanForUnit(engineId);
|
||
}
|
||
}
|
||
|
||
private applyMotionPlanRecords(records: readonly MotionPlanRecord[]): void {
|
||
for (const record of records) {
|
||
switch (record.kind) {
|
||
case "grid": {
|
||
if (record.ticksPerStep < 1 || record.path.length < 1) {
|
||
break;
|
||
}
|
||
const existing = this.unitMotionPlans.get(record.unitId);
|
||
if (existing && record.planId <= existing.planId) {
|
||
break;
|
||
}
|
||
|
||
const path =
|
||
record.path instanceof Uint32Array
|
||
? record.path
|
||
: Uint32Array.from(record.path);
|
||
|
||
this.unitMotionPlans.set(record.unitId, {
|
||
planId: record.planId,
|
||
startTick: record.startTick,
|
||
ticksPerStep: record.ticksPerStep,
|
||
path,
|
||
});
|
||
this.markMotionPlannedUnitIdsDirty();
|
||
break;
|
||
}
|
||
case "train": {
|
||
if (record.speed < 1 || record.path.length < 1) {
|
||
break;
|
||
}
|
||
const existing = this.trainMotionPlans.get(record.engineUnitId);
|
||
if (existing && record.planId <= existing.planId) {
|
||
break;
|
||
}
|
||
if (existing) {
|
||
this.clearTrainPlanForUnit(record.engineUnitId);
|
||
}
|
||
|
||
const carUnitIds =
|
||
record.carUnitIds instanceof Uint32Array
|
||
? record.carUnitIds
|
||
: Uint32Array.from(record.carUnitIds);
|
||
const path =
|
||
record.path instanceof Uint32Array
|
||
? record.path
|
||
: Uint32Array.from(record.path);
|
||
|
||
const usedCap = carUnitIds.length * record.spacing + 3;
|
||
const usedTilesBuf = new Uint32Array(Math.max(0, usedCap));
|
||
|
||
this.trainMotionPlans.set(record.engineUnitId, {
|
||
planId: record.planId,
|
||
startTick: record.startTick,
|
||
speed: record.speed,
|
||
spacing: record.spacing,
|
||
carUnitIds,
|
||
path,
|
||
cursor: 0,
|
||
usedTilesBuf,
|
||
usedHead: 0,
|
||
usedLen: 0,
|
||
lastAdvancedTick: record.startTick,
|
||
});
|
||
this.markMotionPlannedUnitIdsDirty();
|
||
|
||
this.trainUnitToEngine.set(record.engineUnitId, record.engineUnitId);
|
||
for (let i = 0; i < carUnitIds.length; i++) {
|
||
const carId = carUnitIds[i] >>> 0;
|
||
if (carId !== 0)
|
||
this.trainUnitToEngine.set(carId, record.engineUnitId);
|
||
}
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
recentlyUpdatedTiles(): TileRef[] {
|
||
return this.updatedTiles;
|
||
}
|
||
|
||
recentlyUpdatedTerrainTiles(): TileRef[] {
|
||
return this.updatedTerrainTiles;
|
||
}
|
||
|
||
nearbyUnits(
|
||
tile: TileRef,
|
||
searchRange: number,
|
||
types: UnitType | readonly UnitType[],
|
||
predicate?: UnitPredicate,
|
||
): Array<{ unit: UnitView; distSquared: number }> {
|
||
return this.unitGrid.nearbyUnits(
|
||
tile,
|
||
searchRange,
|
||
types,
|
||
predicate,
|
||
) as Array<{
|
||
unit: UnitView;
|
||
distSquared: number;
|
||
}>;
|
||
}
|
||
|
||
hasUnitNearby(
|
||
tile: TileRef,
|
||
searchRange: number,
|
||
type: UnitType,
|
||
playerId?: PlayerID,
|
||
includeUnderConstruction?: boolean,
|
||
) {
|
||
return this.unitGrid.hasUnitNearby(
|
||
tile,
|
||
searchRange,
|
||
type,
|
||
playerId,
|
||
includeUnderConstruction,
|
||
);
|
||
}
|
||
|
||
anyUnitNearby(
|
||
tile: TileRef,
|
||
searchRange: number,
|
||
types: readonly UnitType[],
|
||
predicate: (unit: UnitView) => boolean,
|
||
playerId?: PlayerID,
|
||
includeUnderConstruction?: boolean,
|
||
): boolean {
|
||
return this.unitGrid.anyUnitNearby(
|
||
tile,
|
||
searchRange,
|
||
types,
|
||
predicate as (unit: Unit | UnitView) => boolean,
|
||
playerId,
|
||
includeUnderConstruction,
|
||
);
|
||
}
|
||
|
||
myClientID(): ClientID | undefined {
|
||
return this._myClientID;
|
||
}
|
||
|
||
myPlayer(): PlayerView | null {
|
||
return this._myPlayer;
|
||
}
|
||
|
||
player(id: PlayerID): PlayerView {
|
||
const player = this._players.get(id);
|
||
if (player === undefined) {
|
||
throw Error(`player id ${id} not found`);
|
||
}
|
||
return player;
|
||
}
|
||
|
||
players(): PlayerView[] {
|
||
return Array.from(this._players.values());
|
||
}
|
||
|
||
/**
|
||
* Recompute every player's theme-derived colors. Call when the active theme
|
||
* changes mid-game (e.g. toggling colorblind mode) so existing territories
|
||
* re-color; the renderer palette must be refreshed afterwards.
|
||
*/
|
||
refreshPlayerColors(): void {
|
||
for (const p of this._players.values()) {
|
||
p.refreshColors();
|
||
}
|
||
}
|
||
|
||
playerBySmallID(id: number): PlayerView | TerraNullius {
|
||
if (id === 0) {
|
||
return new TerraNulliusImpl();
|
||
}
|
||
const playerId = this.smallIDToID.get(id);
|
||
if (playerId === undefined) {
|
||
throw new Error(`small id ${id} not found`);
|
||
}
|
||
return this.player(playerId);
|
||
}
|
||
|
||
playerByClientID(id: ClientID): PlayerView | null {
|
||
const player =
|
||
Array.from(this._players.values()).filter(
|
||
(p) => p.clientID() === id,
|
||
)[0] ?? null;
|
||
if (player === null) {
|
||
return null;
|
||
}
|
||
return player;
|
||
}
|
||
hasPlayer(id: PlayerID): boolean {
|
||
return false;
|
||
}
|
||
playerViews(): PlayerView[] {
|
||
return Array.from(this._players.values());
|
||
}
|
||
|
||
owner(tile: TileRef): PlayerView | TerraNullius {
|
||
return this.playerBySmallID(this.ownerID(tile));
|
||
}
|
||
|
||
ticks(): Tick {
|
||
if (this.lastUpdate === null) return 0;
|
||
return this.lastUpdate.tick;
|
||
}
|
||
inSpawnPhase(): boolean {
|
||
return this.startTick === null;
|
||
}
|
||
|
||
isSpawnImmunityActive(): boolean {
|
||
return (
|
||
this.inSpawnPhase() ||
|
||
this.ticksSinceStart() < this._config.spawnImmunityDuration()
|
||
);
|
||
}
|
||
isNationSpawnImmunityActive(): boolean {
|
||
return (
|
||
this.inSpawnPhase() ||
|
||
this.ticksSinceStart() < this._config.nationSpawnImmunityDuration()
|
||
);
|
||
}
|
||
|
||
elapsedGameSeconds(): number {
|
||
return this.ticksSinceStart() / 10;
|
||
}
|
||
|
||
ticksSinceStart(): Tick {
|
||
if (this.inSpawnPhase()) {
|
||
return 0;
|
||
}
|
||
|
||
return Math.max(0, this.ticks() - this.startTick!);
|
||
}
|
||
config(): Config {
|
||
return this._config;
|
||
}
|
||
units(...types: UnitType[]): UnitView[] {
|
||
if (types.length === 0) {
|
||
return Array.from(this._units.values()).filter((u) => u.isActive());
|
||
}
|
||
return Array.from(this._units.values()).filter(
|
||
(u) => u.isActive() && types.includes(u.type()),
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Active units owned by the given player (smallID). The grouping is built
|
||
* lazily at most once per tick; the returned array must not be mutated.
|
||
*/
|
||
unitsOwnedBy(ownerSmallID: number): readonly UnitView[] {
|
||
if (this._unitsByOwnerStale) {
|
||
this._unitsByOwnerStale = false;
|
||
this._unitsByOwner.clear();
|
||
for (const u of this._units.values()) {
|
||
if (!u.isActive()) continue;
|
||
const sid = u.state.ownerID;
|
||
const arr = this._unitsByOwner.get(sid);
|
||
if (arr === undefined) {
|
||
this._unitsByOwner.set(sid, [u]);
|
||
} else {
|
||
arr.push(u);
|
||
}
|
||
}
|
||
}
|
||
return this._unitsByOwner.get(ownerSmallID) ?? [];
|
||
}
|
||
unit(id: number): UnitView | undefined {
|
||
return this._units.get(id);
|
||
}
|
||
unitInfo(type: UnitType): UnitInfo {
|
||
return this._config.unitInfo(type);
|
||
}
|
||
|
||
/**
|
||
* Long-lived map of UnitState records, keyed by unit ID. Mutated in place
|
||
* each tick by `update()`. Renderer code reads from this directly — the
|
||
* UnitView wrapping each entry shares the same UnitState reference.
|
||
*
|
||
* Includes inactive units; renderer filters by `state.isActive`.
|
||
*/
|
||
unitStates(): ReadonlyMap<number, import("../render/types").UnitState> {
|
||
return this._unitStates;
|
||
}
|
||
|
||
/**
|
||
* Long-lived map of PlayerState records, keyed by smallID. Mutated in place
|
||
* each tick by `update()`. Renderer code reads from this directly.
|
||
*/
|
||
playerStates(): ReadonlyMap<number, import("../render/types").PlayerState> {
|
||
return this._playerStates;
|
||
}
|
||
|
||
ref(x: number, y: number): TileRef {
|
||
return this._map.ref(x, y);
|
||
}
|
||
isValidRef(ref: TileRef): boolean {
|
||
return this._map.isValidRef(ref);
|
||
}
|
||
x(ref: TileRef): number {
|
||
return this._map.x(ref);
|
||
}
|
||
y(ref: TileRef): number {
|
||
return this._map.y(ref);
|
||
}
|
||
cell(ref: TileRef): Cell {
|
||
return this._map.cell(ref);
|
||
}
|
||
width(): number {
|
||
return this._map.width();
|
||
}
|
||
height(): number {
|
||
return this._map.height();
|
||
}
|
||
numLandTiles(): number {
|
||
return this._map.numLandTiles();
|
||
}
|
||
isValidCoord(x: number, y: number): boolean {
|
||
return this._map.isValidCoord(x, y);
|
||
}
|
||
isLand(ref: TileRef): boolean {
|
||
return this._map.isLand(ref);
|
||
}
|
||
isImpassable(ref: TileRef): boolean {
|
||
return this._map.isImpassable(ref);
|
||
}
|
||
isOceanShore(ref: TileRef): boolean {
|
||
return this._map.isOceanShore(ref);
|
||
}
|
||
isOcean(ref: TileRef): boolean {
|
||
return this._map.isOcean(ref);
|
||
}
|
||
isShoreline(ref: TileRef): boolean {
|
||
return this._map.isShoreline(ref);
|
||
}
|
||
magnitude(ref: TileRef): number {
|
||
return this._map.magnitude(ref);
|
||
}
|
||
terrainByte(ref: TileRef): number {
|
||
return this._map.terrainByte(ref);
|
||
}
|
||
setWater(ref: TileRef): void {
|
||
this._map.setWater(ref);
|
||
}
|
||
setShorelineBit(ref: TileRef): void {
|
||
this._map.setShorelineBit(ref);
|
||
}
|
||
clearShorelineBit(ref: TileRef): void {
|
||
this._map.clearShorelineBit(ref);
|
||
}
|
||
setOcean(ref: TileRef): void {
|
||
this._map.setOcean(ref);
|
||
}
|
||
setMagnitude(ref: TileRef, value: number): void {
|
||
this._map.setMagnitude(ref, value);
|
||
}
|
||
ownerID(ref: TileRef): number {
|
||
return this._map.ownerID(ref);
|
||
}
|
||
hasOwner(ref: TileRef): boolean {
|
||
return this._map.hasOwner(ref);
|
||
}
|
||
setOwnerID(ref: TileRef, playerId: number): void {
|
||
return this._map.setOwnerID(ref, playerId);
|
||
}
|
||
hasFallout(ref: TileRef): boolean {
|
||
return this._map.hasFallout(ref);
|
||
}
|
||
setFallout(ref: TileRef, value: boolean): void {
|
||
return this._map.setFallout(ref, value);
|
||
}
|
||
isBorder(ref: TileRef): boolean {
|
||
return this._map.isBorder(ref);
|
||
}
|
||
neighbors(ref: TileRef): TileRef[] {
|
||
return this._map.neighbors(ref);
|
||
}
|
||
forEachNeighbor(ref: TileRef, callback: (neighbor: TileRef) => void): void {
|
||
this._map.forEachNeighbor(ref, callback);
|
||
}
|
||
neighbors4(ref: TileRef, out: TileRef[]): number {
|
||
return this._map.neighbors4(ref, out);
|
||
}
|
||
forEachNeighborWithDiag(
|
||
ref: TileRef,
|
||
callback: (neighbor: TileRef) => void,
|
||
): void {
|
||
this._map.forEachNeighborWithDiag(ref, callback);
|
||
}
|
||
isWater(ref: TileRef): boolean {
|
||
return this._map.isWater(ref);
|
||
}
|
||
isShore(ref: TileRef): boolean {
|
||
return this._map.isShore(ref);
|
||
}
|
||
cost(ref: TileRef): number {
|
||
return this._map.cost(ref);
|
||
}
|
||
terrainType(ref: TileRef): TerrainType {
|
||
return this._map.terrainType(ref);
|
||
}
|
||
forEachTile(fn: (tile: TileRef) => void): void {
|
||
return this._map.forEachTile(fn);
|
||
}
|
||
manhattanDist(c1: TileRef, c2: TileRef): number {
|
||
return this._map.manhattanDist(c1, c2);
|
||
}
|
||
euclideanDistSquared(c1: TileRef, c2: TileRef): number {
|
||
return this._map.euclideanDistSquared(c1, c2);
|
||
}
|
||
circleSearch(
|
||
tile: TileRef,
|
||
radius: number,
|
||
filter?: (tile: TileRef, d2: number) => boolean,
|
||
): Set<TileRef> {
|
||
return this._map.circleSearch(tile, radius, filter);
|
||
}
|
||
bfs(
|
||
tile: TileRef,
|
||
filter: (gm: GameMap, tile: TileRef) => boolean,
|
||
): Set<TileRef> {
|
||
return this._map.bfs(tile, filter);
|
||
}
|
||
tileState(tile: TileRef): number {
|
||
return this._map.tileState(tile);
|
||
}
|
||
tileStateBuffer(): Uint16Array {
|
||
return this._map.tileStateBuffer();
|
||
}
|
||
updateTile(tile: TileRef, state: number): boolean {
|
||
return this._map.updateTile(tile, state);
|
||
}
|
||
numTilesWithFallout(): number {
|
||
return this._map.numTilesWithFallout();
|
||
}
|
||
gameID(): GameID {
|
||
return this._gameID;
|
||
}
|
||
|
||
focusedPlayer(): PlayerView | null {
|
||
return this.myPlayer();
|
||
}
|
||
}
|