refactor(glow): move small-player glow strength into graphics overrides (#4634)

## Summary

Moves the small-player glow strength from a standalone UserSettings key
(`settings.highlightGlowStrength`) into the graphics overrides blob
(`smallPlayerGlow.strength`), and moves its slider from the basic
settings modal into the **Effects** section of the graphics settings
modal.

The pass now reads strength from its live `RenderSettings` slice like
every other graphics knob, flowing through `applyGraphicsOverrides`.
This removes the dedicated push chain (ClientGameRunner listener →
MapRenderer → Renderer → pass setter) and MapRenderer's context-restore
reapply — strength survives WebGL context restores automatically now,
participates in the graphics modal's "Reset to defaults", and shows up
in the render debug GUI.

## Bug fix

The old slider had no visible effect below ~115%: strength only drove
the blur-iteration count (`round(strength ** 2.74)`, floored at 1 pass),
and the composite intensity (`alpha * pulse * sqrt(passes)`) never
factored strength directly — so 10% rendered identical to 100%.

Strength is now a linear opacity fade on a fixed-width aura:

- 100% = the aura's full brightness, 10% = barely visible, 0% = off
- Slider capped at 100% (the >100% aura-widening mechanism became
unreachable and is removed: `passesFor`, `lastPasses`, and the
blur-iteration loop)
- Default is 25%
- The pass clamps persisted values above 1 so stale settings from the
old 500% range can't over-brighten

## Notes

- Reuses the existing `user_setting.highlight_glow_strength_label` /
`highlight_small_players_desc` translation keys — no en.json change
- The legacy `settings.highlightGlowStrength` localStorage key is not
migrated (the slider only shipped in #4588 a few days ago); users reset
to the 25% default

## Test plan

- [x] `tests/GraphicsOverrides.test.ts` — schema validation + apply
coverage for the new override group
- [x] Full suite (171 tests), tsc, eslint all pass

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Evan
2026-07-17 19:09:07 -07:00
committed by GitHub
co-authored by Claude Fable 5
parent 1f97f2b252
commit 4544e18ce6
13 changed files with 116 additions and 140 deletions
-11
View File
@@ -572,17 +572,6 @@ async function createClientGame(
{ signal: graphicsListenerAbort.signal },
);
// Push the small-player glow Strength to the pass (which is a pure consumer)
// on the settings-changed event, so moving the slider updates the glow live
// even while the settings modal has the sim paused.
view.setSmallPlayerGlowStrength(userSettings.highlightGlowStrength());
globalThis.addEventListener(
`${USER_SETTINGS_CHANGED_EVENT}:settings.highlightGlowStrength`,
() =>
view.setSmallPlayerGlowStrength(userSettings.highlightGlowStrength()),
{ signal: graphicsListenerAbort.signal },
);
// Re-resolve names drawn on the map when the anonymous-names setting toggles
// so they switch live, like the leaderboard.
globalThis.addEventListener(
@@ -70,6 +70,12 @@ const RAIL_THICKNESS_MIN = 0.5;
const RAIL_THICKNESS_MAX = 3;
const RAIL_THICKNESS_STEP = 0.1;
// Small-player glow strength is shown as a percentage: 0% = off, 100% = the
// glow's full brightness.
const GLOW_STRENGTH_MIN = 0;
const GLOW_STRENGTH_MAX = 100;
const GLOW_STRENGTH_STEP = 5;
// "Ambient light" level shown to the player: 0 = no darkening (lighting off),
// 10 = darkest with the strongest glow. Mapped linearly onto the renderer's
// ambient value (1 = identity, AMBIENT_MIN = darkest).
@@ -597,6 +603,29 @@ export class GraphicsSettingsModal extends LitElement implements Controller {
this.patchPassEnabled({ fallout: !this.currentFallout() });
}
private patchSmallPlayerGlow(
patch: Partial<GraphicsOverrides["smallPlayerGlow"]>,
) {
const current = this.userSettings.graphicsOverrides();
this.userSettings.setGraphicsOverrides({
...current,
smallPlayerGlow: { ...current.smallPlayerGlow, ...patch },
});
this.requestUpdate();
}
private currentGlowStrength(): number {
return (
this.userSettings.graphicsOverrides().smallPlayerGlow?.strength ??
renderDefaults.smallPlayerGlow.strength
);
}
private onGlowStrengthChange(event: Event) {
const value = parseFloat((event.target as HTMLInputElement).value) / 100;
this.patchSmallPlayerGlow({ strength: value });
}
/** Whether colorblind mode is currently enabled. */
private currentColorblind(): boolean {
return (
@@ -679,6 +708,7 @@ export class GraphicsSettingsModal extends LitElement implements Controller {
const nukeColor = this.currentNukeColor();
const ambientLevel = this.currentAmbientLevel();
const unitGlow = this.currentUnitGlow();
const glowStrength = this.currentGlowStrength();
const colorblind = this.currentColorblind();
return html`
@@ -1426,6 +1456,31 @@ export class GraphicsSettingsModal extends LitElement implements Controller {
</div>
</button>
<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("user_setting.highlight_glow_strength_label")}
</div>
<div class="text-sm text-slate-400">
${translateText("user_setting.highlight_small_players_desc")}
</div>
<input
type="range"
min=${GLOW_STRENGTH_MIN}
max=${GLOW_STRENGTH_MAX}
step=${GLOW_STRENGTH_STEP}
.value=${String(glowStrength * 100)}
@input=${this.onGlowStrengthChange}
class="w-full border border-slate-500 rounded-lg"
/>
</div>
<div class="text-sm text-slate-400 w-12 text-right">
${Math.round(glowStrength * 100)}%
</div>
</div>
<div
class="px-3 py-1 text-xs font-semibold text-slate-400 uppercase tracking-wider mt-2"
>
-37
View File
@@ -19,7 +19,6 @@ import { ShowGraphicsSettingsModalEvent } from "./GraphicsSettingsModal";
const cursorPriceIcon = assetUrl("images/CursorPriceIconWhite.svg");
const emojiIcon = assetUrl("images/EmojiIconWhite.svg");
const exitIcon = assetUrl("images/ExitIconWhite.svg");
const highlightIcon = assetUrl("images/HighlightIconWhite.svg");
const mouseIcon = assetUrl("images/MouseIconWhite.svg");
const ninjaIcon = assetUrl("images/NinjaIconWhite.svg");
const settingsIcon = assetUrl("images/SettingIconWhite.svg");
@@ -205,12 +204,6 @@ export class SettingsModal extends LitElement implements Controller {
this.requestUpdate();
}
private onHighlightGlowStrengthChange(event: Event) {
const strength = parseFloat((event.target as HTMLInputElement).value) / 100;
this.userSettings.setHighlightGlowStrength(strength);
this.requestUpdate();
}
render() {
if (!this.isVisible) {
return null;
@@ -357,36 +350,6 @@ export class SettingsModal extends LitElement implements Controller {
</div>
</button>
<div
class="flex gap-3 items-center w-full text-left p-3 hover:bg-slate-700 rounded-sm text-white transition-colors"
>
<img
src=${highlightIcon}
alt="highlightGlowStrength"
width="20"
height="20"
/>
<div class="flex-1">
<div class="font-medium">
${translateText("user_setting.highlight_glow_strength_label")}
</div>
<div class="text-sm text-slate-400">
${translateText("user_setting.highlight_small_players_desc")}
</div>
<input
type="range"
min="0"
max="500"
.value=${this.userSettings.highlightGlowStrength() * 100}
@input=${this.onHighlightGlowStrengthChange}
class="w-full border border-slate-500 rounded-lg"
/>
</div>
<div class="text-sm text-slate-400">
${Math.round(this.userSettings.highlightGlowStrength() * 100)}%
</div>
</div>
<button
class="flex gap-3 items-center w-full text-left p-3 hover:bg-slate-700 rounded-sm text-white transition-colors"
@click="${this.onToggleAlertFrameButtonClick}"
@@ -41,6 +41,12 @@ export const GraphicsOverridesSchema = z
railThickness: z.number(),
})
.partial(),
smallPlayerGlow: z
.object({
// Aura around small players' territory: 0 = off, 1 = full brightness.
strength: z.number(),
})
.partial(),
passEnabled: z
.object({
fx: z.boolean(),
-12
View File
@@ -36,10 +36,6 @@ import type { RenderSettings } from "./RenderSettings";
export class MapRenderer {
private renderer: GPURenderer | null = null;
private resizeObs: ResizeObserver | null = null;
// Persisted so a WebGL context restore (which recreates GPURenderer via
// initRenderer) reapplies the user's chosen glow strength instead of
// resetting it to the pass default until the next settings change.
private smallPlayerGlowStrength = 1;
/**
* Called after a lost WebGL context is restored and the renderer has been
@@ -96,8 +92,6 @@ export class MapRenderer {
const rect = this.canvas.getBoundingClientRect();
if (rect.width > 0) this.renderer.resize(rect.width, rect.height);
// Reapply state that lives outside RenderSettings so it survives a restore.
this.renderer.setSmallPlayerGlowStrength(this.smallPlayerGlowStrength);
};
private handleContextLost = (e: Event) => {
@@ -254,12 +248,6 @@ export class MapRenderer {
this.renderer?.updateSmallPlayerGlow(set);
}
/** Set the small-player glow Strength (0 = off, 1 = default, capped at 5). */
setSmallPlayerGlowStrength(strength: number): void {
this.smallPlayerGlowStrength = strength;
this.renderer?.setSmallPlayerGlowStrength(strength);
}
// ---- Selection box ----
/** Set multiple selected units (multi-select). Pass [] to clear. */
+3
View File
@@ -85,6 +85,9 @@ export function applyGraphicsOverrides(
if (overrides.railroad?.railThickness !== undefined) {
settings.railroad.railThickness = overrides.railroad.railThickness;
}
if (overrides.smallPlayerGlow?.strength !== undefined) {
settings.smallPlayerGlow.strength = overrides.smallPlayerGlow.strength;
}
if (overrides.passEnabled?.fx !== undefined) {
settings.passEnabled.fx = overrides.passEnabled.fx;
}
+1
View File
@@ -389,6 +389,7 @@ export interface RenderSettings {
color: number[]; // RGB, each 01
alpha: number; // peak opacity (01)
pulseSpeed: number; // breath animation speed
strength: number; // opacity fade: 0 = off, 1 = full brightness (default 0.25)
};
altView: {
gridFontSize: number;
-4
View File
@@ -1013,10 +1013,6 @@ export class GPURenderer {
this.smallPlayerGlowPass.update(set);
}
setSmallPlayerGlowStrength(strength: number): void {
this.smallPlayerGlowPass.setGlowStrength(strength);
}
// ---------------------------------------------------------------------------
// Queries
// ---------------------------------------------------------------------------
@@ -57,8 +57,6 @@ export class SmallPlayerGlowPass {
private dirty = false; // aura needs rebuilding (set changed)
private animTime = 0;
private lastTime = 0;
private lastPasses = 1; // last width the aura was blurred at (to detect change)
private glowStrength = 1; // pushed via setGlowStrength; 0 = off, 1 = default, capped at 5
constructor(
gl: WebGL2RenderingContext,
@@ -123,16 +121,6 @@ export class SmallPlayerGlowPass {
this.quadVao = createFullscreenQuad(gl);
}
/**
* Push the glow Strength (0 = off, 1 = default, capped at 5). The client reads
* it from UserSettings and pushes it here so the pass stays a pure consumer
* (passes never read game/DOM state directly); pushing on the settings-changed
* event keeps it live even while the settings modal has the sim paused.
*/
setGlowStrength(strength: number): void {
this.glowStrength = strength;
}
/** Push the highlight set (1 byte per owner smallID), or null to disable. */
update(set: Uint8Array | null): void {
if (set === null) {
@@ -156,13 +144,6 @@ export class SmallPlayerGlowPass {
this.dirty = true;
}
// Strength (0 = off, 1 = default width) -> blur iterations; each iteration
// widens the Gaussian aura. Non-linear so the slider ramps gently low and
// grows fast toward the top: 500% ≈ 82 passes (very wide). Clamped for perf.
private passesFor(strength: number): number {
return Math.min(96, Math.max(1, Math.round(strength ** 2.74)));
}
// One separable-blur axis: sample `src`, write the blurred result into `dst`.
private blurAxis(
src: RenderTarget,
@@ -180,9 +161,11 @@ export class SmallPlayerGlowPass {
}
draw(cameraMatrix: Float32Array): void {
// Strength is pushed by the client (not tick-gated), so a slider change is
// live even while the sim is paused (e.g. the settings modal is open).
const strength = this.glowStrength;
// Strength is a graphics override read from the live settings slice each
// frame (not tick-gated), so a slider change is live even while the sim is
// paused (e.g. the graphics settings modal is open). Clamped to 1 so a
// value persisted from the old 500% range can't over-brighten.
const strength = Math.min(1, this.settings.strength);
if (!this.active || strength <= 0) return;
const gl = this.gl;
@@ -199,11 +182,6 @@ export class SmallPlayerGlowPass {
}
this.lastTime = now;
const pulse = 0.5 + 0.5 * Math.sin(this.animTime);
const passes = this.passesFor(strength);
if (passes !== this.lastPasses) {
this.dirty = true; // width changed -> rebuild the aura this frame
this.lastPasses = passes;
}
// Rebuild the blurred aura only when the set changed (~1/s); its inputs
// don't move faster than that. The composite below still runs every frame.
@@ -222,14 +200,11 @@ export class SmallPlayerGlowPass {
gl.drawArrays(gl.TRIANGLES, 0, 6);
});
// Separable blur, iterated `passes` times to widen the aura (more
// iterations = wider Gaussian). Each pass: horizontal A->B, vertical B->A.
// Separable blur: horizontal A->B, then vertical B->A.
gl.useProgram(this.blurProg);
gl.activeTexture(gl.TEXTURE0);
for (let k = 0; k < passes; k++) {
this.blurAxis(a, b, 1 / a.w, 0); // horizontal A->B
this.blurAxis(b, a, 0, 1 / b.h); // vertical B->A
}
this.blurAxis(a, b, 1 / a.w, 0); // horizontal A->B
this.blurAxis(b, a, 0, 1 / b.h); // vertical B->A
this.dirty = false;
}
@@ -243,9 +218,9 @@ export class SmallPlayerGlowPass {
gl.uniformMatrix3fv(this.uCompositeCam, false, cameraMatrix);
gl.uniform2f(this.uCompositeMapSize, this.mapW, this.mapH);
gl.uniform3fv(this.uGlowColor, s.color);
// Widening spreads the mask thinner, so scale intensity up to keep the
// glow about as bright (wider, not darker), capped by the shader's alpha.
gl.uniform1f(this.uIntensity, s.alpha * pulse * Math.sqrt(passes));
// Strength fades the glow linearly: 1 = the aura's full alpha, 0.1 =
// barely visible.
gl.uniform1f(this.uIntensity, s.alpha * pulse * strength);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, a.tex);
gl.bindVertexArray(this.mapVao);
+2 -1
View File
@@ -335,7 +335,8 @@
"smallPlayerGlow": {
"color": [1.0, 0.12, 0.1],
"alpha": 0.9,
"pulseSpeed": 0.003
"pulseSpeed": 0.003,
"strength": 0.25
},
"altView": {
"gridFontSize": 24,
-11
View File
@@ -140,13 +140,6 @@ export class UserSettings {
return this.getBool("settings.emojis", true);
}
highlightGlowStrength() {
// 0 = off, 1 = default; capped at 5 (the 500% slider max) so a value
// persisted from an older, larger range can't display/apply above it.
const v = this.getFloat("settings.highlightGlowStrength", 1);
return Math.min(5, Math.max(0, v));
}
performanceOverlay() {
return this.getBool(PERFORMANCE_OVERLAY_KEY, false);
}
@@ -199,10 +192,6 @@ export class UserSettings {
this.setBool("settings.emojis", !this.emojis());
}
setHighlightGlowStrength(value: number) {
this.setFloat("settings.highlightGlowStrength", value);
}
// Performance overlay specifically needs a direct setter for Shift-D
setPerformanceOverlay(value: boolean) {
this.setBool(PERFORMANCE_OVERLAY_KEY, value);
+38
View File
@@ -74,6 +74,17 @@ describe("GraphicsOverridesSchema", () => {
}
});
test("accepts partial smallPlayerGlow overrides", () => {
const cases = [
{ smallPlayerGlow: {} },
{ smallPlayerGlow: { strength: 0 } },
{ smallPlayerGlow: { strength: 0.5 } },
];
for (const c of cases) {
expect(GraphicsOverridesSchema.safeParse(c).success).toBe(true);
}
});
test("accepts partial lighting overrides", () => {
const cases = [
{ lighting: {} },
@@ -128,6 +139,11 @@ describe("GraphicsOverridesSchema", () => {
railroad: { railThickness: "wide" },
}).success,
).toBe(false);
expect(
GraphicsOverridesSchema.safeParse({
smallPlayerGlow: { strength: "strong" },
}).success,
).toBe(false);
expect(
GraphicsOverridesSchema.safeParse({
lighting: { ambient: "dark" },
@@ -374,6 +390,28 @@ describe("applyGraphicsOverrides", () => {
expect(z.railThickness).toBe(defaults.railThickness);
});
test("applies smallPlayerGlow strength override (including 0 = off)", () => {
expect(
gen({ smallPlayerGlow: { strength: 0.5 } }).smallPlayerGlow.strength,
).toBe(0.5);
expect(
gen({ smallPlayerGlow: { strength: 0 } }).smallPlayerGlow.strength,
).toBe(0);
});
test("smallPlayerGlow strength absent → keeps default of 0.25", () => {
expect(gen({}).smallPlayerGlow.strength).toBe(0.25);
expect(gen({ smallPlayerGlow: {} }).smallPlayerGlow.strength).toBe(0.25);
});
test("smallPlayerGlow override leaves other glow fields at defaults", () => {
const defaults = createRenderSettings().smallPlayerGlow;
const g = gen({ smallPlayerGlow: { strength: 0.25 } }).smallPlayerGlow;
expect(g.alpha).toBe(defaults.alpha);
expect(g.pulseSpeed).toBe(defaults.pulseSpeed);
expect(g.color).toEqual(defaults.color);
});
test("ambient < 1 sets ambient and enables the lighting pass", () => {
const l = gen({ lighting: { ambient: 0.5 } }).lighting;
expect(l.ambient).toBe(0.5);
-28
View File
@@ -58,31 +58,3 @@ describe("UserSettings effect selection", () => {
expect(s.getSelectedEffectName("hydro")).toBe("hydro_boom");
});
});
describe("UserSettings highlight glow strength", () => {
beforeEach(() => {
localStorage.clear();
(
UserSettings as unknown as { cache: Map<string, string | null> }
).cache.clear();
});
it("defaults to 1 (100%, on)", () => {
expect(new UserSettings().highlightGlowStrength()).toBe(1);
});
it("persists a set value, including 0 (off)", () => {
const s = new UserSettings();
s.setHighlightGlowStrength(2.5);
expect(s.highlightGlowStrength()).toBe(2.5);
s.setHighlightGlowStrength(0);
expect(s.highlightGlowStrength()).toBe(0);
});
it("shares state across instances via the static cache", () => {
// The settings modal and the renderer's frame builder each hold their own
// UserSettings; a change in one must be visible to the other.
new UserSettings().setHighlightGlowStrength(3);
expect(new UserSettings().highlightGlowStrength()).toBe(3);
});
});