From 2bd203968f05ba097254aee51f32800fc7a5976a Mon Sep 17 00:00:00 2001 From: Vivacious Box Date: Fri, 26 Jun 2026 03:12:17 +0200 Subject: [PATCH] Add terrain colors settings (#4391) ## Description: Add terrain color settings for all terrain types image ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [ ] I have added relevant tests to the test directory ## Please put your Discord username so you can be contacted if a bug or regression is found: MR. Box --- resources/lang/en.json | 8 + .../hud/layers/GraphicsSettingsModal.ts | 168 ++++++++++++++++++ src/client/render/gl/GraphicsOverrides.ts | 4 + src/client/render/gl/RenderOverrides.ts | 12 ++ src/client/render/gl/RenderSettings.ts | 4 + src/client/render/gl/Renderer.ts | 24 +-- src/client/render/gl/passes/TerrainPass.ts | 28 +-- src/client/render/gl/render-settings.json | 6 +- src/client/render/gl/utils/ColorUtils.ts | 68 ++++--- 9 files changed, 278 insertions(+), 44 deletions(-) diff --git a/resources/lang/en.json b/resources/lang/en.json index 0e541efa7..a0932d16e 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -545,6 +545,8 @@ "coordinate_grid_opacity_label": "Coordinate grid opacity", "fallout_desc": "Show the green nuclear fallout glow on irradiated territory. Disable to improve performance", "fallout_label": "Fallout effects", + "highland_color_desc": "Base color for higher-elevation terrain.", + "highland_color_label": "Highland color", "highlight_brighten_desc": "How strongly the border brightens on hover (0 to disable)", "highlight_brighten_label": "Border highlight amount", "highlight_fill_desc": "How strongly territory brightens on hover (0 to disable)", @@ -563,6 +565,8 @@ "lighting_ambient_label": "Ambient light", "lighting_unit_glow_desc": "How far the glow spreads around units and structures", "lighting_unit_glow_label": "Unit glow", + "mountain_color_desc": "Base color for mountain terrain.", + "mountain_color_label": "Mountain color", "name_cull_desc": "Hide names smaller than this size", "name_cull_label": "Minimum name size", "name_scale_label": "Name Scale", @@ -570,12 +574,16 @@ "nuke_color_label": "Nuke fallout color", "ocean_color_desc": "Base color of ocean.", "ocean_color_label": "Ocean color", + "plains_color_desc": "Base color for lowland terrain.", + "plains_color_label": "Plains color", "rail_distance_desc": "How far zoomed out train tracks remain visible", "rail_distance_label": "Train track draw distance", "rail_thickness_desc": "How wide train tracks are drawn", "rail_thickness_label": "Train track thickness", "reset_desc": "Clear all graphics overrides", "reset_label": "Reset to defaults", + "sand_color_desc": "Base color for shores.", + "sand_color_label": "Shore color", "section_accessibility": "Accessibility", "section_effects": "Effects", "section_lighting": "Lighting", diff --git a/src/client/hud/layers/GraphicsSettingsModal.ts b/src/client/hud/layers/GraphicsSettingsModal.ts index b6e5720e0..6c9c37293 100644 --- a/src/client/hud/layers/GraphicsSettingsModal.ts +++ b/src/client/hud/layers/GraphicsSettingsModal.ts @@ -425,6 +425,34 @@ export class GraphicsSettingsModal extends LitElement implements Controller { ); } + private currentSandColor(): string { + return ( + this.userSettings.graphicsOverrides().terrain?.sandColor ?? + renderDefaults.terrain.sandColor + ); + } + + private currentPlainsColor(): string { + return ( + this.userSettings.graphicsOverrides().terrain?.plainsColor ?? + renderDefaults.terrain.plainsColor + ); + } + + private currentHighlandColor(): string { + return ( + this.userSettings.graphicsOverrides().terrain?.highlandColor ?? + renderDefaults.terrain.highlandColor + ); + } + + private currentMountainColor(): string { + return ( + this.userSettings.graphicsOverrides().terrain?.mountainColor ?? + renderDefaults.terrain.mountainColor + ); + } + private onOceanColorChange(event: Event) { const value = (event.target as HTMLInputElement).value.trim(); const match = HEX_COLOR_RE.exec(value); @@ -432,6 +460,34 @@ export class GraphicsSettingsModal extends LitElement implements Controller { this.patchTerrain({ oceanColor: `#${match[1].toLowerCase()}` }); } + private onSandColorChange(event: Event) { + const value = (event.target as HTMLInputElement).value.trim(); + const match = HEX_COLOR_RE.exec(value); + if (!match) return; // ignore partial/invalid hex while typing + this.patchTerrain({ sandColor: `#${match[1].toLowerCase()}` }); + } + + private onPlainsColorChange(event: Event) { + const value = (event.target as HTMLInputElement).value.trim(); + const match = HEX_COLOR_RE.exec(value); + if (!match) return; + this.patchTerrain({ plainsColor: `#${match[1].toLowerCase()}` }); + } + + private onHighlandColorChange(event: Event) { + const value = (event.target as HTMLInputElement).value.trim(); + const match = HEX_COLOR_RE.exec(value); + if (!match) return; + this.patchTerrain({ highlandColor: `#${match[1].toLowerCase()}` }); + } + + private onMountainColorChange(event: Event) { + const value = (event.target as HTMLInputElement).value.trim(); + const match = HEX_COLOR_RE.exec(value); + if (!match) return; + this.patchTerrain({ mountainColor: `#${match[1].toLowerCase()}` }); + } + private patchLighting(patch: Partial) { const current = this.userSettings.graphicsOverrides(); this.userSettings.setGraphicsOverrides({ @@ -611,6 +667,10 @@ export class GraphicsSettingsModal extends LitElement implements Controller { const railDrawDistance = RAIL_ZOOM_MAX - this.currentRailMinZoom(); const railThickness = this.currentRailThickness(); const oceanColor = this.currentOceanColor(); + const sandColor = this.currentSandColor(); + const plainsColor = this.currentPlainsColor(); + const highlandColor = this.currentHighlandColor(); + const mountainColor = this.currentMountainColor(); const nukeColor = this.currentNukeColor(); const ambientLevel = this.currentAmbientLevel(); const unitGlow = this.currentUnitGlow(); @@ -1182,6 +1242,114 @@ export class GraphicsSettingsModal extends LitElement implements Controller { /> +
+
+
+ ${translateText("graphics_setting.sand_color_label")} +
+
+ ${translateText("graphics_setting.sand_color_desc")} +
+
+ + +
+ +
+
+
+ ${translateText("graphics_setting.plains_color_label")} +
+
+ ${translateText("graphics_setting.plains_color_desc")} +
+
+ + +
+ +
+
+
+ ${translateText("graphics_setting.highland_color_label")} +
+
+ ${translateText("graphics_setting.highland_color_desc")} +
+
+ + +
+ +
+
+
+ ${translateText("graphics_setting.mountain_color_label")} +
+
+ ${translateText("graphics_setting.mountain_color_desc")} +
+
+ + +
+
diff --git a/src/client/render/gl/GraphicsOverrides.ts b/src/client/render/gl/GraphicsOverrides.ts index f18a3b1df..70d93f07b 100644 --- a/src/client/render/gl/GraphicsOverrides.ts +++ b/src/client/render/gl/GraphicsOverrides.ts @@ -58,6 +58,10 @@ export const GraphicsOverridesSchema = z .object({ // "#rrggbb" hex string; overrides the base ocean (deep water) color. oceanColor: z.string(), + sandColor: z.string(), + plainsColor: z.string(), + highlandColor: z.string(), + mountainColor: z.string(), }) .partial(), lighting: z diff --git a/src/client/render/gl/RenderOverrides.ts b/src/client/render/gl/RenderOverrides.ts index 4219c70b9..4973f42bd 100644 --- a/src/client/render/gl/RenderOverrides.ts +++ b/src/client/render/gl/RenderOverrides.ts @@ -97,6 +97,18 @@ export function applyGraphicsOverrides( if (overrides.terrain?.oceanColor !== undefined) { settings.terrain.oceanColor = overrides.terrain.oceanColor; } + if (overrides.terrain?.sandColor !== undefined) { + settings.terrain.sandColor = overrides.terrain.sandColor; + } + if (overrides.terrain?.plainsColor !== undefined) { + settings.terrain.plainsColor = overrides.terrain.plainsColor; + } + if (overrides.terrain?.highlandColor !== undefined) { + settings.terrain.highlandColor = overrides.terrain.highlandColor; + } + if (overrides.terrain?.mountainColor !== undefined) { + settings.terrain.mountainColor = overrides.terrain.mountainColor; + } if (overrides.lighting?.ambient !== undefined) { settings.lighting.ambient = overrides.lighting.ambient; // The composite only darkens the scene (and reveals the structure/unit diff --git a/src/client/render/gl/RenderSettings.ts b/src/client/render/gl/RenderSettings.ts index ca78b5e94..044a68408 100644 --- a/src/client/render/gl/RenderSettings.ts +++ b/src/client/render/gl/RenderSettings.ts @@ -64,6 +64,10 @@ export interface RenderSettings { * per-depth brightness gradient is preserved relative to this color. */ oceanColor: string; + sandColor: string; + plainsColor: string; + highlandColor: string; + mountainColor: string; }; falloutBloom: { broilSpeedCold: number; diff --git a/src/client/render/gl/Renderer.ts b/src/client/render/gl/Renderer.ts index 7b690d992..51db52a32 100644 --- a/src/client/render/gl/Renderer.ts +++ b/src/client/render/gl/Renderer.ts @@ -214,13 +214,13 @@ export class GPURenderer { this.camera = new Camera(mapW, mapH); // --- Terrain (static) --- - this.terrainPass = new TerrainPass( - gl, - terrainBytes, - mapW, - mapH, - hexToRgb(this.settings.terrain.oceanColor) ?? undefined, - ); + this.terrainPass = new TerrainPass(gl, terrainBytes, mapW, mapH, { + oceanColor: hexToRgb(this.settings.terrain.oceanColor) ?? undefined, + sandColor: hexToRgb(this.settings.terrain.sandColor) ?? undefined, + plainsColor: hexToRgb(this.settings.terrain.plainsColor) ?? undefined, + highlandColor: hexToRgb(this.settings.terrain.highlandColor) ?? undefined, + mountainColor: hexToRgb(this.settings.terrain.mountainColor) ?? undefined, + }); // --- Shared palette texture (RGBA32F, 4096×2) --- this.paletteData = paletteData; @@ -835,9 +835,13 @@ export class GPURenderer { * settings change needs this explicit rebuild. */ rebuildTerrain(): void { - this.terrainPass.setOceanColor( - hexToRgb(this.settings.terrain.oceanColor) ?? undefined, - ); + this.terrainPass.setTerrainColors({ + oceanColor: hexToRgb(this.settings.terrain.oceanColor) ?? undefined, + sandColor: hexToRgb(this.settings.terrain.sandColor) ?? undefined, + plainsColor: hexToRgb(this.settings.terrain.plainsColor) ?? undefined, + highlandColor: hexToRgb(this.settings.terrain.highlandColor) ?? undefined, + mountainColor: hexToRgb(this.settings.terrain.mountainColor) ?? undefined, + }); } applyConquestEvents(events: ConquestFx[]): void { diff --git a/src/client/render/gl/passes/TerrainPass.ts b/src/client/render/gl/passes/TerrainPass.ts index 9e1050c3d..206a2431d 100644 --- a/src/client/render/gl/passes/TerrainPass.ts +++ b/src/client/render/gl/passes/TerrainPass.ts @@ -10,7 +10,11 @@ import terrainFragSrc from "../shaders/terrain/terrain.frag.glsl?raw"; import terrainVertSrc from "../shaders/terrain/terrain.vert.glsl?raw"; -import { buildTerrainRGBA, encodeTerrainTile } from "../utils/ColorUtils"; +import { + buildTerrainRGBA, + encodeTerrainTile, + TerrainColorOverrides, +} from "../utils/ColorUtils"; import { createMapQuad, createProgram, @@ -30,7 +34,7 @@ export class TerrainPass { private mapW: number; private mapH: number; // Base ocean (deep water) color; reused by applyTerrainDelta and rebuilds. - private oceanColor: readonly [number, number, number] | undefined; + private terrainColors: TerrainColorOverrides | undefined; // Scratch buffer for 1×1 sub-uploads; reused across applyTerrainDelta calls. private readonly pixelScratch = new Uint8Array(4); @@ -39,11 +43,11 @@ export class TerrainPass { private terrainBytes: Uint8Array, mapW: number, mapH: number, - oceanColor?: readonly [number, number, number], + terrainColors?: TerrainColorOverrides, ) { this.mapW = mapW; this.mapH = mapH; - this.oceanColor = oceanColor; + this.terrainColors = terrainColors; this.program = createProgram( gl, shaderSrc(terrainVertSrc, { MAP_W: mapW, MAP_H: mapH }), @@ -57,7 +61,7 @@ export class TerrainPass { internalFormat: gl.RGBA8, format: gl.RGBA, type: gl.UNSIGNED_BYTE, - data: buildTerrainRGBA(terrainBytes, mapW, mapH, oceanColor), + data: buildTerrainRGBA(terrainBytes, mapW, mapH, terrainColors), filter: gl.NEAREST, // pixel-crisp at all zoom levels }); @@ -65,11 +69,11 @@ export class TerrainPass { } /** - * Replace the base ocean color and re-upload the whole terrain texture. - * Called when the user changes the ocean color in graphics settings. + * Replace the base terrain colors and re-upload the whole terrain texture. + * Called when the user changes the terrain colors in graphics settings. */ - setOceanColor(oceanColor?: readonly [number, number, number]): void { - this.oceanColor = oceanColor; + setTerrainColors(terrainColors?: TerrainColorOverrides): void { + this.terrainColors = terrainColors; const gl = this.gl; gl.bindTexture(gl.TEXTURE_2D, this.tex); gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1); @@ -82,7 +86,7 @@ export class TerrainPass { this.mapH, gl.RGBA, gl.UNSIGNED_BYTE, - buildTerrainRGBA(this.terrainBytes, this.mapW, this.mapH, oceanColor), + buildTerrainRGBA(this.terrainBytes, this.mapW, this.mapH, terrainColors), ); } @@ -93,7 +97,7 @@ export class TerrainPass { * produces. * * Also writes back into `terrainBytes` so a later full re-upload (e.g. - * setOceanColor) reflects these conversions instead of reverting them. + * setTerrainColor) reflects these conversions instead of reverting them. */ applyTerrainDelta(refs: readonly number[], bytes: Uint8Array): void { if (refs.length === 0) return; @@ -105,7 +109,7 @@ export class TerrainPass { const x = ref % this.mapW; const y = (ref - x) / this.mapW; this.terrainBytes[ref] = bytes[i]; - encodeTerrainTile(bytes[i], this.pixelScratch, 0, this.oceanColor); + encodeTerrainTile(bytes[i], this.pixelScratch, 0, this.terrainColors); gl.texSubImage2D( gl.TEXTURE_2D, 0, diff --git a/src/client/render/gl/render-settings.json b/src/client/render/gl/render-settings.json index 4e43badbe..fb689c6d7 100644 --- a/src/client/render/gl/render-settings.json +++ b/src/client/render/gl/render-settings.json @@ -17,7 +17,11 @@ "nameDebug": false }, "terrain": { - "oceanColor": "#4785b5" + "oceanColor": "#4785b5", + "sandColor": "#CC9E9E", + "plainsColor": "#BECD8A", + "highlandColor": "#C8B78A", + "mountainColor": "#e6e6e6" }, "falloutBloom": { "broilSpeedCold": 0.0018, diff --git a/src/client/render/gl/utils/ColorUtils.ts b/src/client/render/gl/utils/ColorUtils.ts index 041931922..bfe9b46e6 100644 --- a/src/client/render/gl/utils/ColorUtils.ts +++ b/src/client/render/gl/utils/ColorUtils.ts @@ -52,47 +52,73 @@ const DEEP_WATER_BASE: readonly [number, number, number] = hexToRgb( * indistinguishable from the area outside the map. */ /** Encode one terrain byte → RGBA, writing into `out[offset..offset+3]`. */ +export interface TerrainColorOverrides { + oceanColor?: readonly [number, number, number]; + sandColor?: readonly [number, number, number]; + plainsColor?: readonly [number, number, number]; + highlandColor?: readonly [number, number, number]; + mountainColor?: readonly [number, number, number]; +} + export function encodeTerrainTile( tb: number, out: Uint8Array, offset: number, - oceanColor?: readonly [number, number, number], + colors?: TerrainColorOverrides, ): void { + const oceanColor = colors?.oceanColor; + const sandColor = colors?.sandColor; + const plainsColor = colors?.plainsColor; + const highlandColor = colors?.highlandColor; + const mountainColor = colors?.mountainColor; + const isLand = (tb & 0x80) !== 0; const isShoreline = (tb & 0x40) !== 0; const magnitude = tb & 0x1f; let r: number, g: number, b: number; + const terrainColors = { + ocean: oceanColor ?? DEEP_WATER_BASE, + shoreWater: [100, 143, 255], + sand: sandColor ?? [204, 203, 158], + plains: plainsColor ?? [190, 220, 138], + highland: highlandColor ?? [200, 183, 138], + mountain: mountainColor ?? [230, 230, 230], + peak: [60, 60, 60], + }; + // Impassable terrain: render as the map background colour so it blends // with the area outside the map quad. Must match the clear colour in // Renderer.ts drawBaseLayer(): gl.clearColor(60/255, 60/255, 60/255). if (isLand && magnitude === 31) { - r = 60; - g = 60; - b = 60; + [r, g, b] = terrainColors.peak; } else if (isLand && isShoreline) { - // Shore (sand) - r = 204; - g = 203; - b = 158; + [r, g, b] = terrainColors.sand; } else if (isLand) { if (magnitude < 10) { // Plains - r = 190; - g = 220 - 2 * magnitude; - b = 138; + const base = terrainColors.plains; + + r = base[0]; + g = base[1] - 2 * magnitude; + b = base[2]; } else if (magnitude < 20) { // Highland - r = 200 + 2 * magnitude; - g = 183 + 2 * magnitude; - b = 138 + 2 * magnitude; + const base = terrainColors.highland; + const m = magnitude - 10; + + r = Math.min(255, base[0] + 2 * m); + g = Math.min(255, base[1] + 2 * m); + b = Math.min(255, base[2] + 2 * m); } else { // Mountain - const v = Math.min(255, 230 + Math.floor(magnitude / 2)); - r = v; - g = v; - b = v; + const base = terrainColors.mountain; + const m = Math.floor(magnitude / 2); + + r = Math.min(255, base[0] + m); + g = Math.min(255, base[1] + m); + b = Math.min(255, base[2] + m); } } else if (isShoreline) { // Shoreline water — computed dynamically by blending 70% ocean color and 30% white @@ -105,7 +131,7 @@ export function encodeTerrainTile( // shallowest (brightest) shade; the per-depth gradient is preserved by // subtracting the depth from each channel. const m = Math.min(magnitude, 10); - const base = oceanColor ?? DEEP_WATER_BASE; + const base = terrainColors.ocean; r = Math.max(0, base[0] - m); g = Math.max(0, base[1] - m); b = Math.max(0, base[2] - m); @@ -121,11 +147,11 @@ export function buildTerrainRGBA( terrainBytes: Uint8Array, w: number, h: number, - oceanColor?: readonly [number, number, number], + colors?: TerrainColorOverrides, ): Uint8Array { const pixels = new Uint8Array(w * h * 4); for (let i = 0; i < w * h; i++) { - encodeTerrainTile(terrainBytes[i], pixels, i * 4, oceanColor); + encodeTerrainTile(terrainBytes[i], pixels, i * 4, colors); } return pixels; }