Files
space-game/apps/viewer/src/viewerInteractionController.ts

512 lines
17 KiB
TypeScript

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<THREE.Object3D, Selectable>;
systemSelectableTargets: Map<THREE.Object3D, Selectable>;
localSelectableTargets: Map<THREE.Object3D, Selectable>;
hoverLabelEl: HTMLDivElement;
hoverConnectorLineEl: SVGLineElement;
marqueeEl: HTMLDivElement;
hudState: ViewerHudState;
keyState: Set<string>;
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<number, THREE.Vector2>();
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<HTMLElement>("[data-history-ship-id]");
const historyShipId = historyButton?.dataset.historyShipId;
if (historyShipId) {
this.context.historyController.openHistoryWindow({ kind: "ship", id: historyShipId });
return;
}
const shipCard = target.closest<HTMLElement>("[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<HTMLElement>("[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<HTMLElement>("[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<HTMLElement>("[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;
}
}
}