Add player onboarding and tactical viewer updates
This commit is contained in:
@@ -2,8 +2,6 @@
|
||||
import { storeToRefs } from "pinia";
|
||||
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import { GameViewer } from "./GameViewer";
|
||||
import CollapsibleHudPanel from "./components/CollapsibleHudPanel.vue";
|
||||
import HtmlInfoPanel from "./components/HtmlInfoPanel.vue";
|
||||
import ViewerHistoryLayer from "./components/ViewerHistoryLayer.vue";
|
||||
import ViewerEntityBrowserPanel from "./components/ViewerEntityBrowserPanel.vue";
|
||||
import ViewerEntityInspectorPanel from "./components/ViewerEntityInspectorPanel.vue";
|
||||
@@ -13,9 +11,12 @@ import GmTelemetryWindow from "./components/gm/GmTelemetryWindow.vue";
|
||||
import GmSettingsWindow from "./components/gm/GmSettingsWindow.vue";
|
||||
import AuthSessionPanel from "./components/AuthSessionPanel.vue";
|
||||
import AuthLandingPage from "./components/AuthLandingPage.vue";
|
||||
import PlayerOnboardingPanel from "./components/PlayerOnboardingPanel.vue";
|
||||
import { fetchPlayerFaction } from "./api";
|
||||
import { useShipAutomationCatalogStore } from "./ui/stores/shipAutomationCatalogStore";
|
||||
import { createViewerHudState } from "./viewerHudState";
|
||||
import { useAuthStore } from "./ui/stores/authStore";
|
||||
import { usePlayerFactionStore } from "./ui/stores/playerFactionStore";
|
||||
import { useViewerSelectionStore } from "./ui/stores/viewerSelection";
|
||||
import type { Selectable } from "./viewerTypes";
|
||||
|
||||
@@ -27,19 +28,24 @@ const hoverConnectorLineEl = ref<SVGLineElement | null>(null);
|
||||
|
||||
const hudState = createViewerHudState();
|
||||
const authStore = useAuthStore();
|
||||
const playerFactionStore = usePlayerFactionStore();
|
||||
const automationCatalogStore = useShipAutomationCatalogStore();
|
||||
const selectionStore = useViewerSelectionStore();
|
||||
const { selectedEntityId, selectedEntityLabel } = storeToRefs(selectionStore);
|
||||
const { canAccessGm } = storeToRefs(authStore);
|
||||
const { canAccessGm, effectivePlayerId } = storeToRefs(authStore);
|
||||
const { playerFaction } = storeToRefs(playerFactionStore);
|
||||
let viewer: GameViewer | undefined;
|
||||
|
||||
const gmOpsOpen = ref(false);
|
||||
const gmTelemetryOpen = ref(false);
|
||||
const gmSettingsOpen = ref(false);
|
||||
const gmMenuOpen = ref(false);
|
||||
const leftSidebarTab = ref<"player" | "entities">("player");
|
||||
const playerContextReady = ref(false);
|
||||
|
||||
onMounted(async () => {
|
||||
void automationCatalogStore.load();
|
||||
await refreshPlayerContext();
|
||||
await startViewerIfAuthenticated();
|
||||
});
|
||||
|
||||
@@ -47,15 +53,35 @@ onBeforeUnmount(() => {
|
||||
viewer?.dispose();
|
||||
});
|
||||
|
||||
watch(() => authStore.isAuthenticated, async (isAuthenticated) => {
|
||||
if (isAuthenticated) {
|
||||
await startViewerIfAuthenticated();
|
||||
return;
|
||||
}
|
||||
watch(
|
||||
[() => authStore.isAuthenticated, () => effectivePlayerId.value],
|
||||
async ([isAuthenticated]) => {
|
||||
if (!isAuthenticated) {
|
||||
playerContextReady.value = false;
|
||||
playerFactionStore.setPlayerFaction(null);
|
||||
viewer?.dispose();
|
||||
viewer = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
viewer?.dispose();
|
||||
viewer = undefined;
|
||||
});
|
||||
await refreshPlayerContext();
|
||||
await startViewerIfAuthenticated();
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
watch(
|
||||
() => playerFaction.value?.requiresOnboarding ?? false,
|
||||
async (requiresOnboarding) => {
|
||||
if (requiresOnboarding) {
|
||||
viewer?.dispose();
|
||||
viewer = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
await startViewerIfAuthenticated();
|
||||
},
|
||||
);
|
||||
|
||||
function onHistoryWindowResize(id: string, width: number, height: number) {
|
||||
const windowState = hudState.historyWindows.find((entry) => entry.id === id);
|
||||
@@ -76,7 +102,7 @@ function onFocusSelection(selection: Selectable, cameraMode?: "follow" | "tactic
|
||||
}
|
||||
|
||||
async function startViewerIfAuthenticated() {
|
||||
if (!authStore.isAuthenticated || viewer) {
|
||||
if (!authStore.isAuthenticated || viewer || !playerContextReady.value || playerFaction.value?.requiresOnboarding) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -101,58 +127,112 @@ async function startViewerIfAuthenticated() {
|
||||
});
|
||||
void viewer.start();
|
||||
}
|
||||
|
||||
async function refreshPlayerContext() {
|
||||
if (!authStore.isAuthenticated) {
|
||||
playerContextReady.value = false;
|
||||
playerFactionStore.setPlayerFaction(null);
|
||||
return;
|
||||
}
|
||||
|
||||
playerContextReady.value = false;
|
||||
try {
|
||||
playerFactionStore.setPlayerFaction(await fetchPlayerFaction());
|
||||
} catch {
|
||||
playerFactionStore.setPlayerFaction(null);
|
||||
} finally {
|
||||
playerContextReady.value = true;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AuthLandingPage v-if="!authStore.isAuthenticated" />
|
||||
<div v-else-if="!playerContextReady" class="auth-landing">
|
||||
<div class="auth-landing__backdrop" />
|
||||
<div class="auth-landing__hero">
|
||||
<h1>Preparing player context</h1>
|
||||
<p>Loading your in-universe identity and ownership state.</p>
|
||||
</div>
|
||||
</div>
|
||||
<PlayerOnboardingPanel v-else-if="playerContextReady && playerFaction?.requiresOnboarding" />
|
||||
<div v-else class="viewer-app">
|
||||
<div
|
||||
ref="canvasHostEl"
|
||||
class="viewer-canvas-host"
|
||||
/>
|
||||
<div class="pointer-events-none fixed inset-0">
|
||||
<div class="absolute left-5 top-5 flex max-h-[calc(100vh-40px)] w-[min(360px,calc(100vw-40px))] flex-col gap-4 overflow-hidden max-[760px]:right-5 max-[760px]:bottom-[148px] max-[760px]:w-auto max-[760px]:max-h-[38vh]">
|
||||
<AuthSessionPanel />
|
||||
<CollapsibleHudPanel
|
||||
v-model:collapsed="hudState.gamePanel.collapsed"
|
||||
class-name="topbar"
|
||||
panel-name="game"
|
||||
title="Game"
|
||||
:summary="hudState.gamePanel.summary"
|
||||
:body-text="hudState.gamePanel.bodyText"
|
||||
/>
|
||||
<CollapsibleHudPanel
|
||||
v-model:collapsed="hudState.networkPanel.collapsed"
|
||||
class-name="network-panel"
|
||||
panel-name="network"
|
||||
title="Network"
|
||||
:summary="hudState.networkPanel.summary"
|
||||
:body-text="hudState.networkPanel.bodyText"
|
||||
/>
|
||||
<CollapsibleHudPanel
|
||||
v-model:collapsed="hudState.performancePanel.collapsed"
|
||||
class-name="performance-panel"
|
||||
panel-name="performance"
|
||||
title="Performance"
|
||||
:summary="hudState.performancePanel.summary"
|
||||
:body-text="hudState.performancePanel.bodyText"
|
||||
/>
|
||||
<ViewerEntityBrowserPanel
|
||||
class="min-h-0 flex-1"
|
||||
@focus="(selection, cameraMode) => onFocusSelection(selection, cameraMode)"
|
||||
/>
|
||||
<div class="viewer-left-sidebar-dock">
|
||||
<section class="viewer-left-sidebar pointer-events-auto">
|
||||
<div class="viewer-left-sidebar__tabs">
|
||||
<button
|
||||
type="button"
|
||||
class="viewer-left-sidebar__tab"
|
||||
:class="leftSidebarTab === 'player' ? 'viewer-left-sidebar__tab--active' : ''"
|
||||
@click="leftSidebarTab = 'player'"
|
||||
>
|
||||
Player Informations
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="viewer-left-sidebar__tab"
|
||||
:class="leftSidebarTab === 'entities' ? 'viewer-left-sidebar__tab--active' : ''"
|
||||
@click="leftSidebarTab = 'entities'"
|
||||
>
|
||||
Entities
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="viewer-left-sidebar__body">
|
||||
<div
|
||||
v-if="leftSidebarTab === 'player'"
|
||||
class="viewer-left-sidebar__panel viewer-left-sidebar__panel--player"
|
||||
>
|
||||
<AuthSessionPanel />
|
||||
</div>
|
||||
<ViewerEntityBrowserPanel
|
||||
v-else
|
||||
class="viewer-left-sidebar__panel viewer-left-sidebar__panel--entities"
|
||||
@focus="(selection, cameraMode) => onFocusSelection(selection, cameraMode)"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="hudState.statsOverlay.lines.length > 0"
|
||||
class="viewer-stats-overlay-dock"
|
||||
>
|
||||
<div
|
||||
class="viewer-stats-overlay"
|
||||
:class="hudState.statsOverlay.mode === 'compact' ? 'viewer-stats-overlay--compact' : ''"
|
||||
>
|
||||
<div
|
||||
v-for="(line, index) in hudState.statsOverlay.lines"
|
||||
:key="`${index}-${line}`"
|
||||
class="viewer-stats-overlay__line"
|
||||
:class="line === '' ? 'viewer-stats-overlay__line--spacer' : ''"
|
||||
>
|
||||
{{ line === "" ? "\u00A0" : line }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!hudState.systemPanel.hidden"
|
||||
class="viewer-system-label-dock"
|
||||
>
|
||||
<div class="viewer-system-label">
|
||||
<div class="viewer-system-label__title">
|
||||
{{ hudState.systemPanel.title }}
|
||||
</div>
|
||||
<div class="viewer-system-label__subtitle">
|
||||
{{ hudState.systemPanel.bodyHtml }}
|
||||
</div>
|
||||
</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">
|
||||
<HtmlInfoPanel
|
||||
class-name="system-panel-section"
|
||||
title="System"
|
||||
:subtitle="hudState.systemPanel.title"
|
||||
:body-html="hudState.systemPanel.bodyHtml"
|
||||
:hidden="hudState.systemPanel.hidden"
|
||||
subtitle-class="system-title"
|
||||
body-class="system-body"
|
||||
/>
|
||||
<ViewerEntityInspectorPanel
|
||||
class="min-h-0 flex-1"
|
||||
:fallback-title="hudState.detailPanel.title"
|
||||
@@ -222,7 +302,7 @@ async function startViewerIfAuthenticated() {
|
||||
<GmOpsWindow
|
||||
v-if="gmOpsOpen"
|
||||
@close="gmOpsOpen = false"
|
||||
@focus="(id, kind) => onFocusSelection({ kind, id }, kind === 'ship' ? 'follow' : 'tactical')"
|
||||
@focus="(id, kind) => onFocusSelection({ kind, id }, 'tactical')"
|
||||
/>
|
||||
<GmTelemetryWindow
|
||||
v-if="gmTelemetryOpen"
|
||||
|
||||
@@ -91,7 +91,7 @@ export class ViewerAppController {
|
||||
private currentDistance = NAV_DISTANCE.system;
|
||||
private desiredDistance = NAV_DISTANCE.system;
|
||||
private orbitYaw = -2.3;
|
||||
private orbitPitch = 0.62;
|
||||
private orbitPitch = 1.08;
|
||||
private cameraMode: CameraMode = "tactical";
|
||||
private dragMode?: DragMode;
|
||||
private dragPointerId?: number;
|
||||
@@ -195,6 +195,7 @@ export class ViewerAppController {
|
||||
return this.sceneDataController.createWorldPresentationContext({
|
||||
world: this.world,
|
||||
activeSystemId: this.activeSystemId,
|
||||
cameraMode: this.cameraMode,
|
||||
povLevel: this.povLevel,
|
||||
orbitYaw: this.orbitYaw,
|
||||
systemCamera: this.systemLayer.camera,
|
||||
@@ -293,7 +294,7 @@ export class ViewerAppController {
|
||||
}
|
||||
|
||||
this.updatePanFromKeyboard(delta);
|
||||
this.orbitPitch = THREE.MathUtils.clamp(this.orbitPitch, 0.18, 1.3);
|
||||
this.orbitPitch = THREE.MathUtils.clamp(this.orbitPitch, 0.92, 1.32);
|
||||
|
||||
const orbitOffset = this.computeOrbitOffset();
|
||||
|
||||
|
||||
@@ -2,12 +2,15 @@ import type { WorldDelta, WorldSnapshot } from "./contracts";
|
||||
import type { TelemetrySnapshot } from "./contractsTelemetry";
|
||||
import type { BalanceSettings } from "./contractsBalance";
|
||||
import type { PlayerFactionSnapshot } from "./contractsPlayerFaction";
|
||||
import type { AuthSessionResponse, ForgotPasswordResponse } from "./contractsAuth";
|
||||
import type { RaceSnapshot } from "./contractsRaces";
|
||||
import type { AuthSessionResponse, ForgotPasswordResponse, RegisterResponse } from "./contractsAuth";
|
||||
import type { PlayerIdentitySummary } from "./contractsIdentity";
|
||||
import type { ShipAutomationCatalogSnapshot } from "./contractsShipAutomation";
|
||||
import type { FactionSnapshot } from "./contractsFactions";
|
||||
import type { ShipSnapshot } from "./contractsShips";
|
||||
import type { StationSnapshot } from "./contractsInfrastructure";
|
||||
import { clearAuthSession, getAuthSession, setAuthSession } from "./authSession";
|
||||
import { getEffectivePlayerIdentityId } from "./effectiveIdentitySession";
|
||||
import type {
|
||||
PlayerAssetAssignmentCommandRequest,
|
||||
PlayerAutomationPolicyCommandRequest,
|
||||
@@ -35,6 +38,12 @@ async function fetchJson<T>(input: RequestInfo | URL, init?: RequestInit, option
|
||||
if (session?.accessToken) {
|
||||
headers.set("Authorization", `Bearer ${session.accessToken}`);
|
||||
}
|
||||
if (session?.roles.some((role) => role === "gm" || role === "admin")) {
|
||||
const effectivePlayerId = getEffectivePlayerIdentityId();
|
||||
if (effectivePlayerId) {
|
||||
headers.set("X-Act-As-Player-Id", effectivePlayerId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(input, {
|
||||
@@ -160,13 +169,11 @@ export async function resetWorld() {
|
||||
}
|
||||
|
||||
export async function register(request: { email: string; password: string }) {
|
||||
const session = await fetchJson<AuthSessionResponse>("/api/auth/register", {
|
||||
return fetchJson<RegisterResponse>("/api/auth/register", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(request),
|
||||
}, { skipAuth: true, skipRefresh: true });
|
||||
setAuthSession(session);
|
||||
return session;
|
||||
}
|
||||
|
||||
export async function login(request: { email: string; password: string }) {
|
||||
@@ -199,6 +206,22 @@ export async function fetchPlayerFaction(signal?: AbortSignal) {
|
||||
return fetchJson<PlayerFactionSnapshot>("/api/player-faction", { signal });
|
||||
}
|
||||
|
||||
export async function fetchRaces(signal?: AbortSignal) {
|
||||
return fetchJson<RaceSnapshot[]>("/api/auth/races", { signal }, { skipAuth: true });
|
||||
}
|
||||
|
||||
export async function completePlayerOnboarding(request: { name: string; raceId: string }) {
|
||||
return fetchJson<PlayerFactionSnapshot>("/api/player-faction/onboarding", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchPlayerIdentities(signal?: AbortSignal) {
|
||||
return fetchJson<PlayerIdentitySummary[]>("/api/player-faction/identities", { signal });
|
||||
}
|
||||
|
||||
export async function fetchShipAutomationCatalog(signal?: AbortSignal) {
|
||||
return fetchJson<ShipAutomationCatalogSnapshot>("/api/ships/catalog", { signal }, { skipAuth: true });
|
||||
}
|
||||
|
||||
@@ -56,9 +56,12 @@ async function submitLogin() {
|
||||
|
||||
async function submitRegister() {
|
||||
await execute(async () => {
|
||||
const session = await register(registerForm);
|
||||
authStore.setSession(session);
|
||||
await register(registerForm);
|
||||
playerFactionStore.setPlayerFaction(null);
|
||||
infoMessage.value = "Account created. Sign in to enter the universe.";
|
||||
pane.value = "login";
|
||||
loginForm.email = registerForm.email;
|
||||
registerForm.password = "";
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,34 +1,54 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from "vue";
|
||||
import { computed, reactive, ref, watch } from "vue";
|
||||
import { storeToRefs } from "pinia";
|
||||
import { login, register } from "../api";
|
||||
import { fetchPlayerFaction, fetchPlayerIdentities, login, register } from "../api";
|
||||
import { useAuthStore } from "../ui/stores/authStore";
|
||||
import { usePlayerFactionStore } from "../ui/stores/playerFactionStore";
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const playerFactionStore = usePlayerFactionStore();
|
||||
const { session, busy } = storeToRefs(authStore);
|
||||
const { session, busy, availablePlayerIdentities, effectivePlayerId } = storeToRefs(authStore);
|
||||
|
||||
const mode = ref<"login" | "register">("login");
|
||||
const email = ref("");
|
||||
const password = ref("");
|
||||
const errorMessage = ref("");
|
||||
const identityBusy = ref(false);
|
||||
const identityError = ref("");
|
||||
const forgotPasswordOpen = ref(false);
|
||||
const forgotPasswordState = reactive({
|
||||
email: "",
|
||||
});
|
||||
|
||||
const selectedIdentityId = computed({
|
||||
get: () => effectivePlayerId.value ?? "",
|
||||
set: (value: string) => {
|
||||
void switchIdentity(value || null);
|
||||
},
|
||||
});
|
||||
|
||||
const canAccessGm = computed(() => authStore.canAccessGm);
|
||||
const activeIdentitySummary = computed(() =>
|
||||
availablePlayerIdentities.value.find((entry) => entry.userId === (effectivePlayerId.value ?? session.value?.userId ?? "")) ?? null,
|
||||
);
|
||||
|
||||
async function submit() {
|
||||
errorMessage.value = "";
|
||||
authStore.setBusy(true);
|
||||
try {
|
||||
const snapshot = mode.value === "login"
|
||||
? await login({ email: email.value, password: password.value })
|
||||
: await register({ email: email.value, password: password.value });
|
||||
authStore.setSession(snapshot);
|
||||
playerFactionStore.setPlayerFaction(null);
|
||||
password.value = "";
|
||||
forgotPasswordOpen.value = false;
|
||||
if (mode.value === "login") {
|
||||
const snapshot = await login({ email: email.value, password: password.value });
|
||||
authStore.setSession(snapshot);
|
||||
playerFactionStore.setPlayerFaction(null);
|
||||
password.value = "";
|
||||
forgotPasswordOpen.value = false;
|
||||
} else {
|
||||
await register({ email: email.value, password: password.value });
|
||||
playerFactionStore.setPlayerFaction(null);
|
||||
errorMessage.value = "";
|
||||
mode.value = "login";
|
||||
password.value = "";
|
||||
}
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : "Authentication failed.";
|
||||
} finally {
|
||||
@@ -42,6 +62,59 @@ function logout() {
|
||||
errorMessage.value = "";
|
||||
password.value = "";
|
||||
}
|
||||
|
||||
async function refreshPlayerContext() {
|
||||
if (!authStore.isAuthenticated) {
|
||||
playerFactionStore.setPlayerFaction(null);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
playerFactionStore.setPlayerFaction(await fetchPlayerFaction());
|
||||
} catch {
|
||||
playerFactionStore.setPlayerFaction(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPlayerIdentities() {
|
||||
if (!authStore.isAuthenticated || !authStore.canAccessGm) {
|
||||
authStore.setAvailablePlayerIdentities([]);
|
||||
return;
|
||||
}
|
||||
|
||||
identityBusy.value = true;
|
||||
identityError.value = "";
|
||||
try {
|
||||
authStore.setAvailablePlayerIdentities(await fetchPlayerIdentities());
|
||||
} catch (error) {
|
||||
identityError.value = error instanceof Error ? error.message : "Unable to load player identities.";
|
||||
authStore.setAvailablePlayerIdentities([]);
|
||||
} finally {
|
||||
identityBusy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function switchIdentity(nextPlayerId: string | null) {
|
||||
authStore.setEffectivePlayerId(nextPlayerId);
|
||||
identityBusy.value = true;
|
||||
identityError.value = "";
|
||||
try {
|
||||
await refreshPlayerContext();
|
||||
} catch (error) {
|
||||
identityError.value = error instanceof Error ? error.message : "Unable to switch current identity.";
|
||||
} finally {
|
||||
identityBusy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => session.value?.userId ?? null,
|
||||
async () => {
|
||||
await loadPlayerIdentities();
|
||||
await refreshPlayerContext();
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -52,6 +125,29 @@ function logout() {
|
||||
<div class="text-xs uppercase tracking-[0.22em] text-white/45">Identity</div>
|
||||
<div class="mt-1 text-sm font-semibold">{{ session.email }}</div>
|
||||
<div class="mt-1 text-xs text-white/55">Player {{ session.userId.slice(0, 8) }}</div>
|
||||
<div v-if="canAccessGm" class="mt-3">
|
||||
<div class="text-[10px] uppercase tracking-[0.18em] text-white/45">Current Identity</div>
|
||||
<select
|
||||
v-model="selectedIdentityId"
|
||||
class="mt-1 w-full rounded-xl border border-white/10 bg-black/20 px-3 py-2 text-sm outline-none transition focus:border-white/30"
|
||||
:disabled="identityBusy"
|
||||
>
|
||||
<option value="">GM Self</option>
|
||||
<option
|
||||
v-for="identity in availablePlayerIdentities"
|
||||
:key="identity.userId"
|
||||
:value="identity.userId"
|
||||
>
|
||||
{{ identity.email }}{{ identity.playerFactionLabel ? ` · ${identity.playerFactionLabel}` : "" }}
|
||||
</option>
|
||||
</select>
|
||||
<div class="mt-1 text-[11px] text-white/50">
|
||||
Acting as
|
||||
{{ activeIdentitySummary?.email ?? session.email }}
|
||||
<span v-if="activeIdentitySummary?.playerFactionLabel"> · {{ activeIdentitySummary.playerFactionLabel }}</span>
|
||||
</div>
|
||||
<div v-if="identityError" class="mt-2 text-[11px] text-[#ffd8cf]">{{ identityError }}</div>
|
||||
</div>
|
||||
<div v-if="session.roles.length > 0" class="mt-2 flex flex-wrap gap-1.5">
|
||||
<span
|
||||
v-for="role in session.roles"
|
||||
|
||||
115
apps/viewer/src/components/PlayerOnboardingPanel.vue
Normal file
115
apps/viewer/src/components/PlayerOnboardingPanel.vue
Normal file
@@ -0,0 +1,115 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from "vue";
|
||||
import { completePlayerOnboarding, fetchRaces } from "../api";
|
||||
import type { RaceSnapshot } from "../contractsRaces";
|
||||
import { useAuthStore } from "../ui/stores/authStore";
|
||||
import { usePlayerFactionStore } from "../ui/stores/playerFactionStore";
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const playerFactionStore = usePlayerFactionStore();
|
||||
|
||||
const busy = ref(false);
|
||||
const loadingRaces = ref(false);
|
||||
const errorMessage = ref("");
|
||||
const raceOptions = ref<RaceSnapshot[]>([]);
|
||||
const form = reactive({
|
||||
name: "",
|
||||
raceId: "",
|
||||
});
|
||||
|
||||
const canSubmit = computed(() =>
|
||||
form.name.trim().length >= 2 && form.raceId.trim().length > 0 && !busy.value && !loadingRaces.value,
|
||||
);
|
||||
|
||||
onMounted(async () => {
|
||||
loadingRaces.value = true;
|
||||
errorMessage.value = "";
|
||||
try {
|
||||
raceOptions.value = (await fetchRaces()).sort((left, right) => left.name.localeCompare(right.name));
|
||||
if (!form.raceId && raceOptions.value.length > 0) {
|
||||
form.raceId = raceOptions.value[0].id;
|
||||
}
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : "Unable to load race options.";
|
||||
} finally {
|
||||
loadingRaces.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
async function submit() {
|
||||
if (!canSubmit.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
busy.value = true;
|
||||
errorMessage.value = "";
|
||||
try {
|
||||
const snapshot = await completePlayerOnboarding({
|
||||
name: form.name.trim(),
|
||||
raceId: form.raceId,
|
||||
});
|
||||
playerFactionStore.setPlayerFaction(snapshot);
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : "Unable to create your pilot.";
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function signOut() {
|
||||
authStore.clearSession();
|
||||
playerFactionStore.setPlayerFaction(null);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="auth-landing">
|
||||
<div class="auth-landing__backdrop" />
|
||||
|
||||
<div class="auth-landing__hero">
|
||||
<h1>Create your pilot</h1>
|
||||
<p>
|
||||
This account has access to the universe, but it does not have an in-game identity yet. Choose a name and an origin faction, then you will start with a single basic ship.
|
||||
</p>
|
||||
<div class="auth-card">
|
||||
<h2>First Login Setup</h2>
|
||||
|
||||
<form class="auth-card__form" @submit.prevent="submit">
|
||||
<input
|
||||
v-model.trim="form.name"
|
||||
type="text"
|
||||
autocomplete="nickname"
|
||||
maxlength="48"
|
||||
placeholder="Pilot name"
|
||||
>
|
||||
<select v-model="form.raceId" :disabled="loadingRaces || busy">
|
||||
<option value="" disabled>Select a race</option>
|
||||
<option
|
||||
v-for="race in raceOptions"
|
||||
:key="race.id"
|
||||
:value="race.id"
|
||||
>
|
||||
{{ race.name }}
|
||||
</option>
|
||||
</select>
|
||||
<button type="submit" :disabled="!canSubmit">
|
||||
{{ busy ? "Entering universe..." : "Create pilot" }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div v-if="loadingRaces" class="auth-card__message auth-card__message--info">
|
||||
Loading faction options...
|
||||
</div>
|
||||
<div v-if="errorMessage" class="auth-card__message auth-card__message--error">
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
|
||||
<div class="auth-card__footer">
|
||||
<button type="button" class="auth-card__link" @click="signOut">
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,6 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import { storeToRefs } from "pinia";
|
||||
import type { StationSnapshot } from "../contractsInfrastructure";
|
||||
import type { PlayerFleetSnapshot } from "../contractsPlayerFaction";
|
||||
import type { ShipSnapshot } from "../contractsShips";
|
||||
import { getShipBehaviorLabel } from "../shipAutomationPresentation";
|
||||
import { useGmStore } from "../ui/stores/gmStore";
|
||||
import { usePlayerFactionStore } from "../ui/stores/playerFactionStore";
|
||||
@@ -9,22 +12,27 @@ import { useViewerSelectionStore, type ViewerSelectionSummary } from "../ui/stor
|
||||
import type { Selectable } from "../viewerTypes";
|
||||
|
||||
type BrowserTab = "visible" | "owned";
|
||||
type BrowserSortKey = "entity" | "location" | "ai" | "hp";
|
||||
type BrowserRowKind = "system" | "station" | "fleet" | "ship";
|
||||
|
||||
interface BrowserItem {
|
||||
interface BrowserRow {
|
||||
key: string;
|
||||
label: string;
|
||||
subtitle: string;
|
||||
meta?: string;
|
||||
kind: BrowserRowKind;
|
||||
kindLabel: string;
|
||||
name: string;
|
||||
ident: string;
|
||||
location: string;
|
||||
aiStates: string[];
|
||||
hpLabel: string;
|
||||
hpValue: number;
|
||||
selection?: ViewerSelectionSummary;
|
||||
focusSelection?: Selectable;
|
||||
focusMode?: "follow" | "tactical";
|
||||
children: BrowserRow[];
|
||||
}
|
||||
|
||||
interface BrowserSection {
|
||||
key: string;
|
||||
label: string;
|
||||
count: number;
|
||||
items: BrowserItem[];
|
||||
interface BrowserDisplayRow extends BrowserRow {
|
||||
depth: number;
|
||||
}
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -40,22 +48,28 @@ const { selectedEntityId, selectedEntityKind } = storeToRefs(selectionStore);
|
||||
const { playerFaction } = storeToRefs(playerStore);
|
||||
const { activeSystemId, povLevel } = storeToRefs(sceneStore);
|
||||
|
||||
const activeTab = ref<BrowserTab>("visible");
|
||||
const activeTab = ref<BrowserTab>("owned");
|
||||
const sortKey = ref<BrowserSortKey>("entity");
|
||||
const sortDirection = ref<"asc" | "desc">("asc");
|
||||
const searchText = ref("");
|
||||
const expandedRowKeys = ref<Record<string, boolean>>({});
|
||||
|
||||
const systemById = computed(() => new Map(gmStore.systems.map((system) => [system.id, system])));
|
||||
const stationById = computed(() => new Map(gmStore.stations.map((station) => [station.id, station])));
|
||||
const playerFleetByShipId = computed(() => {
|
||||
const mapping = new Map<string, PlayerFleetSnapshot>();
|
||||
for (const fleet of playerFaction.value?.fleets ?? []) {
|
||||
for (const assetId of fleet.assetIds) {
|
||||
mapping.set(assetId, fleet);
|
||||
}
|
||||
}
|
||||
return mapping;
|
||||
});
|
||||
|
||||
function normalize(text: string) {
|
||||
return text.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function matchesSearch(item: BrowserItem, search: string) {
|
||||
if (!search) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const haystack = `${item.label} ${item.subtitle} ${item.meta ?? ""}`.toLowerCase();
|
||||
return haystack.includes(search);
|
||||
}
|
||||
|
||||
function titleCase(value: string | null | undefined) {
|
||||
if (!value) {
|
||||
return "Unknown";
|
||||
@@ -69,168 +83,387 @@ function titleCase(value: string | null | undefined) {
|
||||
.replace(/\b\w/g, (part) => part.toUpperCase());
|
||||
}
|
||||
|
||||
function buildVisibleSections(): BrowserSection[] {
|
||||
const sections: BrowserSection[] = [];
|
||||
function compactLabel(value: string | null | undefined, fallback: string) {
|
||||
if (!value) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const words = titleCase(value).split(" ");
|
||||
if (words.length === 1) {
|
||||
return words[0].slice(0, 4).toUpperCase();
|
||||
}
|
||||
return words
|
||||
.slice(0, 2)
|
||||
.map((word) => word.slice(0, 3).toUpperCase())
|
||||
.join("-");
|
||||
}
|
||||
|
||||
function shortId(value: string) {
|
||||
if (value.length <= 8) {
|
||||
return value.toUpperCase();
|
||||
}
|
||||
return `${value.slice(0, 4).toUpperCase()}-${value.slice(-4).toUpperCase()}`;
|
||||
}
|
||||
|
||||
function uniqueTokens(tokens: string[]) {
|
||||
return tokens.filter((token, index) => token.length > 0 && tokens.indexOf(token) === index);
|
||||
}
|
||||
|
||||
function formatShipLocation(ship: ShipSnapshot) {
|
||||
const dockedStation = ship.dockedStationId ? stationById.value.get(ship.dockedStationId) : undefined;
|
||||
if (dockedStation) {
|
||||
return `Docked ${dockedStation.label}`;
|
||||
}
|
||||
|
||||
if (ship.spatialState.transit?.destinationNodeId) {
|
||||
return `Transit ${ship.systemId}`;
|
||||
}
|
||||
|
||||
if (ship.celestialId) {
|
||||
return `Orbit ${titleCase(ship.celestialId)}`;
|
||||
}
|
||||
|
||||
const system = systemById.value.get(ship.systemId);
|
||||
return system?.label ?? ship.systemId;
|
||||
}
|
||||
|
||||
function formatStationLocation(station: StationSnapshot) {
|
||||
const system = systemById.value.get(station.systemId);
|
||||
if (station.celestialId) {
|
||||
return `${system?.label ?? station.systemId} · ${titleCase(station.celestialId)}`;
|
||||
}
|
||||
|
||||
return system?.label ?? station.systemId;
|
||||
}
|
||||
|
||||
function shipAiStates(ship: ShipSnapshot) {
|
||||
const travelToken = ship.spatialState.transit ? "TRV" : "";
|
||||
const dockToken = ship.dockedStationId ? "DCK" : "";
|
||||
const behaviorToken = compactLabel(getShipBehaviorLabel(ship.defaultBehavior.kind), "AUTO");
|
||||
const planToken = ship.activePlan?.steps.length ? "PLAN" : "";
|
||||
const orderToken = ship.orderQueue.length > 0 ? "ORD" : "";
|
||||
const commandToken = ship.commanderId ? "CMD" : "";
|
||||
|
||||
return uniqueTokens([behaviorToken, orderToken, planToken, travelToken, dockToken, commandToken]).slice(0, 5);
|
||||
}
|
||||
|
||||
function stationAiStates(station: StationSnapshot) {
|
||||
return uniqueTokens([
|
||||
station.currentProcesses.length > 0 ? "PROC" : "",
|
||||
station.dockedShips > 0 ? "DCK" : "",
|
||||
station.commanderId ? "CMD" : "",
|
||||
]);
|
||||
}
|
||||
|
||||
function fleetAiStates(fleet: PlayerFleetSnapshot) {
|
||||
return uniqueTokens([
|
||||
compactLabel(fleet.status, "STAT"),
|
||||
compactLabel(fleet.role, "ROLE"),
|
||||
fleet.commanderId ? "CMD" : "",
|
||||
]);
|
||||
}
|
||||
|
||||
function systemAiStates(systemId: string) {
|
||||
const stations = gmStore.stations.filter((station) => station.systemId === systemId).length;
|
||||
const ships = gmStore.ships.filter((ship) => ship.systemId === systemId).length;
|
||||
return uniqueTokens([stations > 0 ? `ST${stations}` : "", ships > 0 ? `SH${ships}` : ""]);
|
||||
}
|
||||
|
||||
function buildShipRow(ship: ShipSnapshot): BrowserRow {
|
||||
return {
|
||||
key: `ship-${ship.id}`,
|
||||
kind: "ship",
|
||||
kindLabel: "SH",
|
||||
name: ship.name,
|
||||
ident: `${titleCase(ship.type)} · ${shortId(ship.id)}`,
|
||||
location: formatShipLocation(ship),
|
||||
aiStates: shipAiStates(ship),
|
||||
hpLabel: Math.round(ship.health).toString(),
|
||||
hpValue: ship.health,
|
||||
selection: { id: ship.id, kind: "ship", label: ship.name },
|
||||
focusSelection: { kind: "ship", id: ship.id },
|
||||
focusMode: "tactical",
|
||||
children: [],
|
||||
};
|
||||
}
|
||||
|
||||
function buildStationRow(station: StationSnapshot, children: BrowserRow[]): BrowserRow {
|
||||
return {
|
||||
key: `station-${station.id}`,
|
||||
kind: "station",
|
||||
kindLabel: "ST",
|
||||
name: station.label,
|
||||
ident: `${titleCase(station.category)} · ${titleCase(station.objective)}`,
|
||||
location: formatStationLocation(station),
|
||||
aiStates: stationAiStates(station),
|
||||
hpLabel: "--",
|
||||
hpValue: -1,
|
||||
selection: { id: station.id, kind: "station", label: station.label },
|
||||
focusSelection: { kind: "station", id: station.id },
|
||||
focusMode: "tactical",
|
||||
children,
|
||||
};
|
||||
}
|
||||
|
||||
function buildFleetRow(fleet: PlayerFleetSnapshot, children: BrowserRow[]): BrowserRow {
|
||||
const homeStation = fleet.homeStationId ? stationById.value.get(fleet.homeStationId) : undefined;
|
||||
const homeSystem = fleet.homeSystemId ? systemById.value.get(fleet.homeSystemId) : undefined;
|
||||
return {
|
||||
key: `fleet-${fleet.id}`,
|
||||
kind: "fleet",
|
||||
kindLabel: "FL",
|
||||
name: fleet.label,
|
||||
ident: `${titleCase(fleet.role)} · ${shortId(fleet.id)}`,
|
||||
location: homeStation ? `Home ${homeStation.label}` : (homeSystem?.label ?? "No home"),
|
||||
aiStates: fleetAiStates(fleet),
|
||||
hpLabel: `${children.length}`,
|
||||
hpValue: children.length,
|
||||
children,
|
||||
};
|
||||
}
|
||||
|
||||
function buildSystemRow(systemId: string): BrowserRow | null {
|
||||
const system = systemById.value.get(systemId);
|
||||
if (!system) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
key: `system-${system.id}`,
|
||||
kind: "system",
|
||||
kindLabel: "SY",
|
||||
name: system.label,
|
||||
ident: shortId(system.id),
|
||||
location: "Galaxy",
|
||||
aiStates: systemAiStates(system.id),
|
||||
hpLabel: "--",
|
||||
hpValue: -1,
|
||||
selection: { id: system.id, kind: "system", label: system.label },
|
||||
focusSelection: { kind: "system", id: system.id },
|
||||
focusMode: "tactical",
|
||||
children: [],
|
||||
};
|
||||
}
|
||||
|
||||
function buildVisibleRows() {
|
||||
if (povLevel.value === "galaxy" || !activeSystemId.value) {
|
||||
const systems = [...gmStore.systems]
|
||||
.sort((left, right) => left.label.localeCompare(right.label))
|
||||
.map<BrowserItem>((system) => ({
|
||||
key: `system-${system.id}`,
|
||||
label: system.label,
|
||||
subtitle: `${system.planets.length} planets · ${system.stars.length} stars`,
|
||||
meta: system.id,
|
||||
selection: { id: system.id, kind: "system", label: system.label },
|
||||
focusSelection: { kind: "system", id: system.id },
|
||||
focusMode: "tactical",
|
||||
}));
|
||||
sections.push({
|
||||
key: "systems",
|
||||
label: "Systems",
|
||||
count: systems.length,
|
||||
items: systems,
|
||||
});
|
||||
return sections;
|
||||
return gmStore.systems
|
||||
.map((system) => buildSystemRow(system.id))
|
||||
.filter((row): row is BrowserRow => row != null);
|
||||
}
|
||||
|
||||
const systemId = activeSystemId.value;
|
||||
const ships = gmStore.ships
|
||||
.filter((ship) => ship.systemId === systemId)
|
||||
.sort((left, right) => left.name.localeCompare(right.name))
|
||||
.map<BrowserItem>((ship) => ({
|
||||
key: `ship-${ship.id}`,
|
||||
label: ship.name,
|
||||
subtitle: `${titleCase(ship.type)} · ${titleCase(ship.state)}`,
|
||||
meta: `${getShipBehaviorLabel(ship.defaultBehavior.kind)}${ship.defaultBehavior.itemId ? ` · ${ship.defaultBehavior.itemId}` : ""}`,
|
||||
selection: { id: ship.id, kind: "ship", label: ship.name },
|
||||
focusSelection: { kind: "ship", id: ship.id },
|
||||
focusMode: "follow",
|
||||
}));
|
||||
const stations = gmStore.stations
|
||||
.filter((station) => station.systemId === systemId)
|
||||
.sort((left, right) => left.label.localeCompare(right.label))
|
||||
.map<BrowserItem>((station) => ({
|
||||
key: `station-${station.id}`,
|
||||
label: station.label,
|
||||
subtitle: `${titleCase(station.category)} · Docked ${station.dockedShips}/${station.dockingPads}`,
|
||||
meta: station.factionId,
|
||||
selection: { id: station.id, kind: "station", label: station.label },
|
||||
focusSelection: { kind: "station", id: station.id },
|
||||
focusMode: "tactical",
|
||||
}));
|
||||
const stations = gmStore.stations.filter((station) => station.systemId === systemId);
|
||||
const ships = gmStore.ships.filter((ship) => ship.systemId === systemId);
|
||||
const stationIds = new Set(stations.map((station) => station.id));
|
||||
const stationChildren = new Map<string, BrowserRow[]>();
|
||||
const fleetChildren = new Map<string, BrowserRow[]>();
|
||||
const independentShips: BrowserRow[] = [];
|
||||
|
||||
sections.push({
|
||||
key: "ships",
|
||||
label: "Ships",
|
||||
count: ships.length,
|
||||
items: ships,
|
||||
});
|
||||
sections.push({
|
||||
key: "stations",
|
||||
label: "Stations",
|
||||
count: stations.length,
|
||||
items: stations,
|
||||
});
|
||||
for (const ship of ships) {
|
||||
const row = buildShipRow(ship);
|
||||
|
||||
return sections;
|
||||
if (ship.dockedStationId && stationIds.has(ship.dockedStationId)) {
|
||||
const children = stationChildren.get(ship.dockedStationId) ?? [];
|
||||
children.push(row);
|
||||
stationChildren.set(ship.dockedStationId, children);
|
||||
continue;
|
||||
}
|
||||
|
||||
const fleet = playerFleetByShipId.value.get(ship.id);
|
||||
if (fleet) {
|
||||
const children = fleetChildren.get(fleet.id) ?? [];
|
||||
children.push(row);
|
||||
fleetChildren.set(fleet.id, children);
|
||||
continue;
|
||||
}
|
||||
|
||||
independentShips.push(row);
|
||||
}
|
||||
|
||||
const stationRows = stations.map((station) => buildStationRow(station, stationChildren.get(station.id) ?? []));
|
||||
const fleetRows = (playerFaction.value?.fleets ?? [])
|
||||
.filter((fleet) => (fleetChildren.get(fleet.id)?.length ?? 0) > 0)
|
||||
.map((fleet) => buildFleetRow(fleet, fleetChildren.get(fleet.id) ?? []));
|
||||
|
||||
return [...stationRows, ...fleetRows, ...independentShips];
|
||||
}
|
||||
|
||||
function buildOwnedSections(): BrowserSection[] {
|
||||
function buildOwnedRows() {
|
||||
const player = playerFaction.value;
|
||||
if (!player) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const ships = player.assetRegistry.shipIds
|
||||
const ownedShips = player.assetRegistry.shipIds
|
||||
.map((shipId) => gmStore.ships.find((ship) => ship.id === shipId))
|
||||
.filter((ship): ship is NonNullable<typeof ship> => ship != null)
|
||||
.sort((left, right) => left.name.localeCompare(right.name))
|
||||
.map<BrowserItem>((ship) => ({
|
||||
key: `owned-ship-${ship.id}`,
|
||||
label: ship.name,
|
||||
subtitle: `${ship.systemId} · ${titleCase(ship.state)}`,
|
||||
meta: getShipBehaviorLabel(ship.defaultBehavior.kind),
|
||||
selection: { id: ship.id, kind: "ship", label: ship.name },
|
||||
focusSelection: { kind: "ship", id: ship.id },
|
||||
focusMode: "follow",
|
||||
}));
|
||||
const stations = player.assetRegistry.stationIds
|
||||
.filter((ship): ship is ShipSnapshot => ship != null);
|
||||
const ownedStations = player.assetRegistry.stationIds
|
||||
.map((stationId) => gmStore.stations.find((station) => station.id === stationId))
|
||||
.filter((station): station is NonNullable<typeof station> => station != null)
|
||||
.sort((left, right) => left.label.localeCompare(right.label))
|
||||
.map<BrowserItem>((station) => ({
|
||||
key: `owned-station-${station.id}`,
|
||||
label: station.label,
|
||||
subtitle: `${station.systemId} · ${titleCase(station.category)}`,
|
||||
meta: `${station.installedModules.length} modules`,
|
||||
selection: { id: station.id, kind: "station", label: station.label },
|
||||
focusSelection: { kind: "station", id: station.id },
|
||||
focusMode: "tactical",
|
||||
}));
|
||||
const fleets = player.fleets
|
||||
.slice()
|
||||
.sort((left, right) => left.label.localeCompare(right.label))
|
||||
.map<BrowserItem>((fleet) => ({
|
||||
key: `fleet-${fleet.id}`,
|
||||
label: fleet.label,
|
||||
subtitle: `${titleCase(fleet.role)} · ${titleCase(fleet.status)}`,
|
||||
meta: `${fleet.assetIds.length} assets`,
|
||||
}));
|
||||
.filter((station): station is StationSnapshot => station != null);
|
||||
const ownedFleetShipIds = new Set(player.fleets.flatMap((fleet) => fleet.assetIds));
|
||||
const ownedStationIds = new Set(ownedStations.map((station) => station.id));
|
||||
|
||||
return [
|
||||
{
|
||||
key: "owned-fleets",
|
||||
label: "Fleets",
|
||||
count: fleets.length,
|
||||
items: fleets,
|
||||
},
|
||||
{
|
||||
key: "owned-stations",
|
||||
label: "Stations",
|
||||
count: stations.length,
|
||||
items: stations,
|
||||
},
|
||||
{
|
||||
key: "owned-ships",
|
||||
label: "Ships",
|
||||
count: ships.length,
|
||||
items: ships,
|
||||
},
|
||||
];
|
||||
const stationChildren = new Map<string, BrowserRow[]>();
|
||||
for (const ship of ownedShips) {
|
||||
if (!ship.dockedStationId || !ownedStationIds.has(ship.dockedStationId) || ownedFleetShipIds.has(ship.id)) {
|
||||
continue;
|
||||
}
|
||||
const children = stationChildren.get(ship.dockedStationId) ?? [];
|
||||
children.push(buildShipRow(ship));
|
||||
stationChildren.set(ship.dockedStationId, children);
|
||||
}
|
||||
|
||||
const stationRows = ownedStations.map((station) => buildStationRow(station, stationChildren.get(station.id) ?? []));
|
||||
const fleetRows = player.fleets.map((fleet) => buildFleetRow(
|
||||
fleet,
|
||||
fleet.assetIds
|
||||
.map((shipId) => ownedShips.find((ship) => ship.id === shipId))
|
||||
.filter((ship): ship is ShipSnapshot => ship != null)
|
||||
.map((ship) => buildShipRow(ship)),
|
||||
));
|
||||
const independentShips = ownedShips
|
||||
.filter((ship) => !ownedFleetShipIds.has(ship.id) && (!ship.dockedStationId || !ownedStationIds.has(ship.dockedStationId)))
|
||||
.map((ship) => buildShipRow(ship));
|
||||
|
||||
return [...stationRows, ...fleetRows, ...independentShips];
|
||||
}
|
||||
|
||||
const filteredSections = computed(() => {
|
||||
const search = normalize(searchText.value);
|
||||
const sections = activeTab.value === "visible" ? buildVisibleSections() : buildOwnedSections();
|
||||
return sections
|
||||
.map((section) => ({
|
||||
...section,
|
||||
items: section.items.filter((item) => matchesSearch(item, search)),
|
||||
}))
|
||||
.filter((section) => section.items.length > 0);
|
||||
function getRowSortValue(row: BrowserRow, key: BrowserSortKey) {
|
||||
if (key === "hp") {
|
||||
return row.hpValue;
|
||||
}
|
||||
|
||||
if (key === "location") {
|
||||
return row.location;
|
||||
}
|
||||
|
||||
if (key === "ai") {
|
||||
return row.aiStates.join(" ");
|
||||
}
|
||||
|
||||
return `${row.name} ${row.ident}`;
|
||||
}
|
||||
|
||||
function sortRows(rows: BrowserRow[]): BrowserRow[] {
|
||||
const direction = sortDirection.value === "asc" ? 1 : -1;
|
||||
return [...rows]
|
||||
.sort((left, right) => {
|
||||
const leftValue = getRowSortValue(left, sortKey.value);
|
||||
const rightValue = getRowSortValue(right, sortKey.value);
|
||||
|
||||
if (typeof leftValue === "number" && typeof rightValue === "number") {
|
||||
return (leftValue - rightValue) * direction;
|
||||
}
|
||||
|
||||
return String(leftValue).localeCompare(String(rightValue)) * direction;
|
||||
})
|
||||
.map((row) => ({
|
||||
...row,
|
||||
children: sortRows(row.children),
|
||||
}));
|
||||
}
|
||||
|
||||
function rowMatches(row: BrowserRow, search: string) {
|
||||
if (!search) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const haystack = `${row.name} ${row.ident} ${row.location} ${row.aiStates.join(" ")} ${row.hpLabel}`.toLowerCase();
|
||||
return haystack.includes(search);
|
||||
}
|
||||
|
||||
function isExpanded(row: BrowserRow) {
|
||||
if (row.children.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return expandedRowKeys.value[row.key] ?? true;
|
||||
}
|
||||
|
||||
function flattenRows(rows: BrowserRow[], search: string, depth = 0, forceExpand = false): BrowserDisplayRow[] {
|
||||
const flattened: BrowserDisplayRow[] = [];
|
||||
|
||||
for (const row of rows) {
|
||||
const descendantMatches = flattenRows(row.children, search, depth + 1, forceExpand);
|
||||
const matches = rowMatches(row, search);
|
||||
if (!matches && descendantMatches.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
flattened.push({
|
||||
...row,
|
||||
depth,
|
||||
});
|
||||
|
||||
if (row.children.length > 0 && (forceExpand || isExpanded(row))) {
|
||||
flattened.push(...descendantMatches);
|
||||
}
|
||||
}
|
||||
|
||||
return flattened;
|
||||
}
|
||||
|
||||
const rawRows = computed(() => {
|
||||
if (activeTab.value === "owned") {
|
||||
return buildOwnedRows();
|
||||
}
|
||||
return buildVisibleRows();
|
||||
});
|
||||
|
||||
function selectItem(item: BrowserItem) {
|
||||
if (!item.selection) {
|
||||
const displayRows = computed(() => {
|
||||
const search = normalize(searchText.value);
|
||||
const sortedRows = sortRows(rawRows.value);
|
||||
return flattenRows(sortedRows, search, 0, search.length > 0);
|
||||
});
|
||||
|
||||
function toggleSort(nextKey: BrowserSortKey) {
|
||||
if (sortKey.value === nextKey) {
|
||||
sortDirection.value = sortDirection.value === "asc" ? "desc" : "asc";
|
||||
return;
|
||||
}
|
||||
|
||||
selectionStore.selectSelection(item.selection, "ui");
|
||||
sortKey.value = nextKey;
|
||||
sortDirection.value = "asc";
|
||||
}
|
||||
|
||||
function focusItem(item: BrowserItem) {
|
||||
if (item.selection) {
|
||||
selectionStore.selectSelection(item.selection, "ui");
|
||||
function toggleRow(row: BrowserDisplayRow) {
|
||||
if (row.children.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (item.focusSelection) {
|
||||
emit("focus", item.focusSelection, item.focusMode);
|
||||
|
||||
expandedRowKeys.value[row.key] = !isExpanded(row);
|
||||
}
|
||||
|
||||
function selectItem(row: BrowserDisplayRow) {
|
||||
if (!row.selection) {
|
||||
return;
|
||||
}
|
||||
|
||||
selectionStore.selectSelection(row.selection, "ui");
|
||||
}
|
||||
|
||||
function focusItem(row: BrowserDisplayRow) {
|
||||
if (row.selection) {
|
||||
selectionStore.selectSelection(row.selection, "ui");
|
||||
}
|
||||
if (row.focusSelection) {
|
||||
emit("focus", row.focusSelection, row.focusMode);
|
||||
}
|
||||
}
|
||||
|
||||
function isSelected(item: BrowserItem) {
|
||||
return !!item.selection
|
||||
&& item.selection.id === selectedEntityId.value
|
||||
&& item.selection.kind === selectedEntityKind.value;
|
||||
function isSelected(row: BrowserDisplayRow) {
|
||||
return !!row.selection
|
||||
&& row.selection.id === selectedEntityId.value
|
||||
&& row.selection.kind === selectedEntityKind.value;
|
||||
}
|
||||
|
||||
function sortMarker(key: BrowserSortKey) {
|
||||
if (sortKey.value !== key) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return sortDirection.value === "asc" ? " ▲" : " ▼";
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -276,47 +509,91 @@ function isSelected(item: BrowserItem) {
|
||||
<div v-if="activeTab === 'owned' && !playerFaction" class="entity-browser-panel__empty">
|
||||
No player-owned assets yet.
|
||||
</div>
|
||||
<div v-else-if="filteredSections.length === 0" class="entity-browser-panel__empty">
|
||||
<div v-else-if="displayRows.length === 0" class="entity-browser-panel__empty">
|
||||
Nothing matches the current view.
|
||||
</div>
|
||||
<div v-else class="entity-browser-panel__sections">
|
||||
<section
|
||||
v-for="section in filteredSections"
|
||||
:key="section.key"
|
||||
class="entity-browser-section"
|
||||
>
|
||||
<header class="entity-browser-section__header">
|
||||
<span>{{ section.label }}</span>
|
||||
<span>{{ section.items.length }}</span>
|
||||
</header>
|
||||
<div class="entity-browser-section__items">
|
||||
<div
|
||||
v-for="item in section.items"
|
||||
:key="item.key"
|
||||
class="entity-browser-item"
|
||||
:class="isSelected(item) ? 'entity-browser-item--selected' : ''"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="entity-browser-item__body"
|
||||
:disabled="!item.selection"
|
||||
@click="selectItem(item)"
|
||||
<div class="entity-browser-table-wrap">
|
||||
<table class="entity-browser-table entity-browser-table--tree">
|
||||
<colgroup>
|
||||
<col class="entity-browser-table__col entity-browser-table__col--entity">
|
||||
<col class="entity-browser-table__col entity-browser-table__col--ident">
|
||||
<col class="entity-browser-table__col entity-browser-table__col--location">
|
||||
<col class="entity-browser-table__col entity-browser-table__col--ai">
|
||||
<col class="entity-browser-table__col entity-browser-table__col--hp">
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">
|
||||
<button type="button" class="entity-browser-table__sort" @click="toggleSort('entity')">
|
||||
Entity{{ sortMarker("entity") }}
|
||||
</button>
|
||||
</th>
|
||||
<th scope="col">Ident</th>
|
||||
<th scope="col">
|
||||
<button type="button" class="entity-browser-table__sort" @click="toggleSort('location')">
|
||||
Location{{ sortMarker("location") }}
|
||||
</button>
|
||||
</th>
|
||||
<th scope="col">
|
||||
<button type="button" class="entity-browser-table__sort" @click="toggleSort('ai')">
|
||||
AI{{ sortMarker("ai") }}
|
||||
</button>
|
||||
</th>
|
||||
<th scope="col" class="entity-browser-table__numeric">
|
||||
<button type="button" class="entity-browser-table__sort" @click="toggleSort('hp')">
|
||||
HP{{ sortMarker("hp") }}
|
||||
</button>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="row in displayRows"
|
||||
:key="row.key"
|
||||
class="entity-browser-table__row"
|
||||
:class="isSelected(row) ? 'entity-browser-table__row--selected' : ''"
|
||||
@click="selectItem(row)"
|
||||
@dblclick="focusItem(row)"
|
||||
>
|
||||
<div class="entity-browser-item__label">{{ item.label }}</div>
|
||||
<div class="entity-browser-item__subtitle">{{ item.subtitle }}</div>
|
||||
<div v-if="item.meta" class="entity-browser-item__meta">{{ item.meta }}</div>
|
||||
</button>
|
||||
<button
|
||||
v-if="item.focusSelection"
|
||||
type="button"
|
||||
class="entity-browser-item__focus"
|
||||
@click.stop="focusItem(item)"
|
||||
>
|
||||
Focus
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<td class="entity-browser-table__name">
|
||||
<div class="entity-browser-row" :style="{ paddingLeft: `${row.depth * 0.9}rem` }">
|
||||
<button
|
||||
v-if="row.children.length > 0"
|
||||
type="button"
|
||||
class="entity-browser-row__toggle"
|
||||
@click.stop="toggleRow(row)"
|
||||
>
|
||||
{{ isExpanded(row) ? "-" : "+" }}
|
||||
</button>
|
||||
<span v-else class="entity-browser-row__toggle entity-browser-row__toggle--spacer" />
|
||||
<span class="entity-browser-row__kind" :class="`entity-browser-row__kind--${row.kind}`">
|
||||
{{ row.kindLabel }}
|
||||
</span>
|
||||
<span class="entity-browser-row__label">{{ row.name }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="entity-browser-table__detail entity-browser-table__cell--truncate">{{ row.ident }}</td>
|
||||
<td class="entity-browser-table__cell--truncate">{{ row.location }}</td>
|
||||
<td class="entity-browser-table__cell--ai">
|
||||
<div class="entity-browser-ai">
|
||||
<span
|
||||
v-for="token in row.aiStates"
|
||||
:key="`${row.key}-${token}`"
|
||||
class="entity-browser-ai__token"
|
||||
>
|
||||
{{ token }}
|
||||
</span>
|
||||
<span v-if="row.aiStates.length === 0" class="entity-browser-table__muted">--</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="entity-browser-table__numeric">
|
||||
{{ row.hpLabel }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -76,6 +76,50 @@ function formatAmount(value: number) {
|
||||
return Math.abs(value - rounded) < 0.005 ? String(rounded) : value.toFixed(1);
|
||||
}
|
||||
|
||||
function formatPercent(value: number) {
|
||||
return `${Math.round(value * 100)}%`;
|
||||
}
|
||||
|
||||
function joinDetail(parts: Array<string | null | undefined>) {
|
||||
return parts.filter((part): part is string => !!part && part.trim().length > 0).join(" · ");
|
||||
}
|
||||
|
||||
function describeOrderTarget(order: {
|
||||
itemId?: string | null;
|
||||
targetEntityId?: string | null;
|
||||
targetSystemId?: string | null;
|
||||
nodeId?: string | null;
|
||||
constructionSiteId?: string | null;
|
||||
sourceStationId?: string | null;
|
||||
destinationStationId?: string | null;
|
||||
moduleId?: string | null;
|
||||
}) {
|
||||
return order.itemId
|
||||
?? order.targetEntityId
|
||||
?? order.targetSystemId
|
||||
?? order.nodeId
|
||||
?? order.constructionSiteId
|
||||
?? order.destinationStationId
|
||||
?? order.sourceStationId
|
||||
?? order.moduleId
|
||||
?? "—";
|
||||
}
|
||||
|
||||
function describeSubTaskTarget(subTask: {
|
||||
itemId?: string | null;
|
||||
targetEntityId?: string | null;
|
||||
targetSystemId?: string | null;
|
||||
targetNodeId?: string | null;
|
||||
moduleId?: string | null;
|
||||
}) {
|
||||
return subTask.itemId
|
||||
?? subTask.targetEntityId
|
||||
?? subTask.targetSystemId
|
||||
?? subTask.targetNodeId
|
||||
?? subTask.moduleId
|
||||
?? "—";
|
||||
}
|
||||
|
||||
const selectedShip = computed(() => {
|
||||
if (selectedEntityKind.value !== "ship" || !selectedEntityId.value) {
|
||||
return null;
|
||||
@@ -100,10 +144,10 @@ const playerShipIds = computed(() =>
|
||||
new Set(playerFaction.value?.assetRegistry.shipIds ?? []),
|
||||
);
|
||||
|
||||
const canAccessGm = computed(() => authStore.canAccessGm);
|
||||
const canAccessGmDirectly = computed(() => authStore.canAccessGm && !authStore.isActingAsAlternateIdentity);
|
||||
|
||||
const canDirectControlSelectedShip = computed(() =>
|
||||
!!selectedShip.value && (canAccessGm.value || playerShipIds.value.has(selectedShip.value.id)),
|
||||
!!selectedShip.value && (canAccessGmDirectly.value || playerShipIds.value.has(selectedShip.value.id)),
|
||||
);
|
||||
|
||||
const directOrders = computed(() =>
|
||||
@@ -135,20 +179,208 @@ const formBehaviorNotes = computed(() =>
|
||||
getShipBehaviorNotes(behaviorForm.kind),
|
||||
);
|
||||
|
||||
watch(selectedShip, (ship) => {
|
||||
if (!ship) {
|
||||
return;
|
||||
const shipStatusRows = computed(() => {
|
||||
if (!selectedShip.value) {
|
||||
return [];
|
||||
}
|
||||
|
||||
behaviorForm.kind = ship.defaultBehavior.kind;
|
||||
behaviorForm.areaSystemId = ship.defaultBehavior.areaSystemId ?? ship.systemId ?? "";
|
||||
behaviorForm.itemId = ship.defaultBehavior.itemId ?? "ore";
|
||||
mineOrderForm.systemId = ship.systemId ?? "";
|
||||
mineOrderForm.itemId = "ore";
|
||||
moveOrderSystemId.value = ship.systemId ?? "";
|
||||
actionStatus.value = "";
|
||||
actionError.value = "";
|
||||
}, { immediate: true });
|
||||
return [
|
||||
{ label: "State", value: titleCase(selectedShip.value.state) },
|
||||
{ label: "Behavior", value: getShipBehaviorLabel(selectedShip.value.defaultBehavior.kind) },
|
||||
{ label: "Control", value: titleCase(selectedShip.value.controlSourceKind) },
|
||||
{ label: "Assignment", value: selectedShip.value.assignment?.kind ?? "unassigned" },
|
||||
{
|
||||
label: "Plan",
|
||||
value: selectedShip.value.activePlan
|
||||
? `${selectedShip.value.activePlan.kind} · ${titleCase(selectedShip.value.activePlan.status)}`
|
||||
: "none",
|
||||
},
|
||||
{ label: "Failure", value: selectedShip.value.lastAccessFailureReason ?? "none" },
|
||||
{ label: "Commander", value: selectedShip.value.commanderId ?? "none" },
|
||||
{ label: "Docked", value: selectedShip.value.dockedStationId ?? "no" },
|
||||
];
|
||||
});
|
||||
|
||||
const shipCargoSummaryRows = 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) },
|
||||
];
|
||||
});
|
||||
|
||||
const shipCargoRows = computed(() =>
|
||||
selectedShip.value?.inventory.map((entry) => ({
|
||||
key: entry.itemId,
|
||||
ware: entry.itemId,
|
||||
amount: formatAmount(entry.amount),
|
||||
})) ?? [],
|
||||
);
|
||||
|
||||
const shipBehaviorRows = computed(() => {
|
||||
if (!selectedShip.value) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{ label: "Area", value: selectedShip.value.defaultBehavior.areaSystemId ?? "none" },
|
||||
{ label: "Item", value: selectedShip.value.defaultBehavior.itemId ?? "none" },
|
||||
{ label: "Home Station", value: selectedShip.value.defaultBehavior.homeStationId ?? "none" },
|
||||
{ label: "Target", value: selectedShip.value.defaultBehavior.targetEntityId ?? "none" },
|
||||
{ label: "Range", value: String(selectedShip.value.defaultBehavior.maxSystemRange) },
|
||||
{ label: "Known Only", value: selectedShip.value.defaultBehavior.knownStationsOnly ? "yes" : "no" },
|
||||
];
|
||||
});
|
||||
|
||||
const directOrderRows = computed(() =>
|
||||
directOrders.value.map((order) => ({
|
||||
id: order.id,
|
||||
label: getShipOrderLabel(order.kind),
|
||||
status: titleCase(order.status),
|
||||
target: describeOrderTarget(order),
|
||||
detail: joinDetail([
|
||||
`P${order.priority}`,
|
||||
titleCase(order.sourceKind),
|
||||
order.failureReason ?? undefined,
|
||||
]),
|
||||
})),
|
||||
);
|
||||
|
||||
const behaviorOrderRows = computed(() =>
|
||||
behaviorOrders.value.map((order) => ({
|
||||
id: order.id,
|
||||
label: getShipOrderLabel(order.kind),
|
||||
status: titleCase(order.status),
|
||||
target: describeOrderTarget(order),
|
||||
detail: joinDetail([
|
||||
`P${order.priority}`,
|
||||
getShipOrderSupportStatusLabel(order.kind) ?? undefined,
|
||||
getShipOrderNotes(order.kind) ?? undefined,
|
||||
order.failureReason ?? undefined,
|
||||
]),
|
||||
})),
|
||||
);
|
||||
|
||||
const shipPlanRows = computed(() => {
|
||||
if (!selectedShip.value?.activePlan) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return selectedShip.value.activePlan.steps.flatMap((step) => {
|
||||
const stepRow = {
|
||||
id: step.id,
|
||||
scope: "Step",
|
||||
activity: step.summary || titleCase(step.kind),
|
||||
status: titleCase(step.status),
|
||||
detail: joinDetail([
|
||||
step.blockingReason ?? undefined,
|
||||
`${step.subTasks.length} subtasks`,
|
||||
]),
|
||||
isSubTask: false,
|
||||
};
|
||||
|
||||
const subTaskRows = step.subTasks.map((subTask) => ({
|
||||
id: subTask.id,
|
||||
scope: "Subtask",
|
||||
activity: subTask.summary || titleCase(subTask.kind),
|
||||
status: titleCase(subTask.status),
|
||||
detail: joinDetail([
|
||||
describeSubTaskTarget(subTask),
|
||||
subTask.blockingReason ?? undefined,
|
||||
`${Math.round(subTask.progress * 100)}%`,
|
||||
]),
|
||||
isSubTask: true,
|
||||
}));
|
||||
|
||||
return [stepRow, ...subTaskRows];
|
||||
});
|
||||
});
|
||||
|
||||
const stationStatusRows = computed(() => {
|
||||
if (!selectedStation.value) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{ label: "Category", value: titleCase(selectedStation.value.category) },
|
||||
{ label: "Objective", value: titleCase(selectedStation.value.objective) },
|
||||
{ label: "Docked", value: `${selectedStation.value.dockedShips} / ${selectedStation.value.dockingPads}` },
|
||||
{
|
||||
label: "Population",
|
||||
value: `${formatAmount(selectedStation.value.population)} / ${formatAmount(selectedStation.value.populationCapacity)}`,
|
||||
},
|
||||
{ label: "Workforce", value: formatAmount(selectedStation.value.workforceRequired) },
|
||||
{ label: "Efficiency", value: formatPercent(selectedStation.value.workforceEffectiveRatio) },
|
||||
{ label: "Commander", value: selectedStation.value.commanderId ?? "none" },
|
||||
{ label: "Policy", value: selectedStation.value.policySetId ?? "none" },
|
||||
];
|
||||
});
|
||||
|
||||
const stationModuleRows = computed(() =>
|
||||
selectedStation.value?.installedModules.map((moduleId) => ({
|
||||
key: moduleId,
|
||||
module: moduleNameById.get(moduleId) ?? moduleId,
|
||||
moduleId,
|
||||
})) ?? [],
|
||||
);
|
||||
|
||||
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%",
|
||||
})) ?? [],
|
||||
);
|
||||
|
||||
const stationInventoryRows = computed(() =>
|
||||
selectedStation.value?.inventory.map((entry) => ({
|
||||
key: entry.itemId,
|
||||
ware: entry.itemId,
|
||||
amount: formatAmount(entry.amount),
|
||||
})) ?? [],
|
||||
);
|
||||
|
||||
const stationProcessRows = computed(() =>
|
||||
selectedStation.value?.currentProcesses.map((process) => ({
|
||||
key: `${process.lane}-${process.label}`,
|
||||
lane: process.lane,
|
||||
label: process.label,
|
||||
progress: formatPercent(process.progress),
|
||||
timing: `${Math.ceil(process.timeRemainingSeconds)}s / ${Math.ceil(process.cycleSeconds)}s`,
|
||||
})) ?? [],
|
||||
);
|
||||
|
||||
watch(
|
||||
() => `${selectedEntityKind.value ?? "none"}:${selectedEntityId.value ?? "none"}`,
|
||||
() => {
|
||||
const ship = selectedShip.value;
|
||||
if (!ship) {
|
||||
actionStatus.value = "";
|
||||
actionError.value = "";
|
||||
return;
|
||||
}
|
||||
|
||||
behaviorForm.kind = ship.defaultBehavior.kind;
|
||||
behaviorForm.areaSystemId = ship.defaultBehavior.areaSystemId ?? ship.systemId ?? "";
|
||||
behaviorForm.itemId = ship.defaultBehavior.itemId ?? "ore";
|
||||
mineOrderForm.systemId = ship.systemId ?? "";
|
||||
mineOrderForm.itemId = "ore";
|
||||
moveOrderSystemId.value = ship.systemId ?? "";
|
||||
actionStatus.value = "";
|
||||
actionError.value = "";
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
function focusShip(cameraMode?: "follow" | "tactical") {
|
||||
if (!selectedShip.value) {
|
||||
@@ -357,36 +589,52 @@ async function clearOrders() {
|
||||
</div>
|
||||
<div class="entity-inspector-panel__actions">
|
||||
<button type="button" class="entity-inspector-panel__action" @click="focusShip('tactical')">Focus</button>
|
||||
<button type="button" class="entity-inspector-panel__action" @click="focusShip('follow')">Follow</button>
|
||||
<button type="button" class="entity-inspector-panel__action" @click="focusShip('follow')">Track</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="entity-inspector-section">
|
||||
<h4>Status</h4>
|
||||
<div class="entity-inspector-grid">
|
||||
<div><span>State</span><strong>{{ titleCase(selectedShip.state) }}</strong></div>
|
||||
<div><span>Behavior</span><strong>{{ getShipBehaviorLabel(selectedShip.defaultBehavior.kind) }}</strong></div>
|
||||
<div><span>Control</span><strong>{{ selectedShip.controlSourceKind }}</strong></div>
|
||||
<div><span>Assignment</span><strong>{{ selectedShip.assignment?.kind ?? "unassigned" }}</strong></div>
|
||||
<div><span>Plan</span><strong>{{ selectedShip.activePlan ? `${selectedShip.activePlan.kind} · ${selectedShip.activePlan.status}` : "none" }}</strong></div>
|
||||
<div><span>Failure</span><strong>{{ selectedShip.lastAccessFailureReason ?? "none" }}</strong></div>
|
||||
<div class="entity-inspector-table-wrap">
|
||||
<table class="entity-inspector-table entity-inspector-table--kv">
|
||||
<tbody>
|
||||
<tr v-for="row in shipStatusRows" :key="row.label">
|
||||
<th scope="row">{{ row.label }}</th>
|
||||
<td>{{ row.value }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="entity-inspector-section">
|
||||
<h4>Cargo</h4>
|
||||
<div class="entity-inspector-grid">
|
||||
<div><span>Used</span><strong>{{ formatAmount(selectedShip.inventory.reduce((sum, entry) => sum + entry.amount, 0)) }}</strong></div>
|
||||
<div><span>Capacity</span><strong>{{ formatAmount(selectedShip.cargoCapacity) }}</strong></div>
|
||||
<div><span>Travel</span><strong>{{ formatAmount(selectedShip.travelSpeed) }} {{ selectedShip.travelSpeedUnit }}</strong></div>
|
||||
<div><span>Hull</span><strong>{{ formatAmount(selectedShip.health) }}</strong></div>
|
||||
<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>
|
||||
<div v-if="shipCargoRows.length > 0" class="entity-inspector-table-wrap">
|
||||
<table class="entity-inspector-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Ware</th>
|
||||
<th scope="col" class="entity-inspector-table__numeric">Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in shipCargoRows" :key="row.key">
|
||||
<td>{{ row.ware }}</td>
|
||||
<td class="entity-inspector-table__numeric">{{ row.amount }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<ul v-if="selectedShip.inventory.length > 0" class="entity-inspector-list">
|
||||
<li v-for="entry in selectedShip.inventory" :key="entry.itemId">
|
||||
<span>{{ entry.itemId }}</span>
|
||||
<strong>{{ formatAmount(entry.amount) }}</strong>
|
||||
</li>
|
||||
</ul>
|
||||
<div v-else class="entity-inspector-empty">No cargo.</div>
|
||||
</div>
|
||||
|
||||
@@ -395,13 +643,15 @@ async function clearOrders() {
|
||||
<div v-if="selectedBehaviorStatus || selectedBehaviorNotes" class="entity-inspector-note">
|
||||
{{ [selectedBehaviorStatus, selectedBehaviorNotes].filter(Boolean).join(" · ") }}
|
||||
</div>
|
||||
<div class="entity-inspector-grid">
|
||||
<div><span>Area</span><strong>{{ selectedShip.defaultBehavior.areaSystemId ?? "none" }}</strong></div>
|
||||
<div><span>Item</span><strong>{{ selectedShip.defaultBehavior.itemId ?? "none" }}</strong></div>
|
||||
<div><span>Home Station</span><strong>{{ selectedShip.defaultBehavior.homeStationId ?? "none" }}</strong></div>
|
||||
<div><span>Target</span><strong>{{ selectedShip.defaultBehavior.targetEntityId ?? "none" }}</strong></div>
|
||||
<div><span>Range</span><strong>{{ selectedShip.defaultBehavior.maxSystemRange }}</strong></div>
|
||||
<div><span>Known Only</span><strong>{{ selectedShip.defaultBehavior.knownStationsOnly ? "yes" : "no" }}</strong></div>
|
||||
<div class="entity-inspector-table-wrap">
|
||||
<table class="entity-inspector-table entity-inspector-table--kv">
|
||||
<tbody>
|
||||
<tr v-for="row in shipBehaviorRows" :key="row.label">
|
||||
<th scope="row">{{ row.label }}</th>
|
||||
<td>{{ row.value }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div v-if="canDirectControlSelectedShip" class="entity-inspector-form">
|
||||
<label class="entity-inspector-field">
|
||||
@@ -473,52 +723,86 @@ async function clearOrders() {
|
||||
</div>
|
||||
<div v-if="actionStatus" class="entity-inspector-message entity-inspector-message--ok">{{ actionStatus }}</div>
|
||||
<div v-if="actionError" class="entity-inspector-message entity-inspector-message--error">{{ actionError }}</div>
|
||||
<ul v-if="directOrders.length > 0" class="entity-inspector-list">
|
||||
<li v-for="order in directOrders" :key="order.id">
|
||||
<span>{{ getShipOrderLabel(order.kind) }} · {{ order.status }}</span>
|
||||
<div class="entity-inspector-order-actions">
|
||||
<strong>{{ order.itemId ?? order.targetEntityId ?? order.targetSystemId ?? "—" }}</strong>
|
||||
<button
|
||||
v-if="canDirectControlSelectedShip"
|
||||
type="button"
|
||||
class="entity-inspector-order-remove"
|
||||
:disabled="actionBusy"
|
||||
@click="removeOrder(order.id)"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<div v-if="directOrderRows.length > 0" class="entity-inspector-table-wrap">
|
||||
<table class="entity-inspector-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Order</th>
|
||||
<th scope="col">Status</th>
|
||||
<th scope="col">Target</th>
|
||||
<th scope="col">Detail</th>
|
||||
<th v-if="canDirectControlSelectedShip" scope="col" class="entity-inspector-table__action-col">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="order in directOrderRows" :key="order.id">
|
||||
<td>{{ order.label }}</td>
|
||||
<td>{{ order.status }}</td>
|
||||
<td>{{ order.target }}</td>
|
||||
<td class="entity-inspector-table__detail">{{ order.detail }}</td>
|
||||
<td v-if="canDirectControlSelectedShip" class="entity-inspector-table__action-col">
|
||||
<button
|
||||
type="button"
|
||||
class="entity-inspector-order-remove"
|
||||
:disabled="actionBusy"
|
||||
@click="removeOrder(order.id)"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div v-else class="entity-inspector-empty">No direct orders queued.</div>
|
||||
<div class="entity-inspector-divider">
|
||||
<span>Behavior: {{ getShipBehaviorLabel(selectedShip.defaultBehavior.kind) }}</span>
|
||||
</div>
|
||||
<ul v-if="behaviorOrders.length > 0" class="entity-inspector-list">
|
||||
<li v-for="order in behaviorOrders" :key="order.id">
|
||||
<span>{{ getShipOrderLabel(order.kind) }} · {{ order.status }}</span>
|
||||
<strong>{{ [order.itemId ?? order.targetEntityId ?? order.targetSystemId ?? "—", getShipOrderSupportStatusLabel(order.kind), getShipOrderNotes(order.kind)].filter(Boolean).join(" · ") }}</strong>
|
||||
</li>
|
||||
</ul>
|
||||
<div v-if="behaviorOrderRows.length > 0" class="entity-inspector-table-wrap">
|
||||
<table class="entity-inspector-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Order</th>
|
||||
<th scope="col">Status</th>
|
||||
<th scope="col">Target</th>
|
||||
<th scope="col">Detail</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="order in behaviorOrderRows" :key="order.id">
|
||||
<td>{{ order.label }}</td>
|
||||
<td>{{ order.status }}</td>
|
||||
<td>{{ order.target }}</td>
|
||||
<td class="entity-inspector-table__detail">{{ order.detail }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div v-else class="entity-inspector-empty">No behavior orders queued.</div>
|
||||
</div>
|
||||
|
||||
<div class="entity-inspector-section">
|
||||
<h4>Plan Steps</h4>
|
||||
<ul v-if="selectedShip.activePlan" class="entity-inspector-plan">
|
||||
<li v-for="step in selectedShip.activePlan.steps" :key="step.id">
|
||||
<div class="entity-inspector-plan__step">
|
||||
<span>{{ step.kind }} · {{ step.status }}</span>
|
||||
<strong>{{ step.blockingReason ?? "ok" }}</strong>
|
||||
</div>
|
||||
<ul class="entity-inspector-subtasks">
|
||||
<li v-for="subTask in step.subTasks" :key="subTask.id">
|
||||
<span>{{ subTask.kind }} · {{ subTask.status }}</span>
|
||||
<strong>{{ subTask.blockingReason ?? `${Math.round(subTask.progress * 100)}%` }}</strong>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
<div v-if="shipPlanRows.length > 0" class="entity-inspector-table-wrap">
|
||||
<table class="entity-inspector-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Scope</th>
|
||||
<th scope="col">Activity</th>
|
||||
<th scope="col">Status</th>
|
||||
<th scope="col">Detail</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in shipPlanRows" :key="row.id" :class="row.isSubTask ? 'entity-inspector-table__row--subtask' : ''">
|
||||
<td>{{ row.scope }}</td>
|
||||
<td :class="row.isSubTask ? 'entity-inspector-table__subtask' : ''">{{ row.activity }}</td>
|
||||
<td>{{ row.status }}</td>
|
||||
<td class="entity-inspector-table__detail">{{ row.detail }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div v-else class="entity-inspector-empty">No active plan.</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -537,46 +821,102 @@ async function clearOrders() {
|
||||
|
||||
<div class="entity-inspector-section">
|
||||
<h4>Status</h4>
|
||||
<div class="entity-inspector-grid">
|
||||
<div><span>Category</span><strong>{{ titleCase(selectedStation.category) }}</strong></div>
|
||||
<div><span>Objective</span><strong>{{ titleCase(selectedStation.objective) }}</strong></div>
|
||||
<div><span>Docked</span><strong>{{ selectedStation.dockedShips }} / {{ selectedStation.dockingPads }}</strong></div>
|
||||
<div><span>Population</span><strong>{{ formatAmount(selectedStation.population) }} / {{ formatAmount(selectedStation.populationCapacity) }}</strong></div>
|
||||
<div><span>Workforce</span><strong>{{ formatAmount(selectedStation.workforceRequired) }}</strong></div>
|
||||
<div><span>Efficiency</span><strong>{{ Math.round(selectedStation.workforceEffectiveRatio * 100) }}%</strong></div>
|
||||
<div class="entity-inspector-table-wrap">
|
||||
<table class="entity-inspector-table entity-inspector-table--kv">
|
||||
<tbody>
|
||||
<tr v-for="row in stationStatusRows" :key="row.label">
|
||||
<th scope="row">{{ row.label }}</th>
|
||||
<td>{{ row.value }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="entity-inspector-section">
|
||||
<h4>Modules</h4>
|
||||
<ul v-if="selectedStation.installedModules.length > 0" class="entity-inspector-list">
|
||||
<li v-for="moduleId in selectedStation.installedModules" :key="moduleId">
|
||||
<span>{{ moduleNameById.get(moduleId) ?? moduleId }}</span>
|
||||
<strong>{{ moduleId }}</strong>
|
||||
</li>
|
||||
</ul>
|
||||
<div v-if="stationModuleRows.length > 0" class="entity-inspector-table-wrap">
|
||||
<table class="entity-inspector-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Module</th>
|
||||
<th scope="col">Id</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in stationModuleRows" :key="row.key">
|
||||
<td>{{ row.module }}</td>
|
||||
<td>{{ row.moduleId }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div v-else class="entity-inspector-empty">No modules installed.</div>
|
||||
</div>
|
||||
|
||||
<div class="entity-inspector-section">
|
||||
<h4>Storage</h4>
|
||||
<ul v-if="selectedStation.inventory.length > 0" class="entity-inspector-list">
|
||||
<li v-for="entry in selectedStation.inventory" :key="entry.itemId">
|
||||
<span>{{ entry.itemId }}</span>
|
||||
<strong>{{ formatAmount(entry.amount) }}</strong>
|
||||
</li>
|
||||
</ul>
|
||||
<div v-else class="entity-inspector-empty">No inventory.</div>
|
||||
<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>
|
||||
<div v-if="stationInventoryRows.length > 0" class="entity-inspector-table-wrap">
|
||||
<table class="entity-inspector-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Ware</th>
|
||||
<th scope="col" class="entity-inspector-table__numeric">Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in stationInventoryRows" :key="row.key">
|
||||
<td>{{ row.ware }}</td>
|
||||
<td class="entity-inspector-table__numeric">{{ row.amount }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div v-else-if="stationStorageRows.length === 0" class="entity-inspector-empty">No inventory.</div>
|
||||
</div>
|
||||
|
||||
<div class="entity-inspector-section">
|
||||
<h4>Production</h4>
|
||||
<ul v-if="selectedStation.currentProcesses.length > 0" class="entity-inspector-list">
|
||||
<li v-for="process in selectedStation.currentProcesses" :key="`${process.lane}-${process.label}`">
|
||||
<span>{{ process.label }}</span>
|
||||
<strong>{{ Math.round(process.progress * 100) }}% · {{ Math.ceil(process.timeRemainingSeconds) }}s</strong>
|
||||
</li>
|
||||
</ul>
|
||||
<div v-if="stationProcessRows.length > 0" class="entity-inspector-table-wrap">
|
||||
<table class="entity-inspector-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Lane</th>
|
||||
<th scope="col">Process</th>
|
||||
<th scope="col" class="entity-inspector-table__numeric">Progress</th>
|
||||
<th scope="col" class="entity-inspector-table__numeric">Timing</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in stationProcessRows" :key="row.key">
|
||||
<td>{{ row.lane }}</td>
|
||||
<td>{{ row.label }}</td>
|
||||
<td class="entity-inspector-table__numeric">{{ row.progress }}</td>
|
||||
<td class="entity-inspector-table__numeric">{{ row.timing }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div v-else class="entity-inspector-empty">No active processes.</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -50,7 +50,7 @@ const canControlSelectedShip = computed(() => {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (authStore.canAccessGm) {
|
||||
if (authStore.canAccessGm && !authStore.isActingAsAlternateIdentity) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,12 @@ export interface AuthSessionResponse {
|
||||
refreshTokenExpiresAtUtc: string;
|
||||
}
|
||||
|
||||
export interface RegisterResponse {
|
||||
userId: string;
|
||||
email: string;
|
||||
requiresLogin: boolean;
|
||||
}
|
||||
|
||||
export interface ForgotPasswordResponse {
|
||||
accepted: boolean;
|
||||
resetToken?: string | null;
|
||||
|
||||
9
apps/viewer/src/contractsIdentity.ts
Normal file
9
apps/viewer/src/contractsIdentity.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export interface PlayerIdentitySummary {
|
||||
userId: string;
|
||||
email: string;
|
||||
roles: string[];
|
||||
hasPlayerFaction: boolean;
|
||||
playerFactionId?: string | null;
|
||||
playerFactionLabel?: string | null;
|
||||
sovereignFactionId?: string | null;
|
||||
}
|
||||
@@ -266,7 +266,10 @@ export interface PlayerAlertSnapshot {
|
||||
export interface PlayerFactionSnapshot {
|
||||
id: string;
|
||||
label: string;
|
||||
personaName?: string | null;
|
||||
raceId?: string | null;
|
||||
sovereignFactionId: string;
|
||||
requiresOnboarding: boolean;
|
||||
status: string;
|
||||
createdAtUtc: string;
|
||||
updatedAtUtc: string;
|
||||
|
||||
6
apps/viewer/src/contractsRaces.ts
Normal file
6
apps/viewer/src/contractsRaces.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export interface RaceSnapshot {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
}
|
||||
47
apps/viewer/src/effectiveIdentitySession.ts
Normal file
47
apps/viewer/src/effectiveIdentitySession.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
const STORAGE_KEY = "space-game.auth.effective-player-id";
|
||||
|
||||
let currentEffectivePlayerId = loadEffectivePlayerId();
|
||||
const listeners = new Set<(playerId: string | null) => void>();
|
||||
|
||||
export function getEffectivePlayerIdentityId() {
|
||||
return currentEffectivePlayerId;
|
||||
}
|
||||
|
||||
export function setEffectivePlayerIdentityId(playerId: string | null) {
|
||||
currentEffectivePlayerId = playerId && playerId.trim().length > 0 ? playerId.trim() : null;
|
||||
persistEffectivePlayerId(currentEffectivePlayerId);
|
||||
for (const listener of listeners) {
|
||||
listener(currentEffectivePlayerId);
|
||||
}
|
||||
}
|
||||
|
||||
export function clearEffectivePlayerIdentityId() {
|
||||
setEffectivePlayerIdentityId(null);
|
||||
}
|
||||
|
||||
export function subscribeToEffectivePlayerIdentity(listener: (playerId: string | null) => void) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
}
|
||||
|
||||
function loadEffectivePlayerId() {
|
||||
if (typeof window === "undefined") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||
return raw && raw.trim().length > 0 ? raw.trim() : null;
|
||||
}
|
||||
|
||||
function persistEffectivePlayerId(playerId: string | null) {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!playerId) {
|
||||
window.localStorage.removeItem(STORAGE_KEY);
|
||||
return;
|
||||
}
|
||||
|
||||
window.localStorage.setItem(STORAGE_KEY, playerId);
|
||||
}
|
||||
@@ -13,14 +13,22 @@ export class ViewerRenderSurface {
|
||||
private readonly onFrame: () => void;
|
||||
private readonly onResizeCallback: (width: number, height: number) => void;
|
||||
private readonly resizeListener = () => this.resize();
|
||||
private readonly resizeObserver?: ResizeObserver;
|
||||
|
||||
constructor(options: ViewerRenderSurfaceOptions) {
|
||||
this.container = options.container;
|
||||
this.renderer = options.renderer;
|
||||
this.onFrame = options.onFrame;
|
||||
this.onResizeCallback = options.onResize;
|
||||
this.renderer.domElement.style.width = "100%";
|
||||
this.renderer.domElement.style.height = "100%";
|
||||
this.renderer.domElement.style.display = "block";
|
||||
this.container.append(this.renderer.domElement);
|
||||
window.addEventListener("resize", this.resizeListener);
|
||||
if (typeof ResizeObserver !== "undefined") {
|
||||
this.resizeObserver = new ResizeObserver(() => this.resize());
|
||||
this.resizeObserver.observe(this.container);
|
||||
}
|
||||
this.resize();
|
||||
}
|
||||
|
||||
@@ -46,6 +54,7 @@ export class ViewerRenderSurface {
|
||||
dispose() {
|
||||
this.stop();
|
||||
window.removeEventListener("resize", this.resizeListener);
|
||||
this.resizeObserver?.disconnect();
|
||||
this.renderer.dispose();
|
||||
this.renderer.domElement.remove();
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ body,
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 100dvh;
|
||||
overflow: hidden;
|
||||
background:
|
||||
radial-gradient(circle at top, rgba(89, 132, 247, 0.16), transparent 30%),
|
||||
@@ -269,12 +270,162 @@ select {
|
||||
|
||||
canvas {
|
||||
display: block;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.viewer-app,
|
||||
.viewer-canvas-host {
|
||||
width: 100%;
|
||||
height: 100dvh;
|
||||
min-height: 100dvh;
|
||||
}
|
||||
|
||||
.viewer-canvas-host {
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.viewer-left-sidebar-dock {
|
||||
position: absolute;
|
||||
inset: 0 auto 0 0;
|
||||
width: min(360px, 100vw);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.viewer-left-sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
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 left, rgba(127, 214, 255, 0.08), transparent 34%);
|
||||
border-right: 1px solid rgba(132, 196, 255, 0.14);
|
||||
backdrop-filter: blur(18px);
|
||||
box-shadow: 18px 0 42px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
.viewer-left-sidebar__tabs {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.5rem;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.viewer-left-sidebar__tab {
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: var(--viewer-text);
|
||||
padding: 0.75rem 0.95rem;
|
||||
font-size: 0.76rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
transition: background 120ms ease, border-color 120ms ease;
|
||||
}
|
||||
|
||||
.viewer-left-sidebar__tab:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-color: rgba(255, 255, 255, 0.22);
|
||||
}
|
||||
|
||||
.viewer-left-sidebar__tab--active {
|
||||
background: rgba(116, 196, 255, 0.14);
|
||||
border-color: rgba(116, 196, 255, 0.32);
|
||||
}
|
||||
|
||||
.viewer-left-sidebar__body {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
margin-top: 0.9rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.viewer-left-sidebar__panel {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.viewer-left-sidebar__panel--player {
|
||||
overflow: auto;
|
||||
padding-right: 0.2rem;
|
||||
}
|
||||
|
||||
.viewer-left-sidebar__panel--entities {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.viewer-left-sidebar__panel--entities.entity-browser-panel {
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
backdrop-filter: none;
|
||||
}
|
||||
|
||||
.viewer-stats-overlay {
|
||||
font-family: "IBM Plex Mono", "SFMono-Regular", monospace;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.35;
|
||||
color: rgba(234, 244, 255, 0.92);
|
||||
text-shadow:
|
||||
0 1px 0 rgba(0, 0, 0, 0.85),
|
||||
0 0 12px rgba(0, 0, 0, 0.42);
|
||||
white-space: pre-wrap;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.viewer-stats-overlay-dock {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
left: calc(min(360px, calc(100vw - 40px)) + 56px);
|
||||
max-width: min(420px, calc(100vw - 496px));
|
||||
}
|
||||
|
||||
.viewer-system-label-dock {
|
||||
position: absolute;
|
||||
top: 22px;
|
||||
right: calc(min(380px, calc(100vw - 40px)) + 48px);
|
||||
max-width: min(340px, calc(100vw - 500px));
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.viewer-system-label {
|
||||
color: rgba(238, 246, 255, 0.96);
|
||||
text-shadow:
|
||||
0 1px 0 rgba(0, 0, 0, 0.88),
|
||||
0 0 18px rgba(0, 0, 0, 0.42);
|
||||
}
|
||||
|
||||
.viewer-system-label__title {
|
||||
font-family: "Space Grotesk", "Segoe UI", sans-serif;
|
||||
font-size: clamp(1.5rem, 1.1rem + 1vw, 2.1rem);
|
||||
font-weight: 600;
|
||||
line-height: 0.95;
|
||||
letter-spacing: -0.04em;
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
.viewer-system-label__subtitle {
|
||||
margin-top: 6px;
|
||||
color: rgba(203, 219, 235, 0.78);
|
||||
font-family: "IBM Plex Mono", "SFMono-Regular", monospace;
|
||||
font-size: 0.74rem;
|
||||
line-height: 1.35;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.viewer-stats-overlay--compact {
|
||||
font-size: 0.88rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.viewer-stats-overlay__line--spacer {
|
||||
line-height: 0.65;
|
||||
}
|
||||
|
||||
.panel-summary,
|
||||
@@ -1257,54 +1408,253 @@ canvas {
|
||||
color: rgba(173, 220, 255, 0.64);
|
||||
}
|
||||
|
||||
.entity-browser-section__items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.entity-browser-item {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.entity-browser-item__body {
|
||||
flex: 1 1 auto;
|
||||
text-align: left;
|
||||
padding: 0.75rem 0.85rem;
|
||||
.entity-browser-table-wrap,
|
||||
.entity-inspector-table-wrap {
|
||||
overflow: auto;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 1rem;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.entity-browser-item__body:disabled {
|
||||
opacity: 0.82;
|
||||
cursor: default;
|
||||
.entity-browser-table,
|
||||
.entity-inspector-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.entity-browser-item--selected .entity-browser-item__body {
|
||||
border-color: rgba(116, 196, 255, 0.38);
|
||||
.entity-browser-table {
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
.entity-browser-table__col--entity {
|
||||
width: 38%;
|
||||
}
|
||||
|
||||
.entity-browser-table__col--ident {
|
||||
width: 18%;
|
||||
}
|
||||
|
||||
.entity-browser-table__col--location {
|
||||
width: 22%;
|
||||
}
|
||||
|
||||
.entity-browser-table__col--ai {
|
||||
width: 14%;
|
||||
}
|
||||
|
||||
.entity-browser-table__col--hp {
|
||||
width: 8%;
|
||||
}
|
||||
|
||||
.entity-browser-table th,
|
||||
.entity-browser-table td,
|
||||
.entity-inspector-table th,
|
||||
.entity-inspector-table td {
|
||||
padding: 0.68rem 0.8rem;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
font-size: 0.78rem;
|
||||
text-align: left;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.entity-browser-table th,
|
||||
.entity-browser-table td {
|
||||
padding: 0.42rem 0.5rem;
|
||||
}
|
||||
|
||||
.entity-browser-table thead th,
|
||||
.entity-inspector-table thead th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
background: rgba(7, 12, 18, 0.96);
|
||||
font-size: 0.68rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.14em;
|
||||
color: rgba(173, 220, 255, 0.72);
|
||||
}
|
||||
|
||||
.entity-browser-table tbody tr:last-child td,
|
||||
.entity-inspector-table tbody tr:last-child td,
|
||||
.entity-inspector-table tbody tr:last-child th {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.entity-browser-table__sort {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
letter-spacing: inherit;
|
||||
text-transform: inherit;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.entity-browser-table__row {
|
||||
cursor: pointer;
|
||||
transition: background 120ms ease;
|
||||
}
|
||||
|
||||
.entity-browser-table__row:hover {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.entity-browser-table__row--selected {
|
||||
background: rgba(116, 196, 255, 0.12);
|
||||
}
|
||||
|
||||
.entity-browser-item__label {
|
||||
font-size: 0.88rem;
|
||||
.entity-browser-table__name {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.entity-browser-item__subtitle,
|
||||
.entity-browser-item__meta {
|
||||
margin-top: 0.18rem;
|
||||
font-size: 0.75rem;
|
||||
.entity-browser-table__cell--truncate {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.entity-browser-table__cell--ai {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.entity-browser-table__detail,
|
||||
.entity-inspector-table__detail {
|
||||
color: var(--viewer-muted);
|
||||
}
|
||||
|
||||
.entity-browser-item__focus,
|
||||
.entity-browser-table__action-col,
|
||||
.entity-inspector-table__action-col,
|
||||
.entity-inspector-table__numeric {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.entity-browser-table__numeric {
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.entity-browser-table__action,
|
||||
.entity-inspector-panel__action {
|
||||
padding: 0.65rem 0.9rem;
|
||||
font-size: 0.78rem;
|
||||
padding: 0.5rem 0.72rem;
|
||||
font-size: 0.72rem;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.entity-browser-table__action {
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: var(--viewer-text);
|
||||
border-radius: 999px;
|
||||
transition: background 120ms ease, border-color 120ms ease;
|
||||
}
|
||||
|
||||
.entity-browser-table__action:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-color: rgba(255, 255, 255, 0.22);
|
||||
}
|
||||
|
||||
.entity-browser-table__muted {
|
||||
color: var(--viewer-muted);
|
||||
}
|
||||
|
||||
.entity-browser-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.38rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.entity-browser-row__toggle {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 0.35rem;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: var(--viewer-text);
|
||||
font-family: "IBM Plex Mono", "SFMono-Regular", monospace;
|
||||
font-size: 0.66rem;
|
||||
line-height: 1;
|
||||
padding: 0;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.entity-browser-row__toggle--spacer {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.entity-browser-row__kind {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 1.75rem;
|
||||
padding: 0.14rem 0.3rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: rgba(234, 244, 255, 0.88);
|
||||
font-family: "IBM Plex Mono", "SFMono-Regular", monospace;
|
||||
font-size: 0.58rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.entity-browser-row__kind--system {
|
||||
border-color: rgba(127, 214, 255, 0.24);
|
||||
color: rgba(127, 214, 255, 0.96);
|
||||
}
|
||||
|
||||
.entity-browser-row__kind--station {
|
||||
border-color: rgba(255, 191, 105, 0.22);
|
||||
color: rgba(255, 222, 168, 0.92);
|
||||
}
|
||||
|
||||
.entity-browser-row__kind--fleet {
|
||||
border-color: rgba(146, 255, 200, 0.22);
|
||||
color: rgba(190, 255, 223, 0.92);
|
||||
}
|
||||
|
||||
.entity-browser-row__kind--ship {
|
||||
border-color: rgba(255, 255, 255, 0.14);
|
||||
}
|
||||
|
||||
.entity-browser-row__label {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.entity-browser-ai {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.2rem;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.entity-browser-ai__token {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 2rem;
|
||||
max-width: 100%;
|
||||
padding: 0.13rem 0.28rem;
|
||||
border-radius: 999px;
|
||||
background: rgba(127, 214, 255, 0.08);
|
||||
border: 1px solid rgba(127, 214, 255, 0.14);
|
||||
color: rgba(206, 233, 255, 0.84);
|
||||
font-family: "IBM Plex Mono", "SFMono-Regular", monospace;
|
||||
font-size: 0.56rem;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.entity-inspector-panel__actions {
|
||||
display: flex;
|
||||
gap: 0.45rem;
|
||||
@@ -1328,71 +1678,31 @@ canvas {
|
||||
color: rgba(173, 220, 255, 0.7);
|
||||
}
|
||||
|
||||
.entity-inspector-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.7rem 0.9rem;
|
||||
}
|
||||
|
||||
.entity-inspector-grid span {
|
||||
display: block;
|
||||
font-size: 0.72rem;
|
||||
color: var(--viewer-muted);
|
||||
.entity-inspector-table--kv th {
|
||||
width: 38%;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
font-size: 0.68rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
letter-spacing: 0.12em;
|
||||
color: rgba(173, 220, 255, 0.68);
|
||||
}
|
||||
|
||||
.entity-inspector-grid strong {
|
||||
display: block;
|
||||
margin-top: 0.15rem;
|
||||
font-size: 0.86rem;
|
||||
.entity-inspector-table--kv td {
|
||||
font-size: 0.84rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.entity-inspector-list,
|
||||
.entity-inspector-plan,
|
||||
.entity-inspector-subtasks {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.45rem;
|
||||
.entity-inspector-table__row--subtask {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
.entity-inspector-list li,
|
||||
.entity-inspector-plan__step,
|
||||
.entity-inspector-subtasks li {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
align-items: baseline;
|
||||
padding: 0.55rem 0.7rem;
|
||||
border-radius: 0.9rem;
|
||||
background: rgba(255, 255, 255, 0.035);
|
||||
.entity-inspector-table__subtask {
|
||||
padding-left: 1.45rem;
|
||||
}
|
||||
|
||||
.entity-inspector-list span,
|
||||
.entity-inspector-plan__step span,
|
||||
.entity-inspector-subtasks span {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.entity-inspector-list strong,
|
||||
.entity-inspector-plan__step strong,
|
||||
.entity-inspector-subtasks strong {
|
||||
font-size: 0.75rem;
|
||||
color: var(--viewer-muted);
|
||||
}
|
||||
|
||||
.entity-inspector-plan > li {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.entity-inspector-subtasks {
|
||||
padding-left: 0.8rem;
|
||||
.entity-inspector-table__subtask::before {
|
||||
content: "↳ ";
|
||||
color: rgba(173, 220, 255, 0.58);
|
||||
}
|
||||
|
||||
.entity-inspector-panel__fallback {
|
||||
@@ -1592,8 +1902,30 @@ canvas {
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.entity-inspector-grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
.viewer-left-sidebar-dock {
|
||||
width: min(360px, 100vw);
|
||||
}
|
||||
|
||||
.viewer-left-sidebar {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.viewer-stats-overlay-dock {
|
||||
top: 96px;
|
||||
left: 20px;
|
||||
right: 20px;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.viewer-system-label-dock {
|
||||
top: 20px;
|
||||
left: 20px;
|
||||
right: 20px;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.viewer-system-label__title {
|
||||
font-size: clamp(1.35rem, 1rem + 1vw, 1.8rem);
|
||||
}
|
||||
|
||||
.entity-inspector-inline-form,
|
||||
@@ -1602,4 +1934,9 @@ canvas {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.entity-browser-table,
|
||||
.entity-inspector-table {
|
||||
min-width: 640px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
import { defineStore } from "pinia";
|
||||
import type { AuthSessionResponse } from "../../contractsAuth";
|
||||
import type { PlayerIdentitySummary } from "../../contractsIdentity";
|
||||
import { clearAuthSession, getAuthSession, setAuthSession, subscribeToAuthSession } from "../../authSession";
|
||||
import {
|
||||
clearEffectivePlayerIdentityId,
|
||||
getEffectivePlayerIdentityId,
|
||||
setEffectivePlayerIdentityId,
|
||||
subscribeToEffectivePlayerIdentity,
|
||||
} from "../../effectiveIdentitySession";
|
||||
|
||||
export const useAuthStore = defineStore("auth", {
|
||||
state: () => ({
|
||||
session: getAuthSession() as AuthSessionResponse | null,
|
||||
effectivePlayerId: getEffectivePlayerIdentityId() as string | null,
|
||||
availablePlayerIdentities: [] as PlayerIdentitySummary[],
|
||||
busy: false,
|
||||
initialized: false,
|
||||
}),
|
||||
@@ -14,19 +23,35 @@ export const useAuthStore = defineStore("auth", {
|
||||
roles: (state) => state.session?.roles ?? [],
|
||||
canAccessGm: (state) => (state.session?.roles ?? []).some((role) => role === "gm" || role === "admin"),
|
||||
accessToken: (state) => state.session?.accessToken ?? null,
|
||||
isActingAsAlternateIdentity: (state) => state.effectivePlayerId != null && state.effectivePlayerId !== state.session?.userId,
|
||||
activePlayerId: (state) => state.effectivePlayerId ?? state.session?.userId ?? null,
|
||||
},
|
||||
actions: {
|
||||
setSession(session: AuthSessionResponse | null) {
|
||||
this.session = session;
|
||||
setAuthSession(session);
|
||||
if (!session || !(session.roles ?? []).some((role) => role === "gm" || role === "admin")) {
|
||||
this.effectivePlayerId = null;
|
||||
clearEffectivePlayerIdentityId();
|
||||
}
|
||||
},
|
||||
clearSession() {
|
||||
this.session = null;
|
||||
this.effectivePlayerId = null;
|
||||
this.availablePlayerIdentities = [];
|
||||
clearAuthSession();
|
||||
clearEffectivePlayerIdentityId();
|
||||
},
|
||||
setBusy(busy: boolean) {
|
||||
this.busy = busy;
|
||||
},
|
||||
setEffectivePlayerId(playerId: string | null) {
|
||||
this.effectivePlayerId = playerId && playerId.trim().length > 0 ? playerId.trim() : null;
|
||||
setEffectivePlayerIdentityId(this.effectivePlayerId);
|
||||
},
|
||||
setAvailablePlayerIdentities(identities: PlayerIdentitySummary[]) {
|
||||
this.availablePlayerIdentities = identities;
|
||||
},
|
||||
initialize() {
|
||||
if (this.initialized) {
|
||||
return;
|
||||
@@ -36,6 +61,9 @@ export const useAuthStore = defineStore("auth", {
|
||||
subscribeToAuthSession((session) => {
|
||||
this.session = session as AuthSessionResponse | null;
|
||||
});
|
||||
subscribeToEffectivePlayerIdentity((playerId) => {
|
||||
this.effectivePlayerId = playerId;
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -103,6 +103,42 @@ export function updatePanFromKeyboard(
|
||||
galaxyAnchor.addScaledVector(pan, speed * delta);
|
||||
}
|
||||
|
||||
export function applyPanFromScreenDelta(
|
||||
delta: THREE.Vector2,
|
||||
orbitYaw: number,
|
||||
currentDistance: number,
|
||||
povLevel: PovLevel,
|
||||
activeSystemId: string | undefined,
|
||||
systemAnchor: THREE.Vector3,
|
||||
galaxyAnchor: THREE.Vector3,
|
||||
viewportWidth: number,
|
||||
viewportHeight: number,
|
||||
minimumDistance: number,
|
||||
maximumDistance: number,
|
||||
) {
|
||||
const safeWidth = Math.max(viewportWidth, 1);
|
||||
const safeHeight = Math.max(viewportHeight, 1);
|
||||
const normalized = new THREE.Vector2(delta.x / safeWidth, delta.y / safeHeight);
|
||||
if (normalized.lengthSq() === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
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));
|
||||
|
||||
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);
|
||||
return;
|
||||
}
|
||||
|
||||
const galaxyScale = THREE.MathUtils.mapLinear(currentDistance, minimumDistance, maximumDistance, 1800, 22000);
|
||||
galaxyAnchor.addScaledVector(pan, galaxyScale);
|
||||
}
|
||||
|
||||
export function determineActiveSystemId(params: DetermineActiveSystemParams): string | undefined {
|
||||
const {
|
||||
world,
|
||||
|
||||
@@ -5,6 +5,8 @@ import { ViewerPresentationController } from "./viewerPresentationController";
|
||||
import { ViewerSceneDataController } from "./viewerSceneDataController";
|
||||
import { ViewerWorldLifecycle } from "./viewerWorldLifecycle";
|
||||
import { ViewerHistoryWindowController } from "./viewerHistoryWindowController";
|
||||
import { applyPanFromScreenDelta } from "./viewerCamera";
|
||||
import { MAX_CAMERA_DISTANCE, MIN_CAMERA_DISTANCE } from "./viewerConstants";
|
||||
import { useViewerSceneStore } from "./ui/stores/viewerScene";
|
||||
import { useViewerOrderContextMenuStore } from "./ui/stores/viewerOrderContextMenu";
|
||||
import { viewerPinia } from "./ui/stores/pinia";
|
||||
@@ -236,14 +238,21 @@ export function createViewerControllers(host: any) {
|
||||
getFollowCameraPosition: () => host.followCameraPosition,
|
||||
getFollowCameraFocus: () => host.followCameraFocus,
|
||||
screenPointFromClient: (x, y) => presentationController.screenPointFromClient(x, y),
|
||||
applyOrbitDelta: (delta: THREE.Vector2) => {
|
||||
if (host.cameraMode === "follow") {
|
||||
host.followOrbitYaw += delta.x * 0.008;
|
||||
host.followOrbitPitch = THREE.MathUtils.clamp(host.followOrbitPitch + delta.y * 0.004, 0.02, 1.45);
|
||||
} else {
|
||||
host.orbitYaw += delta.x * 0.008;
|
||||
host.orbitPitch = THREE.MathUtils.clamp(host.orbitPitch + delta.y * 0.004, 0.18, 1.3);
|
||||
}
|
||||
applyPanDelta: (delta: THREE.Vector2) => {
|
||||
const bounds = host.renderer.domElement.getBoundingClientRect();
|
||||
applyPanFromScreenDelta(
|
||||
delta,
|
||||
host.orbitYaw,
|
||||
host.currentDistance,
|
||||
host.povLevel,
|
||||
host.activeSystemId,
|
||||
host.systemAnchor,
|
||||
host.galaxyAnchor,
|
||||
bounds.width,
|
||||
bounds.height,
|
||||
MIN_CAMERA_DISTANCE,
|
||||
MAX_CAMERA_DISTANCE,
|
||||
);
|
||||
},
|
||||
syncFollowStateFromSelection: () => navigationController.syncFollowStateFromSelection(),
|
||||
updatePanels: () => host.updatePanels(),
|
||||
@@ -251,6 +260,11 @@ export function createViewerControllers(host: any) {
|
||||
updateGamePanel: (mode) => host.updateGamePanel(mode),
|
||||
openOrderContextMenu: (x, y, target) => orderContextMenuStore.open(x, y, target),
|
||||
closeOrderContextMenu: () => orderContextMenuStore.close(),
|
||||
getStatsOverlayMode: () => host.hudState.statsOverlay.mode,
|
||||
setStatsOverlayMode: (mode) => {
|
||||
host.hudState.statsOverlay.mode = mode;
|
||||
},
|
||||
refreshStatsOverlay: () => presentationController.refreshStatsOverlay(),
|
||||
historyController,
|
||||
});
|
||||
|
||||
@@ -269,6 +283,7 @@ export function wireViewerEvents(host: any) {
|
||||
canvas.addEventListener("pointerdown", host.interactionController.onPointerDown);
|
||||
canvas.addEventListener("pointermove", host.interactionController.onPointerMove);
|
||||
canvas.addEventListener("pointerup", host.interactionController.onPointerUp);
|
||||
canvas.addEventListener("pointercancel", host.interactionController.onPointerUp);
|
||||
canvas.addEventListener("pointerleave", host.interactionController.onPointerUp);
|
||||
canvas.addEventListener("click", host.interactionController.onClick);
|
||||
canvas.addEventListener("contextmenu", host.interactionController.onContextMenu);
|
||||
@@ -284,6 +299,7 @@ export function wireViewerEvents(host: any) {
|
||||
canvas.removeEventListener("pointerdown", host.interactionController.onPointerDown);
|
||||
canvas.removeEventListener("pointermove", host.interactionController.onPointerMove);
|
||||
canvas.removeEventListener("pointerup", host.interactionController.onPointerUp);
|
||||
canvas.removeEventListener("pointercancel", host.interactionController.onPointerUp);
|
||||
canvas.removeEventListener("pointerleave", host.interactionController.onPointerUp);
|
||||
canvas.removeEventListener("click", host.interactionController.onClick);
|
||||
canvas.removeEventListener("contextmenu", host.interactionController.onContextMenu);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { MAX_CAMERA_DISTANCE, MIN_CAMERA_DISTANCE, NAV_DISTANCE } from "./viewer
|
||||
import { scaleGalaxyVector, toThreeVector } from "./viewerMath";
|
||||
import { rawObject } from "./viewerScenePrimitives";
|
||||
import { resolveShipWorldPosition } from "./viewerWorldPresentation";
|
||||
import type { StatsOverlayMode } from "./viewerHudState";
|
||||
import type {
|
||||
CameraMode,
|
||||
Selectable,
|
||||
@@ -250,3 +251,20 @@ export function applyKeyboardControl(params: {
|
||||
|
||||
return { cameraMode, desiredDistance };
|
||||
}
|
||||
|
||||
export function cycleStatsOverlayMode(current: StatsOverlayMode): StatsOverlayMode {
|
||||
switch (current) {
|
||||
case "hidden":
|
||||
return "compact";
|
||||
case "compact":
|
||||
return "status";
|
||||
case "status":
|
||||
return "network";
|
||||
case "network":
|
||||
return "performance";
|
||||
case "performance":
|
||||
return "full";
|
||||
default:
|
||||
return "hidden";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,13 @@ export interface HudPanelState {
|
||||
bodyText: string;
|
||||
}
|
||||
|
||||
export type StatsOverlayMode = "hidden" | "compact" | "status" | "network" | "performance" | "full";
|
||||
|
||||
export interface StatsOverlayState {
|
||||
mode: StatsOverlayMode;
|
||||
lines: string[];
|
||||
}
|
||||
|
||||
export interface HudHtmlPanelState {
|
||||
hidden: boolean;
|
||||
title: string;
|
||||
@@ -100,6 +107,7 @@ export interface ViewerHudState {
|
||||
gamePanel: HudPanelState;
|
||||
networkPanel: HudPanelState;
|
||||
performancePanel: HudPanelState;
|
||||
statsOverlay: StatsOverlayState;
|
||||
systemPanel: HudHtmlPanelState;
|
||||
detailPanel: HudHtmlPanelState;
|
||||
error: HudErrorState;
|
||||
@@ -135,6 +143,10 @@ export function createViewerHudState(): ViewerHudState {
|
||||
summary: "Waiting",
|
||||
bodyText: "Waiting for frame samples.",
|
||||
},
|
||||
statsOverlay: {
|
||||
mode: "compact",
|
||||
lines: [],
|
||||
},
|
||||
systemPanel: {
|
||||
hidden: false,
|
||||
title: "Deep Space",
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
import * as THREE from "three";
|
||||
import {
|
||||
completeMarqueeSelection,
|
||||
hideMarqueeBox,
|
||||
pickSelectableHitAtClientPosition,
|
||||
pickSelectableAtClientPosition,
|
||||
updateHoverLabel,
|
||||
updateMarqueeBox,
|
||||
} from "./viewerInteraction";
|
||||
import {
|
||||
applyKeyboardControl,
|
||||
cycleStatsOverlayMode,
|
||||
toggleCameraMode,
|
||||
navigateFromWheel,
|
||||
} from "./viewerControls";
|
||||
import { NAV_DISTANCE, NAV_DISTANCE_PLANET_ORBIT, NAV_DISTANCE_SHIP_HULL } from "./viewerConstants";
|
||||
import { MAX_CAMERA_DISTANCE, MIN_CAMERA_DISTANCE, NAV_DISTANCE_PLANET_ORBIT } from "./viewerConstants";
|
||||
import { ViewerHistoryWindowController } from "./viewerHistoryWindowController";
|
||||
import type { ViewerHudState } from "./viewerHudState";
|
||||
import type { StatsOverlayMode, ViewerHudState } from "./viewerHudState";
|
||||
import type {
|
||||
CameraMode,
|
||||
DragMode,
|
||||
@@ -61,88 +59,128 @@ export interface ViewerInteractionContext {
|
||||
getFollowCameraPosition: () => THREE.Vector3;
|
||||
getFollowCameraFocus: () => THREE.Vector3;
|
||||
screenPointFromClient: (clientX: number, clientY: number) => THREE.Vector2;
|
||||
applyOrbitDelta: (delta: THREE.Vector2) => void;
|
||||
applyPanDelta: (delta: THREE.Vector2) => void;
|
||||
syncFollowStateFromSelection: () => void;
|
||||
updatePanels: () => void;
|
||||
focusOnSelection: (selection: Selectable) => void;
|
||||
updateGamePanel: (mode: string) => void;
|
||||
openOrderContextMenu: (x: number, y: number, target: ViewerOrderContextMenuTarget) => void;
|
||||
closeOrderContextMenu: () => void;
|
||||
getStatsOverlayMode: () => StatsOverlayMode;
|
||||
setStatsOverlayMode: (mode: StatsOverlayMode) => void;
|
||||
refreshStatsOverlay: () => void;
|
||||
historyController: ViewerHistoryWindowController;
|
||||
}
|
||||
|
||||
export class ViewerInteractionController {
|
||||
private readonly activePointers = new Map<number, THREE.Vector2>();
|
||||
private pinchStartDistance?: number;
|
||||
private pinchStartZoom?: number;
|
||||
private pinchLastCenter?: THREE.Vector2;
|
||||
|
||||
constructor(private readonly context: ViewerInteractionContext) {}
|
||||
|
||||
readonly onPointerDown = (event: PointerEvent) => {
|
||||
if (event.button === 1) {
|
||||
this.context.setDragMode("orbit");
|
||||
this.context.setDragPointerId(event.pointerId);
|
||||
this.context.dragLast.copy(this.context.screenPointFromClient(event.clientX, event.clientY));
|
||||
this.context.renderer.domElement.setPointerCapture(event.pointerId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.button !== 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.context.setDragMode("marquee");
|
||||
this.context.setDragPointerId(event.pointerId);
|
||||
this.context.dragStart.copy(this.context.screenPointFromClient(event.clientX, event.clientY));
|
||||
this.context.dragLast.copy(this.context.dragStart);
|
||||
this.context.setMarqueeActive(false);
|
||||
const point = this.context.screenPointFromClient(event.clientX, event.clientY);
|
||||
this.activePointers.set(event.pointerId, point);
|
||||
this.context.renderer.domElement.setPointerCapture(event.pointerId);
|
||||
|
||||
if (this.activePointers.size >= 2) {
|
||||
const gesture = this.getPinchGesture();
|
||||
if (!gesture) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.context.setSuppressClickSelection(true);
|
||||
this.context.setDragMode("pinch");
|
||||
this.context.setDragPointerId(event.pointerId);
|
||||
this.pinchStartDistance = gesture.distance;
|
||||
this.pinchStartZoom = this.context.getDesiredDistance();
|
||||
this.pinchLastCenter = gesture.center;
|
||||
return;
|
||||
}
|
||||
|
||||
this.context.setDragMode("pan");
|
||||
this.context.setDragPointerId(event.pointerId);
|
||||
this.context.dragStart.copy(point);
|
||||
this.context.dragLast.copy(point);
|
||||
};
|
||||
|
||||
readonly onPointerMove = (event: PointerEvent) => {
|
||||
this.updateHoverLabel(event);
|
||||
|
||||
const point = this.context.screenPointFromClient(event.clientX, event.clientY);
|
||||
if (this.activePointers.has(event.pointerId)) {
|
||||
this.activePointers.set(event.pointerId, point);
|
||||
}
|
||||
|
||||
if (this.context.getDragPointerId() !== event.pointerId || !this.context.getDragMode()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const point = this.context.screenPointFromClient(event.clientX, event.clientY);
|
||||
if (this.context.getDragMode() === "orbit") {
|
||||
const delta = point.clone().sub(this.context.dragLast);
|
||||
this.context.dragLast.copy(point);
|
||||
this.context.applyOrbitDelta(delta);
|
||||
if (this.context.getDragMode() === "pinch") {
|
||||
const gesture = this.getPinchGesture();
|
||||
if (!gesture || !this.pinchStartDistance || !this.pinchStartZoom || !this.pinchLastCenter) {
|
||||
return;
|
||||
}
|
||||
|
||||
const zoomRatio = THREE.MathUtils.clamp(gesture.distance / this.pinchStartDistance, 0.25, 4);
|
||||
this.context.setDesiredDistance(THREE.MathUtils.clamp(
|
||||
this.pinchStartZoom / zoomRatio,
|
||||
MIN_CAMERA_DISTANCE,
|
||||
MAX_CAMERA_DISTANCE,
|
||||
));
|
||||
const centerDelta = gesture.center.clone().sub(this.pinchLastCenter);
|
||||
this.pinchLastCenter = gesture.center;
|
||||
this.context.applyPanDelta(centerDelta);
|
||||
return;
|
||||
}
|
||||
|
||||
const delta = point.clone().sub(this.context.dragLast);
|
||||
const dragDistance = point.distanceTo(this.context.dragStart);
|
||||
if (!this.context.getMarqueeActive() && dragDistance > 8) {
|
||||
this.context.setMarqueeActive(true);
|
||||
if (dragDistance > 6) {
|
||||
this.context.setSuppressClickSelection(true);
|
||||
this.context.hudState.marquee.visible = true;
|
||||
this.context.marqueeEl.style.display = "block";
|
||||
}
|
||||
|
||||
if (!this.context.getMarqueeActive()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.context.dragLast.copy(point);
|
||||
updateMarqueeBox(this.context.hudState.marquee, this.context.marqueeEl, this.context.dragStart, this.context.dragLast);
|
||||
this.context.applyPanDelta(delta);
|
||||
};
|
||||
|
||||
readonly onPointerUp = (event: PointerEvent) => {
|
||||
if (this.context.getDragPointerId() !== event.pointerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.context.renderer.domElement.hasPointerCapture(event.pointerId)) {
|
||||
this.context.renderer.domElement.releasePointerCapture(event.pointerId);
|
||||
}
|
||||
this.activePointers.delete(event.pointerId);
|
||||
|
||||
if (this.context.getDragMode() === "marquee" && this.context.getMarqueeActive()) {
|
||||
this.completeMarqueeSelection();
|
||||
hideMarqueeBox(this.context.hudState.marquee, this.context.marqueeEl);
|
||||
if (this.activePointers.size >= 2) {
|
||||
const gesture = this.getPinchGesture();
|
||||
if (gesture) {
|
||||
this.context.setDragMode("pinch");
|
||||
this.pinchStartDistance = gesture.distance;
|
||||
this.pinchStartZoom = this.context.getDesiredDistance();
|
||||
this.pinchLastCenter = gesture.center;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this.context.setDragMode(undefined);
|
||||
this.context.setDragPointerId(undefined);
|
||||
this.context.setMarqueeActive(false);
|
||||
const remainingPointer = this.activePointers.entries().next();
|
||||
if (!remainingPointer.done) {
|
||||
const [pointerId, point] = remainingPointer.value;
|
||||
this.context.setDragMode("pan");
|
||||
this.context.setDragPointerId(pointerId);
|
||||
this.context.dragStart.copy(point);
|
||||
this.context.dragLast.copy(point);
|
||||
} else {
|
||||
this.context.setDragMode(undefined);
|
||||
this.context.setDragPointerId(undefined);
|
||||
}
|
||||
|
||||
this.pinchStartDistance = undefined;
|
||||
this.pinchStartZoom = undefined;
|
||||
this.pinchLastCenter = undefined;
|
||||
};
|
||||
|
||||
readonly onClick = (event: MouseEvent) => {
|
||||
@@ -225,8 +263,7 @@ export class ViewerInteractionController {
|
||||
this.context.setSelectedItems([{ kind: "ship", id: shipId }]);
|
||||
this.context.syncFollowStateFromSelection();
|
||||
this.context.focusOnSelection({ kind: "ship", id: shipId });
|
||||
this.toggleCameraMode("follow");
|
||||
this.context.setDesiredDistance(NAV_DISTANCE_SHIP_HULL);
|
||||
this.toggleCameraMode("tactical");
|
||||
this.context.updatePanels();
|
||||
this.context.updateGamePanel("Live");
|
||||
return;
|
||||
@@ -268,8 +305,7 @@ export class ViewerInteractionController {
|
||||
}
|
||||
|
||||
if (selection.kind === "ship") {
|
||||
this.toggleCameraMode("follow");
|
||||
this.context.setDesiredDistance(NAV_DISTANCE_SHIP_HULL);
|
||||
this.toggleCameraMode("tactical");
|
||||
this.context.updatePanels();
|
||||
this.context.updateGamePanel("Live");
|
||||
return;
|
||||
@@ -288,6 +324,13 @@ export class ViewerInteractionController {
|
||||
}
|
||||
|
||||
const key = event.key.toLowerCase();
|
||||
if (key === "f10") {
|
||||
event.preventDefault();
|
||||
this.context.setStatsOverlayMode(cycleStatsOverlayMode(this.context.getStatsOverlayMode()));
|
||||
this.context.refreshStatsOverlay();
|
||||
return;
|
||||
}
|
||||
|
||||
const controlState = applyKeyboardControl({
|
||||
keyState: this.context.keyState,
|
||||
cameraMode: this.context.getCameraMode(),
|
||||
@@ -371,17 +414,17 @@ export class ViewerInteractionController {
|
||||
);
|
||||
}
|
||||
|
||||
private completeMarqueeSelection() {
|
||||
const selection = completeMarqueeSelection({
|
||||
renderer: this.context.renderer,
|
||||
systemCamera: this.context.systemCamera,
|
||||
dragStart: this.context.dragStart,
|
||||
dragLast: this.context.dragLast,
|
||||
systemSelectableTargets: this.context.systemSelectableTargets,
|
||||
});
|
||||
this.context.setSelectedItems(selection);
|
||||
this.context.syncFollowStateFromSelection();
|
||||
this.context.updatePanels();
|
||||
private getPinchGesture() {
|
||||
const points = [...this.activePointers.values()];
|
||||
if (points.length < 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [first, second] = points;
|
||||
return {
|
||||
center: first.clone().add(second).multiplyScalar(0.5),
|
||||
distance: first.distanceTo(second),
|
||||
};
|
||||
}
|
||||
|
||||
private shouldFocusSelectionOnClick(selection: Selectable) {
|
||||
|
||||
@@ -248,6 +248,54 @@ function renderSystemOwnership(world: WorldState, systemId: string): string {
|
||||
return lines.join("<br>");
|
||||
}
|
||||
|
||||
function titleCaseLabel(value: string | null | undefined): string {
|
||||
if (!value) {
|
||||
return "Unknown";
|
||||
}
|
||||
|
||||
return value
|
||||
.replace(/([a-z])([A-Z])/g, "$1 $2")
|
||||
.replace(/[-_]+/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.replace(/\b\w/g, (part) => part.toUpperCase());
|
||||
}
|
||||
|
||||
function describeSystemSubtitle(world: WorldState, systemId: string): string {
|
||||
const control = world.geopolitics?.territory.controlStates.find((state) => state.systemId === systemId);
|
||||
const zone = world.geopolitics?.territory.zones.find((entry) => entry.systemId === systemId);
|
||||
const profile = world.geopolitics?.territory.strategicProfiles.find((entry) => entry.systemId === systemId);
|
||||
const region = world.geopolitics?.economyRegions.regions.find((entry) => entry.systemIds.includes(systemId));
|
||||
|
||||
if (region?.label) {
|
||||
return region.label;
|
||||
}
|
||||
|
||||
if (zone?.reason) {
|
||||
return titleCaseLabel(zone.reason);
|
||||
}
|
||||
|
||||
if (zone?.kind) {
|
||||
return titleCaseLabel(zone.kind);
|
||||
}
|
||||
|
||||
if (profile?.zoneKind) {
|
||||
return titleCaseLabel(profile.zoneKind);
|
||||
}
|
||||
|
||||
if (control?.isContested) {
|
||||
return "Contested";
|
||||
}
|
||||
|
||||
if (control) {
|
||||
return titleCaseLabel(control.controlKind);
|
||||
}
|
||||
|
||||
const claims = [...world.claims.values()].filter((claim) =>
|
||||
claim.systemId === systemId && claim.state !== "destroyed");
|
||||
return claims.length === 0 ? "Deep Space" : `Claims ${claims.length}`;
|
||||
}
|
||||
|
||||
export function buildDetailPanelState(params: DetailPanelParams) {
|
||||
const {
|
||||
world,
|
||||
@@ -525,9 +573,7 @@ export function buildSystemPanelState(params: SystemPanelParams) {
|
||||
return {
|
||||
hidden: false,
|
||||
title: activeSystem.label,
|
||||
bodyHtml: `
|
||||
<p>${renderSystemOwnership(world, activeSystem.id)}</p>
|
||||
`,
|
||||
bodyHtml: describeSystemSubtitle(world, activeSystem.id),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as THREE from "three";
|
||||
import {
|
||||
describeCompactStatsLine,
|
||||
describeNetworkPanel,
|
||||
describePerformancePanel,
|
||||
recordPerformanceStats,
|
||||
@@ -40,6 +41,56 @@ export interface ViewerPresentationContext {
|
||||
export class ViewerPresentationController {
|
||||
constructor(private readonly context: ViewerPresentationContext) { }
|
||||
|
||||
private refreshStatsOverlayLines(gameBodyText?: string) {
|
||||
const mode = this.context.hudState.statsOverlay.mode;
|
||||
if (mode === "hidden") {
|
||||
this.context.hudState.statsOverlay.lines = [];
|
||||
return;
|
||||
}
|
||||
|
||||
const compactLine = describeCompactStatsLine(this.context.networkStats, this.context.performanceStats);
|
||||
const gameLines = (gameBodyText ?? this.context.hudState.gamePanel.bodyText)
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
const networkLines = this.context.hudState.networkPanel.bodyText
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
const performanceLines = this.context.hudState.performancePanel.bodyText
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
switch (mode) {
|
||||
case "compact":
|
||||
this.context.hudState.statsOverlay.lines = [compactLine];
|
||||
return;
|
||||
case "status":
|
||||
this.context.hudState.statsOverlay.lines = [compactLine, ...gameLines];
|
||||
return;
|
||||
case "network":
|
||||
this.context.hudState.statsOverlay.lines = [compactLine, ...networkLines];
|
||||
return;
|
||||
case "performance":
|
||||
this.context.hudState.statsOverlay.lines = [compactLine, ...performanceLines];
|
||||
return;
|
||||
case "full":
|
||||
this.context.hudState.statsOverlay.lines = [
|
||||
compactLine,
|
||||
"",
|
||||
...gameLines,
|
||||
"",
|
||||
...networkLines,
|
||||
"",
|
||||
...performanceLines,
|
||||
];
|
||||
return;
|
||||
default:
|
||||
this.context.hudState.statsOverlay.lines = [];
|
||||
}
|
||||
}
|
||||
|
||||
initializeAmbience() {
|
||||
this.context.ambienceGroup.renderOrder = -10;
|
||||
this.context.ambienceGroup.add(createBackdropStars(document));
|
||||
@@ -67,6 +118,7 @@ export class ViewerPresentationController {
|
||||
updateNetworkPanel() {
|
||||
this.context.hudState.networkPanel.bodyText = describeNetworkPanel(this.context.networkStats);
|
||||
this.context.hudState.networkPanel.summary = summarizeNetworkStats(this.context.networkStats);
|
||||
this.refreshStatsOverlayLines();
|
||||
}
|
||||
|
||||
recordPerformanceStats(frameMs: number) {
|
||||
@@ -79,6 +131,7 @@ export class ViewerPresentationController {
|
||||
this.context.hudState.performancePanel.bodyText = bodyText;
|
||||
}
|
||||
this.context.hudState.performancePanel.summary = summarizePerformanceStats(this.context.performanceStats);
|
||||
this.refreshStatsOverlayLines();
|
||||
}
|
||||
|
||||
updateShipPresentation() {
|
||||
@@ -116,6 +169,11 @@ export class ViewerPresentationController {
|
||||
});
|
||||
this.context.hudState.gamePanel.bodyText = state.bodyText;
|
||||
this.context.hudState.gamePanel.summary = state.summaryText;
|
||||
this.refreshStatsOverlayLines(state.bodyText);
|
||||
}
|
||||
|
||||
refreshStatsOverlay() {
|
||||
this.refreshStatsOverlayLines();
|
||||
}
|
||||
|
||||
updateSystemPanel() {
|
||||
|
||||
@@ -202,6 +202,7 @@ export class ViewerSceneDataController {
|
||||
createWorldPresentationContext(overrides: {
|
||||
world: any;
|
||||
activeSystemId?: string;
|
||||
cameraMode: any;
|
||||
povLevel: any;
|
||||
orbitYaw: number;
|
||||
systemCamera: THREE.PerspectiveCamera;
|
||||
@@ -214,6 +215,7 @@ export class ViewerSceneDataController {
|
||||
worldTimeSyncMs: this.context.getWorldTimeSyncMs(),
|
||||
worldSeed: this.context.getWorldSeed(),
|
||||
activeSystemId: overrides.activeSystemId,
|
||||
cameraMode: overrides.cameraMode,
|
||||
povLevel: overrides.povLevel,
|
||||
orbitYaw: overrides.orbitYaw,
|
||||
camera: overrides.systemCamera,
|
||||
|
||||
@@ -571,19 +571,55 @@ export function createTacticalIcon(documentRef: Document, color: string, size: n
|
||||
export function createShipTacticalIcon(documentRef: Document, color: string, size: number): SceneNode {
|
||||
const canvas = documentRef.createElement("canvas");
|
||||
canvas.width = 128;
|
||||
canvas.height = 96;
|
||||
canvas.height = 192;
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) {
|
||||
throw new Error("Unable to create ship tactical icon");
|
||||
}
|
||||
|
||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
context.lineCap = "round";
|
||||
context.lineJoin = "round";
|
||||
context.strokeStyle = color;
|
||||
context.fillStyle = "rgba(7, 16, 30, 0.7)";
|
||||
context.lineWidth = 5;
|
||||
context.fillStyle = "rgba(7, 16, 30, 0.8)";
|
||||
context.lineWidth = 6;
|
||||
|
||||
context.globalAlpha = 0.92;
|
||||
context.beginPath();
|
||||
context.moveTo(64, 182);
|
||||
context.lineTo(64, 108);
|
||||
context.stroke();
|
||||
|
||||
context.globalAlpha = 1;
|
||||
context.beginPath();
|
||||
context.arc(64, 70, 26, 0, Math.PI * 2);
|
||||
context.fill();
|
||||
context.stroke();
|
||||
|
||||
context.beginPath();
|
||||
context.arc(34, 48, 18, 0, Math.PI * 2);
|
||||
context.moveTo(64, 34);
|
||||
context.lineTo(64, 49);
|
||||
context.stroke();
|
||||
|
||||
context.beginPath();
|
||||
context.moveTo(64, 91);
|
||||
context.lineTo(64, 106);
|
||||
context.stroke();
|
||||
|
||||
context.beginPath();
|
||||
context.moveTo(28, 70);
|
||||
context.lineTo(43, 70);
|
||||
context.stroke();
|
||||
|
||||
context.beginPath();
|
||||
context.moveTo(85, 70);
|
||||
context.lineTo(100, 70);
|
||||
context.stroke();
|
||||
|
||||
context.beginPath();
|
||||
context.moveTo(44, 116);
|
||||
context.lineTo(64, 136);
|
||||
context.lineTo(84, 116);
|
||||
context.stroke();
|
||||
|
||||
const texture = new THREE.CanvasTexture(canvas);
|
||||
@@ -593,9 +629,10 @@ export function createShipTacticalIcon(documentRef: Document, color: string, siz
|
||||
depthWrite: false,
|
||||
depthTest: false,
|
||||
color: "#ffffff",
|
||||
fog: false,
|
||||
}));
|
||||
sprite.center.set(0.28, 0.5);
|
||||
sprite.scale.set(size * 1.7, size * 1.275, 1);
|
||||
sprite.center.set(0.5, 0.08);
|
||||
sprite.scale.set(size * 1.2, size * 1.8, 1);
|
||||
sprite.visible = false;
|
||||
return createSceneNode(sprite);
|
||||
}
|
||||
|
||||
@@ -41,14 +41,18 @@ export function describeNetworkPanel(networkStats: NetworkStats) {
|
||||
}
|
||||
|
||||
export function summarizeNetworkStats(networkStats: NetworkStats): string {
|
||||
const kbPerSecond = estimateDownlinkKbPerSecond(networkStats);
|
||||
const direction = networkStats.streamConnected ? "live" : "offline";
|
||||
return `${direction} | down ${kbPerSecond.toFixed(1)} KB/s | ${networkStats.deltasReceived} d`;
|
||||
}
|
||||
|
||||
export function estimateDownlinkKbPerSecond(networkStats: NetworkStats): number {
|
||||
const now = performance.now();
|
||||
const recentBytes = networkStats.throughputSamples.reduce((sum, sample) => sum + sample.bytes, 0);
|
||||
const recentWindowSeconds = networkStats.throughputSamples.length > 1
|
||||
? Math.max((now - networkStats.throughputSamples[0].atMs) / 1000, 1)
|
||||
: 1;
|
||||
const kbPerSecond = recentBytes / 1024 / recentWindowSeconds;
|
||||
const direction = networkStats.streamConnected ? "live" : "offline";
|
||||
return `${direction} | down ${kbPerSecond.toFixed(1)} KB/s | ${networkStats.deltasReceived} d`;
|
||||
return recentBytes / 1024 / recentWindowSeconds;
|
||||
}
|
||||
|
||||
export function recordPerformanceStats(performanceStats: PerformanceStats, frameMs: number) {
|
||||
@@ -116,12 +120,23 @@ export function describePerformancePanel(
|
||||
}
|
||||
|
||||
export function summarizePerformanceStats(performanceStats: PerformanceStats): string {
|
||||
const fps = estimateFps(performanceStats);
|
||||
return `FPS ${fps.toFixed(1)} | ${performanceStats.lastFrameMs.toFixed(1)} ms`;
|
||||
}
|
||||
|
||||
export function estimateFps(performanceStats: PerformanceStats): number {
|
||||
const samples = performanceStats.frameSamples;
|
||||
const elapsedWindowSeconds = samples.length > 1
|
||||
? Math.max((samples[samples.length - 1].atMs - samples[0].atMs) / 1000, 0.25)
|
||||
: 1;
|
||||
const fps = samples.length > 1
|
||||
return samples.length > 1
|
||||
? (samples.length - 1) / elapsedWindowSeconds
|
||||
: 0;
|
||||
return `FPS ${fps.toFixed(1)} | ${performanceStats.lastFrameMs.toFixed(1)} ms`;
|
||||
}
|
||||
|
||||
export function describeCompactStatsLine(networkStats: NetworkStats, performanceStats: PerformanceStats): string {
|
||||
const onlineLabel = networkStats.streamConnected ? "Online" : "Offline";
|
||||
const fps = estimateFps(performanceStats);
|
||||
const down = estimateDownlinkKbPerSecond(networkStats);
|
||||
return `${onlineLabel} | FPS ${fps.toFixed(1)} | Down ${down.toFixed(1)} KB/s`;
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ import type {
|
||||
|
||||
export type PovLevel = "local" | "system" | "galaxy";
|
||||
export type SelectionGroup = "ships" | "structures" | "celestials";
|
||||
export type DragMode = "orbit" | "marquee";
|
||||
export type DragMode = "pan" | "pinch";
|
||||
export type CameraMode = "tactical" | "follow";
|
||||
|
||||
export type Selectable =
|
||||
|
||||
@@ -152,7 +152,6 @@ export class ViewerWorldLifecycle {
|
||||
}
|
||||
|
||||
applySnapshot(snapshot: WorldSnapshot) {
|
||||
usePlayerFactionStore(viewerPinia).setPlayerFaction(null);
|
||||
this.context.setWorldTimeSyncMs(performance.now());
|
||||
const signature = `${snapshot.seed}|${snapshot.systems.length}`;
|
||||
if (signature !== this.context.getWorldSignature()) {
|
||||
|
||||
@@ -16,8 +16,6 @@ import {
|
||||
updateSystemStarPresentation,
|
||||
getAnimatedShipLocalPosition,
|
||||
iconWorldScale,
|
||||
MIN_ICON_PIXELS,
|
||||
MAX_ICON_PIXELS,
|
||||
} from "./viewerPresentation";
|
||||
import { rawObject } from "./viewerScenePrimitives";
|
||||
import type {
|
||||
@@ -42,6 +40,13 @@ import type {
|
||||
|
||||
type SummaryIconKind = "ship" | "station" | "structure";
|
||||
|
||||
const SHIP_BILLBOARD_HIDE_DISTANCE = 0.003;
|
||||
const SHIP_BILLBOARD_FULL_DISTANCE = 0.018;
|
||||
const SHIP_BILLBOARD_MIN_PIXELS = 34;
|
||||
const SHIP_BILLBOARD_MAX_PIXELS = 82;
|
||||
const STATION_ICON_MIN_PIXELS = 28;
|
||||
const STATION_ICON_MAX_PIXELS = 72;
|
||||
|
||||
export interface WorldOrbitalContext {
|
||||
world?: WorldState;
|
||||
worldTimeSyncMs: number;
|
||||
@@ -53,6 +58,7 @@ export interface WorldOrbitalContext {
|
||||
|
||||
export interface WorldPresentationContext extends WorldOrbitalContext {
|
||||
activeSystemId?: string;
|
||||
cameraMode: CameraMode;
|
||||
povLevel: PovLevel;
|
||||
orbitYaw: number;
|
||||
camera: THREE.PerspectiveCamera;
|
||||
@@ -95,14 +101,22 @@ export function updateWorldPresentation(context: WorldPresentationContext) {
|
||||
visual.icon.setPosition(rawObject(visual.mesh).position.clone());
|
||||
const shipVisible = isShipVisible(renderMode, context.activeSystemId, ship);
|
||||
const distToShip = context.camera.position.distanceTo(displayPosition);
|
||||
const useTacticalIcon = renderMode !== "local" || distToShip > 0.012;
|
||||
const billboardOpacity = context.cameraMode === "tactical"
|
||||
? 1
|
||||
: THREE.MathUtils.clamp(
|
||||
(distToShip - SHIP_BILLBOARD_HIDE_DISTANCE) / (SHIP_BILLBOARD_FULL_DISTANCE - SHIP_BILLBOARD_HIDE_DISTANCE),
|
||||
0,
|
||||
1,
|
||||
);
|
||||
const useTacticalIcon = context.cameraMode === "tactical" || billboardOpacity > 0.01;
|
||||
const iconScale = THREE.MathUtils.clamp(
|
||||
visual.iconBaseScale,
|
||||
iconWorldScale(distToShip, context.camera, MIN_ICON_PIXELS),
|
||||
iconWorldScale(distToShip, context.camera, MAX_ICON_PIXELS + 10),
|
||||
iconWorldScale(distToShip, context.camera, SHIP_BILLBOARD_MIN_PIXELS),
|
||||
iconWorldScale(distToShip, context.camera, SHIP_BILLBOARD_MAX_PIXELS),
|
||||
);
|
||||
visual.icon.setScaleScalar(iconScale);
|
||||
visual.mesh.setVisible(shipVisible && !useTacticalIcon);
|
||||
visual.icon.setOpacity(shipVisible ? billboardOpacity : 0);
|
||||
visual.mesh.setVisible(shipVisible && context.cameraMode !== "tactical" && billboardOpacity < 0.98);
|
||||
visual.icon.setVisible(shipVisible && useTacticalIcon);
|
||||
const desiredHeading = resolveShipHeading(visual, worldPosition, context.orbitYaw);
|
||||
if (desiredHeading.lengthSq() > 0.01) {
|
||||
@@ -135,9 +149,19 @@ export function updateWorldPresentation(context: WorldPresentationContext) {
|
||||
|
||||
for (const visual of context.stationVisuals.values()) {
|
||||
const animatedLocalPosition = resolveStructureAnimatedLocalPosition(context, visual, worldTimeSeconds);
|
||||
visual.mesh.setPosition(context.toDisplayLocalPosition(animatedLocalPosition));
|
||||
const displayPosition = context.toDisplayLocalPosition(animatedLocalPosition);
|
||||
visual.mesh.setPosition(displayPosition);
|
||||
visual.icon.setPosition(rawObject(visual.mesh).position.clone());
|
||||
visual.mesh.setVisible(visual.systemId === context.activeSystemId);
|
||||
const stationVisible = visual.systemId === context.activeSystemId;
|
||||
const distToStation = context.camera.position.distanceTo(displayPosition);
|
||||
const stationIconScale = THREE.MathUtils.clamp(
|
||||
130,
|
||||
iconWorldScale(distToStation, context.camera, STATION_ICON_MIN_PIXELS),
|
||||
iconWorldScale(distToStation, context.camera, STATION_ICON_MAX_PIXELS),
|
||||
);
|
||||
visual.icon.setScaleScalar(stationIconScale);
|
||||
visual.icon.setVisible(stationVisible);
|
||||
visual.mesh.setVisible(stationVisible && renderMode === "local" && context.cameraMode !== "tactical");
|
||||
}
|
||||
|
||||
for (const visual of context.claimVisuals.values()) {
|
||||
|
||||
@@ -12,7 +12,7 @@ export default defineConfig({
|
||||
port: 5174,
|
||||
allowedHosts: ["sobina.local"],
|
||||
proxy: {
|
||||
"/api": "http://127.0.0.1:5079",
|
||||
"/api": "http://127.0.0.1:5080",
|
||||
},
|
||||
},
|
||||
build: {
|
||||
|
||||
Reference in New Issue
Block a user