Structure level numbers: classic bitmap font by default + graphics toggle (#4264)

## What

Structure **level numbers** now render in the **`round_6x6_modified`**
bitmap font by default (matching the old PIXI-based `StructureLayer` /
`v31`), with a graphics setting to switch back to the smooth
`overpass-bold` MSDF font.

Two commits:

1. **Default to the classic bitmap font** — `StructureLevelPass` drew
level digits from the `overpass-bold` MSDF atlas (the one `NamePass`
uses for player names); switch the default to the `round_6x6_modified`
pixel font (white digits with a baked-in dark outline).
2. **Add a runtime toggle** — load both fonts and switch between them
live via a new `Classic level numbers` graphics setting.

## How

- `StructureLevelPass` loads both atlases up front and selects one per
frame from `settings.structureLevel.classicFont`, re-laying-out the
digits when the toggle flips (digit advances differ between the fonts).
The fragment shader is a single program with a `uClassic` branch: direct
bitmap sample (white fill + baked outline) vs. MSDF median + synthesized
outline.
- New override `structure.classicNumbers` in `GraphicsOverrides`
(default `true` = classic), applied onto
`settings.structureLevel.classicFont` in `applyGraphicsOverrides` — so
it switches live, like the existing colorblind/classic-icons toggles.
- `GraphicsSettingsModal` gets a `Classic level numbers` toggle next to
`Classic icons` (with `en.json` strings).

## Testing

- `tsc --noEmit`, ESLint, Prettier, and `npm run build-prod` all pass.
- Ran the game headless, built/upgraded cities to level 2–3, and
confirmed: the classic toggle renders the pixel font, flipping it
renders the smooth MSDF font, and flipping back restores the pixel font
— switching live with no shader errors.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Evan
2026-06-13 19:07:17 -07:00
committed by GitHub
parent f4db4a33c8
commit f76f133589
9 changed files with 245 additions and 69 deletions
+2
View File
@@ -516,6 +516,8 @@
"black": "Black",
"classic_icons_desc": "Lighter outline with near-black interior",
"classic_icons_label": "Classic icons",
"classic_numbers_desc": "Pixel font for structure level numbers (off uses the smooth font)",
"classic_numbers_label": "Classic level numbers",
"colored": "Colored",
"colored_names_desc": "Show player names in their player color or in black",
"colored_names_label": "Name color",
@@ -328,6 +328,16 @@ export class GraphicsSettingsModal extends LitElement implements Controller {
this.patchStructure({ classicIcons: !this.currentClassicIcons() });
}
private currentClassicNumbers(): boolean {
return (
this.userSettings.graphicsOverrides().structure?.classicNumbers ?? true
);
}
private onToggleClassicNumbers() {
this.patchStructure({ classicNumbers: !this.currentClassicNumbers() });
}
private patchPassEnabled(patch: Partial<GraphicsOverrides["passEnabled"]>) {
const current = this.userSettings.graphicsOverrides();
this.userSettings.setGraphicsOverrides({
@@ -423,6 +433,7 @@ export class GraphicsSettingsModal extends LitElement implements Controller {
const hoverGlowAlpha = this.currentHoverGlowAlpha();
const namesColored = !this.currentDarkNames();
const classicIcons = this.currentClassicIcons();
const classicNumbers = this.currentClassicNumbers();
const highlightFill = this.currentHighlightFill();
const highlightBrighten = this.currentHighlightBrighten();
const highlightThicken = this.currentHighlightThicken();
@@ -637,6 +648,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.onToggleClassicNumbers}
>
<div class="flex-1">
<div class="font-medium">
${translateText("graphics_setting.classic_numbers_label")}
</div>
<div class="text-sm text-slate-400">
${translateText("graphics_setting.classic_numbers_desc")}
</div>
</div>
<div class="text-sm text-slate-400">
${classicNumbers
? 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"
>
@@ -15,6 +15,7 @@ export const GraphicsOverridesSchema = z
structure: z
.object({
classicIcons: z.boolean(),
classicNumbers: z.boolean(),
})
.partial(),
mapOverlay: z
+4
View File
@@ -36,6 +36,10 @@ export function applyGraphicsOverrides(
settings.structure.iconDarken = 0.3;
settings.structure.iconAlpha = 0.9;
}
if (overrides.structure?.classicNumbers !== undefined) {
settings.structureLevel.classicFont = overrides.structure.classicNumbers;
}
if (overrides.mapOverlay?.highlightFillBrighten !== undefined) {
settings.mapOverlay.highlightFillBrighten =
overrides.mapOverlay.highlightFillBrighten;
+3
View File
@@ -190,8 +190,11 @@ export interface RenderSettings {
};
structureLevel: {
scale: number;
/** MSDF outline width in px; unused by the classic bitmap font. */
outlineWidth: number;
offsetY: number;
/** true = round_6x6_modified bitmap font, false = overpass-bold MSDF. */
classicFont: boolean;
};
bar: {
healthBarW: number;
+142 -52
View File
@@ -1,8 +1,13 @@
/**
* StructureLevelPass — MSDF-rendered level numbers above structures.
* StructureLevelPass — level numbers above structures.
*
* Renders level digits for structures with level > 1 using the same MSDF
* atlas and glyph infrastructure as NamePass. One instanced draw call per frame.
* Renders level digits for structures with level > 1 in one of two fonts,
* switchable at runtime via settings.structureLevel.classicFont:
* - classic: the round_6x6_modified bitmap font (white digits with a
* baked-in dark outline), matching the v31 StructureLayer look.
* - new: the overpass-bold MSDF font shared with NamePass.
* Both fonts are loaded up front; draw() binds the active one. One instanced
* draw call per frame.
*
* Only visible when zoom > dotsThreshold (matching structure icon visibility).
*/
@@ -24,13 +29,15 @@ import type { GlyphTables } from "./name-pass/AtlasData";
import { buildGlyphTables, parseAtlasData } from "./name-pass/AtlasData";
import { buildGlyphMetricsTex } from "./name-pass/DataTextures";
import { layoutString } from "./name-pass/TextLayout";
import type { BMChar, ParsedAtlas } from "./name-pass/Types";
import { CHAR_RANGE, MAX_CHARS } from "./name-pass/Types";
import { assetUrl } from "src/core/AssetUrls";
import fragSrc from "../shaders/structure-level/structure-level.frag.glsl?raw";
import vertSrc from "../shaders/structure-level/structure-level.vert.glsl?raw";
const atlasUrl = assetUrl("atlases/msdf-atlas.png");
const classicAtlasUrl = assetUrl("fonts/round_6x6_modified.png");
const msdfAtlasUrl = assetUrl("atlases/msdf-atlas.png");
// ---------------------------------------------------------------------------
// Constants
@@ -51,6 +58,51 @@ const MAX_LEVEL_CHARS = 4;
const FLOATS_PER_INSTANCE = 5; // worldX, worldY, cursorX, charCode, atlasIdx
const BYTES_PER_INSTANCE = FLOATS_PER_INSTANCE * 4;
// ---------------------------------------------------------------------------
// round_6x6_modified bitmap font (digits only)
// ---------------------------------------------------------------------------
// Atlas-level metrics, taken from resources/fonts/round_6x6_modified.xml.
const FONT_SIZE = 16;
const FONT_BASE = 16;
const FONT_SCALE_W = 208;
const FONT_SCALE_H = 114;
/**
* Digit glyph metrics for round_6x6_modified. Level labels only ever contain
* digits, which sit in a uniform 16×16 grid at y=64 (x = digit·16) and share
* xadvance=14, xoffset=0, yoffset=0. See resources/fonts/round_6x6_modified.xml.
*/
function buildDigitChars(): BMChar[] {
const chars: BMChar[] = [];
for (let d = 0; d <= 9; d++) {
chars.push({
id: 48 + d,
char: String(d),
width: 16,
height: 16,
xoffset: 0,
yoffset: 0,
xadvance: 14,
x: d * 16,
y: 64,
page: 0,
});
}
return chars;
}
/** Per-font GPU + CPU resources. */
interface FontBundle {
glyph: GlyphTables;
metricsTex: WebGLTexture;
fontSize: number;
base: number;
atlasScaleH: number;
distanceRange: number;
atlasTex: WebGLTexture | null;
atlasReady: boolean;
}
// ---------------------------------------------------------------------------
// StructureLevelPass
// ---------------------------------------------------------------------------
@@ -66,40 +118,41 @@ export class StructureLevelPass {
private uDotsThreshold: WebGLUniformLocation;
private uScaleFactor: WebGLUniformLocation;
private uIconGrowZoom: WebGLUniformLocation;
private uFontSize: WebGLUniformLocation;
private uAtlasScaleH: WebGLUniformLocation;
private uBase: WebGLUniformLocation;
private uDistRange: WebGLUniformLocation;
private uOutlineWidth: WebGLUniformLocation;
private uLevelScale: WebGLUniformLocation;
private uLevelOffsetY: WebGLUniformLocation;
private uHighlightMask: WebGLUniformLocation;
private uHighlightDimAlpha: WebGLUniformLocation;
private uClassic: WebGLUniformLocation;
private vao: WebGLVertexArrayObject;
private instanceBuf: DynamicInstanceBuffer;
private instanceCount = 0;
private glyphMetricsTex: WebGLTexture;
private atlasTex: WebGLTexture | null = null;
private atlasReady = false;
// CPU-side glyph tables for layoutString
private glyph: GlyphTables;
private kernTable: Int8Array;
// Both fonts are loaded; draw() selects per settings.structureLevel.classicFont.
private classic: FontBundle;
private msdf: FontBundle;
private kernTable: Int8Array; // shared zero table — digits don't kern
private mapW: number;
// Reusable buffers for layoutString
private charCodes = new Uint8Array(MAX_CHARS);
private cursors = new Float32Array(MAX_CHARS);
private distanceRange: number;
private fontSize: number;
private atlasScaleH: number;
private base: number;
/** unitType string → atlas column index (05). */
private typeToAtlasCol = new Map<string, number>();
/** Build-button hover highlight bitmask (0 = off). */
private highlightMask = 0;
// Last units uploaded + which font that layout used, so draw() can re-layout
// when the font toggles (digit advances differ between fonts).
private lastUnits: Map<number, UnitState> | null = null;
private layoutClassic: boolean | null = null;
constructor(
gl: WebGL2RenderingContext,
header: RendererConfig,
@@ -117,14 +170,42 @@ export class StructureLevelPass {
if (col >= 0) this.typeToAtlasCol.set(header.unitTypes[i], col);
}
// Parse atlas data (same source as NamePass)
const atlas = parseAtlasData();
this.glyph = buildGlyphTables(atlas.chars);
this.kernTable = new Int8Array(CHAR_RANGE * CHAR_RANGE); // digits don't kern
this.distanceRange = atlas.distanceRange;
this.fontSize = atlas.fontSize;
this.atlasScaleH = atlas.scaleH;
this.base = atlas.base;
// Classic bitmap font (round_6x6_modified) — digits only.
const classicChars = buildDigitChars();
const classicAtlas: ParsedAtlas = {
fontSize: FONT_SIZE,
base: FONT_BASE,
scaleW: FONT_SCALE_W,
scaleH: FONT_SCALE_H,
distanceRange: 0,
chars: classicChars,
kernings: [],
};
this.classic = {
glyph: buildGlyphTables(classicChars),
metricsTex: buildGlyphMetricsTex(gl, classicAtlas),
fontSize: classicAtlas.fontSize,
base: classicAtlas.base,
atlasScaleH: classicAtlas.scaleH,
distanceRange: classicAtlas.distanceRange,
atlasTex: null,
atlasReady: false,
};
// New MSDF font (overpass-bold) — same atlas/data as NamePass.
const msdfAtlas = parseAtlasData();
this.msdf = {
glyph: buildGlyphTables(msdfAtlas.chars),
metricsTex: buildGlyphMetricsTex(gl, msdfAtlas),
fontSize: msdfAtlas.fontSize,
base: msdfAtlas.base,
atlasScaleH: msdfAtlas.scaleH,
distanceRange: msdfAtlas.distanceRange,
atlasTex: null,
atlasReady: false,
};
// Compile shaders
this.program = createProgram(gl, vertSrc, fragSrc);
@@ -134,18 +215,7 @@ export class StructureLevelPass {
gl.uniform1i(gl.getUniformLocation(this.program, "uAtlas"), 0);
gl.uniform1i(gl.getUniformLocation(this.program, "uGlyphMetrics"), 1);
// Static uniforms
gl.uniform1f(
gl.getUniformLocation(this.program, "uFontSize")!,
this.fontSize,
);
gl.uniform1f(
gl.getUniformLocation(this.program, "uAtlasScaleH")!,
this.atlasScaleH,
);
gl.uniform1f(gl.getUniformLocation(this.program, "uBase")!, this.base);
// Dynamic uniform locations
// Uniform locations
this.uCamera = gl.getUniformLocation(this.program, "uCamera")!;
this.uZoom = gl.getUniformLocation(this.program, "uZoom")!;
this.uIconSize = gl.getUniformLocation(this.program, "uIconSize")!;
@@ -155,6 +225,9 @@ export class StructureLevelPass {
)!;
this.uScaleFactor = gl.getUniformLocation(this.program, "uScaleFactor")!;
this.uIconGrowZoom = gl.getUniformLocation(this.program, "uIconGrowZoom")!;
this.uFontSize = gl.getUniformLocation(this.program, "uFontSize")!;
this.uAtlasScaleH = gl.getUniformLocation(this.program, "uAtlasScaleH")!;
this.uBase = gl.getUniformLocation(this.program, "uBase")!;
this.uDistRange = gl.getUniformLocation(this.program, "uDistRange")!;
this.uOutlineWidth = gl.getUniformLocation(this.program, "uOutlineWidth")!;
this.uLevelScale = gl.getUniformLocation(this.program, "uLevelScale")!;
@@ -167,12 +240,11 @@ export class StructureLevelPass {
this.program,
"uHighlightDimAlpha",
)!;
this.uClassic = gl.getUniformLocation(this.program, "uClassic")!;
// Glyph metrics data texture
this.glyphMetricsTex = buildGlyphMetricsTex(gl, atlas);
// Start async MSDF atlas load
this.loadAtlas();
// Start async atlas loads (both fonts)
this.loadAtlas(classicAtlasUrl, this.classic);
this.loadAtlas(msdfAtlasUrl, this.msdf);
// Instance buffer
const glBuf = gl.createBuffer()!;
@@ -212,7 +284,7 @@ export class StructureLevelPass {
gl.bindVertexArray(null);
}
private loadAtlas(): void {
private loadAtlas(url: string, bundle: FontBundle): void {
const img = new Image();
img.crossOrigin = "anonymous";
img.onload = () => {
@@ -224,15 +296,19 @@ export class StructureLevelPass {
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, img);
this.atlasTex = tex;
this.atlasReady = true;
bundle.atlasTex = tex;
bundle.atlasReady = true;
};
img.src = atlasUrl;
img.src = url;
}
updateStructures(units: Map<number, UnitState>): void {
let count = 0;
this.lastUnits = units;
const classic = this.settings.structureLevel.classicFont;
this.layoutClassic = classic;
const glyph = classic ? this.classic.glyph : this.msdf.glyph;
let count = 0;
for (const unit of units.values()) {
if (!unit.isActive) continue;
if (!STRUCTURE_TYPES.has(unit.unitType)) continue;
@@ -241,7 +317,7 @@ export class StructureLevelPass {
const levelStr = unit.level.toString();
layoutString(
levelStr,
this.glyph,
glyph,
this.kernTable,
this.charCodes,
this.cursors,
@@ -282,7 +358,15 @@ export class StructureLevelPass {
}
draw(cameraMatrix: Float32Array, zoom: number): void {
if (!this.atlasReady || this.instanceCount === 0) return;
const classic = this.settings.structureLevel.classicFont;
// Re-layout if the font toggled since the buffer was built — digit advances
// (and so cursor positions) differ between the two fonts.
if (this.lastUnits !== null && this.layoutClassic !== classic) {
this.updateStructures(this.lastUnits);
}
const font = classic ? this.classic : this.msdf;
if (!font.atlasReady || this.instanceCount === 0) return;
const gl = this.gl;
const ss = this.settings.structure;
@@ -295,17 +379,21 @@ export class StructureLevelPass {
gl.uniform1f(this.uDotsThreshold, ss.dotsZoomThreshold);
gl.uniform1f(this.uScaleFactor, ss.iconScaleFactorZoomedOut);
gl.uniform1f(this.uIconGrowZoom, ss.iconGrowZoom);
gl.uniform1f(this.uDistRange, this.distanceRange);
gl.uniform1f(this.uFontSize, font.fontSize);
gl.uniform1f(this.uAtlasScaleH, font.atlasScaleH);
gl.uniform1f(this.uBase, font.base);
gl.uniform1f(this.uDistRange, font.distanceRange);
gl.uniform1f(this.uOutlineWidth, sl.outlineWidth);
gl.uniform1f(this.uLevelScale, sl.scale);
gl.uniform1f(this.uLevelOffsetY, sl.offsetY);
gl.uniform1i(this.uHighlightMask, this.highlightMask);
gl.uniform1f(this.uHighlightDimAlpha, ss.highlightDimAlpha);
gl.uniform1i(this.uClassic, classic ? 1 : 0);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, this.atlasTex!);
gl.bindTexture(gl.TEXTURE_2D, font.atlasTex!);
gl.activeTexture(gl.TEXTURE1);
gl.bindTexture(gl.TEXTURE_2D, this.glyphMetricsTex);
gl.bindTexture(gl.TEXTURE_2D, font.metricsTex);
gl.bindVertexArray(this.vao);
gl.drawArraysInstanced(gl.TRIANGLES, 0, 6, this.instanceCount);
@@ -328,7 +416,9 @@ export class StructureLevelPass {
gl.deleteProgram(this.program);
this.instanceBuf.dispose();
gl.deleteVertexArray(this.vao);
gl.deleteTexture(this.glyphMetricsTex);
if (this.atlasTex) gl.deleteTexture(this.atlasTex);
gl.deleteTexture(this.classic.metricsTex);
gl.deleteTexture(this.msdf.metricsTex);
if (this.classic.atlasTex) gl.deleteTexture(this.classic.atlasTex);
if (this.msdf.atlasTex) gl.deleteTexture(this.msdf.atlasTex);
}
}
+2 -1
View File
@@ -155,7 +155,8 @@
"structureLevel": {
"scale": 1,
"outlineWidth": 8,
"offsetY": -0.4
"offsetY": -0.4,
"classicFont": true
},
"bar": {
"healthBarW": 11,
@@ -6,6 +6,7 @@ uniform float uDistRange;
uniform float uOutlineWidth;
uniform int uHighlightMask;
uniform float uHighlightDimAlpha;
uniform int uClassic; // 1 = round_6x6 bitmap font, 0 = overpass MSDF
in vec2 vUV;
flat in float vAlive;
@@ -19,32 +20,43 @@ float median(float r, float g, float b) {
void main() {
if (vAlive <= 0.0) discard;
vec3 msd = texture(uAtlas, vUV).rgb;
float sd = median(msd.r, msd.g, msd.b);
vec3 color;
float alpha;
vec2 unitRange = uDistRange / vec2(textureSize(uAtlas, 0));
vec2 screenTexSize = 1.0 / fwidth(vUV);
float screenPxRange = max(0.5 * dot(unitRange, screenTexSize), 1.0);
if (uClassic == 1) {
// round_6x6_modified bitmap: white digits with a baked-in dark outline
// on a transparent background (non-premultiplied RGBA).
vec4 texel = texture(uAtlas, vUV);
if (texel.a <= 0.0) discard;
color = texel.rgb;
alpha = texel.a;
} else {
// overpass-bold MSDF: white fill with a synthesized dark outline.
vec3 msd = texture(uAtlas, vUV).rgb;
float sd = median(msd.r, msd.g, msd.b);
float screenPxDist = screenPxRange * (sd - 0.5);
float fillAlpha = clamp(screenPxDist + 0.5, 0.0, 1.0);
vec2 unitRange = uDistRange / vec2(textureSize(uAtlas, 0));
vec2 screenTexSize = 1.0 / fwidth(vUV);
float screenPxRange = max(0.5 * dot(unitRange, screenTexSize), 1.0);
// White text with dark outline
float maxOutline = max(screenPxRange * 0.5 - 1.0, 0.0);
float effectiveOutline = min(uOutlineWidth, maxOutline);
float outlineDist = screenPxDist + effectiveOutline;
float outlineAlpha = clamp(outlineDist + 0.5, 0.0, 1.0);
float screenPxDist = screenPxRange * (sd - 0.5);
float fillAlpha = clamp(screenPxDist + 0.5, 0.0, 1.0);
vec3 color = mix(vec3(0.0), vec3(1.0), fillAlpha);
float finalAlpha = outlineAlpha;
float maxOutline = max(screenPxRange * 0.5 - 1.0, 0.0);
float effectiveOutline = min(uOutlineWidth, maxOutline);
float outlineAlpha = clamp(screenPxDist + effectiveOutline + 0.5, 0.0, 1.0);
color = mix(vec3(0.0), vec3(1.0), fillAlpha);
alpha = outlineAlpha;
}
// Dim level text for non-highlighted structure types
if (uHighlightMask != 0) {
int bit = 1 << int(vAtlasIdx + 0.5);
if ((uHighlightMask & bit) == 0) {
finalAlpha *= uHighlightDimAlpha;
alpha *= uHighlightDimAlpha;
}
}
fragColor = vec4(color, finalAlpha);
fragColor = vec4(color, alpha);
}
+33
View File
@@ -38,6 +38,9 @@ describe("GraphicsOverridesSchema", () => {
{ structure: {} },
{ structure: { classicIcons: true } },
{ structure: { classicIcons: false } },
{ structure: { classicNumbers: true } },
{ structure: { classicNumbers: false } },
{ structure: { classicIcons: true, classicNumbers: false } },
{ name: { darkNames: true }, structure: { classicIcons: true } },
];
for (const c of cases) {
@@ -90,6 +93,11 @@ describe("GraphicsOverridesSchema", () => {
structure: { classicIcons: "yes" },
}).success,
).toBe(false);
expect(
GraphicsOverridesSchema.safeParse({
structure: { classicNumbers: "yes" },
}).success,
).toBe(false);
expect(
GraphicsOverridesSchema.safeParse({
mapOverlay: { territorySaturation: "full" },
@@ -254,6 +262,31 @@ describe("applyGraphicsOverrides", () => {
expect(absent.iconAlpha).toBe(0.9);
});
test("classicNumbers=true → classic bitmap font", () => {
expect(
gen({ structure: { classicNumbers: true } }).structureLevel.classicFont,
).toBe(true);
});
test("classicNumbers=false → smooth MSDF font", () => {
expect(
gen({ structure: { classicNumbers: false } }).structureLevel.classicFont,
).toBe(false);
});
test("classicNumbers absent → defaults to classic bitmap font", () => {
expect(gen({ structure: {} }).structureLevel.classicFont).toBe(true);
expect(gen({}).structureLevel.classicFont).toBe(true);
});
test("classicNumbers is independent of classicIcons", () => {
const s = gen({
structure: { classicIcons: false, classicNumbers: true },
});
expect(s.structureLevel.classicFont).toBe(true);
expect(s.structure.iconDarken).toBe(0);
});
test("applies territorySaturation override (including 0)", () => {
expect(
gen({ mapOverlay: { territorySaturation: 0.4 } }).mapOverlay