mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-02 07:48:09 +00:00
1cbee79cc7
## 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
312 lines
9.6 KiB
TypeScript
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();
|
|
}
|
|
}
|