mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-16 08:32:49 +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,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,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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user