mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-19 10:16:04 +00:00
Replace node-canvas in the namelayer asset builder with skia-canvas so icon and emoji atlases are rasterized through the same cross-platform rendering backend. This removes the previous dependency on host Cairo/Pango emoji rendering, which could produce monochrome emoji sprites depending on the local font stack. Add twemoji-colr-font as a pinned COLR/CPAL emoji font input for the build. The emoji atlas now renders from that explicit font instead of whichever OS emoji font happens to be installed, making the generated sprite sheet stable across developer machines and CI. Generalize atlas frame packing for both SVG icons and emoji glyphs: render into a scratch canvas, trim to alpha bounds, center, and scale into the cell with consistent padding. This prevents icon frames from touching cell edges while preserving the existing Pixi atlas JSON contract. Regenerate namelayer emoji and icon atlases. The builder now validates generated atlas frames and fails if any frame is empty or if the emoji atlas contains no color pixels, catching the monochrome-regeneration failure at build time.
393 lines
11 KiB
JavaScript
393 lines
11 KiB
JavaScript
import fs from "node:fs";
|
|
import { createRequire } from "node:module";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { Canvas, FontLibrary, loadImage } from "skia-canvas";
|
|
|
|
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");
|
|
|
|
const fontPng = "namelayer_overpass.png";
|
|
const fontXml = "namelayer_overpass.xml";
|
|
const fontFace = "namelayer_overpass";
|
|
const emojiFontFamily = "NameLayerEmoji";
|
|
const emojiFontPath = require.resolve("twemoji-colr-font/twemoji.woff2");
|
|
const emojiFontSize = 96;
|
|
const atlasFramePaddingRatio = 1 / 16;
|
|
const fontSourceCandidates = [
|
|
"overpass-regular.otf",
|
|
"overpass-regular.ttf",
|
|
"overpass.otf",
|
|
"overpass.ttf",
|
|
"overpass.woff",
|
|
];
|
|
const glyphs = Array.from(
|
|
new Set(
|
|
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_ \u00fc\u00dc.[]+-=(),':!?/@#$%&\"".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 });
|
|
|
|
FontLibrary.use(emojiFontFamily, emojiFontPath);
|
|
|
|
await buildMsdfFont();
|
|
await buildIconAtlas();
|
|
await buildEmojiAtlas();
|
|
|
|
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}"`)
|
|
.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 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}`),
|
|
},
|
|
);
|
|
});
|
|
|
|
for (const texture of textures) {
|
|
fs.writeFileSync(`${texture.filename}.png`, texture.texture);
|
|
}
|
|
|
|
const xml = String(font.data).replace(
|
|
/(<info\s+[^>]*face=")[^"]+(")/,
|
|
`$1${fontFace}$2`,
|
|
);
|
|
fs.writeFileSync(path.join(fontsDir, fontXml), xml);
|
|
}
|
|
|
|
async function buildIconAtlas() {
|
|
const cell = 256;
|
|
const cols = 4;
|
|
const rows = Math.ceil(iconSources.length / cols);
|
|
const canvas = new Canvas(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));
|
|
drawPackedAtlasFrame(ctx, x, y, cell, (scratchCtx, scratchSize) => {
|
|
drawContainedImage(scratchCtx, img, 0, 0, scratchSize, scratchSize);
|
|
});
|
|
} 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 },
|
|
};
|
|
}
|
|
|
|
validateAtlasFramesPixels(ctx, canvas.width, canvas.height, frames, {
|
|
label: "icon",
|
|
requireColor: false,
|
|
});
|
|
|
|
fs.writeFileSync(
|
|
path.join(imagesDir, "namelayer-icons.png"),
|
|
await canvas.toBuffer("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,
|
|
)}\n`,
|
|
);
|
|
}
|
|
|
|
async function loadIconImage(sourcePath) {
|
|
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(Buffer.from(svg, "utf8"));
|
|
}
|
|
|
|
async function buildEmojiAtlas() {
|
|
const emojis = readEmojiTable();
|
|
const cell = 128;
|
|
const cols = 8;
|
|
const rows = Math.max(1, Math.ceil(emojis.length / cols));
|
|
const canvas = new Canvas(cols * cell, rows * cell);
|
|
const ctx = canvas.getContext("2d");
|
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
const frames = {};
|
|
|
|
emojis.forEach((emoji, index) => {
|
|
const col = index % cols;
|
|
const row = Math.floor(index / cols);
|
|
const x = col * cell;
|
|
const y = row * cell;
|
|
drawPackedAtlasFrame(ctx, x, y, cell, (scratchCtx, scratchSize) => {
|
|
scratchCtx.textAlign = "center";
|
|
scratchCtx.textBaseline = "middle";
|
|
scratchCtx.font = `${emojiFontSize}px ${emojiFontFamily}`;
|
|
scratchCtx.fillText(emoji, scratchSize / 2, scratchSize / 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 },
|
|
};
|
|
});
|
|
|
|
validateAtlasFramesPixels(ctx, canvas.width, canvas.height, frames, {
|
|
label: "emoji",
|
|
requireColor: true,
|
|
});
|
|
|
|
fs.writeFileSync(
|
|
path.join(imagesDir, "namelayer-emojis.png"),
|
|
await canvas.toBuffer("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,
|
|
)}\n`,
|
|
);
|
|
}
|
|
|
|
function drawPackedAtlasFrame(targetCtx, x, y, cell, drawSource) {
|
|
const scratchSize = cell * 2;
|
|
const scratch = new Canvas(scratchSize, scratchSize);
|
|
const scratchCtx = scratch.getContext("2d");
|
|
scratchCtx.clearRect(0, 0, scratchSize, scratchSize);
|
|
drawSource(scratchCtx, scratchSize);
|
|
|
|
const bounds = findAlphaBounds(
|
|
scratchCtx.getImageData(0, 0, scratchSize, scratchSize).data,
|
|
scratchSize,
|
|
scratchSize,
|
|
);
|
|
if (!bounds) {
|
|
throw new Error("NameLayer atlas frame source rendered empty");
|
|
}
|
|
|
|
const sourceWidth = bounds.maxX - bounds.minX + 1;
|
|
const sourceHeight = bounds.maxY - bounds.minY + 1;
|
|
const padding = Math.round(cell * atlasFramePaddingRatio);
|
|
const maxSize = cell - padding * 2;
|
|
const scale = Math.min(maxSize / sourceWidth, maxSize / sourceHeight, 1);
|
|
const drawWidth = Math.ceil(sourceWidth * scale);
|
|
const drawHeight = Math.ceil(sourceHeight * scale);
|
|
const drawX = x + Math.floor((cell - drawWidth) / 2);
|
|
const drawY = y + Math.floor((cell - drawHeight) / 2);
|
|
|
|
targetCtx.drawImage(
|
|
scratch,
|
|
bounds.minX,
|
|
bounds.minY,
|
|
sourceWidth,
|
|
sourceHeight,
|
|
drawX,
|
|
drawY,
|
|
drawWidth,
|
|
drawHeight,
|
|
);
|
|
}
|
|
|
|
function drawContainedImage(ctx, image, x, y, width, height) {
|
|
const sourceWidth = image.width ?? width;
|
|
const sourceHeight = image.height ?? height;
|
|
const scale = Math.min(width / sourceWidth, height / sourceHeight);
|
|
const drawWidth = sourceWidth * scale;
|
|
const drawHeight = sourceHeight * scale;
|
|
ctx.drawImage(
|
|
image,
|
|
x + (width - drawWidth) / 2,
|
|
y + (height - drawHeight) / 2,
|
|
drawWidth,
|
|
drawHeight,
|
|
);
|
|
}
|
|
|
|
function findAlphaBounds(data, width, height) {
|
|
let minX = width;
|
|
let minY = height;
|
|
let maxX = -1;
|
|
let maxY = -1;
|
|
|
|
for (let y = 0; y < height; y++) {
|
|
for (let x = 0; x < width; x++) {
|
|
if (data[(y * width + x) * 4 + 3] === 0) {
|
|
continue;
|
|
}
|
|
minX = Math.min(minX, x);
|
|
minY = Math.min(minY, y);
|
|
maxX = Math.max(maxX, x);
|
|
maxY = Math.max(maxY, y);
|
|
}
|
|
}
|
|
|
|
return maxX >= minX && maxY >= minY ? { minX, minY, maxX, maxY } : null;
|
|
}
|
|
|
|
function validateAtlasFramesPixels(
|
|
ctx,
|
|
width,
|
|
height,
|
|
frames,
|
|
{ label, requireColor },
|
|
) {
|
|
const data = ctx.getImageData(0, 0, width, height).data;
|
|
let colorfulPixels = 0;
|
|
|
|
for (const [key, { frame }] of Object.entries(frames)) {
|
|
let alphaPixels = 0;
|
|
for (let y = frame.y; y < frame.y + frame.h; y++) {
|
|
for (let x = frame.x; x < frame.x + frame.w; x++) {
|
|
const offset = (y * width + x) * 4;
|
|
const r = data[offset];
|
|
const g = data[offset + 1];
|
|
const b = data[offset + 2];
|
|
const a = data[offset + 3];
|
|
if (a === 0) {
|
|
continue;
|
|
}
|
|
alphaPixels++;
|
|
if (Math.max(r, g, b) - Math.min(r, g, b) > 12) {
|
|
colorfulPixels++;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (alphaPixels === 0) {
|
|
throw new Error(`NameLayer ${label} atlas frame is empty: ${key}`);
|
|
}
|
|
}
|
|
|
|
if (requireColor && colorfulPixels === 0) {
|
|
throw new Error(`NameLayer ${label} atlas rendered without color pixels`);
|
|
}
|
|
}
|
|
|
|
function readEmojiTable() {
|
|
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;/,
|
|
);
|
|
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]);
|
|
}
|