diff --git a/scripts/build-namelayer-assets.mjs b/scripts/build-namelayer-assets.mjs index 8727a8a60..705338969 100644 --- a/scripts/build-namelayer-assets.mjs +++ b/scripts/build-namelayer-assets.mjs @@ -202,7 +202,7 @@ async function buildIconAtlas() { ); fs.writeFileSync( path.join(imagesDir, "namelayer-icons.json"), - JSON.stringify( + `${JSON.stringify( { frames, meta: { @@ -215,7 +215,7 @@ async function buildIconAtlas() { }, null, 2, - ), + )}\n`, ); } @@ -283,7 +283,7 @@ async function buildEmojiAtlas() { ); fs.writeFileSync( path.join(imagesDir, "namelayer-emojis.json"), - JSON.stringify( + `${JSON.stringify( { frames, meta: { @@ -296,21 +296,26 @@ async function buildEmojiAtlas() { }, null, 2, - ), + )}\n`, ); } function readEmojiTable() { - const utilSource = fs.readFileSync( - path.join(root, "src", "core", "Util.ts"), - "utf8", - ); - const tableSource = utilSource.match( + const utilPath = path.join(root, "src", "core", "Util.ts"); + const utilSource = fs.readFileSync(utilPath, "utf8"); + const match = utilSource.match( /export const emojiTable = \[([\s\S]*?)\] as const;/, - )?.[1]; - return tableSource - ? Array.from(tableSource.matchAll(/"([^"]+)"/g), (match) => match[1]) - : []; + ); + if (!match?.[1]) { + throw new Error( + `emojiTable not found in utilSource (${utilPath}). Start of file: ${utilSource.slice( + 0, + 160, + )}`, + ); + } + + return Array.from(match[1].matchAll(/"([^"]+)"/g), (match) => match[1]); } function writeFallbackAtlas(name, keys) { @@ -333,7 +338,7 @@ function writeFallbackAtlas(name, keys) { fs.writeFileSync(path.join(imagesDir, `${name}.png`), transparentPng); fs.writeFileSync( path.join(imagesDir, `${name}.json`), - JSON.stringify( + `${JSON.stringify( { frames, meta: { @@ -346,6 +351,6 @@ function writeFallbackAtlas(name, keys) { }, null, 2, - ), + )}\n`, ); } diff --git a/src/client/graphics/layers/Layer.ts b/src/client/graphics/layers/Layer.ts index 9cb6b91c2..df1945c7d 100644 --- a/src/client/graphics/layers/Layer.ts +++ b/src/client/graphics/layers/Layer.ts @@ -8,4 +8,5 @@ export interface Layer { renderLayer?: (context: CanvasRenderingContext2D) => void; shouldTransform?: () => boolean; redraw?: () => void; + destroy?: () => void; } diff --git a/src/client/graphics/layers/NameLayer.ts b/src/client/graphics/layers/NameLayer.ts index 16b206299..d88a6082c 100644 --- a/src/client/graphics/layers/NameLayer.ts +++ b/src/client/graphics/layers/NameLayer.ts @@ -88,7 +88,9 @@ export class NameLayer implements Layer { private allianceDuration: number; private alliancesDisabled = false; private myPlayer: PlayerView | null = null; - private pixicanvas: HTMLCanvasElement; + private readonly pixiCanvas: HTMLCanvasElement = + document.createElement("canvas"); + private readonly onWindowResize = () => this.resizeCanvas(); private renderer: PixiRenderer | null = null; private rendererInitialized = false; private rebuildPending = false; @@ -114,19 +116,19 @@ export class NameLayer implements Layer { this.rootStage.position.set(0, 0); this.eventBus.on(AlternateViewEvent, (e) => this.onAlternateViewChange(e)); - window.addEventListener("resize", () => this.resizeCanvas()); + window.addEventListener("resize", this.onWindowResize); await this.setupRenderer(); this.resizeCanvas(); } async redraw() { - if (this.rebuildPending || this.rendererOrGLContextLost()) { + if (this.rebuildPending) { return; } this.rebuildPending = true; try { - if (this.renderer?.name === "webgpu") { + if (!this.renderer || this.renderer.name === "webgpu") { this.rendererInitialized = false; await this.setupRenderer(); } @@ -136,6 +138,13 @@ export class NameLayer implements Layer { } this.renders.length = 0; this.seenPlayers.clear(); + } catch (error) { + console.error("NameLayer redraw failed; retrying next frame", error); + this.renderer = null; + this.rendererInitialized = false; + requestAnimationFrame(() => { + void this.redraw(); + }); } finally { this.rebuildPending = false; } @@ -151,7 +160,10 @@ export class NameLayer implements Layer { for (const player of this.game.playerViews()) { if (player.isAlive() && !this.seenPlayers.has(player)) { this.seenPlayers.add(player); - this.renders.push(this.createPlayerRender(player)); + const render = this.createPlayerRender(player); + if (render) { + this.renders.push(render); + } } } } @@ -175,27 +187,38 @@ export class NameLayer implements Layer { this.renderer?.render(this.rootStage); if (this.renderer) { - mainContext.drawImage(this.renderer.canvas, 0, 0); + mainContext.drawImage( + this.renderer.canvas, + 0, + 0, + this.renderer.canvas.width, + this.renderer.canvas.height, + 0, + 0, + mainContext.canvas.width, + mainContext.canvas.height, + ); } } private async setupRenderer() { if (this.renderer) { - this.renderer.destroy(true); + this.renderer.destroy(false); + this.renderer = null; + this.rendererInitialized = false; this.labelStage.removeChildren(); } await this.assets.preload(); - this.pixicanvas = document.createElement("canvas"); - this.pixicanvas.width = window.innerWidth; - this.pixicanvas.height = window.innerHeight; + const resolution = window.devicePixelRatio || 1; + this.resizePixiCanvasElement(resolution); const renderer = await PIXI.autoDetectRenderer({ - canvas: this.pixicanvas, - resolution: 1, - width: this.pixicanvas.width, - height: this.pixicanvas.height, + canvas: this.pixiCanvas, + resolution, + width: window.innerWidth, + height: window.innerHeight, antialias: false, clearBeforeRender: true, backgroundAlpha: 0, @@ -208,7 +231,9 @@ export class NameLayer implements Layer { if (this.renderer.name === "webgpu") { const gpuRenderer = this.renderer as PIXI.WebGPURenderer; gpuRenderer.gpu.device.lost.then(() => { - this.redraw(); + // device.lost is a one-time Promise; setupRenderer() intentionally + // re-attaches this handler on rebuild so future losses are observed. + void this.redraw(); }); } @@ -216,7 +241,7 @@ export class NameLayer implements Layer { this.renderer.runners.contextChange.add({ contextChange: () => { requestAnimationFrame(() => { - this.redraw(); + void this.redraw(); }); }, }); @@ -237,9 +262,16 @@ export class NameLayer implements Layer { if (this.rendererOrGLContextLost()) { return; } - this.pixicanvas.width = window.innerWidth; - this.pixicanvas.height = window.innerHeight; - this.renderer?.resize(window.innerWidth, window.innerHeight, 1); + const resolution = window.devicePixelRatio || 1; + this.resizePixiCanvasElement(resolution); + this.renderer?.resize(window.innerWidth, window.innerHeight, resolution); + } + + private resizePixiCanvasElement(resolution: number) { + this.pixiCanvas.width = Math.ceil(window.innerWidth * resolution); + this.pixiCanvas.height = Math.ceil(window.innerHeight * resolution); + this.pixiCanvas.style.width = `${window.innerWidth}px`; + this.pixiCanvas.style.height = `${window.innerHeight}px`; } private onAlternateViewChange(event: AlternateViewEvent) { @@ -247,7 +279,11 @@ export class NameLayer implements Layer { this.updateTransformsAndVisibility(); } - private createPlayerRender(player: PlayerView): RenderInfo { + private createPlayerRender(player: PlayerView): RenderInfo | null { + if (!this.assets.fontReady) { + return null; + } + const container = new PIXI.Container(); container.visible = false; @@ -263,6 +299,10 @@ export class NameLayer implements Layer { } private createBitmapText(text: string): PIXI.BitmapText { + if (!this.assets.fontReady || !this.assets.fontFamily) { + throw new Error("NameLayer bitmap font is not ready"); + } + const bitmapText = new PIXI.BitmapText({ text, style: { @@ -285,6 +325,12 @@ export class NameLayer implements Layer { } render.baseSize = Math.max(1, Math.floor(nameLocation.size)); + const fontSize = computeNameLayerFontSize(render.baseSize); + if (render.fontSize !== fontSize) { + render.fontSize = fontSize; + this.updateText(render); + this.layoutRender(render, Math.min(render.fontSize * 1.5, 48)); + } render.location = new Cell(nameLocation.x, nameLocation.y); const isOnScreen = this.transformHandler.isOnScreen(render.location); render.container.visible = computeNameLayerVisible({ @@ -330,7 +376,6 @@ export class NameLayer implements Layer { } render.lastRenderCalc = now + this.rand.nextInt(0, 100); - render.fontSize = computeNameLayerFontSize(render.baseSize); this.updateText(render); this.updateFlag(render); @@ -350,6 +395,10 @@ export class NameLayer implements Layer { } private updateText(render: RenderInfo) { + if (!this.assets.fontFamily) { + return; + } + const displayName = replaceUnsupportedNameGlyphs( render.player.displayName(), ); @@ -357,10 +406,11 @@ export class NameLayer implements Layer { renderTroops(render.player.troops()), ); const fontColor = this.theme.textColor(render.player); + const prevFontColor = render.fontColor; if ( render.lastDisplayName !== displayName || - render.fontColor !== fontColor || + prevFontColor !== fontColor || render.nameText.style.fontSize !== render.fontSize || render.nameText.style.fontFamily !== this.assets.fontFamily ) { @@ -375,7 +425,7 @@ export class NameLayer implements Layer { if ( render.lastTroopsText !== troopsText || - render.fontColor !== fontColor || + prevFontColor !== fontColor || render.troopsText.style.fontSize !== render.fontSize || render.troopsText.style.fontFamily !== this.assets.fontFamily ) { @@ -395,23 +445,17 @@ export class NameLayer implements Layer { const flag = render.player.cosmetics.flag; const src = flag ? assetUrl(flag) : ""; if (!src) { - render.flagSprite?.destroy(); - render.flagSprite = null; - render.flagSrc = ""; + this.hideFlag(render, true); return; } if (src !== render.flagSrc) { - render.flagSprite?.destroy(); - render.flagSprite = null; - render.flagSrc = src; + this.hideFlag(render, true); } const texture = this.assets.getTexture(src); if (!texture) { - if (render.flagSprite) { - render.flagSprite.visible = false; - } + this.hideFlag(render, false); return; } @@ -424,9 +468,18 @@ export class NameLayer implements Layer { render.flagSprite.texture = texture; } + render.flagSrc = src; render.flagSprite.visible = true; } + private hideFlag(render: RenderInfo, clearSource: boolean) { + render.flagSprite?.destroy(); + render.flagSprite = null; + if (clearSource) { + render.flagSrc = ""; + } + } + private updateIcons( render: RenderInfo, icons: PlayerIconDescriptor[], @@ -568,6 +621,7 @@ export class NameLayer implements Layer { const refs = iconRender.alliance!; refs.base.texture = baseTexture; refs.colored.texture = coloredTexture; + iconRender.src = icon.src; refs.base.width = size; refs.base.height = size; refs.colored.width = size; @@ -586,6 +640,9 @@ export class NameLayer implements Layer { ); const topCut = (computeAllianceTopCutPercent(fraction) / 100) * size; refs.mask.clear(); + // computeAllianceTopCutPercent can intentionally make the visible alliance + // height zero when remaining / this.allianceDuration is depleted; PIXI v8 + // tolerates the zero-area refs.mask.rect and the Math.max guard preserves it. refs.mask .rect(-size / 2, -size / 2 + topCut, size, Math.max(0, size - topCut)) .fill(0xffffff); @@ -667,4 +724,17 @@ export class NameLayer implements Layer { this.seenPlayers.delete(render.player); render.container.destroy({ children: true }); } + + destroy() { + window.removeEventListener("resize", this.onWindowResize); + for (const render of this.renders) { + render.container.destroy({ children: true }); + } + this.renders.length = 0; + this.seenPlayers.clear(); + this.rootStage.removeChildren(); + this.renderer?.destroy(true); + this.renderer = null; + this.rendererInitialized = false; + } } diff --git a/src/client/graphics/layers/NameLayerAssets.ts b/src/client/graphics/layers/NameLayerAssets.ts index a9dbfb56b..1075cd03f 100644 --- a/src/client/graphics/layers/NameLayerAssets.ts +++ b/src/client/graphics/layers/NameLayerAssets.ts @@ -10,9 +10,10 @@ export const NAME_LAYER_FONT_FAMILY = "namelayer_overpass"; export const NAME_LAYER_FALLBACK_FONT_FAMILY = "round_6x6_modified"; export class NameLayerAssets { - public fontFamily = NAME_LAYER_FONT_FAMILY; + public fontFamily: string | null = null; + public fontReady = false; - private readonly textures = new Map(); + private readonly textures = new Map(); private readonly pendingTextures = new Map>(); private readonly warnedTextureFailures = new Set(); private preloadPromise: Promise | null = null; @@ -24,7 +25,7 @@ export class NameLayerAssets { getTexture(src: string): PIXI.Texture | null { const cached = this.textures.get(src); - if (cached !== undefined) { + if (cached) { return cached; } @@ -36,7 +37,7 @@ export class NameLayerAssets { this.textures.set(src, texture); }) .catch((error) => { - this.textures.set(src, null); + this.textures.delete(src); this.warnTextureFailure(src, error); }) .finally(() => { @@ -70,6 +71,7 @@ export class NameLayerAssets { try { await PIXI.Assets.load(nameLayerFont); this.fontFamily = NAME_LAYER_FONT_FAMILY; + this.fontReady = true; return; } catch (error) { console.warn( @@ -81,7 +83,10 @@ export class NameLayerAssets { try { await PIXI.Assets.load(fallbackFont); this.fontFamily = NAME_LAYER_FALLBACK_FONT_FAMILY; + this.fontReady = true; } catch (error) { + this.fontFamily = null; + this.fontReady = false; console.error("NameLayer failed to load bitmap font", error); } } diff --git a/src/client/graphics/layers/NameLayerLayout.ts b/src/client/graphics/layers/NameLayerLayout.ts index 6515ab07c..f0181fd33 100644 --- a/src/client/graphics/layers/NameLayerLayout.ts +++ b/src/client/graphics/layers/NameLayerLayout.ts @@ -39,6 +39,11 @@ const SUPPORTED_TEXT_CHARS = new Set( const warnedUnsupportedGlyphs = new Set(); +type IntlSegmenterConstructor = new ( + locales?: string | string[], + options?: { granularity: "grapheme" }, +) => { segment(value: string): Iterable<{ segment: string }> }; + export function computeNameLayerVisible({ isLayerVisible, transformScale, @@ -116,10 +121,19 @@ export function computeNameLayerLayout({ } : null; const nameTextX = nameStartX + flagWidth + nameWidth / 2; + const visibleCenteredIconCount = Math.max(0, centeredIconCount); + const centeredIconRowWidth = + visibleCenteredIconCount > 0 + ? visibleCenteredIconCount * iconSize + + (visibleCenteredIconCount - 1) * NAME_LAYER_ICON_GAP + : 0; const centeredIconPositions = Array.from( - { length: centeredIconCount }, - () => ({ - x: 0, + { length: visibleCenteredIconCount }, + (_, index) => ({ + x: + -centeredIconRowWidth / 2 + + iconSize / 2 + + index * (iconSize + NAME_LAYER_ICON_GAP), y: nameY, }), ); @@ -173,23 +187,37 @@ export function replaceUnsupportedNameGlyphs( let changed = false; let result = ""; - for (const char of value) { - if (SUPPORTED_TEXT_CHARS.has(char)) { - result += char; + const segments = segmentGraphemes(value); + for (const segment of segments) { + if (segment.length === 1 && SUPPORTED_TEXT_CHARS.has(segment)) { + result += segment; continue; } changed = true; result += "?"; - if (!warnedUnsupportedGlyphs.has(char)) { - warnedUnsupportedGlyphs.add(char); - warn(`NameLayer unsupported glyph replaced with ?: ${char}`); + if (!warnedUnsupportedGlyphs.has(segment)) { + warnedUnsupportedGlyphs.add(segment); + warn(`NameLayer unsupported glyph replaced with ?: ${segment}`); } } return changed ? result : value; } +function segmentGraphemes(value: string): string[] { + const Segmenter = ( + Intl as typeof Intl & { Segmenter?: IntlSegmenterConstructor } + ).Segmenter; + if (typeof Segmenter === "function") { + const segmenter = new Segmenter(undefined, { + granularity: "grapheme", + }); + return Array.from(segmenter.segment(value), ({ segment }) => segment); + } + return Array.from(value); +} + export function resetNameLayerGlyphWarningsForTests(): void { warnedUnsupportedGlyphs.clear(); } diff --git a/src/client/graphics/layers/StructureIconsLayer.ts b/src/client/graphics/layers/StructureIconsLayer.ts index 5d879e740..6c7e2c1c5 100644 --- a/src/client/graphics/layers/StructureIconsLayer.ts +++ b/src/client/graphics/layers/StructureIconsLayer.ts @@ -135,17 +135,17 @@ export class StructureIconsLayer implements Layer { } this.pixicanvas = document.createElement("canvas"); - this.pixicanvas.width = window.innerWidth; - this.pixicanvas.height = window.innerHeight; + const resolution = window.devicePixelRatio || 1; + this.resizePixiCanvasElement(resolution); // This will prefer WebGL, eventually WebGPU, and fallback to Canvas // Restrict using 'preferences: ["WebGPU", "WebGL"]' or // 'preferences: "WebGPU"' later if needed const renderer = await PIXI.autoDetectRenderer({ canvas: this.pixicanvas, - resolution: 1, - width: this.pixicanvas.width, - height: this.pixicanvas.height, + resolution, + width: window.innerWidth, + height: window.innerHeight, antialias: false, clearBeforeRender: true, backgroundAlpha: 0, @@ -293,9 +293,16 @@ export class StructureIconsLayer implements Layer { if (this.rendererOrGLContextLost()) { return; } - this.pixicanvas.width = window.innerWidth; - this.pixicanvas.height = window.innerHeight; - this.renderer?.resize(innerWidth, innerHeight, 1); + const resolution = window.devicePixelRatio || 1; + this.resizePixiCanvasElement(resolution); + this.renderer?.resize(window.innerWidth, window.innerHeight, resolution); + } + + private resizePixiCanvasElement(resolution: number) { + this.pixicanvas.width = Math.ceil(window.innerWidth * resolution); + this.pixicanvas.height = Math.ceil(window.innerHeight * resolution); + this.pixicanvas.style.width = `${window.innerWidth}px`; + this.pixicanvas.style.height = `${window.innerHeight}px`; } tick() { @@ -350,8 +357,18 @@ export class StructureIconsLayer implements Layer { (scale <= ZOOM_THRESHOLD || !this.renderSprites); this.levelsStage!.visible = scale > ZOOM_THRESHOLD && this.renderSprites; if (this.renderer) { - this.renderer?.render(this.rootStage); - mainContext.drawImage(this.renderer.canvas, 0, 0); + this.renderer.render(this.rootStage); + mainContext.drawImage( + this.renderer.canvas, + 0, + 0, + this.renderer.canvas.width, + this.renderer.canvas.height, + 0, + 0, + mainContext.canvas.width, + mainContext.canvas.height, + ); } } diff --git a/tests/NameLayer.test.ts b/tests/NameLayer.test.ts index 8473198e9..af22f397f 100644 --- a/tests/NameLayer.test.ts +++ b/tests/NameLayer.test.ts @@ -101,9 +101,28 @@ describe("NameLayerLayout", () => { expect(computeTraitorFlashDurationSeconds(150)).toBeCloseTo(1); expect(computeTraitorFlashDurationSeconds(0)).toBeCloseTo(0.2); expect(computeTraitorFlashAlpha(150, 0)).toBeCloseTo(1); + expect(computeTraitorFlashAlpha(150, 250)).toBeCloseTo(0.65); expect(computeTraitorFlashAlpha(150, 500)).toBeCloseTo(0.3); }); + test("spreads multiple centered icons instead of stacking them", () => { + const layout = computeNameLayerLayout({ + fontSize: 10, + iconSize: 15, + iconCount: 0, + centeredIconCount: 2, + hasFlag: false, + flagAspectRatio: 1, + nameWidth: 40, + troopWidth: 30, + }); + + expect(layout.centeredIconPositions).toEqual([ + { x: -9.5, y: -4.75 }, + { x: 9.5, y: -4.75 }, + ]); + }); + test("replaces unsupported glyphs once per glyph", () => { resetNameLayerGlyphWarningsForTests(); const warn = vi.fn(); @@ -112,4 +131,14 @@ describe("NameLayerLayout", () => { expect(replaceUnsupportedNameGlyphs("🙂", warn)).toBe("?"); expect(warn).toHaveBeenCalledTimes(1); }); + + test("replaces unsupported grapheme clusters with one fallback glyph", () => { + resetNameLayerGlyphWarningsForTests(); + const warn = vi.fn(); + + expect( + replaceUnsupportedNameGlyphs("A\u{1F469}\u200D\u{1F4BB}B", warn), + ).toBe("A?B"); + expect(warn).toHaveBeenCalledTimes(1); + }); });