Render NameLayer text with MSDF assets

This commit is contained in:
scamiv
2026-05-09 02:14:44 +02:00
parent be3bab84ed
commit a60e72ea6f
13 changed files with 3582 additions and 765 deletions
+86 -41
View File
@@ -23,10 +23,9 @@ import { TransformHandler } from "../TransformHandler";
import { Layer } from "./Layer";
import { NameLayerAssets } from "./NameLayerAssets";
import {
computeNameLayerFontSize,
computeNameLayerLayout,
computeNameLayerScreenMetrics,
computeNameLayerVisible,
computeNameLayerWorldScale,
computeTraitorFlashAlpha,
replaceUnsupportedNameGlyphs,
} from "./NameLayerLayout";
@@ -41,7 +40,6 @@ interface PixiIconRender {
centered: boolean;
src?: string;
sprite?: PIXI.Sprite;
text?: PIXI.Text;
alliance?: {
base: PIXI.Sprite;
colored: PIXI.Sprite;
@@ -55,6 +53,7 @@ class RenderInfo {
public location: Cell | null = null;
public baseSize = 1;
public fontSize = 0;
public iconSize = 0;
public fontColor = "";
public flagSrc = "";
public flagSprite: PIXI.Sprite | null = null;
@@ -325,11 +324,19 @@ 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;
const metrics = computeNameLayerScreenMetrics(
render.baseSize,
this.transformHandler.scale,
);
if (
render.fontSize !== metrics.fontSize ||
render.iconSize !== metrics.iconSize
) {
render.fontSize = metrics.fontSize;
render.iconSize = metrics.iconSize;
this.updateText(render);
this.layoutRender(render, Math.min(render.fontSize * 1.5, 48));
this.resizeIcons(render, render.iconSize);
this.layoutRender(render, render.iconSize);
}
render.location = new Cell(nameLocation.x, nameLocation.y);
const isOnScreen = this.transformHandler.isOnScreen(render.location);
@@ -348,12 +355,7 @@ export class NameLayer implements Layer {
render.location,
);
render.container.position.set(screenPos.x, screenPos.y);
render.container.scale.set(
computeNameLayerWorldScale(
render.baseSize,
this.transformHandler.scale,
),
);
render.container.scale.set(1);
this.updateTraitorAlpha(render, now);
}
}
@@ -381,7 +383,6 @@ export class NameLayer implements Layer {
this.updateText(render);
this.updateFlag(render);
const iconSize = Math.min(render.fontSize * 1.5, 48);
const icons = getPlayerIcons({
game: this.game,
player: render.player,
@@ -392,8 +393,8 @@ export class NameLayer implements Layer {
transitiveTargets,
});
this.updateIcons(render, icons, iconSize);
this.layoutRender(render, iconSize);
this.updateIcons(render, icons, render.iconSize);
this.layoutRender(render, render.iconSize);
}
private updateText(render: RenderInfo) {
@@ -506,6 +507,25 @@ export class NameLayer implements Layer {
}
}
private resizeIcons(render: RenderInfo, size: number) {
for (const iconRender of render.icons.values()) {
if (iconRender.sprite) {
iconRender.sprite.width = size;
iconRender.sprite.height = size;
}
if (iconRender.alliance) {
const refs = iconRender.alliance;
refs.base.width = size;
refs.base.height = size;
refs.colored.width = size;
refs.colored.height = size;
refs.questionMark.width = size;
refs.questionMark.height = size;
this.updateAllianceProgressMask(render, refs, size);
}
}
}
private updateImageIcon(
render: RenderInfo,
icon: PlayerIconDescriptor,
@@ -551,33 +571,38 @@ export class NameLayer implements Layer {
icon: PlayerIconDescriptor,
size: number,
) {
const text = icon.text ?? "";
const texture = text ? this.assets.getEmojiTexture(text) : null;
if (!texture) {
const existing = render.icons.get(icon.id);
if (existing) {
existing.container.visible = false;
}
return;
}
let iconRender = render.icons.get(icon.id);
if (!iconRender || !iconRender.text) {
if (!iconRender || iconRender.src !== text || !iconRender.sprite) {
iconRender?.container.destroy({ children: true });
const container = new PIXI.Container();
container.alpha = 0.8;
const text = new PIXI.Text({
text: icon.text ?? "",
style: {
fontFamily: "sans-serif",
fontSize: size,
fill: "#ffffff",
},
});
text.anchor.set(0.5);
container.addChild(text);
const sprite = new PIXI.Sprite(texture);
sprite.anchor.set(0.5);
container.addChild(sprite);
render.container.addChild(container);
iconRender = { container, centered: icon.center ?? false, text };
iconRender = {
container,
centered: icon.center ?? false,
src: text,
sprite,
};
render.icons.set(icon.id, iconRender);
}
iconRender.centered = icon.center ?? false;
iconRender.text!.text = icon.text ?? "";
iconRender.text!.style = {
fontFamily: "sans-serif",
fontSize: size,
fill: "#ffffff",
};
iconRender.sprite!.texture = texture;
iconRender.sprite!.width = size;
iconRender.sprite!.height = size;
iconRender.container.visible = true;
}
@@ -629,6 +654,26 @@ export class NameLayer implements Layer {
refs.colored.width = size;
refs.colored.height = size;
this.updateAllianceProgressMask(render, refs, size);
refs.questionMark.visible =
this.hasAllianceExtensionRequest(render) && questionTexture !== null;
if (questionTexture) {
refs.questionMark.texture = questionTexture;
refs.questionMark.width = size;
refs.questionMark.height = size;
}
}
private updateAllianceProgressMask(
render: RenderInfo,
refs: PixiIconRender["alliance"],
size: number,
) {
if (!refs) {
return;
}
this.myPlayer ??= this.game.myPlayer();
const allianceView = this.myPlayer
?.alliances()
@@ -648,14 +693,14 @@ export class NameLayer implements Layer {
refs.mask
.rect(-size / 2, -size / 2 + topCut, size, Math.max(0, size - topCut))
.fill(0xffffff);
}
refs.questionMark.visible =
allianceView?.hasExtensionRequest === true && questionTexture !== null;
if (questionTexture) {
refs.questionMark.texture = questionTexture;
refs.questionMark.width = size;
refs.questionMark.height = size;
}
private hasAllianceExtensionRequest(render: RenderInfo): boolean {
this.myPlayer ??= this.game.myPlayer();
return (
this.myPlayer?.alliances().find((a) => a.other === render.player.id())
?.hasExtensionRequest === true
);
}
private layoutRender(render: RenderInfo, iconSize: number) {
+49 -4
View File
@@ -14,8 +14,11 @@ export class NameLayerAssets {
public fontReady = false;
private readonly textures = new Map<string, PIXI.Texture>();
private readonly atlasTextures = new Map<string, PIXI.Texture>();
private readonly emojiTextures = new Map<string, PIXI.Texture>();
private readonly pendingTextures = new Map<string, Promise<void>>();
private readonly warnedTextureFailures = new Set<string>();
private readonly warnedMissingEmojis = new Set<string>();
private preloadPromise: Promise<void> | null = null;
preload(): Promise<void> {
@@ -24,6 +27,11 @@ export class NameLayerAssets {
}
getTexture(src: string): PIXI.Texture | null {
const atlasTexture = this.atlasTextures.get(textureKeyFromSrc(src));
if (atlasTexture) {
return atlasTexture;
}
const cached = this.textures.get(src);
if (cached) {
return cached;
@@ -49,6 +57,18 @@ export class NameLayerAssets {
return null;
}
getEmojiTexture(emoji: string): PIXI.Texture | null {
const texture = this.emojiTextures.get(emoji);
if (texture) {
return texture;
}
if (!this.warnedMissingEmojis.has(emoji)) {
this.warnedMissingEmojis.add(emoji);
console.warn(`NameLayer emoji omitted; atlas frame missing: ${emoji}`);
}
return null;
}
preloadTextures(srcs: Iterable<string>): void {
for (const src of srcs) {
this.getTexture(src);
@@ -57,13 +77,18 @@ export class NameLayerAssets {
resetWarningsForTests(): void {
this.warnedTextureFailures.clear();
this.warnedMissingEmojis.clear();
}
private async loadBaseAssets(): Promise<void> {
await this.loadFont();
await Promise.all([
this.loadOptionalAtlas(iconAtlas, "static icon atlas"),
this.loadOptionalAtlas(emojiAtlas, "emoji atlas"),
this.loadOptionalAtlas(
iconAtlas,
"static icon atlas",
this.atlasTextures,
),
this.loadOptionalAtlas(emojiAtlas, "emoji atlas", this.emojiTextures),
]);
}
@@ -91,9 +116,18 @@ export class NameLayerAssets {
}
}
private async loadOptionalAtlas(src: string, label: string): Promise<void> {
private async loadOptionalAtlas(
src: string,
label: string,
target: Map<string, PIXI.Texture>,
): Promise<void> {
try {
await PIXI.Assets.load(src);
const atlas = (await PIXI.Assets.load(src)) as {
textures?: Record<string, PIXI.Texture>;
};
for (const [key, texture] of Object.entries(atlas.textures ?? {})) {
target.set(key, texture);
}
} catch (error) {
console.warn(`NameLayer ${label} unavailable`, error);
}
@@ -107,3 +141,14 @@ export class NameLayerAssets {
console.warn(`NameLayer texture omitted after load failure: ${src}`, error);
}
}
function textureKeyFromSrc(src: string): string {
const clean = src.split(/[?#]/, 1)[0] ?? src;
const slash = clean.lastIndexOf("/");
const key = slash >= 0 ? clean.slice(slash + 1) : clean;
try {
return decodeURIComponent(key);
} catch {
return key;
}
}
+19 -1
View File
@@ -31,8 +31,13 @@ export interface NameLayerLayout {
rows: { iconsY: number | null; nameY: number; troopsY: number };
}
export interface NameLayerScreenMetrics {
fontSize: number;
iconSize: number;
}
const SUPPORTED_TEXT_CHARS = new Set(
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_ üÜ.[]+-=(),':!?/@#$%&\"".split(
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_ \u00fc\u00dc.[]+-=(),':!?/@#$%&\"".split(
"",
),
);
@@ -74,6 +79,19 @@ export function computeNameLayerFontSize(baseSize: number): number {
return Math.max(4, Math.floor(baseSize * 0.4));
}
export function computeNameLayerScreenMetrics(
baseSize: number,
transformScale: number,
): NameLayerScreenMetrics {
const worldScale = computeNameLayerWorldScale(baseSize, transformScale);
const localFontSize = computeNameLayerFontSize(baseSize);
const localIconSize = Math.min(localFontSize * 1.5, 48);
return {
fontSize: Math.max(1, localFontSize * worldScale),
iconSize: Math.max(1, localIconSize * worldScale),
};
}
export function computeNameLayerLayout({
fontSize,
iconSize,