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 15:30:37 -04:00
committed by GitHub
parent 050c7604e8
commit f5ef306d99
18 changed files with 491 additions and 24 deletions
+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#fefffe" stroke-width="2">
<circle cx="12" cy="12" r="2.5" fill="#fefffe" stroke="none" />
<circle cx="12" cy="12" r="6" />
<circle cx="12" cy="12" r="10" opacity="0.45" />
</svg>

After

Width:  |  Height:  |  Size: 266 B

+2
View File
@@ -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.",
+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;
+31
View File
@@ -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 {
</div>
</button>
<button
class="flex gap-3 items-center w-full text-left p-3 hover:bg-slate-700 rounded-sm text-white transition-colors"
@click="${this.onToggleHighlightSmallPlayersButtonClick}"
>
<img
src=${highlightIcon}
alt="highlightSmallPlayers"
width="20"
height="20"
/>
<div class="flex-1">
<div class="font-medium">
${translateText("user_setting.highlight_small_players_label")}
</div>
<div class="text-sm text-slate-400">
${translateText("user_setting.highlight_small_players_desc")}
</div>
</div>
<div class="text-sm text-slate-400">
${this.userSettings.highlightSmallPlayers()
? translateText("user_setting.on")
: translateText("user_setting.off")}
</div>
</button>
<button
class="flex gap-3 items-center w-full text-left p-3 hover:bg-slate-700 rounded-sm text-white transition-colors"
@click="${this.onToggleAlertFrameButtonClick}"
+5
View File
@@ -239,6 +239,11 @@ export class MapRenderer {
this.renderer?.updateSpawnOverlay(inSpawnPhase, centers);
}
/** Set the small-player glow set (1 byte per owner smallID), or null = off. */
updateSmallPlayerGlow(set: Uint8Array | null): void {
this.renderer?.updateSmallPlayerGlow(set);
}
// ---- Selection box ----
/** Set multiple selected units (multi-select). Pass [] to clear. */
+5
View File
@@ -380,6 +380,11 @@ export interface RenderSettings {
gradientInnerEdge: number; // static gradient inner ramp end (01)
gradientSolidEnd: number; // static gradient solid band end (01)
};
smallPlayerGlow: {
color: number[]; // RGB, each 01
alpha: number; // peak opacity (01)
pulseSpeed: number; // breath animation speed
};
altView: {
gridFontSize: number;
recolorStructures: boolean;
+17
View File
@@ -48,6 +48,7 @@ import { RangeCirclePass } from "./passes/RangeCirclePass";
import { SAMRadiusPass } from "./passes/SamRadiusPass";
import { SelectionBoxPass } from "./passes/SelectionBoxPass";
import { SkinAtlasArray } from "./passes/SkinAtlasArray";
import { SmallPlayerGlowPass } from "./passes/SmallPlayerGlowPass";
import type { SpawnCenter } from "./passes/SpawnOverlayPass";
import { SpawnOverlayPass } from "./passes/SpawnOverlayPass";
import { StructureLevelPass } from "./passes/StructureLevelPass";
@@ -140,6 +141,7 @@ export class GPURenderer {
private affiliationPalette: AffiliationPalette;
private coordinateGridPass: CoordinateGridPass;
private spawnOverlayPass: SpawnOverlayPass;
private smallPlayerGlowPass: SmallPlayerGlowPass;
private inSpawnPhase = false;
private paletteTex: WebGLTexture;
@@ -429,6 +431,14 @@ export class GPURenderer {
this.settings.spawnOverlay,
);
this.smallPlayerGlowPass = new SmallPlayerGlowPass(
gl,
mapW,
mapH,
this.res.tileTex,
this.settings.smallPlayerGlow,
);
// --- Trail (needs trailTex, paletteTex, effectTex) ---
this.trailPass = new TrailPass(
gl,
@@ -988,6 +998,10 @@ export class GPURenderer {
this.spawnOverlayPass.update(inSpawnPhase, centers);
}
updateSmallPlayerGlow(set: Uint8Array | null): void {
this.smallPlayerGlowPass.update(set);
}
// ---------------------------------------------------------------------------
// Queries
// ---------------------------------------------------------------------------
@@ -1245,6 +1259,8 @@ export class GPURenderer {
this.crosshairPass.draw(cam);
if (pe.structure) this.structurePass.draw(cam, zoom);
if (pe.structure) this.structureLevelPass.draw(cam, zoom);
// Small-player glow draws after structures so buildings can't hide it.
this.smallPlayerGlowPass.draw(cam);
if (pe.bar) this.barPass.draw(cam);
this.updateSelectionBox();
this.selectionBoxPass.draw(cam, this.frameTick);
@@ -1293,6 +1309,7 @@ export class GPURenderer {
this.affiliationPalette.dispose();
this.coordinateGridPass.dispose();
this.spawnOverlayPass.dispose();
this.smallPlayerGlowPass.dispose();
this.railroadPass.dispose();
this.rangeCirclePass.dispose();
this.samRadiusPass.dispose();
@@ -27,11 +27,11 @@ import type { HeatManager } from "../utils/HeatManager";
import { TILE_DEFINES } from "../utils/TileCodec";
import compositeFragSrc from "../shaders/fallout-bloom/composite.frag.glsl?raw";
import compositeVertSrc from "../shaders/fallout-bloom/composite.vert.glsl?raw";
import extractFragSrc from "../shaders/fallout-bloom/extract.frag.glsl?raw";
import blurFragSrc from "../shaders/shared/blur.frag.glsl?raw";
import fullscreenNoUvVertSrc from "../shaders/shared/fullscreen-no-uv.vert.glsl?raw";
import fullscreenVertSrc from "../shaders/shared/fullscreen.vert.glsl?raw";
import compositeVertSrc from "../shaders/shared/map-quad.vert.glsl?raw";
export class FalloutBloomPass {
private gl: WebGL2RenderingContext;
@@ -24,9 +24,9 @@ import type { HeatManager } from "../utils/HeatManager";
import { TILE_DEFINES } from "../utils/TileCodec";
import falloutCompositeFragSrc from "../shaders/day-night/fallout-composite.frag.glsl?raw";
import falloutCompositeVertSrc from "../shaders/day-night/fallout-composite.vert.glsl?raw";
import falloutLightFragSrc from "../shaders/day-night/fallout-light.frag.glsl?raw";
import fullscreenNoUvVertSrc from "../shaders/shared/fullscreen-no-uv.vert.glsl?raw";
import falloutCompositeVertSrc from "../shaders/shared/map-quad.vert.glsl?raw";
export class FalloutLightPass {
private gl: WebGL2RenderingContext;
@@ -0,0 +1,234 @@
/**
* SmallPlayerGlowPass — a soft, breathing red aura around "small" players'
* territory. Tile-space bloom (extract mask -> separable blur -> additive
* composite), so it's camera-independent and cheap; mirrors FalloutBloomPass.
* A no-op unless the highlight set is non-empty.
*/
import type { RenderSettings } from "../RenderSettings";
import blurFragSrc from "../shaders/shared/blur.frag.glsl?raw";
import fullscreenNoUvVertSrc from "../shaders/shared/fullscreen-no-uv.vert.glsl?raw";
import fullscreenVertSrc from "../shaders/shared/fullscreen.vert.glsl?raw";
import compositeVertSrc from "../shaders/shared/map-quad.vert.glsl?raw";
import compositeFragSrc from "../shaders/small-player-glow/composite.frag.glsl?raw";
import extractFragSrc from "../shaders/small-player-glow/extract.frag.glsl?raw";
import { getPaletteSize } from "../utils/ColorUtils";
import {
createFullscreenQuad,
createMapQuad,
createProgram,
createRenderTarget,
createTexture2D,
type RenderTarget,
shaderSrc,
toScreen,
toTarget,
} from "../utils/GlUtils";
import { TILE_DEFINES } from "../utils/TileCodec";
const SET_TEX_WIDTH = getPaletteSize(); // 1 px per owner smallID
const BLOOM_TILE_SCALE = 4; // bloom buffers run at 1/scale tile resolution
export class SmallPlayerGlowPass {
private gl: WebGL2RenderingContext;
private settings: RenderSettings["smallPlayerGlow"];
private mapW: number;
private mapH: number;
private tileTex: WebGLTexture;
private extractProg: WebGLProgram;
private blurProg: WebGLProgram;
private compositeProg: WebGLProgram;
private uExtractMapSize: WebGLUniformLocation;
private uBlurDir: WebGLUniformLocation;
private uCompositeCam: WebGLUniformLocation;
private uCompositeMapSize: WebGLUniformLocation;
private uGlowColor: WebGLUniformLocation;
private uIntensity: WebGLUniformLocation;
private setTex: WebGLTexture;
private targetA: RenderTarget;
private targetB: RenderTarget;
private mapVao: WebGLVertexArrayObject;
private quadVao: WebGLVertexArrayObject;
private active = false;
private dirty = false; // aura needs rebuilding (set changed)
private animTime = 0;
private lastTime = 0;
constructor(
gl: WebGL2RenderingContext,
mapW: number,
mapH: number,
tileTex: WebGLTexture,
settings: RenderSettings["smallPlayerGlow"],
) {
this.gl = gl;
this.settings = settings;
this.mapW = mapW;
this.mapH = mapH;
this.tileTex = tileTex;
this.extractProg = createProgram(
gl,
fullscreenNoUvVertSrc,
shaderSrc(extractFragSrc, {
...TILE_DEFINES,
TILE_SCALE: BLOOM_TILE_SCALE,
}),
);
this.uExtractMapSize = gl.getUniformLocation(this.extractProg, "uMapSize")!;
gl.useProgram(this.extractProg);
gl.uniform1i(gl.getUniformLocation(this.extractProg, "uTileTex"), 0);
gl.uniform1i(gl.getUniformLocation(this.extractProg, "uHighlightSet"), 1);
this.blurProg = createProgram(gl, fullscreenVertSrc, blurFragSrc);
this.uBlurDir = gl.getUniformLocation(this.blurProg, "uDir")!;
gl.useProgram(this.blurProg);
gl.uniform1i(gl.getUniformLocation(this.blurProg, "uTex"), 0);
this.compositeProg = createProgram(gl, compositeVertSrc, compositeFragSrc);
this.uCompositeCam = gl.getUniformLocation(this.compositeProg, "uCamera")!;
this.uCompositeMapSize = gl.getUniformLocation(
this.compositeProg,
"uMapSize",
)!;
this.uGlowColor = gl.getUniformLocation(this.compositeProg, "uGlowColor")!;
this.uIntensity = gl.getUniformLocation(this.compositeProg, "uIntensity")!;
gl.useProgram(this.compositeProg);
gl.uniform1i(gl.getUniformLocation(this.compositeProg, "uTex"), 0);
this.setTex = createTexture2D(gl, {
width: SET_TEX_WIDTH,
height: 1,
internalFormat: gl.R8UI,
format: gl.RED_INTEGER,
type: gl.UNSIGNED_BYTE,
data: null,
filter: gl.NEAREST,
});
// ceil (not floor) so a partial tile block at the map's right/bottom edge
// still gets a bloom cell — otherwise an edge player on a map whose size
// isn't a multiple of the scale gets no glow.
const bw = Math.max(1, Math.ceil(mapW / BLOOM_TILE_SCALE));
const bh = Math.max(1, Math.ceil(mapH / BLOOM_TILE_SCALE));
this.targetA = createRenderTarget(gl, bw, bh);
this.targetB = createRenderTarget(gl, bw, bh);
this.mapVao = createMapQuad(gl, mapW, mapH);
this.quadVao = createFullscreenQuad(gl);
}
/** Push the highlight set (1 byte per owner smallID), or null to disable. */
update(set: Uint8Array | null): void {
if (set === null) {
this.active = false;
return;
}
const gl = this.gl;
gl.bindTexture(gl.TEXTURE_2D, this.setTex);
gl.texSubImage2D(
gl.TEXTURE_2D,
0,
0,
0,
Math.min(set.length, SET_TEX_WIDTH),
1,
gl.RED_INTEGER,
gl.UNSIGNED_BYTE,
set,
);
this.active = true;
this.dirty = true;
}
draw(cameraMatrix: Float32Array): void {
if (!this.active) return;
const gl = this.gl;
const s = this.settings;
const canvas = gl.canvas as HTMLCanvasElement;
const a = this.targetA;
const b = this.targetB;
const now = performance.now();
if (this.lastTime > 0) {
// Clamp the delta so a long gap (grace period, or a backgrounded tab
// pausing rAF) doesn't leap the pulse to a random phase on resume.
this.animTime += Math.min(now - this.lastTime, 100) * s.pulseSpeed;
}
this.lastTime = now;
const pulse = 0.5 + 0.5 * Math.sin(this.animTime);
// Rebuild the blurred aura only when the set changed (~1/s); its inputs
// don't move faster than that. The composite below still runs every frame.
if (this.dirty) {
gl.disable(gl.BLEND);
// Extract the small-player mask at sub-tile resolution.
gl.useProgram(this.extractProg);
gl.uniform2f(this.uExtractMapSize, this.mapW, this.mapH);
toTarget(gl, a, () => {
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, this.tileTex);
gl.activeTexture(gl.TEXTURE1);
gl.bindTexture(gl.TEXTURE_2D, this.setTex);
gl.bindVertexArray(this.quadVao);
gl.drawArrays(gl.TRIANGLES, 0, 6);
});
// Separable blur: horizontal into B, vertical back into A.
gl.useProgram(this.blurProg);
gl.activeTexture(gl.TEXTURE0);
toTarget(gl, b, () => {
gl.uniform2f(this.uBlurDir, 1 / a.w, 0);
gl.bindTexture(gl.TEXTURE_2D, a.tex);
gl.bindVertexArray(this.quadVao);
gl.drawArrays(gl.TRIANGLES, 0, 6);
});
toTarget(gl, a, () => {
gl.uniform2f(this.uBlurDir, 0, 1 / b.h);
gl.bindTexture(gl.TEXTURE_2D, b.tex);
gl.bindVertexArray(this.quadVao);
gl.drawArrays(gl.TRIANGLES, 0, 6);
});
this.dirty = false;
}
// Composite the cached aura over the map every frame. Premultiplied-over
// (not pure additive) so the glow keeps its color over bright terrain
// instead of washing out to white.
gl.enable(gl.BLEND);
gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
toScreen(gl, canvas.width, canvas.height, () => {
gl.useProgram(this.compositeProg);
gl.uniformMatrix3fv(this.uCompositeCam, false, cameraMatrix);
gl.uniform2f(this.uCompositeMapSize, this.mapW, this.mapH);
gl.uniform3fv(this.uGlowColor, s.color);
gl.uniform1f(this.uIntensity, s.alpha * pulse);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, a.tex);
gl.bindVertexArray(this.mapVao);
gl.drawArrays(gl.TRIANGLES, 0, 6);
});
gl.bindVertexArray(null);
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); // restore overlay default
}
dispose(): void {
const gl = this.gl;
gl.deleteProgram(this.extractProg);
gl.deleteProgram(this.blurProg);
gl.deleteProgram(this.compositeProg);
gl.deleteTexture(this.setTex);
gl.deleteTexture(this.targetA.tex);
gl.deleteTexture(this.targetB.tex);
gl.deleteFramebuffer(this.targetA.fbo);
gl.deleteFramebuffer(this.targetB.fbo);
gl.deleteVertexArray(this.mapVao);
gl.deleteVertexArray(this.quadVao);
// tileTex owned by GPUResources
}
}
@@ -331,6 +331,11 @@
"gradientInnerEdge": 0.01,
"gradientSolidEnd": 0.1
},
"smallPlayerGlow": {
"color": [1.0, 0.12, 0.1],
"alpha": 0.9,
"pulseSpeed": 0.003
},
"altView": {
"gridFontSize": 24,
"recolorStructures": true
@@ -1,11 +0,0 @@
#version 300 es
precision highp float;
layout(location = 0) in vec2 aPos;
uniform mat3 uCamera;
uniform vec2 uMapSize;
out vec2 vUV;
void main() {
vec3 clip = uCamera * vec3(aPos, 1.0);
gl_Position = vec4(clip.xy, 0.0, 1.0);
vUV = aPos / uMapSize;
}
@@ -1,11 +1,13 @@
#version 300 es
precision highp float;
layout(location = 0) in vec2 aPos;
uniform mat3 uCamera;
uniform vec2 uMapSize;
out vec2 vUV;
void main() {
vec3 clip = uCamera * vec3(aPos, 1.0);
gl_Position = vec4(clip.xy, 0.0, 1.0);
vUV = aPos / uMapSize;
}
#version 300 es
precision highp float;
// Camera-projected map quad -> tile-space UV. Shared by the bloom/glow
// composites so they sample an offscreen map-sized texture in world space.
layout(location = 0) in vec2 aPos;
uniform mat3 uCamera;
uniform vec2 uMapSize;
out vec2 vUV;
void main() {
vec3 clip = uCamera * vec3(aPos, 1.0);
gl_Position = vec4(clip.xy, 0.0, 1.0);
vUV = aPos / uMapSize;
}
@@ -0,0 +1,16 @@
#version 300 es
precision highp float;
// Tints the blurred aura with the glow color and the breathing intensity
// (alpha * pulse), premultiplied for an additive composite over the map.
uniform sampler2D uTex;
uniform vec3 uGlowColor;
uniform float uIntensity; // glow alpha * pulse (0 = invisible)
in vec2 vUV;
out vec4 fragColor;
void main() {
float g = texture(uTex, vUV).r;
float a = clamp(g * uIntensity, 0.0, 1.0);
fragColor = vec4(uGlowColor * a, a); // premultiplied
}
@@ -0,0 +1,30 @@
#version 300 es
precision highp float;
precision highp usampler2D;
// Emit 1 where a "small" player owns a tile. Territory is sparse (often lone
// tiles), so each cell scans its whole TILE_SCALE block instead of point-
// sampling, else single tiles get missed. OWNER_MASK/TILE_SCALE are injected.
uniform usampler2D uTileTex; // R16UI — tile state (owner in low bits)
uniform usampler2D uHighlightSet; // R8UI, 1px per owner — 1 = highlighted
uniform vec2 uMapSize;
out vec4 fragColor;
void main() {
ivec2 base = ivec2(gl_FragCoord.xy) * TILE_SCALE;
for (int j = 0; j < TILE_SCALE; j++) {
for (int i = 0; i < TILE_SCALE; i++) {
ivec2 tc = base + ivec2(i, j);
if (tc.x >= int(uMapSize.x) || tc.y >= int(uMapSize.y)) continue;
uint owner = texelFetch(uTileTex, tc, 0).r & uint(OWNER_MASK);
if (owner != 0u &&
texelFetch(uHighlightSet, ivec2(int(owner), 0), 0).r > 0u) {
fragColor = vec4(1.0);
return;
}
}
}
discard;
}
+31
View File
@@ -175,6 +175,37 @@ export function toScreen(
draw();
}
/**
* Create an RGBA8 render target (texture + FBO), LINEAR-filtered and clamped.
* For offscreen passes that ping-pong (blur, bloom).
*/
export function createRenderTarget(
gl: WebGL2RenderingContext,
w: number,
h: number,
): RenderTarget {
const tex = createTexture2D(gl, {
width: w,
height: h,
internalFormat: gl.RGBA8,
format: gl.RGBA,
type: gl.UNSIGNED_BYTE,
data: null,
filter: gl.LINEAR,
});
const fbo = gl.createFramebuffer()!;
gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
gl.framebufferTexture2D(
gl.FRAMEBUFFER,
gl.COLOR_ATTACHMENT0,
gl.TEXTURE_2D,
tex,
0,
);
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
return { fbo, tex, w, h };
}
export function createMapQuad(
gl: WebGL2RenderingContext,
mapWidth: number,
+11
View File
@@ -139,6 +139,10 @@ export class UserSettings {
return this.getBool("settings.emojis", true);
}
highlightSmallPlayers() {
return this.getBool("settings.highlightSmallPlayers", false);
}
performanceOverlay() {
return this.getBool(PERFORMANCE_OVERLAY_KEY, false);
}
@@ -191,6 +195,13 @@ export class UserSettings {
this.setBool("settings.emojis", !this.emojis());
}
toggleHighlightSmallPlayers() {
this.setBool(
"settings.highlightSmallPlayers",
!this.highlightSmallPlayers(),
);
}
// Performance overlay specifically needs a direct setter for Shift-D
setPerformanceOverlay(value: boolean) {
this.setBool(PERFORMANCE_OVERLAY_KEY, value);
+28
View File
@@ -58,3 +58,31 @@ describe("UserSettings effect selection", () => {
expect(s.getSelectedEffectName("hydro")).toBe("hydro_boom");
});
});
describe("UserSettings highlight small players", () => {
beforeEach(() => {
localStorage.clear();
(
UserSettings as unknown as { cache: Map<string, string | null> }
).cache.clear();
});
it("defaults to off", () => {
expect(new UserSettings().highlightSmallPlayers()).toBe(false);
});
it("toggles on and off", () => {
const s = new UserSettings();
s.toggleHighlightSmallPlayers();
expect(s.highlightSmallPlayers()).toBe(true);
s.toggleHighlightSmallPlayers();
expect(s.highlightSmallPlayers()).toBe(false);
});
it("shares state across instances via the static cache", () => {
// The settings modal and the renderer's frame builder each hold their own
// UserSettings; a toggle in one must be visible to the other.
new UserSettings().toggleHighlightSmallPlayers();
expect(new UserSettings().highlightSmallPlayers()).toBe(true);
});
});