Add Classic Icons toggle to Graphics Settings

Adds a "Classic icons" toggle in the structure-icons section of the
Graphics Settings modal. Off (default) keeps today's renderer look;
on switches to a classic style — lighter player-colored shape behind
a dark icon glyph, with 0.75 alpha for a subtle translucent feel.

Exposes the underlying tuning as new render-settings knobs
(`structure.fillDarken`, `borderDarken`, `iconAlpha`, `iconR/G/B`) and
threads them through the structure shader as uniforms, replacing the
previously hardcoded `darken(_, 0.65)` / `darken(_, 0.35)` calls and
the hardcoded white `vec3(1.0)` icon color. The `classicIcons` boolean
in the override schema is the single user-facing knob; the generator
derives the five underlying field values from it. Extends the
ClientGameRunner live-apply path to copy the `structure` slice too,
and adds tests covering the schema and preset derivation.
This commit is contained in:
evanpelle
2026-05-28 14:47:40 -07:00
parent e938e5936b
commit fc3d80ec73
9 changed files with 164 additions and 8 deletions
+3
View File
@@ -927,6 +927,9 @@
"colored_names_desc": "Show player names in their player color or in black",
"colored": "Colored",
"black": "Black",
"section_structure_icons": "Structure Icons",
"classic_icons_label": "Classic icons",
"classic_icons_desc": "Lighter outline with near-black interior",
"reset_label": "Reset to defaults",
"reset_desc": "Clear all graphics overrides"
},
+1
View File
@@ -491,6 +491,7 @@ async function createClientGame(
);
const live = view.getSettings();
Object.assign(live.name, generated.name);
Object.assign(live.structure, generated.structure);
};
applyGraphicsOverrides();
globalThis.addEventListener(
@@ -127,6 +127,25 @@ export class GraphicsSettingsModal extends LitElement implements Controller {
this.requestUpdate();
}
private patchStructure(patch: Partial<GraphicsOverrides["structure"]>) {
const current = this.userSettings.graphicsOverrides();
this.userSettings.setGraphicsOverrides({
...current,
structure: { ...current.structure, ...patch },
});
this.requestUpdate();
}
private currentClassicIcons(): boolean {
return (
this.userSettings.graphicsOverrides().structure?.classicIcons ?? false
);
}
private onToggleClassicIcons() {
this.patchStructure({ classicIcons: !this.currentClassicIcons() });
}
private onNameScaleChange(event: Event) {
const value = parseFloat((event.target as HTMLInputElement).value);
this.patchName({ nameScaleFactor: value });
@@ -159,6 +178,7 @@ export class GraphicsSettingsModal extends LitElement implements Controller {
const nameScale = this.currentNameScale();
const nameCull = this.currentNameCull();
const namesColored = !this.currentDarkNames();
const classicIcons = this.currentClassicIcons();
return html`
<div
@@ -264,6 +284,31 @@ export class GraphicsSettingsModal extends LitElement implements Controller {
</div>
</button>
<div
class="px-3 py-1 text-xs font-semibold text-slate-400 uppercase tracking-wider mt-2"
>
${translateText("graphics_setting.section_structure_icons")}
</div>
<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.onToggleClassicIcons}
>
<div class="flex-1">
<div class="font-medium">
${translateText("graphics_setting.classic_icons_label")}
</div>
<div class="text-sm text-slate-400">
${translateText("graphics_setting.classic_icons_desc")}
</div>
</div>
<div class="text-sm text-slate-400">
${classicIcons
? translateText("user_setting.on")
: translateText("user_setting.off")}
</div>
</button>
<div class="border-t border-slate-600 pt-3 mt-4">
<button
class="flex gap-3 items-center w-full text-left p-3 hover:bg-slate-700 rounded-sm text-white transition-colors"
@@ -9,6 +9,11 @@ export const GraphicsOverridesSchema = z
darkNames: z.boolean(),
})
.partial(),
structure: z
.object({
classicIcons: z.boolean(),
})
.partial(),
})
.partial();
+20
View File
@@ -102,6 +102,16 @@ export interface RenderSettings {
shapes: Record<string, { scale: number; iconFill: number }>;
highlightOutlineWidth: number;
highlightDimAlpha: number;
/** HSV value multiplier applied to the icon fill (interior). 1.0 = no darkening. */
fillDarken: number;
/** HSV value multiplier applied to the icon border (outer ring). 1.0 = no darkening. */
borderDarken: number;
/** Multiplier on final icon alpha. 1.0 = opaque. */
iconAlpha: number;
/** RGB color of the inner icon glyph */
iconR: number;
iconG: number;
iconB: number;
};
structureLevel: {
scale: number;
@@ -279,6 +289,16 @@ export function generateRenderSettings(
if (overrides.name?.cullThreshold !== undefined) {
settings.name.cullThreshold = overrides.name.cullThreshold;
}
if (overrides.structure?.classicIcons === true) {
// Classic look: lighter player-colored shape behind a dark icon glyph,
// with a touch of translucency.
settings.structure.borderDarken = 0.7;
settings.structure.fillDarken = 1.0;
settings.structure.iconR = 0;
settings.structure.iconG = 0;
settings.structure.iconB = 0;
settings.structure.iconAlpha = 0.75;
}
if (overrides.name?.darkNames !== undefined) {
const dark = overrides.name.darkNames;
// Dark: black fill + player-colored outline. Force outline RGB to black
@@ -85,6 +85,10 @@ export class StructurePass {
private uHighlightMask: WebGLUniformLocation;
private uHighlightOutlineW: WebGLUniformLocation;
private uHighlightDimAlpha: WebGLUniformLocation;
private uFillDarken: WebGLUniformLocation;
private uBorderDarken: WebGLUniformLocation;
private uIconAlpha: WebGLUniformLocation;
private uIconColor: WebGLUniformLocation;
private vao: WebGLVertexArrayObject;
private instanceBuf: DynamicInstanceBuffer;
@@ -166,6 +170,10 @@ export class StructurePass {
this.program,
"uHighlightDimAlpha",
)!;
this.uFillDarken = gl.getUniformLocation(this.program, "uFillDarken")!;
this.uBorderDarken = gl.getUniformLocation(this.program, "uBorderDarken")!;
this.uIconAlpha = gl.getUniformLocation(this.program, "uIconAlpha")!;
this.uIconColor = gl.getUniformLocation(this.program, "uIconColor")!;
// Texture unit bindings + ghost defaults
gl.useProgram(this.program);
@@ -358,6 +366,10 @@ export class StructurePass {
gl.uniform1i(this.uHighlightMask, this.highlightMask);
gl.uniform1f(this.uHighlightOutlineW, ss.highlightOutlineWidth);
gl.uniform1f(this.uHighlightDimAlpha, ss.highlightDimAlpha);
gl.uniform1f(this.uFillDarken, ss.fillDarken);
gl.uniform1f(this.uBorderDarken, ss.borderDarken);
gl.uniform1f(this.uIconAlpha, ss.iconAlpha);
gl.uniform3f(this.uIconColor, ss.iconR, ss.iconG, ss.iconB);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, this.paletteTex);
+7 -1
View File
@@ -116,7 +116,13 @@
}
},
"highlightOutlineWidth": 0.04,
"highlightDimAlpha": 0.3
"highlightDimAlpha": 0.3,
"fillDarken": 0.65,
"borderDarken": 0.35,
"iconAlpha": 1.0,
"iconR": 1.0,
"iconG": 1.0,
"iconB": 1.0
},
"structureLevel": {
"scale": 1.2,
@@ -11,6 +11,10 @@ uniform int uAltView;
uniform int uHighlightMask; // bitmask of atlas columns to highlight (0 = off)
uniform float uHighlightOutlineW; // outline width for highlighted structures
uniform float uHighlightDimAlpha; // alpha multiplier for non-highlighted structures
uniform float uFillDarken; // HSV value multiplier on icon fill
uniform float uBorderDarken; // HSV value multiplier on icon border
uniform float uIconAlpha; // global multiplier on final icon alpha
uniform vec3 uIconColor; // color of the inner icon glyph (was white)
in vec2 vLocalPos;
in vec2 vAtlasUV;
@@ -91,8 +95,8 @@ void main() {
if (uAltView != 0 && vUnderConstruction < 0.5) {
vec3 ac = texelFetch(uAffiliation, ivec2(int(vOwnerID), 1), 0).rgb;
fillColor = vec4(darken(ac, 0.65), 1.0);
borderColor = vec4(darken(ac, 0.35), 1.0);
fillColor = vec4(darken(ac, uFillDarken), 1.0);
borderColor = vec4(darken(ac, uBorderDarken), 1.0);
} else if (vUnderConstruction > 0.5) {
fillColor = vec4(198.0/255.0, 198.0/255.0, 198.0/255.0, 1.0);
borderColor = vec4(127.0/255.0, 127.0/255.0, 127.0/255.0, 1.0);
@@ -102,8 +106,8 @@ void main() {
borderColor = texture(uPalette, vec2(u, 0.75));
// Darken via HSV value so hue/saturation stay intact
// vScale < 1.0 = darker, > 1.0 = brighter
fillColor.rgb = darken(fillColor.rgb, 0.65);
borderColor.rgb = darken(borderColor.rgb, 0.35);
fillColor.rgb = darken(fillColor.rgb, uFillDarken);
borderColor.rgb = darken(borderColor.rgb, uBorderDarken);
fillColor.a = 1.0;
borderColor.a = 1.0;
}
@@ -127,8 +131,8 @@ void main() {
iconAlpha = iconSample.a * borderMask * inBounds;
}
// Composite: white icon over player-colored shape
vec3 finalRGB = mix(bgColor.rgb, vec3(1.0), iconAlpha);
// Composite: tinted icon over player-colored shape
vec3 finalRGB = mix(bgColor.rgb, uIconColor, iconAlpha);
// Red X overlay for units marked for deletion
if (vMarkedForDeletion > 0.5) {
@@ -147,7 +151,7 @@ void main() {
float tintActive = step(0.01, dot(uOutlineColor, uOutlineColor));
finalRGB = mix(finalRGB, uOutlineColor, tintActive * 0.5);
float finalAlpha = bgColor.a * outerAlpha * uGhostAlpha;
float finalAlpha = bgColor.a * outerAlpha * uGhostAlpha * uIconAlpha;
// Build-button hover highlight: white outline on matching types, dim the rest
if (uHighlightMask != 0) {
+60
View File
@@ -23,6 +23,18 @@ describe("GraphicsOverridesSchema", () => {
}
});
test("accepts partial structure overrides", () => {
const cases = [
{ structure: {} },
{ structure: { classicIcons: true } },
{ structure: { classicIcons: false } },
{ name: { darkNames: true }, structure: { classicIcons: true } },
];
for (const c of cases) {
expect(GraphicsOverridesSchema.safeParse(c).success).toBe(true);
}
});
test("rejects wrong field types", () => {
expect(
GraphicsOverridesSchema.safeParse({ name: { nameScaleFactor: "big" } })
@@ -31,6 +43,11 @@ describe("GraphicsOverridesSchema", () => {
expect(
GraphicsOverridesSchema.safeParse({ name: { darkNames: "yes" } }).success,
).toBe(false);
expect(
GraphicsOverridesSchema.safeParse({
structure: { classicIcons: "yes" },
}).success,
).toBe(false);
});
});
@@ -117,4 +134,47 @@ describe("generateRenderSettings", () => {
expect(s.dayNight).toEqual(defaults.dayNight);
expect(s.structure).toEqual(defaults.structure);
});
test("classicIcons=true → light shape + dark icon + 0.75 alpha", () => {
const s = generateRenderSettings({
structure: { classicIcons: true },
}).structure;
// Shape (circle behind) is mostly player color, lightly darkened.
expect(s.fillDarken).toBe(1.0);
expect(s.borderDarken).toBe(0.7);
// Icon glyph itself is black.
expect(s.iconR).toBe(0);
expect(s.iconG).toBe(0);
expect(s.iconB).toBe(0);
// Slightly translucent in classic mode.
expect(s.iconAlpha).toBe(0.75);
});
test("classicIcons=false or absent → keeps render-settings.json defaults (fully opaque)", () => {
const defaults = createRenderSettings().structure;
const off = generateRenderSettings({
structure: { classicIcons: false },
}).structure;
expect(off.borderDarken).toBe(defaults.borderDarken);
expect(off.fillDarken).toBe(defaults.fillDarken);
expect(off.iconR).toBe(defaults.iconR);
expect(off.iconAlpha).toBe(1);
const absent = generateRenderSettings({ structure: {} }).structure;
expect(absent.borderDarken).toBe(defaults.borderDarken);
expect(absent.fillDarken).toBe(defaults.fillDarken);
expect(absent.iconR).toBe(defaults.iconR);
expect(absent.iconAlpha).toBe(1);
});
test("classicIcons + name overrides compose independently", () => {
const s = generateRenderSettings({
name: { darkNames: true, nameScaleFactor: 0.9 },
structure: { classicIcons: true },
});
expect(s.name.fillUsePlayerColor).toBe(false);
expect(s.name.nameScaleFactor).toBe(0.9);
expect(s.structure.borderDarken).toBe(0.7);
expect(s.structure.fillDarken).toBe(1.0);
expect(s.structure.iconAlpha).toBe(0.75);
});
});