mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 09:30:45 +00:00
Add a graphics setting to render names/troops in a bitmap Arial font
NamePass can now render player names and troop counts in an Arial bitmap font as an alternative to the overpass-bold MSDF font, toggled live via settings.name.classicFont (mirrors the structure-level font toggle). The Arial atlas is built once at runtime with canvas 2D (there is no Arial asset to ship): each glyph is rasterized, its tight bbox found by a pixel scan, and the glyphs shelf-packed into a coverage atlas. It emits a ParsedAtlas with the same em size (48) and baseline (36) as the MSDF atlas, so it flows through the existing glyph-table/metrics/layout code and leaves name sizing, hit-testing, flag offsets and the icon/status passes unchanged. The name shader gains a uClassic branch that tints the coverage mask with the fill color (no outline, matching the old DOM-rendered names). On toggle, all name/troop lines are re-laid-out since glyph advances differ between the fonts. Exposed as name.classicFont in GraphicsOverrides (default false = MSDF) with a Classic Names toggle in the graphics settings. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -516,6 +516,8 @@
|
||||
"black": "Black",
|
||||
"classic_icons_desc": "Lighter outline with near-black interior",
|
||||
"classic_icons_label": "Classic icons",
|
||||
"classic_names_desc": "Arial bitmap font for player names and troops (off uses the smooth font)",
|
||||
"classic_names_label": "Classic names",
|
||||
"classic_numbers_desc": "Pixel font for structure level numbers (off uses the smooth font)",
|
||||
"classic_numbers_label": "Classic level numbers",
|
||||
"colored": "Colored",
|
||||
|
||||
@@ -535,6 +535,14 @@ export class GraphicsSettingsModal extends LitElement implements Controller {
|
||||
this.patchName({ darkNames: !this.currentDarkNames() });
|
||||
}
|
||||
|
||||
private currentClassicNames(): boolean {
|
||||
return this.userSettings.graphicsOverrides().name?.classicFont ?? false;
|
||||
}
|
||||
|
||||
private onToggleClassicNames() {
|
||||
this.patchName({ classicFont: !this.currentClassicNames() });
|
||||
}
|
||||
|
||||
private onResetClick() {
|
||||
this.userSettings.setGraphicsOverrides({});
|
||||
this.requestUpdate();
|
||||
@@ -550,6 +558,7 @@ export class GraphicsSettingsModal extends LitElement implements Controller {
|
||||
const hoverGlowAlpha = this.currentHoverGlowAlpha();
|
||||
const namesColored = !this.currentDarkNames();
|
||||
const iconSize = this.currentIconSize();
|
||||
const classicNames = this.currentClassicNames();
|
||||
const classicIcons = this.currentClassicIcons();
|
||||
const classicNumbers = this.currentClassicNumbers();
|
||||
const highlightFill = this.currentHighlightFill();
|
||||
@@ -800,6 +809,25 @@ export class GraphicsSettingsModal extends LitElement implements Controller {
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="flex gap-3 items-center w-full text-left p-3 hover:bg-slate-700 rounded-sm text-white transition-colors"
|
||||
@click=${this.onToggleClassicNames}
|
||||
>
|
||||
<div class="flex-1">
|
||||
<div class="font-medium">
|
||||
${translateText("graphics_setting.classic_names_label")}
|
||||
</div>
|
||||
<div class="text-sm text-slate-400">
|
||||
${translateText("graphics_setting.classic_names_desc")}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-sm text-slate-400">
|
||||
${classicNames
|
||||
? translateText("user_setting.on")
|
||||
: translateText("user_setting.off")}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div
|
||||
class="px-3 py-1 text-xs font-semibold text-slate-400 uppercase tracking-wider mt-2"
|
||||
>
|
||||
|
||||
@@ -7,6 +7,7 @@ export const GraphicsOverridesSchema = z
|
||||
nameScaleFactor: z.number(),
|
||||
cullThreshold: z.number(),
|
||||
darkNames: z.boolean(),
|
||||
classicFont: z.boolean(),
|
||||
hoverFadeAlpha: z.number(),
|
||||
hoverGlowWidth: z.number(),
|
||||
hoverGlowAlpha: z.number(),
|
||||
|
||||
@@ -28,6 +28,8 @@ export function applyGraphicsOverrides(
|
||||
if (overrides.structure?.iconSize !== undefined) {
|
||||
settings.structure.iconSize = overrides.structure.iconSize;
|
||||
}
|
||||
// Classic names: Arial bitmap font for names/troops (default false = MSDF).
|
||||
settings.name.classicFont = overrides.name?.classicFont ?? false;
|
||||
if (overrides.structure?.classicIcons ?? true) {
|
||||
// Classic look (default): lighter player-colored shape behind a darkened
|
||||
// player-colored icon glyph (matching the old canvas renderer's
|
||||
|
||||
@@ -265,6 +265,8 @@ export interface RenderSettings {
|
||||
hoverGlowWidth: number;
|
||||
/** Peak opacity of the hover glow (0 disables it). */
|
||||
hoverGlowAlpha: number;
|
||||
/** true = Arial bitmap font for names/troops, false = overpass MSDF. */
|
||||
classicFont: boolean;
|
||||
};
|
||||
fx: {
|
||||
shockwaveRingWidth: number;
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* 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;
|
||||
// Bold to match the weight of the overpass-bold name style.
|
||||
const FONT = `bold ${EM}px Arial, "Liberation Sans", sans-serif`;
|
||||
|
||||
const ATLAS_W = 1024;
|
||||
const PAD = 2; // transparent gutter between packed glyphs
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
const atlas: ParsedAtlas = {
|
||||
fontSize: EM,
|
||||
base: BASE,
|
||||
scaleW: ATLAS_W,
|
||||
scaleH: ATLAS_H,
|
||||
distanceRange: 0,
|
||||
chars,
|
||||
kernings: [],
|
||||
};
|
||||
return { atlas, canvas };
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -1,9 +1,16 @@
|
||||
/**
|
||||
* TextProgram — MSDF text rendering (player names + troop counts).
|
||||
* TextProgram — text rendering for player names + troop counts.
|
||||
*
|
||||
* Owns: shader program, uniform locations, MSDF atlas texture (async loaded).
|
||||
* Shared textures (glyphMetrics, cursor, strings, playerData) are passed in
|
||||
* and bound at draw time but not owned/deleted by this class.
|
||||
* Supports two fonts, switchable per draw via settings.name.classicFont:
|
||||
* - MSDF: the overpass-bold atlas (default), loaded async from CDN.
|
||||
* - classic: an Arial bitmap coverage atlas built at runtime (set via
|
||||
* setArialFont), drawn fill-only (no synthesized outline).
|
||||
* Both fonts share the em size (48) and baseline (36), so name sizing and the
|
||||
* sibling icon/status passes are unaffected by the choice.
|
||||
*
|
||||
* Owns: shader program, uniform locations, the MSDF atlas texture and the
|
||||
* Arial atlas texture. Glyph-metric / cursor / string / player-data textures
|
||||
* are passed in and bound at draw time but not owned here.
|
||||
*/
|
||||
|
||||
import { assetUrl } from "src/core/AssetUrls";
|
||||
@@ -28,14 +35,26 @@ export class TextProgram {
|
||||
private program: WebGLProgram;
|
||||
private textures: TextProgramTextures;
|
||||
|
||||
// Async-loaded MSDF atlas
|
||||
// MSDF atlas (async-loaded)
|
||||
private atlasTex: WebGLTexture | null = null;
|
||||
private atlasReady = false;
|
||||
private msdfScaleW: number;
|
||||
private msdfScaleH: number;
|
||||
private distanceRange: number;
|
||||
|
||||
// Arial bitmap atlas (built at runtime, set via setArialFont)
|
||||
private arialAtlasTex: WebGLTexture | null = null;
|
||||
private arialMetricsTex: WebGLTexture | null = null;
|
||||
private arialScaleW = 0;
|
||||
private arialScaleH = 0;
|
||||
|
||||
// Uniform locations
|
||||
private uCamera: WebGLUniformLocation;
|
||||
private uTime: WebGLUniformLocation;
|
||||
private uDistRange: WebGLUniformLocation;
|
||||
private uAtlasScaleW: WebGLUniformLocation;
|
||||
private uAtlasScaleH: WebGLUniformLocation;
|
||||
private uClassic: WebGLUniformLocation;
|
||||
private uLerpSpeed: WebGLUniformLocation;
|
||||
private uCullThreshold: WebGLUniformLocation;
|
||||
private uNameScaleFactor: WebGLUniformLocation;
|
||||
@@ -52,8 +71,6 @@ export class TextProgram {
|
||||
private uHoverGlowWidth: WebGLUniformLocation;
|
||||
private uHoverGlowAlpha: WebGLUniformLocation;
|
||||
|
||||
private distanceRange: number;
|
||||
|
||||
constructor(
|
||||
gl: WebGL2RenderingContext,
|
||||
atlas: ParsedAtlas,
|
||||
@@ -62,6 +79,8 @@ export class TextProgram {
|
||||
this.gl = gl;
|
||||
this.textures = textures;
|
||||
this.distanceRange = atlas.distanceRange;
|
||||
this.msdfScaleW = atlas.scaleW;
|
||||
this.msdfScaleH = atlas.scaleH;
|
||||
|
||||
this.program = createProgram(
|
||||
gl,
|
||||
@@ -77,25 +96,20 @@ export class TextProgram {
|
||||
gl.uniform1i(gl.getUniformLocation(this.program, "uStrings"), 3);
|
||||
gl.uniform1i(gl.getUniformLocation(this.program, "uPlayerData"), 4);
|
||||
|
||||
// Static uniforms
|
||||
// Static uniforms — em size + baseline are shared by both fonts.
|
||||
gl.uniform1f(
|
||||
gl.getUniformLocation(this.program, "uFontSize")!,
|
||||
atlas.fontSize,
|
||||
);
|
||||
gl.uniform1f(
|
||||
gl.getUniformLocation(this.program, "uAtlasScaleW")!,
|
||||
atlas.scaleW,
|
||||
);
|
||||
gl.uniform1f(
|
||||
gl.getUniformLocation(this.program, "uAtlasScaleH")!,
|
||||
atlas.scaleH,
|
||||
);
|
||||
gl.uniform1f(gl.getUniformLocation(this.program, "uBase")!, atlas.base);
|
||||
|
||||
// Dynamic uniform locations
|
||||
this.uCamera = gl.getUniformLocation(this.program, "uCamera")!;
|
||||
this.uTime = gl.getUniformLocation(this.program, "uTime")!;
|
||||
this.uDistRange = gl.getUniformLocation(this.program, "uDistRange")!;
|
||||
this.uAtlasScaleW = gl.getUniformLocation(this.program, "uAtlasScaleW")!;
|
||||
this.uAtlasScaleH = gl.getUniformLocation(this.program, "uAtlasScaleH")!;
|
||||
this.uClassic = gl.getUniformLocation(this.program, "uClassic")!;
|
||||
this.uLerpSpeed = gl.getUniformLocation(this.program, "uLerpSpeed")!;
|
||||
this.uCullThreshold = gl.getUniformLocation(
|
||||
this.program,
|
||||
@@ -142,8 +156,30 @@ export class TextProgram {
|
||||
this.loadAtlas();
|
||||
}
|
||||
|
||||
get ready(): boolean {
|
||||
return this.atlasReady;
|
||||
/** True when the atlas for the requested font is uploaded and drawable. */
|
||||
isReady(classic: boolean): boolean {
|
||||
return classic ? this.arialAtlasTex !== null : this.atlasReady;
|
||||
}
|
||||
|
||||
/** Install the runtime-built Arial bitmap atlas (coverage mask) + metrics. */
|
||||
setArialFont(
|
||||
canvas: HTMLCanvasElement,
|
||||
metricsTex: WebGLTexture,
|
||||
scaleW: number,
|
||||
scaleH: number,
|
||||
): void {
|
||||
const gl = this.gl;
|
||||
const tex = gl.createTexture()!;
|
||||
gl.bindTexture(gl.TEXTURE_2D, tex);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
||||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, canvas);
|
||||
this.arialAtlasTex = tex;
|
||||
this.arialMetricsTex = metricsTex;
|
||||
this.arialScaleW = scaleW;
|
||||
this.arialScaleH = scaleH;
|
||||
}
|
||||
|
||||
private loadAtlas(): void {
|
||||
@@ -172,16 +208,28 @@ export class TextProgram {
|
||||
ambient: number,
|
||||
highlightOwnerID: number,
|
||||
fadeOwnerID: number,
|
||||
classic: boolean,
|
||||
): void {
|
||||
if (!this.atlasReady) return;
|
||||
if (!this.isReady(classic)) return;
|
||||
|
||||
const gl = this.gl;
|
||||
const ns = settings.name;
|
||||
gl.useProgram(this.program);
|
||||
|
||||
const atlasTex = classic ? this.arialAtlasTex! : this.atlasTex!;
|
||||
const metricsTex = classic
|
||||
? this.arialMetricsTex!
|
||||
: this.textures.glyphMetrics;
|
||||
const scaleW = classic ? this.arialScaleW : this.msdfScaleW;
|
||||
const scaleH = classic ? this.arialScaleH : this.msdfScaleH;
|
||||
const distRange = classic ? 0 : this.distanceRange;
|
||||
|
||||
gl.uniformMatrix3fv(this.uCamera, false, cameraMatrix);
|
||||
gl.uniform1f(this.uTime, performance.now() / 1000);
|
||||
gl.uniform1f(this.uDistRange, this.distanceRange);
|
||||
gl.uniform1f(this.uDistRange, distRange);
|
||||
gl.uniform1f(this.uAtlasScaleW, scaleW);
|
||||
gl.uniform1f(this.uAtlasScaleH, scaleH);
|
||||
gl.uniform1i(this.uClassic, classic ? 1 : 0);
|
||||
gl.uniform1f(this.uLerpSpeed, ns.lerpSpeed);
|
||||
gl.uniform1f(this.uCullThreshold, ns.cullThreshold);
|
||||
gl.uniform1f(this.uNameScaleFactor, ns.nameScaleFactor);
|
||||
@@ -202,9 +250,9 @@ export class TextProgram {
|
||||
gl.uniform1f(this.uHoverGlowAlpha, ns.hoverGlowAlpha);
|
||||
|
||||
gl.activeTexture(gl.TEXTURE0);
|
||||
gl.bindTexture(gl.TEXTURE_2D, this.atlasTex!);
|
||||
gl.bindTexture(gl.TEXTURE_2D, atlasTex);
|
||||
gl.activeTexture(gl.TEXTURE1);
|
||||
gl.bindTexture(gl.TEXTURE_2D, this.textures.glyphMetrics);
|
||||
gl.bindTexture(gl.TEXTURE_2D, metricsTex);
|
||||
gl.activeTexture(gl.TEXTURE2);
|
||||
gl.bindTexture(gl.TEXTURE_2D, this.textures.cursor);
|
||||
gl.activeTexture(gl.TEXTURE3);
|
||||
@@ -225,5 +273,6 @@ export class TextProgram {
|
||||
const gl = this.gl;
|
||||
gl.deleteProgram(this.program);
|
||||
if (this.atlasTex) gl.deleteTexture(this.atlasTex);
|
||||
if (this.arialAtlasTex) gl.deleteTexture(this.arialAtlasTex);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ import type { RenderSettings } from "../../RenderSettings";
|
||||
import { createFullscreenQuad } from "../../utils/GlUtils";
|
||||
|
||||
import { renderTroops } from "../../../../Utils";
|
||||
import { generateArialBitmapAtlas } from "./ArialAtlas";
|
||||
import type { GlyphTables } from "./AtlasData";
|
||||
import {
|
||||
buildEmojiLookup,
|
||||
@@ -49,7 +50,7 @@ import { StatusIconProgram } from "./StatusIconProgram";
|
||||
import { layoutString } from "./TextLayout";
|
||||
import { TextProgram } from "./TextProgram";
|
||||
import type { PlayerSlot } from "./Types";
|
||||
import { LINES_PER_PLAYER, MAX_CHARS } from "./Types";
|
||||
import { CHAR_RANGE, LINES_PER_PLAYER, MAX_CHARS } from "./Types";
|
||||
|
||||
// Flag quad aspect ratio — must match FLAG_CELL_W / FLAG_CELL_H in FlagAtlasArray.ts.
|
||||
const FLAG_ASPECT = 128 / 85;
|
||||
@@ -73,11 +74,19 @@ export class NamePass {
|
||||
private statusIconProgram: StatusIconProgram;
|
||||
private debugProgram: DebugProgram;
|
||||
|
||||
// Atlas + glyph data
|
||||
// Atlas + glyph data. `glyph`/`kernTable` point at the active font; the MSDF
|
||||
// and Arial tables are kept so layout can be recomputed when the font toggles.
|
||||
private glyph: GlyphTables;
|
||||
private kernTable: Int8Array;
|
||||
private fontSize: number;
|
||||
private fontBase: number;
|
||||
private msdfGlyph: GlyphTables;
|
||||
private msdfKern: Int8Array;
|
||||
private arialGlyph: GlyphTables;
|
||||
private arialKern: Int8Array;
|
||||
private arialMetricsTex: WebGLTexture;
|
||||
// Which font the cursor buffers are currently laid out for.
|
||||
private classicFont = false;
|
||||
|
||||
// Player management
|
||||
private playerByID: Map<string, PlayerStatic>;
|
||||
@@ -130,6 +139,8 @@ export class NamePass {
|
||||
this.fontBase = atlas.base;
|
||||
this.glyph = buildGlyphTables(atlas.chars);
|
||||
this.kernTable = buildKernTable(atlas.kernings);
|
||||
this.msdfGlyph = this.glyph;
|
||||
this.msdfKern = this.kernTable;
|
||||
this.emojiCharToIndex = buildEmojiLookup();
|
||||
|
||||
// Runtime flag-image manager (TEXTURE_2D_ARRAY of player flags, fetched
|
||||
@@ -204,6 +215,25 @@ export class NamePass {
|
||||
this.playerDataTex,
|
||||
this.maxPlayers,
|
||||
);
|
||||
|
||||
// Classic Arial bitmap font: built once at runtime, same em/baseline as the
|
||||
// MSDF atlas so sizing/icons are unchanged. Selected via name.classicFont.
|
||||
const arial = generateArialBitmapAtlas();
|
||||
this.arialGlyph = buildGlyphTables(arial.atlas.chars);
|
||||
this.arialKern = new Int8Array(CHAR_RANGE * CHAR_RANGE); // no kerning
|
||||
this.arialMetricsTex = buildGlyphMetricsTex(gl, arial.atlas);
|
||||
this.textProgram.setArialFont(
|
||||
arial.canvas,
|
||||
this.arialMetricsTex,
|
||||
arial.atlas.scaleW,
|
||||
arial.atlas.scaleH,
|
||||
);
|
||||
|
||||
this.classicFont = settings.name.classicFont;
|
||||
if (this.classicFont) {
|
||||
this.glyph = this.arialGlyph;
|
||||
this.kernTable = this.arialKern;
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -643,8 +673,37 @@ export class NamePass {
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch the active text font when settings.name.classicFont changes, then
|
||||
* re-lay-out every name + troop line — advances differ between the two fonts,
|
||||
* so cursor positions and name half-widths must be recomputed.
|
||||
*/
|
||||
private syncFont(): void {
|
||||
const classic = this.settings.name.classicFont;
|
||||
if (classic === this.classicFont) return;
|
||||
this.classicFont = classic;
|
||||
this.glyph = classic ? this.arialGlyph : this.msdfGlyph;
|
||||
this.kernTable = classic ? this.arialKern : this.msdfKern;
|
||||
for (const slot of this.slots.values()) {
|
||||
if (slot.nameLen > 0) {
|
||||
slot.nameHalfWidth = this.uploadStringRow(
|
||||
slot.index * LINES_PER_PLAYER,
|
||||
slot.static.displayName,
|
||||
);
|
||||
}
|
||||
if (slot.troopLen > 0 && slot.lastTroopStr) {
|
||||
this.uploadStringRow(
|
||||
slot.index * LINES_PER_PLAYER + 1,
|
||||
slot.lastTroopStr,
|
||||
);
|
||||
}
|
||||
this.writePlayerDataRow(slot);
|
||||
}
|
||||
}
|
||||
|
||||
draw(cameraMatrix: Float32Array, ambient: number): void {
|
||||
if (!this.textProgram.ready) return;
|
||||
this.syncFont();
|
||||
if (!this.textProgram.isReady(this.classicFont)) return;
|
||||
if (this.slots.size === 0) return;
|
||||
|
||||
const gl = this.gl;
|
||||
@@ -704,6 +763,7 @@ export class NamePass {
|
||||
ambient,
|
||||
this.highlightOwnerID,
|
||||
fadeOwnerID,
|
||||
this.classicFont,
|
||||
);
|
||||
this.statusIconProgram.draw(
|
||||
cameraMatrix,
|
||||
@@ -730,6 +790,7 @@ export class NamePass {
|
||||
this.statusIconProgram.dispose();
|
||||
this.debugProgram.dispose();
|
||||
gl.deleteTexture(this.glyphMetricsTex);
|
||||
gl.deleteTexture(this.arialMetricsTex);
|
||||
gl.deleteTexture(this.cursorTex);
|
||||
gl.deleteTexture(this.stringTex);
|
||||
gl.deleteTexture(this.playerDataTex);
|
||||
|
||||
@@ -217,7 +217,8 @@
|
||||
"statusRowOffset": 1.4,
|
||||
"hoverFadeAlpha": 0.5,
|
||||
"hoverGlowWidth": 5,
|
||||
"hoverGlowAlpha": 0.75
|
||||
"hoverGlowAlpha": 0.75,
|
||||
"classicFont": false
|
||||
},
|
||||
"fx": {
|
||||
"shockwaveRingWidth": 0.04,
|
||||
|
||||
@@ -10,6 +10,7 @@ uniform float uOutlineUsePlayerColor;
|
||||
uniform float uFillUsePlayerColor;
|
||||
uniform float uHoverGlowWidth; // px the white hover glow extends past the outline
|
||||
uniform float uHoverGlowAlpha; // peak opacity of the hover glow
|
||||
uniform int uClassic; // 1 = Arial bitmap coverage atlas, 0 = MSDF
|
||||
|
||||
in vec2 vUV;
|
||||
in vec4 vPlayerColor; // player territory color (rgb) + alpha
|
||||
@@ -25,16 +26,24 @@ void main() {
|
||||
// Degenerate fragment — skip
|
||||
if (vPlayerColor.a <= 0.0) discard;
|
||||
|
||||
// Compute fill color: player color, or per-type grayscale shade
|
||||
// (black for human, grayer for nation/bot).
|
||||
vec3 fillColor = mix(vec3(vNameShade), vPlayerColor.rgb, uFillUsePlayerColor);
|
||||
|
||||
// Classic Arial bitmap: the atlas is a coverage mask; tint it with the fill
|
||||
// color, no outline (matching the old DOM-rendered names).
|
||||
if (uClassic == 1) {
|
||||
float coverage = texture(uAtlas, vUV).a;
|
||||
if (coverage <= 0.0) discard;
|
||||
fragColor = vec4(fillColor, vPlayerColor.a * coverage);
|
||||
return;
|
||||
}
|
||||
|
||||
// Border darkens with night: t² stays dark longer, snaps toward the
|
||||
// outline color late in the day cycle.
|
||||
float t = 1.0 - uNightAmbient;
|
||||
float borderT = t * t;
|
||||
|
||||
// Compute fill color: player color, or per-type grayscale shade
|
||||
// (black for human, grayer for nation/bot). Applies in day and night.
|
||||
vec3 defaultFill = vec3(vNameShade);
|
||||
vec3 fillColor = mix(defaultFill, vPlayerColor.rgb, uFillUsePlayerColor);
|
||||
|
||||
vec3 msd = texture(uAtlas, vUV).rgb;
|
||||
float sd = median(msd.r, msd.g, msd.b);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user