Improve viewer zoom transitions and system summaries
This commit is contained in:
@@ -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");
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user