Update attack labels (#3784)

## 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.

<img width="498" height="456" alt="Screenshot 2026-04-27 at 12 58 53 PM"
src="https://github.com/user-attachments/assets/ea6928b3-5dfa-47fa-84d2-63e1e81ef6a4"
/>


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
This commit is contained in:
Evan
2026-04-27 20:53:09 -06:00
committed by GitHub
parent 4aa726cfd8
commit 0c0f9c2a81
2 changed files with 230 additions and 65 deletions
@@ -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 (01) 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<HTMLDivElement, string>();
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<string>();
// 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) {
@@ -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]);
});
});