Add player onboarding and tactical viewer updates

This commit is contained in:
2026-04-06 17:12:44 -04:00
parent 706e1cda8f
commit 63a9f808bb
52 changed files with 2699 additions and 577 deletions

View File

@@ -1,20 +1,18 @@
import * as THREE from "three";
import {
completeMarqueeSelection,
hideMarqueeBox,
pickSelectableHitAtClientPosition,
pickSelectableAtClientPosition,
updateHoverLabel,
updateMarqueeBox,
} from "./viewerInteraction";
import {
applyKeyboardControl,
cycleStatsOverlayMode,
toggleCameraMode,
navigateFromWheel,
} from "./viewerControls";
import { NAV_DISTANCE, NAV_DISTANCE_PLANET_ORBIT, NAV_DISTANCE_SHIP_HULL } from "./viewerConstants";
import { MAX_CAMERA_DISTANCE, MIN_CAMERA_DISTANCE, NAV_DISTANCE_PLANET_ORBIT } from "./viewerConstants";
import { ViewerHistoryWindowController } from "./viewerHistoryWindowController";
import type { ViewerHudState } from "./viewerHudState";
import type { StatsOverlayMode, ViewerHudState } from "./viewerHudState";
import type {
CameraMode,
DragMode,
@@ -61,88 +59,128 @@ export interface ViewerInteractionContext {
getFollowCameraPosition: () => THREE.Vector3;
getFollowCameraFocus: () => THREE.Vector3;
screenPointFromClient: (clientX: number, clientY: number) => THREE.Vector2;
applyOrbitDelta: (delta: THREE.Vector2) => void;
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 === 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);
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;
}
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);
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 (!this.context.getMarqueeActive() && dragDistance > 8) {
this.context.setMarqueeActive(true);
if (dragDistance > 6) {
this.context.setSuppressClickSelection(true);
this.context.hudState.marquee.visible = true;
this.context.marqueeEl.style.display = "block";
}
if (!this.context.getMarqueeActive()) {
return;
}
this.context.dragLast.copy(point);
updateMarqueeBox(this.context.hudState.marquee, this.context.marqueeEl, this.context.dragStart, this.context.dragLast);
this.context.applyPanDelta(delta);
};
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);
}
this.activePointers.delete(event.pointerId);
if (this.context.getDragMode() === "marquee" && this.context.getMarqueeActive()) {
this.completeMarqueeSelection();
hideMarqueeBox(this.context.hudState.marquee, this.context.marqueeEl);
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;
}
this.context.setDragMode(undefined);
this.context.setDragPointerId(undefined);
this.context.setMarqueeActive(false);
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) => {
@@ -225,8 +263,7 @@ export class ViewerInteractionController {
this.context.setSelectedItems([{ kind: "ship", id: shipId }]);
this.context.syncFollowStateFromSelection();
this.context.focusOnSelection({ kind: "ship", id: shipId });
this.toggleCameraMode("follow");
this.context.setDesiredDistance(NAV_DISTANCE_SHIP_HULL);
this.toggleCameraMode("tactical");
this.context.updatePanels();
this.context.updateGamePanel("Live");
return;
@@ -268,8 +305,7 @@ export class ViewerInteractionController {
}
if (selection.kind === "ship") {
this.toggleCameraMode("follow");
this.context.setDesiredDistance(NAV_DISTANCE_SHIP_HULL);
this.toggleCameraMode("tactical");
this.context.updatePanels();
this.context.updateGamePanel("Live");
return;
@@ -288,6 +324,13 @@ export class ViewerInteractionController {
}
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(),
@@ -371,17 +414,17 @@ export class ViewerInteractionController {
);
}
private completeMarqueeSelection() {
const selection = completeMarqueeSelection({
renderer: this.context.renderer,
systemCamera: this.context.systemCamera,
dragStart: this.context.dragStart,
dragLast: this.context.dragLast,
systemSelectableTargets: this.context.systemSelectableTargets,
});
this.context.setSelectedItems(selection);
this.context.syncFollowStateFromSelection();
this.context.updatePanels();
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) {