Update & refactor dark mode (#4114)

## Description:

- The renderer no longer knows what "dark mode" is.
`RenderSettings.dayNight.mode` (`"light" | "dark"`) is gone — passes
read neutral values (`lighting.ambient: number`, `lighting.enabled:
boolean`).
- `render-settings.json` holds the light-mode baseline. Dark mode is
just another override layer, applied the same way as graphics settings
(`darkNames`, `classicIcons`, etc.).
- New `src/client/render/gl/RenderOverrides.ts` exposes two in-place
mutators with matching shapes:
- `applyGraphicsOverrides(settings, overrides)` — replaces the old
`generateRenderSettings`
  - `applyDarkModeOverride(settings, isDark)`
- `ClientGameRunner` regenerates the live settings each time the user
setting changes via `deepAssign(live, createRenderSettings())` + the
override chain. No per-slice copy list, no intermediate object — adding
a new override that touches a new section just works.
- Renamed `dayNight` → `lighting`; collapsed `nightAmbient`/`dayAmbient`
into single `ambient`; renamed `enableLightCompositing` → `enabled`.
- Bumped dark-mode ambient from 0.15 → 0.35 so terrain stays readable.

<img width="1250" height="846" alt="Screenshot 2026-06-02 at 11 47
28 AM"
src="https://github.com/user-attachments/assets/b41e8ffb-6011-4ba0-9e1f-c2a21ff90794"
/>

## 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
- [x] 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:

evan
This commit is contained in:
Evan
2026-06-02 11:48:52 -07:00
committed by GitHub
parent 2386b4b38a
commit f1045a2022
12 changed files with 125 additions and 125 deletions
+15 -21
View File
@@ -69,8 +69,11 @@ import { createCanvas } from "./Utils";
import { WebGLFrameBuilder } from "./WebGLFrameBuilder";
import { createRenderer, GameRenderer } from "./hud/GameRenderer";
import {
applyDarkModeOverride,
applyGraphicsOverrides,
createDebugGui,
generateRenderSettings,
createRenderSettings,
deepAssign,
GameView as WebGLGameView,
} from "./render/gl";
import { ALL_UNIT_TYPES, UnitState } from "./render/types";
@@ -474,18 +477,6 @@ async function createClientGame(
config,
);
// Bind the WebGL renderer's day/night mode to the existing darkMode
// UserSetting so the in-game map matches the rest of the UI. Initial
// apply + live updates via the per-key settings-changed event.
const applyDayNightMode = (isDark: boolean): void => {
view.getSettings().dayNight.mode = isDark ? "dark" : "light";
};
applyDayNightMode(userSettings.darkMode());
globalThis.addEventListener(
`${USER_SETTINGS_CHANGED_EVENT}:${DARK_MODE_KEY}`,
(e) => applyDayNightMode((e as CustomEvent<string>).detail === "true"),
);
view.setShowPatterns(userSettings.territoryPatterns());
globalThis.addEventListener(
`${USER_SETTINGS_CHANGED_EVENT}:settings.territoryPatterns`,
@@ -493,18 +484,21 @@ async function createClientGame(
);
const graphicsListenerAbort = new AbortController();
const applyGraphicsOverrides = (): void => {
const generated = generateRenderSettings(
userSettings.graphicsOverrides(),
);
const regenerateRenderSettings = (): void => {
const live = view.getSettings();
Object.assign(live.name, generated.name);
Object.assign(live.structure, generated.structure);
deepAssign(live, createRenderSettings());
applyGraphicsOverrides(live, userSettings.graphicsOverrides());
applyDarkModeOverride(live, userSettings.darkMode());
};
applyGraphicsOverrides();
regenerateRenderSettings();
globalThis.addEventListener(
`${USER_SETTINGS_CHANGED_EVENT}:${GRAPHICS_KEY}`,
applyGraphicsOverrides,
regenerateRenderSettings,
{ signal: graphicsListenerAbort.signal },
);
globalThis.addEventListener(
`${USER_SETTINGS_CHANGED_EVENT}:${DARK_MODE_KEY}`,
regenerateRenderSettings,
{ signal: graphicsListenerAbort.signal },
);
+48
View File
@@ -0,0 +1,48 @@
import type { GraphicsOverrides } from "./GraphicsOverrides";
import type { RenderSettings } from "./RenderSettings";
const DARK_AMBIENT = 0.35;
export function applyGraphicsOverrides(
settings: RenderSettings,
overrides: GraphicsOverrides,
): void {
if (overrides.name?.nameScaleFactor !== undefined) {
settings.name.nameScaleFactor = overrides.name.nameScaleFactor;
}
if (overrides.name?.cullThreshold !== undefined) {
settings.name.cullThreshold = overrides.name.cullThreshold;
}
if (overrides.structure?.classicIcons === true) {
// Classic look: lighter player-colored shape behind a dark icon glyph,
// with a touch of translucency.
settings.structure.borderDarken = 0.7;
settings.structure.fillDarken = 1.0;
settings.structure.iconR = 0;
settings.structure.iconG = 0;
settings.structure.iconB = 0;
settings.structure.iconAlpha = 0.75;
}
if (overrides.name?.darkNames !== undefined) {
const dark = overrides.name.darkNames;
// Dark: black fill + player-colored outline. Force outline RGB to black
// so the shader's defaultFill ramp (mix(uOutlineColor, black, fillT))
// collapses to pure black regardless of ambient.
// Colored: player-colored fill + white outline (defaults from JSON).
settings.name.fillUsePlayerColor = !dark;
settings.name.outlineUsePlayerColor = dark;
const channel = dark ? 0 : 1;
settings.name.outlineR = channel;
settings.name.outlineG = channel;
settings.name.outlineB = channel;
}
}
export function applyDarkModeOverride(
settings: RenderSettings,
isDark: boolean,
): void {
if (!isDark) return;
settings.lighting.ambient = DARK_AMBIENT;
settings.lighting.enabled = true;
}
+3 -45
View File
@@ -1,4 +1,3 @@
import { GraphicsOverrides } from "./GraphicsOverrides";
import defaults from "./render-settings.json";
export interface RenderSettings {
@@ -47,10 +46,9 @@ export interface RenderSettings {
particleStrength: number;
particleFreshScale: number;
};
dayNight: {
mode: "light" | "dark";
nightAmbient: number;
dayAmbient: number;
lighting: {
ambient: number;
enabled: boolean;
falloffPower: number;
falloutLightR: number;
falloutLightG: number;
@@ -296,46 +294,6 @@ export function createRenderSettings(): RenderSettings {
return JSON.parse(JSON.stringify(defaults)) as RenderSettings;
}
/**
* Generate a fresh RenderSettings by layering user overrides on top of the
* render-settings.json defaults. Pure — does not mutate any input.
*/
export function generateRenderSettings(
overrides: GraphicsOverrides,
): RenderSettings {
const settings = createRenderSettings();
if (overrides.name?.nameScaleFactor !== undefined) {
settings.name.nameScaleFactor = overrides.name.nameScaleFactor;
}
if (overrides.name?.cullThreshold !== undefined) {
settings.name.cullThreshold = overrides.name.cullThreshold;
}
if (overrides.structure?.classicIcons === true) {
// Classic look: lighter player-colored shape behind a dark icon glyph,
// with a touch of translucency.
settings.structure.borderDarken = 0.7;
settings.structure.fillDarken = 1.0;
settings.structure.iconR = 0;
settings.structure.iconG = 0;
settings.structure.iconB = 0;
settings.structure.iconAlpha = 0.75;
}
if (overrides.name?.darkNames !== undefined) {
const dark = overrides.name.darkNames;
// Dark: black fill + player-colored outline. Force outline RGB to black
// so the shader's defaultFill ramp (mix(uOutlineColor, black, fillT))
// collapses to pure black regardless of ambient.
// Colored: player-colored fill + white outline (defaults from JSON).
settings.name.fillUsePlayerColor = !dark;
settings.name.outlineUsePlayerColor = dark;
const channel = dark ? 0 : 1;
settings.name.outlineR = channel;
settings.name.outlineG = channel;
settings.name.outlineB = channel;
}
return settings;
}
/** Dump current settings to a downloadable JSON file. */
export function dumpSettings(settings: RenderSettings): void {
const json = JSON.stringify(settings, null, 2);
+4 -4
View File
@@ -1208,9 +1208,9 @@ export class GPURenderer {
const zoom = this.camera.zoom;
const cw = this.canvas.width;
const ch = this.canvas.height;
const nightActive = this.isNightActive();
const compositingActive = this.isLightCompositingActive();
if (nightActive) {
if (compositingActive) {
this.resizeSceneTargetIfNeeded(cw, ch);
const sceneTex = toTarget(this.gl, this.sceneTarget, () =>
this.drawBaseLayer(cam),
@@ -1226,8 +1226,8 @@ export class GPURenderer {
this.renderOverlays(cam, zoom);
}
private isNightActive(): boolean {
return this.settings.dayNight.mode === "dark";
private isLightCompositingActive(): boolean {
return this.settings.lighting.enabled;
}
private resizeSceneTargetIfNeeded(cw: number, ch: number): void {
+13 -15
View File
@@ -2,7 +2,6 @@ import type { RenderSettings } from "../RenderSettings";
import type { DebugNode } from "./Folder";
import { folder } from "./Folder";
import { color } from "./props/Color";
import { select } from "./props/Select";
import { slider } from "./props/Slider";
import { toggle } from "./props/Toggle";
@@ -90,30 +89,29 @@ export function buildTree(s: RenderSettings, d: RenderSettings): DebugNode[] {
slider(s.falloutBloom, "particleFreshScale", d.falloutBloom, 0, 1, 0.01),
]),
folder("Day / Night", [
select(s.dayNight, "mode", d.dayNight, ["light", "dark"], "Mode"),
slider(s.dayNight, "nightAmbient", d.dayNight, 0, 1, 0.01),
slider(s.dayNight, "dayAmbient", d.dayNight, 0, 1, 0.01),
slider(s.dayNight, "falloffPower", d.dayNight, 0.5, 5, 0.1),
slider(s.dayNight, "falloutLightIntensity", d.dayNight, 0, 20, 0.1),
slider(s.dayNight, "falloutLightThreshold", d.dayNight, 0, 0.5, 0.001),
slider(s.dayNight, "blurZoomDivisor", d.dayNight, 1, 20, 0.5),
slider(s.dayNight, "lightRadiusMultiplier", d.dayNight, 0.1, 5, 0.1),
folder("Lighting", [
toggle(s.lighting, "enabled", d.lighting),
slider(s.lighting, "ambient", d.lighting, 0, 1, 0.01),
slider(s.lighting, "falloffPower", d.lighting, 0.5, 5, 0.1),
slider(s.lighting, "falloutLightIntensity", d.lighting, 0, 20, 0.1),
slider(s.lighting, "falloutLightThreshold", d.lighting, 0, 0.5, 0.001),
slider(s.lighting, "blurZoomDivisor", d.lighting, 1, 20, 0.5),
slider(s.lighting, "lightRadiusMultiplier", d.lighting, 0.1, 5, 0.1),
color(
s.dayNight,
s.lighting,
"falloutLightR",
"falloutLightG",
"falloutLightB",
d.dayNight,
d.lighting,
"Fallout Light Color",
),
slider(s.dayNight, "emberLightIntensity", d.dayNight, 0, 20, 0.1),
slider(s.lighting, "emberLightIntensity", d.lighting, 0, 20, 0.1),
color(
s.dayNight,
s.lighting,
"emberLightR",
"emberLightG",
"emberLightB",
d.dayNight,
d.lighting,
"Ember Light Color",
),
]),
+4 -4
View File
@@ -13,10 +13,10 @@ export { GraphicsOverridesSchema } from "./GraphicsOverrides";
export type { GraphicsOverrides } from "./GraphicsOverrides";
export type { SpawnCenter } from "./passes/SpawnOverlayPass";
export {
createRenderSettings,
dumpSettings,
generateRenderSettings,
} from "./RenderSettings";
applyDarkModeOverride,
applyGraphicsOverrides,
} from "./RenderOverrides";
export { createRenderSettings, dumpSettings } from "./RenderSettings";
export type { RenderSettings } from "./RenderSettings";
export { deepAssign, deepDiff } from "./SettingsUtils";
export { buildTerrainRGBA, getPaletteSize } from "./utils/ColorUtils";
@@ -192,7 +192,7 @@ export class FalloutLightPass {
tick: number,
): void {
const gl = this.gl;
const dn = this.settings.dayNight;
const dn = this.settings.lighting;
const fb = this.settings.falloutBloom;
// Step 1: Extract fallout light in tile space
+1 -1
View File
@@ -166,7 +166,7 @@ export class LightmapPass {
const zoom = Math.abs(cameraMatrix[0]);
const mapSize = Math.max(this.mapW, this.mapH);
const blurScale = Math.min(
(zoom * mapSize) / this.settings.dayNight.blurZoomDivisor,
(zoom * mapSize) / this.settings.lighting.blurZoomDivisor,
1.0,
);
@@ -47,8 +47,7 @@ export class NightCompositePass {
// -------------------------------------------------------------------------
getAmbient(): number {
const dn = this.settings.dayNight;
return dn.mode === "dark" ? dn.nightAmbient : dn.dayAmbient;
return this.settings.lighting.ambient;
}
// -------------------------------------------------------------------------
@@ -207,7 +207,7 @@ export class PointLightPass {
if (this.lightCount === 0) return;
const gl = this.gl;
const dn = this.settings.dayNight;
const dn = this.settings.lighting;
gl.useProgram(this.lightProg);
gl.uniformMatrix3fv(this.uLightCam, false, cameraMatrix);
+3 -4
View File
@@ -44,10 +44,9 @@
"particleStrength": 1,
"particleFreshScale": 0.2
},
"dayNight": {
"mode": "light",
"nightAmbient": 0.15,
"dayAmbient": 1,
"lighting": {
"ambient": 1,
"enabled": false,
"falloffPower": 2,
"falloutLightR": 0.15,
"falloutLightG": 0.95,
+31 -27
View File
@@ -1,9 +1,16 @@
import { describe, expect, test } from "vitest";
import { GraphicsOverridesSchema } from "../src/client/render/gl/GraphicsOverrides";
import {
createRenderSettings,
generateRenderSettings,
} from "../src/client/render/gl/RenderSettings";
GraphicsOverrides,
GraphicsOverridesSchema,
} from "../src/client/render/gl/GraphicsOverrides";
import { applyGraphicsOverrides } from "../src/client/render/gl/RenderOverrides";
import { createRenderSettings } from "../src/client/render/gl/RenderSettings";
function gen(overrides: GraphicsOverrides) {
const settings = createRenderSettings();
applyGraphicsOverrides(settings, overrides);
return settings;
}
describe("GraphicsOverridesSchema", () => {
test("accepts empty object", () => {
@@ -51,16 +58,16 @@ describe("GraphicsOverridesSchema", () => {
});
});
describe("generateRenderSettings", () => {
describe("applyGraphicsOverrides", () => {
test("with empty overrides matches createRenderSettings defaults", () => {
const fromGen = generateRenderSettings({});
const fromGen = gen({});
const fromCreate = createRenderSettings();
expect(fromGen).toEqual(fromCreate);
});
test("returns a fresh object each call (no shared mutation)", () => {
const a = generateRenderSettings({});
const b = generateRenderSettings({});
const a = gen({});
const b = gen({});
expect(a).not.toBe(b);
expect(a.name).not.toBe(b.name);
a.name.nameScaleFactor = 999;
@@ -70,27 +77,24 @@ describe("generateRenderSettings", () => {
test("does not mutate the overrides input", () => {
const overrides = { name: { darkNames: true as const } };
const snapshot = JSON.parse(JSON.stringify(overrides));
generateRenderSettings(overrides);
gen(overrides);
expect(overrides).toEqual(snapshot);
});
test("applies nameScaleFactor override", () => {
const settings = generateRenderSettings({ name: { nameScaleFactor: 1.3 } });
const settings = gen({ name: { nameScaleFactor: 1.3 } });
expect(settings.name.nameScaleFactor).toBe(1.3);
});
test("applies cullThreshold override (including 0)", () => {
expect(
generateRenderSettings({ name: { cullThreshold: 0.03 } }).name
.cullThreshold,
).toBe(0.03);
expect(
generateRenderSettings({ name: { cullThreshold: 0 } }).name.cullThreshold,
).toBe(0);
expect(gen({ name: { cullThreshold: 0.03 } }).name.cullThreshold).toBe(
0.03,
);
expect(gen({ name: { cullThreshold: 0 } }).name.cullThreshold).toBe(0);
});
test("darkNames=true → black fill + player-colored outline + outline RGB 0", () => {
const s = generateRenderSettings({ name: { darkNames: true } }).name;
const s = gen({ name: { darkNames: true } }).name;
expect(s.fillUsePlayerColor).toBe(false);
expect(s.outlineUsePlayerColor).toBe(true);
expect(s.outlineR).toBe(0);
@@ -99,7 +103,7 @@ describe("generateRenderSettings", () => {
});
test("darkNames=false → player-colored fill + white outline + outline RGB 1", () => {
const s = generateRenderSettings({ name: { darkNames: false } }).name;
const s = gen({ name: { darkNames: false } }).name;
expect(s.fillUsePlayerColor).toBe(true);
expect(s.outlineUsePlayerColor).toBe(false);
expect(s.outlineR).toBe(1);
@@ -109,13 +113,13 @@ describe("generateRenderSettings", () => {
test("only-darkNames override leaves nameScale/cull at defaults", () => {
const defaults = createRenderSettings().name;
const s = generateRenderSettings({ name: { darkNames: true } }).name;
const s = gen({ name: { darkNames: true } }).name;
expect(s.nameScaleFactor).toBe(defaults.nameScaleFactor);
expect(s.cullThreshold).toBe(defaults.cullThreshold);
});
test("combined overrides all apply together", () => {
const s = generateRenderSettings({
const s = gen({
name: { nameScaleFactor: 0.9, cullThreshold: 0.01, darkNames: true },
}).name;
expect(s.nameScaleFactor).toBe(0.9);
@@ -127,16 +131,16 @@ describe("generateRenderSettings", () => {
test("settings outside the name slice are untouched by name overrides", () => {
const defaults = createRenderSettings();
const s = generateRenderSettings({
const s = gen({
name: { nameScaleFactor: 0.6, darkNames: true },
});
expect(s.passEnabled).toEqual(defaults.passEnabled);
expect(s.dayNight).toEqual(defaults.dayNight);
expect(s.lighting).toEqual(defaults.lighting);
expect(s.structure).toEqual(defaults.structure);
});
test("classicIcons=true → light shape + dark icon + 0.75 alpha", () => {
const s = generateRenderSettings({
const s = gen({
structure: { classicIcons: true },
}).structure;
// Shape (circle behind) is mostly player color, lightly darkened.
@@ -152,14 +156,14 @@ describe("generateRenderSettings", () => {
test("classicIcons=false or absent → keeps render-settings.json defaults (fully opaque)", () => {
const defaults = createRenderSettings().structure;
const off = generateRenderSettings({
const off = gen({
structure: { classicIcons: false },
}).structure;
expect(off.borderDarken).toBe(defaults.borderDarken);
expect(off.fillDarken).toBe(defaults.fillDarken);
expect(off.iconR).toBe(defaults.iconR);
expect(off.iconAlpha).toBe(1);
const absent = generateRenderSettings({ structure: {} }).structure;
const absent = gen({ structure: {} }).structure;
expect(absent.borderDarken).toBe(defaults.borderDarken);
expect(absent.fillDarken).toBe(defaults.fillDarken);
expect(absent.iconR).toBe(defaults.iconR);
@@ -167,7 +171,7 @@ describe("generateRenderSettings", () => {
});
test("classicIcons + name overrides compose independently", () => {
const s = generateRenderSettings({
const s = gen({
name: { darkNames: true, nameScaleFactor: 0.9 },
structure: { classicIcons: true },
});