mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 06:10:42 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 (0–5). */
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user