mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-28 16:27:56 +00:00
Arial has no face lighter than Regular, so erode the rasterized coverage (~1px) to shave stroke width for a thinner look. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
237 lines
6.8 KiB
TypeScript
237 lines
6.8 KiB
TypeScript
/**
|
|
* Runtime Arial bitmap-font atlas generator.
|
|
*
|
|
* The "classic" name font is Arial rendered to a coverage atlas (white glyphs
|
|
* on transparent), as an alternative to the overpass-bold MSDF atlas. There's
|
|
* no Arial asset to ship and no offline bmfont tooling, so the atlas is built
|
|
* once at startup with canvas 2D: each glyph is rasterized, its tight bounding
|
|
* box is found by scanning pixels, and the glyphs are shelf-packed.
|
|
*
|
|
* Metrics are emitted with the SAME em size (48) and baseline (36) as the MSDF
|
|
* atlas, so name sizing, hit-testing, flag offsets and icon alignment are all
|
|
* unchanged — only the glyph shapes and advances differ. The returned shape is
|
|
* a ParsedAtlas, so it flows through buildGlyphTables / buildGlyphMetricsTex /
|
|
* layoutString exactly like the MSDF atlas.
|
|
*/
|
|
|
|
import type { BMChar, ParsedAtlas } from "./Types";
|
|
|
|
// Match the MSDF atlas em/base so all downstream sizing stays identical.
|
|
const EM = 48;
|
|
const BASE = 36;
|
|
// Thin Arial. Arial ships no dedicated thin face, so browsers without one fall
|
|
// back to regular weight (still much lighter than the MSDF bold).
|
|
const FONT = `100 ${EM}px Arial, "Liberation Sans", sans-serif`;
|
|
|
|
const ATLAS_W = 1024;
|
|
const PAD = 3; // transparent gutter between packed glyphs (> erosion radius)
|
|
|
|
// Arial has no face thinner than Regular, so thin the strokes by eroding the
|
|
// rasterized coverage. Fractional: each pixel's alpha is reduced toward the
|
|
// minimum of its neighbourhood, shaving ~ERODE_PX off every edge.
|
|
const ERODE_PX = 1;
|
|
const ERODE_STRENGTH = 0.85; // 0 = none, 1 = hard min-filter
|
|
|
|
// Codepoint coverage: ASCII + Latin-1 + Latin Extended-A (matches CHAR_RANGE),
|
|
// skipping the C0/C1 control gaps. Covers player names and troop labels.
|
|
function* codepoints(): Generator<number> {
|
|
for (let c = 32; c <= 383; c++) {
|
|
if (c >= 127 && c <= 159) continue;
|
|
yield c;
|
|
}
|
|
}
|
|
|
|
interface Measured {
|
|
code: number;
|
|
ch: string;
|
|
advance: number;
|
|
w: number;
|
|
h: number;
|
|
xoffset: number;
|
|
yoffset: number;
|
|
// Where the tight bbox landed in the measuring canvas (to re-place it later).
|
|
srcMinX: number;
|
|
srcMinY: number;
|
|
}
|
|
|
|
export function generateArialBitmapAtlas(): {
|
|
atlas: ParsedAtlas;
|
|
canvas: HTMLCanvasElement;
|
|
} {
|
|
// --- measuring pass: rasterize each glyph, find its tight bbox ---
|
|
const CELL = 80; // > any 48px glyph incl. ascenders/descenders
|
|
const PEN_X = 8;
|
|
const PEN_Y = 56; // baseline inside the measuring cell
|
|
const tmp = document.createElement("canvas");
|
|
tmp.width = CELL;
|
|
tmp.height = CELL;
|
|
const tctx = tmp.getContext("2d", { willReadFrequently: true })!;
|
|
tctx.font = FONT;
|
|
tctx.textBaseline = "alphabetic";
|
|
tctx.textAlign = "left";
|
|
tctx.fillStyle = "#fff";
|
|
|
|
const measured: Measured[] = [];
|
|
for (const code of codepoints()) {
|
|
const ch = String.fromCodePoint(code);
|
|
const advance = Math.ceil(tctx.measureText(ch).width);
|
|
tctx.clearRect(0, 0, CELL, CELL);
|
|
tctx.fillText(ch, PEN_X, PEN_Y);
|
|
const data = tctx.getImageData(0, 0, CELL, CELL).data;
|
|
|
|
let minX = CELL;
|
|
let minY = CELL;
|
|
let maxX = -1;
|
|
let maxY = -1;
|
|
for (let y = 0; y < CELL; y++) {
|
|
for (let x = 0; x < CELL; x++) {
|
|
if (data[(y * CELL + x) * 4 + 3] > 8) {
|
|
if (x < minX) minX = x;
|
|
if (x > maxX) maxX = x;
|
|
if (y < minY) minY = y;
|
|
if (y > maxY) maxY = y;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (maxX < 0) {
|
|
// No ink (e.g. space) — advance only, zero-size glyph.
|
|
measured.push({
|
|
code,
|
|
ch,
|
|
advance,
|
|
w: 0,
|
|
h: 0,
|
|
xoffset: 0,
|
|
yoffset: 0,
|
|
srcMinX: 0,
|
|
srcMinY: 0,
|
|
});
|
|
continue;
|
|
}
|
|
|
|
measured.push({
|
|
code,
|
|
ch,
|
|
advance,
|
|
w: maxX - minX + 1,
|
|
h: maxY - minY + 1,
|
|
xoffset: minX - PEN_X, // glyph left relative to the pen
|
|
yoffset: BASE - (PEN_Y - minY), // glyph top relative to the line top
|
|
srcMinX: minX,
|
|
srcMinY: minY,
|
|
});
|
|
}
|
|
|
|
// --- shelf packing into a fixed-width atlas ---
|
|
const chars: BMChar[] = [];
|
|
const placements: { m: Measured; ax: number; ay: number }[] = [];
|
|
let x = PAD;
|
|
let y = PAD;
|
|
let rowH = 0;
|
|
for (const m of measured) {
|
|
if (m.w === 0) {
|
|
chars.push(toBMChar(m, 0, 0));
|
|
continue;
|
|
}
|
|
if (x + m.w + PAD > ATLAS_W) {
|
|
x = PAD;
|
|
y += rowH + PAD;
|
|
rowH = 0;
|
|
}
|
|
placements.push({ m, ax: x, ay: y });
|
|
chars.push(toBMChar(m, x, y));
|
|
x += m.w + PAD;
|
|
if (m.h > rowH) rowH = m.h;
|
|
}
|
|
const ATLAS_H = y + rowH + PAD;
|
|
|
|
// --- render pass: draw each glyph at its packed slot ---
|
|
const canvas = document.createElement("canvas");
|
|
canvas.width = ATLAS_W;
|
|
canvas.height = ATLAS_H;
|
|
const actx = canvas.getContext("2d")!;
|
|
actx.font = FONT;
|
|
actx.textBaseline = "alphabetic";
|
|
actx.textAlign = "left";
|
|
actx.fillStyle = "#fff";
|
|
for (const { m, ax, ay } of placements) {
|
|
// Drawing at pen P put the bbox at (P + (srcMin - PEN)); solve for the pen
|
|
// that lands the tight top-left at (ax, ay).
|
|
const penX = ax - m.srcMinX + PEN_X;
|
|
const penY = ay - m.srcMinY + PEN_Y;
|
|
actx.fillText(m.ch, penX, penY);
|
|
}
|
|
|
|
erodeCoverage(actx, ATLAS_W, ATLAS_H);
|
|
|
|
const atlas: ParsedAtlas = {
|
|
fontSize: EM,
|
|
base: BASE,
|
|
scaleW: ATLAS_W,
|
|
scaleH: ATLAS_H,
|
|
distanceRange: 0,
|
|
chars,
|
|
kernings: [],
|
|
};
|
|
return { atlas, canvas };
|
|
}
|
|
|
|
/**
|
|
* Thin the rasterized glyphs by eroding the alpha (coverage) channel: each
|
|
* pixel is pulled toward the minimum alpha in a (2·ERODE_PX+1)² window, blended
|
|
* by ERODE_STRENGTH. Shrinks every stroke edge by ~ERODE_PX·ERODE_STRENGTH px.
|
|
*/
|
|
function erodeCoverage(
|
|
ctx: CanvasRenderingContext2D,
|
|
w: number,
|
|
h: number,
|
|
): void {
|
|
if (ERODE_PX <= 0 || ERODE_STRENGTH <= 0) return;
|
|
const img = ctx.getImageData(0, 0, w, h);
|
|
const a = img.data;
|
|
const src = new Uint8ClampedArray(a.length);
|
|
src.set(a);
|
|
for (let y = 0; y < h; y++) {
|
|
for (let x = 0; x < w; x++) {
|
|
const i = (y * w + x) * 4 + 3;
|
|
const cur = src[i];
|
|
if (cur === 0) continue;
|
|
let min = cur;
|
|
for (let dy = -ERODE_PX; dy <= ERODE_PX && min > 0; dy++) {
|
|
const yy = y + dy;
|
|
if (yy < 0 || yy >= h) {
|
|
min = 0;
|
|
break;
|
|
}
|
|
for (let dx = -ERODE_PX; dx <= ERODE_PX; dx++) {
|
|
const xx = x + dx;
|
|
if (xx < 0 || xx >= w) {
|
|
min = 0;
|
|
break;
|
|
}
|
|
const v = src[(yy * w + xx) * 4 + 3];
|
|
if (v < min) min = v;
|
|
}
|
|
}
|
|
a[i] = Math.round(cur + (min - cur) * ERODE_STRENGTH);
|
|
}
|
|
}
|
|
ctx.putImageData(img, 0, 0);
|
|
}
|
|
|
|
function toBMChar(m: Measured, ax: number, ay: number): BMChar {
|
|
return {
|
|
id: m.code,
|
|
char: m.ch,
|
|
width: m.w,
|
|
height: m.h,
|
|
xoffset: m.xoffset,
|
|
yoffset: m.yoffset,
|
|
xadvance: m.advance,
|
|
x: ax,
|
|
y: ay,
|
|
page: 0,
|
|
};
|
|
}
|