512 lines
17 KiB
TypeScript
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;
|
|
}
|
|
}
|
|
}
|