Files
space-game/apps/viewer/src/components/ViewerShipOrderContextMenu.vue

321 lines
9.4 KiB
Vue

<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 && !authStore.isActingAsAlternateIdentity) {
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,
anchorId: target.value.anchorId ?? 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,
anchorId: 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,
anchorId: 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,
anchorId: 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>