mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 09:20:47 +00:00
Render NameLayer text with MSDF assets
This commit is contained in:
Generated
+1645
File diff suppressed because it is too large
Load Diff
@@ -65,6 +65,7 @@
|
||||
"lit": "^3.3.2",
|
||||
"lit-markdown": "^1.3.2",
|
||||
"mrmime": "^2.0.1",
|
||||
"msdf-bmfont-xml": "^2.8.0",
|
||||
"pixi-filters": "^6.1.5",
|
||||
"pixi.js": "^8.18.1",
|
||||
"prettier": "^3.8.3",
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 170 KiB |
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
Before Width: | Height: | Size: 65 KiB After Width: | Height: | Size: 144 KiB |
@@ -4,260 +4,260 @@
|
||||
"frame": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"w": 64,
|
||||
"h": 64
|
||||
"w": 256,
|
||||
"h": 256
|
||||
},
|
||||
"rotated": false,
|
||||
"trimmed": false,
|
||||
"spriteSourceSize": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"w": 64,
|
||||
"h": 64
|
||||
"w": 256,
|
||||
"h": 256
|
||||
},
|
||||
"sourceSize": {
|
||||
"w": 64,
|
||||
"h": 64
|
||||
"w": 256,
|
||||
"h": 256
|
||||
}
|
||||
},
|
||||
"AllianceIconFaded.svg": {
|
||||
"frame": {
|
||||
"x": 64,
|
||||
"x": 256,
|
||||
"y": 0,
|
||||
"w": 64,
|
||||
"h": 64
|
||||
"w": 256,
|
||||
"h": 256
|
||||
},
|
||||
"rotated": false,
|
||||
"trimmed": false,
|
||||
"spriteSourceSize": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"w": 64,
|
||||
"h": 64
|
||||
"w": 256,
|
||||
"h": 256
|
||||
},
|
||||
"sourceSize": {
|
||||
"w": 64,
|
||||
"h": 64
|
||||
"w": 256,
|
||||
"h": 256
|
||||
}
|
||||
},
|
||||
"AllianceRequestBlackIcon.svg": {
|
||||
"frame": {
|
||||
"x": 128,
|
||||
"x": 512,
|
||||
"y": 0,
|
||||
"w": 64,
|
||||
"h": 64
|
||||
"w": 256,
|
||||
"h": 256
|
||||
},
|
||||
"rotated": false,
|
||||
"trimmed": false,
|
||||
"spriteSourceSize": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"w": 64,
|
||||
"h": 64
|
||||
"w": 256,
|
||||
"h": 256
|
||||
},
|
||||
"sourceSize": {
|
||||
"w": 64,
|
||||
"h": 64
|
||||
"w": 256,
|
||||
"h": 256
|
||||
}
|
||||
},
|
||||
"AllianceRequestWhiteIcon.svg": {
|
||||
"frame": {
|
||||
"x": 192,
|
||||
"x": 768,
|
||||
"y": 0,
|
||||
"w": 64,
|
||||
"h": 64
|
||||
"w": 256,
|
||||
"h": 256
|
||||
},
|
||||
"rotated": false,
|
||||
"trimmed": false,
|
||||
"spriteSourceSize": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"w": 64,
|
||||
"h": 64
|
||||
"w": 256,
|
||||
"h": 256
|
||||
},
|
||||
"sourceSize": {
|
||||
"w": 64,
|
||||
"h": 64
|
||||
"w": 256,
|
||||
"h": 256
|
||||
}
|
||||
},
|
||||
"CrownIcon.svg": {
|
||||
"frame": {
|
||||
"x": 0,
|
||||
"y": 64,
|
||||
"w": 64,
|
||||
"h": 64
|
||||
"y": 256,
|
||||
"w": 256,
|
||||
"h": 256
|
||||
},
|
||||
"rotated": false,
|
||||
"trimmed": false,
|
||||
"spriteSourceSize": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"w": 64,
|
||||
"h": 64
|
||||
"w": 256,
|
||||
"h": 256
|
||||
},
|
||||
"sourceSize": {
|
||||
"w": 64,
|
||||
"h": 64
|
||||
"w": 256,
|
||||
"h": 256
|
||||
}
|
||||
},
|
||||
"DisconnectedIcon.svg": {
|
||||
"frame": {
|
||||
"x": 64,
|
||||
"y": 64,
|
||||
"w": 64,
|
||||
"h": 64
|
||||
"x": 256,
|
||||
"y": 256,
|
||||
"w": 256,
|
||||
"h": 256
|
||||
},
|
||||
"rotated": false,
|
||||
"trimmed": false,
|
||||
"spriteSourceSize": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"w": 64,
|
||||
"h": 64
|
||||
"w": 256,
|
||||
"h": 256
|
||||
},
|
||||
"sourceSize": {
|
||||
"w": 64,
|
||||
"h": 64
|
||||
"w": 256,
|
||||
"h": 256
|
||||
}
|
||||
},
|
||||
"EmbargoBlackIcon.svg": {
|
||||
"frame": {
|
||||
"x": 128,
|
||||
"y": 64,
|
||||
"w": 64,
|
||||
"h": 64
|
||||
"x": 512,
|
||||
"y": 256,
|
||||
"w": 256,
|
||||
"h": 256
|
||||
},
|
||||
"rotated": false,
|
||||
"trimmed": false,
|
||||
"spriteSourceSize": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"w": 64,
|
||||
"h": 64
|
||||
"w": 256,
|
||||
"h": 256
|
||||
},
|
||||
"sourceSize": {
|
||||
"w": 64,
|
||||
"h": 64
|
||||
"w": 256,
|
||||
"h": 256
|
||||
}
|
||||
},
|
||||
"EmbargoWhiteIcon.svg": {
|
||||
"frame": {
|
||||
"x": 192,
|
||||
"y": 64,
|
||||
"w": 64,
|
||||
"h": 64
|
||||
"x": 768,
|
||||
"y": 256,
|
||||
"w": 256,
|
||||
"h": 256
|
||||
},
|
||||
"rotated": false,
|
||||
"trimmed": false,
|
||||
"spriteSourceSize": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"w": 64,
|
||||
"h": 64
|
||||
"w": 256,
|
||||
"h": 256
|
||||
},
|
||||
"sourceSize": {
|
||||
"w": 64,
|
||||
"h": 64
|
||||
"w": 256,
|
||||
"h": 256
|
||||
}
|
||||
},
|
||||
"NukeIconRed.svg": {
|
||||
"frame": {
|
||||
"x": 0,
|
||||
"y": 128,
|
||||
"w": 64,
|
||||
"h": 64
|
||||
"y": 512,
|
||||
"w": 256,
|
||||
"h": 256
|
||||
},
|
||||
"rotated": false,
|
||||
"trimmed": false,
|
||||
"spriteSourceSize": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"w": 64,
|
||||
"h": 64
|
||||
"w": 256,
|
||||
"h": 256
|
||||
},
|
||||
"sourceSize": {
|
||||
"w": 64,
|
||||
"h": 64
|
||||
"w": 256,
|
||||
"h": 256
|
||||
}
|
||||
},
|
||||
"NukeIconWhite.svg": {
|
||||
"frame": {
|
||||
"x": 64,
|
||||
"y": 128,
|
||||
"w": 64,
|
||||
"h": 64
|
||||
"x": 256,
|
||||
"y": 512,
|
||||
"w": 256,
|
||||
"h": 256
|
||||
},
|
||||
"rotated": false,
|
||||
"trimmed": false,
|
||||
"spriteSourceSize": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"w": 64,
|
||||
"h": 64
|
||||
"w": 256,
|
||||
"h": 256
|
||||
},
|
||||
"sourceSize": {
|
||||
"w": 64,
|
||||
"h": 64
|
||||
"w": 256,
|
||||
"h": 256
|
||||
}
|
||||
},
|
||||
"QuestionMarkIcon.svg": {
|
||||
"frame": {
|
||||
"x": 128,
|
||||
"y": 128,
|
||||
"w": 64,
|
||||
"h": 64
|
||||
"x": 512,
|
||||
"y": 512,
|
||||
"w": 256,
|
||||
"h": 256
|
||||
},
|
||||
"rotated": false,
|
||||
"trimmed": false,
|
||||
"spriteSourceSize": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"w": 64,
|
||||
"h": 64
|
||||
"w": 256,
|
||||
"h": 256
|
||||
},
|
||||
"sourceSize": {
|
||||
"w": 64,
|
||||
"h": 64
|
||||
"w": 256,
|
||||
"h": 256
|
||||
}
|
||||
},
|
||||
"TargetIcon.svg": {
|
||||
"frame": {
|
||||
"x": 192,
|
||||
"y": 128,
|
||||
"w": 64,
|
||||
"h": 64
|
||||
"x": 768,
|
||||
"y": 512,
|
||||
"w": 256,
|
||||
"h": 256
|
||||
},
|
||||
"rotated": false,
|
||||
"trimmed": false,
|
||||
"spriteSourceSize": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"w": 64,
|
||||
"h": 64
|
||||
"w": 256,
|
||||
"h": 256
|
||||
},
|
||||
"sourceSize": {
|
||||
"w": 64,
|
||||
"h": 64
|
||||
"w": 256,
|
||||
"h": 256
|
||||
}
|
||||
},
|
||||
"TraitorIcon.svg": {
|
||||
"frame": {
|
||||
"x": 0,
|
||||
"y": 192,
|
||||
"w": 64,
|
||||
"h": 64
|
||||
"y": 768,
|
||||
"w": 256,
|
||||
"h": 256
|
||||
},
|
||||
"rotated": false,
|
||||
"trimmed": false,
|
||||
"spriteSourceSize": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"w": 64,
|
||||
"h": 64
|
||||
"w": 256,
|
||||
"h": 256
|
||||
},
|
||||
"sourceSize": {
|
||||
"w": 64,
|
||||
"h": 64
|
||||
"w": 256,
|
||||
"h": 256
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -266,8 +266,8 @@
|
||||
"image": "namelayer-icons.png",
|
||||
"format": "RGBA8888",
|
||||
"size": {
|
||||
"w": 256,
|
||||
"h": 256
|
||||
"w": 1024,
|
||||
"h": 1024
|
||||
},
|
||||
"scale": "1"
|
||||
}
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 212 KiB |
@@ -1,8 +1,10 @@
|
||||
import fs from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const require = createRequire(import.meta.url);
|
||||
const root = path.resolve(__dirname, "..");
|
||||
const fontsDir = path.join(root, "resources", "fonts");
|
||||
const imagesDir = path.join(root, "resources", "images");
|
||||
@@ -19,7 +21,7 @@ const fontSourceCandidates = [
|
||||
];
|
||||
const glyphs = Array.from(
|
||||
new Set(
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_ üÜ.[]+-=(),':!?/@#$%&\"".split(
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_ \u00fc\u00dc.[]+-=(),':!?/@#$%&\"".split(
|
||||
"",
|
||||
),
|
||||
),
|
||||
@@ -46,7 +48,7 @@ fs.mkdirSync(imagesDir, { recursive: true });
|
||||
|
||||
const canvasApi = await loadCanvasApi();
|
||||
|
||||
await buildBitmapFont();
|
||||
await buildMsdfFont();
|
||||
await buildIconAtlas();
|
||||
await buildEmojiAtlas();
|
||||
|
||||
@@ -83,8 +85,12 @@ async function loadCanvasApi() {
|
||||
}
|
||||
}
|
||||
|
||||
async function buildBitmapFont() {
|
||||
if (!canvasApi) {
|
||||
async function buildMsdfFont() {
|
||||
const fontPath = fontSourceCandidates
|
||||
.map((fileName) => path.join(fontsDir, fileName))
|
||||
.find((candidate) => fs.existsSync(candidate));
|
||||
|
||||
if (!fontPath) {
|
||||
const fallbackXml = fs
|
||||
.readFileSync(path.join(fontsDir, "round_6x6_modified.xml"), "utf8")
|
||||
.replace(/face="round_6x6_modified"/g, `face="${fontFace}"`)
|
||||
@@ -97,63 +103,46 @@ async function buildBitmapFont() {
|
||||
return;
|
||||
}
|
||||
|
||||
const { createCanvas } = canvasApi;
|
||||
const cell = 64;
|
||||
const cols = 16;
|
||||
const rows = Math.ceil(glyphs.length / cols);
|
||||
const canvas = createCanvas(cols * cell, rows * cell);
|
||||
const ctx = canvas.getContext("2d");
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.fillStyle = "#ffffff";
|
||||
ctx.textBaseline = "alphabetic";
|
||||
ctx.textAlign = "left";
|
||||
ctx.font = '48px "OverpassNameLayer", Arial, sans-serif';
|
||||
|
||||
const chars = [];
|
||||
glyphs.forEach((glyph, index) => {
|
||||
const col = index % cols;
|
||||
const row = Math.floor(index / cols);
|
||||
const x = col * cell;
|
||||
const y = row * cell;
|
||||
const metrics = ctx.measureText(glyph);
|
||||
const advance = glyph === " " ? 16 : Math.max(16, Math.ceil(metrics.width));
|
||||
const drawX = x + 4;
|
||||
const drawY = y + 48;
|
||||
if (glyph !== " ") {
|
||||
ctx.fillText(glyph, drawX, drawY);
|
||||
}
|
||||
chars.push({
|
||||
id: glyph.codePointAt(0),
|
||||
x,
|
||||
y,
|
||||
width: cell,
|
||||
height: cell,
|
||||
xadvance: advance,
|
||||
xoffset: 0,
|
||||
yoffset: 0,
|
||||
label: glyph,
|
||||
});
|
||||
const generateBMFont = require("msdf-bmfont-xml");
|
||||
const { textures, font } = await new Promise((resolve, reject) => {
|
||||
generateBMFont(
|
||||
fontPath,
|
||||
{
|
||||
filename: path.join(fontsDir, path.basename(fontPng, ".png")),
|
||||
outputType: "xml",
|
||||
charset: glyphs,
|
||||
fontSize: 64,
|
||||
textureSize: [2048, 2048],
|
||||
texturePadding: 2,
|
||||
distanceRange: 8,
|
||||
fieldType: "msdf",
|
||||
smartSize: true,
|
||||
pot: true,
|
||||
roundDecimal: 0,
|
||||
},
|
||||
(error, textures, font) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve({ textures, font });
|
||||
},
|
||||
{
|
||||
log: () => {},
|
||||
warn: (message) => console.warn(`NameLayer MSDF font: ${message}`),
|
||||
error: (message) => console.error(`NameLayer MSDF font: ${message}`),
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
const xml = `<?xml version="1.0"?>
|
||||
<font>
|
||||
<info face="${fontFace}" size="48" bold="0" italic="0"/>
|
||||
<common lineHeight="56" base="48" scaleW="${canvas.width}" scaleH="${canvas.height}" pages="1" packed="0"/>
|
||||
<pages>
|
||||
<page id="0" file="${fontPng}"/>
|
||||
</pages>
|
||||
<chars count="${chars.length}">
|
||||
${chars
|
||||
.map(
|
||||
(char) =>
|
||||
` <char id="${char.id}" x="${char.x}" y="${char.y}" width="${char.width}" height="${char.height}" page="0" xadvance="${char.xadvance}" xoffset="${char.xoffset}" yoffset="${char.yoffset}"/>`,
|
||||
)
|
||||
.join("\n")}
|
||||
</chars>
|
||||
</font>
|
||||
`;
|
||||
for (const texture of textures) {
|
||||
fs.writeFileSync(`${texture.filename}.png`, texture.texture);
|
||||
}
|
||||
|
||||
fs.writeFileSync(path.join(fontsDir, fontPng), canvas.toBuffer("image/png"));
|
||||
const xml = String(font.data).replace(
|
||||
/(<info\s+[^>]*face=")[^"]+(")/,
|
||||
`$1${fontFace}$2`,
|
||||
);
|
||||
fs.writeFileSync(path.join(fontsDir, fontXml), xml);
|
||||
}
|
||||
|
||||
@@ -164,7 +153,7 @@ async function buildIconAtlas() {
|
||||
}
|
||||
|
||||
const { createCanvas, loadImage } = canvasApi;
|
||||
const cell = 64;
|
||||
const cell = 256;
|
||||
const cols = 4;
|
||||
const rows = Math.ceil(iconSources.length / cols);
|
||||
const canvas = createCanvas(cols * cell, rows * cell);
|
||||
@@ -250,7 +239,7 @@ async function buildEmojiAtlas() {
|
||||
|
||||
const { createCanvas } = canvasApi;
|
||||
const emojis = readEmojiTable();
|
||||
const cell = 64;
|
||||
const cell = 128;
|
||||
const cols = 8;
|
||||
const rows = Math.max(1, Math.ceil(emojis.length / cols));
|
||||
const canvas = createCanvas(cols * cell, rows * cell);
|
||||
@@ -259,7 +248,7 @@ async function buildEmojiAtlas() {
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.font =
|
||||
'48px "Segoe UI Emoji", "Apple Color Emoji", "Noto Color Emoji", sans-serif';
|
||||
'96px "Segoe UI Emoji", "Apple Color Emoji", "Noto Color Emoji", sans-serif';
|
||||
const frames = {};
|
||||
|
||||
emojis.forEach((emoji, index) => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
} from "../src/client/graphics/PlayerIcons";
|
||||
import {
|
||||
computeNameLayerLayout,
|
||||
computeNameLayerScreenMetrics,
|
||||
computeNameLayerWorldScale,
|
||||
computeTraitorFlashAlpha,
|
||||
computeTraitorFlashDurationSeconds,
|
||||
@@ -102,6 +103,17 @@ describe("NameLayerLayout", () => {
|
||||
expect(computeNameLayerWorldScale(20, 2)).toBeCloseTo(6);
|
||||
});
|
||||
|
||||
test("computes final screen-space text and icon sizes", () => {
|
||||
expect(computeNameLayerScreenMetrics(8, 2)).toEqual({
|
||||
fontSize: 16,
|
||||
iconSize: 24,
|
||||
});
|
||||
expect(computeNameLayerScreenMetrics(20, 2)).toEqual({
|
||||
fontSize: 48,
|
||||
iconSize: 72,
|
||||
});
|
||||
});
|
||||
|
||||
test("matches traitor flash duration thresholds and alpha extrema", () => {
|
||||
expect(computeTraitorFlashDurationSeconds(156)).toBeNull();
|
||||
expect(computeTraitorFlashDurationSeconds(150)).toBeCloseTo(1);
|
||||
|
||||
Reference in New Issue
Block a user