343 lines
11 KiB
TypeScript
343 lines
11 KiB
TypeScript
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<THREE.Object3D, Selectable>;
|
|
hoverLabelEl: HTMLDivElement;
|
|
marqueeEl: HTMLDivElement;
|
|
keyState: Set<string>;
|
|
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<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("follow");
|
|
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;
|
|
}
|
|
|
|
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();
|
|
}
|
|
}
|