From 7fbe7cce1a0437819cb2edaabfdaafa0a9e11e18 Mon Sep 17 00:00:00 2001 From: Jonathan Bourdon Date: Thu, 12 Mar 2026 20:59:35 -0400 Subject: [PATCH] Improve viewer camera and selection controls --- apps/viewer/src/GameViewer.ts | 518 +++++++++++++++++++++++++++------- apps/viewer/src/style.css | 8 + 2 files changed, 424 insertions(+), 102 deletions(-) diff --git a/apps/viewer/src/GameViewer.ts b/apps/viewer/src/GameViewer.ts index a4f4dbb..1ae64fa 100644 --- a/apps/viewer/src/GameViewer.ts +++ b/apps/viewer/src/GameViewer.ts @@ -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 = { + 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(); 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(); + private readonly presentationEntries: PresentationEntry[] = []; private readonly nodeMeshes = new Map(); private readonly stationMeshes = new Map(); private readonly shipVisuals = new Map(); @@ -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 {
Waiting for snapshot.
+
`; 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}
Systems ${this.world.systems.size}
Stations ${this.world.stations.size}
Ships ${this.world.ships.size}
@@ -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}
+ ${this.selectedItems.slice(0, 8).map((item) => this.describeSelectable(item)).join("
")} + `; + 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 = ` +

${system.label}

+

Orbit ${planet.orbitRadius.toFixed(0)}
Size ${planet.size.toFixed(0)}

+ `; + 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("
"); } + 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(); + + 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": diff --git a/apps/viewer/src/style.css b/apps/viewer/src/style.css index 16a1b0a..44e8d3f 100644 --- a/apps/viewer/src/style.css +++ b/apps/viewer/src/style.css @@ -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,