Add white glow behind hovered player's name

The hovered player (tile owner under the cursor, already tracked via
uHighlightOwnerID for cull bypass) now gets a soft white glow behind
their name. The glow is derived from the MSDF distance field: a white
band past the outline with quadratic falloff, composited behind the
glyph and clamped to the SDF margin so it never clips at quad edges.

Glow size and strength are tunable via hoverGlowWidth/hoverGlowAlpha
in render-settings.json, exposed as sliders in the graphics settings
modal (persisted as graphics overrides) and in the debug GUI.

Includes schema and apply tests for the new override fields,
covering the 0 edge case (0 disables the glow, not "unset").
This commit is contained in:
evanpelle
2026-06-11 15:27:56 -07:00
parent 2d747d0f8b
commit 03a5d691ee
11 changed files with 188 additions and 9 deletions
+4
View File
@@ -944,6 +944,10 @@
"name_cull_desc": "Hide names smaller than this size",
"hover_fade_label": "Name opacity under cursor",
"hover_fade_desc": "How visible names are while your cursor is over them (1 to disable fading)",
"hover_glow_width_label": "Hover glow size",
"hover_glow_width_desc": "How far the white glow extends behind the hovered player's name",
"hover_glow_alpha_label": "Hover glow strength",
"hover_glow_alpha_desc": "How bright the white glow behind the hovered player's name is (0 to disable)",
"colored_names_label": "Name color",
"colored_names_desc": "Show player names in their player color or in black",
"colored": "Colored",
@@ -24,6 +24,14 @@ const HOVER_FADE_MIN = 0;
const HOVER_FADE_MAX = 1;
const HOVER_FADE_STEP = 0.05;
const HOVER_GLOW_WIDTH_MIN = 0;
const HOVER_GLOW_WIDTH_MAX = 8;
const HOVER_GLOW_WIDTH_STEP = 0.5;
const HOVER_GLOW_ALPHA_MIN = 0;
const HOVER_GLOW_ALPHA_MAX = 1;
const HOVER_GLOW_ALPHA_STEP = 0.05;
const HIGHLIGHT_FILL_MIN = 0;
const HIGHLIGHT_FILL_MAX = 1;
const HIGHLIGHT_FILL_STEP = 0.01;
@@ -159,6 +167,20 @@ export class GraphicsSettingsModal extends LitElement implements Controller {
);
}
private currentHoverGlowWidth(): number {
return (
this.userSettings.graphicsOverrides().name?.hoverGlowWidth ??
renderDefaults.name.hoverGlowWidth
);
}
private currentHoverGlowAlpha(): number {
return (
this.userSettings.graphicsOverrides().name?.hoverGlowAlpha ??
renderDefaults.name.hoverGlowAlpha
);
}
private patchName(patch: Partial<GraphicsOverrides["name"]>) {
const current = this.userSettings.graphicsOverrides();
this.userSettings.setGraphicsOverrides({
@@ -349,6 +371,16 @@ export class GraphicsSettingsModal extends LitElement implements Controller {
this.patchName({ hoverFadeAlpha: value });
}
private onHoverGlowWidthChange(event: Event) {
const value = parseFloat((event.target as HTMLInputElement).value);
this.patchName({ hoverGlowWidth: value });
}
private onHoverGlowAlphaChange(event: Event) {
const value = parseFloat((event.target as HTMLInputElement).value);
this.patchName({ hoverGlowAlpha: value });
}
private currentDarkNames(): boolean {
return (
this.userSettings.graphicsOverrides().name?.darkNames ??
@@ -371,6 +403,8 @@ export class GraphicsSettingsModal extends LitElement implements Controller {
const nameScale = this.currentNameScale();
const nameCull = this.currentNameCull();
const hoverFade = this.currentHoverFade();
const hoverGlowWidth = this.currentHoverGlowWidth();
const hoverGlowAlpha = this.currentHoverGlowAlpha();
const namesColored = !this.currentDarkNames();
const classicIcons = this.currentClassicIcons();
const highlightFill = this.currentHighlightFill();
@@ -492,6 +526,56 @@ export class GraphicsSettingsModal extends LitElement implements Controller {
</div>
</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.hover_glow_width_label")}
</div>
<div class="text-sm text-slate-400">
${translateText("graphics_setting.hover_glow_width_desc")}
</div>
<input
type="range"
min=${HOVER_GLOW_WIDTH_MIN}
max=${HOVER_GLOW_WIDTH_MAX}
step=${HOVER_GLOW_WIDTH_STEP}
.value=${String(hoverGlowWidth)}
@input=${this.onHoverGlowWidthChange}
class="w-full border border-slate-500 rounded-lg"
/>
</div>
<div class="text-sm text-slate-400 w-12 text-right">
${hoverGlowWidth.toFixed(1)}
</div>
</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.hover_glow_alpha_label")}
</div>
<div class="text-sm text-slate-400">
${translateText("graphics_setting.hover_glow_alpha_desc")}
</div>
<input
type="range"
min=${HOVER_GLOW_ALPHA_MIN}
max=${HOVER_GLOW_ALPHA_MAX}
step=${HOVER_GLOW_ALPHA_STEP}
.value=${String(hoverGlowAlpha)}
@input=${this.onHoverGlowAlphaChange}
class="w-full border border-slate-500 rounded-lg"
/>
</div>
<div class="text-sm text-slate-400 w-12 text-right">
${hoverGlowAlpha.toFixed(2)}
</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.onToggleNamesColored}
@@ -8,6 +8,8 @@ export const GraphicsOverridesSchema = z
cullThreshold: z.number(),
darkNames: z.boolean(),
hoverFadeAlpha: z.number(),
hoverGlowWidth: z.number(),
hoverGlowAlpha: z.number(),
})
.partial(),
structure: z
+6
View File
@@ -21,6 +21,12 @@ export function applyGraphicsOverrides(
if (overrides.name?.hoverFadeAlpha !== undefined) {
settings.name.hoverFadeAlpha = overrides.name.hoverFadeAlpha;
}
if (overrides.name?.hoverGlowWidth !== undefined) {
settings.name.hoverGlowWidth = overrides.name.hoverGlowWidth;
}
if (overrides.name?.hoverGlowAlpha !== undefined) {
settings.name.hoverGlowAlpha = overrides.name.hoverGlowAlpha;
}
if (overrides.structure?.classicIcons === true) {
// Classic look: lighter player-colored shape behind a dark icon glyph,
// with a touch of translucency.
+4
View File
@@ -243,6 +243,10 @@ export interface RenderSettings {
statusRowOffset: number;
/** Alpha multiplier applied to a name while the cursor is over it. */
hoverFadeAlpha: number;
/** White glow behind the hovered player's name: px past the outline. */
hoverGlowWidth: number;
/** Peak opacity of the hover glow (0 disables it). */
hoverGlowAlpha: number;
};
fx: {
shockwaveRingWidth: number;
+2
View File
@@ -326,6 +326,8 @@ export function buildTree(s: RenderSettings, d: RenderSettings): DebugNode[] {
slider(s.name, "emojiRowOffset", d.name, 0, 5, 0.1, "Emoji Row Offset"),
slider(s.name, "statusRowOffset", d.name, 0, 5, 0.1, "Status Row Offset"),
slider(s.name, "hoverFadeAlpha", d.name, 0, 1, 0.05, "Hover Fade Alpha"),
slider(s.name, "hoverGlowWidth", d.name, 0, 8, 0.25, "Hover Glow Width"),
slider(s.name, "hoverGlowAlpha", d.name, 0, 1, 0.05, "Hover Glow Alpha"),
]),
folder("FX", [
@@ -49,6 +49,8 @@ export class TextProgram {
private uOutlineColor: WebGLUniformLocation;
private uOutlineUsePlayerColor: WebGLUniformLocation;
private uFillUsePlayerColor: WebGLUniformLocation;
private uHoverGlowWidth: WebGLUniformLocation;
private uHoverGlowAlpha: WebGLUniformLocation;
private distanceRange: number;
@@ -128,6 +130,14 @@ export class TextProgram {
this.program,
"uFillUsePlayerColor",
)!;
this.uHoverGlowWidth = gl.getUniformLocation(
this.program,
"uHoverGlowWidth",
)!;
this.uHoverGlowAlpha = gl.getUniformLocation(
this.program,
"uHoverGlowAlpha",
)!;
this.loadAtlas();
}
@@ -188,6 +198,8 @@ export class TextProgram {
ns.outlineUsePlayerColor ? 1.0 : 0.0,
);
gl.uniform1f(this.uFillUsePlayerColor, ns.fillUsePlayerColor ? 1.0 : 0.0);
gl.uniform1f(this.uHoverGlowWidth, ns.hoverGlowWidth);
gl.uniform1f(this.uHoverGlowAlpha, ns.hoverGlowAlpha);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, this.atlasTex!);
+3 -1
View File
@@ -207,7 +207,9 @@
"nameShadeBot": 0.4,
"emojiRowOffset": 1.4,
"statusRowOffset": 1.4,
"hoverFadeAlpha": 0.25
"hoverFadeAlpha": 0.5,
"hoverGlowWidth": 5,
"hoverGlowAlpha": 0.75
},
"fx": {
"shockwaveRingWidth": 0.04,
@@ -8,10 +8,13 @@ uniform float uNightAmbient;
uniform vec3 uOutlineColor;
uniform float uOutlineUsePlayerColor;
uniform float uFillUsePlayerColor;
uniform float uHoverGlowWidth; // px the white hover glow extends past the outline
uniform float uHoverGlowAlpha; // peak opacity of the hover glow
in vec2 vUV;
in vec4 vPlayerColor; // player territory color (rgb) + alpha
in float vNameShade; // name fill grayscale shade (0.0 = black)
flat in float vHighlight; // 1.0 when this player is hovered (white glow)
out vec4 fragColor;
float median(float r, float g, float b) {
@@ -42,20 +45,40 @@ void main() {
float screenPxDist = screenPxRange * (sd - 0.5);
float fillAlpha = clamp(screenPxDist + 0.5, 0.0, 1.0);
if (uOutlineWidth > 0.0) {
// The SDF saturates at sd=0 (screenPxDist = -screenPxRange*0.5).
// Reserve a 1px margin so saturated fragments always get alpha=0.
float maxOutline = max(screenPxRange * 0.5 - 1.0, 0.0);
float effectiveOutline = min(uOutlineWidth, maxOutline);
// The SDF saturates at sd=0 (screenPxDist = -screenPxRange*0.5).
// Reserve a 1px margin so saturated fragments always get alpha=0.
float maxOutline = max(screenPxRange * 0.5 - 1.0, 0.0);
float effectiveOutline = min(uOutlineWidth, maxOutline);
vec3 color;
float coverage;
if (uOutlineWidth > 0.0) {
float outlineDist = screenPxDist + effectiveOutline;
float outlineAlpha = clamp(outlineDist + 0.5, 0.0, 1.0);
vec3 nightOutlineColor = mix(vec3(0.0), uOutlineColor, borderT);
vec3 borderColor = mix(nightOutlineColor, vPlayerColor.rgb, uOutlineUsePlayerColor);
vec3 color = mix(borderColor, fillColor, fillAlpha);
fragColor = vec4(color, vPlayerColor.a * outlineAlpha);
color = mix(borderColor, fillColor, fillAlpha);
coverage = outlineAlpha;
} else {
fragColor = vec4(fillColor, vPlayerColor.a * fillAlpha);
color = fillColor;
coverage = fillAlpha;
}
// Soft white glow behind the hovered player's name. Width is clamped to
// the SDF margin past the outline so it never hard-clips at the quad edge.
if (vHighlight > 0.5 && uHoverGlowAlpha > 0.0) {
float glowWidth = min(uHoverGlowWidth, max(maxOutline - effectiveOutline, 0.0));
if (glowWidth > 0.0) {
float g = clamp(1.0 + (screenPxDist + effectiveOutline) / glowWidth, 0.0, 1.0);
float glowAlpha = g * g * uHoverGlowAlpha;
float total = coverage + glowAlpha * (1.0 - coverage);
if (total > 0.0) {
color = mix(vec3(1.0), color, coverage / total);
coverage = total;
}
}
}
fragColor = vec4(color, vPlayerColor.a * coverage);
}
@@ -34,6 +34,7 @@ uniform float uHoverFadeAlpha; // alpha multiplier applied to that player's name
out vec2 vUV;
out vec4 vPlayerColor; // player territory color (rgb) + alpha
out float vNameShade; // name fill grayscale shade (0.0 = black)
flat out float vHighlight; // 1.0 when this player is hovered (white glow)
void main() {
// 1. Decode instance ID → playerIdx, lineIdx, charPos
@@ -57,6 +58,7 @@ void main() {
vUV = vec2(0.0);
vPlayerColor = vec4(0.0);
vNameShade = 0.0;
vHighlight = 0.0;
return;
}
@@ -67,6 +69,7 @@ void main() {
vUV = vec2(0.0);
vPlayerColor = vec4(0.0);
vNameShade = 0.0;
vHighlight = 0.0;
return;
}
@@ -78,6 +81,7 @@ void main() {
vUV = vec2(0.0);
vPlayerColor = vec4(0.0);
vNameShade = 0.0;
vHighlight = 0.0;
return;
}
@@ -112,6 +116,7 @@ void main() {
vUV = vec2(0.0);
vPlayerColor = vec4(0.0);
vNameShade = 0.0;
vHighlight = 0.0;
return;
}
@@ -136,6 +141,7 @@ void main() {
vUV = vec2(0.0);
vPlayerColor = vec4(0.0);
vNameShade = 0.0;
vHighlight = 0.0;
return;
}
@@ -166,4 +172,5 @@ void main() {
vUV = vec2(mix(u0, u1, aPos.x), mix(v0, v1, aPos.y));
vPlayerColor = vec4(pd2.rgb, pd2.a * hoverAlpha); // player territory color + alpha
vNameShade = pd3.z; // name fill grayscale shade (0.0 = black)
vHighlight = isHighlighted ? 1.0 : 0.0;
}
+33
View File
@@ -23,7 +23,10 @@ describe("GraphicsOverridesSchema", () => {
{ name: { nameScaleFactor: 0.8 } },
{ name: { cullThreshold: 0.02 } },
{ name: { darkNames: true } },
{ name: { hoverGlowWidth: 5 } },
{ name: { hoverGlowAlpha: 0.6 } },
{ name: { nameScaleFactor: 1.2, cullThreshold: 0, darkNames: false } },
{ name: { hoverGlowWidth: 0, hoverGlowAlpha: 0 } },
];
for (const c of cases) {
expect(GraphicsOverridesSchema.safeParse(c).success).toBe(true);
@@ -74,6 +77,14 @@ describe("GraphicsOverridesSchema", () => {
expect(
GraphicsOverridesSchema.safeParse({ name: { darkNames: "yes" } }).success,
).toBe(false);
expect(
GraphicsOverridesSchema.safeParse({ name: { hoverGlowWidth: "wide" } })
.success,
).toBe(false);
expect(
GraphicsOverridesSchema.safeParse({ name: { hoverGlowAlpha: true } })
.success,
).toBe(false);
expect(
GraphicsOverridesSchema.safeParse({
structure: { classicIcons: "yes" },
@@ -132,6 +143,28 @@ describe("applyGraphicsOverrides", () => {
expect(gen({ name: { cullThreshold: 0 } }).name.cullThreshold).toBe(0);
});
test("applies hoverGlowWidth override (including 0)", () => {
expect(gen({ name: { hoverGlowWidth: 6 } }).name.hoverGlowWidth).toBe(6);
expect(gen({ name: { hoverGlowWidth: 0 } }).name.hoverGlowWidth).toBe(0);
});
test("applies hoverGlowAlpha override (including 0)", () => {
expect(gen({ name: { hoverGlowAlpha: 0.9 } }).name.hoverGlowAlpha).toBe(
0.9,
);
expect(gen({ name: { hoverGlowAlpha: 0 } }).name.hoverGlowAlpha).toBe(0);
});
test("hover glow overrides leave other name fields at defaults", () => {
const defaults = createRenderSettings().name;
const s = gen({
name: { hoverGlowWidth: 7, hoverGlowAlpha: 0.1 },
}).name;
expect(s.hoverFadeAlpha).toBe(defaults.hoverFadeAlpha);
expect(s.nameScaleFactor).toBe(defaults.nameScaleFactor);
expect(s.cullThreshold).toBe(defaults.cullThreshold);
});
test("darkNames=true → black fill + player-colored outline + outline RGB 0", () => {
const s = gen({ name: { darkNames: true } }).name;
expect(s.fillUsePlayerColor).toBe(false);