Files
OpenFrontIO/src/core/game/GameView.ts
T
Ralfi Salhon 015e3c7d19 feat: Attacking Troops Overlay (#3427)
## Description:

https://troop-advantage-layer.openfront.dev/

Hey OpenFront dev team, I've been really enjoying the game, and the
v0.30 changes have felt great so far. Happy to start contributing!

This PR introduces `AttackingTroopsOverlay`, a layer that renders live
attacker vs. defender troop counts directly on active front lines.
Players can immediately gauge combat strength without leaving the map
view.


![troop-advantage-layer](https://github.com/user-attachments/assets/9e862812-84b4-46cb-a0c6-65fa50320198)

A recent change updates the layer to just the # of attackers and a
symbol for attack/defence:

![visual-front-line](https://github.com/user-attachments/assets/46bc7117-2314-44c9-96fc-8a7e9c6ab5cd)

Left: Perspective of Anon 667 (Blue) | Right: Perspective of Anon332
(Red)

![ezgif-6261e6669d6b972b](https://github.com/user-attachments/assets/734d90c1-8f22-44dc-8f2f-b22e46676f46)

**How it works:**
- Attacker count shown for ground invasions. When attacking, your troop
count will display amber for disadvantageous, and green for advantageous
battles. When defending, the enemy troop count will switch to red if you
are at a severe disadvantage.
- Label position recalculates every tick at 200ms, tracking the front
line as it moves.
- Automatically hidden during Terrain view (spacebar)
- Labels clean up when an attack ends or its target becomes invalid

**Settings:** An "Attacking Troops Overlay" toggle is added to Settings,
enabled by default.
--> the screenshot is old, but the text has been updated
<img width="448" height="410" alt="Settings toggle"
src="https://github.com/user-attachments/assets/2df8ec7a-3f77-48b7-a9b5-ee4a6eed0412"
/>

## Checklist

- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory
- [x] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced

## Discord
Radyus
2026-03-19 15:04:33 -07:00

1321 lines
35 KiB
TypeScript

import { Colord, colord } from "colord";
import { base64url } from "jose";
import { Config } from "../configuration/Config";
import { ColorPalette } from "../CosmeticSchemas";
import { PatternDecoder } from "../PatternDecoder";
import { ClientID, GameID, Player, PlayerCosmetics } from "../Schemas";
import { createRandomName, formatPlayerDisplayName } from "../Util";
import { WorkerClient } from "../worker/WorkerClient";
import {
BuildableUnit,
Cell,
EmojiMessage,
GameUpdates,
Gold,
NameViewData,
PlayerActions,
PlayerBorderTiles,
PlayerBuildableUnitType,
PlayerID,
PlayerProfile,
PlayerType,
Team,
TerrainType,
TerraNullius,
Tick,
TrainType,
UnitInfo,
UnitType,
} from "./Game";
import { GameMap, TileRef } from "./GameMap";
import {
AllianceView,
AttackUpdate,
GameUpdateType,
GameUpdateViewData,
PlayerUpdate,
UnitUpdate,
} from "./GameUpdates";
import { MotionPlanRecord, unpackMotionPlans } from "./MotionPlans";
import { TerrainMapData } from "./TerrainMapLoader";
import { TerraNulliusImpl } from "./TerraNulliusImpl";
import { UnitGrid, UnitPredicate } from "./UnitGrid";
import { UserSettings } from "./UserSettings";
const userSettings: UserSettings = new UserSettings();
const FRIENDLY_TINT_TARGET = { r: 0, g: 255, b: 0, a: 1 };
const EMBARGO_TINT_TARGET = { r: 255, g: 0, b: 0, a: 1 };
const BORDER_TINT_RATIO = 0.35;
export class UnitView {
public _wasUpdated = true;
public lastPos: TileRef[] = [];
private _createdAt: Tick;
constructor(
private gameView: GameView,
private data: UnitUpdate,
) {
this.lastPos.push(data.pos);
this._createdAt = this.gameView.ticks();
}
createdAt(): Tick {
return this._createdAt;
}
wasUpdated(): boolean {
return this._wasUpdated;
}
lastTiles(): TileRef[] {
return this.lastPos;
}
lastTile(): TileRef {
if (this.lastPos.length === 0) {
return this.data.pos;
}
return this.lastPos[0];
}
update(data: UnitUpdate) {
this.lastPos.push(data.pos);
this._wasUpdated = true;
this.data = data;
}
applyDerivedPosition(pos: TileRef) {
const prev = this.data.pos;
this.lastPos.push(pos);
this._wasUpdated = true;
this.data = {
...this.data,
lastPos: prev,
pos,
};
}
id(): number {
return this.data.id;
}
targetable(): boolean {
return this.data.targetable;
}
markedForDeletion(): number | false {
return this.data.markedForDeletion;
}
type(): UnitType {
return this.data.unitType;
}
troops(): number {
return this.data.troops;
}
retreating(): boolean {
if (this.type() !== UnitType.TransportShip) {
throw Error("Must be a transport ship");
}
return this.data.retreating;
}
tile(): TileRef {
return this.data.pos;
}
owner(): PlayerView {
return this.gameView.playerBySmallID(this.data.ownerID)! as PlayerView;
}
isActive(): boolean {
return this.data.isActive;
}
reachedTarget(): boolean {
return this.data.reachedTarget;
}
hasHealth(): boolean {
return this.data.health !== undefined;
}
health(): number {
return this.data.health ?? 0;
}
isUnderConstruction(): boolean {
return this.data.underConstruction === true;
}
targetUnitId(): number | undefined {
return this.data.targetUnitId;
}
targetTile(): TileRef | undefined {
return this.data.targetTile;
}
// How "ready" this unit is from 0 to 1.
missileReadinesss(): number {
const maxMissiles = this.data.level;
const missilesReloading = this.data.missileTimerQueue.length;
if (missilesReloading === 0) {
return 1;
}
const missilesReady = maxMissiles - missilesReloading;
if (missilesReady === 0 && maxMissiles > 1) {
// Unless we have just one missile (level 1),
// show 0% readiness so user knows no missiles are ready.
return 0;
}
let readiness = missilesReady / maxMissiles;
const cooldownDuration =
this.data.unitType === UnitType.SAMLauncher
? this.gameView.config().SAMCooldown()
: this.gameView.config().SiloCooldown();
for (const cooldown of this.data.missileTimerQueue) {
const cooldownProgress = this.gameView.ticks() - cooldown;
const cooldownRatio = cooldownProgress / cooldownDuration;
const adjusted = cooldownRatio / maxMissiles;
readiness += adjusted;
}
return readiness;
}
level(): number {
return this.data.level;
}
hasTrainStation(): boolean {
return this.data.hasTrainStation;
}
trainType(): TrainType | undefined {
return this.data.trainType;
}
isLoaded(): boolean | undefined {
return this.data.loaded;
}
}
export class PlayerView {
public anonymousName: string | null = null;
private decoder?: PatternDecoder;
private _territoryColor: Colord;
private _borderColor: Colord;
// Update here to include structure light and dark colors
private _structureColors: { light: Colord; dark: Colord };
// Pre-computed border color variants
private _borderColorNeutral: Colord;
private _borderColorFriendly: Colord;
private _borderColorEmbargo: Colord;
private _borderColorDefendedNeutral: { light: Colord; dark: Colord };
private _borderColorDefendedFriendly: { light: Colord; dark: Colord };
private _borderColorDefendedEmbargo: { light: Colord; dark: Colord };
constructor(
private game: GameView,
public data: PlayerUpdate,
public nameData: NameViewData,
public cosmetics: PlayerCosmetics,
) {
if (data.clientID === game.myClientID()) {
this.anonymousName = this.data.name;
} else {
this.anonymousName = createRandomName(
this.data.name,
this.data.playerType,
);
}
const defaultTerritoryColor = this.game
.config()
.theme()
.territoryColor(this);
const defaultBorderColor = this.game
.config()
.theme()
.borderColor(defaultTerritoryColor);
const pattern = userSettings.territoryPatterns()
? this.cosmetics.pattern
: undefined;
if (pattern) {
pattern.colorPalette ??= {
name: "",
primaryColor: defaultTerritoryColor.toHex(),
secondaryColor: defaultBorderColor.toHex(),
} satisfies ColorPalette;
}
if (this.team() === null) {
this._territoryColor = colord(
this.cosmetics.color?.color ??
pattern?.colorPalette?.primaryColor ??
defaultTerritoryColor.toHex(),
);
} else {
this._territoryColor = defaultTerritoryColor;
}
this._structureColors = this.game
.config()
.theme()
.structureColors(this._territoryColor);
const maybeFocusedBorderColor =
this.game.myClientID() === this.data.clientID
? this.game.config().theme().focusedBorderColor()
: defaultBorderColor;
this._borderColor = new Colord(
pattern?.colorPalette?.secondaryColor ??
this.cosmetics.color?.color ??
maybeFocusedBorderColor.toHex(),
);
// Pre-compute all border color variants once
const theme = this.game.config().theme();
const baseRgb = this._borderColor.toRgb();
// Neutral is just the base color
this._borderColorNeutral = this._borderColor;
// Compute friendly tint
this._borderColorFriendly = colord({
r: Math.round(
baseRgb.r * (1 - BORDER_TINT_RATIO) +
FRIENDLY_TINT_TARGET.r * BORDER_TINT_RATIO,
),
g: Math.round(
baseRgb.g * (1 - BORDER_TINT_RATIO) +
FRIENDLY_TINT_TARGET.g * BORDER_TINT_RATIO,
),
b: Math.round(
baseRgb.b * (1 - BORDER_TINT_RATIO) +
FRIENDLY_TINT_TARGET.b * BORDER_TINT_RATIO,
),
a: baseRgb.a,
});
// Compute embargo tint
this._borderColorEmbargo = colord({
r: Math.round(
baseRgb.r * (1 - BORDER_TINT_RATIO) +
EMBARGO_TINT_TARGET.r * BORDER_TINT_RATIO,
),
g: Math.round(
baseRgb.g * (1 - BORDER_TINT_RATIO) +
EMBARGO_TINT_TARGET.g * BORDER_TINT_RATIO,
),
b: Math.round(
baseRgb.b * (1 - BORDER_TINT_RATIO) +
EMBARGO_TINT_TARGET.b * BORDER_TINT_RATIO,
),
a: baseRgb.a,
});
// Pre-compute defended variants
this._borderColorDefendedNeutral = theme.defendedBorderColors(
this._borderColorNeutral,
);
this._borderColorDefendedFriendly = theme.defendedBorderColors(
this._borderColorFriendly,
);
this._borderColorDefendedEmbargo = theme.defendedBorderColors(
this._borderColorEmbargo,
);
this.decoder =
pattern === undefined
? undefined
: new PatternDecoder(pattern, base64url.decode);
}
territoryColor(tile?: TileRef): Colord {
if (tile === undefined || this.decoder === undefined) {
return this._territoryColor;
}
const isPrimary = this.decoder.isPrimary(
this.game.x(tile),
this.game.y(tile),
);
return isPrimary ? this._territoryColor : this._borderColor;
}
structureColors(): { light: Colord; dark: Colord } {
return this._structureColors;
}
/**
* Border color for a tile:
* - Tints by neighbor relations (embargo → red, friendly → green, else neutral).
* - If defended, applies theme checkerboard to the tinted color.
*/
borderColor(tile?: TileRef, isDefended: boolean = false): Colord {
if (tile === undefined) {
return this._borderColor;
}
const { hasEmbargo, hasFriendly } = this.borderRelationFlags(tile);
let baseColor: Colord;
let defendedColors: { light: Colord; dark: Colord };
if (hasEmbargo) {
baseColor = this._borderColorEmbargo;
defendedColors = this._borderColorDefendedEmbargo;
} else if (hasFriendly) {
baseColor = this._borderColorFriendly;
defendedColors = this._borderColorDefendedFriendly;
} else {
baseColor = this._borderColorNeutral;
defendedColors = this._borderColorDefendedNeutral;
}
if (!isDefended) {
return baseColor;
}
const x = this.game.x(tile);
const y = this.game.y(tile);
const lightTile =
(x % 2 === 0 && y % 2 === 0) || (y % 2 === 1 && x % 2 === 1);
return lightTile ? defendedColors.light : defendedColors.dark;
}
/**
* Border relation flags for a tile, used by both CPU and WebGL renderers.
*/
borderRelationFlags(tile: TileRef): {
hasEmbargo: boolean;
hasFriendly: boolean;
} {
const mySmallID = this.smallID();
let hasEmbargo = false;
let hasFriendly = false;
for (const n of this.game.neighbors(tile)) {
if (!this.game.hasOwner(n)) {
continue;
}
const otherOwner = this.game.owner(n);
if (!otherOwner.isPlayer() || otherOwner.smallID() === mySmallID) {
continue;
}
if (this.hasEmbargo(otherOwner)) {
hasEmbargo = true;
break;
}
if (this.isFriendly(otherOwner) || otherOwner.isFriendly(this)) {
hasFriendly = true;
}
}
return { hasEmbargo, hasFriendly };
}
async actions(
tile?: TileRef,
units?: readonly PlayerBuildableUnitType[] | null,
): Promise<PlayerActions> {
return this.game.worker.playerInteraction(
this.id(),
tile && this.game.x(tile),
tile && this.game.y(tile),
units,
);
}
async buildables(
tile?: TileRef,
units?: readonly PlayerBuildableUnitType[],
): Promise<BuildableUnit[]> {
return this.game.worker.playerBuildables(
this.id(),
tile && this.game.x(tile),
tile && this.game.y(tile),
units,
);
}
async borderTiles(): Promise<PlayerBorderTiles> {
return this.game.worker.playerBorderTiles(this.id());
}
outgoingAttacks(): AttackUpdate[] {
return this.data.outgoingAttacks;
}
incomingAttacks(): AttackUpdate[] {
return this.data.incomingAttacks;
}
async attackClusteredPositions(
attackID?: string,
): Promise<{ id: string; positions: Cell[] }[]> {
return this.game.worker.attackClusteredPositions(this.smallID(), attackID);
}
units(...types: UnitType[]): UnitView[] {
return this.game
.units(...types)
.filter((u) => u.owner().smallID() === this.smallID());
}
nameLocation(): NameViewData {
return this.nameData;
}
smallID(): number {
return this.data.smallID;
}
name(): string {
return this.anonymousName !== null && userSettings.anonymousNames()
? this.anonymousName
: this.data.name;
}
displayName(): string {
return this.anonymousName !== null && userSettings.anonymousNames()
? this.anonymousName
: this.data.displayName;
}
clientID(): ClientID | null {
return this.data.clientID;
}
id(): PlayerID {
return this.data.id;
}
team(): Team | null {
return this.data.team ?? null;
}
type(): PlayerType {
return this.data.playerType;
}
isAlive(): boolean {
return this.data.isAlive;
}
isPlayer(): this is PlayerView {
return true;
}
numTilesOwned(): number {
return this.data.tilesOwned;
}
allies(): PlayerView[] {
return this.data.allies.map(
(a) => this.game.playerBySmallID(a) as PlayerView,
);
}
targets(): PlayerView[] {
return this.data.targets.map(
(id) => this.game.playerBySmallID(id) as PlayerView,
);
}
gold(): Gold {
return this.data.gold;
}
troops(): number {
return this.data.troops;
}
totalUnitLevels(type: UnitType): number {
return this.units(type)
.filter((unit) => !unit.isUnderConstruction())
.map((unit) => unit.level())
.reduce((a, b) => a + b, 0);
}
isMe(): boolean {
return this.smallID() === this.game.myPlayer()?.smallID();
}
isLobbyCreator(): boolean {
return this.data.isLobbyCreator;
}
isAlliedWith(other: PlayerView): boolean {
return this.data.allies.some((n) => other.smallID() === n);
}
isOnSameTeam(other: PlayerView): boolean {
return this.data.team !== undefined && this.data.team === other.data.team;
}
isFriendly(other: PlayerView): boolean {
return this.isAlliedWith(other) || this.isOnSameTeam(other);
}
isRequestingAllianceWith(other: PlayerView) {
return this.data.outgoingAllianceRequests.some((id) => other.id() === id);
}
alliances(): AllianceView[] {
return this.data.alliances;
}
hasEmbargoAgainst(other: PlayerView): boolean {
return this.data.embargoes.has(other.id());
}
hasEmbargo(other: PlayerView): boolean {
return this.hasEmbargoAgainst(other) || other.hasEmbargoAgainst(this);
}
profile(): Promise<PlayerProfile> {
return this.game.worker.playerProfile(this.smallID());
}
bestTransportShipSpawn(targetTile: TileRef): Promise<TileRef | false> {
return this.game.worker.transportShipSpawn(this.id(), targetTile);
}
transitiveTargets(): PlayerView[] {
return [...this.targets(), ...this.allies().flatMap((p) => p.targets())];
}
isTraitor(): boolean {
return this.data.isTraitor;
}
getTraitorRemainingTicks(): number {
return Math.max(0, this.data.traitorRemainingTicks ?? 0);
}
outgoingEmojis(): EmojiMessage[] {
return this.data.outgoingEmojis;
}
hasSpawned(): boolean {
return this.data.hasSpawned;
}
isDisconnected(): boolean {
return this.data.isDisconnected;
}
lastDeleteUnitTick(): Tick {
return this.data.lastDeleteUnitTick;
}
deleteUnitCooldown(): number {
return (
Math.max(
0,
this.game.config().deleteUnitCooldown() -
(this.game.ticks() + 1 - this.lastDeleteUnitTick()),
) / 10
);
}
}
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 smallIDToID = new Map<number, PlayerID>();
private _players = new Map<PlayerID, PlayerView>();
private _units = new Map<number, UnitView>();
private updatedTiles: TileRef[] = [];
private _myPlayer: PlayerView | null = null;
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,
} satisfies PlayerCosmetics);
}
}
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) {
this.toDelete.forEach((id) => this._units.delete(id));
this.toDelete.clear();
this.lastUpdate = gu;
this.updatedTiles = [];
const packed = this.lastUpdate.packedTileUpdates;
for (let i = 0; i + 1 < packed.length; i += 2) {
const tile = packed[i];
const state = packed[i + 1];
this.updateTile(tile, state);
this.updatedTiles.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 myDisplayName = formatPlayerDisplayName(
this._myUsername,
this._myClanTag,
);
gu.updates[GameUpdateType.Player].forEach((pu) => {
// Replace the local player's name/displayName with their own stored values.
// This way the user does not know they are being censored.
if (pu.clientID === this._myClientID) {
pu.name = this._myUsername;
pu.displayName = myDisplayName;
}
this.smallIDToID.set(pu.smallID, pu.id);
let player = this._players.get(pu.id);
if (player !== undefined) {
player.data = pu;
const nextNameData = gu.playerNameViewData[pu.id];
if (nextNameData !== undefined) {
player.nameData = nextNameData;
}
} else {
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);
}
});
if (this._myClientID) {
this._myPlayer ??= this.playerByClientID(this._myClientID);
}
for (const unit of this._units.values()) {
unit._wasUpdated = false;
unit.lastPos = unit.lastPos.slice(-1);
}
gu.updates[GameUpdateType.Unit].forEach((update) => {
let unit = this._units.get(update.id);
if (unit !== undefined) {
unit.update(update);
} else {
unit = new UnitView(this, update);
this._units.set(update.id, unit);
this.unitGrid.addUnit(unit);
}
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();
}
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;
}
// 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;
}
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,
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());
}
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.ticks() <= this._config.numSpawnPhaseTurns();
}
isSpawnImmunityActive(): boolean {
return (
this._config.numSpawnPhaseTurns() + this._config.spawnImmunityDuration() >
this.ticks()
);
}
isNationSpawnImmunityActive(): boolean {
return (
this._config.numSpawnPhaseTurns() +
this._config.nationSpawnImmunityDuration() >
this.ticks()
);
}
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()),
);
}
unit(id: number): UnitView | undefined {
return this._units.get(id);
}
unitInfo(type: UnitType): UnitInfo {
return this._config.unitInfo(type);
}
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);
}
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);
}
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);
}
isWater(ref: TileRef): boolean {
return this._map.isWater(ref);
}
isLake(ref: TileRef): boolean {
return this._map.isLake(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);
}
updateTile(tile: TileRef, state: number): void {
this._map.updateTile(tile, state);
}
numTilesWithFallout(): number {
return this._map.numTilesWithFallout();
}
gameID(): GameID {
return this._gameID;
}
focusedPlayer(): PlayerView | null {
return this.myPlayer();
}
}