Split viewer and simulation into separate apps

This commit is contained in:
2026-03-12 17:18:29 -04:00
parent 0a76c60ab1
commit 2fb90162ef
45 changed files with 1982 additions and 6600 deletions

12
apps/viewer/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Space Game Viewer</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

1121
apps/viewer/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

19
apps/viewer/package.json Normal file
View File

@@ -0,0 +1,19 @@
{
"name": "space-game-viewer",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -p tsconfig.json && vite build",
"preview": "vite preview"
},
"dependencies": {
"three": "^0.179.1"
},
"devDependencies": {
"@types/three": "^0.183.1",
"typescript": "^5.9.2",
"vite": "^7.1.3"
}
}

View 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);
};
}

19
apps/viewer/src/api.ts Normal file
View File

@@ -0,0 +1,19 @@
import type { WorldSnapshot } from "./contracts";
export async function fetchWorldSnapshot(signal?: AbortSignal) {
const response = await fetch("/api/world", { signal });
if (!response.ok) {
throw new Error(`World request failed with ${response.status}`);
}
return response.json() as Promise<WorldSnapshot>;
}
export async function resetWorld() {
const response = await fetch("/api/world/reset", {
method: "POST",
});
if (!response.ok) {
throw new Error(`Reset request failed with ${response.status}`);
}
return response.json() as Promise<WorldSnapshot>;
}

View File

@@ -0,0 +1,85 @@
export interface WorldSnapshot {
label: string;
seed: number;
generatedAtUtc: string;
systems: SystemSnapshot[];
nodes: ResourceNodeSnapshot[];
stations: StationSnapshot[];
ships: ShipSnapshot[];
factions: FactionSnapshot[];
}
export interface Vector3Dto {
x: number;
y: number;
z: number;
}
export interface SystemSnapshot {
id: string;
label: string;
position: Vector3Dto;
starColor: string;
starSize: number;
planets: PlanetSnapshot[];
}
export interface PlanetSnapshot {
label: string;
orbitRadius: number;
size: number;
color: string;
hasRing: boolean;
}
export interface ResourceNodeSnapshot {
id: string;
systemId: string;
position: Vector3Dto;
oreRemaining: number;
maxOre: number;
itemId: string;
}
export interface StationSnapshot {
id: string;
label: string;
category: string;
systemId: string;
position: Vector3Dto;
color: string;
dockedShips: number;
oreStored: number;
refinedStock: number;
factionId: string;
}
export interface ShipSnapshot {
id: string;
label: string;
role: string;
shipClass: string;
systemId: string;
position: Vector3Dto;
state: string;
orderKind: string | null;
defaultBehaviorKind: string;
controllerTaskKind: string;
cargo: number;
cargoCapacity: number;
cargoItemId: string | null;
factionId: string;
health: number;
history: string[];
}
export interface FactionSnapshot {
id: string;
label: string;
color: string;
credits: number;
oreMined: number;
goodsProduced: number;
shipsBuilt: number;
shipsLost: number;
}

11
apps/viewer/src/main.ts Normal file
View File

@@ -0,0 +1,11 @@
import "./style.css";
import { GameViewer } from "./GameViewer";
const root = document.querySelector<HTMLDivElement>("#app");
if (!root) {
throw new Error("Missing #app root element");
}
const viewer = new GameViewer(root);
void viewer.start();

225
apps/viewer/src/style.css Normal file
View File

