diff --git a/src/client/controllers/AttackingTroopsController.ts b/src/client/controllers/AttackingTroopsController.ts new file mode 100644 index 000000000..f105368c9 --- /dev/null +++ b/src/client/controllers/AttackingTroopsController.ts @@ -0,0 +1,261 @@ +/** + * AttackingTroopsController — pushes attack troop labels to the WebGL + * WorldTextPass. Replaces the DOM-based AttackingTroopsOverlay. + * + * Per-tick (200ms) it polls the worker for clustered front-line positions of + * each active attack involving the local player. Between polls it interpolates + * smoothly from the previously-rendered position to the new target — the + * worker poll cadence (200ms) and the animation duration (250ms) are matched + * to what the old CSS transition did. + */ +import { EventBus } from "../../core/EventBus"; +import { Cell, PlayerType } from "../../core/game/Game"; +import { GameView } from "../../core/game/GameView"; +import { UserSettings } from "../../core/game/UserSettings"; +import { Controller } from "../Controller"; +import { AlternateViewEvent } from "../InputHandler"; +import { GameView as WebGLGameView } from "../render/gl"; +import type { AttackTroopLabel } from "../render/gl/passes/WorldTextPass"; +import { renderTroops } from "../Utils"; + +// Aquarius (#3fa9f5) for outgoing, red-400 (#f87171) for incoming. +const OUTGOING_R = 0x3f / 255; +const OUTGOING_G = 0xa9 / 255; +const OUTGOING_B = 0xf5 / 255; +const INCOMING_R = 0xf8 / 255; +const INCOMING_G = 0x71 / 255; +const INCOMING_B = 0x71 / 255; + +/** Animation duration for cluster shifts — matches old CSS transition. */ +const ANIM_MS = 250; +/** Snap (no animation) when the worker reports a jump larger than this. */ +const SNAP_DISTANCE = 200; + +export interface Slot { + /** Last-rendered (interpolated) position. */ + curX: number; + curY: number; + /** Animation start position. */ + srcX: number; + srcY: number; + /** Animation target position. */ + dstX: number; + dstY: number; + /** Animation start time (performance.now). */ + startMs: number; +} + +interface AttackEntry { + text: string; + isIncoming: boolean; + slots: Slot[]; +} + +export function alignClusterOrder(next: Cell[], prev: Slot[]): void { + if (next.length !== 2 || prev.length !== 2) return; + const dist = (a: number, b: number, c: Cell) => + Math.abs(c.x - a) + Math.abs(c.y - b); + const direct = + dist(prev[0].dstX, prev[0].dstY, next[0]) + + dist(prev[1].dstX, prev[1].dstY, next[1]); + const swapped = + dist(prev[0].dstX, prev[0].dstY, next[1]) + + dist(prev[1].dstX, prev[1].dstY, next[0]); + if (swapped < direct) [next[0], next[1]] = [next[1], next[0]]; +} + +export class AttackingTroopsController implements Controller { + private attacks = new Map(); + private inFlightRequest = false; + private alternateView = false; + /** Reused buffer pushed to the view each frame. */ + private labelBuf: AttackTroopLabel[] = []; + + constructor( + private readonly game: GameView, + private readonly eventBus: EventBus, + private readonly userSettings: UserSettings, + private readonly view: WebGLGameView, + ) {} + + init() { + this.eventBus.on(AlternateViewEvent, (e) => { + this.alternateView = e.alternateView; + }); + + const drive = () => { + this.pushLabels(); + requestAnimationFrame(drive); + }; + requestAnimationFrame(drive); + } + + getTickIntervalMs() { + return 200; + } + + tick() { + if (!this.userSettings.attackingTroopsOverlay() || this.alternateView) { + if (this.attacks.size > 0) this.attacks.clear(); + return; + } + + const myPlayer = this.game.myPlayer(); + if (!myPlayer) { + this.attacks.clear(); + return; + } + + const activeIDs = new Set(); + + // Outgoing: only label attacks targeting another player. + for (const attack of myPlayer.outgoingAttacks()) { + if (!attack.targetID) continue; + const defender = this.game.playerBySmallID(attack.targetID); + if (!defender || !defender.isPlayer()) continue; + activeIDs.add(attack.id); + this.ensureEntry(attack.id, attack.troops, false); + } + + // Incoming: only label attacks coming from another player; skip tribes. + for (const attack of myPlayer.incomingAttacks()) { + const attacker = this.game.playerBySmallID(attack.attackerID); + if ( + !attacker || + !attacker.isPlayer() || + attacker.type() === PlayerType.Bot + ) { + continue; + } + activeIDs.add(attack.id); + this.ensureEntry(attack.id, attack.troops, true); + } + + for (const id of this.attacks.keys()) { + if (!activeIDs.has(id)) this.attacks.delete(id); + } + + // Single worker request per tick; skip if the previous one is still in flight. + if (this.inFlightRequest) return; + this.inFlightRequest = true; + + void myPlayer + .attackClusteredPositions() + .then((attacks) => { + const now = performance.now(); + for (const { id, positions } of attacks) { + const entry = this.attacks.get(id); + if (!entry) continue; + this.reconcileSlots(entry, positions, now); + } + }) + .catch(() => { + // On error, hide all labels until the next successful response. + this.attacks.clear(); + }) + .finally(() => { + this.inFlightRequest = false; + }); + } + + private ensureEntry(attackID: string, troops: number, isIncoming: boolean) { + const text = renderTroops(troops); + const existing = this.attacks.get(attackID); + if (existing) { + existing.text = text; + existing.isIncoming = isIncoming; + return; + } + this.attacks.set(attackID, { text, isIncoming, slots: [] }); + } + + private reconcileSlots( + entry: AttackEntry, + positions: Cell[], + now: number, + ): void { + alignClusterOrder(positions, entry.slots); + + // Trim extra slots. + if (entry.slots.length > positions.length) { + entry.slots.length = positions.length; + } + + for (let i = 0; i < positions.length; i++) { + const next = positions[i]; + const slot = entry.slots[i]; + if (!slot) { + // New slot — snap to position (no animation). + entry.slots.push({ + curX: next.x, + curY: next.y, + srcX: next.x, + srcY: next.y, + dstX: next.x, + dstY: next.y, + startMs: now, + }); + continue; + } + // Compute the current (interpolated) position so animations chain from + // wherever the label is right now, not from the previous target. + const t = Math.min(1, (now - slot.startMs) / ANIM_MS); + const curX = slot.srcX + (slot.dstX - slot.srcX) * t; + const curY = slot.srcY + (slot.dstY - slot.srcY) * t; + + const jump = Math.hypot(next.x - curX, next.y - curY); + if (jump > SNAP_DISTANCE) { + slot.curX = next.x; + slot.curY = next.y; + slot.srcX = next.x; + slot.srcY = next.y; + slot.dstX = next.x; + slot.dstY = next.y; + slot.startMs = now; + } else { + slot.srcX = curX; + slot.srcY = curY; + slot.curX = curX; + slot.curY = curY; + slot.dstX = next.x; + slot.dstY = next.y; + slot.startMs = now; + } + } + } + + private pushLabels(): void { + if (this.alternateView || this.attacks.size === 0) { + if (this.labelBuf.length > 0) { + this.labelBuf = []; + this.view.setAttackTroopLabels(this.labelBuf); + } + return; + } + + const now = performance.now(); + const out: AttackTroopLabel[] = []; + + for (const entry of this.attacks.values()) { + const r = entry.isIncoming ? INCOMING_R : OUTGOING_R; + const g = entry.isIncoming ? INCOMING_G : OUTGOING_G; + const b = entry.isIncoming ? INCOMING_B : OUTGOING_B; + for (const slot of entry.slots) { + const t = Math.min(1, (now - slot.startMs) / ANIM_MS); + slot.curX = slot.srcX + (slot.dstX - slot.srcX) * t; + slot.curY = slot.srcY + (slot.dstY - slot.srcY) * t; + out.push({ + x: slot.curX, + y: slot.curY, + text: entry.text, + colorR: r, + colorG: g, + colorB: b, + }); + } + } + + this.labelBuf = out; + this.view.setAttackTroopLabels(out); + } +} diff --git a/src/client/hud/GameRenderer.ts b/src/client/hud/GameRenderer.ts index aa5bf7e87..eb9cb3944 100644 --- a/src/client/hud/GameRenderer.ts +++ b/src/client/hud/GameRenderer.ts @@ -5,6 +5,7 @@ import { Controller } from "../Controller"; import { GameStartingModal } from "../GameStartingModal"; import { TransformHandler } from "../TransformHandler"; import { UIState } from "../UIState"; +import { AttackingTroopsController } from "../controllers/AttackingTroopsController"; import { BuildPreviewController } from "../controllers/BuildPreviewController"; import { HoverHighlightController } from "../controllers/HoverHighlightController"; import { StructureHighlightController } from "../controllers/StructureHighlightController"; @@ -14,7 +15,6 @@ import { GameView as WebGLGameView } from "../render/gl"; import { FrameProfiler } from "./FrameProfiler"; import { ActionableEvents } from "./layers/ActionableEvents"; import { AlertFrame } from "./layers/AlertFrame"; -import { AttackingTroopsOverlay } from "./layers/AttackingTroopsOverlay"; import { AttacksDisplay } from "./layers/AttacksDisplay"; import { BuildMenu } from "./layers/BuildMenu"; import { ChatDisplay } from "./layers/ChatDisplay"; @@ -283,7 +283,7 @@ export function createRenderer( new HoverHighlightController(game, eventBus, transformHandler, view), new StructureHighlightController(eventBus, view), new ViewModeController(eventBus, view), - new AttackingTroopsOverlay(game, transformHandler, eventBus, userSettings), + new AttackingTroopsController(game, eventBus, userSettings, view), eventsDisplay, actionableEvents, attacksDisplay, diff --git a/src/client/hud/layers/AttackingTroopsOverlay.ts b/src/client/hud/layers/AttackingTroopsOverlay.ts deleted file mode 100644 index bcc434201..000000000 --- a/src/client/hud/layers/AttackingTroopsOverlay.ts +++ /dev/null @@ -1,338 +0,0 @@ -import { EventBus } from "../../../core/EventBus"; -import { Cell, PlayerType } from "../../../core/game/Game"; -import { GameView } from "../../../core/game/GameView"; -import { UserSettings } from "../../../core/game/UserSettings"; -import { Controller } from "../../Controller"; -import { AlternateViewEvent } from "../../InputHandler"; -import { TransformHandler } from "../../TransformHandler"; -import { renderTroops } from "../../Utils"; - -// 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 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; - -// 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 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 -// 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 -// and positions are parallel arrays with one entry per segment. -interface AttackLabel { - elements: HTMLDivElement[]; - positions: (Cell | null)[]; - isIncoming: boolean; - attackerTroops: number; -} - -export class AttackingTroopsOverlay implements Controller { - private container: HTMLDivElement; - private labelTemplate: HTMLDivElement; - private labels = new Map(); - // Guard against queuing multiple worker requests in the same tick window. - 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, - private readonly transformHandler: TransformHandler, - private readonly eventBus: EventBus, - private readonly userSettings: UserSettings, - ) {} - - init() { - this.container = document.createElement("div"); - this.container.style.position = "fixed"; - this.container.style.left = "50%"; - this.container.style.top = "50%"; - this.container.style.pointerEvents = "none"; - // z-index 4 places labels above NameLayer (z-index 3). - this.container.style.zIndex = "4"; - document.body.appendChild(this.container); - - this.labelTemplate = this.createLabelTemplate(); - - this.onAlternateView = (e) => { - this.isVisible = !e.alternateView; - this.container.style.display = this.isVisible ? "" : "none"; - }; - this.eventBus.on(AlternateViewEvent, this.onAlternateView); - - // Self-driven RAF: DOM label positions must update every frame so the - // labels track the WebGL camera as the user pans/zooms. (Previously this - // ran via the now-deleted canvas2D RAF loop.) - const drive = () => { - this.updateLabelDOM(); - requestAnimationFrame(drive); - }; - requestAnimationFrame(drive); - } - - destroy() { - if (!this.container) return; - this.clearAllLabels(); - this.container.remove(); - this.eventBus.off(AlternateViewEvent, this.onAlternateView); - } - - getTickIntervalMs() { - return 200; - } - - private labelScale(): number { - return computeLabelScale(this.transformHandler.scale); - } - - tick() { - if (!this.userSettings.attackingTroopsOverlay() || !this.isVisible) { - if (this.labels.size > 0) this.clearAllLabels(); - return; - } - - const myPlayer = this.game.myPlayer(); - if (!myPlayer) { - this.clearAllLabels(); - return; - } - - const activeIDs = new Set(); - - // Outgoing: only label attacks targeting another player. - for (const attack of myPlayer.outgoingAttacks()) { - activeIDs.add(attack.id); - if (!attack.targetID) { - this.removeLabel(attack.id); - continue; - } - const defender = this.game.playerBySmallID(attack.targetID); - if (!defender || !defender.isPlayer()) { - this.removeLabel(attack.id); - continue; - } - this.ensureLabel(attack.id, attack.troops, false); - } - - // 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() || - attacker.type() === PlayerType.Bot - ) { - this.removeLabel(attack.id); - continue; - } - this.ensureLabel(attack.id, attack.troops, true); - } - - for (const [id] of this.labels) { - if (!activeIDs.has(id)) this.removeLabel(id); - } - - // Single worker request per tick; skip if the previous one is still in flight. - if (this.inFlightRequest) return; - this.inFlightRequest = true; - - void myPlayer - .attackClusteredPositions() - .then((attacks) => { - for (const { id, positions } of attacks) { - const lbl = this.labels.get(id); - if (!lbl) continue; - this.reconcileLabelPositions(lbl, positions); - } - }) - .catch(() => { - // On error, hide all labels until the next successful response. - for (const lbl of this.labels.values()) lbl.positions.fill(null); - }) - .finally(() => { - this.inFlightRequest = false; - }); - } - - private ensureLabel( - attackID: string, - attackerTroops: number, - isIncoming: boolean, - ) { - let label = this.labels.get(attackID); - if (!label) { - label = { - elements: [], - positions: [], - isIncoming, - attackerTroops, - }; - this.labels.set(attackID, label); - } else { - label.attackerTroops = attackerTroops; - } - for (const el of label.elements) { - this.updateLabelContent(el, attackerTroops); - } - } - - private updateLabelDOM() { - const screenPosOld = this.transformHandler.worldToScreenCoordinates( - new Cell(0, 0), - ); - const screenPos = new Cell( - screenPosOld.x - window.innerWidth / 2, - screenPosOld.y - window.innerHeight / 2, - ); - 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(); - 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]; - const pos = label.positions[i]; - - if (!pos || !this.transformHandler.isOnScreen(pos)) { - el.style.display = "none"; - continue; - } - - 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; - } - } - } - - private reconcileLabelPositions(lbl: AttackLabel, positions: Cell[]) { - // Add elements for new clusters. - while (lbl.elements.length < positions.length) { - lbl.elements.push( - this.createLabelElement(lbl.attackerTroops, lbl.isIncoming), - ); - lbl.positions.push(null); - } - - // Remove elements for clusters that no longer exist. - while (lbl.elements.length > positions.length) { - lbl.elements.pop()!.remove(); - lbl.positions.pop(); - } - - 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) > 200) { - const el = lbl.elements[i]; - el.style.transition = "none"; - 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"; - }); - } - lbl.positions[i] = next; - } - } - - // 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 outer = document.createElement("div"); - outer.style.position = "absolute"; - outer.style.display = "none"; - outer.style.pointerEvents = "none"; - outer.style.transition = "transform 0.25s linear"; - - 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); - - return outer; - } - - private createLabelElement( - attackerTroops: number, - isIncoming: boolean, - ): HTMLDivElement { - const el = this.labelTemplate.cloneNode(true) as HTMLDivElement; - 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) { - const inner = el.children[0] as HTMLDivElement; - inner.textContent = renderTroops(attackerTroops); - } - - private removeLabel(attackID: string) { - const label = this.labels.get(attackID); - if (!label) return; - for (const el of label.elements) el.remove(); - this.labels.delete(attackID); - } - - private clearAllLabels() { - for (const label of this.labels.values()) { - for (const el of label.elements) el.remove(); - } - this.labels.clear(); - } -} diff --git a/src/client/render/gl/GameView.ts b/src/client/render/gl/GameView.ts index 3a84a2a95..fc689e5f9 100644 --- a/src/client/render/gl/GameView.ts +++ b/src/client/render/gl/GameView.ts @@ -30,6 +30,7 @@ import type { RadialMenuItem, } from "./Events"; import type { SpawnCenter } from "./passes/SpawnOverlayPass"; +import type { AttackTroopLabel } from "./passes/WorldTextPass"; import { GPURenderer } from "./Renderer"; import type { RenderSettings } from "./RenderSettings"; @@ -289,6 +290,9 @@ export class GameView { applyConquestEvents(events: ConquestFx[]): void { this.renderer?.applyConquestEvents(events); } + setAttackTroopLabels(labels: AttackTroopLabel[]): void { + this.renderer?.setAttackTroopLabels(labels); + } applyBonusEvents(events: BonusEvent[]): void { this.renderer?.applyBonusEvents(events); } diff --git a/src/client/render/gl/Renderer.ts b/src/client/render/gl/Renderer.ts index b9a01518e..8c1e5b349 100644 --- a/src/client/render/gl/Renderer.ts +++ b/src/client/render/gl/Renderer.ts @@ -734,6 +734,12 @@ export class GPURenderer { } } + setAttackTroopLabels( + labels: import("./passes/WorldTextPass").AttackTroopLabel[], + ): void { + this.worldTextPass.setAttackTroopLabels(labels); + } + applyBonusEvents(events: BonusEvent[]): void { if (events.length === 0) return; // In live game, filter to local player only. In replay (localPlayerID=0), show all. @@ -1173,15 +1179,17 @@ export class GPURenderer { this.fxPass.draw(cam, zoom); } - this.worldTextPass.tick(); - this.worldTextPass.draw(cam, zoom); - // Grid shows on either trigger; names hide only under alt-view (space // hold), not under the persistent M-key gridView toggle. if (this.gridView || this.altView) this.coordinateGridPass.draw(cam, zoom); if (pe.name && !this.altView) this.namePass.draw(cam, this.nightCompositePass.getAmbient()); + // World text (attack-troop labels, popups, ghost cost) draws on top of + // player names so attack callouts aren't hidden behind a centered name. + this.worldTextPass.tick(zoom); + this.worldTextPass.draw(cam, zoom); + this.radialMenuPass.draw(); gl.disable(gl.BLEND); diff --git a/src/client/render/gl/passes/WorldTextPass.ts b/src/client/render/gl/passes/WorldTextPass.ts index e4da633f0..5feef24b2 100644 --- a/src/client/render/gl/passes/WorldTextPass.ts +++ b/src/client/render/gl/passes/WorldTextPass.ts @@ -44,6 +44,13 @@ const GHOST_COST_Y_OFFSET = 3; const GHOST_COST_SCALE = 4; /** Matches player-name outline width for a consistent UI look. */ const GHOST_COST_OUTLINE_WIDTH = 1.4; +/** + * Screen-relative em scale for attack troop labels. Pre-divided by the current + * zoom each frame so the on-screen label size stays constant regardless of + * how far the camera is zoomed. + */ +const ATTACK_LABEL_SCREEN_SCALE = 34.0; +const ATTACK_LABEL_OUTLINE_WIDTH = 1.2; // --------------------------------------------------------------------------- // Active popup tracking @@ -63,6 +70,20 @@ interface ActivePopup { outlineWidth: number; } +/** + * Persistent attack-troop label rendered at a world-space position. + * AttackingTroopsController pushes a fresh list each frame with already- + * interpolated positions (smoothing happens controller-side). + */ +export interface AttackTroopLabel { + x: number; + y: number; + text: string; + colorR: number; + colorG: number; + colorB: number; +} + function formatGold(gold: number): string { if (gold >= 1_000_000) return (gold / 1_000_000).toFixed(1) + "M"; if (gold >= 1_000) return (gold / 1_000).toFixed(1) + "K"; @@ -119,6 +140,10 @@ export class WorldTextPass { colorB: number; } | null = null; + // Persistent attack-troop labels. Controller pushes the full list each frame + // (already interpolated), so we just iterate and render. + private attackTroopLabels: AttackTroopLabel[] = []; + // Settings reference private settings: RenderSettings; @@ -336,12 +361,24 @@ export class WorldTextPass { }; } + /** + * Replace the set of attack-troop labels. Controller pushes the full list + * each frame with interpolated positions; empty array clears them. + */ + setAttackTroopLabels(labels: AttackTroopLabel[]): void { + this.attackTroopLabels = labels; + } + // ------------------------------------------------------------------------- // Tick — cull expired, rebuild instance buffer // ------------------------------------------------------------------------- - tick(): void { - if (this.active.length === 0 && this.ghostCostLabel === null) { + tick(zoom: number): void { + if ( + this.active.length === 0 && + this.ghostCostLabel === null && + this.attackTroopLabels.length === 0 + ) { this.instanceCount = 0; return; } @@ -355,10 +392,10 @@ export class WorldTextPass { } } - this.rebuildInstances(now); + this.rebuildInstances(now, zoom); } - private rebuildInstances(now: number): void { + private rebuildInstances(now: number, zoom: number): void { let count = 0; for (const popup of this.active) { @@ -402,6 +439,38 @@ export class WorldTextPass { } } + // Attack troop labels — persistent, no fade. Controller interpolates + // positions before pushing. Scale is divided by zoom so the label keeps + // a constant on-screen size regardless of how zoomed-in the camera is. + const attackScale = ATTACK_LABEL_SCREEN_SCALE / Math.max(zoom, 0.0001); + for (const label of this.attackTroopLabels) { + layoutString( + label.text, + this.glyph, + this.kernTable, + this.charCodes, + this.cursors, + ); + const len = Math.min(label.text.length, MAX_CHARS); + for (let i = 0; i < len; i++) { + if (this.charCodes[i] === 0) continue; + if (count >= this.maxInstances) this.growBuffer(); + + const off = count * FLOATS_PER_INSTANCE; + this.instanceData[off + 0] = label.x; + this.instanceData[off + 1] = label.y; + this.instanceData[off + 2] = this.cursors[i]; + this.instanceData[off + 3] = this.charCodes[i]; + this.instanceData[off + 4] = 1; + this.instanceData[off + 5] = label.colorR; + this.instanceData[off + 6] = label.colorG; + this.instanceData[off + 7] = label.colorB; + this.instanceData[off + 8] = attackScale; + this.instanceData[off + 9] = ATTACK_LABEL_OUTLINE_WIDTH; + count++; + } + } + // Ghost cost label — persistent, no fade or rise. layoutString already // centers cursors around 0, so passing the tile coord places the text // centered on the tile (vertex shader adds the +0.5 tile-center offset). @@ -495,6 +564,7 @@ export class WorldTextPass { clear(): void { this.active.length = 0; + this.attackTroopLabels = []; this.instanceCount = 0; } diff --git a/src/client/render/gl/shaders/world-text/world-text.frag.glsl b/src/client/render/gl/shaders/world-text/world-text.frag.glsl index 870ce6f0e..b58f083bc 100644 --- a/src/client/render/gl/shaders/world-text/world-text.frag.glsl +++ b/src/client/render/gl/shaders/world-text/world-text.frag.glsl @@ -33,6 +33,17 @@ void main() { float outlineDist = screenPxDist + effectiveOutline; float outlineAlpha = clamp(outlineDist + 0.5, 0.0, 1.0); + // Soft dark halo extending past the hard outline — easier to read over + // busy terrain. Uses whatever SDF range is left after the hard outline so + // it can always fade fully to zero before hitting the SDF saturation + // boundary (otherwise the glow clips at the glyph quad and looks square). + // Quadratic falloff for a true glow shape — brightest right next to the + // text, smoothly thinning to nothing. + float glowExtent = max(screenPxRange * 0.5 - effectiveOutline - 0.5, 0.0); + float glowT = + clamp((outlineDist + glowExtent) / max(glowExtent, 0.0001), 0.0, 1.0); + float glowAlpha = glowExtent > 0.0 ? glowT * glowT * 0.75 : 0.0; + vec3 color = mix(vec3(0.0), vColor, fillAlpha); - fragColor = vec4(color, outlineAlpha * vAlpha); + fragColor = vec4(color, max(outlineAlpha, glowAlpha) * vAlpha); } diff --git a/tests/client/graphics/layers/AttackingTroopsOverlay.test.ts b/tests/client/graphics/layers/AttackingTroopsOverlay.test.ts index 031319cbc..48ba9deed 100644 --- a/tests/client/graphics/layers/AttackingTroopsOverlay.test.ts +++ b/tests/client/graphics/layers/AttackingTroopsOverlay.test.ts @@ -1,38 +1,21 @@ import { describe, expect, test } from "vitest"; import { alignClusterOrder, - computeLabelScale, -} from "../../../../src/client/hud/layers/AttackingTroopsOverlay"; + Slot, +} from "../../../../src/client/controllers/AttackingTroopsController"; import { Cell } from "../../../../src/core/game/Game"; -describe("computeLabelScale", () => { - // 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("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("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("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); - }); +// Slots only need the `dst` fields populated for `alignClusterOrder` — it +// compares the new positions against the previous targets to decide whether +// the worker reordered same-size clusters. +const slot = (x: number, y: number): Slot => ({ + curX: x, + curY: y, + srcX: x, + srcY: y, + dstX: x, + dstY: y, + startMs: 0, }); describe("alignClusterOrder", () => { @@ -40,7 +23,7 @@ describe("alignClusterOrder", () => { test("preserves order when direct mapping is closer", () => { const next = [c(10, 10), c(100, 100)]; - const prev = [c(12, 11), c(98, 102)]; + const prev = [slot(12, 11), slot(98, 102)]; alignClusterOrder(next, prev); expect(next[0].x).toBe(10); expect(next[1].x).toBe(100); @@ -50,7 +33,7 @@ describe("alignClusterOrder", () => { // 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)]; + const prev = [slot(10, 10), slot(100, 100)]; alignClusterOrder(next, prev); expect(next[0].x).toBe(11); expect(next[1].x).toBe(101); @@ -58,7 +41,7 @@ describe("alignClusterOrder", () => { 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)]; + const prev = [slot(5, 0), slot(5, 0)]; alignClusterOrder(next, prev); expect(next[0].x).toBe(0); expect(next[1].x).toBe(10); @@ -66,32 +49,28 @@ describe("alignClusterOrder", () => { test("no-op when fewer than two new positions", () => { const single = [c(99, 99)]; - alignClusterOrder(single, [c(0, 0), c(1000, 1000)]); + alignClusterOrder(single, [slot(0, 0), slot(1000, 1000)]); expect(single[0].x).toBe(99); const empty: Cell[] = []; - alignClusterOrder(empty, [c(0, 0), c(1000, 1000)]); + alignClusterOrder(empty, [slot(0, 0), slot(1000, 1000)]); expect(empty.length).toBe(0); }); - test("no-op when either previous slot is null (initial render)", () => { + test("no-op when fewer than two previous slots (initial render)", () => { const next = [c(100, 100), c(0, 0)]; - alignClusterOrder(next, [null, c(0, 0)]); + alignClusterOrder(next, [slot(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]); + alignClusterOrder(next, []); 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)]); + alignClusterOrder(next, [slot(0, 0), slot(100, 0)]); expect(next.map((p) => p.x)).toEqual([100, 0, 50]); }); });