mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 07:40:43 +00:00
aa4b490e68
## Summary The WebGL renderer was adapted from an external extension and carried a lot of machinery this integration never uses (replay playback, its own input/event system, a GL radial menu). This PR is two mechanical cleanup passes with **no behavior change**: delete the dead code, then untangle the `GameView` naming collision. **78 files, +142 / −2,197.** ### Pass 1 — remove dead extension baggage - **Replay/copy mode**: `FrameData.tileMode` was hard-coded `"live"`; the copy branches in `frame/Upload.ts`, `UploadOptions` (never passed), `applyFullFrame`/`applyFullTiles`/`applyDelta` on the facade and `GPURenderer`, `HeatManager.resetForSeek`, and the seek-upload methods on `TerritoryPass`/`TrailPass` were all unreachable. Also deletes `types/Replay.ts`, `types/FrameSource.ts`, `types/GameUpdates.ts`, `types/Game.ts` (imported only by the types barrel). - **FrameEvents**: trimmed from 14 fields to the 3 actually populated and read (`deadUnits`, `conquestEvents`, `bonusEvents`). The other 11 fed the extension's stats system and were never written or read here. - **GL radial menu**: `RadialMenuPass`, its 4 shaders, and ~10 API methods on facade + renderer had zero callers — the game uses the DOM/d3 radial menu in `hud/layers/RadialMenu.ts`. The pass was constructed and drawn every frame for nothing. - **Facade event system**: `GameViewEventMap` defined 10 event types (`click`, `hover`, `scroll`, …) but only `contextrestored` was ever emitted — input actually flows through `InputHandler` → EventBus → controllers. Replaced the listener map with a single `onContextRestored` callback and deleted `Events.ts`. Also fixed the stale header comment claiming the facade handles user interaction. - **Unused API surface**: removed ~20 facade/renderer methods with zero callers (camera passthroughs like `panTo`/`zoomTo`/`fitMap`/`screenToWorld`, hit-testing queries, SAM replay setters, `setSelectedUnit`, `clearFx`/`setFxTimeFn`, `onFrame`/`afterRender`/fps tracking). Deliberately left alone: `Camera`'s pan/zoom primitives (building blocks for a possible future camera unification) and the `timeFn` plumbing inside the FX passes (deeply embedded as defaults; only the dead renderer-level wrappers were removed). ### Pass 2 — untangle the three GameViews - `render/gl/GameView.ts` → **`MapRenderer.ts`** (class `MapRenderer`). Every importer was already aliasing it as `WebGLGameView` to dodge the collision with the simulation-mirror `GameView` in `client/view/`, so this removes aliasing rather than adding churn. `render/CLAUDE.md` updated. - Deleted the `src/core/game/GameView.ts` back-compat shim (its own TODO asked for this). All 51 importers now import from `src/client/view/` directly via a new 3-line barrel `view/index.ts`. ## Test plan - `tsc --noEmit` clean, `eslint` clean - Full test suite passes (1,385 + 65 server tests) - Manual verification via headless Chromium: started a singleplayer game and confirmed the renderer works end-to-end — terrain draws, spawn-phase overlay shows, territories fill with borders after spawning, player names/flags render, no renderer console errors 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
262 lines
7.7 KiB
TypeScript
262 lines
7.7 KiB
TypeScript
/**
|
|
* 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 { UserSettings } from "../../core/game/UserSettings";
|
|
import { Controller } from "../Controller";
|
|
import { AlternateViewEvent } from "../InputHandler";
|
|
import { MapRenderer } from "../render/gl";
|
|
import type { AttackTroopLabel } from "../render/gl/passes/WorldTextPass";
|
|
import { renderTroops } from "../Utils";
|
|
import { GameView } from "../view";
|
|
|
|
// 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: MapRenderer,
|
|
) {}
|
|
|
|
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);
|
|
}
|
|
}
|