Improve viewer camera and selection controls
This commit is contained in:
@@ -1,7 +1,6 @@
|
||||
import * as THREE from "three";
|
||||
import { fetchWorldSnapshot, openWorldStream } from "./api";
|
||||
import type {
|
||||
FactionDelta,
|
||||
FactionSnapshot,
|
||||
ResourceNodeDelta,
|
||||
ResourceNodeSnapshot,
|
||||
@@ -16,14 +15,19 @@ import type {
|
||||
WorldSnapshot,
|
||||
} from "./contracts";
|
||||
|
||||
type ZoomLevel = "local" | "system" | "universe";
|
||||
type SelectionGroup = "ships" | "structures" | "celestials";
|
||||
type DragMode = "orbit" | "marquee";
|
||||
type Selectable =
|
||||
| { kind: "ship"; id: string }
|
||||
| { kind: "station"; id: string }
|
||||
| { kind: "node"; id: string }
|
||||
| { kind: "system"; id: string };
|
||||
| { kind: "system"; id: string }
|
||||
| { kind: "planet"; systemId: string; planetIndex: number };
|
||||
|
||||
interface ShipVisual {
|
||||
mesh: THREE.Mesh;
|
||||
icon: THREE.Sprite;
|
||||
startPosition: THREE.Vector3;
|
||||
authoritativePosition: THREE.Vector3;
|
||||
targetPosition: THREE.Vector3;
|
||||
@@ -64,20 +68,36 @@ interface NetworkStats {
|
||||
throughputSamples: NetworkSample[];
|
||||
}
|
||||
|
||||
interface PresentationEntry {
|
||||
detail: THREE.Object3D;
|
||||
icon: THREE.Sprite;
|
||||
hideDetailInUniverse?: boolean;
|
||||
}
|
||||
|
||||
const ZOOM_ORDER: ZoomLevel[] = ["local", "system", "universe"];
|
||||
const ZOOM_DISTANCE: Record<ZoomLevel, number> = {
|
||||
local: 900,
|
||||
system: 3200,
|
||||
universe: 9800,
|
||||
};
|
||||
|
||||
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, 40000);
|
||||
private readonly camera = new THREE.PerspectiveCamera(50, 1, 0.1, 50000);
|
||||
private readonly clock = new THREE.Clock();
|
||||
private readonly raycaster = new THREE.Raycaster();
|
||||
private readonly mouse = new THREE.Vector2();
|
||||
private readonly focus = new THREE.Vector3(2200, 0, 300);
|
||||
private readonly cameraOffset = new THREE.Vector3();
|
||||
private readonly keyState = new Set<string>();
|
||||
private readonly systemGroup = new THREE.Group();
|
||||
private readonly nodeGroup = new THREE.Group();
|
||||
private readonly stationGroup = new THREE.Group();
|
||||
private readonly shipGroup = 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 shipVisuals = new Map<string, ShipVisual>();
|
||||
@@ -87,6 +107,8 @@ export class GameViewer {
|
||||
private readonly factionStripEl: HTMLDivElement;
|
||||
private readonly networkPanelEl: HTMLDivElement;
|
||||
private readonly errorEl: HTMLDivElement;
|
||||
private readonly marqueeEl: HTMLDivElement;
|
||||
|
||||
private world?: WorldState;
|
||||
private stream?: EventSource;
|
||||
private readonly networkStats: NetworkStats = {
|
||||
@@ -99,10 +121,19 @@ export class GameViewer {
|
||||
streamConnected: false,
|
||||
throughputSamples: [],
|
||||
};
|
||||
private selected?: Selectable;
|
||||
private dragging = false;
|
||||
private lastPointer = new THREE.Vector2();
|
||||
|
||||
private selectedItems: Selectable[] = [];
|
||||
private worldSignature = "";
|
||||
private zoomLevel: ZoomLevel = "system";
|
||||
private desiredDistance = ZOOM_DISTANCE.system;
|
||||
private orbitYaw = -2.3;
|
||||
private orbitPitch = 0.62;
|
||||
private dragMode?: DragMode;
|
||||
private dragPointerId?: number;
|
||||
private dragStart = new THREE.Vector2();
|
||||
private dragLast = new THREE.Vector2();
|
||||
private marqueeActive = false;
|
||||
private suppressClickSelection = false;
|
||||
|
||||
constructor(container: HTMLElement) {
|
||||
this.container = container;
|
||||
@@ -117,9 +148,6 @@ export class GameViewer {
|
||||
this.scene.add(keyLight);
|
||||
this.scene.add(this.systemGroup, this.nodeGroup, this.stationGroup, this.shipGroup);
|
||||
|
||||
this.camera.position.set(2500, 1700, 2800);
|
||||
this.camera.lookAt(this.focus);
|
||||
|
||||
const hud = document.createElement("div");
|
||||
hud.className = "viewer-shell";
|
||||
hud.innerHTML = `
|
||||
@@ -138,6 +166,7 @@ export class GameViewer {
|
||||
<div class="network-body">Waiting for snapshot.</div>
|
||||
</aside>
|
||||
<section class="faction-strip"></section>
|
||||
<div class="marquee-box"></div>
|
||||
`;
|
||||
|
||||
this.statusEl = hud.querySelector(".topbar-body") as HTMLDivElement;
|
||||
@@ -146,17 +175,22 @@ export class GameViewer {
|
||||
this.factionStripEl = hud.querySelector(".faction-strip") as HTMLDivElement;
|
||||
this.networkPanelEl = hud.querySelector(".network-body") as HTMLDivElement;
|
||||
this.errorEl = hud.querySelector(".error-strip") as HTMLDivElement;
|
||||
this.marqueeEl = hud.querySelector(".marquee-box") as HTMLDivElement;
|
||||
|
||||
this.container.append(this.renderer.domElement, hud);
|
||||
|
||||
this.renderer.domElement.addEventListener("pointerdown", this.onPointerDown);
|
||||
this.renderer.domElement.addEventListener("pointermove", this.onPointerMove);
|
||||
this.renderer.domElement.addEventListener("pointerup", this.onPointerUp);
|
||||
this.renderer.domElement.addEventListener("pointerleave", this.onPointerUp);
|
||||
this.renderer.domElement.addEventListener("click", this.onClick);
|
||||
this.renderer.domElement.addEventListener("dblclick", this.onDoubleClick);
|
||||
this.renderer.domElement.addEventListener("wheel", this.onWheel, { passive: false });
|
||||
window.addEventListener("keydown", this.onKeyDown);
|
||||
window.addEventListener("keyup", this.onKeyUp);
|
||||
window.addEventListener("resize", this.onResize);
|
||||
this.onResize();
|
||||
this.updateCamera(0);
|
||||
}
|
||||
|
||||
async start() {
|
||||
@@ -245,6 +279,7 @@ export class GameViewer {
|
||||
this.syncStations(snapshot.stations);
|
||||
this.syncShips(snapshot.ships, snapshot.tickIntervalMs);
|
||||
this.rebuildFactions(snapshot.factions);
|
||||
this.applyZoomPresentation();
|
||||
this.updateNetworkPanel();
|
||||
}
|
||||
|
||||
@@ -261,15 +296,12 @@ export class GameViewer {
|
||||
for (const node of delta.nodes) {
|
||||
this.world.nodes.set(node.id, node);
|
||||
}
|
||||
|
||||
for (const station of delta.stations) {
|
||||
this.world.stations.set(station.id, station);
|
||||
}
|
||||
|
||||
for (const ship of delta.ships) {
|
||||
this.world.ships.set(ship.id, ship);
|
||||
}
|
||||
|
||||
for (const faction of delta.factions) {
|
||||
this.world.factions.set(faction.id, faction);
|
||||
}
|
||||
@@ -285,10 +317,12 @@ export class GameViewer {
|
||||
private rebuildSystems(systems: SystemSnapshot[]) {
|
||||
this.systemGroup.clear();
|
||||
this.selectableTargets.clear();
|
||||
this.presentationEntries.length = 0;
|
||||
|
||||
for (const system of systems) {
|
||||
const root = new THREE.Group();
|
||||
root.position.set(system.position.x, system.position.y, system.position.z);
|
||||
|
||||
const star = new THREE.Mesh(
|
||||
new THREE.SphereGeometry(system.starSize, 32, 32),
|
||||
new THREE.MeshBasicMaterial({ color: system.starColor }),
|
||||
@@ -302,11 +336,14 @@ export class GameViewer {
|
||||
side: THREE.BackSide,
|
||||
}),
|
||||
);
|
||||
root.add(star, halo);
|
||||
const systemIcon = this.createTacticalIcon(system.starColor, 96);
|
||||
root.add(star, halo, systemIcon);
|
||||
this.registerPresentation(star, systemIcon, false);
|
||||
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 });
|
||||
|
||||
for (const planet of system.planets) {
|
||||
for (const [planetIndex, planet] of system.planets.entries()) {
|
||||
const orbit = new THREE.LineLoop(
|
||||
new THREE.BufferGeometry().setFromPoints(
|
||||
Array.from({ length: 80 }, (_, index) => {
|
||||
@@ -329,7 +366,12 @@ export class GameViewer {
|
||||
}),
|
||||
);
|
||||
planetMesh.position.set(planet.orbitRadius, 0, 0);
|
||||
root.add(orbit, planetMesh);
|
||||
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.selectableTargets.set(planetMesh, { kind: "planet", systemId: system.id, planetIndex });
|
||||
this.selectableTargets.set(planetIcon, { kind: "planet", systemId: system.id, planetIndex });
|
||||
}
|
||||
|
||||
this.systemGroup.add(root);
|
||||
@@ -339,35 +381,51 @@ export class GameViewer {
|
||||
private syncNodes(nodes: ResourceNodeSnapshot[]) {
|
||||
this.nodeGroup.clear();
|
||||
this.nodeMeshes.clear();
|
||||
|
||||
for (const node of nodes) {
|
||||
const mesh = this.createNodeMesh(node);
|
||||
const icon = this.createTacticalIcon("#d2b07a", 20);
|
||||
icon.position.copy(mesh.position);
|
||||
this.nodeMeshes.set(node.id, mesh);
|
||||
this.nodeGroup.add(mesh);
|
||||
this.nodeGroup.add(mesh, icon);
|
||||
this.registerPresentation(mesh, icon, true);
|
||||
this.selectableTargets.set(mesh, { kind: "node", id: node.id });
|
||||
this.selectableTargets.set(icon, { kind: "node", id: node.id });
|
||||
}
|
||||
}
|
||||
|
||||
private syncStations(stations: StationSnapshot[]) {
|
||||
this.stationGroup.clear();
|
||||
this.stationMeshes.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.stationGroup.add(mesh);
|
||||
this.stationGroup.add(mesh, icon);
|
||||
this.registerPresentation(mesh, icon, true);
|
||||
this.selectableTargets.set(mesh, { kind: "station", id: station.id });
|
||||
this.selectableTargets.set(icon, { kind: "station", id: station.id });
|
||||
}
|
||||
}
|
||||
|
||||
private syncShips(ships: ShipSnapshot[], tickIntervalMs: number) {
|
||||
this.shipGroup.clear();
|
||||
this.shipVisuals.clear();
|
||||
|
||||
for (const ship of ships) {
|
||||
const mesh = this.createShipMesh(ship);
|
||||
this.shipGroup.add(mesh);
|
||||
this.selectableTargets.set(mesh, { kind: "ship", id: ship.id });
|
||||
const icon = this.createTacticalIcon(this.shipColor(ship.role), 18);
|
||||
const position = this.toThreeVector(ship.position);
|
||||
icon.position.copy(position);
|
||||
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.shipVisuals.set(ship.id, {
|
||||
mesh,
|
||||
icon,
|
||||
startPosition: position.clone(),
|
||||
authoritativePosition: position.clone(),
|
||||
targetPosition: this.toThreeVector(ship.targetPosition),
|
||||
@@ -382,10 +440,6 @@ export class GameViewer {
|
||||
for (const node of nodes) {
|
||||
const mesh = this.nodeMeshes.get(node.id);
|
||||
if (!mesh) {
|
||||
const nextMesh = this.createNodeMesh(node);
|
||||
this.nodeMeshes.set(node.id, nextMesh);
|
||||
this.nodeGroup.add(nextMesh);
|
||||
this.selectableTargets.set(nextMesh, { kind: "node", id: node.id });
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -398,10 +452,6 @@ export class GameViewer {
|
||||
for (const station of stations) {
|
||||
const mesh = this.stationMeshes.get(station.id);
|
||||
if (!mesh) {
|
||||
const nextMesh = this.createStationMesh(station);
|
||||
this.stationMeshes.set(station.id, nextMesh);
|
||||
this.stationGroup.add(nextMesh);
|
||||
this.selectableTargets.set(nextMesh, { kind: "station", id: station.id });
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -416,19 +466,6 @@ export class GameViewer {
|
||||
for (const ship of ships) {
|
||||
const visual = this.shipVisuals.get(ship.id);
|
||||
if (!visual) {
|
||||
const mesh = this.createShipMesh(ship);
|
||||
const position = this.toThreeVector(ship.position);
|
||||
this.shipGroup.add(mesh);
|
||||
this.selectableTargets.set(mesh, { kind: "ship", id: ship.id });
|
||||
this.shipVisuals.set(ship.id, {
|
||||
mesh,
|
||||
startPosition: position.clone(),
|
||||
authoritativePosition: position.clone(),
|
||||
targetPosition: this.toThreeVector(ship.targetPosition),
|
||||
velocity: this.toThreeVector(ship.velocity),
|
||||
receivedAtMs: performance.now(),
|
||||
blendDurationMs: Math.max(tickIntervalMs, 80),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -438,8 +475,7 @@ export class GameViewer {
|
||||
visual.velocity.copy(this.toThreeVector(ship.velocity));
|
||||
visual.receivedAtMs = performance.now();
|
||||
visual.blendDurationMs = Math.max(tickIntervalMs * 1.35, 100);
|
||||
const material = visual.mesh.material as THREE.MeshStandardMaterial;
|
||||
material.color.set(this.shipColor(ship.role));
|
||||
(visual.mesh.material as THREE.MeshStandardMaterial).color.set(this.shipColor(ship.role));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -462,9 +498,10 @@ export class GameViewer {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.selected) {
|
||||
if (this.selectedItems.length === 0) {
|
||||
this.detailTitleEl.textContent = this.world.label;
|
||||
this.detailBodyEl.innerHTML = `
|
||||
Zoom ${this.zoomLevel}<br>
|
||||
Systems ${this.world.systems.size}<br>
|
||||
Stations ${this.world.stations.size}<br>
|
||||
Ships ${this.world.ships.size}<br>
|
||||
@@ -473,8 +510,19 @@ export class GameViewer {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.selected.kind === "ship") {
|
||||
const ship = this.world.ships.get(this.selected.id);
|
||||
if (this.selectedItems.length > 1) {
|
||||
const group = this.getSelectionGroup(this.selectedItems[0]);
|
||||
this.detailTitleEl.textContent = `${this.selectedItems.length} selected`;
|
||||
this.detailBodyEl.innerHTML = `
|
||||
Type ${group}<br>
|
||||
${this.selectedItems.slice(0, 8).map((item) => this.describeSelectable(item)).join("<br>")}
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
const selected = this.selectedItems[0];
|
||||
if (selected.kind === "ship") {
|
||||
const ship = this.world.ships.get(selected.id);
|
||||
if (!ship) {
|
||||
return;
|
||||
}
|
||||
@@ -489,8 +537,8 @@ export class GameViewer {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.selected.kind === "station") {
|
||||
const station = this.world.stations.get(this.selected.id);
|
||||
if (selected.kind === "station") {
|
||||
const station = this.world.stations.get(selected.id);
|
||||
if (!station) {
|
||||
return;
|
||||
}
|
||||
@@ -503,8 +551,8 @@ export class GameViewer {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.selected.kind === "node") {
|
||||
const node = this.world.nodes.get(this.selected.id);
|
||||
if (selected.kind === "node") {
|
||||
const node = this.world.nodes.get(selected.id);
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
@@ -516,7 +564,21 @@ export class GameViewer {
|
||||
return;
|
||||
}
|
||||
|
||||
const system = this.world.systems.get(this.selected.id);
|
||||
if (selected.kind === "planet") {
|
||||
const system = this.world.systems.get(selected.systemId);
|
||||
const planet = system?.planets[selected.planetIndex];
|
||||
if (!system || !planet) {
|
||||
return;
|
||||
}
|
||||
this.detailTitleEl.textContent = planet.label;
|
||||
this.detailBodyEl.innerHTML = `
|
||||
<p>${system.label}</p>
|
||||
<p>Orbit ${planet.orbitRadius.toFixed(0)}<br>Size ${planet.size.toFixed(0)}</p>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
const system = this.world.systems.get(selected.id);
|
||||
if (!system) {
|
||||
return;
|
||||
}
|
||||
@@ -529,13 +591,66 @@ export class GameViewer {
|
||||
|
||||
private render() {
|
||||
const delta = Math.min(this.clock.getDelta(), 0.033);
|
||||
this.camera.position.lerp(new THREE.Vector3(this.focus.x + 2200, 1600, this.focus.z + 2200), Math.min(1, delta * 2));
|
||||
this.camera.lookAt(this.focus);
|
||||
this.updateCamera(delta);
|
||||
this.updateShipPresentation();
|
||||
this.updateNetworkPanel();
|
||||
this.applyZoomPresentation();
|
||||
this.renderer.render(this.scene, this.camera);
|
||||
}
|
||||
|
||||
private updateCamera(delta: number) {
|
||||
this.desiredDistance = THREE.MathUtils.lerp(this.desiredDistance, ZOOM_DISTANCE[this.zoomLevel], Math.min(1, delta * 6));
|
||||
this.updatePanFromKeyboard(delta);
|
||||
this.orbitPitch = THREE.MathUtils.clamp(this.orbitPitch, 0.18, 1.3);
|
||||
|
||||
const horizontalDistance = this.desiredDistance * Math.cos(this.orbitPitch);
|
||||
this.cameraOffset.set(
|
||||
Math.cos(this.orbitYaw) * horizontalDistance,
|
||||
this.desiredDistance * Math.sin(this.orbitPitch),
|
||||
Math.sin(this.orbitYaw) * horizontalDistance,
|
||||
);
|
||||
this.camera.position.copy(this.focus).add(this.cameraOffset);
|
||||
this.camera.lookAt(this.focus);
|
||||
}
|
||||
|
||||
private updatePanFromKeyboard(delta: number) {
|
||||
const move = new THREE.Vector3();
|
||||
if (this.keyState.has("w")) {
|
||||
move.z -= 1;
|
||||
}
|
||||
if (this.keyState.has("s")) {
|
||||
move.z += 1;
|
||||
}
|
||||
if (this.keyState.has("a")) {
|
||||
move.x += 1;
|
||||
}
|
||||
if (this.keyState.has("d")) {
|
||||
move.x -= 1;
|
||||
}
|
||||
if (move.lengthSq() === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
move.normalize();
|
||||
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;
|
||||
this.focus.addScaledVector(pan, speed * delta);
|
||||
}
|
||||
|
||||
private applyZoomPresentation() {
|
||||
const system = this.zoomLevel === "system";
|
||||
const universe = this.zoomLevel === "universe";
|
||||
|
||||
for (const entry of this.presentationEntries) {
|
||||
entry.icon.visible = system || universe;
|
||||
entry.detail.visible = universe ? !entry.hideDetailInUniverse : true;
|
||||
}
|
||||
|
||||
this.scene.fog = new THREE.FogExp2(0x040912, this.zoomLevel === "local" ? 0.00011 : this.zoomLevel === "system" ? 0.000045 : 0.000012);
|
||||
}
|
||||
|
||||
private recordDeltaStats(delta: WorldDelta, rawBytes: number) {
|
||||
const changedEntities = delta.ships.length + delta.stations.length + delta.nodes.length + delta.factions.length;
|
||||
this.networkStats.deltasReceived += 1;
|
||||
@@ -544,10 +659,7 @@ export class GameViewer {
|
||||
this.networkStats.lastEntityChanges = changedEntities;
|
||||
this.networkStats.eventsReceived += delta.events.length;
|
||||
this.networkStats.lastDeltaAtMs = performance.now();
|
||||
this.networkStats.throughputSamples.push({
|
||||
atMs: performance.now(),
|
||||
bytes: rawBytes,
|
||||
});
|
||||
this.networkStats.throughputSamples.push({ atMs: performance.now(), bytes: rawBytes });
|
||||
const cutoff = performance.now() - 4000;
|
||||
this.networkStats.throughputSamples = this.networkStats.throughputSamples.filter((sample) => sample.atMs >= cutoff);
|
||||
}
|
||||
@@ -569,12 +681,18 @@ export class GameViewer {
|
||||
? ((now - this.networkStats.lastDeltaAtMs) / 1000).toFixed(1)
|
||||
: "n/a";
|
||||
|
||||
this.networkPanelEl.textContent = this.buildNetworkPanelText({
|
||||
uptimeSeconds,
|
||||
kbPerSecond,
|
||||
averageDeltaBytes,
|
||||
secondsSinceLastDelta,
|
||||
});
|
||||
this.networkPanelEl.textContent = [
|
||||
`snapshot: ${this.formatBytes(this.networkStats.snapshotBytes)}`,
|
||||
`stream: ${this.networkStats.streamConnected ? "live" : "offline"}`,
|
||||
`deltas: ${this.networkStats.deltasReceived}`,
|
||||
`events: ${this.networkStats.eventsReceived}`,
|
||||
`avg delta: ${this.formatBytes(averageDeltaBytes)}`,
|
||||
`last delta: ${this.formatBytes(this.networkStats.lastDeltaBytes)}`,
|
||||
`recent rate: ${kbPerSecond.toFixed(1)} KB/s`,
|
||||
`changed: ${this.networkStats.lastEntityChanges}`,
|
||||
`uptime: ${uptimeSeconds.toFixed(1)}s`,
|
||||
`last packet: ${secondsSinceLastDelta}s`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
private updateShipPresentation() {
|
||||
@@ -589,11 +707,21 @@ export class GameViewer {
|
||||
visual.mesh.position.copy(visual.authoritativePosition).addScaledVector(visual.velocity, extrapolationSeconds);
|
||||
}
|
||||
|
||||
visual.icon.position.copy(visual.mesh.position);
|
||||
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 mesh of this.stationMeshes.values()) {
|
||||
const entry = this.presentationEntries.find((candidate) => candidate.detail === mesh);
|
||||
entry?.icon.position.copy(mesh.position);
|
||||
}
|
||||
}
|
||||
|
||||
private createNodeMesh(node: ResourceNodeSnapshot) {
|
||||
@@ -627,6 +755,45 @@ export class GameViewer {
|
||||
return mesh;
|
||||
}
|
||||
|
||||
private createTacticalIcon(color: string, size: number) {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = 64;
|
||||
canvas.height = 64;
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) {
|
||||
throw new Error("Unable to create tactical icon");
|
||||
}
|
||||
|
||||
context.clearRect(0, 0, 64, 64);
|
||||
context.strokeStyle = color;
|
||||
context.lineWidth = 5;
|
||||
context.beginPath();
|
||||
context.arc(32, 32, 18, 0, Math.PI * 2);
|
||||
context.stroke();
|
||||
context.beginPath();
|
||||
context.moveTo(32, 8);
|
||||
context.lineTo(32, 56);
|
||||
context.moveTo(8, 32);
|
||||
context.lineTo(56, 32);
|
||||
context.stroke();
|
||||
|
||||
const texture = new THREE.CanvasTexture(canvas);
|
||||
const sprite = new THREE.Sprite(new THREE.SpriteMaterial({
|
||||
map: texture,
|
||||
transparent: true,
|
||||
depthWrite: false,
|
||||
depthTest: false,
|
||||
color: "#ffffff",
|
||||
}));
|
||||
sprite.scale.setScalar(size);
|
||||
sprite.visible = false;
|
||||
return sprite;
|
||||
}
|
||||
|
||||
private registerPresentation(detail: THREE.Object3D, icon: THREE.Sprite, hideDetailInUniverse: boolean) {
|
||||
this.presentationEntries.push({ detail, icon, hideDetailInUniverse });
|
||||
}
|
||||
|
||||
private renderRecentEvents(entityKind: string, entityId: string) {
|
||||
if (!this.world) {
|
||||
return "";
|
||||
@@ -639,6 +806,35 @@ export class GameViewer {
|
||||
.join("<br>");
|
||||
}
|
||||
|
||||
private describeSelectable(item: Selectable) {
|
||||
if (!this.world) {
|
||||
return item.kind;
|
||||
}
|
||||
if (item.kind === "ship") {
|
||||
return this.world.ships.get(item.id)?.label ?? item.id;
|
||||
}
|
||||
if (item.kind === "station") {
|
||||
return this.world.stations.get(item.id)?.label ?? item.id;
|
||||
}
|
||||
if (item.kind === "node") {
|
||||
return item.id;
|
||||
}
|
||||
if (item.kind === "planet") {
|
||||
return this.world.systems.get(item.systemId)?.planets[item.planetIndex]?.label ?? `${item.systemId}:${item.planetIndex}`;
|
||||
}
|
||||
return this.world.systems.get(item.id)?.label ?? item.id;
|
||||
}
|
||||
|
||||
private getSelectionGroup(item: Selectable): SelectionGroup {
|
||||
if (item.kind === "ship") {
|
||||
return "ships";
|
||||
}
|
||||
if (item.kind === "station" || item.kind === "node") {
|
||||
return "structures";
|
||||
}
|
||||
return "celestials";
|
||||
}
|
||||
|
||||
private formatVector(vector: Vector3Dto) {
|
||||
return `${vector.x.toFixed(1)}, ${vector.y.toFixed(1)}, ${vector.z.toFixed(1)}`;
|
||||
}
|
||||
@@ -653,26 +849,6 @@ export class GameViewer {
|
||||
return `${Math.round(bytes)} B`;
|
||||
}
|
||||
|
||||
private buildNetworkPanelText(values: {
|
||||
uptimeSeconds: number;
|
||||
kbPerSecond: number;
|
||||
averageDeltaBytes: number;
|
||||
secondsSinceLastDelta: string;
|
||||
}) {
|
||||
return [
|
||||
`snapshot: ${this.formatBytes(this.networkStats.snapshotBytes)}`,
|
||||
`stream: ${this.networkStats.streamConnected ? "live" : "offline"}`,
|
||||
`deltas: ${this.networkStats.deltasReceived}`,
|
||||
`events: ${this.networkStats.eventsReceived}`,
|
||||
`avg delta: ${this.formatBytes(values.averageDeltaBytes)}`,
|
||||
`last delta: ${this.formatBytes(this.networkStats.lastDeltaBytes)}`,
|
||||
`recent rate: ${values.kbPerSecond.toFixed(1)} KB/s`,
|
||||
`changed: ${this.networkStats.lastEntityChanges}`,
|
||||
`uptime: ${values.uptimeSeconds.toFixed(1)}s`,
|
||||
`last packet: ${values.secondsSinceLastDelta}s`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
private updateGamePanel(mode: string) {
|
||||
const sequence = this.world?.sequence ?? 0;
|
||||
const generatedAt = this.world?.generatedAtUtc
|
||||
@@ -680,6 +856,7 @@ export class GameViewer {
|
||||
: "n/a";
|
||||
this.statusEl.textContent = [
|
||||
`mode: ${mode}`,
|
||||
`zoom: ${this.zoomLevel}`,
|
||||
`sequence: ${sequence}`,
|
||||
`snapshot: ${generatedAt}`,
|
||||
].join("\n");
|
||||
@@ -689,41 +866,100 @@ export class GameViewer {
|
||||
return new THREE.Vector3(vector.x, vector.y, vector.z);
|
||||
}
|
||||
|
||||
private screenPointFromClient(clientX: number, clientY: number) {
|
||||
const bounds = this.renderer.domElement.getBoundingClientRect();
|
||||
return new THREE.Vector2(clientX - bounds.left, clientY - bounds.top);
|
||||
}
|
||||
|
||||
private onPointerDown = (event: PointerEvent) => {
|
||||
this.dragging = true;
|
||||
this.lastPointer.set(event.clientX, event.clientY);
|
||||
if (event.button === 1) {
|
||||
this.dragMode = "orbit";
|
||||
this.dragPointerId = event.pointerId;
|
||||
this.dragLast.copy(this.screenPointFromClient(event.clientX, event.clientY));
|
||||
this.renderer.domElement.setPointerCapture(event.pointerId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.button !== 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.dragMode = "marquee";
|
||||
this.dragPointerId = event.pointerId;
|
||||
this.dragStart.copy(this.screenPointFromClient(event.clientX, event.clientY));
|
||||
this.dragLast.copy(this.dragStart);
|
||||
this.marqueeActive = false;
|
||||
this.renderer.domElement.setPointerCapture(event.pointerId);
|
||||
};
|
||||
|
||||
private onPointerMove = (event: PointerEvent) => {
|
||||
if (!this.dragging) {
|
||||
if (this.dragPointerId !== event.pointerId || !this.dragMode) {
|
||||
return;
|
||||
}
|
||||
const dx = event.clientX - this.lastPointer.x;
|
||||
const dy = event.clientY - this.lastPointer.y;
|
||||
this.focus.x -= dx * 2.4;
|
||||
this.focus.z += dy * 2.4;
|
||||
this.lastPointer.set(event.clientX, event.clientY);
|
||||
|
||||
const point = this.screenPointFromClient(event.clientX, event.clientY);
|
||||
if (this.dragMode === "orbit") {
|
||||
const delta = point.clone().sub(this.dragLast);
|
||||
this.orbitYaw += delta.x * 0.008;
|
||||
this.orbitPitch = THREE.MathUtils.clamp(this.orbitPitch + delta.y * 0.004, 0.18, 1.3);
|
||||
this.dragLast.copy(point);
|
||||
return;
|
||||
}
|
||||
|
||||
const dragDistance = point.distanceTo(this.dragStart);
|
||||
if (!this.marqueeActive && dragDistance > 8) {
|
||||
this.marqueeActive = true;
|
||||
this.suppressClickSelection = true;
|
||||
this.marqueeEl.style.display = "block";
|
||||
}
|
||||
|
||||
if (!this.marqueeActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.dragLast.copy(point);
|
||||
this.updateMarqueeBox();
|
||||
};
|
||||
|
||||
private onPointerUp = () => {
|
||||
this.dragging = false;
|
||||
private onPointerUp = (event: PointerEvent) => {
|
||||
if (this.dragPointerId !== event.pointerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.renderer.domElement.hasPointerCapture(event.pointerId)) {
|
||||
this.renderer.domElement.releasePointerCapture(event.pointerId);
|
||||
}
|
||||
|
||||
if (this.dragMode === "marquee" && this.marqueeActive) {
|
||||
this.completeMarqueeSelection();
|
||||
this.hideMarqueeBox();
|
||||
}
|
||||
|
||||
this.dragMode = undefined;
|
||||
this.dragPointerId = undefined;
|
||||
this.marqueeActive = false;
|
||||
};
|
||||
|
||||
private onClick = (event: MouseEvent) => {
|
||||
if (this.suppressClickSelection) {
|
||||
this.suppressClickSelection = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const bounds = this.renderer.domElement.getBoundingClientRect();
|
||||
this.mouse.x = ((event.clientX - bounds.left) / bounds.width) * 2 - 1;
|
||||
this.mouse.y = -(((event.clientY - bounds.top) / bounds.height) * 2 - 1);
|
||||
this.raycaster.setFromCamera(this.mouse, this.camera);
|
||||
const hit = this.raycaster.intersectObjects([...this.selectableTargets.keys()], false)[0];
|
||||
this.selected = hit ? this.selectableTargets.get(hit.object) : undefined;
|
||||
this.selectedItems = hit ? [this.selectableTargets.get(hit.object)!] : [];
|
||||
this.updatePanels();
|
||||
};
|
||||
|
||||
private onDoubleClick = () => {
|
||||
if (!this.world || !this.selected) {
|
||||
if (this.selectedItems.length !== 1) {
|
||||
return;
|
||||
}
|
||||
const nextFocus = this.resolveSelectionPosition(this.selected);
|
||||
const nextFocus = this.resolveSelectionPosition(this.selectedItems[0]);
|
||||
if (nextFocus) {
|
||||
this.focus.copy(nextFocus);
|
||||
}
|
||||
@@ -731,10 +967,30 @@ export class GameViewer {
|
||||
|
||||
private onWheel = (event: WheelEvent) => {
|
||||
event.preventDefault();
|
||||
const offset = this.camera.position.clone().sub(this.focus);
|
||||
offset.multiplyScalar(event.deltaY > 0 ? 1.08 : 0.92);
|
||||
offset.clampLength(500, 12000);
|
||||
this.camera.position.copy(this.focus).add(offset);
|
||||
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];
|
||||
this.updateGamePanel("Live");
|
||||
};
|
||||
|
||||
private onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.repeat) {
|
||||
return;
|
||||
}
|
||||
const key = event.key.toLowerCase();
|
||||
this.keyState.add(key);
|
||||
if (key === "1") {
|
||||
this.zoomLevel = "local";
|
||||
} else if (key === "2") {
|
||||
this.zoomLevel = "system";
|
||||
} else if (key === "3") {
|
||||
this.zoomLevel = "universe";
|
||||
}
|
||||
this.updateGamePanel("Live");
|
||||
};
|
||||
|
||||
private onKeyUp = (event: KeyboardEvent) => {
|
||||
this.keyState.delete(event.key.toLowerCase());
|
||||
};
|
||||
|
||||
private resolveSelectionPosition(selection: Selectable) {
|
||||
@@ -746,21 +1002,79 @@ export class GameViewer {
|
||||
const ship = this.world.ships.get(selection.id);
|
||||
return ship ? this.toThreeVector(ship.position) : undefined;
|
||||
}
|
||||
|
||||
if (selection.kind === "station") {
|
||||
const station = this.world.stations.get(selection.id);
|
||||
return station ? this.toThreeVector(station.position) : undefined;
|
||||
}
|
||||
|
||||
if (selection.kind === "node") {
|
||||
const node = this.world.nodes.get(selection.id);
|
||||
return node ? this.toThreeVector(node.position) : 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;
|
||||
}
|
||||
const system = this.world.systems.get(selection.id);
|
||||
return system ? this.toThreeVector(system.position) : undefined;
|
||||
}
|
||||
|
||||
private updateMarqueeBox() {
|
||||
const minX = Math.min(this.dragStart.x, this.dragLast.x);
|
||||
const minY = Math.min(this.dragStart.y, this.dragLast.y);
|
||||
const maxX = Math.max(this.dragStart.x, this.dragLast.x);
|
||||
const maxY = Math.max(this.dragStart.y, this.dragLast.y);
|
||||
this.marqueeEl.style.left = `${minX}px`;
|
||||
this.marqueeEl.style.top = `${minY}px`;
|
||||
this.marqueeEl.style.width = `${maxX - minX}px`;
|
||||
this.marqueeEl.style.height = `${maxY - minY}px`;
|
||||
}
|
||||
|
||||
private hideMarqueeBox() {
|
||||
this.marqueeEl.style.display = "none";
|
||||
this.marqueeEl.style.width = "0";
|
||||
this.marqueeEl.style.height = "0";
|
||||
}
|
||||
|
||||
private completeMarqueeSelection() {
|
||||
const bounds = this.renderer.domElement.getBoundingClientRect();
|
||||
const minX = Math.min(this.dragStart.x, this.dragLast.x);
|
||||
const minY = Math.min(this.dragStart.y, this.dragLast.y);
|
||||
const maxX = Math.max(this.dragStart.x, this.dragLast.x);
|
||||
const maxY = Math.max(this.dragStart.y, this.dragLast.y);
|
||||
const grouped = new Map<SelectionGroup, Selectable[]>();
|
||||
|
||||
for (const [object, selectable] of this.selectableTargets.entries()) {
|
||||
if (object instanceof THREE.Sprite && !object.visible) {
|
||||
continue;
|
||||
}
|
||||
if (!object.visible) {
|
||||
continue;
|
||||
}
|
||||
const worldPosition = new THREE.Vector3();
|
||||
object.getWorldPosition(worldPosition);
|
||||
worldPosition.project(this.camera);
|
||||
const screenX = ((worldPosition.x + 1) * 0.5) * bounds.width;
|
||||
const screenY = ((1 - worldPosition.y) * 0.5) * bounds.height;
|
||||
if (screenX < minX || screenX > maxX || screenY < minY || screenY > maxY) {
|
||||
continue;
|
||||
}
|
||||
const group = this.getSelectionGroup(selectable);
|
||||
const list = grouped.get(group) ?? [];
|
||||
if (!list.some((entry) => JSON.stringify(entry) === JSON.stringify(selectable))) {
|
||||
list.push(selectable);
|
||||
}
|
||||
grouped.set(group, list);
|
||||
}
|
||||
|
||||
const selection = [...grouped.entries()]
|
||||
.sort((left, right) => right[1].length - left[1].length)[0]?.[1] ?? [];
|
||||
this.selectedItems = selection;
|
||||
this.updatePanels();
|
||||
}
|
||||
|
||||
private shipSize(ship: ShipSnapshot) {
|
||||
switch (ship.shipClass) {
|
||||
case "capital":
|
||||
|
||||
@@ -37,6 +37,14 @@ canvas {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.marquee-box {
|
||||
position: absolute;
|
||||
display: none;
|
||||
border: 1px solid rgba(127, 214, 255, 0.72);
|
||||
background: rgba(127, 214, 255, 0.14);
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.topbar,
|
||||
.details-panel,
|
||||
.network-panel,
|
||||
|
||||
Reference in New Issue
Block a user