import * as THREE from "three"; import { completeMarqueeSelection, hideMarqueeBox, pickSelectableHitAtClientPosition, pickSelectableAtClientPosition, updateHoverLabel, updateMarqueeBox, } from "./viewerInteraction"; import { applyKeyboardControl, toggleCameraMode, zoomFromWheel, } from "./viewerControls"; import { ViewerHistoryWindowController } from "./viewerHistoryWindowController"; import type { CameraMode, DragMode, Selectable, WorldState, ZoomLevel, } from "./viewerTypes"; export interface ViewerInteractionContext { renderer: THREE.WebGLRenderer; raycaster: THREE.Raycaster; mouse: THREE.Vector2; camera: THREE.PerspectiveCamera; selectableTargets: Map; hoverLabelEl: HTMLDivElement; marqueeEl: HTMLDivElement; keyState: Set; getWorld: () => WorldState | undefined; getActiveSystemId: () => string | undefined; getZoomLevel: () => ZoomLevel; getSelectedItems: () => Selectable[]; setSelectedItems: (items: Selectable[]) => void; getDragMode: () => DragMode | undefined; setDragMode: (mode: DragMode | undefined) => void; getDragPointerId: () => number | undefined; setDragPointerId: (pointerId: number | undefined) => void; dragStart: THREE.Vector2; dragLast: THREE.Vector2; getMarqueeActive: () => boolean; setMarqueeActive: (value: boolean) => void; getSuppressClickSelection: () => boolean; setSuppressClickSelection: (value: boolean) => void; getDesiredDistance: () => number; setDesiredDistance: (value: number) => void; getCameraMode: () => CameraMode; setCameraMode: (value: CameraMode) => void; getCameraTargetShipId: () => string | undefined; setCameraTargetShipId: (value: string | undefined) => void; getFollowCameraPosition: () => THREE.Vector3; getFollowCameraFocus: () => THREE.Vector3; screenPointFromClient: (clientX: number, clientY: number) => THREE.Vector2; applyOrbitDelta: (delta: THREE.Vector2) => void; syncFollowStateFromSelection: () => void; updatePanels: () => void; focusOnSelection: (selection: Selectable) => void; updateGamePanel: (mode: string) => void; historyController: ViewerHistoryWindowController; } export class ViewerInteractionController { constructor(private readonly context: ViewerInteractionContext) {} readonly onPointerDown = (event: PointerEvent) => { if (event.button === 1) { this.context.setDragMode("orbit"); this.context.setDragPointerId(event.pointerId); this.context.dragLast.copy(this.context.screenPointFromClient(event.clientX, event.clientY)); this.context.renderer.domElement.setPointerCapture(event.pointerId); return; } if (event.button !== 0) { return; } this.context.setDragMode("marquee"); this.context.setDragPointerId(event.pointerId); this.context.dragStart.copy(this.context.screenPointFromClient(event.clientX, event.clientY)); this.context.dragLast.copy(this.context.dragStart); this.context.setMarqueeActive(false); this.context.renderer.domElement.setPointerCapture(event.pointerId); }; readonly onPointerMove = (event: PointerEvent) => { this.updateHoverLabel(event); if (this.context.getDragPointerId() !== event.pointerId || !this.context.getDragMode()) { return; } const point = this.context.screenPointFromClient(event.clientX, event.clientY); if (this.context.getDragMode() === "orbit") { const delta = point.clone().sub(this.context.dragLast); this.context.dragLast.copy(point); this.context.applyOrbitDelta(delta); return; } const dragDistance = point.distanceTo(this.context.dragStart); if (!this.context.getMarqueeActive() && dragDistance > 8) { this.context.setMarqueeActive(true); this.context.setSuppressClickSelection(true); this.context.marqueeEl.style.display = "block"; } if (!this.context.getMarqueeActive()) { return; } this.context.dragLast.copy(point); updateMarqueeBox(this.context.marqueeEl, this.context.dragStart, this.context.dragLast); }; readonly onPointerUp = (event: PointerEvent) => { if (this.context.getDragPointerId() !== event.pointerId) { return; } if (this.context.renderer.domElement.hasPointerCapture(event.pointerId)) { this.context.renderer.domElement.releasePointerCapture(event.pointerId); } if (this.context.getDragMode() === "marquee" && this.context.getMarqueeActive()) { this.completeMarqueeSelection(); hideMarqueeBox(this.context.marqueeEl); } this.context.setDragMode(undefined); this.context.setDragPointerId(undefined); this.context.setMarqueeActive(false); }; readonly onClick = (event: MouseEvent) => { if (this.context.getSuppressClickSelection()) { this.context.setSuppressClickSelection(false); return; } const picked = this.pickSelectableAtClientPosition(event.clientX, event.clientY); this.context.setSelectedItems(picked ? [picked] : []); if (picked && this.shouldFocusSelectionOnClick(picked)) { this.context.focusOnSelection(picked); } this.context.syncFollowStateFromSelection(); this.context.updatePanels(); }; readonly onOpsStripClick = (event: MouseEvent) => { const target = event.target; if (!(target instanceof HTMLElement)) { return; } const historyButton = target.closest("[data-history-ship-id]"); const historyShipId = historyButton?.dataset.historyShipId; if (historyShipId) { this.context.historyController.openHistoryWindow({ kind: "ship", id: historyShipId }); return; } const shipCard = target.closest("[data-ship-id]"); const shipId = shipCard?.dataset.shipId; if (shipId) { this.context.setSelectedItems([{ kind: "ship", id: shipId }]); this.context.syncFollowStateFromSelection(); this.context.updatePanels(); return; } const stationCard = target.closest("[data-station-id]"); const stationId = stationCard?.dataset.stationId; if (stationId) { this.context.setSelectedItems([{ kind: "station", id: stationId }]); this.context.syncFollowStateFromSelection(); this.context.updatePanels(); } }; readonly onOpsStripDoubleClick = (event: MouseEvent) => { const target = event.target; if (!(target instanceof HTMLElement)) { return; } if (target.closest("[data-history-ship-id]")) { return; } const shipCard = target.closest("[data-ship-id]"); const shipId = shipCard?.dataset.shipId; if (shipId) { this.context.setSelectedItems([{ kind: "ship", id: shipId }]); this.context.syncFollowStateFromSelection(); this.context.focusOnSelection({ kind: "ship", id: shipId }); this.toggleCameraMode("follow"); this.context.updatePanels(); this.context.updateGamePanel("Live"); return; } const stationCard = target.closest("[data-station-id]"); const stationId = stationCard?.dataset.stationId; if (stationId) { this.context.setSelectedItems([{ kind: "station", id: stationId }]); this.context.syncFollowStateFromSelection(); this.toggleCameraMode("tactical"); this.context.focusOnSelection({ kind: "station", id: stationId }); this.context.updatePanels(); this.context.updateGamePanel("Live"); } }; readonly onHistoryLayerClick = (event: MouseEvent) => this.context.historyController.onHistoryLayerClick(event); readonly onHistoryLayerPointerDown = (event: PointerEvent) => this.context.historyController.onHistoryLayerPointerDown(event); readonly onHistoryWindowPointerMove = (event: PointerEvent) => this.context.historyController.onHistoryWindowPointerMove(event); readonly onHistoryWindowPointerUp = (event: PointerEvent) => this.context.historyController.onHistoryWindowPointerUp(event); readonly onDoubleClick = () => { const selectedItems = this.context.getSelectedItems(); if (selectedItems.length !== 1) { return; } this.context.focusOnSelection(selectedItems[0]); this.context.syncFollowStateFromSelection(); }; readonly onWheel = (event: WheelEvent) => { event.preventDefault(); this.context.setDesiredDistance(zoomFromWheel(this.context.getDesiredDistance(), event.deltaY)); this.context.updateGamePanel("Live"); }; readonly onKeyDown = (event: KeyboardEvent) => { if (event.repeat) { return; } const key = event.key.toLowerCase(); const controlState = applyKeyboardControl({ keyState: this.context.keyState, cameraMode: this.context.getCameraMode(), desiredDistance: this.context.getDesiredDistance(), key, }); this.context.setCameraMode(controlState.cameraMode); this.context.setDesiredDistance(controlState.desiredDistance); if (key === "c") { this.toggleCameraMode(); } this.context.updateGamePanel("Live"); }; readonly onKeyUp = (event: KeyboardEvent) => { this.context.keyState.delete(event.key.toLowerCase()); }; updateHoverLabel(event: PointerEvent) { updateHoverLabel({ dragMode: this.context.getDragMode(), hoverLabelEl: this.context.hoverLabelEl, hoverPick: this.pickSelectableHitAtClientPosition(event.clientX, event.clientY), activeSystemId: this.context.getActiveSystemId(), zoomLevel: this.context.getZoomLevel(), world: this.context.getWorld(), point: this.context.screenPointFromClient(event.clientX, event.clientY), camera: this.context.camera, }); } refreshHistoryWindows() { this.context.historyController.refreshHistoryWindows(); } toggleCameraMode(forceMode?: CameraMode) { const nextState = toggleCameraMode({ cameraMode: this.context.getCameraMode(), cameraTargetShipId: this.context.getCameraTargetShipId(), selectedItems: this.context.getSelectedItems(), desiredDistance: this.context.getDesiredDistance(), followCameraPosition: this.context.getFollowCameraPosition(), followCameraFocus: this.context.getFollowCameraFocus(), forceMode, }); this.context.setCameraMode(nextState.cameraMode); this.context.setCameraTargetShipId(nextState.cameraTargetShipId); this.context.setDesiredDistance(nextState.desiredDistance); } private pickSelectableAtClientPosition(clientX: number, clientY: number) { return pickSelectableAtClientPosition( this.context.renderer, this.context.raycaster, this.context.mouse, this.context.camera, this.context.selectableTargets, clientX, clientY, ); } private pickSelectableHitAtClientPosition(clientX: number, clientY: number) { return pickSelectableHitAtClientPosition( this.context.renderer, this.context.raycaster, this.context.mouse, this.context.camera, this.context.selectableTargets, clientX, clientY, ); } private completeMarqueeSelection() { const selection = completeMarqueeSelection({ renderer: this.context.renderer, camera: this.context.camera, dragStart: this.context.dragStart, dragLast: this.context.dragLast, selectableTargets: this.context.selectableTargets, }); this.context.setSelectedItems(selection); this.context.syncFollowStateFromSelection(); this.context.updatePanels(); } private shouldFocusSelectionOnClick(selection: Selectable) { if (selection.kind === "planet") { return true; } return selection.kind === "system" && selection.id !== this.context.getActiveSystemId(); } }