mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-08 01:10:37 +00:00
22c873cf55
## Problem
Every 100 ms the main thread's worker `onmessage` callback processes a
full game tick (`gameView.update` → `webglBuilder.update` →
`renderer.tick`). At 60 fps this competes with the 16.7 ms frame budget,
and on the Giant World Map it takes several ms — frame drops on low-end
hardware.
## Harness (`npm run perf:client-tick`)
Headless-Chromium harness that times every worker→main `game_update`
dispatch on the main thread, with structured-clone deserialization
measured separately from the handler body (via a
`Worker.prototype.addEventListener` wrapper installed as a page init
script — no product-code changes). It reports windowed distributions,
captures `.cpuprofile` files at chosen ticks, writes raw samples and an
end-of-run screenshot. `AnalyzeCpuProfile.ts` breaks a profile down by
inclusive time under the dispatch subtree.
Init scripts are passed as **strings**: tsx compiles function-form init
scripts with esbuild `keepNames`, whose injected `__name` helper doesn't
exist in-page and silently kills the game worker setup.
## Baseline (Giant World Map, 400 bots, headless)
Dispatch handler ms — cost **grows with game progression**:
| window | mean | p50 | p95 | max |
|---|---|---|---|---|
| tick 506 | 2.22 | 2.20 | 3.40 | 5.00 |
| tick 1506 | 2.60 | 2.00 | 7.00 | 10.40 |
| tick 2000 | 2.67 | 1.90 | **8.70** | **12.70** |
Deserialization is negligible (0.12 ms mean). CPU profiles attributed
the growing tail to the leaderboard's once-per-second refresh: its
Max-troops column calls `config().maxTroops(p)` for **all ~508
players**, and `PlayerView.units()` scanned **every unit in the game**
per call — O(players × units), growing as units accumulate.
## Round 1 — algorithmic fixes
- **GameView**: new `unitsOwnedBy(smallID)` — an active-units-by-owner
index built lazily at most once per tick. `PlayerView.units()` reads its
own units from it: O(own units) instead of O(all units). Also speeds up
unit display, player panel, and buildables queries.
- **NamePass.updateNames**: reads player state directly from the
caller's map by smallID instead of rebuilding three lookup maps per
tick; skips the slot-assignment sweep once every player has a slot.
After (same map, same spawn tile):
| window | mean | p50 | p95 | max |
|---|---|---|---|---|
| tick 506 | 2.12 | 2.00 | 3.10 | 5.20 |
| tick 1506 | 1.86 | 1.80 | 2.90 | 4.30 |
| tick 2000 | 1.74 | 1.60 | **2.40** | **4.70** |
Late-game p95 −65% (8.7 → 2.4 ms), worst dispatch −63% (12.7 → 4.7 ms),
and per-dispatch cost no longer grows with game progression. The
leaderboard disappeared from the dispatch profile entirely.
## Round 2 — allocation churn + time slicing
Aimed at GC pauses and low-end CPUs; measures flat vs round 1 on a fast
machine, as expected:
- **`FrameData.changedTiles`** is now the plain tile-ref array GameView
already builds instead of a per-tile `{ref, state}` object copy — heavy
battle ticks allocated tens of thousands of objects per tick for a
`state` field that was always 0. `TilePair` removed; `TerritoryPass`
buckets refs synchronously, so the live reference is safe.
- **`UnitView.lastPos`** is only re-sliced when a move actually appended
a position — the unconditional `slice(-1)` allocated an identical
1-element array per unit per tick, including for structures that never
move.
- **`NamePass.updateNames`** refreshes slots round-robin, a quarter per
tick — the full per-player diff pass spreads over ~400 ms, under the
existing 500 ms troop-text cadence; positions lerp continuously. Unnamed
slots and snap passes (seeks) are always processed so nothing pops in
late. Dispatch share: 17% → 13%.
Not sliced on purpose: tile ingest and frame upload need a consistent
per-tick snapshot (stale `GameMap` reads would leak into hover queries,
minimap, attack targeting) — a correctness risk not worth ~1 ms while
the worst dispatch already fits in a quarter of the frame budget.
## Verification
- `npx tsc --noEmit`, eslint clean; full suite green (1929 tests)
- 6 new GameView tests cover the owner index (grouping, inactive
exclusion, ownership capture, death, type filtering, copy semantics);
changedTiles tests updated to the ref-array contract
- Headless end-of-run screenshots verified after each round: leaderboard
Max-troops values, map names + troop counts + flags all render correctly
(including with name slicing active)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1329 lines
43 KiB
TypeScript
1329 lines
43 KiB
TypeScript
/**
|
||
* GPURenderer v2 — normalized render pipeline.
|
||
*
|
||
* Draw order:
|
||
* DATA SYNC: tile flush → heat update → border compute
|
||
* BASE PASS (darkened by night): terrain → territory fill + stale-nuke ground
|
||
* NIGHT COMPOSITE (optional): lightmap → scene × (ambient + lightmap)
|
||
* FULL BRIGHTNESS (always): borders → railroads → ground units → structures →
|
||
* structure levels → bars → bloom → trails → missiles → fx → conquest → names
|
||
*/
|
||
|
||
import type { Config } from "../../../core/configuration/Config";
|
||
import type {
|
||
AttackRingInput,
|
||
BonusEvent,
|
||
ConquestFx,
|
||
DeadUnitFx,
|
||
GhostPreviewData,
|
||
NameEntry,
|
||
NukeTelegraphData,
|
||
NukeTrajectoryData,
|
||
PlayerState,
|
||
PlayerStatic,
|
||
PlayerStatusData,
|
||
RendererConfig,
|
||
UnitState,
|
||
} from "../types";
|
||
import { Camera } from "./Camera";
|
||
import { GLUnavailableError, initGL } from "./initGL";
|
||
import { BarPass } from "./passes/BarPass";
|
||
import { BorderComputePass } from "./passes/BorderComputePass";
|
||
import { BorderStampPass } from "./passes/BorderStampPass";
|
||
import { CoordinateGridPass } from "./passes/CoordinateGridPass";
|
||
import { CrosshairPass } from "./passes/CrosshairPass";
|
||
import { DefenseCoveragePass } from "./passes/DefenseCoveragePass";
|
||
import { FalloutBloomPass } from "./passes/FalloutBloomPass";
|
||
import { FalloutLightPass } from "./passes/FalloutLightPass";
|
||
import { FxPass } from "./passes/fx-pass";
|
||
import { LightmapPass } from "./passes/LightmapPass";
|
||
import { MoveIndicatorPass } from "./passes/MoveIndicatorPass";
|
||
import { NamePass } from "./passes/name-pass";
|
||
import { NightCompositePass } from "./passes/NightCompositePass";
|
||
import { NukeTelegraphPass } from "./passes/NukeTelegraphPass";
|
||
import { NukeTrajectoryPass } from "./passes/NukeTrajectoryPass";
|
||
import { PointLightPass } from "./passes/PointLightPass";
|
||
import { RailroadPass } from "./passes/RailroadPass";
|
||
import { RangeCirclePass } from "./passes/RangeCirclePass";
|
||
import { SAMRadiusPass } from "./passes/SamRadiusPass";
|
||
import { SelectionBoxPass } from "./passes/SelectionBoxPass";
|
||
import { SkinAtlasArray } from "./passes/SkinAtlasArray";
|
||
import type { SpawnCenter } from "./passes/SpawnOverlayPass";
|
||
import { SpawnOverlayPass } from "./passes/SpawnOverlayPass";
|
||
import { StructureLevelPass } from "./passes/StructureLevelPass";
|
||
import { StructurePass } from "./passes/StructurePass";
|
||
import { TerrainPass } from "./passes/TerrainPass";
|
||
import { TerritoryPass } from "./passes/TerritoryPass";
|
||
import { TrailPass } from "./passes/TrailPass";
|
||
import { UnitPass } from "./passes/UnitPass";
|
||
import { WorldTextPass } from "./passes/WorldTextPass";
|
||
import type { RenderSettings } from "./RenderSettings";
|
||
import { AffiliationPalette } from "./utils/Affiliation";
|
||
import {
|
||
EFFECT_PALETTE_BLOCKS,
|
||
getPaletteSize,
|
||
hexToRgb,
|
||
MAX_TRAIL_COLORS,
|
||
} from "./utils/ColorUtils";
|
||
import { renderDpr } from "./utils/Dpr";
|
||
import {
|
||
createTexture2D,
|
||
toScreen,
|
||
toTarget,
|
||
type RenderTarget,
|
||
} from "./utils/GlUtils";
|
||
import {
|
||
createGPUResources,
|
||
disposeGPUResources,
|
||
type GPUResources,
|
||
} from "./utils/GpuResources";
|
||
import { HeatManager } from "./utils/HeatManager";
|
||
|
||
/** Ghost types that trigger SAM radius overlay (matches upstream SAMRadiusLayer). */
|
||
const SAM_RADIUS_GHOST_TYPES = new Set([
|
||
"Missile Silo",
|
||
"SAM Launcher",
|
||
"City",
|
||
"Atom Bomb",
|
||
"Hydrogen Bomb",
|
||
]);
|
||
|
||
/** Subset for build-button hover — excludes City/Silo (SAM radii irrelevant). */
|
||
const SAM_RADIUS_HIGHLIGHT_TYPES = new Set([
|
||
"SAM Launcher",
|
||
"Atom Bomb",
|
||
"Hydrogen Bomb",
|
||
]);
|
||
|
||
const GRID_VIEW_KEY = "renderer:grid_view_enabled";
|
||
|
||
export class GPURenderer {
|
||
private gl: WebGL2RenderingContext;
|
||
private camera: Camera;
|
||
private res: GPUResources;
|
||
|
||
/**
|
||
* Set when the context is hardware-accelerated but its MAX_TEXTURE_SIZE is
|
||
* below what the game needs (fingerprinting protection, #4357). The game
|
||
* runs, but the map may render with black areas — the owner should warn.
|
||
*/
|
||
readonly glLimited: { renderer: string; maxTextureSize: number } | null;
|
||
|
||
// Passes
|
||
private terrainPass: TerrainPass;
|
||
private territoryPass: TerritoryPass;
|
||
private trailPass: TrailPass;
|
||
private borderStampPass: BorderStampPass;
|
||
private borderPass: BorderComputePass;
|
||
private defenseCoveragePass: DefenseCoveragePass;
|
||
private bloomPass: FalloutBloomPass;
|
||
private pointLightPass: PointLightPass;
|
||
private falloutLightPass: FalloutLightPass;
|
||
private lightmapPass: LightmapPass;
|
||
private nightCompositePass: NightCompositePass;
|
||
private structurePass: StructurePass;
|
||
private structureLevelPass: StructureLevelPass;
|
||
private unitPass: UnitPass;
|
||
private namePass: NamePass;
|
||
private fxPass: FxPass;
|
||
private rangeCirclePass: RangeCirclePass;
|
||
private samRadiusPass: SAMRadiusPass;
|
||
private crosshairPass: CrosshairPass;
|
||
private railroadPass: RailroadPass;
|
||
private barPass: BarPass;
|
||
private worldTextPass: WorldTextPass;
|
||
private selectionBoxPass: SelectionBoxPass;
|
||
private moveIndicatorPass: MoveIndicatorPass;
|
||
private nukeTrajectoryPass: NukeTrajectoryPass;
|
||
private nukeTelegraphPass: NukeTelegraphPass;
|
||
private heatManager: HeatManager;
|
||
private affiliationPalette: AffiliationPalette;
|
||
private coordinateGridPass: CoordinateGridPass;
|
||
private spawnOverlayPass: SpawnOverlayPass;
|
||
private inSpawnPhase = false;
|
||
|
||
private paletteTex: WebGLTexture;
|
||
private paletteData: Float32Array;
|
||
// Per-player trail-effect palette, keyed by smallID (RGBA32F,
|
||
// 4096×(MAX_TRAIL_COLORS·TRAIL_EFFECT_BLOCKS)): one MAX_TRAIL_COLORS-row block
|
||
// per trail effectType (block 0 = transportShipTrail, block 1 = nukeTrail).
|
||
// Sampled by TrailPass; the shader picks the block from the trail tile's nuke
|
||
// bit.
|
||
private effectTex: WebGLTexture;
|
||
private patternMetaTex: WebGLTexture;
|
||
private patternDataTex: WebGLTexture;
|
||
private skinAtlas: SkinAtlasArray;
|
||
private skinLayerTex: WebGLTexture;
|
||
/** CPU-side mirror of skinLayerTex (0 = no skin, otherwise layer + 1). */
|
||
private skinLayerCpu: Uint8Array;
|
||
/** Per-player anchor (x,y) for skin sampling. (0,0) = world-origin anchor. */
|
||
private skinAnchorTex: WebGLTexture;
|
||
private skinAnchorCpu: Uint16Array;
|
||
private canvas: HTMLCanvasElement;
|
||
private settings: RenderSettings;
|
||
private sceneTarget: RenderTarget;
|
||
private raf: typeof requestAnimationFrame;
|
||
private caf: typeof cancelAnimationFrame;
|
||
|
||
private animId: number | null = null;
|
||
private frameTick = 0;
|
||
private mapW = 0;
|
||
private mapH = 0;
|
||
|
||
// Last-uploaded unit/structure maps (selection box + bar pass inputs)
|
||
private lastUnits: Map<number, UnitState> = new Map();
|
||
private lastStructures: Map<number, UnitState> = new Map();
|
||
|
||
// Local player relationship data (for SAM radius coloring)
|
||
private localPlayerID = 0;
|
||
private playerTeams = new Map<number, string>(); // smallID → team
|
||
|
||
// Alt-view: affiliation recoloring (space hold)
|
||
private altView = false;
|
||
// Grid-view: coordinate grid overlay (M toggle)
|
||
private gridView = false;
|
||
|
||
// SAM radius visibility tracking (show if either source is true)
|
||
private samGhostVisible = false;
|
||
private samHighlightVisible = false;
|
||
|
||
// Warship selection — supports any number of selections.
|
||
private selectedUnitIds: number[] = [];
|
||
/** Reusable scratch buffer of {x,y,r,g,b} for the selection-box pass. */
|
||
private readonly selectionBoxEntries: import("./passes/SelectionBoxPass").SelectionEntry[] =
|
||
[];
|
||
|
||
constructor(
|
||
canvas: HTMLCanvasElement,
|
||
header: RendererConfig,
|
||
terrainSource: () => Uint8Array,
|
||
paletteData: Float32Array,
|
||
config: Config,
|
||
settings: RenderSettings,
|
||
raf: typeof requestAnimationFrame = requestAnimationFrame.bind(window),
|
||
caf: typeof cancelAnimationFrame = cancelAnimationFrame.bind(window),
|
||
) {
|
||
this.canvas = canvas;
|
||
// Settings are resolved (defaults + user overrides) by the caller and
|
||
// passed in, so every pass — including texture-baking ones like terrain —
|
||
// is built with the final values. Live changes mutate this object in place.
|
||
this.settings = settings;
|
||
this.raf = raf;
|
||
this.caf = caf;
|
||
|
||
// Demand a GPU-accelerated context. A software (SwiftShader) or missing
|
||
// WebGL2 context throws GLUnavailableError, which the game-start path
|
||
// turns into an actionable gate instead of letting the game crawl at
|
||
// ~1fps. A fingerprint-capped context ("limited" — MAX_TEXTURE_SIZE below
|
||
// the palette width, #4357) proceeds anyway; glLimited lets the owner
|
||
// warn the player that the map may render with black areas.
|
||
const res = initGL(canvas, {
|
||
alpha: false,
|
||
antialias: false,
|
||
powerPreference: "high-performance",
|
||
});
|
||
if (res.gl === null) {
|
||
throw new GLUnavailableError(res.status, res.renderer);
|
||
}
|
||
this.glLimited =
|
||
res.status === "limited"
|
||
? { renderer: res.renderer, maxTextureSize: res.maxTextureSize }
|
||
: null;
|
||
const gl = res.gl;
|
||
this.gl = gl;
|
||
gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1);
|
||
|
||
const floatExt = gl.getExtension("EXT_color_buffer_float");
|
||
if (!floatExt)
|
||
console.warn("EXT_color_buffer_float not available — palette may fail");
|
||
|
||
const mapW = header.mapWidth;
|
||
const mapH = header.mapHeight;
|
||
this.mapW = mapW;
|
||
this.mapH = mapH;
|
||
|
||
this.camera = new Camera(mapW, mapH);
|
||
|
||
// --- Terrain (static) ---
|
||
// Bake once and let the array go — nothing below retains map-sized
|
||
// terrain bytes; re-bakes call terrainSource again.
|
||
const terrainBytes = terrainSource();
|
||
this.terrainPass = new TerrainPass(
|
||
gl,
|
||
terrainSource,
|
||
terrainBytes,
|
||
mapW,
|
||
mapH,
|
||
{
|
||
oceanColor: hexToRgb(this.settings.terrain.oceanColor) ?? undefined,
|
||
sandColor: hexToRgb(this.settings.terrain.sandColor) ?? undefined,
|
||
plainsColor: hexToRgb(this.settings.terrain.plainsColor) ?? undefined,
|
||
highlandColor:
|
||
hexToRgb(this.settings.terrain.highlandColor) ?? undefined,
|
||
mountainColor:
|
||
hexToRgb(this.settings.terrain.mountainColor) ?? undefined,
|
||
},
|
||
);
|
||
|
||
// --- Shared palette texture (RGBA32F, 4096×2) ---
|
||
this.paletteData = paletteData;
|
||
const palW = getPaletteSize();
|
||
this.paletteTex = createTexture2D(gl, {
|
||
width: palW,
|
||
height: 2,
|
||
internalFormat: gl.RGBA32F,
|
||
format: gl.RGBA,
|
||
type: gl.FLOAT,
|
||
data: paletteData,
|
||
filter: gl.NEAREST,
|
||
});
|
||
|
||
// Per-player effect texture: EFFECT_PALETTE_BLOCKS stacked blocks of
|
||
// MAX_TRAIL_COLORS rows (block 0 = transportShipTrail, block 1 = nukeTrail,
|
||
// block 2 = structures). Starts zeroed (color count 0 everywhere = no
|
||
// effect → territory/player color).
|
||
const effectRows = MAX_TRAIL_COLORS * EFFECT_PALETTE_BLOCKS;
|
||
this.effectTex = createTexture2D(gl, {
|
||
width: palW,
|
||
height: effectRows,
|
||
internalFormat: gl.RGBA32F,
|
||
format: gl.RGBA,
|
||
type: gl.FLOAT,
|
||
data: new Float32Array(palW * effectRows * 4),
|
||
filter: gl.NEAREST,
|
||
});
|
||
|
||
this.patternMetaTex = createTexture2D(gl, {
|
||
width: palW,
|
||
height: 1,
|
||
internalFormat: gl.RGBA32F,
|
||
format: gl.RGBA,
|
||
type: gl.FLOAT,
|
||
data: new Float32Array(palW * 4),
|
||
filter: gl.NEAREST,
|
||
});
|
||
|
||
this.patternDataTex = createTexture2D(gl, {
|
||
width: 1024,
|
||
height: palW,
|
||
internalFormat: gl.R8UI,
|
||
format: gl.RED_INTEGER,
|
||
type: gl.UNSIGNED_BYTE,
|
||
data: new Uint8Array(palW * 1024),
|
||
filter: gl.NEAREST,
|
||
});
|
||
|
||
// --- Skin atlas (TEXTURE_2D_ARRAY of PNG layers) + per-player layer map ---
|
||
this.skinLayerCpu = new Uint8Array(palW);
|
||
this.skinLayerTex = createTexture2D(gl, {
|
||
width: palW,
|
||
height: 1,
|
||
internalFormat: gl.R8UI,
|
||
format: gl.RED_INTEGER,
|
||
type: gl.UNSIGNED_BYTE,
|
||
data: this.skinLayerCpu,
|
||
filter: gl.NEAREST,
|
||
});
|
||
// Per-player skin anchor: RG16UI, 2× uint16 per player → 4 bytes each.
|
||
// (0,0) sentinel means "no anchor" — shader uses world origin.
|
||
this.skinAnchorCpu = new Uint16Array(palW * 2);
|
||
this.skinAnchorTex = createTexture2D(gl, {
|
||
width: palW,
|
||
height: 1,
|
||
internalFormat: gl.RG16UI,
|
||
format: gl.RG_INTEGER,
|
||
type: gl.UNSIGNED_SHORT,
|
||
data: this.skinAnchorCpu,
|
||
filter: gl.NEAREST,
|
||
});
|
||
// Construct with no URLs — the real atlas is built once initSkinAtlas() is
|
||
// called with the locked-in player skin URLs at game start.
|
||
this.skinAtlas = new SkinAtlasArray(gl, [], () => {});
|
||
|
||
// --- Border compute (creates its own borderTex) ---
|
||
// Need a temporary tileTex reference for border compute — we'll create
|
||
// GPUResources first, then wire everything.
|
||
// But borderPass creates its own borderTex internally, so we need to
|
||
// create GPUResources with it. Let's sequence carefully:
|
||
|
||
// 1. Create GPUResources (creates tileTex, trailTex, heatTexA/B)
|
||
// borderTex placeholder — we'll get it from borderPass
|
||
// First create a dummy, then replace after borderPass is created.
|
||
|
||
// Actually: borderPass creates its own internal borderTex (RGBA8).
|
||
// We need tileTex to exist before borderPass. So:
|
||
// a) Create shared resources (tileTex, trailTex, heatA/B)
|
||
// b) Create borderPass with tileTex → gives us borderTex
|
||
// c) Store borderTex in res
|
||
|
||
// Create shared textures except borderTex
|
||
this.res = createGPUResources(gl, mapW, mapH, this.paletteTex, null!);
|
||
|
||
// --- Border compute (needs tileTex) ---
|
||
this.borderPass = new BorderComputePass(
|
||
gl,
|
||
mapW,
|
||
mapH,
|
||
this.res.tileTex,
|
||
this.settings,
|
||
);
|
||
this.res.borderTex = this.borderPass.getBorderTex();
|
||
|
||
// --- Defense coverage (needs tileTex) — per-tile "defended by same-owner
|
||
// post" flag, stamped one instanced circle per post. Replaces the old
|
||
// 64-cap uniform loop; consumed by BorderStampPass. ---
|
||
this.defenseCoveragePass = new DefenseCoveragePass(
|
||
gl,
|
||
mapW,
|
||
mapH,
|
||
this.res.tileTex,
|
||
this.settings,
|
||
);
|
||
|
||
// --- Heat manager (needs tileTex, heatTexA/B) ---
|
||
this.heatManager = new HeatManager(
|
||
gl,
|
||
mapW,
|
||
mapH,
|
||
this.res.tileTex,
|
||
this.res.heatTexA,
|
||
this.res.heatTexB,
|
||
this.settings,
|
||
);
|
||
|
||
// --- Territory (needs tileTex, paletteTex, patternTexs, skinTexs) ---
|
||
this.territoryPass = new TerritoryPass(
|
||
gl,
|
||
mapW,
|
||
mapH,
|
||
this.res.tileTex,
|
||
this.paletteTex,
|
||
this.patternMetaTex,
|
||
this.patternDataTex,
|
||
this.skinAtlas.texture,
|
||
this.skinLayerTex,
|
||
this.skinAnchorTex,
|
||
this.settings,
|
||
);
|
||
// Route per-tile changes to the border pass so it can scatter-recompute
|
||
// just the affected tiles instead of rebuilding the whole map. A tile
|
||
// changing owner can also flip its defense-coverage flag (same-owner test),
|
||
// so mark the coverage stale too — one coalesced re-stamp happens per frame.
|
||
this.territoryPass.setBorderPatchConsumer((x, y, prevOwner, newOwner) => {
|
||
this.borderPass.patchTile(x, y, prevOwner, newOwner);
|
||
this.defenseCoveragePass.markTileDirty(x, y);
|
||
});
|
||
// Territory fill darkens on interior tiles defended by a same-owner post;
|
||
// borderTex lets the fill skip border tiles (those get the checkerboard).
|
||
this.territoryPass.setDefenseCoverageTex(
|
||
this.defenseCoveragePass.getCoverageTex(),
|
||
);
|
||
this.territoryPass.setBorderTex(this.res.borderTex);
|
||
|
||
// --- Spawn overlay (needs tileTex) ---
|
||
this.spawnOverlayPass = new SpawnOverlayPass(
|
||
gl,
|
||
mapW,
|
||
mapH,
|
||
this.res.tileTex,
|
||
this.settings.spawnOverlay,
|
||
);
|
||
|
||
// --- Trail (needs trailTex, paletteTex, effectTex) ---
|
||
this.trailPass = new TrailPass(
|
||
gl,
|
||
mapW,
|
||
mapH,
|
||
this.res.trailTex,
|
||
this.paletteTex,
|
||
this.effectTex,
|
||
this.settings,
|
||
);
|
||
|
||
// --- Border stamp (needs tileTex, paletteTex, borderTex) ---
|
||
this.borderStampPass = new BorderStampPass(
|
||
gl,
|
||
mapW,
|
||
mapH,
|
||
this.res.tileTex,
|
||
this.paletteTex,
|
||
this.res.borderTex,
|
||
this.settings,
|
||
);
|
||
this.borderStampPass.setDefenseCoverageTex(
|
||
this.defenseCoveragePass.getCoverageTex(),
|
||
);
|
||
|
||
// --- Fallout bloom (needs tileTex, heatManager) ---
|
||
this.bloomPass = new FalloutBloomPass(
|
||
gl,
|
||
mapW,
|
||
mapH,
|
||
this.res.tileTex,
|
||
this.heatManager,
|
||
this.settings,
|
||
);
|
||
|
||
// --- Point lights ---
|
||
this.pointLightPass = new PointLightPass(
|
||
gl,
|
||
header,
|
||
paletteData,
|
||
this.settings,
|
||
config,
|
||
);
|
||
|
||
// --- Fallout light (needs tileTex + heatManager; particle flicker is
|
||
// computed inline using the falloutBloom particle settings) ---
|
||
this.falloutLightPass = new FalloutLightPass(
|
||
gl,
|
||
mapW,
|
||
mapH,
|
||
this.res.tileTex,
|
||
this.heatManager,
|
||
this.settings,
|
||
);
|
||
|
||
// --- Lightmap orchestrator ---
|
||
this.lightmapPass = new LightmapPass(
|
||
gl,
|
||
mapW,
|
||
mapH,
|
||
this.pointLightPass,
|
||
this.falloutLightPass,
|
||
this.settings,
|
||
);
|
||
|
||
// --- Night composite ---
|
||
this.nightCompositePass = new NightCompositePass(gl, this.settings);
|
||
|
||
// --- Railroad (needs tileTex) ---
|
||
this.railroadPass = new RailroadPass(
|
||
gl,
|
||
mapW,
|
||
mapH,
|
||
this.res.tileTex,
|
||
this.paletteTex,
|
||
terrainBytes,
|
||
this.settings,
|
||
);
|
||
|
||
// --- Range circle (ghost preview radius) ---
|
||
this.rangeCirclePass = new RangeCirclePass(gl);
|
||
|
||
// --- SAM radius overlay (dashed green circles during build mode) ---
|
||
this.samRadiusPass = new SAMRadiusPass(gl, mapW, this.settings);
|
||
this.samRadiusPass.setPaletteData(paletteData);
|
||
|
||
// --- Crosshair (warship placement) ---
|
||
this.crosshairPass = new CrosshairPass(gl);
|
||
|
||
// --- Remaining passes (unchanged from v1) ---
|
||
this.structurePass = new StructurePass(
|
||
gl,
|
||
header,
|
||
this.paletteTex,
|
||
this.effectTex,
|
||
this.settings,
|
||
);
|
||
this.structureLevelPass = new StructureLevelPass(gl, header, this.settings);
|
||
this.unitPass = new UnitPass(
|
||
gl,
|
||
header,
|
||
this.paletteTex,
|
||
this.settings,
|
||
config,
|
||
);
|
||
this.namePass = new NamePass(
|
||
gl,
|
||
header,
|
||
paletteData,
|
||
this.settings,
|
||
config,
|
||
);
|
||
this.fxPass = new FxPass(gl, header, this.settings, config);
|
||
this.barPass = new BarPass(gl, header, this.settings, config);
|
||
this.worldTextPass = new WorldTextPass(gl, this.settings, config);
|
||
this.worldTextPass.setMapWidth(this.mapW);
|
||
this.selectionBoxPass = new SelectionBoxPass(gl);
|
||
this.moveIndicatorPass = new MoveIndicatorPass(gl, this.settings);
|
||
this.nukeTrajectoryPass = new NukeTrajectoryPass(gl, this.settings);
|
||
this.nukeTelegraphPass = new NukeTelegraphPass(gl, this.settings);
|
||
|
||
// --- Scene capture target (for night composite) ---
|
||
const sceneTex = gl.createTexture()!;
|
||
gl.bindTexture(gl.TEXTURE_2D, sceneTex);
|
||
gl.texImage2D(
|
||
gl.TEXTURE_2D,
|
||
0,
|
||
gl.RGBA8,
|
||
1,
|
||
1,
|
||
0,
|
||
gl.RGBA,
|
||
gl.UNSIGNED_BYTE,
|
||
null,
|
||
);
|
||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
|
||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
|
||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
||
const sceneFbo = gl.createFramebuffer()!;
|
||
gl.bindFramebuffer(gl.FRAMEBUFFER, sceneFbo);
|
||
gl.framebufferTexture2D(
|
||
gl.FRAMEBUFFER,
|
||
gl.COLOR_ATTACHMENT0,
|
||
gl.TEXTURE_2D,
|
||
sceneTex,
|
||
0,
|
||
);
|
||
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
|
||
this.sceneTarget = { fbo: sceneFbo, tex: sceneTex, w: 1, h: 1 };
|
||
|
||
// --- Alt-view passes ---
|
||
this.affiliationPalette = new AffiliationPalette(gl, this.settings);
|
||
const affTex = this.affiliationPalette.getTexture();
|
||
this.borderStampPass.setAffiliationTex(affTex);
|
||
this.unitPass.setAffiliationTex(affTex);
|
||
this.structurePass.setAffiliationTex(affTex);
|
||
this.trailPass.setAffiliationTex(affTex);
|
||
this.coordinateGridPass = new CoordinateGridPass(
|
||
gl,
|
||
mapW,
|
||
mapH,
|
||
this.settings,
|
||
);
|
||
try {
|
||
this.gridView = window.localStorage.getItem(GRID_VIEW_KEY) === "true";
|
||
} catch {
|
||
this.setGridView(false);
|
||
}
|
||
|
||
for (const p of header.players) {
|
||
if (p.team !== null) this.playerTeams.set(p.smallID, p.team);
|
||
}
|
||
// Team mode = any player has a team. Drives skin tint behavior:
|
||
// FFA shows raw skin colors; teams multiply skin by team primary color.
|
||
this.territoryPass.setTeamMode(this.playerTeams.size > 0);
|
||
|
||
this.startLoop();
|
||
}
|
||
|
||
private renderLoop = (): void => {
|
||
this.draw();
|
||
this.animId = this.raf(this.renderLoop);
|
||
};
|
||
|
||
private startLoop(): void {
|
||
this.animId ??= this.raf(this.renderLoop);
|
||
}
|
||
|
||
private stopLoop(): void {
|
||
if (this.animId !== null) {
|
||
this.caf(this.animId);
|
||
this.animId = null;
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Canvas / Camera
|
||
// ---------------------------------------------------------------------------
|
||
|
||
resize(cssWidth: number, cssHeight: number): void {
|
||
const dpr = renderDpr();
|
||
this.canvas.width = Math.round(cssWidth * dpr);
|
||
this.canvas.height = Math.round(cssHeight * dpr);
|
||
this.camera.resize(cssWidth, cssHeight);
|
||
}
|
||
|
||
setCameraState(x: number, y: number, z: number): void {
|
||
this.camera.setCameraState(x, y, z);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Data upload
|
||
// ---------------------------------------------------------------------------
|
||
|
||
uploadTileAndTrailState(
|
||
tileState: Uint16Array,
|
||
trailState: Uint16Array,
|
||
): void {
|
||
this.territoryPass.setLiveRef(tileState);
|
||
this.trailPass.setLiveRef(trailState);
|
||
}
|
||
|
||
uploadLiveDelta(
|
||
tileState: Uint16Array,
|
||
changedTiles: readonly number[],
|
||
): void {
|
||
this.territoryPass.applyLiveDelta(tileState, changedTiles);
|
||
}
|
||
|
||
uploadLiveTrailDelta(
|
||
trailState: Uint16Array,
|
||
dirtyRowMin: number,
|
||
dirtyRowMax: number,
|
||
): void {
|
||
this.trailPass.applyLiveDelta(trailState, dirtyRowMin, dirtyRowMax);
|
||
}
|
||
|
||
/** Re-upload palette data to the GPU texture (e.g. when players appear after initial startup). */
|
||
updatePalette(paletteData: Float32Array): void {
|
||
const gl = this.gl;
|
||
// Mutate the stored array in-place so all passes sharing the reference see the update.
|
||
this.paletteData.set(paletteData);
|
||
// Re-upload to the GPU texture
|
||
gl.activeTexture(gl.TEXTURE0);
|
||
gl.bindTexture(gl.TEXTURE_2D, this.paletteTex);
|
||
gl.texSubImage2D(
|
||
gl.TEXTURE_2D,
|
||
0,
|
||
0,
|
||
0,
|
||
getPaletteSize(),
|
||
2,
|
||
gl.RGBA,
|
||
gl.FLOAT,
|
||
this.paletteData,
|
||
);
|
||
// SAM radius pass stores its own copy
|
||
this.samRadiusPass.setPaletteData(this.paletteData);
|
||
// Name pass caches per-player colors and bakes them into slot rows
|
||
this.namePass.refreshPlayerColors(this.paletteData);
|
||
}
|
||
|
||
/** Re-upload the per-player effect texture (style + colors by smallID). */
|
||
updateEffectPalette(effectData: Float32Array): void {
|
||
const gl = this.gl;
|
||
gl.activeTexture(gl.TEXTURE0);
|
||
gl.bindTexture(gl.TEXTURE_2D, this.effectTex);
|
||
gl.texSubImage2D(
|
||
gl.TEXTURE_2D,
|
||
0,
|
||
0,
|
||
0,
|
||
getPaletteSize(),
|
||
MAX_TRAIL_COLORS * EFFECT_PALETTE_BLOCKS,
|
||
gl.RGBA,
|
||
gl.FLOAT,
|
||
effectData,
|
||
);
|
||
}
|
||
|
||
/** Register late-arriving players (updates palette + NamePass lookup maps). */
|
||
addPlayers(
|
||
players: PlayerStatic[],
|
||
paletteData: Float32Array,
|
||
patternMeta: Float32Array,
|
||
patternData: Uint8Array,
|
||
): void {
|
||
this.updatePalette(paletteData);
|
||
|
||
const gl = this.gl;
|
||
const palW = getPaletteSize();
|
||
|
||
gl.bindTexture(gl.TEXTURE_2D, this.patternMetaTex);
|
||
gl.texSubImage2D(
|
||
gl.TEXTURE_2D,
|
||
0,
|
||
0,
|
||
0,
|
||
palW,
|
||
1,
|
||
gl.RGBA,
|
||
gl.FLOAT,
|
||
patternMeta,
|
||
);
|
||
|
||
gl.bindTexture(gl.TEXTURE_2D, this.patternDataTex);
|
||
gl.texSubImage2D(
|
||
gl.TEXTURE_2D,
|
||
0,
|
||
0,
|
||
0,
|
||
1024,
|
||
palW,
|
||
gl.RED_INTEGER,
|
||
gl.UNSIGNED_BYTE,
|
||
patternData,
|
||
);
|
||
|
||
this.namePass.addPlayers(players, this.paletteData);
|
||
for (const p of players) {
|
||
if (p.team !== null) this.playerTeams.set(p.smallID, p.team);
|
||
}
|
||
// Renderer was constructed with players: [] (real list arrives via this
|
||
// method), so team mode must be re-evaluated whenever new players arrive
|
||
// — otherwise team games never enable the skin-tint branch.
|
||
this.territoryPass.setTeamMode(this.playerTeams.size > 0);
|
||
}
|
||
|
||
/**
|
||
* Anchor a player's skin sampling at world coords (x, y). The center of the
|
||
* skin image lines up with this tile. Default (0,0) anchors at world origin.
|
||
*/
|
||
setPlayerSpawn(smallID: number, x: number, y: number): void {
|
||
const off = smallID * 2;
|
||
this.skinAnchorCpu[off] = x;
|
||
this.skinAnchorCpu[off + 1] = y;
|
||
const gl = this.gl;
|
||
gl.bindTexture(gl.TEXTURE_2D, this.skinAnchorTex);
|
||
gl.texSubImage2D(
|
||
gl.TEXTURE_2D,
|
||
0,
|
||
0,
|
||
0,
|
||
getPaletteSize(),
|
||
1,
|
||
gl.RG_INTEGER,
|
||
gl.UNSIGNED_SHORT,
|
||
this.skinAnchorCpu,
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Allocate the skin atlas to exactly `urls.length` layers. The player set is
|
||
* locked at game start so this is called once with the complete URL list;
|
||
* URLs not in this set will be ignored by `setPlayerSkin`.
|
||
*
|
||
* Layers are zero-initialized (browsers do this for security regardless of
|
||
* the GL spec's "undefined" wording), so players whose images haven't
|
||
* decoded yet render with alpha=0 → falls through to base player color.
|
||
*/
|
||
initSkinAtlas(urls: readonly string[]): void {
|
||
this.skinAtlas.dispose();
|
||
this.skinAtlas = new SkinAtlasArray(this.gl, urls, () => {});
|
||
this.territoryPass.setSkinAtlas(this.skinAtlas.texture);
|
||
}
|
||
|
||
/**
|
||
* Map a player to a pre-registered skin layer. URLs not registered via
|
||
* `initSkinAtlas` are silently dropped. If the image is still decoding the
|
||
* layer renders transparent (zero-init) until decode completes.
|
||
*/
|
||
setPlayerSkin(smallID: number, url: string): void {
|
||
const layer = this.skinAtlas.getLayer(url);
|
||
if (layer < 0) return;
|
||
this.skinLayerCpu[smallID] = layer + 1;
|
||
this.uploadSkinLayerTex();
|
||
}
|
||
|
||
private uploadSkinLayerTex(): void {
|
||
const gl = this.gl;
|
||
gl.bindTexture(gl.TEXTURE_2D, this.skinLayerTex);
|
||
gl.texSubImage2D(
|
||
gl.TEXTURE_2D,
|
||
0,
|
||
0,
|
||
0,
|
||
getPaletteSize(),
|
||
1,
|
||
gl.RED_INTEGER,
|
||
gl.UNSIGNED_BYTE,
|
||
this.skinLayerCpu,
|
||
);
|
||
}
|
||
|
||
uploadRailroadState(data: Uint8Array): void {
|
||
this.railroadPass.uploadRailroadState(data);
|
||
}
|
||
|
||
updateUnits(units: Map<number, UnitState>, gameTick: number): void {
|
||
this.lastUnits = units;
|
||
this.frameTick++;
|
||
this.unitPass.updateUnits(units, this.frameTick);
|
||
this.barPass.updateBars(units, this.lastStructures, gameTick);
|
||
this.pointLightPass.updateLights(units);
|
||
this.heatManager.decayHeat();
|
||
}
|
||
|
||
updateNames(
|
||
names: Map<string, NameEntry>,
|
||
players: Map<number, PlayerState>,
|
||
snap: boolean,
|
||
statusData?: Map<number, PlayerStatusData>,
|
||
): void {
|
||
this.namePass.updateNames(names, players, snap, statusData);
|
||
|
||
// Extract local player's allies + teammates for SAM radius coloring
|
||
if (this.localPlayerID > 0) {
|
||
const localPS = players.get(this.localPlayerID);
|
||
const friendly = new Set(localPS?.allies ?? []);
|
||
const myTeam = this.playerTeams.get(this.localPlayerID);
|
||
if (myTeam !== undefined) {
|
||
for (const [sid, team] of this.playerTeams) {
|
||
if (team === myTeam && sid !== this.localPlayerID) friendly.add(sid);
|
||
}
|
||
}
|
||
this.samRadiusPass.setAllies(friendly);
|
||
this.unitPass.setAllies(friendly);
|
||
}
|
||
}
|
||
|
||
/** Re-resolve player name strings live (e.g. anonymous-names toggle). */
|
||
refreshNames(displayNames: Map<string, string>): void {
|
||
this.namePass.refreshNames(displayNames);
|
||
}
|
||
|
||
updateRelations(data: Uint8Array, size: number): void {
|
||
this.borderPass.updateRelations(data, size);
|
||
this.affiliationPalette.updateRelations(data, size);
|
||
}
|
||
|
||
updateStructures(units: Map<number, UnitState>): void {
|
||
this.lastStructures = units;
|
||
this.structurePass.updateStructures(units);
|
||
this.structureLevelPass.updateStructures(units);
|
||
this.samRadiusPass.updateStructures(units);
|
||
this.unitPass.setStructures(units);
|
||
const posts: { x: number; y: number; ownerID: number }[] = [];
|
||
const w = this.mapW;
|
||
for (const u of units.values()) {
|
||
if (u.unitType === "Defense Post" && !u.underConstruction) {
|
||
posts.push({
|
||
x: u.pos % w,
|
||
y: (u.pos - (u.pos % w)) / w,
|
||
ownerID: u.ownerID,
|
||
});
|
||
}
|
||
}
|
||
this.defenseCoveragePass.updateDefensePosts(posts);
|
||
}
|
||
|
||
applyDeadUnits(deadUnits: DeadUnitFx[]): void {
|
||
if (deadUnits.length > 0) this.fxPass.applyDeadUnits(deadUnits);
|
||
}
|
||
|
||
applyRailroadDust(tileRefs: number[]): void {
|
||
if (tileRefs.length > 0) this.fxPass.applyRailroadDust(tileRefs);
|
||
}
|
||
|
||
/**
|
||
* Update terrain texels for tiles whose terrain byte changed (e.g. water
|
||
* nukes converting land → water). `terrainBytes[i]` is the new byte for
|
||
* `refs[i]`. Forwards to both TerrainPass (RGBA color) and RailroadPass
|
||
* (R8UI water-detection for bridges).
|
||
*/
|
||
applyTerrainDelta(refs: readonly number[], terrainBytes: Uint8Array): void {
|
||
if (refs.length === 0) return;
|
||
this.terrainPass.applyTerrainDelta(refs, terrainBytes);
|
||
this.railroadPass.applyTerrainDelta(refs, terrainBytes);
|
||
}
|
||
|
||
/**
|
||
* Rebuild the terrain texture from the current `settings.terrain` colors.
|
||
* Terrain is baked into a GPU texture rather than read per-frame, so a
|
||
* settings change needs this explicit rebuild.
|
||
*/
|
||
rebuildTerrain(): void {
|
||
this.terrainPass.setTerrainColors({
|
||
oceanColor: hexToRgb(this.settings.terrain.oceanColor) ?? undefined,
|
||
sandColor: hexToRgb(this.settings.terrain.sandColor) ?? undefined,
|
||
plainsColor: hexToRgb(this.settings.terrain.plainsColor) ?? undefined,
|
||
highlandColor: hexToRgb(this.settings.terrain.highlandColor) ?? undefined,
|
||
mountainColor: hexToRgb(this.settings.terrain.mountainColor) ?? undefined,
|
||
});
|
||
}
|
||
|
||
applyConquestEvents(events: ConquestFx[]): void {
|
||
if (events.length > 0) {
|
||
this.fxPass.applyConquestEvents(events);
|
||
this.worldTextPass.applyConquestEvents(events);
|
||
}
|
||
}
|
||
|
||
setAttackTroopLabels(
|
||
labels: import("./passes/WorldTextPass").AttackTroopLabel[],
|
||
): void {
|
||
this.worldTextPass.setAttackTroopLabels(labels);
|
||
}
|
||
|
||
applyBonusEvents(events: BonusEvent[]): void {
|
||
if (events.length === 0) return;
|
||
// In live game, filter to local player only. In replay (localPlayerID=0), show all.
|
||
const filtered =
|
||
this.localPlayerID > 0
|
||
? events.filter((e) => e.smallID === this.localPlayerID)
|
||
: events;
|
||
if (filtered.length > 0) this.worldTextPass.applyBonusEvents(filtered);
|
||
}
|
||
|
||
updateAttackRings(rings: AttackRingInput[]): void {
|
||
this.fxPass.updateAttackRings(rings);
|
||
}
|
||
|
||
updateGhostPreview(data: GhostPreviewData | null): void {
|
||
this.structurePass.updateGhostPreview(data);
|
||
this.railroadPass.updateGhostPreview(data);
|
||
this.rangeCirclePass.updateGhostPreview(data);
|
||
this.crosshairPass.updateGhostPreview(data);
|
||
this.worldTextPass.setGhostCostLabel(
|
||
data && data.showCost && data.cost > 0
|
||
? {
|
||
tileX: data.tileX,
|
||
tileY: data.tileY,
|
||
cost: data.cost,
|
||
canAfford: data.canAfford,
|
||
canPlace: data.canBuild || data.canUpgrade,
|
||
}
|
||
: null,
|
||
);
|
||
this.samGhostVisible =
|
||
data !== null && SAM_RADIUS_GHOST_TYPES.has(data.ghostType);
|
||
this.samRadiusPass.setVisible(
|
||
this.samGhostVisible || this.samHighlightVisible,
|
||
);
|
||
}
|
||
|
||
updateNukeTrajectory(data: NukeTrajectoryData | null): void {
|
||
this.nukeTrajectoryPass.update(data);
|
||
}
|
||
|
||
updateNukeTelegraphs(data: NukeTelegraphData[]): void {
|
||
this.nukeTelegraphPass.update(data);
|
||
}
|
||
|
||
updateSpawnOverlay(inSpawnPhase: boolean, centers: SpawnCenter[]): void {
|
||
this.inSpawnPhase = inSpawnPhase;
|
||
this.spawnOverlayPass.update(inSpawnPhase, centers);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Queries
|
||
// ---------------------------------------------------------------------------
|
||
|
||
setHighlightOwner(ownerID: number): void {
|
||
this.borderPass.setHighlightOwner(ownerID);
|
||
this.territoryPass.setHighlightOwner(ownerID);
|
||
this.namePass.setHighlightOwner(ownerID);
|
||
this.structurePass.setHighlightOwner(ownerID);
|
||
}
|
||
setMouseWorldPos(x: number, y: number): void {
|
||
this.namePass.setMouseWorldPos(x, y);
|
||
}
|
||
setHighlightStructureTypes(unitTypes: string[] | null): void {
|
||
this.structurePass.setHighlightTypes(unitTypes);
|
||
this.structureLevelPass.setHighlightTypes(unitTypes);
|
||
this.samHighlightVisible =
|
||
unitTypes !== null &&
|
||
unitTypes.some((t) => SAM_RADIUS_HIGHLIGHT_TYPES.has(t));
|
||
this.samRadiusPass.setVisible(
|
||
this.samGhostVisible || this.samHighlightVisible,
|
||
);
|
||
}
|
||
|
||
setLocalPlayerID(id: number): void {
|
||
if (id === this.localPlayerID) return;
|
||
this.localPlayerID = id;
|
||
this.samRadiusPass.setLocalPlayer(id);
|
||
this.structurePass.setLocalPlayer(id);
|
||
this.affiliationPalette.setLocalPlayer(id);
|
||
this.unitPass.setLocalPlayer(id);
|
||
this.railroadPass.setLocalPlayer(id);
|
||
}
|
||
|
||
setLocalRailColor(r: number, g: number, b: number): void {
|
||
this.railroadPass.setLocalRailColor(r, g, b);
|
||
}
|
||
|
||
setSAMAllianceClusters(clusters: Map<number, number>): void {
|
||
this.samRadiusPass.setAllianceClusters(clusters);
|
||
}
|
||
|
||
setAltView(active: boolean): void {
|
||
this.altView = active;
|
||
this.territoryPass.setAltView(active);
|
||
this.borderStampPass.setAltView(active);
|
||
this.unitPass.setAltView(active);
|
||
this.structurePass.setAltView(active);
|
||
this.trailPass.setAltView(active);
|
||
}
|
||
|
||
setShowPatterns(active: boolean): void {
|
||
this.territoryPass.setShowPatterns(active);
|
||
}
|
||
|
||
setGridView(active: boolean): void {
|
||
this.gridView = active;
|
||
try {
|
||
window.localStorage.setItem(GRID_VIEW_KEY, active ? "true" : "false");
|
||
} catch {
|
||
// Ignore if we are unable to use localstorage.
|
||
}
|
||
}
|
||
|
||
getSettings(): RenderSettings {
|
||
return this.settings;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Selection box (warship selection)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
setSelectedUnits(unitIds: readonly number[]): void {
|
||
// Copy in (callers may mutate their array).
|
||
this.selectedUnitIds.length = 0;
|
||
for (let i = 0; i < unitIds.length; i++) {
|
||
this.selectedUnitIds.push(unitIds[i]);
|
||
}
|
||
if (this.selectedUnitIds.length === 0) {
|
||
this.selectionBoxPass.hide();
|
||
}
|
||
// Position + color are rebuilt each frame in updateSelectionBox() from
|
||
// lastUnits — dead units get dropped automatically.
|
||
}
|
||
|
||
private updateSelectionBox(): void {
|
||
if (this.selectedUnitIds.length === 0) return;
|
||
|
||
// Build the entries for this frame and prune dead unit IDs in place.
|
||
const entries = this.selectionBoxEntries;
|
||
entries.length = 0;
|
||
let writeIdx = 0;
|
||
for (let i = 0; i < this.selectedUnitIds.length; i++) {
|
||
const id = this.selectedUnitIds[i];
|
||
const unit = this.lastUnits.get(id);
|
||
if (!unit || !unit.isActive) continue; // dead — drop
|
||
this.selectedUnitIds[writeIdx++] = id;
|
||
|
||
const centerX = unit.pos % this.mapW;
|
||
const centerY = Math.floor(unit.pos / this.mapW);
|
||
// Lighten the owner's territory color by ~20% (mix toward white).
|
||
const off = unit.ownerID * 4;
|
||
const r = Math.min(
|
||
1,
|
||
this.paletteData[off] + (1 - this.paletteData[off]) * 0.3,
|
||
);
|
||
const g = Math.min(
|
||
1,
|
||
this.paletteData[off + 1] + (1 - this.paletteData[off + 1]) * 0.3,
|
||
);
|
||
const b = Math.min(
|
||
1,
|
||
this.paletteData[off + 2] + (1 - this.paletteData[off + 2]) * 0.3,
|
||
);
|
||
entries.push({ centerX, centerY, r, g, b });
|
||
}
|
||
this.selectedUnitIds.length = writeIdx;
|
||
|
||
this.selectionBoxPass.setSelections(entries);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Move indicator (warship move-target chevrons)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
showMoveIndicator(tileX: number, tileY: number, ownerID: number): void {
|
||
const off = ownerID * 4;
|
||
const r = Math.min(
|
||
1,
|
||
this.paletteData[off] + (1 - this.paletteData[off]) * 0.3,
|
||
);
|
||
const g = Math.min(
|
||
1,
|
||
this.paletteData[off + 1] + (1 - this.paletteData[off + 1]) * 0.3,
|
||
);
|
||
const b = Math.min(
|
||
1,
|
||
this.paletteData[off + 2] + (1 - this.paletteData[off + 2]) * 0.3,
|
||
);
|
||
this.moveIndicatorPass.show(tileX, tileY, r, g, b);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Render — normalized draw order
|
||
// ---------------------------------------------------------------------------
|
||
|
||
draw(): void {
|
||
this.uploadTextures();
|
||
this.computeTextures();
|
||
this.renderFrame();
|
||
}
|
||
|
||
private uploadTextures(): void {
|
||
if (this.altView) this.affiliationPalette.flush();
|
||
if (this.inSpawnPhase) {
|
||
this.territoryPass.flushAllDripBuckets();
|
||
} else {
|
||
this.territoryPass.drainDripBucket();
|
||
}
|
||
// Full uploads need a full border recompute; scatter uploads already
|
||
// pushed per-tile border patches via the wired `borderPatchConsumer`.
|
||
if (this.territoryPass.flushTileTexture() === "full") {
|
||
this.borderPass.markGlobalDirty();
|
||
this.defenseCoveragePass.markDirty();
|
||
}
|
||
// Heat decay only runs while fallout is in play — (re)activate whenever a
|
||
// fallout bit flipped in the tile state that just reached the GPU.
|
||
if (this.territoryPass.consumeFalloutTouched()) {
|
||
this.heatManager.activate();
|
||
}
|
||
this.trailPass.flushTexture();
|
||
this.heatManager.updateHeat();
|
||
}
|
||
|
||
private computeTextures(): void {
|
||
if (this.settings.passEnabled.borderCompute) this.borderPass.draw();
|
||
// Re-stamp defense coverage if posts/territory changed (dirty-gated).
|
||
// Leaves the default framebuffer bound; renderFrame resets the viewport.
|
||
this.defenseCoveragePass.draw();
|
||
}
|
||
|
||
private renderFrame(): void {
|
||
const cam = this.camera.getMatrix();
|
||
const zoom = this.camera.zoom;
|
||
const cw = this.canvas.width;
|
||
const ch = this.canvas.height;
|
||
const compositingActive = this.isLightCompositingActive();
|
||
|
||
if (compositingActive) {
|
||
this.resizeSceneTargetIfNeeded(cw, ch);
|
||
const sceneTex = toTarget(this.gl, this.sceneTarget, () =>
|
||
this.drawBaseLayer(cam),
|
||
);
|
||
const lightTex = this.lightmapPass.draw(cam, cw, ch, this.frameTick);
|
||
toScreen(this.gl, cw, ch, () =>
|
||
this.nightCompositePass.draw(sceneTex, lightTex),
|
||
);
|
||
} else {
|
||
toScreen(this.gl, cw, ch, () => this.drawBaseLayer(cam));
|
||
}
|
||
|
||
this.renderOverlays(cam, zoom);
|
||
}
|
||
|
||
private isLightCompositingActive(): boolean {
|
||
return this.settings.lighting.enabled;
|
||
}
|
||
|
||
private resizeSceneTargetIfNeeded(cw: number, ch: number): void {
|
||
if (this.sceneTarget.w === cw && this.sceneTarget.h === ch) return;
|
||
this.sceneTarget.w = cw;
|
||
this.sceneTarget.h = ch;
|
||
const gl = this.gl;
|
||
gl.bindTexture(gl.TEXTURE_2D, this.sceneTarget.tex);
|
||
gl.texImage2D(
|
||
gl.TEXTURE_2D,
|
||
0,
|
||
gl.RGBA8,
|
||
cw,
|
||
ch,
|
||
0,
|
||
gl.RGBA,
|
||
gl.UNSIGNED_BYTE,
|
||
null,
|
||
);
|
||
}
|
||
|
||
private drawBaseLayer(cam: Float32Array): void {
|
||
const gl = this.gl;
|
||
const pe = this.settings.passEnabled;
|
||
gl.clearColor(60 / 255, 60 / 255, 60 / 255, 1.0);
|
||
gl.clear(gl.COLOR_BUFFER_BIT);
|
||
gl.disable(gl.BLEND);
|
||
if (pe.terrain) this.terrainPass.draw(cam);
|
||
gl.enable(gl.BLEND);
|
||
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
|
||
if (pe.territory) this.territoryPass.draw(cam);
|
||
}
|
||
|
||
private renderOverlays(cam: Float32Array, zoom: number): void {
|
||
const gl = this.gl;
|
||
const pe = this.settings.passEnabled;
|
||
|
||
gl.enable(gl.BLEND);
|
||
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
|
||
|
||
this.spawnOverlayPass.draw(cam);
|
||
if (pe.borderStamp) this.borderStampPass.draw(cam);
|
||
if (pe.railroad) this.railroadPass.draw(cam, zoom);
|
||
if (pe.unit) this.unitPass.drawGround(cam);
|
||
if (pe.falloutBloom) this.bloomPass.draw(cam, this.frameTick);
|
||
this.samRadiusPass.draw(cam);
|
||
this.rangeCirclePass.draw(cam);
|
||
this.nukeTrajectoryPass.draw(cam);
|
||
this.crosshairPass.draw(cam);
|
||
if (pe.structure) this.structurePass.draw(cam, zoom);
|
||
if (pe.structure) this.structureLevelPass.draw(cam, zoom);
|
||
if (pe.bar) this.barPass.draw(cam);
|
||
this.updateSelectionBox();
|
||
this.selectionBoxPass.draw(cam, this.frameTick);
|
||
this.moveIndicatorPass.draw(cam, zoom);
|
||
this.nukeTelegraphPass.draw(cam);
|
||
if (pe.trail) this.trailPass.draw(cam);
|
||
if (pe.unit) this.unitPass.drawMissiles(cam);
|
||
|
||
if (pe.fx) {
|
||
this.fxPass.tick();
|
||
this.fxPass.draw(cam, zoom);
|
||
}
|
||
|
||
// Grid shows on either trigger; names hide only under alt-view (space
|
||
// hold), not under the persistent M-key gridView toggle.
|
||
if (this.gridView || this.altView) this.coordinateGridPass.draw(cam, zoom);
|
||
if (pe.name && !this.altView)
|
||
this.namePass.draw(cam, this.nightCompositePass.getAmbient());
|
||
|
||
// World text (attack-troop labels, popups, ghost cost) draws on top of
|
||
// player names so attack callouts aren't hidden behind a centered name.
|
||
this.worldTextPass.tick(zoom);
|
||
this.worldTextPass.draw(cam, zoom);
|
||
|
||
gl.disable(gl.BLEND);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Lifecycle
|
||
// ---------------------------------------------------------------------------
|
||
|
||
dispose(): void {
|
||
this.stopLoop();
|
||
this.terrainPass.dispose();
|
||
this.territoryPass.dispose();
|
||
this.trailPass.dispose();
|
||
this.borderStampPass.dispose();
|
||
this.borderPass.dispose();
|
||
this.defenseCoveragePass.dispose();
|
||
this.bloomPass.dispose();
|
||
this.pointLightPass.dispose();
|
||
this.falloutLightPass.dispose();
|
||
this.lightmapPass.dispose();
|
||
this.nightCompositePass.dispose();
|
||
this.heatManager.dispose();
|
||
this.affiliationPalette.dispose();
|
||
this.coordinateGridPass.dispose();
|
||
this.spawnOverlayPass.dispose();
|
||
this.railroadPass.dispose();
|
||
this.rangeCirclePass.dispose();
|
||
this.samRadiusPass.dispose();
|
||
this.crosshairPass.dispose();
|
||
this.structurePass.dispose();
|
||
this.structureLevelPass.dispose();
|
||
this.unitPass.dispose();
|
||
this.namePass.dispose();
|
||
this.fxPass.dispose();
|
||
this.worldTextPass.dispose();
|
||
this.selectionBoxPass.dispose();
|
||
this.moveIndicatorPass.dispose();
|
||
this.nukeTrajectoryPass.dispose();
|
||
this.nukeTelegraphPass.dispose();
|
||
this.barPass.dispose();
|
||
disposeGPUResources(this.gl, this.res);
|
||
this.gl.deleteTexture(this.paletteTex);
|
||
this.gl.deleteTexture(this.effectTex);
|
||
this.gl.deleteTexture(this.patternMetaTex);
|
||
this.gl.deleteTexture(this.patternDataTex);
|
||
this.gl.deleteTexture(this.skinLayerTex);
|
||
this.gl.deleteTexture(this.skinAnchorTex);
|
||
this.skinAtlas.dispose();
|
||
this.gl.deleteFramebuffer(this.sceneTarget.fbo);
|
||
this.gl.deleteTexture(this.sceneTarget.tex);
|
||
this.lastUnits = new Map();
|
||
this.lastStructures = new Map();
|
||
// Deleting GL resources isn't enough — the context itself counts against
|
||
// the browser's WebGL context limit until it's GC'd, which is unreliable
|
||
// on mobile. Explicitly drop it so repeated game starts don't overflow.
|
||
this.gl.getExtension("WEBGL_lose_context")?.loseContext();
|
||
}
|
||
}
|