Files
OpenFrontIO/src/client/controllers/WarshipSelectionController.ts
T
evanpelle 7b1557b886 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.
2026-05-16 22:58:31 -07:00

317 lines
10 KiB
TypeScript

import { Cell } from "src/core/game/Game";
import { EventBus } from "../../core/EventBus";
import { UnitType } from "../../core/game/Game";
import { TileRef } from "../../core/game/GameMap";
import { GameView, UnitView } from "../../core/game/GameView";
import { Controller } from "../graphics/layers/Controller";
import { TransformHandler } from "../graphics/TransformHandler";
import {
CloseViewEvent,
ContextMenuEvent,
MouseUpEvent,
SelectAllWarshipsEvent,
TouchEvent,
UnitSelectionEvent,
WarshipSelectionBoxCancelEvent,
WarshipSelectionBoxCompleteEvent,
WarshipSelectionBoxUpdateEvent,
} from "../InputHandler";
import { GameView as WebGLGameView } from "../render/gl";
import { MoveWarshipIntentEvent } from "../Transport";
const WARSHIP_SELECTION_RADIUS = 10;
/**
* Controller for warship selection state + click handling.
*
* Drawing for selection boxes (single + multi) lives in the WebGL
* SelectionBoxPass (forwarded via UnitSelectionEvent from ClientGameRunner).
* The drag-rectangle preview is a screen-space DOM overlay (dragRectEl) we
* own here.
*
* This class does not render anything to canvas2D — it's purely a state +
* click controller. The "Controller" pattern: main-thread analog of the
* worker's Execution (init + tick + event subscriptions).
*/
export class WarshipSelectionController implements Controller {
// Currently selected single warship (game-logic readers use this; the
// visual is drawn by WebGL SelectionBoxPass).
private selectedUnit: UnitView | null = null;
// Currently multi-selected warships (shift+drag box select).
private multiSelectedWarships: UnitView[] = [];
// Drag rectangle (shift+drag warship selection box) — a screen-space DOM
// overlay positioned via inline style.
private dragRectEl: HTMLDivElement | null = null;
constructor(
private game: GameView,
private eventBus: EventBus,
private transformHandler: TransformHandler,
private view: WebGLGameView,
) {}
tick() {
// 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(),
);
}
init() {
this.eventBus.on(UnitSelectionEvent, (e) => this.onUnitSelection(e));
this.ensureDragRectEl();
this.eventBus.on(WarshipSelectionBoxUpdateEvent, (e) => {
this.updateDragRect(e.startX, e.startY, e.endX, e.endY);
});
const clearBox = () => this.hideDragRect();
this.eventBus.on(WarshipSelectionBoxCompleteEvent, clearBox);
this.eventBus.on(WarshipSelectionBoxCancelEvent, clearBox);
this.eventBus.on(CloseViewEvent, clearBox);
// Warship select/move click flow (previously in the deleted UnitLayer).
this.eventBus.on(MouseUpEvent, (e) => this.onMouseUp(e));
this.eventBus.on(TouchEvent, (e) => this.onTouch(e));
this.eventBus.on(WarshipSelectionBoxCompleteEvent, (e) =>
this.onSelectionBoxComplete(e),
);
this.eventBus.on(SelectAllWarshipsEvent, () => this.onSelectAllWarships());
}
/**
* Lazily create the shift+drag rectangle overlay. Screen-space DOM element,
* pointer-events: none so it doesn't intercept the drag itself. z-index
* sits above the WebGL/canvas2D map canvases but below HUD modals.
*/
private ensureDragRectEl(): void {
if (this.dragRectEl !== null) return;
const el = document.createElement("div");
el.id = "warship-drag-rect";
el.style.position = "fixed";
el.style.pointerEvents = "none";
el.style.display = "none";
el.style.zIndex = "30";
el.style.borderStyle = "dashed";
el.style.borderWidth = "1px";
el.style.boxSizing = "border-box";
document.body.appendChild(el);
this.dragRectEl = el;
}
private updateDragRect(
startX: number,
startY: number,
endX: number,
endY: number,
): void {
const el = this.dragRectEl;
if (el === null) return;
const x1 = Math.min(startX, endX);
const y1 = Math.min(startY, endY);
const w = Math.abs(endX - startX);
const h = Math.abs(endY - startY);
// Color from the local player's territory tint (matches the canvas2D look).
const myPlayer = this.game.myPlayer();
const base = myPlayer ? myPlayer.territoryColor().lighten(0.2) : null;
const border = base
? base.alpha(0.85).toRgbString()
: "rgba(100, 200, 255, 0.85)";
const fill = base
? base.alpha(0.06).toRgbString()
: "rgba(100, 200, 255, 0.06)";
el.style.left = `${x1}px`;
el.style.top = `${y1}px`;
el.style.width = `${w}px`;
el.style.height = `${h}px`;
el.style.borderColor = border;
el.style.backgroundColor = fill;
el.style.display = "block";
}
private hideDragRect(): void {
if (this.dragRectEl !== null) this.dragRectEl.style.display = "none";
}
/**
* Find player-owned warships near the given cell, sorted by distance.
*/
private findWarshipsNearCell(clickRef: TileRef): UnitView[] {
const myPlayer = this.game.myPlayer();
if (!myPlayer) return [];
return this.game
.units(UnitType.Warship)
.filter(
(unit) =>
unit.isActive() &&
unit.owner() === myPlayer &&
this.game.manhattanDist(unit.tile(), clickRef) <=
WARSHIP_SELECTION_RADIUS,
)
.sort(
(a, b) =>
this.game.manhattanDist(a.tile(), clickRef) -
this.game.manhattanDist(b.tile(), clickRef),
);
}
/**
* Resolve a left-click in the world:
* - multi-selected warships present + clicked water → move them all
* - single selected warship + clicked water → move it, then deselect
* - otherwise → if there's a nearby warship, select the closest one
*/
private onMouseUp(
event: MouseUpEvent,
clickRef?: TileRef,
nearbyWarships?: UnitView[],
) {
if (clickRef === undefined) {
const cell = this.transformHandler.screenToWorldCoordinates(
event.x,
event.y,
);
if (!this.game.isValidCoord(cell.x, cell.y)) return;
clickRef = this.game.ref(cell.x, cell.y);
}
if (!this.game.isWater(clickRef)) return;
if (this.multiSelectedWarships.length > 0) {
const myPlayer = this.game.myPlayer();
const activeIds = this.multiSelectedWarships
.filter((u) => u.isActive() && u.owner() === myPlayer)
.map((u) => u.id());
if (activeIds.length > 0) {
this.eventBus.emit(new MoveWarshipIntentEvent(activeIds, clickRef));
}
this.eventBus.emit(new UnitSelectionEvent(null, false));
return;
}
if (this.selectedUnit) {
this.eventBus.emit(
new MoveWarshipIntentEvent([this.selectedUnit.id()], clickRef),
);
this.eventBus.emit(new UnitSelectionEvent(this.selectedUnit, false));
return;
}
nearbyWarships ??= this.findWarshipsNearCell(clickRef);
if (nearbyWarships.length > 0) {
this.eventBus.emit(new UnitSelectionEvent(nearbyWarships[0], true));
}
}
/**
* Touch handler mirroring mouse-up. On dry land with no selection, falls
* back to opening the radial menu.
*/
private onTouch(event: TouchEvent) {
const cell = this.transformHandler.screenToWorldCoordinates(
event.x,
event.y,
);
if (!this.game.isValidCoord(cell.x, cell.y)) return;
const clickRef = this.game.ref(cell.x, cell.y);
if (this.game.inSpawnPhase()) {
if (!this.game.isWater(clickRef)) {
this.eventBus.emit(new MouseUpEvent(event.x, event.y));
}
return;
}
if (!this.game.isWater(clickRef)) {
this.eventBus.emit(new ContextMenuEvent(event.x, event.y));
return;
}
if (this.selectedUnit || this.multiSelectedWarships.length > 0) {
this.onMouseUp(new MouseUpEvent(event.x, event.y), clickRef);
return;
}
const nearbyWarships = this.findWarshipsNearCell(clickRef);
if (nearbyWarships.length > 0) {
this.onMouseUp(
new MouseUpEvent(event.x, event.y),
clickRef,
nearbyWarships,
);
} else {
this.eventBus.emit(new ContextMenuEvent(event.x, event.y));
}
}
/**
* Resolve a shift+drag selection box: gather all player-owned warships
* whose screen position falls inside the rectangle.
*/
private onSelectionBoxComplete(event: WarshipSelectionBoxCompleteEvent) {
const x1 = Math.min(event.startX, event.endX);
const y1 = Math.min(event.startY, event.endY);
const x2 = Math.max(event.startX, event.endX);
const y2 = Math.max(event.startY, event.endY);
const myPlayer = this.game.myPlayer();
if (!myPlayer) return;
const selected = this.game.units(UnitType.Warship).filter((unit) => {
if (!unit.isActive() || unit.owner() !== myPlayer) return false;
const screen = this.transformHandler.worldToScreenCoordinates(
new Cell(this.game.x(unit.tile()), this.game.y(unit.tile())),
);
return (
screen.x >= x1 && screen.x <= x2 && screen.y >= y1 && screen.y <= y2
);
});
// Clear single selection if we got a box selection
if (selected.length > 0 && this.selectedUnit) {
this.eventBus.emit(new UnitSelectionEvent(this.selectedUnit, false));
}
this.eventBus.emit(new UnitSelectionEvent(null, true, selected));
}
private onSelectAllWarships() {
const myPlayer = this.game.myPlayer();
if (!myPlayer) return;
const allWarships = this.game
.units(UnitType.Warship)
.filter((u) => u.isActive() && u.owner() === myPlayer);
if (allWarships.length === 0) return;
if (this.selectedUnit) {
this.eventBus.emit(new UnitSelectionEvent(this.selectedUnit, false));
}
this.eventBus.emit(new UnitSelectionEvent(null, true, allWarships));
}
/**
* Handle the unit selection event (single or multi).
* When event.units.length > 0 it's a multi-selection from box/select-all.
* When event.unit is set it's a single warship selection.
* When event.isSelected is false it clears all selection state.
*/
private onUnitSelection(event: UnitSelectionEvent) {
this.multiSelectedWarships = [];
this.selectedUnit = null;
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()] : []);
}
}
}