Files
OpenFrontIO/src/client/render/gl/passes/name-pass/index.ts
T
Evan PelleandClaude Opus 4.8 e2aa55ac4f Switch classic name font to a drop-in Arial (Arimo) MSDF atlas
Replace the runtime-generated bitmap Arial with a real MSDF atlas in the exact overpass format (resources/atlases/arial-atlas.{png,json}), generated from Arimo (Apache-licensed, Arial-metric clone). NamePass loads both MSDF atlases and switches per draw using each atlas's own metrics (em size, baseline, atlas dims, distance range); the flag/status/debug passes follow via setFont. Crisp at any zoom — no blur, wobble, or thinning hacks.

Removes the runtime bitmap generator, the uClassic shader branch, and the supersample/erosion code.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 04:36:30 +00:00

815 lines
27 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* NamePass — GPU-rendered player names + troop counts using MSDF text.
*
* All text layout, interpolation, and sizing runs on the GPU via instanced
* rendering. CPU cost per frame is effectively zero: one uniform update and
* one instanced draw call. Data changes (position/size targets, troop counts)
* are pushed as tiny texture sub-updates.
*
* Submodules:
* - text-program — MSDF text shader (names + troop counts)
* - icon-program — instanced flag + emoji icons
* - debug-program — wireframe bounding boxes for layout debugging
* - atlas-data — font/atlas parsing + glyph lookup tables
* - text-layout — pure CPU text shaping (cursor positions)
* - data-textures — GL data texture factories
* - types — shared interfaces + constants
*/
import type { Config } from "../../../../../core/configuration/Config";
import type {
NameEntry,
PlayerState,
PlayerStatic,
PlayerStatusData,
RendererConfig,
} from "../../../types";
import { PlayerTypeEnum } from "../../../types";
import type { RenderSettings } from "../../RenderSettings";
import { createFullscreenQuad } from "../../utils/GlUtils";
import { renderTroops } from "../../../../Utils";
import type { GlyphTables } from "./AtlasData";
import {
buildEmojiLookup,
buildGlyphTables,
buildKernTable,
parseArialAtlasData,
parseAtlasData,
} from "./AtlasData";
import {
buildCursorTex,
buildGlyphMetricsTex,
buildPlayerDataTex,
buildStringTex,
} from "./DataTextures";
import { DebugProgram } from "./DebugProgram";
import { FlagAtlasArray } from "./FlagAtlasArray";
import { IconProgram } from "./IconProgram";
import { StatusIconProgram } from "./StatusIconProgram";
import { layoutString } from "./TextLayout";
import { TextProgram } from "./TextProgram";
import type { PlayerSlot } from "./Types";
import { LINES_PER_PLAYER, MAX_CHARS } from "./Types";
// Flag quad aspect ratio — must match FLAG_CELL_W / FLAG_CELL_H in FlagAtlasArray.ts.
const FLAG_ASPECT = 128 / 85;
export class NamePass {
private gl: WebGL2RenderingContext;
private settings: RenderSettings;
// Shared geometry
private vao: WebGLVertexArrayObject;
// Shared data textures
private glyphMetricsTex: WebGLTexture;
private cursorTex: WebGLTexture;
private stringTex: WebGLTexture;
private playerDataTex: WebGLTexture;
// Sub-programs
private textProgram: TextProgram;
private iconProgram: IconProgram;
private statusIconProgram: StatusIconProgram;
private debugProgram: DebugProgram;
// Atlas + glyph data. `glyph`/`kernTable` point at the active font; the MSDF
// and Arial tables are kept so layout can be recomputed when the font toggles.
private glyph: GlyphTables;
private kernTable: Int8Array;
private fontSize: number;
private fontBase: number;
private msdfGlyph: GlyphTables;
private msdfKern: Int8Array;
private msdfFontSize: number;
private msdfBase: number;
private arialGlyph: GlyphTables;
private arialKern: Int8Array;
private arialMetricsTex: WebGLTexture;
private arialFontSize: number;
private arialBase: number;
// Which font the cursor buffers are currently laid out for.
private classicFont = false;
// Player management
private playerByID: Map<string, PlayerStatic>;
private smallIDToPlayerID: Map<number, string>;
private slots: Map<string, PlayerSlot> = new Map();
private maxPlayers: number;
private playerColors: Map<string, [number, number, number]> = new Map();
private flagAtlas: FlagAtlasArray;
private emojiCharToIndex: Map<string, number>;
/** Slots waiting on a flag URL → set of slots that want that URL's layer. */
private slotsWaitingForFlag = new Map<string, Set<PlayerSlot>>();
// CPU-side mirrors — batched upload in draw()
private cpuPlayerData: Float32Array;
private cpuStringData: Uint8Array;
private cpuCursorData: Float32Array;
private playerDataDirty = false;
private stringDataDirty = false;
private cursorDataDirty = false;
// Reusable buffers for text layout
private stringRow: Uint8Array;
private cursorRow: Float32Array;
// Reusable per-tick lookup maps (avoid allocation + GC)
private alivePlayerIDs = new Set<string>();
private troopsByPlayerID = new Map<string, number>();
// Hovered player's small ID (0 = no highlight, matches TerritoryPass).
private highlightOwnerID = 0;
// Cursor in world coords — fades names under it (far off-map = no fade).
private mouseWorldX = -1e9;
private mouseWorldY = -1e9;
private playerStateByID = new Map<string, PlayerState>();
constructor(
gl: WebGL2RenderingContext,
header: RendererConfig,
paletteData: Float32Array,
settings: RenderSettings,
config: Config,
) {
this.gl = gl;
this.settings = settings;
this.maxPlayers = header.maxPlayers ?? header.players.length;
// Parse atlas + build CPU lookup tables
const atlas = parseAtlasData();
this.fontSize = atlas.fontSize;
this.fontBase = atlas.base;
this.glyph = buildGlyphTables(atlas.chars);
this.kernTable = buildKernTable(atlas.kernings);
this.msdfGlyph = this.glyph;
this.msdfKern = this.kernTable;
this.msdfFontSize = atlas.fontSize;
this.msdfBase = atlas.base;
this.emojiCharToIndex = buildEmojiLookup();
// Runtime flag-image manager (TEXTURE_2D_ARRAY of player flags, fetched
// on demand from URLs). When a flag finishes loading, flip every slot
// that wanted that URL from layer -1 to the resolved layer.
this.flagAtlas = new FlagAtlasArray(gl, (url, layer) => {
const waiting = this.slotsWaitingForFlag.get(url);
if (!waiting) return;
this.slotsWaitingForFlag.delete(url);
for (const slot of waiting) {
if (slot.flagUrl !== url) continue; // player changed flag while we waited
slot.flagLayerIdx = layer;
this.writePlayerDataRow(slot);
}
});
// Build player lookups and extract territory colors from palette
this.playerByID = new Map();
this.smallIDToPlayerID = new Map();
for (const p of header.players) {
this.playerByID.set(p.id, p);
this.smallIDToPlayerID.set(p.smallID, p.id);
const off = p.smallID * 4;
this.playerColors.set(p.id, [
paletteData[off],
paletteData[off + 1],
paletteData[off + 2],
]);
}
// CPU-side texture mirrors + reusable layout buffers
const textRows = this.maxPlayers * LINES_PER_PLAYER;
this.cpuPlayerData = new Float32Array(8 * this.maxPlayers * 4);
this.cpuStringData = new Uint8Array(MAX_CHARS * textRows);
this.cpuCursorData = new Float32Array(MAX_CHARS * textRows);
this.stringRow = new Uint8Array(MAX_CHARS);
this.cursorRow = new Float32Array(MAX_CHARS);
// Shared VAO (unit [0,1]² quad)
this.vao = createFullscreenQuad(gl);
// Data textures
this.glyphMetricsTex = buildGlyphMetricsTex(gl, atlas);
this.cursorTex = buildCursorTex(gl, this.maxPlayers);
this.stringTex = buildStringTex(gl, this.maxPlayers);
this.playerDataTex = buildPlayerDataTex(gl, this.maxPlayers);
// Sub-programs
this.textProgram = new TextProgram(gl, atlas, {
glyphMetrics: this.glyphMetricsTex,
cursor: this.cursorTex,
strings: this.stringTex,
playerData: this.playerDataTex,
});
this.iconProgram = new IconProgram(
gl,
atlas,
this.playerDataTex,
this.flagAtlas,
this.maxPlayers,
);
this.statusIconProgram = new StatusIconProgram(
gl,
atlas,
this.playerDataTex,
this.maxPlayers,
config.allianceExtensionPromptOffset(),
);
this.debugProgram = new DebugProgram(
gl,
atlas,
this.playerDataTex,
this.maxPlayers,
);
// Classic name font: Arial (Arimo) MSDF atlas — same format as overpass,
// its own metrics. Selected via name.classicFont.
const arialAtlas = parseArialAtlasData();
this.arialGlyph = buildGlyphTables(arialAtlas.chars);
this.arialKern = buildKernTable(arialAtlas.kernings);
this.arialMetricsTex = buildGlyphMetricsTex(gl, arialAtlas);
this.arialFontSize = arialAtlas.fontSize;
this.arialBase = arialAtlas.base;
this.textProgram.setArialFont(this.arialMetricsTex, arialAtlas);
// Apply the initially-selected font to the glyph tables + sibling passes.
this.classicFont = false;
this.applyFont(settings.name.classicFont);
}
/**
* Switch the active font: point the layout tables + line metrics at it and
* push its metrics to the icon/status/debug passes so they stay aligned.
* Each font carries its own em size and baseline.
*/
private applyFont(classic: boolean): void {
this.classicFont = classic;
this.glyph = classic ? this.arialGlyph : this.msdfGlyph;
this.kernTable = classic ? this.arialKern : this.msdfKern;
this.fontSize = classic ? this.arialFontSize : this.msdfFontSize;
this.fontBase = classic ? this.arialBase : this.msdfBase;
this.iconProgram.setFont(this.fontSize, this.fontBase);
this.statusIconProgram.setFont(this.fontSize, this.fontBase);
this.debugProgram.setFont(this.fontSize, this.fontBase);
}
// -------------------------------------------------------------------------
// Late player registration (bots arrive on tick 1)
// -------------------------------------------------------------------------
/** Register players that arrived after construction (palette already updated). */
addPlayers(players: PlayerStatic[], paletteData: Float32Array): void {
for (const p of players) {
if (this.playerByID.has(p.id)) continue;
this.playerByID.set(p.id, p);
this.smallIDToPlayerID.set(p.smallID, p.id);
const off = p.smallID * 4;
this.playerColors.set(p.id, [
paletteData[off],
paletteData[off + 1],
paletteData[off + 2],
]);
}
}
/**
* Re-read every known player's territory color from the palette and rewrite
* the live slot rows. Called after a mid-game palette refresh (e.g. toggling
* colorblind mode) so name fills/outlines pick up the re-themed colors.
*/
refreshPlayerColors(paletteData: Float32Array): void {
for (const [id, p] of this.playerByID) {
const off = p.smallID * 4;
this.playerColors.set(id, [
paletteData[off],
paletteData[off + 1],
paletteData[off + 2],
]);
}
for (const slot of this.slots.values()) {
this.writePlayerDataRow(slot);
}
}
/**
* Request the texture layer for a slot's flag (called once at slot creation).
* If the image is already loaded the layer index is set immediately; otherwise
* the slot joins a wait list and is updated when the image arrives.
*/
private resolveSlotFlag(slot: PlayerSlot): void {
const url = slot.flagUrl;
if (!url) return;
this.flagAtlas.request(url);
const layer = this.flagAtlas.getLayer(url);
if (layer >= 0) {
slot.flagLayerIdx = layer;
return;
}
let waiting = this.slotsWaitingForFlag.get(url);
if (!waiting) {
waiting = new Set();
this.slotsWaitingForFlag.set(url, waiting);
}
waiting.add(slot);
}
// -------------------------------------------------------------------------
// Name updates — called by GPURenderer
// -------------------------------------------------------------------------
updateNames(
names: Map<string, NameEntry>,
players: Map<number, PlayerState>,
snap: boolean,
statusData?: Map<number, PlayerStatusData>,
): void {
const now = performance.now() / 1000;
// Build alive set and emoji lookup from smallID → playerID
const alivePlayerIDs = this.alivePlayerIDs;
alivePlayerIDs.clear();
const troopsByPlayerID = this.troopsByPlayerID;
troopsByPlayerID.clear();
const playerStateByID = this.playerStateByID;
playerStateByID.clear();
for (const [, ps] of players) {
const pid = this.smallIDToPlayerID.get(ps.smallID);
if (!pid) continue;
if (ps.isAlive) alivePlayerIDs.add(pid);
troopsByPlayerID.set(pid, ps.troops ?? 0);
playerStateByID.set(pid, ps);
}
// Assign slot indices to players (stable ordering by header index)
let nextSlotIndex = 0;
for (const p of this.playerByID.values()) {
if (!this.slots.has(p.id)) {
const slot: PlayerSlot = {
index: nextSlotIndex++,
playerID: p.id,
static: p,
srcX: 0,
srcY: 0,
srcScale: 0,
tgtX: 0,
tgtY: 0,
tgtScale: 0,
startTime: now,
alive: false,
nameLen: 0,
troopLen: 0,
lastTroopStr: "",
lastTroopBucket: -1,
flagUrl: p.flag,
flagLayerIdx: -1,
emojiAtlasIdx: -1,
nameHalfWidth: 0,
crown: false,
traitor: false,
disconnected: false,
alliance: false,
allianceReq: false,
target: false,
embargo: false,
nukeActive: false,
nukeTargetsMe: false,
traitorRemainingTicks: 0,
allianceFraction: 0,
allianceRemainingTicks: 0,
};
this.slots.set(p.id, slot);
this.resolveSlotFlag(slot);
} else {
nextSlotIndex = Math.max(
nextSlotIndex,
this.slots.get(p.id)!.index + 1,
);
}
}
for (const [playerID, entry] of names) {
const slot = this.slots.get(playerID);
if (!slot) continue;
const alive = alivePlayerIDs.has(playerID);
// Skip dead players already marked dead — no work needed
if (!alive && !slot.alive) continue;
// Newly dead: mark and write once, then skip expensive work
if (!alive && slot.alive) {
slot.alive = false;
this.writePlayerDataRow(slot);
continue;
}
// Track whether anything changed that requires a GPU write
let dirty = !slot.alive; // first time alive → must write
slot.alive = alive;
// Write name string (only on first encounter)
if (slot.nameLen === 0) {
const name = slot.static.displayName;
slot.nameLen = Math.min(name.length, MAX_CHARS);
slot.nameHalfWidth = this.uploadStringRow(
slot.index * LINES_PER_PLAYER,
name,
);
dirty = true;
}
// Write troop count string (refreshed per slot every 500ms, staggered by
// slot index so updates spread across the window instead of bursting).
const troopBucket = Math.floor((now + (slot.index % 5) * 0.1) / 0.5);
if (snap || slot.troopLen === 0 || troopBucket !== slot.lastTroopBucket) {
slot.lastTroopBucket = troopBucket;
const troops = troopsByPlayerID.get(playerID) ?? 0;
const troopStr = renderTroops(troops);
if (troopStr !== slot.lastTroopStr) {
slot.troopLen = Math.min(troopStr.length, MAX_CHARS);
slot.lastTroopStr = troopStr;
this.uploadStringRow(slot.index * LINES_PER_PLAYER + 1, troopStr);
dirty = true;
}
}
// Check if target position changed — only then recompute lerp source
if (
entry.x !== slot.tgtX ||
entry.y !== slot.tgtY ||
entry.size !== slot.tgtScale
) {
if (!snap) {
const elapsed = now - slot.startTime;
const t = Math.min(
1 - Math.exp(-this.settings.name.lerpSpeed * elapsed),
1,
);
slot.srcX = slot.srcX + (slot.tgtX - slot.srcX) * t;
slot.srcY = slot.srcY + (slot.tgtY - slot.srcY) * t;
slot.srcScale = slot.srcScale + (slot.tgtScale - slot.srcScale) * t;
} else {
slot.srcX = entry.x;
slot.srcY = entry.y;
slot.srcScale = entry.size;
}
slot.tgtX = entry.x;
slot.tgtY = entry.y;
slot.tgtScale = entry.size;
slot.startTime = now;
dirty = true;
}
// Resolve active broadcast emoji for this player
let newEmoji = -1;
const ps = playerStateByID.get(playerID);
if (ps?.outgoingEmojis && ps.outgoingEmojis.length > 0) {
for (const e of ps.outgoingEmojis) {
if (e.recipientID === "AllPlayers") {
const idx = this.emojiCharToIndex.get(e.message);
if (idx !== undefined) {
newEmoji = idx;
break;
}
}
}
}
if (newEmoji !== slot.emojiAtlasIdx) {
slot.emojiAtlasIdx = newEmoji;
dirty = true;
}
// Resolve status data from per-player map — diff each field
const sd = statusData?.get(slot.static.smallID);
const crown = sd?.crown ?? false;
const traitor = sd?.traitor ?? false;
const disconnected = sd?.disconnected ?? false;
const alliance = sd?.alliance ?? false;
const allianceReq = sd?.allianceReq ?? false;
const target = sd?.target ?? false;
const embargo = sd?.embargo ?? false;
const nukeActive = sd?.nukeActive ?? false;
const nukeTargetsMe = sd?.nukeTargetsMe ?? false;
const traitorRemainingTicks = sd?.traitorRemainingTicks ?? 0;
const allianceFraction = sd?.allianceFraction ?? 0;
const allianceRemainingTicks = sd?.allianceRemainingTicks ?? 0;
if (
crown !== slot.crown ||
traitor !== slot.traitor ||
disconnected !== slot.disconnected ||
alliance !== slot.alliance ||
allianceReq !== slot.allianceReq ||
target !== slot.target ||
embargo !== slot.embargo ||
nukeActive !== slot.nukeActive ||
nukeTargetsMe !== slot.nukeTargetsMe ||
traitorRemainingTicks !== slot.traitorRemainingTicks ||
allianceFraction !== slot.allianceFraction ||
allianceRemainingTicks !== slot.allianceRemainingTicks
) {
slot.crown = crown;
slot.traitor = traitor;
slot.disconnected = disconnected;
slot.alliance = alliance;
slot.allianceReq = allianceReq;
slot.target = target;
slot.embargo = embargo;
slot.nukeActive = nukeActive;
slot.nukeTargetsMe = nukeTargetsMe;
slot.traitorRemainingTicks = traitorRemainingTicks;
slot.allianceFraction = allianceFraction;
slot.allianceRemainingTicks = allianceRemainingTicks;
dirty = true;
}
if (dirty) this.writePlayerDataRow(slot);
}
// Update alive/dead status for players not in the names map
for (const [pid, slot] of this.slots) {
if (!names.has(pid) && slot.alive) {
slot.alive = false;
this.writePlayerDataRow(slot);
}
}
}
// -------------------------------------------------------------------------
// Texture sub-update helpers
// -------------------------------------------------------------------------
/** Lay out a string into CPU buffers (flushed to GPU in draw). Returns halfWidth. */
private uploadStringRow(row: number, text: string): number {
const halfWidth = layoutString(
text,
this.glyph,
this.kernTable,
this.stringRow,
this.cursorRow,
);
const off = row * MAX_CHARS;
this.cpuStringData.set(this.stringRow, off);
this.cpuCursorData.set(this.cursorRow, off);
this.stringDataDirty = true;
this.cursorDataDirty = true;
return halfWidth;
}
/** Pack player data into the CPU buffer (flushed to GPU in draw). */
private writePlayerDataRow(slot: PlayerSlot): void {
const d = this.cpuPlayerData;
const off = slot.index * 32; // 8 columns × 4 floats per RGBA texel
// Column 0: srcX, srcY, srcScale, startTime
d[off + 0] = slot.srcX;
d[off + 1] = slot.srcY;
d[off + 2] = slot.srcScale;
d[off + 3] = slot.startTime;
// Column 1: tgtX, tgtY, tgtScale, alive
d[off + 4] = slot.tgtX;
d[off + 5] = slot.tgtY;
d[off + 6] = slot.tgtScale;
d[off + 7] = slot.alive ? 1.0 : 0.0;
// Column 2: player territory color (r, g, b) + alpha
const color = this.playerColors.get(slot.playerID) ?? [0, 0, 0];
d[off + 8] = color[0];
d[off + 9] = color[1];
d[off + 10] = color[2];
d[off + 11] = 1.0;
// Column 3: nameLen, troopLen, nameShade, nameHalfWidth
// Name fill darkens by type: human black, nation a bit gray, bot greyer.
const ns = this.settings.name;
let nameShade = 0.0;
if (slot.static.playerType === PlayerTypeEnum.Nation) {
nameShade = ns.nameShadeNation;
} else if (slot.static.playerType === PlayerTypeEnum.Bot) {
nameShade = ns.nameShadeBot;
}
d[off + 12] = slot.nameLen;
d[off + 13] = slot.troopLen;
d[off + 14] = nameShade;
d[off + 15] = slot.nameHalfWidth;
// Column 4: flagLayerIdx, emojiAtlasIdx, [free], [free]
d[off + 16] = slot.flagLayerIdx;
d[off + 17] = slot.emojiAtlasIdx;
d[off + 18] = slot.static.smallID;
d[off + 19] = 0;
// Column 5: crown, traitor, disconnected, alliance
d[off + 20] = slot.crown ? 1.0 : 0.0;
d[off + 21] = slot.traitor ? 1.0 : 0.0;
d[off + 22] = slot.disconnected ? 1.0 : 0.0;
d[off + 23] = slot.alliance ? 1.0 : 0.0;
// Column 6: allianceReq, target, embargo, nukeActive
d[off + 24] = slot.allianceReq ? 1.0 : 0.0;
d[off + 25] = slot.target ? 1.0 : 0.0;
d[off + 26] = slot.embargo ? 1.0 : 0.0;
d[off + 27] = slot.nukeActive ? 1.0 : 0.0;
// Column 7: nukeTargetsMe, traitorRemainingTicks, allianceFraction, allianceRemainingTicks
d[off + 28] = slot.nukeTargetsMe ? 1.0 : 0.0;
d[off + 29] = slot.traitorRemainingTicks;
d[off + 30] = slot.allianceFraction;
d[off + 31] = slot.allianceRemainingTicks;
this.playerDataDirty = true;
}
// -------------------------------------------------------------------------
// Render
// -------------------------------------------------------------------------
setHighlightOwner(ownerID: number): void {
this.highlightOwnerID = ownerID;
}
setMouseWorldPos(x: number, y: number): void {
this.mouseWorldX = x;
this.mouseWorldY = y;
}
/**
* Find the player whose name plate (name + troops + flag + emoji/status row)
* is under the cursor, so the whole plate can fade as a unit. Mirrors the
* lerp + sizing math in name.vert.glsl. Returns the smallID, or 0 for none.
*/
private hitTestNamePlate(now: number): number {
const mx = this.mouseWorldX;
const my = this.mouseWorldY;
const ns = this.settings.name;
for (const slot of this.slots.values()) {
if (!slot.alive) continue;
const t = Math.min(
1 - Math.exp(-ns.lerpSpeed * (now - slot.startTime)),
1,
);
const wx = slot.srcX + (slot.tgtX - slot.srcX) * t;
const wy = slot.srcY + (slot.tgtY - slot.srcY) * t;
const ws = slot.srcScale + (slot.tgtScale - slot.srcScale) * t;
const baseSize = Math.max(1, Math.floor(ws));
const nameSize = Math.max(4, Math.floor(baseSize * ns.nameScaleFactor));
const nameScale = Math.min(baseSize * 0.25, ns.nameScaleCap);
const lineH = this.fontBase * ((nameSize * nameScale) / this.fontSize);
const halfW =
slot.nameHalfWidth * ((nameSize * nameScale) / this.fontSize);
let left = wx - halfW;
const right = wx + halfW;
if (slot.flagLayerIdx >= 0) left -= lineH * 1.2 * FLAG_ASPECT;
// Name line (flag is slightly taller); emoji/status rows sit above it.
let top = wy - lineH * 0.6;
const hasStatus =
slot.crown ||
slot.traitor ||
slot.disconnected ||
slot.alliance ||
slot.allianceReq ||
slot.target ||
slot.embargo ||
slot.nukeActive;
if (slot.emojiAtlasIdx >= 0) {
top = wy - lineH * ns.emojiRowOffset;
} else if (hasStatus) {
top = wy - lineH * ns.statusRowOffset;
}
const bottom = wy + lineH * (1.1 + 0.5 * ns.troopSizeMultiplier);
if (mx >= left && mx <= right && my >= top && my <= bottom) {
return slot.static.smallID;
}
}
return 0;
}
/**
* Switch the active text font when settings.name.classicFont changes, then
* re-lay-out every name + troop line — advances differ between the two fonts,
* so cursor positions and name half-widths must be recomputed.
*/
private syncFont(): void {
const classic = this.settings.name.classicFont;
if (classic === this.classicFont) return;
this.applyFont(classic);
for (const slot of this.slots.values()) {
if (slot.nameLen > 0) {
slot.nameHalfWidth = this.uploadStringRow(
slot.index * LINES_PER_PLAYER,
slot.static.displayName,
);
}
if (slot.troopLen > 0 && slot.lastTroopStr) {
this.uploadStringRow(
slot.index * LINES_PER_PLAYER + 1,
slot.lastTroopStr,
);
}
this.writePlayerDataRow(slot);
}
}
draw(cameraMatrix: Float32Array, ambient: number): void {
this.syncFont();
if (!this.textProgram.isReady(this.classicFont)) return;
if (this.slots.size === 0) return;
const gl = this.gl;
if (this.stringDataDirty) {
gl.bindTexture(gl.TEXTURE_2D, this.stringTex);
gl.texSubImage2D(
gl.TEXTURE_2D,
0,
0,
0,
MAX_CHARS,
this.maxPlayers * LINES_PER_PLAYER,
gl.RED_INTEGER,
gl.UNSIGNED_BYTE,
this.cpuStringData,
);
this.stringDataDirty = false;
}
if (this.cursorDataDirty) {
gl.bindTexture(gl.TEXTURE_2D, this.cursorTex);
gl.texSubImage2D(
gl.TEXTURE_2D,
0,
0,
0,
MAX_CHARS,
this.maxPlayers * LINES_PER_PLAYER,
gl.RED,
gl.FLOAT,
this.cpuCursorData,
);
this.cursorDataDirty = false;
}
if (this.playerDataDirty) {
gl.bindTexture(gl.TEXTURE_2D, this.playerDataTex);
gl.texSubImage2D(
gl.TEXTURE_2D,
0,
0,
0,
8,
this.maxPlayers,
gl.RGBA,
gl.FLOAT,
this.cpuPlayerData,
);
this.playerDataDirty = false;
}
const fadeOwnerID = this.hitTestNamePlate(performance.now() / 1000);
this.textProgram.draw(
cameraMatrix,
this.settings,
this.vao,
this.maxPlayers,
ambient,
this.highlightOwnerID,
fadeOwnerID,
this.classicFont,
);
this.statusIconProgram.draw(
cameraMatrix,
this.settings,
this.vao,
fadeOwnerID,
);
this.iconProgram.draw(cameraMatrix, this.settings, this.vao, fadeOwnerID);
if (this.settings.passEnabled.nameDebug) {
this.debugProgram.draw(cameraMatrix, this.settings, this.vao);
}
}
// -------------------------------------------------------------------------
// Lifecycle
// -------------------------------------------------------------------------
dispose(): void {
const gl = this.gl;
this.textProgram.dispose();
this.iconProgram.dispose();
this.flagAtlas.dispose();
this.statusIconProgram.dispose();
this.debugProgram.dispose();
gl.deleteTexture(this.glyphMetricsTex);
gl.deleteTexture(this.arialMetricsTex);
gl.deleteTexture(this.cursorTex);
gl.deleteTexture(this.stringTex);
gl.deleteTexture(this.playerDataTex);
gl.deleteVertexArray(this.vao);
}
}