feat(viewer): add Vue-based HUD, ops strip, and history window

This commit is contained in:
2026-03-19 13:49:56 -04:00
parent 710addf1f5
commit 3ca568c05d
36 changed files with 2648 additions and 1017 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -5,15 +5,21 @@
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -p tsconfig.json && vite build",
"build": "vue-tsc -p tsconfig.json --noEmit && vite build",
"preview": "vite preview"
},
"dependencies": {
"three": "^0.179.1"
"pinia": "^3.0.3",
"three": "^0.179.1",
"vue": "^3.5.21"
},
"devDependencies": {
"@tailwindcss/vite": "^4.2.2",
"@types/three": "^0.183.1",
"@vitejs/plugin-vue": "^6.0.1",
"tailwindcss": "^4.2.2",
"typescript": "^5.9.2",
"vite": "^7.1.3"
"vite": "^7.1.3",
"vue-tsc": "^3.0.7"
}
}

197
apps/viewer/src/App.vue Normal file
View File

@@ -0,0 +1,197 @@
<script setup lang="ts">
import { storeToRefs } from "pinia";
import { nextTick, onBeforeUnmount, onMounted, ref } from "vue";
import { GameViewer } from "./GameViewer";
import CollapsibleHudPanel from "./components/CollapsibleHudPanel.vue";
import HtmlInfoPanel from "./components/HtmlInfoPanel.vue";
import ViewerHistoryLayer from "./components/ViewerHistoryLayer.vue";
import ViewerOpsStrip from "./components/ViewerOpsStrip.vue";
import { createViewerHudState } from "./viewerHudState";
import { useViewerSelectionStore } from "./ui/stores/viewerSelection";
import type { Selectable } from "./viewerTypes";
const canvasHostEl = ref<HTMLDivElement | null>(null);
const opsStripHostEl = ref<HTMLDivElement | null>(null);
const historyLayerHostEl = ref<HTMLDivElement | null>(null);
const marqueeEl = ref<HTMLDivElement | null>(null);
const hoverLabelEl = ref<HTMLDivElement | null>(null);
const hoverConnectorLineEl = ref<SVGLineElement | null>(null);
const hudState = createViewerHudState();
const selectionStore = useViewerSelectionStore();
const { selectedEntityId, selectedEntityLabel } = storeToRefs(selectionStore);
let viewer: GameViewer | undefined;
onMounted(async () => {
await nextTick();
if (
!canvasHostEl.value
|| !opsStripHostEl.value
|| !historyLayerHostEl.value
|| !marqueeEl.value
|| !hoverLabelEl.value
|| !hoverConnectorLineEl.value
) {
throw new Error("Viewer HUD mount failed");
}
viewer = new GameViewer(canvasHostEl.value, {
state: hudState,
selectionStore,
opsStripEl: opsStripHostEl.value,
historyLayerEl: historyLayerHostEl.value,
marqueeEl: marqueeEl.value,
hoverLabelEl: hoverLabelEl.value,
hoverConnectorLineEl: hoverConnectorLineEl.value,
});
void viewer.start();
});
onBeforeUnmount(() => {
viewer?.dispose();
});
function onHistoryWindowResize(id: string, width: number, height: number) {
const windowState = hudState.historyWindows.find((entry) => entry.id === id);
if (!windowState) {
return;
}
windowState.width = width;
windowState.height = height;
}
function onOpenHistory(selection: Selectable) {
viewer?.openHistoryWindow(selection);
}
function onFocusSelection(selection: Selectable, cameraMode?: "follow" | "tactical") {
viewer?.focusSelection(selection, cameraMode);
}
</script>
<template>
<div class="viewer-app">
<div
ref="canvasHostEl"
class="viewer-canvas-host"
/>
<div class="pointer-events-none fixed inset-0">
<div class="absolute left-5 top-5 flex w-[min(360px,calc(100vw-40px))] flex-col gap-4 max-[760px]:right-5 max-[760px]:w-auto">
<CollapsibleHudPanel
v-model:collapsed="hudState.gamePanel.collapsed"
class-name="topbar"
panel-name="game"
title="Game"
:summary="hudState.gamePanel.summary"
:body-text="hudState.gamePanel.bodyText"
/>
<CollapsibleHudPanel
v-model:collapsed="hudState.networkPanel.collapsed"
class-name="network-panel"
panel-name="network"
title="Network"
:summary="hudState.networkPanel.summary"
:body-text="hudState.networkPanel.bodyText"
/>
<CollapsibleHudPanel
v-model:collapsed="hudState.performancePanel.collapsed"
class-name="performance-panel"
panel-name="performance"
title="Performance"
:summary="hudState.performancePanel.summary"
:body-text="hudState.performancePanel.bodyText"
/>
</div>
<div class="absolute right-5 top-5 flex w-[min(380px,calc(100vw-40px))] flex-col gap-4 max-[760px]:bottom-[148px] max-[760px]:left-5 max-[760px]:right-5 max-[760px]:top-auto max-[760px]:max-h-[38vh] max-[760px]:w-auto max-[760px]:overflow-auto">
<HtmlInfoPanel
class-name="system-panel-section"
title="System"
:subtitle="hudState.systemPanel.title"
:body-html="hudState.systemPanel.bodyHtml"
:hidden="hudState.systemPanel.hidden"
subtitle-class="system-title"
body-class="system-body"
/>
<HtmlInfoPanel
class-name="detail-panel-section"
title="Focus"
:subtitle="hudState.detailPanel.title"
:body-html="hudState.detailPanel.bodyHtml"
:hidden="hudState.detailPanel.hidden"
subtitle-class="detail-title"
body-class="detail-body"
/>
<div
class="pointer-events-auto rounded-xl bg-[rgba(255,116,88,0.14)] px-3.5 py-3 text-[#ffd8cf]"
:hidden="hudState.error.hidden"
>
{{ hudState.error.message }}
</div>
<button
v-if="selectedEntityId"
type="button"
class="selection-action-button pointer-events-auto self-end rounded-full border border-white/10 bg-white/5 px-3.5 py-2.5 text-sm text-[color:var(--viewer-text)] transition hover:bg-white/10"
@click="selectionStore.clearSelection('ui')"
>
Clear {{ selectedEntityLabel ?? "Selection" }}
</button>
</div>
<div ref="historyLayerHostEl">
<ViewerHistoryLayer
:windows="hudState.historyWindows"
@resize="onHistoryWindowResize"
/>
</div>
<div ref="opsStripHostEl">
<ViewerOpsStrip
:state="hudState.opsStrip"
@history="onOpenHistory"
@focus="onFocusSelection"
/>
</div>
<div
ref="marqueeEl"
class="marquee-box"
:style="{
display: hudState.marquee.visible ? 'block' : 'none',
left: `${hudState.marquee.x}px`,
top: `${hudState.marquee.y}px`,
width: `${hudState.marquee.width}px`,
height: `${hudState.marquee.height}px`,
}"
/>
<svg
class="hover-connector-svg"
aria-hidden="true"
>
<line
ref="hoverConnectorLineEl"
class="hover-connector-line"
:x1="hudState.hoverLabel.x1"
:y1="hudState.hoverLabel.y1"
:x2="hudState.hoverLabel.x2"
:y2="hudState.hoverLabel.y2"
:hidden="hudState.hoverLabel.connectorHidden"
/>
</svg>
<div
ref="hoverLabelEl"
class="hover-label"
:hidden="hudState.hoverLabel.hidden"
:style="{
left: `${hudState.hoverLabel.x}px`,
top: `${hudState.hoverLabel.y}px`,
}"
>
{{ hudState.hoverLabel.text }}
</div>
</div>
</div>
</template>

View File

