mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 07:50:45 +00:00
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:
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user