Improve viewer zoom transitions and system summaries

This commit is contained in:
2026-03-12 21:27:05 -04:00
parent 7fbe7cce1a
commit e57378ad2a
3 changed files with 335 additions and 237 deletions

View File

@@ -72,20 +72,34 @@ interface PresentationEntry {
detail: THREE.Object3D;
icon: THREE.Sprite;
hideDetailInUniverse?: boolean;
hideIconInUniverse?: boolean;
}
interface SystemSummaryVisual {
sprite: THREE.Sprite;
texture: THREE.CanvasTexture;
anchor: THREE.Vector3;
}
const ZOOM_ORDER: ZoomLevel[] = ["local", "system", "universe"];
const ZOOM_DISTANCE: Record<ZoomLevel, number> = {
local: 900,
system: 3200,
universe: 9800,
universe: 26000,
};
const MIN_CAMERA_DISTANCE = 450;
const MAX_CAMERA_DISTANCE = 42000;
interface ZoomBlend {
localWeight: number;
systemWeight: number;
universeWeight: number;
}
export class GameViewer {
private readonly container: HTMLElement;
private readonly renderer = new THREE.WebGLRenderer({ antialias: true });
private readonly scene = new THREE.Scene();
private readonly camera = new THREE.PerspectiveCamera(50, 1, 0.1, 50000);
private readonly camera = new THREE.PerspectiveCamera(50, 1, 0.1, 100000);
private readonly clock = new THREE.Clock();
private readonly raycaster = new THREE.Raycaster();
private readonly mouse = new THREE.Vector2();
@@ -101,6 +115,8 @@ export class GameViewer {
private readonly nodeMeshes = new Map<string, THREE.Mesh>();
private readonly stationMeshes = new Map<string, THREE.Mesh>();
private readonly shipVisuals = new Map<string, ShipVisual>();
private readonly systemSummaryVisuals = new Map<string, SystemSummaryVisual>();
private readonly orbitLines: THREE.Object3D[] = [];
private readonly statusEl: HTMLDivElement;
private readonly detailTitleEl: HTMLHeadingElement;
private readonly detailBodyEl: HTMLDivElement;
@@ -125,6 +141,7 @@ export class GameViewer {
private selectedItems: Selectable[] = [];
private worldSignature = "";
private zoomLevel: ZoomLevel = "system";
private currentDistance = ZOOM_DISTANCE.system;
private desiredDistance = ZOOM_DISTANCE.system;
private orbitYaw = -2.3;
private orbitPitch = 0.62;
@@ -279,6 +296,7 @@ export class GameViewer {
this.syncStations(snapshot.stations);
this.syncShips(snapshot.ships, snapshot.tickIntervalMs);
this.rebuildFactions(snapshot.factions);
this.updateSystemSummaries();
this.applyZoomPresentation();
this.updateNetworkPanel();
}
@@ -312,12 +330,15 @@ export class GameViewer {
if (delta.factions.length > 0) {
this.rebuildFactions([...this.world.factions.values()]);
}
this.updateSystemSummaries();
}
private rebuildSystems(systems: SystemSnapshot[]) {
this.systemGroup.clear();
this.selectableTargets.clear();
this.presentationEntries.length = 0;
this.orbitLines.length = 0;
this.systemSummaryVisuals.clear();
for (const system of systems) {
const root = new THREE.Group();
@@ -337,8 +358,12 @@ export class GameViewer {
}),
);
const systemIcon = this.createTacticalIcon(system.starColor, 96);
root.add(star, halo, systemIcon);
this.registerPresentation(star, systemIcon, false);
const summaryVisual = this.createSystemSummaryVisual(new THREE.Vector3(system.position.x, system.position.y + system.starSize + 110, system.position.z));
summaryVisual.sprite.position.set(0, system.starSize + 110, 0);
root.add(star, halo, systemIcon, summaryVisual.sprite);
this.registerPresentation(star, systemIcon, true);
this.registerPresentation(halo, systemIcon, true);
this.systemSummaryVisuals.set(system.id, summaryVisual);
this.selectableTargets.set(star, { kind: "system", id: system.id });
this.selectableTargets.set(halo, { kind: "system", id: system.id });
this.selectableTargets.set(systemIcon, { kind: "system", id: system.id });
@@ -369,7 +394,8 @@ export class GameViewer {
const planetIcon = this.createTacticalIcon(planet.color, Math.max(24, planet.size * 2));
planetIcon.position.copy(planetMesh.position);
root.add(orbit, planetMesh, planetIcon);
this.registerPresentation(planetMesh, planetIcon, true);
this.orbitLines.push(orbit);
this.registerPresentation(planetMesh, planetIcon, true, true);
this.selectableTargets.set(planetMesh, { kind: "planet", systemId: system.id, planetIndex });
this.selectableTargets.set(planetIcon, { kind: "planet", systemId: system.id, planetIndex });
}
@@ -388,7 +414,7 @@ export class GameViewer {
icon.position.copy(mesh.position);
this.nodeMeshes.set(node.id, mesh);
this.nodeGroup.add(mesh, icon);
this.registerPresentation(mesh, icon, true);
this.registerPresentation(mesh, icon, true, true);
this.selectableTargets.set(mesh, { kind: "node", id: node.id });
this.selectableTargets.set(icon, { kind: "node", id: node.id });
}
@@ -404,7 +430,7 @@ export class GameViewer {
icon.position.copy(mesh.position);
this.stationMeshes.set(station.id, mesh);
this.stationGroup.add(mesh, icon);
this.registerPresentation(mesh, icon, true);
this.registerPresentation(mesh, icon, true, true);
this.selectableTargets.set(mesh, { kind: "station", id: station.id });
this.selectableTargets.set(icon, { kind: "station", id: station.id });
}
@@ -422,7 +448,7 @@ 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);
this.registerPresentation(mesh, icon, true, true);
this.shipVisuals.set(ship.id, {
mesh,
icon,
@@ -585,7 +611,6 @@ export class GameViewer {
this.detailTitleEl.textContent = system.label;
this.detailBodyEl.innerHTML = `
<p>${system.id}</p>
<p>Planets ${system.planets.length}</p>
`;
}
@@ -599,14 +624,15 @@ export class GameViewer {
}
private updateCamera(delta: number) {
this.desiredDistance = THREE.MathUtils.lerp(this.desiredDistance, ZOOM_DISTANCE[this.zoomLevel], Math.min(1, delta * 6));
this.currentDistance = THREE.MathUtils.damp(this.currentDistance, this.desiredDistance, 7.5, delta);
this.zoomLevel = this.classifyZoomLevel(this.currentDistance);
this.updatePanFromKeyboard(delta);
this.orbitPitch = THREE.MathUtils.clamp(this.orbitPitch, 0.18, 1.3);
const horizontalDistance = this.desiredDistance * Math.cos(this.orbitPitch);
const horizontalDistance = this.currentDistance * Math.cos(this.orbitPitch);
this.cameraOffset.set(
Math.cos(this.orbitYaw) * horizontalDistance,
this.desiredDistance * Math.sin(this.orbitPitch),
this.currentDistance * Math.sin(this.orbitPitch),
Math.sin(this.orbitYaw) * horizontalDistance,
);
this.camera.position.copy(this.focus).add(this.cameraOffset);
@@ -635,20 +661,38 @@ export class GameViewer {
const forward = new THREE.Vector3(Math.cos(this.orbitYaw), 0, Math.sin(this.orbitYaw));
const right = new THREE.Vector3(-forward.z, 0, forward.x);
const pan = right.multiplyScalar(move.x).add(forward.multiplyScalar(move.z));
const speed = this.zoomLevel === "local" ? 420 : this.zoomLevel === "system" ? 1600 : 4200;
const speed = THREE.MathUtils.mapLinear(this.currentDistance, MIN_CAMERA_DISTANCE, MAX_CAMERA_DISTANCE, 320, 6800);
this.focus.addScaledVector(pan, speed * delta);
}
private applyZoomPresentation() {
const system = this.zoomLevel === "system";
const universe = this.zoomLevel === "universe";
const blend = this.computeZoomBlend(this.currentDistance);
for (const entry of this.presentationEntries) {
entry.icon.visible = system || universe;
entry.detail.visible = universe ? !entry.hideDetailInUniverse : true;
const detailAlpha = entry.hideDetailInUniverse
? Math.max(blend.localWeight, blend.systemWeight)
: 1;
const iconAlpha = entry.hideIconInUniverse
? blend.systemWeight
: Math.max(blend.systemWeight, blend.universeWeight);
this.setObjectOpacity(entry.detail, detailAlpha);
this.setObjectOpacity(entry.icon, iconAlpha);
}
this.scene.fog = new THREE.FogExp2(0x040912, this.zoomLevel === "local" ? 0.00011 : this.zoomLevel === "system" ? 0.000045 : 0.000012);
for (const orbitLine of this.orbitLines) {
const alpha = Math.max(blend.localWeight * 0.55, blend.systemWeight);
this.setObjectOpacity(orbitLine, alpha);
}
for (const summaryVisual of this.systemSummaryVisuals.values()) {
this.setObjectOpacity(summaryVisual.sprite, blend.universeWeight);
}
this.scene.fog = new THREE.FogExp2(
0x040912,
THREE.MathUtils.lerp(0.00011, 0.000012, blend.universeWeight),
);
}
private recordDeltaStats(delta: WorldDelta, rawBytes: number) {
@@ -722,6 +766,8 @@ export class GameViewer {
const entry = this.presentationEntries.find((candidate) => candidate.detail === mesh);
entry?.icon.position.copy(mesh.position);
}
this.updateSystemSummaryPresentation();
}
private createNodeMesh(node: ResourceNodeSnapshot) {
@@ -790,8 +836,131 @@ export class GameViewer {
return sprite;
}
private registerPresentation(detail: THREE.Object3D, icon: THREE.Sprite, hideDetailInUniverse: boolean) {
this.presentationEntries.push({ detail, icon, hideDetailInUniverse });
private createSystemSummaryVisual(anchor: THREE.Vector3): SystemSummaryVisual {
const canvas = document.createElement("canvas");
canvas.width = 512;
canvas.height = 160;
const texture = new THREE.CanvasTexture(canvas);
const sprite = new THREE.Sprite(new THREE.SpriteMaterial({
map: texture,
transparent: true,
depthWrite: false,
depthTest: false,
}));
sprite.scale.set(520, 160, 1);
sprite.visible = false;
return { sprite, texture, anchor };
}
private updateSystemSummaries() {
if (!this.world) {
return;
}
const shipCounts = new Map<string, number>();
const stationCounts = new Map<string, number>();
const structureCounts = new Map<string, number>();
for (const ship of this.world.ships.values()) {
shipCounts.set(ship.systemId, (shipCounts.get(ship.systemId) ?? 0) + 1);
}
for (const station of this.world.stations.values()) {
stationCounts.set(station.systemId, (stationCounts.get(station.systemId) ?? 0) + 1);
structureCounts.set(station.systemId, (structureCounts.get(station.systemId) ?? 0) + 1);
}
for (const node of this.world.nodes.values()) {
structureCounts.set(node.systemId, (structureCounts.get(node.systemId) ?? 0) + 1);
}
for (const [systemId, system] of this.world.systems.entries()) {
const visual = this.systemSummaryVisuals.get(systemId);
if (!visual) {
continue;
}
const canvas = visual.texture.image as HTMLCanvasElement;
const context = canvas.getContext("2d");
if (!context) {
continue;
}
context.clearRect(0, 0, canvas.width, canvas.height);
context.fillStyle = "#eaf4ff";
context.font = "600 34px Space Grotesk, sans-serif";
context.textAlign = "center";
context.fillText(system.label, canvas.width / 2, 40);
const ships = shipCounts.get(systemId) ?? 0;
const stations = stationCounts.get(systemId) ?? 0;
const structures = structureCounts.get(systemId) ?? 0;
const total = ships + stations + structures;
if (total > 0) {
context.fillStyle = "rgba(3, 8, 18, 0.72)";
context.fillRect(56, 64, canvas.width - 112, 68);
context.strokeStyle = "rgba(132, 196, 255, 0.22)";
context.strokeRect(56, 64, canvas.width - 112, 68);
this.drawCountIcon(context, "ship", 126, 98, ships, "#8bc0ff");
this.drawCountIcon(context, "station", 256, 98, stations, "#ffbf69");
this.drawCountIcon(context, "structure", 386, 98, structures, "#98adc4");
}
visual.texture.needsUpdate = true;
}
}
private drawCountIcon(
context: CanvasRenderingContext2D,
kind: "ship" | "station" | "structure",
x: number,
y: number,
value: number,
color: string,
) {
context.save();
context.strokeStyle = color;
context.fillStyle = color;
context.lineWidth = 3;
if (kind === "ship") {
context.beginPath();
context.moveTo(x - 14, y + 10);
context.lineTo(x, y - 14);
context.lineTo(x + 14, y + 10);
context.closePath();
context.stroke();
} else if (kind === "station") {
context.strokeRect(x - 14, y - 14, 28, 28);
} else {
context.beginPath();
context.arc(x, y, 14, 0, Math.PI * 2);
context.stroke();
context.beginPath();
context.moveTo(x - 8, y);
context.lineTo(x + 8, y);
context.moveTo(x, y - 8);
context.lineTo(x, y + 8);
context.stroke();
}
context.fillStyle = "#eaf4ff";
context.font = "600 26px IBM Plex Mono, monospace";
context.textAlign = "left";
context.fillText(String(value), x + 24, y + 9);
context.restore();
}
private updateSystemSummaryPresentation() {
const distanceScale = this.zoomLevel === "universe" ? 0.11 : 0.05;
for (const visual of this.systemSummaryVisuals.values()) {
const distance = this.camera.position.distanceTo(visual.anchor);
const scale = Math.max(1400, 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 renderRecentEvents(entityKind: string, entityId: string) {
@@ -862,6 +1031,52 @@ export class GameViewer {
].join("\n");
}
private classifyZoomLevel(distance: number): ZoomLevel {
const blend = this.computeZoomBlend(distance);
if (blend.localWeight >= blend.systemWeight && blend.localWeight >= blend.universeWeight) {
return "local";
}
if (blend.systemWeight >= blend.universeWeight) {
return "system";
}
return "universe";
}
private computeZoomBlend(distance: number): ZoomBlend {
const localToSystem = this.smoothBand(distance, 1200, 5200);
const systemToUniverse = this.smoothBand(distance, 9000, 22000);
return {
localWeight: 1 - localToSystem,
systemWeight: Math.min(localToSystem, 1 - systemToUniverse),
universeWeight: systemToUniverse,
};
}
private smoothBand(value: number, start: number, end: number) {
const t = THREE.MathUtils.clamp((value - start) / Math.max(end - start, 1), 0, 1);
return t * t * (3 - (2 * t));
}
private setObjectOpacity(object: THREE.Object3D, opacity: number) {
const visible = opacity > 0.02;
object.visible = visible;
object.traverse((child) => {
if (!("material" in child)) {
return;
}
const materials = Array.isArray(child.material) ? child.material : [child.material];
for (const material of materials) {
if (!("opacity" in material)) {
continue;
}
material.transparent = true;
material.opacity = opacity;
material.needsUpdate = true;
}
});
}
private toThreeVector(vector: Vector3Dto) {
return new THREE.Vector3(vector.x, vector.y, vector.z);
}
@@ -967,9 +1182,9 @@ export class GameViewer {
private onWheel = (event: WheelEvent) => {
event.preventDefault();
const direction = event.deltaY > 0 ? 1 : -1;
const nextIndex = THREE.MathUtils.clamp(ZOOM_ORDER.indexOf(this.zoomLevel) + direction, 0, ZOOM_ORDER.length - 1);
this.zoomLevel = ZOOM_ORDER[nextIndex];
const deltaY = THREE.MathUtils.clamp(event.deltaY, -180, 180);
const zoomFactor = Math.exp(deltaY * 0.00135);
this.desiredDistance = THREE.MathUtils.clamp(this.desiredDistance * zoomFactor, MIN_CAMERA_DISTANCE, MAX_CAMERA_DISTANCE);
this.updateGamePanel("Live");
};
@@ -980,11 +1195,11 @@ export class GameViewer {
const key = event.key.toLowerCase();
this.keyState.add(key);
if (key === "1") {
this.zoomLevel = "local";
this.desiredDistance = ZOOM_DISTANCE.local;
} else if (key === "2") {
this.zoomLevel = "system";
this.desiredDistance = ZOOM_DISTANCE.system;
} else if (key === "3") {
this.zoomLevel = "universe";
this.desiredDistance = ZOOM_DISTANCE.universe;
}
this.updateGamePanel("Live");
};