feat(highlight): replace the small-player highlight toggle with a strength slider (#4588)

## Description:

Replaces the "Highlight small players" on/off toggle with a **0-500%
Strength slider** controlling the glow's **width** (0 = off, 100% = the
previous default).

- `UserSettings`: `highlightSmallPlayers()`/toggle →
`highlightGlowStrength()`/set, float-backed, clamped to [0, 5].
- `SmallPlayerGlowPass` takes the strength via a setter (pushed by
`ClientGameRunner` on the settings-changed event), so it stays a pure
consumer and the slider still updates live while the settings modal has
the sim paused. Width = a non-linear blur-iteration count; intensity
compensated so wider stays about as bright.
- `MapRenderer` persists the strength across a WebGL context restore.
- `SettingsModal`: the toggle row becomes a range input.

Behavior note: with the toggle gone the glow **defaults ON at 100%**
(old toggle defaulted OFF); can default to 0 (opt-in) if preferred.

## 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
This commit is contained in:
Zixer1
2026-07-13 11:07:22 -07:00
committed by GitHub
parent e76b34be22
commit d07f7d46dd
9 changed files with 120 additions and 50 deletions
+11
View File
@@ -572,6 +572,17 @@ 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(
+2 -3
View File
@@ -14,7 +14,6 @@ import {
} from "../core/CosmeticSchemas";
import { decodePatternData } from "../core/PatternDecoder";
import { PlayerType } from "../core/game/Game";
import { UserSettings } from "../core/game/UserSettings";
import { getCachedCosmetics } from "./Cosmetics";
import { uploadFrameData } from "./render/frame/Upload";
// Type-only: a value import would pull GPURenderer and its `.glsl?raw` shader
@@ -197,7 +196,6 @@ export class WebGLFrameBuilder {
}
private readonly highlightSetBuf = new Uint8Array(PALETTE_SIZE);
private readonly userSettings = new UserSettings();
private glowRescanTick = 0;
update(gameView: GameView): void {
@@ -350,8 +348,9 @@ export class WebGLFrameBuilder {
* Client-only view — toggle it live in the settings.
*/
private syncSmallPlayerGlow(gameView: GameView): void {
// Strength (incl. off at 0) is read live in the glow pass; here we only
// decide who qualifies. Skip spawn + the first minute.
if (
!this.userSettings.highlightSmallPlayers() ||
gameView.inSpawnPhase() ||
gameView.elapsedGameSeconds() < SMALL_PLAYER_GLOW_GRACE_SECONDS
) {
+19 -13
View File
@@ -135,11 +135,6 @@ export class SettingsModal extends LitElement implements Controller {
this.requestUpdate();
}
private onToggleHighlightSmallPlayersButtonClick() {
this.userSettings.toggleHighlightSmallPlayers();
this.requestUpdate();
}
private onToggleAlertFrameButtonClick() {
this.userSettings.toggleAlertFrame();
this.requestUpdate();
@@ -210,6 +205,12 @@ 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;
@@ -356,30 +357,35 @@ export class SettingsModal extends LitElement implements Controller {
</div>
</button>
<button
<div
class="flex gap-3 items-center w-full text-left p-3 hover:bg-slate-700 rounded-sm text-white transition-colors"
@click="${this.onToggleHighlightSmallPlayersButtonClick}"
>
<img
src=${highlightIcon}
alt="highlightSmallPlayers"
alt="highlightGlowStrength"
width="20"
height="20"
/>
<div class="flex-1">
<div class="font-medium">
${translateText("user_setting.highlight_small_players_label")}
${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">
${this.userSettings.highlightSmallPlayers()
? translateText("user_setting.on")
: translateText("user_setting.off")}
${Math.round(this.userSettings.highlightGlowStrength() * 100)}%
</div>
</button>
</div>
<button
class="flex gap-3 items-center w-full text-left p-3 hover:bg-slate-700 rounded-sm text-white transition-colors"
+12
View File
@@ -35,6 +35,10 @@ 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
@@ -91,6 +95,8 @@ 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) => {
@@ -244,6 +250,12 @@ 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. */
+4
View File
@@ -1002,6 +1002,10 @@ export class GPURenderer {
this.smallPlayerGlowPass.update(set);
}
setSmallPlayerGlowStrength(strength: number): void {
this.smallPlayerGlowPass.setGlowStrength(strength);
}
// ---------------------------------------------------------------------------
// Queries
// ---------------------------------------------------------------------------
@@ -57,6 +57,8 @@ 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,
@@ -121,6 +123,16 @@ 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) {
@@ -144,8 +156,34 @@ 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,
dst: RenderTarget,
dx: number,
dy: number,
): void {
const gl = this.gl;
toTarget(gl, dst, () => {
gl.uniform2f(this.uBlurDir, dx, dy);
gl.bindTexture(gl.TEXTURE_2D, src.tex);
gl.bindVertexArray(this.quadVao);
gl.drawArrays(gl.TRIANGLES, 0, 6);
});
}
draw(cameraMatrix: Float32Array): void {
if (!this.active) return;
// 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;
if (!this.active || strength <= 0) return;
const gl = this.gl;
const s = this.settings;
@@ -161,6 +199,11 @@ 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.
@@ -179,21 +222,14 @@ export class SmallPlayerGlowPass {
gl.drawArrays(gl.TRIANGLES, 0, 6);
});
// Separable blur: horizontal into B, vertical back into A.
// Separable blur, iterated `passes` times to widen the aura (more
// iterations = wider Gaussian). Each pass: horizontal A->B, vertical B->A.
gl.useProgram(this.blurProg);
gl.activeTexture(gl.TEXTURE0);
toTarget(gl, b, () => {
gl.uniform2f(this.uBlurDir, 1 / a.w, 0);
gl.bindTexture(gl.TEXTURE_2D, a.tex);
gl.bindVertexArray(this.quadVao);
gl.drawArrays(gl.TRIANGLES, 0, 6);
});
toTarget(gl, a, () => {
gl.uniform2f(this.uBlurDir, 0, 1 / b.h);
gl.bindTexture(gl.TEXTURE_2D, b.tex);
gl.bindVertexArray(this.quadVao);
gl.drawArrays(gl.TRIANGLES, 0, 6);
});
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.dirty = false;
}
@@ -207,7 +243,9 @@ export class SmallPlayerGlowPass {
gl.uniformMatrix3fv(this.uCompositeCam, false, cameraMatrix);
gl.uniform2f(this.uCompositeMapSize, this.mapW, this.mapH);
gl.uniform3fv(this.uGlowColor, s.color);
gl.uniform1f(this.uIntensity, s.alpha * pulse);
// 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));
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, a.tex);
gl.bindVertexArray(this.mapVao);
+7 -7
View File
@@ -139,8 +139,11 @@ export class UserSettings {
return this.getBool("settings.emojis", true);
}
highlightSmallPlayers() {
return this.getBool("settings.highlightSmallPlayers", false);
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() {
@@ -195,11 +198,8 @@ export class UserSettings {
this.setBool("settings.emojis", !this.emojis());
}
toggleHighlightSmallPlayers() {
this.setBool(
"settings.highlightSmallPlayers",
!this.highlightSmallPlayers(),
);
setHighlightGlowStrength(value: number) {
this.setFloat("settings.highlightGlowStrength", value);
}
// Performance overlay specifically needs a direct setter for Shift-D