Files
OpenFrontIO/src/client/WebGLFrameBuilder.ts
T
Zixer1andGitHub d07f7d46dd feat(highlight): replace the small-player highlight toggle with a strength slider (#4588)
## Description:

Replaces the "Highlight small players" on/off toggle with a **0-500%
Strength slider** controlling the glow's **width** (0 = off, 100% = the
previous default).

- `UserSettings`: `highlightSmallPlayers()`/toggle →
`highlightGlowStrength()`/set, float-backed, clamped to [0, 5].
- `SmallPlayerGlowPass` takes the strength via a setter (pushed by
`ClientGameRunner` on the settings-changed event), so it stays a pure
consumer and the slider still updates live while the settings modal has
the sim paused. Width = a non-linear blur-iteration count; intensity
compensated so wider stays about as bright.
- `MapRenderer` persists the strength across a WebGL context restore.
- `SettingsModal`: the toggle row becomes a range input.

Behavior note: with the toggle gone the glow **defaults ON at 100%**
(old toggle defaulted OFF); can default to 0 (opt-in) if preferred.

## Please complete the following:

- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory
2026-07-13 11:07:22 -07:00

559 lines
22 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.
import { Colord, colord } from "colord";
import { base64url } from "jose";
import { assetUrl } from "../core/AssetUrls";
import {
findEffect,
findEffectForSlot,
isNukeExplosionEffect,
isTrailEffect,
type NukeExplosionAttributes,
type NukeExplosionType,
type StructuresEffectAttributes,
TRAIL_EFFECT_TYPES,
type TrailEffectAttributes,
} from "../core/CosmeticSchemas";
import { decodePatternData } from "../core/PatternDecoder";
import { PlayerType } from "../core/game/Game";
import { getCachedCosmetics } from "./Cosmetics";
import { uploadFrameData } from "./render/frame/Upload";
// Type-only: a value import would pull GPURenderer and its `.glsl?raw` shader
// imports into any non-Vite consumer (e.g. the Node perf harness).
import type { MapRenderer, PlayerStatic, SpawnCenter } from "./render/gl";
import {
DEFAULT_NUKE_EXPLOSION_COLOR,
MAX_NUKE_EXPLOSION_COLORS,
type NukeExplosionRenderParams,
} from "./render/types";
// Value import from the leaf module (not the ./render/gl barrel) so non-Vite
// consumers don't pull in GPURenderer and its shaders — see note above.
import {
EFFECT_PALETTE_BLOCKS,
MAX_TRAIL_COLORS,
STRUCTURES_EFFECT_BLOCK,
} from "./render/gl/utils/ColorUtils";
import {
UT_ATOM_BOMB,
UT_HYDROGEN_BOMB,
UT_MIRV_WARHEAD,
} from "./render/types/UnitType";
import type { GameView } from "./view";
const PALETTE_SIZE = 4096;
// A human player counts as "small" (and glows) at or below this fraction of the
// map; the glow is suppressed for a grace window after the game starts.
const SMALL_PLAYER_MAX_MAP_FRACTION = 0.002; // 0.2%
const SMALL_PLAYER_GLOW_GRACE_SECONDS = 60;
// The set is a visual aid, not tick-critical, so rescan ~once a second
// (10 ticks) instead of every tick.
const SMALL_PLAYER_GLOW_RESCAN_TICKS = 10;
// The effect-palette block order: index = block (rows block·MAX_TRAIL_COLORS …).
// trail.frag.glsl picks its block from the trail tile's nuke bit — block 0 =
// transportShipTrail (nuke bit 0), block 1 = nukeTrail (nuke bit 1, set by
// NUKE_TRAIL_BIT in TrailManager) — and structure.frag.glsl reads block
// STRUCTURES_EFFECT_BLOCK (2). Reordering TRAIL_EFFECT_TYPES in CosmeticSchemas
// (or moving the structures block) would silently swap effect colors, so these
// guards fail the build if the shader-coupled order ever drifts.
const _EFFECT_BLOCK_ORDER: readonly ["transportShipTrail", "nukeTrail"] =
TRAIL_EFFECT_TYPES;
void _EFFECT_BLOCK_ORDER;
const _STRUCTURES_BLOCK_IS_2: 2 = STRUCTURES_EFFECT_BLOCK;
void _STRUCTURES_BLOCK_IS_2;
// Attribute → render-param mappings:
// size = the ring's final WIDTH (diameter) in world tiles when it fades
// out — absolute, so maxRadius = size / 2 regardless of bomb type.
// speed = world tiles/s the ring's width grows, so the effect lasts
// size / speed seconds (the pass clamps the duration).
// thickness = the ring band's thickness in world tiles.
// transitionSpeed passes through as the palette step rate (colors/s).
// Detonating bomb → nuke-explosion slot.
// Only these unit types produce a shockwave; plain MIRV splits and never detonates.
const UNIT_TYPE_TO_NUKE_TYPE: Readonly<Record<string, NukeExplosionType>> = {
[UT_ATOM_BOMB]: "atom",
[UT_HYDROGEN_BOMB]: "hydro",
[UT_MIRV_WARHEAD]: "mirvWarhead",
};
function toRgb01(s: string): [number, number, number] | null {
const c = colord(s);
if (!c.isValid()) return null;
const { r, g, b } = c.toRgb();
return [r / 255, g / 255, b / 255];
}
/** Resolve a nuke-explosion cosmetic's catalog attributes into render params. */
function attributesToExplosionParams(
attrs: NukeExplosionAttributes,
): NukeExplosionRenderParams {
// The shader cycles through the whole palette; the instance layout carries
// at most MAX_NUKE_EXPLOSION_COLORS, extras are dropped.
const colors = attrs.colors
.map(toRgb01)
.filter((c): c is [number, number, number] => c !== null)
.slice(0, MAX_NUKE_EXPLOSION_COLORS);
const base = {
colors: colors.length > 0 ? colors : [DEFAULT_NUKE_EXPLOSION_COLOR],
maxRadius: attrs.size / 2,
speed: attrs.speed,
thickness: attrs.thickness,
transitionSpeed: attrs.transitionSpeed,
};
return attrs.type === "sparkles"
? { ...base, type: "sparkles", density: attrs.density }
: { ...base, type: "shockwave" };
}
/**
* The renderer-side glue between GameView (which already builds the full
* FrameData each tick) and the WebGL view. Two responsibilities:
*
* 1. Palette management — translate PlayerView colors into a Float32Array
* the renderer uploads to a 1D texture, and call view.addPlayers() when
* new players appear (this is a renderer-side lifecycle event, not part
* of FrameData).
* 2. Per-tick upload — pass the FrameData to the renderer's uploadFrameData
* helper, which dispatches to all the view.update*() methods.
*/
export class WebGLFrameBuilder {
private readonly palette: Float32Array;
// Per-player effect palette, keyed by smallID. Layout is
// 4096×(MAX_TRAIL_COLORS·EFFECT_PALETTE_BLOCKS): block 0 (rows 07) =
// transportShipTrail, block 1 (rows 815) = nukeTrail, block 2 (rows 1623)
// = structures. Consumed by TrailPass (block from the trail tile's nuke bit)
// and StructurePass (block 2).
private readonly effectPalette: Float32Array;
private readonly patternMeta: Float32Array;
private readonly patternData: Uint8Array;
private readonly knownSmallIDs = new Set<number>();
/**
* smallIDs whose trail effect has been resolved into the effect palette.
* Separate from knownSmallIDs because effect resolution depends on the
* cosmetics catalog, which may not be loaded the tick a player is first seen
* — keeping it separate lets us retry next tick instead of skipping forever.
*/
private readonly effectResolved = new Set<number>();
/**
* Last spawn tile pushed to the renderer per smallID. Players can re-pick
* spawn during the spawn phase, so this tracks the latest value rather than
* just first-seen — re-uploads only when the tile actually changes.
*/
private readonly lastSpawnTile = new Map<number, number>();
/** Skin atlas allocated once on first syncPlayers — player set is locked at game start. */
private skinsInitialized = false;
// The renderer needs to know which player is "me" so affiliation tint,
// unit colors, and SAM-radius perspective work. Push it once the local
// player's update arrives (may take several ticks during join).
private localPlayerSmallID = 0;
// Scratch buffer for terrain-delta uploads (parallel to the refs list).
private terrainDeltaBytes: Uint8Array = new Uint8Array(0);
constructor(private readonly view: MapRenderer) {
this.palette = new Float32Array(PALETTE_SIZE * 2 * 4);
this.effectPalette = new Float32Array(
PALETTE_SIZE * MAX_TRAIL_COLORS * EFFECT_PALETTE_BLOCKS * 4,
);
this.patternMeta = new Float32Array(PALETTE_SIZE * 4);
this.patternData = new Uint8Array(PALETTE_SIZE * 1024);
}
/** Drop internal caches to force a full re-upload of state on the next update(). */
clearCaches(): void {
this.knownSmallIDs.clear();
this.effectResolved.clear();
this.lastSpawnTile.clear();
this.localPlayerSmallID = 0;
this.skinsInitialized = false;
}
/**
* Re-write every player's palette entry from their current (possibly re-themed)
* colors and re-upload just the palette texture. Used after a mid-game theme
* change (e.g. toggling colorblind mode) so existing territories re-color
* without re-syncing players, skins, or spawns.
*/
refreshPalette(gameView: GameView): void {
for (const p of gameView.players()) {
this.writePaletteEntry(p.smallID(), p.territoryColor(), p.borderColor());
}
this.view.updatePalette(this.palette);
}
/**
* Re-resolve every player's display name (e.g. after toggling the
* anonymous-names setting) and push it to the renderer so the names drawn on
* the map switch live, matching the leaderboard.
*/
refreshNames(gameView: GameView): void {
const displayNames = new Map<string, string>();
for (const p of gameView.players()) {
displayNames.set(p.id(), p.displayName());
}
this.view.refreshNames(displayNames);
}
private readonly highlightSetBuf = new Uint8Array(PALETTE_SIZE);
private glowRescanTick = 0;
update(gameView: GameView): void {
this.syncPlayers(gameView);
this.syncPlayerEffects(gameView);
this.syncPlayerSpawns(gameView);
this.syncLocalPlayer(gameView);
this.syncSpawnOverlay(gameView);
this.syncSmallPlayerGlow(gameView);
this.syncTerrainDeltas(gameView);
this.resolveDeadUnitExplosions(gameView);
uploadFrameData(this.view, gameView.frameData());
}
/**
* Attach the firing player's resolved nuke-explosion cosmetic to each dead
* nuke event, so every client renders the shockwave in the owner's colors.
* The effect is per-bomb-type: the detonating unit maps to a nukeType slot
* (atom / hydro / mirvWarhead) and we resolve the player's selection for THAT
* slot, so an atom effect only shows on atom bombs, etc. Runs before
* uploadFrameData so the FX pass sees the params on the event; a player with no
* selection for that bomb is left undefined (the shockwave falls back to default).
*/
private resolveDeadUnitExplosions(gameView: GameView): void {
const deadUnits = gameView.frameData().events.deadUnits;
if (deadUnits.length === 0) return;
const catalog = getCachedCosmetics();
if (!catalog) return; // Catalog not loaded yet — default FX this frame.
for (const du of deadUnits) {
if (!du.reachedTarget) continue; // SAM interceptions have no explosion cosmetic
const nukeType = UNIT_TYPE_TO_NUKE_TYPE[du.unitType];
if (!nukeType) continue; // not a shockwave-producing bomb
// playerBySmallID throws on an unknown smallID; a stale/bad event must
// not kill the frame builder — skip it (default FX).
let player: ReturnType<GameView["playerBySmallID"]>;
try {
player = gameView.playerBySmallID(du.ownerSmallID);
} catch {
continue;
}
if (!player.isPlayer()) continue;
const name = player.cosmetics.effects?.[nukeType]?.name;
if (!name) continue;
const effect = findEffectForSlot(catalog, nukeType, name);
if (!effect || !isNukeExplosionEffect(effect)) continue;
du.explosion = attributesToExplosionParams(effect.attributes);
}
}
/**
* Push each player's current spawn tile to the renderer as the skin anchor
* (image center lines up with this tile). Players re-pick spawn during the
* spawn phase, so we re-upload whenever the tile changes, not just on first
* sighting. Once spawn phase ends, spawnTile is locked and this becomes a
* no-op via the cache check.
*/
private syncPlayerSpawns(gameView: GameView): void {
for (const p of gameView.players()) {
const smallID = p.smallID();
const spawnTile = p.state.spawnTile;
if (spawnTile === undefined) continue;
if (this.lastSpawnTile.get(smallID) === spawnTile) continue;
this.lastSpawnTile.set(smallID, spawnTile);
this.view.setPlayerSpawn(
smallID,
gameView.x(spawnTile),
gameView.y(spawnTile),
);
}
}
/**
* Water-nuke conversions (land → water) mutate the underlying terrain.
* Forward this tick's terrain-changed refs to the renderer so it can
* re-upload those texels in both the RGBA color texture and the R8UI
* water-detection texture used by railroads/bridges.
*/
private syncTerrainDeltas(gameView: GameView): void {
const refs = gameView.recentlyUpdatedTerrainTiles();
if (refs.length === 0) return;
if (this.terrainDeltaBytes.length < refs.length) {
this.terrainDeltaBytes = new Uint8Array(refs.length);
}
for (let i = 0; i < refs.length; i++) {
this.terrainDeltaBytes[i] = gameView.terrainByte(refs[i]);
}
this.view.applyTerrainDelta(refs, this.terrainDeltaBytes);
}
private syncLocalPlayer(gameView: GameView): void {
const me = gameView.myPlayer();
const sid = me?.smallID() ?? 0;
if (sid === this.localPlayerSmallID) return;
this.localPlayerSmallID = sid;
this.view.setLocalPlayerID(sid);
if (me) {
const rail = me.railColor().toRgb();
this.view.setLocalRailColor(rail.r / 255, rail.g / 255, rail.b / 255);
}
}
/**
* Spawn-phase highlights: each already-spawned human player gets a colored
* ring + tile glow around their starting territory. Pushed every tick
* during spawn phase; the pass animates locally from the snapshot.
*/
private syncSpawnOverlay(gameView: GameView): void {
const inSpawnPhase = gameView.inSpawnPhase();
if (!inSpawnPhase) {
this.view.updateSpawnOverlay(false, []);
return;
}
const me = gameView.myPlayer();
const myTeam = me?.team() ?? null;
const centers: SpawnCenter[] = [];
for (const p of gameView.players()) {
if (!p.isPlayer() || p.type() !== PlayerType.Human) continue;
const spawnTile = p.state.spawnTile;
if (spawnTile === undefined) continue;
const isSelf = me !== null && p.smallID() === me.smallID();
// myPlayer's ring pulses white→this color in SpawnOverlayPass: gold
// when teamless, own territory tint in team games (matches teammates'
// rings). Everyone else uses their territory tint directly.
const c = p.territoryColor().toRgb();
const useGold = isSelf && myTeam === null;
centers.push({
// spawnTile tracks the player's currently-selected spawn directly —
// updates the same tick the player picks a new location (faster than
// the nameData centroid which only refreshes every 2 ticks).
x: gameView.x(spawnTile),
y: gameView.y(spawnTile),
r: useGold ? 1 : c.r / 255,
g: useGold ? 0.84 : c.g / 255,
b: useGold ? 0 : c.b / 255,
isSelf,
isTeammate:
myTeam !== null &&
p.team() === myTeam &&
p.smallID() !== me?.smallID(),
});
}
this.view.updateSpawnOverlay(true, centers);
}
/**
* Small-player glow: when the client "Highlight small players" setting is on,
* collect the alive human players holding <=0.2% of the map and push their
* smallIDs so the glow pass radiates around their territory. Skips the first
* minute of play so everyone's tiny starting territory doesn't glow.
* Client-only view — toggle it live in the settings.
*/
private syncSmallPlayerGlow(gameView: GameView): void {
// Strength (incl. off at 0) is read live in the glow pass; here we only
// decide who qualifies. Skip spawn + the first minute.
if (
gameView.inSpawnPhase() ||
gameView.elapsedGameSeconds() < SMALL_PLAYER_GLOW_GRACE_SECONDS
) {
this.view.updateSmallPlayerGlow(null);
return;
}
// Throttle the per-player scan + upload; the glow keeps rendering the last
// set between rescans. The off/spawn/grace checks above run every tick, so
// toggling off takes effect on the next tick (deferred while the game is
// paused, since ticks stop; it clears on unpause).
if (this.glowRescanTick++ % SMALL_PLAYER_GLOW_RESCAN_TICKS !== 0) return;
// "% of the map" uses the same denominator the leaderboard/win-check use.
const denom = gameView.numLandTiles() - gameView.numTilesWithFallout();
if (denom <= 0) {
this.view.updateSmallPlayerGlow(null);
return;
}
const set = this.highlightSetBuf;
set.fill(0);
let any = false;
for (const p of gameView.players()) {
if (!p.isPlayer() || p.type() !== PlayerType.Human || !p.isAlive()) {
continue;
}
if (p.numTilesOwned() / denom <= SMALL_PLAYER_MAX_MAP_FRACTION) {
set[p.smallID()] = 1;
any = true;
}
}
this.view.updateSmallPlayerGlow(any ? set : null);
}
private syncPlayers(gameView: GameView): void {
if (!this.skinsInitialized) {
this.skinsInitialized = true;
const urls = new Set<string>();
for (const p of gameView.players()) {
const url = p.cosmetics.skin?.url;
if (url) urls.add(assetUrl(url));
}
this.view.initSkinAtlas([...urls]);
}
const newPlayers: PlayerStatic[] = [];
for (const p of gameView.players()) {
const smallID = p.smallID();
if (this.knownSmallIDs.has(smallID)) continue;
this.knownSmallIDs.add(smallID);
this.writePaletteEntry(smallID, p.territoryColor(), p.borderColor());
// p.cosmetics.flag has already been server-resolved to either a full URL
// or a relative asset path (e.g. "/flags/US.svg" or a CDN URL for a
// custom flag). assetUrl() passes URLs through and rewrites paths.
const flagRef = p.cosmetics.flag;
const flagUrl = flagRef ? assetUrl(flagRef) : undefined;
const skin = p.cosmetics.skin;
if (skin?.url) {
this.view.setPlayerSkin(smallID, assetUrl(skin.url));
}
const pattern = p.cosmetics.pattern;
if (pattern && pattern.patternData) {
try {
const decoded = decodePatternData(
pattern.patternData,
base64url.decode,
);
const metaOff = smallID * 4;
this.patternMeta[metaOff] = 1.0; // hasPattern = true
this.patternMeta[metaOff + 1] = decoded.width;
this.patternMeta[metaOff + 2] = decoded.height;
this.patternMeta[metaOff + 3] = decoded.scale;
this.patternData.set(decoded.bytes.slice(3), smallID * 1024);
} catch (e) {
console.warn("Failed to decode territory pattern", e);
}
}
newPlayers.push({
...p.static,
// displayName() honors the anonymous-names setting; static.displayName
// is always the real name.
displayName: p.displayName(),
flag: flagUrl,
color: p.territoryColor().toHex(),
});
}
if (newPlayers.length > 0) {
this.view.addPlayers(
newPlayers,
this.palette,
this.patternMeta,
this.patternData,
);
}
}
/**
* Resolve each player's transport-ship-trail effect into the effect palette.
* A player's resolved cosmetic is just { name, effectType }; the style and
* colors live in the catalog, so we look them up via the cached cosmetics.
* Decoupled from syncPlayers' first-seen guard: if the catalog isn't loaded
* yet we leave the player unresolved and retry next tick (the trail keeps its
* territory color meanwhile). Re-uploads the effect texture only when a
* recognized style was actually written.
*/
private syncPlayerEffects(gameView: GameView): void {
const catalog = getCachedCosmetics();
if (!catalog) return; // Catalog not loaded yet — retry on a later tick.
let dirty = false;
for (const p of gameView.players()) {
const smallID = p.smallID();
if (this.effectResolved.has(smallID)) continue;
this.effectResolved.add(smallID);
// Resolve each trail-styled effectType into its own block of the effect
// palette. rowBase block*MAX_TRAIL_COLORS must match the consumer
// shaders' block layout (ship=0, nuke=1 in trail.frag.glsl; structures=2
// in structure.frag.glsl) — see _EFFECT_BLOCK_ORDER above. nukeExplosion
// is not trail-styled and renders through the FX pass instead.
const blockOrder = [...TRAIL_EFFECT_TYPES, "structures"] as const;
blockOrder.forEach((effectType, block) => {
const selected = p.cosmetics.effects?.[effectType];
if (!selected) return;
const effect = findEffect(catalog, effectType, selected.name);
if (!effect || effect.effectType !== effectType) return;
// Narrows attributes to trail attrs (structures share the shape).
if (!isTrailEffect(effect) && effect.effectType !== "structures") {
return;
}
const rowBase = block * MAX_TRAIL_COLORS;
if (this.writeEffectEntry(smallID, effect.attributes, rowBase)) {
dirty = true;
}
});
}
if (dirty) this.view.updateEffectPalette(this.effectPalette);
}
/**
* Encode a player's trail-styled effect into one block of the effect palette.
* The block starts at row `rowBase` (block · MAX_TRAIL_COLORS; see
* _EFFECT_BLOCK_ORDER). Within the block, row r holds color r's rgb, and the spare alpha
* channels (rows rowBase+0..3 always exist) carry the scalar params —
* row 0.a = color count (0 → the shader falls back to the territory color),
* row 1.a = styleId (0 = gradient, 1 = transition),
* row 2.a = scalar0 (gradient: colorSize; transition: frequency),
* row 3.a = scalar1 (gradient: movementSpeed; transition: unused).
* colord doesn't throw on a bad color string (it returns black), so unparseable
* colors are dropped — leaving an empty list, which falls back to the territory
* color rather than rendering black. Returns whether any color was written.
*/
private writeEffectEntry(
smallID: number,
attrs: TrailEffectAttributes | StructuresEffectAttributes,
rowBase: number,
): boolean {
const colors = attrs.colors
.map((s) => colord(s))
.filter((c) => c.isValid())
.slice(0, MAX_TRAIL_COLORS)
.map((c) => c.toRgb());
for (let r = 0; r < MAX_TRAIL_COLORS; r++) {
const off = ((rowBase + r) * PALETTE_SIZE + smallID) * 4;
const c = colors[r] ?? { r: 0, g: 0, b: 0 };
this.effectPalette[off] = c.r / 255;
this.effectPalette[off + 1] = c.g / 255;
this.effectPalette[off + 2] = c.b / 255;
this.effectPalette[off + 3] = 0;
}
const [styleId, scalar0, scalar1] =
attrs.type === "transition"
? [1, attrs.frequency, 0]
: [0, attrs.colorSize, attrs.movementSpeed];
const alpha = (row: number) =>
((rowBase + row) * PALETTE_SIZE + smallID) * 4 + 3;
this.effectPalette[alpha(0)] = colors.length;
this.effectPalette[alpha(1)] = styleId;
this.effectPalette[alpha(2)] = scalar0;
this.effectPalette[alpha(3)] = scalar1;
return colors.length > 0;
}
private writePaletteEntry(
smallID: number,
fill: Colord,
border: Colord,
): void {
const fillRgba = fill.toRgb();
const fillOff = smallID * 4;
this.palette[fillOff] = fillRgba.r / 255;
this.palette[fillOff + 1] = fillRgba.g / 255;
this.palette[fillOff + 2] = fillRgba.b / 255;
this.palette[fillOff + 3] = 150 / 255;
const borderRgba = border.toRgb();
const borderOff = PALETTE_SIZE * 4 + smallID * 4;
this.palette[borderOff] = borderRgba.r / 255;
this.palette[borderOff + 1] = borderRgba.g / 255;
this.palette[borderOff + 2] = borderRgba.b / 255;
this.palette[borderOff + 3] = 1.0;
}
}