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 },