350 lines
13 KiB
TypeScript
350 lines
13 KiB
TypeScript
import * as THREE from "three";
|
|
import {
|
|
MAX_CAMERA_DISTANCE,
|
|
MIN_CAMERA_DISTANCE,
|
|
NAV_DISTANCE,
|
|
} from "./viewerConstants";
|
|
import { createViewerHud } from "./viewerHud";
|
|
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 { toDisplayLocalPosition, getSystemCameraFocus } from "./viewerCamera";
|
|
import { UniverseLayer } from "./viewerUniverseLayer";
|
|
import { GalaxyLayer } from "./viewerGalaxyLayer";
|
|
import { SystemLayer } from "./viewerSystemLayer";
|
|
import { LocalLayer } from "./viewerLocalLayer";
|
|
import type { FactionSnapshot } from "./contracts";
|
|
import type {
|
|
CameraMode,
|
|
DragMode,
|
|
HistoryWindowState,
|
|
NetworkStats,
|
|
PerformanceStats,
|
|
Selectable,
|
|
SystemVisual,
|
|
WorldState,
|
|
PovLevel,
|
|
} from "./viewerTypes";
|
|
|
|
export class ViewerAppController {
|
|
private readonly container: HTMLElement;
|
|
private readonly renderer = new THREE.WebGLRenderer({ antialias: true });
|
|
|
|
// ── 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>();
|
|
|
|
private readonly gamePanelEl: HTMLDivElement;
|
|
|
|
private readonly statusEl: HTMLDivElement;
|
|
private readonly gameSummaryEl: HTMLSpanElement;
|
|
private readonly systemPanelEl: HTMLDivElement;
|
|
private readonly systemTitleEl: HTMLHeadingElement;
|
|
private readonly systemBodyEl: HTMLDivElement;
|
|
private readonly detailTitleEl: HTMLHeadingElement;
|
|
private readonly detailBodyEl: HTMLDivElement;
|
|
private readonly opsStripEl: HTMLDivElement;
|
|
private readonly networkSectionEl: HTMLDivElement;
|
|
private readonly networkSummaryEl: HTMLSpanElement;
|
|
private readonly networkPanelEl: HTMLDivElement;
|
|
private readonly performanceSectionEl: HTMLDivElement;
|
|
private readonly performanceSummaryEl: HTMLSpanElement;
|
|
private readonly performancePanelEl: HTMLDivElement;
|
|
private readonly errorEl: HTMLDivElement;
|
|
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 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;
|
|
|
|
constructor(container: HTMLElement) {
|
|
this.container = container;
|
|
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
|
this.renderer.outputColorSpace = THREE.SRGBColorSpace;
|
|
|
|
|
|
const hud = createViewerHud(document);
|
|
this.gamePanelEl = hud.gamePanelEl;
|
|
this.statusEl = hud.statusEl;
|
|
this.gameSummaryEl = hud.gameSummaryEl;
|
|
this.networkSectionEl = hud.networkSectionEl;
|
|
this.systemPanelEl = hud.systemPanelEl;
|
|
this.systemTitleEl = hud.systemTitleEl;
|
|
this.systemBodyEl = hud.systemBodyEl;
|
|
this.detailTitleEl = hud.detailTitleEl;
|
|
this.detailBodyEl = hud.detailBodyEl;
|
|
this.opsStripEl = hud.opsStripEl;
|
|
this.networkSummaryEl = hud.networkSummaryEl;
|
|
this.networkPanelEl = hud.networkPanelEl;
|
|
this.performanceSectionEl = hud.performanceSectionEl;
|
|
this.performanceSummaryEl = hud.performanceSummaryEl;
|
|
this.performancePanelEl = hud.performancePanelEl;
|
|
this.errorEl = hud.errorEl;
|
|
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.container.append(this.renderer.domElement, hud.root);
|
|
this.initializePanelToggles();
|
|
wireViewerEvents(this);
|
|
this.onResize();
|
|
this.updateCamera(0);
|
|
}
|
|
|
|
private initializePanelToggles() {
|
|
for (const panel of [this.gamePanelEl, this.networkSectionEl, this.performanceSectionEl]) {
|
|
const toggle = panel.querySelector(".panel-toggle");
|
|
if (!(toggle instanceof HTMLButtonElement)) {
|
|
continue;
|
|
}
|
|
|
|
toggle.addEventListener("click", () => {
|
|
const collapsed = panel.classList.toggle("is-collapsed");
|
|
toggle.textContent = collapsed ? "+" : "-";
|
|
toggle.setAttribute("aria-expanded", collapsed ? "false" : "true");
|
|
toggle.setAttribute("aria-label", `${collapsed ? "Expand" : "Collapse"} ${panel.dataset.panelName ?? "panel"}`);
|
|
});
|
|
}
|
|
}
|
|
|
|
async start() {
|
|
await this.worldLifecycle.bootstrapWorld();
|
|
this.renderer.setAnimationLoop(() => this.render());
|
|
}
|
|
|
|
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 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 = () => {
|
|
resizeViewer({
|
|
renderer: this.renderer,
|
|
galaxyLayer: this.galaxyLayer,
|
|
systemLayer: this.systemLayer,
|
|
localLayer: this.localLayer,
|
|
});
|
|
};
|
|
|
|
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();
|
|
}
|
|
}
|