From f1045a2022df41556871bd531ff898bcb2e858c0 Mon Sep 17 00:00:00 2001 From: Evan Date: Tue, 2 Jun 2026 11:48:52 -0700 Subject: [PATCH] Update & refactor dark mode (#4114) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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. Screenshot 2026-06-02 at 11 47
28 AM ## 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 --- src/client/ClientGameRunner.ts | 36 +++++------- src/client/render/gl/RenderOverrides.ts | 48 +++++++++++++++ src/client/render/gl/RenderSettings.ts | 48 +-------------- src/client/render/gl/Renderer.ts | 8 +-- src/client/render/gl/debug/Layout.ts | 28 +++++---- src/client/render/gl/index.ts | 8 +-- .../render/gl/passes/FalloutLightPass.ts | 2 +- src/client/render/gl/passes/LightmapPass.ts | 2 +- .../render/gl/passes/NightCompositePass.ts | 3 +- src/client/render/gl/passes/PointLightPass.ts | 2 +- src/client/render/gl/render-settings.json | 7 +-- tests/GraphicsOverrides.test.ts | 58 ++++++++++--------- 12 files changed, 125 insertions(+), 125 deletions(-) create mode 100644 src/client/render/gl/RenderOverrides.ts diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index 0ac688062..808ca0ed1 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -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).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 }, ); diff --git a/src/client/render/gl/RenderOverrides.ts b/src/client/render/gl/RenderOverrides.ts new file mode 100644 index 000000000..39f3f565f --- /dev/null +++ b/src/client/render/gl/RenderOverrides.ts @@ -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; +} diff --git a/src/client/render/gl/RenderSettings.ts b/src/client/render/gl/RenderSettings.ts index 7ea731b8b..71c423868 100644 --- a/src/client/render/gl/RenderSettings.ts +++ b/src/client/render/gl/RenderSettings.ts @@ -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); diff --git a/src/client/render/gl/Renderer.ts b/src/client/render/gl/Renderer.ts index 360b85337..25fb47e43 100644 --- a/src/client/render/gl/Renderer.ts +++ b/src/client/render/gl/Renderer.ts @@ -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 { diff --git a/src/client/render/gl/debug/Layout.ts b/src/client/render/gl/debug/Layout.ts index 831f4465a..c5c6dc01c 100644 --- a/src/client/render/gl/debug/Layout.ts +++ b/src/client/render/gl/debug/Layout.ts @@ -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", ), ]), diff --git a/src/client/render/gl/index.ts b/src/client/render/gl/index.ts index 45c8ed497..034cb05e2 100644 --- a/src/client/render/gl/index.ts +++ b/src/client/render/gl/index.ts @@ -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"; diff --git a/src/client/render/gl/passes/FalloutLightPass.ts b/src/client/render/gl/passes/FalloutLightPass.ts index c5266d8d2..6e82f75f3 100644 --- a/src/client/render/gl/passes/FalloutLightPass.ts +++ b/src/client/render/gl/passes/FalloutLightPass.ts @@ -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 diff --git a/src/client/render/gl/passes/LightmapPass.ts b/src/client/render/gl/passes/LightmapPass.ts index aa2c3086b..5994b1fc3 100644 --- a/src/client/render/gl/passes/LightmapPass.ts +++ b/src/client/render/gl/passes/LightmapPass.ts @@ -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, ); diff --git a/src/client/render/gl/passes/NightCompositePass.ts b/src/client/render/gl/passes/NightCompositePass.ts index 13e956e68..05ed84f21 100644 --- a/src/client/render/gl/passes/NightCompositePass.ts +++ b/src/client/render/gl/passes/NightCompositePass.ts @@ -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; } // ------------------------------------------------------------------------- diff --git a/src/client/render/gl/passes/PointLightPass.ts b/src/client/render/gl/passes/PointLightPass.ts index bbc0abea5..21939af81 100644 --- a/src/client/render/gl/passes/PointLightPass.ts +++ b/src/client/render/gl/passes/PointLightPass.ts @@ -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); diff --git a/src/client/render/gl/render-settings.json b/src/client/render/gl/render-settings.json index 06d17c894..3a7777bf2 100644 --- a/src/client/render/gl/render-settings.json +++ b/src/client/render/gl/render-settings.json @@ -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, diff --git a/tests/GraphicsOverrides.test.ts b/tests/GraphicsOverrides.test.ts index 019bd26ff..aedebc77b 100644 --- a/tests/GraphicsOverrides.test.ts +++ b/tests/GraphicsOverrides.test.ts @@ -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 }, });