Complete universe model migration

This commit is contained in:
2026-04-07 14:16:59 -04:00
parent d0c6e30304
commit 6c92ab50c8
76 changed files with 2061 additions and 1072 deletions

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { storeToRefs } from "pinia";
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { GameViewer } from "./GameViewer";
import ViewerHistoryLayer from "./components/ViewerHistoryLayer.vue";
import ViewerEntityBrowserPanel from "./components/ViewerEntityBrowserPanel.vue";
@@ -31,8 +31,8 @@ const authStore = useAuthStore();
const playerFactionStore = usePlayerFactionStore();
const automationCatalogStore = useShipAutomationCatalogStore();
const selectionStore = useViewerSelectionStore();
const { selectedEntityId, selectedEntityLabel } = storeToRefs(selectionStore);
const { canAccessGm, effectivePlayerId } = storeToRefs(authStore);
const { selectedEntityId } = storeToRefs(selectionStore);
const { canAccessGm, effectivePlayerId, isActingAsAlternateIdentity } = storeToRefs(authStore);
const { playerFaction } = storeToRefs(playerFactionStore);
let viewer: GameViewer | undefined;
@@ -42,14 +42,27 @@ const gmSettingsOpen = ref(false);
const gmMenuOpen = ref(false);
const leftSidebarTab = ref<"player" | "entities">("player");
const playerContextReady = ref(false);
const rightSidebarWidth = ref(380);
const rightSidebarResizing = ref(false);
const shouldShowOnboarding = computed(() =>
!!playerContextReady.value
&& !!playerFaction.value?.requiresOnboarding
&& (!canAccessGm.value || isActingAsAlternateIdentity.value),
);
onMounted(async () => {
window.addEventListener("pointermove", onWindowPointerMove);
window.addEventListener("pointerup", stopRightSidebarResize);
window.addEventListener("pointercancel", stopRightSidebarResize);
void automationCatalogStore.load();
await refreshPlayerContext();
await startViewerIfAuthenticated();
});
onBeforeUnmount(() => {
window.removeEventListener("pointermove", onWindowPointerMove);
window.removeEventListener("pointerup", stopRightSidebarResize);
window.removeEventListener("pointercancel", stopRightSidebarResize);
viewer?.dispose();
});
@@ -71,7 +84,7 @@ watch(
);
watch(
() => playerFaction.value?.requiresOnboarding ?? false,
() => shouldShowOnboarding.value,
async (requiresOnboarding) => {
if (requiresOnboarding) {
viewer?.dispose();
@@ -101,8 +114,31 @@ function onFocusSelection(selection: Selectable, cameraMode?: "follow" | "tactic
viewer?.focusSelection(selection, cameraMode);
}
function startRightSidebarResize(event: PointerEvent) {
if (window.innerWidth <= 760 || event.button !== 0) {
return;
}
rightSidebarResizing.value = true;
event.preventDefault();
}
function onWindowPointerMove(event: PointerEvent) {
if (!rightSidebarResizing.value) {
return;
}
const minWidth = 280;
const maxWidth = Math.min(720, Math.max(window.innerWidth - 240, minWidth));
rightSidebarWidth.value = Math.min(maxWidth, Math.max(minWidth, window.innerWidth - event.clientX));
}
function stopRightSidebarResize() {
rightSidebarResizing.value = false;
}
async function startViewerIfAuthenticated() {
if (!authStore.isAuthenticated || viewer || !playerContextReady.value || playerFaction.value?.requiresOnboarding) {
if (!authStore.isAuthenticated || viewer || !playerContextReady.value || shouldShowOnboarding.value) {
return;
}
@@ -155,7 +191,7 @@ async function refreshPlayerContext() {
<p>Loading your in-universe identity and ownership state.</p>
</div>
</div>
<PlayerOnboardingPanel v-else-if="playerContextReady && playerFaction?.requiresOnboarding" />
<PlayerOnboardingPanel v-else-if="shouldShowOnboarding" />
<div v-else class="viewer-app">
<div
ref="canvasHostEl"
@@ -232,27 +268,28 @@ async function refreshPlayerContext() {
</div>
</div>
<div class="absolute right-5 top-5 flex max-h-[calc(100vh-40px)] w-[min(380px,calc(100vw-40px))] flex-col gap-4 overflow-hidden 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">
<ViewerEntityInspectorPanel
class="min-h-0 flex-1"
:fallback-title="hudState.detailPanel.title"
:fallback-html="hudState.detailPanel.bodyHtml"
@focus="(selection, cameraMode) => onFocusSelection(selection, cameraMode)"
/>
<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 class="viewer-right-sidebar-dock" :style="{ width: `${rightSidebarWidth}px` }">
<section class="viewer-right-sidebar pointer-events-auto">
<div
class="viewer-right-sidebar__resize-handle"
:class="rightSidebarResizing ? 'viewer-right-sidebar__resize-handle--active' : ''"
@pointerdown="startRightSidebarResize"
/>
<div class="viewer-right-sidebar__body">
<ViewerEntityInspectorPanel
class="viewer-right-sidebar__panel"
:fallback-title="hudState.detailPanel.title"
:fallback-html="hudState.detailPanel.bodyHtml"
@focus="(selection, cameraMode) => onFocusSelection(selection, cameraMode)"
/>
</div>
<div
class="viewer-right-sidebar__error"
:hidden="hudState.error.hidden"
>
{{ hudState.error.message }}
</div>
</section>
</div>
<div ref="historyLayerHostEl">

View File

@@ -8,7 +8,7 @@ import { updatePanFromKeyboard } from "./viewerCamera";
import { setShellReticleOpacity } from "./viewerControls";
import { renderFrame, resizeViewer, stepCamera } from "./viewerRenderLoop";
import { updateSystemStarPresentation } from "./viewerPresentation";
import { resolveFocusedCelestialId } from "./viewerSelection";
import { resolveFocusedAnchorId } from "./viewerSelection";
import { describeSelectionParent } from "./viewerPanels";
import {
createInitialNetworkStats,
@@ -195,6 +195,7 @@ export class ViewerAppController {
return this.sceneDataController.createWorldPresentationContext({
world: this.world,
activeSystemId: this.activeSystemId,
focusedAnchorId: this.resolveFocusedAnchorId(),
cameraMode: this.cameraMode,
povLevel: this.povLevel,
orbitYaw: this.orbitYaw,
@@ -284,6 +285,7 @@ export class ViewerAppController {
this.sceneStore.setViewContext(this.activeSystemId ?? null, this.povLevel);
}
this.navigationController.updateActiveSystem();
this.navigationController.syncGalaxyAnchorToActiveSystem();
if (this.cameraMode === "follow" && this.navigationController.updateFollowCamera(delta)) {
// Follow camera directly controls systemLayer.camera in updateFollowCamera.
@@ -350,8 +352,8 @@ export class ViewerAppController {
this.interactionController.refreshHistoryWindows();
}
private resolveFocusedCelestialId() {
return resolveFocusedCelestialId(this.world, this.selectedItems);
private resolveFocusedAnchorId() {
return resolveFocusedAnchorId(this.world, this.selectedItems);
}
private onResize(width: number, height: number) {

View File

@@ -28,7 +28,7 @@ import type {
export interface WorldStreamScope {
scopeKind?: string;
systemId?: string | null;
bubbleId?: string | null;
anchorId?: string | null;
}
async function fetchJson<T>(input: RequestInfo | URL, init?: RequestInit, options?: { skipAuth?: boolean; skipRefresh?: boolean }): Promise<T> {
@@ -105,8 +105,8 @@ export function openWorldStream(
if (scope?.systemId) {
query.set("systemId", scope.systemId);
}
if (scope?.bubbleId) {
query.set("bubbleId", scope.bubbleId);
if (scope?.anchorId) {
query.set("anchorId", scope.anchorId);
}
const stream = new EventSource(`/api/world/stream?${query.toString()}`);

View File

@@ -115,12 +115,13 @@ function formatShipLocation(ship: ShipSnapshot) {
return `Docked ${dockedStation.label}`;
}
if (ship.spatialState.transit?.destinationNodeId) {
return `Transit ${ship.systemId}`;
const transitAnchorId = ship.spatialState.transit?.destinationAnchorId ?? ship.spatialState.destinationAnchorId;
if (transitAnchorId) {
return `Transit ${titleCase(transitAnchorId)}`;
}
if (ship.celestialId) {
return `Orbit ${titleCase(ship.celestialId)}`;
if (ship.spatialState.currentAnchorId) {
return `Anchor ${compactAnchorId(ship.spatialState.currentAnchorId)}`;
}
const system = systemById.value.get(ship.systemId);
@@ -129,13 +130,32 @@ function formatShipLocation(ship: ShipSnapshot) {
function formatStationLocation(station: StationSnapshot) {
const system = systemById.value.get(station.systemId);
if (station.celestialId) {
return `${system?.label ?? station.systemId} · ${titleCase(station.celestialId)}`;
if (station.anchorId) {
return `${system?.label ?? station.systemId} · ${compactAnchorId(station.anchorId)}`;
}
return system?.label ?? station.systemId;
}
function compactAnchorId(value: string) {
const lagrangeMatch = value.match(/(l[1-5])$/i);
if (lagrangeMatch) {
return lagrangeMatch[1].toUpperCase();
}
const moonMatch = value.match(/moon-(\d+)$/i);
if (moonMatch) {
return `Moon ${moonMatch[1]}`;
}
const planetMatch = value.match(/planet-(\d+)$/i);
if (planetMatch) {
return `Planet ${planetMatch[1]}`;
}
return titleCase(value);
}
function shipAiStates(ship: ShipSnapshot) {
const travelToken = ship.spatialState.transit ? "TRV" : "";
const dockToken = ship.dockedStationId ? "DCK" : "";

View File

@@ -80,6 +80,14 @@ function formatPercent(value: number) {
return `${Math.round(value * 100)}%`;
}
function formatCargoTypeLabel(types: string[] | null | undefined) {
if (!types || types.length === 0) {
return "general";
}
return types.join(" / ");
}
function joinDetail(parts: Array<string | null | undefined>) {
return parts.filter((part): part is string => !!part && part.trim().length > 0).join(" · ");
}
@@ -88,7 +96,7 @@ function describeOrderTarget(order: {
itemId?: string | null;
targetEntityId?: string | null;
targetSystemId?: string | null;
nodeId?: string | null;
anchorId?: string | null;
constructionSiteId?: string | null;
sourceStationId?: string | null;
destinationStationId?: string | null;
@@ -97,7 +105,7 @@ function describeOrderTarget(order: {
return order.itemId
?? order.targetEntityId
?? order.targetSystemId
?? order.nodeId
?? order.anchorId
?? order.constructionSiteId
?? order.destinationStationId
?? order.sourceStationId
@@ -109,13 +117,15 @@ function describeSubTaskTarget(subTask: {
itemId?: string | null;
targetEntityId?: string | null;
targetSystemId?: string | null;
targetNodeId?: string | null;
targetAnchorId?: string | null;
targetResourceNodeId?: string | null;
moduleId?: string | null;
}) {
return subTask.itemId
?? subTask.targetEntityId
?? subTask.targetSystemId
?? subTask.targetNodeId
?? subTask.targetAnchorId
?? subTask.targetResourceNodeId
?? subTask.moduleId
?? "—";
}
@@ -184,8 +194,14 @@ const shipStatusRows = computed(() => {
return [];
}
const shipLocation = selectedShip.value.spatialState.currentAnchorId
?? selectedShip.value.anchorId
?? selectedShip.value.systemId
?? "unknown";
return [
{ label: "State", value: titleCase(selectedShip.value.state) },
{ label: "Location", value: shipLocation },
{ label: "Behavior", value: getShipBehaviorLabel(selectedShip.value.defaultBehavior.kind) },
{ label: "Control", value: titleCase(selectedShip.value.controlSourceKind) },
{ label: "Assignment", value: selectedShip.value.assignment?.kind ?? "unassigned" },
@@ -201,20 +217,21 @@ const shipStatusRows = computed(() => {
];
});
const shipCargoSummaryRows = computed(() => {
const shipCargoBarRows = computed(() => {
if (!selectedShip.value) {
return [];
}
const usedCargo = selectedShip.value.inventory.reduce((sum, entry) => sum + entry.amount, 0);
return [
{ label: "Used", value: formatAmount(usedCargo) },
{ label: "Capacity", value: formatAmount(selectedShip.value.cargoCapacity) },
{ label: "Free", value: formatAmount(Math.max(selectedShip.value.cargoCapacity - usedCargo, 0)) },
{ label: "Travel", value: `${formatAmount(selectedShip.value.travelSpeed)} ${selectedShip.value.travelSpeedUnit}` },
{ label: "Hull", value: formatAmount(selectedShip.value.health) },
{ label: "Regime", value: titleCase(selectedShip.value.spatialState.movementRegime) },
];
return [{
key: "cargo",
label: `${formatCargoTypeLabel(selectedShip.value.cargoTypes)}`,
value: usedCargo,
valueLabel: formatAmount(usedCargo),
max: selectedShip.value.cargoCapacity,
maxLabel: formatAmount(selectedShip.value.cargoCapacity),
fillRatio: selectedShip.value.cargoCapacity > 0 ? usedCargo / selectedShip.value.cargoCapacity : 0,
}];
});
const shipCargoRows = computed(() =>
@@ -309,8 +326,13 @@ const stationStatusRows = computed(() => {
return [];
}
const stationLocation = selectedStation.value.anchorId
?? selectedStation.value.systemId
?? "unknown";
return [
{ label: "Category", value: titleCase(selectedStation.value.category) },
{ label: "Location", value: stationLocation },
{ label: "Objective", value: titleCase(selectedStation.value.objective) },
{ label: "Docked", value: `${selectedStation.value.dockedShips} / ${selectedStation.value.dockingPads}` },
{
@@ -335,10 +357,12 @@ const stationModuleRows = computed(() =>
const stationStorageRows = computed(() =>
selectedStation.value?.storageUsage.map((entry) => ({
key: entry.storageClass,
storageClass: titleCase(entry.storageClass),
used: formatAmount(entry.used),
capacity: formatAmount(entry.capacity),
fill: entry.capacity > 0 ? formatPercent(entry.used / entry.capacity) : "0%",
label: titleCase(entry.storageClass),
value: entry.used,
valueLabel: formatAmount(entry.used),
max: entry.capacity,
maxLabel: formatAmount(entry.capacity),
fillRatio: entry.capacity > 0 ? entry.used / entry.capacity : 0,
})) ?? [],
);
@@ -429,7 +453,7 @@ async function saveBehavior() {
itemId: behaviorForm.kind === "local-auto-mine"
? (behaviorForm.itemId.trim() || null)
: null,
preferredNodeId: null,
preferredAnchorId: null,
preferredConstructionSiteId: null,
preferredModuleId: null,
targetPosition: null,
@@ -461,7 +485,7 @@ async function queueHoldPositionOrder() {
sourceStationId: null,
destinationStationId: null,
itemId: null,
nodeId: null,
anchorId: null,
constructionSiteId: null,
moduleId: null,
waitSeconds: 0,
@@ -497,7 +521,7 @@ async function queueMoveOrder() {
sourceStationId: null,
destinationStationId: null,
itemId: null,
nodeId: null,
anchorId: null,
constructionSiteId: null,
moduleId: null,
waitSeconds: 0,
@@ -540,7 +564,7 @@ async function queueMineResourceOrder() {
sourceStationId: null,
destinationStationId: null,
itemId,
nodeId: null,
anchorId: null,
constructionSiteId: null,
moduleId: null,
waitSeconds: 0,
@@ -609,15 +633,20 @@ async function clearOrders() {
<div class="entity-inspector-section">
<h4>Cargo</h4>
<div class="entity-inspector-table-wrap">
<table class="entity-inspector-table entity-inspector-table--kv">
<tbody>
<tr v-for="row in shipCargoSummaryRows" :key="row.label">
<th scope="row">{{ row.label }}</th>
<td>{{ row.value }}</td>
</tr>
</tbody>
</table>
<div class="entity-inspector-capacity-list">
<div v-for="row in shipCargoBarRows" :key="row.key" class="entity-inspector-capacity">
<div class="entity-inspector-capacity__header">
<span class="entity-inspector-capacity__label">{{ row.label }}</span>
<span class="entity-inspector-capacity__value">{{ row.valueLabel }} / {{ row.maxLabel }}</span>
</div>
<div class="entity-inspector-capacity__scale">
<span>0</span>
<div class="entity-inspector-capacity__track">
<div class="entity-inspector-capacity__fill" :style="{ width: `${Math.max(0, Math.min(100, row.fillRatio * 100))}%` }"></div>
</div>
<span>{{ row.maxLabel }}</span>
</div>
</div>
</div>
<div v-if="shipCargoRows.length > 0" class="entity-inspector-table-wrap">
<table class="entity-inspector-table">
@@ -635,7 +664,7 @@ async function clearOrders() {
</tbody>
</table>
</div>
<div v-else class="entity-inspector-empty">No cargo.</div>
<div v-else class="entity-inspector-empty">No wares loaded.</div>
</div>
<div class="entity-inspector-section">
@@ -856,25 +885,20 @@ async function clearOrders() {
<div class="entity-inspector-section">
<h4>Storage</h4>
<div v-if="stationStorageRows.length > 0" class="entity-inspector-table-wrap">
<table class="entity-inspector-table">
<thead>
<tr>
<th scope="col">Class</th>
<th scope="col" class="entity-inspector-table__numeric">Used</th>
<th scope="col" class="entity-inspector-table__numeric">Capacity</th>
<th scope="col" class="entity-inspector-table__numeric">Fill</th>
</tr>
</thead>
<tbody>
<tr v-for="row in stationStorageRows" :key="row.key">
<td>{{ row.storageClass }}</td>
<td class="entity-inspector-table__numeric">{{ row.used }}</td>
<td class="entity-inspector-table__numeric">{{ row.capacity }}</td>
<td class="entity-inspector-table__numeric">{{ row.fill }}</td>
</tr>
</tbody>
</table>
<div v-if="stationStorageRows.length > 0" class="entity-inspector-capacity-list">
<div v-for="row in stationStorageRows" :key="row.key" class="entity-inspector-capacity">
<div class="entity-inspector-capacity__header">
<span class="entity-inspector-capacity__label">{{ row.label }}</span>
<span class="entity-inspector-capacity__value">{{ row.valueLabel }} / {{ row.maxLabel }}</span>
</div>
<div class="entity-inspector-capacity__scale">
<span>0</span>
<div class="entity-inspector-capacity__track">
<div class="entity-inspector-capacity__fill" :style="{ width: `${Math.max(0, Math.min(100, row.fillRatio * 100))}%` }"></div>
</div>
<span>{{ row.maxLabel }}</span>
</div>
</div>
</div>
<div v-if="stationInventoryRows.length > 0" class="entity-inspector-table-wrap">
<table class="entity-inspector-table">

View File

@@ -157,7 +157,7 @@ async function runAction(action: MenuAction) {
sourceStationId: null,
destinationStationId: null,
itemId,
nodeId: target.value.selection.kind === "node" ? target.value.selection.id : null,
anchorId: target.value.anchorId ?? null,
constructionSiteId: null,
moduleId: null,
waitSeconds: 0,
@@ -182,7 +182,7 @@ async function runAction(action: MenuAction) {
sourceStationId: null,
destinationStationId: null,
itemId: null,
nodeId: null,
anchorId: null,
constructionSiteId: null,
moduleId: null,
waitSeconds: 8,
@@ -207,7 +207,7 @@ async function runAction(action: MenuAction) {
sourceStationId: null,
destinationStationId: null,
itemId: null,
nodeId: null,
anchorId: null,
constructionSiteId: null,
moduleId: null,
waitSeconds: 6,
@@ -232,7 +232,7 @@ async function runAction(action: MenuAction) {
sourceStationId: null,
destinationStationId: null,
itemId: null,
nodeId: null,
anchorId: null,
constructionSiteId: null,
moduleId: null,
waitSeconds: 0,

View File

@@ -149,13 +149,6 @@ function compactRate(value: number | null | undefined) {
return `${sign}${value.toFixed(2)}/s`;
}
function formatCargoAmount(value: number | null | undefined) {
if (value == null || Number.isNaN(value)) return "—";
const rounded = Math.round(value);
if (Math.abs(value - rounded) < 0.005) return String(rounded);
return value.toFixed(2).replace(/\.?0+$/, "");
}
function formatPercent(value: number | null | undefined) {
if (value == null || Number.isNaN(value)) return "—";
return `${Math.round(value * 100)}%`;
@@ -281,8 +274,6 @@ type ShipRow = {
plan: string;
step: string;
subtask: string;
cargo: number;
health: number;
};
const shipRows = computed<ShipRow[]>(() =>
@@ -305,8 +296,6 @@ const shipRows = computed<ShipRow[]>(() =>
plan: s.activePlan ? `${titleCaseToken(s.activePlan.kind)} · ${titleCaseToken(s.activePlan.status)}` : "—",
step: currentStep ? `${titleCaseToken(currentStep.kind)} · ${titleCaseToken(currentStep.status)}` : "—",
subtask: currentSubTask ? `${titleCaseToken(currentSubTask.kind)} ${Math.round(currentSubTask.progress * 100)}%` : "—",
cargo: s.inventory.reduce((sum, e) => sum + e.amount, 0),
health: Math.round(s.health),
};
}),
);
@@ -329,16 +318,11 @@ const shipColumns = [
shipColumnHelper.accessor("plan", { header: "Plan" }),
shipColumnHelper.accessor("step", { header: "Current Step" }),
shipColumnHelper.accessor("subtask", { header: "SubTask" }),
shipColumnHelper.accessor("cargo", {
header: "Cargo",
cell: (info) => formatCargoAmount(info.getValue()),
}),
shipColumnHelper.accessor("health", { header: "HP" }),
];
const shipFilter = ref("");
const shipSorting = ref<SortingState>([]);
const shipOrder = useColumnOrder(["label", "class", "factionColor", "faction", "system", "state", "assignment", "behavior", "orders", "plan", "step", "subtask", "cargo", "health"]);
const shipOrder = useColumnOrder(["label", "class", "factionColor", "faction", "system", "state", "assignment", "behavior", "orders", "plan", "step", "subtask"]);
const shipTable = useVueTable({
get data() { return shipRows.value; },
@@ -373,7 +357,6 @@ type StationRow = {
docked: string;
orders: number;
orderDetails: MarketOrderSnapshot[];
cargo: number;
modules: number;
};
@@ -400,7 +383,6 @@ const stationRows = computed<StationRow[]>(() =>
const order = marketOrderMap.value.get(id);
return order ? [order] : [];
}),
cargo: s.inventory.reduce((sum, e) => sum + e.amount, 0),
modules: s.installedModules.length,
})),
);
@@ -421,16 +403,12 @@ const stationColumns = [
stationColumnHelper.accessor("workforce", { header: "Workforce" }),
stationColumnHelper.accessor("docked", { header: "Docked" }),
stationColumnHelper.accessor("orders", { header: "Orders" }),
stationColumnHelper.accessor("cargo", {
header: "Cargo",
cell: (info) => formatCargoAmount(info.getValue()),
}),
stationColumnHelper.accessor("modules", { header: "Modules" }),
];
const stationFilter = ref("");
const stationSorting = ref<SortingState>([]);
const stationOrder = useColumnOrder(["label", "category", "objective", "factionColor", "faction", "system", "process", "workforce", "docked", "orders", "cargo", "modules"]);
const stationOrder = useColumnOrder(["label", "category", "objective", "factionColor", "faction", "system", "process", "workforce", "docked", "orders", "modules"]);
const stationTable = useVueTable({
get data() { return stationRows.value; },

View File

@@ -251,7 +251,7 @@ const behaviorForm = reactive({
areaSystemId: "",
targetEntityId: "",
itemId: "",
preferredNodeId: "",
preferredAnchorId: "",
preferredConstructionSiteId: "",
preferredModuleId: "",
waitSeconds: 3,
@@ -268,7 +268,7 @@ const orderForm = reactive({
targetEntityId: "",
targetSystemId: "",
itemId: "",
nodeId: "",
anchorId: "",
constructionSiteId: "",
moduleId: "",
waitSeconds: 3,
@@ -344,7 +344,7 @@ watch(selectedShip, (ship) => {
behaviorForm.areaSystemId = ship.defaultBehavior.areaSystemId ?? ship.systemId;
behaviorForm.targetEntityId = ship.defaultBehavior.targetEntityId ?? "";
behaviorForm.itemId = ship.defaultBehavior.itemId ?? "";
behaviorForm.preferredNodeId = ship.defaultBehavior.preferredNodeId ?? "";
behaviorForm.preferredAnchorId = ship.defaultBehavior.preferredAnchorId ?? "";
behaviorForm.preferredConstructionSiteId = ship.defaultBehavior.preferredConstructionSiteId ?? "";
behaviorForm.preferredModuleId = ship.defaultBehavior.preferredModuleId ?? "";
behaviorForm.waitSeconds = ship.defaultBehavior.waitSeconds;
@@ -484,7 +484,7 @@ async function submitDirective() {
sourceStationId: null,
destinationStationId: null,
itemId: directiveForm.itemId || null,
preferredNodeId: null,
preferredAnchorId: null,
preferredConstructionSiteId: null,
preferredModuleId: null,
priority: directiveForm.priority,
@@ -612,7 +612,7 @@ async function submitDirectBehavior() {
areaSystemId: behaviorForm.areaSystemId || null,
targetEntityId: behaviorForm.targetEntityId || null,
itemId: behaviorForm.itemId || null,
preferredNodeId: behaviorForm.preferredNodeId || null,
preferredAnchorId: behaviorForm.preferredAnchorId || null,
preferredConstructionSiteId: behaviorForm.preferredConstructionSiteId || null,
preferredModuleId: behaviorForm.preferredModuleId || null,
targetPosition: null,
@@ -646,7 +646,7 @@ async function submitDirectOrder() {
sourceStationId: null,
destinationStationId: null,
itemId: orderForm.itemId || null,
nodeId: orderForm.nodeId || null,
anchorId: orderForm.anchorId || null,
constructionSiteId: orderForm.constructionSiteId || null,
moduleId: orderForm.moduleId || null,
waitSeconds: orderForm.waitSeconds,
@@ -706,7 +706,7 @@ async function submitDirectOrder() {
<label><span>Area System</span><input v-model="behaviorForm.areaSystemId" type="text"></label>
<label><span>Target Entity</span><input v-model="behaviorForm.targetEntityId" type="text"></label>
<label><span>Item</span><input v-model="behaviorForm.itemId" type="text"></label>
<label><span>Preferred Node</span><input v-model="behaviorForm.preferredNodeId" type="text"></label>
<label><span>Preferred Anchor</span><input v-model="behaviorForm.preferredAnchorId" type="text"></label>
<label><span>Construction Site</span><input v-model="behaviorForm.preferredConstructionSiteId" type="text"></label>
<label><span>Module</span><input v-model="behaviorForm.preferredModuleId" type="text"></label>
<label><span>Radius</span><input v-model.number="behaviorForm.radius" type="number" min="0" step="1"></label>
@@ -723,7 +723,7 @@ async function submitDirectOrder() {
<label><span>Target System</span><input v-model="orderForm.targetSystemId" type="text"></label>
<label><span>Target Entity</span><input v-model="orderForm.targetEntityId" type="text"></label>
<label><span>Item</span><input v-model="orderForm.itemId" type="text"></label>
<label><span>Node</span><input v-model="orderForm.nodeId" type="text"></label>
<label><span>Anchor</span><input v-model="orderForm.anchorId" type="text"></label>
<label><span>Construction Site</span><input v-model="orderForm.constructionSiteId" type="text"></label>
<label><span>Module</span><input v-model="orderForm.moduleId" type="text"></label>
<label><span>Priority</span><input v-model.number="orderForm.priority" type="number" min="0" step="1"></label>

View File

@@ -7,10 +7,13 @@ export type {
OrbitalSimulationSnapshot,
} from "./contractsWorld";
export type {
AnchorSnapshot,
AnchorDelta,
StarSnapshot,
MoonSnapshot,
SystemSnapshot,
PlanetSnapshot,
ResourceDepositSnapshot,
ResourceNodeSnapshot,
ResourceNodeDelta,
CelestialSnapshot,

View File

@@ -46,26 +46,50 @@ export interface PlanetSnapshot {
hasRing: boolean;
}
export interface ResourceDepositSnapshot {
id: string;
nodeId: string;
anchorId: string;
localPosition: Vector3Dto;
oreRemaining: number;
maxOre: number;
}
export interface ResourceNodeSnapshot {
id: string;
anchorId: string;
systemId: string;
localPosition: Vector3Dto;
celestialId?: string | null;
localSpaceRadius: number;
sourceKind: string;
oreRemaining: number;
maxOre: number;
itemId: string;
deposits: ResourceDepositSnapshot[];
}
export interface ResourceNodeDelta extends ResourceNodeSnapshot {}
export interface AnchorSnapshot {
id: string;
systemId: string;
kind: string;
systemPosition: Vector3Dto;
localSpaceRadius: number;
parentAnchorId?: string | null;
occupyingStructureId?: string | null;
orbitReferenceId?: string | null;
}
export interface AnchorDelta extends AnchorSnapshot {}
export interface CelestialSnapshot {
id: string;
systemId: string;
kind: string;
orbitalAnchor: Vector3Dto;
localSpaceRadius: number;
parentNodeId?: string | null;
parentAnchorId?: string | null;
occupyingStructureId?: string | null;
orbitReferenceId?: string | null;
}

View File

@@ -93,7 +93,7 @@ export interface TerritoryClaimSnapshot {
sourceClaimId?: string | null;
factionId: string;
systemId: string;
celestialId?: string | null;
anchorId: string;
status: string;
claimKind: string;
claimStrength: number;

View File

@@ -27,8 +27,8 @@ export interface StationSnapshot {
category: string;
objective: string;
systemId: string;
anchorId?: string | null;
localPosition: Vector3Dto;
celestialId?: string | null;
color: string;
dockedShips: number;
dockedShipIds: string[];
@@ -53,7 +53,7 @@ export interface ClaimSnapshot {
id: string;
factionId: string;
systemId: string;
celestialId: string;
anchorId: string;
state: string;
health: number;
placedAtUtc: string;
@@ -66,7 +66,7 @@ export interface ConstructionSiteSnapshot {
id: string;
factionId: string;
systemId: string;
celestialId: string;
anchorId: string;
targetKind: string;
targetDefinitionId: string;
blueprintId?: string | null;

View File

@@ -207,7 +207,7 @@ export interface PlayerDirectiveSnapshot {
useOrders: boolean;
stagingOrderKind?: string | null;
itemId?: string | null;
preferredNodeId?: string | null;
preferredAnchorId?: string | null;
preferredConstructionSiteId?: string | null;
preferredModuleId?: string | null;
priority: number;

View File

@@ -24,7 +24,7 @@ export interface ShipOrderSnapshot {
sourceStationId?: string | null;
destinationStationId?: string | null;
itemId?: string | null;
nodeId?: string | null;
anchorId?: string | null;
constructionSiteId?: string | null;
moduleId?: string | null;
waitSeconds: number;
@@ -43,7 +43,7 @@ export interface ShipOrderTemplateSnapshot {
sourceStationId?: string | null;
destinationStationId?: string | null;
itemId?: string | null;
nodeId?: string | null;
anchorId?: string | null;
constructionSiteId?: string | null;
moduleId?: string | null;
waitSeconds: number;
@@ -59,7 +59,7 @@ export interface DefaultBehaviorSnapshot {
areaSystemId?: string | null;
targetEntityId?: string | null;
itemId?: string | null;
preferredNodeId?: string | null;
preferredAnchorId?: string | null;
preferredConstructionSiteId?: string | null;
preferredModuleId?: string | null;
targetPosition?: Vector3Dto | null;
@@ -100,7 +100,9 @@ export interface ShipSubTaskSnapshot {
summary: string;
targetEntityId?: string | null;
targetSystemId?: string | null;
targetNodeId?: string | null;
targetAnchorId?: string | null;
targetResourceNodeId?: string | null;
targetResourceDepositId?: string | null;
targetPosition?: Vector3Dto | null;
itemId?: string | null;
moduleId?: string | null;
@@ -143,6 +145,7 @@ export interface ShipSnapshot {
purpose: string;
type: string;
systemId: string;
anchorId?: string | null;
localPosition: Vector3Dto;
localVelocity: Vector3Dto;
targetLocalPosition: Vector3Dto;
@@ -159,11 +162,11 @@ export interface ShipSnapshot {
controlReason?: string | null;
lastReplanReason?: string | null;
lastAccessFailureReason?: string | null;
celestialId?: string | null;
dockedStationId?: string | null;
commanderId?: string | null;
policySetId?: string | null;
cargoCapacity: number;
cargoTypes: string[];
travelSpeed: number;
travelSpeedUnit: string;
inventory: InventoryEntry[];
@@ -178,18 +181,18 @@ export interface ShipDelta extends ShipSnapshot {}
export interface ShipSpatialStateSnapshot {
spaceLayer: string;
currentSystemId: string;
currentCelestialId?: string | null;
currentAnchorId?: string | null;
localPosition?: Vector3Dto | null;
systemPosition?: Vector3Dto | null;
movementRegime: string;
destinationNodeId?: string | null;
destinationAnchorId?: string | null;
transit?: ShipTransitSnapshot | null;
}
export interface ShipTransitSnapshot {
regime: string;
originNodeId?: string | null;
destinationNodeId?: string | null;
originAnchorId?: string | null;
destinationAnchorId?: string | null;
startedAtUtc?: string | null;
arrivalDueAtUtc?: string | null;
progress: number;

View File

@@ -9,6 +9,8 @@ import type {
FactionSnapshot,
} from "./contractsFactions";
import type {
AnchorDelta,
AnchorSnapshot,
CelestialDelta,
CelestialSnapshot,
ResourceNodeDelta,
@@ -37,6 +39,7 @@ export interface WorldSnapshot {
generatedAtUtc: string;
systems: SystemSnapshot[];
celestials: CelestialSnapshot[];
anchors: AnchorSnapshot[];
nodes: ResourceNodeSnapshot[];
stations: import("./contractsInfrastructure").StationSnapshot[];
claims: ClaimSnapshot[];
@@ -57,6 +60,7 @@ export interface WorldDelta {
requiresSnapshotRefresh: boolean;
events: SimulationEventRecord[];
celestials: CelestialDelta[];
anchors: AnchorDelta[];
nodes: ResourceNodeDelta[];
stations: import("./contractsInfrastructure").StationDelta[];
claims: ClaimDelta[];
@@ -84,7 +88,7 @@ export interface SimulationEventRecord {
export interface ObserverScope {
scopeKind: string;
systemId?: string | null;
celestialId?: string | null;
anchorId?: string | null;
}
export interface OrbitalSimulationSnapshot {

View File

@@ -44,7 +44,7 @@ export interface PlayerDirectiveCommandRequest {
sourceStationId?: string | null;
destinationStationId?: string | null;
itemId?: string | null;
preferredNodeId?: string | null;
preferredAnchorId?: string | null;
preferredConstructionSiteId?: string | null;
preferredModuleId?: string | null;
priority: number;

View File

@@ -12,7 +12,7 @@ export interface ShipOrderCommandRequest {
sourceStationId?: string | null;
destinationStationId?: string | null;
itemId?: string | null;
nodeId?: string | null;
anchorId?: string | null;
constructionSiteId?: string | null;
moduleId?: string | null;
waitSeconds?: number | null;
@@ -28,7 +28,7 @@ export interface ShipDefaultBehaviorCommandRequest {
areaSystemId?: string | null;
targetEntityId?: string | null;
itemId?: string | null;
preferredNodeId?: string | null;
preferredAnchorId?: string | null;
preferredConstructionSiteId?: string | null;
preferredModuleId?: string | null;
targetPosition?: Vector3Dto | null;

View File

@@ -366,6 +366,74 @@ canvas {
backdrop-filter: none;
}
.viewer-right-sidebar-dock {
position: absolute;
inset: 0 0 0 auto;
width: min(380px, 100vw);
padding: 0;
}
.viewer-right-sidebar {
display: flex;
flex-direction: column;
position: relative;
height: 100%;
min-height: 0;
padding: 16px;
background:
linear-gradient(180deg, rgba(7, 14, 27, 0.9), rgba(7, 14, 27, 0.78)),
radial-gradient(circle at top right, rgba(127, 214, 255, 0.08), transparent 34%);
border-left: 1px solid rgba(132, 196, 255, 0.14);
backdrop-filter: blur(18px);
box-shadow: -18px 0 42px rgba(0, 0, 0, 0.18);
}
.viewer-right-sidebar__resize-handle {
position: absolute;
inset: 0 auto 0 -8px;
width: 16px;
cursor: ew-resize;
}
.viewer-right-sidebar__resize-handle::before {
content: "";
position: absolute;
inset: 0 6px;
background: rgba(132, 196, 255, 0.06);
transition: background 120ms ease;
}
.viewer-right-sidebar__resize-handle:hover::before,
.viewer-right-sidebar__resize-handle--active::before {
background: rgba(132, 196, 255, 0.18);
}
.viewer-right-sidebar__body {
display: flex;
flex: 1 1 auto;
min-height: 0;
overflow: hidden;
}
.viewer-right-sidebar__panel.entity-inspector-panel {
height: 100%;
padding: 0;
border: none;
border-radius: 0;
background: transparent;
box-shadow: none;
backdrop-filter: none;
}
.viewer-right-sidebar__error {
margin-top: 0.9rem;
border-radius: 1rem;
background: rgba(255, 116, 88, 0.14);
padding: 0.85rem 0.95rem;
color: #ffd8cf;
}
.viewer-stats-overlay {
font-family: "IBM Plex Mono", "SFMono-Regular", monospace;
font-size: 0.8rem;
@@ -1705,6 +1773,62 @@ canvas {
color: rgba(173, 220, 255, 0.58);
}
.entity-inspector-capacity-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.entity-inspector-capacity {
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 1rem;
background: rgba(255, 255, 255, 0.03);
padding: 0.8rem 0.9rem;
}
.entity-inspector-capacity__header,
.entity-inspector-capacity__scale {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.7rem;
}
.entity-inspector-capacity__header {
margin-bottom: 0.45rem;
}
.entity-inspector-capacity__label {
font-size: 0.74rem;
letter-spacing: 0.12em;
text-transform: uppercase;
color: rgba(173, 220, 255, 0.72);
}
.entity-inspector-capacity__value,
.entity-inspector-capacity__scale span {
font-family: var(--viewer-mono-font);
font-size: 0.76rem;
color: rgba(255, 255, 255, 0.78);
}
.entity-inspector-capacity__track {
position: relative;
flex: 1 1 auto;
min-width: 0;
height: 0.65rem;
border-radius: 999px;
background: rgba(255, 255, 255, 0.08);
overflow: hidden;
}
.entity-inspector-capacity__fill {
position: absolute;
inset: 0 auto 0 0;
border-radius: inherit;
background: linear-gradient(90deg, rgba(116, 196, 255, 0.5), rgba(116, 196, 255, 0.9));
}
.entity-inspector-panel__fallback {
margin-top: 0.9rem;
font-size: 0.83rem;
@@ -1910,6 +2034,23 @@ canvas {
padding: 14px;
}
.viewer-right-sidebar-dock {
inset: auto 20px 148px 20px;
width: auto;
max-height: 38vh;
}
.viewer-right-sidebar {
padding: 14px;
border-left: none;
border-top: 1px solid rgba(132, 196, 255, 0.14);
box-shadow: 0 -18px 42px rgba(0, 0, 0, 0.18);
}
.viewer-right-sidebar__resize-handle {
display: none;
}
.viewer-stats-overlay-dock {
top: 96px;
left: 20px;

View File

@@ -6,6 +6,7 @@ export interface ViewerOrderContextMenuTarget {
selection: Selectable;
label: string;
systemId?: string | null;
anchorId?: string | null;
itemId?: string | null;
targetPosition?: Vector3Dto | null;
}

View File

@@ -18,7 +18,7 @@ interface ResolveSelectionPositionParams {
nodeVisuals: Map<string, NodeVisual>;
planetVisuals: PlanetVisual[];
computeNodeLocalPosition: (visual: NodeVisual, timeSeconds: number) => THREE.Vector3;
resolvePointPosition: (systemId: string, celestialId?: string | null) => THREE.Vector3;
resolvePointPosition: (systemId: string, celestialId?: string | null, anchorId?: string | null) => THREE.Vector3;
}
interface FocusOnSelectionParams extends ResolveSelectionPositionParams {
@@ -47,7 +47,7 @@ interface SeedSystemFocusParams {
nodeVisuals: Map<string, NodeVisual>;
planetVisuals: PlanetVisual[];
computeNodeLocalPosition: (visual: NodeVisual, timeSeconds: number) => THREE.Vector3;
resolvePointPosition: (systemId: string, celestialId?: string | null) => THREE.Vector3;
resolvePointPosition: (systemId: string, celestialId?: string | null, anchorId?: string | null) => THREE.Vector3;
}
interface CameraFocusParams {
@@ -107,6 +107,7 @@ export function applyPanFromScreenDelta(
delta: THREE.Vector2,
orbitYaw: number,
currentDistance: number,
cameraFovDegrees: number,
povLevel: PovLevel,
activeSystemId: string | undefined,
systemAnchor: THREE.Vector3,
@@ -125,18 +126,19 @@ export function applyPanFromScreenDelta(
const forward = new THREE.Vector3(Math.cos(orbitYaw), 0, Math.sin(orbitYaw));
const right = new THREE.Vector3(-forward.z, 0, forward.x);
const pan = right.multiplyScalar(-normalized.x).add(forward.multiplyScalar(-normalized.y));
const visibleHeight = 2 * Math.tan(THREE.MathUtils.degToRad(cameraFovDegrees) * 0.5) * currentDistance;
const visibleWidth = visibleHeight * (safeWidth / safeHeight);
const horizontalDistance = normalized.x * visibleWidth;
const verticalDistance = -normalized.y * visibleHeight;
const pan = right.multiplyScalar(horizontalDistance).add(forward.multiplyScalar(verticalDistance));
if (activeSystemId) {
const scale = povLevel === "system"
? THREE.MathUtils.mapLinear(currentDistance, 80, 4000, KILOMETERS_PER_AU * 0.35, KILOMETERS_PER_AU * 6.5)
: THREE.MathUtils.mapLinear(currentDistance, minimumDistance, 120, 1200, 180000);
systemAnchor.addScaledVector(pan, scale);
const systemDisplayToKilometers = 1 / (DISPLAY_UNITS_PER_KILOMETER * ACTIVE_SYSTEM_DETAIL_SCALE);
systemAnchor.addScaledVector(pan, systemDisplayToKilometers);
return;
}
const galaxyScale = THREE.MathUtils.mapLinear(currentDistance, minimumDistance, maximumDistance, 1800, 22000);
galaxyAnchor.addScaledVector(pan, galaxyScale);
galaxyAnchor.add(pan);
}
export function determineActiveSystemId(params: DetermineActiveSystemParams): string | undefined {
@@ -235,11 +237,11 @@ export function resolveSelectionPosition(params: ResolveSelectionPositionParams)
}
if (selection.kind === "claim") {
const claim = world.claims.get(selection.id);
return claim ? resolvePointPosition(claim.systemId, claim.celestialId) : undefined;
return claim ? resolvePointPosition(claim.systemId, null, claim.anchorId) : undefined;
}
if (selection.kind === "construction-site") {
const site = world.constructionSites.get(selection.id);
return site ? resolvePointPosition(site.systemId, site.celestialId) : undefined;
return site ? resolvePointPosition(site.systemId, null, site.anchorId) : undefined;
}
if (selection.kind === "planet") {
const system = world.systems.get(selection.systemId);

View File

@@ -30,8 +30,14 @@ export function createViewerControllers(host: any) {
claimGroup: host.systemLayer.claimGroup,
constructionSiteGroup: host.systemLayer.constructionSiteGroup,
shipGroup: host.systemLayer.shipGroup,
localNodeGroup: host.localLayer.nodeGroup,
localStationGroup: host.localLayer.stationGroup,
localClaimGroup: host.localLayer.claimGroup,
localConstructionSiteGroup: host.localLayer.constructionSiteGroup,
localShipGroup: host.localLayer.shipGroup,
galaxySelectableTargets: host.galaxyLayer.selectableTargets,
systemSelectableTargets: host.systemLayer.selectableTargets,
localSelectableTargets: host.localLayer.selectableTargets,
systemVisuals: host.galaxyLayer.systemVisuals,
planetVisuals: host.systemLayer.planetVisuals,
celestialVisuals: host.systemLayer.celestialVisuals,
@@ -40,6 +46,11 @@ export function createViewerControllers(host: any) {
claimVisuals: host.systemLayer.claimVisuals,
constructionSiteVisuals: host.systemLayer.constructionSiteVisuals,
shipVisuals: host.systemLayer.shipVisuals,
localNodeVisuals: host.localLayer.nodeVisuals,
localStationVisuals: host.localLayer.stationVisuals,
localClaimVisuals: host.localLayer.claimVisuals,
localConstructionSiteVisuals: host.localLayer.constructionSiteVisuals,
localShipVisuals: host.localLayer.shipVisuals,
});
const navigationController = new ViewerNavigationController({
@@ -152,8 +163,9 @@ export function createViewerControllers(host: any) {
applyClaimDeltas: (claims) => sceneDataController.applyClaimDeltas(claims),
applyConstructionSiteDeltas: (sites) => sceneDataController.applyConstructionSiteDeltas(sites),
applyShipDeltas: (ships, tickIntervalMs) => sceneDataController.applyShipDeltas(ships, tickIntervalMs),
refreshLocalLayer: () => sceneDataController.refreshLocalLayer(host.world, host.resolveFocusedAnchorId()),
refreshHistoryWindows: () => host.refreshHistoryWindows(),
resolveFocusedCelestialId: () => host.resolveFocusedCelestialId(),
resolveFocusedAnchorId: () => host.resolveFocusedAnchorId(),
updateSystemSummaries: () => host.updateSystemSummaries(),
applyZoomPresentation: () => presentationController.applyZoomPresentation(),
updateNetworkPanel: () => presentationController.updateNetworkPanel(),
@@ -191,8 +203,10 @@ export function createViewerControllers(host: any) {
mouse: host.mouse,
galaxyCamera: host.galaxyLayer.camera,
systemCamera: host.systemLayer.camera,
localCamera: host.localLayer.camera,
galaxySelectableTargets: host.galaxyLayer.selectableTargets,
systemSelectableTargets: host.systemLayer.selectableTargets,
localSelectableTargets: host.localLayer.selectableTargets,
hoverLabelEl: host.hoverLabelEl,
hoverConnectorLineEl: host.hoverConnectorLineEl,
marqueeEl: host.marqueeEl,
@@ -244,6 +258,9 @@ export function createViewerControllers(host: any) {
delta,
host.orbitYaw,
host.currentDistance,
host.activeSystemId
? (host.povLevel === "local" ? host.localLayer.camera.fov : host.systemLayer.camera.fov)
: host.galaxyLayer.camera.fov,
host.povLevel,
host.activeSystemId,
host.systemAnchor,

View File

@@ -148,9 +148,11 @@ export function updateFollowCamera(params: {
if (ship.spatialState.movementRegime === "ftl-transit") {
systemAnchor.set(0, 0, 0);
const destinationNodeId = ship.spatialState.transit?.destinationNodeId;
const destinationCelestial = destinationNodeId ? world.celestials.get(destinationNodeId) : undefined;
const destinationSystem = destinationCelestial ? world.systems.get(destinationCelestial.systemId) : undefined;
const destinationAnchorId = ship.spatialState.transit?.destinationAnchorId ?? ship.spatialState.destinationAnchorId;
const destinationAnchor = destinationAnchorId ? world.anchors.get(destinationAnchorId) : undefined;
const destinationSystem = destinationAnchor
? world.systems.get(destinationAnchor.systemId)
: undefined;
const originSystem = world.systems.get(ship.systemId);
if (originSystem && destinationSystem) {
followCameraDesiredDirection

View File

@@ -36,14 +36,17 @@ export function pickSelectableAtClientPosition(
renderer: THREE.WebGLRenderer,
raycaster: THREE.Raycaster,
mouse: THREE.Vector2,
povLevel: PovLevel,
galaxyCamera: THREE.Camera,
galaxySelectableTargets: Map<THREE.Object3D, Selectable>,
systemCamera: THREE.Camera,
systemSelectableTargets: Map<THREE.Object3D, Selectable>,
localCamera: THREE.Camera,
localSelectableTargets: Map<THREE.Object3D, Selectable>,
clientX: number,
clientY: number,
) {
const hit = pickSelectableHitAtClientPosition(renderer, raycaster, mouse, galaxyCamera, galaxySelectableTargets, systemCamera, systemSelectableTargets, clientX, clientY);
const hit = pickSelectableHitAtClientPosition(renderer, raycaster, mouse, povLevel, galaxyCamera, galaxySelectableTargets, systemCamera, systemSelectableTargets, localCamera, localSelectableTargets, clientX, clientY);
return hit?.selection;
}
@@ -51,13 +54,23 @@ export function pickSelectableHitAtClientPosition(
renderer: THREE.WebGLRenderer,
raycaster: THREE.Raycaster,
mouse: THREE.Vector2,
povLevel: PovLevel,
galaxyCamera: THREE.Camera,
galaxySelectableTargets: Map<THREE.Object3D, Selectable>,
systemCamera: THREE.Camera,
systemSelectableTargets: Map<THREE.Object3D, Selectable>,
localCamera: THREE.Camera,
localSelectableTargets: Map<THREE.Object3D, Selectable>,
clientX: number,
clientY: number,
): HoverPickResult | undefined {
if (povLevel === "local") {
const localHit = pickOneCamera(renderer, raycaster, mouse, localCamera, localSelectableTargets, clientX, clientY);
if (localHit) {
return localHit;
}
}
// Try system camera first (higher priority when in a system)
const systemHit = pickOneCamera(renderer, raycaster, mouse, systemCamera, systemSelectableTargets, clientX, clientY);
if (systemHit) {

View File

@@ -28,8 +28,10 @@ export interface ViewerInteractionContext {
mouse: THREE.Vector2;
galaxyCamera: THREE.PerspectiveCamera;
systemCamera: THREE.PerspectiveCamera;
localCamera: THREE.PerspectiveCamera;
galaxySelectableTargets: Map<THREE.Object3D, Selectable>;
systemSelectableTargets: Map<THREE.Object3D, Selectable>;
localSelectableTargets: Map<THREE.Object3D, Selectable>;
hoverLabelEl: HTMLDivElement;
hoverConnectorLineEl: SVGLineElement;
marqueeEl: HTMLDivElement;
@@ -391,10 +393,13 @@ export class ViewerInteractionController {
this.context.renderer,
this.context.raycaster,
this.context.mouse,
this.context.getPovLevel(),
this.context.galaxyCamera,
this.context.galaxySelectableTargets,
this.context.systemCamera,
this.context.systemSelectableTargets,
this.context.localCamera,
this.context.localSelectableTargets,
clientX,
clientY,
);
@@ -405,10 +410,13 @@ export class ViewerInteractionController {
this.context.renderer,
this.context.raycaster,
this.context.mouse,
this.context.getPovLevel(),
this.context.galaxyCamera,
this.context.galaxySelectableTargets,
this.context.systemCamera,
this.context.systemSelectableTargets,
this.context.localCamera,
this.context.localSelectableTargets,
clientX,
clientY,
);
@@ -466,6 +474,7 @@ export class ViewerInteractionController {
selection,
label: node.itemId,
systemId: node.systemId,
anchorId: node.anchorId,
itemId: node.itemId,
targetPosition: node.localPosition,
} : null;

View File

@@ -1,17 +1,50 @@
import * as THREE from "three";
import type {
ClaimVisual,
ConstructionSiteVisual,
NodeVisual,
Selectable,
ShipVisual,
StructureVisual,
} from "./viewerTypes";
/**
* Local rendering layer.
* Scene coordinate unit: reserved for future close-up detail.
* Camera far plane covers immediate surroundings.
* Currently empty — populated when local-space objects are introduced.
*/
export class LocalLayer {
readonly scene = new THREE.Scene();
readonly camera = new THREE.PerspectiveCamera(50, 1, 0.1, 2000);
readonly nodeGroup = new THREE.Group();
readonly stationGroup = new THREE.Group();
readonly claimGroup = new THREE.Group();
readonly constructionSiteGroup = new THREE.Group();
readonly shipGroup = new THREE.Group();
readonly selectableTargets = new Map<THREE.Object3D, Selectable>();
readonly shipVisuals = new Map<string, ShipVisual>();
readonly nodeVisuals = new Map<string, NodeVisual>();
readonly stationVisuals = new Map<string, StructureVisual>();
readonly claimVisuals = new Map<string, ClaimVisual>();
readonly constructionSiteVisuals = new Map<string, ConstructionSiteVisual>();
private static readonly ORIGIN = new THREE.Vector3(0, 0, 0);
constructor() {
this.scene.add(new THREE.AmbientLight(0x90a6c0, 0.8));
const keyLight = new THREE.DirectionalLight(0xdcecff, 1.4);
keyLight.position.set(180, 220, 140);
this.scene.add(keyLight);
this.scene.add(
this.nodeGroup,
this.stationGroup,
this.claimGroup,
this.constructionSiteGroup,
this.shipGroup,
);
}
updateCamera(orbitOffset: THREE.Vector3) {
this.camera.position.copy(orbitOffset);
this.camera.lookAt(LocalLayer.ORIGIN);

View File

@@ -1,4 +1,5 @@
import * as THREE from "three";
import { scaleGalaxyVector, toThreeVector } from "./viewerMath";
import {
determineActiveSystemId,
focusOnSelection,
@@ -62,6 +63,22 @@ export interface ViewerNavigationContext {
export class ViewerNavigationController {
constructor(private readonly context: ViewerNavigationContext) {}
syncGalaxyAnchorToActiveSystem() {
const world = this.context.getWorld();
const activeSystemId = this.context.getActiveSystemId();
const povLevel = this.context.getPovLevel();
if (!world || !activeSystemId || povLevel === "galaxy") {
return;
}
const system = world.systems.get(activeSystemId);
if (!system) {
return;
}
this.context.galaxyAnchor.copy(scaleGalaxyVector(toThreeVector(system.galaxyPosition)));
}
focusOnSelection(selection: Selectable) {
focusOnSelection({
world: this.context.getWorld(),
@@ -70,7 +87,7 @@ export class ViewerNavigationController {
nodeVisuals: this.context.nodeVisuals,
planetVisuals: this.context.planetVisuals,
computeNodeLocalPosition: (node, timeSeconds) => computeNodeLocalPosition(this.context.createWorldPresentationContext(), node, timeSeconds),
resolvePointPosition: (systemId, celestialId) => resolvePointPosition(this.context.createWorldPresentationContext(), systemId, celestialId),
resolvePointPosition: (systemId, celestialId, anchorId) => resolvePointPosition(this.context.createWorldPresentationContext(), systemId, celestialId, anchorId),
activeSystemId: this.context.getActiveSystemId(),
galaxyAnchor: this.context.galaxyAnchor,
systemAnchor: this.context.systemAnchor,
@@ -85,7 +102,7 @@ export class ViewerNavigationController {
nodeVisuals: this.context.nodeVisuals,
planetVisuals: this.context.planetVisuals,
computeNodeLocalPosition: (node, timeSeconds) => computeNodeLocalPosition(this.context.createWorldPresentationContext(), node, timeSeconds),
resolvePointPosition: (systemId, celestialId) => resolvePointPosition(this.context.createWorldPresentationContext(), systemId, celestialId),
resolvePointPosition: (systemId, celestialId, anchorId) => resolvePointPosition(this.context.createWorldPresentationContext(), systemId, celestialId, anchorId),
});
}
@@ -171,7 +188,7 @@ export class ViewerNavigationController {
nodeVisuals: this.context.nodeVisuals,
planetVisuals: this.context.planetVisuals,
computeNodeLocalPosition: (node, timeSeconds) => computeNodeLocalPosition(this.context.createWorldPresentationContext(), node, timeSeconds),
resolvePointPosition: (systemIdValue, celestialId) => resolvePointPosition(this.context.createWorldPresentationContext(), systemIdValue, celestialId),
resolvePointPosition: (systemIdValue, celestialId, anchorId) => resolvePointPosition(this.context.createWorldPresentationContext(), systemIdValue, celestialId, anchorId),
});
}

View File

@@ -52,7 +52,7 @@ const moduleProductionById = new Map<string, {
const itemTransportById = new Map<string, string>(
(itemsData as { id: string; transport: string }[]).map((item) => [item.id, item.transport]),
);
import { describeCelestialPathWithinSystem, describeOrbitalParent, describeSelectable, describeShipBehavior, describeShipCurrentAction, describeShipOrder, describeShipState, getSelectionGroup, renderSystemDetails } from "./viewerSelection";
import { describeAnchorPathWithinSystem, describeCelestialPathWithinSystem, describeOrbitalParent, describeSelectable, describeShipBehavior, describeShipCurrentAction, describeShipOrder, describeShipState, getSelectionGroup, renderSystemDetails } from "./viewerSelection";
import type {
CameraMode,
NodeVisual,
@@ -461,7 +461,7 @@ export function buildDetailPanelState(params: DetailPanelParams) {
title: `${celestial.kind} celestial`,
bodyHtml: `
<p>${celestial.systemId}</p>
<p>Parent ${celestial.parentNodeId ?? "none"}<br>Orbit ref ${celestial.orbitReferenceId ?? "none"}</p>
<p>Parent ${celestial.parentAnchorId ?? "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>
`,
@@ -477,7 +477,7 @@ export function buildDetailPanelState(params: DetailPanelParams) {
title: `Claim ${claim.id}`,
bodyHtml: `
<p>${claim.systemId}</p>
<p>Celestial ${claim.celestialId}</p>
<p>Anchor ${describeAnchorPathWithinSystem(world, claim.systemId, claim.anchorId) ?? claim.anchorId}</p>
<p>State ${claim.state}<br>Health ${claim.health.toFixed(0)}</p>
<p>Activates ${new Date(claim.activatesAtUtc).toLocaleTimeString()}</p>
`,
@@ -494,7 +494,7 @@ export function buildDetailPanelState(params: DetailPanelParams) {
title: `Construction ${site.id}`,
bodyHtml: `
<p>${site.systemId}</p>
<p>Celestial ${site.celestialId}</p>
<p>Anchor ${describeAnchorPathWithinSystem(world, site.systemId, site.anchorId) ?? site.anchorId}</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>
@@ -608,24 +608,33 @@ export function describeSelectionParent(
return "unknown";
}
return station.celestialId
? describeCelestialPathWithinSystem(world, station.systemId, station.celestialId) ?? `${station.systemId} network`
return station.anchorId
? describeAnchorPathWithinSystem(world, station.systemId, station.anchorId) ?? `${station.systemId} network`
: "unknown";
}
if (selection.kind === "node") {
const node = world.nodes.get(selection.id);
const visual = node ? nodeVisuals.get(selection.id) : undefined;
return describeOrbitalParent(world, node?.systemId, visual?.anchor);
return node
? describeAnchorPathWithinSystem(world, node.systemId, node.anchorId) ?? node.anchorId
: "unknown";
}
if (selection.kind === "celestial") {
const celestial = world.celestials.get(selection.id);
return celestial?.parentNodeId ?? `${celestial?.systemId ?? "unknown"} network`;
return celestial
? describeCelestialPathWithinSystem(world, celestial.systemId, celestial.id) ?? `${celestial.systemId} network`
: "unknown";
}
if (selection.kind === "claim") {
return world.claims.get(selection.id)?.celestialId ?? "unknown";
const claim = world.claims.get(selection.id);
return claim
? describeAnchorPathWithinSystem(world, claim.systemId, claim.anchorId) ?? claim.anchorId
: "unknown";
}
if (selection.kind === "construction-site") {
return world.constructionSites.get(selection.id)?.celestialId ?? "unknown";
const site = world.constructionSites.get(selection.id);
return site
? describeAnchorPathWithinSystem(world, site.systemId, site.anchorId) ?? site.anchorId
: "unknown";
}
return "unknown";

View File

@@ -14,6 +14,16 @@ import {
syncShips as syncShipScene,
syncStations as syncStationScene,
} from "./viewerSceneSync";
import {
createClaimMesh,
createConstructionSiteMesh,
createNodeMesh,
createResourceDepositMesh,
createShipMesh,
createShipTacticalIcon,
createStationMesh,
createTacticalIcon,
} from "./viewerSceneFactory";
import {
deriveNodeOrbital,
deriveOrbitalFromLocalPosition,
@@ -43,7 +53,7 @@ import type {
SystemSnapshot,
} from "./contracts";
import type { OrbitalAnchor, Selectable } from "./viewerTypes";
import { rawObject } from "./viewerScenePrimitives";
import { rawObject, registerSelectableTarget } from "./viewerScenePrimitives";
export interface ViewerSceneDataContext {
documentRef: Document;
@@ -61,8 +71,14 @@ export interface ViewerSceneDataContext {
claimGroup: THREE.Group;
constructionSiteGroup: THREE.Group;
shipGroup: THREE.Group;
localNodeGroup: THREE.Group;
localStationGroup: THREE.Group;
localClaimGroup: THREE.Group;
localConstructionSiteGroup: THREE.Group;
localShipGroup: THREE.Group;
galaxySelectableTargets: Map<THREE.Object3D, Selectable>;
systemSelectableTargets: Map<THREE.Object3D, Selectable>;
localSelectableTargets: Map<THREE.Object3D, Selectable>;
systemVisuals: Map<any, any>;
planetVisuals: any[];
celestialVisuals: Map<any, any>;
@@ -71,6 +87,11 @@ export interface ViewerSceneDataContext {
claimVisuals: Map<any, any>;
constructionSiteVisuals: Map<any, any>;
shipVisuals: Map<any, any>;
localNodeVisuals: Map<any, any>;
localStationVisuals: Map<any, any>;
localClaimVisuals: Map<any, any>;
localConstructionSiteVisuals: Map<any, any>;
localShipVisuals: Map<any, any>;
}
export class ViewerSceneDataController {
@@ -136,6 +157,162 @@ export class ViewerSceneDataController {
applyShipDeltaUpdates(this.createSceneSyncContext(), ships, tickIntervalMs, this.context.getActiveSystemId());
}
refreshLocalLayer(world: any, focusedAnchorId?: string) {
this.context.localNodeGroup.clear();
this.context.localStationGroup.clear();
this.context.localClaimGroup.clear();
this.context.localConstructionSiteGroup.clear();
this.context.localShipGroup.clear();
this.context.localSelectableTargets.clear();
this.context.localNodeVisuals.clear();
this.context.localStationVisuals.clear();
this.context.localClaimVisuals.clear();
this.context.localConstructionSiteVisuals.clear();
this.context.localShipVisuals.clear();
if (!world || !focusedAnchorId) {
return;
}
const activeSystemId = this.context.getActiveSystemId();
const inFocusedSystem = (systemId: string) => !activeSystemId || systemId === activeSystemId;
for (const node of world.nodes.values()) {
if (node.anchorId !== focusedAnchorId || !inFocusedSystem(node.systemId)) {
continue;
}
const mesh = createNodeMesh(node);
const icon = createTacticalIcon(this.context.documentRef, node.sourceKind === "gas-cloud" ? "#7fd6ff" : "#d2b07a", 100);
const localPosition = new THREE.Vector3(0, 0, 0);
mesh.setPosition(localPosition);
icon.setPosition(localPosition);
this.context.localNodeVisuals.set(node.id, {
systemId: node.systemId,
anchorId: node.anchorId,
mesh,
icon,
sourceKind: node.sourceKind,
anchor: { kind: "star" as const },
localPosition,
orbitRadius: 0,
orbitPhase: 0,
orbitInclination: 0,
});
this.context.localNodeGroup.add(rawObject(mesh), rawObject(icon));
for (const deposit of node.deposits) {
const depositMesh = createResourceDepositMesh(deposit, node);
this.context.localNodeGroup.add(rawObject(depositMesh));
}
registerSelectableTarget(this.context.localSelectableTargets, mesh, { kind: "node", id: node.id });
registerSelectableTarget(this.context.localSelectableTargets, icon, { kind: "node", id: node.id });
}
for (const station of world.stations.values()) {
if (station.anchorId !== focusedAnchorId || !inFocusedSystem(station.systemId)) {
continue;
}
const mesh = createStationMesh(station);
const icon = createTacticalIcon(this.context.documentRef, station.color, 130);
const localPosition = new THREE.Vector3(station.localPosition.x, station.localPosition.y, station.localPosition.z);
mesh.setPosition(localPosition);
icon.setPosition(localPosition);
this.context.localStationVisuals.set(station.id, {
id: station.id,
systemId: station.systemId,
anchorId: station.anchorId,
mesh,
icon,
anchor: { kind: "star" as const },
orbitRadius: 0,
orbitPhase: 0,
orbitInclination: 0,
localPosition,
});
this.context.localStationGroup.add(rawObject(mesh), rawObject(icon));
registerSelectableTarget(this.context.localSelectableTargets, mesh, { kind: "station", id: station.id });
registerSelectableTarget(this.context.localSelectableTargets, icon, { kind: "station", id: station.id });
}
for (const claim of world.claims.values()) {
if (claim.anchorId !== focusedAnchorId || !inFocusedSystem(claim.systemId)) {
continue;
}
const mesh = createClaimMesh(claim);
const icon = createTacticalIcon(this.context.documentRef, "#ff5b5b", 90);
const localPosition = new THREE.Vector3(0, 0, 0);
mesh.setPosition(localPosition);
icon.setPosition(localPosition);
this.context.localClaimVisuals.set(claim.id, {
id: claim.id,
anchorId: claim.anchorId,
systemId: claim.systemId,
mesh,
icon,
localPosition,
});
this.context.localClaimGroup.add(rawObject(mesh), rawObject(icon));
registerSelectableTarget(this.context.localSelectableTargets, mesh, { kind: "claim", id: claim.id });
registerSelectableTarget(this.context.localSelectableTargets, icon, { kind: "claim", id: claim.id });
}
for (const site of world.constructionSites.values()) {
if (site.anchorId !== focusedAnchorId || !inFocusedSystem(site.systemId)) {
continue;
}
const mesh = createConstructionSiteMesh(site);
const icon = createTacticalIcon(this.context.documentRef, "#9df29c", 90);
const localPosition = new THREE.Vector3(0, 0, 0);
mesh.setPosition(localPosition);
icon.setPosition(localPosition);
this.context.localConstructionSiteVisuals.set(site.id, {
id: site.id,
anchorId: site.anchorId,
systemId: site.systemId,
mesh,
icon,
localPosition,
});
this.context.localConstructionSiteGroup.add(rawObject(mesh), rawObject(icon));
registerSelectableTarget(this.context.localSelectableTargets, mesh, { kind: "construction-site", id: site.id });
registerSelectableTarget(this.context.localSelectableTargets, icon, { kind: "construction-site", id: site.id });
}
for (const ship of world.ships.values()) {
const shipAnchorId = ship.spatialState.currentAnchorId ?? ship.anchorId;
if (shipAnchorId !== focusedAnchorId || !inFocusedSystem(ship.systemId) || ship.spatialState.spaceLayer !== "local-space") {
continue;
}
const shipColor = shipPresentationColor(ship);
const mesh = createShipMesh(ship, shipSize(ship), shipLength(ship), shipColor);
const icon = createShipTacticalIcon(this.context.documentRef, shipColor, 78);
const localPosition = new THREE.Vector3(ship.localPosition.x, ship.localPosition.y, ship.localPosition.z);
mesh.setPosition(localPosition);
icon.setPosition(localPosition);
icon.setColor(shipColor);
this.context.localShipVisuals.set(ship.id, {
systemId: ship.systemId,
anchorId: shipAnchorId,
mesh,
icon,
iconBaseScale: 78,
startPosition: localPosition.clone(),
authoritativePosition: localPosition.clone(),
targetPosition: new THREE.Vector3(ship.targetLocalPosition.x, ship.targetLocalPosition.y, ship.targetLocalPosition.z),
velocity: new THREE.Vector3(ship.localVelocity.x, ship.localVelocity.y, ship.localVelocity.z),
receivedAtMs: performance.now(),
blendDurationMs: Math.max(world.tickIntervalMs ?? 80, 80),
});
this.context.localShipGroup.add(rawObject(mesh), rawObject(icon));
registerSelectableTarget(this.context.localSelectableTargets, mesh, { kind: "ship", id: ship.id });
registerSelectableTarget(this.context.localSelectableTargets, icon, { kind: "ship", id: ship.id });
}
}
/**
* Called when the active system changes. Swaps which system's root is in systemScene
* and updates visibility of all system-filtered objects.
@@ -202,6 +379,7 @@ export class ViewerSceneDataController {
createWorldPresentationContext(overrides: {
world: any;
activeSystemId?: string;
focusedAnchorId?: string;
cameraMode: any;
povLevel: any;
orbitYaw: number;
@@ -215,17 +393,23 @@ export class ViewerSceneDataController {
worldTimeSyncMs: this.context.getWorldTimeSyncMs(),
worldSeed: this.context.getWorldSeed(),
activeSystemId: overrides.activeSystemId,
focusedAnchorId: overrides.focusedAnchorId,
cameraMode: overrides.cameraMode,
povLevel: overrides.povLevel,
orbitYaw: overrides.orbitYaw,
camera: overrides.systemCamera,
systemAnchor: overrides.systemAnchor,
shipVisuals: this.context.shipVisuals,
localShipVisuals: this.context.localShipVisuals,
nodeVisuals: this.context.nodeVisuals,
localNodeVisuals: this.context.localNodeVisuals,
celestialVisuals: this.context.celestialVisuals,
stationVisuals: this.context.stationVisuals,
localStationVisuals: this.context.localStationVisuals,
claimVisuals: this.context.claimVisuals,
localClaimVisuals: this.context.localClaimVisuals,
constructionSiteVisuals: this.context.constructionSiteVisuals,
localConstructionSiteVisuals: this.context.localConstructionSiteVisuals,
systemVisuals: this.context.systemVisuals,
systemSummaryVisuals: new Map(),
toDisplayLocalPosition: overrides.toDisplayLocalPosition,
@@ -248,8 +432,14 @@ export class ViewerSceneDataController {
claimGroup: this.context.claimGroup,
constructionSiteGroup: this.context.constructionSiteGroup,
shipGroup: this.context.shipGroup,
localNodeGroup: this.context.localNodeGroup,
localStationGroup: this.context.localStationGroup,
localClaimGroup: this.context.localClaimGroup,
localConstructionSiteGroup: this.context.localConstructionSiteGroup,
localShipGroup: this.context.localShipGroup,
galaxySelectableTargets: this.context.galaxySelectableTargets,
systemSelectableTargets: this.context.systemSelectableTargets,
localSelectableTargets: this.context.localSelectableTargets,
systemVisuals: this.context.systemVisuals,
planetVisuals: this.context.planetVisuals,
celestialVisuals: this.context.celestialVisuals,
@@ -258,12 +448,17 @@ export class ViewerSceneDataController {
claimVisuals: this.context.claimVisuals,
constructionSiteVisuals: this.context.constructionSiteVisuals,
shipVisuals: this.context.shipVisuals,
localNodeVisuals: this.context.localNodeVisuals,
localStationVisuals: this.context.localStationVisuals,
localClaimVisuals: this.context.localClaimVisuals,
localConstructionSiteVisuals: this.context.localConstructionSiteVisuals,
localShipVisuals: this.context.localShipVisuals,
shipSize,
shipLength,
shipPresentationColor,
celestialColor,
createCirclePoints,
resolvePointPosition: (systemId: string, celestialId?: string | null) => resolvePointPosition(this.context.getWorldPresentationContext(), systemId, celestialId),
resolvePointPosition: (systemId: string, celestialId?: string | null, anchorId?: string | null) => resolvePointPosition(this.context.getWorldPresentationContext(), systemId, celestialId, anchorId),
resolveOrbitalAnchor: (systemId: string, localPosition: THREE.Vector3) => resolveOrbitalAnchor(this.context.getWorldPresentationContext(), systemId, localPosition),
deriveNodeOrbital: (node: ResourceNodeSnapshot | ResourceNodeDelta, anchor: OrbitalAnchor) => deriveNodeOrbital(this.context.getWorldPresentationContext(), node, anchor),
deriveOrbitalFromLocalPosition: (localPosition: THREE.Vector3, systemId: string, anchor: OrbitalAnchor) => deriveOrbitalFromLocalPosition(this.context.getWorldPresentationContext(), localPosition, systemId, anchor),

View File

@@ -11,6 +11,7 @@ import type {
ConstructionSiteSnapshot,
MoonSnapshot,
PlanetSnapshot,
ResourceDepositSnapshot,
ResourceNodeSnapshot,
ShipSnapshot,
StationSnapshot,
@@ -46,6 +47,23 @@ export function createNodeMesh(node: ResourceNodeSnapshot): SceneNode {
return createSceneNode(mesh);
}
export function createResourceDepositMesh(deposit: ResourceDepositSnapshot, node: ResourceNodeSnapshot): SceneNode {
const isGas = node.sourceKind === "gas-cloud" || node.itemId === "gas";
const oreRatio = deposit.maxOre <= 0.01 ? 0 : deposit.oreRemaining / deposit.maxOre;
const mesh = new THREE.Mesh(
isGas ? new THREE.SphereGeometry(10, 12, 12) : new THREE.DodecahedronGeometry(8 + (oreRatio * 5), 0),
new THREE.MeshStandardMaterial({
color: isGas ? 0x7fd6ff : 0xc9a165,
flatShading: !isGas,
transparent: isGas,
opacity: isGas ? 0.55 : 1,
emissive: new THREE.Color(isGas ? 0x7fd6ff : 0xc9a165).multiplyScalar(isGas ? 0.18 : 0.05),
}),
);
mesh.position.copy(toThreeVector(deposit.localPosition));
return createSceneNode(mesh);
}
export function createCelestialMesh(node: CelestialSnapshot, celestialColor: (kind: string) => string): SceneNode {
const color = celestialColor(node.kind);
return createSceneNode(new THREE.Mesh(

View File

@@ -70,6 +70,18 @@ function toSystemPos(localPosition: THREE.Vector3): THREE.Vector3 {
return localPosition.clone().multiplyScalar(DISPLAY_UNITS_PER_KILOMETER * ACTIVE_SYSTEM_DETAIL_SCALE);
}
function resolveShipSystemPosition(ship: ShipSnapshot | ShipDelta, context: SceneSyncContext) {
if (ship.spatialState.systemPosition) {
return toThreeVector(ship.spatialState.systemPosition);
}
if (ship.anchorId) {
return context.resolvePointPosition(ship.systemId, null, ship.anchorId);
}
return toThreeVector(ship.localPosition);
}
interface SceneSyncContext {
documentRef: Document;
worldOrbitalTimeSeconds?: number;
@@ -98,7 +110,7 @@ interface SceneSyncContext {
shipPresentationColor: (ship: ShipSnapshot) => string;
celestialColor: (kind: string) => string;
createCirclePoints: (radius: number, segments: number) => THREE.Vector3[];
resolvePointPosition: (systemId: string, celestialId?: string | null) => THREE.Vector3;
resolvePointPosition: (systemId: string, celestialId?: string | null, anchorId?: string | null) => THREE.Vector3;
resolveOrbitalAnchor: (systemId: string, localPosition: THREE.Vector3) => NodeVisual["anchor"];
deriveNodeOrbital: (node: ResourceNodeSnapshot | ResourceNodeDelta, anchor: NodeVisual["anchor"]) => {
radius: number;
@@ -250,7 +262,8 @@ export function syncNodes(context: SceneSyncContext, nodes: ResourceNodeSnapshot
const mesh = createNodeMesh(node);
const icon = createTacticalIcon(context.documentRef, node.sourceKind === "gas-cloud" ? "#7fd6ff" : "#d2b07a", 100);
const localPosition = toThreeVector(node.localPosition);
const displayPos = toSystemPos(localPosition);
const systemPosition = context.resolvePointPosition(node.systemId, null, node.anchorId);
const displayPos = toSystemPos(systemPosition);
mesh.setPosition(displayPos);
icon.setPosition(displayPos);
const isActive = node.systemId === activeSystemId;
@@ -260,6 +273,7 @@ export function syncNodes(context: SceneSyncContext, nodes: ResourceNodeSnapshot
const orbital = context.deriveNodeOrbital(node, anchor);
context.nodeVisuals.set(node.id, {
systemId: node.systemId,
anchorId: node.anchorId,
mesh,
icon,
sourceKind: node.sourceKind,
@@ -283,7 +297,8 @@ export function syncStations(context: SceneSyncContext, stations: StationSnapsho
const mesh = createStationMesh(station);
const icon = createTacticalIcon(context.documentRef, station.color, 130);
const localPosition = toThreeVector(station.localPosition);
const displayPos = toSystemPos(localPosition);
const systemPosition = context.resolvePointPosition(station.systemId, null, station.anchorId);
const displayPos = toSystemPos(systemPosition);
mesh.setPosition(displayPos);
icon.setPosition(displayPos);
const isActive = station.systemId === activeSystemId;
@@ -294,6 +309,7 @@ export function syncStations(context: SceneSyncContext, stations: StationSnapsho
context.stationVisuals.set(station.id, {
id: station.id,
systemId: station.systemId,
anchorId: station.anchorId,
mesh,
icon,
anchor,
@@ -313,7 +329,7 @@ export function syncClaims(context: SceneSyncContext, claims: ClaimSnapshot[], a
context.claimVisuals.clear();
for (const claim of claims) {
const localPosition = context.resolvePointPosition(claim.systemId, claim.celestialId);
const localPosition = context.resolvePointPosition(claim.systemId, null, claim.anchorId);
const displayPos = toSystemPos(localPosition);
const mesh = createClaimMesh(claim);
const icon = createTacticalIcon(context.documentRef, "#ff5b5b", 90);
@@ -324,7 +340,7 @@ export function syncClaims(context: SceneSyncContext, claims: ClaimSnapshot[], a
icon.setVisible(isActive);
context.claimVisuals.set(claim.id, {
id: claim.id,
celestialId: claim.celestialId,
anchorId: claim.anchorId,
systemId: claim.systemId,
mesh,
icon,
@@ -341,7 +357,7 @@ export function syncConstructionSites(context: SceneSyncContext, sites: Construc
context.constructionSiteVisuals.clear();
for (const site of sites) {
const localPosition = context.resolvePointPosition(site.systemId, site.celestialId);
const localPosition = context.resolvePointPosition(site.systemId, null, site.anchorId);
const displayPos = toSystemPos(localPosition);
const mesh = createConstructionSiteMesh(site);
const icon = createTacticalIcon(context.documentRef, "#9df29c", 90);
@@ -352,7 +368,7 @@ export function syncConstructionSites(context: SceneSyncContext, sites: Construc
icon.setVisible(isActive);
context.constructionSiteVisuals.set(site.id, {
id: site.id,
celestialId: site.celestialId,
anchorId: site.anchorId,
systemId: site.systemId,
mesh,
icon,
@@ -374,7 +390,7 @@ export function syncShips(context: SceneSyncContext, ships: ShipSnapshot[], tick
const iconBaseScale = 78;
const icon = createShipTacticalIcon(context.documentRef, shipColor, iconBaseScale);
const localPosition = toThreeVector(ship.localPosition);
const displayPos = toSystemPos(localPosition);
const displayPos = toSystemPos(resolveShipSystemPosition(ship, context));
mesh.setPosition(displayPos);
icon.setPosition(displayPos);
icon.setColor(shipColor);
@@ -386,6 +402,7 @@ export function syncShips(context: SceneSyncContext, ships: ShipSnapshot[], tick
registerSelectableTarget(context.systemSelectableTargets, icon, { kind: "ship", id: ship.id });
context.shipVisuals.set(ship.id, {
systemId: ship.systemId,
anchorId: ship.anchorId ?? ship.spatialState.currentAnchorId ?? undefined,
mesh,
icon,
iconBaseScale,
@@ -430,6 +447,7 @@ export function applyNodeDeltas(context: SceneSyncContext, nodes: ResourceNodeDe
}
visual.systemId = node.systemId;
visual.anchorId = node.anchorId;
visual.sourceKind = node.sourceKind;
visual.localPosition.copy(toThreeVector(node.localPosition));
visual.anchor = context.resolveOrbitalAnchor(node.systemId, visual.localPosition);
@@ -452,6 +470,7 @@ export function applyStationDeltas(context: SceneSyncContext, stations: StationD
}
visual.systemId = station.systemId;
visual.anchorId = station.anchorId;
visual.localPosition.copy(toThreeVector(station.localPosition));
visual.anchor = context.resolveOrbitalAnchor(station.systemId, visual.localPosition);
const orbital = context.deriveOrbitalFromLocalPosition(visual.localPosition, station.systemId, visual.anchor);
@@ -474,7 +493,8 @@ export function applyClaimDeltas(context: SceneSyncContext, claims: ClaimDelta[]
}
visual.systemId = claim.systemId;
visual.localPosition.copy(context.resolvePointPosition(claim.systemId, claim.celestialId));
visual.anchorId = claim.anchorId;
visual.localPosition.copy(context.resolvePointPosition(claim.systemId, null, claim.anchorId));
const displayPos = toSystemPos(visual.localPosition);
visual.mesh.setPosition(displayPos);
visual.icon.setPosition(displayPos);
@@ -494,7 +514,8 @@ export function applyConstructionSiteDeltas(context: SceneSyncContext, sites: Co
}
visual.systemId = site.systemId;
visual.localPosition.copy(context.resolvePointPosition(site.systemId, site.celestialId));
visual.anchorId = site.anchorId;
visual.localPosition.copy(context.resolvePointPosition(site.systemId, null, site.anchorId));
const displayPos = toSystemPos(visual.localPosition);
visual.mesh.setPosition(displayPos);
visual.icon.setPosition(displayPos);
@@ -514,6 +535,7 @@ export function applyShipDeltas(context: SceneSyncContext, ships: ShipDelta[], t
}
visual.systemId = ship.systemId;
visual.anchorId = ship.anchorId ?? ship.spatialState.currentAnchorId ?? undefined;
visual.startPosition.copy(getAnimatedShipLocalPosition(visual));
visual.authoritativePosition.copy(toThreeVector(ship.localPosition));
visual.targetPosition.copy(toThreeVector(ship.targetLocalPosition));

View File

@@ -20,10 +20,17 @@ export function describeSelectable(world: WorldState | undefined, item: Selectab
return world.stations.get(item.id)?.label ?? item.id;
}
if (item.kind === "node") {
return item.id;
const node = world.nodes.get(item.id);
return node ? `${node.itemId} source` : item.id;
}
if (item.kind === "celestial") {
return `${world.celestials.get(item.id)?.kind ?? "celestial"} ${item.id}`;
const celestial = world.celestials.get(item.id);
if (!celestial) {
return item.id;
}
return describeCelestialPathWithinSystem(world, celestial.systemId, celestial.id)
?? `${world.systems.get(celestial.systemId)?.label ?? celestial.systemId} / ${celestial.kind}`;
}
if (item.kind === "claim") {
return `claim ${item.id}`;
@@ -113,9 +120,7 @@ export function describeHoverLabel(world: WorldState | undefined, item: Selectab
return item.id;
}
const anchorPath = node.celestialId
? describeCelestialPathWithinSystem(world, node.systemId, node.celestialId)
: undefined;
const anchorPath = describeAnchorPathWithinSystem(world, node.systemId, node.anchorId);
return anchorPath ? `${anchorPath} / ${node.itemId}` : `${node.systemId} / ${node.itemId}`;
}
@@ -135,16 +140,16 @@ export function describeHoverLabel(world: WorldState | undefined, item: Selectab
if (item.kind === "claim") {
const claim = world.claims.get(item.id);
const anchorPath = claim?.celestialId
? describeCelestialPathWithinSystem(world, claim.systemId, claim.celestialId)
const anchorPath = claim
? describeAnchorPathWithinSystem(world, claim.systemId, claim.anchorId)
: undefined;
return anchorPath ? `${anchorPath} claim` : `Claim ${item.id}`;
}
if (item.kind === "construction-site") {
const site = world.constructionSites.get(item.id);
const anchorPath = site?.celestialId
? describeCelestialPathWithinSystem(world, site.systemId, site.celestialId)
const anchorPath = site
? describeAnchorPathWithinSystem(world, site.systemId, site.anchorId)
: undefined;
const siteLabel = site ? (site.blueprintId ?? site.targetDefinitionId) : item.id;
return anchorPath ? `${anchorPath} / ${siteLabel}` : `Construction ${siteLabel}`;
@@ -210,20 +215,74 @@ export function resolveFocusedCelestialId(world: WorldState | undefined, selecte
return selected.id;
}
if (selected.kind === "ship") {
return world.ships.get(selected.id)?.spatialState.currentCelestialId ?? world.ships.get(selected.id)?.celestialId ?? undefined;
const ship = world.ships.get(selected.id);
return ship?.spatialState.currentAnchorId && world.celestials.has(ship.spatialState.currentAnchorId)
? ship.spatialState.currentAnchorId
: (ship?.anchorId && world.celestials.has(ship.anchorId) ? ship.anchorId : undefined);
}
if (selected.kind === "station") {
return world.stations.get(selected.id)?.celestialId ?? undefined;
const station = world.stations.get(selected.id);
return station?.anchorId && world.celestials.has(station.anchorId) ? station.anchorId : undefined;
}
if (selected.kind === "claim") {
return world.claims.get(selected.id)?.celestialId ?? undefined;
const claim = world.claims.get(selected.id);
return claim && world.celestials.has(claim.anchorId) ? claim.anchorId : undefined;
}
if (selected.kind === "construction-site") {
return world.constructionSites.get(selected.id)?.celestialId ?? undefined;
const site = world.constructionSites.get(selected.id);
return site && world.celestials.has(site.anchorId) ? site.anchorId : undefined;
}
return undefined;
}
export function resolveFocusedAnchorId(world: WorldState | undefined, selectedItems: Selectable[]): string | undefined {
if (!world || selectedItems.length !== 1) {
return undefined;
}
const selected = selectedItems[0];
if (selected.kind === "node") {
return world.nodes.get(selected.id)?.anchorId;
}
if (selected.kind === "ship") {
const ship = world.ships.get(selected.id);
return ship?.spatialState.currentAnchorId
?? ship?.anchorId
?? resolveFocusedCelestialId(world, selectedItems);
}
if (selected.kind === "station") {
const station = world.stations.get(selected.id);
return station?.anchorId
?? resolveFocusedCelestialId(world, selectedItems);
}
if (selected.kind === "claim") {
const claim = world.claims.get(selected.id);
return claim?.anchorId
?? resolveFocusedCelestialId(world, selectedItems);
}
if (selected.kind === "construction-site") {
const site = world.constructionSites.get(selected.id);
return site?.anchorId
?? resolveFocusedCelestialId(world, selectedItems);
}
if (selected.kind === "celestial") {
if (world.anchors.has(selected.id)) {
return selected.id;
}
const orbitBackedAnchor = [...world.anchors.values()].find((anchor) => anchor.orbitReferenceId === selected.id);
return orbitBackedAnchor?.id;
}
if (selected.kind === "planet") {
return `${selected.systemId}-planet-${selected.planetIndex + 1}`;
}
if (selected.kind === "moon") {
return `${selected.systemId}-planet-${selected.planetIndex + 1}-moon-${selected.moonIndex + 1}`;
}
return undefined;
}
export function describeOrbitalParent(world: WorldState | undefined, systemId?: string, anchor?: OrbitalAnchor): string {
if (!world || !systemId) {
return "unknown";
@@ -330,23 +389,31 @@ export function describeShipState(world: WorldState | undefined, ship: ShipSnaps
return baseState;
}
const destinationNodeId = ship.spatialState.destinationNodeId ?? ship.spatialState.transit?.destinationNodeId;
if (!destinationNodeId) {
const destinationAnchorId = ship.spatialState.destinationAnchorId ?? ship.spatialState.transit?.destinationAnchorId;
if (!destinationAnchorId) {
return baseState;
}
const destinationCelestial = world.celestials.get(destinationNodeId);
if (!destinationCelestial) {
return `${baseState} -> ${destinationNodeId}`;
}
const destinationAnchor = destinationAnchorId ? world.anchors.get(destinationAnchorId) : undefined;
if (baseState === "warping" || baseState === "spooling-warp") {
const destinationPath = describeCelestialPathWithinSystem(world, destinationCelestial.systemId, destinationNodeId);
return `${baseState} -> ${destinationPath ?? destinationNodeId}`;
const destinationSystemId = destinationAnchor?.systemId ?? ship.spatialState.currentSystemId ?? ship.systemId;
const destinationPath = describeAnchorPathWithinSystem(
world,
destinationSystemId,
destinationAnchorId,
);
return `${baseState} -> ${destinationPath ?? destinationAnchorId}`;
}
const destinationSystem = world.systems.get(destinationCelestial.systemId);
return `${baseState} -> ${destinationSystem?.label ?? destinationCelestial.systemId}`;
const destinationSystemId = destinationAnchor?.systemId
?? ship.spatialState.currentSystemId
?? ship.systemId;
const destinationSystem = world.systems.get(destinationSystemId);
if (!destinationSystem) {
return `${baseState} -> ${destinationAnchorId}`;
}
return `${baseState} -> ${destinationSystem.label}`;
}
export function describeShipObjective(objective: string): string {
@@ -406,8 +473,8 @@ export function describeShipLocation(world: WorldState | undefined, ship: ShipSn
if (ship.dockedStationId) {
const station = world.stations.get(ship.dockedStationId);
if (station) {
const anchorPath = station.celestialId
? describeCelestialPathWithinSystem(world, station.systemId, station.celestialId)
const anchorPath = station.anchorId
? describeAnchorPathWithinSystem(world, station.systemId, station.anchorId)
: undefined;
return {
system: systemLabel,
@@ -416,11 +483,11 @@ export function describeShipLocation(world: WorldState | undefined, ship: ShipSn
}
}
const currentCelestialId = ship.spatialState.currentCelestialId ?? ship.celestialId;
if (currentCelestialId) {
const celestialPath = describeCelestialPathWithinSystem(world, systemId, currentCelestialId);
if (celestialPath) {
return { system: systemLabel, local: celestialPath };
const currentAnchorId = ship.spatialState.currentAnchorId ?? ship.anchorId;
if (currentAnchorId) {
const anchorPath = describeAnchorPathWithinSystem(world, systemId, currentAnchorId);
if (anchorPath) {
return { system: systemLabel, local: anchorPath };
}
}
@@ -446,9 +513,9 @@ export function describeActiveSpace(
return activeSystem.label;
}
const celestialId = resolveFocusedCelestialId(world, selectedItems);
if (celestialId) {
const localPath = describeCelestialPathWithinSystem(world, activeSystem.id, celestialId);
const anchorId = resolveFocusedAnchorId(world, selectedItems);
if (anchorId) {
const localPath = describeAnchorPathWithinSystem(world, activeSystem.id, anchorId);
return localPath
? `${activeSystem.label} / ${localPath}`
: activeSystem.label;
@@ -472,10 +539,9 @@ export function describeCelestialPathWithinSystem(world: WorldState, systemId: s
return undefined;
}
if (celestial.parentNodeId) {
const parentPath = describeCelestialPathWithinSystem(world, systemId, celestial.parentNodeId);
const segment = describeCelestialSegment(system, celestial);
return parentPath ? `${parentPath}/${segment}` : segment;
const anchorId = resolveAnchorIdForCelestial(world, celestialId);
if (anchorId) {
return describeAnchorPathWithinSystem(world, systemId, anchorId);
}
if (celestial.kind === "star") {
@@ -485,6 +551,60 @@ export function describeCelestialPathWithinSystem(world: WorldState, systemId: s
return describeCelestialSegment(system, celestial);
}
export function describeAnchorPathWithinSystem(world: WorldState, systemId: string, anchorId: string, celestialId?: string | null): string | undefined {
const anchor = world.anchors.get(anchorId);
if (anchor?.parentAnchorId) {
const parentPath = describeAnchorPathWithinSystem(world, systemId, anchor.parentAnchorId);
const segment = describeAnchorSegment(anchor);
return parentPath ? `${parentPath}/${segment}` : segment;
}
if (celestialId) {
return describeCelestialPathWithinSystem(world, systemId, celestialId);
}
if (!anchor) {
return undefined;
}
return describeAnchorSegment(anchor);
}
function describeAnchorSegment(anchor: { kind: string; id: string; orbitReferenceId?: string | null }): string {
if (anchor.orbitReferenceId) {
return describeAnchorOrbitReference(anchor.orbitReferenceId);
}
if (anchor.kind === "resource-node") {
return anchor.id;
}
return anchor.kind.replace(/-/g, " ");
}
function resolveAnchorIdForCelestial(world: WorldState, celestialId: string): string | undefined {
return world.anchors.has(celestialId) ? celestialId : undefined;
}
function describeAnchorOrbitReference(referenceId: string): string {
const lagrangeMatch = referenceId.match(/(l[1-5])$/i);
if (lagrangeMatch) {
return lagrangeMatch[1].toUpperCase();
}
const moonMatch = referenceId.match(/moon-(\d+)$/i);
if (moonMatch) {
return `Moon ${moonMatch[1]}`;
}
const planetMatch = referenceId.match(/planet-(\d+)$/i);
if (planetMatch) {
return `Planet ${planetMatch[1]}`;
}
return referenceId;
}
function describeCelestialSegment(system: SystemSnapshot, celestial: CelestialSnapshot): string {
const moonMatch = celestial.id.match(/-planet-(\d+)-moon-(\d+)$/);
if (moonMatch) {

View File

@@ -41,6 +41,7 @@ export function createWorldState(snapshot: WorldSnapshot): WorldState {
generatedAtUtc: snapshot.generatedAtUtc,
systems: new Map(snapshot.systems.map((system) => [system.id, system])),
celestials: new Map(snapshot.celestials.map((celestial) => [celestial.id, celestial])),
anchors: new Map(snapshot.anchors.map((anchor) => [anchor.id, anchor])),
nodes: new Map(snapshot.nodes.map((node) => [node.id, node])),
stations: new Map(snapshot.stations.map((station) => [station.id, station])),
claims: new Map(snapshot.claims.map((claim) => [claim.id, claim])),
@@ -65,6 +66,9 @@ export function applyDeltaToWorld(world: WorldState, delta: WorldDelta): boolean
for (const celestial of delta.celestials) {
world.celestials.set(celestial.id, celestial);
}
for (const anchor of delta.anchors) {
world.anchors.set(anchor.id, anchor);
}
for (const node of delta.nodes) {
world.nodes.set(node.id, node);
}
@@ -101,6 +105,7 @@ export function recordDeltaStats(networkStats: NetworkStats, delta: WorldDelta,
+ delta.stations.length
+ delta.nodes.length
+ delta.celestials.length
+ delta.anchors.length
+ delta.claims.length
+ delta.constructionSites.length
+ delta.marketOrders.length

View File

@@ -1,6 +1,7 @@
import * as THREE from "three";
import type { SceneNode } from "./viewerScenePrimitives";
import type {
AnchorSnapshot,
CelestialSnapshot,
ClaimSnapshot,
ConstructionSiteSnapshot,
@@ -35,6 +36,7 @@ export type Selectable =
export interface ShipVisual {
systemId: string;
anchorId?: string;
mesh: SceneNode;
icon: SceneNode;
iconBaseScale: number;
@@ -74,6 +76,7 @@ export type OrbitalAnchor =
export interface NodeVisual {
systemId: string;
anchorId: string;
mesh: SceneNode;
icon: SceneNode;
sourceKind: string;
@@ -96,7 +99,8 @@ export interface CelestialVisual {
export interface ClaimVisual {
id: string;
celestialId: string;
anchorId: string;
celestialId?: string | null;
systemId: string;
mesh: SceneNode;
icon: SceneNode;
@@ -105,7 +109,8 @@ export interface ClaimVisual {
export interface ConstructionSiteVisual {
id: string;
celestialId: string;
anchorId: string;
celestialId?: string | null;
systemId: string;
mesh: SceneNode;
icon: SceneNode;
@@ -115,6 +120,7 @@ export interface ConstructionSiteVisual {
export interface StructureVisual {
id: string;
systemId: string;
anchorId?: string | null;
mesh: SceneNode;
icon: SceneNode;
anchor: OrbitalAnchor;
@@ -145,6 +151,7 @@ export interface WorldState {
generatedAtUtc: string;
systems: Map<string, SystemSnapshot>;
celestials: Map<string, CelestialSnapshot>;
anchors: Map<string, AnchorSnapshot>;
nodes: Map<string, ResourceNodeSnapshot>;
stations: Map<string, StationSnapshot>;
claims: Map<string, ClaimSnapshot>;

View File

@@ -65,8 +65,9 @@ export interface ViewerWorldLifecycleContext {
applyClaimDeltas: (claims: ClaimDelta[]) => void;
applyConstructionSiteDeltas: (sites: ConstructionSiteDelta[]) => void;
applyShipDeltas: (ships: ShipDelta[], tickIntervalMs: number) => void;
refreshLocalLayer: () => void;
refreshHistoryWindows: () => void;
resolveFocusedCelestialId: () => string | undefined;
resolveFocusedAnchorId: () => string | undefined;
updateSystemSummaries: () => void;
applyZoomPresentation: () => void;
updateNetworkPanel: () => void;
@@ -165,6 +166,7 @@ export class ViewerWorldLifecycle {
this.context.syncClaims(snapshot.claims);
this.context.syncConstructionSites(snapshot.constructionSites);
this.context.syncShips(snapshot.ships, snapshot.tickIntervalMs);
this.context.refreshLocalLayer();
this.rebuildFactions(snapshot.factions);
this.context.updateSystemSummaries();
this.context.applyZoomPresentation();
@@ -185,6 +187,7 @@ export class ViewerWorldLifecycle {
this.context.applyClaimDeltas(delta.claims);
this.context.applyConstructionSiteDeltas(delta.constructionSites);
this.context.applyShipDeltas(delta.ships, delta.tickIntervalMs);
this.context.refreshLocalLayer();
this.rebuildFactions(cloneFactions(world));
this.context.updateSystemSummaries();
}
@@ -219,6 +222,7 @@ export class ViewerWorldLifecycle {
}
this.context.refreshHistoryWindows();
this.context.refreshLocalLayer();
this.context.updateSystemPanel();
this.refreshStreamScopeIfNeeded();
const detailState = buildDetailPanelState({
@@ -241,12 +245,12 @@ export class ViewerWorldLifecycle {
return { scopeKind: "universe" as const };
}
const celestialId = this.context.resolveFocusedCelestialId();
if (this.context.getPovLevel() === "local" && celestialId) {
const anchorId = this.context.resolveFocusedAnchorId();
if (this.context.getPovLevel() === "local" && anchorId) {
return {
scopeKind: "local-celestial" as const,
scopeKind: "local-anchor" as const,
systemId: activeSystemId,
celestialId,
anchorId,
};
}

View File

@@ -10,7 +10,7 @@ import {
toThreeVector,
} from "./viewerMath";
import { ACTIVE_SYSTEM_DETAIL_SCALE } from "./viewerConstants";
import { describeActiveSpace, resolveFocusedCelestialId } from "./viewerSelection";
import { describeActiveSpace, resolveFocusedAnchorId } from "./viewerSelection";
import {
resolveShipHeading,
updateSystemStarPresentation,
@@ -58,15 +58,21 @@ export interface WorldOrbitalContext {
export interface WorldPresentationContext extends WorldOrbitalContext {
activeSystemId?: string;
focusedAnchorId?: string;
cameraMode: CameraMode;
povLevel: PovLevel;
orbitYaw: number;
camera: THREE.PerspectiveCamera;
systemAnchor: THREE.Vector3;
shipVisuals: Map<string, ShipVisual>;
localShipVisuals: Map<string, ShipVisual>;
claimVisuals: Map<string, ClaimVisual>;
localClaimVisuals: Map<string, ClaimVisual>;
constructionSiteVisuals: Map<string, ConstructionSiteVisual>;
localConstructionSiteVisuals: Map<string, ConstructionSiteVisual>;
systemVisuals: Map<string, SystemVisual>;
localNodeVisuals: Map<string, NodeVisual>;
localStationVisuals: Map<string, StructureVisual>;
systemSummaryVisuals: Map<string, any>;
toDisplayLocalPosition: (localPosition: THREE.Vector3) => THREE.Vector3;
updateSystemDetailVisibility: () => void;
@@ -95,7 +101,7 @@ export function updateWorldPresentation(context: WorldPresentationContext) {
continue;
}
const worldPosition = getAnimatedShipLocalPosition(visual, now);
const worldPosition = resolveShipRenderPosition(context, ship, visual, now, renderMode);
const displayPosition = context.toDisplayLocalPosition(worldPosition);
visual.mesh.setPosition(displayPosition);
visual.icon.setPosition(rawObject(visual.mesh).position.clone());
@@ -124,8 +130,22 @@ export function updateWorldPresentation(context: WorldPresentationContext) {
}
}
for (const [shipId, visual] of context.localShipVisuals.entries()) {
const ship = context.world?.ships.get(shipId);
if (!ship) {
continue;
}
const localPosition = getAnimatedShipLocalPosition(visual, now);
const displayPosition = context.toDisplayLocalPosition(localPosition);
visual.mesh.setPosition(displayPosition);
visual.icon.setPosition(rawObject(visual.mesh).position.clone());
visual.mesh.setVisible(renderMode === "local");
visual.icon.setVisible(renderMode === "local");
}
for (const visual of context.nodeVisuals.values()) {
const animatedLocalPosition = computeNodeLocalPosition(context, visual, worldTimeSeconds);
const animatedLocalPosition = resolveNodeRenderPosition(context, visual, worldTimeSeconds, renderMode);
visual.mesh.setPosition(context.toDisplayLocalPosition(animatedLocalPosition));
visual.icon.setPosition(rawObject(visual.mesh).position.clone());
visual.mesh.setVisible(visual.systemId === context.activeSystemId);
@@ -148,7 +168,7 @@ export function updateWorldPresentation(context: WorldPresentationContext) {
}
for (const visual of context.stationVisuals.values()) {
const animatedLocalPosition = resolveStructureAnimatedLocalPosition(context, visual, worldTimeSeconds);
const animatedLocalPosition = resolveStructureRenderPosition(context, visual, worldTimeSeconds, renderMode);
const displayPosition = context.toDisplayLocalPosition(animatedLocalPosition);
visual.mesh.setPosition(displayPosition);
visual.icon.setPosition(rawObject(visual.mesh).position.clone());
@@ -165,7 +185,7 @@ export function updateWorldPresentation(context: WorldPresentationContext) {
}
for (const visual of context.claimVisuals.values()) {
const animatedLocalPosition = computeCelestialLocalPositionById(context, visual.celestialId, worldTimeSeconds) ?? visual.localPosition.clone();
const animatedLocalPosition = visual.localPosition.clone();
visual.mesh.setPosition(context.toDisplayLocalPosition(animatedLocalPosition));
visual.icon.setPosition(rawObject(visual.mesh).position.clone());
visual.mesh.setVisible(visual.systemId === context.activeSystemId);
@@ -173,12 +193,41 @@ export function updateWorldPresentation(context: WorldPresentationContext) {
}
for (const visual of context.constructionSiteVisuals.values()) {
const animatedLocalPosition = computeCelestialLocalPositionById(context, visual.celestialId, worldTimeSeconds) ?? visual.localPosition.clone();
const animatedLocalPosition = visual.localPosition.clone();
visual.mesh.setPosition(context.toDisplayLocalPosition(animatedLocalPosition));
visual.icon.setPosition(rawObject(visual.mesh).position.clone());
visual.mesh.setVisible(visual.systemId === context.activeSystemId);
visual.icon.setVisible(visual.systemId === context.activeSystemId);
}
for (const visual of context.localNodeVisuals.values()) {
visual.mesh.setPosition(context.toDisplayLocalPosition(visual.localPosition.clone()));
visual.icon.setPosition(rawObject(visual.mesh).position.clone());
visual.mesh.setVisible(renderMode === "local");
visual.icon.setVisible(renderMode === "local");
}
for (const visual of context.localStationVisuals.values()) {
const displayPosition = context.toDisplayLocalPosition(visual.localPosition.clone());
visual.mesh.setPosition(displayPosition);
visual.icon.setPosition(rawObject(visual.mesh).position.clone());
visual.mesh.setVisible(renderMode === "local");
visual.icon.setVisible(renderMode === "local");
}
for (const visual of context.localClaimVisuals.values()) {
visual.mesh.setPosition(context.toDisplayLocalPosition(visual.localPosition.clone()));
visual.icon.setPosition(rawObject(visual.mesh).position.clone());
visual.mesh.setVisible(renderMode === "local");
visual.icon.setVisible(renderMode === "local");
}
for (const visual of context.localConstructionSiteVisuals.values()) {
visual.mesh.setPosition(context.toDisplayLocalPosition(visual.localPosition.clone()));
visual.icon.setPosition(rawObject(visual.mesh).position.clone());
visual.mesh.setVisible(renderMode === "local");
visual.icon.setVisible(renderMode === "local");
}
}
type RenderSpaceMode = "galaxy" | "system" | "local";
@@ -213,6 +262,60 @@ export function resolveShipWorldPosition(
return context.toDisplayLocalPosition(animatedLocalPosition);
}
function resolveAnchorSystemPosition(context: WorldOrbitalContext, anchorId?: string | null) {
if (!anchorId) {
return undefined;
}
const anchor = context.world?.anchors.get(anchorId);
return anchor ? toThreeVector(anchor.systemPosition) : undefined;
}
function resolveShipRenderPosition(
context: WorldPresentationContext,
ship: ShipSnapshot,
visual: ShipVisual,
now: number,
renderMode: RenderSpaceMode,
) {
const animatedLocalPosition = getAnimatedShipLocalPosition(visual, now);
const currentAnchorId = ship.spatialState.currentAnchorId ?? ship.anchorId ?? visual.anchorId;
const anchoredSystemPosition = resolveAnchorSystemPosition(context, currentAnchorId)
?? (ship.spatialState.systemPosition ? toThreeVector(ship.spatialState.systemPosition) : undefined);
if (renderMode === "local" && currentAnchorId && currentAnchorId === context.focusedAnchorId) {
return animatedLocalPosition;
}
return anchoredSystemPosition ?? animatedLocalPosition;
}
function resolveNodeRenderPosition(
context: WorldPresentationContext,
visual: NodeVisual,
timeSeconds: number,
renderMode: RenderSpaceMode,
) {
if (renderMode === "local" && visual.anchorId === context.focusedAnchorId) {
return computeNodeLocalPosition(context, visual, timeSeconds);
}
return resolveAnchorSystemPosition(context, visual.anchorId) ?? visual.localPosition.clone();
}
function resolveStructureRenderPosition(
context: WorldPresentationContext,
visual: StructureVisual,
timeSeconds: number,
renderMode: RenderSpaceMode,
) {
if (renderMode === "local" && visual.anchorId && visual.anchorId === context.focusedAnchorId) {
return resolveStructureAnimatedLocalPosition(context, visual, timeSeconds);
}
return resolveAnchorSystemPosition(context, visual.anchorId) ?? visual.localPosition.clone();
}
export function updateSystemSummaries(world: WorldState | undefined, systemSummaryVisuals: Map<string, any>) {
if (!world) {
return;
@@ -310,9 +413,9 @@ export function describeGameStatus(params: GameStatusParams) {
? `sys pos: ${fmtVec(systemAnchor.clone().divideScalar(KILOMETERS_PER_AU), 3)} AU`
: "";
// Local space: position relative to the focused celestial's orbital anchor in km
const focusedCelestialId = resolveFocusedCelestialId(world, selectedItems);
const celestialAnchor = focusedCelestialId
? world?.celestials.get(focusedCelestialId)?.orbitalAnchor
const focusedAnchorId = resolveFocusedAnchorId(world, selectedItems);
const celestialAnchor = focusedAnchorId
? (world?.anchors.get(focusedAnchorId)?.systemPosition ?? world?.celestials.get(focusedAnchorId)?.orbitalAnchor)
: undefined;
const locPos = systemAnchor && celestialAnchor
? `loc pos: ${fmtVec(systemAnchor.clone().sub(toThreeVector(celestialAnchor)), 0)} km`
@@ -415,7 +518,14 @@ export function resolveOrbitalAnchor(context: WorldOrbitalContext, systemId: str
return bestAnchor;
}
export function resolvePointPosition(context: WorldOrbitalContext, _systemId: string, celestialId?: string | null) {
export function resolvePointPosition(context: WorldOrbitalContext, _systemId: string, celestialId?: string | null, anchorId?: string | null) {
if (anchorId) {
const anchor = context.world?.anchors.get(anchorId);
if (anchor) {
return toThreeVector(anchor.systemPosition);
}
}
if (celestialId) {
const celestial = context.world?.celestials.get(celestialId);
if (celestial) {
@@ -446,17 +556,17 @@ export function computeCelestialLocalPositionById(
}
const basePosition = toThreeVector(celestial.orbitalAnchor);
if (!celestial.parentNodeId) {
if (!celestial.parentAnchorId) {
return basePosition;
}
const parentCelestial = context.world.celestials.get(celestial.parentNodeId);
const parentCelestial = context.world.celestials.get(celestial.parentAnchorId);
if (!parentCelestial) {
return basePosition;
}
visiting.add(celestialId);
const parentCurrentPosition = computeCelestialLocalPositionById(context, celestial.parentNodeId, timeSeconds, visiting);
const parentCurrentPosition = computeCelestialLocalPositionById(context, celestial.parentAnchorId, timeSeconds, visiting);
visiting.delete(celestialId);
if (!parentCurrentPosition) {
return basePosition;
@@ -548,9 +658,16 @@ function resolveStructureAnimatedLocalPosition(context: WorldOrbitalContext, vis
}
const station = context.world.stations.get(visual.id);
if (!station?.celestialId) {
if (!station) {
return computeStructureLocalPosition(context, visual, timeSeconds, 0.14);
}
return computeCelestialLocalPositionById(context, station.celestialId, timeSeconds) ?? visual.localPosition.clone();
if (station.anchorId) {
const anchor = context.world.anchors.get(station.anchorId);
if (anchor) {
return toThreeVector(anchor.systemPosition);
}
}
return computeStructureLocalPosition(context, visual, timeSeconds, 0.14);
}

View File

@@ -12,7 +12,7 @@ export default defineConfig({
port: 5174,
allowedHosts: ["sobina.local"],
proxy: {
"/api": "http://127.0.0.1:5080",
"/api": "http://127.0.0.1:5079",
},
},
build: {