@@ -1,13 +1,27 @@
import type { ViewerHudBindings } from "./viewerHudState";
import type { Selectable, CameraMode } from "./viewerTypes";
import { ViewerAppController } from "./ViewerAppController";
export class GameViewer {
private readonly controller: ViewerAppController;
constructor(container: HTMLElement) {
this.controller = new ViewerAppController(container);
constructor(container: HTMLElement, hud: ViewerHudBindings) {
this.controller = new ViewerAppController(container, hud);
}
async start() {
await this.controller.start();
}
focusSelection(selection: Selectable, cameraMode?: CameraMode) {
this.controller.focusSelection(selection, cameraMode);
}
openHistoryWindow(selection: Selectable) {
this.controller.openHistoryWindow(selection);
}
dispose() {
this.controller.dispose();
}
}

View File

@@ -4,7 +4,6 @@ import {
MIN_CAMERA_DISTANCE,
NAV_DISTANCE,
} from "./viewerConstants";
import { createViewerHud } from "./viewerHud";
import { updatePanFromKeyboard } from "./viewerCamera";
import { setShellReticleOpacity } from "./viewerControls";
import { renderFrame, resizeViewer, stepCamera } from "./viewerRenderLoop";
@@ -21,16 +20,21 @@ import { ViewerNavigationController } from "./viewerNavigationController";
import { ViewerSceneDataController } from "./viewerSceneDataController";
import { ViewerPresentationController } from "./viewerPresentationController";
import { createViewerControllers, wireViewerEvents } from "./viewerControllerFactory";
import { createViewerRenderer } from "./runtime/rendering/createViewerRenderer";
import { disposeSceneResources } from "./runtime/rendering/disposeThreeResources";
import { ViewerRenderSurface } from "./runtime/rendering/ViewerRenderSurface";
import { toDisplayLocalPosition, getSystemCameraFocus } from "./viewerCamera";
import { UniverseLayer } from "./viewerUniverseLayer";
import { GalaxyLayer } from "./viewerGalaxyLayer";
import { SystemLayer } from "./viewerSystemLayer";
import { LocalLayer } from "./viewerLocalLayer";
import type { HistoryWindowState, ViewerHudBindings, ViewerHudState } from "./viewerHudState";
import { describeSelectable } from "./viewerSelection";
import { entityIdToSelectable, selectionToEntityId, type ViewerSelectionStore } from "./ui/stores/viewerSelection";
import type { FactionSnapshot } from "./contracts";
import type {
CameraMode,
DragMode,
HistoryWindowState,
NetworkStats,
PerformanceStats,
Selectable,
@@ -41,7 +45,8 @@ import type {
export class ViewerAppController {
private readonly container: HTMLElement;
private readonly renderer = new THREE.WebGLRenderer({ antialias: true });
private readonly renderer = createViewerRenderer();
private readonly renderSurface: ViewerRenderSurface;
// ── Three independent rendering layers ───────────────────────────────────
readonly universeLayer = new UniverseLayer();
@@ -61,23 +66,9 @@ export class ViewerAppController {
private readonly cameraOffset = new THREE.Vector3();
private readonly keyState = new Set<string>();
private readonly gamePanelEl: HTMLDivElement;
private readonly statusEl: HTMLDivElement;
private readonly gameSummaryEl: HTMLSpanElement;
private readonly systemPanelEl: HTMLDivElement;
private readonly systemTitleEl: HTMLHeadingElement;
private readonly systemBodyEl: HTMLDivElement;
private readonly detailTitleEl: HTMLHeadingElement;
private readonly detailBodyEl: HTMLDivElement;
readonly hudState: ViewerHudState;
readonly selectionStore: ViewerSelectionStore;
private readonly opsStripEl: HTMLDivElement;
private readonly networkSectionEl: HTMLDivElement;
private readonly networkSummaryEl: HTMLSpanElement;
private readonly networkPanelEl: HTMLDivElement;
private readonly performanceSectionEl: HTMLDivElement;
private readonly performanceSummaryEl: HTMLSpanElement;
private readonly performancePanelEl: HTMLDivElement;
private readonly errorEl: HTMLDivElement;
private readonly historyLayerEl: HTMLDivElement;
private readonly marqueeEl: HTMLDivElement;
private readonly hoverLabelEl: HTMLDivElement;
@@ -124,30 +115,14 @@ export class ViewerAppController {
private readonly navigationController: ViewerNavigationController;
private readonly sceneDataController: ViewerSceneDataController;
private readonly presentationController: ViewerPresentationController;
private readonly disposeEventBindings: () => void;
private readonly unsubscribeSelectionStore: () => void;
constructor(container: HTMLElement) {
constructor(container: HTMLElement, hud: ViewerHudBindings) {
this.container = container;
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
this.renderer.outputColorSpace = THREE.SRGBColorSpace;
const hud = createViewerHud(document);
this.gamePanelEl = hud.gamePanelEl;
this.statusEl = hud.statusEl;
this.gameSummaryEl = hud.gameSummaryEl;
this.networkSectionEl = hud.networkSectionEl;
this.systemPanelEl = hud.systemPanelEl;
this.systemTitleEl = hud.systemTitleEl;
this.systemBodyEl = hud.systemBodyEl;
this.detailTitleEl = hud.detailTitleEl;
this.detailBodyEl = hud.detailBodyEl;
this.hudState = hud.state;
this.selectionStore = hud.selectionStore;
this.opsStripEl = hud.opsStripEl;
this.networkSummaryEl = hud.networkSummaryEl;
this.networkPanelEl = hud.networkPanelEl;
this.performanceSectionEl = hud.performanceSectionEl;
this.performanceSummaryEl = hud.performanceSummaryEl;
this.performancePanelEl = hud.performancePanelEl;
this.errorEl = hud.errorEl;
this.historyLayerEl = hud.historyLayerEl;
this.marqueeEl = hud.marqueeEl;
this.hoverLabelEl = hud.hoverLabelEl;
@@ -160,33 +135,51 @@ export class ViewerAppController {
interactionController: this.interactionController,
} = createViewerControllers(this));
this.presentationController.initializeAmbience();
this.container.append(this.renderer.domElement, hud.root);
this.initializePanelToggles();
wireViewerEvents(this);
this.onResize();
this.unsubscribeSelectionStore = this.selectionStore.$subscribe((_mutation, state) => {
this.syncSelectionFromStore(state.selectedEntityKind, state.selectedEntityId);
});
this.renderSurface = new ViewerRenderSurface({
container: this.container,
renderer: this.renderer,
onFrame: () => this.render(),
onResize: (width, height) => this.onResize(width, height),
});
this.disposeEventBindings = wireViewerEvents(this);
this.updateCamera(0);
}
private initializePanelToggles() {
for (const panel of [this.gamePanelEl, this.networkSectionEl, this.performanceSectionEl]) {
const toggle = panel.querySelector(".panel-toggle");
if (!(toggle instanceof HTMLButtonElement)) {
continue;
}
toggle.addEventListener("click", () => {
const collapsed = panel.classList.toggle("is-collapsed");
toggle.textContent = collapsed ? "+" : "-";
toggle.setAttribute("aria-expanded", collapsed ? "false" : "true");
toggle.setAttribute("aria-label", `${collapsed ? "Expand" : "Collapse"} ${panel.dataset.panelName ?? "panel"}`);
});
}
}
async start() {
this.selectionStore.clearSelection();
await this.worldLifecycle.bootstrapWorld();
this.renderer.setAnimationLoop(() => this.render());
this.renderSurface.start();
}
dispose() {
this.disposeEventBindings();
this.unsubscribeSelectionStore();
this.stream?.close();
this.renderSurface.dispose();
disposeSceneResources(this.universeLayer.scene);
disposeSceneResources(this.galaxyLayer.scene);
disposeSceneResources(this.systemLayer.scene);
disposeSceneResources(this.localLayer.scene);
}
focusSelection(selection: Selectable, cameraMode?: CameraMode) {
this.applySelectedItems([selection], "ui");
this.navigationController.focusOnSelection(selection);
if (cameraMode) {
this.interactionController.toggleCameraMode(cameraMode);
if (selection.kind === "ship" && cameraMode === "follow") {
this.desiredDistance = 0.00018;
}
}
this.updatePanels();
this.updateGamePanel("Live");
}
openHistoryWindow(selection: Selectable) {
this.interactionController.openHistoryWindow(selection);
}
private refreshStreamScopeIfNeeded() {
@@ -214,6 +207,32 @@ export class ViewerAppController {
this.worldLifecycle.updatePanels();
}
private applySelectedItems(items: Selectable[], source: "viewer" | "ui") {
this.selectedItems = items;
if (items.length === 1) {
const selection = items[0];
this.selectionStore.selectSelection({
id: selectionToEntityId(selection),
kind: selection.kind,
label: describeSelectable(this.world, selection),
}, source);
return;
}
this.selectionStore.clearSelection(source);
}
private syncSelectionFromStore(
kind: Selectable["kind"] | null,
entityId: string | null,
) {
const selection = entityIdToSelectable(kind, entityId);
this.selectedItems = selection ? [selection] : [];
this.navigationController.syncFollowStateFromSelection();
this.updatePanels();
this.updateGamePanel("Live");
}
private render() {
renderFrame({
clock: this.clock,
@@ -324,14 +343,15 @@ export class ViewerAppController {
return resolveFocusedCelestialId(this.world, this.selectedItems);
}
private onResize = () => {
private onResize(width: number, height: number) {
resizeViewer({
renderer: this.renderer,
galaxyLayer: this.galaxyLayer,
systemLayer: this.systemLayer,
localLayer: this.localLayer,
width,
height,
});
};
}
private setShellReticleOpacity(sprite: SystemVisual["shellReticle"], opacity: number) {
setShellReticleOpacity(sprite, opacity);

View File

@@ -0,0 +1,40 @@
<script setup lang="ts">
const collapsed = defineModel<boolean>("collapsed", { required: true });
defineProps<{
title: string;
summary: string;
bodyText: string;
className?: string;
panelName: string;
}>();
</script>
<template>
<section :class="[
'pointer-events-auto rounded-3xl border border-(--viewer-panel-border) bg-(--viewer-panel) p-4 text-(--viewer-text) shadow-[0_18px_54px_rgba(0,0,0,0.35)] backdrop-blur-xl',
'collapsible-panel',
className,
collapsed && 'is-collapsed',
]" :data-panel-name="panelName">
<div class="flex items-center justify-between gap-3">
<h2 class="m-0 text-[0.64rem] leading-none tracking-[0.16em] text-(--viewer-accent) uppercase">
{{ title }}
</h2>
<div class="flex min-w-0 items-center gap-2.5">
<span class="panel-summary hud-mono hidden text-right text-[0.72rem] leading-none text-(--viewer-muted)">
{{ summary }}
</span>
<button type="button"
class="h-7 w-7 cursor-pointer rounded-full border border-white/10 bg-white/5 text-sm text-(--viewer-text) transition hover:bg-white/10"
:aria-expanded="!collapsed" :aria-label="`${collapsed ? 'Expand' : 'Collapse'} ${title} panel`"
@click="collapsed = !collapsed">
{{ collapsed ? "+" : "-" }}
</button>
</div>
</div>
<div :class="`${panelName}-body hud-mono mt-3.5 text-[0.8rem] leading-6 text-(--viewer-muted) whitespace-pre-wrap`">
{{ bodyText }}
</div>
</section>
</template>

View File

@@ -0,0 +1,25 @@
<script setup lang="ts">
defineProps<{
title: string;
subtitle: string;
bodyHtml: string;
hidden?: boolean;
className?: string;
subtitleClass?: string;
bodyClass?: string;
}>();
</script>
<template>
<aside v-if="!hidden" :class="[
'pointer-events-auto rounded-3xl border border-(--viewer-panel-border) bg-(--viewer-panel) p-4 text-(--viewer-text) shadow-[0_18px_54px_rgba(0,0,0,0.35)] backdrop-blur-xl',
'info-panel',
className,
]">
<h2 class="m-0 text-[0.72rem] tracking-[0.16em] text-(--viewer-accent) uppercase">
{{ title }}
</h2>
<h3 :class="subtitleClass">{{ subtitle }}</h3>
<div :class="bodyClass" v-html="bodyHtml" />
</aside>
</template>

View File

@@ -0,0 +1,88 @@
<script setup lang="ts">
import { nextTick, onBeforeUnmount, onMounted, useTemplateRef, watch } from "vue";
import type { HistoryWindowState } from "../viewerHudState";
const props = defineProps<{
windows: HistoryWindowState[];
}>();
const emit = defineEmits<{
resize: [id: string, width: number, height: number];
}>();
const windowRefs = useTemplateRef<HTMLElement[]>("historyWindows");
let resizeObserver: ResizeObserver | undefined;
onMounted(() => {
resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
const id = (entry.target as HTMLElement).dataset.historyWindowId;
if (!id) {
continue;
}
emit("resize", id, entry.contentRect.width, entry.contentRect.height);
}
});
for (const element of windowRefs.value ?? []) {
resizeObserver.observe(element);
}
});
watch(
() => props.windows.map((windowState) => `${windowState.id}:${windowState.width}:${windowState.height}`).join("|"),
async () => {
await nextTick();
resizeObserver?.disconnect();
for (const element of windowRefs.value ?? []) {
resizeObserver?.observe(element);
}
},
);
onBeforeUnmount(() => {
resizeObserver?.disconnect();
});
</script>
<template>
<div class="history-layer">
<aside
v-for="windowState in props.windows"
:key="windowState.id"
ref="historyWindows"
class="history-window"
:data-history-window-id="windowState.id"
:style="{
left: `${windowState.x}px`,
top: `${windowState.y}px`,
width: `${windowState.width}px`,
height: `${windowState.height}px`,
zIndex: windowState.zIndex.toString(),
}"
>
<div class="history-window-header">
<h2 class="history-window-title">{{ windowState.title }}</h2>
<div class="history-window-actions">
<button
type="button"
class="history-window-copy"
>
{{ windowState.copyLabel }}
</button>
<button
type="button"
class="history-window-close"
>
Close
</button>
</div>
</div>
<div
class="history-window-body"
v-html="windowState.bodyHtml"
/>
</aside>
</div>
</template>

View File

@@ -0,0 +1,184 @@
<script setup lang="ts">
import { storeToRefs } from "pinia";
import type { OpsStripState } from "../viewerHudState";
import { useViewerSelectionStore } from "../ui/stores/viewerSelection";
import type { Selectable } from "../viewerTypes";
defineProps<{
state: OpsStripState;
}>();
const emit = defineEmits<{
history: [selection: Selectable];
focus: [selection: Selectable, cameraMode?: "follow" | "tactical"];
}>();
const selectionStore = useViewerSelectionStore();
const { selectedEntityId, selectedEntityKind } = storeToRefs(selectionStore);
function isSelected(kind: Selectable["kind"], id: string) {
return selectedEntityKind.value === kind && selectedEntityId.value === id;
}
function onStationClick(id: string, label: string) {
selectionStore.selectSelection({ id, kind: "station", label }, "ui");
}
function onStationDoubleClick(id: string, label: string) {
selectionStore.selectSelection({ id, kind: "station", label }, "ui");
emit("focus", { kind: "station", id }, "tactical");
}
function onShipClick(id: string, label: string) {
selectionStore.selectSelection({ id, kind: "ship", label }, "ui");
}
function onShipDoubleClick(id: string, label: string) {
selectionStore.selectSelection({ id, kind: "ship", label }, "ui");
emit("focus", { kind: "ship", id }, "follow");
}
</script>
<template>
<section class="ops-strip">
<article
v-for="faction in state.factions"
:key="faction.id"
class="ship-card faction-card"
:data-faction-id="faction.id"
>
<div class="ship-card-header">
<h3>{{ faction.label }}</h3>
<span class="ship-card-badge">faction</span>
</div>
<div
v-if="faction.stateLines.length > 0"
class="ship-card-ai"
>
<p class="ship-card-section-title">GOAP State</p>
<p
v-for="line in faction.stateLines"
:key="line"
>
{{ line }}
</p>
</div>
<div
v-if="faction.priorities.length > 0"
class="ship-card-ai"
>
<p class="ship-card-section-title">Priorities</p>
<p
v-for="priority in faction.priorities"
:key="`${faction.id}-${priority.label}`"
class="ship-card-split-line"
>
<span>{{ priority.label }}</span>
<span>{{ priority.value }}</span>
</p>
</div>
</article>
<article
v-for="station in state.stations"
:key="station.id"
:class="['ship-card', 'station-card', isSelected('station', station.id) && 'is-selected']"
:data-station-id="station.id"
@click="onStationClick(station.id, station.label)"
@dblclick="onStationDoubleClick(station.id, station.label)"
>
<div class="ship-card-header">
<h3>{{ station.label }}</h3>
<span class="ship-card-badge">{{ station.badge }}</span>
</div>
<p
v-for="line in station.lines"
:key="`${station.id}-${line}`"
>
{{ line }}
</p>
<div
v-if="station.processes.length > 0"
class="ship-card-ai"
>
<div
v-for="process in station.processes"
:key="`${station.id}-${process.label}`"
class="ship-action-progress"
>
<div class="ship-action-progress-label">
<span>{{ process.label }}</span>
<span>{{ process.valueLabel }}</span>
</div>
<div class="ship-action-progress-track">
<div
class="ship-action-progress-fill"
:style="{ width: `${process.progress}%` }"
/>
</div>
</div>
</div>
</article>
<article
v-for="ship in state.ships"
:key="ship.id"
:class="['ship-card', isSelected('ship', ship.id) && 'is-selected', ship.followed && 'is-followed']"
:data-ship-id="ship.id"
@click="onShipClick(ship.id, ship.label)"
@dblclick="onShipDoubleClick(ship.id, ship.label)"
>
<div class="ship-card-header">
<h3>{{ ship.label }}</h3>
<div class="ship-card-meta">
<span class="ship-card-badge">{{ ship.badge }}</span>
<button
type="button"
class="ship-card-history-button"
:data-history-ship-id="ship.id"
:aria-label="`Open history for ${ship.label}`"
title="Open history"
@click.stop="emit('history', { kind: 'ship', id: ship.id })"
>
&#128340;
</button>
</div>
</div>
<p
v-for="line in ship.locationLines"
:key="`${ship.id}-${line}`"
>
{{ line }}
</p>
<p
v-for="line in ship.lines"
:key="`${ship.id}-${line}`"
>
{{ line }}
</p>
<div
v-if="ship.action"
class="ship-action-progress"
>
<div class="ship-action-progress-label">
<span>{{ ship.action.label }}</span>
<span>{{ ship.action.valueLabel }}</span>
</div>
<div class="ship-action-progress-track">
<div
class="ship-action-progress-fill"
:style="{ width: `${ship.action.progress}%` }"
/>
</div>
</div>
<div class="ship-card-ai">
<p
v-for="line in ship.aiLines"
:key="`${ship.id}-${line}`"
>
{{ line }}
</p>
</div>
</article>
</section>
</template>

8
apps/viewer/src/env.d.ts vendored Normal file
View File

@@ -0,0 +1,8 @@
/// <reference types="vite/client" />
declare module "*.vue" {
import type { DefineComponent } from "vue";
const component: DefineComponent<Record<string, never>, Record<string, never>, unknown>;
export default component;
}

View File

@@ -1,5 +1,7 @@
import "./style.css";
import { GameViewer } from "./GameViewer";
import "./styles/index.css";
import { createApp } from "vue";
import App from "./App.vue";
import { viewerPinia } from "./ui/stores/pinia";
const root = document.querySelector<HTMLDivElement>("#app");
@@ -7,5 +9,6 @@ if (!root) {
throw new Error("Missing #app root element");
}
const viewer = new GameViewer(root);
void viewer.start();
createApp(App)
.use(viewerPinia)
.mount(root);

View File

@@ -0,0 +1,52 @@
import * as THREE from "three";
interface ViewerRenderSurfaceOptions {
container: HTMLElement;
renderer: THREE.WebGLRenderer;
onFrame: () => void;
onResize: (width: number, height: number) => void;
}
export class ViewerRenderSurface {
private readonly container: HTMLElement;
readonly renderer: THREE.WebGLRenderer;
private readonly onFrame: () => void;
private readonly onResizeCallback: (width: number, height: number) => void;
private readonly resizeListener = () => this.resize();
constructor(options: ViewerRenderSurfaceOptions) {
this.container = options.container;
this.renderer = options.renderer;
this.onFrame = options.onFrame;
this.onResizeCallback = options.onResize;
this.container.append(this.renderer.domElement);
window.addEventListener("resize", this.resizeListener);
this.resize();
}
get domElement() {
return this.renderer.domElement;
}
start() {
this.renderer.setAnimationLoop(this.onFrame);
}
stop() {
this.renderer.setAnimationLoop(null);
}
resize() {
const width = this.container.clientWidth || window.innerWidth;
const height = this.container.clientHeight || window.innerHeight;
this.renderer.setSize(width, height, false);
this.onResizeCallback(width, height);
}
dispose() {
this.stop();
window.removeEventListener("resize", this.resizeListener);
this.renderer.dispose();
this.renderer.domElement.remove();
}
}

View File

@@ -0,0 +1,9 @@
import * as THREE from "three";
export function createViewerRenderer() {
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.setClearColor(0x040912, 1);
return renderer;
}

View File

@@ -0,0 +1,35 @@
import * as THREE from "three";
function disposeMaterialTextures(material: THREE.Material, disposedTextures: Set<THREE.Texture>) {
for (const value of Object.values(material)) {
if (value instanceof THREE.Texture && !disposedTextures.has(value)) {
disposedTextures.add(value);
value.dispose();
}
}
}
export function disposeSceneResources(root: THREE.Object3D) {
const disposedGeometries = new Set<THREE.BufferGeometry>();
const disposedMaterials = new Set<THREE.Material>();
const disposedTextures = new Set<THREE.Texture>();
root.traverse((object) => {
const mesh = object as THREE.Mesh;
const geometry = mesh.geometry;
if (geometry instanceof THREE.BufferGeometry && !disposedGeometries.has(geometry)) {
disposedGeometries.add(geometry);
geometry.dispose();
}
const material = mesh.material;
const materials = Array.isArray(material) ? material : material ? [material] : [];
for (const candidate of materials) {
if (candidate instanceof THREE.Material && !disposedMaterials.has(candidate)) {
disposedMaterials.add(candidate);
disposeMaterialTextures(candidate, disposedTextures);
candidate.dispose();
}
}
});
}

View File

@@ -0,0 +1,2 @@
@import "tailwindcss";
@import "./viewer.css";

View File

@@ -1,13 +1,12 @@
: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;
--viewer-panel: rgba(9, 18, 34, 0.78);
--viewer-panel-border: rgba(132, 196, 255, 0.18);
--viewer-text: #eaf4ff;
--viewer-muted: #98adc4;
--viewer-accent: #7fd6ff;
--viewer-warning: #ffbf69;
}
* {
@@ -27,39 +26,95 @@ body,
linear-gradient(180deg, #03060d 0%, #060c18 100%);
}
body {
color: var(--viewer-text);
}
canvas {
display: block;
}
.viewer-shell {
position: fixed;
inset: 0;
pointer-events: none;
.viewer-app,
.viewer-canvas-host {
width: 100%;
height: 100%;
}
.left-panel-stack {
position: absolute;
top: 20px;
left: 20px;
width: min(360px, calc(100vw - 40px));
display: flex;
flex-direction: column;
gap: 16px;
.panel-summary,
.hud-mono,
.system-body,
.detail-body,
.ship-card p,
.history,
.history-window-body,
.hover-label {
font-family: "IBM Plex Mono", "SFMono-Regular", monospace;
}
.right-panel-stack {
position: absolute;
top: 20px;
right: 20px;
width: min(380px, calc(100vw - 40px));
.collapsible-panel.is-collapsed .game-body,
.collapsible-panel.is-collapsed .network-body,
.collapsible-panel.is-collapsed .performance-body {
display: none;
}
.collapsible-panel.is-collapsed .panel-summary {
display: inline-block;
}
.system-title,
.detail-title {
margin: 12px 0 0;
font-size: 1.05rem;
}
.system-body,
.detail-body {
margin-top: 12px;
color: var(--viewer-muted);
line-height: 1.55;
}
.system-body p,
.detail-body p {
margin: 0 0 12px;
}
.detail-progress,
.ship-action-progress {
margin: 0 0 3px;
}
.detail-progress-label,
.ship-action-progress-label {
display: flex;
flex-direction: column;
gap: 16px;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 6px;
color: var(--viewer-muted);
font-family: "IBM Plex Mono", "SFMono-Regular", monospace;
font-size: 0.72rem;
line-height: 1;
}
.detail-progress-track,
.ship-action-progress-track {
height: 6px;
border-radius: 999px;
overflow: hidden;
background: rgba(127, 214, 255, 0.12);
border: 1px solid rgba(127, 214, 255, 0.14);
}
.detail-progress-fill,
.ship-action-progress-fill {
height: 100%;
border-radius: 999px;
background: linear-gradient(90deg, rgba(127, 214, 255, 0.72), rgba(255, 191, 105, 0.9));
}
.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);
@@ -91,7 +146,6 @@ canvas {
background: rgba(7, 15, 28, 0.88);
border: 1px solid rgba(255, 88, 72, 0.5);
color: #fff2ef;
font-family: "IBM Plex Mono", "SFMono-Regular", monospace;
font-size: 0.75rem;
line-height: 1.35;
white-space: pre-line;
@@ -102,235 +156,6 @@ canvas {
display: none;
}
.topbar,
.info-panel,
.network-panel,
.performance-panel,
.ops-strip {
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 {
border-radius: 22px;
padding: 14px 16px;
pointer-events: auto;
}
.eyebrow {
margin: 0 0 6px;
color: var(--accent);
letter-spacing: 0.18em;
font-size: 0.72rem;
text-transform: uppercase;
}
.topbar h1,
.topbar h2,
.info-panel h2,
.info-panel h3,
.ship-card h3 {
margin: 0;
}
.topbar {
display: block;
align-items: start;
}
.topbar h2 {
color: var(--accent);
letter-spacing: 0.16em;
font-size: 0.64rem;
text-transform: uppercase;
line-height: 1;
}
.panel-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.panel-heading-meta {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.panel-summary {
display: none;
color: var(--muted);
font-family: "IBM Plex Mono", "SFMono-Regular", monospace;
font-size: 0.72rem;
line-height: 1;
text-align: right;
white-space: nowrap;
}
.panel-toggle {
border: 1px solid rgba(127, 214, 255, 0.2);
background: rgba(127, 214, 255, 0.08);
color: var(--text);
border-radius: 999px;
width: 28px;
height: 28px;
cursor: pointer;
font: inherit;
}
.panel-toggle:hover {
background: rgba(127, 214, 255, 0.16);
}
.topbar-body {
margin-top: 14px;
color: var(--muted);
font-family: "IBM Plex Mono", "SFMono-Regular", monospace;
font-size: 0.8rem;
line-height: 1.6;
white-space: pre-wrap;
}
.info-panel {
border-radius: 24px;
padding: 16px;
color: var(--text);
pointer-events: auto;
overflow: auto;
}
.network-panel {
border-radius: 24px;
padding: 14px 16px;
color: var(--text);
pointer-events: auto;
}
.performance-panel {
width: min(360px, calc(100vw - 40px));
border-radius: 24px;
padding: 14px 16px;
color: var(--text);
pointer-events: auto;
}
.info-panel h2 {
color: var(--accent);
letter-spacing: 0.16em;
font-size: 0.72rem;
text-transform: uppercase;
}
.network-panel h2,
.performance-panel h2 {
margin: 0;
color: var(--accent);
letter-spacing: 0.16em;
font-size: 0.64rem;
line-height: 1;
text-transform: uppercase;
}
.network-body,
.performance-body {
margin-top: 14px;
color: var(--muted);
font-family: "IBM Plex Mono", "SFMono-Regular", monospace;
font-size: 0.8rem;
line-height: 1.6;
white-space: pre-wrap;
}
.collapsible-panel.is-collapsed .topbar-body,
.collapsible-panel.is-collapsed .network-body,
.collapsible-panel.is-collapsed .performance-body {
display: none;
}
.collapsible-panel.is-collapsed .panel-summary {
display: inline-block;
}
.collapsible-panel.is-collapsed {
padding-bottom: 12px;
}
.detail-title {
margin-top: 12px;
font-size: 1.05rem;
}
.system-title {
margin-top: 12px;
font-size: 1.05rem;
}
.system-body,
.detail-body {
margin-top: 12px;
color: var(--muted);
line-height: 1.55;
}
.system-body p,
.detail-body p {
margin: 0 0 12px;
}
.detail-progress,
.ship-action-progress {
margin: 0 0 3px;
}
.detail-progress-label,
.ship-action-progress-label {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 6px;
color: var(--muted);
font-family: "IBM Plex Mono", "SFMono-Regular", monospace;
font-size: 0.72rem;
line-height: 1;
}
.module-item {
display: block;
padding-left: 1em;
color: var(--muted);
font-family: "IBM Plex Mono", "SFMono-Regular", monospace;
font-size: 0.72rem;
line-height: 1.5;
}
.detail-progress-track,
.ship-action-progress-track {
height: 6px;
border-radius: 999px;
overflow: hidden;
background: rgba(127, 214, 255, 0.12);
border: 1px solid rgba(127, 214, 255, 0.14);
}
.detail-progress-fill,
.ship-action-progress-fill {
height: 100%;
border-radius: 999px;
background: linear-gradient(90deg, rgba(127, 214, 255, 0.72), rgba(255, 191, 105, 0.9));
}
.history {
font-family: "IBM Plex Mono", "SFMono-Regular", monospace;
font-size: 0.78rem;
line-height: 1.6;
}
.history-window {
position: absolute;
right: auto;
@@ -353,10 +178,6 @@ canvas {
resize: both;
}
.history-window[hidden] {
display: none;
}
.history-window-header {
display: flex;
justify-content: space-between;
@@ -369,41 +190,16 @@ canvas {
.history-window-title {
margin: 0;
color: var(--accent);
color: var(--viewer-accent);
font-size: 0.8rem;
letter-spacing: 0.16em;
text-transform: uppercase;
}
.history-window-close,
.ship-card-history-button {
border: 1px solid rgba(127, 214, 255, 0.22);
border-radius: 999px;
background: rgba(127, 214, 255, 0.08);
color: var(--text);
font: inherit;
cursor: pointer;
}
.history-window-actions {
display: flex;
align-items: center;
gap: 8px;
}
.history-window-close {
padding: 8px 12px;
}
.history-window-copy {
padding: 8px 12px;
}
.history-window-body {
overflow: auto;
padding: 16px;
color: var(--text);
font-family: "IBM Plex Mono", "SFMono-Regular", monospace;
color: var(--viewer-text);
font-size: 0.78rem;
line-height: 1.6;
white-space: pre-wrap;
@@ -411,34 +207,10 @@ canvas {
cursor: text;
}
.error-strip {
border-radius: 14px;
padding: 12px 14px;
background: rgba(255, 116, 88, 0.14);
color: #ffd8cf;
pointer-events: auto;
}
.right-panel-stack .error-strip {
margin-top: -4px;
}
.system-panel-section[hidden] {
display: none;
}
.detail-panel-section[hidden] {
display: none;
}
.error-strip[hidden] {
display: none;
}
.history-layer {
position: absolute;
inset: 0;
pointer-events: none;
.history-window-actions {
display: flex;
align-items: center;
gap: 8px;
}
.ops-strip {
@@ -448,8 +220,6 @@ canvas {
bottom: 0;
width: 50vw;
min-height: 128px;
border-radius: 0;
padding: 0;
display: flex;
align-items: stretch;
gap: 0;
@@ -461,7 +231,6 @@ canvas {
}
.ship-card {
border-radius: 0;
border-top: 1px solid rgba(127, 214, 255, 0.14);
border-right: 1px solid rgba(127, 214, 255, 0.1);
background: linear-gradient(180deg, rgba(10, 20, 36, 0.96), rgba(6, 12, 22, 0.98));
@@ -471,7 +240,7 @@ canvas {
display: flex;
flex-direction: column;
gap: 6px;
color: var(--text);
color: var(--viewer-text);
cursor: pointer;
transition: transform 140ms ease, border-color 140ms ease, box-shadow 140ms ease, background 140ms ease;
}
@@ -499,11 +268,26 @@ canvas {
}
.ship-card h3 {
margin: 0;
font-size: 0.82rem;
line-height: 1.15;
letter-spacing: 0.04em;
}
.ship-card p {
margin: 2px 0 0;
color: var(--viewer-muted);
line-height: 1.35;
font-size: 0.72rem;
white-space: pre-line;
}
.ship-card-header + p {
font-size: 0.62rem;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.ship-card-meta {
display: flex;
flex-direction: column;
@@ -515,30 +299,12 @@ canvas {
padding: 3px 8px;
border-radius: 999px;
background: rgba(127, 214, 255, 0.12);
color: var(--accent);
color: var(--viewer-accent);
font-size: 0.64rem;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.ship-card p {
margin: 2px 0 0;
color: var(--muted);
line-height: 1.35;
font-family: "IBM Plex Mono", "SFMono-Regular", monospace;
font-size: 0.72rem;
}
.ship-card-header+p {
font-size: 0.62rem;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.ship-action-progress {
margin-top: 2px;
}
.ship-card-ai {
margin-top: 2px;
padding-top: 6px;
@@ -547,11 +313,22 @@ canvas {
.ship-card-section-title {
margin: 0;
color: var(--accent);
color: var(--viewer-accent);
letter-spacing: 0.14em;
text-transform: uppercase;
}
.ship-card-history-button,
.history-window-copy,
.history-window-close {
border: 1px solid rgba(127, 214, 255, 0.22);
border-radius: 999px;
background: rgba(127, 214, 255, 0.08);
color: var(--viewer-text);
font: inherit;
cursor: pointer;
}
.ship-card-history-button {
width: 24px;
height: 24px;
@@ -564,6 +341,11 @@ canvas {
line-height: 1;
}
.history-window-copy,
.history-window-close {
padding: 8px 12px;
}
.faction-card {
border-top-color: rgba(180, 130, 255, 0.3);
cursor: default;
@@ -582,11 +364,14 @@ canvas {
border-color: rgba(127, 255, 180, 0.5);
}
.swatch {
width: 14px;
height: 48px;
border-radius: 999px;
flex: none;
.ship-card-split-line {
display: flex;
justify-content: space-between;
gap: 12px;
}
.selection-action-button {
pointer-events: auto;
}
@media (max-width: 1080px) {
@@ -596,53 +381,8 @@ canvas {
}
@media (max-width: 760px) {
.left-panel-stack {
right: 20px;
width: auto;
}
.right-panel-stack {
left: 20px;
right: 20px;
top: auto;
width: auto;
bottom: 148px;
max-height: 38vh;
overflow: auto;
}
.info-panel {
max-height: none;
overflow: visible;
}
.system-panel-section,
.detail-panel-section,
.error-strip {
width: auto;
}
.network-panel {
width: auto;
}
.performance-panel {
width: auto;
}
.ops-strip {
left: 0;
right: 0;
bottom: 0;
width: 50vw;
width: 100vw;
min-height: 120px;
}
.history-window {
left: 20px;
right: 20px;
width: auto;
max-width: calc(100vw - 40px);
max-height: calc(100vh - 40px);
}
}

View File

@@ -0,0 +1,3 @@
import { createPinia } from "pinia";
export const viewerPinia = createPinia();

View File

@@ -0,0 +1,108 @@
import { defineStore } from "pinia";
import type { Selectable } from "../../viewerTypes";
export type ViewerSelectionSource = "viewer" | "ui" | null;
export interface ViewerSelectionSummary {
id: string;
kind: Selectable["kind"];
label?: string | null;
}
export function selectionToEntityId(selection: Selectable): string {
switch (selection.kind) {
case "planet":
return `${selection.systemId}:${selection.planetIndex}`;
case "moon":
return `${selection.systemId}:${selection.planetIndex}:${selection.moonIndex}`;
default:
return selection.id;
}
}
export function entityIdToSelectable(
kind: Selectable["kind"] | null,
entityId: string | null,
): Selectable | null {
if (!kind || !entityId) {
return null;
}
if (kind === "planet") {
const [systemId, planetIndex] = entityId.split(":");
if (!systemId || planetIndex == null) {
return null;
}
return {
kind,
systemId,
planetIndex: Number(planetIndex),
};
}
if (kind === "moon") {
const [systemId, planetIndex, moonIndex] = entityId.split(":");
if (!systemId || planetIndex == null || moonIndex == null) {
return null;
}
return {
kind,
systemId,
planetIndex: Number(planetIndex),
moonIndex: Number(moonIndex),
};
}
return {
kind,
id: entityId,
} as Selectable;
}
export const useViewerSelectionStore = defineStore("viewerSelection", {
state: () => ({
selectedEntityId: null as string | null,
selectedEntityKind: null as Selectable["kind"] | null,
selectedEntityLabel: null as string | null,
hoveredEntityId: null as string | null,
inspectedEntityId: null as string | null,
selectionSource: null as ViewerSelectionSource,
}),
actions: {
selectEntity(id: string | null, source: ViewerSelectionSource = null) {
this.selectedEntityId = id;
this.selectionSource = source;
if (id == null) {
this.selectedEntityKind = null;
this.selectedEntityLabel = null;
}
},
selectSelection(selection: ViewerSelectionSummary | null, source: ViewerSelectionSource = null) {
if (!selection) {
this.clearSelection(source);
return;
}
this.selectedEntityId = selection.id;
this.selectedEntityKind = selection.kind;
this.selectedEntityLabel = selection.label ?? null;
this.selectionSource = source;
},
clearSelection(source: ViewerSelectionSource = null) {
this.selectedEntityId = null;
this.selectedEntityKind = null;
this.selectedEntityLabel = null;
this.selectionSource = source;
},
hoverEntity(id: string | null) {
this.hoveredEntityId = id;
},
inspectEntity(id: string | null) {
this.inspectedEntityId = id;
},
},
});
export type ViewerSelectionStore = ReturnType<typeof useViewerSelectionStore>;

View File

@@ -79,21 +79,13 @@ export function createViewerControllers(host: any) {
const presentationController = new ViewerPresentationController({
renderer: host.renderer,
hudState: host.hudState,
galaxyScene: host.galaxyLayer.scene,
galaxyCamera: host.galaxyLayer.camera,
systemCamera: host.systemLayer.camera,
galaxyAnchor: host.galaxyAnchor,
systemAnchor: host.systemAnchor,
ambienceGroup: host.universeLayer.ambienceGroup,
gameSummaryEl: host.gameSummaryEl,
networkSummaryEl: host.networkSummaryEl,
performanceSummaryEl: host.performanceSummaryEl,
statusEl: host.statusEl,
networkPanelEl: host.networkPanelEl,
performancePanelEl: host.performancePanelEl,
systemPanelEl: host.systemPanelEl,
systemTitleEl: host.systemTitleEl,
systemBodyEl: host.systemBodyEl,
networkStats: host.networkStats,
performanceStats: host.performanceStats,
getWorld: () => host.world,
@@ -137,10 +129,7 @@ export function createViewerControllers(host: any) {
getCameraTargetShipId: () => host.cameraTargetShipId,
getNetworkStats: () => host.networkStats,
getSystemSummaryVisuals: () => new Map(),
errorEl: host.errorEl,
opsStripEl: host.opsStripEl,
detailTitleEl: host.detailTitleEl,
detailBodyEl: host.detailBodyEl,
hudState: host.hudState,
worldLabel: () => host.world?.label ?? "",
rebuildSystems: (systems) => sceneDataController.rebuildSystems(systems),
syncCelestials: (celestials) => sceneDataController.syncCelestials(celestials),
@@ -166,7 +155,6 @@ export function createViewerControllers(host: any) {
});
const historyController = new ViewerHistoryWindowController({
historyLayerEl: host.historyLayerEl,
historyWindows: host.historyWindows,
getWorld: () => host.world,
getHistoryWindowCounter: () => host.historyWindowCounter,
@@ -200,13 +188,14 @@ export function createViewerControllers(host: any) {
hoverLabelEl: host.hoverLabelEl,
hoverConnectorLineEl: host.hoverConnectorLineEl,
marqueeEl: host.marqueeEl,
hudState: host.hudState,
keyState: host.keyState,
getWorld: () => host.world,
getActiveSystemId: () => host.activeSystemId,
getPovLevel: () => host.povLevel,
getSelectedItems: () => host.selectedItems,
setSelectedItems: (items) => {
host.selectedItems = items;
host.applySelectedItems(items, "viewer");
},
getDragMode: () => host.dragMode,
setDragMode: (mode) => {
@@ -268,20 +257,33 @@ export function createViewerControllers(host: any) {
}
export function wireViewerEvents(host: any) {
host.renderer.domElement.addEventListener("pointerdown", host.interactionController.onPointerDown);
host.renderer.domElement.addEventListener("pointermove", host.interactionController.onPointerMove);
host.renderer.domElement.addEventListener("pointerup", host.interactionController.onPointerUp);
host.renderer.domElement.addEventListener("pointerleave", host.interactionController.onPointerUp);
host.renderer.domElement.addEventListener("click", host.interactionController.onClick);
host.renderer.domElement.addEventListener("dblclick", host.interactionController.onDoubleClick);
host.renderer.domElement.addEventListener("wheel", host.interactionController.onWheel, { passive: false });
host.opsStripEl.addEventListener("click", host.interactionController.onOpsStripClick);
host.opsStripEl.addEventListener("dblclick", host.interactionController.onOpsStripDoubleClick);
const canvas = host.renderer.domElement;
canvas.addEventListener("pointerdown", host.interactionController.onPointerDown);
canvas.addEventListener("pointermove", host.interactionController.onPointerMove);
canvas.addEventListener("pointerup", host.interactionController.onPointerUp);
canvas.addEventListener("pointerleave", host.interactionController.onPointerUp);
canvas.addEventListener("click", host.interactionController.onClick);
canvas.addEventListener("dblclick", host.interactionController.onDoubleClick);
canvas.addEventListener("wheel", host.interactionController.onWheel, { passive: false });
host.historyLayerEl.addEventListener("click", host.interactionController.onHistoryLayerClick);
host.historyLayerEl.addEventListener("pointerdown", host.interactionController.onHistoryLayerPointerDown);
window.addEventListener("pointermove", host.interactionController.onHistoryWindowPointerMove);
window.addEventListener("pointerup", host.interactionController.onHistoryWindowPointerUp);
window.addEventListener("keydown", host.interactionController.onKeyDown);
window.addEventListener("keyup", host.interactionController.onKeyUp);
window.addEventListener("resize", host.onResize);
return () => {
canvas.removeEventListener("pointerdown", host.interactionController.onPointerDown);
canvas.removeEventListener("pointermove", host.interactionController.onPointerMove);
canvas.removeEventListener("pointerup", host.interactionController.onPointerUp);
canvas.removeEventListener("pointerleave", host.interactionController.onPointerUp);
canvas.removeEventListener("click", host.interactionController.onClick);
canvas.removeEventListener("dblclick", host.interactionController.onDoubleClick);
canvas.removeEventListener("wheel", host.interactionController.onWheel);
host.historyLayerEl.removeEventListener("click", host.interactionController.onHistoryLayerClick);
host.historyLayerEl.removeEventListener("pointerdown", host.interactionController.onHistoryLayerPointerDown);
window.removeEventListener("pointermove", host.interactionController.onHistoryWindowPointerMove);
window.removeEventListener("pointerup", host.interactionController.onHistoryWindowPointerUp);
window.removeEventListener("keydown", host.interactionController.onKeyDown);
window.removeEventListener("keyup", host.interactionController.onKeyUp);
};
}

View File

@@ -1,39 +1,23 @@
import type { HistoryWindowState, Selectable, WorldState } from "./viewerTypes";
import type { HistoryWindowState } from "./viewerHudState";
import type { Selectable, WorldState } from "./viewerTypes";
export function createHistoryWindowState(
documentRef: Document,
target: Selectable,
historyWindowsCount: number,
historyWindowCounter: number,
): HistoryWindowState {
const id = `history-${historyWindowCounter}`;
const root = documentRef.createElement("aside");
root.className = "history-window";
root.dataset.historyWindowId = id;
root.innerHTML = `
<div class="history-window-header">
<h2 class="history-window-title">History</h2>
<div class="history-window-actions">
<button type="button" class="history-window-copy">Copy</button>
<button type="button" class="history-window-close">Close</button>
</div>
</div>
<div class="history-window-body">No history selected.</div>
`;
root.style.width = `${Math.min(520, window.innerWidth - 40)}px`;
root.style.height = `${Math.min(360, Math.max(240, window.innerHeight * 0.42))}px`;
root.style.left = `${Math.max(20, 20 + ((historyWindowsCount * 28) % Math.max(40, window.innerWidth - 580)))}px`;
root.style.top = `${Math.max(20, 20 + ((historyWindowsCount * 28) % Math.max(40, window.innerHeight - 420)))}px`;
return {
id,
id: `history-${historyWindowCounter}`,
target,
root,
titleEl: root.querySelector(".history-window-title") as HTMLHeadingElement,
bodyEl: root.querySelector(".history-window-body") as HTMLDivElement,
copyButtonEl: root.querySelector(".history-window-copy") as HTMLButtonElement,
title: "History",
bodyHtml: "No history selected.",
text: "",
copyLabel: "Copy",
width: Math.min(520, window.innerWidth - 40),
height: Math.min(360, Math.max(240, window.innerHeight * 0.42)),
x: Math.max(20, 20 + ((historyWindowsCount * 28) % Math.max(40, window.innerWidth - 580))),
y: Math.max(20, 20 + ((historyWindowsCount * 28) % Math.max(40, window.innerHeight - 420))),
zIndex: 1,
};
}
@@ -48,9 +32,9 @@ export function refreshHistoryWindow(
return false;
}
windowState.titleEl.textContent = `${ship.label} History`;
windowState.title = `${ship.label} History`;
windowState.text = ship.history.length > 0 ? ship.history.join("\n") : "No history yet.";
windowState.bodyEl.innerHTML = windowState.text.replaceAll("\n", "<br>");
windowState.bodyHtml = windowState.text.replaceAll("\n", "<br>");
return true;
}
@@ -60,9 +44,9 @@ export function refreshHistoryWindow(
return false;
}
windowState.titleEl.textContent = `${station.label} History`;
windowState.title = `${station.label} History`;
windowState.text = renderRecentEvents("station", station.id).replaceAll("<br>", "\n") || "No history yet.";
windowState.bodyEl.innerHTML = windowState.text.replaceAll("\n", "<br>");
windowState.bodyHtml = windowState.text.replaceAll("\n", "<br>");
return true;
}

View File

@@ -1,10 +1,10 @@
import * as THREE from "three";
import { createHistoryWindowState, refreshHistoryWindow } from "./viewerHistory";
import type { HistoryWindowState, Selectable, WorldState } from "./viewerTypes";
import type { HistoryWindowState } from "./viewerHudState";
import type { Selectable, WorldState } from "./viewerTypes";
export function openHistoryWindow(
historyWindows: HistoryWindowState[],
historyLayerEl: HTMLDivElement,
target: Selectable,
nextCounter: number,
bringToFront: (windowState: HistoryWindowState) => void,
@@ -17,9 +17,8 @@ export function openHistoryWindow(
return nextCounter;
}
const windowState = createHistoryWindowState(document, target, historyWindows.length, nextCounter);
const windowState = createHistoryWindowState(target, historyWindows.length, nextCounter);
historyWindows.push(windowState);
historyLayerEl.append(windowState.root);
bringToFront(windowState);
refreshWindows();
return nextCounter;
@@ -56,8 +55,7 @@ export function destroyHistoryWindow(
};
}
const [removed] = historyWindows.splice(index, 1);
removed.root.remove();
historyWindows.splice(index, 1);
if (historyWindowDragId === id) {
return {
historyWindowDragId: undefined,
@@ -72,7 +70,7 @@ export function destroyHistoryWindow(
}
export function bringHistoryWindowToFront(windowState: HistoryWindowState, nextZIndex: number) {
windowState.root.style.zIndex = `${nextZIndex}`;
windowState.zIndex = nextZIndex;
}
export function beginHistoryWindowDrag(
@@ -91,9 +89,7 @@ export function beginHistoryWindowDrag(
};
}
const bounds = windowState.root.getBoundingClientRect();
historyWindowDragOffset.set(clientX - bounds.left, clientY - bounds.top);
windowState.root.setPointerCapture?.(pointerId);
historyWindowDragOffset.set(clientX - windowState.x, clientY - windowState.y);
return {
historyWindowDragId: windowId,
historyWindowDragPointerId: pointerId,
@@ -118,16 +114,12 @@ export function updateHistoryWindowDrag(
return;
}
const width = windowState.root.offsetWidth;
const height = windowState.root.offsetHeight;
const left = THREE.MathUtils.clamp(clientX - historyWindowDragOffset.x, 20, window.innerWidth - width - 20);
const top = THREE.MathUtils.clamp(clientY - historyWindowDragOffset.y, 20, window.innerHeight - height - 20);
windowState.root.style.left = `${left}px`;
windowState.root.style.top = `${top}px`;
windowState.x = THREE.MathUtils.clamp(clientX - historyWindowDragOffset.x, 20, window.innerWidth - windowState.width - 20);
windowState.y = THREE.MathUtils.clamp(clientY - historyWindowDragOffset.y, 20, window.innerHeight - windowState.height - 20);
}
export function endHistoryWindowDrag(
historyWindows: HistoryWindowState[],
_historyWindows: HistoryWindowState[],
historyWindowDragId: string | undefined,
historyWindowDragPointerId: number | undefined,
pointerId: number,
@@ -139,8 +131,6 @@ export function endHistoryWindowDrag(
};
}
const windowState = historyWindows.find((candidate) => candidate.id === historyWindowDragId);
windowState?.root.releasePointerCapture?.(pointerId);
return {
historyWindowDragId: undefined,
historyWindowDragPointerId: undefined,

View File

@@ -9,10 +9,10 @@ import {
refreshHistoryWindows,
updateHistoryWindowDrag,
} from "./viewerHistoryManager";
import type { HistoryWindowState, Selectable, WorldState } from "./viewerTypes";
import type { HistoryWindowState } from "./viewerHudState";
import type { Selectable, WorldState } from "./viewerTypes";
export interface ViewerHistoryWindowContext {
historyLayerEl: HTMLDivElement;
historyWindows: HistoryWindowState[];
getWorld: () => WorldState | undefined;
getHistoryWindowCounter: () => number;
@@ -33,7 +33,6 @@ export class ViewerHistoryWindowController {
openHistoryWindow(target: Selectable) {
const nextCounter = openHistoryWindow(
this.context.historyWindows,
this.context.historyLayerEl,
target,
this.context.getHistoryWindowCounter() + 1,
(windowState) => this.bringHistoryWindowToFront(windowState),
@@ -155,14 +154,14 @@ export class ViewerHistoryWindowController {
try {
await copyTextToClipboard(windowState.text);
windowState.copyButtonEl.textContent = "Copied";
windowState.copyLabel = "Copied";
window.setTimeout(() => {
windowState.copyButtonEl.textContent = "Copy";
windowState.copyLabel = "Copy";
}, 1200);
} catch {
windowState.copyButtonEl.textContent = "Failed";
windowState.copyLabel = "Failed";
window.setTimeout(() => {
windowState.copyButtonEl.textContent = "Copy";
windowState.copyLabel = "Copy";
}, 1200);
}
}

View File

@@ -1,106 +0,0 @@
export interface ViewerHudElements {
root: HTMLDivElement;
gamePanelEl: HTMLDivElement;
statusEl: HTMLDivElement;
gameSummaryEl: HTMLSpanElement;
networkSectionEl: HTMLDivElement;
systemPanelEl: HTMLDivElement;
systemTitleEl: HTMLHeadingElement;
systemBodyEl: HTMLDivElement;
detailTitleEl: HTMLHeadingElement;
detailBodyEl: HTMLDivElement;
opsStripEl: HTMLDivElement;
networkSummaryEl: HTMLSpanElement;
networkPanelEl: HTMLDivElement;
performanceSectionEl: HTMLDivElement;
performanceSummaryEl: HTMLSpanElement;
performancePanelEl: HTMLDivElement;
errorEl: HTMLDivElement;
historyLayerEl: HTMLDivElement;
marqueeEl: HTMLDivElement;
hoverLabelEl: HTMLDivElement;
hoverConnectorLineEl: SVGLineElement;
}
export function createViewerHud(documentRef: Document): ViewerHudElements {
const root = documentRef.createElement("div");
root.className = "viewer-shell";
root.innerHTML = `
<div class="left-panel-stack">
<header class="topbar collapsible-panel is-collapsed" data-panel-name="game">
<div class="panel-heading">
<h2>Game</h2>
<div class="panel-heading-meta">
<span class="panel-summary game-summary">Bootstrapping</span>
<button type="button" class="panel-toggle" aria-expanded="false" aria-label="Expand Game panel">+</button>
</div>
</div>
<div class="topbar-body">Bootstrapping</div>
</header>
<aside class="network-panel collapsible-panel is-collapsed" data-panel-name="network">
<div class="panel-heading">
<h2>Network</h2>
<div class="panel-heading-meta">
<span class="panel-summary network-summary">Waiting</span>
<button type="button" class="panel-toggle" aria-expanded="false" aria-label="Expand Network panel">+</button>
</div>
</div>
<div class="network-body">Waiting for snapshot.</div>
</aside>
<aside class="performance-panel collapsible-panel is-collapsed" data-panel-name="performance">
<div class="panel-heading">
<h2>Performance</h2>
<div class="panel-heading-meta">
<span class="panel-summary performance-summary">Waiting</span>
<button type="button" class="panel-toggle" aria-expanded="false" aria-label="Expand Performance panel">+</button>
</div>
</div>
<div class="performance-body">Waiting for frame samples.</div>
</aside>
</div>
<div class="right-panel-stack">
<aside class="info-panel system-panel-section">
<h2>System</h2>
<h3 class="system-title">Deep Space</h3>
<div class="system-body">Waiting for the authoritative snapshot.</div>
</aside>
<aside class="info-panel detail-panel-section">
<h2>Focus</h2>
<h3 class="detail-title">Nothing selected</h3>
<div class="detail-body">Waiting for the authoritative snapshot.</div>
</aside>
<div class="error-strip" hidden></div>
</div>
<div class="history-layer"></div>
<section class="ops-strip"></section>
<div class="marquee-box"></div>
<svg class="hover-connector-svg" aria-hidden="true">
<line class="hover-connector-line" x1="0" y1="0" x2="0" y2="0" hidden></line>
</svg>
<div class="hover-label" hidden></div>
`;
return {
root,
gamePanelEl: root.querySelector(".topbar") as HTMLDivElement,
statusEl: root.querySelector(".topbar-body") as HTMLDivElement,
gameSummaryEl: root.querySelector(".game-summary") as HTMLSpanElement,
networkSectionEl: root.querySelector(".network-panel") as HTMLDivElement,
systemPanelEl: root.querySelector(".system-panel-section") as HTMLDivElement,
systemTitleEl: root.querySelector(".system-title") as HTMLHeadingElement,
systemBodyEl: root.querySelector(".system-body") as HTMLDivElement,
detailTitleEl: root.querySelector(".detail-title") as HTMLHeadingElement,
detailBodyEl: root.querySelector(".detail-body") as HTMLDivElement,
opsStripEl: root.querySelector(".ops-strip") as HTMLDivElement,
networkSummaryEl: root.querySelector(".network-summary") as HTMLSpanElement,
networkPanelEl: root.querySelector(".network-body") as HTMLDivElement,
performanceSectionEl: root.querySelector(".performance-panel") as HTMLDivElement,
performanceSummaryEl: root.querySelector(".performance-summary") as HTMLSpanElement,
performancePanelEl: root.querySelector(".performance-body") as HTMLDivElement,
errorEl: root.querySelector(".error-strip") as HTMLDivElement,
historyLayerEl: root.querySelector(".history-layer") as HTMLDivElement,
marqueeEl: root.querySelector(".marquee-box") as HTMLDivElement,
hoverLabelEl: root.querySelector(".hover-label") as HTMLDivElement,
hoverConnectorLineEl: root.querySelector(".hover-connector-line") as unknown as SVGLineElement,
};
}

View File

@@ -0,0 +1,178 @@
import { reactive } from "vue";
import type { ViewerSelectionStore } from "./ui/stores/viewerSelection";
import type { Selectable } from "./viewerTypes";
export interface HudPanelState {
collapsed: boolean;
summary: string;
bodyText: string;
}
export interface HudHtmlPanelState {
hidden: boolean;
title: string;
bodyHtml: string;
}
export interface HudErrorState {
hidden: boolean;
message: string;
}
export interface HudProgressBar {
label: string;
valueLabel: string;
progress: number;
}
export interface OpsFactionCardState {
kind: "faction";
id: string;
label: string;
stateLines: string[];
priorities: { label: string; value: string }[];
}
export interface OpsStationCardState {
kind: "station";
id: string;
label: string;
badge: string;
selected: boolean;
lines: string[];
processes: HudProgressBar[];
}
export interface OpsShipCardState {
kind: "ship";
id: string;
label: string;
badge: string;
selected: boolean;
followed: boolean;
locationLines: string[];
lines: string[];
action?: HudProgressBar;
aiLines: string[];
}
export interface OpsStripState {
factions: OpsFactionCardState[];
stations: OpsStationCardState[];
ships: OpsShipCardState[];
}
export interface HistoryWindowState {
id: string;
target: Selectable;
title: string;
bodyHtml: string;
text: string;
copyLabel: string;
x: number;
y: number;
width: number;
height: number;
zIndex: number;
}
export interface HoverLabelState {
hidden: boolean;
text: string;
x: number;
y: number;
connectorHidden: boolean;
x1: number;
y1: number;
x2: number;
y2: number;
}
export interface MarqueeState {
visible: boolean;
x: number;
y: number;
width: number;
height: number;
}
export interface ViewerHudState {
gamePanel: HudPanelState;
networkPanel: HudPanelState;
performancePanel: HudPanelState;
systemPanel: HudHtmlPanelState;
detailPanel: HudHtmlPanelState;
error: HudErrorState;
opsStrip: OpsStripState;
historyWindows: HistoryWindowState[];
hoverLabel: HoverLabelState;
marquee: MarqueeState;
}
export interface ViewerHudBindings {
state: ViewerHudState;
selectionStore: ViewerSelectionStore;
opsStripEl: HTMLDivElement;
historyLayerEl: HTMLDivElement;
marqueeEl: HTMLDivElement;
hoverLabelEl: HTMLDivElement;
hoverConnectorLineEl: SVGLineElement;
}
export function createViewerHudState(): ViewerHudState {
return reactive({
gamePanel: {
collapsed: true,
summary: "Bootstrapping",
bodyText: "Bootstrapping",
},
networkPanel: {
collapsed: true,
summary: "Waiting",
bodyText: "Waiting for snapshot.",
},
performancePanel: {
collapsed: true,
summary: "Waiting",
bodyText: "Waiting for frame samples.",
},
systemPanel: {
hidden: false,
title: "Deep Space",
bodyHtml: "Waiting for the authoritative snapshot.",
},
detailPanel: {
hidden: false,
title: "Nothing selected",
bodyHtml: "Waiting for the authoritative snapshot.",
},
error: {
hidden: true,
message: "",
},
opsStrip: {
factions: [],
stations: [],
ships: [],
},
historyWindows: [],
hoverLabel: {
hidden: true,
text: "",
x: 0,
y: 0,
connectorHidden: true,
x1: 0,
y1: 0,
x2: 0,
y2: 0,
},
marquee: {
visible: false,
x: 0,
y: 0,
width: 0,
height: 0,
},
});
}

View File

@@ -2,6 +2,7 @@ import * as THREE from "three";
import { describeHoverLabel, getSelectionGroup } from "./viewerSelection";
import { ACTIVE_SYSTEM_DETAIL_SCALE } from "./viewerConstants";
import { DISPLAY_UNITS_PER_KILOMETER, DISPLAY_UNITS_PER_LIGHT_YEAR, KILOMETERS_PER_AU, formatAdaptiveDistanceFromKilometers, formatSystemDistance } from "./viewerMath";
import type { HoverLabelState, MarqueeState } from "./viewerHudState";
import type { Selectable, SelectionGroup, WorldState, PovLevel } from "./viewerTypes";
export interface HoverPickResult {
@@ -67,6 +68,7 @@ export function pickSelectableHitAtClientPosition(
export function updateHoverLabel(params: {
dragMode?: string;
hoverState: HoverLabelState;
hoverLabelEl: HTMLDivElement;
hoverConnectorLineEl: SVGLineElement;
hoverPick: HoverPickResult | undefined;
@@ -77,6 +79,7 @@ export function updateHoverLabel(params: {
}) {
const {
dragMode,
hoverState,
hoverLabelEl,
hoverConnectorLineEl,
hoverPick,
@@ -87,6 +90,8 @@ export function updateHoverLabel(params: {
} = params;
if (dragMode || !hoverPick) {
hoverState.hidden = true;
hoverState.connectorHidden = true;
hoverLabelEl.hidden = true;
hoverConnectorLineEl.setAttribute("hidden", "");
return;
@@ -95,6 +100,8 @@ export function updateHoverLabel(params: {
const { selection, object, camera } = hoverPick;
const label = describeHoverLabel(world, selection);
if (!label) {
hoverState.hidden = true;
hoverState.connectorHidden = true;
hoverLabelEl.hidden = true;
hoverConnectorLineEl.setAttribute("hidden", "");
return;
@@ -102,18 +109,27 @@ export function updateHoverLabel(params: {
const distance = formatHoverDistance(camera, object, selection, povLevel, activeSystemId);
hoverState.hidden = false;
hoverState.text = `${label}\n${distance}`;
hoverState.x = point.x + 44;
hoverState.y = point.y - 90;
hoverLabelEl.hidden = false;
hoverLabelEl.textContent = `${label}\n${distance}`;
hoverLabelEl.style.left = `${point.x + 44}px`;
hoverLabelEl.style.top = `${point.y - 90}px`;
hoverLabelEl.textContent = hoverState.text;
hoverLabelEl.style.left = `${hoverState.x}px`;
hoverLabelEl.style.top = `${hoverState.y}px`;
const rect = hoverLabelEl.getBoundingClientRect();
const svgRect = (hoverConnectorLineEl.ownerSVGElement as SVGSVGElement).getBoundingClientRect();
hoverState.connectorHidden = false;
hoverState.x1 = point.x;
hoverState.y1 = point.y;
hoverState.x2 = rect.left - svgRect.left;
hoverState.y2 = rect.top - svgRect.top + rect.height / 2;
hoverConnectorLineEl.removeAttribute("hidden");
hoverConnectorLineEl.setAttribute("x1", String(point.x));
hoverConnectorLineEl.setAttribute("y1", String(point.y));
hoverConnectorLineEl.setAttribute("x2", String(rect.left - svgRect.left));
hoverConnectorLineEl.setAttribute("y2", String(rect.top - svgRect.top + rect.height / 2));
hoverConnectorLineEl.setAttribute("x1", String(hoverState.x1));
hoverConnectorLineEl.setAttribute("y1", String(hoverState.y1));
hoverConnectorLineEl.setAttribute("x2", String(hoverState.x2));
hoverConnectorLineEl.setAttribute("y2", String(hoverState.y2));
}
function formatHoverDistance(
@@ -150,6 +166,7 @@ function formatHoverDistance(
}
export function updateMarqueeBox(
marqueeState: MarqueeState,
marqueeEl: HTMLDivElement,
dragStart: THREE.Vector2,
dragLast: THREE.Vector2,
@@ -158,13 +175,21 @@ export function updateMarqueeBox(
const minY = Math.min(dragStart.y, dragLast.y);
const maxX = Math.max(dragStart.x, dragLast.x);
const maxY = Math.max(dragStart.y, dragLast.y);
marqueeState.visible = true;
marqueeState.x = minX;
marqueeState.y = minY;
marqueeState.width = maxX - minX;
marqueeState.height = maxY - minY;
marqueeEl.style.left = `${minX}px`;
marqueeEl.style.top = `${minY}px`;
marqueeEl.style.width = `${maxX - minX}px`;
marqueeEl.style.height = `${maxY - minY}px`;
}
export function hideMarqueeBox(marqueeEl: HTMLDivElement) {
export function hideMarqueeBox(marqueeState: MarqueeState, marqueeEl: HTMLDivElement) {
marqueeState.visible = false;
marqueeState.width = 0;
marqueeState.height = 0;
marqueeEl.style.display = "none";
marqueeEl.style.width = "0";
marqueeEl.style.height = "0";

View File

@@ -14,6 +14,7 @@ import {
} from "./viewerControls";
import { NAV_DISTANCE, NAV_DISTANCE_PLANET_ORBIT, NAV_DISTANCE_SHIP_HULL } from "./viewerConstants";
import { ViewerHistoryWindowController } from "./viewerHistoryWindowController";
import type { ViewerHudState } from "./viewerHudState";
import type {
CameraMode,
DragMode,
@@ -33,6 +34,7 @@ export interface ViewerInteractionContext {
hoverLabelEl: HTMLDivElement;
hoverConnectorLineEl: SVGLineElement;
marqueeEl: HTMLDivElement;
hudState: ViewerHudState;
keyState: Set<string>;
getWorld: () => WorldState | undefined;
getActiveSystemId: () => string | undefined;
@@ -109,6 +111,7 @@ export class ViewerInteractionController {
if (!this.context.getMarqueeActive() && dragDistance > 8) {
this.context.setMarqueeActive(true);
this.context.setSuppressClickSelection(true);
this.context.hudState.marquee.visible = true;
this.context.marqueeEl.style.display = "block";
}
@@ -117,7 +120,7 @@ export class ViewerInteractionController {
}
this.context.dragLast.copy(point);
updateMarqueeBox(this.context.marqueeEl, this.context.dragStart, this.context.dragLast);
updateMarqueeBox(this.context.hudState.marquee, this.context.marqueeEl, this.context.dragStart, this.context.dragLast);
};
readonly onPointerUp = (event: PointerEvent) => {
@@ -131,7 +134,7 @@ export class ViewerInteractionController {
if (this.context.getDragMode() === "marquee" && this.context.getMarqueeActive()) {
this.completeMarqueeSelection();
hideMarqueeBox(this.context.marqueeEl);
hideMarqueeBox(this.context.hudState.marquee, this.context.marqueeEl);
}
this.context.setDragMode(undefined);
@@ -285,6 +288,7 @@ export class ViewerInteractionController {
updateHoverLabel(event: PointerEvent) {
updateHoverLabel({
dragMode: this.context.getDragMode(),
hoverState: this.context.hudState.hoverLabel,
hoverLabelEl: this.context.hoverLabelEl,
hoverConnectorLineEl: this.context.hoverConnectorLineEl,
hoverPick: this.pickSelectableHitAtClientPosition(event.clientX, event.clientY),
@@ -299,6 +303,10 @@ export class ViewerInteractionController {
this.context.historyController.refreshHistoryWindows();
}
openHistoryWindow(selection: Selectable) {
this.context.historyController.openHistoryWindow(selection);
}
toggleCameraMode(forceMode?: CameraMode) {
const nextState = toggleCameraMode({
cameraMode: this.context.getCameraMode(),

View File

@@ -1,154 +1,131 @@
import type { StationSnapshot } from "./contractsInfrastructure";
import type { FactionSnapshot } from "./contractsFactions";
import { inventoryAmount } from "./viewerMath";
import type {
HudProgressBar,
OpsFactionCardState,
OpsShipCardState,
OpsStationCardState,
OpsStripState,
} from "./viewerHudState";
import { describeShipCurrentAction, describeShipLocation, describeShipObjective, describeShipState } from "./viewerSelection";
import type { CameraMode, Selectable, WorldState, PovLevel } from "./viewerTypes";
function renderFactionCard(faction: FactionSnapshot): string {
function buildFactionCard(faction: FactionSnapshot): OpsFactionCardState {
const state = faction.goapState;
const priorities = faction.goapPriorities;
return `
<article class="ship-card faction-card" data-faction-id="${faction.id}">
<div class="ship-card-header">
<h3>${faction.label}</h3>
<span class="ship-card-badge">faction</span>
</div>
${state ? `
<div class="ship-card-ai">
<p class="ship-card-section-title">GOAP State</p>
<p>Military ${state.militaryShipCount} · Miners ${state.minerShipCount}</p>
<p>Transport ${state.transportShipCount} · Constructors ${state.constructorShipCount}</p>
<p>Systems ${state.controlledSystemCount} / ${state.targetSystemCount}</p>
<p>Factory ${state.hasShipFactory ? "yes" : "no"} · Ore ${state.oreStockpile.toFixed(0)}</p>
</div>
` : ""}
${priorities && priorities.length > 0 ? `
<div class="ship-card-ai">
<p class="ship-card-section-title">Priorities</p>
${priorities.map(p => `<p>${p.goalName} <span style="float:right">${p.priority.toFixed(0)}</span></p>`).join("")}
</div>
` : ""}
</article>
`;
return {
kind: "faction",
id: faction.id,
label: faction.label,
stateLines: state ? [
`Military ${state.militaryShipCount} · Miners ${state.minerShipCount}`,
`Transport ${state.transportShipCount} · Constructors ${state.constructorShipCount}`,
`Systems ${state.controlledSystemCount} / ${state.targetSystemCount}`,
`Factory ${state.hasShipFactory ? "yes" : "no"} · Ore ${state.oreStockpile.toFixed(0)}`,
] : [],
priorities: (faction.goapPriorities ?? []).map((entry) => ({
label: entry.goalName,
value: entry.priority.toFixed(0),
})),
};
}
function renderStationCard(station: StationSnapshot, isSelected: boolean): string {
const cargo = station.inventory.reduce((sum, e) => sum + e.amount, 0);
const processes = station.currentProcesses;
return `
<article class="ship-card station-card${isSelected ? " is-selected" : ""}" data-station-id="${station.id}">
<div class="ship-card-header">
<h3>${station.label}</h3>
<span class="ship-card-badge">${station.category}</span>
</div>
<p>${station.systemId}</p>
<p>Docked ${station.dockedShips} / ${station.dockingPads}</p>
<p>Cargo ${cargo.toFixed(0)} · Pop ${station.population.toFixed(0)}</p>
<p>Modules ${station.installedModules.length}</p>
${processes.length > 0 ? `
<div class="ship-card-ai">
${processes.map(p => `
<div class="ship-action-progress">
<div class="ship-action-progress-label">
<span>${p.label}</span>
<span>${Math.round(p.progress * 100)}%</span>
</div>
<div class="ship-action-progress-track">
<div class="ship-action-progress-fill" style="width: ${(p.progress * 100).toFixed(1)}%"></div>
</div>
</div>
`).join("")}
</div>
` : ""}
</article>
`;
function buildProgressBar(label: string, progress: number): HudProgressBar {
return {
label,
valueLabel: `${Math.round(progress * 100)}%`,
progress: Number((progress * 100).toFixed(1)),
};
}
export function renderOpsStrip(
function buildStationCard(station: StationSnapshot, isSelected: boolean): OpsStationCardState {
const cargo = station.inventory.reduce((sum, entry) => sum + entry.amount, 0);
return {
kind: "station",
id: station.id,
label: station.label,
badge: station.category,
selected: isSelected,
lines: [
station.systemId,
`Docked ${station.dockedShips} / ${station.dockingPads}`,
`Cargo ${cargo.toFixed(0)} · Pop ${station.population.toFixed(0)}`,
`Modules ${station.installedModules.length}`,
],
processes: station.currentProcesses.map((process) => buildProgressBar(process.label, process.progress)),
};
}
function buildShipCard(
world: WorldState,
ship: WorldState["ships"] extends Map<string, infer Ship> ? Ship : never,
isSelected: boolean,
isFollowed: boolean,
): OpsShipCardState {
const cargo = ship.inventory.reduce((sum, entry) => sum + entry.amount, 0);
const shipLocation = describeShipLocation(world, ship);
const shipState = describeShipState(world, ship);
const shipAction = describeShipCurrentAction(ship);
return {
kind: "ship",
id: ship.id,
label: ship.label,
badge: ship.class,
selected: isSelected,
followed: isFollowed,
locationLines: [shipLocation.system, ...(shipLocation.local ? [shipLocation.local] : [])],
lines: [
`Cargo ${cargo.toFixed(0)}`,
`State ${shipState}`,
],
action: shipAction ? buildProgressBar(shipAction.label, shipAction.progress) : undefined,
aiLines: [
...(ship.commanderObjective ? [`Objective ${describeShipObjective(ship.commanderObjective)}`] : []),
`Behavior ${ship.defaultBehaviorKind}${ship.behaviorPhase ? ` · ${ship.behaviorPhase}` : ""}`,
`Task ${ship.controllerTaskKind}`,
],
};
}
export function buildOpsStripState(
world: WorldState | undefined,
selectedItems: Selectable[],
cameraMode: CameraMode,
cameraTargetShipId?: string,
povLevel?: PovLevel,
activeSystemId?: string,
) {
): OpsStripState {
if (!world) {
return "";
return {
factions: [],
stations: [],
ships: [],
};
}
const isSystemFiltered = povLevel !== "galaxy" && activeSystemId != null;
const factionCards = [...world.factions.values()]
.sort((a, b) => a.label.localeCompare(b.label))
.map(renderFactionCard)
.join("");
const factions = [...world.factions.values()]
.sort((left, right) => left.label.localeCompare(right.label))
.map(buildFactionCard);
const stationCards = [...world.stations.values()]
const stations = [...world.stations.values()]
.filter((station) => !isSystemFiltered || station.systemId === activeSystemId)
.sort((a, b) => a.label.localeCompare(b.label))
.map((station) => {
const isSelected = selectedItems.length === 1
&& selectedItems[0].kind === "station"
&& selectedItems[0].id === station.id;
return renderStationCard(station, isSelected);
})
.join("");
.sort((left, right) => left.label.localeCompare(right.label))
.map((station) => buildStationCard(
station,
selectedItems.length === 1 && selectedItems[0].kind === "station" && selectedItems[0].id === station.id,
));
const ships = [...world.ships.values()]
.filter((ship) => !isSystemFiltered || ship.systemId === activeSystemId)
.sort((a, b) => a.label.localeCompare(b.label));
.sort((left, right) => left.label.localeCompare(right.label))
.map((ship) => buildShipCard(
world,
ship,
selectedItems.length === 1 && selectedItems[0].kind === "ship" && selectedItems[0].id === ship.id,
cameraMode === "follow" && cameraTargetShipId === ship.id,
));
const shipCards = ships
.map((ship) => {
const cargo = ship.inventory.reduce((sum, e) => sum + e.amount, 0);
const shipLocation = describeShipLocation(world, ship);
const shipState = describeShipState(world, ship);
const shipAction = describeShipCurrentAction(ship);
const isSelected = selectedItems.length === 1
&& selectedItems[0].kind === "ship"
&& selectedItems[0].id === ship.id;
const isFollowed = cameraMode === "follow" && cameraTargetShipId === ship.id;
return `
<article class="ship-card${isSelected ? " is-selected" : ""}${isFollowed ? " is-followed" : ""}" data-ship-id="${ship.id}">
<div class="ship-card-header">
<h3>${ship.label}</h3>
<div class="ship-card-meta">
<span class="ship-card-badge">${ship.class}</span>
<button
type="button"
class="ship-card-history-button"
data-history-ship-id="${ship.id}"
aria-label="Open history for ${ship.label}"
title="Open history"
>&#128340;</button>
</div>
</div>
<p>${shipLocation.system}${shipLocation.local ? `<br>${shipLocation.local}` : ""}</p>
<p>Cargo ${cargo.toFixed(0)}</p>
<p>State ${shipState}</p>
${shipAction ? `
<div class="ship-action-progress">
<div class="ship-action-progress-label">
<span>${shipAction.label}</span>
<span>${Math.round(shipAction.progress * 100)}%</span>
</div>
<div class="ship-action-progress-track">
<div class="ship-action-progress-fill" style="width: ${(shipAction.progress * 100).toFixed(1)}%"></div>
</div>
</div>
` : ""}
<div class="ship-card-ai">
${ship.commanderObjective ? `<p>Objective ${describeShipObjective(ship.commanderObjective)}</p>` : ""}
<p>Behavior ${ship.defaultBehaviorKind}${ship.behaviorPhase ? ` · ${ship.behaviorPhase}` : ""}</p>
<p>Task ${ship.controllerTaskKind}</p>
</div>
</article>
`;
})
.join("");
return factionCards + stationCards + shipCards;
return { factions, stations, ships };
}

View File

@@ -17,7 +17,6 @@ const itemTransportById = new Map<string, string>(
import { describeCelestialPathWithinSystem, describeOrbitalParent, describeSelectable, describeShipBehavior, describeShipCurrentAction, describeShipOrder, describeShipState, getSelectionGroup, renderSystemDetails } from "./viewerSelection";
import type {
CameraMode,
HistoryWindowState,
NodeVisual,
OrbitalAnchor,
Selectable,
@@ -39,9 +38,6 @@ interface DetailPanelParams {
interface SystemPanelParams {
world: WorldState;
activeSystemId?: string;
systemTitleEl: HTMLHeadingElement;
systemBodyEl: HTMLDivElement;
systemPanelEl: HTMLDivElement;
cameraMode: CameraMode;
cameraTargetShipId?: string;
}
@@ -70,7 +66,15 @@ function formatModuleListWithConstruction(
world: WorldState,
stationId: string,
installedModules: string[],
currentProcesses: { lane: string; label: string; progress: number; timeRemainingSeconds: number }[],
currentProcesses: {
lane: string;
label: string;
progress: number;
timeRemainingSeconds: number;
cycleSeconds: number;
inputs: { itemId: string; amount: number }[];
outputs: { itemId: string; amount: number }[];
}[],
): string {
const processByModule = new Map<string, { label: string; progress: number; timeRemainingSeconds: number; cycleSeconds: number; inputs: { itemId: string; amount: number }[]; outputs: { itemId: string; amount: number }[] }[]>();
for (const process of currentProcesses) {
@@ -175,11 +179,7 @@ function renderSystemOwnership(world: WorldState, systemId: string): string {
.join("<br>");
}
export function updateDetailPanel(
detailTitleEl: HTMLHeadingElement,
detailBodyEl: HTMLDivElement,
params: DetailPanelParams,
) {
export function buildDetailPanelState(params: DetailPanelParams) {
const {
world,
selectedItems,
@@ -191,8 +191,9 @@ export function updateDetailPanel(
} = params;
if (selectedItems.length === 0) {
detailTitleEl.textContent = worldLabel;
detailBodyEl.innerHTML = `
return {
title: worldLabel,
bodyHtml: `
Zoom ${povLevel}<br>
Systems ${world.systems.size}<br>
Celestials ${world.celestials.size}<br>
@@ -201,25 +202,26 @@ export function updateDetailPanel(
Construction ${world.constructionSites.size}<br>
Ships ${world.ships.size}<br>
Recent events ${world.recentEvents.length}
`;
return;
`,
};
}
if (selectedItems.length > 1) {
const group = getSelectionGroup(selectedItems[0]);
detailTitleEl.textContent = `${selectedItems.length} selected`;
detailBodyEl.innerHTML = `
return {
title: `${selectedItems.length} selected`,
bodyHtml: `
Type ${group}<br>
${selectedItems.slice(0, 8).map((item) => describeSelectable(world, item)).join("<br>")}
`;
return;
`,
};
}
const selected = selectedItems[0];
if (selected.kind === "ship") {
const ship = world.ships.get(selected.id);
if (!ship) {
return;
return { title: "Missing ship", bodyHtml: "" };
}
const parent = describeSelectionParent(selected);
const cargoUsed = ship.inventory.reduce((sum, e) => sum + e.amount, 0);
@@ -227,8 +229,9 @@ export function updateDetailPanel(
const shipBehavior = describeShipBehavior(ship);
const shipOrder = describeShipOrder(ship);
const shipAction = describeShipCurrentAction(ship);
detailTitleEl.textContent = ship.label;
detailBodyEl.innerHTML = `
return {
title: ship.label,
bodyHtml: `
<p>Parent ${parent}</p>
<p>Behavior ${shipBehavior}</p>
<p>State ${shipState}</p>
@@ -249,14 +252,14 @@ export function updateDetailPanel(
<p>Inventory ${formatInventory(ship.inventory)}</p>
<p>Speed ${formatShipSpeed(ship)}</p>
<p>Camera ${cameraMode === "follow" && cameraTargetShipId === ship.id ? "camera-follow" : "tactical"}<br>Press C to toggle follow</p>
`;
return;
`,
};
}
if (selected.kind === "station") {
const station = world.stations.get(selected.id);
if (!station) {
return;
return { title: "Missing station", bodyHtml: "" };
}
const parent = describeSelectionParent(selected);
const moduleList = formatModuleListWithConstruction(world, station.id, station.installedModules, station.currentProcesses);
@@ -264,8 +267,9 @@ export function updateDetailPanel(
? station.dockedShipIds.map((shipId) => world.ships.get(shipId)?.label ?? shipId).join("<br>")
: "none";
const stationStorage = formatStorageWithInventory(station.storageUsage, station.inventory);
detailTitleEl.textContent = station.label;
detailBodyEl.innerHTML = `
return {
title: station.label,
bodyHtml: `
<p>${station.category} · ${station.systemId}</p>
<p>Parent ${parent}</p>
<p>Docked ${station.dockedShips} / ${station.dockingPads}
@@ -273,21 +277,22 @@ export function updateDetailPanel(
${dockedShipLabels}</p>
<p>Modules ${moduleList}</p>
<p>Storage ${stationStorage}</p>
`;
return;
`,
};
}
if (selected.kind === "node") {
const node = world.nodes.get(selected.id);
if (!node) {
return;
return { title: "Missing node", bodyHtml: "" };
}
const parent = describeSelectionParent(selected);
const nodeLevel = node.maxOre > 0
? Math.max(0, Math.min(node.oreRemaining / node.maxOre, 1))
: 0;
detailTitleEl.textContent = `Node ${node.id}`;
detailBodyEl.innerHTML = `
return {
title: `Node ${node.id}`,
bodyHtml: `
<p>${node.systemId}</p>
<p>Parent ${parent}</p>
<p>Source ${node.sourceKind}<br>Resource ${node.itemId}</p>
@@ -301,73 +306,77 @@ export function updateDetailPanel(
</div>
</div>
<p>Stock ${node.oreRemaining.toFixed(0)} / ${node.maxOre.toFixed(0)}</p>
`;
return;
`,
};
}
if (selected.kind === "celestial") {
const celestial = world.celestials.get(selected.id);
if (!celestial) {
return;
return { title: "Missing celestial", bodyHtml: "" };
}
detailTitleEl.textContent = `${celestial.kind} celestial`;
detailBodyEl.innerHTML = `
return {
title: `${celestial.kind} celestial`,
bodyHtml: `
<p>${celestial.systemId}</p>
<p>Parent ${celestial.parentNodeId ?? "none"}<br>Orbit ref ${celestial.orbitReferenceId ?? "none"}</p>
<p>Occupying structure ${celestial.occupyingStructureId ?? "none"}</p>
<p>Local space radius ${celestial.localSpaceRadius.toFixed(0)} km</p>
`;
return;
`,
};
}
if (selected.kind === "claim") {
const claim = world.claims.get(selected.id);
if (!claim) {
return;
return { title: "Missing claim", bodyHtml: "" };
}
detailTitleEl.textContent = `Claim ${claim.id}`;
detailBodyEl.innerHTML = `
return {
title: `Claim ${claim.id}`,
bodyHtml: `
<p>${claim.systemId}</p>
<p>Celestial ${claim.celestialId}</p>
<p>State ${claim.state}<br>Health ${claim.health.toFixed(0)}</p>
<p>Activates ${new Date(claim.activatesAtUtc).toLocaleTimeString()}</p>
`;
return;
`,
};
}
if (selected.kind === "construction-site") {
const site = world.constructionSites.get(selected.id);
if (!site) {
return;
return { title: "Missing construction", bodyHtml: "" };
}
const orderCount = [...world.marketOrders.values()].filter((order) => order.constructionSiteId === site.id).length;
detailTitleEl.textContent = `Construction ${site.id}`;
detailBodyEl.innerHTML = `
return {
title: `Construction ${site.id}`,
bodyHtml: `
<p>${site.systemId}</p>
<p>Celestial ${site.celestialId}</p>
<p>${site.targetKind} ${site.targetDefinitionId}</p>
<p>State ${site.state}<br>Progress ${(site.progress * 100).toFixed(0)}%</p>
<p>Orders ${orderCount}<br>Assigned constructors ${site.assignedConstructorShipIds.length}</p>
`;
return;
`,
};
}
if (selected.kind === "planet") {
const system = world.systems.get(selected.systemId);
const planet = system?.planets[selected.planetIndex];
if (!system || !planet) {
return;
return { title: "Missing planet", bodyHtml: "" };
}
const parent = describeSelectionParent(selected);
detailTitleEl.textContent = planet.label;
detailBodyEl.innerHTML = `
return {
title: planet.label,
bodyHtml: `
<p>${system.label}</p>
<p>Parent ${parent}</p>
<p>${planet.planetType} · ${planet.shape} · Moons ${planet.moons.length}</p>
<p>Orbit ${formatSystemDistance(planet.orbitRadius)}<br>Speed ${planet.orbitSpeed.toFixed(3)}<br>Ecc ${planet.orbitEccentricity.toFixed(3)}<br>Inc ${planet.orbitInclination.toFixed(1)}°</p>
<p>Phase ${planet.orbitPhaseAtEpoch.toFixed(1)}°</p>
`;
return;
`,
};
}
if (selected.kind === "moon") {
@@ -375,51 +384,57 @@ export function updateDetailPanel(
const planet = system?.planets[selected.planetIndex];
const moon = planet?.moons[selected.moonIndex];
if (moon) {
detailTitleEl.textContent = moon.label;
detailBodyEl.innerHTML = `
return {
title: moon.label,
bodyHtml: `
<p>${system?.label ?? selected.systemId} / ${planet?.label ?? `planet ${selected.planetIndex + 1}`}</p>
<p>Orbit ${formatSystemDistance(moon.orbitRadius)}<br>Inc ${moon.orbitInclination.toFixed(1)}°</p>
`;
`,
};
}
return;
return { title: "Moon", bodyHtml: "" };
}
const system = world.systems.get(selected.id);
if (!system) {
return;
return {
title: "Unknown selection",
bodyHtml: "",
};
}
detailTitleEl.textContent = system.label;
detailBodyEl.innerHTML = `
return {
title: system.label,
bodyHtml: `
<p>Parent galaxy</p>
${renderSystemDetails(world, system, false, cameraMode, cameraTargetShipId)}
`;
`,
};
}
export function updateSystemPanel(params: SystemPanelParams) {
export function buildSystemPanelState(params: SystemPanelParams) {
const {
world,
activeSystemId,
systemTitleEl,
systemBodyEl,
systemPanelEl,
cameraMode,
cameraTargetShipId,
} = params;
const activeSystem = activeSystemId ? world.systems.get(activeSystemId) : undefined;
systemPanelEl.hidden = !activeSystem;
if (!activeSystem) {
systemTitleEl.textContent = "Deep Space";
systemBodyEl.innerHTML = "";
return;
return {
hidden: true,
title: "Deep Space",
bodyHtml: "",
};
}
systemTitleEl.textContent = activeSystem.label;
systemBodyEl.innerHTML = `
return {
hidden: false,
title: activeSystem.label,
bodyHtml: `
<p>${renderSystemOwnership(world, activeSystem.id)}</p>
`;
`,
};
}
export function describeSelectionParent(

View File

@@ -1,34 +1,27 @@
import * as THREE from "three";
import {
updateNetworkPanel as renderNetworkPanel,
describeNetworkPanel,
describePerformancePanel,
recordPerformanceStats,
summarizeNetworkStats,
summarizePerformanceStats,
updatePerformancePanel as renderPerformancePanel,
} from "./viewerTelemetry";
import { updatePlanetPresentation } from "./viewerPresentation";
import { renderRecentEvents, updateGameStatus, updateSystemSummaries, updateWorldPresentation } from "./viewerWorldPresentation";
import { updateSystemPanel } from "./viewerPanels";
import { buildSystemPanelState } from "./viewerPanels";
import { describeGameStatus, renderRecentEvents, updateSystemSummaries, updateWorldPresentation } from "./viewerWorldPresentation";
import { createBackdropStars, createNebulaClouds, createNebulaTexture } from "./viewerSceneFactory";
import type { ViewerHudState } from "./viewerHudState";
import type { Selectable } from "./viewerTypes";
export interface ViewerPresentationContext {
renderer: THREE.WebGLRenderer;
hudState: ViewerHudState;
galaxyScene: THREE.Scene;
galaxyCamera: THREE.PerspectiveCamera;
systemCamera: THREE.PerspectiveCamera;
galaxyAnchor: THREE.Vector3;
systemAnchor: THREE.Vector3;
ambienceGroup: THREE.Group;
gameSummaryEl: HTMLSpanElement;
networkSummaryEl: HTMLSpanElement;
performanceSummaryEl: HTMLSpanElement;
statusEl: HTMLDivElement;
networkPanelEl: HTMLDivElement;
performancePanelEl: HTMLDivElement;
systemPanelEl: HTMLDivElement;
systemTitleEl: HTMLHeadingElement;
systemBodyEl: HTMLDivElement;
networkStats: any;
performanceStats: any;
getWorld: () => any;
@@ -61,7 +54,6 @@ export class ViewerPresentationController {
}
applyZoomPresentation() {
const activeSystemId = this.context.getActiveSystemId();
const povLevel = this.context.getPovLevel();
this.context.galaxyScene.fog = new THREE.FogExp2(0x040912, 0.000035);
@@ -73,8 +65,8 @@ export class ViewerPresentationController {
}
updateNetworkPanel() {
renderNetworkPanel(this.context.networkPanelEl, this.context.networkStats);
this.context.networkSummaryEl.textContent = summarizeNetworkStats(this.context.networkStats);
this.context.hudState.networkPanel.bodyText = describeNetworkPanel(this.context.networkStats);
this.context.hudState.networkPanel.summary = summarizeNetworkStats(this.context.networkStats);
}
recordPerformanceStats(frameMs: number) {
@@ -82,8 +74,11 @@ export class ViewerPresentationController {
}
updatePerformancePanel() {
renderPerformancePanel(this.context.performancePanelEl, this.context.performanceStats, this.context.renderer);
this.context.performanceSummaryEl.textContent = summarizePerformanceStats(this.context.performanceStats);
const bodyText = describePerformancePanel(this.context.performanceStats, this.context.renderer);
if (bodyText) {
this.context.hudState.performancePanel.bodyText = bodyText;
}
this.context.hudState.performancePanel.summary = summarizePerformanceStats(this.context.performanceStats);
}
updateShipPresentation() {
@@ -109,9 +104,7 @@ export class ViewerPresentationController {
}
updateGamePanel(mode: string) {
updateGameStatus({
statusEl: this.context.statusEl,
summaryEl: this.context.gameSummaryEl,
const state = describeGameStatus({
world: this.context.getWorld(),
activeSystemId: this.context.getActiveSystemId(),
cameraMode: this.context.getCameraMode(),
@@ -121,6 +114,8 @@ export class ViewerPresentationController {
galaxyAnchor: this.context.galaxyAnchor,
systemAnchor: this.context.systemAnchor,
});
this.context.hudState.gamePanel.bodyText = state.bodyText;
this.context.hudState.gamePanel.summary = state.summaryText;
}
updateSystemPanel() {
@@ -129,15 +124,15 @@ export class ViewerPresentationController {
return;
}
updateSystemPanel({
const state = buildSystemPanelState({
world,
activeSystemId: this.context.getActiveSystemId(),
systemTitleEl: this.context.systemTitleEl,
systemBodyEl: this.context.systemBodyEl,
systemPanelEl: this.context.systemPanelEl,
cameraMode: this.context.getCameraMode(),
cameraTargetShipId: this.context.getCameraTargetShipId(),
});
this.context.hudState.systemPanel.hidden = state.hidden;
this.context.hudState.systemPanel.title = state.title;
this.context.hudState.systemPanel.bodyHtml = state.bodyHtml;
}
screenPointFromClient(clientX: number, clientY: number) {

View File

@@ -25,10 +25,11 @@ export interface RenderFrameParams {
}
export interface ResizeParams {
renderer: THREE.WebGLRenderer;
galaxyLayer: GalaxyLayer;
systemLayer: SystemLayer;
localLayer: LocalLayer;
width: number;
height: number;
}
export interface CameraStepParams {
@@ -72,12 +73,10 @@ export function renderFrame(params: RenderFrameParams) {
}
export function resizeViewer(params: ResizeParams) {
const width = window.innerWidth;
const height = window.innerHeight;
params.galaxyLayer.onResize(width / height);
params.systemLayer.onResize(width / height);
params.localLayer.onResize(width / height);
params.renderer.setSize(width, height);
const aspect = params.width / params.height;
params.galaxyLayer.onResize(aspect);
params.systemLayer.onResize(aspect);
params.localLayer.onResize(aspect);
}
export function stepCamera(params: CameraStepParams) {

View File

@@ -6,6 +6,10 @@ import type {
} from "./viewerTypes";
export function updateNetworkPanel(networkPanelEl: HTMLDivElement, networkStats: NetworkStats) {
networkPanelEl.textContent = describeNetworkPanel(networkStats);
}
export function describeNetworkPanel(networkStats: NetworkStats) {
const now = performance.now();
const uptimeSeconds = networkStats.streamOpenedAtMs
? (now - networkStats.streamOpenedAtMs) / 1000
@@ -22,7 +26,7 @@ export function updateNetworkPanel(networkPanelEl: HTMLDivElement, networkStats:
? ((now - networkStats.lastDeltaAtMs) / 1000).toFixed(1)
: "n/a";
networkPanelEl.textContent = [
return [
`snapshot: ${formatBytes(networkStats.snapshotBytes)}`,
`stream: ${networkStats.streamConnected ? "live" : "offline"}`,
`deltas: ${networkStats.deltasReceived}`,
@@ -59,13 +63,23 @@ export function updatePerformancePanel(
performancePanelEl: HTMLDivElement,
performanceStats: PerformanceStats,
renderer: THREE.WebGLRenderer,
) {
const text = describePerformancePanel(performanceStats, renderer);
if (text) {
performancePanelEl.textContent = text;
}
}
export function describePerformancePanel(
performanceStats: PerformanceStats,
renderer: THREE.WebGLRenderer,
) {
const now = performance.now();
if (
performanceStats.lastPanelUpdateAtMs > 0 &&
now - performanceStats.lastPanelUpdateAtMs < 250
) {
return;
return undefined;
}
const samples = performanceStats.frameSamples;
@@ -84,7 +98,8 @@ export function updatePerformancePanel(
const recentLowFps = averageFrameMs > 0 ? 1000 / Math.max(worstFrameMs, averageFrameMs) : 0;
const renderInfo = renderer.info;
performancePanelEl.textContent = [
performanceStats.lastPanelUpdateAtMs = now;
return [
`fps: ${fps.toFixed(1)}`,
`frame avg: ${averageFrameMs.toFixed(2)} ms`,
`frame last: ${performanceStats.lastFrameMs.toFixed(2)} ms`,
@@ -98,7 +113,6 @@ export function updatePerformancePanel(
`textures: ${renderInfo.memory.textures}`,
`pixel ratio: ${renderer.getPixelRatio().toFixed(2)}`,
].join("\n");
performanceStats.lastPanelUpdateAtMs = now;
}
export function summarizePerformanceStats(performanceStats: PerformanceStats): string {

View File

@@ -183,13 +183,3 @@ export interface PerformanceStats {
lastFrameMs: number;
lastPanelUpdateAtMs: number;
}
export interface HistoryWindowState {
id: string;
target: Selectable;
root: HTMLElement;
titleEl: HTMLHeadingElement;
bodyEl: HTMLDivElement;
copyButtonEl: HTMLButtonElement;
text: string;
}

View File

@@ -1,6 +1,7 @@
import { fetchWorldSnapshot, openWorldStream } from "./api";
import { renderOpsStrip } from "./viewerOpsStrip";
import { updateDetailPanel } from "./viewerPanels";
import type { ViewerHudState } from "./viewerHudState";
import { buildOpsStripState } from "./viewerOpsStrip";
import { buildDetailPanelState } from "./viewerPanels";
import { applyDeltaToWorld, cloneFactions, createWorldState, recordDeltaStats } from "./viewerState";
import type {
CelestialDelta,
@@ -46,10 +47,7 @@ export interface ViewerWorldLifecycleContext {
getCameraTargetShipId: () => string | undefined;
getNetworkStats: () => NetworkStats;
getSystemSummaryVisuals: () => Map<string, unknown>;
errorEl: HTMLDivElement;
opsStripEl: HTMLDivElement;
detailTitleEl: HTMLHeadingElement;
detailBodyEl: HTMLDivElement;
hudState: ViewerHudState;
worldLabel: () => string;
rebuildSystems: (systems: SystemSnapshot[]) => void;
syncCelestials: (celestials: CelestialSnapshot[]) => void;
@@ -83,14 +81,15 @@ export class ViewerWorldLifecycle {
this.context.setWorld(createWorldState(snapshot));
this.context.getNetworkStats().snapshotBytes = new Blob([JSON.stringify(snapshot)]).size;
this.context.updateGamePanel("Bootstrapped");
this.context.errorEl.hidden = true;
this.context.hudState.error.hidden = true;
this.context.hudState.error.message = "";
this.applySnapshot(snapshot);
this.openDeltaStream(snapshot.sequence);
this.updatePanels();
} catch (error) {
this.context.updateGamePanel("Backend offline");
this.context.errorEl.hidden = false;
this.context.errorEl.textContent = error instanceof Error ? error.message : "Unable to bootstrap the backend snapshot.";
this.context.hudState.error.hidden = false;
this.context.hudState.error.message = error instanceof Error ? error.message : "Unable to bootstrap the backend snapshot.";
}
}
@@ -188,7 +187,7 @@ export class ViewerWorldLifecycle {
}
rebuildFactions(_factions: FactionSnapshot[]) {
this.context.opsStripEl.innerHTML = renderOpsStrip(
this.context.hudState.opsStrip = buildOpsStripState(
this.context.getWorld(),
this.context.getSelectedItems(),
this.context.getCameraMode(),
@@ -207,7 +206,7 @@ export class ViewerWorldLifecycle {
this.context.refreshHistoryWindows();
this.context.updateSystemPanel();
this.refreshStreamScopeIfNeeded();
updateDetailPanel(this.context.detailTitleEl, this.context.detailBodyEl, {
const detailState = buildDetailPanelState({
world,
selectedItems: this.context.getSelectedItems(),
povLevel: this.context.getPovLevel(),
@@ -216,6 +215,9 @@ export class ViewerWorldLifecycle {
worldLabel: this.context.worldLabel(),
describeSelectionParent: this.context.describeSelectionParent,
});
this.context.hudState.detailPanel.hidden = false;
this.context.hudState.detailPanel.title = detailState.title;
this.context.hudState.detailPanel.bodyHtml = detailState.bodyHtml;
}
private getPreferredStreamScope() {

View File

@@ -68,8 +68,6 @@ export interface WorldPresentationContext extends WorldOrbitalContext {
}
export interface GameStatusParams {
statusEl: HTMLDivElement;
summaryEl?: HTMLSpanElement;
world?: WorldState;
activeSystemId?: string;
cameraMode: CameraMode;
@@ -269,8 +267,8 @@ function fmtVec(v: THREE.Vector3, digits: number) {
return `${v.x.toFixed(digits)} ${v.y.toFixed(digits)} ${v.z.toFixed(digits)}`;
}
export function updateGameStatus(params: GameStatusParams) {
const { statusEl, summaryEl, world, activeSystemId, cameraMode, povLevel, selectedItems, mode, galaxyAnchor, systemAnchor } = params;
export function describeGameStatus(params: GameStatusParams) {
const { world, activeSystemId, cameraMode, povLevel, selectedItems, mode, galaxyAnchor, systemAnchor } = params;
const sequence = world?.sequence ?? 0;
const generatedAt = world?.generatedAtUtc
? new Date(world.generatedAtUtc).toLocaleTimeString()
@@ -296,7 +294,8 @@ export function updateGameStatus(params: GameStatusParams) {
? `loc pos: ${fmtVec(systemAnchor.clone().sub(toThreeVector(celestialAnchor)), 0)} km`
: "";
statusEl.textContent = [
return {
bodyText: [
`mode: ${mode}`,
`camera: ${cameraModeLabel}`,
`zoom: ${displayPovLevel}`,
@@ -306,9 +305,16 @@ export function updateGameStatus(params: GameStatusParams) {
locPos,
`sequence: ${sequence}`,
`snapshot: ${generatedAt}`,
].filter(Boolean).join("\n");
if (summaryEl) {
summaryEl.textContent = `${mode} | ${displayPovLevel} | ${activeSpace}`;
].filter(Boolean).join("\n"),
summaryText: `${mode} | ${displayPovLevel} | ${activeSpace}`,
};
}
export function updateGameStatus(params: GameStatusParams & { statusEl: HTMLDivElement; summaryEl?: HTMLSpanElement }) {
const state = describeGameStatus(params);
params.statusEl.textContent = state.bodyText;
if (params.summaryEl) {
params.summaryEl.textContent = state.summaryText;
}
}

View File

@@ -9,7 +9,8 @@
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"skipLibCheck": true
"skipLibCheck": true,
"types": ["vite/client"]
},
"include": ["src", "vite.config.ts"]
}

View File

@@ -1,8 +1,11 @@
import tailwindcss from "@tailwindcss/vite";
import vue from "@vitejs/plugin-vue";
import { defineConfig } from "vite";
const root = new URL(".", import.meta.url).pathname;
export default defineConfig({
plugins: [tailwindcss(), vue()],
root,
server: {
host: true,