Files
OpenFrontIO/src/client/render/gl/shaders/name/status-icon.vert.glsl
T
766fda991a Render custom crown cosmetics above player names (#4619)
## What

Renders each player's crown cosmetic in-game (follow-up to #4618). The
crown **skins the first-place crown**: when the tile leader owns a crown
cosmetic, their image replaces the default crown icon above their name —
on the map name plate and in the hover overlay. Players without the
cosmetic keep the default crown.

The image pipeline follows custom flags: the server-resolved crown URL
rides `PlayerStatic` into the GPU name pass and loads on demand into a
runtime `TEXTURE_2D_ARRAY` (layers deduped by URL, square 128×128
cells).

## Changes

**GL name pass**
- `FlagAtlasArray` cell size is now a constructor parameter; a second
instance holds crown images (square cells, no letterbox margins)
- Per-player data texture widens 8 → 9 columns (`PLAYER_DATA_COLS`);
column 8 carries the crown atlas layer
- `status-icon.vert/frag.glsl`: status slot 0 (first-place crown)
samples the crown atlas instead of the status atlas when the player has
a crown cosmetic — same size and position as the default crown
- `WebGLFrameBuilder.syncPlayers` resolves `cosmetics.crown.url` →
`PlayerStatic.crown`

**DOM**
- `getPlayerIcons` swaps the first-place crown icon src for the cosmetic
image (hover overlay)

**Dev hook**: `localStorage.setItem("dev-crown", "<image url>")` forces
a crown in singleplayer (mirrors `dev-pattern`)

## Verified

Drove a headless solo game (real-GPU WebGL via ANGLE Metal) with the
dev-crown hook, expanded until first place — the cosmetic replaces the
default crown above the name on the map and in the hover overlay
(default `CrownIcon.svg` absent, cosmetic present).

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 15:24:25 -07:00

293 lines
11 KiB
GLSL
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#version 300 es
precision highp float;
precision highp int;
// Unit quad vertex position [0,0]→[1,1]
layout(location = 0) in vec2 aPos;
// Data textures (shared with name shader)
uniform sampler2D uPlayerData; // 8 × MAX_PLAYERS, RGBA32F
// Uniforms
uniform mat3 uCamera;
uniform float uTime;
uniform float uLerpSpeed;
uniform float uCullThreshold;
uniform float uNameScaleFactor;
uniform float uNameScaleCap;
uniform float uFontSize;
uniform float uFontBase;
// Status atlas layout
uniform float uStatusCell; // texels per cell (square)
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)
uniform float uFadeOwnerID; // smallID of player whose name plate the cursor is over (0 = none)
uniform float uHoverFadeAlpha; // alpha multiplier applied to that player's name plate
uniform float uAllianceFlashWindowSec; // seconds before expiry the alliance icon flashes (= renewal prompt offset)
out vec2 vUV;
out vec2 vLocalUV; // 0..1 within the icon cell
flat out int vCrownLayer; // crown-cosmetic atlas layer for slot 0, or -1
flat out int vDiscard;
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.
// Slot mapping: 0=crown, 1=traitor, 2=disconnected, 3=alliance,
// 4=allianceReq, 5=target, 6=embargo, 7=nukeActive, 8=doomsdayClock
float statusFlag[9];
// Read status flags from pd4.w/pd5/pd6 into the statusFlag array.
void readStatusFlags(int playerIdx) {
vec4 pd4 = texelFetch(uPlayerData, ivec2(4, playerIdx), 0);
vec4 pd5 = texelFetch(uPlayerData, ivec2(5, playerIdx), 0);
vec4 pd6 = texelFetch(uPlayerData, ivec2(6, playerIdx), 0);
statusFlag[0] = pd5.x; // crown
statusFlag[1] = pd5.y; // traitor
statusFlag[2] = pd5.z; // disconnected
statusFlag[3] = pd5.w; // alliance
statusFlag[4] = pd6.x; // allianceReq
statusFlag[5] = pd6.y; // target
statusFlag[6] = pd6.z; // embargo
statusFlag[7] = pd6.w; // nukeActive
statusFlag[8] = pd4.w; // doomsdayClock
}
// Count active icons with index < pos.
int countBelow(int pos) {
int count = 0;
for (int i = 0; i < pos; i++) {
if (statusFlag[i] > 0.5) count++;
}
return count;
}
// Compute padded UV rect for an atlas cell.
// Returns (u0, v0) in xy and (u1, v1) in zw, inset by pad pixels.
vec4 cellUV(int idx) {
int col = idx - (idx / int(uStatusCols)) * int(uStatusCols);
int row = idx / int(uStatusCols);
float u0 = (float(col) * uStatusCell + uStatusPad) / uStatusAtlasW;
float v0 = (float(row) * uStatusCell + uStatusPad) / uStatusAtlasH;
float iconSize = uStatusCell - 2.0 * uStatusPad;
float u1 = u0 + iconSize / uStatusAtlasW;
float v1 = v0 + iconSize / uStatusAtlasH;
return vec4(u0, v0, u1, v1);
}
void main() {
// Decode instance ID → playerIdx + iconSlot (0..8)
int playerIdx = gl_InstanceID / 9;
int iconSlot = gl_InstanceID - playerIdx * 9;
// Read player data
vec4 pd0 = texelFetch(uPlayerData, ivec2(0, playerIdx), 0); // srcX, srcY, srcScale, startTime
vec4 pd1 = texelFetch(uPlayerData, ivec2(1, playerIdx), 0); // tgtX, tgtY, tgtScale, alive
vec4 pd4 = texelFetch(uPlayerData, ivec2(4, playerIdx), 0); // flagIdx, emojiIdx, smallID, [free]
vec4 pd7 = texelFetch(uPlayerData, ivec2(7, playerIdx), 0); // nukeTargetsMe, traitorRemainingTicks, allianceFraction, allianceRemainingTicks
vec4 pd8 = texelFetch(uPlayerData, ivec2(8, playerIdx), 0); // crownLayer, [free]
// A crown cosmetic skins the first-place crown (slot 0).
vCrownLayer = (iconSlot == 0 && pd8.x >= 0.0) ? int(pd8.x) : -1;
// Early out: dead player OR emoji is active
if (pd1.w <= 0.0 || pd4.y >= 0.0) {
gl_Position = vec4(0.0);
vUV = vec2(0.0);
vLocalUV = vec2(0.0);
vDiscard = 1;
vAllianceFraction = 0.0;
vFadedUV0 = vec2(0.0);
vFadedUV1 = vec2(0.0);
vFlashAlpha = 1.0;
vOutline = 0.0;
vHoverAlpha = 1.0;
return;
}
// Read status flags into array
readStatusFlags(playerIdx);
// Early out: this icon slot is inactive
if (statusFlag[iconSlot] < 0.5) {
gl_Position = vec4(0.0);
vUV = vec2(0.0);
vLocalUV = vec2(0.0);
vDiscard = 1;
vAllianceFraction = 0.0;
vFadedUV0 = vec2(0.0);
vFadedUV1 = vec2(0.0);
vFlashAlpha = 1.0;
vOutline = 0.0;
vHoverAlpha = 1.0;
return;
}
// Lerped world position and size (same math as name.vert.glsl)
float elapsed = uTime - pd0.w;
float t = clamp(1.0 - exp(-uLerpSpeed * elapsed), 0.0, 1.0);
float wx = mix(pd0.x, pd1.x, t);
float wy = mix(pd0.y, pd1.y, t);
float ws = mix(pd0.z, pd1.z, t);
// Sizing pipeline (must match name.vert.glsl exactly)
float baseSize = max(1.0, floor(ws));
float nameSize = max(4.0, floor(baseSize * uNameScaleFactor));
float nameScale = min(baseSize * 0.25, uNameScaleCap);
float nameWorldScale = (nameSize * nameScale) / uFontSize;
// Zoom-based culling (same as name shader)
float cameraScale = length(vec2(uCamera[0][0], uCamera[1][0]));
float screenSize = nameWorldScale * uFontBase * cameraScale;
if (screenSize < uCullThreshold) {
gl_Position = vec4(0.0);
vUV = vec2(0.0);
vLocalUV = vec2(0.0);
vDiscard = 1;
vAllianceFraction = 0.0;
vFadedUV0 = vec2(0.0);
vFadedUV1 = vec2(0.0);
vFlashAlpha = 1.0;
vOutline = 0.0;
vHoverAlpha = 1.0;
return;
}
// Icon world size: matches name text height
float iconWorldSize = uFontBase * nameWorldScale * 1.1;
// Count active icons and position of this one (left-to-right)
int totalActive = 0;
for (int i = 0; i < 9; i++) {
if (statusFlag[i] > 0.5) totalActive++;
}
int myIndex = countBelow(iconSlot);
// Horizontal centering: spread icons evenly above the name
float gap = iconWorldSize * 0.15;
float totalWidth = float(totalActive) * iconWorldSize + float(totalActive - 1) * gap;
float startX = wx - totalWidth * 0.5;
float iconX = startX + float(myIndex) * (iconWorldSize + gap);
// Position: row above the emoji row
float iconY = wy - uFontBase * nameWorldScale * uStatusRowOffset;
// Determine atlas index
// Slots 0-6 map directly to atlas indices 0-6
// Slot 7 (nuke): use nukeRed (7) if nukeTargetsMe, else nukeWhite (8)
int atlasIdx = iconSlot;
if (iconSlot == 7) {
atlasIdx = (pd7.x > 0.5) ? 7 : 8;
}
if (iconSlot == 8) {
atlasIdx = 10; // doomsday-clock skull
}
// 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;
// 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, vLocalUV.x), mix(uv.y, uv.w, vLocalUV.y));
// Alliance drain: slot 3 = alliance icon
float allianceFrac = pd7.z;
if (iconSlot == 3 && allianceFrac > 0.0 && allianceFrac < 1.0) {
vAllianceFraction = allianceFrac;
// Faded alliance icon is at atlas index 9
vec4 fadedUV = cellUV(9);
vFadedUV0 = fadedUV.xy;
vFadedUV1 = fadedUV.zw;
} else {
vAllianceFraction = 0.0;
vFadedUV0 = vec2(0.0);
vFadedUV1 = vec2(0.0);
}
// Traitor flash: slot 1 = traitor icon
// Frequency ramps linearly from 2 Hz (at 15s) to 5 Hz (at 0s).
// Phase = uTime*2 + elapsed²*0.1 — the quadratic term adds smooth
// acceleration without phase discontinuities between ticks.
vFlashAlpha = 1.0;
if (iconSlot == 1) {
float remaining = pd7.y; // ticks (0-300, 10/sec)
float remainingSec = remaining / 10.0; // seconds
if (remainingSec <= 15.0 && remainingSec > 0.0) {
float elapsed = 15.0 - remainingSec;
float phase = uTime * 2.0 + elapsed * elapsed * 0.1;
vFlashAlpha = 0.3 + 0.7 * (0.5 + 0.5 * cos(phase * 6.2832));
}
}
// Alliance expiry flash: slot 3 = alliance icon
// Window matches the renewal prompt offset so the icon flashes exactly
// while the prompt is up. Same pulse as the traitor flash (2 Hz → 5 Hz).
if (iconSlot == 3) {
float window = uAllianceFlashWindowSec;
float remainingSec = pd7.w / 10.0; // ticks → seconds
if (window > 0.0 && remainingSec <= window && remainingSec > 0.0) {
float elapsed = window - remainingSec;
float phase = uTime * 2.0 + elapsed * elapsed * (1.5 / window);
vFlashAlpha = 0.3 + 0.7 * (0.5 + 0.5 * cos(phase * 6.2832));
}
}
// Doomsday Clock skull: slot 8. While in danger (flag 1.0-1.49) the skull
// blinks and speeds up as the warn countdown runs out — the progress 0->1 is
// packed into the flag's fraction. Base ~2 Hz plus a p^2 phase term so it
// accelerates toward the drain (same trick as the alliance-renewal flash
// above, which keeps the phase continuous). Holds steady once draining (>=1.5).
if (iconSlot == 8) {
if (statusFlag[8] < 1.5) {
float p = clamp((statusFlag[8] - 1.0) / 0.49, 0.0, 1.0);
float phase = uTime * 2.0 + p * p * 40.0;
vFlashAlpha = 0.35 + 0.65 * (0.5 + 0.5 * cos(phase * 6.2832));
} else {
vFlashAlpha = 1.0;
}
}
vDiscard = 0;
}