From a1c93d6e49e0929a512e621063a8f73c8abe28a1 Mon Sep 17 00:00:00 2001 From: 1brucben <1benjbruce@gmail.com> Date: Mon, 23 Feb 2026 06:50:11 +0100 Subject: [PATCH] Add unit layer performance benchmark harness - Introduced a new benchmark harness for measuring the performance of various Layer implementations that render mobile units. - Implemented utility functions for creating mock game states, units, and GPU counters. - Added scenarios for benchmarking including full redraw, tick and render, and unit movement simulations. - Provided presets for unit mixes to simulate realistic game scenarios. - Included detailed statistics computation for benchmarking results. --- src/client/graphics/GameRenderer.ts | 4 +- src/client/graphics/layers/UnitLayerV2.ts | 2347 +++++++++++++++++ tests/client/layers/UnitLayer.perf.test.ts | 285 ++ tests/client/layers/UnitLayerV2.perf.test.ts | 257 ++ .../client/layers/unit-layer-bench-harness.ts | 1322 ++++++++++ 5 files changed, 4213 insertions(+), 2 deletions(-) create mode 100644 src/client/graphics/layers/UnitLayerV2.ts create mode 100644 tests/client/layers/UnitLayer.perf.test.ts create mode 100644 tests/client/layers/UnitLayerV2.perf.test.ts create mode 100644 tests/client/layers/unit-layer-bench-harness.ts diff --git a/src/client/graphics/GameRenderer.ts b/src/client/graphics/GameRenderer.ts index aa9e30d4..fd1eb467 100644 --- a/src/client/graphics/GameRenderer.ts +++ b/src/client/graphics/GameRenderer.ts @@ -46,7 +46,7 @@ import { TradeDebugOverlay } from "./layers/TradeDebugOverlay"; import { TutorialToast } from "./layers/TutorialToast"; import { TutorialTriggers } from "./layers/TutorialTriggers"; import { UILayer } from "./layers/UILayer"; -import { UnitLayer } from "./layers/UnitLayer"; +import { UnitLayerV2 as UnitLayer } from "./layers/UnitLayerV2"; import { WarScoreOverlay } from "./layers/WarScoreOverlay"; import { WinModal } from "./layers/WinModal"; @@ -285,7 +285,7 @@ export function createRenderer( structureLayer, new ArtilleryLayer(game, eventBus, transformHandler), new UnitLayer(game, eventBus, transformHandler, uiState), - // UILayer placed right after UnitLayer: both use shouldTransform=true, + // UILayer placed right after UnitLayerV2: both use shouldTransform=true, // keeping them adjacent avoids extra context.save/restore transitions. new UILayer(game, eventBus, transformHandler), new AABulletLayer(game, transformHandler), diff --git a/src/client/graphics/layers/UnitLayerV2.ts b/src/client/graphics/layers/UnitLayerV2.ts new file mode 100644 index 00000000..ef11678a --- /dev/null +++ b/src/client/graphics/layers/UnitLayerV2.ts @@ -0,0 +1,2347 @@ +/** + * UnitLayerV2 — Performance-optimized fork of UnitLayer + * ===================================================== + * + * Key optimizations vs V1: + * + * 1. **Set for interpolatedUnitTypes** — O(1) `.has()` vs O(n) `.includes()` + * on the hot `drawingBasePass` check and `updateInterpolatedUnits` filter. + * + * 2. **Map for PIXI units** — O(1) add / remove / lookup + * instead of `findIndex` + `splice` (O(n) per removal → O(n²) for batch). + * + * 3. **Cached tick-alpha per frame** — `computeTickAlpha()` evaluated once in + * `renderLayer`, stored in `_frameAlpha`, reused by every interpolated unit + * and PIXI sprite update. + * + * 4. **Reusable point for worldToScreen** — one `_tmpPoint` object reused across + * all coordinate conversions instead of allocating `new Cell(...)` per unit + * per frame (300+ allocations → 0). + * + * 5. **Cached colored images** — `getImageColored()` results keyed by + * image.src + color string, avoiding repeated canvas allocation and + * composite draw for the same icon/color pair. + * + * 6. **Airfield tile Set per tick** — built once per `tick()`, used for O(1) + * bomber-at-airfield checks instead of linear scan of all Airfield units + * per bomber. + * + * 7. **No getBounds() in renderPixiUnits** — viewport culling uses the + * already-computed screen position + fixed icon radius instead of the + * expensive PIXI `getBounds()` traversal. + * + * 8. **Canvas reuse in redraw()** — existing canvases are cleared rather + * than reallocated (avoids DOM node creation + GC). + * + * 9. **Bitwise pixel-snap** — `(v + 0.5) | 0` replaces `Math.floor(v + 0.5)` + * and `Math.round(v)` in the inner render loop. + * + * 10. **Eliminated redundant angle computation** — `getUnitAngle` short-circuits + * immediately for non-aircraft via a static Set. + */ + +import type { Colord } from "colord"; +import { colord } from "colord"; +import * as PIXI from "pixi.js"; +import type { EventBus } from "../../../core/EventBus"; +import type { Theme } from "../../../core/configuration/Config"; +import { Cell, UnitType } from "../../../core/game/Game"; +import type { TileRef } from "../../../core/game/GameMap"; +import type { GameView, UnitView } from "../../../core/game/GameView"; +import { BezenhamLine } from "../../../core/utilities/Line"; +import { + AlternateViewEvent, + MouseUpEvent, + ReplaySpeedChangeEvent, + UnitSelectionEvent, +} from "../../InputHandler"; +import { + ArtilleryOutOfRangeEvent, + MoveArtilleryIntentEvent, + MoveFighterJetIntentEvent, + MoveSubmarineIntentEvent, + MoveWarshipIntentEvent, +} from "../../Transport"; +import { PerformanceMetrics } from "../../utilities/PerformanceMetrics"; +import type { ReplaySpeedMultiplier } from "../../utilities/ReplaySpeedMultiplier"; +import { defaultReplaySpeedMultiplier } from "../../utilities/ReplaySpeedMultiplier"; +import type { TransformHandler } from "../TransformHandler"; +import type { UIState } from "../UIState"; +import type { Layer } from "./Layer"; + +import { GameUpdateType } from "../../../core/game/GameUpdates"; +import { getArtilleryMaxDistance } from "../../../core/game/UnitUpgrades"; +import { + getColoredSprite, + isSpriteReady, + loadAllSprites, +} from "../SpriteLoader"; + +// PIXI unit icons +import bomberSprite from "../../../../proprietary/images/bomberv3.png"; +import tradeShipIcon from "../../../../proprietary/images/tradeship.png"; +import warshipIcon from "../../../../resources/images/BattleshipIconWhite.svg"; +import fighterJetIcon from "../../../../resources/images/FighterJetIcon.svg"; +import submarineIcon from "../../../../resources/images/submarine.svg"; + +// ── constants ────────────────────────────────────────────────────────────── +const ICON_TEXTURE_QUALITY = 4; +const ICON_DIM = 28; +const ICON_GROW_ZOOM_THRESHOLD = 2; +const SIZE_SCALE = 0.8; + +// ── static lookup sets (allocated once, never mutated) ───────────────────── + +/** Units rendered via the PIXI/WebGL pipeline. */ +const PIXI_UNIT_TYPES: ReadonlySet = new Set([ + UnitType.Warship, + UnitType.Submarine, + UnitType.Bomber, + UnitType.FighterJet, + UnitType.TradeShip, + UnitType.Shell, +]); + +/** All unit types processed by this layer (excludes structures). */ +const UNIT_LAYER_TYPES: ReadonlySet = new Set([ + UnitType.TransportShip, + UnitType.Paratrooper, + UnitType.Submarine, + UnitType.Warship, + UnitType.Shell, + UnitType.SAMMissile, + UnitType.TradeShip, + UnitType.CargoPlane, + UnitType.MIRVWarhead, + UnitType.Bomber, + UnitType.FighterJet, + UnitType.AtomBomb, + UnitType.HydrogenBomb, + UnitType.MIRV, +]); + +/** + * OPT 1 — Set for O(1) membership test (was Array.includes → O(n)). + * Checked on every unit in the hot drawUnitsCells base-pass guard and + * every unit in updateInterpolatedUnits. + */ +const INTERPOLATED_UNIT_TYPES: ReadonlySet = new Set([ + UnitType.SAMMissile, + UnitType.AtomBomb, + UnitType.HydrogenBomb, + UnitType.MIRV, + UnitType.MIRVWarhead, + UnitType.Shell, + UnitType.Warship, + UnitType.TransportShip, + UnitType.TradeShip, + UnitType.Submarine, + UnitType.Bomber, + UnitType.FighterJet, + UnitType.CargoPlane, +]); + +/** Array form of the same set, for passing to game.units(). */ +const INTERPOLATED_UNIT_TYPES_ARRAY: UnitType[] = [...INTERPOLATED_UNIT_TYPES]; + +/** + * OPT 10 — Only these types can produce a non-null angle. Short-circuit + * immediately for everything else (avoids lastTile/tile lookups + Math.atan2). + */ +const ROTATABLE_UNIT_TYPES: ReadonlySet = new Set([ + UnitType.Bomber, + UnitType.FighterJet, + UnitType.CargoPlane, +]); + +/** Units that use horizontal flip instead of rotation. */ +const FLIP_UNIT_TYPES: ReadonlySet = new Set([ + UnitType.Warship, + UnitType.Submarine, + UnitType.TradeShip, +]); + +// ── PIXI render info ────────────────────────────────────────────────────── + +class UnitRenderInfo { + constructor( + public unit: UnitView, + public pixiSprite: PIXI.Sprite, + public lastAngle: number = 0, + public facingLeft: boolean = false, + ) {} +} + +class GhostRenderInfo { + constructor( + public ghostId: number, + public position: { x: number; y: number }, + public pixiSprite: PIXI.Sprite, + ) {} +} + +enum Relationship { + Self, + Ally, + Enemy, +} + +// ═══════════════════════════════════════════════════════════════════════════ +// UnitLayerV2 +// ═══════════════════════════════════════════════════════════════════════════ + +export class UnitLayerV2 implements Layer { + layerName = "UnitLayerV2"; + + // ── canvases ── + private canvas!: HTMLCanvasElement; + private context!: CanvasRenderingContext2D; + private transportShipTrailCanvas!: HTMLCanvasElement; + private unitTrailContext!: CanvasRenderingContext2D; + private interpolationCanvas!: HTMLCanvasElement; + private interpolationContext!: CanvasRenderingContext2D; + /** OPT 8 — track whether canvases have been allocated so `redraw()` can reuse. */ + private canvasesAllocated = false; + + private unitToTrail = new Map(); + private unitToLastAngle = new Map(); + private theme: Theme; + private alternateView = false; + private oldShellTile = new Map(); + private transformHandler: TransformHandler; + private selectedUnit: UnitView | null = null; + + private readonly WARSHIP_SELECTION_RADIUS = 10; + private readonly SUBMARINE_SELECTION_RADIUS = 10; + private readonly FIGHTER_JET_SELECTION_RADIUS = 10; + + private drawingBasePass = false; + + private baseTickIntervalMs = 100; + private tickIntervalMs = 100; + private replaySpeedMultiplier: ReplaySpeedMultiplier = + defaultReplaySpeedMultiplier; + private lastTickTimestamp = 0; + + private spriteSizeCache = new Map(); + private renderedGhosts = new Map(); + + // ── PIXI infra ── + + private pixiCanvas!: HTMLCanvasElement; + private pixiStage!: PIXI.Container; + private pixiRenderer!: PIXI.Renderer; + + /** + * OPT 2 — Map keyed by unit ID for O(1) lookup & removal. + * Was: UnitRenderInfo[] with findIndex O(n) per removal. + */ + private pixiRenderMap = new Map(); + private textureCache = new Map(); + private targetingTextureCache = new Map(); + private starTextureCache = new Map(); + + private warshipIconImage: HTMLImageElement | null = null; + private submarineIconImage: HTMLImageElement | null = null; + private fighterJetIconImage: HTMLImageElement | null = null; + private bomberIconImage: HTMLImageElement | null = null; + private tradeShipIconImage: HTMLImageElement | null = null; + + private ghostRenders: GhostRenderInfo[] = []; + private renderedUnits = new Map(); + + /** OPT 6 — Set of airfield tiles per ownerSmallID, rebuilt once per tick. */ + private airfieldTilesByOwner = new Map>(); + + /** + * OPT 5 — Cache for `getImageColored()` results. + * Key: `${image.src}|${colorString}`. + */ + private coloredImageCache = new Map(); + + /** + * OPT 3 — Per-frame cached alpha, computed once in `renderLayer` + * and consumed by all downstream interpolation. + */ + private _frameAlpha = 0; + + /** + * OPT 4 — Single mutable point reused for all worldToScreen conversions + * in a frame. Eliminates hundreds of `new Cell(x,y)` allocations per + * render call. We use a plain `{x,y}` because Cell.x/y are readonly. + */ + private _tmpPoint: { x: number; y: number } = { x: 0, y: 0 }; + + // ──────────────────────────────────────────────────────────────────────── + // Constructor + // ──────────────────────────────────────────────────────────────────────── + + constructor( + private game: GameView, + private eventBus: EventBus, + transformHandler: TransformHandler, + private uiState: UIState, + ) { + this.theme = game.config().theme(); + this.transformHandler = transformHandler; + this.baseTickIntervalMs = this.game + .config() + .serverConfig() + .turnIntervalMs(); + this.updateTickInterval(); + this.lastTickTimestamp = this.now(); + this.loadPixiIcons(); + } + + // ──────────────────────────────────────────────────────────────────────── + // Icon loading (unchanged — runs once) + // ──────────────────────────────────────────────────────────────────────── + + private loadPixiIcons() { + const load = ( + src: string, + cb: (img: HTMLImageElement) => void, + unitType: UnitType, + ) => { + const img = new Image(); + img.src = src; + img.onload = () => { + cb(img); + this.clearTextureCache(unitType); + }; + }; + load(warshipIcon, (i) => (this.warshipIconImage = i), UnitType.Warship); + load( + submarineIcon, + (i) => (this.submarineIconImage = i), + UnitType.Submarine, + ); + load( + fighterJetIcon, + (i) => (this.fighterJetIconImage = i), + UnitType.FighterJet, + ); + load(bomberSprite, (i) => (this.bomberIconImage = i), UnitType.Bomber); + load( + tradeShipIcon, + (i) => (this.tradeShipIconImage = i), + UnitType.TradeShip, + ); + } + + private clearTextureCache(unitType: UnitType) { + const tag = unitType.toString(); + for (const key of this.textureCache.keys()) { + if (key.includes(tag)) { + this.textureCache.delete(key); + this.targetingTextureCache.delete(key); + } + } + // Also bust colored image cache for this type + this.coloredImageCache.clear(); + } + + // ──────────────────────────────────────────────────────────────────────── + // PIXI renderer setup (unchanged — runs once) + // ──────────────────────────────────────────────────────────────────────── + + async setupPixiRenderer() { + this.pixiRenderer = new PIXI.WebGLRenderer(); + this.pixiCanvas = document.createElement("canvas"); + this.pixiCanvas.width = window.innerWidth; + this.pixiCanvas.height = window.innerHeight; + + this.pixiCanvas.style.position = "fixed"; + this.pixiCanvas.style.left = "0"; + this.pixiCanvas.style.top = "0"; + this.pixiCanvas.style.width = "100%"; + this.pixiCanvas.style.height = "100%"; + this.pixiCanvas.style.pointerEvents = "none"; + this.pixiCanvas.style.zIndex = "33"; + document.body.appendChild(this.pixiCanvas); + + this.pixiStage = new PIXI.Container(); + await this.pixiRenderer.init({ + canvas: this.pixiCanvas, + resolution: 1, + width: this.pixiCanvas.width, + height: this.pixiCanvas.height, + clearBeforeRender: true, + backgroundAlpha: 0, + backgroundColor: 0x00000000, + }); + } + + resizePixiCanvas() { + if (this.pixiRenderer?.view) { + this.pixiCanvas.width = window.innerWidth; + this.pixiCanvas.height = window.innerHeight; + this.pixiRenderer.resize(innerWidth, innerHeight, 1); + } + } + + // ──────────────────────────────────────────────────────────────────────── + // Icon helpers + // ──────────────────────────────────────────────────────────────────────── + + private iconScreenScale(): number { + const s = this.transformHandler.scale; + if (s <= ICON_GROW_ZOOM_THRESHOLD) { + return (Math.min(1, s) / ICON_TEXTURE_QUALITY) * SIZE_SCALE; + } + return (s / ICON_GROW_ZOOM_THRESHOLD / ICON_TEXTURE_QUALITY) * SIZE_SCALE; + } + + private getIconImage(unitType: UnitType): HTMLImageElement | null { + switch (unitType) { + case UnitType.Warship: + return this.warshipIconImage; + case UnitType.Submarine: + return this.submarineIconImage; + case UnitType.FighterJet: + return this.fighterJetIconImage; + case UnitType.Bomber: + return this.bomberIconImage; + case UnitType.TradeShip: + return this.tradeShipIconImage; + default: + return null; + } + } + + // ──────────────────────────────────────────────────────────────────────── + // Texture creation (with OPT 5 — cached getImageColored) + // ──────────────────────────────────────────────────────────────────────── + + private createPixiTexture( + unit: UnitView, + isTargeting: boolean = false, + ): PIXI.Texture { + const border = this.theme.borderColor(unit.owner()); + const unitType = unit.type(); + + const isNavalUnit = + unitType === UnitType.Warship || + unitType === UnitType.Submarine || + unitType === UnitType.TradeShip; + const borderColor = isNavalUnit + ? border.lighten(0.1).toRgbString() + : border.darken(0.1).toRgbString(); + + const level = (unit.level && unit.level()) || 1; + const cache = isTargeting ? this.targetingTextureCache : this.textureCache; + const cacheSuffix = isTargeting ? "-targeting" : ""; + const cacheKey = `${unitType}-${unit.owner().id()}-${borderColor}-${level}${cacheSuffix}`; + + if (cache.has(cacheKey)) { + return cache.get(cacheKey)!; + } + + const iconImage = this.getIconImage(unitType); + if (!iconImage || !iconImage.complete) { + return PIXI.Texture.EMPTY; + } + + const CANVAS_PX = Math.max(1, (ICON_DIM * ICON_TEXTURE_QUALITY + 0.5) | 0); + const canvas = document.createElement("canvas"); + canvas.width = CANVAS_PX; + canvas.height = CANVAS_PX; + const ctx = canvas.getContext("2d")!; + ctx.imageSmoothingEnabled = true; + ctx.imageSmoothingQuality = "high"; + ctx.scale(ICON_TEXTURE_QUALITY, ICON_TEXTURE_QUALITY); + + // OPT 5 — use cached getImageColored + const colored = this.getImageColored(iconImage, borderColor); + const padded = 4; + const maxW = ICON_DIM - padded * 2; + const maxH = ICON_DIM - padded * 2; + const iw = Math.max(1, colored.width); + const ih = Math.max(1, colored.height); + const baseScale = Math.min(maxW / iw, maxH / ih); + const factor = + unitType === UnitType.Bomber || unitType === UnitType.FighterJet + ? 1.0 + : 1.4; + const dw = Math.min( + ICON_DIM, + Math.max(1, (iw * baseScale * factor + 0.5) | 0), + ); + const dh = Math.min( + ICON_DIM, + Math.max(1, (ih * baseScale * factor + 0.5) | 0), + ); + const dx = ((ICON_DIM - dw) / 2 + 0.5) | 0; + const dy = ((ICON_DIM - dh) / 2 + 0.5) | 0; + + ctx.drawImage(colored, dx, dy, dw, dh); + + // Level stars (skip FighterJet, TradeShip, Bomber) + if ( + unitType !== UnitType.TradeShip && + unitType !== UnitType.Bomber && + unitType !== UnitType.FighterJet && + level >= 1 && + level <= 4 + ) { + const tierColor = "#CD7F32"; + const starSize = 4; + const spacing = 0.3; + const padding = 1; + const startX = padding + starSize / 2; + const startY = padding + starSize / 2; + + ctx.fillStyle = tierColor; + for (let i = 0; i < level; i++) { + const x = startX + i * (starSize + spacing); + this.drawStar(ctx, x, startY, starSize); + } + } + + // Targeting marker + if (isTargeting) { + const markerSize = 5; + const padding = 1; + const centerX = ICON_DIM - padding - markerSize / 2; + const centerY = padding + markerSize / 2; + + ctx.strokeStyle = "#FF0000"; + ctx.lineWidth = 0.8; + ctx.beginPath(); + ctx.arc(centerX, centerY, markerSize / 2, 0, Math.PI * 2); + ctx.stroke(); + ctx.moveTo(centerX - markerSize / 2, centerY); + ctx.lineTo(centerX + markerSize / 2, centerY); + ctx.stroke(); + ctx.moveTo(centerX, centerY - markerSize / 2); + ctx.lineTo(centerX, centerY + markerSize / 2); + ctx.stroke(); + ctx.closePath(); + } + + const texture = PIXI.Texture.from(canvas); + cache.set(cacheKey, texture); + return texture; + } + + // Star drawing helpers (unchanged) + private drawStar( + ctx: CanvasRenderingContext2D, + cx: number, + cy: number, + size: number, + ) { + const spikes = 5; + const outerRadius = size / 2; + const innerRadius = outerRadius * 0.4; + let rot = (Math.PI / 2) * 3; + const step = Math.PI / spikes; + ctx.beginPath(); + ctx.moveTo(cx, cy - outerRadius); + for (let i = 0; i < spikes; i++) { + let x = cx + Math.cos(rot) * outerRadius; + let y = cy + Math.sin(rot) * outerRadius; + ctx.lineTo(x, y); + rot += step; + x = cx + Math.cos(rot) * innerRadius; + y = cy + Math.sin(rot) * innerRadius; + ctx.lineTo(x, y); + rot += step; + } + ctx.lineTo(cx, cy - outerRadius); + ctx.closePath(); + ctx.fill(); + } + + private drawPixiStar( + graphics: PIXI.Graphics, + cx: number, + cy: number, + size: number, + ) { + const spikes = 5; + const outerRadius = size / 2; + const innerRadius = outerRadius * 0.4; + let rot = (Math.PI / 2) * 3; + const step = Math.PI / spikes; + const points: number[] = []; + points.push(cx, cy - outerRadius); + for (let i = 0; i < spikes; i++) { + let x = cx + Math.cos(rot) * outerRadius; + let y = cy + Math.sin(rot) * outerRadius; + points.push(x, y); + rot += step; + x = cx + Math.cos(rot) * innerRadius; + y = cy + Math.sin(rot) * innerRadius; + points.push(x, y); + rot += step; + } + points.push(cx, cy - outerRadius); + graphics.drawPolygon(points); + } + + private getStarTexture(level: number): PIXI.Texture { + if (this.starTextureCache.has(level)) { + return this.starTextureCache.get(level)!; + } + const canvas = document.createElement("canvas"); + const starSize = 8; + const spacing = 0.6; + const padding = 1; + canvas.width = Math.ceil(padding * 2 + level * (starSize + spacing)); + canvas.height = starSize + padding * 2; + const ctx = canvas.getContext("2d")!; + ctx.fillStyle = "#cd7f32"; + for (let i = 0; i < level; i++) { + const x = padding + starSize / 2 + i * (starSize + spacing); + const y = padding + starSize / 2; + this.drawStar(ctx, x, y, starSize); + } + const texture = PIXI.Texture.from(canvas); + this.starTextureCache.set(level, texture); + return texture; + } + + /** + * OPT 5 — Cached version: results are keyed by image src + color string. + * The original created a new canvas *every* call. + */ + private getImageColored( + image: HTMLImageElement, + color: string, + ): HTMLCanvasElement { + const cacheKey = `${image.src}|${color}`; + const cached = this.coloredImageCache.get(cacheKey); + if (cached) return cached; + + const canvas = document.createElement("canvas"); + canvas.width = image.width; + canvas.height = image.height; + const ctx = canvas.getContext("2d")!; + ctx.fillStyle = color; + ctx.fillRect(0, 0, canvas.width, canvas.height); + ctx.globalCompositeOperation = "destination-in"; + ctx.drawImage(image, 0, 0); + + this.coloredImageCache.set(cacheKey, canvas); + return canvas; + } + + // ──────────────────────────────────────────────────────────────────────── + // Layer interface + // ──────────────────────────────────────────────────────────────────────── + + shouldTransform(): boolean { + return true; + } + + /** + * OPT 6 — Build airfield tile set once per tick instead of linear + * scanning all Airfield units for every bomber. + */ + tick() { + this.lastTickTimestamp = this.now(); + + const configuredInterval = this.game + .config() + .serverConfig() + .turnIntervalMs(); + if (configuredInterval !== this.baseTickIntervalMs) { + this.baseTickIntervalMs = configuredInterval; + this.updateTickInterval(); + } + + // OPT 6 — rebuild airfield lookup + this.airfieldTilesByOwner.clear(); + for (const airfield of this.game.units(UnitType.Airfield)) { + if (!airfield.isActive()) continue; + const ownerSid = airfield.owner().smallID(); + let set = this.airfieldTilesByOwner.get(ownerSid); + if (!set) { + set = new Set(); + this.airfieldTilesByOwner.set(ownerSid, set); + } + set.add(airfield.tile()); + } + + const unitIds = + this.game + .updatesSinceLastTick() + ?.[GameUpdateType.Unit]?.map((unit) => unit.id) ?? []; + + this.updateUnitsSprites(unitIds); + + // Sweep zombies + const zombieIds: number[] = []; + for (const [id, unit] of this.renderedUnits) { + if (!unit.isActive()) { + zombieIds.push(id); + } + } + if (zombieIds.length > 0) { + this.updateUnitsSprites(zombieIds); + } + + // Clean up inactive PIXI sprites (OPT 2 — iterate Map, O(1) removal) + for (const [id, render] of this.pixiRenderMap) { + if (!render.unit.isActive()) { + this.removePixiUnit(id); + } + } + + this.updateGhosts(); + this.updatePixiUnits(); + + // OPT 6 — O(1) bomber-at-airfield lookup + // (bomberAtAirfield map removed; checked inline via airfieldTilesByOwner) + } + + /** + * OPT 6 — O(1) airfield check using the per-tick Set. + */ + private isUnitAtOwnedAirfield(unit: UnitView): boolean { + const ownerSid = unit.owner().smallID(); + const tiles = this.airfieldTilesByOwner.get(ownerSid); + return tiles !== undefined && tiles.has(unit.tile()); + } + + private updatePixiUnits() { + if (!this.pixiRenderer) return; + + const updates = this.game.updatesSinceLastTick(); + const unitUpdates = updates !== null ? updates[GameUpdateType.Unit] : []; + + const metrics = PerformanceMetrics.getInstance(); + + if (metrics.enabled) { + for (const u of unitUpdates) { + const unitView = this.game.unit(u.id); + if (unitView && PIXI_UNIT_TYPES.has(unitView.type())) { + metrics.recordUnitExecutionTime(unitView.type(), 0.1); + } + } + } + + for (const u of unitUpdates) { + const unitView = this.game.unit(u.id); + if (unitView === undefined) continue; + if (!PIXI_UNIT_TYPES.has(unitView.type())) continue; + + if (unitView.isActive()) { + // OPT 2 — Map check, O(1) + if (!this.pixiRenderMap.has(unitView.id())) { + const sprite = this.createPixiSprite(unitView); + const render = new UnitRenderInfo(unitView, sprite); + this.pixiRenderMap.set(unitView.id(), render); + } + } else { + this.removePixiUnit(unitView.id()); + } + } + } + + /** + * OPT 2 — O(1) removal via Map (was findIndex + splice → O(n)). + */ + private removePixiUnit(unitId: number) { + const render = this.pixiRenderMap.get(unitId); + if (render) { + render.pixiSprite.destroy(); + this.pixiRenderMap.delete(unitId); + } + } + + private createPixiSprite(unit: UnitView): PIXI.Sprite { + if (unit.type() === UnitType.Shell) { + const graphics = new PIXI.Graphics() as any; + this.pixiStage.addChild(graphics); + return graphics; + } + + if (unit.type() === UnitType.FighterJet) { + const container = new PIXI.Container() as any as PIXI.Sprite; + const texture = this.createPixiTexture(unit, false); + const sprite = new PIXI.Sprite(texture); + sprite.anchor.set(0.5, 0.5); + container.addChild(sprite); + + const level = unit.level ? unit.level() : 1; + const starTexture = this.getStarTexture(level); + const starSprite = new PIXI.Sprite(starTexture); + starSprite.anchor.set(0, 0); + container.addChild(starSprite); + (container as any)._jetSprite = sprite; + (container as any)._starSprite = starSprite; + + this.pixiStage.addChild(container); + return container; + } + + const texture = this.createPixiTexture(unit, false); + const sprite = new PIXI.Sprite(texture); + sprite.anchor.set(0.5, 0.5); + this.pixiStage.addChild(sprite); + return sprite; + } + + // ──────────────────────────────────────────────────────────────────────── + // init / redraw + // ──────────────────────────────────────────────────────────────────────── + + init() { + this.eventBus.on(AlternateViewEvent, (e: AlternateViewEvent) => + this.onAlternativeViewEvent(e), + ); + this.eventBus.on(MouseUpEvent, (e: MouseUpEvent) => this.onMouseUp(e)); + this.eventBus.on(UnitSelectionEvent, (e: UnitSelectionEvent) => + this.onUnitSelectionChange(e), + ); + this.eventBus.on(ReplaySpeedChangeEvent, (e: ReplaySpeedChangeEvent) => + this.onReplaySpeedChange(e.replaySpeedMultiplier), + ); + window.addEventListener("resize", () => this.resizePixiCanvas()); + + this.redraw(); + loadAllSprites(); + this.setupPixiRenderer().then(() => this.redrawPixiUnits()); + } + + /** + * OPT 8 — Reuse existing canvas elements when possible. + */ + redraw() { + if (!this.canvasesAllocated) { + this.canvas = document.createElement("canvas"); + this.transportShipTrailCanvas = document.createElement("canvas"); + this.interpolationCanvas = document.createElement("canvas"); + this.canvasesAllocated = true; + } + + const w = this.game.width(); + const h = this.game.height(); + + // Only resize if dimensions changed + if (this.canvas.width !== w || this.canvas.height !== h) { + this.canvas.width = w; + this.canvas.height = h; + this.transportShipTrailCanvas.width = w; + this.transportShipTrailCanvas.height = h; + this.interpolationCanvas.width = w; + this.interpolationCanvas.height = h; + } + + this.context = this.canvas.getContext("2d")!; + this.unitTrailContext = this.transportShipTrailCanvas.getContext("2d")!; + this.interpolationContext = this.interpolationCanvas.getContext("2d")!; + this.interpolationContext.imageSmoothingEnabled = false; + + // Clear all three + this.context.clearRect(0, 0, w, h); + this.unitTrailContext.clearRect(0, 0, w, h); + this.interpolationContext.clearRect(0, 0, w, h); + + this.renderedUnits.clear(); + const units = this.game.units(); + units.forEach((u) => { + if (UNIT_LAYER_TYPES.has(u.type()) && !PIXI_UNIT_TYPES.has(u.type())) { + this.renderedUnits.set(u.id(), u); + } + }); + this.updateUnitsSprites(units.map((unit) => unit.id())); + + this.redrawPixiUnits(); + + this.renderedGhosts.clear(); + const ghosts = (this.game as any).submarineGhosts?.call(this.game) ?? []; + for (const ghost of ghosts as Array<{ + id: number; + pos: number; + expiresAt: number; + ownerID: number; + }>) { + this.createPixiGhost(ghost); + this.renderedGhosts.set(ghost.id, ghost.pos); + } + + this.unitToTrail.forEach((trail, unit) => { + for (const t of trail) { + this.paintCell( + this.game.x(t), + this.game.y(t), + this.relationship(unit), + this.theme.territoryColor(unit.owner()), + 150, + this.unitTrailContext, + ); + } + }); + } + + private redrawPixiUnits() { + if (!this.pixiRenderer) return; + + // OPT 2 — iterate Map + for (const render of this.pixiRenderMap.values()) { + render.pixiSprite.destroy(); + } + this.pixiRenderMap.clear(); + + for (const ghost of this.ghostRenders) { + ghost.pixiSprite.destroy(); + } + this.ghostRenders = []; + + const units = this.game.units(); + for (const unit of units) { + if (PIXI_UNIT_TYPES.has(unit.type()) && unit.isActive()) { + const sprite = this.createPixiSprite(unit); + this.pixiRenderMap.set(unit.id(), new UnitRenderInfo(unit, sprite)); + } + } + } + + // ──────────────────────────────────────────────────────────────────────── + // renderLayer (OPT 3 — cache alpha, OPT 4 — reuse Cell) + // ──────────────────────────────────────────────────────────────────────── + + renderLayer(context: CanvasRenderingContext2D) { + // OPT 3 — compute once, reuse everywhere this frame + this._frameAlpha = this.computeTickAlpha(); + + this.updateInterpolatedUnits(); + this.renderPixiUnits(); + + PerformanceMetrics.getInstance().incrementVisibleEntities( + this.renderedUnits.size + this.pixiRenderMap.size, + ); + + const hw = -this.game.width() / 2; + const hh = -this.game.height() / 2; + const w = this.game.width(); + const h = this.game.height(); + + context.drawImage(this.transportShipTrailCanvas, hw, hh, w, h); + context.drawImage(this.canvas, hw, hh, w, h); + if (this.interpolationCanvas) { + context.drawImage(this.interpolationCanvas, hw, hh, w, h); + } + } + + // ──────────────────────────────────────────────────────────────────────── + // PIXI render loop (OPT 7 — no getBounds, OPT 4 — reuse Cell) + // ──────────────────────────────────────────────────────────────────────── + + private renderPixiUnits() { + if (!this.pixiRenderer) return; + + const metrics = PerformanceMetrics.getInstance(); + const unitCounts = new Map(); + const renderTimes = new Map(); + + const canvasWidth = this.pixiRenderer.canvas.width; + const canvasHeight = this.pixiRenderer.canvas.height; + + // OPT 2 — iterate map values + for (const render of this.pixiRenderMap.values()) { + const startTime = metrics.enabled ? performance.now() : 0; + this.updatePixiSpritePosition(render); + + if (metrics.enabled) { + const duration = performance.now() - startTime; + const t = render.unit.type(); + renderTimes.set(t, (renderTimes.get(t) ?? 0) + duration); + + // OPT 7 — use sprite x/y directly instead of getBounds() + if (render.pixiSprite.visible) { + const sx = render.pixiSprite.x; + const sy = render.pixiSprite.y; + const margin = ICON_DIM * this.iconScreenScale(); + if ( + sx + margin >= 0 && + sy + margin >= 0 && + sx - margin <= canvasWidth && + sy - margin <= canvasHeight + ) { + unitCounts.set(t, (unitCounts.get(t) ?? 0) + 1); + } + } + } + } + + if (metrics.enabled) { + renderTimes.forEach((time, ut) => metrics.recordUnitRenderTime(ut, time)); + unitCounts.forEach((count, ut) => metrics.recordUnitVisible(ut, count)); + } + + this.updatePixiGhosts(); + this.pixiRenderer.render(this.pixiStage); + } + + /** + * OPT 4 — use _tmpPoint to avoid `new Cell(...)` per unit. + * OPT 9 — bitwise pixel-snap. + */ + private updatePixiSpritePosition(render: UnitRenderInfo) { + const unit = render.unit; + + if (unit.type() === UnitType.Shell) { + this.updateShellGraphics(render); + return; + } + + // Bomber at airfield — OPT 6 O(1) check + if (unit.type() === UnitType.Bomber) { + if (this.isUnitAtOwnedAirfield(unit)) { + render.pixiSprite.visible = false; + return; + } + render.pixiSprite.visible = true; + const angle = this.getUnitAngle(unit); + if (angle !== null) render.pixiSprite.rotation = angle; + } + + // FighterJet rotation + stars + if (unit.type() === UnitType.FighterJet) { + const angle = this.getUnitAngle(unit); + const jetSprite = (render.pixiSprite as any)._jetSprite; + const starSprite = (render.pixiSprite as any)._starSprite; + + if (angle !== null && jetSprite) { + jetSprite.rotation = angle; + } + if (starSprite && jetSprite) { + const level = unit.level ? unit.level() : 1; + const starTexture = this.getStarTexture(level); + if (starSprite.texture !== starTexture) { + starSprite.texture = starTexture; + } + const spriteWidth = jetSprite.texture.width; + const spriteHeight = jetSprite.texture.height; + const padding = 1; + starSprite.x = -spriteWidth / 2 + padding; + starSprite.y = -spriteHeight / 2 + padding; + } + } + + // Submarine stealth + if ( + unit.type() === UnitType.Submarine && + unit.owner() === this.game.myPlayer() + ) { + const visible = + unit.isAttacking() || unit.isDetectedByNavalUnit() || unit.isCooldown(); + render.pixiSprite.alpha = visible ? 1.0 : 0.75; + } else { + render.pixiSprite.alpha = 1.0; + } + + // Interpolated position (OPT 3 — use cached _frameAlpha) + const lastTile = unit.lastTile(); + const currentTile = unit.tile(); + const isMoving = lastTile !== currentTile; + + let px: number, py: number; + if (isMoving) { + const alpha = this._frameAlpha; + const sx = this.game.x(lastTile); + const sy = this.game.y(lastTile); + const ex = this.game.x(currentTile); + const ey = this.game.y(currentTile); + px = sx + (ex - sx) * alpha; + py = sy + (ey - sy) * alpha; + } else { + px = this.game.x(currentTile); + py = this.game.y(currentTile); + } + + // OPT 4 — reuse _tmpPoint + this._tmpPoint.x = px; + this._tmpPoint.y = py; + const screenPos = this.transformHandler.worldToScreenCoordinates( + this._tmpPoint as Cell, + ); + + // OPT 9 — bitwise pixel-snap + render.pixiSprite.x = (screenPos.x + 0.5) | 0; + render.pixiSprite.y = (screenPos.y + 0.5) | 0; + + // Scale + horizontal flip + const baseScale = this.iconScreenScale(); + + if (FLIP_UNIT_TYPES.has(unit.type())) { + if (lastTile && currentTile) { + const dx = this.game.x(currentTile) - this.game.x(lastTile); + if (dx !== 0) render.facingLeft = dx < 0; + render.pixiSprite.scale.set( + render.facingLeft ? -baseScale : baseScale, + baseScale, + ); + } else { + render.pixiSprite.scale.set(baseScale, baseScale); + } + } else { + render.pixiSprite.scale.set(baseScale, baseScale); + } + + // Targeting texture swap + const isTargeting = this.isUnitTargeting(unit); + const texture = this.createPixiTexture(unit, isTargeting); + if (render.pixiSprite.texture !== texture) { + render.pixiSprite.texture = texture; + } + } + + private updateShellGraphics(render: UnitRenderInfo) { + const unit = render.unit; + const graphics = render.pixiSprite as any as PIXI.Graphics; + graphics.clear(); + + const color = this.theme.borderColor(unit.owner()); + const colorNum = parseInt(color.toHex().substring(1), 16); + + // OPT 3 — cached alpha + const alpha = this._frameAlpha; + const sx = this.game.x(unit.lastTile()); + const sy = this.game.y(unit.lastTile()); + const ex = this.game.x(unit.tile()); + const ey = this.game.y(unit.tile()); + const px = sx + (ex - sx) * alpha; + const py = sy + (ey - sy) * alpha; + + // OPT 4 — reuse _tmpPoint + this._tmpPoint.x = px; + this._tmpPoint.y = py; + const screenPos = this.transformHandler.worldToScreenCoordinates( + this._tmpPoint as Cell, + ); + + if (sx !== px || sy !== py) { + this._tmpPoint.x = sx; + this._tmpPoint.y = sy; + const lastScreenPos = this.transformHandler.worldToScreenCoordinates( + this._tmpPoint as Cell, + ); + graphics.lineStyle(1, colorNum, 0.7); + graphics.moveTo(lastScreenPos.x, lastScreenPos.y); + graphics.lineTo(screenPos.x, screenPos.y); + } + + graphics.beginFill(colorNum, 1.0); + graphics.drawRect(screenPos.x - 0.5, screenPos.y - 0.5, 1, 1); + graphics.endFill(); + + graphics.beginFill(colorNum, 0.4); + graphics.drawRect(screenPos.x - 1, screenPos.y - 1, 2, 2); + graphics.endFill(); + } + + private isUnitTargeting(unit: UnitView): boolean { + if ( + unit.type() === UnitType.Warship || + unit.type() === UnitType.Submarine || + unit.type() === UnitType.FighterJet + ) { + return unit.targetUnitId() !== undefined; + } + return false; + } + + // ──────────────────────────────────────────────────────────────────────── + // Canvas2D unit update (OPT 1, 10) + // ──────────────────────────────────────────────────────────────────────── + + private updateUnitsSprites(unitIds: number[]) { + const unitsToUpdate: UnitView[] = []; + const unitsToRemove: UnitView[] = []; + + for (const id of unitIds) { + const unit = this.game.unit(id); + if (unit) { + if ( + UNIT_LAYER_TYPES.has(unit.type()) && + !PIXI_UNIT_TYPES.has(unit.type()) + ) { + unitsToUpdate.push(unit); + this.renderedUnits.set(id, unit); + } + } else { + const removed = this.renderedUnits.get(id); + if (removed) { + unitsToRemove.push(removed); + this.renderedUnits.delete(id); + } + } + } + + const allUnitsToClear = [...unitsToUpdate, ...unitsToRemove]; + if (allUnitsToClear.length === 0) return; + + const oldAngleByUnit = new Map(); + for (const u of allUnitsToClear) { + oldAngleByUnit.set(u, this.unitToLastAngle.get(u) ?? null); + } + + // OPT 10 — angleByUnit only computed for rotatable types + const angleByUnit = new Map(); + for (const u of unitsToUpdate) { + // getUnitAngle now short-circuits for non-rotatable types + angleByUnit.set(u, this.getUnitAngle(u)); + } + + this.clearUnitsCells(allUnitsToClear, oldAngleByUnit); + this.drawUnitsCells(unitsToUpdate, angleByUnit); + + for (const u of unitsToRemove) { + this.handleUnitDeactivation(u); + } + } + + private clearUnitsCells( + unitViews: UnitView[], + angleByUnit: Map, + ) { + for (const unitView of unitViews) { + if (!isSpriteReady(unitView.type())) continue; + + const spriteSize = this.getSpriteSize(unitView); + const sizeMult = this.effectiveSizeMultiplier(unitView); + const newWidth = spriteSize * sizeMult; + const newHeight = spriteSize * sizeMult; + + const level = (unitView as any).level ? (unitView as any).level() : 1; + const badgeSize = Math.max(2, Math.min(3, (newWidth * 0.18 + 0.5) | 0)); + const offset = 1; + const overlayTop = badgeSize + offset; + const extraRight = badgeSize + offset; + const padding = 2; + const maxHalfWidth = newWidth / 2 + extraRight; + const lastX = this.game.x(unitView.lastTile()); + const lastY = this.game.y(unitView.lastTile()); + const angle = angleByUnit.get(unitView) ?? null; + + if (angle !== null) { + this.context.save(); + this.context.translate(lastX, lastY); + this.context.rotate(angle); + this.context.translate(-lastX, -lastY); + } + + const left = lastX - maxHalfWidth - padding; + const top = lastY - newHeight / 2 - overlayTop - padding; + const width = maxHalfWidth * 2 + padding * 2; + const height = newHeight + overlayTop + padding * 2; + this.context.clearRect(left, top, width, height); + + if (angle !== null) { + this.context.restore(); + } + } + } + + private drawUnitsCells( + unitViews: UnitView[], + angleByUnit: Map, + ) { + this.drawingBasePass = true; + + const metrics = PerformanceMetrics.getInstance(); + const canvasUnitCounts = new Map(); + const canvasRenderTimes = new Map(); + + const canvasWidth = this.context.canvas.width; + const canvasHeight = this.context.canvas.height; + + try { + for (const unitView of unitViews) { + if (!PIXI_UNIT_TYPES.has(unitView.type())) { + const startTime = metrics.enabled ? performance.now() : 0; + this.onUnitEvent(unitView, angleByUnit); + + if (metrics.enabled) { + const duration = performance.now() - startTime; + const t = unitView.type(); + canvasRenderTimes.set( + t, + (canvasRenderTimes.get(t) ?? 0) + duration, + ); + + const worldX = this.game.x(unitView.tile()); + const worldY = this.game.y(unitView.tile()); + // OPT 4 + this._tmpPoint.x = worldX; + this._tmpPoint.y = worldY; + const screenPos = this.transformHandler.worldToScreenCoordinates( + this._tmpPoint as Cell, + ); + const margin = 50; + if ( + screenPos.x >= -margin && + screenPos.y >= -margin && + screenPos.x <= canvasWidth + margin && + screenPos.y <= canvasHeight + margin + ) { + canvasUnitCounts.set(t, (canvasUnitCounts.get(t) ?? 0) + 1); + } + } + } else { + this.onUnitEvent(unitView, angleByUnit); + } + } + + if (metrics.enabled) { + canvasRenderTimes.forEach((time, ut) => + metrics.recordUnitRenderTime(ut, time), + ); + canvasUnitCounts.forEach((count, ut) => + metrics.recordUnitVisible(ut, count), + ); + } + } finally { + this.drawingBasePass = false; + } + } + + // ──────────────────────────────────────────────────────────────────────── + // Interpolation (OPT 1, 3) + // ──────────────────────────────────────────────────────────────────────── + + private interpolatePosition(unit: UnitView, alpha: number) { + const startTile = unit.lastTile(); + const endTile = unit.tile(); + const sx = this.game.x(startTile); + const sy = this.game.y(startTile); + const ex = this.game.x(endTile); + const ey = this.game.y(endTile); + return { x: sx + (ex - sx) * alpha, y: sy + (ey - sy) * alpha }; + } + + private updateInterpolatedUnits() { + if (!this.interpolationContext || !this.interpolationCanvas) return; + + this.interpolationContext.clearRect( + 0, + 0, + this.interpolationCanvas.width, + this.interpolationCanvas.height, + ); + + // OPT 3 — use cached _frameAlpha + const alpha = this._frameAlpha; + const units = this.game.units(...INTERPOLATED_UNIT_TYPES_ARRAY); + + for (const unit of units) { + if (!unit.isActive()) continue; + if (PIXI_UNIT_TYPES.has(unit.type())) continue; + + if (unit.type() === UnitType.Bomber && this.isUnitAtOwnedAirfield(unit)) { + continue; + } + + if (unit.type() === UnitType.AABullet) continue; + + const position = this.interpolatePosition(unit, alpha); + + switch (unit.type()) { + case UnitType.Shell: + this.renderShell(unit, position); + continue; + case UnitType.MIRVWarhead: + this.renderWarhead(unit, position); + continue; + default: + if (!isSpriteReady(unit.type())) continue; + this.drawSpriteAtPosition( + unit, + position, + this.getInterpolatedSpriteColor(unit), + this.interpolationContext, + true, + ); + } + } + } + + private getInterpolatedSpriteColor(unit: UnitView): Colord | undefined { + if (unit.targetUnitId()) { + const t = unit.type(); + if (t === UnitType.Warship || t === UnitType.FighterJet) { + return colord("rgb(200,0,0)"); + } + } + return undefined; + } + + private renderShell(unit: UnitView, position: { x: number; y: number }) { + const rel = this.relationship(unit); + const color = this.theme.borderColor(unit.owner()); + this.drawInterpolatedSquare(position, rel, color, 1, 1); + this.drawInterpolatedSquare(position, rel, color, 2, 0.4); + const last = { + x: this.game.x(unit.lastTile()), + y: this.game.y(unit.lastTile()), + }; + if (last.x !== position.x || last.y !== position.y) { + this.drawInterpolatedSegment(last, position, rel, color, 0.7); + } + } + + private renderWarhead(unit: UnitView, position: { x: number; y: number }) { + const rel = this.relationship(unit); + const color = this.theme.borderColor(unit.owner()); + this.drawInterpolatedSquare(position, rel, color, 1, 1); + this.drawInterpolatedSquare(position, rel, color, 2, 0.35); + const last = { + x: this.game.x(unit.lastTile()), + y: this.game.y(unit.lastTile()), + }; + if (last.x !== position.x || last.y !== position.y) { + this.drawInterpolatedSegment(last, position, rel, color, 0.5); + } + } + + private drawInterpolatedSquare( + position: { x: number; y: number }, + relationship: Relationship, + color: Colord, + size: number, + alpha: number, + ) { + if (!this.interpolationContext) return; + const ctx = this.interpolationContext; + ctx.fillStyle = this.resolveInterpolatedColor(relationship, color, alpha); + ctx.fillRect(position.x - size / 2, position.y - size / 2, size, size); + } + + private drawInterpolatedSegment( + start: { x: number; y: number }, + end: { x: number; y: number }, + relationship: Relationship, + color: Colord, + alpha: number, + ) { + if (!this.interpolationContext) return; + const ctx = this.interpolationContext; + ctx.strokeStyle = this.resolveInterpolatedColor(relationship, color, alpha); + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(start.x, start.y); + ctx.lineTo(end.x, end.y); + ctx.stroke(); + } + + private resolveInterpolatedColor( + relationship: Relationship, + color: Colord, + alpha: number, + ): string { + if (this.alternateView) { + return this.getAlternateViewColor(relationship) + .alpha(alpha) + .toRgbString(); + } + return color.alpha(alpha).toRgbString(); + } + + private getAlternateViewColor(relationship: Relationship): Colord { + switch (relationship) { + case Relationship.Self: + return this.theme.selfColor(); + case Relationship.Ally: + return this.theme.allyColor(); + case Relationship.Enemy: + default: + return this.theme.enemyColor(); + } + } + + private relationship(unit: UnitView): Relationship { + const myPlayer = this.game.myPlayer(); + if (myPlayer === null) return Relationship.Enemy; + if (myPlayer === unit.owner()) return Relationship.Self; + if (myPlayer.isFriendly(unit.owner())) return Relationship.Ally; + return Relationship.Enemy; + } + + // ──────────────────────────────────────────────────────────────────────── + // Selection / event handlers (identical logic, kept for parity) + // ──────────────────────────────────────────────────────────────────────── + + private findWarshipsNearCell(cell: { x: number; y: number }): UnitView[] { + if (!this.game.isValidCoord(cell.x, cell.y)) return []; + const clickRef = this.game.ref(cell.x, cell.y); + PerformanceMetrics.getInstance().recordUnitQuery(UnitType.Warship); + return this.game + .units(UnitType.Warship) + .filter( + (u) => + u.isActive() && + u.owner() === this.game.myPlayer() && + this.game.manhattanDist(u.tile(), clickRef) <= + this.WARSHIP_SELECTION_RADIUS, + ) + .sort( + (a, b) => + this.game.manhattanDist(a.tile(), clickRef) - + this.game.manhattanDist(b.tile(), clickRef), + ); + } + + private findSubmarinesNearCell(cell: { x: number; y: number }): UnitView[] { + if (!this.game.isValidCoord(cell.x, cell.y)) return []; + const clickRef = this.game.ref(cell.x, cell.y); + return this.game + .units(UnitType.Submarine) + .filter( + (u) => + u.isActive() && + u.owner() === this.game.myPlayer() && + this.game.manhattanDist(u.tile(), clickRef) <= + this.SUBMARINE_SELECTION_RADIUS, + ) + .sort( + (a, b) => + this.game.manhattanDist(a.tile(), clickRef) - + this.game.manhattanDist(b.tile(), clickRef), + ); + } + + private findFighterJetsNearCell(cell: { x: number; y: number }): UnitView[] { + if (!this.game.isValidCoord(cell.x, cell.y)) return []; + const clickRef = this.game.ref(cell.x, cell.y); + return this.game + .units(UnitType.FighterJet) + .filter( + (u) => + u.isActive() && + u.owner() === this.game.myPlayer() && + this.game.manhattanDist(u.tile(), clickRef) <= + this.FIGHTER_JET_SELECTION_RADIUS, + ) + .sort( + (a, b) => + this.game.manhattanDist(a.tile(), clickRef) - + this.game.manhattanDist(b.tile(), clickRef), + ); + } + + private findArtilleryNearCell(cell: { x: number; y: number }): UnitView[] { + if (!this.game.isValidCoord(cell.x, cell.y)) return []; + const clickRef = this.game.ref(cell.x, cell.y); + return this.game + .units(UnitType.Artillery) + .filter( + (u) => + u.isActive() && + u.owner() === this.game.myPlayer() && + this.game.manhattanDist(u.tile(), clickRef) <= + this.WARSHIP_SELECTION_RADIUS, + ) + .sort( + (a, b) => + this.game.manhattanDist(a.tile(), clickRef) - + this.game.manhattanDist(b.tile(), clickRef), + ); + } + + private onMouseUp(event: MouseUpEvent) { + const cell = this.transformHandler.screenToWorldCoordinates( + event.x, + event.y, + ); + const nearbyWarships = this.findWarshipsNearCell(cell); + const nearbySubmarines = this.findSubmarinesNearCell(cell); + const nearbyFighterJets = this.findFighterJetsNearCell(cell); + const nearbyArtillery = this.findArtilleryNearCell(cell); + + if (this.selectedUnit) { + const clickRef = this.game.ref(cell.x, cell.y); + if (this.selectedUnit.type() === UnitType.FighterJet) { + this.eventBus.emit( + new MoveFighterJetIntentEvent(this.selectedUnit.id(), clickRef), + ); + } else if ( + this.selectedUnit.type() === UnitType.Warship && + this.game.isOcean(clickRef) + ) { + this.eventBus.emit( + new MoveWarshipIntentEvent(this.selectedUnit.id(), clickRef), + ); + } else if ( + this.selectedUnit.type() === UnitType.Submarine && + this.game.isOcean(clickRef) + ) { + this.eventBus.emit( + new MoveSubmarineIntentEvent(this.selectedUnit.id(), clickRef), + ); + } else if ( + this.selectedUnit.type() === UnitType.Artillery && + !this.game.isOcean(clickRef) + ) { + const lvl = this.selectedUnit.level ? this.selectedUnit.level() : 1; + const maxDist = getArtilleryMaxDistance(lvl); + const distSq = this.game.euclideanDistSquared( + this.selectedUnit.tile(), + clickRef, + ); + if (distSq > maxDist * maxDist) { + this.eventBus.emit(new ArtilleryOutOfRangeEvent(lvl, maxDist)); + } else { + this.eventBus.emit( + new MoveArtilleryIntentEvent(this.selectedUnit.id(), clickRef), + ); + } + } + event.consumed = true; + this.eventBus.emit(new UnitSelectionEvent(this.selectedUnit, false)); + return; + } else if (nearbyWarships.length > 0) { + this.eventBus.emit(new UnitSelectionEvent(nearbyWarships[0], true)); + } else if (nearbySubmarines.length > 0) { + this.eventBus.emit(new UnitSelectionEvent(nearbySubmarines[0], true)); + } else if (nearbyFighterJets.length > 0) { + this.eventBus.emit(new UnitSelectionEvent(nearbyFighterJets[0], true)); + } else if (nearbyArtillery.length > 0) { + this.eventBus.emit(new UnitSelectionEvent(nearbyArtillery[0], true)); + } + } + + private onUnitSelectionChange(event: UnitSelectionEvent) { + if (event.isSelected) { + this.selectedUnit = event.unit; + } else if (this.selectedUnit === event.unit) { + this.selectedUnit = null; + } + } + + private handleUnitDeactivation(unit: UnitView) { + if (this.selectedUnit === unit && !unit.isActive()) { + this.eventBus.emit(new UnitSelectionEvent(unit, false)); + } + this.unitToLastAngle.delete(unit); + } + + onAlternativeViewEvent(event: AlternateViewEvent) { + this.alternateView = event.alternateView; + this.redraw(); + } + + // ──────────────────────────────────────────────────────────────────────── + // Canvas2D unit event dispatch + // ──────────────────────────────────────────────────────────────────────── + + onUnitEvent(unit: UnitView, angleByUnit?: Map) { + if (PIXI_UNIT_TYPES.has(unit.type())) return; + + if (!unit.isActive()) { + this.handleUnitDeactivation(unit); + } + + if (unit.type() === UnitType.Bomber && this.isUnitAtOwnedAirfield(unit)) { + return; + } + + // Submarine owner stealth + if ( + unit.type() === UnitType.Submarine && + unit.owner() === this.game.myPlayer() + ) { + const visible = + unit.isAttacking() || unit.isDetectedByNavalUnit() || unit.isCooldown(); + if (!visible) { + this.drawSprite(unit, undefined, 0.75); + return; + } + } + + switch (unit.type()) { + case UnitType.TransportShip: + case UnitType.Paratrooper: + this.handleBoatEvent(unit); + break; + case UnitType.Submarine: + case UnitType.Warship: + this.handleWarShipEvent(unit, angleByUnit); + break; + case UnitType.Artillery: + break; // Rendered by StructureLayer + case UnitType.Shell: + this.handleShellEvent(unit); + break; + case UnitType.SAMMissile: + this.handleMissileEvent(unit, angleByUnit); + break; + case UnitType.TradeShip: + this.handleTradeShipEvent(unit, angleByUnit); + break; + case UnitType.CargoPlane: + this.handleCargoPlaneEvent(unit, angleByUnit); + break; + case UnitType.MIRVWarhead: + this.handleMIRVWarhead(unit); + break; + case UnitType.Bomber: + this.handleBomberEvent(unit, angleByUnit); + break; + case UnitType.FighterJet: + this.handleFighterJetEvent(unit, angleByUnit); + break; + case UnitType.AtomBomb: + case UnitType.HydrogenBomb: + case UnitType.MIRV: + this.handleNuke(unit); + break; + } + } + + private handleWarShipEvent( + unit: UnitView, + angleByUnit?: Map, + ) { + if (unit.targetUnitId()) { + this.drawSprite(unit, colord({ r: 200, b: 0, g: 0 }), angleByUnit); + } else { + this.drawSprite(unit, undefined, angleByUnit); + } + } + + private handleShellEvent(unit: UnitView) { + const rel = this.relationship(unit); + this.clearCell(this.game.x(unit.lastTile()), this.game.y(unit.lastTile())); + const oldTile = this.oldShellTile.get(unit); + if (oldTile !== undefined) { + this.clearCell(this.game.x(oldTile), this.game.y(oldTile)); + } + this.oldShellTile.set(unit, unit.lastTile()); + if (!unit.isActive()) return; + + this.paintCell( + this.game.x(unit.tile()), + this.game.y(unit.tile()), + rel, + this.theme.borderColor(unit.owner()), + 255, + ); + this.paintCell( + this.game.x(unit.lastTile()), + this.game.y(unit.lastTile()), + rel, + this.theme.borderColor(unit.owner()), + 255, + ); + } + + private handleMissileEvent( + unit: UnitView, + angleByUnit?: Map, + ) { + this.drawSprite(unit, undefined, angleByUnit); + } + + private drawTrail(trail: number[], color: Colord, rel: Relationship) { + for (const t of trail) { + this.paintCell( + this.game.x(t), + this.game.y(t), + rel, + color, + 150, + this.unitTrailContext, + ); + } + } + + private clearTrail(unit: UnitView) { + const trail = this.unitToTrail.get(unit) ?? []; + const rel = this.relationship(unit); + for (const t of trail) { + this.clearCell(this.game.x(t), this.game.y(t), this.unitTrailContext); + } + this.unitToTrail.delete(unit); + + const trailSet = new Set(trail); + for (const [other, trail] of this.unitToTrail) { + for (const t of trail) { + if (trailSet.has(t)) { + this.paintCell( + this.game.x(t), + this.game.y(t), + rel, + this.theme.territoryColor(other.owner()), + 150, + this.unitTrailContext, + ); + } + } + } + } + + private handleNuke( + unit: UnitView, + angleByUnit?: Map, + ) { + const rel = this.relationship(unit); + if (!this.unitToTrail.has(unit)) { + this.unitToTrail.set(unit, []); + } + let newTrailSize = 1; + const trail = this.unitToTrail.get(unit)!; + if (trail.length >= 1) { + const cur = { + x: this.game.x(unit.lastTile()), + y: this.game.y(unit.lastTile()), + }; + const prev = { + x: this.game.x(trail[trail.length - 1]), + y: this.game.y(trail[trail.length - 1]), + }; + const line = new BezenhamLine(prev, cur); + let point = line.increment(); + while (point !== true) { + trail.push(this.game.ref(point.x, point.y)); + point = line.increment(); + } + newTrailSize = line.size(); + } else { + trail.push(unit.lastTile()); + } + this.drawTrail( + trail.slice(-newTrailSize), + this.theme.territoryColor(unit.owner()), + rel, + ); + this.drawSprite(unit, undefined, angleByUnit); + if (!unit.isActive()) { + this.clearTrail(unit); + } + } + + private handleMIRVWarhead(unit: UnitView) { + const rel = this.relationship(unit); + this.clearCell(this.game.x(unit.lastTile()), this.game.y(unit.lastTile())); + if (unit.isActive()) { + this.paintCell( + this.game.x(unit.tile()), + this.game.y(unit.tile()), + rel, + this.theme.borderColor(unit.owner()), + 255, + ); + } + } + + private handleTradeShipEvent( + unit: UnitView, + angleByUnit?: Map, + ) { + this.drawSprite(unit, undefined, angleByUnit); + } + + private handleCargoPlaneEvent( + unit: UnitView, + angleByUnit?: Map, + ) { + this.drawSprite(unit, undefined, angleByUnit); + } + + private handleBomberEvent( + unit: UnitView, + angleByUnit?: Map, + ) { + this.drawSprite(unit, undefined, angleByUnit); + } + + private handleFighterJetEvent( + unit: UnitView, + angleByUnit?: Map, + ) { + if (unit.targetUnitId()) { + this.drawSprite(unit, colord({ r: 200, b: 0, g: 0 }), angleByUnit); + } else { + this.drawSprite(unit, undefined, angleByUnit); + } + } + + private handleBoatEvent(unit: UnitView) { + const rel = this.relationship(unit); + if (!this.unitToTrail.has(unit)) { + this.unitToTrail.set(unit, []); + } + const trail = this.unitToTrail.get(unit)!; + trail.push(unit.lastTile()); + this.drawTrail( + trail.slice(-1), + this.theme.territoryColor(unit.owner()), + rel, + ); + this.drawSprite(unit); + if (!unit.isActive()) { + this.clearTrail(unit); + } + } + + // ──────────────────────────────────────────────────────────────────────── + // Paint / clear helpers + // ──────────────────────────────────────────────────────────────────────── + + paintCell( + x: number, + y: number, + relationship: Relationship, + color: Colord, + alpha: number, + context: CanvasRenderingContext2D = this.context, + ) { + this.clearCell(x, y, context); + if (this.alternateView) { + switch (relationship) { + case Relationship.Self: + context.fillStyle = this.theme.selfColor().toRgbString(); + break; + case Relationship.Ally: + context.fillStyle = this.theme.allyColor().toRgbString(); + break; + case Relationship.Enemy: + context.fillStyle = this.theme.enemyColor().toRgbString(); + break; + } + } else { + context.fillStyle = color.alpha(alpha / 255).toRgbString(); + } + context.fillRect(x, y, 1, 1); + } + + clearCell( + x: number, + y: number, + context: CanvasRenderingContext2D = this.context, + ) { + context.clearRect(x, y, 1, 1); + } + + // ──────────────────────────────────────────────────────────────────────── + // Sprite drawing (OPT 1 — Set.has for base-pass guard) + // ──────────────────────────────────────────────────────────────────────── + + drawSprite( + unit: UnitView, + customTerritoryColor?: Colord, + sizeMultiplier?: number, + ): void; + drawSprite( + unit: UnitView, + customTerritoryColor?: Colord, + angleByUnit?: Map, + sizeMultiplier?: number, + ): void; + drawSprite( + unit: UnitView, + customTerritoryColor?: Colord, + angleByUnitOrSizeMultiplier?: Map | number, + sizeMultiplier: number = 1.0, + ) { + let angleByUnit: Map | undefined; + let sizeMult = sizeMultiplier; + + if (typeof angleByUnitOrSizeMultiplier === "number") { + sizeMult = angleByUnitOrSizeMultiplier; + } else { + angleByUnit = angleByUnitOrSizeMultiplier; + } + + // OPT 1 — Set.has O(1) instead of Array.includes O(n) + if (this.drawingBasePass && INTERPOLATED_UNIT_TYPES.has(unit.type())) { + return; + } + + const x = this.game.x(unit.tile()); + const y = this.game.y(unit.tile()); + + let alternateViewColor: Colord | null = null; + if (this.alternateView) { + let rel = this.relationship(unit); + const destinationId = unit.targetUnitId(); + if ( + (unit.type() === UnitType.TradeShip || + unit.type() === UnitType.CargoPlane) && + destinationId !== undefined + ) { + const target = this.game.unit(destinationId)?.owner(); + const myPlayer = this.game.myPlayer(); + if (myPlayer !== null && target !== undefined) { + if (myPlayer === target) rel = Relationship.Self; + else if (myPlayer.isFriendly(target)) rel = Relationship.Ally; + } + } + switch (rel) { + case Relationship.Self: + alternateViewColor = this.theme.selfColor(); + break; + case Relationship.Ally: + alternateViewColor = this.theme.allyColor(); + break; + case Relationship.Enemy: + alternateViewColor = this.theme.enemyColor(); + break; + } + } + + const sprite = getColoredSprite( + unit, + this.theme, + alternateViewColor ?? customTerritoryColor, + alternateViewColor ?? undefined, + ); + + if (unit.isActive()) { + const targetable = unit.targetable(); + if (!targetable) { + this.context.save(); + this.context.globalAlpha = 0.5; + } + + const angle = angleByUnit?.get(unit) ?? this.getUnitAngle(unit); + // OPT 9 — bitwise round + const cx = (x + 0.5) | 0; + const cy = (y + 0.5) | 0; + const newWidth = sprite.width * sizeMult; + const newHeight = sprite.width * sizeMult; + + if (angle !== null) { + this.context.save(); + this.context.translate(cx, cy); + this.context.rotate(angle); + this.context.translate(-cx, -cy); + } + + this.context.drawImage( + sprite, + cx - newWidth / 2, + cy - newHeight / 2, + newWidth, + newHeight, + ); + + // Level badge + const type = unit.type(); + if ( + type === UnitType.Warship || + type === UnitType.FighterJet || + type === UnitType.Submarine || + type === UnitType.Bomber + ) { + const level = unit.level ? unit.level() : 1; + const tierColor = + level >= 4 + ? "#E5E4E2" + : level === 3 + ? "#FFD700" + : level === 2 + ? "#C0C0C0" + : "#CD7F32"; + const badgeSize = Math.max(2, Math.min(3, (newWidth * 0.18 + 0.5) | 0)); + const offset = 1; + const badgeLeft = (cx + newWidth / 2 + offset + 0.5) | 0; + const badgeTop = (cy - newHeight / 2 - badgeSize - offset + 0.5) | 0; + this.context.fillStyle = tierColor; + this.context.fillRect(badgeLeft, badgeTop, badgeSize, badgeSize); + } + + if (angle !== null) this.context.restore(); + if (!targetable) this.context.restore(); + } + } + + private drawSpriteAtPosition( + unit: UnitView, + position: { x: number; y: number }, + customTerritoryColor?: Colord, + context: CanvasRenderingContext2D = this.context, + snapToPixel = true, + ) { + let alternateViewColor: Colord | null = null; + if (this.alternateView) { + let rel = this.relationship(unit); + const destinationId = unit.targetUnitId(); + if ( + (unit.type() === UnitType.TradeShip || + unit.type() === UnitType.CargoPlane) && + destinationId !== undefined + ) { + const target = this.game.unit(destinationId)?.owner(); + const myPlayer = this.game.myPlayer(); + if (myPlayer !== null && target !== undefined) { + if (myPlayer === target) rel = Relationship.Self; + else if (myPlayer.isFriendly(target)) rel = Relationship.Ally; + } + } + switch (rel) { + case Relationship.Self: + alternateViewColor = this.theme.selfColor(); + break; + case Relationship.Ally: + alternateViewColor = this.theme.allyColor(); + break; + case Relationship.Enemy: + alternateViewColor = this.theme.enemyColor(); + break; + } + } + + const sprite = getColoredSprite( + unit, + this.theme, + alternateViewColor ?? customTerritoryColor, + alternateViewColor ?? undefined, + ); + + if (unit.isActive()) { + const targetable = unit.targetable(); + if (!targetable) { + context.save(); + context.globalAlpha = 0.5; + } + + const offsetX = snapToPixel + ? (position.x - sprite.width / 2 + 0.5) | 0 + : position.x - sprite.width / 2; + const offsetY = snapToPixel + ? (position.y - sprite.width / 2 + 0.5) | 0 + : position.y - sprite.width / 2; + + const isAircraft = ROTATABLE_UNIT_TYPES.has(unit.type()); + let rotated = false; + if (isAircraft) { + const angle = this.getUnitAngle(unit); + if (angle !== null) { + const cx = offsetX + sprite.width / 2; + const cy = offsetY + sprite.width / 2; + context.save(); + context.translate(cx, cy); + context.rotate(angle); + context.translate(-cx, -cy); + rotated = true; + } + } + + context.drawImage(sprite, offsetX, offsetY, sprite.width, sprite.width); + + const type = unit.type(); + if ( + type === UnitType.Warship || + type === UnitType.FighterJet || + type === UnitType.Submarine || + type === UnitType.Bomber + ) { + const level = (unit as any).level ? (unit as any).level() : 1; + const tierColor = + level >= 4 + ? "#E5E4E2" + : level === 3 + ? "#FFD700" + : level === 2 + ? "#C0C0C0" + : "#CD7F32"; + const badgeSize = Math.max( + 2, + Math.min(3, (sprite.width * 0.18 + 0.5) | 0), + ); + const offset = 1; + const cx = offsetX + sprite.width / 2; + const cy = offsetY + sprite.width / 2; + const badgeLeft = (cx + sprite.width / 2 + offset + 0.5) | 0; + const badgeTop = (cy - sprite.width / 2 - badgeSize - offset + 0.5) | 0; + context.fillStyle = tierColor; + context.fillRect(badgeLeft, badgeTop, badgeSize, badgeSize); + } + + if (rotated) context.restore(); + if (!targetable) context.restore(); + } + } + + // ──────────────────────────────────────────────────────────────────────── + // Angle computation (OPT 10 — early exit for non-rotatable types) + // ──────────────────────────────────────────────────────────────────────── + + /** + * OPT 10 — Short-circuit immediately if unit type is not rotatable. + * Avoids lastTile/tile lookups + math for the majority of units. + */ + private getUnitAngle(unit: UnitView): number | null { + if (!ROTATABLE_UNIT_TYPES.has(unit.type())) return null; + + const lastTile = unit.lastTile(); + const currentTile = unit.tile(); + if (!lastTile || !currentTile) return null; + + const lastX = this.game.x(lastTile); + const lastY = this.game.y(lastTile); + const curX = this.game.x(currentTile); + const curY = this.game.y(currentTile); + const dx = curX - lastX; + const dy = curY - lastY; + + const lastAngle = this.unitToLastAngle.get(unit); + if (dx === 0 && dy === 0) return lastAngle ?? null; + + let angle = Math.atan2(dy, dx); + + if (lastAngle !== undefined) { + const smoothingFactor = 0.25; + let angleDiff = angle - lastAngle; + while (angleDiff > Math.PI) angleDiff -= 2 * Math.PI; + while (angleDiff < -Math.PI) angleDiff += 2 * Math.PI; + angle = lastAngle + angleDiff * smoothingFactor; + } + + this.unitToLastAngle.set(unit, angle); + return angle; + } + + private getSpriteSize(unit: UnitView): number { + const t = unit.type(); + const existing = this.spriteSizeCache.get(t); + if (existing !== undefined) return existing; + const canvas = getColoredSprite(unit, this.theme); + const size = canvas.width; + this.spriteSizeCache.set(t, size); + return size; + } + + private effectiveSizeMultiplier(unit: UnitView): number { + if ( + unit.type() === UnitType.Submarine && + unit.owner() === this.game.myPlayer() + ) { + const isAttacking = ((unit as any).isAttacking?.() ?? false) as boolean; + const isDetected = ((unit as any).isDetectedByNavalUnit?.() ?? + false) as boolean; + const isOnCooldown = ((unit as any).isCooldown?.() ?? false) as boolean; + if (!(isAttacking || isDetected || isOnCooldown)) return 0.75; + } + return 1.0; + } + + private computeTickAlpha(): number { + const elapsed = Math.min( + this.now() - this.lastTickTimestamp, + this.tickIntervalMs, + ); + if (this.tickIntervalMs === 0) return 1; + return Math.max(0, elapsed / this.tickIntervalMs); + } + + private onReplaySpeedChange(multiplier: ReplaySpeedMultiplier) { + this.replaySpeedMultiplier = multiplier; + this.updateTickInterval(); + this.lastTickTimestamp = this.now(); + } + + private updateTickInterval() { + const baseInterval = this.baseTickIntervalMs; + if (baseInterval <= 0) { + this.tickIntervalMs = 0; + return; + } + this.tickIntervalMs = baseInterval * this.replaySpeedMultiplier; + } + + private now(): number { + if (typeof performance !== "undefined" && performance.now) { + return performance.now(); + } + return Date.now(); + } + + // ──────────────────────────────────────────────────────────────────────── + // Ghost management (unchanged) + // ──────────────────────────────────────────────────────────────────────── + + private updateGhosts() { + const ghosts = (this.game as any).submarineGhosts?.call(this.game) ?? []; + const currentGhostIds = new Set(); + + for (const ghost of ghosts as Array<{ + id: number; + pos: number; + expiresAt: number; + ownerID: number; + }>) { + currentGhostIds.add(ghost.id); + if (!this.renderedGhosts.has(ghost.id)) { + this.createPixiGhost(ghost); + this.renderedGhosts.set(ghost.id, ghost.pos); + } + } + + const ghostsToRemove: number[] = []; + for (const [id] of this.renderedGhosts) { + if (!currentGhostIds.has(id)) { + ghostsToRemove.push(id); + } + } + for (const id of ghostsToRemove) { + this.removePixiGhost(id); + this.renderedGhosts.delete(id); + } + } + + private createPixiGhost(ghost: { id: number; pos: number; ownerID: number }) { + if (!this.pixiRenderer) return; + const owner = this.game.playerBySmallID(ghost.ownerID); + if (!owner) return; + + const dummyUnit = { + tile: () => ghost.pos, + type: () => UnitType.Submarine, + owner: () => owner, + level: () => 1, + target: () => null, + isActive: () => true, + } as unknown as UnitView; + + const texture = this.createPixiTexture(dummyUnit, false); + const sprite = new PIXI.Sprite(texture); + sprite.anchor.set(0.5, 0.5); + sprite.alpha = 0.3; + + const worldX = this.game.x(ghost.pos); + const worldY = this.game.y(ghost.pos); + // OPT 4 — reuse _tmpPoint + this._tmpPoint.x = worldX; + this._tmpPoint.y = worldY; + const screenPos = this.transformHandler.worldToScreenCoordinates( + this._tmpPoint as Cell, + ); + sprite.x = (screenPos.x + 0.5) | 0; + sprite.y = (screenPos.y + 0.5) | 0; + sprite.scale.set(this.iconScreenScale()); + + this.pixiStage.addChild(sprite); + this.ghostRenders.push( + new GhostRenderInfo(ghost.id, { x: worldX, y: worldY }, sprite), + ); + } + + private removePixiGhost(ghostId: number) { + const idx = this.ghostRenders.findIndex((g) => g.ghostId === ghostId); + if (idx !== -1) { + this.ghostRenders[idx].pixiSprite.destroy(); + this.ghostRenders.splice(idx, 1); + } + } + + private updatePixiGhosts() { + for (const ghostRender of this.ghostRenders) { + // OPT 4 — reuse _tmpPoint + this._tmpPoint.x = ghostRender.position.x; + this._tmpPoint.y = ghostRender.position.y; + const screenPos = this.transformHandler.worldToScreenCoordinates( + this._tmpPoint as Cell, + ); + ghostRender.pixiSprite.x = (screenPos.x + 0.5) | 0; + ghostRender.pixiSprite.y = (screenPos.y + 0.5) | 0; + ghostRender.pixiSprite.scale.set(this.iconScreenScale()); + } + } +} diff --git a/tests/client/layers/UnitLayer.perf.test.ts b/tests/client/layers/UnitLayer.perf.test.ts new file mode 100644 index 00000000..b01bbe0f --- /dev/null +++ b/tests/client/layers/UnitLayer.perf.test.ts @@ -0,0 +1,285 @@ +/** + * @jest-environment jsdom + */ + +/** + * UnitLayer Performance Benchmark + * ================================ + * Uses the shared unit-layer harness to benchmark the current UnitLayer + * implementation. To benchmark an alternative implementation, create a new + * test file that imports the same harness and passes a different factory: + * + * import { runUnitBenchSuite } from "./unit-layer-bench-harness"; + * import { MyOptimizedUnitLayer } from "..."; + * + * runUnitBenchSuite("Optimized UnitLayer", (game, eventBus, transform, uiState) => + * new MyOptimizedUnitLayer(game, eventBus, transform, uiState), + * ); + */ + +// ── Polyfills for jsdom ────────────────────────────────────────────────── +// jsdom doesn't provide ImageData — polyfill before any imports that need it +if (typeof globalThis.ImageData === "undefined") { + (globalThis as any).ImageData = class ImageData { + readonly width: number; + readonly height: number; + readonly data: Uint8ClampedArray; + constructor(sw: number, sh: number); + constructor(data: Uint8ClampedArray, sw: number, sh?: number); + constructor( + swOrData: number | Uint8ClampedArray, + shOrSw: number, + maybeH?: number, + ) { + if (swOrData instanceof Uint8ClampedArray) { + this.data = swOrData; + this.width = shOrSw; + this.height = maybeH ?? swOrData.length / 4 / shOrSw; + } else { + this.width = swOrData; + this.height = shOrSw; + this.data = new Uint8ClampedArray(this.width * this.height * 4); + } + } + }; +} + +// jsdom doesn't provide createImageBitmap +if (typeof globalThis.createImageBitmap === "undefined") { + (globalThis as any).createImageBitmap = async (img: any) => { + return { + width: img.width || 16, + height: img.height || 16, + close: () => {}, + }; + }; +} + +// ── Mock heavy/browser-only dependencies before importing UnitLayer ────── + +// Mock PIXI.js — avoid real WebGL initialization in jsdom +jest.mock("pixi.js", () => { + class MockContainer { + children: any[] = []; + x = 0; + y = 0; + rotation = 0; + alpha = 1; + visible = true; + anchor = { set: () => {} }; + scale = { set: () => {}, x: 1, y: 1 }; + texture: any = null; + addChild(child: any) { + this.children.push(child); + return child; + } + removeChild(child: any) { + const idx = this.children.indexOf(child); + if (idx !== -1) this.children.splice(idx, 1); + } + destroy() { + this.children = []; + } + getBounds() { + return { x: this.x - 14, y: this.y - 14, width: 28, height: 28 }; + } + } + + class MockSprite extends MockContainer { + constructor(texture?: any) { + super(); + this.texture = texture; + } + } + + class MockGraphics extends MockSprite { + clear() { + return this; + } + lineStyle() { + return this; + } + beginFill() { + return this; + } + endFill() { + return this; + } + drawRect() { + return this; + } + drawPolygon() { + return this; + } + moveTo() { + return this; + } + lineTo() { + return this; + } + } + + class MockTexture { + static EMPTY = new MockTexture(); + width = 28; + height = 28; + static from(_source: any) { + return new MockTexture(); + } + } + + class MockWebGLRenderer { + canvas = { width: 800, height: 600 }; + view = { width: 800, height: 600 }; + async init(_opts: any) {} + render(_stage: any) {} + resize() {} + destroy() {} + } + + return { + Container: MockContainer, + Sprite: MockSprite, + Graphics: MockGraphics, + Texture: MockTexture, + WebGLRenderer: MockWebGLRenderer, + }; +}); + +// Mock image imports (webpack loaders return URL strings) +jest.mock("../../../../proprietary/images/bomberv3.png", () => "bomber.png", { + virtual: true, +}); +jest.mock( + "../../../../proprietary/images/tradeship.png", + () => "tradeship.png", + { + virtual: true, + }, +); +jest.mock( + "../../../../resources/images/BattleshipIconWhite.svg", + () => "warship.svg", + { + virtual: true, + }, +); +jest.mock( + "../../../../resources/images/FighterJetIcon.svg", + () => "fighter.svg", + { + virtual: true, + }, +); +jest.mock("../../../../resources/images/submarine.svg", () => "submarine.svg", { + virtual: true, +}); + +// Mock SpriteLoader — return simple canvases instead of loading real PNGs +jest.mock("../../../src/client/graphics/SpriteLoader", () => { + const fakeCanvas = () => { + // Return a minimal canvas-like object + return { + width: 16, + height: 16, + getContext: () => ({ + drawImage: jest.fn(), + getImageData: () => ({ + data: new Uint8ClampedArray(16 * 16 * 4), + width: 16, + height: 16, + }), + putImageData: jest.fn(), + fillRect: jest.fn(), + clearRect: jest.fn(), + fillStyle: "", + globalCompositeOperation: "", + }), + }; + }; + return { + getColoredSprite: () => fakeCanvas(), + isSpriteReady: () => true, + loadAllSprites: jest.fn().mockResolvedValue(undefined), + colorizeCanvas: () => fakeCanvas(), + }; +}); + +// Mock PerformanceMetrics +jest.mock("../../../src/client/utilities/PerformanceMetrics", () => { + const mockInstance = { + enabled: false, + incrementVisibleEntities: jest.fn(), + recordUnitRenderTime: jest.fn(), + recordUnitExecutionTime: jest.fn(), + recordUnitQuery: jest.fn(), + recordUnitVisible: jest.fn(), + }; + return { + PerformanceMetrics: { + getInstance: () => mockInstance, + }, + }; +}); + +// Mock ReplaySpeedMultiplier +jest.mock("../../../src/client/utilities/ReplaySpeedMultiplier", () => ({ + defaultReplaySpeedMultiplier: 1, +})); + +// Mock InputHandler events +jest.mock("../../../src/client/InputHandler", () => ({ + AlternateViewEvent: "AlternateViewEvent", + MouseUpEvent: "MouseUpEvent", + UnitSelectionEvent: class { + constructor( + public unit: any, + public isSelected: boolean, + ) {} + }, + ReplaySpeedChangeEvent: "ReplaySpeedChangeEvent", +})); + +// Mock Transport events +jest.mock("../../../src/client/Transport", () => ({ + ArtilleryOutOfRangeEvent: class {}, + MoveArtilleryIntentEvent: class {}, + MoveFighterJetIntentEvent: class {}, + MoveSubmarineIntentEvent: class {}, + MoveWarshipIntentEvent: class {}, +})); + +// Mock UnitUpgrades +jest.mock("../../../src/core/game/UnitUpgrades", () => ({ + getArtilleryMaxDistance: () => 20, +})); + +// Mock BezenhamLine +jest.mock("../../../src/core/utilities/Line", () => ({ + BezenhamLine: class { + private done = false; + constructor( + private start: { x: number; y: number }, + private end: { x: number; y: number }, + ) {} + increment() { + if (this.done) return true; + this.done = true; + return { x: this.end.x, y: this.end.y }; + } + size() { + return 1; + } + }, +})); + +// ── Import and run ─────────────────────────────────────────────────────── + +import { UnitLayer } from "../../../src/client/graphics/layers/UnitLayer"; +import { runUnitBenchSuite } from "./unit-layer-bench-harness"; + +runUnitBenchSuite( + "Canvas2D+PIXI UnitLayer (baseline)", + (game, eventBus, transformHandler, uiState) => + new UnitLayer(game, eventBus, transformHandler, uiState), +); diff --git a/tests/client/layers/UnitLayerV2.perf.test.ts b/tests/client/layers/UnitLayerV2.perf.test.ts new file mode 100644 index 00000000..93e95d9d --- /dev/null +++ b/tests/client/layers/UnitLayerV2.perf.test.ts @@ -0,0 +1,257 @@ +/** + * @jest-environment jsdom + */ + +/** + * UnitLayerV2 Performance Benchmark + * ================================== + * Benchmarks the optimized UnitLayerV2 using the same shared harness and + * identical scenarios as the baseline test, enabling direct comparison. + */ + +// ── Polyfills for jsdom ────────────────────────────────────────────────── +if (typeof globalThis.ImageData === "undefined") { + (globalThis as any).ImageData = class ImageData { + readonly width: number; + readonly height: number; + readonly data: Uint8ClampedArray; + constructor(sw: number, sh: number); + constructor(data: Uint8ClampedArray, sw: number, sh?: number); + constructor( + swOrData: number | Uint8ClampedArray, + shOrSw: number, + maybeH?: number, + ) { + if (swOrData instanceof Uint8ClampedArray) { + this.data = swOrData; + this.width = shOrSw; + this.height = maybeH ?? swOrData.length / 4 / shOrSw; + } else { + this.width = swOrData; + this.height = shOrSw; + this.data = new Uint8ClampedArray(this.width * this.height * 4); + } + } + }; +} + +if (typeof globalThis.createImageBitmap === "undefined") { + (globalThis as any).createImageBitmap = async (img: any) => { + return { + width: img.width || 16, + height: img.height || 16, + close: () => {}, + }; + }; +} + +// ── Mocks (identical to baseline) ──────────────────────────────────────── + +jest.mock("pixi.js", () => { + class MockContainer { + children: any[] = []; + x = 0; + y = 0; + rotation = 0; + alpha = 1; + visible = true; + anchor = { set: () => {} }; + scale = { set: () => {}, x: 1, y: 1 }; + texture: any = null; + addChild(child: any) { + this.children.push(child); + return child; + } + removeChild(child: any) { + const idx = this.children.indexOf(child); + if (idx !== -1) this.children.splice(idx, 1); + } + destroy() { + this.children = []; + } + getBounds() { + return { x: this.x - 14, y: this.y - 14, width: 28, height: 28 }; + } + } + + class MockSprite extends MockContainer { + constructor(texture?: any) { + super(); + this.texture = texture; + } + } + + class MockGraphics extends MockSprite { + clear() { + return this; + } + lineStyle() { + return this; + } + beginFill() { + return this; + } + endFill() { + return this; + } + drawRect() { + return this; + } + drawPolygon() { + return this; + } + moveTo() { + return this; + } + lineTo() { + return this; + } + } + + class MockTexture { + static EMPTY = new MockTexture(); + width = 28; + height = 28; + static from(_source: any) { + return new MockTexture(); + } + } + + class MockWebGLRenderer { + canvas = { width: 800, height: 600 }; + view = { width: 800, height: 600 }; + async init(_opts: any) {} + render(_stage: any) {} + resize() {} + destroy() {} + } + + return { + Container: MockContainer, + Sprite: MockSprite, + Graphics: MockGraphics, + Texture: MockTexture, + WebGLRenderer: MockWebGLRenderer, + }; +}); + +jest.mock("../../../../proprietary/images/bomberv3.png", () => "bomber.png", { + virtual: true, +}); +jest.mock( + "../../../../proprietary/images/tradeship.png", + () => "tradeship.png", + { virtual: true }, +); +jest.mock( + "../../../../resources/images/BattleshipIconWhite.svg", + () => "warship.svg", + { virtual: true }, +); +jest.mock( + "../../../../resources/images/FighterJetIcon.svg", + () => "fighter.svg", + { virtual: true }, +); +jest.mock("../../../../resources/images/submarine.svg", () => "submarine.svg", { + virtual: true, +}); + +jest.mock("../../../src/client/graphics/SpriteLoader", () => { + const fakeCanvas = () => ({ + width: 16, + height: 16, + getContext: () => ({ + drawImage: jest.fn(), + getImageData: () => ({ + data: new Uint8ClampedArray(16 * 16 * 4), + width: 16, + height: 16, + }), + putImageData: jest.fn(), + fillRect: jest.fn(), + clearRect: jest.fn(), + fillStyle: "", + globalCompositeOperation: "", + }), + }); + return { + getColoredSprite: () => fakeCanvas(), + isSpriteReady: () => true, + loadAllSprites: jest.fn().mockResolvedValue(undefined), + colorizeCanvas: () => fakeCanvas(), + }; +}); + +jest.mock("../../../src/client/utilities/PerformanceMetrics", () => { + const mockInstance = { + enabled: false, + incrementVisibleEntities: jest.fn(), + recordUnitRenderTime: jest.fn(), + recordUnitExecutionTime: jest.fn(), + recordUnitQuery: jest.fn(), + recordUnitVisible: jest.fn(), + }; + return { + PerformanceMetrics: { + getInstance: () => mockInstance, + }, + }; +}); + +jest.mock("../../../src/client/utilities/ReplaySpeedMultiplier", () => ({ + defaultReplaySpeedMultiplier: 1, +})); + +jest.mock("../../../src/client/InputHandler", () => ({ + AlternateViewEvent: "AlternateViewEvent", + MouseUpEvent: "MouseUpEvent", + UnitSelectionEvent: class { + constructor( + public unit: any, + public isSelected: boolean, + ) {} + }, + ReplaySpeedChangeEvent: "ReplaySpeedChangeEvent", +})); + +jest.mock("../../../src/client/Transport", () => ({ + ArtilleryOutOfRangeEvent: class {}, + MoveArtilleryIntentEvent: class {}, + MoveFighterJetIntentEvent: class {}, + MoveSubmarineIntentEvent: class {}, + MoveWarshipIntentEvent: class {}, +})); + +jest.mock("../../../src/core/game/UnitUpgrades", () => ({ + getArtilleryMaxDistance: () => 20, +})); + +jest.mock("../../../src/core/utilities/Line", () => ({ + BezenhamLine: class { + private done = false; + constructor( + private start: { x: number; y: number }, + private end: { x: number; y: number }, + ) {} + increment() { + if (this.done) return true; + this.done = true; + return { x: this.end.x, y: this.end.y }; + } + size() { + return 1; + } + }, +})); + +// ── Import and run ─────────────────────────────────────────────────────── + +import { UnitLayerV2 } from "../../../src/client/graphics/layers/UnitLayerV2"; +import { runUnitBenchSuite } from "./unit-layer-bench-harness"; + +runUnitBenchSuite( + "UnitLayerV2 (optimized)", + (game, eventBus, transformHandler, uiState) => + new UnitLayerV2(game, eventBus, transformHandler, uiState), +); diff --git a/tests/client/layers/unit-layer-bench-harness.ts b/tests/client/layers/unit-layer-bench-harness.ts new file mode 100644 index 00000000..c3e802f0 --- /dev/null +++ b/tests/client/layers/unit-layer-bench-harness.ts @@ -0,0 +1,1322 @@ +/** + * UnitLayer Performance Benchmark Harness + * ======================================== + * Implementation-agnostic harness for benchmarking any Layer that renders + * mobile units. Exports mock game state, unit spawning/movement simulation, + * stats utilities, and a `runUnitBenchSuite()` function that works with any + * factory producing a `Layer`. + * + * Usage in a test file: + * + * import { runUnitBenchSuite } from "./unit-layer-bench-harness"; + * import { UnitLayer } from "..."; + * + * runUnitBenchSuite("Canvas2D+PIXI UnitLayer", (game, eventBus, transform, uiState) => + * new UnitLayer(game, eventBus, transform, uiState), + * ); + * + * Each implementation gets identical scenarios and the results table is + * printed at the end so you can compare side-by-side. + */ + +import { colord, type Colord } from "colord"; +import type { Layer } from "../../../src/client/graphics/layers/Layer"; +import type { TransformHandler } from "../../../src/client/graphics/TransformHandler"; +import type { UIState } from "../../../src/client/graphics/UIState"; +import type { EventBus } from "../../../src/core/EventBus"; +import { Cell, PlayerType, UnitType } from "../../../src/core/game/Game"; +import type { TileRef } from "../../../src/core/game/GameMap"; +import { + GameUpdateType, + type UnitUpdate, +} from "../../../src/core/game/GameUpdates"; +import type { GameView, UnitView } from "../../../src/core/game/GameView"; +import { PlayerView } from "../../../src/core/game/GameView"; + +// ═══════════════════════════════════════════════════════════════════════════ +// Map / player constants +// ═══════════════════════════════════════════════════════════════════════════ + +export const MAP_WIDTH = 600; +export const MAP_HEIGHT = 400; +export const TOTAL_TILES = MAP_WIDTH * MAP_HEIGHT; +export const NUM_PLAYERS = 4; + +// ═══════════════════════════════════════════════════════════════════════════ +// Types +// ═══════════════════════════════════════════════════════════════════════════ + +export interface BenchmarkResult { + scenario: string; + samples: number; + /** Mean wall-clock time in ms */ + meanMs: number; + /** Median wall-clock time in ms */ + medianMs: number; + /** 95th percentile in ms */ + p95Ms: number; + /** Standard deviation in ms */ + stdMs: number; + /** Minimum in ms */ + minMs: number; + /** Maximum in ms */ + maxMs: number; + /** Total drawImage calls during measured samples */ + drawImageCalls: number; + /** Total fillRect calls during measured samples */ + fillRectCalls: number; + /** Total clearRect calls during measured samples */ + clearRectCalls: number; + /** Total canvas context save/restore cycles */ + saveRestoreCycles: number; +} + +export interface GpuCounters { + drawImageCalls: number; + fillRectCalls: number; + clearRectCalls: number; + saveRestoreCycles: number; +} + +/** Simple player region — contiguous band of tiles. */ +export interface PlayerRegion { + id: string; + smallID: number; + startTile: number; + tileCount: number; +} + +/** A mock unit with all fields needed by UnitLayer and GameView */ +export interface MockUnit { + id: number; + unitType: UnitType; + ownerSmallID: number; + pos: TileRef; + lastPos: TileRef; + isActive: boolean; + health: number; + maxHealth: number; + level: number; + targetUnitId?: number; + targetTile?: TileRef; + isAttacking: boolean; + isDetectedByNavalUnit: boolean; + isCooldown: boolean; + targetable: boolean; + returning: boolean; + retreating: boolean; + reachedTarget: boolean; + troops: number; +} + +export interface MockGameState { + ownerMap: Int32Array; + regions: PlayerRegion[]; + players: PlayerView[]; + units: Map; + /** Unit IDs updated in the most recent "tick" */ + recentUnitUpdates: UnitUpdate[]; + currentTick: number; +} + +/** + * Factory signature: given mocked dependencies, return a Layer. + */ +export type UnitLayerFactory = ( + game: GameView, + eventBus: EventBus, + transformHandler: TransformHandler, + uiState: UIState, +) => Layer; + +// ═══════════════════════════════════════════════════════════════════════════ +// Stats helpers +// ═══════════════════════════════════════════════════════════════════════════ + +export function computeStats( + label: string, + timings: number[], + gpuMetrics: GpuCounters, +): BenchmarkResult { + const sorted = [...timings].sort((a, b) => a - b); + const n = sorted.length; + const mean = sorted.reduce((s, v) => s + v, 0) / n; + const median = + n % 2 === 0 + ? (sorted[n / 2 - 1] + sorted[n / 2]) / 2 + : sorted[Math.floor(n / 2)]; + const p95 = sorted[Math.min(Math.ceil(n * 0.95) - 1, n - 1)]; + const variance = sorted.reduce((s, v) => s + (v - mean) ** 2, 0) / n; + const std = Math.sqrt(variance); + return { + scenario: label, + samples: n, + meanMs: +mean.toFixed(3), + medianMs: +median.toFixed(3), + p95Ms: +p95.toFixed(3), + stdMs: +std.toFixed(3), + minMs: +sorted[0].toFixed(3), + maxMs: +sorted[n - 1].toFixed(3), + drawImageCalls: gpuMetrics.drawImageCalls, + fillRectCalls: gpuMetrics.fillRectCalls, + clearRectCalls: gpuMetrics.clearRectCalls, + saveRestoreCycles: gpuMetrics.saveRestoreCycles, + }; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Canvas / context instrumented mock +// ═══════════════════════════════════════════════════════════════════════════ + +export function resetGpuCounters(c: GpuCounters) { + c.drawImageCalls = 0; + c.fillRectCalls = 0; + c.clearRectCalls = 0; + c.saveRestoreCycles = 0; +} + +export function createInstrumentedContext( + width: number, + height: number, + counters: GpuCounters, +): CanvasRenderingContext2D { + let saveDepth = 0; + return { + drawImage: () => { + counters.drawImageCalls++; + }, + putImageData: () => {}, + clearRect: () => { + counters.clearRectCalls++; + }, + fillRect: () => { + counters.fillRectCalls++; + }, + strokeRect: () => {}, + beginPath: () => {}, + closePath: () => {}, + moveTo: () => {}, + lineTo: () => {}, + stroke: () => {}, + arc: () => {}, + fill: () => {}, + save: () => { + saveDepth++; + }, + restore: () => { + if (saveDepth > 0) { + saveDepth--; + counters.saveRestoreCycles++; + } + }, + translate: () => {}, + rotate: () => {}, + scale: () => {}, + setTransform: () => {}, + getTransform: () => ({ a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }), + getImageData: (_sx: number, _sy: number, sw: number, sh: number) => ({ + data: new Uint8ClampedArray(sw * sh * 4), + width: sw, + height: sh, + }), + fillStyle: "", + strokeStyle: "", + lineWidth: 1, + globalAlpha: 1, + globalCompositeOperation: "source-over", + imageSmoothingEnabled: true, + imageSmoothingQuality: "high", + canvas: { width, height }, + } as unknown as CanvasRenderingContext2D; +} + +// Capture the real createElement once, before any spies wrap it +let _realCreateElement: typeof document.createElement | null = null; + +function getRealCreateElement(): typeof document.createElement { + if (_realCreateElement === null) { + _realCreateElement = document.createElement.bind(document); + } + return _realCreateElement!; +} + +/** + * Monkey-patch `document.createElement("canvas")` to return instrumented + * canvases that track GPU-proxy calls. Safe to call repeatedly — always + * delegates non-canvas calls to the true original. + * + * Returns real HTMLCanvasElement nodes (so they can be appended to the DOM) + * but with mocked getContext returning instrumented contexts. + */ +export function installCanvasMock( + width: number, + height: number, + counters: GpuCounters, +) { + const origCreateElement = getRealCreateElement(); + + // Restore any prior spy before installing a new one + if (jest.isMockFunction(document.createElement)) { + (document.createElement as jest.Mock).mockRestore?.(); + } + + jest + .spyOn(document, "createElement") + .mockImplementation((tag: string, options?: ElementCreationOptions) => { + if (tag === "canvas") { + // Create a real canvas element so it can be appended to the DOM + const realCanvas = origCreateElement("canvas") as HTMLCanvasElement; + realCanvas.width = width; + realCanvas.height = height; + + // Override getContext to return instrumented context + const ctx = createInstrumentedContext(width, height, counters); + realCanvas.getContext = ((_id: string, _opts?: any) => ctx) as any; + realCanvas.toDataURL = () => ""; + return realCanvas; + } + return origCreateElement(tag, options); + }); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Player / game-state mock builders +// ═══════════════════════════════════════════════════════════════════════════ + +const PLAYER_COLORS: Colord[] = [ + colord("#e63946"), + colord("#457b9d"), + colord("#2a9d8f"), + colord("#e9c46a"), +]; + +export function buildPlayerRegions(): PlayerRegion[] { + const tilesPerPlayer = Math.floor(TOTAL_TILES / NUM_PLAYERS); + const regions: PlayerRegion[] = []; + for (let i = 0; i < NUM_PLAYERS; i++) { + regions.push({ + id: `player-${i}`, + smallID: i + 1, + startTile: i * tilesPerPlayer, + tileCount: tilesPerPlayer, + }); + } + return regions; +} + +export function buildOwnerMap(regions: PlayerRegion[]): Int32Array { + const map = new Int32Array(TOTAL_TILES).fill(-1); + for (const r of regions) { + for (let t = r.startTile; t < r.startTile + r.tileCount; t++) { + map[t] = r.smallID; + } + } + return map; +} + +function createMockPlayerView(region: PlayerRegion, color: Colord): PlayerView { + return { + id: () => region.id, + smallID: () => region.smallID, + type: () => PlayerType.Human, + isPlayer: () => true, + isFriendly: () => false, + isAtWarWith: () => false, + isAlliedWith: () => false, + nameLocation: () => ({ + x: ((region.startTile % MAP_WIDTH) + MAP_WIDTH / NUM_PLAYERS / 2) | 0, + y: (Math.floor(region.startTile / MAP_WIDTH) + MAP_HEIGHT / 2) | 0, + }), + borderTiles: () => + Promise.resolve({ borderTiles: [], innerBorderTiles: [] }), + numTilesOwned: () => region.tileCount, + _color: color, + } as unknown as PlayerView; +} + +function createMockTheme() { + return { + territoryColor: (pv: any) => (pv as any)._color ?? colord("#888888"), + borderColor: (pv: any) => + ((pv as any)._color ?? colord("#888888")).darken(0.2), + defendedBorderColors: (pv: any) => ({ + light: ((pv as any)._color ?? colord("#888888")).lighten(0.1), + dark: ((pv as any)._color ?? colord("#888888")).darken(0.3), + }), + focusedBorderColor: () => colord("#ffffff"), + falloutColor: () => colord("#333333"), + selfColor: () => colord("#00ff00"), + allyColor: () => colord("#0000ff"), + enemyColor: () => colord("#ff0000"), + spawnHighlightColor: () => colord("#ffff00"), + specialBuildingColor: (pv: any) => + ((pv as any)._color ?? colord("#888888")).lighten(0.2), + terrainColor: () => colord("#558b2f"), + backgroundColor: () => colord("#1a1a2e"), + font: () => "sans-serif", + textColor: () => "#ffffff", + teamColor: () => colord("#ffffff"), + }; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Unit spawning helpers +// ═══════════════════════════════════════════════════════════════════════════ + +let nextUnitId = 1; + +export function resetUnitIdCounter() { + nextUnitId = 1; +} + +/** + * Create a mock unit at a random ocean-ish tile for the given player. + * We use the bottom half of the map as "ocean" conceptually. + */ +export function spawnUnit( + state: MockGameState, + unitType: UnitType, + ownerSmallID: number, + opts: Partial = {}, +): MockUnit { + const id = nextUnitId++; + // Place units in ocean area (bottom half conceptually) + const oceanStart = Math.floor(TOTAL_TILES * 0.5); + const pos = + opts.pos ?? + oceanStart + Math.floor(Math.random() * (TOTAL_TILES - oceanStart)); + const unit: MockUnit = { + id, + unitType, + ownerSmallID, + pos, + lastPos: opts.lastPos ?? pos, + isActive: opts.isActive ?? true, + health: opts.health ?? 100, + maxHealth: opts.maxHealth ?? 100, + level: opts.level ?? 1, + targetUnitId: opts.targetUnitId, + targetTile: opts.targetTile, + isAttacking: opts.isAttacking ?? false, + isDetectedByNavalUnit: opts.isDetectedByNavalUnit ?? false, + isCooldown: opts.isCooldown ?? false, + targetable: opts.targetable ?? true, + returning: opts.returning ?? false, + retreating: opts.retreating ?? false, + reachedTarget: opts.reachedTarget ?? false, + troops: opts.troops ?? 10, + }; + state.units.set(id, unit); + return unit; +} + +/** + * Spawn N units of a given type evenly across all players. + */ +export function spawnUnitsEvenly( + state: MockGameState, + unitType: UnitType, + count: number, + opts: Partial = {}, +): MockUnit[] { + const spawned: MockUnit[] = []; + for (let i = 0; i < count; i++) { + const ownerIdx = i % state.regions.length; + spawned.push( + spawnUnit(state, unitType, state.regions[ownerIdx].smallID, opts), + ); + } + return spawned; +} + +/** + * Simulate unit movement: shift each unit's position by a small random delta, + * recording lastPos. Returns the unit IDs that moved (for update list). + */ +export function simulateUnitMovement( + state: MockGameState, + unitIds?: number[], +): UnitUpdate[] { + const updates: UnitUpdate[] = []; + const idsToMove = unitIds ?? [...state.units.keys()]; + + for (const id of idsToMove) { + const unit = state.units.get(id); + if (!unit || !unit.isActive) continue; + + // Move 1-3 tiles in a random direction + const oldPos = unit.pos; + const x = oldPos % MAP_WIDTH; + const y = Math.floor(oldPos / MAP_WIDTH); + const dx = Math.floor(Math.random() * 3) - 1; // -1, 0, 1 + const dy = Math.floor(Math.random() * 3) - 1; + const nx = Math.max(0, Math.min(MAP_WIDTH - 1, x + dx)); + const ny = Math.max(0, Math.min(MAP_HEIGHT - 1, y + dy)); + const newPos = ny * MAP_WIDTH + nx; + + unit.lastPos = oldPos; + unit.pos = newPos; + + updates.push(mockUnitToUpdate(unit)); + } + + return updates; +} + +/** + * Convert a MockUnit to a UnitUpdate wire-format object. + */ +export function mockUnitToUpdate(unit: MockUnit): UnitUpdate { + return { + type: GameUpdateType.Unit, + unitType: unit.unitType, + id: unit.id, + ownerID: unit.ownerSmallID, + pos: unit.pos, + lastPos: unit.lastPos, + isActive: unit.isActive, + health: unit.health, + maxHealth: unit.maxHealth, + level: unit.level, + targetUnitId: unit.targetUnitId, + targetTile: unit.targetTile, + isAttacking: unit.isAttacking, + isDetectedByNavalUnit: unit.isDetectedByNavalUnit, + targetable: unit.targetable, + returning: unit.returning, + retreating: unit.retreating, + reachedTarget: unit.reachedTarget, + troops: unit.troops, + }; +} + +/** + * Deactivate N randomly-selected units (simulate destruction). + */ +export function deactivateUnits( + state: MockGameState, + count: number, +): UnitUpdate[] { + const activeIds = [...state.units.values()] + .filter((u) => u.isActive) + .map((u) => u.id); + const toKill = activeIds.slice(0, Math.min(count, activeIds.length)); + const updates: UnitUpdate[] = []; + + for (const id of toKill) { + const unit = state.units.get(id)!; + unit.isActive = false; + updates.push(mockUnitToUpdate(unit)); + } + + return updates; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Mock UnitView wrapping MockUnit (lightweight proxy for GameView) +// ═══════════════════════════════════════════════════════════════════════════ + +function createMockUnitView( + unit: MockUnit, + playersBySmallID: Map, +): UnitView { + const owner = playersBySmallID.get(unit.ownerSmallID)!; + return { + id: () => unit.id, + type: () => unit.unitType, + tile: () => unit.pos, + lastTile: () => unit.lastPos, + lastTiles: () => [unit.lastPos], + owner: () => owner, + isActive: () => unit.isActive, + health: () => unit.health, + effectiveMaxHealth: () => unit.maxHealth, + level: () => unit.level, + targetUnitId: () => unit.targetUnitId, + targetTile: () => unit.targetTile, + isAttacking: () => unit.isAttacking, + isDetectedByNavalUnit: () => unit.isDetectedByNavalUnit, + isCooldown: () => unit.isCooldown, + targetable: () => unit.targetable, + returning: () => unit.returning, + retreating: () => unit.retreating, + reachedTarget: () => unit.reachedTarget, + troops: () => unit.troops, + wasUpdated: () => true, + _wasUpdated: true, + lastPos: [unit.lastPos], + hasHealth: () => true, + info: () => ({ cost: () => 0, territoryBound: false }), + constructionType: () => undefined, + constructionTargetLevel: () => 1, + ticksLeftInCooldown: () => undefined, + cooldownEndsAt: () => undefined, + cooldownDuration: () => undefined, + stackCount: () => 1, + launchesRemaining: () => null, + bomberLevel: () => 1, + pendingTradeShipDueTick: () => null, + pendingTradeShipDueTicks: () => [], + tradeRouteStartOwner: () => null, + tradeRouteEndOwner: () => null, + tradePhase: () => null, + dockedAtPortOwner: () => null, + targetedBySAM: () => false, + update: () => {}, + } as unknown as UnitView; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Mock GameView (with full unit support) +// ═══════════════════════════════════════════════════════════════════════════ + +export function createMockGameView(state: MockGameState): GameView { + const theme = createMockTheme(); + + const playersBySmallID = new Map(); + const playersById = new Map(); + for (const p of state.players) { + playersBySmallID.set(p.smallID(), p); + playersById.set(p.id(), p); + } + + // Build UnitView wrappers that stay synced with MockUnit + const unitViewCache = new Map(); + function getUnitView(id: number): UnitView | undefined { + const mu = state.units.get(id); + if (!mu) return undefined; + let uv = unitViewCache.get(id); + if (!uv) { + uv = createMockUnitView(mu, playersBySmallID); + unitViewCache.set(id, uv); + } + return uv; + } + + function allActiveUnits(...types: UnitType[]): UnitView[] { + const result: UnitView[] = []; + for (const mu of state.units.values()) { + if (!mu.isActive) continue; + if (types.length > 0 && !types.includes(mu.unitType)) continue; + const uv = getUnitView(mu.id); + if (uv) result.push(uv); + } + return result; + } + + const game: Partial = { + width: () => MAP_WIDTH, + height: () => MAP_HEIGHT, + config: () => + ({ + theme: () => theme, + serverConfig: () => ({ + turnIntervalMs: () => 100, + }), + defensePostRange: () => 3, + unitInfo: () => ({ cost: () => 0, territoryBound: false }), + }) as any, + ref: (x: number, y: number) => y * MAP_WIDTH + x, + x: (t: TileRef) => t % MAP_WIDTH, + y: (t: TileRef) => Math.floor(t / MAP_WIDTH), + cell: (t: TileRef) => new Cell(t % MAP_WIDTH, Math.floor(t / MAP_WIDTH)), + isValidCoord: (x: number, y: number) => + x >= 0 && x < MAP_WIDTH && y >= 0 && y < MAP_HEIGHT, + isValidRef: (ref: TileRef) => ref >= 0 && ref < TOTAL_TILES, + hasOwner: (t: TileRef) => state.ownerMap[t] !== -1, + ownerID: (t: TileRef) => state.ownerMap[t], + owner: (t: TileRef) => { + const sid = state.ownerMap[t]; + if (sid === -1) return { isPlayer: () => false } as any; + return playersBySmallID.get(sid) ?? ({ isPlayer: () => false } as any); + }, + isOcean: (t: TileRef) => t >= Math.floor(TOTAL_TILES * 0.5), + isLand: (t: TileRef) => t < Math.floor(TOTAL_TILES * 0.5), + isBorder: () => false, + hasFallout: () => false, + neighbors: (t: TileRef) => { + const x = t % MAP_WIDTH; + const y = Math.floor(t / MAP_WIDTH); + const result: number[] = []; + if (x > 0) result.push(t - 1); + if (x < MAP_WIDTH - 1) result.push(t + 1); + if (y > 0) result.push(t - MAP_WIDTH); + if (y < MAP_HEIGHT - 1) result.push(t + MAP_WIDTH); + return new Uint32Array(result); + }, + forEachTile: (fn: (t: TileRef) => void) => { + for (let t = 0; t < TOTAL_TILES; t++) fn(t); + }, + ticks: () => state.currentTick, + inSpawnPhase: () => false, + myPlayer: () => state.players[0] ?? null, + focusedPlayer: () => state.players[0] ?? null, + playerViews: () => state.players, + playerBySmallID: (id: number) => + playersBySmallID.get(id) ?? ({ isPlayer: () => false } as any), + hasUnitNearby: () => false, + manhattanDist: (c1: TileRef, c2: TileRef) => { + const x1 = c1 % MAP_WIDTH, + y1 = Math.floor(c1 / MAP_WIDTH); + const x2 = c2 % MAP_WIDTH, + y2 = Math.floor(c2 / MAP_WIDTH); + return Math.abs(x1 - x2) + Math.abs(y1 - y2); + }, + euclideanDistSquared: (c1: TileRef, c2: TileRef) => { + const x1 = c1 % MAP_WIDTH, + y1 = Math.floor(c1 / MAP_WIDTH); + const x2 = c2 % MAP_WIDTH, + y2 = Math.floor(c2 / MAP_WIDTH); + return (x1 - x2) ** 2 + (y1 - y2) ** 2; + }, + units: (...types: UnitType[]) => allActiveUnits(...types), + unit: (id: number) => getUnitView(id), + unitInfo: () => ({ cost: () => 0, territoryBound: false }) as any, + recentlyUpdatedTiles: () => [], + updatesSinceLastTick: () => { + const updates: any = {}; + for (const key of Object.values(GameUpdateType)) { + if (typeof key === "number") updates[key] = []; + } + updates[GameUpdateType.Unit] = state.recentUnitUpdates; + return updates; + }, + submarineGhosts: () => [], + }; + + return game as GameView; +} + +export function createMockEventBus(): EventBus { + return { + on: jest.fn(), + off: jest.fn(), + emit: jest.fn(), + } as unknown as EventBus; +} + +export function createMockTransformHandler(): TransformHandler { + return { + scale: 1.5, + screenToWorldCoordinates: (sx: number, sy: number) => ({ x: sx, y: sy }), + worldToScreenCoordinates: (cell: any) => ({ + x: (cell.x ?? 0) * 1.5, + y: (cell.y ?? 0) * 1.5, + }), + } as unknown as TransformHandler; +} + +export function createMockUIState(): UIState { + return { + attackRatio: 0.5, + investmentRate: 0.2, + pendingBuildUnitType: null, + multibuildEnabled: false, + upgradeMode: false, + unitLevels: {}, + }; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Fresh state builder +// ═══════════════════════════════════════════════════════════════════════════ + +export function freshState(): MockGameState { + resetUnitIdCounter(); + const regions = buildPlayerRegions(); + const players = regions.map((r, i) => + createMockPlayerView(r, PLAYER_COLORS[i]), + ); + const ownerMap = buildOwnerMap(regions); + return { + ownerMap, + regions, + players, + units: new Map(), + recentUnitUpdates: [], + currentTick: 0, + }; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Unit mix presets (realistic game scenarios) +// ═══════════════════════════════════════════════════════════════════════════ + +export interface UnitMix { + name: string; + composition: [UnitType, number][]; // [type, count] pairs +} + +export const UNIT_MIXES: Record = { + /** Typical mid-game: lots of warships, some fighters and trade ships */ + midGame: { + name: "Mid-game (200 units)", + composition: [ + [UnitType.Warship, 60], + [UnitType.FighterJet, 40], + [UnitType.TradeShip, 30], + [UnitType.TransportShip, 25], + [UnitType.Submarine, 20], + [UnitType.Bomber, 15], + [UnitType.Shell, 10], + ], + }, + /** Heavy naval battle: lots of warships and submarines */ + navalBattle: { + name: "Naval battle (300 units)", + composition: [ + [UnitType.Warship, 120], + [UnitType.Submarine, 60], + [UnitType.TradeShip, 40], + [UnitType.Shell, 40], + [UnitType.FighterJet, 30], + [UnitType.Bomber, 10], + ], + }, + /** Air superiority: many fighters and bombers */ + airWar: { + name: "Air war (250 units)", + composition: [ + [UnitType.FighterJet, 100], + [UnitType.Bomber, 60], + [UnitType.SAMMissile, 30], + [UnitType.Warship, 30], + [UnitType.TradeShip, 20], + [UnitType.CargoPlane, 10], + ], + }, + /** Stress test: maximum unit count */ + stress: { + name: "Stress test (500 units)", + composition: [ + [UnitType.Warship, 150], + [UnitType.FighterJet, 100], + [UnitType.TradeShip, 80], + [UnitType.Submarine, 60], + [UnitType.Shell, 50], + [UnitType.Bomber, 30], + [UnitType.TransportShip, 20], + [UnitType.SAMMissile, 10], + ], + }, + /** Minimal: just a few units for baseline */ + minimal: { + name: "Minimal (20 units)", + composition: [ + [UnitType.Warship, 8], + [UnitType.FighterJet, 4], + [UnitType.TradeShip, 4], + [UnitType.Submarine, 4], + ], + }, +}; + +/** + * Spawn all units from a UnitMix into the state. + */ +export function spawnMix(state: MockGameState, mix: UnitMix): MockUnit[] { + const allUnits: MockUnit[] = []; + for (const [type, count] of mix.composition) { + allUnits.push(...spawnUnitsEvenly(state, type, count)); + } + return allUnits; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Generic benchmark runner +// ═══════════════════════════════════════════════════════════════════════════ + +function hrtime(): number { + return performance.now(); +} + +interface BenchCtx { + layer: Layer; + renderCtx: CanvasRenderingContext2D; + gpuCounters: GpuCounters; + state: MockGameState; +} + +function runBenchmark( + label: string, + warmup: number, + iterations: number, + setup: () => BenchCtx, + action: (ctx: BenchCtx) => void, +): BenchmarkResult { + const timings: number[] = []; + const totalGpu: GpuCounters = { + drawImageCalls: 0, + fillRectCalls: 0, + clearRectCalls: 0, + saveRestoreCycles: 0, + }; + const totalRuns = warmup + iterations; + + for (let i = 0; i < totalRuns; i++) { + const ctx = setup(); + resetGpuCounters(ctx.gpuCounters); + + const t0 = hrtime(); + action(ctx); + const t1 = hrtime(); + + if (i >= warmup) { + timings.push(t1 - t0); + totalGpu.drawImageCalls += ctx.gpuCounters.drawImageCalls; + totalGpu.fillRectCalls += ctx.gpuCounters.fillRectCalls; + totalGpu.clearRectCalls += ctx.gpuCounters.clearRectCalls; + totalGpu.saveRestoreCycles += ctx.gpuCounters.saveRestoreCycles; + } + } + + return computeStats(label, timings, totalGpu); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Public: run the full benchmark suite for any Layer implementation +// ═══════════════════════════════════════════════════════════════════════════ + +/** + * Registers a Jest `describe` block with benchmark scenarios for the given + * Layer implementation. Call this from a `*.test.ts` file. + * + * @param suiteName Label for the describe block (e.g. "UnitLayer") + * @param factory Creates the Layer under test from mock dependencies. + * @param options Optional: warmup/iteration counts, which mixes to run, + * custom mixes to add. + */ +export function runUnitBenchSuite( + suiteName: string, + factory: UnitLayerFactory, + options: { + warmup?: number; + iterations?: number; + /** Which preset mixes to benchmark (default: all) */ + mixes?: string[]; + /** Additional custom mixes to benchmark */ + customMixes?: UnitMix[]; + } = {}, +) { + const WARMUP = options.warmup ?? 3; + const ITERATIONS = options.iterations ?? 10; + + // Resolve which mixes to run + const mixKeys = options.mixes ?? Object.keys(UNIT_MIXES); + const mixesToRun: UnitMix[] = mixKeys + .filter((k) => UNIT_MIXES[k]) + .map((k) => UNIT_MIXES[k]); + if (options.customMixes) { + mixesToRun.push(...options.customMixes); + } + + describe(suiteName, () => { + const allResults: BenchmarkResult[] = []; + + afterAll(() => { + // Print comparison table + console.log( + `\n╔══════════════════════════════════════════════════════════════════════════╗`, + ); + console.log(`║ ${suiteName.padEnd(70)} ║`); + console.log( + `╚══════════════════════════════════════════════════════════════════════════╝\n`, + ); + console.table( + allResults.map((r) => ({ + Scenario: r.scenario, + Samples: r.samples, + "Mean (ms)": r.meanMs, + "Median (ms)": r.medianMs, + "P95 (ms)": r.p95Ms, + "Std (ms)": r.stdMs, + "Min (ms)": r.minMs, + "Max (ms)": r.maxMs, + drawImage: r.drawImageCalls, + fillRect: r.fillRectCalls, + clearRect: r.clearRectCalls, + "save/restore": r.saveRestoreCycles, + })), + ); + }); + + // ── helpers ── + + function makeLayer( + state: MockGameState, + gpuCounters: GpuCounters, + ): BenchCtx { + installCanvasMock(MAP_WIDTH, MAP_HEIGHT, gpuCounters); + const gameView = createMockGameView(state); + const eventBus = createMockEventBus(); + const transformHandler = createMockTransformHandler(); + const uiState = createMockUIState(); + const layer = factory(gameView, eventBus, transformHandler, uiState); + const renderCtx = createInstrumentedContext( + MAP_WIDTH, + MAP_HEIGHT, + gpuCounters, + ); + return { layer, renderCtx, gpuCounters, state }; + } + + function newGpuCounters(): GpuCounters { + return { + drawImageCalls: 0, + fillRectCalls: 0, + clearRectCalls: 0, + saveRestoreCycles: 0, + }; + } + + // ═══════════════════════════════════════════════════════════════════════ + // Scenario 1: Full redraw (baseline per mix) + // ═══════════════════════════════════════════════════════════════════════ + + for (const mix of mixesToRun) { + const totalUnits = mix.composition.reduce((s, [, c]) => s + c, 0); + + it(`S1: Full redraw — ${mix.name}`, () => { + const result = runBenchmark( + `1. Full redraw: ${mix.name}`, + WARMUP, + ITERATIONS, + () => { + const state = freshState(); + spawnMix(state, mix); + // Put all units in the update list so redraw picks them up + state.recentUnitUpdates = [...state.units.values()].map( + mockUnitToUpdate, + ); + return makeLayer(state, newGpuCounters()); + }, + ({ layer }) => { + layer.redraw!(); + }, + ); + allResults.push(result); + expect(result.meanMs).toBeDefined(); + }); + + // ═════════════════════════════════════════════════════════════════════ + // Scenario 2: tick() + renderLayer() with all units moving + // ═════════════════════════════════════════════════════════════════════ + + it(`S2: tick+render (all moving) — ${mix.name}`, () => { + const result = runBenchmark( + `2. tick+render (moving): ${mix.name}`, + WARMUP, + ITERATIONS, + () => { + const state = freshState(); + spawnMix(state, mix); + state.recentUnitUpdates = [...state.units.values()].map( + mockUnitToUpdate, + ); + const ctx = makeLayer(state, newGpuCounters()); + ctx.layer.init?.(); + ctx.layer.redraw!(); + resetGpuCounters(ctx.gpuCounters); + + // Simulate movement for all units + state.recentUnitUpdates = simulateUnitMovement(state); + state.currentTick++; + return ctx; + }, + ({ layer, renderCtx }) => { + layer.tick!(); + layer.renderLayer!(renderCtx); + }, + ); + allResults.push(result); + expect(result.meanMs).toBeDefined(); + }); + + // ═════════════════════════════════════════════════════════════════════ + // Scenario 3: tick() only (no render) — measure update processing cost + // ═════════════════════════════════════════════════════════════════════ + + it(`S3: tick() only — ${mix.name}`, () => { + const result = runBenchmark( + `3. tick() only: ${mix.name}`, + WARMUP, + ITERATIONS, + () => { + const state = freshState(); + spawnMix(state, mix); + state.recentUnitUpdates = [...state.units.values()].map( + mockUnitToUpdate, + ); + const ctx = makeLayer(state, newGpuCounters()); + ctx.layer.init?.(); + ctx.layer.redraw!(); + resetGpuCounters(ctx.gpuCounters); + + state.recentUnitUpdates = simulateUnitMovement(state); + state.currentTick++; + return ctx; + }, + ({ layer }) => { + layer.tick!(); + }, + ); + allResults.push(result); + expect(result.meanMs).toBeDefined(); + }); + + // ═════════════════════════════════════════════════════════════════════ + // Scenario 4: renderLayer() only — measure pure rendering cost + // ═════════════════════════════════════════════════════════════════════ + + it(`S4: renderLayer() only — ${mix.name}`, () => { + const result = runBenchmark( + `4. renderLayer() only: ${mix.name}`, + WARMUP, + ITERATIONS, + () => { + const state = freshState(); + spawnMix(state, mix); + state.recentUnitUpdates = [...state.units.values()].map( + mockUnitToUpdate, + ); + const ctx = makeLayer(state, newGpuCounters()); + ctx.layer.init?.(); + ctx.layer.redraw!(); + + // Move units, tick, then measure only renderLayer + state.recentUnitUpdates = simulateUnitMovement(state); + state.currentTick++; + ctx.layer.tick!(); + + // Reset counters for pure render measurement + resetGpuCounters(ctx.gpuCounters); + state.recentUnitUpdates = []; + return ctx; + }, + ({ layer, renderCtx }) => { + layer.renderLayer!(renderCtx); + }, + ); + allResults.push(result); + expect(result.meanMs).toBeDefined(); + }); + } + + // ═══════════════════════════════════════════════════════════════════════ + // Scenario 5: Sustained ticks — 50 ticks of movement (mid-game mix) + // ═══════════════════════════════════════════════════════════════════════ + + it("S5: Sustained 50 ticks — Mid-game mix", () => { + const NUM_TICKS = 50; + + const result = runBenchmark( + "5. Sustained 50 ticks (mid-game)", + WARMUP, + ITERATIONS, + () => { + const state = freshState(); + spawnMix(state, UNIT_MIXES.midGame); + state.recentUnitUpdates = [...state.units.values()].map( + mockUnitToUpdate, + ); + const ctx = makeLayer(state, newGpuCounters()); + ctx.layer.init?.(); + ctx.layer.redraw!(); + resetGpuCounters(ctx.gpuCounters); + return ctx; + }, + ({ layer, renderCtx, state }) => { + for (let tick = 0; tick < NUM_TICKS; tick++) { + state.recentUnitUpdates = simulateUnitMovement(state); + state.currentTick++; + layer.tick!(); + layer.renderLayer!(renderCtx); + } + }, + ); + allResults.push(result); + expect(result.meanMs).toBeDefined(); + }); + + // ═══════════════════════════════════════════════════════════════════════ + // Scenario 6: Unit churn — spawn + destroy units each tick + // ═══════════════════════════════════════════════════════════════════════ + + it("S6: Unit churn (spawn+destroy per tick)", () => { + const NUM_TICKS = 30; + const SPAWN_PER_TICK = 10; + const KILL_PER_TICK = 8; + + const result = runBenchmark( + "6. Unit churn (30 ticks, +10/-8 per tick)", + WARMUP, + ITERATIONS, + () => { + const state = freshState(); + spawnMix(state, UNIT_MIXES.midGame); + state.recentUnitUpdates = [...state.units.values()].map( + mockUnitToUpdate, + ); + const ctx = makeLayer(state, newGpuCounters()); + ctx.layer.init?.(); + ctx.layer.redraw!(); + resetGpuCounters(ctx.gpuCounters); + return ctx; + }, + ({ layer, renderCtx, state }) => { + for (let tick = 0; tick < NUM_TICKS; tick++) { + // Destroy some units + const killUpdates = deactivateUnits(state, KILL_PER_TICK); + + // Spawn new ones + const newUnits: MockUnit[] = []; + for (let s = 0; s < SPAWN_PER_TICK; s++) { + const types: UnitType[] = [ + UnitType.Warship, + UnitType.FighterJet, + UnitType.TradeShip, + ]; + const t = types[s % types.length]; + const ownerIdx = s % state.regions.length; + newUnits.push( + spawnUnit(state, t, state.regions[ownerIdx].smallID), + ); + } + const spawnUpdates = newUnits.map(mockUnitToUpdate); + + // Move remaining active units + const moveUpdates = simulateUnitMovement(state); + + state.recentUnitUpdates = [ + ...killUpdates, + ...spawnUpdates, + ...moveUpdates, + ]; + state.currentTick++; + layer.tick!(); + layer.renderLayer!(renderCtx); + } + }, + ); + allResults.push(result); + expect(result.meanMs).toBeDefined(); + }); + + // ═══════════════════════════════════════════════════════════════════════ + // Scenario 7: Mixed levels — units with varying upgrade levels + // ═══════════════════════════════════════════════════════════════════════ + + it("S7: Mixed levels (level 1-4 units)", () => { + const result = runBenchmark( + "7. Mixed levels (200 units, L1-L4)", + WARMUP, + ITERATIONS, + () => { + const state = freshState(); + // Spawn units with varying levels + for (let i = 0; i < 50; i++) { + const level = (i % 4) + 1; + spawnUnit(state, UnitType.Warship, state.regions[i % 4].smallID, { + level, + }); + } + for (let i = 0; i < 50; i++) { + const level = (i % 4) + 1; + spawnUnit( + state, + UnitType.FighterJet, + state.regions[i % 4].smallID, + { level }, + ); + } + for (let i = 0; i < 50; i++) { + const level = (i % 4) + 1; + spawnUnit(state, UnitType.Submarine, state.regions[i % 4].smallID, { + level, + }); + } + for (let i = 0; i < 50; i++) { + spawnUnit(state, UnitType.TradeShip, state.regions[i % 4].smallID); + } + + state.recentUnitUpdates = [...state.units.values()].map( + mockUnitToUpdate, + ); + const ctx = makeLayer(state, newGpuCounters()); + ctx.layer.init?.(); + ctx.layer.redraw!(); + resetGpuCounters(ctx.gpuCounters); + + state.recentUnitUpdates = simulateUnitMovement(state); + state.currentTick++; + return ctx; + }, + ({ layer, renderCtx }) => { + layer.tick!(); + layer.renderLayer!(renderCtx); + }, + ); + allResults.push(result); + expect(result.meanMs).toBeDefined(); + }); + + // ═══════════════════════════════════════════════════════════════════════ + // Scenario 8: Targeting units (with attack markers / texture swaps) + // ═══════════════════════════════════════════════════════════════════════ + + it("S8: Targeting (attack markers)", () => { + const result = runBenchmark( + "8. Targeting (100 attacking units)", + WARMUP, + ITERATIONS, + () => { + const state = freshState(); + // Spawn warships and fighters that are attacking + for (let i = 0; i < 50; i++) { + const u = spawnUnit( + state, + UnitType.Warship, + state.regions[i % 4].smallID, + { + isAttacking: true, + targetUnitId: 9999, + }, + ); + } + for (let i = 0; i < 50; i++) { + spawnUnit( + state, + UnitType.FighterJet, + state.regions[i % 4].smallID, + { + isAttacking: true, + targetUnitId: 9999, + }, + ); + } + // Add some non-attacking units too + spawnUnitsEvenly(state, UnitType.TradeShip, 40); + spawnUnitsEvenly(state, UnitType.Submarine, 20); + + state.recentUnitUpdates = [...state.units.values()].map( + mockUnitToUpdate, + ); + const ctx = makeLayer(state, newGpuCounters()); + ctx.layer.init?.(); + ctx.layer.redraw!(); + resetGpuCounters(ctx.gpuCounters); + + state.recentUnitUpdates = simulateUnitMovement(state); + state.currentTick++; + return ctx; + }, + ({ layer, renderCtx }) => { + layer.tick!(); + layer.renderLayer!(renderCtx); + }, + ); + allResults.push(result); + expect(result.meanMs).toBeDefined(); + }); + }); +}