mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 06:10:42 +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:
@@ -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)",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<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 {
|
||||
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 {
|
||||
</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
|
||||
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(),
|
||||
})
|
||||
.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