move multi-unit warship selection box to WebGL SelectionBoxPass

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.
This commit is contained in:
evanpelle
2026-05-16 20:02:31 -07:00
parent ede0fb7668
commit 923cba8c2d
6 changed files with 133 additions and 235 deletions
+7 -9
View File
@@ -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 };
+17 -162
View File
@@ -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);
}
}
+5
View File
@@ -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);
@@ -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 {
+48 -29
View File
@@ -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);
}
// ---------------------------------------------------------------------------
+17 -12
View File
@@ -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();
});
});