mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 12:20:46 +00:00
Address NameLayer review feedback
This commit is contained in:
@@ -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`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,4 +8,5 @@ export interface Layer {
|
||||
renderLayer?: (context: CanvasRenderingContext2D) => void;
|
||||
shouldTransform?: () => boolean;
|
||||
redraw?: () => void;
|
||||
destroy?: () => void;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user