mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-18 15:33:32 +00:00
## 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>
293 lines
11 KiB
GLSL
293 lines
11 KiB
GLSL
#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;
|
||
}
|