move single-unit warship selection box to WebGL SelectionBoxPass

UnitSelectionEvent now forwards to view.setSelectedUnit(unit.id()) in
mountWebGLDebugRenderer; the renderer's SelectionBoxPass draws the
animated stippled outline on the GPU. UILayer still tracks
selectedUnit for game-logic readers (the click handlers) but no longer
paints to canvas2D for it.

Drops drawSelectionBox + lastSelectionBoxCenter (~50 LOC) plus the
per-tick single-unit redraw in tick(). Multi-selection stays on
canvas2D — SelectionBoxPass is single-unit only.

Test update: replaces the now-dead drawSelectionBox spy with a
selectedUnit state assertion + a deselect case.
This commit is contained in:
evanpelle
2026-05-16 19:53:13 -07:00
parent d1651017ea
commit ede0fb7668
3 changed files with 61 additions and 91 deletions
+18
View File
@@ -43,6 +43,7 @@ import {
MouseMoveEvent,
MouseUpEvent,
TickMetricsEvent,
UnitSelectionEvent,
} from "./InputHandler";
import { endGame, startGame, startTime } from "./LocalPersistantStats";
import { terrainMapFileLoader } from "./TerrainMapFileLoader";
@@ -345,6 +346,23 @@ 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.
eventBus.on(UnitSelectionEvent, (e) => {
if (!e.isSelected) {
view.setSelectedUnit(null);
return;
}
if ((e.units ?? []).length > 0) {
// Multi-selection: drop any prior single highlight; canvas2D draws
// the multi outlines in UILayer.
view.setSelectedUnit(null);
return;
}
view.setSelectedUnit(e.unit?.id() ?? null);
});
return { builder: new WebGLFrameBuilder(view), syncCamera };
}
+21 -88
View File
@@ -44,13 +44,6 @@ export class UILayer implements Layer {
{ x: number; y: number; size: number }
> = new Map();
// Keep track of previous selection box position for cleanup
private lastSelectionBoxCenter: {
x: number;
y: number;
size: number;
} | null = null;
// Visual settings for selection
private readonly SELECTION_BOX_SIZE = 6; // Size of the selection box (should be larger than the warship)
@@ -71,14 +64,10 @@ export class UILayer implements Layer {
}
tick() {
// Update the selection animation time
// 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;
// If there's a selected warship, redraw to update the selection box animation
if (this.selectedUnit && this.selectedUnit.type() === UnitType.Warship) {
this.drawSelectionBox(this.selectedUnit);
}
// Animate multi-selected warships
for (const unit of this.multiSelectedWarships) {
if (unit.isActive()) {
@@ -354,50 +343,29 @@ export class UILayer implements Layer {
* When event.isSelected is false it clears all selection state.
*/
private onUnitSelection(event: UnitSelectionEvent) {
if (event.isSelected) {
// Always clear single-selection outline first
if (this.lastSelectionBoxCenter) {
const { x, y, size } = this.lastSelectionBoxCenter;
this.clearSelectionBox(x, y, size);
this.lastSelectionBoxCenter = null;
}
// selectedUnit is always reset regardless of lastSelectionBoxCenter
this.selectedUnit = null;
// Always clear previous multi-selection boxes
for (const [, center] of this.multiSelectionBoxCenters) {
this.clearSelectionBox(center.x, center.y, center.size);
}
this.multiSelectionBoxCenters.clear();
this.multiSelectedWarships = [];
// 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();
this.multiSelectedWarships = [];
this.selectedUnit = null;
if ((event.units ?? []).length > 0) {
// Multi-selection
this.multiSelectedWarships = event.units;
for (const unit of this.multiSelectedWarships) {
if (unit.isActive()) {
this.drawSelectionBoxMulti(unit);
}
}
} else {
// Single selection
this.selectedUnit = event.unit;
if (event.unit && event.unit.type() === UnitType.Warship) {
this.drawSelectionBox(event.unit);
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 {
// Deselect everything
if (this.lastSelectionBoxCenter) {
const { x, y, size } = this.lastSelectionBoxCenter;
this.clearSelectionBox(x, y, size);
this.lastSelectionBoxCenter = null;
}
this.selectedUnit = null;
for (const [, center] of this.multiSelectionBoxCenters) {
this.clearSelectionBox(center.x, center.y, center.size);
}
this.multiSelectionBoxCenters.clear();
this.multiSelectedWarships = [];
// Single selection — state only; WebGL draws the box.
this.selectedUnit = event.unit;
}
}
@@ -471,41 +439,6 @@ export class UILayer implements Layer {
}
}
/**
* Draw a selection box around the given unit
*/
public drawSelectionBox(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());
// Clear previous box if unit moved
if (
this.lastSelectionBoxCenter &&
(this.lastSelectionBoxCenter.x !== centerX ||
this.lastSelectionBoxCenter.y !== centerY)
) {
this.clearSelectionBox(
this.lastSelectionBoxCenter.x,
this.lastSelectionBoxCenter.y,
this.lastSelectionBoxCenter.size,
);
}
this.paintSelectionBoxAt(centerX, centerY, selectionColor);
this.lastSelectionBoxCenter = {
x: centerX,
y: centerY,
size: this.SELECTION_BOX_SIZE,
};
}
paintCell(x: number, y: number, color: Colord, alpha: number) {
if (this.context === null) throw new Error("null context");
this.clearCell(x, y);
+22 -3
View File
@@ -36,7 +36,7 @@ describe("UILayer", () => {
expect(ui["context"]).not.toBeNull();
});
it("should handle unit selection event", () => {
it("tracks the selected unit on single-unit selection (rendering is WebGL)", () => {
const ui = new UILayer(game, eventBus, transformHandler);
ui.redraw();
const unit = {
@@ -46,8 +46,27 @@ describe("UILayer", () => {
owner: () => ({}),
};
const event = { isSelected: true, unit };
ui.drawSelectionBox = vi.fn();
ui["onUnitSelection"](event as UnitSelectionEvent);
expect(ui.drawSelectionBox).toHaveBeenCalledWith(unit);
// 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()).
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,
tile: () => ({}),
owner: () => ({}),
};
ui["onUnitSelection"]({ isSelected: true, unit } as UnitSelectionEvent);
ui["onUnitSelection"]({
isSelected: false,
unit: null,
} as unknown as UnitSelectionEvent);
expect(ui["selectedUnit"]).toBeNull();
});
});