Add black outline to alliance icon for terrain contrast (#4353)

## Problem

The green alliance icon above player names blends into similarly-colored
terrain — most notably irradiated land, which is the same green — making
it hard to spot allied players.

## Fix

Add a configurable dark outline to the alliance status icon, rendered in
the status-icon shader (the icons come from a pre-baked atlas with no
regeneration script, so this is done in-shader rather than by editing
the PNG).

- **Outline**: an alpha dilation gated to the alliance icon (slot 3).
8-direction sampling of the icon's alpha builds a black halo around its
silhouette; interior pixels and all other status icons are untouched.
- **No clipping**: the alliance icon's quad is grown outward into the
atlas cell's existing transparent padding so the halo isn't clipped at
the quad edge. The icon's on-screen size and position are unchanged; 8px
of the cell's 16px mipmap-safety padding is preserved.
- **Drain stays aligned**: the alliance-expiry drain effect's cut line
and faded-icon UVs are remapped into the expanded quad space so the
animation still lines up.
- **Tunable**: width is driven by `name.statusOutlineWidth` in
`render-settings.json` (default 6 texels; 0 disables), with a matching
"Status Outline Width" slider in the debug GUI.

## Testing

`tsc` and `eslint` pass. Verified in-game: the handshake now reads
clearly against irradiated terrain, with the outline rendering fully (no
edge clipping) and the drain animation still aligned.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Evan
2026-06-19 20:16:45 -07:00
committed by GitHub
parent 2f594ebc26
commit 08b8715667
6 changed files with 79 additions and 7 deletions
+2
View File
@@ -260,6 +260,8 @@ export interface RenderSettings {
nameShadeBot: number;
emojiRowOffset: number;
statusRowOffset: number;
/** Dark outline radius (atlas texels) drawn behind the alliance icon; 0 = off. */
statusOutlineWidth: number;
/** Alpha multiplier applied to a name while the cursor is over it. */
hoverFadeAlpha: number;
/** White glow behind the hovered player's name: px past the outline. */
+9
View File
@@ -355,6 +355,15 @@ export function buildTree(s: RenderSettings, d: RenderSettings): DebugNode[] {
toggle(s.name, "fillUsePlayerColor", d.name, "Fill = Player Color"),
slider(s.name, "emojiRowOffset", d.name, 0, 5, 0.1, "Emoji Row Offset"),
slider(s.name, "statusRowOffset", d.name, 0, 5, 0.1, "Status Row Offset"),
slider(
s.name,
"statusOutlineWidth",
d.name,
0,
16,
0.5,
"Status Outline Width",
),
slider(s.name, "hoverFadeAlpha", d.name, 0, 1, 0.05, "Hover Fade Alpha"),
slider(s.name, "hoverGlowWidth", d.name, 0, 8, 0.25, "Hover Glow Width"),
slider(s.name, "hoverGlowAlpha", d.name, 0, 1, 0.05, "Hover Glow Alpha"),
@@ -40,6 +40,7 @@ export class StatusIconProgram {
private uStatusRowOffset: WebGLUniformLocation;
private uFadeOwnerID: WebGLUniformLocation;
private uHoverFadeAlpha: WebGLUniformLocation;
private uStatusOutlinePx: WebGLUniformLocation;
constructor(
gl: WebGL2RenderingContext,
@@ -83,6 +84,12 @@ export class StatusIconProgram {
gl.getUniformLocation(this.program, "uStatusPad")!,
sm.pad ?? 0,
);
// Texel size for the outline dilation sampling (static).
gl.uniform2f(
gl.getUniformLocation(this.program, "uStatusTexel")!,
1 / sm.width,
1 / sm.height,
);
// Flash window matches the alliance renewal prompt (10 ticks/sec)
gl.uniform1f(
gl.getUniformLocation(this.program, "uAllianceFlashWindowSec")!,
@@ -111,6 +118,10 @@ export class StatusIconProgram {
this.program,
"uHoverFadeAlpha",
)!;
this.uStatusOutlinePx = gl.getUniformLocation(
this.program,
"uStatusOutlinePx",
)!;
this.loadAtlas();
}
@@ -159,6 +170,7 @@ export class StatusIconProgram {
gl.uniform1f(this.uStatusRowOffset, ns.statusRowOffset);
gl.uniform1f(this.uFadeOwnerID, fadeOwnerID);
gl.uniform1f(this.uHoverFadeAlpha, ns.hoverFadeAlpha);
gl.uniform1f(this.uStatusOutlinePx, ns.statusOutlineWidth);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, this.playerDataTex);
@@ -216,6 +216,7 @@
"nameShadeBot": 0.4,
"emojiRowOffset": 1.4,
"statusRowOffset": 1.4,
"statusOutlineWidth": 6,
"hoverFadeAlpha": 0.5,
"hoverGlowWidth": 5,
"hoverGlowAlpha": 0.75
@@ -2,6 +2,8 @@
precision highp float;
uniform sampler2D uStatusAtlas;
uniform vec2 uStatusTexel; // 1/atlasW, 1/atlasH
uniform float uStatusOutlinePx; // outline radius in atlas texels (0 = off)
in vec2 vUV;
in vec2 vLocalUV;
@@ -10,10 +12,18 @@ flat in float vAllianceFraction;
flat in vec2 vFadedUV0;
flat in vec2 vFadedUV1;
flat in float vFlashAlpha;
flat in float vOutline; // 1.0 = draw a dark outline behind this icon
in float vHoverAlpha;
out vec4 fragColor;
// 8 unit directions for the outline dilation sample ring.
const vec2 kRing[8] = vec2[8](
vec2(1.0, 0.0), vec2(-1.0, 0.0), vec2(0.0, 1.0), vec2(0.0, -1.0),
vec2(0.707, 0.707), vec2(-0.707, 0.707),
vec2(0.707, -0.707), vec2(-0.707, -0.707)
);
void main() {
if (vDiscard != 0) discard;
@@ -33,8 +43,23 @@ void main() {
texel = vLocalUV.y < topCut ? fadedTexel : texel;
}
// Traitor flash: modulate alpha for urgency pulse
texel.a *= vFlashAlpha * vHoverAlpha;
// Traitor flash + hover fade: modulate alpha
float fade = vFlashAlpha * vHoverAlpha;
texel.a *= fade;
// Dark outline: dilate the icon's alpha so it stays legible over terrain of a
// similar color (the green alliance icon vs. irradiated land). Sampling the
// padded atlas cell never reaches a neighbouring icon.
if (vOutline > 0.5 && uStatusOutlinePx > 0.0) {
float ring = 0.0;
vec2 sampleStep = uStatusTexel * uStatusOutlinePx;
for (int i = 0; i < 8; i++) {
ring = max(ring, texture(uStatusAtlas, vUV + kRing[i] * sampleStep).a);
}
ring *= fade;
float outlineA = ring * (1.0 - texel.a);
texel = vec4(mix(vec3(0.0), texel.rgb, texel.a), max(texel.a, outlineA));
}
if (texel.a < 0.01) discard;
fragColor = texel;
@@ -24,6 +24,7 @@ uniform float uStatusCols; // columns in atlas
uniform float uStatusAtlasW; // atlas texture width
uniform float uStatusAtlasH; // atlas texture height
uniform float uStatusPad; // transparent padding in texels per side
uniform float uStatusOutlinePx; // dark-outline radius in atlas texels (0 = off)
// Configurable layout
uniform float uStatusRowOffset; // row Y offset (multiples of uFontBase * nameWorldScale)
@@ -39,6 +40,7 @@ flat out float vAllianceFraction; // 0 = no drain effect, >0 = active drain
flat out vec2 vFadedUV0; // top-left UV of faded alliance cell
flat out vec2 vFadedUV1; // bottom-right UV of faded alliance cell
flat out float vFlashAlpha; // traitor flash opacity (1.0 = fully visible)
flat out float vOutline; // 1.0 = alliance icon, draw a dark outline
out float vHoverAlpha;
// Status flag float array — indexed by icon slot.
@@ -103,6 +105,7 @@ void main() {
vFadedUV0 = vec2(0.0);
vFadedUV1 = vec2(0.0);
vFlashAlpha = 1.0;
vOutline = 0.0;
vHoverAlpha = 1.0;
return;
}
@@ -120,6 +123,7 @@ void main() {
vFadedUV0 = vec2(0.0);
vFadedUV1 = vec2(0.0);
vFlashAlpha = 1.0;
vOutline = 0.0;
vHoverAlpha = 1.0;
return;
}
@@ -149,6 +153,7 @@ void main() {
vFadedUV0 = vec2(0.0);
vFadedUV1 = vec2(0.0);
vFlashAlpha = 1.0;
vOutline = 0.0;
vHoverAlpha = 1.0;
return;
}
@@ -180,23 +185,41 @@ void main() {
atlasIdx = (pd7.x > 0.5) ? 7 : 8;
}
// Only the alliance icon (slot 3) gets the dark outline.
vOutline = (iconSlot == 3) ? 1.0 : 0.0;
// Fade the status row along with the rest of the name plate when the cursor
// is over any part of it. Hit test runs on the CPU (NamePass).
vHoverAlpha = (uFadeOwnerID > 0.0 && pd4.z == uFadeOwnerID)
? uHoverFadeAlpha : 1.0;
// Quad world position
vec2 iconOrigin = vec2(iconX, iconY);
vec2 worldPos = iconOrigin + aPos * vec2(iconWorldSize, iconWorldSize);
// Dark-outline margin: grow the alliance icon's quad outward into the cell's
// transparent padding so the outline halo isn't clipped at the quad edge.
// The icon content keeps its size; only the quad's bounding box grows. Other
// icons keep marginWorld = 0 and render pixel-identically.
float iconTexels = uStatusCell - 2.0 * uStatusPad;
float marginTex = (vOutline > 0.5 && uStatusOutlinePx > 0.0)
? min(uStatusPad - 2.0, uStatusOutlinePx + 2.0)
: 0.0;
float marginWorld = marginTex * (iconWorldSize / iconTexels);
// Quad world position (expanded by the outline margin, centred on the icon)
vec2 iconOrigin = vec2(iconX, iconY) - vec2(marginWorld);
float quadSize = iconWorldSize + 2.0 * marginWorld;
vec2 worldPos = iconOrigin + aPos * vec2(quadSize, quadSize);
// Camera transform
vec3 clip = uCamera * vec3(worldPos, 1.0);
gl_Position = vec4(clip.xy, 0.0, 1.0);
// vLocalUV in icon-content space: 0..1 over the icon, <0/>1 in the outline
// margin. This keeps the drain math below unchanged and samples the
// transparent padding (never a neighbour) when the quad is expanded.
vLocalUV = (aPos * quadSize - marginWorld) / iconWorldSize;
// UV from atlas grid (padded to avoid mipmap bleed)
vec4 uv = cellUV(atlasIdx);
vUV = vec2(mix(uv.x, uv.z, aPos.x), mix(uv.y, uv.w, aPos.y));
vLocalUV = aPos;
vUV = vec2(mix(uv.x, uv.z, vLocalUV.x), mix(uv.y, uv.w, vLocalUV.y));
// Alliance drain: slot 3 = alliance icon
float allianceFrac = pd7.z;