mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-13 20:59:00 +00:00
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.  A recent change updates the layer to just the # of attackers and a symbol for attack/defence:  Left: Perspective of Anon 667 (Blue) | Right: Perspective of Anon332 (Red)  **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 <img width="448" height="410" alt="Settings toggle" src="https://github.com/user-attachments/assets/2df8ec7a-3f77-48b7-a9b5-ee4a6eed0412" /> ## 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
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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<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.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 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();
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="flex gap-3 items-center w-full text-left p-3 hover:bg-slate-700 rounded-sm text-white transition-colors"
|
||||
@click="${this.onToggleAttackingTroopsOverlayButtonClick}"
|
||||
>
|
||||
<img src=${swordIcon} alt="swordIcon" width="20" height="20" />
|
||||
<div class="flex-1">
|
||||
<div class="font-medium">
|
||||
${translateText(
|
||||
"user_setting.attacking_troops_overlay_label",
|
||||
)}
|
||||
</div>
|
||||
<div class="text-sm text-slate-400">
|
||||
${translateText("user_setting.attacking_troops_overlay_desc")}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-sm text-slate-400">
|
||||
${this.userSettings.attackingTroopsOverlay()
|
||||
? translateText("user_setting.on")
|
||||
: translateText("user_setting.off")}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="flex gap-3 items-center w-full text-left p-3 hover:bg-slate-700 rounded-sm text-white transition-colors"
|
||||
@click="${this.onToggleCursorCostLabelButtonClick}"
|
||||
|
||||
Reference in New Issue
Block a user