mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-28 23:34:18 +00:00
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:
@@ -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();
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -15,6 +15,9 @@
|
||||
"bar": true,
|
||||
"nameDebug": false
|
||||
},
|
||||
"terrain": {
|
||||
"oceanColor": "#4785b5"
|
||||
},
|
||||
"falloutBloom": {
|
||||
"broilSpeedCold": 0.0018,
|
||||
"broilSpeedHot": 0,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user