From f5ef306d995b60d2a0181df99b163b36cd6f1035 Mon Sep 17 00:00:00 2001 From: Zixer1 <99333209+Zixer1@users.noreply.github.com> Date: Wed, 8 Jul 2026 15:30:37 -0400 Subject: [PATCH] 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): image image ## 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._ --- resources/images/HighlightIconWhite.svg | 5 + resources/lang/en.json | 2 + src/client/WebGLFrameBuilder.ts | 56 +++++ src/client/hud/layers/SettingsModal.ts | 31 +++ src/client/render/gl/MapRenderer.ts | 5 + src/client/render/gl/RenderSettings.ts | 5 + src/client/render/gl/Renderer.ts | 17 ++ .../render/gl/passes/FalloutBloomPass.ts | 2 +- .../render/gl/passes/FalloutLightPass.ts | 2 +- .../render/gl/passes/SmallPlayerGlowPass.ts | 234 ++++++++++++++++++ src/client/render/gl/render-settings.json | 5 + .../shaders/fallout-bloom/composite.vert.glsl | 11 - .../map-quad.vert.glsl} | 24 +- .../small-player-glow/composite.frag.glsl | 16 ++ .../small-player-glow/extract.frag.glsl | 30 +++ src/client/render/gl/utils/GlUtils.ts | 31 +++ src/core/game/UserSettings.ts | 11 + tests/UserSettings.test.ts | 28 +++ 18 files changed, 491 insertions(+), 24 deletions(-) create mode 100644 resources/images/HighlightIconWhite.svg create mode 100644 src/client/render/gl/passes/SmallPlayerGlowPass.ts delete mode 100644 src/client/render/gl/shaders/fallout-bloom/composite.vert.glsl rename src/client/render/gl/shaders/{day-night/fallout-composite.vert.glsl => shared/map-quad.vert.glsl} (63%) create mode 100644 src/client/render/gl/shaders/small-player-glow/composite.frag.glsl create mode 100644 src/client/render/gl/shaders/small-player-glow/extract.frag.glsl diff --git a/resources/images/HighlightIconWhite.svg b/resources/images/HighlightIconWhite.svg new file mode 100644 index 000000000..607277ee2 --- /dev/null +++ b/resources/images/HighlightIconWhite.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/resources/lang/en.json b/resources/lang/en.json index 852b4c33a..4e1d7b3d3 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -1488,6 +1488,8 @@ "ground_attack_desc": "Send a ground attack to the tile under your cursor.", "help_messages_desc": "Show contextual tips and warnings during gameplay, such as army limit warnings and general gameplay advice.", "help_messages_label": "Help Messages", + "highlight_small_players_desc": "Add a glow to small players so they're easy to find.", + "highlight_small_players_label": "Highlight small players", "keybind_conflict_error": "The key {key} is already bound to another action.", "keybinds_hint": "Click a key to rebind it. You can assign a single key or Shift + key combination.", "left_click_desc": "When ON, left-click opens menu and sword button attacks. When OFF, left-click attacks directly.", diff --git a/src/client/WebGLFrameBuilder.ts b/src/client/WebGLFrameBuilder.ts index e6030ce5a..cf569a3db 100644 --- a/src/client/WebGLFrameBuilder.ts +++ b/src/client/WebGLFrameBuilder.ts @@ -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; diff --git a/src/client/hud/layers/SettingsModal.ts b/src/client/hud/layers/SettingsModal.ts index 14c40e79a..6b905f63a 100644 --- a/src/client/hud/layers/SettingsModal.ts +++ b/src/client/hud/layers/SettingsModal.ts @@ -19,6 +19,7 @@ import { ShowGraphicsSettingsModalEvent } from "./GraphicsSettingsModal"; const cursorPriceIcon = assetUrl("images/CursorPriceIconWhite.svg"); const emojiIcon = assetUrl("images/EmojiIconWhite.svg"); const exitIcon = assetUrl("images/ExitIconWhite.svg"); +const highlightIcon = assetUrl("images/HighlightIconWhite.svg"); const mouseIcon = assetUrl("images/MouseIconWhite.svg"); const ninjaIcon = assetUrl("images/NinjaIconWhite.svg"); const settingsIcon = assetUrl("images/SettingIconWhite.svg"); @@ -134,6 +135,11 @@ export class SettingsModal extends LitElement implements Controller { this.requestUpdate(); } + private onToggleHighlightSmallPlayersButtonClick() { + this.userSettings.toggleHighlightSmallPlayers(); + this.requestUpdate(); + } + private onToggleAlertFrameButtonClick() { this.userSettings.toggleAlertFrame(); this.requestUpdate(); @@ -350,6 +356,31 @@ export class SettingsModal extends LitElement implements Controller { + +