diff --git a/resources/lang/en.json b/resources/lang/en.json index 7d383594c..613c2e7c7 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -945,6 +945,10 @@ "highlight_brighten_desc": "How strongly the border brightens on hover (0 to disable)", "highlight_thicken_label": "Border highlight thickness", "highlight_thicken_desc": "How much the border thickens on hover", + "territory_sat_label": "Territory saturation", + "territory_sat_desc": "How vivid the territory fill colors are (lower mutes them)", + "territory_alpha_label": "Territory opacity", + "territory_alpha_desc": "How opaque the territory fill is (lower lets terrain show through)", "rail_distance_label": "Train track draw distance", "rail_distance_desc": "How far zoomed out train tracks remain visible", "section_effects": "Effects", diff --git a/src/client/hud/layers/GraphicsSettingsModal.ts b/src/client/hud/layers/GraphicsSettingsModal.ts index 8ea255318..4cad92c4e 100644 --- a/src/client/hud/layers/GraphicsSettingsModal.ts +++ b/src/client/hud/layers/GraphicsSettingsModal.ts @@ -32,6 +32,14 @@ const HIGHLIGHT_THICKEN_MIN = 0; const HIGHLIGHT_THICKEN_MAX = 5; const HIGHLIGHT_THICKEN_STEP = 1; +const TERRITORY_SAT_MIN = 0; +const TERRITORY_SAT_MAX = 1; +const TERRITORY_SAT_STEP = 0.01; + +const TERRITORY_ALPHA_MIN = 0; +const TERRITORY_ALPHA_MAX = 1; +const TERRITORY_ALPHA_STEP = 0.01; + // Train track "draw distance" is presented inverted: a higher slider value means // tracks stay visible when more zoomed out, i.e. a lower railMinZoom. const RAIL_ZOOM_MIN = 0; @@ -193,6 +201,20 @@ export class GraphicsSettingsModal extends LitElement implements Controller { ); } + private currentTerritorySat(): number { + return ( + this.userSettings.graphicsOverrides().mapOverlay?.territorySaturation ?? + renderDefaults.mapOverlay.territorySaturation + ); + } + + private currentTerritoryAlpha(): number { + return ( + this.userSettings.graphicsOverrides().mapOverlay?.territoryAlpha ?? + renderDefaults.mapOverlay.territoryAlpha + ); + } + private currentRailMinZoom(): number { return ( this.userSettings.graphicsOverrides().railroad?.railMinZoom ?? @@ -215,6 +237,16 @@ export class GraphicsSettingsModal extends LitElement implements Controller { this.patchMapOverlay({ highlightThicken: value }); } + private onTerritorySatChange(event: Event) { + const value = parseFloat((event.target as HTMLInputElement).value); + this.patchMapOverlay({ territorySaturation: value }); + } + + private onTerritoryAlphaChange(event: Event) { + const value = parseFloat((event.target as HTMLInputElement).value); + this.patchMapOverlay({ territoryAlpha: value }); + } + private onRailDrawDistanceChange(event: Event) { const drawDistance = parseFloat((event.target as HTMLInputElement).value); // Invert: higher draw distance => tracks visible when more zoomed out. @@ -287,6 +319,8 @@ export class GraphicsSettingsModal extends LitElement implements Controller { const highlightFill = this.currentHighlightFill(); const highlightBrighten = this.currentHighlightBrighten(); const highlightThicken = this.currentHighlightThicken(); + const territorySat = this.currentTerritorySat(); + const territoryAlpha = this.currentTerritoryAlpha(); const railDrawDistance = RAIL_ZOOM_MAX - this.currentRailMinZoom(); return html` @@ -499,6 +533,56 @@ export class GraphicsSettingsModal extends LitElement implements Controller { +
+
+
+ ${translateText("graphics_setting.territory_sat_label")} +
+
+ ${translateText("graphics_setting.territory_sat_desc")} +
+ +
+
+ ${territorySat.toFixed(2)} +
+
+ +
+
+
+ ${translateText("graphics_setting.territory_alpha_label")} +
+
+ ${translateText("graphics_setting.territory_alpha_desc")} +
+ +
+
+ ${territoryAlpha.toFixed(2)} +
+
+
diff --git a/src/client/render/gl/GraphicsOverrides.ts b/src/client/render/gl/GraphicsOverrides.ts index 6522ff5fe..9f6ff26b5 100644 --- a/src/client/render/gl/GraphicsOverrides.ts +++ b/src/client/render/gl/GraphicsOverrides.ts @@ -19,6 +19,8 @@ export const GraphicsOverridesSchema = z highlightFillBrighten: z.number(), highlightBrighten: z.number(), highlightThicken: z.number(), + territorySaturation: z.number(), + territoryAlpha: z.number(), }) .partial(), railroad: z diff --git a/src/client/render/gl/RenderOverrides.ts b/src/client/render/gl/RenderOverrides.ts index 0b146d2bc..e44d5f3f4 100644 --- a/src/client/render/gl/RenderOverrides.ts +++ b/src/client/render/gl/RenderOverrides.ts @@ -35,6 +35,13 @@ export function applyGraphicsOverrides( settings.mapOverlay.highlightThicken = overrides.mapOverlay.highlightThicken; } + if (overrides.mapOverlay?.territorySaturation !== undefined) { + settings.mapOverlay.territorySaturation = + overrides.mapOverlay.territorySaturation; + } + if (overrides.mapOverlay?.territoryAlpha !== undefined) { + settings.mapOverlay.territoryAlpha = overrides.mapOverlay.territoryAlpha; + } if (overrides.railroad?.railMinZoom !== undefined) { settings.railroad.railMinZoom = overrides.railroad.railMinZoom; } diff --git a/src/client/render/gl/RenderSettings.ts b/src/client/render/gl/RenderSettings.ts index f3387defe..3adfb9096 100644 --- a/src/client/render/gl/RenderSettings.ts +++ b/src/client/render/gl/RenderSettings.ts @@ -69,6 +69,10 @@ export interface RenderSettings { trailAlpha: number; defenseCheckerDarken: number; territoryDefenseDarken: number; + /** Saturation of the territory fill. 1 = full color, 0 = grayscale. */ + territorySaturation: number; + /** Absolute opacity of the territory fill. 1 = fully opaque (terrain hidden), ~0.588 = default. */ + territoryAlpha: number; staleNukeBase: number; staleNukeVariation: number; staleNukeAlpha: number; diff --git a/src/client/render/gl/debug/Layout.ts b/src/client/render/gl/debug/Layout.ts index 66ebef676..ef4c7d813 100644 --- a/src/client/render/gl/debug/Layout.ts +++ b/src/client/render/gl/debug/Layout.ts @@ -123,6 +123,24 @@ export function buildTree(s: RenderSettings, d: RenderSettings): DebugNode[] { slider(s.mapOverlay, "trailAlpha", d.mapOverlay, 0, 1, 0.01), slider(s.mapOverlay, "defenseCheckerDarken", d.mapOverlay, 0, 1, 0.01), slider(s.mapOverlay, "territoryDefenseDarken", d.mapOverlay, 0, 1, 0.01), + slider( + s.mapOverlay, + "territorySaturation", + d.mapOverlay, + 0, + 1, + 0.01, + "Territory Saturation", + ), + slider( + s.mapOverlay, + "territoryAlpha", + d.mapOverlay, + 0, + 1, + 0.01, + "Territory Alpha", + ), slider(s.mapOverlay, "staleNukeBase", d.mapOverlay, 0, 0.3, 0.005), slider(s.mapOverlay, "staleNukeVariation", d.mapOverlay, 0, 0.3, 0.005), slider(s.mapOverlay, "staleNukeAlpha", d.mapOverlay, 0, 1, 0.01), diff --git a/src/client/render/gl/passes/TerritoryPass.ts b/src/client/render/gl/passes/TerritoryPass.ts index a94a897e6..3139eaf74 100644 --- a/src/client/render/gl/passes/TerritoryPass.ts +++ b/src/client/render/gl/passes/TerritoryPass.ts @@ -41,6 +41,8 @@ export class TerritoryPass { private uShowPatterns: WebGLUniformLocation; private uIsTeamMode: WebGLUniformLocation; private uDefenseDarken: WebGLUniformLocation; + private uSaturation: WebGLUniformLocation; + private uTerritoryAlpha: WebGLUniformLocation; private highlightOwner = 0; private isTeamMode = false; @@ -165,6 +167,11 @@ export class TerritoryPass { this.program, "uDefenseDarken", )!; + this.uSaturation = gl.getUniformLocation(this.program, "uSaturation")!; + this.uTerritoryAlpha = gl.getUniformLocation( + this.program, + "uTerritoryAlpha", + )!; gl.useProgram(this.program); gl.uniform1i(gl.getUniformLocation(this.program, "uTileTex"), 0); @@ -458,6 +465,8 @@ export class TerritoryPass { ); gl.uniform1i(this.uIsTeamMode, this.isTeamMode ? 1 : 0); gl.uniform1f(this.uDefenseDarken, mo.territoryDefenseDarken); + gl.uniform1f(this.uSaturation, mo.territorySaturation); + gl.uniform1f(this.uTerritoryAlpha, mo.territoryAlpha); gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, this.tileTex); diff --git a/src/client/render/gl/render-settings.json b/src/client/render/gl/render-settings.json index ecf9852af..d9e85f4d6 100644 --- a/src/client/render/gl/render-settings.json +++ b/src/client/render/gl/render-settings.json @@ -67,6 +67,8 @@ "trailAlpha": 0.588, "defenseCheckerDarken": 0.7, "territoryDefenseDarken": 0.85, + "territorySaturation": 1, + "territoryAlpha": 0.588, "staleNukeBase": 0, "staleNukeVariation": 0.05, "staleNukeAlpha": 1, diff --git a/src/client/render/gl/shaders/map-overlay/territory.frag.glsl b/src/client/render/gl/shaders/map-overlay/territory.frag.glsl index 4a6b5e128..d3f472d53 100644 --- a/src/client/render/gl/shaders/map-overlay/territory.frag.glsl +++ b/src/client/render/gl/shaders/map-overlay/territory.frag.glsl @@ -25,6 +25,8 @@ uniform float uHighlightBrighten; // hover contrast boost strength; 0 = disable uniform sampler2D uDefenseCoverageTex; // R8 — 1.0 = tile defended by same-owner post uniform float uDefenseDarken; // multiplier applied to fill on defended tiles uniform sampler2D uBorderTex; // RGBA8 — border flags; R > 0.25 = border tile +uniform float uSaturation; // 1 = full color, 0 = grayscale +uniform float uTerritoryAlpha; // absolute fill opacity; 1 = fully opaque in vec2 vWorldPos; out vec4 fragColor; @@ -121,5 +123,13 @@ void main() { color.rgb *= uDefenseDarken; } + // Adjust how saturated the fill is by blending toward its luminance. + if (uSaturation != 1.0) { + float luma = dot(color.rgb, vec3(0.299, 0.587, 0.114)); + color.rgb = mix(vec3(luma), color.rgb, uSaturation); + } + + color.a = uTerritoryAlpha; + fragColor = color; } diff --git a/tests/GraphicsOverrides.test.ts b/tests/GraphicsOverrides.test.ts index aedebc77b..570033f5c 100644 --- a/tests/GraphicsOverrides.test.ts +++ b/tests/GraphicsOverrides.test.ts @@ -42,6 +42,18 @@ describe("GraphicsOverridesSchema", () => { } }); + test("accepts partial mapOverlay overrides", () => { + const cases = [ + { mapOverlay: {} }, + { mapOverlay: { territorySaturation: 0.5 } }, + { mapOverlay: { territoryAlpha: 0.8 } }, + { mapOverlay: { territorySaturation: 0, territoryAlpha: 1 } }, + ]; + for (const c of cases) { + expect(GraphicsOverridesSchema.safeParse(c).success).toBe(true); + } + }); + test("rejects wrong field types", () => { expect( GraphicsOverridesSchema.safeParse({ name: { nameScaleFactor: "big" } }) @@ -55,6 +67,11 @@ describe("GraphicsOverridesSchema", () => { structure: { classicIcons: "yes" }, }).success, ).toBe(false); + expect( + GraphicsOverridesSchema.safeParse({ + mapOverlay: { territorySaturation: "full" }, + }).success, + ).toBe(false); }); }); @@ -170,6 +187,33 @@ describe("applyGraphicsOverrides", () => { expect(absent.iconAlpha).toBe(1); }); + test("applies territorySaturation override (including 0)", () => { + expect( + gen({ mapOverlay: { territorySaturation: 0.4 } }).mapOverlay + .territorySaturation, + ).toBe(0.4); + expect( + gen({ mapOverlay: { territorySaturation: 0 } }).mapOverlay + .territorySaturation, + ).toBe(0); + }); + + test("applies territoryAlpha override (including 0)", () => { + expect( + gen({ mapOverlay: { territoryAlpha: 0.3 } }).mapOverlay.territoryAlpha, + ).toBe(0.3); + expect( + gen({ mapOverlay: { territoryAlpha: 0 } }).mapOverlay.territoryAlpha, + ).toBe(0); + }); + + test("mapOverlay override leaves other mapOverlay fields at defaults", () => { + const defaults = createRenderSettings().mapOverlay; + const mo = gen({ mapOverlay: { territorySaturation: 0.2 } }).mapOverlay; + expect(mo.territoryAlpha).toBe(defaults.territoryAlpha); + expect(mo.territoryDefenseDarken).toBe(defaults.territoryDefenseDarken); + }); + test("classicIcons + name overrides compose independently", () => { const s = gen({ name: { darkNames: true, nameScaleFactor: 0.9 },