Add player onboarding and tactical viewer updates

This commit is contained in:
2026-04-06 17:12:44 -04:00
parent 706e1cda8f
commit 63a9f808bb
52 changed files with 2699 additions and 577 deletions

View File

@@ -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"

View File

@@ -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();

View File

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

View File

@@ -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 = "";
});
}

View File

@@ -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"

View 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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -50,7 +50,7 @@ const canControlSelectedShip = computed(() => {
return false;
}
if (authStore.canAccessGm) {
if (authStore.canAccessGm && !authStore.isActingAsAlternateIdentity) {
return true;
}

View File

@@ -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;

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

View File

@@ -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;

View File

@@ -0,0 +1,6 @@
export interface RaceSnapshot {
id: string;
name: string;
description: string;
icon: string;
}

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

View File

@@ -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();
}

View File

@@ -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;
}
}

View File

@@ -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;
});
},
},
});

View File

@@ -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,

View File

@@ -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);

View File

@@ -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";
}
}

View File

@@ -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",

View File

@@ -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) {

View File

@@ -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),
};
}

View File

@@ -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() {

View File

@@ -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,

View File

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

View File

@@ -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`;
}

View File

@@ -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 =

View File

@@ -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()) {

View File

@@ -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()) {

View File

@@ -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: {