From 08b9fd96e6ee95278e8fcd90fb91cf0feb2e0d43 Mon Sep 17 00:00:00 2001 From: Evan Date: Mon, 4 May 2026 18:10:06 -0600 Subject: [PATCH] simplify attack overlay to reduce visual clutter (#3848) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description: Simplifies the attacking-troops overlay: removes the soldier icon and strength bar, dropping each label down to just the troop number in cyan (outgoing) or red (incoming) with a soft dark text-shadow halo and no background fill so territory borders show through cleanly. Also splits the label into outer (transitioned position) and inner (instant scale) divs so zoom changes no longer get smeared by the 0.25s cluster-move transition, retunes the zoom→size curve, and skips incoming labels from bot tribes to cut clutter. Screenshot 2026-05-04 at 5 53 17 PM ## 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 - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: evan --- .../graphics/layers/AttackingTroopsOverlay.ts | 187 +++++++----------- .../layers/AttackingTroopsOverlay.test.ts | 69 ++----- 2 files changed, 89 insertions(+), 167 deletions(-) diff --git a/src/client/graphics/layers/AttackingTroopsOverlay.ts b/src/client/graphics/layers/AttackingTroopsOverlay.ts index fb94ada77..b279489a0 100644 --- a/src/client/graphics/layers/AttackingTroopsOverlay.ts +++ b/src/client/graphics/layers/AttackingTroopsOverlay.ts @@ -1,54 +1,33 @@ -import { assetUrl } from "../../../core/AssetUrls"; import { EventBus } from "../../../core/EventBus"; -import { Cell } from "../../../core/game/Game"; +import { Cell, PlayerType } from "../../../core/game/Game"; import { GameView } from "../../../core/game/GameView"; import { UserSettings } from "../../../core/game/UserSettings"; import { AlternateViewEvent } from "../../InputHandler"; import { renderTroops } from "../../Utils"; import { TransformHandler } from "../TransformHandler"; import { Layer } from "./Layer"; -const soldierIcon = assetUrl("images/SoldierIcon.svg"); // Match AttacksDisplay: aquarius for outgoing, red-400 for incoming. const OUTGOING_COLOR = "var(--color-aquarius)"; const INCOMING_COLOR = "var(--color-red-400)"; -// At/above this zoom, the label stays at its full screen size. Below it the -// label shrinks linearly with zoom-out, floored so it never disappears. -const LABEL_FULL_SIZE_ZOOM = 1.5; -const LABEL_MIN_SCREEN_SCALE = 0.5; -const OUTGOING_ICON_FILTER = - "brightness(0) saturate(100%) invert(62%) sepia(80%) saturate(500%) hue-rotate(175deg) brightness(100%)"; -const INCOMING_ICON_FILTER = - "brightness(0) saturate(100%) invert(27%) sepia(91%) saturate(4551%) hue-rotate(348deg) brightness(89%) contrast(97%)"; +// At/above this zoom the label is rendered at full size; below it shrinks +// linearly toward LABEL_MIN_RENDERED_SIZE as zoom→0. +const LABEL_FULL_SIZE_ZOOM = 4.0; +const LABEL_MIN_RENDERED_SIZE = 0.63; +// Overall size multiplier applied to the rendered label. +const LABEL_SIZE_MULTIPLIER = 1.0; -// Vertical strength bar to the left of the icon: grows in height as the -// attacker outnumbers the opposition. Maxes out at BAR_MAX_HEIGHT_PX when the -// attacker has BAR_FULL_HEIGHT_RATIO× the opposing troops. -const BAR_FULL_HEIGHT_RATIO = 2; -const BAR_MAX_HEIGHT_PX = 13; - -// Element scale factor that, combined with the container's `scale(zoom)`, -// yields the desired on-screen label size: constant screen size when zoomed -// in past LABEL_FULL_SIZE_ZOOM, then shrinking linearly as zoom drops, with a -// floor at LABEL_MIN_SCREEN_SCALE so the label never disappears. +// Counter-scale against the container's `scale(zoom)`. At/above +// LABEL_FULL_SIZE_ZOOM the rendered size is capped at LABEL_SIZE_MULTIPLIER; +// below it the rendered size shrinks linearly toward +// LABEL_SIZE_MULTIPLIER * LABEL_MIN_RENDERED_SIZE as zoom→0. export function computeLabelScale(zoom: number): number { - const netScale = Math.max( - LABEL_MIN_SCREEN_SCALE, - Math.min(1, zoom / LABEL_FULL_SIZE_ZOOM), - ); - return netScale / zoom; -} - -// Fraction (0–1) of BAR_MAX_HEIGHT_PX the strength bar should occupy. 0 means -// the attacker is harmless; 1 means they have BAR_FULL_HEIGHT_RATIO× or more -// of the opposing troops. -export function computeBarStrength( - attackerTroops: number, - opposingTroops: number, -): number { - if (opposingTroops <= 0) return 1; - return Math.min(1, attackerTroops / opposingTroops / BAR_FULL_HEIGHT_RATIO); + const t = Math.min(1, zoom / LABEL_FULL_SIZE_ZOOM); + const renderedSize = + LABEL_SIZE_MULTIPLIER * + (LABEL_MIN_RENDERED_SIZE + (1 - LABEL_MIN_RENDERED_SIZE) * t); + return renderedSize / zoom; } // Worker returns clusters sorted by size; two near-equal-size fronts can flip @@ -70,7 +49,6 @@ interface AttackLabel { positions: (Cell | null)[]; isIncoming: boolean; attackerTroops: number; - barStrength: number; } export class AttackingTroopsOverlay implements Layer { @@ -144,7 +122,7 @@ export class AttackingTroopsOverlay implements Layer { const activeIDs = new Set(); - // Outgoing: cyan bar widens as our attack outnumbers the defender. + // Outgoing: only label attacks targeting another player. for (const attack of myPlayer.outgoingAttacks()) { activeIDs.add(attack.id); if (!attack.targetID) { @@ -156,20 +134,22 @@ export class AttackingTroopsOverlay implements Layer { this.removeLabel(attack.id); continue; } - const barStrength = computeBarStrength(attack.troops, defender.troops()); - this.ensureLabel(attack.id, attack.troops, false, barStrength); + this.ensureLabel(attack.id, attack.troops, false); } - // Incoming: red bar widens as the attacker outnumbers the player. + // Incoming: only label attacks coming from another player; skip tribes. for (const attack of myPlayer.incomingAttacks()) { activeIDs.add(attack.id); const attacker = this.game.playerBySmallID(attack.attackerID); - if (!attacker || !attacker.isPlayer()) { + if ( + !attacker || + !attacker.isPlayer() || + attacker.type() === PlayerType.Bot + ) { this.removeLabel(attack.id); continue; } - const barStrength = computeBarStrength(attack.troops, myPlayer.troops()); - this.ensureLabel(attack.id, attack.troops, true, barStrength); + this.ensureLabel(attack.id, attack.troops, true); } for (const [id] of this.labels) { @@ -202,7 +182,6 @@ export class AttackingTroopsOverlay implements Layer { attackID: string, attackerTroops: number, isIncoming: boolean, - barStrength: number, ) { let label = this.labels.get(attackID); if (!label) { @@ -211,15 +190,13 @@ export class AttackingTroopsOverlay implements Layer { positions: [], isIncoming, attackerTroops, - barStrength, }; this.labels.set(attackID, label); } else { label.attackerTroops = attackerTroops; - label.barStrength = barStrength; } for (const el of label.elements) { - this.updateLabelContent(el, attackerTroops, barStrength); + this.updateLabelContent(el, attackerTroops); } } @@ -235,6 +212,7 @@ export class AttackingTroopsOverlay implements Layer { // Hoist the per-frame label scale once; zoom is constant within a frame. const scale = this.labelScale(); + const innerTransform = `scale(${scale})`; for (const label of this.labels.values()) { for (let i = 0; i < label.elements.length; i++) { const el = label.elements[i]; @@ -245,15 +223,17 @@ export class AttackingTroopsOverlay implements Layer { continue; } - el.style.display = "inline-flex"; - // Centre the label on its world position; counter-scale keeps the - // label at constant screen size while zoomed in, then it shrinks - // (floored) as zoom drops below LABEL_FULL_SIZE_ZOOM. - const transform = `translate(${pos.x}px, ${pos.y}px) translate(-50%, -50%) scale(${scale})`; - if (this.lastTransform.get(el) !== transform) { - el.style.transform = transform; - this.lastTransform.set(el, transform); + el.style.display = ""; + const inner = el.children[0] as HTMLDivElement; + // Outer: world position only — the 0.25s transition smooths cluster + // shifts. Inner: scale only — applied without transition so zoom is + // instant. + const outerTransform = `translate(${pos.x}px, ${pos.y}px) translate(-50%, -50%)`; + if (this.lastTransform.get(el) !== outerTransform) { + el.style.transform = outerTransform; + this.lastTransform.set(el, outerTransform); } + inner.style.transform = innerTransform; } } } @@ -262,11 +242,7 @@ export class AttackingTroopsOverlay implements Layer { // Add elements for new clusters. while (lbl.elements.length < positions.length) { lbl.elements.push( - this.createLabelElement( - lbl.attackerTroops, - lbl.isIncoming, - lbl.barStrength, - ), + this.createLabelElement(lbl.attackerTroops, lbl.isIncoming), ); lbl.positions.push(null); } @@ -286,9 +262,9 @@ export class AttackingTroopsOverlay implements Layer { if (old && Math.hypot(next.x - old.x, next.y - old.y) > 200) { const el = lbl.elements[i]; el.style.transition = "none"; - const transform = `translate(${next.x}px, ${next.y}px) translate(-50%, -50%) scale(${this.labelScale()})`; - el.style.transform = transform; - this.lastTransform.set(el, transform); + const outerTransform = `translate(${next.x}px, ${next.y}px) translate(-50%, -50%)`; + el.style.transform = outerTransform; + this.lastTransform.set(el, outerTransform); requestAnimationFrame(() => { el.style.transition = "transform 0.25s linear"; }); @@ -297,73 +273,48 @@ export class AttackingTroopsOverlay implements Layer { } } + // Outer wraps position+transition (animates cluster moves). Inner holds the + // scale (instant on zoom) plus all visual chrome. Splitting them keeps the + // 0.25s transition off zoom changes. private createLabelTemplate(): HTMLDivElement { - const el = document.createElement("div"); - el.style.position = "absolute"; - el.style.display = "none"; - el.style.alignItems = "center"; - el.style.gap = "3px"; - el.style.whiteSpace = "nowrap"; - el.style.fontSize = "14px"; - el.style.fontWeight = "bold"; - el.style.padding = "2px 5px"; - el.style.borderRadius = "3px"; - el.style.backgroundColor = "rgba(0,0,0,0.85)"; - el.style.pointerEvents = "none"; - el.style.lineHeight = "1.3"; - el.style.transition = "transform 0.25s linear"; - el.style.width = "max-content"; + const outer = document.createElement("div"); + outer.style.position = "absolute"; + outer.style.display = "none"; + outer.style.pointerEvents = "none"; + outer.style.transition = "transform 0.25s linear"; - const bar = document.createElement("div"); - bar.style.width = "2px"; - bar.style.borderRadius = "1px"; - bar.style.alignSelf = "flex-end"; - bar.style.transition = "height 0.25s linear"; - el.appendChild(bar); + const inner = document.createElement("div"); + inner.style.whiteSpace = "nowrap"; + inner.style.fontSize = "17px"; + inner.style.fontWeight = "bold"; + inner.style.lineHeight = "1.3"; + inner.style.width = "max-content"; + // No background — let the territory border show through. Stacked black + // text-shadows form a soft dark glow so the number stays readable over + // any terrain. + inner.style.textShadow = + "0 0 2px rgba(0,0,0,1), 0 0 3px rgba(0,0,0,0.85), 0 0 5px rgba(0,0,0,0.5)"; + outer.appendChild(inner); - const icon = document.createElement("img"); - icon.style.width = "13px"; - icon.style.height = "13px"; - el.appendChild(icon); - - const span = document.createElement("span"); - span.style.minWidth = "25px"; - el.appendChild(span); - - return el; + return outer; } private createLabelElement( attackerTroops: number, isIncoming: boolean, - barStrength: number, ): HTMLDivElement { const el = this.labelTemplate.cloneNode(true) as HTMLDivElement; - el.style.fontFamily = this.game.config().theme().font(); - const bar = el.children[0] as HTMLDivElement; - const icon = el.children[1] as HTMLImageElement; - const span = el.children[2] as HTMLSpanElement; - icon.src = soldierIcon; - icon.style.filter = isIncoming - ? INCOMING_ICON_FILTER - : OUTGOING_ICON_FILTER; - span.style.color = isIncoming ? INCOMING_COLOR : OUTGOING_COLOR; - span.textContent = renderTroops(attackerTroops); - bar.style.backgroundColor = isIncoming ? INCOMING_COLOR : OUTGOING_COLOR; - bar.style.height = `${barStrength * BAR_MAX_HEIGHT_PX}px`; + const inner = el.children[0] as HTMLDivElement; + inner.style.fontFamily = this.game.config().theme().font(); + inner.style.color = isIncoming ? INCOMING_COLOR : OUTGOING_COLOR; + inner.textContent = renderTroops(attackerTroops); this.container.appendChild(el); return el; } - private updateLabelContent( - el: HTMLDivElement, - attackerTroops: number, - barStrength: number, - ) { - const bar = el.children[0] as HTMLDivElement; - const span = el.children[2] as HTMLSpanElement; - span.textContent = renderTroops(attackerTroops); - bar.style.height = `${barStrength * BAR_MAX_HEIGHT_PX}px`; + private updateLabelContent(el: HTMLDivElement, attackerTroops: number) { + const inner = el.children[0] as HTMLDivElement; + inner.textContent = renderTroops(attackerTroops); } private removeLabel(attackID: string) { diff --git a/tests/client/graphics/layers/AttackingTroopsOverlay.test.ts b/tests/client/graphics/layers/AttackingTroopsOverlay.test.ts index 9b691bddf..de8a78e93 100644 --- a/tests/client/graphics/layers/AttackingTroopsOverlay.test.ts +++ b/tests/client/graphics/layers/AttackingTroopsOverlay.test.ts @@ -1,66 +1,37 @@ import { describe, expect, test } from "vitest"; import { alignClusterOrder, - computeBarStrength, computeLabelScale, } from "../../../../src/client/graphics/layers/AttackingTroopsOverlay"; import { Cell } from "../../../../src/core/game/Game"; describe("computeLabelScale", () => { - test("counter-scales the zoom when above the full-size threshold", () => { - // zoom = 2 → label rendered at 1/2 to stay at full screen size. - expect(computeLabelScale(2)).toBeCloseTo(0.5); + // LABEL_FULL_SIZE_ZOOM = 4, LABEL_MIN_RENDERED_SIZE = 0.63, + // LABEL_SIZE_MULTIPLIER = 1.0. Rendered size at zoom z: + // 1.0 * (0.63 + 0.37 * min(1, z/4)). + test("at the full-size threshold, rendered size is capped at the multiplier", () => { + // zoom = 4 → rendered = 1.0 → scale = 1.0 / 4. + expect(computeLabelScale(4)).toBeCloseTo(1.0 / 4); }); - test("counter-scales exactly at the full-size threshold", () => { - // zoom = 1.5 → label rendered at 1/1.5 ≈ 0.6667. - expect(computeLabelScale(1.5)).toBeCloseTo(1 / 1.5); + test("above the threshold, rendered size stays capped (counter-scales zoom)", () => { + // zoom = 8 → rendered still 1.0 → scale = 1.0 / 8. + expect(computeLabelScale(8)).toBeCloseTo(1.0 / 8); }); - test("rides the world transform between the floor and the threshold", () => { - // Below the threshold, netScale = zoom / 1.5, so the factor is constant 1/1.5. - expect(computeLabelScale(1)).toBeCloseTo(1 / 1.5); - expect(computeLabelScale(0.9)).toBeCloseTo(1 / 1.5); + test("at zoom = 0+, rendered size approaches the floor", () => { + // As zoom→0, t→0, rendered → 1.0 * 0.63 (the floor). + // At zoom = 0.001, rendered ≈ floor, so scale ≈ floor / zoom = huge. + const scale = computeLabelScale(0.001); + const floorRendered = 1.0 * 0.63; + // Within 1% of the floor-divided-by-zoom value. + expect(scale).toBeGreaterThan((floorRendered / 0.001) * 0.99); + expect(scale).toBeLessThan((floorRendered / 0.001) * 1.01); }); - test("floor engages exactly at zoom = 0.75 (LABEL_MIN_SCREEN_SCALE * LABEL_FULL_SIZE_ZOOM)", () => { - expect(computeLabelScale(0.75)).toBeCloseTo(1 / 1.5); - }); - - test("grows in screen space when zoomed out past the floor", () => { - // zoom = 0.5 → netScale clamped to 0.5, factor = 0.5 / 0.5 = 1. - expect(computeLabelScale(0.5)).toBeCloseTo(1); - // zoom = 0.25 → factor = 0.5 / 0.25 = 2. - expect(computeLabelScale(0.25)).toBeCloseTo(2); - }); -}); - -describe("computeBarStrength", () => { - test("equal troops sit at the midpoint", () => { - // 1000 vs 1000 → ratio 1, divided by full-height ratio of 2 → 0.5. - expect(computeBarStrength(1000, 1000)).toBeCloseTo(0.5); - }); - - test("attacker with no troops yields a zero-height bar", () => { - expect(computeBarStrength(0, 1000)).toBe(0); - }); - - test("scales linearly between zero and the full-height threshold", () => { - // 500 vs 1000 → ratio 0.5 → 0.25. - expect(computeBarStrength(500, 1000)).toBeCloseTo(0.25); - // 1500 vs 1000 → ratio 1.5 → 0.75. - expect(computeBarStrength(1500, 1000)).toBeCloseTo(0.75); - }); - - test("clamps at full height when attacker has 2× the opposition", () => { - expect(computeBarStrength(2000, 1000)).toBeCloseTo(1); - expect(computeBarStrength(10_000, 1000)).toBeCloseTo(1); - }); - - test("returns full height when the opposing side has no troops", () => { - // Avoids division-by-zero: an undefended target is maximum strength. - expect(computeBarStrength(500, 0)).toBe(1); - expect(computeBarStrength(0, 0)).toBe(1); + test("interpolates linearly between floor and full-size threshold", () => { + // zoom = 2 → t = 0.5 → rendered = 1.0 * (0.63 + 0.185) = 0.815. + expect(computeLabelScale(2)).toBeCloseTo(0.815 / 2); }); });