mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 09:10:42 +00:00
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:
@@ -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. */
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user