Introduces a floating, draggable, resizable Game Master console as the first of a planned series of GM/debug windows. Replaces the horizontal ops-strip card layout with proper data tables using TanStack Table v8. - GmWindow.vue: reusable draggable+resizable floating window base; snapshots offsetWidth/Height on drag start so resize is preserved - GmOpsWindow.vue: Ships / Stations / Factions tabs with global filter, column sorting, and drag-to-reorder columns (useColumnOrder composable) - gmStore.ts: Pinia store fed from ViewerWorldLifecycle.rebuildFactions with raw world arrays (ships, stations, factions) - Removes opsStripEl binding (was stored but never read by controller) - GM Console toggle button replaces the bottom ops strip Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
370 lines
14 KiB
TypeScript
370 lines
14 KiB
TypeScript
import * as THREE from "three";
|
|
import {
|
|
MAX_CAMERA_DISTANCE,
|
|
MIN_CAMERA_DISTANCE,
|
|
NAV_DISTANCE,
|
|
} from "./viewerConstants";
|
|
import { updatePanFromKeyboard } from "./viewerCamera";
|
|
import { setShellReticleOpacity } from "./viewerControls";
|
|
import { renderFrame, resizeViewer, stepCamera } from "./viewerRenderLoop";
|
|
import { updateSystemStarPresentation } from "./viewerPresentation";
|
|
import { resolveFocusedCelestialId } from "./viewerSelection";
|
|
import { describeSelectionParent } from "./viewerPanels";
|
|
import {
|
|
createInitialNetworkStats,
|
|
createInitialPerformanceStats,
|
|
} from "./viewerState";
|
|
import { ViewerWorldLifecycle } from "./viewerWorldLifecycle";
|
|
import { ViewerInteractionController } from "./viewerInteractionController";
|
|
import { ViewerNavigationController } from "./viewerNavigationController";
|
|
import { ViewerSceneDataController } from "./viewerSceneDataController";
|
|
import { ViewerPresentationController } from "./viewerPresentationController";
|
|
import { createViewerControllers, wireViewerEvents } from "./viewerControllerFactory";
|
|
import { createViewerRenderer } from "./runtime/rendering/createViewerRenderer";
|
|
import { disposeSceneResources } from "./runtime/rendering/disposeThreeResources";
|
|
import { ViewerRenderSurface } from "./runtime/rendering/ViewerRenderSurface";
|
|
import { toDisplayLocalPosition, getSystemCameraFocus } from "./viewerCamera";
|
|
import { UniverseLayer } from "./viewerUniverseLayer";
|
|
import { GalaxyLayer } from "./viewerGalaxyLayer";
|
|
import { SystemLayer } from "./viewerSystemLayer";
|
|
import { LocalLayer } from "./viewerLocalLayer";
|
|
import type { HistoryWindowState, ViewerHudBindings, ViewerHudState } from "./viewerHudState";
|
|
import { describeSelectable } from "./viewerSelection";
|
|
import { entityIdToSelectable, selectionToEntityId, type ViewerSelectionStore } from "./ui/stores/viewerSelection";
|
|
import type { FactionSnapshot } from "./contracts";
|
|
import type {
|
|
CameraMode,
|
|
DragMode,
|
|
NetworkStats,
|
|
PerformanceStats,
|
|
Selectable,
|
|
SystemVisual,
|
|
WorldState,
|
|
PovLevel,
|
|
} from "./viewerTypes";
|
|
|
|
export class ViewerAppController {
|
|
private readonly container: HTMLElement;
|
|
private readonly renderer = createViewerRenderer();
|
|
private readonly renderSurface: ViewerRenderSurface;
|
|
|
|
// ── Three independent rendering layers ───────────────────────────────────
|
|
readonly universeLayer = new UniverseLayer();
|
|
readonly galaxyLayer = new GalaxyLayer();
|
|
readonly systemLayer = new SystemLayer();
|
|
readonly localLayer = new LocalLayer();
|
|
|
|
private readonly clock = new THREE.Clock();
|
|
private readonly raycaster = new THREE.Raycaster();
|
|
private readonly mouse = new THREE.Vector2();
|
|
|
|
// ── Galaxy-space anchor ───────────────────────────────────────────────────
|
|
private readonly galaxyAnchor = new THREE.Vector3(2200, 0, 300);
|
|
// ── System-space anchor ───────────────────────────────────────────────────
|
|
private readonly systemAnchor = new THREE.Vector3();
|
|
|
|
private readonly cameraOffset = new THREE.Vector3();
|
|
private readonly keyState = new Set<string>();
|
|
|
|
readonly hudState: ViewerHudState;
|
|
readonly selectionStore: ViewerSelectionStore;
|
|
private readonly historyLayerEl: HTMLDivElement;
|
|
private readonly marqueeEl: HTMLDivElement;
|
|
private readonly hoverLabelEl: HTMLDivElement;
|
|
private readonly hoverConnectorLineEl: SVGLineElement;
|
|
|
|
private world?: WorldState;
|
|
private worldTimeSyncMs = performance.now();
|
|
private stream?: EventSource;
|
|
private currentStreamScopeKey = "";
|
|
private readonly networkStats: NetworkStats = createInitialNetworkStats();
|
|
private readonly performanceStats: PerformanceStats = createInitialPerformanceStats();
|
|
|
|
private selectedItems: Selectable[] = [];
|
|
private worldSignature = "";
|
|
private povLevel: PovLevel = "system";
|
|
private currentDistance = NAV_DISTANCE.system;
|
|
private desiredDistance = NAV_DISTANCE.system;
|
|
private orbitYaw = -2.3;
|
|
private orbitPitch = 0.62;
|
|
private cameraMode: CameraMode = "tactical";
|
|
private dragMode?: DragMode;
|
|
private dragPointerId?: number;
|
|
private dragStart = new THREE.Vector2();
|
|
private dragLast = new THREE.Vector2();
|
|
private marqueeActive = false;
|
|
private suppressClickSelection = false;
|
|
private activeSystemId?: string;
|
|
private cameraTargetShipId?: string;
|
|
private readonly followCameraPosition = new THREE.Vector3();
|
|
private readonly followCameraFocus = new THREE.Vector3();
|
|
private readonly followCameraDirection = new THREE.Vector3(0, 0.16, 1);
|
|
private readonly followCameraDesiredDirection = new THREE.Vector3(0, 0.16, 1);
|
|
private readonly followCameraOffset = new THREE.Vector3();
|
|
private followOrbitYaw = 0;
|
|
private followOrbitPitch = 0.2;
|
|
private readonly historyWindows: HistoryWindowState[] = [];
|
|
private historyWindowCounter = 0;
|
|
private historyWindowZCounter = 10;
|
|
private historyWindowDragId?: string;
|
|
private historyWindowDragPointerId?: number;
|
|
private historyWindowDragOffset = new THREE.Vector2();
|
|
private readonly worldLifecycle: ViewerWorldLifecycle;
|
|
private readonly interactionController: ViewerInteractionController;
|
|
private readonly navigationController: ViewerNavigationController;
|
|
private readonly sceneDataController: ViewerSceneDataController;
|
|
private readonly presentationController: ViewerPresentationController;
|
|
private readonly disposeEventBindings: () => void;
|
|
private readonly unsubscribeSelectionStore: () => void;
|
|
|
|
constructor(container: HTMLElement, hud: ViewerHudBindings) {
|
|
this.container = container;
|
|
this.hudState = hud.state;
|
|
this.selectionStore = hud.selectionStore;
|
|
this.historyLayerEl = hud.historyLayerEl;
|
|
this.marqueeEl = hud.marqueeEl;
|
|
this.hoverLabelEl = hud.hoverLabelEl;
|
|
this.hoverConnectorLineEl = hud.hoverConnectorLineEl;
|
|
({
|
|
sceneDataController: this.sceneDataController,
|
|
navigationController: this.navigationController,
|
|
presentationController: this.presentationController,
|
|
worldLifecycle: this.worldLifecycle,
|
|
interactionController: this.interactionController,
|
|
} = createViewerControllers(this));
|
|
this.presentationController.initializeAmbience();
|
|
this.unsubscribeSelectionStore = this.selectionStore.$subscribe((_mutation, state) => {
|
|
this.syncSelectionFromStore(state.selectedEntityKind, state.selectedEntityId);
|
|
});
|
|
this.renderSurface = new ViewerRenderSurface({
|
|
container: this.container,
|
|
renderer: this.renderer,
|
|
onFrame: () => this.render(),
|
|
onResize: (width, height) => this.onResize(width, height),
|
|
});
|
|
this.disposeEventBindings = wireViewerEvents(this);
|
|
this.updateCamera(0);
|
|
}
|
|
|
|
async start() {
|
|
this.selectionStore.clearSelection();
|
|
await this.worldLifecycle.bootstrapWorld();
|
|
this.renderSurface.start();
|
|
}
|
|
|
|
dispose() {
|
|
this.disposeEventBindings();
|
|
this.unsubscribeSelectionStore();
|
|
this.stream?.close();
|
|
this.renderSurface.dispose();
|
|
disposeSceneResources(this.universeLayer.scene);
|
|
disposeSceneResources(this.galaxyLayer.scene);
|
|
disposeSceneResources(this.systemLayer.scene);
|
|
disposeSceneResources(this.localLayer.scene);
|
|
}
|
|
|
|
focusSelection(selection: Selectable, cameraMode?: CameraMode) {
|
|
this.applySelectedItems([selection], "ui");
|
|
this.navigationController.focusOnSelection(selection);
|
|
if (cameraMode) {
|
|
this.interactionController.toggleCameraMode(cameraMode);
|
|
if (selection.kind === "ship" && cameraMode === "follow") {
|
|
this.desiredDistance = 0.00018;
|
|
}
|
|
}
|
|
this.updatePanels();
|
|
this.updateGamePanel("Live");
|
|
}
|
|
|
|
openHistoryWindow(selection: Selectable) {
|
|
this.interactionController.openHistoryWindow(selection);
|
|
}
|
|
|
|
private refreshStreamScopeIfNeeded() {
|
|
this.worldLifecycle.refreshStreamScopeIfNeeded();
|
|
}
|
|
|
|
private createWorldPresentationContext() {
|
|
return this.sceneDataController.createWorldPresentationContext({
|
|
world: this.world,
|
|
activeSystemId: this.activeSystemId,
|
|
povLevel: this.povLevel,
|
|
orbitYaw: this.orbitYaw,
|
|
systemCamera: this.systemLayer.camera,
|
|
systemAnchor: this.systemAnchor,
|
|
toDisplayLocalPosition: (localPosition) => toDisplayLocalPosition(localPosition),
|
|
setShellReticleOpacity: (sprite, opacity) => this.setShellReticleOpacity(sprite, opacity),
|
|
});
|
|
}
|
|
|
|
private rebuildFactions(_factions: FactionSnapshot[]) {
|
|
this.worldLifecycle.rebuildFactions(_factions);
|
|
}
|
|
|
|
private updatePanels() {
|
|
this.worldLifecycle.updatePanels();
|
|
}
|
|
|
|
private applySelectedItems(items: Selectable[], source: "viewer" | "ui") {
|
|
this.selectedItems = items;
|
|
if (items.length === 1) {
|
|
const selection = items[0];
|
|
this.selectionStore.selectSelection({
|
|
id: selectionToEntityId(selection),
|
|
kind: selection.kind,
|
|
label: describeSelectable(this.world, selection),
|
|
}, source);
|
|
return;
|
|
}
|
|
|
|
this.selectionStore.clearSelection(source);
|
|
}
|
|
|
|
private syncSelectionFromStore(
|
|
kind: Selectable["kind"] | null,
|
|
entityId: string | null,
|
|
) {
|
|
const selection = entityIdToSelectable(kind, entityId);
|
|
this.selectedItems = selection ? [selection] : [];
|
|
this.navigationController.syncFollowStateFromSelection();
|
|
this.updatePanels();
|
|
this.updateGamePanel("Live");
|
|
}
|
|
|
|
private render() {
|
|
renderFrame({
|
|
clock: this.clock,
|
|
renderer: this.renderer,
|
|
universeLayer: this.universeLayer,
|
|
galaxyLayer: this.galaxyLayer,
|
|
systemLayer: this.systemLayer,
|
|
localLayer: this.localLayer,
|
|
getPovLevel: () => this.povLevel,
|
|
updateCamera: (delta) => this.updateCamera(delta),
|
|
updateAmbience: (delta) => this.presentationController.updateAmbience(delta),
|
|
updatePlanetPresentation: () => this.presentationController.updatePlanetPresentation(),
|
|
updateShipPresentation: () => this.presentationController.updateShipPresentation(),
|
|
updateNetworkPanel: () => this.presentationController.updateNetworkPanel(),
|
|
applyZoomPresentation: () => this.presentationController.applyZoomPresentation(),
|
|
recordPerformanceStats: (frameMs) => this.presentationController.recordPerformanceStats(frameMs),
|
|
updatePerformancePanel: () => this.presentationController.updatePerformancePanel(),
|
|
});
|
|
}
|
|
|
|
private computeOrbitOffset(): THREE.Vector3 {
|
|
const horizontalDistance = this.currentDistance * Math.cos(this.orbitPitch);
|
|
return new THREE.Vector3(
|
|
Math.cos(this.orbitYaw) * horizontalDistance,
|
|
this.currentDistance * Math.sin(this.orbitPitch),
|
|
Math.sin(this.orbitYaw) * horizontalDistance,
|
|
);
|
|
}
|
|
|
|
private updateCamera(delta: number) {
|
|
const nextState = stepCamera({
|
|
currentDistance: this.currentDistance,
|
|
desiredDistance: this.desiredDistance,
|
|
orbitPitch: this.orbitPitch,
|
|
delta,
|
|
});
|
|
this.currentDistance = nextState.currentDistance;
|
|
this.povLevel = nextState.povLevel;
|
|
this.orbitPitch = nextState.orbitPitch;
|
|
this.navigationController.updateActiveSystem();
|
|
|
|
if (this.cameraMode === "follow" && this.navigationController.updateFollowCamera(delta)) {
|
|
// Follow camera directly controls systemLayer.camera in updateFollowCamera.
|
|
// Still update galaxy camera independently.
|
|
const orbitOffset = this.computeOrbitOffset();
|
|
this.galaxyLayer.updateCamera(this.galaxyAnchor, orbitOffset);
|
|
return;
|
|
}
|
|
|
|
this.updatePanFromKeyboard(delta);
|
|
this.orbitPitch = THREE.MathUtils.clamp(this.orbitPitch, 0.18, 1.3);
|
|
|
|
const orbitOffset = this.computeOrbitOffset();
|
|
|
|
this.galaxyLayer.updateCamera(this.galaxyAnchor, orbitOffset);
|
|
|
|
if (this.activeSystemId) {
|
|
this.systemLayer.updateCamera(getSystemCameraFocus(this.systemAnchor), orbitOffset);
|
|
}
|
|
|
|
this.localLayer.updateCamera(orbitOffset);
|
|
|
|
// Update star dot scales in galaxy scene
|
|
updateSystemStarPresentation(
|
|
this.galaxyLayer.systemVisuals,
|
|
this.activeSystemId,
|
|
this.galaxyLayer.camera,
|
|
(sprite, opacity) => this.setShellReticleOpacity(sprite, opacity),
|
|
);
|
|
}
|
|
|
|
private updatePanFromKeyboard(delta: number) {
|
|
updatePanFromKeyboard(
|
|
this.keyState,
|
|
this.orbitYaw,
|
|
this.currentDistance,
|
|
this.povLevel,
|
|
this.activeSystemId,
|
|
this.systemAnchor,
|
|
this.galaxyAnchor,
|
|
delta,
|
|
MIN_CAMERA_DISTANCE,
|
|
MAX_CAMERA_DISTANCE,
|
|
);
|
|
}
|
|
|
|
private updateSystemSummaries() {
|
|
this.presentationController.updateSystemSummaries();
|
|
}
|
|
|
|
private renderRecentEvents(entityKind: string, entityId: string) {
|
|
return this.presentationController.renderRecentEvents(entityKind, entityId);
|
|
}
|
|
|
|
private updateGamePanel(mode: string) {
|
|
this.presentationController.updateGamePanel(mode);
|
|
}
|
|
|
|
private screenPointFromClient(clientX: number, clientY: number) {
|
|
return this.presentationController.screenPointFromClient(clientX, clientY);
|
|
}
|
|
|
|
private refreshHistoryWindows() {
|
|
this.interactionController.refreshHistoryWindows();
|
|
}
|
|
|
|
private resolveFocusedCelestialId() {
|
|
return resolveFocusedCelestialId(this.world, this.selectedItems);
|
|
}
|
|
|
|
private onResize(width: number, height: number) {
|
|
resizeViewer({
|
|
galaxyLayer: this.galaxyLayer,
|
|
systemLayer: this.systemLayer,
|
|
localLayer: this.localLayer,
|
|
width,
|
|
height,
|
|
});
|
|
}
|
|
|
|
private setShellReticleOpacity(sprite: SystemVisual["shellReticle"], opacity: number) {
|
|
setShellReticleOpacity(sprite, opacity);
|
|
}
|
|
|
|
private describeSelectionParent(selection: Selectable) {
|
|
return describeSelectionParent(this.world, selection, this.systemLayer.stationVisuals, this.systemLayer.nodeVisuals);
|
|
}
|
|
|
|
private toDisplayLocalPosition(localPosition: THREE.Vector3, systemId?: string) {
|
|
return toDisplayLocalPosition(localPosition);
|
|
}
|
|
|
|
private updateSystemPanel() {
|
|
this.presentationController.updateSystemPanel();
|
|
}
|
|
}
|