From 7b1557b886c97105370fe7d58f8be76a043b0fe5 Mon Sep 17 00:00:00 2001 From: evanpelle Date: Sat, 16 May 2026 22:58:31 -0700 Subject: [PATCH] controllers push to the WebGL view directly, drop ClientGameRunner relays MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/client/ClientGameRunner.ts | 75 ++++++++++--------- src/client/InputHandler.ts | 11 --- .../controllers/BuildPreviewController.ts | 18 ++--- .../controllers/WarshipSelectionController.ts | 11 ++- src/client/graphics/GameRenderer.ts | 6 +- .../WarshipSelectionController.test.ts | 25 ++++++- 6 files changed, 81 insertions(+), 65 deletions(-) diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index 3de721c35..e347998f9 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -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, diff --git a/src/client/InputHandler.ts b/src/client/InputHandler.ts index 8a30e150e..4dd3cc36a 100644 --- a/src/client/InputHandler.ts +++ b/src/client/InputHandler.ts @@ -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 { diff --git a/src/client/controllers/BuildPreviewController.ts b/src/client/controllers/BuildPreviewController.ts index ef29d3ca2..17275c8f1 100644 --- a/src/client/controllers/BuildPreviewController.ts +++ b/src/client/controllers/BuildPreviewController.ts @@ -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() { diff --git a/src/client/controllers/WarshipSelectionController.ts b/src/client/controllers/WarshipSelectionController.ts index cdf51d431..d8e01541a 100644 --- a/src/client/controllers/WarshipSelectionController.ts +++ b/src/client/controllers/WarshipSelectionController.ts @@ -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()] : []); } } } diff --git a/src/client/graphics/GameRenderer.ts b/src/client/graphics/GameRenderer.ts index 9e14f28bc..03ed58346 100644 --- a/src/client/graphics/GameRenderer.ts +++ b/src/client/graphics/GameRenderer.ts @@ -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, diff --git a/tests/client/controllers/WarshipSelectionController.test.ts b/tests/client/controllers/WarshipSelectionController.test.ts index 6754d453c..a28aef4a1 100644 --- a/tests/client/controllers/WarshipSelectionController.test.ts +++ b/tests/client/controllers/WarshipSelectionController.test.ts @@ -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 },