From 923cba8c2d106ddcd07e5a5d6a5556cbba749baa Mon Sep 17 00:00:00 2001 From: evanpelle Date: Sat, 16 May 2026 20:02:31 -0700 Subject: [PATCH] move multi-unit warship selection box to WebGL SelectionBoxPass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SelectionBoxPass now stores an array of selections and renders one quad per entry. GPURenderer gains setSelectedUnits(ids) — the single-unit setSelectedUnit becomes a wrapper. Position + color are rebuilt each frame from lastUnits; dead unit IDs get pruned in place. ClientGameRunner's UnitSelectionEvent listener forwards both single and multi to view.setSelectedUnits — no more single/multi split. UILayer drops everything canvas2D-related: the offscreen canvas + context, theme, selectionAnimTime, multiSelectionBoxCenters, SELECTION_BOX_SIZE, drawSelectionBoxMulti, paintSelectionBoxAt, clearSelectionBox, paintCell, clearCell, and renderLayer / redraw / shouldTransform. tick() now only prunes destroyed warships from the selection list; the layer is purely state + click handling. ~120 LOC gone. Tests: UILayer.test.ts updated — drops the canvas/redraw asserts, adds a multi-selection state assertion. --- src/client/ClientGameRunner.ts | 16 +- src/client/graphics/layers/UILayer.ts | 179 ++---------------- src/client/render/gl/game-view.ts | 5 + .../render/gl/passes/selection-box-pass.ts | 62 +++--- src/client/render/gl/renderer.ts | 77 +++++--- tests/client/graphics/UILayer.test.ts | 29 +-- 6 files changed, 133 insertions(+), 235 deletions(-) diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index 93de7a16e..d832989a8 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -346,21 +346,19 @@ function mountWebGLDebugRenderer( view.showMoveIndicator(tx, ty, firstUnit.owner().smallID()); }); - // Single-unit warship selection box: forward UnitSelectionEvent to the - // renderer's SelectionBoxPass. Multi-selection (event.units.length > 0) - // stays canvas2D for now — SelectionBoxPass only supports one unit. + // Warship selection boxes: forward UnitSelectionEvent to the renderer's + // SelectionBoxPass for both single and multi selections. eventBus.on(UnitSelectionEvent, (e) => { if (!e.isSelected) { - view.setSelectedUnit(null); + view.setSelectedUnits([]); return; } - if ((e.units ?? []).length > 0) { - // Multi-selection: drop any prior single highlight; canvas2D draws - // the multi outlines in UILayer. - view.setSelectedUnit(null); + const multi = e.units ?? []; + if (multi.length > 0) { + view.setSelectedUnits(multi.map((u) => u.id())); return; } - view.setSelectedUnit(e.unit?.id() ?? null); + view.setSelectedUnits(e.unit ? [e.unit.id()] : []); }); return { builder: new WebGLFrameBuilder(view), syncCamera }; diff --git a/src/client/graphics/layers/UILayer.ts b/src/client/graphics/layers/UILayer.ts index 3cb75baf1..d6072d02c 100644 --- a/src/client/graphics/layers/UILayer.ts +++ b/src/client/graphics/layers/UILayer.ts @@ -1,5 +1,3 @@ -import { Colord } from "colord"; -import { Theme } from "src/core/configuration/Theme"; import { Cell } from "src/core/game/Game"; import { EventBus } from "../../../core/EventBus"; import { UnitType } from "../../../core/game/Game"; @@ -23,65 +21,35 @@ import { Layer } from "./Layer"; const WARSHIP_SELECTION_RADIUS = 10; /** - * Layer responsible for drawing UI elements that overlay the game. - * Currently: warship selection boxes + drag-rectangle selection. - * Health/progress bars are now drawn by the WebGL BarPass. + * Layer responsible for warship selection state + click handling. + * + * Drawing for selection boxes (single + multi) lives in the WebGL + * SelectionBoxPass; the drag-rectangle preview is a screen-space DOM + * overlay (dragRectEl). This layer does not draw to canvas2D at all — + * it stays in the Layer list for lifecycle hooks (init / tick / event + * subscriptions). */ export class UILayer implements Layer { - private canvas: HTMLCanvasElement; - private context: CanvasRenderingContext2D | null; - private theme: Theme | null = null; - private selectionAnimTime = 0; - // Keep track of currently selected unit + // Currently selected single warship (game-logic readers use this; the + // visual is drawn by WebGL SelectionBoxPass). private selectedUnit: UnitView | null = null; - - // Keep track of multi-selected warships (box selection) + // Currently multi-selected warships (shift+drag box select). private multiSelectedWarships: UnitView[] = []; - // Per-unit last selection box position for multi-select cleanup - private multiSelectionBoxCenters: Map< - number, - { x: number; y: number; size: number } - > = new Map(); - - // Visual settings for selection - private readonly SELECTION_BOX_SIZE = 6; // Size of the selection box (should be larger than the warship) - // Drag rectangle (shift+drag warship selection box) — a screen-space DOM - // overlay positioned via inline style. Not part of the canvas2D draw path. + // overlay positioned via inline style. private dragRectEl: HTMLDivElement | null = null; constructor( private game: GameView, private eventBus: EventBus, private transformHandler: TransformHandler, - ) { - this.theme = game.config().theme(); - } - - shouldTransform(): boolean { - return true; - } + ) {} tick() { - // Update the selection animation time (only used by the multi-selection - // boxes — the single-unit box is now drawn by the WebGL SelectionBoxPass). - this.selectionAnimTime = (this.selectionAnimTime + 1) % 60; - - // Animate multi-selected warships - for (const unit of this.multiSelectedWarships) { - if (unit.isActive()) { - this.drawSelectionBoxMulti(unit); - } else { - // Unit was destroyed — clean up its box - const prev = this.multiSelectionBoxCenters.get(unit.id()); - if (prev) { - this.clearSelectionBox(prev.x, prev.y, prev.size); - this.multiSelectionBoxCenters.delete(unit.id()); - } - } - } - // Remove destroyed units from the list + // Prune any destroyed warships from the multi-selection so callers + // (move-warship intent) don't try to act on dead units. The WebGL + // SelectionBoxPass also drops them automatically. this.multiSelectedWarships = this.multiSelectedWarships.filter((u) => u.isActive(), ); @@ -106,8 +74,6 @@ export class UILayer implements Layer { this.onSelectionBoxComplete(e), ); this.eventBus.on(SelectAllWarshipsEvent, () => this.onSelectAllWarships()); - - this.redraw(); } /** @@ -319,23 +285,6 @@ export class UILayer implements Layer { this.eventBus.emit(new UnitSelectionEvent(null, true, allWarships)); } - renderLayer(context: CanvasRenderingContext2D) { - context.drawImage( - this.canvas, - -this.game.width() / 2, - -this.game.height() / 2, - this.game.width(), - this.game.height(), - ); - } - - redraw() { - this.canvas = document.createElement("canvas"); - this.context = this.canvas.getContext("2d"); - this.canvas.width = this.game.width(); - this.canvas.height = this.game.height(); - } - /** * Handle the unit selection event (single or multi). * When event.units.length > 0 it's a multi-selection from box/select-all. @@ -343,111 +292,17 @@ export class UILayer implements Layer { * When event.isSelected is false it clears all selection state. */ private onUnitSelection(event: UnitSelectionEvent) { - // Clear previous multi-selection boxes (the single-unit box is now drawn - // by the WebGL SelectionBoxPass — see ClientGameRunner.mountWebGLDebugRenderer - // which forwards this event to view.setSelectedUnit). - for (const [, center] of this.multiSelectionBoxCenters) { - this.clearSelectionBox(center.x, center.y, center.size); - } - this.multiSelectionBoxCenters.clear(); + // 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.units ?? []).length > 0) { - // Multi-selection — canvas2D draws the per-unit outlines. this.multiSelectedWarships = event.units; - for (const unit of this.multiSelectedWarships) { - if (unit.isActive()) { - this.drawSelectionBoxMulti(unit); - } - } } else { - // Single selection — state only; WebGL draws the box. this.selectedUnit = event.unit; } } - - /** - * Draw selection box for a multi-selected warship, tracking position per unit id. - */ - private drawSelectionBoxMulti(unit: UnitView) { - if (!unit || !unit.isActive()) return; - - if (this.theme === null) throw new Error("missing theme"); - const selectionColor = unit.owner().territoryColor().lighten(0.2); - const centerX = this.game.x(unit.tile()); - const centerY = this.game.y(unit.tile()); - - const prev = this.multiSelectionBoxCenters.get(unit.id()); - if (prev && (prev.x !== centerX || prev.y !== centerY)) { - this.clearSelectionBox(prev.x, prev.y, prev.size); - } - - this.paintSelectionBoxAt(centerX, centerY, selectionColor); - - this.multiSelectionBoxCenters.set(unit.id(), { - x: centerX, - y: centerY, - size: this.SELECTION_BOX_SIZE, - }); - } - - /** - * Shared helper: paint the dashed pulsing border pixels for a selection box. - */ - private paintSelectionBoxAt( - centerX: number, - centerY: number, - selectionColor: Colord, - ) { - const size = this.SELECTION_BOX_SIZE; - const opacity = 200 + Math.sin(this.selectionAnimTime * 0.1) * 55; - - for (let x = centerX - size; x <= centerX + size; x++) { - for (let y = centerY - size; y <= centerY + size; y++) { - if ( - x === centerX - size || - x === centerX + size || - y === centerY - size || - y === centerY + size - ) { - if ((x + y) % 2 === 0) { - this.paintCell(x, y, selectionColor, opacity); - } - } - } - } - } - - /** - * Clear the selection box at a specific position - */ - private clearSelectionBox(x: number, y: number, size: number) { - for (let px = x - size; px <= x + size; px++) { - for (let py = y - size; py <= y + size; py++) { - if ( - px === x - size || - px === x + size || - py === y - size || - py === y + size - ) { - this.clearCell(px, py); - } - } - } - } - - paintCell(x: number, y: number, color: Colord, alpha: number) { - if (this.context === null) throw new Error("null context"); - this.clearCell(x, y); - this.context.fillStyle = color.alpha(alpha / 255).toRgbString(); - this.context.fillRect(x, y, 1, 1); - } - - clearCell(x: number, y: number) { - if (this.context === null) throw new Error("null context"); - this.context.clearRect(x, y, 1, 1); - } } diff --git a/src/client/render/gl/game-view.ts b/src/client/render/gl/game-view.ts index 05fc29767..6786d9cc1 100644 --- a/src/client/render/gl/game-view.ts +++ b/src/client/render/gl/game-view.ts @@ -341,6 +341,11 @@ export class GameView { this.renderer.setSelectedUnit(unitId); } + /** Set multiple selected units (multi-select). Pass [] to clear. */ + setSelectedUnits(unitIds: readonly number[]): void { + this.renderer.setSelectedUnits(unitIds); + } + /** Flash converging-chevron animation at a warship move target. */ showMoveIndicator(tileX: number, tileY: number, ownerID: number): void { this.renderer.showMoveIndicator(tileX, tileY, ownerID); diff --git a/src/client/render/gl/passes/selection-box-pass.ts b/src/client/render/gl/passes/selection-box-pass.ts index cf4af16cf..2e74c475b 100644 --- a/src/client/render/gl/passes/selection-box-pass.ts +++ b/src/client/render/gl/passes/selection-box-pass.ts @@ -1,9 +1,9 @@ /** - * SelectionBoxPass — draws a stippled pulsating square border around a - * selected warship, matching the game's native UILayer selection box. + * SelectionBoxPass — draws stippled pulsating square borders around selected + * warships. Supports any number of selections; renders one quad per selection. * - * Single quad with tile-space SDF logic in the fragment shader. - * Active only when a unit is selected via setSelectedUnit(). + * For typical use (1-50 selected units) the draw-call overhead is fine; if + * this ever becomes hot we could swap to instanced rendering. */ import { createProgram } from "../utils/gl-utils"; @@ -14,6 +14,14 @@ import vertSrc from "../shaders/selection-box/selection-box.vert.glsl?raw"; /** Half-size of the selection box in tiles (matches game's SELECTION_BOX_SIZE). */ const HALF_SIZE = 6; +export interface SelectionEntry { + centerX: number; + centerY: number; + r: number; + g: number; + b: number; +} + export class SelectionBoxPass { private gl: WebGL2RenderingContext; private program: WebGLProgram; @@ -25,12 +33,8 @@ export class SelectionBoxPass { private uTime: WebGLUniformLocation; private uColor: WebGLUniformLocation; - private active = false; - private centerX = 0; - private centerY = 0; - private colorR = 1; - private colorG = 1; - private colorB = 1; + /** Reusable buffer of selections — caller mutates via setSelections(). */ + private readonly selections: SelectionEntry[] = []; constructor(gl: WebGL2RenderingContext) { this.gl = gl; @@ -58,8 +62,18 @@ export class SelectionBoxPass { } /** - * Set the selection box center and color. Pass active=false to hide. + * Replace the set of selections drawn this frame. Call with [] to hide. + * Stored by reference — the renderer rebuilds the array each frame from + * the current unit positions/colors, so we just swap pointers. */ + setSelections(entries: readonly SelectionEntry[]): void { + this.selections.length = 0; + for (let i = 0; i < entries.length; i++) { + this.selections.push(entries[i]); + } + } + + /** Legacy single-selection API kept for callers that haven't migrated. */ update( active: boolean, centerX: number, @@ -68,31 +82,33 @@ export class SelectionBoxPass { g: number, b: number, ): void { - this.active = active; - this.centerX = centerX; - this.centerY = centerY; - this.colorR = r; - this.colorG = g; - this.colorB = b; + this.selections.length = 0; + if (active) this.selections.push({ centerX, centerY, r, g, b }); } hide(): void { - this.active = false; + this.selections.length = 0; } draw(cameraMatrix: Float32Array, frameTick: number): void { - if (!this.active) return; + if (this.selections.length === 0) return; const gl = this.gl; gl.useProgram(this.program); gl.uniformMatrix3fv(this.uCamera, false, cameraMatrix); - gl.uniform2f(this.uCenter, this.centerX, this.centerY); gl.uniform1f(this.uHalfSize, HALF_SIZE); gl.uniform1f(this.uTime, frameTick); - gl.uniform3f(this.uColor, this.colorR, this.colorG, this.colorB); - gl.bindVertexArray(this.vao); - gl.drawArrays(gl.TRIANGLES, 0, 6); + + // One draw call per selection — for the typical N=1..50, this is cheap. + // (If profiling ever shows it matters, swap to instanced rendering with a + // small per-instance VBO of {centerX, centerY, r, g, b}.) + for (let i = 0; i < this.selections.length; i++) { + const s = this.selections[i]; + gl.uniform2f(this.uCenter, s.centerX, s.centerY); + gl.uniform3f(this.uColor, s.r, s.g, s.b); + gl.drawArrays(gl.TRIANGLES, 0, 6); + } } dispose(): void { diff --git a/src/client/render/gl/renderer.ts b/src/client/render/gl/renderer.ts index dd42383bb..67775ff0b 100644 --- a/src/client/render/gl/renderer.ts +++ b/src/client/render/gl/renderer.ts @@ -164,8 +164,11 @@ export class GPURenderer { private samGhostVisible = false; private samHighlightVisible = false; - // Warship selection - private selectedUnitId: number | null = null; + // Warship selection — supports any number of selections. + private selectedUnitIds: number[] = []; + /** Reusable scratch buffer of {x,y,r,g,b} for the selection-box pass. */ + private readonly selectionBoxEntries: import("./passes/selection-box-pass").SelectionEntry[] = + []; constructor( canvas: HTMLCanvasElement, @@ -884,40 +887,56 @@ export class GPURenderer { // --------------------------------------------------------------------------- setSelectedUnit(unitId: number | null): void { - this.selectedUnitId = unitId; - if (unitId === null) { + this.setSelectedUnits(unitId === null ? [] : [unitId]); + } + + setSelectedUnits(unitIds: readonly number[]): void { + // Copy in (callers may mutate their array). + this.selectedUnitIds.length = 0; + for (let i = 0; i < unitIds.length; i++) { + this.selectedUnitIds.push(unitIds[i]); + } + if (this.selectedUnitIds.length === 0) { this.selectionBoxPass.hide(); } - // Position + color are updated each frame in draw() from lastUnits. + // Position + color are rebuilt each frame in updateSelectionBox() from + // lastUnits — dead units get dropped automatically. } private updateSelectionBox(): void { - if (this.selectedUnitId === null) return; - const unit = this.lastUnits.get(this.selectedUnitId); - if (!unit || !unit.isActive) { - this.selectedUnitId = null; - this.selectionBoxPass.hide(); - return; + if (this.selectedUnitIds.length === 0) return; + + // Build the entries for this frame and prune dead unit IDs in place. + const entries = this.selectionBoxEntries; + entries.length = 0; + let writeIdx = 0; + for (let i = 0; i < this.selectedUnitIds.length; i++) { + const id = this.selectedUnitIds[i]; + const unit = this.lastUnits.get(id); + if (!unit || !unit.isActive) continue; // dead — drop + this.selectedUnitIds[writeIdx++] = id; + + const centerX = unit.pos % this.mapW; + const centerY = Math.floor(unit.pos / this.mapW); + // Lighten the owner's territory color by ~20% (mix toward white). + const off = unit.ownerID * 4; + const r = Math.min( + 1, + this.paletteData[off] + (1 - this.paletteData[off]) * 0.3, + ); + const g = Math.min( + 1, + this.paletteData[off + 1] + (1 - this.paletteData[off + 1]) * 0.3, + ); + const b = Math.min( + 1, + this.paletteData[off + 2] + (1 - this.paletteData[off + 2]) * 0.3, + ); + entries.push({ centerX, centerY, r, g, b }); } - const x = unit.pos % this.mapW; - const y = Math.floor(unit.pos / this.mapW); + this.selectedUnitIds.length = writeIdx; - // Lighten the owner's territory color by ~20% (mix toward white) - const off = unit.ownerID * 4; - const lr = Math.min( - 1, - this.paletteData[off] + (1 - this.paletteData[off]) * 0.3, - ); - const lg = Math.min( - 1, - this.paletteData[off + 1] + (1 - this.paletteData[off + 1]) * 0.3, - ); - const lb = Math.min( - 1, - this.paletteData[off + 2] + (1 - this.paletteData[off + 2]) * 0.3, - ); - - this.selectionBoxPass.update(true, x, y, lr, lg, lb); + this.selectionBoxPass.setSelections(entries); } // --------------------------------------------------------------------------- diff --git a/tests/client/graphics/UILayer.test.ts b/tests/client/graphics/UILayer.test.ts index 63d0501a3..aaafded00 100644 --- a/tests/client/graphics/UILayer.test.ts +++ b/tests/client/graphics/UILayer.test.ts @@ -28,17 +28,8 @@ describe("UILayer", () => { transformHandler = {}; }); - it("should initialize and redraw canvas", () => { - const ui = new UILayer(game, eventBus, transformHandler); - ui.redraw(); - expect(ui["canvas"].width).toBe(100); - expect(ui["canvas"].height).toBe(100); - expect(ui["context"]).not.toBeNull(); - }); - it("tracks the selected unit on single-unit selection (rendering is WebGL)", () => { const ui = new UILayer(game, eventBus, transformHandler); - ui.redraw(); const unit = { type: () => "Warship", isActive: () => true, @@ -48,14 +39,13 @@ describe("UILayer", () => { const event = { isSelected: true, unit }; ui["onUnitSelection"](event as UnitSelectionEvent); // selectedUnit is held for game-logic callers (the click handlers). The - // visual selection box is now drawn by WebGL SelectionBoxPass — wired - // from ClientGameRunner via view.setSelectedUnit(unit.id()). + // visual selection box is drawn by WebGL SelectionBoxPass — wired from + // ClientGameRunner via view.setSelectedUnits([unit.id()]). expect(ui["selectedUnit"]).toBe(unit); }); it("clears selection on deselect", () => { const ui = new UILayer(game, eventBus, transformHandler); - ui.redraw(); const unit = { type: () => "Warship", isActive: () => true, @@ -69,4 +59,19 @@ describe("UILayer", () => { } as unknown as UnitSelectionEvent); expect(ui["selectedUnit"]).toBeNull(); }); + + it("tracks multi-selection list", () => { + const ui = new UILayer(game, eventBus, transformHandler); + const units = [ + { id: () => 1, isActive: () => true }, + { id: () => 2, isActive: () => true }, + ]; + ui["onUnitSelection"]({ + isSelected: true, + unit: null, + units, + } as unknown as UnitSelectionEvent); + expect(ui["multiSelectedWarships"]).toEqual(units); + expect(ui["selectedUnit"]).toBeNull(); + }); });