feat(viewer): add GM Ops Console window replacing ops strip
Introduces a floating, draggable, resizable Game Master console as the first of a planned series of GM/debug windows. Replaces the horizontal ops-strip card layout with proper data tables using TanStack Table v8. - GmWindow.vue: reusable draggable+resizable floating window base; snapshots offsetWidth/Height on drag start so resize is preserved - GmOpsWindow.vue: Ships / Stations / Factions tabs with global filter, column sorting, and drag-to-reorder columns (useColumnOrder composable) - gmStore.ts: Pinia store fed from ViewerWorldLifecycle.rebuildFactions with raw world arrays (ships, stations, factions) - Removes opsStripEl binding (was stored but never read by controller) - GM Console toggle button replaces the bottom ops strip Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
31
apps/viewer/package-lock.json
generated
31
apps/viewer/package-lock.json
generated
@@ -8,6 +8,7 @@
|
||||
"name": "space-game-viewer",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@tanstack/vue-table": "^8.21.3",
|
||||
"pinia": "^3.0.3",
|
||||
"three": "^0.179.1",
|
||||
"vue": "^3.5.21"
|
||||
@@ -1118,6 +1119,36 @@
|
||||
"vite": "^5.2.0 || ^6 || ^7 || ^8"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/table-core": {
|
||||
"version": "8.21.3",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz",
|
||||
"integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/vue-table": {
|
||||
"version": "8.21.3",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/vue-table/-/vue-table-8.21.3.tgz",
|
||||
"integrity": "sha512-rusRyd77c5tDPloPskctMyPLFEQUeBzxdQ+2Eow4F7gDPlPOB1UnnhzfpdvqZ8ZyX2rRNGmqNnQWm87OI2OQPw==",
|
||||
"dependencies": {
|
||||
"@tanstack/table-core": "8.21.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": ">=3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@tweenjs/tween.js": {
|
||||
"version": "23.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/vue-table": "^8.21.3",
|
||||
"pinia": "^3.0.3",
|
||||
"three": "^0.179.1",
|
||||
"vue": "^3.5.21"
|
||||
|
||||
@@ -5,13 +5,12 @@ import { GameViewer } from "./GameViewer";
|
||||
import CollapsibleHudPanel from "./components/CollapsibleHudPanel.vue";
|
||||
import HtmlInfoPanel from "./components/HtmlInfoPanel.vue";
|
||||
import ViewerHistoryLayer from "./components/ViewerHistoryLayer.vue";
|
||||
import ViewerOpsStrip from "./components/ViewerOpsStrip.vue";
|
||||
import GmOpsWindow from "./components/gm/GmOpsWindow.vue";
|
||||
import { createViewerHudState } from "./viewerHudState";
|
||||
import { useViewerSelectionStore } from "./ui/stores/viewerSelection";
|
||||
import type { Selectable } from "./viewerTypes";
|
||||
|
||||
const canvasHostEl = ref<HTMLDivElement | null>(null);
|
||||
const opsStripHostEl = ref<HTMLDivElement | null>(null);
|
||||
const historyLayerHostEl = ref<HTMLDivElement | null>(null);
|
||||
const marqueeEl = ref<HTMLDivElement | null>(null);
|
||||
const hoverLabelEl = ref<HTMLDivElement | null>(null);
|
||||
@@ -22,11 +21,12 @@ const selectionStore = useViewerSelectionStore();
|
||||
const { selectedEntityId, selectedEntityLabel } = storeToRefs(selectionStore);
|
||||
let viewer: GameViewer | undefined;
|
||||
|
||||
const gmOpsOpen = ref(false);
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick();
|
||||
if (
|
||||
!canvasHostEl.value
|
||||
|| !opsStripHostEl.value
|
||||
|| !historyLayerHostEl.value
|
||||
|| !marqueeEl.value
|
||||
|| !hoverLabelEl.value
|
||||
@@ -38,7 +38,6 @@ onMounted(async () => {
|
||||
viewer = new GameViewer(canvasHostEl.value, {
|
||||
state: hudState,
|
||||
selectionStore,
|
||||
opsStripEl: opsStripHostEl.value,
|
||||
historyLayerEl: historyLayerHostEl.value,
|
||||
marqueeEl: marqueeEl.value,
|
||||
hoverLabelEl: hoverLabelEl.value,
|
||||
@@ -146,13 +145,19 @@ function onFocusSelection(selection: Selectable, cameraMode?: "follow" | "tactic
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div ref="opsStripHostEl">
|
||||
<ViewerOpsStrip
|
||||
:state="hudState.opsStrip"
|
||||
@history="onOpenHistory"
|
||||
@focus="onFocusSelection"
|
||||
<button
|
||||
type="button"
|
||||
class="gm-console-toggle"
|
||||
@click="gmOpsOpen = !gmOpsOpen"
|
||||
>
|
||||
{{ gmOpsOpen ? "Close" : "GM Console" }}
|
||||
</button>
|
||||
|
||||
<GmOpsWindow
|
||||
v-if="gmOpsOpen"
|
||||
@close="gmOpsOpen = false"
|
||||
@focus="(id, kind) => onFocusSelection({ kind, id }, kind === 'ship' ? 'follow' : 'tactical')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref="marqueeEl"
|
||||
|
||||
@@ -68,7 +68,6 @@ export class ViewerAppController {
|
||||
|
||||
readonly hudState: ViewerHudState;
|
||||
readonly selectionStore: ViewerSelectionStore;
|
||||
private readonly opsStripEl: HTMLDivElement;
|
||||
private readonly historyLayerEl: HTMLDivElement;
|
||||
private readonly marqueeEl: HTMLDivElement;
|
||||
private readonly hoverLabelEl: HTMLDivElement;
|
||||
@@ -122,7 +121,6 @@ export class ViewerAppController {
|
||||
this.container = container;
|
||||
this.hudState = hud.state;
|
||||
this.selectionStore = hud.selectionStore;
|
||||
this.opsStripEl = hud.opsStripEl;
|
||||
this.historyLayerEl = hud.historyLayerEl;
|
||||
this.marqueeEl = hud.marqueeEl;
|
||||
this.hoverLabelEl = hud.hoverLabelEl;
|
||||
|
||||
617
apps/viewer/src/components/gm/GmOpsWindow.vue
Normal file
617
apps/viewer/src/components/gm/GmOpsWindow.vue
Normal file
@@ -0,0 +1,617 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import {
|
||||
useVueTable,
|
||||
getCoreRowModel,
|
||||
getFilteredRowModel,
|
||||
getSortedRowModel,
|
||||
createColumnHelper,
|
||||
FlexRender,
|
||||
type SortingState,
|
||||
type ColumnOrderState,
|
||||
type Updater,
|
||||
} from "@tanstack/vue-table";
|
||||
import { storeToRefs } from "pinia";
|
||||
import GmWindow from "./GmWindow.vue";
|
||||
import { useGmStore } from "../../ui/stores/gmStore";
|
||||
import { useViewerSelectionStore } from "../../ui/stores/viewerSelection";
|
||||
import type { ShipSnapshot } from "../../contractsShips";
|
||||
import type { StationSnapshot } from "../../contractsInfrastructure";
|
||||
import type { FactionSnapshot } from "../../contractsFactions";
|
||||
|
||||
// ── Column ordering composable ─────────────────────────────────────────────
|
||||
|
||||
function useColumnOrder(initialIds: string[]) {
|
||||
const columnOrder = ref<ColumnOrderState>([...initialIds]);
|
||||
const draggingId = ref<string | null>(null);
|
||||
const overId = ref<string | null>(null);
|
||||
|
||||
function onColumnOrderChange(updater: Updater<ColumnOrderState>) {
|
||||
columnOrder.value = typeof updater === "function" ? updater(columnOrder.value) : updater;
|
||||
}
|
||||
|
||||
function onDragStart(e: DragEvent, id: string) {
|
||||
draggingId.value = id;
|
||||
if (e.dataTransfer) {
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
e.dataTransfer.setData("text/plain", id);
|
||||
}
|
||||
}
|
||||
|
||||
function onDragOver(e: DragEvent, id: string) {
|
||||
e.preventDefault();
|
||||
if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
|
||||
overId.value = id;
|
||||
}
|
||||
|
||||
function onDragLeave() {
|
||||
overId.value = null;
|
||||
}
|
||||
|
||||
function onDrop(targetId: string) {
|
||||
if (!draggingId.value || draggingId.value === targetId) return;
|
||||
const order = [...columnOrder.value];
|
||||
const from = order.indexOf(draggingId.value);
|
||||
const to = order.indexOf(targetId);
|
||||
order.splice(from, 1);
|
||||
order.splice(to, 0, draggingId.value);
|
||||
columnOrder.value = order;
|
||||
draggingId.value = null;
|
||||
overId.value = null;
|
||||
}
|
||||
|
||||
function onDragEnd() {
|
||||
draggingId.value = null;
|
||||
overId.value = null;
|
||||
}
|
||||
|
||||
return { columnOrder, draggingId, overId, onColumnOrderChange, onDragStart, onDragOver, onDragLeave, onDrop, onDragEnd };
|
||||
}
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: [];
|
||||
focus: [id: string, kind: "ship" | "station"];
|
||||
}>();
|
||||
|
||||
type TabId = "ships" | "stations" | "factions";
|
||||
const activeTab = ref<TabId>("ships");
|
||||
|
||||
const gmStore = useGmStore();
|
||||
const selectionStore = useViewerSelectionStore();
|
||||
const { selectedEntityId, selectedEntityKind } = storeToRefs(selectionStore);
|
||||
|
||||
// Faction name lookup
|
||||
const factionMap = computed(() =>
|
||||
new Map(gmStore.factions.map((f) => [f.id, f.label])),
|
||||
);
|
||||
|
||||
// ── Ships table ────────────────────────────────────────────────────────────
|
||||
|
||||
type ShipRow = {
|
||||
id: string;
|
||||
label: string;
|
||||
class: string;
|
||||
faction: string;
|
||||
system: string;
|
||||
state: string;
|
||||
behavior: string;
|
||||
task: string;
|
||||
cargo: number;
|
||||
health: number;
|
||||
};
|
||||
|
||||
const shipRows = computed<ShipRow[]>(() =>
|
||||
gmStore.ships.map((s) => ({
|
||||
id: s.id,
|
||||
label: s.label,
|
||||
class: s.class,
|
||||
faction: factionMap.value.get(s.factionId) ?? s.factionId,
|
||||
system: s.systemId,
|
||||
state: s.state,
|
||||
behavior: s.defaultBehaviorKind + (s.behaviorPhase ? ` · ${s.behaviorPhase}` : ""),
|
||||
task: s.controllerTaskKind,
|
||||
cargo: s.inventory.reduce((sum, e) => sum + e.amount, 0),
|
||||
health: Math.round(s.health),
|
||||
})),
|
||||
);
|
||||
|
||||
const shipColumnHelper = createColumnHelper<ShipRow>();
|
||||
const shipColumns = [
|
||||
shipColumnHelper.accessor("label", { header: "Name" }),
|
||||
shipColumnHelper.accessor("class", { header: "Class" }),
|
||||
shipColumnHelper.accessor("faction", { header: "Faction" }),
|
||||
shipColumnHelper.accessor("system", { header: "System" }),
|
||||
shipColumnHelper.accessor("state", { header: "State" }),
|
||||
shipColumnHelper.accessor("behavior", { header: "Behavior" }),
|
||||
shipColumnHelper.accessor("task", { header: "Task" }),
|
||||
shipColumnHelper.accessor("cargo", { header: "Cargo" }),
|
||||
shipColumnHelper.accessor("health", { header: "HP" }),
|
||||
];
|
||||
|
||||
const shipFilter = ref("");
|
||||
const shipSorting = ref<SortingState>([]);
|
||||
const shipOrder = useColumnOrder(["label", "class", "faction", "system", "state", "behavior", "task", "cargo", "health"]);
|
||||
|
||||
const shipTable = useVueTable({
|
||||
get data() { return shipRows.value; },
|
||||
columns: shipColumns,
|
||||
state: {
|
||||
get globalFilter() { return shipFilter.value; },
|
||||
get sorting() { return shipSorting.value; },
|
||||
get columnOrder() { return shipOrder.columnOrder.value; },
|
||||
},
|
||||
onGlobalFilterChange: (v) => { shipFilter.value = String(v); },
|
||||
onSortingChange: (updater) => {
|
||||
shipSorting.value = typeof updater === "function" ? updater(shipSorting.value) : updater;
|
||||
},
|
||||
onColumnOrderChange: shipOrder.onColumnOrderChange,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
});
|
||||
|
||||
// ── Stations table ─────────────────────────────────────────────────────────
|
||||
|
||||
type StationRow = {
|
||||
id: string;
|
||||
label: string;
|
||||
category: string;
|
||||
faction: string;
|
||||
system: string;
|
||||
docked: string;
|
||||
cargo: number;
|
||||
population: number;
|
||||
modules: number;
|
||||
};
|
||||
|
||||
const stationRows = computed<StationRow[]>(() =>
|
||||
gmStore.stations.map((s) => ({
|
||||
id: s.id,
|
||||
label: s.label,
|
||||
category: s.category,
|
||||
faction: factionMap.value.get(s.factionId) ?? s.factionId,
|
||||
system: s.systemId,
|
||||
docked: `${s.dockedShips} / ${s.dockingPads}`,
|
||||
cargo: s.inventory.reduce((sum, e) => sum + e.amount, 0),
|
||||
population: Math.round(s.population),
|
||||
modules: s.installedModules.length,
|
||||
})),
|
||||
);
|
||||
|
||||
const stationColumnHelper = createColumnHelper<StationRow>();
|
||||
const stationColumns = [
|
||||
stationColumnHelper.accessor("label", { header: "Name" }),
|
||||
stationColumnHelper.accessor("category", { header: "Category" }),
|
||||
stationColumnHelper.accessor("faction", { header: "Faction" }),
|
||||
stationColumnHelper.accessor("system", { header: "System" }),
|
||||
stationColumnHelper.accessor("docked", { header: "Docked" }),
|
||||
stationColumnHelper.accessor("cargo", { header: "Cargo" }),
|
||||
stationColumnHelper.accessor("population", { header: "Pop" }),
|
||||
stationColumnHelper.accessor("modules", { header: "Modules" }),
|
||||
];
|
||||
|
||||
const stationFilter = ref("");
|
||||
const stationSorting = ref<SortingState>([]);
|
||||
const stationOrder = useColumnOrder(["label", "category", "faction", "system", "docked", "cargo", "population", "modules"]);
|
||||
|
||||
const stationTable = useVueTable({
|
||||
get data() { return stationRows.value; },
|
||||
columns: stationColumns,
|
||||
state: {
|
||||
get globalFilter() { return stationFilter.value; },
|
||||
get sorting() { return stationSorting.value; },
|
||||
get columnOrder() { return stationOrder.columnOrder.value; },
|
||||
},
|
||||
onGlobalFilterChange: (v) => { stationFilter.value = String(v); },
|
||||
onSortingChange: (updater) => {
|
||||
stationSorting.value = typeof updater === "function" ? updater(stationSorting.value) : updater;
|
||||
},
|
||||
onColumnOrderChange: stationOrder.onColumnOrderChange,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
});
|
||||
|
||||
// ── Factions table ─────────────────────────────────────────────────────────
|
||||
|
||||
type FactionRow = {
|
||||
id: string;
|
||||
label: string;
|
||||
credits: number;
|
||||
population: number;
|
||||
military: number;
|
||||
miners: number;
|
||||
transport: number;
|
||||
constructors: number;
|
||||
systems: string;
|
||||
ore: number;
|
||||
shipsBuilt: number;
|
||||
shipsLost: number;
|
||||
};
|
||||
|
||||
const factionRows = computed<FactionRow[]>(() =>
|
||||
gmStore.factions.map((f) => {
|
||||
const gs = f.goapState;
|
||||
return {
|
||||
id: f.id,
|
||||
label: f.label,
|
||||
credits: Math.round(f.credits),
|
||||
population: Math.round(f.populationTotal),
|
||||
military: gs?.militaryShipCount ?? 0,
|
||||
miners: gs?.minerShipCount ?? 0,
|
||||
transport: gs?.transportShipCount ?? 0,
|
||||
constructors: gs?.constructorShipCount ?? 0,
|
||||
systems: gs ? `${gs.controlledSystemCount} / ${gs.targetSystemCount}` : "—",
|
||||
ore: gs ? Math.round(gs.oreStockpile) : 0,
|
||||
shipsBuilt: f.shipsBuilt,
|
||||
shipsLost: f.shipsLost,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
const factionColumnHelper = createColumnHelper<FactionRow>();
|
||||
const factionColumns = [
|
||||
factionColumnHelper.accessor("label", { header: "Faction" }),
|
||||
factionColumnHelper.accessor("credits", { header: "Credits" }),
|
||||
factionColumnHelper.accessor("population", { header: "Pop" }),
|
||||
factionColumnHelper.accessor("military", { header: "Military" }),
|
||||
factionColumnHelper.accessor("miners", { header: "Miners" }),
|
||||
factionColumnHelper.accessor("transport", { header: "Transport" }),
|
||||
factionColumnHelper.accessor("constructors", { header: "Constructors" }),
|
||||
factionColumnHelper.accessor("systems", { header: "Systems" }),
|
||||
factionColumnHelper.accessor("ore", { header: "Ore" }),
|
||||
factionColumnHelper.accessor("shipsBuilt", { header: "Built" }),
|
||||
factionColumnHelper.accessor("shipsLost", { header: "Lost" }),
|
||||
];
|
||||
|
||||
const factionFilter = ref("");
|
||||
const factionSorting = ref<SortingState>([]);
|
||||
const factionOrder = useColumnOrder(["label", "credits", "population", "military", "miners", "transport", "constructors", "systems", "ore", "shipsBuilt", "shipsLost"]);
|
||||
|
||||
const factionTable = useVueTable({
|
||||
get data() { return factionRows.value; },
|
||||
columns: factionColumns,
|
||||
state: {
|
||||
get globalFilter() { return factionFilter.value; },
|
||||
get sorting() { return factionSorting.value; },
|
||||
get columnOrder() { return factionOrder.columnOrder.value; },
|
||||
},
|
||||
onGlobalFilterChange: (v) => { factionFilter.value = String(v); },
|
||||
onSortingChange: (updater) => {
|
||||
factionSorting.value = typeof updater === "function" ? updater(factionSorting.value) : updater;
|
||||
},
|
||||
onColumnOrderChange: factionOrder.onColumnOrderChange,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
});
|
||||
|
||||
// ── Row counts ─────────────────────────────────────────────────────────────
|
||||
|
||||
const tabs: { id: TabId; label: string }[] = [
|
||||
{ id: "ships", label: "Ships" },
|
||||
{ id: "stations", label: "Stations" },
|
||||
{ id: "factions", label: "Factions" },
|
||||
];
|
||||
|
||||
const activeFilter = computed({
|
||||
get: () => {
|
||||
if (activeTab.value === "ships") return shipFilter.value;
|
||||
if (activeTab.value === "stations") return stationFilter.value;
|
||||
return factionFilter.value;
|
||||
},
|
||||
set: (v: string) => {
|
||||
if (activeTab.value === "ships") shipFilter.value = v;
|
||||
else if (activeTab.value === "stations") stationFilter.value = v;
|
||||
else factionFilter.value = v;
|
||||
},
|
||||
});
|
||||
|
||||
const activeRowCount = computed(() => {
|
||||
if (activeTab.value === "ships") return shipTable.getFilteredRowModel().rows.length;
|
||||
if (activeTab.value === "stations") return stationTable.getFilteredRowModel().rows.length;
|
||||
return factionTable.getFilteredRowModel().rows.length;
|
||||
});
|
||||
|
||||
const activeTotalCount = computed(() => {
|
||||
if (activeTab.value === "ships") return gmStore.ships.length;
|
||||
if (activeTab.value === "stations") return gmStore.stations.length;
|
||||
return gmStore.factions.length;
|
||||
});
|
||||
|
||||
// ── Row interaction ────────────────────────────────────────────────────────
|
||||
|
||||
function onShipClick(row: ShipRow) {
|
||||
selectionStore.selectSelection({ id: row.id, kind: "ship", label: row.label }, "ui");
|
||||
}
|
||||
|
||||
function onShipDblClick(row: ShipRow) {
|
||||
selectionStore.selectSelection({ id: row.id, kind: "ship", label: row.label }, "ui");
|
||||
emit("focus", row.id, "ship");
|
||||
}
|
||||
|
||||
function onStationClick(row: StationRow) {
|
||||
selectionStore.selectSelection({ id: row.id, kind: "station", label: row.label }, "ui");
|
||||
}
|
||||
|
||||
function onStationDblClick(row: StationRow) {
|
||||
selectionStore.selectSelection({ id: row.id, kind: "station", label: row.label }, "ui");
|
||||
emit("focus", row.id, "station");
|
||||
}
|
||||
|
||||
function isShipSelected(id: string) {
|
||||
return selectedEntityKind.value === "ship" && selectedEntityId.value === id;
|
||||
}
|
||||
|
||||
function isStationSelected(id: string) {
|
||||
return selectedEntityKind.value === "station" && selectedEntityId.value === id;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<GmWindow
|
||||
title="Ships"
|
||||
:initial-width="980"
|
||||
:initial-height="560"
|
||||
:initial-x="80"
|
||||
:initial-y="80"
|
||||
@close="emit('close')"
|
||||
>
|
||||
<div class="flex h-full flex-col">
|
||||
<!-- Tab bar + search -->
|
||||
<div class="gm-toolbar flex shrink-0 items-center gap-3 px-3 py-2">
|
||||
<div class="flex gap-1">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.id"
|
||||
type="button"
|
||||
class="gm-tab-btn rounded px-3 py-1 text-xs font-semibold uppercase tracking-widest transition"
|
||||
:class="activeTab === tab.id ? 'gm-tab-btn--active' : ''"
|
||||
@click="activeTab = tab.id"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="relative flex-1">
|
||||
<input
|
||||
v-model="activeFilter"
|
||||
class="gm-search-input w-full rounded border py-1 pl-7 pr-7 text-xs"
|
||||
placeholder="Filter…"
|
||||
type="search"
|
||||
/>
|
||||
<svg class="pointer-events-none absolute left-2 top-1/2 -translate-y-1/2 opacity-40" width="12" height="12" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M11.742 10.344a6.5 6.5 0 1 0-1.397 1.398h-.001q.044.06.098.115l3.85 3.85a1 1 0 0 0 1.415-1.414l-3.85-3.85a1 1 0 0 0-.115-.1zM12 6.5a5.5 5.5 0 1 1-11 0 5.5 5.5 0 0 1 11 0" />
|
||||
</svg>
|
||||
<button
|
||||
v-if="activeFilter"
|
||||
type="button"
|
||||
class="absolute right-2 top-1/2 -translate-y-1/2 text-[10px] opacity-40 hover:opacity-80"
|
||||
@click="activeFilter = ''"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<span class="gm-row-count shrink-0 font-mono text-xs tabular-nums opacity-60">
|
||||
{{ activeRowCount }} / {{ activeTotalCount }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Ships table -->
|
||||
<div
|
||||
v-show="activeTab === 'ships'"
|
||||
class="gm-table-container min-h-0 flex-1 overflow-auto"
|
||||
>
|
||||
<table class="gm-table w-full min-w-max border-separate border-spacing-0 text-xs">
|
||||
<thead class="sticky top-0 z-10">
|
||||
<tr
|
||||
v-for="headerGroup in shipTable.getHeaderGroups()"
|
||||
:key="headerGroup.id"
|
||||
>
|
||||
<th
|
||||
v-for="header in headerGroup.headers"
|
||||
:key="header.id"
|
||||
class="gm-th whitespace-nowrap px-3 py-2 text-left"
|
||||
:class="[
|
||||
header.column.getCanSort() ? 'cursor-pointer' : '',
|
||||
shipOrder.draggingId.value === header.column.id ? 'gm-th--dragging' : '',
|
||||
shipOrder.overId.value === header.column.id ? 'gm-th--dragover' : '',
|
||||
]"
|
||||
draggable="true"
|
||||
@dragstart="shipOrder.onDragStart($event, header.column.id)"
|
||||
@dragover="shipOrder.onDragOver($event, header.column.id)"
|
||||
@dragleave="shipOrder.onDragLeave()"
|
||||
@drop="shipOrder.onDrop(header.column.id)"
|
||||
@dragend="shipOrder.onDragEnd()"
|
||||
@click="header.column.getToggleSortingHandler()?.($event)"
|
||||
>
|
||||
<span class="flex select-none items-center gap-1">
|
||||
<FlexRender
|
||||
:render="header.column.columnDef.header"
|
||||
:props="header.getContext()"
|
||||
/>
|
||||
<span class="text-[10px] opacity-60">
|
||||
{{ header.column.getIsSorted() === "asc" ? "↑" : header.column.getIsSorted() === "desc" ? "↓" : "" }}
|
||||
</span>
|
||||
</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="row in shipTable.getRowModel().rows"
|
||||
:key="row.id"
|
||||
class="gm-tr cursor-pointer"
|
||||
:class="isShipSelected(row.original.id) ? 'gm-tr--selected' : ''"
|
||||
@click="onShipClick(row.original)"
|
||||
@dblclick="onShipDblClick(row.original)"
|
||||
>
|
||||
<td
|
||||
v-for="cell in row.getVisibleCells()"
|
||||
:key="cell.id"
|
||||
class="gm-td whitespace-nowrap px-3 py-1.5"
|
||||
>
|
||||
<span
|
||||
v-if="cell.column.id === 'class'"
|
||||
class="gm-badge rounded px-1.5 py-0.5 font-mono text-[10px] uppercase tracking-wider"
|
||||
>
|
||||
<FlexRender :render="cell.column.columnDef.cell" :props="cell.getContext()" />
|
||||
</span>
|
||||
<FlexRender
|
||||
v-else
|
||||
:render="cell.column.columnDef.cell"
|
||||
:props="cell.getContext()"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="shipTable.getRowModel().rows.length === 0">
|
||||
<td :colspan="shipColumns.length" class="gm-td px-3 py-6 text-center opacity-40">
|
||||
No ships match the filter.
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Stations table -->
|
||||
<div
|
||||
v-show="activeTab === 'stations'"
|
||||
class="gm-table-container min-h-0 flex-1 overflow-auto"
|
||||
>
|
||||
<table class="gm-table w-full min-w-max border-separate border-spacing-0 text-xs">
|
||||
<thead class="sticky top-0 z-10">
|
||||
<tr
|
||||
v-for="headerGroup in stationTable.getHeaderGroups()"
|
||||
:key="headerGroup.id"
|
||||
>
|
||||
<th
|
||||
v-for="header in headerGroup.headers"
|
||||
:key="header.id"
|
||||
class="gm-th whitespace-nowrap px-3 py-2 text-left"
|
||||
:class="[
|
||||
header.column.getCanSort() ? 'cursor-pointer' : '',
|
||||
stationOrder.draggingId.value === header.column.id ? 'gm-th--dragging' : '',
|
||||
stationOrder.overId.value === header.column.id ? 'gm-th--dragover' : '',
|
||||
]"
|
||||
draggable="true"
|
||||
@dragstart="stationOrder.onDragStart($event, header.column.id)"
|
||||
@dragover="stationOrder.onDragOver($event, header.column.id)"
|
||||
@dragleave="stationOrder.onDragLeave()"
|
||||
@drop="stationOrder.onDrop(header.column.id)"
|
||||
@dragend="stationOrder.onDragEnd()"
|
||||
@click="header.column.getToggleSortingHandler()?.($event)"
|
||||
>
|
||||
<span class="flex select-none items-center gap-1">
|
||||
<FlexRender
|
||||
:render="header.column.columnDef.header"
|
||||
:props="header.getContext()"
|
||||
/>
|
||||
<span class="text-[10px] opacity-60">
|
||||
{{ header.column.getIsSorted() === "asc" ? "↑" : header.column.getIsSorted() === "desc" ? "↓" : "" }}
|
||||
</span>
|
||||
</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="row in stationTable.getRowModel().rows"
|
||||
:key="row.id"
|
||||
class="gm-tr cursor-pointer"
|
||||
:class="isStationSelected(row.original.id) ? 'gm-tr--selected' : ''"
|
||||
@click="onStationClick(row.original)"
|
||||
@dblclick="onStationDblClick(row.original)"
|
||||
>
|
||||
<td
|
||||
v-for="cell in row.getVisibleCells()"
|
||||
:key="cell.id"
|
||||
class="gm-td whitespace-nowrap px-3 py-1.5"
|
||||
>
|
||||
<span
|
||||
v-if="cell.column.id === 'category'"
|
||||
class="gm-badge rounded px-1.5 py-0.5 font-mono text-[10px] uppercase tracking-wider"
|
||||
>
|
||||
<FlexRender :render="cell.column.columnDef.cell" :props="cell.getContext()" />
|
||||
</span>
|
||||
<FlexRender
|
||||
v-else
|
||||
:render="cell.column.columnDef.cell"
|
||||
:props="cell.getContext()"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="stationTable.getRowModel().rows.length === 0">
|
||||
<td :colspan="stationColumns.length" class="gm-td px-3 py-6 text-center opacity-40">
|
||||
No stations match the filter.
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Factions table -->
|
||||
<div
|
||||
v-show="activeTab === 'factions'"
|
||||
class="gm-table-container min-h-0 flex-1 overflow-auto"
|
||||
>
|
||||
<table class="gm-table w-full min-w-max border-separate border-spacing-0 text-xs">
|
||||
<thead class="sticky top-0 z-10">
|
||||
<tr
|
||||
v-for="headerGroup in factionTable.getHeaderGroups()"
|
||||
:key="headerGroup.id"
|
||||
>
|
||||
<th
|
||||
v-for="header in headerGroup.headers"
|
||||
:key="header.id"
|
||||
class="gm-th whitespace-nowrap px-3 py-2 text-left"
|
||||
:class="[
|
||||
header.column.getCanSort() ? 'cursor-pointer' : '',
|
||||
factionOrder.draggingId.value === header.column.id ? 'gm-th--dragging' : '',
|
||||
factionOrder.overId.value === header.column.id ? 'gm-th--dragover' : '',
|
||||
]"
|
||||
draggable="true"
|
||||
@dragstart="factionOrder.onDragStart($event, header.column.id)"
|
||||
@dragover="factionOrder.onDragOver($event, header.column.id)"
|
||||
@dragleave="factionOrder.onDragLeave()"
|
||||
@drop="factionOrder.onDrop(header.column.id)"
|
||||
@dragend="factionOrder.onDragEnd()"
|
||||
@click="header.column.getToggleSortingHandler()?.($event)"
|
||||
>
|
||||
<span class="flex select-none items-center gap-1">
|
||||
<FlexRender
|
||||
:render="header.column.columnDef.header"
|
||||
:props="header.getContext()"
|
||||
/>
|
||||
<span class="text-[10px] opacity-60">
|
||||
{{ header.column.getIsSorted() === "asc" ? "↑" : header.column.getIsSorted() === "desc" ? "↓" : "" }}
|
||||
</span>
|
||||
</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="row in factionTable.getRowModel().rows"
|
||||
:key="row.id"
|
||||
class="gm-tr"
|
||||
>
|
||||
<td
|
||||
v-for="cell in row.getVisibleCells()"
|
||||
:key="cell.id"
|
||||
class="gm-td whitespace-nowrap px-3 py-1.5"
|
||||
>
|
||||
<FlexRender :render="cell.column.columnDef.cell" :props="cell.getContext()" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="factionTable.getRowModel().rows.length === 0">
|
||||
<td :colspan="factionColumns.length" class="gm-td px-3 py-6 text-center opacity-40">
|
||||
No factions match the filter.
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</GmWindow>
|
||||
</template>
|
||||
100
apps/viewer/src/components/gm/GmWindow.vue
Normal file
100
apps/viewer/src/components/gm/GmWindow.vue
Normal file
@@ -0,0 +1,100 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from "vue";
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
title: string;
|
||||
initialX?: number;
|
||||
initialY?: number;
|
||||
initialWidth?: number;
|
||||
initialHeight?: number;
|
||||
}>(), {
|
||||
initialX: 80,
|
||||
initialY: 80,
|
||||
initialWidth: 960,
|
||||
initialHeight: 580,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: [];
|
||||
}>();
|
||||
|
||||
const windowEl = ref<HTMLDivElement | null>(null);
|
||||
const x = ref(props.initialX);
|
||||
const y = ref(props.initialY);
|
||||
const w = ref(props.initialWidth);
|
||||
const h = ref(props.initialHeight);
|
||||
const isDragging = ref(false);
|
||||
let dragOffsetX = 0;
|
||||
let dragOffsetY = 0;
|
||||
|
||||
function onTitleMouseDown(e: MouseEvent) {
|
||||
if ((e.target as HTMLElement).closest("button")) return;
|
||||
// Snapshot current rendered size so resize isn't lost on drag
|
||||
if (windowEl.value) {
|
||||
w.value = windowEl.value.offsetWidth;
|
||||
h.value = windowEl.value.offsetHeight;
|
||||
}
|
||||
isDragging.value = true;
|
||||
dragOffsetX = e.clientX - x.value;
|
||||
dragOffsetY = e.clientY - y.value;
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
function onMouseMove(e: MouseEvent) {
|
||||
if (!isDragging.value) return;
|
||||
x.value = e.clientX - dragOffsetX;
|
||||
y.value = e.clientY - dragOffsetY;
|
||||
}
|
||||
|
||||
function onMouseUp() {
|
||||
isDragging.value = false;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener("mousemove", onMouseMove);
|
||||
window.addEventListener("mouseup", onMouseUp);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener("mousemove", onMouseMove);
|
||||
window.removeEventListener("mouseup", onMouseUp);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="windowEl"
|
||||
class="gm-window pointer-events-auto fixed flex flex-col overflow-hidden rounded-xl border"
|
||||
:style="{
|
||||
left: `${x}px`,
|
||||
top: `${y}px`,
|
||||
width: `${w}px`,
|
||||
height: `${h}px`,
|
||||
cursor: isDragging ? 'grabbing' : 'default',
|
||||
zIndex: 200,
|
||||
}"
|
||||
>
|
||||
<!-- Title bar -->
|
||||
<div
|
||||
class="gm-window-titlebar flex shrink-0 cursor-grab select-none items-center gap-2 px-4 py-2.5"
|
||||
:style="{ cursor: isDragging ? 'grabbing' : 'grab' }"
|
||||
@mousedown="onTitleMouseDown"
|
||||
>
|
||||
<span class="gm-window-title-badge mr-1 rounded px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-widest">GM</span>
|
||||
<h2 class="flex-1 font-[Space_Grotesk] text-sm font-semibold tracking-wide">{{ title }}</h2>
|
||||
<button
|
||||
type="button"
|
||||
class="gm-window-close-btn flex h-6 w-6 items-center justify-center rounded text-xs opacity-60 transition hover:opacity-100"
|
||||
aria-label="Close window"
|
||||
@click="emit('close')"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="min-h-0 flex-1 overflow-hidden">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -386,3 +386,181 @@ canvas {
|
||||
min-height: 120px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── GM Windows ──────────────────────────────────────────────────────────── */
|
||||
|
||||
.gm-window {
|
||||
backdrop-filter: blur(18px);
|
||||
background: rgba(7, 14, 27, 0.92);
|
||||
border-color: rgba(132, 196, 255, 0.18);
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(127, 214, 255, 0.06) inset,
|
||||
0 32px 72px rgba(0, 0, 0, 0.52);
|
||||
color: var(--viewer-text);
|
||||
resize: both;
|
||||
min-width: 480px;
|
||||
min-height: 240px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.gm-window-titlebar {
|
||||
background: rgba(127, 214, 255, 0.04);
|
||||
border-bottom: 1px solid rgba(132, 196, 255, 0.12);
|
||||
}
|
||||
|
||||
.gm-window-title-badge {
|
||||
background: rgba(127, 214, 255, 0.14);
|
||||
color: var(--viewer-accent);
|
||||
font-family: "IBM Plex Mono", monospace;
|
||||
}
|
||||
|
||||
.gm-window-close-btn {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
color: var(--viewer-text);
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.gm-window-close-btn:hover {
|
||||
background: rgba(255, 80, 60, 0.18);
|
||||
border-color: rgba(255, 80, 60, 0.3);
|
||||
}
|
||||
|
||||
.gm-toolbar {
|
||||
background: rgba(127, 214, 255, 0.03);
|
||||
border-bottom: 1px solid rgba(132, 196, 255, 0.1);
|
||||
}
|
||||
|
||||
.gm-tab-btn {
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
color: var(--viewer-muted);
|
||||
cursor: pointer;
|
||||
font-family: "IBM Plex Mono", monospace;
|
||||
}
|
||||
|
||||
.gm-tab-btn:hover {
|
||||
color: var(--viewer-text);
|
||||
background: rgba(127, 214, 255, 0.06);
|
||||
}
|
||||
|
||||
.gm-tab-btn--active {
|
||||
background: rgba(127, 214, 255, 0.12);
|
||||
border-color: rgba(127, 214, 255, 0.22);
|
||||
color: var(--viewer-accent);
|
||||
}
|
||||
|
||||
.gm-search-input {
|
||||
background: rgba(127, 214, 255, 0.05);
|
||||
border-color: rgba(132, 196, 255, 0.18);
|
||||
color: var(--viewer-text);
|
||||
font-family: "IBM Plex Mono", monospace;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.gm-search-input:focus {
|
||||
border-color: rgba(127, 214, 255, 0.4);
|
||||
background: rgba(127, 214, 255, 0.08);
|
||||
}
|
||||
|
||||
.gm-search-input::placeholder {
|
||||
color: var(--viewer-muted);
|
||||
}
|
||||
|
||||
.gm-row-count {
|
||||
color: var(--viewer-muted);
|
||||
font-family: "IBM Plex Mono", monospace;
|
||||
}
|
||||
|
||||
.gm-table-container {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(127, 214, 255, 0.2) transparent;
|
||||
}
|
||||
|
||||
.gm-table {
|
||||
font-family: "IBM Plex Mono", monospace;
|
||||
}
|
||||
|
||||
.gm-th {
|
||||
background: rgba(7, 14, 27, 0.98);
|
||||
color: var(--viewer-accent);
|
||||
font-size: 0.68rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
border-bottom: 1px solid rgba(132, 196, 255, 0.14);
|
||||
}
|
||||
|
||||
.gm-th:hover {
|
||||
color: var(--viewer-text);
|
||||
}
|
||||
|
||||
.gm-tr {
|
||||
border-bottom: 1px solid transparent;
|
||||
transition: background 80ms ease;
|
||||
}
|
||||
|
||||
.gm-tr:nth-child(even) .gm-td {
|
||||
background: rgba(127, 214, 255, 0.025);
|
||||
}
|
||||
|
||||
.gm-tr:hover .gm-td {
|
||||
background: rgba(127, 214, 255, 0.07);
|
||||
}
|
||||
|
||||
.gm-tr--selected .gm-td {
|
||||
background: rgba(255, 191, 105, 0.1) !important;
|
||||
border-bottom-color: rgba(255, 191, 105, 0.14);
|
||||
}
|
||||
|
||||
.gm-td {
|
||||
color: var(--viewer-muted);
|
||||
font-size: 0.72rem;
|
||||
border-bottom: 1px solid rgba(132, 196, 255, 0.06);
|
||||
}
|
||||
|
||||
.gm-td:first-child {
|
||||
color: var(--viewer-text);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.gm-badge {
|
||||
background: rgba(127, 214, 255, 0.1);
|
||||
color: var(--viewer-accent);
|
||||
}
|
||||
|
||||
.gm-th--dragging {
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
.gm-th--dragover {
|
||||
background: rgba(127, 214, 255, 0.14);
|
||||
box-shadow: inset 2px 0 0 var(--viewer-accent);
|
||||
}
|
||||
|
||||
.gm-console-toggle {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
pointer-events: auto;
|
||||
z-index: 100;
|
||||
padding: 7px 18px;
|
||||
border-radius: 999px;
|
||||
background: rgba(127, 214, 255, 0.1);
|
||||
border: 1px solid rgba(127, 214, 255, 0.24);
|
||||
color: var(--viewer-accent);
|
||||
font-family: "IBM Plex Mono", monospace;
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
transition: background 140ms ease, border-color 140ms ease;
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.gm-console-toggle:hover {
|
||||
background: rgba(127, 214, 255, 0.18);
|
||||
border-color: rgba(127, 214, 255, 0.4);
|
||||
}
|
||||
|
||||
23
apps/viewer/src/ui/stores/gmStore.ts
Normal file
23
apps/viewer/src/ui/stores/gmStore.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { defineStore } from "pinia";
|
||||
import type { ShipSnapshot } from "../../contractsShips";
|
||||
import type { StationSnapshot } from "../../contractsInfrastructure";
|
||||
import type { FactionSnapshot } from "../../contractsFactions";
|
||||
|
||||
export const useGmStore = defineStore("gm", {
|
||||
state: () => ({
|
||||
ships: [] as ShipSnapshot[],
|
||||
stations: [] as StationSnapshot[],
|
||||
factions: [] as FactionSnapshot[],
|
||||
}),
|
||||
actions: {
|
||||
updateWorld(
|
||||
ships: ShipSnapshot[],
|
||||
stations: StationSnapshot[],
|
||||
factions: FactionSnapshot[],
|
||||
) {
|
||||
this.ships = ships;
|
||||
this.stations = stations;
|
||||
this.factions = factions;
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -112,7 +112,6 @@ export interface ViewerHudState {
|
||||
export interface ViewerHudBindings {
|
||||
state: ViewerHudState;
|
||||
selectionStore: ViewerSelectionStore;
|
||||
opsStripEl: HTMLDivElement;
|
||||
historyLayerEl: HTMLDivElement;
|
||||
marqueeEl: HTMLDivElement;
|
||||
hoverLabelEl: HTMLDivElement;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { fetchWorldSnapshot, openWorldStream } from "./api";
|
||||
import type { ViewerHudState } from "./viewerHudState";
|
||||
import { buildOpsStripState } from "./viewerOpsStrip";
|
||||
import { useGmStore } from "./ui/stores/gmStore";
|
||||
import { viewerPinia } from "./ui/stores/pinia";
|
||||
import { buildDetailPanelState } from "./viewerPanels";
|
||||
import { applyDeltaToWorld, cloneFactions, createWorldState, recordDeltaStats } from "./viewerState";
|
||||
import type {
|
||||
@@ -195,6 +197,15 @@ export class ViewerWorldLifecycle {
|
||||
this.context.getPovLevel(),
|
||||
this.context.getActiveSystemId(),
|
||||
);
|
||||
|
||||
const world = this.context.getWorld();
|
||||
if (world) {
|
||||
useGmStore(viewerPinia).updateWorld(
|
||||
[...world.ships.values()],
|
||||
[...world.stations.values()],
|
||||
[...world.factions.values()],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
updatePanels() {
|
||||
|
||||
Reference in New Issue
Block a user