From 015e3c7d199fdcbf29363258359ba8bea5671ff3 Mon Sep 17 00:00:00 2001 From: Ralfi Salhon Date: Thu, 19 Mar 2026 22:04:33 +0000 Subject: [PATCH] feat: Attacking Troops Overlay (#3427) ## Description: https://troop-advantage-layer.openfront.dev/ Hey OpenFront dev team, I've been really enjoying the game, and the v0.30 changes have felt great so far. Happy to start contributing! This PR introduces `AttackingTroopsOverlay`, a layer that renders live attacker vs. defender troop counts directly on active front lines. Players can immediately gauge combat strength without leaving the map view. ![troop-advantage-layer](https://github.com/user-attachments/assets/9e862812-84b4-46cb-a0c6-65fa50320198) A recent change updates the layer to just the # of attackers and a symbol for attack/defence: ![visual-front-line](https://github.com/user-attachments/assets/46bc7117-2314-44c9-96fc-8a7e9c6ab5cd) Left: Perspective of Anon 667 (Blue) | Right: Perspective of Anon332 (Red) ![ezgif-6261e6669d6b972b](https://github.com/user-attachments/assets/734d90c1-8f22-44dc-8f2f-b22e46676f46) **How it works:** - Attacker count shown for ground invasions. When attacking, your troop count will display amber for disadvantageous, and green for advantageous battles. When defending, the enemy troop count will switch to red if you are at a severe disadvantage. - Label position recalculates every tick at 200ms, tracking the front line as it moves. - Automatically hidden during Terrain view (spacebar) - Labels clean up when an attack ends or its target becomes invalid **Settings:** An "Attacking Troops Overlay" toggle is added to Settings, enabled by default. --> the screenshot is old, but the text has been updated Settings toggle ## Checklist - [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 ## Discord Radyus --- resources/lang/en.json | 2 + src/client/graphics/GameRenderer.ts | 2 + .../graphics/layers/AttackingTroopsOverlay.ts | 305 ++++++++++++++++++ src/client/graphics/layers/AttacksDisplay.ts | 12 +- src/client/graphics/layers/SettingsModal.ts | 28 ++ src/core/GameRunner.ts | 29 +- src/core/game/AttackImpl.ts | 97 ++++-- src/core/game/Game.ts | 2 +- src/core/game/GameView.ts | 9 +- src/core/game/UserSettings.ts | 8 + src/core/worker/Worker.worker.ts | 21 +- src/core/worker/WorkerClient.ts | 41 ++- src/core/worker/WorkerMessages.ts | 22 +- .../layers/AttackingTroopsOverlay.test.ts | 32 ++ 14 files changed, 526 insertions(+), 84 deletions(-) create mode 100644 src/client/graphics/layers/AttackingTroopsOverlay.ts create mode 100644 tests/client/graphics/layers/AttackingTroopsOverlay.test.ts diff --git a/resources/lang/en.json b/resources/lang/en.json index 7e1fbf8fe..56468b246 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -540,6 +540,8 @@ "territory_patterns_desc": "Choose whether to display territory skin designs in game", "coordinate_grid_label": "Coordinate Grid", "coordinate_grid_desc": "Toggle the alphanumeric grid overlay", + "attacking_troops_overlay_label": "Attacking Troops Overlay", + "attacking_troops_overlay_desc": "Show attacker vs defender troop counts on active front lines.", "performance_overlay_label": "Performance Overlay", "performance_overlay_desc": "Toggle the performance overlay. When enabled, the performance overlay will be displayed. Press shift-D during game to toggle.", "easter_writing_speed_label": "Writing Speed Multiplier", diff --git a/src/client/graphics/GameRenderer.ts b/src/client/graphics/GameRenderer.ts index 81d12fc79..1937b4a3e 100644 --- a/src/client/graphics/GameRenderer.ts +++ b/src/client/graphics/GameRenderer.ts @@ -7,6 +7,7 @@ import { FrameProfiler } from "./FrameProfiler"; import { TransformHandler } from "./TransformHandler"; import { UIState } from "./UIState"; 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"; @@ -284,6 +285,7 @@ export function createRenderer( new StructureIconsLayer(game, eventBus, uiState, transformHandler), new DynamicUILayer(game, transformHandler, eventBus), new NameLayer(game, transformHandler, eventBus), + new AttackingTroopsOverlay(game, transformHandler, eventBus, userSettings), eventsDisplay, attacksDisplay, chatDisplay, diff --git a/src/client/graphics/layers/AttackingTroopsOverlay.ts b/src/client/graphics/layers/AttackingTroopsOverlay.ts new file mode 100644 index 000000000..ccad9f38b --- /dev/null +++ b/src/client/graphics/layers/AttackingTroopsOverlay.ts @@ -0,0 +1,305 @@ +import { EventBus } from "../../../core/EventBus"; +import { Cell } 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"; +import shieldIcon from "/images/ShieldIconWhite.svg?url"; +import swordIcon from "/images/SwordIconWhite.svg?url"; + +export function troopAttackColor( + attackerTroops: number, + defenderTroops: number, +): string { + return attackerTroops > defenderTroops ? "#66ff66" : "#ffbe3c"; +} + +export function troopDefenceColor( + attackerTroops: number, + myTroops: number, +): string { + return attackerTroops > myTroops ? "#ff4444" : "#ff9944"; +} + +// 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; + defenderTroops: number; +} + +export class AttackingTroopsOverlay implements Layer { + private container: 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; + + constructor( + private readonly game: GameView, + private readonly transformHandler: TransformHandler, + private readonly eventBus: EventBus, + private readonly userSettings: UserSettings, + ) {} + + shouldTransform(): boolean { + return false; + } + + 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.onAlternateView = (e) => { + this.isVisible = !e.alternateView; + this.container.style.display = this.isVisible ? "" : "none"; + }; + this.eventBus.on(AlternateViewEvent, this.onAlternateView); + } + + destroy() { + if (!this.container) return; + this.clearAllLabels(); + this.container.remove(); + this.eventBus.off(AlternateViewEvent, this.onAlternateView); + } + + getTickIntervalMs() { + return 200; + } + + 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 attacks — green if winning, amber if losing. + 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, defender.troops(), false); + } + + // Incoming attacks — red if the attacker outnumbers the player, orange otherwise. + for (const attack of myPlayer.incomingAttacks()) { + activeIDs.add(attack.id); + const attacker = this.game.playerBySmallID(attack.attackerID); + if (!attacker || !attacker.isPlayer()) { + this.removeLabel(attack.id); + continue; + } + this.ensureLabel(attack.id, attack.troops, myPlayer.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, + defenderTroops: number, + isIncoming: boolean, + ) { + let label = this.labels.get(attackID); + if (!label) { + label = { + elements: [], + positions: [], + isIncoming, + attackerTroops, + defenderTroops, + }; + this.labels.set(attackID, label); + } else { + label.attackerTroops = attackerTroops; + label.defenderTroops = defenderTroops; + } + for (const el of label.elements) { + this.updateLabelContent(el, attackerTroops, defenderTroops, isIncoming); + } + } + + renderLayer(_context: CanvasRenderingContext2D) { + 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})`; + + 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 = "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})`; + } + } + } + + 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.defenderTroops, + 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(); + } + + // Snap large jumps instantly; let the CSS transition handle small advances. + 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) { + 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})`; + requestAnimationFrame(() => { + el.style.transition = "transform 0.2s ease-out"; + }); + } + lbl.positions[i] = next; + } + } + + private createLabelElement( + attackerTroops: number, + defenderTroops: number, + isIncoming: boolean, + ): HTMLDivElement { + const el = document.createElement("div"); + el.style.position = "absolute"; + el.style.display = "none"; + el.style.alignItems = "center"; + el.style.gap = "3px"; + el.style.width = "max-content"; + el.style.whiteSpace = "nowrap"; + el.style.fontSize = "11px"; + el.style.fontWeight = "bold"; + el.style.fontFamily = this.game.config().theme().font(); + el.style.padding = "1px 4px"; + el.style.borderRadius = "3px"; + el.style.backgroundColor = "rgba(0,0,0,0.55)"; + el.style.pointerEvents = "none"; + el.style.lineHeight = "1.3"; + // Smooth the label to its new position as the front line advances. + el.style.transition = "transform 0.2s ease-out"; + this.updateLabelContent(el, attackerTroops, defenderTroops, isIncoming); + this.container.appendChild(el); + return el; + } + + private updateLabelContent( + el: HTMLDivElement, + attackerTroops: number, + defenderTroops: number, + isIncoming: boolean, + ) { + // Reuse existing children to avoid DOM churn on every tick. + let icon = el.querySelector("img") as HTMLImageElement | null; + let span = el.querySelector("span") as HTMLSpanElement | null; + if (!icon || !span) { + icon = document.createElement("img"); + icon.style.width = "10px"; + icon.style.height = "10px"; + span = document.createElement("span"); + el.replaceChildren(icon, span); + } + + 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); + } + } + + 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/graphics/layers/AttacksDisplay.ts b/src/client/graphics/layers/AttacksDisplay.ts index 830bbe6a0..f7f2a68e7 100644 --- a/src/client/graphics/layers/AttacksDisplay.ts +++ b/src/client/graphics/layers/AttacksDisplay.ts @@ -184,17 +184,13 @@ export class AttacksDisplay extends LitElement implements Layer { const playerView = this.game.playerBySmallID(attack.attackerID); if (playerView !== undefined) { if (playerView instanceof PlayerView) { - const averagePosition = await playerView.attackAveragePosition( - attack.attackerID, - attack.id, - ); + const attacks = await playerView.attackClusteredPositions(attack.id); + const pos = attacks[0]?.positions[0]; - if (averagePosition === null) { + if (!pos) { this.emitGoToPlayerEvent(attack.attackerID); } else { - this.eventBus.emit( - new GoToPositionEvent(averagePosition.x, averagePosition.y), - ); + this.eventBus.emit(new GoToPositionEvent(pos.x, pos.y)); } } } else { diff --git a/src/client/graphics/layers/SettingsModal.ts b/src/client/graphics/layers/SettingsModal.ts index 0a5b184bf..8a285a661 100644 --- a/src/client/graphics/layers/SettingsModal.ts +++ b/src/client/graphics/layers/SettingsModal.ts @@ -18,6 +18,7 @@ import mouseIcon from "/images/MouseIconWhite.svg?url"; import ninjaIcon from "/images/NinjaIconWhite.svg?url"; import settingsIcon from "/images/SettingIconWhite.svg?url"; import sirenIcon from "/images/SirenIconWhite.svg?url"; +import swordIcon from "/images/SwordIconWhite.svg?url"; import treeIcon from "/images/TreeIconWhite.svg?url"; import musicIcon from "/images/music.svg?url"; @@ -163,6 +164,11 @@ export class SettingsModal extends LitElement implements Layer { this.requestUpdate(); } + private onToggleAttackingTroopsOverlayButtonClick() { + this.userSettings.toggleAttackingTroopsOverlay(); + this.requestUpdate(); + } + private onTogglePerformanceOverlayButtonClick() { this.userSettings.togglePerformanceOverlay(); this.requestUpdate(); @@ -408,6 +414,28 @@ export class SettingsModal extends LitElement implements Layer { + +