Render name layer with Pixi

This commit is contained in:
scamiv
2026-05-09 00:42:09 +02:00
parent c1936fb289
commit fe216cba4b
16 changed files with 2929 additions and 404 deletions
+1
View File
@@ -26,6 +26,7 @@ export default [
allowDefaultProject: [
"__mocks__/fileMock.js",
"eslint.config.js",
"scripts/build-namelayer-assets.mjs",
"scripts/sync-assets.mjs",
],
},
+1
View File
@@ -12,6 +12,7 @@
"docs:map-generator": "cd map-generator && go doc -cmd -u -all",
"tunnel": "npm run build-prod && npm run start:server",
"test": "vitest run && vitest run tests/server",
"build:namelayer-assets": "node scripts/build-namelayer-assets.mjs",
"perf": "npx tsx tests/perf/run-all.ts",
"test:coverage": "vitest run --coverage",
"format": "prettier --ignore-unknown --write .",
Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

+96
View File
@@ -0,0 +1,96 @@
<?xml version="1.0"?>
<font>
<info face="namelayer_overpass" size="48" bold="0" italic="0"/>
<common lineHeight="56" base="48" scaleW="1024" scaleH="384" pages="1" packed="0"/>
<pages>
<page id="0" file="namelayer_overpass.png"/>
</pages>
<chars count="86">
<char id="65" x="0" y="0" width="64" height="64" page="0" xadvance="33" xoffset="0" yoffset="0"/>
<char id="66" x="64" y="0" width="64" height="64" page="0" xadvance="33" xoffset="0" yoffset="0"/>
<char id="67" x="128" y="0" width="64" height="64" page="0" xadvance="35" xoffset="0" yoffset="0"/>
<char id="68" x="192" y="0" width="64" height="64" page="0" xadvance="35" xoffset="0" yoffset="0"/>
<char id="69" x="256" y="0" width="64" height="64" page="0" xadvance="33" xoffset="0" yoffset="0"/>
<char id="70" x="320" y="0" width="64" height="64" page="0" xadvance="30" xoffset="0" yoffset="0"/>
<char id="71" x="384" y="0" width="64" height="64" page="0" xadvance="38" xoffset="0" yoffset="0"/>
<char id="72" x="448" y="0" width="64" height="64" page="0" xadvance="35" xoffset="0" yoffset="0"/>
<char id="73" x="512" y="0" width="64" height="64" page="0" xadvance="16" xoffset="0" yoffset="0"/>
<char id="74" x="576" y="0" width="64" height="64" page="0" xadvance="24" xoffset="0" yoffset="0"/>
<char id="75" x="640" y="0" width="64" height="64" page="0" xadvance="33" xoffset="0" yoffset="0"/>
<char id="76" x="704" y="0" width="64" height="64" page="0" xadvance="27" xoffset="0" yoffset="0"/>
<char id="77" x="768" y="0" width="64" height="64" page="0" xadvance="40" xoffset="0" yoffset="0"/>
<char id="78" x="832" y="0" width="64" height="64" page="0" xadvance="35" xoffset="0" yoffset="0"/>
<char id="79" x="896" y="0" width="64" height="64" page="0" xadvance="38" xoffset="0" yoffset="0"/>
<char id="80" x="960" y="0" width="64" height="64" page="0" xadvance="33" xoffset="0" yoffset="0"/>
<char id="81" x="0" y="64" width="64" height="64" page="0" xadvance="38" xoffset="0" yoffset="0"/>
<char id="82" x="64" y="64" width="64" height="64" page="0" xadvance="35" xoffset="0" yoffset="0"/>
<char id="83" x="128" y="64" width="64" height="64" page="0" xadvance="33" xoffset="0" yoffset="0"/>
<char id="84" x="192" y="64" width="64" height="64" page="0" xadvance="30" xoffset="0" yoffset="0"/>
<char id="85" x="256" y="64" width="64" height="64" page="0" xadvance="35" xoffset="0" yoffset="0"/>
<char id="86" x="320" y="64" width="64" height="64" page="0" xadvance="33" xoffset="0" yoffset="0"/>
<char id="87" x="384" y="64" width="64" height="64" page="0" xadvance="46" xoffset="0" yoffset="0"/>
<char id="88" x="448" y="64" width="64" height="64" page="0" xadvance="33" xoffset="0" yoffset="0"/>
<char id="89" x="512" y="64" width="64" height="64" page="0" xadvance="33" xoffset="0" yoffset="0"/>
<char id="90" x="576" y="64" width="64" height="64" page="0" xadvance="30" xoffset="0" yoffset="0"/>
<char id="97" x="640" y="64" width="64" height="64" page="0" xadvance="27" xoffset="0" yoffset="0"/>
<char id="98" x="704" y="64" width="64" height="64" page="0" xadvance="27" xoffset="0" yoffset="0"/>
<char id="99" x="768" y="64" width="64" height="64" page="0" xadvance="24" xoffset="0" yoffset="0"/>
<char id="100" x="832" y="64" width="64" height="64" page="0" xadvance="27" xoffset="0" yoffset="0"/>
<char id="101" x="896" y="64" width="64" height="64" page="0" xadvance="27" xoffset="0" yoffset="0"/>
<char id="102" x="960" y="64" width="64" height="64" page="0" xadvance="16" xoffset="0" yoffset="0"/>
<char id="103" x="0" y="128" width="64" height="64" page="0" xadvance="27" xoffset="0" yoffset="0"/>
<char id="104" x="64" y="128" width="64" height="64" page="0" xadvance="27" xoffset="0" yoffset="0"/>
<char id="105" x="128" y="128" width="64" height="64" page="0" xadvance="16" xoffset="0" yoffset="0"/>
<char id="106" x="192" y="128" width="64" height="64" page="0" xadvance="16" xoffset="0" yoffset="0"/>
<char id="107" x="256" y="128" width="64" height="64" page="0" xadvance="24" xoffset="0" yoffset="0"/>
<char id="108" x="320" y="128" width="64" height="64" page="0" xadvance="16" xoffset="0" yoffset="0"/>
<char id="109" x="384" y="128" width="64" height="64" page="0" xadvance="40" xoffset="0" yoffset="0"/>
<char id="110" x="448" y="128" width="64" height="64" page="0" xadvance="27" xoffset="0" yoffset="0"/>
<char id="111" x="512" y="128" width="64" height="64" page="0" xadvance="27" xoffset="0" yoffset="0"/>
<char id="112" x="576" y="128" width="64" height="64" page="0" xadvance="27" xoffset="0" yoffset="0"/>
<char id="113" x="640" y="128" width="64" height="64" page="0" xadvance="27" xoffset="0" yoffset="0"/>
<char id="114" x="704" y="128" width="64" height="64" page="0" xadvance="16" xoffset="0" yoffset="0"/>
<char id="115" x="768" y="128" width="64" height="64" page="0" xadvance="24" xoffset="0" yoffset="0"/>
<char id="116" x="832" y="128" width="64" height="64" page="0" xadvance="16" xoffset="0" yoffset="0"/>
<char id="117" x="896" y="128" width="64" height="64" page="0" xadvance="27" xoffset="0" yoffset="0"/>
<char id="118" x="960" y="128" width="64" height="64" page="0" xadvance="24" xoffset="0" yoffset="0"/>
<char id="119" x="0" y="192" width="64" height="64" page="0" xadvance="35" xoffset="0" yoffset="0"/>
<char id="120" x="64" y="192" width="64" height="64" page="0" xadvance="24" xoffset="0" yoffset="0"/>
<char id="121" x="128" y="192" width="64" height="64" page="0" xadvance="24" xoffset="0" yoffset="0"/>
<char id="122" x="192" y="192" width="64" height="64" page="0" xadvance="24" xoffset="0" yoffset="0"/>
<char id="48" x="256" y="192" width="64" height="64" page="0" xadvance="27" xoffset="0" yoffset="0"/>
<char id="49" x="320" y="192" width="64" height="64" page="0" xadvance="27" xoffset="0" yoffset="0"/>
<char id="50" x="384" y="192" width="64" height="64" page="0" xadvance="27" xoffset="0" yoffset="0"/>
<char id="51" x="448" y="192" width="64" height="64" page="0" xadvance="27" xoffset="0" yoffset="0"/>
<char id="52" x="512" y="192" width="64" height="64" page="0" xadvance="27" xoffset="0" yoffset="0"/>
<char id="53" x="576" y="192" width="64" height="64" page="0" xadvance="27" xoffset="0" yoffset="0"/>
<char id="54" x="640" y="192" width="64" height="64" page="0" xadvance="27" xoffset="0" yoffset="0"/>
<char id="55" x="704" y="192" width="64" height="64" page="0" xadvance="27" xoffset="0" yoffset="0"/>
<char id="56" x="768" y="192" width="64" height="64" page="0" xadvance="27" xoffset="0" yoffset="0"/>
<char id="57" x="832" y="192" width="64" height="64" page="0" xadvance="27" xoffset="0" yoffset="0"/>
<char id="95" x="896" y="192" width="64" height="64" page="0" xadvance="27" xoffset="0" yoffset="0"/>
<char id="32" x="960" y="192" width="64" height="64" page="0" xadvance="16" xoffset="0" yoffset="0"/>
<char id="252" x="0" y="256" width="64" height="64" page="0" xadvance="27" xoffset="0" yoffset="0"/>
<char id="220" x="64" y="256" width="64" height="64" page="0" xadvance="35" xoffset="0" yoffset="0"/>
<char id="46" x="128" y="256" width="64" height="64" page="0" xadvance="16" xoffset="0" yoffset="0"/>
<char id="91" x="192" y="256" width="64" height="64" page="0" xadvance="16" xoffset="0" yoffset="0"/>
<char id="93" x="256" y="256" width="64" height="64" page="0" xadvance="16" xoffset="0" yoffset="0"/>
<char id="43" x="320" y="256" width="64" height="64" page="0" xadvance="29" xoffset="0" yoffset="0"/>
<char id="45" x="384" y="256" width="64" height="64" page="0" xadvance="16" xoffset="0" yoffset="0"/>
<char id="61" x="448" y="256" width="64" height="64" page="0" xadvance="29" xoffset="0" yoffset="0"/>
<char id="40" x="512" y="256" width="64" height="64" page="0" xadvance="16" xoffset="0" yoffset="0"/>
<char id="41" x="576" y="256" width="64" height="64" page="0" xadvance="16" xoffset="0" yoffset="0"/>
<char id="44" x="640" y="256" width="64" height="64" page="0" xadvance="16" xoffset="0" yoffset="0"/>
<char id="39" x="704" y="256" width="64" height="64" page="0" xadvance="16" xoffset="0" yoffset="0"/>
<char id="58" x="768" y="256" width="64" height="64" page="0" xadvance="16" xoffset="0" yoffset="0"/>
<char id="33" x="832" y="256" width="64" height="64" page="0" xadvance="16" xoffset="0" yoffset="0"/>
<char id="63" x="896" y="256" width="64" height="64" page="0" xadvance="27" xoffset="0" yoffset="0"/>
<char id="47" x="960" y="256" width="64" height="64" page="0" xadvance="16" xoffset="0" yoffset="0"/>
<char id="64" x="0" y="320" width="64" height="64" page="0" xadvance="49" xoffset="0" yoffset="0"/>
<char id="35" x="64" y="320" width="64" height="64" page="0" xadvance="27" xoffset="0" yoffset="0"/>
<char id="36" x="128" y="320" width="64" height="64" page="0" xadvance="27" xoffset="0" yoffset="0"/>
<char id="37" x="192" y="320" width="64" height="64" page="0" xadvance="43" xoffset="0" yoffset="0"/>
<char id="38" x="256" y="320" width="64" height="64" page="0" xadvance="33" xoffset="0" yoffset="0"/>
<char id="34" x="320" y="320" width="64" height="64" page="0" xadvance="18" xoffset="0" yoffset="0"/>
</chars>
</font>
+93
View File
@@ -0,0 +1,93 @@
Copyright 2021 The Overpass Project Authors (https://github.com/RedHatOfficial/Overpass)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

+274
View File
@@ -0,0 +1,274 @@
{
"frames": {
"AllianceIcon.svg": {
"frame": {
"x": 0,
"y": 0,
"w": 64,
"h": 64
},
"rotated": false,
"trimmed": false,
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 64,
"h": 64
},
"sourceSize": {
"w": 64,
"h": 64
}
},
"AllianceIconFaded.svg": {
"frame": {
"x": 64,
"y": 0,
"w": 64,
"h": 64
},
"rotated": false,
"trimmed": false,
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 64,
"h": 64
},
"sourceSize": {
"w": 64,
"h": 64
}
},
"AllianceRequestBlackIcon.svg": {
"frame": {
"x": 128,
"y": 0,
"w": 64,
"h": 64
},
"rotated": false,
"trimmed": false,
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 64,
"h": 64
},
"sourceSize": {
"w": 64,
"h": 64
}
},
"AllianceRequestWhiteIcon.svg": {
"frame": {
"x": 192,
"y": 0,
"w": 64,
"h": 64
},
"rotated": false,
"trimmed": false,
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 64,
"h": 64
},
"sourceSize": {
"w": 64,
"h": 64
}
},
"CrownIcon.svg": {
"frame": {
"x": 0,
"y": 64,
"w": 64,
"h": 64
},
"rotated": false,
"trimmed": false,
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 64,
"h": 64
},
"sourceSize": {
"w": 64,
"h": 64
}
},
"DisconnectedIcon.svg": {
"frame": {
"x": 64,
"y": 64,
"w": 64,
"h": 64
},
"rotated": false,
"trimmed": false,
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 64,
"h": 64
},
"sourceSize": {
"w": 64,
"h": 64
}
},
"EmbargoBlackIcon.svg": {
"frame": {
"x": 128,
"y": 64,
"w": 64,
"h": 64
},
"rotated": false,
"trimmed": false,
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 64,
"h": 64
},
"sourceSize": {
"w": 64,
"h": 64
}
},
"EmbargoWhiteIcon.svg": {
"frame": {
"x": 192,
"y": 64,
"w": 64,
"h": 64
},
"rotated": false,
"trimmed": false,
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 64,
"h": 64
},
"sourceSize": {
"w": 64,
"h": 64
}
},
"NukeIconRed.svg": {
"frame": {
"x": 0,
"y": 128,
"w": 64,
"h": 64
},
"rotated": false,
"trimmed": false,
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 64,
"h": 64
},
"sourceSize": {
"w": 64,
"h": 64
}
},
"NukeIconWhite.svg": {
"frame": {
"x": 64,
"y": 128,
"w": 64,
"h": 64
},
"rotated": false,
"trimmed": false,
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 64,
"h": 64
},
"sourceSize": {
"w": 64,
"h": 64
}
},
"QuestionMarkIcon.svg": {
"frame": {
"x": 128,
"y": 128,
"w": 64,
"h": 64
},
"rotated": false,
"trimmed": false,
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 64,
"h": 64
},
"sourceSize": {
"w": 64,
"h": 64
}
},
"TargetIcon.svg": {
"frame": {
"x": 192,
"y": 128,
"w": 64,
"h": 64
},
"rotated": false,
"trimmed": false,
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 64,
"h": 64
},
"sourceSize": {
"w": 64,
"h": 64
}
},
"TraitorIcon.svg": {
"frame": {
"x": 0,
"y": 192,
"w": 64,
"h": 64
},
"rotated": false,
"trimmed": false,
"spriteSourceSize": {
"x": 0,
"y": 0,
"w": 64,
"h": 64
},
"sourceSize": {
"w": 64,
"h": 64
}
}
},
"meta": {
"app": "scripts/build-namelayer-assets.mjs",
"image": "namelayer-icons.png",
"format": "RGBA8888",
"size": {
"w": 256,
"h": 256
},
"scale": "1"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

+351
View File
@@ -0,0 +1,351 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const root = path.resolve(__dirname, "..");
const fontsDir = path.join(root, "resources", "fonts");
const imagesDir = path.join(root, "resources", "images");
const fontPng = "namelayer_overpass.png";
const fontXml = "namelayer_overpass.xml";
const fontFace = "namelayer_overpass";
const fontSourceCandidates = [
"overpass-regular.otf",
"overpass-regular.ttf",
"overpass.otf",
"overpass.ttf",
"overpass.woff",
];
const glyphs = Array.from(
new Set(
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_ üÜ.[]+-=(),':!?/@#$%&\"".split(
"",
),
),
);
const iconSources = [
"AllianceIcon.svg",
"AllianceIconFaded.svg",
"AllianceRequestBlackIcon.svg",
"AllianceRequestWhiteIcon.svg",
"CrownIcon.svg",
"DisconnectedIcon.svg",
"EmbargoBlackIcon.svg",
"EmbargoWhiteIcon.svg",
"NukeIconRed.svg",
"NukeIconWhite.svg",
"QuestionMarkIcon.svg",
"TargetIcon.svg",
"TraitorIcon.svg",
];
fs.mkdirSync(fontsDir, { recursive: true });
fs.mkdirSync(imagesDir, { recursive: true });
const canvasApi = await loadCanvasApi();
await buildBitmapFont();
await buildIconAtlas();
await buildEmojiAtlas();
async function loadCanvasApi() {
try {
const api = await import("canvas");
const fontPath = fontSourceCandidates
.map((fileName) => path.join(fontsDir, fileName))
.find((candidate) => fs.existsSync(candidate));
try {
if (!fontPath) {
throw new Error(
`No Overpass font source found. Tried: ${fontSourceCandidates.join(
", ",
)}`,
);
}
api.registerFont(fontPath, {
family: "OverpassNameLayer",
});
} catch (error) {
console.warn(
"Could not register Overpass; using canvas fallback font",
error,
);
}
return api;
} catch (error) {
console.warn(
"canvas native bindings are unavailable; writing deterministic fallback NameLayer assets",
error,
);
return null;
}
}
async function buildBitmapFont() {
if (!canvasApi) {
const fallbackXml = fs
.readFileSync(path.join(fontsDir, "round_6x6_modified.xml"), "utf8")
.replace(/face="round_6x6_modified"/g, `face="${fontFace}"`)
.replace(/file="round_6x6_modified\.png"/g, `file="${fontPng}"`);
fs.writeFileSync(
path.join(fontsDir, fontPng),
fs.readFileSync(path.join(fontsDir, "round_6x6_modified.png")),
);
fs.writeFileSync(path.join(fontsDir, fontXml), fallbackXml);
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 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>
`;
fs.writeFileSync(path.join(fontsDir, fontPng), canvas.toBuffer("image/png"));
fs.writeFileSync(path.join(fontsDir, fontXml), xml);
}
async function buildIconAtlas() {
if (!canvasApi) {
writeFallbackAtlas("namelayer-icons", iconSources);
return;
}
const { createCanvas, loadImage } = canvasApi;
const cell = 64;
const cols = 4;
const rows = Math.ceil(iconSources.length / cols);
const canvas = createCanvas(cols * cell, rows * cell);
const ctx = canvas.getContext("2d");
ctx.clearRect(0, 0, canvas.width, canvas.height);
const frames = {};
for (let i = 0; i < iconSources.length; i++) {
const source = iconSources[i];
const col = i % cols;
const row = Math.floor(i / cols);
const x = col * cell;
const y = row * cell;
try {
const img = await loadIconImage(path.join(imagesDir, source), loadImage);
ctx.drawImage(img, x, y, cell, cell);
} catch (error) {
console.warn(
`Could not pack ${source}; leaving empty atlas frame`,
error,
);
}
frames[source] = {
frame: { x, y, w: cell, h: cell },
rotated: false,
trimmed: false,
spriteSourceSize: { x: 0, y: 0, w: cell, h: cell },
sourceSize: { w: cell, h: cell },
};
}
fs.writeFileSync(
path.join(imagesDir, "namelayer-icons.png"),
canvas.toBuffer("image/png"),
);
fs.writeFileSync(
path.join(imagesDir, "namelayer-icons.json"),
JSON.stringify(
{
frames,
meta: {
app: "scripts/build-namelayer-assets.mjs",
image: "namelayer-icons.png",
format: "RGBA8888",
size: { w: canvas.width, h: canvas.height },
scale: "1",
},
},
null,
2,
),
);
}
async function loadIconImage(sourcePath, loadImage) {
if (path.extname(sourcePath).toLowerCase() !== ".svg") {
return loadImage(sourcePath);
}
let svg = fs.readFileSync(sourcePath, "utf8");
if (!/<svg[^>]*\swidth=/i.test(svg) || !/<svg[^>]*\sheight=/i.test(svg)) {
const [, , , width, height] =
svg.match(
/viewBox=["']\s*([-\d.]+)\s+([-\d.]+)\s+([-\d.]+)\s+([-\d.]+)\s*["']/i,
) ?? [];
svg = svg.replace(
/<svg\b/i,
`<svg width="${width ?? 64}" height="${height ?? 64}"`,
);
}
return loadImage(
`data:image/svg+xml;base64,${Buffer.from(svg, "utf8").toString("base64")}`,
);
}
async function buildEmojiAtlas() {
if (!canvasApi) {
const emojis = readEmojiTable();
writeFallbackAtlas("namelayer-emojis", emojis);
return;
}
const { createCanvas } = canvasApi;
const emojis = readEmojiTable();
const cell = 64;
const cols = 8;
const rows = Math.max(1, Math.ceil(emojis.length / cols));
const canvas = createCanvas(cols * cell, rows * cell);
const ctx = canvas.getContext("2d");
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.font =
'48px "Segoe UI Emoji", "Apple Color Emoji", "Noto Color Emoji", sans-serif';
const frames = {};
emojis.forEach((emoji, index) => {
const col = index % cols;
const row = Math.floor(index / cols);
const x = col * cell;
const y = row * cell;
ctx.fillText(emoji, x + cell / 2, y + cell / 2);
frames[emoji] = {
frame: { x, y, w: cell, h: cell },
rotated: false,
trimmed: false,
spriteSourceSize: { x: 0, y: 0, w: cell, h: cell },
sourceSize: { w: cell, h: cell },
};
});
fs.writeFileSync(
path.join(imagesDir, "namelayer-emojis.png"),
canvas.toBuffer("image/png"),
);
fs.writeFileSync(
path.join(imagesDir, "namelayer-emojis.json"),
JSON.stringify(
{
frames,
meta: {
app: "scripts/build-namelayer-assets.mjs",
image: "namelayer-emojis.png",
format: "RGBA8888",
size: { w: canvas.width, h: canvas.height },
scale: "1",
},
},
null,
2,
),
);
}
function readEmojiTable() {
const utilSource = fs.readFileSync(
path.join(root, "src", "core", "Util.ts"),
"utf8",
);
const tableSource = utilSource.match(
/export const emojiTable = \[([\s\S]*?)\] as const;/,
)?.[1];
return tableSource
? Array.from(tableSource.matchAll(/"([^"]+)"/g), (match) => match[1])
: [];
}
function writeFallbackAtlas(name, keys) {
const transparentPng = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAFgwJ/lD6N7wAAAABJRU5ErkJggg==",
"base64",
);
const frames = {};
for (const key of keys) {
frames[key] = {
frame: { x: 0, y: 0, w: 1, h: 1 },
rotated: false,
trimmed: false,
spriteSourceSize: { x: 0, y: 0, w: 1, h: 1 },
sourceSize: { w: 1, h: 1 },
};
}
fs.writeFileSync(path.join(imagesDir, `${name}.png`), transparentPng);
fs.writeFileSync(
path.join(imagesDir, `${name}.json`),
JSON.stringify(
{
frames,
meta: {
app: "scripts/build-namelayer-assets.mjs",
image: `${name}.png`,
format: "RGBA8888",
size: { w: 1, h: 1 },
scale: "1",
},
},
null,
2,
),
);
}
+5 -1
View File
@@ -341,6 +341,10 @@ export function updateAllianceProgressIconRefs(
}
export function computeAllianceClipPath(fraction: number): string {
const topCut = 20 + (1 - fraction) * 80 * 0.78; // min 20%, max 82.40%
const topCut = computeAllianceTopCutPercent(fraction);
return `inset(${topCut.toFixed(2)}% -2px 0 -2px)`;
}
export function computeAllianceTopCutPercent(fraction: number): number {
return 20 + (1 - fraction) * 80 * 0.78; // min 20%, max 82.40%
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,104 @@
import * as PIXI from "pixi.js";
import { assetUrl } from "../../../core/AssetUrls";
const nameLayerFont = assetUrl("fonts/namelayer_overpass.xml");
const fallbackFont = assetUrl("fonts/round_6x6_modified.xml");
const iconAtlas = assetUrl("images/namelayer-icons.json");
const emojiAtlas = assetUrl("images/namelayer-emojis.json");
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;
private readonly textures = new Map<string, PIXI.Texture | null>();
private readonly pendingTextures = new Map<string, Promise<void>>();
private readonly warnedTextureFailures = new Set<string>();
private preloadPromise: Promise<void> | null = null;
preload(): Promise<void> {
this.preloadPromise ??= this.loadBaseAssets();
return this.preloadPromise;
}
getTexture(src: string): PIXI.Texture | null {
const cached = this.textures.get(src);
if (cached !== undefined) {
return cached;
}
if (!this.pendingTextures.has(src)) {
this.pendingTextures.set(
src,
PIXI.Assets.load(src)
.then((texture: PIXI.Texture) => {
this.textures.set(src, texture);
})
.catch((error) => {
this.textures.set(src, null);
this.warnTextureFailure(src, error);
})
.finally(() => {
this.pendingTextures.delete(src);
}),
);
}
return null;
}
preloadTextures(srcs: Iterable<string>): void {
for (const src of srcs) {
this.getTexture(src);
}
}
resetWarningsForTests(): void {
this.warnedTextureFailures.clear();
}
private async loadBaseAssets(): Promise<void> {
await this.loadFont();
await Promise.all([
this.loadOptionalAtlas(iconAtlas, "static icon atlas"),
this.loadOptionalAtlas(emojiAtlas, "emoji atlas"),
]);
}
private async loadFont(): Promise<void> {
try {
await PIXI.Assets.load(nameLayerFont);
this.fontFamily = NAME_LAYER_FONT_FAMILY;
return;
} catch (error) {
console.warn(
"NameLayer generated bitmap font unavailable; using fallback font",
error,
);
}
try {
await PIXI.Assets.load(fallbackFont);
this.fontFamily = NAME_LAYER_FALLBACK_FONT_FAMILY;
} catch (error) {
console.error("NameLayer failed to load bitmap font", error);
}
}
private async loadOptionalAtlas(src: string, label: string): Promise<void> {
try {
await PIXI.Assets.load(src);
} catch (error) {
console.warn(`NameLayer ${label} unavailable`, error);
}
}
private warnTextureFailure(src: string, error: unknown): void {
if (this.warnedTextureFailures.has(src)) {
return;
}
this.warnedTextureFailures.add(src);
console.warn(`NameLayer texture omitted after load failure: ${src}`, error);
}
}
@@ -0,0 +1,195 @@
export const NAME_LAYER_ICON_GAP = 4;
export const NAME_LAYER_MAX_ZOOM_SCALE = 17;
export const NAME_LAYER_TROOP_MARGIN_RATIO = -0.05;
export interface NameLayerVisibilityInput {
isLayerVisible: boolean;
transformScale: number;
baseSize: number;
isOnScreen: boolean;
}
export interface NameLayerLayoutInput {
fontSize: number;
iconSize: number;
iconCount: number;
centeredIconCount: number;
hasFlag: boolean;
flagAspectRatio: number;
nameWidth: number;
troopWidth: number;
}
export interface NameLayerLayout {
flag: { x: number; y: number; width: number; height: number } | null;
nameText: { x: number; y: number };
troopText: { x: number; y: number };
iconPositions: { x: number; y: number }[];
centeredIconPositions: { x: number; y: number }[];
height: number;
width: number;
rows: { iconsY: number | null; nameY: number; troopsY: number };
}
const SUPPORTED_TEXT_CHARS = new Set(
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_ üÜ.[]+-=(),':!?/@#$%&\"".split(
"",
),
);
const warnedUnsupportedGlyphs = new Set<string>();
export function computeNameLayerVisible({
isLayerVisible,
transformScale,
baseSize,
isOnScreen,
}: NameLayerVisibilityInput): boolean {
const size = transformScale * baseSize;
return (
isLayerVisible &&
size >= 7 &&
!(transformScale > NAME_LAYER_MAX_ZOOM_SCALE && size > 100) &&
isOnScreen
);
}
export function computeNameLayerScale(baseSize: number): number {
return Math.min(baseSize * 0.25, 3);
}
export function computeNameLayerFontSize(baseSize: number): number {
return Math.max(4, Math.floor(baseSize * 0.4));
}
export function computeNameLayerLayout({
fontSize,
iconSize,
iconCount,
centeredIconCount,
hasFlag,
flagAspectRatio,
nameWidth,
troopWidth,
}: NameLayerLayoutInput): NameLayerLayout {
const visibleIconCount = Math.max(0, iconCount);
const iconRowHeight = visibleIconCount > 0 ? iconSize : 0;
const iconRowWidth =
visibleIconCount > 0
? visibleIconCount * iconSize +
(visibleIconCount - 1) * NAME_LAYER_ICON_GAP
: 0;
const flagHeight = hasFlag ? fontSize : 0;
const flagWidth = hasFlag ? Math.max(0, flagHeight * flagAspectRatio) : 0;
const nameRowHeight = fontSize;
const troopMargin = fontSize * NAME_LAYER_TROOP_MARGIN_RATIO;
const troopHeight = fontSize;
const nameRowWidth = flagWidth + nameWidth;
const totalHeight = iconRowHeight + nameRowHeight + troopMargin + troopHeight;
const width = Math.max(iconRowWidth, nameRowWidth, troopWidth);
let cursorY = -totalHeight / 2;
const iconsY = visibleIconCount > 0 ? cursorY + iconRowHeight / 2 : null;
cursorY += iconRowHeight;
const nameY = cursorY + nameRowHeight / 2;
cursorY += nameRowHeight + troopMargin;
const troopsY = cursorY + troopHeight / 2;
const iconPositions: { x: number; y: number }[] = [];
if (visibleIconCount > 0 && iconsY !== null) {
const startX = -iconRowWidth / 2 + iconSize / 2;
for (let i = 0; i < visibleIconCount; i++) {
iconPositions.push({
x: startX + i * (iconSize + NAME_LAYER_ICON_GAP),
y: iconsY,
});
}
}
const nameStartX = -nameRowWidth / 2;
const flag = hasFlag
? {
x: nameStartX + flagWidth / 2,
y: nameY,
width: flagWidth,
height: flagHeight,
}
: null;
const nameTextX = nameStartX + flagWidth + nameWidth / 2;
const centeredIconPositions = Array.from(
{ length: centeredIconCount },
() => ({
x: 0,
y: nameY,
}),
);
return {
flag,
nameText: { x: nameTextX, y: nameY },
troopText: { x: 0, y: troopsY },
iconPositions,
centeredIconPositions,
height: totalHeight,
width,
rows: { iconsY, nameY, troopsY },
};
}
export function computeTraitorFlashDurationSeconds(
remainingTicks: number,
): number | null {
const remainingSeconds = Math.round((remainingTicks / 10) * 2) / 2;
if (remainingSeconds > 15) {
return null;
}
const clampedSeconds = Math.max(0, Math.min(15, remainingSeconds));
const normalizedTime = clampedSeconds / 15;
const easedProgress = 1 - Math.pow(1 - normalizedTime, 3);
return 0.2 + (1.0 - 0.2) * easedProgress;
}
export function computeTraitorFlashAlpha(
remainingTicks: number,
nowMs: number,
): number {
const duration = computeTraitorFlashDurationSeconds(remainingTicks);
if (duration === null) {
return 1;
}
const durationMs = Math.max(1, duration * 1000);
const phase = (nowMs % durationMs) / durationMs;
const wave = phase < 0.5 ? phase / 0.5 : (1 - phase) / 0.5;
const eased = 0.5 - Math.cos(wave * Math.PI) / 2;
return 1 - eased * 0.7;
}
export function replaceUnsupportedNameGlyphs(
value: string,
warn: (message: string) => void = console.warn,
): string {
let changed = false;
let result = "";
for (const char of value) {
if (SUPPORTED_TEXT_CHARS.has(char)) {
result += char;
continue;
}
changed = true;
result += "?";
if (!warnedUnsupportedGlyphs.has(char)) {
warnedUnsupportedGlyphs.add(char);
warn(`NameLayer unsupported glyph replaced with ?: ${char}`);
}
}
return changed ? result : value;
}
export function resetNameLayerGlyphWarningsForTests(): void {
warnedUnsupportedGlyphs.clear();
}
+75 -1
View File
@@ -1,4 +1,14 @@
import { computeAllianceClipPath } from "../src/client/graphics/PlayerIcons";
import {
computeAllianceClipPath,
computeAllianceTopCutPercent,
} from "../src/client/graphics/PlayerIcons";
import {
computeNameLayerLayout,
computeTraitorFlashAlpha,
computeTraitorFlashDurationSeconds,
replaceUnsupportedNameGlyphs,
resetNameLayerGlyphWarningsForTests,
} from "../src/client/graphics/layers/NameLayerLayout";
describe("PlayerIcons", () => {
describe("computeAllianceClipPath", () => {
@@ -37,5 +47,69 @@ describe("PlayerIcons", () => {
expect(result).toContain("-2px");
expect(result.match(/-2px/g)).toHaveLength(2); // Should appear twice (left and right)
});
test("shares numeric top-cut helper with Pixi masks", () => {
expect(computeAllianceTopCutPercent(1.0)).toBeCloseTo(20);
expect(computeAllianceTopCutPercent(0.5)).toBeCloseTo(51.2);
expect(computeAllianceTopCutPercent(0.0)).toBeCloseTo(82.4);
});
});
});
describe("NameLayerLayout", () => {
test("computes DOM-compatible local row positions with flag and icon gaps", () => {
const layout = computeNameLayerLayout({
fontSize: 10,
iconSize: 15,
iconCount: 2,
centeredIconCount: 1,
hasFlag: true,
flagAspectRatio: 2,
nameWidth: 40,
troopWidth: 30,
});
expect(layout.iconPositions).toEqual([
{ x: -9.5, y: -9.75 },
{ x: 9.5, y: -9.75 },
]);
expect(layout.flag).toEqual({ x: -20, y: 2.75, width: 20, height: 10 });
expect(layout.nameText).toEqual({ x: 10, y: 2.75 });
expect(layout.troopText).toEqual({ x: 0, y: 12.25 });
expect(layout.centeredIconPositions).toEqual([{ x: 0, y: 2.75 }]);
});
test("keeps no-flag names centered on the text width", () => {
const layout = computeNameLayerLayout({
fontSize: 12,
iconSize: 18,
iconCount: 0,
centeredIconCount: 0,
hasFlag: false,
flagAspectRatio: 1,
nameWidth: 60,
troopWidth: 24,
});
expect(layout.flag).toBeNull();
expect(layout.nameText.x).toBe(0);
expect(layout.width).toBe(60);
});
test("matches traitor flash duration thresholds and alpha extrema", () => {
expect(computeTraitorFlashDurationSeconds(156)).toBeNull();
expect(computeTraitorFlashDurationSeconds(150)).toBeCloseTo(1);
expect(computeTraitorFlashDurationSeconds(0)).toBeCloseTo(0.2);
expect(computeTraitorFlashAlpha(150, 0)).toBeCloseTo(1);
expect(computeTraitorFlashAlpha(150, 500)).toBeCloseTo(0.3);
});
test("replaces unsupported glyphs once per glyph", () => {
resetNameLayerGlyphWarningsForTests();
const warn = vi.fn();
expect(replaceUnsupportedNameGlyphs("A🙂🙂B", warn)).toBe("A??B");
expect(replaceUnsupportedNameGlyphs("🙂", warn)).toBe("?");
expect(warn).toHaveBeenCalledTimes(1);
});
});