mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-17 01:00:06 +00:00
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:
@@ -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 };
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 ~50–200. */
|
||||
/** Initial unique-flag capacity. Real working set is ~50–200. */
|
||||
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;
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user