controllers push to the WebGL view directly, drop ClientGameRunner relays

BuildPreviewController and WarshipSelectionController now take the WebGL
view in their constructor and call view.updateGhostPreview /
view.setSelectedUnits themselves instead of emitting bus events that
ClientGameRunner forwarded. Splits the old mountWebGLDebugRenderer in
two — createWebGLView builds the view up front so the renderer can wire
controllers to it, mountWebGLDebugRenderer does the per-frame plumbing
after the transformHandler exists. GhostPreviewUpdatedEvent had no
remaining consumers and is removed.
This commit is contained in:
evanpelle
2026-05-16 22:58:31 -07:00
parent a708a8c984
commit 7b1557b886
6 changed files with 81 additions and 65 deletions
+39 -36
View File
@@ -39,12 +39,10 @@ import {
DoGroundAttackEvent,
DoRequestAllianceEvent,
DoRetaliateAttackEvent,
GhostPreviewUpdatedEvent,
InputHandler,
MouseMoveEvent,
MouseUpEvent,
TickMetricsEvent,
UnitSelectionEvent,
} from "./InputHandler";
import { endGame, startGame, startTime } from "./LocalPersistantStats";
import { terrainMapFileLoader } from "./TerrainMapFileLoader";
@@ -231,12 +229,13 @@ export function joinLobby(
};
}
function mountWebGLDebugRenderer(
terrainMap: TerrainMapData,
transformHandler: import("./graphics/TransformHandler").TransformHandler,
gameView: GameView,
eventBus: EventBus,
): { builder: WebGLFrameBuilder } {
// Build the WebGL view + its glCanvas. Must run before createRenderer so the
// controllers can be wired directly to the view.
function createWebGLView(terrainMap: TerrainMapData): {
view: WebGLGameView;
glCanvas: HTMLCanvasElement;
cachedWebGLFrameCallback: { current: FrameRequestCallback | null };
} {
const gameMap = terrainMap.gameMap;
const mapWidth = gameMap.width();
const mapHeight = gameMap.height();
@@ -259,13 +258,15 @@ function mountWebGLDebugRenderer(
// because its RAF fires before canvas2D's RAF (which would have synced the
// camera). Driving WebGL's draw synchronously from canvas2D's onPreRender
// hook locks them to the same frame.
let cachedWebGLFrameCallback: FrameRequestCallback | null = null;
const cachedWebGLFrameCallback: { current: FrameRequestCallback | null } = {
current: null,
};
const captureRaf = (cb: FrameRequestCallback): number => {
cachedWebGLFrameCallback = cb;
cachedWebGLFrameCallback.current = cb;
return 0;
};
const captureCaf = (_id: number): void => {
cachedWebGLFrameCallback = null;
cachedWebGLFrameCallback.current = null;
};
const palette = new Float32Array(4096 * 2 * 4);
@@ -288,6 +289,24 @@ function mountWebGLDebugRenderer(
captureCaf,
);
(window as unknown as { __webglView?: unknown }).__webglView = view;
return { view, glCanvas, cachedWebGLFrameCallback };
}
function mountWebGLDebugRenderer(
terrainMap: TerrainMapData,
view: WebGLGameView,
glCanvas: HTMLCanvasElement,
cachedWebGLFrameCallback: { current: FrameRequestCallback | null },
transformHandler: import("./graphics/TransformHandler").TransformHandler,
gameView: GameView,
eventBus: EventBus,
): { builder: WebGLFrameBuilder } {
const gameMap = terrainMap.gameMap;
const mapWidth = gameMap.width();
const mapHeight = gameMap.height();
window.addEventListener("keydown", (e) => {
if (e.key === "\\") {
glCanvas.style.display =
@@ -326,13 +345,11 @@ function mountWebGLDebugRenderer(
// Invoke the WebGL renderer's frame callback synchronously, with the just-
// updated camera state. The callback re-arms itself via captureRaf, so
// we'll get a fresh callback ready for the next canvas2D frame.
const cb = cachedWebGLFrameCallback;
cachedWebGLFrameCallback = null;
const cb = cachedWebGLFrameCallback.current;
cachedWebGLFrameCallback.current = null;
cb?.(performance.now());
};
(window as unknown as { __webglView?: unknown }).__webglView = view;
// Move-target chevrons: when the player issues a warship move, show the
// animated chevron pass at the target tile. The renderer needs the target's
// tile x/y and the warship's owner smallID (so the chevrons use the right
@@ -347,27 +364,6 @@ function mountWebGLDebugRenderer(
view.showMoveIndicator(tx, ty, firstUnit.owner().smallID());
});
// Build-mode ghost preview: forward the per-frame state to the renderer's
// ghost passes (structure outline, range circle, rail snap, crosshair).
eventBus.on(GhostPreviewUpdatedEvent, (e) => {
view.updateGhostPreview(e.data);
});
// Warship selection boxes: forward UnitSelectionEvent to the renderer's
// SelectionBoxPass for both single and multi selections.
eventBus.on(UnitSelectionEvent, (e) => {
if (!e.isSelected) {
view.setSelectedUnits([]);
return;
}
const multi = e.units ?? [];
if (multi.length > 0) {
view.setSelectedUnits(multi.map((u) => u.id()));
return;
}
view.setSelectedUnits(e.unit ? [e.unit.id()] : []);
});
// Self-driving RAF: syncCamera reads the latest camera state from
// TransformHandler, pushes it to WebGL, and synchronously invokes the
// renderer's captured frame callback (which draws). One RAF = one
@@ -437,15 +433,22 @@ async function createClientGame(
const soundManager = new SoundManager(eventBus, userSettings);
try {
const { view, glCanvas, cachedWebGLFrameCallback } =
createWebGLView(gameMap);
const gameRenderer = createRenderer(
inputOverlay,
gameView,
eventBus,
lobbyConfig.playerRole,
view,
);
const { builder: webglBuilder } = mountWebGLDebugRenderer(
gameMap,
view,
glCanvas,
cachedWebGLFrameCallback,
gameRenderer.transformHandler,
gameView,
eventBus,
-11
View File
@@ -96,17 +96,6 @@ export class GhostStructureChangedEvent implements GameEvent {
constructor(public readonly ghostStructure: PlayerBuildableUnitType | null) {}
}
/**
* Per-frame ghost preview state for the WebGL renderer. Emitted by the
* canvas2D ghost layer; consumed in ClientGameRunner.mountWebGLDebugRenderer
* to push to view.updateGhostPreview. `data` is null when no ghost is active.
*/
export class GhostPreviewUpdatedEvent implements GameEvent {
constructor(
public readonly data: import("./render/types").GhostPreviewData | null,
) {}
}
export class ConfirmGhostStructureEvent implements GameEvent {}
export class SwapRocketDirectionEvent implements GameEvent {
@@ -4,7 +4,7 @@
* All rendering for the build ghost (outline, range circle, rail snap,
* crosshair) lives in the WebGL renderer. This controller owns the state:
* it queries buildables for the cursor tile, tracks whether the placement
* is valid, and emits GhostPreviewUpdatedEvent to feed the renderer.
* is valid, and pushes preview data straight to the WebGL view.
*/
import { EventBus } from "../../core/EventBus";
@@ -21,11 +21,11 @@ import { TransformHandler } from "../graphics/TransformHandler";
import { UIState } from "../graphics/UIState";
import {
ConfirmGhostStructureEvent,
GhostPreviewUpdatedEvent,
GhostStructureChangedEvent,
MouseMoveEvent,
MouseUpEvent,
} from "../InputHandler";
import { GameView as WebGLGameView } from "../render/gl";
import type { GhostPreviewData } from "../render/types";
import {
BuildUnitIntentEvent,
@@ -50,6 +50,7 @@ export class BuildPreviewController implements Controller {
private eventBus: EventBus,
public uiState: UIState,
private transformHandler: TransformHandler,
private view: WebGLGameView,
) {}
init() {
@@ -183,15 +184,12 @@ export class BuildPreviewController implements Controller {
}
/**
* Build a GhostPreviewData snapshot from the current ghost state and emit
* it for the WebGL renderer to consume (StructurePass / RangeCirclePass /
* RailroadPass / CrosshairPass all read it via view.updateGhostPreview).
* Emits null when the ghost can't be placed.
* Push a GhostPreviewData snapshot to the WebGL view (StructurePass /
* RangeCirclePass / RailroadPass / CrosshairPass all read it). null when
* the ghost can't be placed.
*/
private emitGhostPreview(tileRef: TileRef | undefined): void {
this.eventBus.emit(
new GhostPreviewUpdatedEvent(this.buildGhostPreviewData(tileRef)),
);
this.view.updateGhostPreview(this.buildGhostPreviewData(tileRef));
}
private buildGhostPreviewData(
@@ -310,7 +308,7 @@ export class BuildPreviewController implements Controller {
this.pendingConfirm = null;
this.ghostUnit = null;
this.uiState.ghostRailPaths = [];
this.eventBus.emit(new GhostPreviewUpdatedEvent(null));
this.view.updateGhostPreview(null);
}
private removeGhostStructure() {
@@ -16,6 +16,7 @@ import {
WarshipSelectionBoxCompleteEvent,
WarshipSelectionBoxUpdateEvent,
} from "../InputHandler";
import { GameView as WebGLGameView } from "../render/gl";
import { MoveWarshipIntentEvent } from "../Transport";
const WARSHIP_SELECTION_RADIUS = 10;
@@ -47,6 +48,7 @@ export class WarshipSelectionController implements Controller {
private game: GameView,
private eventBus: EventBus,
private transformHandler: TransformHandler,
private view: WebGLGameView,
) {}
tick() {
@@ -295,17 +297,20 @@ export class WarshipSelectionController implements Controller {
* When event.isSelected is false it clears all selection state.
*/
private onUnitSelection(event: UnitSelectionEvent) {
// Selection box visuals are drawn by the WebGL SelectionBoxPass; this
// method just tracks selection state for the click-handler logic.
this.multiSelectedWarships = [];
this.selectedUnit = null;
if (!event.isSelected) return;
if (!event.isSelected) {
this.view.setSelectedUnits([]);
return;
}
if ((event.units ?? []).length > 0) {
this.multiSelectedWarships = event.units;
this.view.setSelectedUnits(event.units.map((u) => u.id()));
} else {
this.selectedUnit = event.unit;
this.view.setSelectedUnits(event.unit ? [event.unit.id()] : []);
}
}
}
+4 -2
View File
@@ -4,6 +4,7 @@ import { UserSettings } from "../../core/game/UserSettings";
import { GameStartingModal } from "../GameStartingModal";
import { BuildPreviewController } from "../controllers/BuildPreviewController";
import { WarshipSelectionController } from "../controllers/WarshipSelectionController";
import { GameView as WebGLGameView } from "../render/gl";
import { FrameProfiler } from "./FrameProfiler";
import { TransformHandler } from "./TransformHandler";
import { UIState } from "./UIState";
@@ -40,6 +41,7 @@ export function createRenderer(
game: GameView,
eventBus: EventBus,
playerRole: string | null,
view: WebGLGameView,
): GameRenderer {
const transformHandler = new TransformHandler(game, eventBus, inputEl);
const userSettings = new UserSettings();
@@ -257,8 +259,8 @@ export function createRenderer(
inGamePromo.game = game;
const layers: Controller[] = [
new WarshipSelectionController(game, eventBus, transformHandler),
new BuildPreviewController(game, eventBus, uiState, transformHandler),
new WarshipSelectionController(game, eventBus, transformHandler, view),
new BuildPreviewController(game, eventBus, uiState, transformHandler, view),
new AttackingTroopsOverlay(game, transformHandler, eventBus, userSettings),
eventsDisplay,
attacksDisplay,
@@ -5,6 +5,7 @@ describe("WarshipSelectionController", () => {
let game: any;
let eventBus: any;
let transformHandler: any;
let view: any;
beforeEach(() => {
game = {
@@ -26,11 +27,18 @@ describe("WarshipSelectionController", () => {
};
eventBus = { on: vi.fn() };
transformHandler = {};
view = { setSelectedUnits: vi.fn() };
});
it("tracks the selected unit on single-unit selection (rendering is WebGL)", () => {
const ui = new WarshipSelectionController(game, eventBus, transformHandler);
const ui = new WarshipSelectionController(
game,
eventBus,
transformHandler,
view,
);
const unit = {
id: () => 1,
type: () => "Warship",
isActive: () => true,
tile: () => ({}),
@@ -45,8 +53,14 @@ describe("WarshipSelectionController", () => {
});
it("clears selection on deselect", () => {
const ui = new WarshipSelectionController(game, eventBus, transformHandler);
const ui = new WarshipSelectionController(
game,
eventBus,
transformHandler,
view,
);
const unit = {
id: () => 1,
type: () => "Warship",
isActive: () => true,
tile: () => ({}),
@@ -61,7 +75,12 @@ describe("WarshipSelectionController", () => {
});
it("tracks multi-selection list", () => {
const ui = new WarshipSelectionController(game, eventBus, transformHandler);
const ui = new WarshipSelectionController(
game,
eventBus,
transformHandler,
view,
);
const units = [
{ id: () => 1, isActive: () => true },
{ id: () => 2, isActive: () => true },