feat(client): highlight small players with a pulsing glow (#4525)

## Description:

Adds a **"Highlight small players"** client setting (in the in-game
Settings menu). When it's on, human players holding **0.2% or less of
the map** get a soft red glow that breathes (a pulsing aura) around
their territory, starting **one minute into the game**. This makes
near-eliminated players easy to spot on a busy map, even when their
territory is scattered in fragments or sitting under structures.

How it works:

- **Purely client-side**, no simulation or determinism impact.
`SmallPlayerGlowPass` renders a tile-space bloom: extract a sub-tile
mask of the small players' tiles, run a separable Gaussian blur, then
composite the soft aura over the map additively. It's camera-independent
(no shimmer when panning/zooming), so scattered tiles blur into one
clean halo. The aura breathes: its intensity fades fully to 0 at the
trough for clear contrast.
- The set is recomputed each tick in `WebGLFrameBuilder`: alive human
players with `tilesOwned / (landTiles - fallout) <= 0.002` (0.2%),
suppressed during the spawn phase and the first minute.
- Backed by a persisted `UserSettings` flag, toggleable live from the
in-game Settings modal (with a new, distinct icon).
- Drawn after the structure passes so buildings can't hide it.
- Mirrors the existing FalloutBloom pipeline and reuses the shared blur
shader and render-target helpers rather than duplicating them. Tunable
via `render-settings.json` (`smallPlayerGlow`: color / alpha /
pulseSpeed).

UI (glow fades in and out):
<img width="474" height="334" alt="image"
src="https://github.com/user-attachments/assets/2b94f292-2cbb-43d3-82fb-f274d1afdedf"
/>
<img width="976" height="400" alt="image"
src="https://github.com/user-attachments/assets/f1b972d7-e046-4f7a-ab43-da1eb758c7b5"
/>


## 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

## Please put your Discord username so you can be contacted if a bug or
regression is found:

zixer._
This commit is contained in:
Zixer1
2026-07-08 12:30:37 -07:00
committed by GitHub
parent 050c7604e8
commit f5ef306d99
18 changed files with 491 additions and 24 deletions
+56
View File
@@ -14,6 +14,7 @@ import {
} from "../core/CosmeticSchemas";
import { decodePatternData } from "../core/PatternDecoder";
import { PlayerType } from "../core/game/Game";
import { UserSettings } from "../core/game/UserSettings";
import { getCachedCosmetics } from "./Cosmetics";
import { uploadFrameData } from "./render/frame/Upload";
// Type-only: a value import would pull GPURenderer and its `.glsl?raw` shader
@@ -40,6 +41,14 @@ 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
@@ -187,12 +196,17 @@ export class WebGLFrameBuilder {
this.view.refreshNames(displayNames);
}
private readonly highlightSetBuf = new Uint8Array(PALETTE_SIZE);
private readonly userSettings = new UserSettings();
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());
@@ -328,6 +342,48 @@ export class WebGLFrameBuilder {
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 {
if (
!this.userSettings.highlightSmallPlayers() ||
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;