Add ocean color override to graphics settings (#4269)

## 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 <noreply@anthropic.com>
This commit is contained in:
Evan
2026-06-13 21:13:46 -07:00
committed by GitHub
parent 54a7042303
commit 8b9bda1c8b
11 changed files with 172 additions and 15 deletions
+3
View File
@@ -540,6 +540,8 @@
"name_cull_desc": "Hide names smaller than this size", "name_cull_desc": "Hide names smaller than this size",
"name_cull_label": "Minimum name size", "name_cull_label": "Minimum name size",
"name_scale_label": "Name Scale", "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_desc": "How far zoomed out train tracks remain visible",
"rail_distance_label": "Train track draw distance", "rail_distance_label": "Train track draw distance",
"rail_thickness_desc": "How wide train tracks are drawn", "rail_thickness_desc": "How wide train tracks are drawn",
@@ -551,6 +553,7 @@
"section_map": "Map", "section_map": "Map",
"section_name_labels": "Name Labels", "section_name_labels": "Name Labels",
"section_structure_icons": "Structure Icons", "section_structure_icons": "Structure Icons",
"section_terrain": "Terrain",
"territory_alpha_desc": "How opaque the territory fill is (lower lets terrain show through)", "territory_alpha_desc": "How opaque the territory fill is (lower lets terrain show through)",
"territory_alpha_label": "Territory opacity", "territory_alpha_label": "Territory opacity",
"territory_sat_desc": "How vivid the territory fill colors are (lower mutes them)", "territory_sat_desc": "How vivid the territory fill colors are (lower mutes them)",
+6 -2
View File
@@ -521,14 +521,18 @@ async function createClientGame(
// graphics-override change (covers a theme switch such as colorblind mode). // graphics-override change (covers a theme switch such as colorblind mode).
const onGraphicsChanged = (): void => { const onGraphicsChanged = (): void => {
regenerateRenderSettings(); 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), // 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 // so re-theme existing players and re-upload the palette to recolor their
// territory fills/borders live. // territory fills/borders live.
gameView.refreshPlayerColors(); gameView.refreshPlayerColors();
webglBuilder.refreshPalette(gameView); webglBuilder.refreshPalette(gameView);
}; };
// No initial regenerate needed — the renderer was constructed with the // No initial regenerate or terrain rebuild needed — the renderer was
// resolved settings above. // constructed with the resolved settings above, so the terrain texture
// already bakes any saved ocean-color override.
globalThis.addEventListener( globalThis.addEventListener(
`${USER_SETTINGS_CHANGED_EVENT}:${GRAPHICS_KEY}`, `${USER_SETTINGS_CHANGED_EVENT}:${GRAPHICS_KEY}`,
onGraphicsChanged, onGraphicsChanged,
@@ -70,6 +70,8 @@ const RAIL_THICKNESS_MIN = 0.5;
const RAIL_THICKNESS_MAX = 3; const RAIL_THICKNESS_MAX = 3;
const RAIL_THICKNESS_STEP = 0.1; const RAIL_THICKNESS_STEP = 0.1;
const HEX_COLOR_RE = /^#?([0-9a-fA-F]{6})$/;
export class ShowGraphicsSettingsModalEvent { export class ShowGraphicsSettingsModalEvent {
constructor( constructor(
public readonly isVisible: boolean = true, public readonly isVisible: boolean = true,
@@ -334,6 +336,29 @@ export class GraphicsSettingsModal extends LitElement implements Controller {
this.patchStructure({ iconSize: value }); this.patchStructure({ iconSize: value });
} }
private patchTerrain(patch: Partial<GraphicsOverrides["terrain"]>) {
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 { private currentClassicIcons(): boolean {
return ( return (
this.userSettings.graphicsOverrides().structure?.classicIcons ?? true this.userSettings.graphicsOverrides().structure?.classicIcons ?? true
@@ -459,6 +484,7 @@ export class GraphicsSettingsModal extends LitElement implements Controller {
const coordinateGridOpacity = this.currentCoordinateGridOpacity(); const coordinateGridOpacity = this.currentCoordinateGridOpacity();
const railDrawDistance = RAIL_ZOOM_MAX - this.currentRailMinZoom(); const railDrawDistance = RAIL_ZOOM_MAX - this.currentRailMinZoom();
const railThickness = this.currentRailThickness(); const railThickness = this.currentRailThickness();
const oceanColor = this.currentOceanColor();
const colorblind = this.currentColorblind(); const colorblind = this.currentColorblind();
return html` return html`
@@ -919,6 +945,39 @@ export class GraphicsSettingsModal extends LitElement implements Controller {
</div> </div>
</div> </div>
<div
class="px-3 py-1 text-xs font-semibold text-slate-400 uppercase tracking-wider mt-2"
>
${translateText("graphics_setting.section_terrain")}
</div>
<div
class="flex gap-3 items-center w-full text-left p-3 hover:bg-slate-700 rounded-sm text-white transition-colors"
>
<div class="flex-1">
<div class="font-medium">
${translateText("graphics_setting.ocean_color_label")}
</div>
<div class="text-sm text-slate-400">
${translateText("graphics_setting.ocean_color_desc")}
</div>
</div>
<input
type="text"
.value=${oceanColor}
placeholder=${renderDefaults.terrain.oceanColor}
spellcheck="false"
@change=${this.onOceanColorChange}
class="w-24 px-2 py-1 bg-slate-900 border border-slate-500 rounded-sm text-sm text-white font-mono"
/>
<input
type="color"
.value=${oceanColor}
@input=${this.onOceanColorChange}
class="w-10 h-8 bg-transparent border border-slate-500 rounded-sm cursor-pointer"
/>
</div>
<div <div
class="px-3 py-1 text-xs font-semibold text-slate-400 uppercase tracking-wider mt-2" class="px-3 py-1 text-xs font-semibold text-slate-400 uppercase tracking-wider mt-2"
> >
@@ -45,6 +45,12 @@ export const GraphicsOverridesSchema = z
colorblind: z.boolean(), colorblind: z.boolean(),
}) })
.partial(), .partial(),
terrain: z
.object({
// "#rrggbb" hex string; overrides the base ocean (deep water) color.
oceanColor: z.string(),
})
.partial(),
}) })
.partial(); .partial();
+5
View File
@@ -188,6 +188,11 @@ export class MapRenderer {
applyTerrainDelta(refs: readonly number[], terrainBytes: Uint8Array): void { applyTerrainDelta(refs: readonly number[], terrainBytes: Uint8Array): void {
this.renderer?.applyTerrainDelta(refs, terrainBytes); 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 { updateAttackRings(rings: AttackRingInput[]): void {
this.renderer?.updateAttackRings(rings); this.renderer?.updateAttackRings(rings);
} }
+3
View File
@@ -75,6 +75,9 @@ export function applyGraphicsOverrides(
if (overrides.passEnabled?.fx !== undefined) { if (overrides.passEnabled?.fx !== undefined) {
settings.passEnabled.fx = overrides.passEnabled.fx; settings.passEnabled.fx = overrides.passEnabled.fx;
} }
if (overrides.terrain?.oceanColor !== undefined) {
settings.terrain.oceanColor = overrides.terrain.oceanColor;
}
if (overrides.name?.darkNames !== undefined) { if (overrides.name?.darkNames !== undefined) {
const dark = overrides.name.darkNames; const dark = overrides.name.darkNames;
// Dark: black fill + player-colored outline. Force outline RGB to black // Dark: black fill + player-colored outline. Force outline RGB to black
+7
View File
@@ -57,6 +57,13 @@ export interface RenderSettings {
bar: boolean; bar: boolean;
nameDebug: 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: { falloutBloom: {
broilSpeedCold: number; broilSpeedCold: number;
broilSpeedHot: number; broilSpeedHot: number;
+19 -3
View File
@@ -59,7 +59,7 @@ import { UnitPass } from "./passes/UnitPass";
import { WorldTextPass } from "./passes/WorldTextPass"; import { WorldTextPass } from "./passes/WorldTextPass";
import type { RenderSettings } from "./RenderSettings"; import type { RenderSettings } from "./RenderSettings";
import { AffiliationPalette } from "./utils/Affiliation"; import { AffiliationPalette } from "./utils/Affiliation";
import { buildTerrainRGBA, getPaletteSize } from "./utils/ColorUtils"; import { getPaletteSize, hexToRgb } from "./utils/ColorUtils";
import { import {
createTexture2D, createTexture2D,
toScreen, toScreen,
@@ -213,8 +213,13 @@ export class GPURenderer {
this.camera = new Camera(mapW, mapH); this.camera = new Camera(mapW, mapH);
// --- Terrain (static) --- // --- Terrain (static) ---
const terrainRGBA = buildTerrainRGBA(terrainBytes, mapW, mapH); this.terrainPass = new TerrainPass(
this.terrainPass = new TerrainPass(gl, terrainRGBA, mapW, mapH); gl,
terrainBytes,
mapW,
mapH,
hexToRgb(this.settings.terrain.oceanColor) ?? undefined,
);
// --- Shared palette texture (RGBA32F, 4096×2) --- // --- Shared palette texture (RGBA32F, 4096×2) ---
this.paletteData = paletteData; this.paletteData = paletteData;
@@ -817,6 +822,17 @@ export class GPURenderer {
this.railroadPass.applyTerrainDelta(refs, terrainBytes); 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 { applyConquestEvents(events: ConquestFx[]): void {
if (events.length > 0) { if (events.length > 0) {
this.fxPass.applyConquestEvents(events); this.fxPass.applyConquestEvents(events);
+32 -4
View File
@@ -10,7 +10,7 @@
import terrainFragSrc from "../shaders/terrain/terrain.frag.glsl?raw"; import terrainFragSrc from "../shaders/terrain/terrain.frag.glsl?raw";
import terrainVertSrc from "../shaders/terrain/terrain.vert.glsl?raw"; import terrainVertSrc from "../shaders/terrain/terrain.vert.glsl?raw";
import { encodeTerrainTile } from "../utils/ColorUtils"; import { buildTerrainRGBA, encodeTerrainTile } from "../utils/ColorUtils";
import { import {
createMapQuad, createMapQuad,
createProgram, createProgram,
@@ -28,16 +28,22 @@ export class TerrainPass {
private vao: WebGLVertexArrayObject; private vao: WebGLVertexArrayObject;
private uCamera: WebGLUniformLocation; private uCamera: WebGLUniformLocation;
private mapW: number; 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. // Scratch buffer for 1×1 sub-uploads; reused across applyTerrainDelta calls.
private readonly pixelScratch = new Uint8Array(4); private readonly pixelScratch = new Uint8Array(4);
constructor( constructor(
private gl: WebGL2RenderingContext, private gl: WebGL2RenderingContext,
terrainRGBA: Uint8Array, private terrainBytes: Uint8Array,
mapW: number, mapW: number,
mapH: number, mapH: number,
oceanColor?: readonly [number, number, number],
) { ) {
this.mapW = mapW; this.mapW = mapW;
this.mapH = mapH;
this.oceanColor = oceanColor;
this.program = createProgram( this.program = createProgram(
gl, gl,
shaderSrc(terrainVertSrc, { MAP_W: mapW, MAP_H: mapH }), shaderSrc(terrainVertSrc, { MAP_W: mapW, MAP_H: mapH }),
@@ -51,13 +57,35 @@ export class TerrainPass {
internalFormat: gl.RGBA8, internalFormat: gl.RGBA8,
format: gl.RGBA, format: gl.RGBA,
type: gl.UNSIGNED_BYTE, type: gl.UNSIGNED_BYTE,
data: terrainRGBA, data: buildTerrainRGBA(terrainBytes, mapW, mapH, oceanColor),
filter: gl.NEAREST, // pixel-crisp at all zoom levels filter: gl.NEAREST, // pixel-crisp at all zoom levels
}); });
this.vao = createMapQuad(gl, mapW, mapH); 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 * 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). * 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 ref = refs[i];
const x = ref % this.mapW; const x = ref % this.mapW;
const y = (ref - x) / 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.texSubImage2D(
gl.TEXTURE_2D, gl.TEXTURE_2D,
0, 0,
@@ -15,6 +15,9 @@
"bar": true, "bar": true,
"nameDebug": false "nameDebug": false
}, },
"terrain": {
"oceanColor": "#4785b5"
},
"falloutBloom": { "falloutBloom": {
"broilSpeedCold": 0.0018, "broilSpeedCold": 0.0018,
"broilSpeedHot": 0, "broilSpeedHot": 0,
+29 -6
View File
@@ -8,6 +8,8 @@
* Float32Array(PALETTE_SIZE × 2 × 4) to the GPURenderer constructor. * Float32Array(PALETTE_SIZE × 2 × 4) to the GPURenderer constructor.
*/ */
import renderDefaults from "../render-settings.json";
/** Must cover 12-bit smallID range (0-4095). */ /** Must cover 12-bit smallID range (0-4095). */
const PALETTE_SIZE = 4096; const PALETTE_SIZE = 4096;
@@ -17,6 +19,23 @@ export function getPaletteSize(): number {
// ---------- Terrain ---------- // ---------- 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. * Compute a static RGBA8 texture from raw terrain bytes.
* The single source of truth for terrain colors. * The single source of truth for terrain colors.
@@ -32,6 +51,7 @@ export function encodeTerrainTile(
tb: number, tb: number,
out: Uint8Array, out: Uint8Array,
offset: number, offset: number,
oceanColor?: readonly [number, number, number],
): void { ): void {
const isLand = (tb & 0x80) !== 0; const isLand = (tb & 0x80) !== 0;
const isShoreline = (tb & 0x40) !== 0; const isShoreline = (tb & 0x40) !== 0;
@@ -68,12 +88,14 @@ export function encodeTerrainTile(
g = 143; g = 143;
b = 255; b = 255;
} else { } 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 m = Math.min(magnitude, 10);
const off = 11 - m; const base = oceanColor ?? DEEP_WATER_BASE;
r = Math.max(0, 70 - 10 + off); r = Math.max(0, base[0] - m);
g = Math.max(0, 132 - 10 + off); g = Math.max(0, base[1] - m);
b = Math.max(0, 180 - 10 + off); b = Math.max(0, base[2] - m);
} }
out[offset] = r; out[offset] = r;
@@ -86,10 +108,11 @@ export function buildTerrainRGBA(
terrainBytes: Uint8Array, terrainBytes: Uint8Array,
w: number, w: number,
h: number, h: number,
oceanColor?: readonly [number, number, number],
): Uint8Array { ): Uint8Array {
const pixels = new Uint8Array(w * h * 4); const pixels = new Uint8Array(w * h * 4);
for (let i = 0; i < w * h; i++) { for (let i = 0; i < w * h; i++) {
encodeTerrainTile(terrainBytes[i], pixels, i * 4); encodeTerrainTile(terrainBytes[i], pixels, i * 4, oceanColor);
} }
return pixels; return pixels;
} }