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>
This commit is contained in:
Evan
2026-07-15 15:24:25 -07:00
committed by GitHub
co-authored by Claude Fable 5
parent 8f1bbdd20e
commit 766fda991a
14 changed files with 227 additions and 52 deletions
+5 -1
View File
@@ -790,7 +790,11 @@ export async function getPlayerCosmetics(): Promise<PlayerCosmetics> {
}
}
if (refs.crownName && cosmetics) {
const devCrown = new UserSettings().getDevOnlyCrown();
if (devCrown) {
result.crown = { name: "dev_crown", url: devCrown };
} else if (refs.crownName && cosmetics) {
const crown = cosmetics.crowns?.[refs.crownName];
if (crown) {
result.crown = { name: refs.crownName, url: crown.url };
+5
View File
@@ -407,6 +407,10 @@ export class WebGLFrameBuilder {
const flagRef = p.cosmetics.flag;
const flagUrl = flagRef ? assetUrl(flagRef) : undefined;
// Crown cosmetic: already server-resolved to the catalog image URL.
const crownRef = p.cosmetics.crown?.url;
const crownUrl = crownRef ? assetUrl(crownRef) : undefined;
const skin = p.cosmetics.skin;
if (skin?.url) {
this.view.setPlayerSkin(smallID, assetUrl(skin.url));
@@ -437,6 +441,7 @@ export class WebGLFrameBuilder {
// is always the real name.
displayName: p.displayName(),
flag: flagUrl,
crown: crownUrl,
color: p.territoryColor().toHex(),
});
}
+7 -2
View File
@@ -112,9 +112,14 @@ export function getPlayerIcons(
const icons: PlayerIconDescriptor[] = [];
// Crown icon for first place
// Crown icon for first place — a crown cosmetic skins the default icon.
if (player === firstPlace) {
icons.push({ id: CROWN_ICON_ID, kind: IMAGE_ICON_KIND, src: crownIcon });
const crownUrl = player.cosmetics.crown?.url;
icons.push({
id: CROWN_ICON_ID,
kind: IMAGE_ICON_KIND,
src: crownUrl ? assetUrl(crownUrl) : crownIcon,
});
}
// Traitor icon
@@ -73,17 +73,20 @@ export function buildStringTex(
});
}
/** Player data: 8 x maxPlayers, RGBA32F. Dynamic. */
/** Columns in the per-player data texture (see NamePass.writePlayerDataRow). */
export const PLAYER_DATA_COLS = 9;
/** Player data: PLAYER_DATA_COLS x maxPlayers, RGBA32F. Dynamic. */
export function buildPlayerDataTex(
gl: WebGL2RenderingContext,
maxPlayers: number,
): WebGLTexture {
return createTexture2D(gl, {
width: 8,
width: PLAYER_DATA_COLS,
height: maxPlayers,
internalFormat: gl.RGBA32F,
format: gl.RGBA,
type: gl.FLOAT,
data: new Float32Array(8 * maxPlayers * 4),
data: new Float32Array(PLAYER_DATA_COLS * maxPlayers * 4),
});
}
@@ -1,22 +1,25 @@
/**
* FlagAtlasArray — runtime TEXTURE_2D_ARRAY of player flag images.
* FlagAtlasArray — runtime TEXTURE_2D_ARRAY of player icon images.
*
* Replaces the build-time flag atlas. Layers are assigned on demand as players
* arrive, keyed by URL so identical flags share a layer (every "Mercia" bot
* costs one slot, not one per player). Images are fetched async and drawn into
* a fixed-size cell so all layers have the same dimensions.
* Replaces the build-time flag atlas. One instance holds flags (3:2 cells);
* a second instance holds crown cosmetics (square cells). Layers are assigned
* on demand as players arrive, keyed by URL so identical images share a layer
* (every "Mercia" bot costs one slot, not one per player). Images are fetched
* async and drawn into a fixed-size cell so all layers have the same
* dimensions.
*
* When a layer becomes ready, `onLayerReady(url, layer)` fires so the owning
* pass can flip slots from -1 to the assigned layer.
*
* Layers are not reclaimed; if the cap is hit, further requests return -1 and
* render no icon.
* Layers are not reclaimed. When the initial capacity fills, the array doubles
* (existing layers are copied GPU-side); at the hardware layer cap further
* requests render no icon.
*/
const FLAG_CELL_W = 128;
const FLAG_CELL_H = 85;
export const FLAG_CELL_W = 128;
export const FLAG_CELL_H = 85;
/** Hard cap on unique flags per game. Real working set is ~50200. */
/** Initial unique-flag capacity. Real working set is ~50200. */
export const MAX_FLAG_LAYERS = 512;
interface PendingEntry {
@@ -28,6 +31,7 @@ export class FlagAtlasArray {
private gl: WebGL2RenderingContext;
private tex: WebGLTexture;
private layerCount: number;
private readonly hwMaxLayers: number;
private nextLayer = 0;
private entries = new Map<string, PendingEntry>();
@@ -39,22 +43,35 @@ export class FlagAtlasArray {
constructor(
gl: WebGL2RenderingContext,
onLayerReady: (url: string, layer: number) => void,
private cellW: number = FLAG_CELL_W,
private cellH: number = FLAG_CELL_H,
initialLayers: number = MAX_FLAG_LAYERS,
) {
this.gl = gl;
this.onLayerReady = onLayerReady;
const maxLayers = gl.getParameter(gl.MAX_ARRAY_TEXTURE_LAYERS) as number;
this.layerCount = Math.min(MAX_FLAG_LAYERS, maxLayers);
this.hwMaxLayers = gl.getParameter(gl.MAX_ARRAY_TEXTURE_LAYERS) as number;
this.layerCount = Math.min(initialLayers, this.hwMaxLayers);
this.tex = this.allocTexture(this.layerCount);
this.tex = gl.createTexture()!;
gl.bindTexture(gl.TEXTURE_2D_ARRAY, this.tex);
this.canvas = document.createElement("canvas");
this.canvas.width = this.cellW;
this.canvas.height = this.cellH;
this.ctx = this.canvas.getContext("2d", { willReadFrequently: false })!;
}
/** Allocate the immutable texture array; leaves it bound to TEXTURE_2D_ARRAY. */
private allocTexture(layerCount: number): WebGLTexture {
const gl = this.gl;
const tex = gl.createTexture()!;
gl.bindTexture(gl.TEXTURE_2D_ARRAY, tex);
gl.texStorage3D(
gl.TEXTURE_2D_ARRAY,
mipLevels(FLAG_CELL_W, FLAG_CELL_H),
mipLevels(this.cellW, this.cellH),
gl.RGBA8,
FLAG_CELL_W,
FLAG_CELL_H,
this.layerCount,
this.cellW,
this.cellH,
layerCount,
);
gl.texParameteri(
gl.TEXTURE_2D_ARRAY,
@@ -64,11 +81,52 @@ export class FlagAtlasArray {
gl.texParameteri(gl.TEXTURE_2D_ARRAY, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D_ARRAY, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D_ARRAY, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
return tex;
}
this.canvas = document.createElement("canvas");
this.canvas.width = FLAG_CELL_W;
this.canvas.height = FLAG_CELL_H;
this.ctx = this.canvas.getContext("2d", { willReadFrequently: false })!;
/**
* Double the layer capacity, copying existing layers into the new texture
* GPU-side (texStorage3D is immutable, so growth means a new allocation).
* Returns false at the hardware layer cap. Draw calls pick up the new
* texture automatically — owners bind `this.texture` every frame.
*/
private grow(): boolean {
const newCount = Math.min(this.layerCount * 2, this.hwMaxLayers);
if (newCount <= this.layerCount) return false;
const gl = this.gl;
const newTex = this.allocTexture(newCount);
const fb = gl.createFramebuffer()!;
gl.bindFramebuffer(gl.READ_FRAMEBUFFER, fb);
for (let layer = 0; layer < this.nextLayer; layer++) {
gl.framebufferTextureLayer(
gl.READ_FRAMEBUFFER,
gl.COLOR_ATTACHMENT0,
this.tex,
0,
layer,
);
gl.copyTexSubImage3D(
gl.TEXTURE_2D_ARRAY,
0,
0,
0,
layer,
0,
0,
this.cellW,
this.cellH,
);
}
gl.bindFramebuffer(gl.READ_FRAMEBUFFER, null);
gl.deleteFramebuffer(fb);
gl.generateMipmap(gl.TEXTURE_2D_ARRAY);
gl.deleteTexture(this.tex);
this.tex = newTex;
this.layerCount = newCount;
return true;
}
get texture(): WebGLTexture {
@@ -87,7 +145,9 @@ export class FlagAtlasArray {
*/
request(url: string): void {
if (this.entries.has(url)) return;
if (this.nextLayer >= this.layerCount) return; // hit cap → no icon
if (this.nextLayer >= this.layerCount && !this.grow()) {
return; // hardware layer cap → no icon
}
const layer = this.nextLayer++;
const entry: PendingEntry = { layer, ready: false };
@@ -97,20 +157,20 @@ export class FlagAtlasArray {
img.crossOrigin = "anonymous";
img.onload = () => {
// Draw into a fixed-size cell to normalize the image to layer dimensions.
// Center via aspect-fit so non-3:2 flags don't stretch.
this.ctx.clearRect(0, 0, FLAG_CELL_W, FLAG_CELL_H);
// Center via aspect-fit so mismatched images don't stretch.
this.ctx.clearRect(0, 0, this.cellW, this.cellH);
const srcAspect = img.width / img.height;
const dstAspect = FLAG_CELL_W / FLAG_CELL_H;
const dstAspect = this.cellW / this.cellH;
let dw: number, dh: number;
if (srcAspect > dstAspect) {
dw = FLAG_CELL_W;
dh = FLAG_CELL_W / srcAspect;
dw = this.cellW;
dh = this.cellW / srcAspect;
} else {
dh = FLAG_CELL_H;
dw = FLAG_CELL_H * srcAspect;
dh = this.cellH;
dw = this.cellH * srcAspect;
}
const dx = (FLAG_CELL_W - dw) * 0.5;
const dy = (FLAG_CELL_H - dh) * 0.5;
const dx = (this.cellW - dw) * 0.5;
const dy = (this.cellH - dh) * 0.5;
this.ctx.drawImage(img, dx, dy, dw, dh);
const gl = this.gl;
@@ -121,8 +181,8 @@ export class FlagAtlasArray {
0,
0,
layer,
FLAG_CELL_W,
FLAG_CELL_H,
this.cellW,
this.cellH,
1,
gl.RGBA,
gl.UNSIGNED_BYTE,
@@ -135,7 +195,7 @@ export class FlagAtlasArray {
};
img.onerror = () => {
// Leave entry as not-ready forever; layer is consumed but harmless.
console.warn("Flag image failed to load:", url);
console.warn("Icon image failed to load:", url);
};
img.src = url;
}
@@ -13,14 +13,13 @@ import iconFragSrc from "../../shaders/name/icon.frag.glsl?raw";
import iconVertSrc from "../../shaders/name/icon.vert.glsl?raw";
import { createProgram } from "../../utils/GlUtils";
import type { FlagAtlasArray } from "./FlagAtlasArray";
import { FLAG_CELL_H, FLAG_CELL_W } from "./FlagAtlasArray";
import type { ParsedAtlas } from "./Types";
const emojiAtlasUrl = assetUrl("atlases/emoji-atlas.png");
// Must match FLAG_CELL_W / FLAG_CELL_H in FlagAtlasArray.ts. Used only for
// world-space aspect ratio of the flag quad.
const FLAG_CELL_W = 128;
const FLAG_CELL_H = 85;
/** Icon instances per player: flag, emoji (must match icon.vert.glsl). */
const ICONS_PER_PLAYER = 2;
export class IconProgram {
private gl: WebGL2RenderingContext;
@@ -175,7 +174,12 @@ export class IconProgram {
gl.bindTexture(gl.TEXTURE_2D, this.emojiAtlasTex!);
gl.bindVertexArray(vao);
gl.drawArraysInstanced(gl.TRIANGLES, 0, 6, this.maxPlayers * 2);
gl.drawArraysInstanced(
gl.TRIANGLES,
0,
6,
this.maxPlayers * ICONS_PER_PLAYER,
);
}
dispose(): void {
@@ -15,6 +15,7 @@ import type { RenderSettings } from "../../RenderSettings";
import statusFragSrc from "../../shaders/name/status-icon.frag.glsl?raw";
import statusVertSrc from "../../shaders/name/status-icon.vert.glsl?raw";
import { createProgram } from "../../utils/GlUtils";
import type { FlagAtlasArray } from "./FlagAtlasArray";
import type { ParsedAtlas } from "./Types";
const statusAtlasUrl = assetUrl("atlases/status-atlas.png");
@@ -46,6 +47,8 @@ export class StatusIconProgram {
gl: WebGL2RenderingContext,
atlas: ParsedAtlas,
playerDataTex: WebGLTexture,
// Crown-cosmetic images; skins the first-place crown (slot 0).
private crownAtlas: FlagAtlasArray,
maxPlayers: number,
allianceFlashWindowTicks: number,
) {
@@ -59,6 +62,7 @@ export class StatusIconProgram {
// Texture unit bindings
gl.uniform1i(gl.getUniformLocation(this.program, "uPlayerData"), 0);
gl.uniform1i(gl.getUniformLocation(this.program, "uStatusAtlas"), 1);
gl.uniform1i(gl.getUniformLocation(this.program, "uCrownAtlas"), 2);
// Static uniforms from atlas metadata
const sm = statusAtlasMeta as any;
@@ -176,6 +180,8 @@ export class StatusIconProgram {
gl.bindTexture(gl.TEXTURE_2D, this.playerDataTex);
gl.activeTexture(gl.TEXTURE1);
gl.bindTexture(gl.TEXTURE_2D, this.statusAtlasTex!);
gl.activeTexture(gl.TEXTURE2);
gl.bindTexture(gl.TEXTURE_2D_ARRAY, this.crownAtlas.texture);
gl.bindVertexArray(vao);
gl.drawArraysInstanced(
@@ -60,8 +60,12 @@ export interface PlayerSlot {
lastTroopBucket: number;
/** URL identifying which flag this player wants (dedup key). undefined = none. */
flagUrl: string | undefined;
/** Layer index in FlagAtlasArray, or -1 if not loaded yet / no flag. */
/** Layer index in the flag atlas array, or -1 if not loaded yet / no flag. */
flagLayerIdx: number;
/** URL of this player's crown cosmetic (dedup key). undefined = none. */
crownUrl: string | undefined;
/** Layer index in the crown atlas array, or -1 if not loaded yet / no crown. */
crownLayerIdx: number;
emojiAtlasIdx: number;
nameHalfWidth: number;
+66 -3
View File
@@ -41,6 +41,7 @@ import {
buildGlyphMetricsTex,
buildPlayerDataTex,
buildStringTex,
PLAYER_DATA_COLS,
} from "./DataTextures";
import { DebugProgram } from "./DebugProgram";
import { FlagAtlasArray } from "./FlagAtlasArray";
@@ -54,6 +55,14 @@ import { LINES_PER_PLAYER, MAX_CHARS } from "./Types";
// Flag quad aspect ratio — must match FLAG_CELL_W / FLAG_CELL_H in FlagAtlasArray.ts.
const FLAG_ASPECT = 128 / 85;
// Crown cosmetics use square cells (must match the crownAtlas cell size below).
export const CROWN_CELL = 128;
// Initial unique-crown capacity. Crowns are a rare cosmetic, so start small
// (32 × 128×128 RGBA8 + mips ≈ 2.8 MB vs ~43 MB at the 512 flag default);
// the atlas doubles itself if a game exceeds it.
const CROWN_INITIAL_LAYERS = 32;
export class NamePass {
private gl: WebGL2RenderingContext;
private settings: RenderSettings;
@@ -85,9 +94,12 @@ export class NamePass {
private maxPlayers: number;
private playerColors: Map<string, [number, number, number]> = new Map();
private flagAtlas: FlagAtlasArray;
private crownAtlas: FlagAtlasArray;
private emojiCharToIndex: Map<string, number>;
/** Slots waiting on a flag URL → set of slots that want that URL's layer. */
private slotsWaitingForFlag = new Map<string, Set<PlayerSlot>>();
/** Slots waiting on a crown URL → set of slots that want that URL's layer. */
private slotsWaitingForCrown = new Map<string, Set<PlayerSlot>>();
// CPU-side mirrors — batched upload in draw()
private cpuPlayerData: Float32Array;
@@ -144,6 +156,26 @@ export class NamePass {
}
});
// Crown cosmetics share the flag-atlas mechanism but use their own array
// (square cells so crown quads carry no letterbox margins). The crown
// image skins the first-place status crown (StatusIconProgram slot 0).
this.crownAtlas = new FlagAtlasArray(
gl,
(url, layer) => {
const waiting = this.slotsWaitingForCrown.get(url);
if (!waiting) return;
this.slotsWaitingForCrown.delete(url);
for (const slot of waiting) {
if (slot.crownUrl !== url) continue;
slot.crownLayerIdx = layer;
this.writePlayerDataRow(slot);
}
},
CROWN_CELL,
CROWN_CELL,
CROWN_INITIAL_LAYERS,
);
// Build player lookups and extract territory colors from palette
this.playerByID = new Map();
for (const p of header.players) {
@@ -158,7 +190,9 @@ export class NamePass {
// CPU-side texture mirrors + reusable layout buffers
const textRows = this.maxPlayers * LINES_PER_PLAYER;
this.cpuPlayerData = new Float32Array(8 * this.maxPlayers * 4);
this.cpuPlayerData = new Float32Array(
PLAYER_DATA_COLS * this.maxPlayers * 4,
);
this.cpuStringData = new Uint8Array(MAX_CHARS * textRows);
this.cpuCursorData = new Float32Array(MAX_CHARS * textRows);
this.stringRow = new Uint8Array(MAX_CHARS);
@@ -191,6 +225,7 @@ export class NamePass {
gl,
atlas,
this.playerDataTex,
this.crownAtlas,
this.maxPlayers,
config.allianceExtensionPromptOffset(),
);
@@ -277,6 +312,24 @@ export class NamePass {
waiting.add(slot);
}
/** Same as resolveSlotFlag, for the slot's crown cosmetic. */
private resolveSlotCrown(slot: PlayerSlot): void {
const url = slot.crownUrl;
if (!url) return;
this.crownAtlas.request(url);
const layer = this.crownAtlas.getLayer(url);
if (layer >= 0) {
slot.crownLayerIdx = layer;
return;
}
let waiting = this.slotsWaitingForCrown.get(url);
if (!waiting) {
waiting = new Set();
this.slotsWaitingForCrown.set(url, waiting);
}
waiting.add(slot);
}
// -------------------------------------------------------------------------
// Name updates — called by GPURenderer
// -------------------------------------------------------------------------
@@ -314,6 +367,8 @@ export class NamePass {
lastTroopBucket: -1,
flagUrl: p.flag,
flagLayerIdx: -1,
crownUrl: p.crown,
crownLayerIdx: -1,
emojiAtlasIdx: -1,
nameHalfWidth: 0,
crown: false,
@@ -334,6 +389,7 @@ export class NamePass {
};
this.slots.set(p.id, slot);
this.resolveSlotFlag(slot);
this.resolveSlotCrown(slot);
} else {
nextSlotIndex = Math.max(
nextSlotIndex,
@@ -544,7 +600,7 @@ export class NamePass {
/** Pack player data into the CPU buffer (flushed to GPU in draw). */
private writePlayerDataRow(slot: PlayerSlot): void {
const d = this.cpuPlayerData;
const off = slot.index * 32; // 8 columns × 4 floats per RGBA texel
const off = slot.index * (PLAYER_DATA_COLS * 4); // 4 floats per RGBA texel
// Column 0: srcX, srcY, srcScale, startTime
d[off + 0] = slot.srcX;
@@ -611,6 +667,12 @@ export class NamePass {
d[off + 30] = slot.allianceFraction;
d[off + 31] = slot.allianceRemainingTicks;
// Column 8: crownLayerIdx (crown cosmetic), rest free
d[off + 32] = slot.crownLayerIdx;
d[off + 33] = 0.0;
d[off + 34] = 0.0;
d[off + 35] = 0.0;
this.playerDataDirty = true;
}
@@ -724,7 +786,7 @@ export class NamePass {
0,
0,
0,
8,
PLAYER_DATA_COLS,
this.maxPlayers,
gl.RGBA,
gl.FLOAT,
@@ -766,6 +828,7 @@ export class NamePass {
this.textProgram.dispose();
this.iconProgram.dispose();
this.flagAtlas.dispose();
this.crownAtlas.dispose();
this.statusIconProgram.dispose();
this.debugProgram.dispose();
gl.deleteTexture(this.glyphMetricsTex);
@@ -48,7 +48,7 @@ void main() {
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 pd3 = texelFetch(uPlayerData, ivec2(3, playerIdx), 0); // nameLen, troopLen, isHuman, nameHalfWidth
vec4 pd4 = texelFetch(uPlayerData, ivec2(4, playerIdx), 0); // flagLayer, emojiIdx, smallID, [free]
vec4 pd4 = texelFetch(uPlayerData, ivec2(4, playerIdx), 0); // flagLayer, emojiIdx, smallID, doomsday
// Early out: dead player
if (pd1.w <= 0.0) {
@@ -1,12 +1,15 @@
#version 300 es
precision highp float;
precision highp sampler2DArray;
uniform sampler2D uStatusAtlas;
uniform sampler2DArray uCrownAtlas; // crown-cosmetic images (skins slot 0)
uniform vec2 uStatusTexel; // 1/atlasW, 1/atlasH
uniform float uStatusOutlinePx; // outline radius in atlas texels (0 = off)
in vec2 vUV;
in vec2 vLocalUV;
flat in int vCrownLayer;
flat in int vDiscard;
flat in float vAllianceFraction;
flat in vec2 vFadedUV0;
@@ -27,7 +30,12 @@ const vec2 kRing[8] = vec2[8](
void main() {
if (vDiscard != 0) discard;
vec4 texel = texture(uStatusAtlas, vUV);
// A crown cosmetic replaces the default first-place crown icon. Only slot 0
// ever carries a layer, and its quad has no outline margin, so vLocalUV is
// plain [0,1] over the icon.
vec4 texel = (vCrownLayer >= 0)
? texture(uCrownAtlas, vec3(vLocalUV, float(vCrownLayer)))
: texture(uStatusAtlas, vUV);
// Alliance drain: composite faded icon behind colored icon, clipped by fraction.
// Matches the game's CSS clip-path: inset(topCut% -2px 0 -2px) behavior.
@@ -35,6 +35,7 @@ uniform float uAllianceFlashWindowSec; // seconds before expiry the alliance ico
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
@@ -96,6 +97,10 @@ void main() {
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) {
+2
View File
@@ -26,6 +26,8 @@ export interface PlayerStatic {
isLobbyCreator: boolean;
/** Resolved flag image URL, or undefined for no flag. */
flag?: string;
/** Resolved crown-cosmetic image URL, or undefined for no crown. */
crown?: string;
/** Hex color (e.g. "#ff0000"). Populated from territoryColor (live) or palette (replay). */
color?: string;
}
+6
View File
@@ -302,6 +302,12 @@ export class UserSettings {
return data.startsWith(skinPrefix) ? data.slice(skinPrefix.length) : null;
}
// For development only. Crown image URL for testing, set in the console
// manually (localStorage "dev-crown"), like getDevOnlyPattern.
getDevOnlyCrown(): string | undefined {
return localStorage.getItem("dev-crown") ?? undefined;
}
/** Returns the selected crown name, or null if none is selected. */
getSelectedCrownName(): string | null {
return this.getCached(CROWN_KEY);