Improve viewer camera and selection controls
This commit is contained in:
@@ -1,7 +1,6 @@
|
|||||||
import * as THREE from "three";
|
import * as THREE from "three";
|
||||||
import { fetchWorldSnapshot, openWorldStream } from "./api";
|
import { fetchWorldSnapshot, openWorldStream } from "./api";
|
||||||
import type {
|
import type {
|
||||||
FactionDelta,
|
|
||||||
FactionSnapshot,
|
FactionSnapshot,
|
||||||
ResourceNodeDelta,
|
ResourceNodeDelta,
|
||||||
ResourceNodeSnapshot,
|
ResourceNodeSnapshot,
|
||||||
@@ -16,14 +15,19 @@ import type {
|
|||||||
WorldSnapshot,
|
WorldSnapshot,
|
||||||
} from "./contracts";
|
} from "./contracts";
|
||||||
|
|
||||||
|
type ZoomLevel = "local" | "system" | "universe";
|
||||||
|
type SelectionGroup = "ships" | "structures" | "celestials";
|
||||||
|
type DragMode = "orbit" | "marquee";
|
||||||
type Selectable =
|
type Selectable =
|
||||||
| { kind: "ship"; id: string }
|
| { kind: "ship"; id: string }
|
||||||
| { kind: "station"; id: string }
|
| { kind: "station"; id: string }
|
||||||
| { kind: "node"; id: string }
|
| { kind: "node"; id: string }
|
||||||
| { kind: "system"; id: string };
|
| { kind: "system"; id: string }
|
||||||
|
| { kind: "planet"; systemId: string; planetIndex: number };
|
||||||
|
|
||||||
interface ShipVisual {
|
interface ShipVisual {
|
||||||
mesh: THREE.Mesh;
|
mesh: THREE.Mesh;
|
||||||
|
icon: THREE.Sprite;
|
||||||
startPosition: THREE.Vector3;
|
startPosition: THREE.Vector3;
|
||||||
authoritativePosition: THREE.Vector3;
|
authoritativePosition: THREE.Vector3;
|
||||||
targetPosition: THREE.Vector3;
|
targetPosition: THREE.Vector3;
|
||||||
@@ -64,20 +68,36 @@ interface NetworkStats {
|
|||||||
throughputSamples: NetworkSample[];
|
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 {
|
export class GameViewer {
|
||||||
private readonly container: HTMLElement;
|
private readonly container: HTMLElement;
|
||||||
private readonly renderer = new THREE.WebGLRenderer({ antialias: true });
|
private readonly renderer = new THREE.WebGLRenderer({ antialias: true });
|
||||||
private readonly scene = new THREE.Scene();
|
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 clock = new THREE.Clock();
|
||||||
private readonly raycaster = new THREE.Raycaster();
|
private readonly raycaster = new THREE.Raycaster();
|
||||||
private readonly mouse = new THREE.Vector2();
|
private readonly mouse = new THREE.Vector2();
|
||||||
private readonly focus = new THREE.Vector3(2200, 0, 300);
|
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 systemGroup = new THREE.Group();
|
||||||
private readonly nodeGroup = new THREE.Group();
|
private readonly nodeGroup = new THREE.Group();
|
||||||
private readonly stationGroup = new THREE.Group();
|
private readonly stationGroup = new THREE.Group();
|
||||||
private readonly shipGroup = new THREE.Group();
|
private readonly shipGroup = new THREE.Group();
|
||||||
private readonly selectableTargets = new Map<THREE.Object3D, Selectable>();
|
private readonly selectableTargets = new Map<THREE.Object3D, Selectable>();
|
||||||
|
private readonly presentationEntries: PresentationEntry[] = [];
|
||||||
private readonly nodeMeshes = new Map<string, THREE.Mesh>();
|
private readonly nodeMeshes = new Map<string, THREE.Mesh>();
|
||||||
private readonly stationMeshes = new Map<string, THREE.Mesh>();
|
private readonly stationMeshes = new Map<string, THREE.Mesh>();
|
||||||
private readonly shipVisuals = new Map<string, ShipVisual>();
|
private readonly shipVisuals = new Map<string, ShipVisual>();
|
||||||
@@ -87,6 +107,8 @@ export class GameViewer {
|
|||||||
private readonly factionStripEl: HTMLDivElement;
|
private readonly factionStripEl: HTMLDivElement;
|
||||||
private readonly networkPanelEl: HTMLDivElement;
|
private readonly networkPanelEl: HTMLDivElement;
|
||||||
private readonly errorEl: HTMLDivElement;
|
private readonly errorEl: HTMLDivElement;
|
||||||
|
private readonly marqueeEl: HTMLDivElement;
|
||||||
|
|
||||||
private world?: WorldState;
|
private world?: WorldState;
|
||||||
private stream?: EventSource;
|
private stream?: EventSource;
|
||||||
private readonly networkStats: NetworkStats = {
|
private readonly networkStats: NetworkStats = {
|
||||||
@@ -99,10 +121,19 @@ export class GameViewer {
|
|||||||
streamConnected: false,
|
streamConnected: false,
|
||||||
throughputSamples: [],
|
throughputSamples: [],
|
||||||
};
|
};
|
||||||
private selected?: Selectable;
|
|
||||||
private dragging = false;
|
private selectedItems: Selectable[] = [];
|
||||||
private lastPointer = new THREE.Vector2();
|
|
||||||
private worldSignature = "";
|
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) {
|
constructor(container: HTMLElement) {
|
||||||
this.container = container;
|
this.container = container;
|
||||||
@@ -117,9 +148,6 @@ export class GameViewer {
|
|||||||
this.scene.add(keyLight);
|
this.scene.add(keyLight);
|
||||||
this.scene.add(this.systemGroup, this.nodeGroup, this.stationGroup, this.shipGroup);
|
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");
|
const hud = document.createElement("div");
|
||||||
hud.className = "viewer-shell";
|
hud.className = "viewer-shell";
|
||||||
hud.innerHTML = `
|
hud.innerHTML = `
|
||||||
@@ -138,6 +166,7 @@ export class GameViewer {
|
|||||||
<div class="network-body">Waiting for snapshot.</div>
|
<div class="network-body">Waiting for snapshot.</div>
|
||||||
</aside>
|
</aside>
|
||||||
<section class="faction-strip"></section>
|
<section class="faction-strip"></section>
|
||||||
|
<div class="marquee-box"></div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
this.statusEl = hud.querySelector(".topbar-body") as HTMLDivElement;
|
this.statusEl = hud.querySelector(".topbar-body") as HTMLDivElement;
|
||||||
@@ -146,17 +175,22 @@ export class GameViewer {
|
|||||||
this.factionStripEl = hud.querySelector(".faction-strip") as HTMLDivElement;
|
this.factionStripEl = hud.querySelector(".faction-strip") as HTMLDivElement;
|
||||||
this.networkPanelEl = hud.querySelector(".network-body") as HTMLDivElement;
|
this.networkPanelEl = hud.querySelector(".network-body") as HTMLDivElement;
|
||||||
this.errorEl = hud.querySelector(".error-strip") 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.container.append(this.renderer.domElement, hud);
|
||||||
|
|
||||||
this.renderer.domElement.addEventListener("pointerdown", this.onPointerDown);
|
this.renderer.domElement.addEventListener("pointerdown", this.onPointerDown);
|
||||||
this.renderer.domElement.addEventListener("pointermove", this.onPointerMove);
|
this.renderer.domElement.addEventListener("pointermove", this.onPointerMove);
|
||||||
this.renderer.domElement.addEventListener("pointerup", this.onPointerUp);
|
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("click", this.onClick);
|
||||||
this.renderer.domElement.addEventListener("dblclick", this.onDoubleClick);
|
this.renderer.domElement.addEventListener("dblclick", this.onDoubleClick);
|
||||||
this.renderer.domElement.addEventListener("wheel", this.onWheel, { passive: false });
|
this.renderer.domElement.addEventListener("wheel", this.onWheel, { passive: false });
|
||||||
|
window.addEventListener("keydown", this.onKeyDown);
|
||||||
|
window.addEventListener("keyup", this.onKeyUp);
|
||||||
window.addEventListener("resize", this.onResize);
|
window.addEventListener("resize", this.onResize);
|
||||||
this.onResize();
|
this.onResize();
|
||||||
|
this.updateCamera(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
async start() {
|
async start() {
|
||||||
@@ -245,6 +279,7 @@ export class GameViewer {
|
|||||||
this.syncStations(snapshot.stations);
|
this.syncStations(snapshot.stations);
|
||||||
this.syncShips(snapshot.ships, snapshot.tickIntervalMs);
|
this.syncShips(snapshot.ships, snapshot.tickIntervalMs);
|
||||||
this.rebuildFactions(snapshot.factions);
|
this.rebuildFactions(snapshot.factions);
|
||||||
|
this.applyZoomPresentation();
|
||||||
this.updateNetworkPanel();
|
this.updateNetworkPanel();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,15 +296,12 @@ export class GameViewer {
|
|||||||
for (const node of delta.nodes) {
|
for (const node of delta.nodes) {
|
||||||
this.world.nodes.set(node.id, node);
|
this.world.nodes.set(node.id, node);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const station of delta.stations) {
|
for (const station of delta.stations) {
|
||||||
this.world.stations.set(station.id, station);
|
this.world.stations.set(station.id, station);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const ship of delta.ships) {
|
for (const ship of delta.ships) {
|
||||||
this.world.ships.set(ship.id, ship);
|
this.world.ships.set(ship.id, ship);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const faction of delta.factions) {
|
for (const faction of delta.factions) {
|
||||||
this.world.factions.set(faction.id, faction);
|
this.world.factions.set(faction.id, faction);
|
||||||
}
|
}
|
||||||
@@ -285,10 +317,12 @@ export class GameViewer {
|
|||||||
private rebuildSystems(systems: SystemSnapshot[]) {
|
private rebuildSystems(systems: SystemSnapshot[]) {
|
||||||
this.systemGroup.clear();
|
this.systemGroup.clear();
|
||||||
this.selectableTargets.clear();
|
this.selectableTargets.clear();
|
||||||
|
this.presentationEntries.length = 0;
|
||||||
|
|
||||||
for (const system of systems) {
|
for (const system of systems) {
|
||||||
const root = new THREE.Group();
|
const root = new THREE.Group();
|
||||||
root.position.set(system.position.x, system.position.y, system.position.z);
|
root.position.set(system.position.x, system.position.y, system.position.z);
|
||||||
|
|
||||||
const star = new THREE.Mesh(
|
const star = new THREE.Mesh(
|
||||||
new THREE.SphereGeometry(system.starSize, 32, 32),
|
new THREE.SphereGeometry(system.starSize, 32, 32),
|
||||||
new THREE.MeshBasicMaterial({ color: system.starColor }),
|
new THREE.MeshBasicMaterial({ color: system.starColor }),
|
||||||
@@ -302,11 +336,14 @@ export class GameViewer {
|
|||||||
side: THREE.BackSide,
|
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(star, { kind: "system", id: system.id });
|
||||||
this.selectableTargets.set(halo, { 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(
|
const orbit = new THREE.LineLoop(
|
||||||
new THREE.BufferGeometry().setFromPoints(
|
new THREE.BufferGeometry().setFromPoints(
|
||||||
Array.from({ length: 80 }, (_, index) => {
|
Array.from({ length: 80 }, (_, index) => {
|
||||||
@@ -329,7 +366,12 @@ export class GameViewer {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
planetMesh.position.set(planet.orbitRadius, 0, 0);
|
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);
|
this.systemGroup.add(root);
|
||||||
@@ -339,35 +381,51 @@ export class GameViewer {
|
|||||||
private syncNodes(nodes: ResourceNodeSnapshot[]) {
|
private syncNodes(nodes: ResourceNodeSnapshot[]) {
|
||||||
this.nodeGroup.clear();
|
this.nodeGroup.clear();
|
||||||
this.nodeMeshes.clear();
|
this.nodeMeshes.clear();
|
||||||
|
|
||||||
for (const node of nodes) {
|
for (const node of nodes) {
|
||||||
const mesh = this.createNodeMesh(node);
|
const mesh = this.createNodeMesh(node);
|
||||||
|
const icon = this.createTacticalIcon("#d2b07a", 20);
|
||||||
|
icon.position.copy(mesh.position);
|
||||||
this.nodeMeshes.set(node.id, mesh);
|
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(mesh, { kind: "node", id: node.id });
|
||||||
|
this.selectableTargets.set(icon, { kind: "node", id: node.id });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private syncStations(stations: StationSnapshot[]) {
|
private syncStations(stations: StationSnapshot[]) {
|
||||||
this.stationGroup.clear();
|
this.stationGroup.clear();
|
||||||
this.stationMeshes.clear();
|
this.stationMeshes.clear();
|
||||||
|
|
||||||
for (const station of stations) {
|
for (const station of stations) {
|
||||||
const mesh = this.createStationMesh(station);
|
const mesh = this.createStationMesh(station);
|
||||||
|
const icon = this.createTacticalIcon(station.color, 26);
|
||||||
|
icon.position.copy(mesh.position);
|
||||||
this.stationMeshes.set(station.id, mesh);
|
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(mesh, { kind: "station", id: station.id });
|
||||||
|
this.selectableTargets.set(icon, { kind: "station", id: station.id });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private syncShips(ships: ShipSnapshot[], tickIntervalMs: number) {
|
private syncShips(ships: ShipSnapshot[], tickIntervalMs: number) {
|
||||||
this.shipGroup.clear();
|
this.shipGroup.clear();
|
||||||
this.shipVisuals.clear();
|
this.shipVisuals.clear();
|
||||||
|
|
||||||
for (const ship of ships) {
|
for (const ship of ships) {
|
||||||
const mesh = this.createShipMesh(ship);
|
const mesh = this.createShipMesh(ship);
|
||||||
this.shipGroup.add(mesh);
|
const icon = this.createTacticalIcon(this.shipColor(ship.role), 18);
|
||||||
this.selectableTargets.set(mesh, { kind: "ship", id: ship.id });
|
|
||||||
const position = this.toThreeVector(ship.position);
|
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, {
|
this.shipVisuals.set(ship.id, {
|
||||||
mesh,
|
mesh,
|
||||||
|
icon,
|
||||||
startPosition: position.clone(),
|
startPosition: position.clone(),
|
||||||
authoritativePosition: position.clone(),
|
authoritativePosition: position.clone(),
|
||||||
targetPosition: this.toThreeVector(ship.targetPosition),
|
targetPosition: this.toThreeVector(ship.targetPosition),
|
||||||
@@ -382,10 +440,6 @@ export class GameViewer {
|
|||||||
for (const node of nodes) {
|
for (const node of nodes) {
|
||||||
const mesh = this.nodeMeshes.get(node.id);
|
const mesh = this.nodeMeshes.get(node.id);
|
||||||
if (!mesh) {
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -398,10 +452,6 @@ export class GameViewer {
|
|||||||
for (const station of stations) {
|
for (const station of stations) {
|
||||||
const mesh = this.stationMeshes.get(station.id);
|
const mesh = this.stationMeshes.get(station.id);
|
||||||
if (!mesh) {
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -416,19 +466,6 @@ export class GameViewer {
|
|||||||
for (const ship of ships) {
|
for (const ship of ships) {
|
||||||
const visual = this.shipVisuals.get(ship.id);
|
const visual = this.shipVisuals.get(ship.id);
|
||||||
if (!visual) {
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -438,8 +475,7 @@ export class GameViewer {
|
|||||||
visual.velocity.copy(this.toThreeVector(ship.velocity));
|
visual.velocity.copy(this.toThreeVector(ship.velocity));
|
||||||
visual.receivedAtMs = performance.now();
|
visual.receivedAtMs = performance.now();
|
||||||
visual.blendDurationMs = Math.max(tickIntervalMs * 1.35, 100);
|
visual.blendDurationMs = Math.max(tickIntervalMs * 1.35, 100);
|
||||||
const material = visual.mesh.material as THREE.MeshStandardMaterial;
|
(visual.mesh.material as THREE.MeshStandardMaterial).color.set(this.shipColor(ship.role));
|
||||||
material.color.set(this.shipColor(ship.role));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -462,9 +498,10 @@ export class GameViewer {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!this.selected) {
|
if (this.selectedItems.length === 0) {
|
||||||
this.detailTitleEl.textContent = this.world.label;
|
this.detailTitleEl.textContent = this.world.label;
|
||||||
this.detailBodyEl.innerHTML = `
|
this.detailBodyEl.innerHTML = `
|
||||||
|
Zoom ${this.zoomLevel}<br>
|
||||||
Systems ${this.world.systems.size}<br>
|
Systems ${this.world.systems.size}<br>
|
||||||
Stations ${this.world.stations.size}<br>
|
Stations ${this.world.stations.size}<br>
|
||||||
Ships ${this.world.ships.size}<br>
|
Ships ${this.world.ships.size}<br>
|
||||||
@@ -473,8 +510,19 @@ export class GameViewer {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.selected.kind === "ship") {
|
if (this.selectedItems.length > 1) {
|
||||||
const ship = this.world.ships.get(this.selected.id);
|
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) {
|
if (!ship) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -489,8 +537,8 @@ export class GameViewer {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.selected.kind === "station") {
|
if (selected.kind === "station") {
|
||||||
const station = this.world.stations.get(this.selected.id);
|
const station = this.world.stations.get(selected.id);
|
||||||
if (!station) {
|
if (!station) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -503,8 +551,8 @@ export class GameViewer {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.selected.kind === "node") {
|
if (selected.kind === "node") {
|
||||||
const node = this.world.nodes.get(this.selected.id);
|
const node = this.world.nodes.get(selected.id);
|
||||||
if (!node) {
|
if (!node) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -516,7 +564,21 @@ export class GameViewer {
|
|||||||
return;
|
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) {
|
if (!system) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -529,13 +591,66 @@ export class GameViewer {
|
|||||||
|
|
||||||
private render() {
|
private render() {
|
||||||
const delta = Math.min(this.clock.getDelta(), 0.033);
|
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.updateCamera(delta);
|
||||||
this.camera.lookAt(this.focus);
|
|
||||||
this.updateShipPresentation();
|
this.updateShipPresentation();
|
||||||
this.updateNetworkPanel();
|
this.updateNetworkPanel();
|
||||||
|
this.applyZoomPresentation();
|
||||||
this.renderer.render(this.scene, this.camera);
|
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) {
|
private recordDeltaStats(delta: WorldDelta, rawBytes: number) {
|
||||||
const changedEntities = delta.ships.length + delta.stations.length + delta.nodes.length + delta.factions.length;
|
const changedEntities = delta.ships.length + delta.stations.length + delta.nodes.length + delta.factions.length;
|
||||||
this.networkStats.deltasReceived += 1;
|
this.networkStats.deltasReceived += 1;
|
||||||
@@ -544,10 +659,7 @@ export class GameViewer {
|
|||||||
this.networkStats.lastEntityChanges = changedEntities;
|
this.networkStats.lastEntityChanges = changedEntities;
|
||||||
this.networkStats.eventsReceived += delta.events.length;
|
this.networkStats.eventsReceived += delta.events.length;
|
||||||
this.networkStats.lastDeltaAtMs = performance.now();
|
this.networkStats.lastDeltaAtMs = performance.now();
|
||||||
this.networkStats.throughputSamples.push({
|
this.networkStats.throughputSamples.push({ atMs: performance.now(), bytes: rawBytes });
|
||||||
atMs: performance.now(),
|
|
||||||
bytes: rawBytes,
|
|
||||||
});
|
|
||||||
const cutoff = performance.now() - 4000;
|
const cutoff = performance.now() - 4000;
|
||||||
this.networkStats.throughputSamples = this.networkStats.throughputSamples.filter((sample) => sample.atMs >= cutoff);
|
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)
|
? ((now - this.networkStats.lastDeltaAtMs) / 1000).toFixed(1)
|
||||||
: "n/a";
|
: "n/a";
|
||||||
|
|
||||||
this.networkPanelEl.textContent = this.buildNetworkPanelText({
|
this.networkPanelEl.textContent = [
|
||||||
uptimeSeconds,
|
`snapshot: ${this.formatBytes(this.networkStats.snapshotBytes)}`,
|
||||||
kbPerSecond,
|
`stream: ${this.networkStats.streamConnected ? "live" : "offline"}`,
|
||||||
averageDeltaBytes,
|
`deltas: ${this.networkStats.deltasReceived}`,
|
||||||
secondsSinceLastDelta,
|
`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() {
|
private updateShipPresentation() {
|
||||||
@@ -589,11 +707,21 @@ export class GameViewer {
|
|||||||
visual.mesh.position.copy(visual.authoritativePosition).addScaledVector(visual.velocity, extrapolationSeconds);
|
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);
|
const desiredHeading = visual.targetPosition.clone().sub(visual.mesh.position);
|
||||||
if (desiredHeading.lengthSq() > 0.01) {
|
if (desiredHeading.lengthSq() > 0.01) {
|
||||||
visual.mesh.lookAt(visual.mesh.position.clone().add(desiredHeading));
|
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) {
|
private createNodeMesh(node: ResourceNodeSnapshot) {
|
||||||
@@ -627,6 +755,45 @@ export class GameViewer {
|
|||||||
return mesh;
|
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) {
|
private renderRecentEvents(entityKind: string, entityId: string) {
|
||||||
if (!this.world) {
|
if (!this.world) {
|
||||||
return "";
|
return "";
|
||||||
@@ -639,6 +806,35 @@ export class GameViewer {
|
|||||||
.join("<br>");
|
.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) {
|
private formatVector(vector: Vector3Dto) {
|
||||||
return `${vector.x.toFixed(1)}, ${vector.y.toFixed(1)}, ${vector.z.toFixed(1)}`;
|
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`;
|
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) {
|
private updateGamePanel(mode: string) {
|
||||||
const sequence = this.world?.sequence ?? 0;
|
const sequence = this.world?.sequence ?? 0;
|
||||||
const generatedAt = this.world?.generatedAtUtc
|
const generatedAt = this.world?.generatedAtUtc
|
||||||
@@ -680,6 +856,7 @@ export class GameViewer {
|
|||||||
: "n/a";
|
: "n/a";
|
||||||
this.statusEl.textContent = [
|
this.statusEl.textContent = [
|
||||||
`mode: ${mode}`,
|
`mode: ${mode}`,
|
||||||
|
`zoom: ${this.zoomLevel}`,
|
||||||
`sequence: ${sequence}`,
|
`sequence: ${sequence}`,
|
||||||
`snapshot: ${generatedAt}`,
|
`snapshot: ${generatedAt}`,
|
||||||
].join("\n");
|
].join("\n");
|
||||||
@@ -689,41 +866,100 @@ export class GameViewer {
|
|||||||
return new THREE.Vector3(vector.x, vector.y, vector.z);
|
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) => {
|
private onPointerDown = (event: PointerEvent) => {
|
||||||
this.dragging = true;
|
if (event.button === 1) {
|
||||||
this.lastPointer.set(event.clientX, event.clientY);
|
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) => {
|
private onPointerMove = (event: PointerEvent) => {
|
||||||
if (!this.dragging) {
|
if (this.dragPointerId !== event.pointerId || !this.dragMode) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const dx = event.clientX - this.lastPointer.x;
|
|
||||||
const dy = event.clientY - this.lastPointer.y;
|
const point = this.screenPointFromClient(event.clientX, event.clientY);
|
||||||
this.focus.x -= dx * 2.4;
|
if (this.dragMode === "orbit") {
|
||||||
this.focus.z += dy * 2.4;
|
const delta = point.clone().sub(this.dragLast);
|
||||||
this.lastPointer.set(event.clientX, event.clientY);
|
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 = () => {
|
private onPointerUp = (event: PointerEvent) => {
|
||||||
this.dragging = false;
|
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) => {
|
private onClick = (event: MouseEvent) => {
|
||||||
|
if (this.suppressClickSelection) {
|
||||||
|
this.suppressClickSelection = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const bounds = this.renderer.domElement.getBoundingClientRect();
|
const bounds = this.renderer.domElement.getBoundingClientRect();
|
||||||
this.mouse.x = ((event.clientX - bounds.left) / bounds.width) * 2 - 1;
|
this.mouse.x = ((event.clientX - bounds.left) / bounds.width) * 2 - 1;
|
||||||
this.mouse.y = -(((event.clientY - bounds.top) / bounds.height) * 2 - 1);
|
this.mouse.y = -(((event.clientY - bounds.top) / bounds.height) * 2 - 1);
|
||||||
this.raycaster.setFromCamera(this.mouse, this.camera);
|
this.raycaster.setFromCamera(this.mouse, this.camera);
|
||||||
const hit = this.raycaster.intersectObjects([...this.selectableTargets.keys()], false)[0];
|
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();
|
this.updatePanels();
|
||||||
};
|
};
|
||||||
|
|
||||||
private onDoubleClick = () => {
|
private onDoubleClick = () => {
|
||||||
if (!this.world || !this.selected) {
|
if (this.selectedItems.length !== 1) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const nextFocus = this.resolveSelectionPosition(this.selected);
|
const nextFocus = this.resolveSelectionPosition(this.selectedItems[0]);
|
||||||
if (nextFocus) {
|
if (nextFocus) {
|
||||||
this.focus.copy(nextFocus);
|
this.focus.copy(nextFocus);
|
||||||
}
|
}
|
||||||
@@ -731,10 +967,30 @@ export class GameViewer {
|
|||||||
|
|
||||||
private onWheel = (event: WheelEvent) => {
|
private onWheel = (event: WheelEvent) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const offset = this.camera.position.clone().sub(this.focus);
|
const direction = event.deltaY > 0 ? 1 : -1;
|
||||||
offset.multiplyScalar(event.deltaY > 0 ? 1.08 : 0.92);
|
const nextIndex = THREE.MathUtils.clamp(ZOOM_ORDER.indexOf(this.zoomLevel) + direction, 0, ZOOM_ORDER.length - 1);
|
||||||
offset.clampLength(500, 12000);
|
this.zoomLevel = ZOOM_ORDER[nextIndex];
|
||||||
this.camera.position.copy(this.focus).add(offset);
|
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) {
|
private resolveSelectionPosition(selection: Selectable) {
|
||||||
@@ -746,21 +1002,79 @@ export class GameViewer {
|
|||||||
const ship = this.world.ships.get(selection.id);
|
const ship = this.world.ships.get(selection.id);
|
||||||
return ship ? this.toThreeVector(ship.position) : undefined;
|
return ship ? this.toThreeVector(ship.position) : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selection.kind === "station") {
|
if (selection.kind === "station") {
|
||||||
const station = this.world.stations.get(selection.id);
|
const station = this.world.stations.get(selection.id);
|
||||||
return station ? this.toThreeVector(station.position) : undefined;
|
return station ? this.toThreeVector(station.position) : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selection.kind === "node") {
|
if (selection.kind === "node") {
|
||||||
const node = this.world.nodes.get(selection.id);
|
const node = this.world.nodes.get(selection.id);
|
||||||
return node ? this.toThreeVector(node.position) : undefined;
|
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);
|
const system = this.world.systems.get(selection.id);
|
||||||
return system ? this.toThreeVector(system.position) : undefined;
|
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) {
|
private shipSize(ship: ShipSnapshot) {
|
||||||
switch (ship.shipClass) {
|
switch (ship.shipClass) {
|
||||||
case "capital":
|
case "capital":
|
||||||
|
|||||||
@@ -37,6 +37,14 @@ canvas {
|
|||||||
pointer-events: none;
|
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,
|
.topbar,
|
||||||
.details-panel,
|
.details-panel,
|
||||||
.network-panel,
|
.network-panel,
|
||||||
|
|||||||
Reference in New Issue
Block a user