import * as THREE from "three"; import { pickSelectableHitAtClientPosition, pickSelectableAtClientPosition, updateHoverLabel, } from "./viewerInteraction"; import { applyKeyboardControl, cycleStatsOverlayMode, toggleCameraMode, navigateFromWheel, } from "./viewerControls"; import { MAX_CAMERA_DISTANCE, MIN_CAMERA_DISTANCE, NAV_DISTANCE_PLANET_ORBIT } from "./viewerConstants"; import { ViewerHistoryWindowController } from "./viewerHistoryWindowController"; import type { StatsOverlayMode, ViewerHudState } from "./viewerHudState"; import type { CameraMode, DragMode, Selectable, WorldState, PovLevel, } from "./viewerTypes"; import type { ViewerOrderContextMenuTarget } from "./ui/stores/viewerOrderContextMenu"; export interface ViewerInteractionContext { renderer: THREE.WebGLRenderer; raycaster: THREE.Raycaster; mouse: THREE.Vector2; galaxyCamera: THREE.PerspectiveCamera; systemCamera: THREE.PerspectiveCamera; localCamera: THREE.PerspectiveCamera; galaxySelectableTargets: Map; systemSelectableTargets: Map; localSelectableTargets: Map; hoverLabelEl: HTMLDivElement; hoverConnectorLineEl: SVGLineElement; marqueeEl: HTMLDivElement; hudState: ViewerHudState; keyState: Set; getWorld: () => WorldState | undefined; getActiveSystemId: () => string | undefined; getPovLevel: () => PovLevel; 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; applyPanDelta: (delta: THREE.Vector2) => void; syncFollowStateFromSelection: () => void; updatePanels: () => void; focusOnSelection: (selection: Selectable) => void; updateGamePanel: (mode: string) => void; openOrderContextMenu: (x: number, y: number, target: ViewerOrderContextMenuTarget) => void; closeOrderContextMenu: () => void; getStatsOverlayMode: () => StatsOverlayMode; setStatsOverlayMode: (mode: StatsOverlayMode) => void; refreshStatsOverlay: () => void; historyController: ViewerHistoryWindowController; } export class ViewerInteractionController { private readonly activePointers = new Map(); private pinchStartDistance?: number; private pinchStartZoom?: number; private pinchLastCenter?: THREE.Vector2; constructor(private readonly context: ViewerInteractionContext) {} readonly onPointerDown = (event: PointerEvent) => { if (event.button !== 0) { return; } const point = this.context.screenPointFromClient(event.clientX, event.clientY); this.activePointers.set(event.pointerId, point); this.context.renderer.domElement.setPointerCapture(event.pointerId); if (this.activePointers.size >= 2) { const gesture = this.getPinchGesture(); if (!gesture) { return; } this.context.setSuppressClickSelection(true); this.context.setDragMode("pinch"); this.context.setDragPointerId(event.pointerId); this.pinchStartDistance = gesture.distance; this.pinchStartZoom = this.context.getDesiredDistance(); this.pinchLastCenter = gesture.center; return; } this.context.setDragMode("pan"); this.context.setDragPointerId(event.pointerId); this.context.dragStart.copy(point); this.context.dragLast.copy(point); }; readonly onPointerMove = (event: PointerEvent) => { this.updateHoverLabel(event); const point = this.context.screenPointFromClient(event.clientX, event.clientY); if (this.activePointers.has(event.pointerId)) { this.activePointers.set(event.pointerId, point); } if (this.context.getDragPointerId() !== event.pointerId || !this.context.getDragMode()) { return; } if (this.context.getDragMode() === "pinch") { const gesture = this.getPinchGesture(); if (!gesture || !this.pinchStartDistance || !this.pinchStartZoom || !this.pinchLastCenter) { return; } const zoomRatio = THREE.MathUtils.clamp(gesture.distance / this.pinchStartDistance, 0.25, 4); this.context.setDesiredDistance(THREE.MathUtils.clamp( this.pinchStartZoom / zoomRatio, MIN_CAMERA_DISTANCE, MAX_CAMERA_DISTANCE, )); const centerDelta = gesture.center.clone().sub(this.pinchLastCenter); this.pinchLastCenter = gesture.center; this.context.applyPanDelta(centerDelta); return; } const delta = point.clone().sub(this.context.dragLast); const dragDistance = point.distanceTo(this.context.dragStart); if (dragDistance > 6) { this.context.setSuppressClickSelection(true); } this.context.dragLast.copy(point); this.context.applyPanDelta(delta); }; readonly onPointerUp = (event: PointerEvent) => { if (this.context.renderer.domElement.hasPointerCapture(event.pointerId)) { this.context.renderer.domElement.releasePointerCapture(event.pointerId); } this.activePointers.delete(event.pointerId); if (this.activePointers.size >= 2) { const gesture = this.getPinchGesture(); if (gesture) { this.context.setDragMode("pinch"); this.pinchStartDistance = gesture.distance; this.pinchStartZoom = this.context.getDesiredDistance(); this.pinchLastCenter = gesture.center; } return; } const remainingPointer = this.activePointers.entries().next(); if (!remainingPointer.done) { const [pointerId, point] = remainingPointer.value; this.context.setDragMode("pan"); this.context.setDragPointerId(pointerId); this.context.dragStart.copy(point); this.context.dragLast.copy(point); } else { this.context.setDragMode(undefined); this.context.setDragPointerId(undefined); } this.pinchStartDistance = undefined; this.pinchStartZoom = undefined; this.pinchLastCenter = undefined; }; readonly onClick = (event: MouseEvent) => { this.context.closeOrderContextMenu(); 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 onContextMenu = (event: MouseEvent) => { event.preventDefault(); this.context.closeOrderContextMenu(); const picked = this.pickSelectableAtClientPosition(event.clientX, event.clientY); if (!picked) { return; } const target = this.buildOrderContextTarget(picked); if (!target) { return; } this.context.openOrderContextMenu(event.clientX, event.clientY, target); }; 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("tactical"); 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; } const selection = selectedItems[0]; this.context.focusOnSelection(selection); this.context.syncFollowStateFromSelection(); if (selection.kind === "planet") { this.context.setDesiredDistance(NAV_DISTANCE_PLANET_ORBIT); this.context.updateGamePanel("Live"); return; } if (selection.kind === "ship") { this.toggleCameraMode("tactical"); this.context.updatePanels(); this.context.updateGamePanel("Live"); return; } }; readonly onWheel = (event: WheelEvent) => { event.preventDefault(); this.context.setDesiredDistance(navigateFromWheel(this.context.getDesiredDistance(), event.deltaY)); this.context.updateGamePanel("Live"); }; readonly onKeyDown = (event: KeyboardEvent) => { if (event.repeat) { return; } const key = event.key.toLowerCase(); if (key === "f10") { event.preventDefault(); this.context.setStatsOverlayMode(cycleStatsOverlayMode(this.context.getStatsOverlayMode())); this.context.refreshStatsOverlay(); return; } 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(), hoverState: this.context.hudState.hoverLabel, hoverLabelEl: this.context.hoverLabelEl, hoverConnectorLineEl: this.context.hoverConnectorLineEl, hoverPick: this.pickSelectableHitAtClientPosition(event.clientX, event.clientY), activeSystemId: this.context.getActiveSystemId(), povLevel: this.context.getPovLevel(), world: this.context.getWorld(), point: this.context.screenPointFromClient(event.clientX, event.clientY), }); } refreshHistoryWindows() { this.context.historyController.refreshHistoryWindows(); } openHistoryWindow(selection: Selectable) { this.context.historyController.openHistoryWindow(selection); } 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.getPovLevel(), this.context.galaxyCamera, this.context.galaxySelectableTargets, this.context.systemCamera, this.context.systemSelectableTargets, this.context.localCamera, this.context.localSelectableTargets, clientX, clientY, ); } private pickSelectableHitAtClientPosition(clientX: number, clientY: number) { return pickSelectableHitAtClientPosition( this.context.renderer, this.context.raycaster, this.context.mouse, this.context.getPovLevel(), this.context.galaxyCamera, this.context.galaxySelectableTargets, this.context.systemCamera, this.context.systemSelectableTargets, this.context.localCamera, this.context.localSelectableTargets, clientX, clientY, ); } private getPinchGesture() { const points = [...this.activePointers.values()]; if (points.length < 2) { return null; } const [first, second] = points; return { center: first.clone().add(second).multiplyScalar(0.5), distance: first.distanceTo(second), }; } private shouldFocusSelectionOnClick(selection: Selectable) { if (selection.kind === "planet") { return true; } return selection.kind === "system" && selection.id !== this.context.getActiveSystemId(); } private buildOrderContextTarget(selection: Selectable): ViewerOrderContextMenuTarget | null { const world = this.context.getWorld(); if (!world) { return null; } switch (selection.kind) { case "ship": { const ship = world.ships.get(selection.id); return ship ? { selection, label: ship.name, systemId: ship.systemId, targetPosition: ship.localPosition, } : null; } case "station": { const station = world.stations.get(selection.id); return station ? { selection, label: station.label, systemId: station.systemId, targetPosition: station.localPosition, } : null; } case "node": { const node = world.nodes.get(selection.id); return node ? { selection, label: node.itemId, systemId: node.systemId, anchorId: node.anchorId, itemId: node.itemId, targetPosition: node.localPosition, } : null; } case "celestial": { const celestial = world.celestials.get(selection.id); return celestial ? { selection, label: selection.id, systemId: celestial.systemId, targetPosition: celestial.orbitalAnchor, } : null; } case "construction-site": { const site = world.constructionSites.get(selection.id); return site ? { selection, label: site.blueprintId ?? site.targetDefinitionId ?? site.id, systemId: site.systemId, } : null; } case "system": { const system = world.systems.get(selection.id); return system ? { selection, label: system.label, systemId: system.id, } : null; } default: return null; } } }