@@ -0,0 +1,225 @@
:root {
color-scheme: dark;
font-family: "Space Grotesk", "Segoe UI", sans-serif;
--bg: #050812;
--panel: rgba(9, 18, 34, 0.78);
--panel-border: rgba(132, 196, 255, 0.18);
--text: #eaf4ff;
--muted: #98adc4;
--accent: #7fd6ff;
--warning: #ffbf69;
}
* {
box-sizing: border-box;
}
html,
body,
#app {
margin: 0;
width: 100%;
height: 100%;
overflow: hidden;
background:
radial-gradient(circle at top, rgba(89, 132, 247, 0.16), transparent 30%),
radial-gradient(circle at 18% 42%, rgba(255, 136, 92, 0.14), transparent 24%),
linear-gradient(180deg, #03060d 0%, #060c18 100%);
}
canvas {
display: block;
}
.viewer-shell {
position: fixed;
inset: 0;
pointer-events: none;
}
.topbar,
.details-panel,
.faction-strip {
position: absolute;
backdrop-filter: blur(18px);
background: var(--panel);
border: 1px solid var(--panel-border);
box-shadow: 0 18px 54px rgba(0, 0, 0, 0.35);
}
.topbar {
top: 20px;
left: 20px;
right: 20px;
border-radius: 22px;
padding: 18px 20px;
display: flex;
justify-content: space-between;
align-items: center;
gap: 20px;
pointer-events: auto;
}
.eyebrow {
margin: 0 0 6px;
color: var(--accent);
letter-spacing: 0.18em;
font-size: 0.72rem;
text-transform: uppercase;
}
.topbar h1,
.details-panel h2,
.details-panel h3,
.faction-card h3 {
margin: 0;
}
.topbar h1 {
font-size: 1.2rem;
}
.topbar-actions {
display: flex;
align-items: center;
gap: 12px;
}
.status-pill,
.reset-button {
border-radius: 999px;
border: 1px solid rgba(127, 214, 255, 0.18);
background: rgba(12, 25, 46, 0.92);
color: var(--text);
padding: 11px 16px;
font: inherit;
}
.reset-button {
cursor: pointer;
pointer-events: auto;
transition: transform 120ms ease, border-color 120ms ease;
}
.reset-button:hover {
transform: translateY(-1px);
border-color: rgba(127, 214, 255, 0.42);
}
.details-panel {
top: 110px;
right: 20px;
width: min(380px, calc(100vw - 40px));
bottom: 20px;
border-radius: 24px;
padding: 18px;
color: var(--text);
pointer-events: auto;
overflow: auto;
}
.details-panel h2 {
color: var(--accent);
letter-spacing: 0.16em;
font-size: 0.72rem;
text-transform: uppercase;
}
.detail-title {
margin-top: 12px;
font-size: 1.05rem;
}
.detail-body {
margin-top: 12px;
color: var(--muted);
line-height: 1.55;
}
.detail-body p {
margin: 0 0 12px;
}
.history {
font-family: "IBM Plex Mono", "SFMono-Regular", monospace;
font-size: 0.78rem;
line-height: 1.6;
}
.error-strip {
margin-top: 14px;
padding: 12px 14px;
border-radius: 14px;
background: rgba(255, 116, 88, 0.14);
color: #ffd8cf;
}
.faction-strip {
left: 20px;
bottom: 20px;
width: min(920px, calc(100vw - 440px));
min-height: 110px;
border-radius: 24px;
padding: 16px;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 12px;
pointer-events: auto;
}
.faction-card {
border-radius: 18px;
border: 1px solid rgba(127, 214, 255, 0.14);
background: linear-gradient(180deg, rgba(11, 23, 43, 0.85), rgba(7, 15, 28, 0.9));
padding: 14px;
display: flex;
gap: 12px;
align-items: flex-start;
color: var(--text);
}
.faction-card p {
margin: 6px 0 0;
color: var(--muted);
line-height: 1.45;
}
.swatch {
width: 14px;
height: 48px;
border-radius: 999px;
flex: none;
}
@media (max-width: 1080px) {
.faction-strip {
right: 20px;
width: auto;
}
}
@media (max-width: 760px) {
.topbar {
flex-direction: column;
align-items: flex-start;
}
.details-panel {
position: absolute;
top: auto;
left: 20px;
right: 20px;
bottom: 148px;
width: auto;
max-height: 38vh;
}
.faction-strip {
left: 20px;
right: 20px;
bottom: 20px;
width: auto;
min-height: 100px;
grid-template-columns: 1fr;
}
}

15
apps/viewer/tsconfig.json Normal file
View File

@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"lib": ["ES2022", "DOM"],
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src", "vite.config.ts"]
}

View File

@@ -0,0 +1,18 @@
import { defineConfig } from "vite";
const root = new URL(".", import.meta.url).pathname;
export default defineConfig({
root,
server: {
host: true,
port: 5174,
proxy: {
"/api": "http://127.0.0.1:5079",
},
},
build: {
outDir: "../../dist/viewer",
emptyOutDir: true,
},
});