Refactor runtime bootstrap and ship control flows
This commit is contained in:
320
apps/viewer/src/components/ViewerShipOrderContextMenu.vue
Normal file
320
apps/viewer/src/components/ViewerShipOrderContextMenu.vue
Normal file
@@ -0,0 +1,320 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import { storeToRefs } from "pinia";
|
||||
import { enqueueShipOrder } from "../api";
|
||||
import { getShipOrderLabel, getShipOrderNotes, getShipOrderSupportStatusLabel } from "../shipAutomationPresentation";
|
||||
import { useAuthStore } from "../ui/stores/authStore";
|
||||
import { useGmStore } from "../ui/stores/gmStore";
|
||||
import { usePlayerFactionStore } from "../ui/stores/playerFactionStore";
|
||||
import { useShipAutomationCatalogStore } from "../ui/stores/shipAutomationCatalogStore";
|
||||
import { useViewerOrderContextMenuStore } from "../ui/stores/viewerOrderContextMenu";
|
||||
import { useViewerSelectionStore } from "../ui/stores/viewerSelection";
|
||||
|
||||
type MenuAction =
|
||||
| "mine-resource"
|
||||
| "fly-to-and-wait"
|
||||
| "follow"
|
||||
| "attack";
|
||||
|
||||
interface OrderMenuActionEntry {
|
||||
key: MenuAction;
|
||||
orderKind: string;
|
||||
label: string;
|
||||
detail?: string;
|
||||
supportStatus?: string | null;
|
||||
notes?: string | null;
|
||||
}
|
||||
|
||||
const rootEl = ref<HTMLElement | null>(null);
|
||||
const authStore = useAuthStore();
|
||||
const gmStore = useGmStore();
|
||||
const playerStore = usePlayerFactionStore();
|
||||
const automationStore = useShipAutomationCatalogStore();
|
||||
const selectionStore = useViewerSelectionStore();
|
||||
const orderMenuStore = useViewerOrderContextMenuStore();
|
||||
|
||||
const { playerFaction } = storeToRefs(playerStore);
|
||||
const { selectedEntityId, selectedEntityKind } = storeToRefs(selectionStore);
|
||||
const { isOpen, x, y, target } = storeToRefs(orderMenuStore);
|
||||
|
||||
const selectedShip = computed(() => {
|
||||
if (selectedEntityKind.value !== "ship" || !selectedEntityId.value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return gmStore.ships.find((ship) => ship.id === selectedEntityId.value) ?? null;
|
||||
});
|
||||
|
||||
const canControlSelectedShip = computed(() => {
|
||||
if (!selectedShip.value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (authStore.canAccessGm) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!playerFaction.value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return playerFaction.value.assetRegistry.shipIds.includes(selectedShip.value.id);
|
||||
});
|
||||
|
||||
function emptyActions(): OrderMenuActionEntry[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
const actions = computed<OrderMenuActionEntry[]>(() => {
|
||||
if (!target.value || !selectedShip.value || !canControlSelectedShip.value) {
|
||||
return emptyActions();
|
||||
}
|
||||
|
||||
switch (target.value.selection.kind) {
|
||||
case "node":
|
||||
return [{
|
||||
key: "mine-resource",
|
||||
orderKind: "mine-and-deliver",
|
||||
label: getShipOrderLabel("mine-and-deliver"),
|
||||
detail: `${target.value.itemId ?? "resource"} · ${target.value.systemId ?? selectedShip.value.systemId}`,
|
||||
supportStatus: getShipOrderSupportStatusLabel("mine-and-deliver"),
|
||||
notes: getShipOrderNotes("mine-and-deliver"),
|
||||
}];
|
||||
case "ship":
|
||||
if (target.value.selection.id === selectedShip.value.id) {
|
||||
return emptyActions();
|
||||
}
|
||||
return [
|
||||
{
|
||||
key: "follow",
|
||||
orderKind: "follow-ship",
|
||||
label: getShipOrderLabel("follow-ship"),
|
||||
detail: target.value.label,
|
||||
supportStatus: getShipOrderSupportStatusLabel("follow-ship"),
|
||||
notes: getShipOrderNotes("follow-ship"),
|
||||
},
|
||||
{
|
||||
key: "attack",
|
||||
orderKind: "attack-target",
|
||||
label: getShipOrderLabel("attack-target"),
|
||||
detail: target.value.label,
|
||||
supportStatus: getShipOrderSupportStatusLabel("attack-target"),
|
||||
notes: getShipOrderNotes("attack-target"),
|
||||
},
|
||||
];
|
||||
case "station":
|
||||
case "celestial":
|
||||
case "construction-site":
|
||||
return [{
|
||||
key: "fly-to-and-wait",
|
||||
orderKind: "fly-and-wait",
|
||||
label: getShipOrderLabel("fly-and-wait"),
|
||||
detail: target.value.label,
|
||||
supportStatus: getShipOrderSupportStatusLabel("fly-and-wait"),
|
||||
notes: getShipOrderNotes("fly-and-wait"),
|
||||
}];
|
||||
case "system":
|
||||
return emptyActions();
|
||||
default:
|
||||
return emptyActions();
|
||||
}
|
||||
});
|
||||
|
||||
const menuStyle = computed(() => ({
|
||||
left: `${Math.min(x.value, window.innerWidth - 280)}px`,
|
||||
top: `${Math.min(y.value, window.innerHeight - 240)}px`,
|
||||
}));
|
||||
|
||||
watch([selectedEntityKind, selectedEntityId], () => {
|
||||
if (isOpen.value && selectedEntityKind.value !== "ship") {
|
||||
orderMenuStore.close();
|
||||
}
|
||||
});
|
||||
|
||||
function closeMenu() {
|
||||
orderMenuStore.close();
|
||||
}
|
||||
|
||||
async function runAction(action: MenuAction) {
|
||||
if (!selectedShip.value || !target.value || !canControlSelectedShip.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "mine-resource") {
|
||||
const itemId = target.value.itemId;
|
||||
if (!itemId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ship = await enqueueShipOrder(selectedShip.value.id, {
|
||||
kind: "mine-and-deliver",
|
||||
priority: 100,
|
||||
interruptCurrentPlan: true,
|
||||
label: `Mine ${itemId} in ${target.value.systemId ?? selectedShip.value.systemId}`,
|
||||
targetEntityId: null,
|
||||
targetSystemId: target.value.systemId ?? selectedShip.value.systemId,
|
||||
targetPosition: null,
|
||||
sourceStationId: null,
|
||||
destinationStationId: null,
|
||||
itemId,
|
||||
nodeId: target.value.selection.kind === "node" ? target.value.selection.id : null,
|
||||
constructionSiteId: null,
|
||||
moduleId: null,
|
||||
waitSeconds: 0,
|
||||
radius: 0,
|
||||
maxSystemRange: 0,
|
||||
knownStationsOnly: false,
|
||||
});
|
||||
gmStore.upsertShip(ship);
|
||||
closeMenu();
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "fly-to-and-wait") {
|
||||
const ship = await enqueueShipOrder(selectedShip.value.id, {
|
||||
kind: "fly-and-wait",
|
||||
priority: 100,
|
||||
interruptCurrentPlan: true,
|
||||
label: `Fly to ${target.value.label}`,
|
||||
targetEntityId: null,
|
||||
targetSystemId: target.value.systemId ?? selectedShip.value.systemId,
|
||||
targetPosition: target.value.targetPosition ?? null,
|
||||
sourceStationId: null,
|
||||
destinationStationId: null,
|
||||
itemId: null,
|
||||
nodeId: null,
|
||||
constructionSiteId: null,
|
||||
moduleId: null,
|
||||
waitSeconds: 8,
|
||||
radius: 0,
|
||||
maxSystemRange: 0,
|
||||
knownStationsOnly: false,
|
||||
});
|
||||
gmStore.upsertShip(ship);
|
||||
closeMenu();
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "follow") {
|
||||
const ship = await enqueueShipOrder(selectedShip.value.id, {
|
||||
kind: "follow-ship",
|
||||
priority: 100,
|
||||
interruptCurrentPlan: true,
|
||||
label: `Follow ${target.value.label}`,
|
||||
targetEntityId: target.value.selection.kind === "ship" ? target.value.selection.id : null,
|
||||
targetSystemId: target.value.systemId ?? null,
|
||||
targetPosition: null,
|
||||
sourceStationId: null,
|
||||
destinationStationId: null,
|
||||
itemId: null,
|
||||
nodeId: null,
|
||||
constructionSiteId: null,
|
||||
moduleId: null,
|
||||
waitSeconds: 6,
|
||||
radius: 22,
|
||||
maxSystemRange: 0,
|
||||
knownStationsOnly: false,
|
||||
});
|
||||
gmStore.upsertShip(ship);
|
||||
closeMenu();
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "attack") {
|
||||
const ship = await enqueueShipOrder(selectedShip.value.id, {
|
||||
kind: "attack-target",
|
||||
priority: 100,
|
||||
interruptCurrentPlan: true,
|
||||
label: `Attack ${target.value.label}`,
|
||||
targetEntityId: target.value.selection.kind === "ship" ? target.value.selection.id : null,
|
||||
targetSystemId: target.value.systemId ?? null,
|
||||
targetPosition: null,
|
||||
sourceStationId: null,
|
||||
destinationStationId: null,
|
||||
itemId: null,
|
||||
nodeId: null,
|
||||
constructionSiteId: null,
|
||||
moduleId: null,
|
||||
waitSeconds: 0,
|
||||
radius: 0,
|
||||
maxSystemRange: 0,
|
||||
knownStationsOnly: false,
|
||||
});
|
||||
gmStore.upsertShip(ship);
|
||||
closeMenu();
|
||||
}
|
||||
}
|
||||
|
||||
function onActionClick(action: { key: MenuAction }) {
|
||||
void runAction(action.key);
|
||||
}
|
||||
|
||||
function onWindowPointerDown(event: PointerEvent) {
|
||||
if (!isOpen.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (rootEl.value?.contains(event.target as Node)) {
|
||||
return;
|
||||
}
|
||||
|
||||
closeMenu();
|
||||
}
|
||||
|
||||
function onWindowKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") {
|
||||
closeMenu();
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void automationStore.load();
|
||||
window.addEventListener("pointerdown", onWindowPointerDown, true);
|
||||
window.addEventListener("keydown", onWindowKeyDown);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener("pointerdown", onWindowPointerDown, true);
|
||||
window.removeEventListener("keydown", onWindowKeyDown);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
v-if="isOpen && target"
|
||||
ref="rootEl"
|
||||
class="viewer-order-context-menu pointer-events-auto"
|
||||
:style="menuStyle"
|
||||
@pointerdown.stop
|
||||
@contextmenu.prevent.stop
|
||||
>
|
||||
<header class="viewer-order-context-menu__header">
|
||||
<div class="viewer-order-context-menu__kicker">Direct Order</div>
|
||||
<h4>{{ target.label }}</h4>
|
||||
<p v-if="selectedShip">Selected ship: {{ selectedShip.name }}</p>
|
||||
</header>
|
||||
|
||||
<div v-if="!selectedShip" class="viewer-order-context-menu__empty">
|
||||
Select a ship first.
|
||||
</div>
|
||||
<div v-else-if="!canControlSelectedShip" class="viewer-order-context-menu__empty">
|
||||
Direct orders are only available for player-owned ships or GM users.
|
||||
</div>
|
||||
<div v-else-if="actions.length === 0" class="viewer-order-context-menu__empty">
|
||||
No direct actions for this target yet.
|
||||
</div>
|
||||
<div v-else class="viewer-order-context-menu__actions">
|
||||
<button
|
||||
v-for="action in actions"
|
||||
:key="action.key"
|
||||
type="button"
|
||||
class="viewer-order-context-menu__action"
|
||||
@click="onActionClick(action)"
|
||||
>
|
||||
<span>{{ action.label }}</span>
|
||||
<strong v-if="action.detail">{{ action.detail }}</strong>
|
||||
<small v-if="action.supportStatus || action.notes">{{ [action.supportStatus, action.notes].filter(Boolean).join(" · ") }}</small>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
Reference in New Issue
Block a user