600 lines
19 KiB
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>
|