Files
space-game/apps/viewer/src/components/ViewerEntityBrowserPanel.vue

600 lines
19 KiB
Vue

<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";
import { useViewerSceneStore } from "../ui/stores/viewerScene";
import { useViewerSelectionStore, type ViewerSelectionSummary } from "../ui/stores/viewerSelection";
import type { Selectable } from "../viewerTypes";
type BrowserTab = "visible" | "owned";
type BrowserSortKey = "entity" | "location" | "ai" | "hp";
type BrowserRowKind = "system" | "station" | "fleet" | "ship";
interface BrowserRow {
key: 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 BrowserDisplayRow extends BrowserRow {
depth: number;
}
const emit = defineEmits<{
focus: [selection: Selectable, cameraMode?: "follow" | "tactical"];
}>();
const gmStore = useGmStore();
const playerStore = usePlayerFactionStore();
const selectionStore = useViewerSelectionStore();
const sceneStore = useViewerSceneStore();
const { selectedEntityId, selectedEntityKind } = storeToRefs(selectionStore);
const { playerFaction } = storeToRefs(playerStore);
const { activeSystemId, povLevel } = storeToRefs(sceneStore);
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 titleCase(value: string | null | undefined) {
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 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) {
return gmStore.systems
.map((system) => buildSystemRow(system.id))
.filter((row): row is BrowserRow => row != null);
}
const systemId = activeSystemId.value;
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[] = [];
for (const ship of ships) {
const row = buildShipRow(ship);
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 buildOwnedRows() {
const player = playerFaction.value;
if (!player) {
return [];
}
const ownedShips = player.assetRegistry.shipIds
.map((shipId) => gmStore.ships.find((ship) => ship.id === shipId))
.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 StationSnapshot => station != null);
const ownedFleetShipIds = new Set(player.fleets.flatMap((fleet) => fleet.assetIds));
const ownedStationIds = new Set(ownedStations.map((station) => station.id));
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];
}
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();
});
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;
}
sortKey.value = nextKey;
sortDirection.value = "asc";
}
function toggleRow(row: BrowserDisplayRow) {
if (row.children.length === 0) {
return;
}
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(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>
<template>
<section class="entity-browser-panel pointer-events-auto">
<div class="entity-browser-panel__header">
<div>
<div class="entity-browser-panel__kicker">Tactical View</div>
<h3>Entities</h3>
</div>
<div class="entity-browser-panel__context">
<span>{{ activeSystemId ?? "Galaxy" }}</span>
<span>{{ povLevel }}</span>
</div>
</div>
<div class="entity-browser-panel__tabs">
<button
type="button"
class="entity-browser-panel__tab"
:class="activeTab === 'visible' ? 'entity-browser-panel__tab--active' : ''"
@click="activeTab = 'visible'"
>
Visible
</button>
<button
type="button"
class="entity-browser-panel__tab"
:class="activeTab === 'owned' ? 'entity-browser-panel__tab--active' : ''"
@click="activeTab = 'owned'"
>
Owned
</button>
</div>
<input
v-model="searchText"
class="entity-browser-panel__search"
type="text"
placeholder="Filter entities"
>
<div v-if="activeTab === 'owned' && !playerFaction" class="entity-browser-panel__empty">
No player-owned assets yet.
</div>
<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">
<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)"
>
<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>