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); }); });