Improve viewer camera and selection controls

This commit is contained in:
2026-03-12 20:59:35 -04:00
parent 0340e1cc7d
commit 7fbe7cce1a
2 changed files with 424 additions and 102 deletions

View File

@@ -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":

View File

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