mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 08:20:50 +00:00
flags (#3985)
# Dynamic flag atlas (runtime TEXTURE_2D_ARRAY) Replaces the build-time `flag-atlas.png` with a runtime `TEXTURE_2D_ARRAY` populated on demand from each player's server-resolved flag URL. Layers are deduped by URL (every "Mercia" bot shares one slot), so the per-game working set is bounded by unique flags, not player count. ## Why The store will eventually ship hundreds of custom flags fetched from the CDN, which can't be baked into a static atlas. Moving to a runtime array also lets the catalog grow without bloating the client bundle. ## Side effect (bonus) Human players' country flags (`country:US`, etc.) now display next to their names in-game. The old atlas only contained nation names, so non-nation flags were silently dropped. ## Notes - Cell size is fixed at 128×85; loaded images are aspect-fit and centered. - Layer cap is 512 (clamped to `MAX_ARRAY_TEXTURE_LAYERS`). Past the cap, further flag requests render no icon. - Mipmaps are regenerated after each layer upload. - Recommend store pipeline caps custom flag uploads at SVG or PNG ≤ 256×170, ≤ 50 KB (decode-time RAM and bandwidth, not VRAM). ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: evan
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 995 KiB |
@@ -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(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<string, number> {
|
||||
const map = new Map<string, number>();
|
||||
const meta = flagAtlasMeta as { flags: Record<string, number> };
|
||||
for (const [code, idx] of Object.entries(meta.flags)) {
|
||||
map.set(code, idx);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
export function buildEmojiLookup(): Map<string, number> {
|
||||
const map = new Map<string, number>();
|
||||
const meta = emojiAtlasMeta as { emojis: Record<string, number> };
|
||||
|
||||
@@ -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")!;
|
||||
|
||||
@@ -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<string, PendingEntry>();
|
||||
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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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<string, PlayerSlot> = new Map();
|
||||
private maxPlayers: number;
|
||||
private playerColors: Map<string, [number, number, number]> = new Map();
|
||||
private flagCodeToIndex: Map<string, number>;
|
||||
private flagAtlas: 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>>();
|
||||
|
||||
// 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);
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
#version 300 es
|
||||
precision highp float;
|
||||
precision highp sampler2DArray;
|
||||
|
||||
uniform sampler2D uFlagAtlas;
|
||||
uniform sampler2D uEmojiAtlas;
|
||||
uniform sampler2DArray uFlagAtlas;
|
||||
uniform sampler2D uEmojiAtlas;
|
||||
|
||||
in vec2 vUV;
|
||||
flat in int vIconType; // 0 = flag, 1 = emoji, -1 = discard
|
||||
flat in int vIconType; // 0 = flag, 1 = emoji, -1 = discard
|
||||
flat in int vFlagLayer;
|
||||
|
||||
out vec4 fragColor;
|
||||
|
||||
@@ -14,7 +16,7 @@ void main() {
|
||||
|
||||
vec4 texel;
|
||||
if (vIconType == 0) {
|
||||
texel = texture(uFlagAtlas, vUV);
|
||||
texel = texture(uFlagAtlas, vec3(vUV, float(vFlagLayer)));
|
||||
} else {
|
||||
texel = texture(uEmojiAtlas, vUV);
|
||||
}
|
||||
|
||||
@@ -18,12 +18,9 @@ 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
|
||||
// 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)
|
||||
@@ -35,7 +32,8 @@ uniform float uEmojiAtlasH; // emoji atlas texture height
|
||||
uniform float uEmojiRowOffset;
|
||||
|
||||
out vec2 vUV;
|
||||
flat out int vIconType; // 0 = flag, 1 = emoji, -1 = discard
|
||||
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)
|
||||
@@ -46,22 +44,24 @@ 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); // flagIdx, emojiIdx, [free], [free]
|
||||
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 index for this icon type
|
||||
// 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;
|
||||
}
|
||||
|
||||
@@ -85,6 +85,7 @@ void main() {
|
||||
gl_Position = vec4(0.0);
|
||||
vUV = vec2(0.0);
|
||||
vIconType = -1;
|
||||
vFlagLayer = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -92,46 +93,49 @@ void main() {
|
||||
|
||||
// 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
|
||||
// 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 * (cellW / cellH);
|
||||
float flagWorldW = flagWorldH * (uFlagCellW / uFlagCellH);
|
||||
|
||||
// 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
|
||||
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;
|
||||
|
||||
// Position: centered above name
|
||||
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
|
||||
@@ -141,14 +145,5 @@ void main() {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
+1
-40
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user