Refine viewer system focus and HUD panels

This commit is contained in:
2026-03-12 22:29:45 -04:00
parent 9719c7c438
commit 22a4b18be8
2 changed files with 385 additions and 78 deletions

View File

@@ -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);
}
}

View File

@@ -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 {