Address NameLayer review feedback

This commit is contained in:
scamiv
2026-05-09 01:54:35 +02:00
parent fe216cba4b
commit 645efeab9d
7 changed files with 225 additions and 70 deletions
+20 -15
View File
@@ -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`,
);
}
+1
View File
@@ -8,4 +8,5 @@ export interface Layer {
renderLayer?: (context: CanvasRenderingContext2D) => void;
shouldTransform?: () => boolean;
redraw?: () => void;
destroy?: () => void;
}
+102 -32
View File
@@ -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;
}
}
@@ -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<string, PIXI.Texture | null>();
private readonly textures = new Map<string, PIXI.Texture>();
private readonly pendingTextures = new Map<string, Promise<void>>();
private readonly warnedTextureFailures = new Set<string>();
private preloadPromise: Promise<void> | 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);
}
}
+37 -9
View File
@@ -39,6 +39,11 @@ const SUPPORTED_TEXT_CHARS = new Set(
const warnedUnsupportedGlyphs = new Set<string>();
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();
}
@@ -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,
);
}
}
+29
View File
@@ -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);
});
});