perf(NameLayer): derive shared state per tick + incremental nuke tracking

Stop periodically rescanning game state in NameLayer and instead update the
expensive pieces once per game tick.

Changes (staged):
- Add tick-derived caches for first-place id + my transitive targets.
- Track nuke owners/“targeting me” incrementally from GameUpdateType.Unit:
  - initialize once from current active nuke units
  - apply per-tick unit updates to maintain `nukingPlayers` / `nukesTargetingMe`
  - keep per-owner counts so multiple nukes behave correctly
  - reset state if tick goes backwards (replay/seek).
- Rework player text cache to be tick-based (and font-aware) to avoid repeated
  `renderTroops()` / `measureText()` churn within a tick.
This commit is contained in:
scamiv
2026-02-06 00:41:17 +01:00
parent 818477129c
commit 9bd86dfb23
+227 -36
View File
@@ -1,6 +1,7 @@
import { EventBus } from "../../../core/EventBus"; import { EventBus } from "../../../core/EventBus";
import { Theme } from "../../../core/configuration/Config"; import { Theme } from "../../../core/configuration/Config";
import { AllPlayers, Cell, nukeTypes, PlayerID } from "../../../core/game/Game"; import { AllPlayers, Cell, nukeTypes, PlayerID } from "../../../core/game/Game";
import { GameUpdateType, UnitUpdate } from "../../../core/game/GameUpdates";
import { GameView, PlayerView } from "../../../core/game/GameView"; import { GameView, PlayerView } from "../../../core/game/GameView";
import { AlternateViewEvent } from "../../InputHandler"; import { AlternateViewEvent } from "../../InputHandler";
import { renderTroops } from "../../Utils"; import { renderTroops } from "../../Utils";
@@ -52,7 +53,7 @@ type PlayerIconsSharedState = {
}; };
type PlayerRenderCache = { type PlayerRenderCache = {
lastUpdatedAtMs: number; lastTick: number;
lastFont: string; lastFont: string;
lastName: string; lastName: string;
lastTroops: bigint | number; lastTroops: bigint | number;
@@ -70,8 +71,22 @@ export class NameLayer implements Layer {
private theme: Theme = this.game.config().theme(); private theme: Theme = this.game.config().theme();
private isVisible: boolean = true; private isVisible: boolean = true;
private readonly sharedStateRefreshMs = 200; private readonly sharedStateRefreshMs = 200;
private readonly playerCacheRefreshMs = 200;
private readonly customFlagRefreshMs = 120; private readonly customFlagRefreshMs = 120;
private readonly nukeTypeSet = new Set(nukeTypes);
private lastTickDerivedAt = -1;
private tickFirstPlaceId: PlayerID | null = null;
private tickTransitiveTargets: ReadonlySet<PlayerView> | null = null;
private nukeStateInitialized = false;
private nukeUnitState = new Map<
number,
{ ownerId: PlayerID; targetingMe: boolean }
>();
private nukingCounts = new Map<PlayerID, number>();
private nukesTargetingMeCounts = new Map<PlayerID, number>();
private nukingPlayers = new Set<PlayerID>();
private nukesTargetingMe = new Set<PlayerID>();
constructor( constructor(
private game: GameView, private game: GameView,
@@ -102,6 +117,7 @@ export class NameLayer implements Layer {
} }
const nowMs = performance.now(); const nowMs = performance.now();
this.processTickDerivedState();
const sharedState = this.getSharedState(nowMs); const sharedState = this.getSharedState(nowMs);
this.renderPlayers(mainContex, sharedState, nowMs); this.renderPlayers(mainContex, sharedState, nowMs);
} }
@@ -111,59 +127,212 @@ export class NameLayer implements Layer {
this.sharedState !== null && this.sharedState !== null &&
nowMs - this.lastSharedStateUpdatedAtMs < this.sharedStateRefreshMs nowMs - this.lastSharedStateUpdatedAtMs < this.sharedStateRefreshMs
) { ) {
this.sharedState.firstPlaceId = this.tickFirstPlaceId;
this.sharedState.transitiveTargets = this.tickTransitiveTargets;
this.sharedState.nukingPlayers = this.nukingPlayers;
this.sharedState.nukesTargetingMe = this.nukesTargetingMe;
return this.sharedState; return this.sharedState;
} }
this.lastSharedStateUpdatedAtMs = nowMs; this.lastSharedStateUpdatedAtMs = nowMs;
const myPlayer = this.game.myPlayer();
const userSettings = this.game.config().userSettings(); const userSettings = this.game.config().userSettings();
const isDarkMode = userSettings?.darkMode() ?? false; const isDarkMode = userSettings?.darkMode() ?? false;
const emojisEnabled = userSettings?.emojis() ?? false; const emojisEnabled = userSettings?.emojis() ?? false;
let firstPlace: PlayerView | null = null; this.sharedState = {
firstPlaceId: this.tickFirstPlaceId,
transitiveTargets: this.tickTransitiveTargets,
nukingPlayers: this.nukingPlayers,
nukesTargetingMe: this.nukesTargetingMe,
isDarkMode,
emojisEnabled,
};
return this.sharedState;
}
private processTickDerivedState(): void {
const tick = this.game.ticks();
if (tick === this.lastTickDerivedAt) return;
if (tick < this.lastTickDerivedAt) {
this.resetTickDerivedState();
}
this.lastTickDerivedAt = tick;
const myPlayer = this.game.myPlayer();
this.tickTransitiveTargets =
myPlayer !== null ? new Set(myPlayer.transitiveTargets()) : null;
let firstPlaceId: PlayerID | null = null;
let firstTiles = -Infinity; let firstTiles = -Infinity;
for (const player of this.game.playerViews()) { for (const player of this.game.playerViews()) {
if (!player.isAlive()) continue; if (!player.isAlive()) continue;
const tiles = player.numTilesOwned(); const tiles = player.numTilesOwned();
if (tiles > firstTiles) { if (tiles > firstTiles) {
firstTiles = tiles; firstTiles = tiles;
firstPlace = player; firstPlaceId = player.id();
} }
} }
const nukingPlayers = new Set<PlayerID>(); this.tickFirstPlaceId = firstPlaceId;
const nukesTargetingMe = new Set<PlayerID>(); this.ensureNukeStateInitialized();
this.applyNukeUnitUpdates();
}
private resetTickDerivedState(): void {
this.tickFirstPlaceId = null;
this.tickTransitiveTargets = null;
this.nukeStateInitialized = false;
this.nukeUnitState.clear();
this.nukingCounts.clear();
this.nukesTargetingMeCounts.clear();
this.nukingPlayers.clear();
this.nukesTargetingMe.clear();
}
private ensureNukeStateInitialized(): void {
if (this.nukeStateInitialized) return;
this.nukeStateInitialized = true;
const myPlayer = this.game.myPlayer();
for (const unit of this.game.units(...nukeTypes)) { for (const unit of this.game.units(...nukeTypes)) {
if (!unit.isActive()) continue; if (!unit.isActive()) continue;
const owner = unit.owner(); const owner = unit.owner();
if (myPlayer && owner.id() === myPlayer.id()) continue; if (myPlayer && owner.id() === myPlayer.id()) continue;
nukingPlayers.add(owner.id());
let targetingMe = false;
if (myPlayer) { if (myPlayer) {
const detonationDst = unit.targetTile(); const target = unit.targetTile();
if (detonationDst) { if (target !== undefined) {
const targetId = this.game.owner(detonationDst).id(); const targetOwner = this.game.owner(target);
if (targetId === myPlayer.id()) { if (
nukesTargetingMe.add(owner.id()); targetOwner instanceof PlayerView &&
targetOwner.id() === myPlayer.id()
) {
targetingMe = true;
} }
} }
} }
this.upsertNukeUnit(unit.id(), owner.id(), targetingMe);
}
}
private applyNukeUnitUpdates(): void {
const updates = this.game.updatesSinceLastTick();
if (updates === null) return;
const unitUpdates = updates[GameUpdateType.Unit] as
| UnitUpdate[]
| undefined;
if (!unitUpdates || unitUpdates.length === 0) return;
const myPlayer = this.game.myPlayer();
const myPlayerId = myPlayer?.id() ?? null;
for (const update of unitUpdates) {
if (!this.nukeTypeSet.has(update.unitType)) continue;
const ownerEntity = this.game.playerBySmallID(update.ownerID);
if (!(ownerEntity instanceof PlayerView)) {
this.removeNukeUnit(update.id);
continue;
}
const ownerId = ownerEntity.id();
const isOwnNuke = myPlayerId !== null && ownerId === myPlayerId;
const isActive = update.isActive && !isOwnNuke;
if (!isActive) {
this.removeNukeUnit(update.id);
continue;
}
let targetingMe = false;
if (myPlayer && update.targetTile !== undefined) {
const targetOwner = this.game.owner(update.targetTile);
if (
targetOwner instanceof PlayerView &&
targetOwner.id() === myPlayerId
) {
targetingMe = true;
}
}
this.upsertNukeUnit(update.id, ownerId, targetingMe);
}
}
private upsertNukeUnit(
unitId: number,
ownerId: PlayerID,
targetingMe: boolean,
): void {
const prev = this.nukeUnitState.get(unitId);
if (prev) {
if (prev.ownerId === ownerId && prev.targetingMe === targetingMe) {
return;
}
this.decCount(this.nukingCounts, this.nukingPlayers, prev.ownerId);
if (prev.targetingMe) {
this.decCount(
this.nukesTargetingMeCounts,
this.nukesTargetingMe,
prev.ownerId,
);
}
} }
const transitiveTargets = this.nukeUnitState.set(unitId, { ownerId, targetingMe });
myPlayer !== null ? new Set(myPlayer.transitiveTargets()) : null; this.incCount(this.nukingCounts, this.nukingPlayers, ownerId);
if (targetingMe) {
this.incCount(
this.nukesTargetingMeCounts,
this.nukesTargetingMe,
ownerId,
);
}
}
this.sharedState = { private removeNukeUnit(unitId: number): void {
firstPlaceId: firstPlace?.id() ?? null, const prev = this.nukeUnitState.get(unitId);
transitiveTargets, if (!prev) return;
nukingPlayers, this.nukeUnitState.delete(unitId);
nukesTargetingMe, this.decCount(this.nukingCounts, this.nukingPlayers, prev.ownerId);
isDarkMode, if (prev.targetingMe) {
emojisEnabled, this.decCount(
}; this.nukesTargetingMeCounts,
this.nukesTargetingMe,
prev.ownerId,
);
}
}
return this.sharedState; private incCount(
map: Map<PlayerID, number>,
set: Set<PlayerID>,
id: PlayerID,
): void {
const next = (map.get(id) ?? 0) + 1;
map.set(id, next);
if (next === 1) set.add(id);
}
private decCount(
map: Map<PlayerID, number>,
set: Set<PlayerID>,
id: PlayerID,
): void {
const prev = map.get(id) ?? 0;
const next = prev - 1;
if (next <= 0) {
map.delete(id);
set.delete(id);
return;
}
map.set(id, next);
} }
private renderPlayers( private renderPlayers(
@@ -173,6 +342,7 @@ export class NameLayer implements Layer {
): void { ): void {
const fontFamily = this.theme.font(); const fontFamily = this.theme.font();
const scale = this.transformHandler.scale; const scale = this.transformHandler.scale;
const tick = this.game.ticks();
for (const player of this.game.playerViews()) { for (const player of this.game.playerViews()) {
if (!player.isAlive()) { if (!player.isAlive()) {
@@ -218,7 +388,7 @@ export class NameLayer implements Layer {
ctx.textBaseline = "middle"; ctx.textBaseline = "middle";
ctx.textAlign = "left"; ctx.textAlign = "left";
const cache = this.getPlayerCache(player, ctx, nowMs); const cache = this.getPlayerCache(player, ctx, tick);
const iconsY = Math.round(y - fontPx * 1.1 - iconPx * 0.6); const iconsY = Math.round(y - fontPx * 1.1 - iconPx * 0.6);
this.renderPlayerIcons( this.renderPlayerIcons(
@@ -276,27 +446,48 @@ export class NameLayer implements Layer {
private getPlayerCache( private getPlayerCache(
player: PlayerView, player: PlayerView,
ctx: CanvasRenderingContext2D, ctx: CanvasRenderingContext2D,
nowMs: number, tick: number,
): PlayerRenderCache { ): PlayerRenderCache {
const id = player.id(); const id = player.id();
const name = player.name();
const troops = player.troops();
const font = ctx.font; const font = ctx.font;
const existing = this.playerCache.get(id); const existing = this.playerCache.get(id);
if ( if (existing && existing.lastTick === tick) {
existing && if (existing.lastFont !== font) {
nowMs - existing.lastUpdatedAtMs < this.playerCacheRefreshMs && existing.lastFont = font;
existing.lastFont === font && existing.nameTextWidth = ctx.measureText(existing.lastName).width;
existing.lastName === name && existing.troopsTextWidth = ctx.measureText(existing.troopsText).width;
existing.lastTroops === troops }
) {
return existing; return existing;
} }
const name = player.name();
const troops = player.troops();
const troopsText = renderTroops(troops); const troopsText = renderTroops(troops);
if (existing) {
if (existing.lastName !== name) {
existing.lastName = name;
existing.nameTextWidth = ctx.measureText(name).width;
} else if (existing.lastFont !== font) {
existing.nameTextWidth = ctx.measureText(name).width;
}
if (existing.troopsText !== troopsText) {
existing.troopsText = troopsText;
existing.troopsTextWidth = ctx.measureText(troopsText).width;
} else if (existing.lastFont !== font) {
existing.troopsTextWidth = ctx.measureText(troopsText).width;
}
existing.lastTick = tick;
existing.lastFont = font;
existing.lastTroops = troops;
return existing;
}
const next: PlayerRenderCache = { const next: PlayerRenderCache = {
lastUpdatedAtMs: nowMs, lastTick: tick,
lastFont: font, lastFont: font,
lastName: name, lastName: name,
lastTroops: troops, lastTroops: troops,