From 8b9bda1c8bd31c72930adfb3d943df26b2be1891 Mon Sep 17 00:00:00 2001 From: Evan Date: Sat, 13 Jun 2026 21:13:46 -0700 Subject: [PATCH] Add ocean color override to graphics settings (#4269) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What Adds a **Terrain** section to the graphics settings modal with a color picker and a hex-code text field (paste a `#rrggbb` code) for the **ocean** (deep water) color. ## Details - The picked color sets the *shallow-water base*; the existing per-depth brightness gradient is preserved (deeper water still darkens). - Only deep water is affected — shoreline water and land are untouched. - Follows the same override pattern as every other graphics setting: the default lives in `render-settings.json` (`terrain.oceanColor`), the override is a field in `GraphicsOverrides`, and `applyGraphicsOverrides` copies it into the live `RenderSettings`. - Rebased on #4271 (settings resolved before renderer construction): the terrain texture **bakes the resolved ocean color at construction**, so a saved override shows on load with no special-casing. Terrain is baked into a GPU texture rather than read per-frame, so a *live* change still triggers an explicit `view.rebuildTerrain()`. - Resetting graphics overrides clears it back to the default ocean color. ## Testing Verified live in a headless singleplayer game: - A **saved** ocean override renders green deep-water on load, baked at construction with no settings-change event fired. - A mid-game color change recolors the deep ocean instantly, gradient preserved, shoreline/land untouched. `tsc` and ESLint clean. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.8 --- resources/lang/en.json | 3 + src/client/ClientGameRunner.ts | 8 ++- .../hud/layers/GraphicsSettingsModal.ts | 59 +++++++++++++++++++ src/client/render/gl/GraphicsOverrides.ts | 6 ++ src/client/render/gl/MapRenderer.ts | 5 ++ src/client/render/gl/RenderOverrides.ts | 3 + src/client/render/gl/RenderSettings.ts | 7 +++ src/client/render/gl/Renderer.ts | 22 ++++++- src/client/render/gl/passes/TerrainPass.ts | 36 +++++++++-- src/client/render/gl/render-settings.json | 3 + src/client/render/gl/utils/ColorUtils.ts | 35 +++++++++-- 11 files changed, 172 insertions(+), 15 deletions(-) diff --git a/resources/lang/en.json b/resources/lang/en.json index f5879e7b7..e387fc5e5 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -540,6 +540,8 @@ "name_cull_desc": "Hide names smaller than this size", "name_cull_label": "Minimum name size", "name_scale_label": "Name Scale", + "ocean_color_desc": "Base color of ocean.", + "ocean_color_label": "Ocean 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", @@ -551,6 +553,7 @@ "section_map": "Map", "section_name_labels": "Name Labels", "section_structure_icons": "Structure Icons", + "section_terrain": "Terrain", "territory_alpha_desc": "How opaque the territory fill is (lower lets terrain show through)", "territory_alpha_label": "Territory opacity", "territory_sat_desc": "How vivid the territory fill colors are (lower mutes them)", diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index 8a67b84a4..0e27bcc01 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -521,14 +521,18 @@ async function createClientGame( // graphics-override change (covers a theme switch such as colorblind mode). const onGraphicsChanged = (): void => { regenerateRenderSettings(); + // Terrain is baked into a GPU texture rather than read per-frame, so a + // terrain-color override (e.g. ocean) needs an explicit texture rebuild. + view.rebuildTerrain(); // A graphics override can switch the active theme (e.g. colorblind mode), // so re-theme existing players and re-upload the palette to recolor their // territory fills/borders live. gameView.refreshPlayerColors(); webglBuilder.refreshPalette(gameView); }; - // No initial regenerate needed — the renderer was constructed with the - // resolved settings above. + // No initial regenerate or terrain rebuild needed — the renderer was + // constructed with the resolved settings above, so the terrain texture + // already bakes any saved ocean-color override. globalThis.addEventListener( `${USER_SETTINGS_CHANGED_EVENT}:${GRAPHICS_KEY}`, onGraphicsChanged, diff --git a/src/client/hud/layers/GraphicsSettingsModal.ts b/src/client/hud/layers/GraphicsSettingsModal.ts index 977161f3a..b185b4db6 100644 --- a/src/client/hud/layers/GraphicsSettingsModal.ts +++ b/src/client/hud/layers/GraphicsSettingsModal.ts @@ -70,6 +70,8 @@ const RAIL_THICKNESS_MIN = 0.5; const RAIL_THICKNESS_MAX = 3; const RAIL_THICKNESS_STEP = 0.1; +const HEX_COLOR_RE = /^#?([0-9a-fA-F]{6})$/; + export class ShowGraphicsSettingsModalEvent { constructor( public readonly isVisible: boolean = true, @@ -334,6 +336,29 @@ export class GraphicsSettingsModal extends LitElement implements Controller { this.patchStructure({ iconSize: value }); } + private patchTerrain(patch: Partial) { + const current = this.userSettings.graphicsOverrides(); + this.userSettings.setGraphicsOverrides({ + ...current, + terrain: { ...current.terrain, ...patch }, + }); + this.requestUpdate(); + } + + private currentOceanColor(): string { + return ( + this.userSettings.graphicsOverrides().terrain?.oceanColor ?? + renderDefaults.terrain.oceanColor + ); + } + + private onOceanColorChange(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({ oceanColor: `#${match[1].toLowerCase()}` }); + } + private currentClassicIcons(): boolean { return ( this.userSettings.graphicsOverrides().structure?.classicIcons ?? true @@ -459,6 +484,7 @@ export class GraphicsSettingsModal extends LitElement implements Controller { const coordinateGridOpacity = this.currentCoordinateGridOpacity(); const railDrawDistance = RAIL_ZOOM_MAX - this.currentRailMinZoom(); const railThickness = this.currentRailThickness(); + const oceanColor = this.currentOceanColor(); const colorblind = this.currentColorblind(); return html` @@ -919,6 +945,39 @@ export class GraphicsSettingsModal extends LitElement implements Controller { +
+ ${translateText("graphics_setting.section_terrain")} +
+ +
+
+
+ ${translateText("graphics_setting.ocean_color_label")} +
+
+ ${translateText("graphics_setting.ocean_color_desc")} +
+
+ + +
+
diff --git a/src/client/render/gl/GraphicsOverrides.ts b/src/client/render/gl/GraphicsOverrides.ts index 03c857983..cdb0928ae 100644 --- a/src/client/render/gl/GraphicsOverrides.ts +++ b/src/client/render/gl/GraphicsOverrides.ts @@ -45,6 +45,12 @@ export const GraphicsOverridesSchema = z colorblind: z.boolean(), }) .partial(), + terrain: z + .object({ + // "#rrggbb" hex string; overrides the base ocean (deep water) color. + oceanColor: z.string(), + }) + .partial(), }) .partial(); diff --git a/src/client/render/gl/MapRenderer.ts b/src/client/render/gl/MapRenderer.ts index 423b9189a..20777d2b3 100644 --- a/src/client/render/gl/MapRenderer.ts +++ b/src/client/render/gl/MapRenderer.ts @@ -188,6 +188,11 @@ export class MapRenderer { applyTerrainDelta(refs: readonly number[], terrainBytes: Uint8Array): void { this.renderer?.applyTerrainDelta(refs, terrainBytes); } + + /** Rebuild the terrain texture from current settings (e.g. ocean color). */ + rebuildTerrain(): void { + this.renderer?.rebuildTerrain(); + } updateAttackRings(rings: AttackRingInput[]): void { this.renderer?.updateAttackRings(rings); } diff --git a/src/client/render/gl/RenderOverrides.ts b/src/client/render/gl/RenderOverrides.ts index b1b7af58f..7d4521b2d 100644 --- a/src/client/render/gl/RenderOverrides.ts +++ b/src/client/render/gl/RenderOverrides.ts @@ -75,6 +75,9 @@ export function applyGraphicsOverrides( if (overrides.passEnabled?.fx !== undefined) { settings.passEnabled.fx = overrides.passEnabled.fx; } + if (overrides.terrain?.oceanColor !== undefined) { + settings.terrain.oceanColor = overrides.terrain.oceanColor; + } if (overrides.name?.darkNames !== undefined) { const dark = overrides.name.darkNames; // Dark: black fill + player-colored outline. Force outline RGB to black diff --git a/src/client/render/gl/RenderSettings.ts b/src/client/render/gl/RenderSettings.ts index 05f77cc5d..6654e50a4 100644 --- a/src/client/render/gl/RenderSettings.ts +++ b/src/client/render/gl/RenderSettings.ts @@ -57,6 +57,13 @@ export interface RenderSettings { bar: boolean; nameDebug: boolean; }; + terrain: { + /** + * Base (shallowest) color of deep water as a "#rrggbb" hex string. The + * per-depth brightness gradient is preserved relative to this color. + */ + oceanColor: string; + }; falloutBloom: { broilSpeedCold: number; broilSpeedHot: number; diff --git a/src/client/render/gl/Renderer.ts b/src/client/render/gl/Renderer.ts index 3231204b5..8f933410c 100644 --- a/src/client/render/gl/Renderer.ts +++ b/src/client/render/gl/Renderer.ts @@ -59,7 +59,7 @@ import { UnitPass } from "./passes/UnitPass"; import { WorldTextPass } from "./passes/WorldTextPass"; import type { RenderSettings } from "./RenderSettings"; import { AffiliationPalette } from "./utils/Affiliation"; -import { buildTerrainRGBA, getPaletteSize } from "./utils/ColorUtils"; +import { getPaletteSize, hexToRgb } from "./utils/ColorUtils"; import { createTexture2D, toScreen, @@ -213,8 +213,13 @@ export class GPURenderer { this.camera = new Camera(mapW, mapH); // --- Terrain (static) --- - const terrainRGBA = buildTerrainRGBA(terrainBytes, mapW, mapH); - this.terrainPass = new TerrainPass(gl, terrainRGBA, mapW, mapH); + this.terrainPass = new TerrainPass( + gl, + terrainBytes, + mapW, + mapH, + hexToRgb(this.settings.terrain.oceanColor) ?? undefined, + ); // --- Shared palette texture (RGBA32F, 4096×2) --- this.paletteData = paletteData; @@ -817,6 +822,17 @@ export class GPURenderer { this.railroadPass.applyTerrainDelta(refs, terrainBytes); } + /** + * Rebuild the terrain texture from the current `settings.terrain` colors. + * Terrain is baked into a GPU texture rather than read per-frame, so a + * settings change needs this explicit rebuild. + */ + rebuildTerrain(): void { + this.terrainPass.setOceanColor( + hexToRgb(this.settings.terrain.oceanColor) ?? undefined, + ); + } + applyConquestEvents(events: ConquestFx[]): void { if (events.length > 0) { this.fxPass.applyConquestEvents(events); diff --git a/src/client/render/gl/passes/TerrainPass.ts b/src/client/render/gl/passes/TerrainPass.ts index c0754572d..1df627b8c 100644 --- a/src/client/render/gl/passes/TerrainPass.ts +++ b/src/client/render/gl/passes/TerrainPass.ts @@ -10,7 +10,7 @@ import terrainFragSrc from "../shaders/terrain/terrain.frag.glsl?raw"; import terrainVertSrc from "../shaders/terrain/terrain.vert.glsl?raw"; -import { encodeTerrainTile } from "../utils/ColorUtils"; +import { buildTerrainRGBA, encodeTerrainTile } from "../utils/ColorUtils"; import { createMapQuad, createProgram, @@ -28,16 +28,22 @@ export class TerrainPass { private vao: WebGLVertexArrayObject; private uCamera: WebGLUniformLocation; private mapW: number; + private mapH: number; + // Base ocean (deep water) color; reused by applyTerrainDelta and rebuilds. + private oceanColor: readonly [number, number, number] | undefined; // Scratch buffer for 1×1 sub-uploads; reused across applyTerrainDelta calls. private readonly pixelScratch = new Uint8Array(4); constructor( private gl: WebGL2RenderingContext, - terrainRGBA: Uint8Array, + private terrainBytes: Uint8Array, mapW: number, mapH: number, + oceanColor?: readonly [number, number, number], ) { this.mapW = mapW; + this.mapH = mapH; + this.oceanColor = oceanColor; this.program = createProgram( gl, shaderSrc(terrainVertSrc, { MAP_W: mapW, MAP_H: mapH }), @@ -51,13 +57,35 @@ export class TerrainPass { internalFormat: gl.RGBA8, format: gl.RGBA, type: gl.UNSIGNED_BYTE, - data: terrainRGBA, + data: buildTerrainRGBA(terrainBytes, mapW, mapH, oceanColor), filter: gl.NEAREST, // pixel-crisp at all zoom levels }); this.vao = createMapQuad(gl, mapW, mapH); } + /** + * Replace the base ocean color and re-upload the whole terrain texture. + * Called when the user changes the ocean color in graphics settings. + */ + setOceanColor(oceanColor?: readonly [number, number, number]): void { + this.oceanColor = oceanColor; + const gl = this.gl; + gl.bindTexture(gl.TEXTURE_2D, this.tex); + gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1); + gl.texSubImage2D( + gl.TEXTURE_2D, + 0, + 0, + 0, + this.mapW, + this.mapH, + gl.RGBA, + gl.UNSIGNED_BYTE, + buildTerrainRGBA(this.terrainBytes, this.mapW, this.mapH, oceanColor), + ); + } + /** * Update a subset of terrain tiles in-place (e.g. land→water from a water * nuke). `bytes[i]` is the new terrain byte for `refs[i]` (parallel arrays). @@ -73,7 +101,7 @@ export class TerrainPass { const ref = refs[i]; const x = ref % this.mapW; const y = (ref - x) / this.mapW; - encodeTerrainTile(bytes[i], this.pixelScratch, 0); + encodeTerrainTile(bytes[i], this.pixelScratch, 0, this.oceanColor); 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 ec126f055..f0109ccc3 100644 --- a/src/client/render/gl/render-settings.json +++ b/src/client/render/gl/render-settings.json @@ -15,6 +15,9 @@ "bar": true, "nameDebug": false }, + "terrain": { + "oceanColor": "#4785b5" + }, "falloutBloom": { "broilSpeedCold": 0.0018, "broilSpeedHot": 0, diff --git a/src/client/render/gl/utils/ColorUtils.ts b/src/client/render/gl/utils/ColorUtils.ts index e91f87bec..038332bdf 100644 --- a/src/client/render/gl/utils/ColorUtils.ts +++ b/src/client/render/gl/utils/ColorUtils.ts @@ -8,6 +8,8 @@ * Float32Array(PALETTE_SIZE × 2 × 4) to the GPURenderer constructor. */ +import renderDefaults from "../render-settings.json"; + /** Must cover 12-bit smallID range (0-4095). */ const PALETTE_SIZE = 4096; @@ -17,6 +19,23 @@ export function getPaletteSize(): number { // ---------- Terrain ---------- +/** Parse a "#rrggbb" (or "rrggbb") hex string into an RGB tuple, or null. */ +export function hexToRgb(hex: string): [number, number, number] | null { + const m = /^#?([0-9a-fA-F]{6})$/.exec(hex.trim()); + if (!m) return null; + const n = parseInt(m[1], 16); + return [(n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff]; +} + +/** + * Default base (shallowest, magnitude 0) color for deep water. Derived from + * the `terrain.oceanColor` default in render-settings.json (the single source + * of truth); used as a fallback when no override color is supplied. + */ +const DEEP_WATER_BASE: readonly [number, number, number] = hexToRgb( + renderDefaults.terrain.oceanColor, +)!; + /** * Compute a static RGBA8 texture from raw terrain bytes. * The single source of truth for terrain colors. @@ -32,6 +51,7 @@ export function encodeTerrainTile( tb: number, out: Uint8Array, offset: number, + oceanColor?: readonly [number, number, number], ): void { const isLand = (tb & 0x80) !== 0; const isShoreline = (tb & 0x40) !== 0; @@ -68,12 +88,14 @@ export function encodeTerrainTile( g = 143; b = 255; } else { - // Deep water + // Deep water — darkens with depth (magnitude). The base color sets the + // shallowest (brightest) shade; the per-depth gradient is preserved by + // subtracting the depth from each channel. const m = Math.min(magnitude, 10); - const off = 11 - m; - r = Math.max(0, 70 - 10 + off); - g = Math.max(0, 132 - 10 + off); - b = Math.max(0, 180 - 10 + off); + const base = oceanColor ?? DEEP_WATER_BASE; + r = Math.max(0, base[0] - m); + g = Math.max(0, base[1] - m); + b = Math.max(0, base[2] - m); } out[offset] = r; @@ -86,10 +108,11 @@ export function buildTerrainRGBA( terrainBytes: Uint8Array, w: number, h: number, + oceanColor?: readonly [number, number, number], ): Uint8Array { const pixels = new Uint8Array(w * h * 4); for (let i = 0; i < w * h; i++) { - encodeTerrainTile(terrainBytes[i], pixels, i * 4); + encodeTerrainTile(terrainBytes[i], pixels, i * 4, oceanColor); } return pixels; }