Refine viewer system focus and HUD panels
This commit is contained in:
@@ -27,6 +27,7 @@ type Selectable =
|
||||
| { kind: "planet"; systemId: string; planetIndex: number };
|
||||
|
||||
interface ShipVisual {
|
||||
systemId: string;
|
||||
mesh: THREE.Mesh;
|
||||
icon: THREE.Sprite;
|
||||
startPosition: THREE.Vector3;
|
||||
@@ -38,7 +39,9 @@ interface ShipVisual {
|
||||
}
|
||||
|
||||
interface PlanetVisual {
|
||||
systemId: string;
|
||||
planet: PlanetSnapshot;
|
||||
orbit: THREE.LineLoop;
|
||||
mesh: THREE.Mesh;
|
||||
icon: THREE.Sprite;
|
||||
ring?: THREE.Mesh;
|
||||
@@ -50,6 +53,19 @@ interface MoonVisual {
|
||||
orbit: THREE.LineLoop;
|
||||
}
|
||||
|
||||
interface StructureVisual {
|
||||
systemId: string;
|
||||
mesh: THREE.Mesh;
|
||||
icon: THREE.Sprite;
|
||||
worldPosition: THREE.Vector3;
|
||||
}
|
||||
|
||||
interface SystemVisual {
|
||||
root: THREE.Group;
|
||||
detailGroup: THREE.Group;
|
||||
summary: SystemSummaryVisual;
|
||||
}
|
||||
|
||||
interface WorldState {
|
||||
label: string;
|
||||
seed: number;
|
||||
@@ -96,6 +112,7 @@ interface PerformanceStats {
|
||||
interface PresentationEntry {
|
||||
detail: THREE.Object3D;
|
||||
icon: THREE.Sprite;
|
||||
systemId?: string;
|
||||
hideDetailInUniverse?: boolean;
|
||||
hideIconInUniverse?: boolean;
|
||||
}
|
||||
@@ -111,6 +128,8 @@ const ZOOM_DISTANCE: Record<ZoomLevel, number> = {
|
||||
system: 3200,
|
||||
universe: 26000,
|
||||
};
|
||||
const ACTIVE_SYSTEM_DETAIL_SCALE = 2.2;
|
||||
const ACTIVE_SYSTEM_CAPTURE_RADIUS = 9000;
|
||||
const MIN_CAMERA_DISTANCE = 450;
|
||||
const MAX_CAMERA_DISTANCE = 42000;
|
||||
|
||||
@@ -138,13 +157,17 @@ export class GameViewer {
|
||||
private readonly ambienceGroup = new THREE.Group();
|
||||
private readonly selectableTargets = new Map<THREE.Object3D, Selectable>();
|
||||
private readonly presentationEntries: PresentationEntry[] = [];
|
||||
private readonly nodeMeshes = new Map<string, THREE.Mesh>();
|
||||
private readonly stationMeshes = new Map<string, THREE.Mesh>();
|
||||
private readonly nodeVisuals = new Map<string, StructureVisual>();
|
||||
private readonly stationVisuals = new Map<string, StructureVisual>();
|
||||
private readonly shipVisuals = new Map<string, ShipVisual>();
|
||||
private readonly systemVisuals = new Map<string, SystemVisual>();
|
||||
private readonly systemSummaryVisuals = new Map<string, SystemSummaryVisual>();
|
||||
private readonly planetVisuals: PlanetVisual[] = [];
|
||||
private readonly orbitLines: THREE.Object3D[] = [];
|
||||
private readonly statusEl: HTMLDivElement;
|
||||
private readonly systemPanelEl: HTMLDivElement;
|
||||
private readonly systemTitleEl: HTMLHeadingElement;
|
||||
private readonly systemBodyEl: HTMLDivElement;
|
||||
private readonly detailTitleEl: HTMLHeadingElement;
|
||||
private readonly detailBodyEl: HTMLDivElement;
|
||||
private readonly factionStripEl: HTMLDivElement;
|
||||
@@ -185,6 +208,8 @@ export class GameViewer {
|
||||
private dragLast = new THREE.Vector2();
|
||||
private marqueeActive = false;
|
||||
private suppressClickSelection = false;
|
||||
private activeSystemId?: string;
|
||||
private followedShipId?: string;
|
||||
|
||||
constructor(container: HTMLElement) {
|
||||
this.container = container;
|
||||
@@ -217,17 +242,27 @@ export class GameViewer {
|
||||
<div class="performance-body">Waiting for frame samples.</div>
|
||||
</aside>
|
||||
</div>
|
||||
<aside class="details-panel">
|
||||
<h2>Selection</h2>
|
||||
<h3 class="detail-title">Nothing selected</h3>
|
||||
<div class="detail-body">Waiting for the authoritative snapshot.</div>
|
||||
<div class="right-panel-stack">
|
||||
<aside class="info-panel system-panel-section">
|
||||
<h2>System</h2>
|
||||
<h3 class="system-title">Deep Space</h3>
|
||||
<div class="system-body">Waiting for the authoritative snapshot.</div>
|
||||
</aside>
|
||||
<aside class="info-panel detail-panel-section">
|
||||
<h2>Focus</h2>
|
||||
<h3 class="detail-title">Nothing selected</h3>
|
||||
<div class="detail-body">Waiting for the authoritative snapshot.</div>
|
||||
</aside>
|
||||
<div class="error-strip" hidden></div>
|
||||
</aside>
|
||||
</div>
|
||||
<section class="faction-strip"></section>
|
||||
<div class="marquee-box"></div>
|
||||
`;
|
||||
|
||||
this.statusEl = hud.querySelector(".topbar-body") as HTMLDivElement;
|
||||
this.systemPanelEl = hud.querySelector(".system-panel-section") as HTMLDivElement;
|
||||
this.systemTitleEl = hud.querySelector(".system-title") as HTMLHeadingElement;
|
||||
this.systemBodyEl = hud.querySelector(".system-body") as HTMLDivElement;
|
||||
this.detailTitleEl = hud.querySelector(".detail-title") as HTMLHeadingElement;
|
||||
this.detailBodyEl = hud.querySelector(".detail-body") as HTMLDivElement;
|
||||
this.factionStripEl = hud.querySelector(".faction-strip") as HTMLDivElement;
|
||||
@@ -383,18 +418,21 @@ export class GameViewer {
|
||||
this.presentationEntries.length = 0;
|
||||
this.planetVisuals.length = 0;
|
||||
this.orbitLines.length = 0;
|
||||
this.systemVisuals.clear();
|
||||
this.systemSummaryVisuals.clear();
|
||||
|
||||
for (const system of systems) {
|
||||
const root = new THREE.Group();
|
||||
root.position.set(system.position.x, system.position.y, system.position.z);
|
||||
const detailGroup = new THREE.Group();
|
||||
|
||||
const starCluster = this.createStarCluster(system);
|
||||
const systemIcon = this.createTacticalIcon(system.starColor, 96);
|
||||
const summaryVisual = this.createSystemSummaryVisual(new THREE.Vector3(system.position.x, system.position.y + system.starSize + 140, system.position.z));
|
||||
summaryVisual.sprite.position.set(0, system.starSize + 110, 0);
|
||||
root.add(starCluster, systemIcon, summaryVisual.sprite);
|
||||
root.add(starCluster, systemIcon, summaryVisual.sprite, detailGroup);
|
||||
this.registerPresentation(starCluster, systemIcon, true);
|
||||
this.systemVisuals.set(system.id, { root, detailGroup, summary: summaryVisual });
|
||||
this.systemSummaryVisuals.set(system.id, summaryVisual);
|
||||
starCluster.traverse((child) => {
|
||||
if (child instanceof THREE.Mesh) {
|
||||
@@ -422,23 +460,23 @@ export class GameViewer {
|
||||
ring.position.copy(planetMesh.position);
|
||||
}
|
||||
const moons = this.createMoonVisuals(planet);
|
||||
root.add(orbit, planetMesh, planetIcon);
|
||||
detailGroup.add(orbit, planetMesh, planetIcon);
|
||||
if (ring) {
|
||||
root.add(ring);
|
||||
detailGroup.add(ring);
|
||||
}
|
||||
for (const moon of moons) {
|
||||
moon.orbit.position.copy(planetMesh.position);
|
||||
moon.mesh.position.copy(planetMesh.position);
|
||||
root.add(moon.orbit, moon.mesh);
|
||||
detailGroup.add(moon.orbit, moon.mesh);
|
||||
this.orbitLines.push(moon.orbit);
|
||||
this.registerPresentation(moon.mesh, planetIcon, true, true);
|
||||
this.registerPresentation(moon.mesh, planetIcon, true, true, system.id);
|
||||
}
|
||||
this.orbitLines.push(orbit);
|
||||
this.registerPresentation(planetMesh, planetIcon, true, true);
|
||||
this.registerPresentation(planetMesh, planetIcon, true, true, system.id);
|
||||
if (ring) {
|
||||
this.registerPresentation(ring, planetIcon, true, true);
|
||||
this.registerPresentation(ring, planetIcon, true, true, system.id);
|
||||
}
|
||||
this.planetVisuals.push({ planet, mesh: planetMesh, icon: planetIcon, ring, moons });
|
||||
this.planetVisuals.push({ systemId: system.id, planet, orbit, mesh: planetMesh, icon: planetIcon, ring, moons });
|
||||
this.selectableTargets.set(planetMesh, { kind: "planet", systemId: system.id, planetIndex });
|
||||
this.selectableTargets.set(planetIcon, { kind: "planet", systemId: system.id, planetIndex });
|
||||
}
|
||||
@@ -449,15 +487,20 @@ export class GameViewer {
|
||||
|
||||
private syncNodes(nodes: ResourceNodeSnapshot[]) {
|
||||
this.nodeGroup.clear();
|
||||
this.nodeMeshes.clear();
|
||||
this.nodeVisuals.clear();
|
||||
|
||||
for (const node of nodes) {
|
||||
const mesh = this.createNodeMesh(node);
|
||||
const icon = this.createTacticalIcon(node.sourceKind === "gas-cloud" ? "#7fd6ff" : "#d2b07a", 20);
|
||||
icon.position.copy(mesh.position);
|
||||
this.nodeMeshes.set(node.id, mesh);
|
||||
this.nodeVisuals.set(node.id, {
|
||||
systemId: node.systemId,
|
||||
mesh,
|
||||
icon,
|
||||
worldPosition: this.toThreeVector(node.position),
|
||||
});
|
||||
this.nodeGroup.add(mesh, icon);
|
||||
this.registerPresentation(mesh, icon, true, true);
|
||||
this.registerPresentation(mesh, icon, true, true, node.systemId);
|
||||
this.selectableTargets.set(mesh, { kind: "node", id: node.id });
|
||||
this.selectableTargets.set(icon, { kind: "node", id: node.id });
|
||||
}
|
||||
@@ -465,15 +508,20 @@ export class GameViewer {
|
||||
|
||||
private syncStations(stations: StationSnapshot[]) {
|
||||
this.stationGroup.clear();
|
||||
this.stationMeshes.clear();
|
||||
this.stationVisuals.clear();
|
||||
|
||||
for (const station of stations) {
|
||||
const mesh = this.createStationMesh(station);
|
||||
const icon = this.createTacticalIcon(station.color, 26);
|
||||
icon.position.copy(mesh.position);
|
||||
this.stationMeshes.set(station.id, mesh);
|
||||
this.stationVisuals.set(station.id, {
|
||||
systemId: station.systemId,
|
||||
mesh,
|
||||
icon,
|
||||
worldPosition: this.toThreeVector(station.position),
|
||||
});
|
||||
this.stationGroup.add(mesh, icon);
|
||||
this.registerPresentation(mesh, icon, true, true);
|
||||
this.registerPresentation(mesh, icon, true, true, station.systemId);
|
||||
this.selectableTargets.set(mesh, { kind: "station", id: station.id });
|
||||
this.selectableTargets.set(icon, { kind: "station", id: station.id });
|
||||
}
|
||||
@@ -491,8 +539,9 @@ export class GameViewer {
|
||||
this.shipGroup.add(mesh, icon);
|
||||
this.selectableTargets.set(mesh, { kind: "ship", id: ship.id });
|
||||
this.selectableTargets.set(icon, { kind: "ship", id: ship.id });
|
||||
this.registerPresentation(mesh, icon, true, true);
|
||||
this.registerPresentation(mesh, icon, true, true, ship.systemId);
|
||||
this.shipVisuals.set(ship.id, {
|
||||
systemId: ship.systemId,
|
||||
mesh,
|
||||
icon,
|
||||
startPosition: position.clone(),
|
||||
@@ -507,25 +556,27 @@ export class GameViewer {
|
||||
|
||||
private applyNodeDeltas(nodes: ResourceNodeDelta[]) {
|
||||
for (const node of nodes) {
|
||||
const mesh = this.nodeMeshes.get(node.id);
|
||||
if (!mesh) {
|
||||
const visual = this.nodeVisuals.get(node.id);
|
||||
if (!visual) {
|
||||
continue;
|
||||
}
|
||||
|
||||
mesh.position.copy(this.toThreeVector(node.position));
|
||||
mesh.scale.setScalar(0.8 + (node.oreRemaining / Math.max(node.maxOre, 1)) * 0.6);
|
||||
visual.systemId = node.systemId;
|
||||
visual.worldPosition.copy(this.toThreeVector(node.position));
|
||||
visual.mesh.scale.setScalar(0.8 + (node.oreRemaining / Math.max(node.maxOre, 1)) * 0.6);
|
||||
}
|
||||
}
|
||||
|
||||
private applyStationDeltas(stations: StationDelta[]) {
|
||||
for (const station of stations) {
|
||||
const mesh = this.stationMeshes.get(station.id);
|
||||
if (!mesh) {
|
||||
const visual = this.stationVisuals.get(station.id);
|
||||
if (!visual) {
|
||||
continue;
|
||||
}
|
||||
|
||||
mesh.position.copy(this.toThreeVector(station.position));
|
||||
const material = mesh.material as THREE.MeshStandardMaterial;
|
||||
visual.systemId = station.systemId;
|
||||
visual.worldPosition.copy(this.toThreeVector(station.position));
|
||||
const material = visual.mesh.material as THREE.MeshStandardMaterial;
|
||||
material.color.set(station.color);
|
||||
material.emissive = new THREE.Color(station.color).multiplyScalar(0.1);
|
||||
}
|
||||
@@ -538,7 +589,8 @@ export class GameViewer {
|
||||
continue;
|
||||
}
|
||||
|
||||
visual.startPosition.copy(visual.mesh.position);
|
||||
visual.systemId = ship.systemId;
|
||||
visual.startPosition.copy(visual.authoritativePosition);
|
||||
visual.authoritativePosition.copy(this.toThreeVector(ship.position));
|
||||
visual.targetPosition.copy(this.toThreeVector(ship.targetPosition));
|
||||
visual.velocity.copy(this.toThreeVector(ship.velocity));
|
||||
@@ -567,6 +619,8 @@ export class GameViewer {
|
||||
return;
|
||||
}
|
||||
|
||||
this.updateSystemPanel();
|
||||
|
||||
if (this.selectedItems.length === 0) {
|
||||
this.detailTitleEl.textContent = this.world.label;
|
||||
this.detailBodyEl.innerHTML = `
|
||||
@@ -601,6 +655,7 @@ export class GameViewer {
|
||||
<p>State ${ship.state}<br>Behavior ${ship.defaultBehaviorKind}<br>Task ${ship.controllerTaskKind}</p>
|
||||
<p>Cargo ${ship.cargo.toFixed(0)} / ${ship.cargoCapacity.toFixed(0)} ${ship.cargoItemId ?? ""}</p>
|
||||
<p>Velocity ${this.formatVector(ship.velocity)}</p>
|
||||
<p>${this.followedShipId === ship.id ? "Camera follow engaged" : "Camera follow idle"}</p>
|
||||
<p class="history">${ship.history.join("<br>")}</p>
|
||||
`;
|
||||
return;
|
||||
@@ -655,11 +710,7 @@ export class GameViewer {
|
||||
return;
|
||||
}
|
||||
this.detailTitleEl.textContent = system.label;
|
||||
this.detailBodyEl.innerHTML = `
|
||||
<p>${system.id}</p>
|
||||
<p>${system.starKind} · ${system.starCount} star${system.starCount > 1 ? "s" : ""}</p>
|
||||
<p>Planets ${system.planets.length}<br>Height ${system.position.y.toFixed(0)}</p>
|
||||
`;
|
||||
this.detailBodyEl.innerHTML = this.renderSystemDetails(system, false);
|
||||
}
|
||||
|
||||
private render() {
|
||||
@@ -685,6 +736,8 @@ export class GameViewer {
|
||||
private updateCamera(delta: number) {
|
||||
this.currentDistance = THREE.MathUtils.damp(this.currentDistance, this.desiredDistance, 7.5, delta);
|
||||
this.zoomLevel = this.classifyZoomLevel(this.currentDistance);
|
||||
this.updateActiveSystem();
|
||||
this.updateFollowCamera(delta);
|
||||
this.updatePanFromKeyboard(delta);
|
||||
this.orbitPitch = THREE.MathUtils.clamp(this.orbitPitch, 0.18, 1.3);
|
||||
|
||||
@@ -699,6 +752,10 @@ export class GameViewer {
|
||||
}
|
||||
|
||||
private updatePanFromKeyboard(delta: number) {
|
||||
if (this.followedShipId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const move = new THREE.Vector3();
|
||||
if (this.keyState.has("w")) {
|
||||
move.z -= 1;
|
||||
@@ -728,11 +785,13 @@ export class GameViewer {
|
||||
const blend = this.computeZoomBlend(this.currentDistance);
|
||||
|
||||
for (const entry of this.presentationEntries) {
|
||||
const systemId = entry.systemId;
|
||||
const isActiveDetail = !systemId || systemId === this.activeSystemId;
|
||||
const detailAlpha = entry.hideDetailInUniverse
|
||||
? Math.max(blend.localWeight, blend.systemWeight)
|
||||
? Math.max(blend.localWeight, blend.systemWeight) * (isActiveDetail ? 1 : 0)
|
||||
: 1;
|
||||
const iconAlpha = entry.hideIconInUniverse
|
||||
? blend.systemWeight
|
||||
? blend.systemWeight * (isActiveDetail ? 1 : 0)
|
||||
: Math.max(blend.systemWeight, blend.universeWeight);
|
||||
|
||||
this.setObjectOpacity(entry.detail, detailAlpha);
|
||||
@@ -740,12 +799,15 @@ export class GameViewer {
|
||||
}
|
||||
|
||||
for (const orbitLine of this.orbitLines) {
|
||||
const alpha = Math.max(blend.localWeight * 0.55, blend.systemWeight);
|
||||
const alpha = Math.max(blend.localWeight * 0.55, blend.systemWeight) * (this.activeSystemId ? 1 : 0);
|
||||
this.setObjectOpacity(orbitLine, alpha);
|
||||
}
|
||||
|
||||
for (const summaryVisual of this.systemSummaryVisuals.values()) {
|
||||
this.setObjectOpacity(summaryVisual.sprite, blend.universeWeight);
|
||||
for (const [systemId, summaryVisual] of this.systemSummaryVisuals.entries()) {
|
||||
const summaryOpacity = systemId === this.activeSystemId
|
||||
? 0
|
||||
: (this.activeSystemId ? 0.72 : 0.96);
|
||||
this.setObjectOpacity(summaryVisual.sprite, summaryOpacity);
|
||||
}
|
||||
|
||||
this.scene.fog = new THREE.FogExp2(0x040912, 0.000035);
|
||||
@@ -850,36 +912,45 @@ export class GameViewer {
|
||||
for (const visual of this.shipVisuals.values()) {
|
||||
const elapsedMs = now - visual.receivedAtMs;
|
||||
const blendT = THREE.MathUtils.clamp(elapsedMs / visual.blendDurationMs, 0, 1);
|
||||
visual.mesh.position.lerpVectors(visual.startPosition, visual.authoritativePosition, blendT);
|
||||
const worldPosition = new THREE.Vector3().lerpVectors(visual.startPosition, visual.authoritativePosition, blendT);
|
||||
|
||||
if (blendT >= 1) {
|
||||
const extrapolationSeconds = Math.min((elapsedMs - visual.blendDurationMs) / 1000, 0.35);
|
||||
visual.mesh.position.copy(visual.authoritativePosition).addScaledVector(visual.velocity, extrapolationSeconds);
|
||||
worldPosition.copy(visual.authoritativePosition).addScaledVector(visual.velocity, extrapolationSeconds);
|
||||
}
|
||||
|
||||
visual.mesh.position.copy(this.toDisplayPosition(worldPosition, visual.systemId));
|
||||
visual.icon.position.copy(visual.mesh.position);
|
||||
const shipVisible = visual.systemId === this.activeSystemId;
|
||||
visual.mesh.visible = shipVisible;
|
||||
visual.icon.visible = shipVisible && visual.icon.visible;
|
||||
const desiredHeading = visual.targetPosition.clone().sub(visual.mesh.position);
|
||||
if (desiredHeading.lengthSq() > 0.01) {
|
||||
visual.mesh.lookAt(visual.mesh.position.clone().add(desiredHeading));
|
||||
}
|
||||
}
|
||||
|
||||
for (const mesh of this.nodeMeshes.values()) {
|
||||
const entry = this.presentationEntries.find((candidate) => candidate.detail === mesh);
|
||||
entry?.icon.position.copy(mesh.position);
|
||||
for (const visual of this.nodeVisuals.values()) {
|
||||
visual.mesh.position.copy(this.toDisplayPosition(visual.worldPosition, visual.systemId));
|
||||
visual.icon.position.copy(visual.mesh.position);
|
||||
visual.mesh.visible = visual.systemId === this.activeSystemId;
|
||||
}
|
||||
for (const mesh of this.stationMeshes.values()) {
|
||||
const entry = this.presentationEntries.find((candidate) => candidate.detail === mesh);
|
||||
entry?.icon.position.copy(mesh.position);
|
||||
for (const visual of this.stationVisuals.values()) {
|
||||
visual.mesh.position.copy(this.toDisplayPosition(visual.worldPosition, visual.systemId));
|
||||
visual.icon.position.copy(visual.mesh.position);
|
||||
visual.mesh.visible = visual.systemId === this.activeSystemId;
|
||||
}
|
||||
|
||||
this.updateSystemDetailVisibility();
|
||||
this.updateSystemSummaryPresentation();
|
||||
}
|
||||
|
||||
private updatePlanetPresentation() {
|
||||
const nowSeconds = this.currentWorldTimeSeconds();
|
||||
for (const visual of this.planetVisuals) {
|
||||
const position = this.computePlanetLocalPosition(visual.planet, nowSeconds);
|
||||
const scale = visual.systemId === this.activeSystemId ? ACTIVE_SYSTEM_DETAIL_SCALE : 1;
|
||||
const position = this.computePlanetLocalPosition(visual.planet, nowSeconds).multiplyScalar(scale);
|
||||
visual.orbit.scale.setScalar(scale);
|
||||
visual.mesh.position.copy(position);
|
||||
visual.icon.position.copy(position);
|
||||
if (visual.ring) {
|
||||
@@ -887,7 +958,8 @@ export class GameViewer {
|
||||
}
|
||||
for (const [moonIndex, moon] of visual.moons.entries()) {
|
||||
moon.orbit.position.copy(position);
|
||||
moon.mesh.position.copy(position).add(this.computeMoonLocalPosition(visual.planet, moonIndex, nowSeconds));
|
||||
moon.orbit.scale.setScalar(scale);
|
||||
moon.mesh.position.copy(position).add(this.computeMoonLocalPosition(visual.planet, moonIndex, nowSeconds).multiplyScalar(scale));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1290,16 +1362,23 @@ export class GameViewer {
|
||||
}
|
||||
|
||||
private updateSystemSummaryPresentation() {
|
||||
const distanceScale = this.zoomLevel === "universe" ? 0.11 : 0.05;
|
||||
for (const visual of this.systemSummaryVisuals.values()) {
|
||||
const distanceScale = this.activeSystemId ? 0.05 : 0.085;
|
||||
for (const [systemId, visual] of this.systemSummaryVisuals.entries()) {
|
||||
const distance = this.camera.position.distanceTo(visual.anchor);
|
||||
const scale = Math.max(1400, distance * distanceScale);
|
||||
const minimumScale = this.activeSystemId && systemId !== this.activeSystemId ? 1200 : 1400;
|
||||
const scale = Math.max(minimumScale, distance * distanceScale);
|
||||
visual.sprite.scale.set(scale, scale * 0.3125, 1);
|
||||
}
|
||||
}
|
||||
|
||||
private registerPresentation(detail: THREE.Object3D, icon: THREE.Sprite, hideDetailInUniverse: boolean, hideIconInUniverse = false) {
|
||||
this.presentationEntries.push({ detail, icon, hideDetailInUniverse, hideIconInUniverse });
|
||||
private registerPresentation(
|
||||
detail: THREE.Object3D,
|
||||
icon: THREE.Sprite,
|
||||
hideDetailInUniverse: boolean,
|
||||
hideIconInUniverse = false,
|
||||
systemId?: string,
|
||||
) {
|
||||
this.presentationEntries.push({ detail, icon, systemId, hideDetailInUniverse, hideIconInUniverse });
|
||||
}
|
||||
|
||||
private renderRecentEvents(entityKind: string, entityId: string) {
|
||||
@@ -1362,9 +1441,11 @@ export class GameViewer {
|
||||
const generatedAt = this.world?.generatedAtUtc
|
||||
? new Date(this.world.generatedAtUtc).toLocaleTimeString()
|
||||
: "n/a";
|
||||
const activeSystem = this.activeSystemId ?? "deep-space";
|
||||
this.statusEl.textContent = [
|
||||
`mode: ${mode}`,
|
||||
`zoom: ${this.zoomLevel}`,
|
||||
`system: ${activeSystem}`,
|
||||
`sequence: ${sequence}`,
|
||||
`snapshot: ${generatedAt}`,
|
||||
].join("\n");
|
||||
@@ -1594,6 +1675,7 @@ export class GameViewer {
|
||||
this.raycaster.setFromCamera(this.mouse, this.camera);
|
||||
const hit = this.raycaster.intersectObjects([...this.selectableTargets.keys()], false)[0];
|
||||
this.selectedItems = hit ? [this.selectableTargets.get(hit.object)!] : [];
|
||||
this.syncFollowStateFromSelection();
|
||||
this.updatePanels();
|
||||
};
|
||||
|
||||
@@ -1605,6 +1687,7 @@ export class GameViewer {
|
||||
if (nextFocus) {
|
||||
this.focus.copy(nextFocus);
|
||||
}
|
||||
this.syncFollowStateFromSelection();
|
||||
};
|
||||
|
||||
private onWheel = (event: WheelEvent) => {
|
||||
@@ -1621,6 +1704,9 @@ export class GameViewer {
|
||||
}
|
||||
const key = event.key.toLowerCase();
|
||||
this.keyState.add(key);
|
||||
if (["w", "a", "s", "d"].includes(key)) {
|
||||
this.followedShipId = undefined;
|
||||
}
|
||||
if (key === "1") {
|
||||
this.desiredDistance = ZOOM_DISTANCE.local;
|
||||
} else if (key === "2") {
|
||||
@@ -1642,22 +1728,25 @@ export class GameViewer {
|
||||
|
||||
if (selection.kind === "ship") {
|
||||
const ship = this.world.ships.get(selection.id);
|
||||
return ship ? this.toThreeVector(ship.position) : undefined;
|
||||
return ship ? this.toDisplayPosition(this.toThreeVector(ship.position), ship.systemId) : undefined;
|
||||
}
|
||||
if (selection.kind === "station") {
|
||||
const station = this.world.stations.get(selection.id);
|
||||
return station ? this.toThreeVector(station.position) : undefined;
|
||||
return station ? this.toDisplayPosition(this.toThreeVector(station.position), station.systemId) : undefined;
|
||||
}
|
||||
if (selection.kind === "node") {
|
||||
const node = this.world.nodes.get(selection.id);
|
||||
return node ? this.toThreeVector(node.position) : undefined;
|
||||
return node ? this.toDisplayPosition(this.toThreeVector(node.position), node.systemId) : undefined;
|
||||
}
|
||||
if (selection.kind === "planet") {
|
||||
const system = this.world.systems.get(selection.systemId);
|
||||
const planet = system?.planets[selection.planetIndex];
|
||||
return system && planet
|
||||
? new THREE.Vector3(system.position.x + planet.orbitRadius, system.position.y, system.position.z)
|
||||
: undefined;
|
||||
if (!system || !planet) {
|
||||
return undefined;
|
||||
}
|
||||
const visual = this.planetVisuals.find((candidate) =>
|
||||
candidate.systemId === selection.systemId && candidate.planet === planet);
|
||||
return visual?.mesh.getWorldPosition(new THREE.Vector3());
|
||||
}
|
||||
const system = this.world.systems.get(selection.id);
|
||||
return system ? this.toThreeVector(system.position) : undefined;
|
||||
@@ -1714,6 +1803,7 @@ export class GameViewer {
|
||||
const selection = [...grouped.entries()]
|
||||
.sort((left, right) => right[1].length - left[1].length)[0]?.[1] ?? [];
|
||||
this.selectedItems = selection;
|
||||
this.syncFollowStateFromSelection();
|
||||
this.updatePanels();
|
||||
}
|
||||
|
||||
@@ -1753,4 +1843,182 @@ export class GameViewer {
|
||||
this.camera.updateProjectionMatrix();
|
||||
this.renderer.setSize(width, height);
|
||||
};
|
||||
|
||||
private updateActiveSystem() {
|
||||
const nextActiveSystemId = this.determineActiveSystemId();
|
||||
if (nextActiveSystemId === this.activeSystemId) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.activeSystemId = nextActiveSystemId;
|
||||
this.updateSystemDetailVisibility();
|
||||
this.updatePanels();
|
||||
this.updateGamePanel("Live");
|
||||
}
|
||||
|
||||
private determineActiveSystemId() {
|
||||
if (!this.world || this.currentDistance >= 12000) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const selected = this.selectedItems[0];
|
||||
if (selected && this.selectedItems.length === 1) {
|
||||
if (selected.kind === "system") {
|
||||
return selected.id;
|
||||
}
|
||||
if (selected.kind === "planet") {
|
||||
return selected.systemId;
|
||||
}
|
||||
const selectedSystemId = this.resolveSelectableSystemId(selected);
|
||||
if (selectedSystemId) {
|
||||
return selectedSystemId;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.followedShipId) {
|
||||
return this.world.ships.get(this.followedShipId)?.systemId;
|
||||
}
|
||||
|
||||
let nearestSystemId: string | undefined;
|
||||
let nearestDistance = Number.POSITIVE_INFINITY;
|
||||
for (const system of this.world.systems.values()) {
|
||||
const center = this.toThreeVector(system.position);
|
||||
const distance = center.distanceTo(this.focus);
|
||||
if (distance < nearestDistance) {
|
||||
nearestDistance = distance;
|
||||
nearestSystemId = system.id;
|
||||
}
|
||||
}
|
||||
|
||||
return nearestDistance <= Math.max(ACTIVE_SYSTEM_CAPTURE_RADIUS, this.currentDistance * 2.2)
|
||||
? nearestSystemId
|
||||
: undefined;
|
||||
}
|
||||
|
||||
private updateFollowCamera(delta: number) {
|
||||
if (!this.followedShipId || !this.world) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ship = this.world.ships.get(this.followedShipId);
|
||||
if (!ship) {
|
||||
this.followedShipId = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
const target = this.toDisplayPosition(this.toThreeVector(ship.position), ship.systemId);
|
||||
this.focus.lerp(target, 1 - Math.exp(-delta * 8));
|
||||
}
|
||||
|
||||
private syncFollowStateFromSelection() {
|
||||
if (this.selectedItems.length === 1 && this.selectedItems[0].kind === "ship") {
|
||||
this.followedShipId = this.selectedItems[0].id;
|
||||
this.desiredDistance = Math.min(this.desiredDistance, 1600);
|
||||
return;
|
||||
}
|
||||
|
||||
this.followedShipId = undefined;
|
||||
}
|
||||
|
||||
private updateSystemDetailVisibility() {
|
||||
for (const [systemId, visual] of this.systemVisuals.entries()) {
|
||||
visual.detailGroup.visible = systemId === this.activeSystemId;
|
||||
}
|
||||
}
|
||||
|
||||
private resolveSelectableSystemId(selection: Selectable) {
|
||||
if (!this.world) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (selection.kind === "ship") {
|
||||
return this.world.ships.get(selection.id)?.systemId;
|
||||
}
|
||||
if (selection.kind === "station") {
|
||||
return this.world.stations.get(selection.id)?.systemId;
|
||||
}
|
||||
if (selection.kind === "node") {
|
||||
return this.world.nodes.get(selection.id)?.systemId;
|
||||
}
|
||||
if (selection.kind === "planet") {
|
||||
return selection.systemId;
|
||||
}
|
||||
return selection.id;
|
||||
}
|
||||
|
||||
private toDisplayPosition(worldPosition: THREE.Vector3, systemId?: string) {
|
||||
if (!this.world || !systemId || systemId !== this.activeSystemId) {
|
||||
return worldPosition.clone();
|
||||
}
|
||||
|
||||
const system = this.world.systems.get(systemId);
|
||||
if (!system) {
|
||||
return worldPosition.clone();
|
||||
}
|
||||
|
||||
const center = this.toThreeVector(system.position);
|
||||
return worldPosition.clone().sub(center).multiplyScalar(ACTIVE_SYSTEM_DETAIL_SCALE).add(center);
|
||||
}
|
||||
|
||||
private renderSystemDetails(system: SystemSnapshot, activeContext: boolean) {
|
||||
if (!this.world) {
|
||||
return "";
|
||||
}
|
||||
|
||||
let shipCount = 0;
|
||||
let stationCount = 0;
|
||||
let nodeCount = 0;
|
||||
let moonCount = 0;
|
||||
|
||||
for (const ship of this.world.ships.values()) {
|
||||
if (ship.systemId === system.id) {
|
||||
shipCount += 1;
|
||||
}
|
||||
}
|
||||
for (const station of this.world.stations.values()) {
|
||||
if (station.systemId === system.id) {
|
||||
stationCount += 1;
|
||||
}
|
||||
}
|
||||
for (const node of this.world.nodes.values()) {
|
||||
if (node.systemId === system.id) {
|
||||
nodeCount += 1;
|
||||
}
|
||||
}
|
||||
for (const planet of system.planets) {
|
||||
moonCount += planet.moonCount;
|
||||
}
|
||||
|
||||
const followText = activeContext && this.followedShipId
|
||||
? `<p>Camera locked to ${this.world.ships.get(this.followedShipId)?.label ?? this.followedShipId}</p>`
|
||||
: "";
|
||||
|
||||
return `
|
||||
<p>${system.id}${activeContext ? " · active system" : ""}</p>
|
||||
<p>${system.starKind} · ${system.starCount} star${system.starCount > 1 ? "s" : ""}</p>
|
||||
<p>Planets ${system.planets.length}<br>Moons ${moonCount}<br>Ships ${shipCount}<br>Stations ${stationCount}<br>Nodes ${nodeCount}</p>
|
||||
<p>Height ${system.position.y.toFixed(0)}</p>
|
||||
<p>${system.planets.slice(0, 8).map((planet) => `${planet.label} (${planet.planetType})`).join("<br>")}</p>
|
||||
${followText}
|
||||
`;
|
||||
}
|
||||
|
||||
private updateSystemPanel() {
|
||||
if (!this.world) {
|
||||
return;
|
||||
}
|
||||
|
||||
const activeSystem = this.activeSystemId ? this.world.systems.get(this.activeSystemId) : undefined;
|
||||
const showSystemPanel = !!activeSystem;
|
||||
this.systemPanelEl.hidden = !showSystemPanel;
|
||||
|
||||
if (!activeSystem) {
|
||||
this.systemTitleEl.textContent = "Deep Space";
|
||||
this.systemBodyEl.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
|
||||
this.systemTitleEl.textContent = activeSystem.label;
|
||||
this.systemBodyEl.innerHTML = this.renderSystemDetails(activeSystem, true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +47,16 @@ canvas {
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.right-panel-stack {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
width: min(380px, calc(100vw - 40px));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.marquee-box {
|
||||
position: absolute;
|
||||
display: none;
|
||||
@@ -56,7 +66,7 @@ canvas {
|
||||
}
|
||||
|
||||
.topbar,
|
||||
.details-panel,
|
||||
.info-panel,
|
||||
.network-panel,
|
||||
.performance-panel,
|
||||
.faction-strip {
|
||||
@@ -82,8 +92,8 @@ canvas {
|
||||
|
||||
.topbar h1,
|
||||
.topbar h2,
|
||||
.details-panel h2,
|
||||
.details-panel h3,
|
||||
.info-panel h2,
|
||||
.info-panel h3,
|
||||
.faction-card h3 {
|
||||
margin: 0;
|
||||
}
|
||||
@@ -109,12 +119,7 @@ canvas {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.details-panel {
|
||||
position: absolute;
|
||||
top: 110px;
|
||||
right: 20px;
|
||||
width: min(380px, calc(100vw - 40px));
|
||||
bottom: 20px;
|
||||
.info-panel {
|
||||
border-radius: 24px;
|
||||
padding: 18px;
|
||||
color: var(--text);
|
||||
@@ -137,7 +142,7 @@ canvas {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.details-panel h2 {
|
||||
.info-panel h2 {
|
||||
color: var(--accent);
|
||||
letter-spacing: 0.16em;
|
||||
font-size: 0.72rem;
|
||||
@@ -168,12 +173,19 @@ canvas {
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.system-title {
|
||||
margin-top: 12px;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.system-body,
|
||||
.detail-body {
|
||||
margin-top: 12px;
|
||||
color: var(--muted);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.system-body p,
|
||||
.detail-body p {
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
@@ -185,11 +197,27 @@ canvas {
|
||||
}
|
||||
|
||||
.error-strip {
|
||||
margin-top: 14px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 14px;
|
||||
padding: 12px 14px;
|
||||
background: rgba(255, 116, 88, 0.14);
|
||||
color: #ffd8cf;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.right-panel-stack .error-strip {
|
||||
margin-top: -4px;
|
||||
}
|
||||
|
||||
.system-panel-section[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.detail-panel-section[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.error-strip[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.faction-strip {
|
||||
@@ -243,14 +271,25 @@ canvas {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.details-panel {
|
||||
position: absolute;
|
||||
top: auto;
|
||||
.right-panel-stack {
|
||||
left: 20px;
|
||||
right: 20px;
|
||||
bottom: 148px;
|
||||
top: auto;
|
||||
width: auto;
|
||||
bottom: 148px;
|
||||
max-height: 38vh;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.info-panel {
|
||||
max-height: none;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.system-panel-section,
|
||||
.detail-panel-section,
|
||||
.error-strip {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.network-panel {
|
||||
|
||||
Reference in New Issue
Block a user