Split viewer and simulation into separate apps
This commit is contained in:
427
apps/viewer/src/GameViewer.ts
Normal file
427
apps/viewer/src/GameViewer.ts
Normal file
@@ -0,0 +1,427 @@
|
||||
import * as THREE from "three";
|
||||
import { fetchWorldSnapshot, resetWorld } from "./api";
|
||||
import type {
|
||||
FactionSnapshot,
|
||||
ResourceNodeSnapshot,
|
||||
ShipSnapshot,
|
||||
StationSnapshot,
|
||||
SystemSnapshot,
|
||||
WorldSnapshot,
|
||||
} from "./contracts";
|
||||
|
||||
type Selectable =
|
||||
| { kind: "ship"; id: string }
|
||||
| { kind: "station"; id: string }
|
||||
| { kind: "node"; id: string }
|
||||
| { kind: "system"; id: string };
|
||||
|
||||
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 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 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 statusEl: HTMLDivElement;
|
||||
private readonly detailTitleEl: HTMLHeadingElement;
|
||||
private readonly detailBodyEl: HTMLDivElement;
|
||||
private readonly factionStripEl: HTMLDivElement;
|
||||
private readonly resetButton: HTMLButtonElement;
|
||||
private readonly errorEl: HTMLDivElement;
|
||||
private snapshot?: WorldSnapshot;
|
||||
private selected?: Selectable;
|
||||
private dragging = false;
|
||||
private lastPointer = new THREE.Vector2();
|
||||
private worldSignature = "";
|
||||
|
||||
constructor(container: HTMLElement) {
|
||||
this.container = container;
|
||||
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||
this.renderer.outputColorSpace = THREE.SRGBColorSpace;
|
||||
|
||||
this.scene.background = new THREE.Color(0x040912);
|
||||
this.scene.fog = new THREE.FogExp2(0x040912, 0.00011);
|
||||
this.scene.add(new THREE.AmbientLight(0x90a6c0, 0.55));
|
||||
const keyLight = new THREE.DirectionalLight(0xdcecff, 1.3);
|
||||
keyLight.position.set(1000, 1200, 800);
|
||||
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 = `
|
||||
<header class="topbar">
|
||||
<div>
|
||||
<p class="eyebrow">Frontend Viewer</p>
|
||||
<h1>Space Game Observer</h1>
|
||||
</div>
|
||||
<div class="topbar-actions">
|
||||
<div class="status-pill">Connecting</div>
|
||||
<button type="button" class="reset-button">Reset World</button>
|
||||
</div>
|
||||
</header>
|
||||
<aside class="details-panel">
|
||||
<h2>Selection</h2>
|
||||
<h3 class="detail-title">Nothing selected</h3>
|
||||
<div class="detail-body">Click a star, station, node, or ship to inspect the server snapshot.</div>
|
||||
<div class="error-strip" hidden></div>
|
||||
</aside>
|
||||
<section class="faction-strip"></section>
|
||||
`;
|
||||
|
||||
this.statusEl = hud.querySelector(".status-pill") as HTMLDivElement;
|
||||
this.detailTitleEl = hud.querySelector(".detail-title") as HTMLHeadingElement;
|
||||
this.detailBodyEl = hud.querySelector(".detail-body") as HTMLDivElement;
|
||||
this.factionStripEl = hud.querySelector(".faction-strip") as HTMLDivElement;
|
||||
this.resetButton = hud.querySelector(".reset-button") as HTMLButtonElement;
|
||||
this.errorEl = hud.querySelector(".error-strip") as HTMLDivElement;
|
||||
|
||||
this.container.append(this.renderer.domElement, hud);
|
||||
|
||||
this.resetButton.addEventListener("click", () => void this.handleReset());
|
||||
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("click", this.onClick);
|
||||
this.renderer.domElement.addEventListener("dblclick", this.onDoubleClick);
|
||||
this.renderer.domElement.addEventListener("wheel", this.onWheel, { passive: false });
|
||||
window.addEventListener("resize", this.onResize);
|
||||
this.onResize();
|
||||
}
|
||||
|
||||
async start() {
|
||||
await this.refreshSnapshot();
|
||||
window.setInterval(() => {
|
||||
void this.refreshSnapshot();
|
||||
}, 500);
|
||||
this.renderer.setAnimationLoop(() => this.render());
|
||||
}
|
||||
|
||||
private async refreshSnapshot() {
|
||||
try {
|
||||
const snapshot = await fetchWorldSnapshot();
|
||||
this.snapshot = snapshot;
|
||||
this.statusEl.textContent = `Live ${new Date(snapshot.generatedAtUtc).toLocaleTimeString()}`;
|
||||
this.errorEl.hidden = true;
|
||||
this.applySnapshot(snapshot);
|
||||
this.updatePanels();
|
||||
} catch (error) {
|
||||
this.statusEl.textContent = "Backend offline";
|
||||
this.errorEl.hidden = false;
|
||||
this.errorEl.textContent = error instanceof Error ? error.message : "Unable to load the backend snapshot.";
|
||||
}
|
||||
}
|
||||
|
||||
private async handleReset() {
|
||||
this.resetButton.disabled = true;
|
||||
try {
|
||||
const snapshot = await resetWorld();
|
||||
this.snapshot = snapshot;
|
||||
this.applySnapshot(snapshot);
|
||||
this.updatePanels();
|
||||
} finally {
|
||||
this.resetButton.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
private applySnapshot(snapshot: WorldSnapshot) {
|
||||
const signature = `${snapshot.seed}|${snapshot.systems.length}`;
|
||||
if (signature !== this.worldSignature) {
|
||||
this.worldSignature = signature;
|
||||
this.rebuildSystems(snapshot.systems);
|
||||
}
|
||||
this.rebuildNodes(snapshot.nodes);
|
||||
this.rebuildStations(snapshot.stations);
|
||||
this.rebuildShips(snapshot.ships);
|
||||
this.rebuildFactions(snapshot.factions);
|
||||
}
|
||||
|
||||
private rebuildSystems(systems: SystemSnapshot[]) {
|
||||
this.systemGroup.clear();
|
||||
this.selectableTargets.clear();
|
||||
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 }),
|
||||
);
|
||||
const halo = new THREE.Mesh(
|
||||
new THREE.SphereGeometry(system.starSize * 1.65, 24, 24),
|
||||
new THREE.MeshBasicMaterial({
|
||||
color: system.starColor,
|
||||
transparent: true,
|
||||
opacity: 0.14,
|
||||
side: THREE.BackSide,
|
||||
}),
|
||||
);
|
||||
root.add(star, halo);
|
||||
this.selectableTargets.set(star, { kind: "system", id: system.id });
|
||||
this.selectableTargets.set(halo, { kind: "system", id: system.id });
|
||||
for (const planet of system.planets) {
|
||||
const orbit = new THREE.LineLoop(
|
||||
new THREE.BufferGeometry().setFromPoints(
|
||||
Array.from({ length: 80 }, (_, index) => {
|
||||
const angle = (index / 80) * Math.PI * 2;
|
||||
return new THREE.Vector3(
|
||||
Math.cos(angle) * planet.orbitRadius,
|
||||
0,
|
||||
Math.sin(angle) * planet.orbitRadius,
|
||||
);
|
||||
}),
|
||||
),
|
||||
new THREE.LineBasicMaterial({ color: 0x17314d, transparent: true, opacity: 0.45 }),
|
||||
);
|
||||
const planetMesh = new THREE.Mesh(
|
||||
new THREE.SphereGeometry(planet.size, 18, 18),
|
||||
new THREE.MeshStandardMaterial({
|
||||
color: planet.color,
|
||||
roughness: 0.92,
|
||||
metalness: 0.08,
|
||||
}),
|
||||
);
|
||||
planetMesh.position.set(planet.orbitRadius, 0, 0);
|
||||
root.add(orbit, planetMesh);
|
||||
}
|
||||
this.systemGroup.add(root);
|
||||
}
|
||||
}
|
||||
|
||||
private rebuildNodes(nodes: ResourceNodeSnapshot[]) {
|
||||
this.nodeGroup.clear();
|
||||
for (const node of nodes) {
|
||||
const mesh = new THREE.Mesh(
|
||||
new THREE.IcosahedronGeometry(12, 0),
|
||||
new THREE.MeshStandardMaterial({ color: 0xd2b07a, flatShading: true }),
|
||||
);
|
||||
mesh.position.set(node.position.x, node.position.y, node.position.z);
|
||||
mesh.scale.setScalar(0.8 + (node.oreRemaining / Math.max(node.maxOre, 1)) * 0.6);
|
||||
this.nodeGroup.add(mesh);
|
||||
this.selectableTargets.set(mesh, { kind: "node", id: node.id });
|
||||
}
|
||||
}
|
||||
|
||||
private rebuildStations(stations: StationSnapshot[]) {
|
||||
this.stationGroup.clear();
|
||||
for (const station of stations) {
|
||||
const mesh = new THREE.Mesh(
|
||||
new THREE.CylinderGeometry(24, 24, 18, 10),
|
||||
new THREE.MeshStandardMaterial({ color: station.color, emissive: new THREE.Color(station.color).multiplyScalar(0.1) }),
|
||||
);
|
||||
mesh.rotation.x = Math.PI / 2;
|
||||
mesh.position.set(station.position.x, station.position.y, station.position.z);
|
||||
this.stationGroup.add(mesh);
|
||||
this.selectableTargets.set(mesh, { kind: "station", id: station.id });
|
||||
}
|
||||
}
|
||||
|
||||
private rebuildShips(ships: ShipSnapshot[]) {
|
||||
this.shipGroup.clear();
|
||||
for (const ship of ships) {
|
||||
const geometry = new THREE.ConeGeometry(this.shipSize(ship), this.shipLength(ship), 7);
|
||||
geometry.rotateX(Math.PI / 2);
|
||||
const mesh = new THREE.Mesh(
|
||||
geometry,
|
||||
new THREE.MeshStandardMaterial({ color: this.shipColor(ship.role) }),
|
||||
);
|
||||
mesh.position.set(ship.position.x, ship.position.y, ship.position.z);
|
||||
this.shipGroup.add(mesh);
|
||||
this.selectableTargets.set(mesh, { kind: "ship", id: ship.id });
|
||||
}
|
||||
}
|
||||
|
||||
private rebuildFactions(factions: FactionSnapshot[]) {
|
||||
this.factionStripEl.innerHTML = factions
|
||||
.map((faction) => `
|
||||
<article class="faction-card">
|
||||
<div class="swatch" style="background:${faction.color}"></div>
|
||||
<div>
|
||||
<h3>${faction.label}</h3>
|
||||
<p>Credits ${faction.credits.toFixed(0)} · Ore ${faction.oreMined.toFixed(0)} · Goods ${faction.goodsProduced.toFixed(0)}</p>
|
||||
</div>
|
||||
</article>
|
||||
`)
|
||||
.join("");
|
||||
}
|
||||
|
||||
private updatePanels() {
|
||||
if (!this.snapshot) {
|
||||
return;
|
||||
}
|
||||
if (!this.selected) {
|
||||
this.detailTitleEl.textContent = this.snapshot.label;
|
||||
this.detailBodyEl.innerHTML = `Systems ${this.snapshot.systems.length}<br>Stations ${this.snapshot.stations.length}<br>Ships ${this.snapshot.ships.length}`;
|
||||
return;
|
||||
}
|
||||
|
||||
const selected = this.selected;
|
||||
if (selected.kind === "ship") {
|
||||
const ship = this.snapshot.ships.find((candidate) => candidate.id === selected.id);
|
||||
if (ship) {
|
||||
this.detailTitleEl.textContent = ship.label;
|
||||
this.detailBodyEl.innerHTML = `
|
||||
<p>${ship.shipClass} · ${ship.role} · ${ship.systemId}</p>
|
||||
<p>State ${ship.state}<br>Behavior ${ship.defaultBehaviorKind}<br>Task ${ship.controllerTaskKind}</p>
|
||||
<p>Cargo ${ship.cargo.toFixed(0)} / ${ship.cargoCapacity.toFixed(0)} ${ship.cargoItemId ?? ""}</p>
|
||||
<p class="history">${ship.history.join("<br>")}</p>
|
||||
`;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (selected.kind === "station") {
|
||||
const station = this.snapshot.stations.find((candidate) => candidate.id === selected.id);
|
||||
if (station) {
|
||||
this.detailTitleEl.textContent = station.label;
|
||||
this.detailBodyEl.innerHTML = `
|
||||
<p>${station.category} · ${station.systemId}</p>
|
||||
<p>Ore ${station.oreStored.toFixed(0)}<br>Refined ${station.refinedStock.toFixed(0)}<br>Docked ${station.dockedShips}</p>
|
||||
`;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (selected.kind === "node") {
|
||||
const node = this.snapshot.nodes.find((candidate) => candidate.id === selected.id);
|
||||
if (node) {
|
||||
this.detailTitleEl.textContent = `Node ${node.id}`;
|
||||
this.detailBodyEl.innerHTML = `
|
||||
<p>${node.systemId}</p>
|
||||
<p>${node.itemId} ${node.oreRemaining.toFixed(0)} / ${node.maxOre.toFixed(0)}</p>
|
||||
`;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const system = this.snapshot.systems.find((candidate) => candidate.id === selected.id);
|
||||
if (system) {
|
||||
this.detailTitleEl.textContent = system.label;
|
||||
this.detailBodyEl.innerHTML = `
|
||||
<p>${system.id}</p>
|
||||
<p>Planets ${system.planets.length}</p>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
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.renderer.render(this.scene, this.camera);
|
||||
}
|
||||
|
||||
private onPointerDown = (event: PointerEvent) => {
|
||||
this.dragging = true;
|
||||
this.lastPointer.set(event.clientX, event.clientY);
|
||||
};
|
||||
|
||||
private onPointerMove = (event: PointerEvent) => {
|
||||
if (!this.dragging) {
|
||||
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);
|
||||
};
|
||||
|
||||
private onPointerUp = () => {
|
||||
this.dragging = false;
|
||||
};
|
||||
|
||||
private onClick = (event: MouseEvent) => {
|
||||
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.updatePanels();
|
||||
};
|
||||
|
||||
private onDoubleClick = () => {
|
||||
if (!this.snapshot || !this.selected) {
|
||||
return;
|
||||
}
|
||||
const nextFocus = this.resolveSelectionPosition(this.selected);
|
||||
if (nextFocus) {
|
||||
this.focus.copy(nextFocus);
|
||||
}
|
||||
};
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
private resolveSelectionPosition(selection: Selectable) {
|
||||
if (!this.snapshot) {
|
||||
return undefined;
|
||||
}
|
||||
if (selection.kind === "ship") {
|
||||
const ship = this.snapshot.ships.find((candidate) => candidate.id === selection.id);
|
||||
return ship ? new THREE.Vector3(ship.position.x, ship.position.y, ship.position.z) : undefined;
|
||||
}
|
||||
if (selection.kind === "station") {
|
||||
const station = this.snapshot.stations.find((candidate) => candidate.id === selection.id);
|
||||
return station ? new THREE.Vector3(station.position.x, station.position.y, station.position.z) : undefined;
|
||||
}
|
||||
if (selection.kind === "node") {
|
||||
const node = this.snapshot.nodes.find((candidate) => candidate.id === selection.id);
|
||||
return node ? new THREE.Vector3(node.position.x, node.position.y, node.position.z) : undefined;
|
||||
}
|
||||
const system = this.snapshot.systems.find((candidate) => candidate.id === selection.id);
|
||||
return system ? new THREE.Vector3(system.position.x, system.position.y, system.position.z) : undefined;
|
||||
}
|
||||
|
||||
private shipSize(ship: ShipSnapshot) {
|
||||
switch (ship.shipClass) {
|
||||
case "capital":
|
||||
return 18;
|
||||
case "cruiser":
|
||||
return 13;
|
||||
case "destroyer":
|
||||
return 10;
|
||||
case "industrial":
|
||||
return 11;
|
||||
default:
|
||||
return 8;
|
||||
}
|
||||
}
|
||||
|
||||
private shipLength(ship: ShipSnapshot) {
|
||||
return this.shipSize(ship) * 2.6;
|
||||
}
|
||||
|
||||
private shipColor(role: ShipSnapshot["role"]) {
|
||||
if (role === "mining") {
|
||||
return "#ffcf6e";
|
||||
}
|
||||
if (role === "transport") {
|
||||
return "#9ff0aa";
|
||||
}
|
||||
return "#8bc0ff";
|
||||
}
|
||||
|
||||
private onResize = () => {
|
||||
const width = window.innerWidth;
|
||||
const height = window.innerHeight;
|
||||
this.camera.aspect = width / height;
|
||||
this.camera.updateProjectionMatrix();
|
||||
this.renderer.setSize(width, height);
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user