Add terrain colors settings (#4391)

## Description:

Add terrain color settings for all terrain types

<img width="977" height="485" alt="image"
src="https://github.com/user-attachments/assets/ac1cef11-4b1a-45f2-8cf6-94f557ba8f6e"
/>

## 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
This commit is contained in:
Vivacious Box
2026-06-26 03:12:17 +02:00
committed by GitHub
parent 06c5a4ef35
commit 2bd203968f
9 changed files with 278 additions and 44 deletions
+8
View File
@@ -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",
@@ -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<GraphicsOverrides["lighting"]>) {
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 {
/>
</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.sand_color_label")}
</div>
<div class="text-sm text-slate-400">
${translateText("graphics_setting.sand_color_desc")}
</div>
</div>
<input
type="text"
.value=${sandColor}
placeholder=${renderDefaults.terrain.sandColor}
spellcheck="false"
@change=${this.onSandColorChange}
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=${sandColor}
@input=${this.onSandColorChange}
class="w-10 h-8 bg-transparent border border-slate-500 rounded-sm cursor-pointer"
/>
</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.plains_color_label")}
</div>
<div class="text-sm text-slate-400">
${translateText("graphics_setting.plains_color_desc")}
</div>
</div>
<input
type="text"
.value=${plainsColor}
placeholder=${renderDefaults.terrain.plainsColor}
spellcheck="false"
@change=${this.onPlainsColorChange}
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=${plainsColor}
@input=${this.onPlainsColorChange}
class="w-10 h-8 bg-transparent border border-slate-500 rounded-sm cursor-pointer"
/>
</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.highland_color_label")}
</div>
<div class="text-sm text-slate-400">
${translateText("graphics_setting.highland_color_desc")}
</div>
</div>
<input
type="text"
.value=${highlandColor}
placeholder=${renderDefaults.terrain.highlandColor}
spellcheck="false"
@change=${this.onHighlandColorChange}
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=${highlandColor}
@input=${this.onHighlandColorChange}
class="w-10 h-8 bg-transparent border border-slate-500 rounded-sm cursor-pointer"
/>
</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.mountain_color_label")}
</div>
<div class="text-sm text-slate-400">
${translateText("graphics_setting.mountain_color_desc")}
</div>
</div>
<input
type="text"
.value=${mountainColor}
placeholder=${renderDefaults.terrain.mountainColor}
spellcheck="false"
@change=${this.onMountainColorChange}
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=${mountainColor}
@input=${this.onMountainColorChange}
class="w-10 h-8 bg-transparent border border-slate-500 rounded-sm cursor-pointer"
/>
</div>
<div
class="flex gap-3 items-center w-full text-left p-3 hover:bg-slate-700 rounded-sm text-white transition-colors"
>
@@ -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
+12
View File
@@ -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
+4
View File
@@ -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;
+14 -10
View File
@@ -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 {
+16 -12
View File
@@ -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,
+5 -1
View File
@@ -17,7 +17,11 @@
"nameDebug": false
},
"terrain": {
"oceanColor": "#4785b5"
"oceanColor": "#4785b5",
"sandColor": "#CC9E9E",
"plainsColor": "#BECD8A",
"highlandColor": "#C8B78A",
"mountainColor": "#e6e6e6"
},
"falloutBloom": {
"broilSpeedCold": 0.0018,
+47 -21
View File
@@ -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;
}