Files
OpenFrontIO/src/client/graphics/layers/AttackingTroopsOverlay.ts
T
Ralfi Salhon 1cbee79cc7 Reduce Attacking Troops Overlay Reflows (#3608)
## Description:

Vimacs on Discord pointed out a heavier than needed DOM load from the
[AttackingTroopsOverlay
PR](https://github.com/openfrontio/OpenFrontIO/pull/3427)

- Caches a single `labelTemplate` in `AttackingTroopsOverlay`, built
once on init and cloned per label instead of recreating it each time
- Removes redundant inline style assignments that were repeated on every
label creation
- Simplifies `updateLabelContent` by accessing template-guaranteed
children directly by index

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

Radyus
2026-04-07 09:51:23 -07:00

312 lines
9.6 KiB
TypeScript

import { assetUrl } from "../../../core/AssetUrls";
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";
const shieldIcon = assetUrl("images/ShieldIconWhite.svg");
const swordIcon = assetUrl("images/SwordIconWhite.svg");
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 labelTemplate: HTMLDivElement;
private labels = new Map<string, AttackLabel>();
// 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.labelTemplate = this.createLabelTemplate();
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<string>();
// 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 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 = "11px";
el.style.fontWeight = "bold";
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";
el.style.transition = "transform 0.2s ease-out";
el.style.width = "max-content";
const icon = document.createElement("img");
icon.style.width = "10px";
icon.style.height = "10px";
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,
): HTMLDivElement {
const el = this.labelTemplate.cloneNode(true) as HTMLDivElement;
el.style.fontFamily = this.game.config().theme().font();
this.updateLabelContent(el, attackerTroops, defenderTroops, isIncoming);
this.container.appendChild(el);
return el;
}
private updateLabelContent(
el: HTMLDivElement,
attackerTroops: number,
defenderTroops: number,
isIncoming: boolean,
) {
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);
}
}
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();
}
}