diff --git a/resources/atlases/flag-atlas-meta.json b/resources/atlases/flag-atlas-meta.json deleted file mode 100644 index 33a9501a6..000000000 --- a/resources/atlases/flag-atlas-meta.json +++ /dev/null @@ -1,568 +0,0 @@ -{ - "width": 2048, - "height": 2975, - "cellW": 128, - "cellH": 85, - "cols": 16, - "flags": { - "1_Airgialla": 0, - "1_Connacht": 1, - "1_Dalriata": 2, - "1_Dumnonia": 3, - "1_Dyfed": 4, - "1_East Anglia": 5, - "1_Essex": 6, - "1_Fortriu": 7, - "1_Franks": 8, - "1_Gwent": 9, - "1_Gwynedd": 10, - "1_Kent": 11, - "1_Laigin": 12, - "1_Mercia": 13, - "1_Munster": 14, - "1_Northern Ui Neill": 15, - "1_Northumbria": 16, - "1_Occitania": 17, - "1_Powys": 18, - "1_Southern Ui Neill": 19, - "1_Strathclyde": 20, - "1_Sussex": 21, - "1_Ulaid": 22, - "1_Wessex": 23, - "Abbasid Caliphate": 24, - "Achaemenid Empire": 25, - "African union": 26, - "Alabama": 27, - "Alaska": 28, - "Alkebulan": 29, - "Amazigh flag": 30, - "American_Samoa": 31, - "Anarchist flag": 32, - "Apartheid South Africa": 33, - "Arabia": 34, - "Aram Damascus": 35, - "Arizona": 36, - "Arkansas": 37, - "Assyria": 38, - "Athens": 39, - "Australian Aboriginal Flag": 40, - "Aztec Empire": 41, - "Babylonia": 42, - "Burma": 43, - "Burma2": 44, - "Byelorussian SSR": 45, - "Byzantine Empire": 46, - "California": 47, - "Capybara": 48, - "Carthage": 49, - "Ceara": 50, - "Chinook": 51, - "Chuvashia": 52, - "Circassia": 53, - "Colchis": 54, - "Colorado": 55, - "Communist Romania": 56, - "Communist flag": 57, - "Confederate States": 58, - "Connecticut": 59, - "Corsica": 60, - "Cthulhu Republic": 61, - "Danzig": 62, - "Delaware": 63, - "Dilmun": 64, - "District_of_Columbia": 65, - "Dutch East India Company": 66, - "Elam": 67, - "Empire of Japan": 68, - "Empire of Japan1": 69, - "Essex": 70, - "Fascist Spain": 71, - "Flag_of_the_Trucial_States_(1968–1971)": 72, - "Flanders": 73, - "Florida": 74, - "Franks": 75, - "French foreign legion": 76, - "Garamant": 77, - "Georgia_US": 78, - "Georgian SSR": 79, - "German Empire": 80, - "Guam": 81, - "Habsburg Austria": 82, - "Hawaii": 83, - "Holy Roman Empire": 84, - "Hyrcania": 85, - "Idaho": 86, - "Illinois": 87, - "Imperial Ethiopia": 88, - "Indiana": 89, - "Iowa": 90, - "Kansas": 91, - "Kazakh SSR": 92, - "Kemet": 93, - "Kent": 94, - "Kentucky": 95, - "Khemet": 96, - "Kingdom of Egypt": 97, - "Kingdom of Iraq": 98, - "Kingdom of Jerusalem": 99, - "Kingdom of Judah": 100, - "Kingdom_of_Iraq": 101, - "Kingdom_of_Judah": 102, - "Kiwi": 103, - "Kush": 104, - "Laigin": 105, - "League of Nations": 106, - "Leinster": 107, - "Liberalism_flag": 108, - "Libyan Jamahiriya": 109, - "Lihyan": 110, - "Listenbourg": 111, - "Louisiana": 112, - "Lower Silesia": 113, - "Lydia": 114, - "Macedonia": 115, - "Maine": 116, - "Maori flag": 117, - "Maryland": 118, - "Massachusetts": 119, - "Mauritania": 120, - "Median Empire": 121, - "Michigan": 122, - "Minnesota": 123, - "Mississippi": 124, - "Missouri": 125, - "Mongol Empire": 126, - "Montana": 127, - "Munster": 128, - "NATO": 129, - "Nebraska": 130, - "Nevada": 131, - "New_Hampshire": 132, - "New_Jersey": 133, - "New_Mexico": 134, - "New_York": 135, - "Newfoundland": 136, - "North karelia": 137, - "North yemen": 138, - "North_Carolina": 139, - "North_Dakota": 140, - "Northern_Mariana_Islands": 141, - "Nunavut": 142, - "OFM": 143, - "Ohio": 144, - "Oklahoma": 145, - "Oregon": 146, - "Ottoman Empire": 147, - "Pahlavi Iran": 148, - "Palekh": 149, - "Para": 150, - "Pennsylvania": 151, - "Persia": 152, - "Phrygia": 153, - "Poland Lithuania": 154, - "Polish–Lithuanian Commonwealth": 155, - "Qing Dynasty": 156, - "Quebec": 157, - "Republic of China": 158, - "Republic of Egypt": 159, - "Republic of Formosa": 160, - "Republic of Korea": 161, - "Republic of Pirates": 162, - "Rhode_Island": 163, - "Rhodesia": 164, - "Romanov Russia": 165, - "Ror Empire": 166, - "Russian SSR": 167, - "SPQR": 168, - "Saba kingdom": 169, - "Sakhalin": 170, - "Sami flag": 171, - "Santa Cruz": 172, - "Sao Paulo": 173, - "Sassanid Empire": 174, - "Second Republic of Iraq": 175, - "Second Spanish Republic": 176, - "Siam": 177, - "Siberia": 178, - "Sicily": 179, - "Socialist_flag": 180, - "South Vietnam": 181, - "South_Carolina": 182, - "South_Dakota": 183, - "Sparta": 184, - "Sultanate of Nejd": 185, - "Sweden Norway Union": 186, - "Tennessee": 187, - "Texas": 188, - "Trucial States": 189, - "Turkmen SSR": 190, - "USA 1776": 191, - "Ukrainian SSR": 192, - "Ulaid": 193, - "Umayyad Caliphate": 194, - "United Arab Republic": 195, - "United_States_Virgin_Islands": 196, - "Upper Silesia": 197, - "Urartu": 198, - "Utah": 199, - "Vermont": 200, - "Virginia": 201, - "Wallonia": 202, - "Washington": 203, - "Wassex": 204, - "West Roman Empire": 205, - "West_Virginia": 206, - "Wisconsin": 207, - "Wyoming": 208, - "Yellow_Flag": 209, - "Yukon": 210, - "Zaire": 211, - "Zheleznogorsk": 212, - "ac": 213, - "ad": 214, - "ae": 215, - "af": 216, - "ag": 217, - "ai": 218, - "al": 219, - "am": 220, - "amazonas": 221, - "an_pe": 222, - "antipope": 223, - "ao": 224, - "aq": 225, - "aquitaine": 226, - "ar": 227, - "armagnac": 228, - "as": 229, - "asturias": 230, - "at": 231, - "au": 232, - "aus_norter": 233, - "aus_nsw": 234, - "aus_quelan": 235, - "aus_souaus": 236, - "aus_tas": 237, - "aus_vic": 238, - "aus_wesaus": 239, - "austria-hungary": 240, - "aw": 241, - "ax": 242, - "az": 243, - "ba": 244, - "baguette": 245, - "bahia": 246, - "bai_bur": 247, - "bai_irk": 248, - "bb": 249, - "bd": 250, - "be": 251, - "bf": 252, - "bg": 253, - "bh": 254, - "bi": 255, - "bj": 256, - "bl": 257, - "bm": 258, - "bn": 259, - "bo": 260, - "bq": 261, - "br": 262, - "brittany": 263, - "bs": 264, - "bt": 265, - "buenos_aires": 266, - "bulgaria": 267, - "burgundy": 268, - "bv": 269, - "bw": 270, - "by": 271, - "bz": 272, - "ca": 273, - "ca_nb": 274, - "ca_ns": 275, - "ca_pe": 276, - "castille": 277, - "catalonia": 278, - "catamarca": 279, - "cc": 280, - "cd": 281, - "cf": 282, - "cg": 283, - "ch": 284, - "ci": 285, - "ck": 286, - "cl": 287, - "cm": 288, - "cn": 289, - "co": 290, - "cordoba": 291, - "cp": 292, - "cr": 293, - "cu": 294, - "cv": 295, - "cw": 296, - "cx": 297, - "cy": 298, - "cz": 299, - "de": 300, - "denmark": 301, - "dg": 302, - "dj": 303, - "dk": 304, - "dm": 305, - "do": 306, - "dz": 307, - "east_germany": 308, - "ec": 309, - "ee": 310, - "eg": 311, - "eh": 312, - "eo": 313, - "er": 314, - "es-ct": 315, - "es-ga": 316, - "es-pv": 317, - "es": 318, - "estonia": 319, - "et": 320, - "eu": 321, - "fi": 322, - "finland": 323, - "fj": 324, - "fk": 325, - "fm": 326, - "fo": 327, - "fr": 328, - "frost_giant": 329, - "ga": 330, - "galapagos": 331, - "gb-eng": 332, - "gb-sct": 333, - "gb-wls": 334, - "gb": 335, - "gd": 336, - "ge": 337, - "gf": 338, - "gg": 339, - "gh": 340, - "gi": 341, - "gl": 342, - "gm": 343, - "gn": 344, - "gp": 345, - "gq": 346, - "gr": 347, - "granada": 348, - "greece": 349, - "gs": 350, - "gt": 351, - "gu": 352, - "gw": 353, - "gy": 354, - "ha_ma": 355, - "hk": 356, - "hm": 357, - "hn": 358, - "hr": 359, - "ht": 360, - "hu": 361, - "hungary": 362, - "ic": 363, - "iceland": 364, - "id": 365, - "ie": 366, - "il": 367, - "im": 368, - "in": 369, - "io": 370, - "iq": 371, - "ir": 372, - "iraq": 373, - "ireland": 374, - "is": 375, - "it": 376, - "italy": 377, - "je": 378, - "jm": 379, - "jo": 380, - "jp": 381, - "ke": 382, - "kg": 383, - "kh": 384, - "ki": 385, - "km": 386, - "kn": 387, - "kp": 388, - "kr": 389, - "kurdistan": 390, - "kw": 391, - "ky": 392, - "kz": 393, - "la": 394, - "latvia": 395, - "lb": 396, - "lc": 397, - "leon": 398, - "li": 399, - "lithuania": 400, - "lk": 401, - "lr": 402, - "ls": 403, - "lt": 404, - "lu": 405, - "lv": 406, - "ly": 407, - "ma": 408, - "mc": 409, - "md": 410, - "me": 411, - "mf": 412, - "mg": 413, - "mh": 414, - "minas_gerais": 415, - "mk": 416, - "ml": 417, - "mm": 418, - "mn": 419, - "mo": 420, - "mp": 421, - "mq": 422, - "mr": 423, - "ms": 424, - "mt": 425, - "mu": 426, - "mv": 427, - "mw": 428, - "mx": 429, - "my": 430, - "mz": 431, - "na": 432, - "nc": 433, - "ne": 434, - "netherlands": 435, - "neuragic_empire": 436, - "nf": 437, - "ng": 438, - "ni": 439, - "nl": 440, - "no": 441, - "normandy": 442, - "northern_ireland": 443, - "norway": 444, - "np": 445, - "nr": 446, - "nu": 447, - "nz": 448, - "om": 449, - "pa": 450, - "paris": 451, - "pe": 452, - "pf": 453, - "pg": 454, - "ph": 455, - "pk": 456, - "pl": 457, - "pm": 458, - "pn": 459, - "poland": 460, - "polar_bears": 461, - "portugal": 462, - "pr": 463, - "provence": 464, - "prussia": 465, - "ps": 466, - "pt": 467, - "pw": 468, - "py": 469, - "qa": 470, - "re": 471, - "rio_de_janeiro": 472, - "ro": 473, - "rs": 474, - "ru": 475, - "rw": 476, - "sa": 477, - "santa_claus": 478, - "santa_cruz": 479, - "sardines": 480, - "sb": 481, - "sc": 482, - "sd": 483, - "se": 484, - "seville": 485, - "sg": 486, - "sh-ac": 487, - "sh-hl": 488, - "sh-ta": 489, - "sh": 490, - "sh_yugo": 491, - "si": 492, - "sj": 493, - "sk": 494, - "sl": 495, - "sm": 496, - "sn": 497, - "so": 498, - "south yemen": 499, - "spain": 500, - "spanish_empire": 501, - "sr": 502, - "ss": 503, - "st": 504, - "sv": 505, - "sweden": 506, - "sx": 507, - "sy": 508, - "sz": 509, - "ta": 510, - "tc": 511, - "td": 512, - "tf": 513, - "tg": 514, - "th": 515, - "tibet": 516, - "tj": 517, - "tk": 518, - "tl": 519, - "tm": 520, - "tn": 521, - "to": 522, - "toki_pona": 523, - "tr": 524, - "tt": 525, - "tv": 526, - "tw": 527, - "tz": 528, - "ua": 529, - "ug": 530, - "uk": 531, - "uk_us_flag": 532, - "um": 533, - "un": 534, - "us": 535, - "ussr": 536, - "uy": 537, - "uz": 538, - "va": 539, - "valencia": 540, - "vc": 541, - "ve": 542, - "venice": 543, - "vg": 544, - "vi": 545, - "vn": 546, - "vu": 547, - "west_germany": 548, - "wf": 549, - "ws": 550, - "xk": 551, - "xx": 552, - "ye": 553, - "yt": 554, - "yugoslavia": 555, - "za": 556, - "zm": 557, - "zw": 558 - } -} diff --git a/resources/atlases/flag-atlas.png b/resources/atlases/flag-atlas.png deleted file mode 100644 index 288e3ac3f..000000000 Binary files a/resources/atlases/flag-atlas.png and /dev/null differ diff --git a/src/client/WebGLFrameBuilder.ts b/src/client/WebGLFrameBuilder.ts index 32aac97b9..4851c5fb0 100644 --- a/src/client/WebGLFrameBuilder.ts +++ b/src/client/WebGLFrameBuilder.ts @@ -1,6 +1,6 @@ import { Colord } from "colord"; import { base64url } from "jose"; -import { extractFlagName } from "../core/AssetUrls"; +import { assetUrl } from "../core/AssetUrls"; import { decodePatternData } from "../core/PatternDecoder"; import { PlayerType } from "../core/game/Game"; import { GameView } from "../core/game/GameView"; @@ -134,10 +134,11 @@ export class WebGLFrameBuilder { this.writePaletteEntry(smallID, p.territoryColor(), p.borderColor()); - let flag = p.cosmetics.flag; - if (flag) { - flag = extractFlagName(flag); - } + // p.cosmetics.flag has already been server-resolved to either a full URL + // or a relative asset path (e.g. "/flags/US.svg" or a CDN URL for a + // custom flag). assetUrl() passes URLs through and rewrites paths. + const flagRef = p.cosmetics.flag; + const flagUrl = flagRef ? assetUrl(flagRef) : undefined; const pattern = p.cosmetics.pattern; if (pattern && pattern.patternData) { @@ -160,7 +161,7 @@ export class WebGLFrameBuilder { newPlayers.push({ ...p.static, - flag: flag, + flag: flagUrl, color: p.territoryColor().toHex(), }); } diff --git a/src/client/render/gl/passes/name-pass/AtlasData.ts b/src/client/render/gl/passes/name-pass/AtlasData.ts index e385a452f..cfbeee708 100644 --- a/src/client/render/gl/passes/name-pass/AtlasData.ts +++ b/src/client/render/gl/passes/name-pass/AtlasData.ts @@ -4,7 +4,6 @@ */ import emojiAtlasMeta from "resources/atlases/emoji-atlas-meta.json"; -import flagAtlasMeta from "resources/atlases/flag-atlas-meta.json"; import atlasData from "resources/atlases/msdf-atlas.json"; import type { BMChar, BMKerning, ParsedAtlas } from "./Types"; import { CHAR_RANGE } from "./Types"; @@ -67,15 +66,6 @@ export function buildKernTable(kernings: BMKerning[]): Int8Array { // Icon atlas lookups // --------------------------------------------------------------------------- -export function buildFlagLookup(): Map { - const map = new Map(); - const meta = flagAtlasMeta as { flags: Record }; - for (const [code, idx] of Object.entries(meta.flags)) { - map.set(code, idx); - } - return map; -} - export function buildEmojiLookup(): Map { const map = new Map(); const meta = emojiAtlasMeta as { emojis: Record }; diff --git a/src/client/render/gl/passes/name-pass/DebugProgram.ts b/src/client/render/gl/passes/name-pass/DebugProgram.ts index c9c7e18ef..753ec9fcf 100644 --- a/src/client/render/gl/passes/name-pass/DebugProgram.ts +++ b/src/client/render/gl/passes/name-pass/DebugProgram.ts @@ -5,13 +5,16 @@ * The shared playerDataTex is passed in but not owned/deleted. */ -import flagAtlasMeta from "resources/atlases/flag-atlas-meta.json"; import type { RenderSettings } from "../../RenderSettings"; import debugBoxFragSrc from "../../shaders/name/debug-box.frag.glsl?raw"; import debugBoxVertSrc from "../../shaders/name/debug-box.vert.glsl?raw"; import { createProgram } from "../../utils/GlUtils"; import type { ParsedAtlas } from "./Types"; +// Must match FLAG_CELL_W / FLAG_CELL_H in FlagAtlasArray.ts. +const FLAG_CELL_W = 128; +const FLAG_CELL_H = 85; + export class DebugProgram { private gl: WebGL2RenderingContext; private program: WebGLProgram; @@ -35,7 +38,6 @@ export class DebugProgram { this.playerDataTex = playerDataTex; this.maxPlayers = maxPlayers; - const fm = flagAtlasMeta as any; this.program = createProgram(gl, debugBoxVertSrc, debugBoxFragSrc); gl.useProgram(this.program); gl.uniform1i(gl.getUniformLocation(this.program, "uPlayerData"), 0); @@ -44,8 +46,14 @@ export class DebugProgram { atlas.fontSize, ); gl.uniform1f(gl.getUniformLocation(this.program, "uFontBase")!, atlas.base); - gl.uniform1f(gl.getUniformLocation(this.program, "uFlagCellW")!, fm.cellW); - gl.uniform1f(gl.getUniformLocation(this.program, "uFlagCellH")!, fm.cellH); + gl.uniform1f( + gl.getUniformLocation(this.program, "uFlagCellW")!, + FLAG_CELL_W, + ); + gl.uniform1f( + gl.getUniformLocation(this.program, "uFlagCellH")!, + FLAG_CELL_H, + ); this.uCamera = gl.getUniformLocation(this.program, "uCamera")!; this.uTime = gl.getUniformLocation(this.program, "uTime")!; diff --git a/src/client/render/gl/passes/name-pass/FlagAtlasArray.ts b/src/client/render/gl/passes/name-pass/FlagAtlasArray.ts new file mode 100644 index 000000000..3e2035db5 --- /dev/null +++ b/src/client/render/gl/passes/name-pass/FlagAtlasArray.ts @@ -0,0 +1,151 @@ +/** + * FlagAtlasArray — runtime TEXTURE_2D_ARRAY of player flag 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. + * + * 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. + */ + +const FLAG_CELL_W = 128; +const FLAG_CELL_H = 85; + +/** Hard cap on unique flags per game. Real working set is ~50–200. */ +export const MAX_FLAG_LAYERS = 512; + +interface PendingEntry { + layer: number; + ready: boolean; +} + +export class FlagAtlasArray { + private gl: WebGL2RenderingContext; + private tex: WebGLTexture; + private layerCount: number; + private nextLayer = 0; + + private entries = new Map(); + private onLayerReady: (url: string, layer: number) => void; + + private canvas: HTMLCanvasElement; + private ctx: CanvasRenderingContext2D; + + constructor( + gl: WebGL2RenderingContext, + onLayerReady: (url: string, layer: number) => void, + ) { + 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.tex = gl.createTexture()!; + gl.bindTexture(gl.TEXTURE_2D_ARRAY, this.tex); + gl.texStorage3D( + gl.TEXTURE_2D_ARRAY, + mipLevels(FLAG_CELL_W, FLAG_CELL_H), + gl.RGBA8, + FLAG_CELL_W, + FLAG_CELL_H, + this.layerCount, + ); + gl.texParameteri( + gl.TEXTURE_2D_ARRAY, + gl.TEXTURE_MIN_FILTER, + gl.LINEAR_MIPMAP_LINEAR, + ); + 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); + + 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 })!; + } + + get texture(): WebGLTexture { + return this.tex; + } + + /** Layer index for an already-loaded URL, or -1 if pending/missing/unassigned. */ + getLayer(url: string): number { + const e = this.entries.get(url); + return e && e.ready ? e.layer : -1; + } + + /** + * Request a flag. Returns immediately; `onLayerReady` fires once the image is + * loaded and uploaded. Subsequent calls for the same URL are no-ops. + */ + request(url: string): void { + if (this.entries.has(url)) return; + if (this.nextLayer >= this.layerCount) return; // hit cap → no icon + + const layer = this.nextLayer++; + const entry: PendingEntry = { layer, ready: false }; + this.entries.set(url, entry); + + const img = new Image(); + 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); + const srcAspect = img.width / img.height; + const dstAspect = FLAG_CELL_W / FLAG_CELL_H; + let dw: number, dh: number; + if (srcAspect > dstAspect) { + dw = FLAG_CELL_W; + dh = FLAG_CELL_W / srcAspect; + } else { + dh = FLAG_CELL_H; + dw = FLAG_CELL_H * srcAspect; + } + const dx = (FLAG_CELL_W - dw) * 0.5; + const dy = (FLAG_CELL_H - dh) * 0.5; + this.ctx.drawImage(img, dx, dy, dw, dh); + + const gl = this.gl; + gl.bindTexture(gl.TEXTURE_2D_ARRAY, this.tex); + gl.texSubImage3D( + gl.TEXTURE_2D_ARRAY, + 0, + 0, + 0, + layer, + FLAG_CELL_W, + FLAG_CELL_H, + 1, + gl.RGBA, + gl.UNSIGNED_BYTE, + this.canvas, + ); + gl.generateMipmap(gl.TEXTURE_2D_ARRAY); + + entry.ready = true; + this.onLayerReady(url, layer); + }; + img.onerror = () => { + // Leave entry as not-ready forever; layer is consumed but harmless. + console.warn("Flag image failed to load:", url); + }; + img.src = url; + } + + dispose(): void { + this.gl.deleteTexture(this.tex); + this.entries.clear(); + } +} + +function mipLevels(w: number, h: number): number { + return Math.floor(Math.log2(Math.max(w, h))) + 1; +} diff --git a/src/client/render/gl/passes/name-pass/IconProgram.ts b/src/client/render/gl/passes/name-pass/IconProgram.ts index 447522049..946ce6f50 100644 --- a/src/client/render/gl/passes/name-pass/IconProgram.ts +++ b/src/client/render/gl/passes/name-pass/IconProgram.ts @@ -1,31 +1,36 @@ /** * IconProgram — instanced flag + emoji icons beside player names. * - * Owns: shader program, uniform locations, flag atlas + emoji atlas textures. - * The shared playerDataTex is passed in but not owned/deleted. + * Owns the shader program and the emoji atlas texture. The flag texture is a + * sampler2DArray populated at runtime by FlagAtlasArray (passed in, not owned). + * The shared playerDataTex is also passed in but not owned/deleted. */ import emojiAtlasMeta from "resources/atlases/emoji-atlas-meta.json"; -import flagAtlasMeta from "resources/atlases/flag-atlas-meta.json"; import { assetUrl } from "src/core/AssetUrls"; import type { RenderSettings } from "../../RenderSettings"; 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 type { ParsedAtlas } from "./Types"; const emojiAtlasUrl = assetUrl("atlases/emoji-atlas.png"); -const flagAtlasUrl = assetUrl("atlases/flag-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; export class IconProgram { private gl: WebGL2RenderingContext; private program: WebGLProgram; private playerDataTex: WebGLTexture; + private flagAtlas: FlagAtlasArray; private maxPlayers: number; - private flagAtlasTex: WebGLTexture | null = null; private emojiAtlasTex: WebGLTexture | null = null; - private iconsReady = false; + private emojiReady = false; // Dynamic uniform locations private uCamera: WebGLUniformLocation; @@ -40,10 +45,12 @@ export class IconProgram { gl: WebGL2RenderingContext, atlas: ParsedAtlas, playerDataTex: WebGLTexture, + flagAtlas: FlagAtlasArray, maxPlayers: number, ) { this.gl = gl; this.playerDataTex = playerDataTex; + this.flagAtlas = flagAtlas; this.maxPlayers = maxPlayers; this.program = createProgram(gl, iconVertSrc, iconFragSrc); @@ -55,20 +62,19 @@ export class IconProgram { gl.uniform1i(gl.getUniformLocation(this.program, "uEmojiAtlas"), 2); // Static uniforms from atlas metadata - const fm = flagAtlasMeta as any; const em = emojiAtlasMeta as any; gl.uniform1f( gl.getUniformLocation(this.program, "uFontSize")!, atlas.fontSize, ); gl.uniform1f(gl.getUniformLocation(this.program, "uFontBase")!, atlas.base); - gl.uniform1f(gl.getUniformLocation(this.program, "uFlagCellW")!, fm.cellW); - gl.uniform1f(gl.getUniformLocation(this.program, "uFlagCellH")!, fm.cellH); - gl.uniform1f(gl.getUniformLocation(this.program, "uFlagCols")!, fm.cols); - gl.uniform1f(gl.getUniformLocation(this.program, "uFlagAtlasW")!, fm.width); gl.uniform1f( - gl.getUniformLocation(this.program, "uFlagAtlasH")!, - fm.height, + gl.getUniformLocation(this.program, "uFlagCellW")!, + FLAG_CELL_W, + ); + gl.uniform1f( + gl.getUniformLocation(this.program, "uFlagCellH")!, + FLAG_CELL_H, ); gl.uniform1f( gl.getUniformLocation(this.program, "uEmojiCell")!, @@ -102,52 +108,34 @@ export class IconProgram { "uEmojiRowOffset", )!; - this.loadAtlases(); + this.loadEmojiAtlas(); } get ready(): boolean { - return this.iconsReady; + return this.emojiReady; } - private loadAtlases(): void { + private loadEmojiAtlas(): void { const gl = this.gl; - const load = (url: string, cb: (tex: WebGLTexture) => void) => { - const img = new Image(); - img.crossOrigin = "anonymous"; - img.onload = () => { - const tex = gl.createTexture()!; - gl.bindTexture(gl.TEXTURE_2D, tex); - gl.texParameteri( - gl.TEXTURE_2D, - gl.TEXTURE_MIN_FILTER, - gl.LINEAR_MIPMAP_LINEAR, - ); - gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); - gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); - gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); - gl.texImage2D( - gl.TEXTURE_2D, - 0, - gl.RGBA, - gl.RGBA, - gl.UNSIGNED_BYTE, - img, - ); - gl.generateMipmap(gl.TEXTURE_2D); - cb(tex); - }; - img.src = url; - }; - load(flagAtlasUrl, (tex) => { - this.flagAtlasTex = tex; - this.iconsReady = - this.flagAtlasTex !== null && this.emojiAtlasTex !== null; - }); - load(emojiAtlasUrl, (tex) => { + const img = new Image(); + img.crossOrigin = "anonymous"; + img.onload = () => { + const tex = gl.createTexture()!; + gl.bindTexture(gl.TEXTURE_2D, tex); + gl.texParameteri( + gl.TEXTURE_2D, + gl.TEXTURE_MIN_FILTER, + gl.LINEAR_MIPMAP_LINEAR, + ); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, img); + gl.generateMipmap(gl.TEXTURE_2D); this.emojiAtlasTex = tex; - this.iconsReady = - this.flagAtlasTex !== null && this.emojiAtlasTex !== null; - }); + this.emojiReady = true; + }; + img.src = emojiAtlasUrl; } draw( @@ -155,7 +143,7 @@ export class IconProgram { settings: RenderSettings, vao: WebGLVertexArrayObject, ): void { - if (!this.iconsReady) return; + if (!this.emojiReady) return; const gl = this.gl; const ns = settings.name; @@ -172,7 +160,7 @@ export class IconProgram { gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, this.playerDataTex); gl.activeTexture(gl.TEXTURE1); - gl.bindTexture(gl.TEXTURE_2D, this.flagAtlasTex!); + gl.bindTexture(gl.TEXTURE_2D_ARRAY, this.flagAtlas.texture); gl.activeTexture(gl.TEXTURE2); gl.bindTexture(gl.TEXTURE_2D, this.emojiAtlasTex!); @@ -183,7 +171,6 @@ export class IconProgram { dispose(): void { const gl = this.gl; gl.deleteProgram(this.program); - if (this.flagAtlasTex) gl.deleteTexture(this.flagAtlasTex); if (this.emojiAtlasTex) gl.deleteTexture(this.emojiAtlasTex); } } diff --git a/src/client/render/gl/passes/name-pass/Types.ts b/src/client/render/gl/passes/name-pass/Types.ts index 85e3d9c8b..9bacac274 100644 --- a/src/client/render/gl/passes/name-pass/Types.ts +++ b/src/client/render/gl/passes/name-pass/Types.ts @@ -56,7 +56,10 @@ export interface PlayerSlot { nameLen: number; troopLen: number; lastTroopStr: string; - flagAtlasIdx: 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. */ + flagLayerIdx: number; emojiAtlasIdx: number; nameHalfWidth: number; diff --git a/src/client/render/gl/passes/name-pass/index.ts b/src/client/render/gl/passes/name-pass/index.ts index b9fd51c00..d93d37c1c 100644 --- a/src/client/render/gl/passes/name-pass/index.ts +++ b/src/client/render/gl/passes/name-pass/index.ts @@ -30,7 +30,6 @@ import { createFullscreenQuad } from "../../utils/GlUtils"; import type { GlyphTables } from "./AtlasData"; import { buildEmojiLookup, - buildFlagLookup, buildGlyphTables, buildKernTable, parseAtlasData, @@ -42,6 +41,7 @@ import { buildStringTex, } from "./DataTextures"; import { DebugProgram } from "./DebugProgram"; +import { FlagAtlasArray } from "./FlagAtlasArray"; import { IconProgram } from "./IconProgram"; import { StatusIconProgram } from "./StatusIconProgram"; import { formatTroops, layoutString } from "./TextLayout"; @@ -78,8 +78,10 @@ export class NamePass { private slots: Map = new Map(); private maxPlayers: number; private playerColors: Map = new Map(); - private flagCodeToIndex: Map; + private flagAtlas: FlagAtlasArray; private emojiCharToIndex: Map; + /** Slots waiting on a flag URL → set of slots that want that URL's layer. */ + private slotsWaitingForFlag = new Map>(); // CPU-side mirrors — batched upload in draw() private cpuPlayerData: Float32Array; @@ -112,9 +114,22 @@ export class NamePass { const atlas = parseAtlasData(); this.glyph = buildGlyphTables(atlas.chars); this.kernTable = buildKernTable(atlas.kernings); - this.flagCodeToIndex = buildFlagLookup(); this.emojiCharToIndex = buildEmojiLookup(); + // Runtime flag-image manager (TEXTURE_2D_ARRAY of player flags, fetched + // on demand from URLs). When a flag finishes loading, flip every slot + // that wanted that URL from layer -1 to the resolved layer. + this.flagAtlas = new FlagAtlasArray(gl, (url, layer) => { + const waiting = this.slotsWaitingForFlag.get(url); + if (!waiting) return; + this.slotsWaitingForFlag.delete(url); + for (const slot of waiting) { + if (slot.flagUrl !== url) continue; // player changed flag while we waited + slot.flagLayerIdx = layer; + this.writePlayerDataRow(slot); + } + }); + // Build player lookups and extract territory colors from palette this.playerByID = new Map(); this.smallIDToPlayerID = new Map(); @@ -157,6 +172,7 @@ export class NamePass { gl, atlas, this.playerDataTex, + this.flagAtlas, this.maxPlayers, ); this.statusIconProgram = new StatusIconProgram( @@ -192,6 +208,28 @@ export class NamePass { } } + /** + * Request the texture layer for a slot's flag (called once at slot creation). + * If the image is already loaded the layer index is set immediately; otherwise + * the slot joins a wait list and is updated when the image arrives. + */ + private resolveSlotFlag(slot: PlayerSlot): void { + const url = slot.flagUrl; + if (!url) return; + this.flagAtlas.request(url); + const layer = this.flagAtlas.getLayer(url); + if (layer >= 0) { + slot.flagLayerIdx = layer; + return; + } + let waiting = this.slotsWaitingForFlag.get(url); + if (!waiting) { + waiting = new Set(); + this.slotsWaitingForFlag.set(url, waiting); + } + waiting.add(slot); + } + // ------------------------------------------------------------------------- // Name updates — called by GPURenderer // ------------------------------------------------------------------------- @@ -223,8 +261,7 @@ export class NamePass { let nextSlotIndex = 0; for (const p of this.playerByID.values()) { if (!this.slots.has(p.id)) { - const flagCode = p.flag; - this.slots.set(p.id, { + const slot: PlayerSlot = { index: nextSlotIndex++, playerID: p.id, static: p, @@ -239,9 +276,8 @@ export class NamePass { nameLen: 0, troopLen: 0, lastTroopStr: "", - flagAtlasIdx: flagCode - ? (this.flagCodeToIndex.get(flagCode) ?? -1) - : -1, + flagUrl: p.flag, + flagLayerIdx: -1, emojiAtlasIdx: -1, nameHalfWidth: 0, crown: false, @@ -255,7 +291,9 @@ export class NamePass { nukeTargetsMe: false, traitorRemainingTicks: 0, allianceFraction: 0, - }); + }; + this.slots.set(p.id, slot); + this.resolveSlotFlag(slot); } else { nextSlotIndex = Math.max( nextSlotIndex, @@ -457,8 +495,8 @@ export class NamePass { d[off + 14] = slot.static.playerType === PlayerTypeEnum.Human ? 1.0 : 0.0; d[off + 15] = slot.nameHalfWidth; - // Column 4: flagAtlasIdx, emojiAtlasIdx, [free], [free] - d[off + 16] = slot.flagAtlasIdx; + // Column 4: flagLayerIdx, emojiAtlasIdx, [free], [free] + d[off + 16] = slot.flagLayerIdx; d[off + 17] = slot.emojiAtlasIdx; d[off + 18] = 0; d[off + 19] = 0; @@ -562,6 +600,7 @@ export class NamePass { const gl = this.gl; this.textProgram.dispose(); this.iconProgram.dispose(); + this.flagAtlas.dispose(); this.statusIconProgram.dispose(); this.debugProgram.dispose(); gl.deleteTexture(this.glyphMetricsTex); diff --git a/src/client/render/gl/shaders/name/icon.frag.glsl b/src/client/render/gl/shaders/name/icon.frag.glsl index 050b83984..16adf4578 100644 --- a/src/client/render/gl/shaders/name/icon.frag.glsl +++ b/src/client/render/gl/shaders/name/icon.frag.glsl @@ -1,24 +1,26 @@ -#version 300 es -precision highp float; - -uniform sampler2D uFlagAtlas; -uniform sampler2D uEmojiAtlas; - -in vec2 vUV; -flat in int vIconType; // 0 = flag, 1 = emoji, -1 = discard - -out vec4 fragColor; - -void main() { - if (vIconType < 0) discard; - - vec4 texel; - if (vIconType == 0) { - texel = texture(uFlagAtlas, vUV); - } else { - texel = texture(uEmojiAtlas, vUV); - } - - if (texel.a < 0.01) discard; - fragColor = texel; -} +#version 300 es +precision highp float; +precision highp sampler2DArray; + +uniform sampler2DArray uFlagAtlas; +uniform sampler2D uEmojiAtlas; + +in vec2 vUV; +flat in int vIconType; // 0 = flag, 1 = emoji, -1 = discard +flat in int vFlagLayer; + +out vec4 fragColor; + +void main() { + if (vIconType < 0) discard; + + vec4 texel; + if (vIconType == 0) { + texel = texture(uFlagAtlas, vec3(vUV, float(vFlagLayer))); + } else { + texel = texture(uEmojiAtlas, vUV); + } + + if (texel.a < 0.01) discard; + fragColor = texel; +} diff --git a/src/client/render/gl/shaders/name/icon.vert.glsl b/src/client/render/gl/shaders/name/icon.vert.glsl index 1caad7ae1..9c528044b 100644 --- a/src/client/render/gl/shaders/name/icon.vert.glsl +++ b/src/client/render/gl/shaders/name/icon.vert.glsl @@ -1,154 +1,149 @@ -#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; // PLAYER_DATA_COLS × 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; // atlas reference font size (same as name shader's uFontSize) -uniform float uFontBase; // atlas baseline height (same as name shader's uBase) - -// Flag atlas layout -uniform float uFlagCellW; // texels per flag cell (width) -uniform float uFlagCellH; // texels per flag cell (height) -uniform float uFlagCols; // columns in flag atlas -uniform float uFlagAtlasW; // flag atlas texture width -uniform float uFlagAtlasH; // flag atlas texture height - -// Emoji atlas layout -uniform float uEmojiCell; // texels per emoji cell (square) -uniform float uEmojiCols; // columns in emoji atlas -uniform float uEmojiAtlasW; // emoji atlas texture width -uniform float uEmojiAtlasH; // emoji atlas texture height - -// Row offset (multiples of uFontBase * nameWorldScale) -uniform float uEmojiRowOffset; - -out vec2 vUV; -flat out int vIconType; // 0 = flag, 1 = emoji, -1 = discard - -void main() { - // Decode instance ID → playerIdx + iconType (0=flag, 1=emoji) - int playerIdx = gl_InstanceID / 2; - int iconType = gl_InstanceID - playerIdx * 2; - - // 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 pd3 = texelFetch(uPlayerData, ivec2(3, playerIdx), 0); // nameLen, troopLen, isHuman, nameHalfWidth - vec4 pd4 = texelFetch(uPlayerData, ivec2(4, playerIdx), 0); // flagIdx, emojiIdx, [free], [free] - - // Early out: dead player - if (pd1.w <= 0.0) { - gl_Position = vec4(0.0); - vUV = vec2(0.0); - vIconType = -1; - return; - } - - // Get atlas index for this icon type - float atlasIdx = (iconType == 0) ? pd4.x : pd4.y; - if (atlasIdx < 0.0) { - gl_Position = vec4(0.0); - vUV = vec2(0.0); - vIconType = -1; - 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); - vIconType = -1; - return; - } - - float nameHalfWidth = pd3.w; // in font units (pre-scaled by nameWorldScale at runtime) - - // Compute icon size and position based on type - float iconW, iconH; - float cellW, cellH, cols, atlasW, atlasH; - vec2 iconOrigin; - - if (iconType == 0) { - // FLAG — to the left of the name - cellW = uFlagCellW; - cellH = uFlagCellH; - cols = uFlagCols; - atlasW = uFlagAtlasW; - atlasH = uFlagAtlasH; - - // Flag world size: height matches ~120% of the name text height - float flagWorldH = uFontBase * nameWorldScale * 1.2; - float flagWorldW = flagWorldH * (cellW / cellH); - - // Position: left of name, vertically centered on the name baseline - iconOrigin = vec2( - wx - nameHalfWidth * nameWorldScale - flagWorldW, - wy - flagWorldH * 0.5 - ); - iconW = flagWorldW; - iconH = flagWorldH; - } else { - // EMOJI — above the name - cellW = uEmojiCell; - cellH = uEmojiCell; - cols = uEmojiCols; - atlasW = uEmojiAtlasW; - atlasH = uEmojiAtlasH; - - // Emoji world size: slightly larger than name text height - float emojiWorldSize = uFontBase * nameWorldScale * 1.0; - - // Position: centered above name - iconOrigin = vec2( - wx - emojiWorldSize * 0.5, - wy - uFontBase * nameWorldScale * uEmojiRowOffset - ); - iconW = emojiWorldSize; - iconH = emojiWorldSize; - } - - // Quad world position - vec2 worldPos = iconOrigin + aPos * vec2(iconW, iconH); - - // Camera transform - vec3 clip = uCamera * vec3(worldPos, 1.0); - gl_Position = vec4(clip.xy, 0.0, 1.0); - - // UV from atlas grid - int idx = int(atlasIdx); - int col = idx - (idx / int(cols)) * int(cols); - int row = idx / int(cols); - float u0 = float(col) * cellW / atlasW; - float v0 = float(row) * cellH / atlasH; - float u1 = u0 + cellW / atlasW; - float v1 = v0 + cellH / atlasH; - vUV = vec2(mix(u0, u1, aPos.x), mix(v0, v1, aPos.y)); - vIconType = iconType; -} +#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; // PLAYER_DATA_COLS × 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; // atlas reference font size (same as name shader's uFontSize) +uniform float uFontBase; // atlas baseline height (same as name shader's uBase) + +// Flag cell shape (fixed, matches FlagAtlasArray cell size) +uniform float uFlagCellW; +uniform float uFlagCellH; + +// Emoji atlas layout +uniform float uEmojiCell; // texels per emoji cell (square) +uniform float uEmojiCols; // columns in emoji atlas +uniform float uEmojiAtlasW; // emoji atlas texture width +uniform float uEmojiAtlasH; // emoji atlas texture height + +// Row offset (multiples of uFontBase * nameWorldScale) +uniform float uEmojiRowOffset; + +out vec2 vUV; +flat out int vIconType; // 0 = flag, 1 = emoji, -1 = discard +flat out int vFlagLayer; // valid when vIconType == 0 + +void main() { + // Decode instance ID → playerIdx + iconType (0=flag, 1=emoji) + int playerIdx = gl_InstanceID / 2; + int iconType = gl_InstanceID - playerIdx * 2; + + // 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 pd3 = texelFetch(uPlayerData, ivec2(3, playerIdx), 0); // nameLen, troopLen, isHuman, nameHalfWidth + vec4 pd4 = texelFetch(uPlayerData, ivec2(4, playerIdx), 0); // flagLayer, emojiIdx, [free], [free] + + // Early out: dead player + if (pd1.w <= 0.0) { + gl_Position = vec4(0.0); + vUV = vec2(0.0); + vIconType = -1; + vFlagLayer = 0; + return; + } + + // Get atlas/layer index for this icon type + float atlasIdx = (iconType == 0) ? pd4.x : pd4.y; + if (atlasIdx < 0.0) { + gl_Position = vec4(0.0); + vUV = vec2(0.0); + vIconType = -1; + vFlagLayer = 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); + vIconType = -1; + vFlagLayer = 0; + return; + } + + float nameHalfWidth = pd3.w; // in font units (pre-scaled by nameWorldScale at runtime) + + // Compute icon size and position based on type + float iconW, iconH; + vec2 iconOrigin; + + if (iconType == 0) { + // FLAG — to the left of the name. Sampled from sampler2DArray; uses + // plain [0,1] UVs and the layer is passed via vFlagLayer. + float flagWorldH = uFontBase * nameWorldScale * 1.2; + float flagWorldW = flagWorldH * (uFlagCellW / uFlagCellH); + + iconOrigin = vec2( + wx - nameHalfWidth * nameWorldScale - flagWorldW, + wy - flagWorldH * 0.5 + ); + iconW = flagWorldW; + iconH = flagWorldH; + + vUV = aPos; + vFlagLayer = int(atlasIdx); + } else { + // EMOJI — above the name. Sampled from a 2D atlas; compute grid UVs. + float cellW = uEmojiCell; + float cellH = uEmojiCell; + float cols = uEmojiCols; + float atlasW = uEmojiAtlasW; + float atlasH = uEmojiAtlasH; + + float emojiWorldSize = uFontBase * nameWorldScale * 1.0; + + iconOrigin = vec2( + wx - emojiWorldSize * 0.5, + wy - uFontBase * nameWorldScale * uEmojiRowOffset + ); + iconW = emojiWorldSize; + iconH = emojiWorldSize; + + int idx = int(atlasIdx); + int col = idx - (idx / int(cols)) * int(cols); + int row = idx / int(cols); + float u0 = float(col) * cellW / atlasW; + float v0 = float(row) * cellH / atlasH; + float u1 = u0 + cellW / atlasW; + float v1 = v0 + cellH / atlasH; + vUV = vec2(mix(u0, u1, aPos.x), mix(v0, v1, aPos.y)); + vFlagLayer = 0; + } + + // Quad world position + vec2 worldPos = iconOrigin + aPos * vec2(iconW, iconH); + + // Camera transform + vec3 clip = uCamera * vec3(worldPos, 1.0); + gl_Position = vec4(clip.xy, 0.0, 1.0); + + vIconType = iconType; +} diff --git a/src/client/render/types/Renderer.ts b/src/client/render/types/Renderer.ts index ef53602a8..383f307d4 100644 --- a/src/client/render/types/Renderer.ts +++ b/src/client/render/types/Renderer.ts @@ -24,6 +24,7 @@ export interface PlayerStatic { playerType: PlayerTypeEnum; team: string | null; isLobbyCreator: boolean; + /** Resolved flag image URL, or undefined for no flag. */ flag?: string; /** Hex color (e.g. "#ff0000"). Populated from territoryColor (live) or palette (replay). */ color?: string; diff --git a/src/core/AssetUrls.ts b/src/core/AssetUrls.ts index 27a1f6384..fb293e08a 100644 --- a/src/core/AssetUrls.ts +++ b/src/core/AssetUrls.ts @@ -100,22 +100,6 @@ export function assetUrl(path: string): string { return buildAssetUrl(path, getAssetManifest(), getCdnBase()); } -/** - * Extracts a clean flag name from a cosmetic flag string, which could be - * a literal "flag:Name" token or a full CDN URL (e.g. ".../flags/Name.hash.svg"). - */ -export function extractFlagName(flagData: string): string { - if (flagData.startsWith("flag:")) { - return flagData.slice(5); - } - const match = flagData.match(/\/flags\/([^?#]+)\.svg/); - if (match) { - const raw = match[1].replace(/\.[a-f0-9]{8,12}$/i, ""); - return safeDecodeAssetSegment(raw); - } - return flagData; -} - // Rewrites Vite's emitted /assets/... references in the built index.html to // use the cdnBaseRaw EJS placeholder, so RenderHtml.ts can prefix them with // CDN_BASE at request time. Scoped to src=/href= attribute values so inline diff --git a/tests/AssetUrls.test.ts b/tests/AssetUrls.test.ts index b125be584..78a97ff92 100644 --- a/tests/AssetUrls.test.ts +++ b/tests/AssetUrls.test.ts @@ -1,9 +1,5 @@ import { describe, expect, test } from "vitest"; -import { - buildAssetUrl, - extractFlagName, - rewriteAssetsForCdn, -} from "../src/core/AssetUrls"; +import { buildAssetUrl, rewriteAssetsForCdn } from "../src/core/AssetUrls"; describe("AssetUrls", () => { test("returns hashed URLs for direct asset matches", () => { @@ -106,41 +102,6 @@ describe("AssetUrls", () => { }); }); -describe("extractFlagName", () => { - test("extracts from flag: prefix", () => { - expect(extractFlagName("flag:Abbasid Caliphate")).toBe("Abbasid Caliphate"); - }); - - test("extracts and decodes from URL", () => { - expect( - extractFlagName( - "https://cdn.ofedge.dev/game_assets/_assets/flags/Abbasid%20Caliphate.ed17b262829c.svg", - ), - ).toBe("Abbasid Caliphate"); - expect(extractFlagName("/flags/Abbasid%20Caliphate.svg")).toBe( - "Abbasid Caliphate", - ); - }); - - test("returns raw string if not matching flag: or /flags/ URL", () => { - expect(extractFlagName("Some Random String")).toBe("Some Random String"); - }); - - test("handles malformed percent-encodings without throwing", () => { - expect(extractFlagName("/flags/Bad%C2Encoding.12345678.svg")).toBe( - "Bad%C2Encoding", - ); - }); - - test("handles edge cases like names containing colons or URLs with hashes", () => { - expect( - extractFlagName( - "/flags/Name%3AWith%3AColons.12345678.svg?query=1#anchor", - ), - ).toBe("Name:With:Colons"); - }); -}); - describe("rewriteAssetsForCdn", () => { test("rewrites src=/assets/ to EJS placeholder", () => { const out = rewriteAssetsForCdn(