From 0c0f9c2a8117ec78a58238e6fecf1a54f452281b Mon Sep 17 00:00:00 2001 From: Evan Date: Mon, 27 Apr 2026 20:53:09 -0600 Subject: [PATCH] Update attack labels (#3784) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description: The motivation behind this PR is to standardize colors & icons for incoming and outgoing attacks. Outgoing attacks are always aquarious and incoming are red. This also makes it much easier to see which attacks are incoming vs outgoing at a glance, as previously the color changed depending on attack effictiveness. Instead, show a small bar on the left side that displays attack effectiveness. Screenshot 2026-04-27 at 12 58 53 PM Updates the in-game attack labels to match AttacksDisplay: a single soldier icon recolored via CSS filters, aquarius for outgoing and red-400 for incoming. Color is now purely directional — the previous attacker-vs-defender comparison (and the troopAttackColor / troopDefenceColor helpers that drove it) is gone, along with the defenderTroops plumbing. Also adds zoom-aware sizing via a new computeLabelScale(zoom) (full screen size when zoomed in, linear shrink with a floor so labels never disappear), bumps font/padding/snap-jump threshold for readability, and moves immutable per-label DOM writes (icon src/filter, color) into element creation so the per-tick path only updates the troop count. Also fixes a bug where the labels kept swapping when 2 clusters where similar size ## 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 | 169 +++++++++++++----- .../layers/AttackingTroopsOverlay.test.ts | 126 +++++++++++-- 2 files changed, 230 insertions(+), 65 deletions(-) diff --git a/src/client/graphics/layers/AttackingTroopsOverlay.ts b/src/client/graphics/layers/AttackingTroopsOverlay.ts index d40c6f08a..fb94ada77 100644 --- a/src/client/graphics/layers/AttackingTroopsOverlay.ts +++ b/src/client/graphics/layers/AttackingTroopsOverlay.ts @@ -7,21 +7,60 @@ import { AlternateViewEvent } from "../../InputHandler"; import { renderTroops } from "../../Utils"; import { TransformHandler } from "../TransformHandler"; import { Layer } from "./Layer"; -const shieldIcon = assetUrl("images/ShieldIconWhite.svg"); -const swordIcon = assetUrl("images/SwordIconWhite.svg"); +const soldierIcon = assetUrl("images/SoldierIcon.svg"); -export function troopAttackColor( - attackerTroops: number, - defenderTroops: number, -): string { - return attackerTroops > defenderTroops ? "#66ff66" : "#ffbe3c"; +// 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%)"; + +// 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. +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; } -export function troopDefenceColor( +// 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, - myTroops: number, -): string { - return attackerTroops > myTroops ? "#ff4444" : "#ff9944"; + opposingTroops: number, +): number { + if (opposingTroops <= 0) return 1; + return Math.min(1, attackerTroops / opposingTroops / BAR_FULL_HEIGHT_RATIO); +} + +// Worker returns clusters sorted by size; two near-equal-size fronts can flip +// ordering tick-to-tick. If swapping brings each new position closer to where +// its label already is, swap `next` in place. (clusteredPositions caps at 2.) +export function alignClusterOrder(next: Cell[], prev: (Cell | null)[]): void { + const [a, b] = prev; + if (next.length !== 2 || !a || !b) return; + const dist = (p: Cell, q: Cell) => Math.abs(p.x - q.x) + Math.abs(p.y - q.y); + const direct = dist(next[0], a) + dist(next[1], b); + const swapped = dist(next[1], a) + dist(next[0], b); + if (swapped < direct) [next[0], next[1]] = [next[1], next[0]]; } // An attack can have multiple disconnected front-line segments, so elements @@ -31,7 +70,7 @@ interface AttackLabel { positions: (Cell | null)[]; isIncoming: boolean; attackerTroops: number; - defenderTroops: number; + barStrength: number; } export class AttackingTroopsOverlay implements Layer { @@ -42,6 +81,9 @@ export class AttackingTroopsOverlay implements Layer { private inFlightRequest = false; private isVisible = true; private onAlternateView: (e: AlternateViewEvent) => void; + // Last transform string written per element; lets renderLayer skip identical + // re-assignments every frame (~60fps × N labels). + private lastTransform = new WeakMap(); constructor( private readonly game: GameView, @@ -84,6 +126,10 @@ export class AttackingTroopsOverlay implements Layer { return 200; } + private labelScale(): number { + return computeLabelScale(this.transformHandler.scale); + } + tick() { if (!this.userSettings.attackingTroopsOverlay() || !this.isVisible) { if (this.labels.size > 0) this.clearAllLabels(); @@ -98,7 +144,7 @@ export class AttackingTroopsOverlay implements Layer { const activeIDs = new Set(); - // Outgoing attacks — green if winning, amber if losing. + // Outgoing: cyan bar widens as our attack outnumbers the defender. for (const attack of myPlayer.outgoingAttacks()) { activeIDs.add(attack.id); if (!attack.targetID) { @@ -110,10 +156,11 @@ export class AttackingTroopsOverlay implements Layer { this.removeLabel(attack.id); continue; } - this.ensureLabel(attack.id, attack.troops, defender.troops(), false); + const barStrength = computeBarStrength(attack.troops, defender.troops()); + this.ensureLabel(attack.id, attack.troops, false, barStrength); } - // Incoming attacks — red if the attacker outnumbers the player, orange otherwise. + // Incoming: red bar widens as the attacker outnumbers the player. for (const attack of myPlayer.incomingAttacks()) { activeIDs.add(attack.id); const attacker = this.game.playerBySmallID(attack.attackerID); @@ -121,7 +168,8 @@ export class AttackingTroopsOverlay implements Layer { this.removeLabel(attack.id); continue; } - this.ensureLabel(attack.id, attack.troops, myPlayer.troops(), true); + const barStrength = computeBarStrength(attack.troops, myPlayer.troops()); + this.ensureLabel(attack.id, attack.troops, true, barStrength); } for (const [id] of this.labels) { @@ -153,8 +201,8 @@ export class AttackingTroopsOverlay implements Layer { private ensureLabel( attackID: string, attackerTroops: number, - defenderTroops: number, isIncoming: boolean, + barStrength: number, ) { let label = this.labels.get(attackID); if (!label) { @@ -163,15 +211,15 @@ export class AttackingTroopsOverlay implements Layer { positions: [], isIncoming, attackerTroops, - defenderTroops, + barStrength, }; this.labels.set(attackID, label); } else { label.attackerTroops = attackerTroops; - label.defenderTroops = defenderTroops; + label.barStrength = barStrength; } for (const el of label.elements) { - this.updateLabelContent(el, attackerTroops, defenderTroops, isIncoming); + this.updateLabelContent(el, attackerTroops, barStrength); } } @@ -185,6 +233,8 @@ export class AttackingTroopsOverlay implements Layer { ); this.container.style.transform = `translate(${screenPos.x}px, ${screenPos.y}px) scale(${this.transformHandler.scale})`; + // Hoist the per-frame label scale once; zoom is constant within a frame. + const scale = this.labelScale(); for (const label of this.labels.values()) { for (let i = 0; i < label.elements.length; i++) { const el = label.elements[i]; @@ -196,9 +246,14 @@ export class AttackingTroopsOverlay implements Layer { } el.style.display = "inline-flex"; - // Centre the label on its world position and counter-scale so text - // stays the same screen size regardless of zoom level. - el.style.transform = `translate(${pos.x}px, ${pos.y}px) translate(-50%, -50%) scale(${1 / this.transformHandler.scale})`; + // 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); + } } } } @@ -209,8 +264,8 @@ export class AttackingTroopsOverlay implements Layer { lbl.elements.push( this.createLabelElement( lbl.attackerTroops, - lbl.defenderTroops, lbl.isIncoming, + lbl.barStrength, ), ); lbl.positions.push(null); @@ -222,16 +277,20 @@ export class AttackingTroopsOverlay implements Layer { lbl.positions.pop(); } - // Snap large jumps instantly; let the CSS transition handle small advances. + alignClusterOrder(positions, lbl.positions); + + // Snap teleport-sized jumps instantly; let the CSS transition handle the rest. for (let i = 0; i < positions.length; i++) { const old = lbl.positions[i]; const next = positions[i]; - if (old && Math.hypot(next.x - old.x, next.y - old.y) > 50) { + if (old && Math.hypot(next.x - old.x, next.y - old.y) > 200) { const el = lbl.elements[i]; el.style.transition = "none"; - el.style.transform = `translate(${next.x}px, ${next.y}px) translate(-50%, -50%) scale(${1 / this.transformHandler.scale})`; + const transform = `translate(${next.x}px, ${next.y}px) translate(-50%, -50%) scale(${this.labelScale()})`; + el.style.transform = transform; + this.lastTransform.set(el, transform); requestAnimationFrame(() => { - el.style.transition = "transform 0.2s ease-out"; + el.style.transition = "transform 0.25s linear"; }); } lbl.positions[i] = next; @@ -245,33 +304,53 @@ export class AttackingTroopsOverlay implements Layer { el.style.alignItems = "center"; el.style.gap = "3px"; el.style.whiteSpace = "nowrap"; - el.style.fontSize = "11px"; + el.style.fontSize = "14px"; el.style.fontWeight = "bold"; - el.style.padding = "1px 4px"; + el.style.padding = "2px 5px"; el.style.borderRadius = "3px"; - el.style.backgroundColor = "rgba(0,0,0,0.55)"; + el.style.backgroundColor = "rgba(0,0,0,0.85)"; el.style.pointerEvents = "none"; el.style.lineHeight = "1.3"; - el.style.transition = "transform 0.2s ease-out"; + el.style.transition = "transform 0.25s linear"; el.style.width = "max-content"; + + 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 icon = document.createElement("img"); - icon.style.width = "10px"; - icon.style.height = "10px"; + 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; } private createLabelElement( attackerTroops: number, - defenderTroops: number, isIncoming: boolean, + barStrength: number, ): HTMLDivElement { const el = this.labelTemplate.cloneNode(true) as HTMLDivElement; el.style.fontFamily = this.game.config().theme().font(); - this.updateLabelContent(el, attackerTroops, defenderTroops, isIncoming); + 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`; this.container.appendChild(el); return el; } @@ -279,20 +358,12 @@ export class AttackingTroopsOverlay implements Layer { private updateLabelContent( el: HTMLDivElement, attackerTroops: number, - defenderTroops: number, - isIncoming: boolean, + barStrength: number, ) { - const icon = el.children[0] as HTMLImageElement; - const span = el.children[1] as HTMLSpanElement; - if (isIncoming) { - icon.src = shieldIcon; - span.style.color = troopDefenceColor(attackerTroops, defenderTroops); - span.textContent = renderTroops(attackerTroops); - } else { - icon.src = swordIcon; - span.style.color = troopAttackColor(attackerTroops, defenderTroops); - span.textContent = renderTroops(attackerTroops); - } + 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 removeLabel(attackID: string) { diff --git a/tests/client/graphics/layers/AttackingTroopsOverlay.test.ts b/tests/client/graphics/layers/AttackingTroopsOverlay.test.ts index 94d07515c..9b691bddf 100644 --- a/tests/client/graphics/layers/AttackingTroopsOverlay.test.ts +++ b/tests/client/graphics/layers/AttackingTroopsOverlay.test.ts @@ -1,32 +1,126 @@ +import { describe, expect, test } from "vitest"; import { - troopAttackColor, - troopDefenceColor, + alignClusterOrder, + computeBarStrength, + computeLabelScale, } from "../../../../src/client/graphics/layers/AttackingTroopsOverlay"; +import { Cell } from "../../../../src/core/game/Game"; -describe("troopAttackColor", () => { - test("returns green when attacker has more troops", () => { - expect(troopAttackColor(1000, 500)).toBe("#66ff66"); +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); }); - test("returns amber when defender has more troops", () => { - expect(troopAttackColor(500, 1000)).toBe("#ffbe3c"); + 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("returns amber when troops are equal", () => { - expect(troopAttackColor(500, 500)).toBe("#ffbe3c"); + 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("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("troopDefenceColor", () => { - test("returns red when attacker has more troops than defender", () => { - expect(troopDefenceColor(1000, 500)).toBe("#ff4444"); +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("returns orange when defender has more troops", () => { - expect(troopDefenceColor(500, 1000)).toBe("#ff9944"); + test("attacker with no troops yields a zero-height bar", () => { + expect(computeBarStrength(0, 1000)).toBe(0); }); - test("returns orange when troops are equal", () => { - expect(troopDefenceColor(500, 500)).toBe("#ff9944"); + 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); + }); +}); + +describe("alignClusterOrder", () => { + const c = (x: number, y: number) => new Cell(x, y); + + test("preserves order when direct mapping is closer", () => { + const next = [c(10, 10), c(100, 100)]; + const prev = [c(12, 11), c(98, 102)]; + alignClusterOrder(next, prev); + expect(next[0].x).toBe(10); + expect(next[1].x).toBe(100); + }); + + test("swaps when the worker reordered same-size clusters", () => { + // prev[0] is near (10,10), prev[1] is near (100,100); the worker returned + // them in the opposite order. Expect swap so each label sticks to its front. + const next = [c(101, 99), c(11, 12)]; + const prev = [c(10, 10), c(100, 100)]; + alignClusterOrder(next, prev); + expect(next[0].x).toBe(11); + expect(next[1].x).toBe(101); + }); + + test("does not swap on a tie (strict less-than)", () => { + const next = [c(0, 0), c(10, 0)]; + const prev = [c(5, 0), c(5, 0)]; + alignClusterOrder(next, prev); + expect(next[0].x).toBe(0); + expect(next[1].x).toBe(10); + }); + + test("no-op when fewer than two new positions", () => { + const single = [c(99, 99)]; + alignClusterOrder(single, [c(0, 0), c(1000, 1000)]); + expect(single[0].x).toBe(99); + + const empty: Cell[] = []; + alignClusterOrder(empty, [c(0, 0), c(1000, 1000)]); + expect(empty.length).toBe(0); + }); + + test("no-op when either previous slot is null (initial render)", () => { + const next = [c(100, 100), c(0, 0)]; + alignClusterOrder(next, [null, c(0, 0)]); + expect(next[0].x).toBe(100); + expect(next[1].x).toBe(0); + + alignClusterOrder(next, [c(0, 0), null]); + expect(next[0].x).toBe(100); + expect(next[1].x).toBe(0); + + alignClusterOrder(next, [null, null]); + expect(next[0].x).toBe(100); + expect(next[1].x).toBe(0); + }); + + test("no-op when more than two new positions (assumed cap)", () => { + const next = [c(100, 0), c(0, 0), c(50, 0)]; + alignClusterOrder(next, [c(0, 0), c(100, 0)]); + expect(next.map((p) => p.x)).toEqual([100, 0, 50]); }); });