diff --git a/apps/viewer/package-lock.json b/apps/viewer/package-lock.json index 8ad73b2..f8b5489 100644 --- a/apps/viewer/package-lock.json +++ b/apps/viewer/package-lock.json @@ -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", diff --git a/apps/viewer/package.json b/apps/viewer/package.json index dda30c0..f20ce9a 100644 --- a/apps/viewer/package.json +++ b/apps/viewer/package.json @@ -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" diff --git a/apps/viewer/src/App.vue b/apps/viewer/src/App.vue index b4e1878..a6c2742 100644 --- a/apps/viewer/src/App.vue +++ b/apps/viewer/src/App.vue @@ -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(null); -const opsStripHostEl = ref(null); const historyLayerHostEl = ref(null); const marqueeEl = ref(null); const hoverLabelEl = ref(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 /> -
- -
+ + +
+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([...initialIds]); + const draggingId = ref(null); + const overId = ref(null); + + function onColumnOrderChange(updater: Updater) { + 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("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(() => + 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(); +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([]); +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(() => + 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(); +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([]); +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(() => + 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(); +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([]); +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; +} + + + diff --git a/apps/viewer/src/components/gm/GmWindow.vue b/apps/viewer/src/components/gm/GmWindow.vue new file mode 100644 index 0000000..c14f043 --- /dev/null +++ b/apps/viewer/src/components/gm/GmWindow.vue @@ -0,0 +1,100 @@ + + + diff --git a/apps/viewer/src/styles/viewer.css b/apps/viewer/src/styles/viewer.css index 959a8d2..7f68e37 100644 --- a/apps/viewer/src/styles/viewer.css +++ b/apps/viewer/src/styles/viewer.css @@ -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); +} diff --git a/apps/viewer/src/ui/stores/gmStore.ts b/apps/viewer/src/ui/stores/gmStore.ts new file mode 100644 index 0000000..e97d3a0 --- /dev/null +++ b/apps/viewer/src/ui/stores/gmStore.ts @@ -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; + }, + }, +}); diff --git a/apps/viewer/src/viewerHudState.ts b/apps/viewer/src/viewerHudState.ts index 9746b69..54703d7 100644 --- a/apps/viewer/src/viewerHudState.ts +++ b/apps/viewer/src/viewerHudState.ts @@ -112,7 +112,6 @@ export interface ViewerHudState { export interface ViewerHudBindings { state: ViewerHudState; selectionStore: ViewerSelectionStore; - opsStripEl: HTMLDivElement; historyLayerEl: HTMLDivElement; marqueeEl: HTMLDivElement; hoverLabelEl: HTMLDivElement; diff --git a/apps/viewer/src/viewerWorldLifecycle.ts b/apps/viewer/src/viewerWorldLifecycle.ts index 9ea306e..f7d0a4f 100644 --- a/apps/viewer/src/viewerWorldLifecycle.ts +++ b/apps/viewer/src/viewerWorldLifecycle.ts @@ -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() {