mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-04 16:02:33 +00:00
Move attack troop overlay to WebGL (#3996)
## Description: Replaces the DOM-based `AttackingTroopsOverlay` with `AttackingTroopsController`, rendering attack troop counts through `WorldTextPass` instead of a separate fixed-position DOM container. ## Summary - New `AttackingTroopsController` polls `attackClusteredPositions()` every 200ms and pushes labels to the WebGL view each frame, lerping cluster positions over 250ms for smooth front-line movement (replaces the old CSS `transform 0.25s` transition). - `WorldTextPass` gains `setAttackTroopLabels()` and renders them at a fixed on-screen size (zoom-independent) using `screenScale / zoom`. - World text now draws on top of `NamePass` so attack callouts aren't hidden behind centered player names. - Fragment shader adds a soft quadratic dark halo around every world-text label; extent uses the remaining SDF range after the hard outline so it fades smoothly to zero (no rectangular clipping). - Deletes `AttackingTroopsOverlay.ts`; existing unit tests repointed to the controller's exported `alignClusterOrder`. <img width="369" height="395" alt="Screenshot 2026-05-24 at 4 43 51 PM" src="https://github.com/user-attachments/assets/4dbffe20-77f9-4c0f-b956-ecf543538f8d" /> ## 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: evan
This commit is contained in:
@@ -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<string, AttackEntry>();
|
||||
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<string>();
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user