mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-23 09:43:06 +00:00
## Summary
Reduces the simulation's steady-state memory footprint. On Giant World
Map at 20 game-minutes (12 000 ticks, 400 bots, seed `perf-default`),
live memory after a full GC drops **293 MB → 161 MB (−45%)**; unforced
peak heap drops **326 MB → 165 MB**. The simulation also runs ~10%
faster (85 → 94 ticks/s). The final game-state hash is **bit-identical**
(`57830793797434300`) — no behavior change.
## Measurement (first commit)
The full-game perf harness gains a footprint mode:
- `--footprint` — forces a full GC at every `--window` boundary and
records the live heap / ArrayBuffer / RSS curve across the game
(requires `NODE_OPTIONS=--expose-gc`).
- `--snapshot-at 0,2000,12000` — writes V8 `.heapsnapshot` files at
chosen ticks.
- `HeapSnapshotRetainers.ts` — attributes every heap node to its nearest
meaningfully-named retainer (e.g. `PlayerImpl._tiles`), plus prints
retainer chains for all nodes ≥128 KB. `HeapSnapshotSummary.ts` is a
streaming fallback for snapshots too large to `JSON.parse`.
Baseline attribution at tick 12 000: player `_tiles`/`_borderTiles` Sets
**83 MB**, GameMap `refToX`/`refToY` lookup tables **38 MB**, two
duplicate 30.5 MB visited-scratch arrays, trade-ship stepper paths **15
MB**, a construction-only flood-fill queue **9.5 MB**.
## Optimizations
**Map-sized buffers (second commit):**
- `GameMap.x()/y()` compute `ref % width` / `(ref / width) | 0` instead
of reading two per-tile Uint16 tables (−38 MB). The arithmetic is
cheaper than the tables' random-access cache misses — this is where the
speedup comes from.
- `PlayerExecution` and `SpatialQuery` each kept their own per-game
generation-stamped visited `Uint32Array`; both now share one via
`TileTraversalScratch` (−30 MB).
- `PathFinderStepper` stores numeric paths as `Uint32Array` (half the
bytes; steppers hold their full path for a unit's whole journey).
- `ConnectedComponents` frees its flood-fill queue after `initialize()`.
**Player tile sets (third commit):**
- New `TileSet`: insertion-ordered set of tile refs backed by a dense
`Uint32Array` plus an open-addressing hash index — ~12 bytes/element vs
~34 for a native `Set<number>`. Deletes tombstone; compaction is
deferred while iteration is in progress so positions never shift under
an iterator.
- Iteration semantics match `Set` exactly (insertion order, entries
added mid-iteration visited, deleted ones skipped, delete+re-add moves
to end) — the simulation relies on this order for determinism, and the
unchanged hash confirms it.
- `Player.borderTiles()` now returns `ReadonlyTileSet` (a native `Set`
still satisfies it structurally); `GameRunner.playerBorderTiles` copies
into a real `Set` since that result crosses the worker boundary via
structured clone.
## Footprint curve (giant world map, live MB after forced GC)
| checkpoint | before | after |
|---|---|---|
| spawn end | 20 + 100 buf | 20 + 55 buf |
| tick 6301 | 119 + 161 buf | 29 + 127 buf |
| tick 12301 | 130 + 161 buf | 32 + 129 buf |
## Validation
- Final hash `57830793797434300` identical across baseline / round 1 /
round 2 runs (12 000 ticks).
- Full suite passes (1798 + 126 tests), including new `TileSet` tests:
order semantics, mutation-during-iteration parity with `Set`, tombstone
compaction, and a 20 000-op randomized differential test against native
`Set`.
- Runs recorded in
`tests/perf/output/footprint-{baseline,round1,round2}-giant.txt`.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
664 lines
19 KiB
TypeScript
664 lines
19 KiB
TypeScript
import { Cell, TerrainType } from "./Game";
|
||
|
||
export type TileRef = number;
|
||
|
||
export interface GameMap {
|
||
ref(x: number, y: number): TileRef;
|
||
isValidRef(ref: TileRef): boolean;
|
||
x(ref: TileRef): number;
|
||
y(ref: TileRef): number;
|
||
cell(ref: TileRef): Cell;
|
||
width(): number;
|
||
height(): number;
|
||
numLandTiles(): number;
|
||
|
||
isValidCoord(x: number, y: number): boolean;
|
||
// Terrain getters
|
||
isLand(ref: TileRef): boolean;
|
||
isImpassable(ref: TileRef): boolean;
|
||
isOceanShore(ref: TileRef): boolean;
|
||
isOcean(ref: TileRef): boolean;
|
||
isShoreline(ref: TileRef): boolean;
|
||
magnitude(ref: TileRef): number;
|
||
terrainByte(ref: TileRef): number;
|
||
// Terrain setters
|
||
setWater(ref: TileRef): void;
|
||
setShorelineBit(ref: TileRef): void;
|
||
clearShorelineBit(ref: TileRef): void;
|
||
setOcean(ref: TileRef): void;
|
||
setMagnitude(ref: TileRef, value: number): void;
|
||
// State getters and setters (mutable)
|
||
ownerID(ref: TileRef): number;
|
||
hasOwner(ref: TileRef): boolean;
|
||
|
||
setOwnerID(ref: TileRef, playerId: number): void;
|
||
hasFallout(ref: TileRef): boolean;
|
||
setFallout(ref: TileRef, value: boolean): void;
|
||
isOnEdgeOfMap(ref: TileRef): boolean;
|
||
isBorder(ref: TileRef): boolean;
|
||
neighbors(ref: TileRef): TileRef[];
|
||
// Zero-allocation neighbor iteration (cardinal only), in the same N, S, W, E
|
||
// order as neighbors(). All cardinal-neighbor helpers share this order so
|
||
// they are interchangeable even in order-sensitive simulation code.
|
||
forEachNeighbor(ref: TileRef, callback: (neighbor: TileRef) => void): void;
|
||
// Writes the cardinal neighbors of ref into out (same N, S, W, E order as
|
||
// neighbors()) and returns the count. out must have length >= 4; reuse it
|
||
// across calls to avoid allocation in hot loops.
|
||
neighbors4(ref: TileRef, out: TileRef[]): number;
|
||
// Zero-allocation neighbor iteration including diagonals, in dx-major
|
||
// order: (-1,-1),(-1,0),(-1,1),(0,-1),(0,1),(1,-1),(1,0),(1,1).
|
||
forEachNeighborWithDiag(
|
||
ref: TileRef,
|
||
callback: (neighbor: TileRef) => void,
|
||
): void;
|
||
isWater(ref: TileRef): boolean;
|
||
isShore(ref: TileRef): boolean;
|
||
cost(ref: TileRef): number;
|
||
terrainType(ref: TileRef): TerrainType;
|
||
forEachTile(fn: (tile: TileRef) => void): void;
|
||
|
||
manhattanDist(c1: TileRef, c2: TileRef): number;
|
||
euclideanDistSquared(c1: TileRef, c2: TileRef): number;
|
||
circleSearch(
|
||
tile: TileRef,
|
||
radius: number,
|
||
filter?: (tile: TileRef, d2: number) => boolean,
|
||
): Set<TileRef>;
|
||
bfs(
|
||
tile: TileRef,
|
||
filter: (gm: GameMap, tile: TileRef) => boolean,
|
||
): Set<TileRef>;
|
||
|
||
/**
|
||
* Returns the packed per-tile state as an unsigned 16-bit value (`0..65535`).
|
||
*
|
||
* Backed by a `Uint16Array` in `GameMapImpl`, so callers must treat this as `uint16`.
|
||
*/
|
||
tileState(tile: TileRef): number;
|
||
|
||
/**
|
||
* Applies a packed per-tile state value.
|
||
*
|
||
* `state` must be an unsigned 16-bit value (`0..65535`). Implementations may
|
||
* store this in a `Uint16Array` and will truncate higher bits if provided.
|
||
*
|
||
* Returns `true` when the terrain byte changed (land/water/shoreline/magnitude).
|
||
*/
|
||
updateTile(tile: TileRef, state: number): boolean;
|
||
|
||
/**
|
||
* Direct access to the per-tile state buffer for zero-copy consumers
|
||
* (e.g. WebGL renderer uploading to a R16UI texture).
|
||
*
|
||
* The returned array is a live reference — it is mutated by `updateTile()`
|
||
* each tick. Callers must not write to it.
|
||
*
|
||
* The bit layout of each `uint16` matches the renderer's tile state:
|
||
* bits 0-11: ownerID
|
||
* bit 13: fallout
|
||
* bit 14: defense bonus
|
||
*/
|
||
tileStateBuffer(): Uint16Array;
|
||
|
||
numTilesWithFallout(): number;
|
||
}
|
||
|
||
export class GameMapImpl implements GameMap {
|
||
private _numTilesWithFallout = 0;
|
||
|
||
private readonly terrain: Uint8Array; // Immutable terrain data
|
||
private readonly state: Uint16Array; // Mutable game state
|
||
private readonly width_: number;
|
||
private readonly height_: number;
|
||
|
||
// Row-start ref per y, so ref(x, y) avoids a multiply. x/y are derived from
|
||
// a ref arithmetically (ref % width, ref / width) rather than via per-tile
|
||
// lookup tables — two Uint16 tables cost 4 bytes per tile (~32 MB on the
|
||
// largest maps) and their random-access reads miss cache more often than
|
||
// the division costs.
|
||
private readonly yToRef: Int32Array;
|
||
|
||
// Terrain bits (Uint8Array)
|
||
private static readonly IS_LAND_BIT = 7;
|
||
private static readonly SHORELINE_BIT = 6;
|
||
private static readonly OCEAN_BIT = 5;
|
||
private static readonly MAGNITUDE_MASK = 0x1f; // 11111 in binary
|
||
// Land tiles with magnitude == IMPASSABLE_MAGNITUDE are impassable terrain:
|
||
// solid ground that cannot be owned, attacked, or nuked, and that nuke
|
||
// trajectories cannot cross. Rendered as the map background colour so the
|
||
// map appears non-rectangular.
|
||
private static readonly IMPASSABLE_MAGNITUDE = 31;
|
||
|
||
// State bits (Uint16Array)
|
||
private static readonly PLAYER_ID_MASK = 0xfff;
|
||
private static readonly FALLOUT_BIT = 13;
|
||
private static readonly DEFENSE_BONUS_BIT = 14;
|
||
// Bit 15 still reserved
|
||
|
||
constructor(
|
||
width: number,
|
||
height: number,
|
||
terrainData: Uint8Array,
|
||
private numLandTiles_: number,
|
||
) {
|
||
if (terrainData.length !== width * height) {
|
||
throw new Error(
|
||
`Terrain data length ${terrainData.length} doesn't match dimensions ${width}x${height}`,
|
||
);
|
||
}
|
||
this.width_ = width;
|
||
this.height_ = height;
|
||
this.terrain = terrainData;
|
||
this.state = new Uint16Array(width * height);
|
||
this.yToRef = new Int32Array(height);
|
||
for (let y = 0; y < height; y++) {
|
||
this.yToRef[y] = y * width;
|
||
}
|
||
}
|
||
numTilesWithFallout(): number {
|
||
return this._numTilesWithFallout;
|
||
}
|
||
|
||
ref(x: number, y: number): TileRef {
|
||
if (!this.isValidCoord(x, y)) {
|
||
throw new Error(`Invalid coordinates: ${x},${y}`);
|
||
}
|
||
return this.yToRef[y] + x;
|
||
}
|
||
|
||
isValidRef(ref: TileRef): boolean {
|
||
return ref >= 0 && ref < this.width_ * this.height_;
|
||
}
|
||
|
||
x(ref: TileRef): number {
|
||
return ref % this.width_;
|
||
}
|
||
|
||
y(ref: TileRef): number {
|
||
return (ref / this.width_) | 0;
|
||
}
|
||
|
||
cell(ref: TileRef): Cell {
|
||
return new Cell(this.x(ref), this.y(ref));
|
||
}
|
||
|
||
width(): number {
|
||
return this.width_;
|
||
}
|
||
height(): number {
|
||
return this.height_;
|
||
}
|
||
numLandTiles(): number {
|
||
return this.numLandTiles_;
|
||
}
|
||
|
||
isValidCoord(x: number, y: number): boolean {
|
||
return (
|
||
Number.isInteger(x) &&
|
||
Number.isInteger(y) &&
|
||
x >= 0 &&
|
||
x < this.width_ &&
|
||
y >= 0 &&
|
||
y < this.height_
|
||
);
|
||
}
|
||
|
||
// Terrain getters (immutable)
|
||
isLand(ref: TileRef): boolean {
|
||
return Boolean(this.terrain[ref] & (1 << GameMapImpl.IS_LAND_BIT));
|
||
}
|
||
|
||
isImpassable(ref: TileRef): boolean {
|
||
return (
|
||
this.isLand(ref) &&
|
||
(this.terrain[ref] & GameMapImpl.MAGNITUDE_MASK) ===
|
||
GameMapImpl.IMPASSABLE_MAGNITUDE
|
||
);
|
||
}
|
||
|
||
isOceanShore(ref: TileRef): boolean {
|
||
if (!this.isLand(ref)) {
|
||
return false;
|
||
}
|
||
const w = this.width_;
|
||
const x = ref % w;
|
||
if (x !== 0 && this.isOcean(ref - 1)) return true;
|
||
if (x !== w - 1 && this.isOcean(ref + 1)) return true;
|
||
if (ref >= w && this.isOcean(ref - w)) return true;
|
||
if (ref < (this.height_ - 1) * w && this.isOcean(ref + w)) return true;
|
||
return false;
|
||
}
|
||
|
||
isOcean(ref: TileRef): boolean {
|
||
return Boolean(this.terrain[ref] & (1 << GameMapImpl.OCEAN_BIT));
|
||
}
|
||
|
||
isShoreline(ref: TileRef): boolean {
|
||
return Boolean(this.terrain[ref] & (1 << GameMapImpl.SHORELINE_BIT));
|
||
}
|
||
|
||
magnitude(ref: TileRef): number {
|
||
return this.terrain[ref] & GameMapImpl.MAGNITUDE_MASK;
|
||
}
|
||
|
||
terrainByte(ref: TileRef): number {
|
||
return this.terrain[ref];
|
||
}
|
||
|
||
setWater(ref: TileRef): void {
|
||
if (!this.isLand(ref) || this.isImpassable(ref)) return;
|
||
this.terrain[ref] = 0; // Lake water: no land, no ocean, no shoreline, magnitude 0
|
||
this.numLandTiles_--;
|
||
}
|
||
|
||
setShorelineBit(ref: TileRef): void {
|
||
this.terrain[ref] |= 1 << GameMapImpl.SHORELINE_BIT;
|
||
}
|
||
|
||
clearShorelineBit(ref: TileRef): void {
|
||
this.terrain[ref] &= ~(1 << GameMapImpl.SHORELINE_BIT);
|
||
}
|
||
|
||
setOcean(ref: TileRef): void {
|
||
this.terrain[ref] |= 1 << GameMapImpl.OCEAN_BIT;
|
||
}
|
||
|
||
setMagnitude(ref: TileRef, value: number): void {
|
||
this.terrain[ref] =
|
||
(this.terrain[ref] & ~GameMapImpl.MAGNITUDE_MASK) |
|
||
(value & GameMapImpl.MAGNITUDE_MASK);
|
||
}
|
||
|
||
// State getters and setters (mutable)
|
||
ownerID(ref: TileRef): number {
|
||
return this.state[ref] & GameMapImpl.PLAYER_ID_MASK;
|
||
}
|
||
|
||
hasOwner(ref: TileRef): boolean {
|
||
return this.ownerID(ref) !== 0;
|
||
}
|
||
|
||
setOwnerID(ref: TileRef, playerId: number): void {
|
||
if (playerId > GameMapImpl.PLAYER_ID_MASK) {
|
||
throw new Error(
|
||
`Player ID ${playerId} exceeds maximum value ${GameMapImpl.PLAYER_ID_MASK}`,
|
||
);
|
||
}
|
||
this.state[ref] =
|
||
(this.state[ref] & ~GameMapImpl.PLAYER_ID_MASK) | playerId;
|
||
}
|
||
|
||
hasFallout(ref: TileRef): boolean {
|
||
return Boolean(this.state[ref] & (1 << GameMapImpl.FALLOUT_BIT));
|
||
}
|
||
|
||
setFallout(ref: TileRef, value: boolean): void {
|
||
const existingFallout = this.hasFallout(ref);
|
||
if (value) {
|
||
if (!existingFallout) {
|
||
this._numTilesWithFallout++;
|
||
this.state[ref] |= 1 << GameMapImpl.FALLOUT_BIT;
|
||
}
|
||
} else {
|
||
if (existingFallout) {
|
||
this._numTilesWithFallout--;
|
||
this.state[ref] &= ~(1 << GameMapImpl.FALLOUT_BIT);
|
||
}
|
||
}
|
||
}
|
||
|
||
isOnEdgeOfMap(ref: TileRef): boolean {
|
||
const x = this.x(ref);
|
||
const y = this.y(ref);
|
||
return (
|
||
x === 0 || x === this.width() - 1 || y === 0 || y === this.height() - 1
|
||
);
|
||
}
|
||
|
||
isBorder(ref: TileRef): boolean {
|
||
const w = this.width_;
|
||
const x = ref % w;
|
||
const owner = this.ownerID(ref);
|
||
if (x !== 0 && this.ownerID(ref - 1) !== owner) return true;
|
||
if (x !== w - 1 && this.ownerID(ref + 1) !== owner) return true;
|
||
if (ref >= w && this.ownerID(ref - w) !== owner) return true;
|
||
if (ref < (this.height_ - 1) * w && this.ownerID(ref + w) !== owner) {
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
hasDefenseBonus(ref: TileRef): boolean {
|
||
return Boolean(this.state[ref] & (1 << GameMapImpl.DEFENSE_BONUS_BIT));
|
||
}
|
||
|
||
setDefenseBonus(ref: TileRef, value: boolean): void {
|
||
if (value) {
|
||
this.state[ref] |= 1 << GameMapImpl.DEFENSE_BONUS_BIT;
|
||
} else {
|
||
this.state[ref] &= ~(1 << GameMapImpl.DEFENSE_BONUS_BIT);
|
||
}
|
||
}
|
||
|
||
// Helper methods
|
||
isWater(ref: TileRef): boolean {
|
||
return !this.isLand(ref);
|
||
}
|
||
|
||
isShore(ref: TileRef): boolean {
|
||
return this.isLand(ref) && this.isShoreline(ref);
|
||
}
|
||
|
||
cost(ref: TileRef): number {
|
||
return this.magnitude(ref) < 10 ? 2 : 1;
|
||
}
|
||
|
||
// if updating these magnitude values, also update
|
||
// `../../../map-generator/map_generator.go` `getThumbnailColor`
|
||
terrainType(ref: TileRef): TerrainType {
|
||
if (this.isLand(ref)) {
|
||
const magnitude = this.magnitude(ref);
|
||
if (magnitude >= GameMapImpl.IMPASSABLE_MAGNITUDE)
|
||
return TerrainType.Impassable;
|
||
if (magnitude < 10) return TerrainType.Plains;
|
||
if (magnitude < 20) return TerrainType.Highland;
|
||
return TerrainType.Mountain;
|
||
}
|
||
return TerrainType.Ocean;
|
||
}
|
||
|
||
neighbors(ref: TileRef): TileRef[] {
|
||
const neighbors: TileRef[] = [];
|
||
const w = this.width_;
|
||
const x = ref % w;
|
||
|
||
if (ref >= w) neighbors.push(ref - w);
|
||
if (ref < (this.height_ - 1) * w) neighbors.push(ref + w);
|
||
if (x !== 0) neighbors.push(ref - 1);
|
||
if (x !== w - 1) neighbors.push(ref + 1);
|
||
|
||
return neighbors;
|
||
}
|
||
|
||
forEachNeighbor(ref: TileRef, callback: (neighbor: TileRef) => void): void {
|
||
const w = this.width_;
|
||
const x = ref % w;
|
||
|
||
if (ref >= w) callback(ref - w);
|
||
if (ref < (this.height_ - 1) * w) callback(ref + w);
|
||
if (x !== 0) callback(ref - 1);
|
||
if (x !== w - 1) callback(ref + 1);
|
||
}
|
||
|
||
neighbors4(ref: TileRef, out: TileRef[]): number {
|
||
const w = this.width_;
|
||
const x = ref % w;
|
||
let n = 0;
|
||
|
||
if (ref >= w) out[n++] = ref - w;
|
||
if (ref < (this.height_ - 1) * w) out[n++] = ref + w;
|
||
if (x !== 0) out[n++] = ref - 1;
|
||
if (x !== w - 1) out[n++] = ref + 1;
|
||
return n;
|
||
}
|
||
|
||
forEachNeighborWithDiag(
|
||
ref: TileRef,
|
||
callback: (neighbor: TileRef) => void,
|
||
): void {
|
||
const w = this.width_;
|
||
const x = ref % w;
|
||
const hasN = ref >= w;
|
||
const hasS = ref < (this.height_ - 1) * w;
|
||
|
||
if (x !== 0) {
|
||
if (hasN) callback(ref - 1 - w);
|
||
callback(ref - 1);
|
||
if (hasS) callback(ref - 1 + w);
|
||
}
|
||
if (hasN) callback(ref - w);
|
||
if (hasS) callback(ref + w);
|
||
if (x !== w - 1) {
|
||
if (hasN) callback(ref + 1 - w);
|
||
callback(ref + 1);
|
||
if (hasS) callback(ref + 1 + w);
|
||
}
|
||
}
|
||
|
||
forEachTile(fn: (tile: TileRef) => void): void {
|
||
for (let ref: TileRef = 0; ref < this.width_ * this.height_; ref++) {
|
||
fn(ref);
|
||
}
|
||
}
|
||
|
||
manhattanDist(c1: TileRef, c2: TileRef): number {
|
||
return (
|
||
Math.abs(this.x(c1) - this.x(c2)) + Math.abs(this.y(c1) - this.y(c2))
|
||
);
|
||
}
|
||
euclideanDistSquared(c1: TileRef, c2: TileRef): number {
|
||
const x = this.x(c1) - this.x(c2);
|
||
const y = this.y(c1) - this.y(c2);
|
||
return x * x + y * y;
|
||
}
|
||
circleSearch(
|
||
tile: TileRef,
|
||
radius: number,
|
||
filter?: (tile: TileRef, d2: number) => boolean,
|
||
): Set<TileRef> {
|
||
const center = { x: this.x(tile), y: this.y(tile) };
|
||
const tiles: Set<TileRef> = new Set<TileRef>();
|
||
const minX = Math.max(0, center.x - radius);
|
||
const maxX = Math.min(this.width_ - 1, center.x + radius);
|
||
const minY = Math.max(0, center.y - radius);
|
||
const maxY = Math.min(this.height_ - 1, center.y + radius);
|
||
for (let i = minX; i <= maxX; ++i) {
|
||
for (let j = minY; j <= maxY; j++) {
|
||
const t = this.yToRef[j] + i;
|
||
const d2 = this.euclideanDistSquared(tile, t);
|
||
if (d2 > radius * radius) continue;
|
||
if (!filter || filter(t, d2)) {
|
||
tiles.add(t);
|
||
}
|
||
}
|
||
}
|
||
return tiles;
|
||
}
|
||
bfs(
|
||
tile: TileRef,
|
||
filter: (gm: GameMap, tile: TileRef) => boolean,
|
||
): Set<TileRef> {
|
||
const seen = new Set<TileRef>();
|
||
const q: TileRef[] = [];
|
||
if (filter(this, tile)) {
|
||
seen.add(tile);
|
||
q.push(tile);
|
||
}
|
||
|
||
// Neighbors are enumerated inline in the same order as neighbors() to
|
||
// avoid allocating an array per visited tile.
|
||
const w = this.width_;
|
||
const southLimit = (this.height_ - 1) * w;
|
||
const visit = (n: TileRef) => {
|
||
if (!seen.has(n) && filter(this, n)) {
|
||
seen.add(n);
|
||
q.push(n);
|
||
}
|
||
};
|
||
while (q.length > 0) {
|
||
const curr = q.pop();
|
||
if (curr === undefined) continue;
|
||
const x = curr % w;
|
||
if (curr >= w) visit(curr - w);
|
||
if (curr < southLimit) visit(curr + w);
|
||
if (x !== 0) visit(curr - 1);
|
||
if (x !== w - 1) visit(curr + 1);
|
||
}
|
||
return seen;
|
||
}
|
||
|
||
tileState(tile: TileRef): number {
|
||
return this.state[tile];
|
||
}
|
||
|
||
tileStateBuffer(): Uint16Array {
|
||
return this.state;
|
||
}
|
||
|
||
/**
|
||
* Update a tile from a packed uint32:
|
||
* bits 0-15: tile state (owner, fallout, etc.)
|
||
* bits 16-23: terrain byte (land, ocean, shoreline, magnitude)
|
||
*/
|
||
updateTile(tile: TileRef, packed: number): boolean {
|
||
const state = packed & 0xffff;
|
||
const terrainByte = (packed >>> 16) & 0xff;
|
||
|
||
const existingFallout = this.hasFallout(tile);
|
||
this.state[tile] = state;
|
||
const newFallout = this.hasFallout(tile);
|
||
if (existingFallout && !newFallout) {
|
||
this._numTilesWithFallout--;
|
||
}
|
||
if (!existingFallout && newFallout) {
|
||
this._numTilesWithFallout++;
|
||
}
|
||
|
||
// Update terrain if the packed value includes a terrain byte that differs
|
||
const terrainChanged = this.terrain[tile] !== terrainByte;
|
||
if (terrainChanged) {
|
||
const wasLand = this.isLand(tile);
|
||
this.terrain[tile] = terrainByte;
|
||
const isNowLand = Boolean(terrainByte & (1 << GameMapImpl.IS_LAND_BIT));
|
||
if (wasLand && !isNowLand) this.numLandTiles_--;
|
||
else if (!wasLand && isNowLand) this.numLandTiles_++;
|
||
}
|
||
return terrainChanged;
|
||
}
|
||
}
|
||
|
||
export function euclDistFN(
|
||
root: TileRef,
|
||
dist: number,
|
||
center: boolean = false,
|
||
): (gm: GameMap, tile: TileRef) => boolean {
|
||
const dist2 = dist * dist;
|
||
if (!center) {
|
||
return (gm: GameMap, n: TileRef) =>
|
||
gm.euclideanDistSquared(root, n) <= dist2;
|
||
} else {
|
||
return (gm: GameMap, n: TileRef) => {
|
||
// shifts the root tile’s coordinates by -0.5 so that its “center”
|
||
// center becomes the corner of four pixels rather than the middle of one pixel.
|
||
// just makes things based off even pixels instead of odd. Used to use 9x9 icons now 10x10 icons etc...
|
||
const rootX = gm.x(root) - 0.5;
|
||
const rootY = gm.y(root) - 0.5;
|
||
const dx = gm.x(n) - rootX;
|
||
const dy = gm.y(n) - rootY;
|
||
return dx * dx + dy * dy <= dist2;
|
||
};
|
||
}
|
||
}
|
||
|
||
export function manhattanDistFN(
|
||
root: TileRef,
|
||
dist: number,
|
||
center: boolean = false,
|
||
): (gm: GameMap, tile: TileRef) => boolean {
|
||
if (!center) {
|
||
return (gm: GameMap, n: TileRef) => gm.manhattanDist(root, n) <= dist;
|
||
} else {
|
||
return (gm: GameMap, n: TileRef) => {
|
||
const rootX = gm.x(root) - 0.5;
|
||
const rootY = gm.y(root) - 0.5;
|
||
const dx = Math.abs(gm.x(n) - rootX);
|
||
const dy = Math.abs(gm.y(n) - rootY);
|
||
return dx + dy <= dist;
|
||
};
|
||
}
|
||
}
|
||
|
||
export function rectDistFN(
|
||
root: TileRef,
|
||
dist: number,
|
||
center: boolean = false,
|
||
): (gm: GameMap, tile: TileRef) => boolean {
|
||
if (!center) {
|
||
return (gm: GameMap, n: TileRef) => {
|
||
const dx = Math.abs(gm.x(n) - gm.x(root));
|
||
const dy = Math.abs(gm.y(n) - gm.y(root));
|
||
return dx <= dist && dy <= dist;
|
||
};
|
||
} else {
|
||
return (gm: GameMap, n: TileRef) => {
|
||
const rootX = gm.x(root) - 0.5;
|
||
const rootY = gm.y(root) - 0.5;
|
||
const dx = Math.abs(gm.x(n) - rootX);
|
||
const dy = Math.abs(gm.y(n) - rootY);
|
||
return dx <= dist && dy <= dist;
|
||
};
|
||
}
|
||
}
|
||
|
||
function isInIsometricTile(
|
||
center: { x: number; y: number },
|
||
tile: { x: number; y: number },
|
||
yOffset: number,
|
||
distance: number,
|
||
): boolean {
|
||
const dx = Math.abs(tile.x - center.x);
|
||
const dy = Math.abs(tile.y - (center.y + yOffset));
|
||
return dx + dy * 2 <= distance + 1;
|
||
}
|
||
|
||
export function isometricDistFN(
|
||
root: TileRef,
|
||
dist: number,
|
||
center: boolean = false,
|
||
): (gm: GameMap, tile: TileRef) => boolean {
|
||
if (!center) {
|
||
return (gm: GameMap, n: TileRef) => gm.manhattanDist(root, n) <= dist;
|
||
} else {
|
||
return (gm: GameMap, n: TileRef) => {
|
||
const rootX = gm.x(root) - 0.5;
|
||
const rootY = gm.y(root) - 0.5;
|
||
|
||
return isInIsometricTile(
|
||
{ x: rootX, y: rootY },
|
||
{ x: gm.x(n), y: gm.y(n) },
|
||
0,
|
||
dist,
|
||
);
|
||
};
|
||
}
|
||
}
|
||
|
||
export function hexDistFN(
|
||
root: TileRef,
|
||
dist: number,
|
||
center: boolean = false,
|
||
): (gm: GameMap, tile: TileRef) => boolean {
|
||
if (!center) {
|
||
return (gm: GameMap, n: TileRef) => {
|
||
const dx = Math.abs(gm.x(n) - gm.x(root));
|
||
const dy = Math.abs(gm.y(n) - gm.y(root));
|
||
return dx <= dist && dy <= dist && dx + dy <= dist * 1.5;
|
||
};
|
||
} else {
|
||
return (gm: GameMap, n: TileRef) => {
|
||
const rootX = gm.x(root) - 0.5;
|
||
const rootY = gm.y(root) - 0.5;
|
||
const dx = Math.abs(gm.x(n) - rootX);
|
||
const dy = Math.abs(gm.y(n) - rootY);
|
||
return dx <= dist && dy <= dist && dx + dy <= dist * 1.5;
|
||
};
|
||
}
|
||
}
|
||
|
||
export function andFN(
|
||
x: (gm: GameMap, tile: TileRef) => boolean,
|
||
y: (gm: GameMap, tile: TileRef) => boolean,
|
||
): (gm: GameMap, tile: TileRef) => boolean {
|
||
return (gm: GameMap, tile: TileRef) => x(gm, tile) && y(gm, tile);
|
||
}
|