Refactor runtime bootstrap and ship control flows
This commit is contained in:
322
apps/viewer/src/components/ViewerEntityBrowserPanel.vue
Normal file
322
apps/viewer/src/components/ViewerEntityBrowserPanel.vue
Normal file
@@ -0,0 +1,322 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import { storeToRefs } from "pinia";
|
||||
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";
|
||||
|
||||
interface BrowserItem {
|
||||
key: string;
|
||||
label: string;
|
||||
subtitle: string;
|
||||
meta?: string;
|
||||
selection?: ViewerSelectionSummary;
|
||||
focusSelection?: Selectable;
|
||||
focusMode?: "follow" | "tactical";
|
||||
}
|
||||
|
||||
interface BrowserSection {
|
||||
key: string;
|
||||
label: string;
|
||||
count: number;
|
||||
items: BrowserItem[];
|
||||
}
|
||||
|
||||
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>("visible");
|
||||
const searchText = ref("");
|
||||
|
||||
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";
|
||||
}
|
||||
|
||||
return value
|
||||
.replace(/([a-z])([A-Z])/g, "$1 $2")
|
||||
.replace(/[-_]+/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.replace(/\b\w/g, (part) => part.toUpperCase());
|
||||
}
|
||||
|
||||
function buildVisibleSections(): BrowserSection[] {
|
||||
const sections: BrowserSection[] = [];
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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",
|
||||
}));
|
||||
|
||||
sections.push({
|
||||
key: "ships",
|
||||
label: "Ships",
|
||||
count: ships.length,
|
||||
items: ships,
|
||||
});
|
||||
sections.push({
|
||||
key: "stations",
|
||||
label: "Stations",
|
||||
count: stations.length,
|
||||
items: stations,
|
||||
});
|
||||
|
||||
return sections;
|
||||
}
|
||||
|
||||
function buildOwnedSections(): BrowserSection[] {
|
||||
const player = playerFaction.value;
|
||||
if (!player) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const ships = 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
|
||||
.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`,
|
||||
}));
|
||||
|
||||
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 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 selectItem(item: BrowserItem) {
|
||||
if (!item.selection) {
|
||||
return;
|
||||
}
|
||||
|
||||
selectionStore.selectSelection(item.selection, "ui");
|
||||
}
|
||||
|
||||
function focusItem(item: BrowserItem) {
|
||||
if (item.selection) {
|
||||
selectionStore.selectSelection(item.selection, "ui");
|
||||
}
|
||||
if (item.focusSelection) {
|
||||
emit("focus", item.focusSelection, item.focusMode);
|
||||
}
|
||||
}
|
||||
|
||||
function isSelected(item: BrowserItem) {
|
||||
return !!item.selection
|
||||
&& item.selection.id === selectedEntityId.value
|
||||
&& item.selection.kind === selectedEntityKind.value;
|
||||
}
|
||||
</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="filteredSections.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-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>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
Reference in New Issue
Block a user