Refactor simulation and viewer architecture

This commit is contained in:
2026-03-14 15:08:49 -04:00
parent ddca4a16d5
commit 651556c916
71 changed files with 11472 additions and 9031 deletions

View File

@@ -0,0 +1,297 @@
import * as THREE from "three";
import {
completeMarqueeSelection,
hideMarqueeBox,
pickSelectableAtClientPosition,
updateHoverLabel,
updateMarqueeBox,
} from "./viewerInteraction";
import {
applyKeyboardControl,
toggleCameraMode,
zoomFromWheel,
} from "./viewerControls";
import { ViewerHistoryWindowController } from "./viewerHistoryWindowController";
import type {
CameraMode,
DragMode,
Selectable,
WorldState,
} 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;
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] : []);
this.context.syncFollowStateFromSelection();
this.context.updatePanels();
};
readonly onShipStripClick = (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 card = target.closest<HTMLElement>("[data-ship-id]");
const shipId = card?.dataset.shipId;
if (!shipId) {
return;
}
this.context.setSelectedItems([{ kind: "ship", id: shipId }]);
this.context.syncFollowStateFromSelection();
this.context.updatePanels();
};
readonly onShipStripDoubleClick = (event: MouseEvent) => {
const target = event.target;
if (!(target instanceof HTMLElement)) {
return;
}
if (target.closest("[data-history-ship-id]")) {
return;
}
const card = target.closest<HTMLElement>("[data-ship-id]");
const shipId = card?.dataset.shipId;
if (!shipId) {
return;
}
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");
};
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,
selection: this.pickSelectableAtClientPosition(event.clientX, event.clientY),
activeSystemId: this.context.getActiveSystemId(),
world: this.context.getWorld(),
point: this.context.screenPointFromClient(event.clientX, event.clientY),
});
}
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 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();
}
}