Refine ship orders and viewer controls

This commit is contained in:
2026-04-09 12:42:52 -04:00
parent 6c92ab50c8
commit 8503855a4c
64 changed files with 2939 additions and 2037 deletions

View File

@@ -1,7 +1,11 @@
import * as THREE from "three";
import {
LOCAL_CAMERA_DISTANCE_AT_MIN_ZOOM,
LOCAL_CAMERA_DISTANCE_AT_TRANSITION,
LOCAL_SYSTEM_BACKDROP_DISTANCE,
MAX_CAMERA_DISTANCE,
MIN_CAMERA_DISTANCE,
MIN_LOCAL_CAMERA_DISTANCE,
NAV_DISTANCE,
} from "./viewerConstants";
import { updatePanFromKeyboard } from "./viewerCamera";
@@ -30,6 +34,7 @@ import { SystemLayer } from "./viewerSystemLayer";
import { LocalLayer } from "./viewerLocalLayer";
import type { HistoryWindowState, ViewerHudBindings, ViewerHudState } from "./viewerHudState";
import { describeSelectable } from "./viewerSelection";
import { resolveLocalAnchorOffset } from "./viewerWorldPresentation";
import { entityIdToSelectable, selectionToEntityId, type ViewerSelectionStore } from "./ui/stores/viewerSelection";
import { useViewerSceneStore } from "./ui/stores/viewerScene";
import { useViewerOrderContextMenuStore } from "./ui/stores/viewerOrderContextMenu";
@@ -88,6 +93,7 @@ export class ViewerAppController {
private selectedItems: Selectable[] = [];
private worldSignature = "";
private povLevel: PovLevel = "system";
private previousPovLevel: PovLevel = "system";
private currentDistance = NAV_DISTANCE.system;
private desiredDistance = NAV_DISTANCE.system;
private orbitYaw = -2.3;
@@ -100,6 +106,7 @@ export class ViewerAppController {
private marqueeActive = false;
private suppressClickSelection = false;
private activeSystemId?: string;
private cameraFocusedAnchorId?: string;
private cameraTargetShipId?: string;
private readonly followCameraPosition = new THREE.Vector3();
private readonly followCameraFocus = new THREE.Vector3();
@@ -262,15 +269,34 @@ export class ViewerAppController {
});
}
private computeOrbitOffset(): THREE.Vector3 {
const horizontalDistance = this.currentDistance * Math.cos(this.orbitPitch);
private computeOrbitOffset(cameraDistance: number): THREE.Vector3 {
const horizontalDistance = cameraDistance * Math.cos(this.orbitPitch);
return new THREE.Vector3(
Math.cos(this.orbitYaw) * horizontalDistance,
this.currentDistance * Math.sin(this.orbitPitch),
cameraDistance * Math.sin(this.orbitPitch),
Math.sin(this.orbitYaw) * horizontalDistance,
);
}
private resolveLocalOrbitCameraDistance() {
const clamped = THREE.MathUtils.clamp(this.currentDistance, MIN_LOCAL_CAMERA_DISTANCE, 650);
return THREE.MathUtils.mapLinear(
clamped,
MIN_LOCAL_CAMERA_DISTANCE,
650,
LOCAL_CAMERA_DISTANCE_AT_MIN_ZOOM,
LOCAL_CAMERA_DISTANCE_AT_TRANSITION,
);
}
private resolveSystemOrbitCameraDistance() {
if (this.povLevel !== "local") {
return this.currentDistance;
}
return LOCAL_SYSTEM_BACKDROP_DISTANCE;
}
private updateCamera(delta: number) {
const nextState = stepCamera({
currentDistance: this.currentDistance,
@@ -279,6 +305,7 @@ export class ViewerAppController {
delta,
});
this.currentDistance = nextState.currentDistance;
this.previousPovLevel = this.povLevel;
this.povLevel = nextState.povLevel;
this.orbitPitch = nextState.orbitPitch;
if (this.sceneStore.povLevel !== this.povLevel) {
@@ -286,27 +313,29 @@ export class ViewerAppController {
}
this.navigationController.updateActiveSystem();
this.navigationController.syncGalaxyAnchorToActiveSystem();
this.updateCameraFocusedAnchor();
if (this.cameraMode === "follow" && this.navigationController.updateFollowCamera(delta)) {
// Follow camera directly controls systemLayer.camera in updateFollowCamera.
// Still update galaxy camera independently.
const orbitOffset = this.computeOrbitOffset();
this.galaxyLayer.updateCamera(this.galaxyAnchor, orbitOffset);
const systemOrbitOffset = this.computeOrbitOffset(this.resolveSystemOrbitCameraDistance());
this.galaxyLayer.updateCamera(this.galaxyAnchor, systemOrbitOffset);
return;
}
this.updatePanFromKeyboard(delta);
this.orbitPitch = THREE.MathUtils.clamp(this.orbitPitch, 0.92, 1.32);
const orbitOffset = this.computeOrbitOffset();
const systemOrbitOffset = this.computeOrbitOffset(this.resolveSystemOrbitCameraDistance());
const localOrbitOffset = this.computeOrbitOffset(this.resolveLocalOrbitCameraDistance());
this.galaxyLayer.updateCamera(this.galaxyAnchor, orbitOffset);
this.galaxyLayer.updateCamera(this.galaxyAnchor, systemOrbitOffset);
if (this.activeSystemId) {
this.systemLayer.updateCamera(getSystemCameraFocus(this.systemAnchor), orbitOffset);
this.systemLayer.updateCamera(getSystemCameraFocus(this.systemAnchor), systemOrbitOffset);
}
this.localLayer.updateCamera(orbitOffset);
this.localLayer.updateCamera(this.systemAnchor, localOrbitOffset, resolveLocalAnchorOffset(this.world, this.resolveFocusedAnchorId()));
// Update star dot scales in galaxy scene
updateSystemStarPresentation(
@@ -353,7 +382,48 @@ export class ViewerAppController {
}
private resolveFocusedAnchorId() {
return resolveFocusedAnchorId(this.world, this.selectedItems);
return this.cameraFocusedAnchorId;
}
private updateCameraFocusedAnchor() {
if (!this.world || !this.activeSystemId || this.povLevel === "galaxy") {
this.cameraFocusedAnchorId = undefined;
return;
}
if (this.povLevel === "system") {
this.cameraFocusedAnchorId = this.resolveNearestAnchorToSystemFocus();
return;
}
if (this.previousPovLevel !== "local" || !this.cameraFocusedAnchorId) {
this.cameraFocusedAnchorId = this.resolveNearestAnchorToSystemFocus() ?? this.cameraFocusedAnchorId;
}
}
private resolveNearestAnchorToSystemFocus() {
if (!this.world || !this.activeSystemId) {
return undefined;
}
let bestAnchorId: string | undefined;
let bestDistance = Number.POSITIVE_INFINITY;
for (const anchor of this.world.anchors.values()) {
if (anchor.systemId !== this.activeSystemId) {
continue;
}
const dx = anchor.systemPosition.x - this.systemAnchor.x;
const dy = anchor.systemPosition.y - this.systemAnchor.y;
const dz = anchor.systemPosition.z - this.systemAnchor.z;
const distanceSquared = (dx * dx) + (dy * dy) + (dz * dz);
if (distanceSquared < bestDistance) {
bestDistance = distanceSquared;
bestAnchorId = anchor.id;
}
}
return bestAnchorId;
}
private onResize(width: number, height: number) {

View File

@@ -23,6 +23,7 @@ import type {
import type {
ShipDefaultBehaviorCommandRequest,
ShipOrderCommandRequest,
ShipOrderUpdateCommandRequest,
} from "./shipCommands";
export interface WorldStreamScope {
@@ -318,3 +319,11 @@ export async function removeShipOrder(shipId: string, orderId: string) {
method: "DELETE",
});
}
export async function updateShipOrder(shipId: string, orderId: string, request: ShipOrderUpdateCommandRequest) {
return fetchJson<ShipSnapshot>(`/api/ships/${shipId}/orders/${orderId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(request),
});
}

View File

@@ -160,11 +160,11 @@ function shipAiStates(ship: ShipSnapshot) {
const travelToken = ship.spatialState.transit ? "TRV" : "";
const dockToken = ship.dockedStationId ? "DCK" : "";
const behaviorToken = compactLabel(getShipBehaviorLabel(ship.defaultBehavior.kind), "AUTO");
const planToken = ship.activePlan?.steps.length ? "PLAN" : "";
const taskToken = ship.activeSubTasks.length > 0 ? "TSK" : "";
const orderToken = ship.orderQueue.length > 0 ? "ORD" : "";
const commandToken = ship.commanderId ? "CMD" : "";
return uniqueTokens([behaviorToken, orderToken, planToken, travelToken, dockToken, commandToken]).slice(0, 5);
return uniqueTokens([behaviorToken, orderToken, taskToken, travelToken, dockToken, commandToken]).slice(0, 5);
}
function stationAiStates(station: StationSnapshot) {

View File

@@ -2,7 +2,8 @@
import { computed, reactive, ref, watch } from "vue";
import { storeToRefs } from "pinia";
import modulesData from "../../../../shared/data/modules.json";
import { enqueueShipOrder, removeShipOrder, updateShipDefaultBehavior } from "../api";
import { removeShipOrder, updateShipDefaultBehavior, updateShipOrder } from "../api";
import type { ShipOrderSnapshot } from "../contractsShips";
import {
formatShipAutomationSupportStatus,
getShipBehaviorLabel,
@@ -43,16 +44,23 @@ const behaviorForm = reactive({
areaSystemId: "",
itemId: "ore",
});
const mineOrderForm = reactive({
systemId: "",
itemId: "ore",
});
const moveOrderSystemId = ref("");
const actionBusy = ref(false);
const actionStatus = ref("");
const actionError = ref("");
const expandedDirectOrderId = ref<string | null>(null);
const orderEditForm = reactive({
label: "",
priority: "100",
interruptCurrentPlan: true,
targetSystemId: "",
targetEntityId: "",
itemId: "",
waitSeconds: "0",
radius: "0",
maxSystemRange: "",
knownStationsOnly: false,
});
const moduleNameById = new Map<string, string>(
(modulesData as { id: string; name: string }[]).map((module) => [module.id, module.name]),
@@ -92,6 +100,34 @@ function joinDetail(parts: Array<string | null | undefined>) {
return parts.filter((part): part is string => !!part && part.trim().length > 0).join(" · ");
}
function describeOrderFailure(order: {
failureReason?: string | null;
kind: string;
itemId?: string | null;
}) {
switch (order.failureReason) {
case "mine-order-node-missing":
return `Cannot find ${order.itemId ?? "resource"} to mine`;
case "mine-order-item-missing":
return "No mining ware selected";
case "mine-order-node-system-mismatch":
return "Selected mining target is in the wrong system";
case "mine-order-node-item-mismatch":
return `Selected mining target does not provide ${order.itemId ?? "the requested ware"}`;
case "mine-order-incomplete":
case "mine-and-deliver-order-incomplete":
return `Cannot complete ${getShipOrderLabel(order.kind).toLowerCase()}`;
case "target-ship-missing":
return "Target ship no longer exists";
case "target-missing":
return "Target no longer exists";
case "station-missing":
return "Station no longer exists";
default:
return order.failureReason ? titleCase(order.failureReason) : null;
}
}
function describeOrderTarget(order: {
itemId?: string | null;
targetEntityId?: string | null;
@@ -161,11 +197,7 @@ const canDirectControlSelectedShip = computed(() =>
);
const directOrders = computed(() =>
selectedShip.value?.orderQueue.filter((order) => order.sourceKind !== "behavior") ?? [],
);
const behaviorOrders = computed(() =>
selectedShip.value?.orderQueue.filter((order) => order.sourceKind === "behavior") ?? [],
selectedShip.value?.orderQueue.filter((order) => order.sourceKind === "player") ?? [],
);
const editableBehaviorDefinitions = computed(() =>
@@ -189,6 +221,10 @@ const formBehaviorNotes = computed(() =>
getShipBehaviorNotes(behaviorForm.kind),
);
const behaviorGeneratedOrderCount = computed(() =>
selectedShip.value?.orderQueue.filter((order) => order.sourceKind === "behavior").length ?? 0,
);
const shipStatusRows = computed(() => {
if (!selectedShip.value) {
return [];
@@ -206,9 +242,9 @@ const shipStatusRows = computed(() => {
{ label: "Control", value: titleCase(selectedShip.value.controlSourceKind) },
{ label: "Assignment", value: selectedShip.value.assignment?.kind ?? "unassigned" },
{
label: "Plan",
value: selectedShip.value.activePlan
? `${selectedShip.value.activePlan.kind} · ${titleCase(selectedShip.value.activePlan.status)}`
label: "Activity",
value: selectedShip.value.activeSubTasks[0]
? `${selectedShip.value.activeSubTasks[0].summary || titleCase(selectedShip.value.activeSubTasks[0].kind)} · ${titleCase(selectedShip.value.activeSubTasks[0].status)}`
: "none",
},
{ label: "Failure", value: selectedShip.value.lastAccessFailureReason ?? "none" },
@@ -260,67 +296,33 @@ const shipBehaviorRows = computed(() => {
const directOrderRows = computed(() =>
directOrders.value.map((order) => ({
id: order.id,
kind: order.kind,
label: getShipOrderLabel(order.kind),
status: titleCase(order.status),
target: describeOrderTarget(order),
detail: joinDetail([
`P${order.priority}`,
titleCase(order.sourceKind),
order.failureReason ?? undefined,
describeOrderFailure(order) ?? undefined,
]),
})),
);
const behaviorOrderRows = computed(() =>
behaviorOrders.value.map((order) => ({
id: order.id,
label: getShipOrderLabel(order.kind),
status: titleCase(order.status),
target: describeOrderTarget(order),
const shipPlanRows = computed(() =>
(selectedShip.value?.activeSubTasks ?? []).map((subTask) => ({
id: subTask.id,
scope: "Task",
activity: subTask.summary || titleCase(subTask.kind),
status: titleCase(subTask.status),
detail: joinDetail([
`P${order.priority}`,
getShipOrderSupportStatusLabel(order.kind) ?? undefined,
getShipOrderNotes(order.kind) ?? undefined,
order.failureReason ?? undefined,
describeSubTaskTarget(subTask),
subTask.blockingReason ?? undefined,
`${Math.round(subTask.progress * 100)}%`,
]),
isSubTask: false,
})),
);
const shipPlanRows = computed(() => {
if (!selectedShip.value?.activePlan) {
return [];
}
return selectedShip.value.activePlan.steps.flatMap((step) => {
const stepRow = {
id: step.id,
scope: "Step",
activity: step.summary || titleCase(step.kind),
status: titleCase(step.status),
detail: joinDetail([
step.blockingReason ?? undefined,
`${step.subTasks.length} subtasks`,
]),
isSubTask: false,
};
const subTaskRows = step.subTasks.map((subTask) => ({
id: subTask.id,
scope: "Subtask",
activity: subTask.summary || titleCase(subTask.kind),
status: titleCase(subTask.status),
detail: joinDetail([
describeSubTaskTarget(subTask),
subTask.blockingReason ?? undefined,
`${Math.round(subTask.progress * 100)}%`,
]),
isSubTask: true,
}));
return [stepRow, ...subTaskRows];
});
});
const stationStatusRows = computed(() => {
if (!selectedStation.value) {
return [];
@@ -397,15 +399,116 @@ watch(
behaviorForm.kind = ship.defaultBehavior.kind;
behaviorForm.areaSystemId = ship.defaultBehavior.areaSystemId ?? ship.systemId ?? "";
behaviorForm.itemId = ship.defaultBehavior.itemId ?? "ore";
mineOrderForm.systemId = ship.systemId ?? "";
mineOrderForm.itemId = "ore";
moveOrderSystemId.value = ship.systemId ?? "";
actionStatus.value = "";
actionError.value = "";
expandedDirectOrderId.value = null;
},
{ immediate: true },
);
function supportsOrderField(kind: string, field: "targetSystemId" | "targetEntityId" | "itemId" | "waitSeconds" | "radius" | "maxSystemRange" | "knownStationsOnly") {
switch (field) {
case "targetSystemId":
return kind === "move" || kind === "mine-and-deliver";
case "targetEntityId":
return kind === "follow-ship" || kind === "attack-target";
case "itemId":
return kind === "mine-and-deliver";
case "waitSeconds":
return kind === "hold-position" || kind === "follow-ship";
case "radius":
return kind === "move" || kind === "follow-ship";
case "maxSystemRange":
return kind === "mine-and-deliver";
case "knownStationsOnly":
return kind === "mine-and-deliver";
default:
return false;
}
}
function loadOrderEditor(order: ShipOrderSnapshot) {
orderEditForm.label = order.label ?? "";
orderEditForm.priority = String(order.priority);
orderEditForm.interruptCurrentPlan = order.interruptCurrentPlan;
orderEditForm.targetSystemId = order.targetSystemId ?? "";
orderEditForm.targetEntityId = order.targetEntityId ?? "";
orderEditForm.itemId = order.itemId ?? "ore";
orderEditForm.waitSeconds = String(order.waitSeconds ?? 0);
orderEditForm.radius = String(order.radius ?? 0);
orderEditForm.maxSystemRange = order.maxSystemRange == null ? "" : String(order.maxSystemRange);
orderEditForm.knownStationsOnly = order.knownStationsOnly;
}
function toggleOrderEditor(order: ShipOrderSnapshot) {
if (expandedDirectOrderId.value === order.id) {
expandedDirectOrderId.value = null;
return;
}
loadOrderEditor(order);
expandedDirectOrderId.value = order.id;
}
function parseNumber(value: string, fallback: number) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
}
function parseOptionalInt(value: string) {
const trimmed = value.trim();
if (!trimmed) {
return null;
}
const parsed = Number.parseInt(trimmed, 10);
return Number.isFinite(parsed) ? parsed : null;
}
async function saveOrder(order: ShipOrderSnapshot) {
if (!selectedShip.value || !canDirectControlSelectedShip.value) {
return;
}
await runShipAction(async () => {
const ship = await updateShipOrder(selectedShip.value!.id, order.id, {
kind: order.kind,
priority: Math.max(0, Math.round(parseNumber(orderEditForm.priority, order.priority))),
interruptCurrentPlan: orderEditForm.interruptCurrentPlan,
label: orderEditForm.label.trim() || null,
targetEntityId: supportsOrderField(order.kind, "targetEntityId")
? (orderEditForm.targetEntityId.trim() || null)
: order.targetEntityId ?? null,
targetSystemId: supportsOrderField(order.kind, "targetSystemId")
? (orderEditForm.targetSystemId.trim() || null)
: order.targetSystemId ?? null,
targetPosition: order.targetPosition ?? null,
sourceStationId: order.sourceStationId ?? null,
destinationStationId: order.destinationStationId ?? null,
itemId: supportsOrderField(order.kind, "itemId")
? (orderEditForm.itemId.trim() || null)
: order.itemId ?? null,
anchorId: order.anchorId ?? null,
constructionSiteId: order.constructionSiteId ?? null,
moduleId: order.moduleId ?? null,
waitSeconds: supportsOrderField(order.kind, "waitSeconds")
? parseNumber(orderEditForm.waitSeconds, order.waitSeconds)
: order.waitSeconds,
radius: supportsOrderField(order.kind, "radius")
? parseNumber(orderEditForm.radius, order.radius)
: order.radius,
maxSystemRange: supportsOrderField(order.kind, "maxSystemRange")
? parseOptionalInt(orderEditForm.maxSystemRange)
: order.maxSystemRange ?? null,
knownStationsOnly: supportsOrderField(order.kind, "knownStationsOnly")
? orderEditForm.knownStationsOnly
: order.knownStationsOnly,
});
gmStore.upsertShip(ship);
expandedDirectOrderId.value = null;
}, "Order updated.");
}
function focusShip(cameraMode?: "follow" | "tactical") {
if (!selectedShip.value) {
return;
@@ -468,114 +571,6 @@ async function saveBehavior() {
}, "Default behavior updated.");
}
async function queueHoldPositionOrder() {
if (!selectedShip.value || !canDirectControlSelectedShip.value) {
return;
}
await runShipAction(async () => {
const ship = await enqueueShipOrder(selectedShip.value!.id, {
kind: "hold-position",
priority: 100,
interruptCurrentPlan: true,
label: "Hold position",
targetEntityId: null,
targetSystemId: 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);
}, "Hold position order queued.");
}
async function queueMoveOrder() {
if (!selectedShip.value || !canDirectControlSelectedShip.value) {
return;
}
const targetSystemId = moveOrderSystemId.value.trim();
if (!targetSystemId) {
actionError.value = "Select a target system.";
actionStatus.value = "";
return;
}
await runShipAction(async () => {
const ship = await enqueueShipOrder(selectedShip.value!.id, {
kind: "move",
priority: 90,
interruptCurrentPlan: true,
label: `Move to ${targetSystemId}`,
targetEntityId: null,
targetSystemId,
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);
}, "Move order queued.");
}
async function queueMineResourceOrder() {
if (!selectedShip.value || !canDirectControlSelectedShip.value) {
return;
}
const targetSystemId = mineOrderForm.systemId.trim() || selectedShip.value.systemId;
const itemId = mineOrderForm.itemId.trim();
if (!targetSystemId) {
actionError.value = "Select a mining system.";
actionStatus.value = "";
return;
}
if (!itemId) {
actionError.value = "Select a ware to mine.";
actionStatus.value = "";
return;
}
await runShipAction(async () => {
const ship = await enqueueShipOrder(selectedShip.value!.id, {
kind: "mine-and-deliver",
priority: 95,
interruptCurrentPlan: true,
label: `Mine ${itemId} in ${targetSystemId}`,
targetEntityId: null,
targetSystemId,
targetPosition: null,
sourceStationId: null,
destinationStationId: null,
itemId,
anchorId: null,
constructionSiteId: null,
moduleId: null,
waitSeconds: 0,
radius: 0,
maxSystemRange: 0,
knownStationsOnly: false,
});
gmStore.upsertShip(ship);
}, "Mine Resource order queued.");
}
async function removeOrder(orderId: string) {
if (!selectedShip.value || !canDirectControlSelectedShip.value) {
return;
@@ -632,43 +627,114 @@ async function clearOrders() {
</div>
<div class="entity-inspector-section">
<h4>Cargo</h4>
<div class="entity-inspector-capacity-list">
<div v-for="row in shipCargoBarRows" :key="row.key" class="entity-inspector-capacity">
<div class="entity-inspector-capacity__header">
<span class="entity-inspector-capacity__label">{{ row.label }}</span>
<span class="entity-inspector-capacity__value">{{ row.valueLabel }} / {{ row.maxLabel }}</span>
</div>
<div class="entity-inspector-capacity__scale">
<span>0</span>
<div class="entity-inspector-capacity__track">
<div class="entity-inspector-capacity__fill" :style="{ width: `${Math.max(0, Math.min(100, row.fillRatio * 100))}%` }"></div>
<h4>Order Queue</h4>
<div v-if="canDirectControlSelectedShip && directOrders.length > 0" class="entity-inspector-actions-row">
<button type="button" class="entity-inspector-panel__action" :disabled="actionBusy" @click="clearOrders">Clear Orders</button>
</div>
<div v-if="actionStatus" class="entity-inspector-message entity-inspector-message--ok">{{ actionStatus }}</div>
<div v-if="actionError" class="entity-inspector-message entity-inspector-message--error">{{ actionError }}</div>
<div v-if="directOrders.length > 0" class="entity-inspector-order-list">
<article v-for="order in directOrders" :key="order.id" class="entity-inspector-order-card">
<header class="entity-inspector-order-card__header">
<button
type="button"
class="entity-inspector-order-card__toggle"
:aria-expanded="expandedDirectOrderId === order.id"
@click="toggleOrderEditor(order)"
>
<span>{{ expandedDirectOrderId === order.id ? "▾" : "▸" }}</span>
<span>{{ getShipOrderLabel(order.kind) }}</span>
</button>
<div class="entity-inspector-order-card__actions">
<span class="entity-inspector-order-card__status">{{ titleCase(order.status) }}</span>
<button
v-if="canDirectControlSelectedShip"
type="button"
class="entity-inspector-order-remove"
:disabled="actionBusy"
@click="removeOrder(order.id)"
>
Remove
</button>
</div>
<span>{{ row.maxLabel }}</span>
</header>
<div class="entity-inspector-order-card__summary">
<span>{{ describeOrderTarget(order) }}</span>
<span>{{ joinDetail([`P${order.priority}`, titleCase(order.sourceKind), describeOrderFailure(order) ?? undefined]) }}</span>
</div>
</div>
<div v-if="expandedDirectOrderId === order.id" class="entity-inspector-order-editor">
<div class="entity-inspector-note">
{{ [getShipOrderSupportStatusLabel(order.kind), getShipOrderNotes(order.kind)].filter(Boolean).join(" · ") }}
</div>
<div class="entity-inspector-form">
<label class="entity-inspector-field entity-inspector-field--grow">
<span>Label</span>
<input v-model="orderEditForm.label" type="text" />
</label>
<div class="entity-inspector-inline-form">
<label class="entity-inspector-field entity-inspector-field--grow">
<span>Priority</span>
<input v-model="orderEditForm.priority" type="number" min="0" step="1" />
</label>
<label class="entity-inspector-field entity-inspector-field--checkbox">
<span>Interrupt current plan</span>
<input v-model="orderEditForm.interruptCurrentPlan" type="checkbox" />
</label>
</div>
<label v-if="supportsOrderField(order.kind, 'targetSystemId')" class="entity-inspector-field entity-inspector-field--grow">
<span>Target System</span>
<select v-model="orderEditForm.targetSystemId">
<option value="">None</option>
<option v-for="system in gmStore.systems" :key="system.id" :value="system.id">{{ system.label }} ({{ system.id }})</option>
</select>
</label>
<label v-if="supportsOrderField(order.kind, 'targetEntityId')" class="entity-inspector-field entity-inspector-field--grow">
<span>Target Entity Id</span>
<input v-model="orderEditForm.targetEntityId" type="text" />
</label>
<label v-if="supportsOrderField(order.kind, 'itemId')" class="entity-inspector-field entity-inspector-field--grow">
<span>Ware</span>
<select v-model="orderEditForm.itemId">
<option v-for="itemId in commonMiningItems" :key="itemId" :value="itemId">{{ itemId }}</option>
</select>
</label>
<div v-if="supportsOrderField(order.kind, 'waitSeconds') || supportsOrderField(order.kind, 'radius')" class="entity-inspector-inline-form">
<label v-if="supportsOrderField(order.kind, 'waitSeconds')" class="entity-inspector-field entity-inspector-field--grow">
<span>Wait Seconds</span>
<input v-model="orderEditForm.waitSeconds" type="number" min="0" step="1" />
</label>
<label v-if="supportsOrderField(order.kind, 'radius')" class="entity-inspector-field entity-inspector-field--grow">
<span>Radius</span>
<input v-model="orderEditForm.radius" type="number" min="0" step="1" />
</label>
</div>
<div v-if="supportsOrderField(order.kind, 'maxSystemRange') || supportsOrderField(order.kind, 'knownStationsOnly')" class="entity-inspector-inline-form">
<label v-if="supportsOrderField(order.kind, 'maxSystemRange')" class="entity-inspector-field entity-inspector-field--grow">
<span>Max System Range</span>
<input v-model="orderEditForm.maxSystemRange" type="number" min="0" step="1" />
</label>
<label v-if="supportsOrderField(order.kind, 'knownStationsOnly')" class="entity-inspector-field entity-inspector-field--checkbox">
<span>Known Stations Only</span>
<input v-model="orderEditForm.knownStationsOnly" type="checkbox" />
</label>
</div>
<div class="entity-inspector-order-actions">
<button type="button" class="entity-inspector-panel__action" :disabled="actionBusy" @click="saveOrder(order)">Save</button>
<button type="button" class="entity-inspector-panel__action" :disabled="actionBusy" @click="toggleOrderEditor(order)">Cancel</button>
</div>
</div>
</div>
</article>
</div>
<div v-if="shipCargoRows.length > 0" class="entity-inspector-table-wrap">
<table class="entity-inspector-table">
<thead>
<tr>
<th scope="col">Ware</th>
<th scope="col" class="entity-inspector-table__numeric">Amount</th>
</tr>
</thead>
<tbody>
<tr v-for="row in shipCargoRows" :key="row.key">
<td>{{ row.ware }}</td>
<td class="entity-inspector-table__numeric">{{ row.amount }}</td>
</tr>
</tbody>
</table>
<div v-else class="entity-inspector-empty">No direct orders queued.</div>
<div class="entity-inspector-note">
Behavior-generated queue entries are managed from Default Behavior.
<span v-if="behaviorGeneratedOrderCount > 0"> Active generated orders: {{ behaviorGeneratedOrderCount }}.</span>
</div>
<div v-else class="entity-inspector-empty">No wares loaded.</div>
</div>
<div class="entity-inspector-section">
<h4>Behavior</h4>
<h4>Default Behavior</h4>
<div v-if="selectedBehaviorStatus || selectedBehaviorNotes" class="entity-inspector-note">
{{ [selectedBehaviorStatus, selectedBehaviorNotes].filter(Boolean).join(" · ") }}
</div>
@@ -715,125 +781,6 @@ async function clearOrders() {
Direct behavior editing is only available for player-owned ships or GM users.
</div>
</div>
<div class="entity-inspector-section">
<h4>Orders</h4>
<div v-if="canDirectControlSelectedShip" class="entity-inspector-form">
<div class="entity-inspector-actions-row">
<button type="button" class="entity-inspector-panel__action" :disabled="actionBusy" @click="queueHoldPositionOrder">Hold Position</button>
<button type="button" class="entity-inspector-panel__action" :disabled="actionBusy || directOrders.length === 0" @click="clearOrders">Clear Orders</button>
</div>
<div class="entity-inspector-inline-form">
<label class="entity-inspector-field entity-inspector-field--grow">
<span>Move To System</span>
<select v-model="moveOrderSystemId">
<option value="">Select system</option>
<option v-for="system in gmStore.systems" :key="system.id" :value="system.id">{{ system.label }} ({{ system.id }})</option>
</select>
</label>
<button type="button" class="entity-inspector-panel__action" :disabled="actionBusy" @click="queueMoveOrder">Queue Move</button>
</div>
<div class="entity-inspector-inline-form">
<label class="entity-inspector-field entity-inspector-field--grow">
<span>Mine Resource System</span>
<select v-model="mineOrderForm.systemId">
<option value="">Current system</option>
<option v-for="system in gmStore.systems" :key="system.id" :value="system.id">{{ system.label }} ({{ system.id }})</option>
</select>
</label>
<label class="entity-inspector-field entity-inspector-field--grow">
<span>Ware</span>
<select v-model="mineOrderForm.itemId">
<option v-for="itemId in commonMiningItems" :key="itemId" :value="itemId">{{ itemId }}</option>
</select>
</label>
<button type="button" class="entity-inspector-panel__action" :disabled="actionBusy" @click="queueMineResourceOrder">Queue Mine</button>
</div>
</div>
<div v-if="actionStatus" class="entity-inspector-message entity-inspector-message--ok">{{ actionStatus }}</div>
<div v-if="actionError" class="entity-inspector-message entity-inspector-message--error">{{ actionError }}</div>
<div v-if="directOrderRows.length > 0" class="entity-inspector-table-wrap">
<table class="entity-inspector-table">
<thead>
<tr>
<th scope="col">Order</th>
<th scope="col">Status</th>
<th scope="col">Target</th>
<th scope="col">Detail</th>
<th v-if="canDirectControlSelectedShip" scope="col" class="entity-inspector-table__action-col">Action</th>
</tr>
</thead>
<tbody>
<tr v-for="order in directOrderRows" :key="order.id">
<td>{{ order.label }}</td>
<td>{{ order.status }}</td>
<td>{{ order.target }}</td>
<td class="entity-inspector-table__detail">{{ order.detail }}</td>
<td v-if="canDirectControlSelectedShip" class="entity-inspector-table__action-col">
<button
type="button"
class="entity-inspector-order-remove"
:disabled="actionBusy"
@click="removeOrder(order.id)"
>
Remove
</button>
</td>
</tr>
</tbody>
</table>
</div>
<div v-else class="entity-inspector-empty">No direct orders queued.</div>
<div class="entity-inspector-divider">
<span>Behavior: {{ getShipBehaviorLabel(selectedShip.defaultBehavior.kind) }}</span>
</div>
<div v-if="behaviorOrderRows.length > 0" class="entity-inspector-table-wrap">
<table class="entity-inspector-table">
<thead>
<tr>
<th scope="col">Order</th>
<th scope="col">Status</th>
<th scope="col">Target</th>
<th scope="col">Detail</th>
</tr>
</thead>
<tbody>
<tr v-for="order in behaviorOrderRows" :key="order.id">
<td>{{ order.label }}</td>
<td>{{ order.status }}</td>
<td>{{ order.target }}</td>
<td class="entity-inspector-table__detail">{{ order.detail }}</td>
</tr>
</tbody>
</table>
</div>
<div v-else class="entity-inspector-empty">No behavior orders queued.</div>
</div>
<div class="entity-inspector-section">
<h4>Plan Steps</h4>
<div v-if="shipPlanRows.length > 0" class="entity-inspector-table-wrap">
<table class="entity-inspector-table">
<thead>
<tr>
<th scope="col">Scope</th>
<th scope="col">Activity</th>
<th scope="col">Status</th>
<th scope="col">Detail</th>
</tr>
</thead>
<tbody>
<tr v-for="row in shipPlanRows" :key="row.id" :class="row.isSubTask ? 'entity-inspector-table__row--subtask' : ''">
<td>{{ row.scope }}</td>
<td :class="row.isSubTask ? 'entity-inspector-table__subtask' : ''">{{ row.activity }}</td>
<td>{{ row.status }}</td>
<td class="entity-inspector-table__detail">{{ row.detail }}</td>
</tr>
</tbody>
</table>
</div>
<div v-else class="entity-inspector-empty">No active plan.</div>
</div>
</template>
<template v-else-if="selectedStation">

View File

@@ -1,184 +0,0 @@
<script setup lang="ts">
import { storeToRefs } from "pinia";
import type { OpsStripState } from "../viewerHudState";
import { useViewerSelectionStore } from "../ui/stores/viewerSelection";
import type { Selectable } from "../viewerTypes";
defineProps<{
state: OpsStripState;
}>();
const emit = defineEmits<{
history: [selection: Selectable];
focus: [selection: Selectable, cameraMode?: "follow" | "tactical"];
}>();
const selectionStore = useViewerSelectionStore();
const { selectedEntityId, selectedEntityKind } = storeToRefs(selectionStore);
function isSelected(kind: Selectable["kind"], id: string) {
return selectedEntityKind.value === kind && selectedEntityId.value === id;
}
function onStationClick(id: string, label: string) {
selectionStore.selectSelection({ id, kind: "station", label }, "ui");
}
function onStationDoubleClick(id: string, label: string) {
selectionStore.selectSelection({ id, kind: "station", label }, "ui");
emit("focus", { kind: "station", id }, "tactical");
}
function onShipClick(id: string, label: string) {
selectionStore.selectSelection({ id, kind: "ship", label }, "ui");
}
function onShipDoubleClick(id: string, label: string) {
selectionStore.selectSelection({ id, kind: "ship", label }, "ui");
emit("focus", { kind: "ship", id }, "follow");
}
</script>
<template>
<section class="ops-strip">
<article
v-for="faction in state.factions"
:key="faction.id"
class="ship-card faction-card"
:data-faction-id="faction.id"
>
<div class="ship-card-header">
<h3>{{ faction.label }}</h3>
<span class="ship-card-badge">faction</span>
</div>
<div
v-if="faction.stateLines.length > 0"
class="ship-card-ai"
>
<p class="ship-card-section-title">GOAP State</p>
<p
v-for="line in faction.stateLines"
:key="line"
>
{{ line }}
</p>
</div>
<div
v-if="faction.priorities.length > 0"
class="ship-card-ai"
>
<p class="ship-card-section-title">Priorities</p>
<p
v-for="priority in faction.priorities"
:key="`${faction.id}-${priority.label}`"
class="ship-card-split-line"
>
<span>{{ priority.label }}</span>
<span>{{ priority.value }}</span>
</p>
</div>
</article>
<article
v-for="station in state.stations"
:key="station.id"
:class="['ship-card', 'station-card', isSelected('station', station.id) && 'is-selected']"
:data-station-id="station.id"
@click="onStationClick(station.id, station.label)"
@dblclick="onStationDoubleClick(station.id, station.label)"
>
<div class="ship-card-header">
<h3>{{ station.label }}</h3>
<span class="ship-card-badge">{{ station.badge }}</span>
</div>
<p
v-for="line in station.lines"
:key="`${station.id}-${line}`"
>
{{ line }}
</p>
<div
v-if="station.processes.length > 0"
class="ship-card-ai"
>
<div
v-for="process in station.processes"
:key="`${station.id}-${process.label}`"
class="ship-action-progress"
>
<div class="ship-action-progress-label">
<span>{{ process.label }}</span>
<span>{{ process.valueLabel }}</span>
</div>
<div class="ship-action-progress-track">
<div
class="ship-action-progress-fill"
:style="{ width: `${process.progress}%` }"
/>
</div>
</div>
</div>
</article>
<article
v-for="ship in state.ships"
:key="ship.id"
:class="['ship-card', isSelected('ship', ship.id) && 'is-selected', ship.followed && 'is-followed']"
:data-ship-id="ship.id"
@click="onShipClick(ship.id, ship.label)"
@dblclick="onShipDoubleClick(ship.id, ship.label)"
>
<div class="ship-card-header">
<h3>{{ ship.label }}</h3>
<div class="ship-card-meta">
<span class="ship-card-badge">{{ ship.badge }}</span>
<button
type="button"
class="ship-card-history-button"
:data-history-ship-id="ship.id"
:aria-label="`Open history for ${ship.label}`"
title="Open history"
@click.stop="emit('history', { kind: 'ship', id: ship.id })"
>
&#128340;
</button>
</div>
</div>
<p
v-for="line in ship.locationLines"
:key="`${ship.id}-${line}`"
>
{{ line }}
</p>
<p
v-for="line in ship.lines"
:key="`${ship.id}-${line}`"
>
{{ line }}
</p>
<div
v-if="ship.action"
class="ship-action-progress"
>
<div class="ship-action-progress-label">
<span>{{ ship.action.label }}</span>
<span>{{ ship.action.valueLabel }}</span>
</div>
<div class="ship-action-progress-track">
<div
class="ship-action-progress-fill"
:style="{ width: `${ship.action.progress}%` }"
/>
</div>
</div>
<div class="ship-card-ai">
<p
v-for="line in ship.aiLines"
:key="`${ship.id}-${line}`"
>
{{ line }}
</p>
</div>
</article>
</section>
</template>

View File

@@ -12,7 +12,7 @@ import { useViewerSelectionStore } from "../ui/stores/viewerSelection";
type MenuAction =
| "mine-resource"
| "fly-to-and-wait"
| "fly-to"
| "follow"
| "attack";
@@ -105,13 +105,14 @@ const actions = computed<OrderMenuActionEntry[]>(() => {
case "station":
case "celestial":
case "construction-site":
case "point":
return [{
key: "fly-to-and-wait",
orderKind: "fly-and-wait",
label: getShipOrderLabel("fly-and-wait"),
key: "fly-to",
orderKind: "move",
label: getShipOrderLabel("move"),
detail: target.value.label,
supportStatus: getShipOrderSupportStatusLabel("fly-and-wait"),
notes: getShipOrderNotes("fly-and-wait"),
supportStatus: getShipOrderSupportStatusLabel("move"),
notes: getShipOrderNotes("move"),
}];
case "system":
return emptyActions();
@@ -170,9 +171,9 @@ async function runAction(action: MenuAction) {
return;
}
if (action === "fly-to-and-wait") {
if (action === "fly-to") {
const ship = await enqueueShipOrder(selectedShip.value.id, {
kind: "fly-and-wait",
kind: "move",
priority: 100,
interruptCurrentPlan: true,
label: `Fly to ${target.value.label}`,
@@ -185,7 +186,7 @@ async function runAction(action: MenuAction) {
anchorId: null,
constructionSiteId: null,
moduleId: null,
waitSeconds: 8,
waitSeconds: 0,
radius: 0,
maxSystemRange: 0,
knownStationsOnly: false,

View File

@@ -278,9 +278,7 @@ type ShipRow = {
const shipRows = computed<ShipRow[]>(() =>
gmStore.ships.map((s) => {
const topOrder = [...s.orderQueue]
.sort((left, right) => right.priority - left.priority || left.createdAtUtc.localeCompare(right.createdAtUtc))[0];
const currentStep = s.activePlan?.steps[s.activePlan.currentStepIndex];
const topOrder = s.orderQueue[0];
const currentSubTask = s.activeSubTasks[0];
return {
id: s.id,
@@ -293,8 +291,8 @@ const shipRows = computed<ShipRow[]>(() =>
assignment: s.assignment ? titleCaseToken(s.assignment.kind) : "—",
behavior: getShipBehaviorLabel(s.defaultBehavior.kind),
orders: topOrder ? `${getShipOrderLabel(topOrder.kind)} · ${s.orderQueue.length}` : "—",
plan: s.activePlan ? `${titleCaseToken(s.activePlan.kind)} · ${titleCaseToken(s.activePlan.status)}` : "—",
step: currentStep ? `${titleCaseToken(currentStep.kind)} · ${titleCaseToken(currentStep.status)}` : "—",
plan: currentSubTask ? "Task execution" : "—",
step: currentSubTask ? titleCaseToken(currentSubTask.kind) : "—",
subtask: currentSubTask ? `${titleCaseToken(currentSubTask.kind)} ${Math.round(currentSubTask.progress * 100)}%` : "—",
};
}),

View File

@@ -691,7 +691,7 @@ async function submitDirectOrder() {
<div v-if="selectedShip" class="player-card">
<strong>Behavior</strong>
<span>{{ getShipBehaviorLabel(selectedShip.defaultBehavior.kind) }}</span>
<span>Orders {{ selectedShip.orderQueue.length }} · Plan {{ selectedShip.activePlan?.kind ?? "none" }}</span>
<span>Orders {{ selectedShip.orderQueue.length }} · Tasks {{ selectedShip.activeSubTasks.length }}</span>
<span>Command {{ titleCase(selectedShip.controlSourceKind) }}<template v-if="selectedShip.controlReason"> · {{ selectedShip.controlReason }}</template></span>
<span v-if="selectedShip.lastReplanReason">Replan {{ selectedShip.lastReplanReason }}</span>
<span v-if="selectedShip.lastAccessFailureReason">Access {{ selectedShip.lastAccessFailureReason }}</span>

View File

@@ -114,31 +114,6 @@ export interface ShipSubTaskSnapshot {
blockingReason?: string | null;
}
export interface ShipPlanStepSnapshot {
id: string;
kind: string;
status: string;
summary: string;
blockingReason?: string | null;
currentSubTaskIndex: number;
subTasks: ShipSubTaskSnapshot[];
}
export interface ShipPlanSnapshot {
id: string;
sourceKind: string;
sourceId: string;
kind: string;
status: string;
summary: string;
currentStepIndex: number;
createdAtUtc: string;
updatedAtUtc: string;
interruptReason?: string | null;
failureReason?: string | null;
steps: ShipPlanStepSnapshot[];
}
export interface ShipSnapshot {
id: string;
name: string;
@@ -154,8 +129,6 @@ export interface ShipSnapshot {
defaultBehavior: DefaultBehaviorSnapshot;
assignment?: ShipAssignmentSnapshot | null;
skills: ShipSkillProfileSnapshot;
activePlan?: ShipPlanSnapshot | null;
currentStepId?: string | null;
activeSubTasks: ShipSubTaskSnapshot[];
controlSourceKind: string;
controlSourceId?: string | null;

View File

@@ -21,6 +21,26 @@ export interface ShipOrderCommandRequest {
knownStationsOnly?: boolean | null;
}
export interface ShipOrderUpdateCommandRequest {
kind: string;
priority: number;
interruptCurrentPlan: boolean;
label?: string | null;
targetEntityId?: string | null;
targetSystemId?: string | null;
targetPosition?: Vector3Dto | null;
sourceStationId?: string | null;
destinationStationId?: string | null;
itemId?: string | null;
anchorId?: string | null;
constructionSiteId?: string | null;
moduleId?: string | null;
waitSeconds?: number | null;
radius?: number | null;
maxSystemRange?: number | null;
knownStationsOnly?: boolean | null;
}
export interface ShipDefaultBehaviorCommandRequest {
kind: string;
homeSystemId?: string | null;

View File

@@ -669,112 +669,6 @@ canvas {
gap: 8px;
}
.ops-strip {
position: absolute;
left: 0;
right: 0;
bottom: 0;
width: 50vw;
min-height: 128px;
display: flex;
align-items: stretch;
gap: 0;
overflow-x: auto;
overflow-y: hidden;
pointer-events: auto;
scrollbar-width: thin;
background: linear-gradient(180deg, rgba(5, 10, 18, 0), rgba(5, 10, 18, 0.92) 28%);
}
.ship-card {
border-top: 1px solid rgba(127, 214, 255, 0.14);
border-right: 1px solid rgba(127, 214, 255, 0.1);
background: linear-gradient(180deg, rgba(10, 20, 36, 0.96), rgba(6, 12, 22, 0.98));
padding: 10px 12px 12px;
min-width: 208px;
max-width: 208px;
display: flex;
flex-direction: column;
gap: 6px;
color: var(--viewer-text);
cursor: pointer;
transition: transform 140ms ease, border-color 140ms ease, box-shadow 140ms ease, background 140ms ease;
}
.ship-card:hover {
transform: translateY(-2px);
border-color: rgba(127, 214, 255, 0.38);
box-shadow: 0 16px 32px rgba(0, 0, 0, 0.28);
}
.ship-card.is-selected {
border-top-color: rgba(255, 191, 105, 0.82);
background: linear-gradient(180deg, rgba(31, 33, 20, 0.9), rgba(20, 18, 10, 0.92));
}
.ship-card.is-followed {
box-shadow: inset 0 0 0 1px rgba(127, 214, 255, 0.34);
}
.ship-card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 10px;
}
.ship-card h3 {
margin: 0;
font-size: 0.82rem;
line-height: 1.15;
letter-spacing: 0.04em;
}
.ship-card p {
margin: 2px 0 0;
color: var(--viewer-muted);
line-height: 1.35;
font-size: 0.72rem;
white-space: pre-line;
}
.ship-card-header + p {
font-size: 0.62rem;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.ship-card-meta {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 6px;
}
.ship-card-badge {
padding: 3px 8px;
border-radius: 999px;
background: rgba(127, 214, 255, 0.12);
color: var(--viewer-accent);
font-size: 0.64rem;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.ship-card-ai {
margin-top: 2px;
padding-top: 6px;
border-top: 1px solid rgba(127, 214, 255, 0.08);
}
.ship-card-section-title {
margin: 0;
color: var(--viewer-accent);
letter-spacing: 0.14em;
text-transform: uppercase;
}
.ship-card-history-button,
.history-window-copy,
.history-window-close {
border: 1px solid rgba(127, 214, 255, 0.22);
@@ -785,64 +679,15 @@ canvas {
cursor: pointer;
}
.ship-card-history-button {
width: 24px;
height: 24px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
align-self: flex-end;
font-size: 0.78rem;
line-height: 1;
}
.history-window-copy,
.history-window-close {
padding: 8px 12px;
}
.faction-card {
border-top-color: rgba(180, 130, 255, 0.3);
cursor: default;
}
.faction-card:hover {
transform: none;
border-color: rgba(180, 130, 255, 0.5);
}
.station-card {
border-top-color: rgba(127, 255, 180, 0.22);
}
.station-card:hover {
border-color: rgba(127, 255, 180, 0.5);
}
.ship-card-split-line {
display: flex;
justify-content: space-between;
gap: 12px;
}
.selection-action-button {
pointer-events: auto;
}
@media (max-width: 1080px) {
.ops-strip {
width: 100vw;
}
}
@media (max-width: 760px) {
.ops-strip {
width: 100vw;
min-height: 120px;
}
}
/* ── GM Windows ──────────────────────────────────────────────────────────── */
.gm-window {
@@ -1859,6 +1704,65 @@ canvas {
align-items: center;
}
.entity-inspector-order-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.entity-inspector-order-card {
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 1rem;
background: rgba(255, 255, 255, 0.03);
padding: 0.8rem 0.9rem;
}
.entity-inspector-order-card__header,
.entity-inspector-order-card__actions,
.entity-inspector-order-card__summary {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.7rem;
}
.entity-inspector-order-card__toggle {
display: inline-flex;
align-items: center;
gap: 0.55rem;
border: none;
background: transparent;
color: var(--viewer-text);
padding: 0;
font: inherit;
font-weight: 600;
cursor: pointer;
}
.entity-inspector-order-card__status {
font-size: 0.72rem;
color: rgba(173, 220, 255, 0.78);
text-transform: uppercase;
letter-spacing: 0.12em;
}
.entity-inspector-order-card__summary {
margin-top: 0.55rem;
align-items: flex-start;
color: var(--viewer-muted);
font-size: 0.76rem;
}
.entity-inspector-order-card__summary span:last-child {
text-align: right;
}
.entity-inspector-order-editor {
margin-top: 0.8rem;
padding-top: 0.8rem;
border-top: 1px solid rgba(255, 255, 255, 0.08);
}
.entity-inspector-field {
display: flex;
flex-direction: column;
@@ -1870,6 +1774,10 @@ canvas {
flex: 1 1 auto;
}
.entity-inspector-field--checkbox {
justify-content: flex-end;
}
.entity-inspector-field span {
font-size: 0.72rem;
color: var(--viewer-muted);
@@ -1893,6 +1801,14 @@ canvas {
border-color: rgba(173, 220, 255, 0.4);
}
.entity-inspector-field--checkbox input {
width: 1rem;
height: 1rem;
min-width: 1rem;
padding: 0;
accent-color: #7fd6ff;
}
.entity-inspector-note {
margin-top: 0.9rem;
color: var(--viewer-muted);

View File

@@ -2,8 +2,13 @@ import { defineStore } from "pinia";
import type { Vector3Dto } from "../../contractsCommon";
import type { Selectable } from "../../viewerTypes";
export interface ViewerOrderContextMenuPointSelection {
kind: "point";
id: "local-point";
}
export interface ViewerOrderContextMenuTarget {
selection: Selectable;
selection: Selectable | ViewerOrderContextMenuPointSelection;
label: string;
systemId?: string | null;
anchorId?: string | null;

View File

@@ -92,10 +92,10 @@ export function updatePanFromKeyboard(
const right = new THREE.Vector3(-forward.z, 0, forward.x);
const pan = right.multiplyScalar(move.x).add(forward.multiplyScalar(move.z));
if (activeSystemId) {
const speedKilometers = povLevel === "system"
const panSpeed = povLevel === "system"
? THREE.MathUtils.mapLinear(currentDistance, 80, 4000, KILOMETERS_PER_AU * 0.002, KILOMETERS_PER_AU * 0.35)
: THREE.MathUtils.mapLinear(currentDistance, minimumDistance, 120, 40, 180000);
systemAnchor.addScaledVector(pan, speedKilometers * delta);
: THREE.MathUtils.mapLinear(currentDistance, Math.max(minimumDistance, 4), 4000, 8, 6000);
systemAnchor.addScaledVector(pan, panSpeed * delta);
return;
}
@@ -133,6 +133,11 @@ export function applyPanFromScreenDelta(
const pan = right.multiplyScalar(horizontalDistance).add(forward.multiplyScalar(verticalDistance));
if (activeSystemId) {
if (povLevel === "local") {
systemAnchor.add(pan);
return;
}
const systemDisplayToKilometers = 1 / (DISPLAY_UNITS_PER_KILOMETER * ACTIVE_SYSTEM_DETAIL_SCALE);
systemAnchor.addScaledVector(pan, systemDisplayToKilometers);
return;
@@ -353,7 +358,7 @@ export function getCameraFocusWorldPosition(params: CameraFocusParams): THREE.Ve
}
/**
* Convert a local km position to system-scene display coordinates.
* Convert a system-space kilometer position to system-scene display coordinates.
* System scene coordinate system: star at origin, all positions scaled by
* DISPLAY_UNITS_PER_KILOMETER * ACTIVE_SYSTEM_DETAIL_SCALE.
*/

View File

@@ -1,7 +1,7 @@
import type { PovLevel } from "./viewerTypes";
export const NAV_DISTANCE: Record<PovLevel, number> = {
local: 18,
local: 180,
system: 3200,
galaxy: 32000,
};
@@ -21,6 +21,11 @@ export const MOON_RENDER_SCALE = 1.1;
// 0.00005 units = ~3 km — allows scrolling very close to ships and structures.
export const MIN_CAMERA_DISTANCE = 0.00005;
export const MAX_CAMERA_DISTANCE = 150000;
export const MIN_LOCAL_CAMERA_DISTANCE = 4;
export const MAX_LOCAL_CAMERA_DISTANCE = 120000;
export const LOCAL_SYSTEM_BACKDROP_DISTANCE = 650;
export const LOCAL_CAMERA_DISTANCE_AT_TRANSITION = 100000;
export const LOCAL_CAMERA_DISTANCE_AT_MIN_ZOOM = 40;
export interface ZoomBlend {
localWeight: number;

View File

@@ -112,6 +112,7 @@ export function createViewerControllers(host: any) {
getCameraMode: () => host.cameraMode,
getCameraTargetShipId: () => host.cameraTargetShipId,
getPovLevel: () => host.povLevel,
getFocusedAnchorId: () => host.resolveFocusedAnchorId(),
getSelectedItems: () => host.selectedItems,
getWorldTimeSyncMs: () => host.worldTimeSyncMs,
getCurrentDistance: () => host.currentDistance,
@@ -251,6 +252,8 @@ export function createViewerControllers(host: any) {
},
getFollowCameraPosition: () => host.followCameraPosition,
getFollowCameraFocus: () => host.followCameraFocus,
getLocalRootPosition: () => host.localLayer.localRoot.position.clone(),
getFocusedAnchorId: () => host.resolveFocusedAnchorId(),
screenPointFromClient: (x, y) => presentationController.screenPointFromClient(x, y),
applyPanDelta: (delta: THREE.Vector2) => {
const bounds = host.renderer.domElement.getBoundingClientRect();

View File

@@ -1,11 +1,12 @@
import * as THREE from "three";
import { MAX_CAMERA_DISTANCE, MIN_CAMERA_DISTANCE, NAV_DISTANCE } from "./viewerConstants";
import { MAX_CAMERA_DISTANCE, MAX_LOCAL_CAMERA_DISTANCE, MIN_CAMERA_DISTANCE, MIN_LOCAL_CAMERA_DISTANCE, NAV_DISTANCE } from "./viewerConstants";
import { scaleGalaxyVector, toThreeVector } from "./viewerMath";
import { rawObject } from "./viewerScenePrimitives";
import { resolveShipWorldPosition } from "./viewerWorldPresentation";
import type { StatsOverlayMode } from "./viewerHudState";
import type {
CameraMode,
PovLevel,
Selectable,
ShipVisual,
SystemVisual,
@@ -212,10 +213,12 @@ export function setShellReticleOpacity(sprite: SystemVisual["shellReticle"], opa
material.needsUpdate = true;
}
export function navigateFromWheel(desiredDistance: number, deltaY: number) {
export function navigateFromWheel(desiredDistance: number, deltaY: number, povLevel: PovLevel) {
const clampedDelta = THREE.MathUtils.clamp(deltaY, -180, 180);
const zoomFactor = Math.exp(clampedDelta * 0.00135);
return THREE.MathUtils.clamp(desiredDistance * zoomFactor, MIN_CAMERA_DISTANCE, MAX_CAMERA_DISTANCE);
const minimumDistance = povLevel === "local" ? MIN_LOCAL_CAMERA_DISTANCE : MIN_CAMERA_DISTANCE;
const maximumDistance = povLevel === "local" ? MAX_LOCAL_CAMERA_DISTANCE : MAX_CAMERA_DISTANCE;
return THREE.MathUtils.clamp(desiredDistance * zoomFactor, minimumDistance, maximumDistance);
}
export function applyKeyboardControl(params: {

View File

@@ -32,43 +32,6 @@ export interface HudProgressBar {
progress: number;
}
export interface OpsFactionCardState {
kind: "faction";
id: string;
label: string;
stateLines: string[];
priorities: { label: string; value: string }[];
}
export interface OpsStationCardState {
kind: "station";
id: string;
label: string;
badge: string;
selected: boolean;
lines: string[];
processes: HudProgressBar[];
}
export interface OpsShipCardState {
kind: "ship";
id: string;
label: string;
badge: string;
selected: boolean;
followed: boolean;
locationLines: string[];
lines: string[];
action?: HudProgressBar;
aiLines: string[];
}
export interface OpsStripState {
factions: OpsFactionCardState[];
stations: OpsStationCardState[];
ships: OpsShipCardState[];
}
export interface HistoryWindowState {
id: string;
target: Selectable;
@@ -111,7 +74,6 @@ export interface ViewerHudState {
systemPanel: HudHtmlPanelState;
detailPanel: HudHtmlPanelState;
error: HudErrorState;
opsStrip: OpsStripState;
historyWindows: HistoryWindowState[];
hoverLabel: HoverLabelState;
marquee: MarqueeState;
@@ -161,11 +123,6 @@ export function createViewerHudState(): ViewerHudState {
hidden: true,
message: "",
},
opsStrip: {
factions: [],
stations: [],
ships: [],
},
historyWindows: [],
hoverLabel: {
hidden: true,

View File

@@ -1,7 +1,7 @@
import * as THREE from "three";
import { describeHoverLabel, getSelectionGroup } from "./viewerSelection";
import { ACTIVE_SYSTEM_DETAIL_SCALE } from "./viewerConstants";
import { DISPLAY_UNITS_PER_KILOMETER, DISPLAY_UNITS_PER_LIGHT_YEAR, KILOMETERS_PER_AU, formatAdaptiveDistanceFromKilometers, formatSystemDistance } from "./viewerMath";
import { DISPLAY_UNITS_PER_KILOMETER, DISPLAY_UNITS_PER_LIGHT_YEAR, KILOMETERS_PER_AU, METERS_PER_KILOMETER, formatAdaptiveDistanceFromKilometers, formatAdaptiveDistanceFromMeters, formatSystemDistance } from "./viewerMath";
import type { HoverLabelState, MarqueeState } from "./viewerHudState";
import type { Selectable, SelectionGroup, WorldState, PovLevel } from "./viewerTypes";
@@ -169,13 +169,17 @@ function formatHoverDistance(
|| selection.kind === "construction-site";
if (inActiveSystem && activeSystemId) {
if (povLevel === "local") {
return formatAdaptiveDistanceFromMeters(displayDistance);
}
const kilometers = displayDistance / (DISPLAY_UNITS_PER_KILOMETER * ACTIVE_SYSTEM_DETAIL_SCALE);
return povLevel === "system"
? formatSystemDistance(kilometers / KILOMETERS_PER_AU)
: formatAdaptiveDistanceFromKilometers(kilometers);
}
return formatAdaptiveDistanceFromKilometers(displayDistance / DISPLAY_UNITS_PER_KILOMETER);
return formatAdaptiveDistanceFromKilometers((displayDistance / DISPLAY_UNITS_PER_KILOMETER) / METERS_PER_KILOMETER);
}
export function updateMarqueeBox(

View File

@@ -20,7 +20,10 @@ import type {
WorldState,
PovLevel,
} from "./viewerTypes";
import type { ViewerOrderContextMenuTarget } from "./ui/stores/viewerOrderContextMenu";
import type {
ViewerOrderContextMenuPointSelection,
ViewerOrderContextMenuTarget,
} from "./ui/stores/viewerOrderContextMenu";
export interface ViewerInteractionContext {
renderer: THREE.WebGLRenderer;
@@ -60,6 +63,8 @@ export interface ViewerInteractionContext {
setCameraTargetShipId: (value: string | undefined) => void;
getFollowCameraPosition: () => THREE.Vector3;
getFollowCameraFocus: () => THREE.Vector3;
getLocalRootPosition: () => THREE.Vector3;
getFocusedAnchorId: () => string | undefined;
screenPointFromClient: (clientX: number, clientY: number) => THREE.Vector2;
applyPanDelta: (delta: THREE.Vector2) => void;
syncFollowStateFromSelection: () => void;
@@ -206,11 +211,9 @@ export class ViewerInteractionController {
this.context.closeOrderContextMenu();
const picked = this.pickSelectableAtClientPosition(event.clientX, event.clientY);
if (!picked) {
return;
}
const target = this.buildOrderContextTarget(picked);
const target = picked
? this.buildOrderContextTarget(picked)
: this.buildLocalPointContextTarget(event.clientX, event.clientY);
if (!target) {
return;
}
@@ -218,71 +221,6 @@ export class ViewerInteractionController {
this.context.openOrderContextMenu(event.clientX, event.clientY, target);
};
readonly onOpsStripClick = (event: MouseEvent) => {
const target = event.target;
if (!(target instanceof HTMLElement)) {
return;
}
const historyButton = target.closest<HTMLElement>("[data-history-ship-id]");
const historyShipId = historyButton?.dataset.historyShipId;
if (historyShipId) {
this.context.historyController.openHistoryWindow({ kind: "ship", id: historyShipId });
return;
}
const shipCard = target.closest<HTMLElement>("[data-ship-id]");
const shipId = shipCard?.dataset.shipId;
if (shipId) {
this.context.setSelectedItems([{ kind: "ship", id: shipId }]);
this.context.syncFollowStateFromSelection();
this.context.updatePanels();
return;
}
const stationCard = target.closest<HTMLElement>("[data-station-id]");
const stationId = stationCard?.dataset.stationId;
if (stationId) {
this.context.setSelectedItems([{ kind: "station", id: stationId }]);
this.context.syncFollowStateFromSelection();
this.context.updatePanels();
}
};
readonly onOpsStripDoubleClick = (event: MouseEvent) => {
const target = event.target;
if (!(target instanceof HTMLElement)) {
return;
}
if (target.closest("[data-history-ship-id]")) {
return;
}
const shipCard = target.closest<HTMLElement>("[data-ship-id]");
const shipId = shipCard?.dataset.shipId;
if (shipId) {
this.context.setSelectedItems([{ kind: "ship", id: shipId }]);
this.context.syncFollowStateFromSelection();
this.context.focusOnSelection({ kind: "ship", id: shipId });
this.toggleCameraMode("tactical");
this.context.updatePanels();
this.context.updateGamePanel("Live");
return;
}
const stationCard = target.closest<HTMLElement>("[data-station-id]");
const stationId = stationCard?.dataset.stationId;
if (stationId) {
this.context.setSelectedItems([{ kind: "station", id: stationId }]);
this.context.syncFollowStateFromSelection();
this.toggleCameraMode("tactical");
this.context.focusOnSelection({ kind: "station", id: stationId });
this.context.updatePanels();
this.context.updateGamePanel("Live");
}
};
readonly onHistoryLayerClick = (event: MouseEvent) => this.context.historyController.onHistoryLayerClick(event);
readonly onHistoryLayerPointerDown = (event: PointerEvent) => this.context.historyController.onHistoryLayerPointerDown(event);
@@ -316,7 +254,7 @@ export class ViewerInteractionController {
readonly onWheel = (event: WheelEvent) => {
event.preventDefault();
this.context.setDesiredDistance(navigateFromWheel(this.context.getDesiredDistance(), event.deltaY));
this.context.setDesiredDistance(navigateFromWheel(this.context.getDesiredDistance(), event.deltaY, this.context.getPovLevel()));
this.context.updateGamePanel("Live");
};
@@ -508,4 +446,45 @@ export class ViewerInteractionController {
return null;
}
}
private buildLocalPointContextTarget(clientX: number, clientY: number): ViewerOrderContextMenuTarget | null {
if (this.context.getPovLevel() !== "local") {
return null;
}
const world = this.context.getWorld();
const systemId = this.context.getActiveSystemId();
const anchorId = this.context.getFocusedAnchorId();
if (!world || !systemId || !anchorId || !world.anchors.has(anchorId)) {
return null;
}
const bounds = this.context.renderer.domElement.getBoundingClientRect();
this.context.mouse.x = ((clientX - bounds.left) / bounds.width) * 2 - 1;
this.context.mouse.y = -((clientY - bounds.top) / bounds.height) * 2 + 1;
this.context.raycaster.setFromCamera(this.context.mouse, this.context.localCamera);
const localRootPosition = this.context.getLocalRootPosition();
const plane = new THREE.Plane(new THREE.Vector3(0, 1, 0), -localRootPosition.y);
const worldIntersection = new THREE.Vector3();
if (!this.context.raycaster.ray.intersectPlane(plane, worldIntersection)) {
return null;
}
const localPosition = worldIntersection.sub(localRootPosition);
const rounded = localPosition.clone().round();
const selection: ViewerOrderContextMenuPointSelection = { kind: "point", id: "local-point" };
return {
selection,
label: `Point ${rounded.x}m, ${rounded.y}m, ${rounded.z}m`,
systemId,
anchorId,
targetPosition: {
x: rounded.x,
y: rounded.y,
z: rounded.z,
},
};
}
}

View File

@@ -14,8 +14,12 @@ import type {
* Camera far plane covers immediate surroundings.
*/
export class LocalLayer {
readonly localRoot = new THREE.Group();
readonly fineGrid = createLocalGrid(1000, 10, 0x35506d, 0x233449, 0.42);
readonly majorGrid = createLocalGrid(10000, 100, 0x6d88a3, 0x4b6078, 0.42);
readonly outerGrid = createLocalGrid(80000, 1000, 0x7e98b2, 0x55687f, 0.26);
readonly scene = new THREE.Scene();
readonly camera = new THREE.PerspectiveCamera(50, 1, 0.1, 2000);
readonly camera = new THREE.PerspectiveCamera(50, 1, 0.1, 200000);
readonly nodeGroup = new THREE.Group();
readonly stationGroup = new THREE.Group();
readonly claimGroup = new THREE.Group();
@@ -36,18 +40,24 @@ export class LocalLayer {
const keyLight = new THREE.DirectionalLight(0xdcecff, 1.4);
keyLight.position.set(180, 220, 140);
this.scene.add(keyLight);
this.scene.add(
this.localRoot.add(
this.fineGrid,
this.majorGrid,
this.outerGrid,
this.nodeGroup,
this.stationGroup,
this.claimGroup,
this.constructionSiteGroup,
this.shipGroup,
);
this.scene.add(this.localRoot);
}
updateCamera(orbitOffset: THREE.Vector3) {
this.camera.position.copy(orbitOffset);
this.camera.lookAt(LocalLayer.ORIGIN);
updateCamera(localFocus: THREE.Vector3, orbitOffset: THREE.Vector3, anchorOffset: THREE.Vector3) {
const worldFocus = localFocus.clone().add(anchorOffset);
this.localRoot.position.copy(anchorOffset);
this.camera.position.copy(worldFocus).add(orbitOffset);
this.camera.lookAt(worldFocus);
}
onResize(aspect: number) {
@@ -59,3 +69,13 @@ export class LocalLayer {
renderer.render(this.scene, this.camera);
}
}
function createLocalGrid(sizeMeters: number, stepMeters: number, majorColor: number, minorColor: number, opacity: number) {
const divisions = Math.max(1, Math.round(sizeMeters / stepMeters));
const grid = new THREE.GridHelper(sizeMeters, divisions, majorColor, minorColor);
const material = grid.material as THREE.Material & { opacity: number; transparent: boolean };
material.transparent = true;
material.opacity = opacity;
grid.position.y = -0.04;
return grid;
}

View File

@@ -15,6 +15,7 @@ import type {
import type { ZoomBlend } from "./viewerConstants";
export const KILOMETERS_PER_AU = 149_597_870.7;
export const METERS_PER_KILOMETER = 1000;
export const DISPLAY_UNITS_PER_KILOMETER = 0.0000015;
export const DISPLAY_UNITS_PER_LIGHT_YEAR = 2600;
@@ -44,7 +45,7 @@ function formatNumber(value: number, fractionDigits: number) {
}
export function formatLocalDistance(value: number): string {
return `${formatNumber(value, 0)} km`;
return `${formatNumber(value, value >= 100 ? 0 : 1)} m`;
}
export function formatSystemDistance(value: number): string {
@@ -76,6 +77,16 @@ export function formatAdaptiveDistanceFromKilometers(kilometers: number): string
return `${formatNumber(meters, meters >= 100 ? 0 : 1)} m`;
}
export function formatAdaptiveDistanceFromMeters(meters: number): string {
const absoluteMeters = Math.max(0, meters);
if (absoluteMeters >= METERS_PER_KILOMETER) {
const kilometers = absoluteMeters / METERS_PER_KILOMETER;
return `${formatNumber(kilometers, kilometers >= 100 ? 0 : 2)} km`;
}
return `${formatNumber(absoluteMeters, absoluteMeters >= 100 ? 0 : 1)} m`;
}
export function formatShipSpeed(ship: ShipSnapshot): string {
const speed = Math.max(0, ship.travelSpeed);
const unit = ship.travelSpeedUnit;
@@ -107,7 +118,7 @@ export function smoothBand(value: number, start: number, end: number): number {
}
export function computeZoomBlend(distance: number): ZoomBlend {
const localToSystem = smoothBand(distance, 1200, 5200);
const localToSystem = smoothBand(distance, 120, 650);
const systemToUniverse = smoothBand(distance, 9000, 22000);
return {

View File

@@ -196,7 +196,7 @@ export class ViewerNavigationController {
return toDisplayLocalPosition(localPosition);
}
/** Returns a display position for the system camera, derived from a raw local position in km. */
/** Returns a display position for the system camera, derived from a raw local position in meters. */
toSystemDisplayPosition(localPosition: THREE.Vector3) {
return toDisplayLocalPosition(localPosition);
}

View File

@@ -1,172 +0,0 @@
import type { StationSnapshot } from "./contractsInfrastructure";
import type { FactionSnapshot } from "./contractsFactions";
import type {
HudProgressBar,
OpsFactionCardState,
OpsShipCardState,
OpsStationCardState,
OpsStripState,
} from "./viewerHudState";
import { describeShipCurrentAction, describeShipLocation, describeShipState } from "./viewerSelection";
import { getShipBehaviorLabel, getShipOrderLabel } from "./shipAutomationPresentation";
import type { CameraMode, Selectable, WorldState, PovLevel } from "./viewerTypes";
import { usePlayerFactionStore } from "./ui/stores/playerFactionStore";
import { viewerPinia } from "./ui/stores/pinia";
function buildFactionCard(world: WorldState, faction: FactionSnapshot): OpsFactionCardState {
const playerFaction = usePlayerFactionStore(viewerPinia).playerFaction;
if (playerFaction && playerFaction.sovereignFactionId === faction.id) {
const selectedDirective = playerFaction.directives[0];
return {
kind: "faction",
id: faction.id,
label: `${faction.label} Command`,
stateLines: [
`Player ${playerFaction.assetRegistry.shipIds.length} ships · ${playerFaction.assetRegistry.stationIds.length} stations`,
`Groups ${playerFaction.fleets.length + playerFaction.taskForces.length + playerFaction.stationGroups.length + playerFaction.economicRegions.length + playerFaction.fronts.length + playerFaction.reserves.length}`,
`Intent ${playerFaction.strategicIntent.strategicPosture} · ${playerFaction.strategicIntent.economicPosture}`,
`Alerts ${playerFaction.alerts.length} · Decisions ${playerFaction.decisionLog.length}`,
`Lead ${selectedDirective ? `${selectedDirective.behaviorKind} · ${selectedDirective.scopeKind}` : "no active directives"}`,
],
priorities: [
{ label: "Reserve", value: `${Math.round(playerFaction.strategicIntent.desiredReserveRatio * 100)}%` },
{ label: "Auto", value: `${Number(playerFaction.strategicIntent.allowDelegatedEconomicAutomation)}/${Number(playerFaction.strategicIntent.allowDelegatedCombatAutomation)}` },
],
};
}
const strategicState = faction.strategicState;
const economic = strategicState.economicAssessment;
const activeCampaigns = strategicState.campaigns.filter((campaign) => campaign.status === "active");
const activeTheaters = strategicState.theaters.filter((theater) => theater.status === "active");
const activeWars = world.geopolitics?.diplomacy.wars.filter((war) => war.factionAId === faction.id || war.factionBId === faction.id).length ?? 0;
const contestedSystems = world.geopolitics?.territory.controlStates.filter((state) =>
state.isContested && (state.controllerFactionId === faction.id || state.primaryClaimantFactionId === faction.id || state.claimantFactionIds.includes(faction.id))).length ?? 0;
const leadCampaign = [...strategicState.campaigns]
.sort((left, right) => right.priority - left.priority)[0];
const leadTheater = [...strategicState.theaters]
.sort((left, right) => right.priority - left.priority)[0];
const latestDecision = [...faction.decisionLog]
.sort((left, right) => right.occurredAtUtc.localeCompare(left.occurredAtUtc))[0];
return {
kind: "faction",
id: faction.id,
label: faction.label,
stateLines: [
`Posture ${faction.doctrine.strategicPosture} · ${faction.doctrine.militaryPosture}`,
`Campaigns ${activeCampaigns.length} · Fronts ${activeTheaters.length} · Wars ${activeWars}`,
`Commit ${economic.militaryShipCount}/${economic.targetMilitaryShipCount} mil · ${economic.minerShipCount}/${economic.targetMinerShipCount} min`,
`Reserve ${strategicState.budget.reservedMilitaryAssets} mil · ${strategicState.budget.reservedLogisticsAssets} log`,
`Bottleneck ${economic.industrialBottleneckItemId ?? "none"} · Contested ${contestedSystems}${latestDecision ? ` · ${latestDecision.kind}` : ""}`,
],
priorities: [
...(leadCampaign ? [{ label: leadCampaign.kind, value: leadCampaign.priority.toFixed(0) }] : []),
...(leadTheater ? [{ label: leadTheater.kind, value: leadTheater.priority.toFixed(0) }] : []),
],
};
}
function buildProgressBar(label: string, progress: number): HudProgressBar {
return {
label,
valueLabel: `${Math.round(progress * 100)}%`,
progress: Number((progress * 100).toFixed(1)),
};
}
function buildStationCard(station: StationSnapshot, isSelected: boolean): OpsStationCardState {
const cargo = station.inventory.reduce((sum, entry) => sum + entry.amount, 0);
return {
kind: "station",
id: station.id,
label: station.label,
badge: station.category,
selected: isSelected,
lines: [
station.systemId,
`Docked ${station.dockedShips} / ${station.dockingPads}`,
`Cargo ${cargo.toFixed(0)} · Pop ${station.population.toFixed(0)}`,
`Modules ${station.installedModules.length}`,
],
processes: station.currentProcesses.map((process) => buildProgressBar(process.label, process.progress)),
};
}
function buildShipCard(
world: WorldState,
ship: WorldState["ships"] extends Map<string, infer Ship> ? Ship : never,
isSelected: boolean,
isFollowed: boolean,
): OpsShipCardState {
const cargo = ship.inventory.reduce((sum, entry) => sum + entry.amount, 0);
const shipLocation = describeShipLocation(world, ship);
const shipState = describeShipState(world, ship);
const shipAction = describeShipCurrentAction(ship);
const currentStep = ship.activePlan?.steps[ship.activePlan.currentStepIndex];
const topOrder = [...ship.orderQueue]
.sort((left, right) => right.priority - left.priority || left.createdAtUtc.localeCompare(right.createdAtUtc))[0];
return {
kind: "ship",
id: ship.id,
label: ship.name,
badge: ship.type,
selected: isSelected,
followed: isFollowed,
locationLines: [shipLocation.system, ...(shipLocation.local ? [shipLocation.local] : [])],
lines: [
`Cargo ${cargo.toFixed(0)}`,
`State ${shipState}`,
],
action: shipAction ? buildProgressBar(shipAction.label, shipAction.progress) : undefined,
aiLines: [
`Assignment ${ship.assignment?.kind ?? "unassigned"}`,
`Behavior ${getShipBehaviorLabel(ship.defaultBehavior.kind)}`,
`Plan ${ship.activePlan ? `${ship.activePlan.kind}${currentStep ? ` · ${currentStep.kind}` : ""}` : "none"}`,
`Orders ${topOrder ? `${getShipOrderLabel(topOrder.kind)} +${Math.max(0, ship.orderQueue.length - 1)}` : "none"}`,
],
};
}
export function buildOpsStripState(
world: WorldState | undefined,
selectedItems: Selectable[],
cameraMode: CameraMode,
cameraTargetShipId?: string,
povLevel?: PovLevel,
activeSystemId?: string,
): OpsStripState {
if (!world) {
return {
factions: [],
stations: [],
ships: [],
};
}
const isSystemFiltered = povLevel !== "galaxy" && activeSystemId != null;
const factions = [...world.factions.values()]
.sort((left, right) => left.label.localeCompare(right.label))
.map((faction) => buildFactionCard(world, faction));
const stations = [...world.stations.values()]
.filter((station) => !isSystemFiltered || station.systemId === activeSystemId)
.sort((left, right) => left.label.localeCompare(right.label))
.map((station) => buildStationCard(
station,
selectedItems.length === 1 && selectedItems[0].kind === "station" && selectedItems[0].id === station.id,
));
const ships = [...world.ships.values()]
.filter((ship) => !isSystemFiltered || ship.systemId === activeSystemId)
.sort((left, right) => left.name.localeCompare(right.name))
.map((ship) => buildShipCard(
world,
ship,
selectedItems.length === 1 && selectedItems[0].kind === "ship" && selectedItems[0].id === ship.id,
cameraMode === "follow" && cameraTargetShipId === ship.id,
));
return { factions, stations, ships };
}

View File

@@ -346,7 +346,6 @@ export function buildDetailPanelState(params: DetailPanelParams) {
const shipBehavior = describeShipBehavior(ship);
const shipOrder = describeShipOrder(ship);
const shipAction = describeShipCurrentAction(ship);
const currentStep = ship.activePlan?.steps[ship.activePlan.currentStepIndex];
const orderQueue = ship.orderQueue.length > 0
? ship.orderQueue.slice(0, 4).map((order) => `${getShipOrderLabel(order.kind)} [${order.status}]`).join("<br>")
: "none";
@@ -369,7 +368,7 @@ export function buildDetailPanelState(params: DetailPanelParams) {
<p>State ${shipState}</p>
<p>Order ${shipOrder}</p>
<p>Queue ${orderQueue}</p>
<p>Plan ${ship.activePlan ? `${ship.activePlan.kind} · ${ship.activePlan.status}` : "none"}${currentStep ? `<br>Step ${currentStep.kind} · ${currentStep.status}` : ""}</p>
<p>Activity ${subTaskList}</p>
<p>Subtasks ${subTaskList}</p>
${ship.lastReplanReason ? `<p>Last replan ${ship.lastReplanReason}</p>` : ""}
${ship.lastAccessFailureReason ? `<p>Access ${ship.lastAccessFailureReason}</p>` : ""}
@@ -463,7 +462,7 @@ export function buildDetailPanelState(params: DetailPanelParams) {
<p>${celestial.systemId}</p>
<p>Parent ${celestial.parentAnchorId ?? "none"}<br>Orbit ref ${celestial.orbitReferenceId ?? "none"}</p>
<p>Occupying structure ${celestial.occupyingStructureId ?? "none"}</p>
<p>Local space radius ${celestial.localSpaceRadius.toFixed(0)} km</p>
<p>Local space radius ${celestial.localSpaceRadius.toFixed(0)} m</p>
`,
};
}

View File

@@ -30,6 +30,7 @@ export interface ViewerPresentationContext {
getCameraMode: () => any;
getCameraTargetShipId: () => string | undefined;
getPovLevel: () => any;
getFocusedAnchorId: () => string | undefined;
getSelectedItems: () => Selectable[];
getWorldTimeSyncMs: () => number;
getCurrentDistance: () => number;
@@ -106,12 +107,52 @@ export class ViewerPresentationController {
applyZoomPresentation() {
const povLevel = this.context.getPovLevel();
const world = this.context.getWorld();
const focusedAnchorId = this.context.getFocusedAnchorId();
const focusedAnchor = focusedAnchorId ? world?.anchors.get(focusedAnchorId) : undefined;
const focusedPlanetMatch = focusedAnchorId?.match(/^node-[^-]+-planet-(\d+)$/);
const focusedMoonMatch = focusedAnchorId?.match(/^node-[^-]+-planet-(\d+)-moon-(\d+)$/);
const focusedPlanetIndex = focusedMoonMatch
? Number.parseInt(focusedMoonMatch[1], 10) - 1
: (focusedPlanetMatch ? Number.parseInt(focusedPlanetMatch[1], 10) - 1 : undefined);
const focusedMoonIndex = focusedMoonMatch ? Number.parseInt(focusedMoonMatch[2], 10) - 1 : undefined;
this.context.galaxyScene.fog = new THREE.FogExp2(0x040912, 0.000035);
const showPlanetIcons = povLevel !== "local";
for (const visual of this.context.planetVisuals) {
for (const [planetIndex, visual] of this.context.planetVisuals.entries()) {
visual.icon.setVisible(showPlanetIcons);
if (povLevel === "local") {
const showPlanetMesh = focusedAnchor?.kind === "planet"
? planetIndex === focusedPlanetIndex
: focusedAnchor?.kind === "moon"
? planetIndex === focusedPlanetIndex
: false;
visual.mesh.setVisible(showPlanetMesh);
visual.orbit.setVisible(false);
if (visual.ring) {
visual.ring.setVisible(showPlanetMesh);
}
for (const [moonIndex, moon] of visual.moons.entries()) {
const showMoonMesh = focusedAnchor?.kind === "moon"
&& planetIndex === focusedPlanetIndex
&& moonIndex === focusedMoonIndex;
moon.mesh.setVisible(showMoonMesh);
moon.icon.setVisible(false);
moon.orbit.setVisible(false);
}
continue;
}
visual.mesh.setVisible(true);
visual.orbit.setVisible(true);
if (visual.ring) {
visual.ring.setVisible(true);
}
}
for (const systemVisual of this.context.systemVisuals.values()) {
systemVisual.starCluster.setVisible(povLevel !== "local" || focusedAnchor?.kind === "star");
}
}

View File

@@ -4,20 +4,20 @@ import type { ShipSnapshot } from "./contracts";
export function shipSize(ship: ShipSnapshot) {
switch (ship.type) {
case "carrier":
return 0.018;
return 18;
case "battleship":
return 0.012;
return 12;
case "destroyer":
return 0.009;
return 9;
case "builder":
case "freighter":
case "transporter":
case "resupplier":
case "miner":
case "largeminer":
return 0.01;
return 10;
default:
return 0.007;
return 7;
}
}

View File

@@ -17,6 +17,8 @@ import {
import {
createClaimMesh,
createConstructionSiteMesh,
createLocalResourceDepositMesh,
createLocalResourceNodeMesh,
createNodeMesh,
createResourceDepositMesh,
createShipMesh,
@@ -182,7 +184,7 @@ export class ViewerSceneDataController {
continue;
}
const mesh = createNodeMesh(node);
const mesh = createLocalResourceNodeMesh(node);
const icon = createTacticalIcon(this.context.documentRef, node.sourceKind === "gas-cloud" ? "#7fd6ff" : "#d2b07a", 100);
const localPosition = new THREE.Vector3(0, 0, 0);
mesh.setPosition(localPosition);
@@ -201,7 +203,7 @@ export class ViewerSceneDataController {
});
this.context.localNodeGroup.add(rawObject(mesh), rawObject(icon));
for (const deposit of node.deposits) {
const depositMesh = createResourceDepositMesh(deposit, node);
const depositMesh = createLocalResourceDepositMesh(deposit, node);
this.context.localNodeGroup.add(rawObject(depositMesh));
}
registerSelectableTarget(this.context.localSelectableTargets, mesh, { kind: "node", id: node.id });

View File

@@ -64,6 +64,47 @@ export function createResourceDepositMesh(deposit: ResourceDepositSnapshot, node
return createSceneNode(mesh);
}
export function createLocalResourceNodeMesh(node: ResourceNodeSnapshot): SceneNode {
const isGas = node.sourceKind === "gas-cloud" || node.itemId === "gas";
const oreRatio = node.maxOre <= 0.01 ? 0 : node.oreRemaining / node.maxOre;
const radius = isGas
? 120 + (oreRatio * 30)
: 55 + (oreRatio * 20);
const mesh = new THREE.Mesh(
new THREE.SphereGeometry(radius, isGas ? 18 : 16, isGas ? 18 : 16),
new THREE.MeshStandardMaterial({
color: isGas ? 0x7fd6ff : 0xb28b59,
roughness: isGas ? 0.4 : 0.92,
metalness: isGas ? 0.06 : 0.08,
transparent: isGas,
opacity: isGas ? 0.5 : 1,
emissive: new THREE.Color(isGas ? 0x7fd6ff : 0xb28b59).multiplyScalar(isGas ? 0.18 : 0.03),
}),
);
return createSceneNode(mesh);
}
export function createLocalResourceDepositMesh(deposit: ResourceDepositSnapshot, node: ResourceNodeSnapshot): SceneNode {
const isGas = node.sourceKind === "gas-cloud" || node.itemId === "gas";
const oreRatio = deposit.maxOre <= 0.01 ? 0 : deposit.oreRemaining / deposit.maxOre;
const radius = isGas
? 8 + (oreRatio * 4)
: 3 + (oreRatio * 4);
const mesh = new THREE.Mesh(
new THREE.SphereGeometry(radius, 12, 12),
new THREE.MeshStandardMaterial({
color: isGas ? 0x92deff : 0xd0ad77,
roughness: isGas ? 0.36 : 0.95,
metalness: isGas ? 0.04 : 0.02,
transparent: isGas,
opacity: isGas ? 0.58 : 1,
emissive: new THREE.Color(isGas ? 0x92deff : 0xd0ad77).multiplyScalar(isGas ? 0.16 : 0.025),
}),
);
mesh.position.copy(toThreeVector(deposit.localPosition));
return createSceneNode(mesh);
}
export function createCelestialMesh(node: CelestialSnapshot, celestialColor: (kind: string) => string): SceneNode {
const color = celestialColor(node.kind);
return createSceneNode(new THREE.Mesh(
@@ -229,17 +270,31 @@ export function createStationMesh(station: StationSnapshot): SceneNode {
}
export function createShipMesh(ship: ShipSnapshot, size: number, length: number, color: string): SceneNode {
const geometry = new THREE.ConeGeometry(size, length, 7);
geometry.rotateX(Math.PI / 2);
const mesh = new THREE.Mesh(
geometry,
new THREE.MeshStandardMaterial({
color,
emissive: new THREE.Color(color).multiplyScalar(0.18),
}),
const root = new THREE.Group();
const material = new THREE.MeshStandardMaterial({
color,
emissive: new THREE.Color(color).multiplyScalar(0.28),
});
const bodyRadius = Math.max(size * 0.48, 2.4);
const bodyLength = Math.max(length - (bodyRadius * 1.8), bodyRadius * 1.2);
const body = new THREE.Mesh(
new THREE.CapsuleGeometry(bodyRadius, bodyLength, 6, 12),
material,
);
mesh.position.copy(toThreeVector(ship.localPosition));
return createSceneNode(mesh);
body.rotation.x = Math.PI / 2;
root.add(body);
const nose = new THREE.Mesh(
new THREE.ConeGeometry(Math.max(bodyRadius * 0.72, 1.8), Math.max(bodyRadius * 1.4, 3.2), 8),
material,
);
nose.rotation.x = Math.PI / 2;
nose.position.z = (bodyLength * 0.5) + (bodyRadius * 0.55);
root.add(nose);
root.position.copy(toThreeVector(ship.localPosition));
return createSceneNode(root);
}
function createStarGlowTexture(documentRef: Document): THREE.CanvasTexture {

View File

@@ -65,7 +65,7 @@ import {
} from "./viewerScenePrimitives";
import type { SceneNode } from "./viewerScenePrimitives";
/** Scale a local km position to system-scene display coordinates. */
/** Scale a system-space kilometer position to system-scene display coordinates. */
function toSystemPos(localPosition: THREE.Vector3): THREE.Vector3 {
return localPosition.clone().multiplyScalar(DISPLAY_UNITS_PER_KILOMETER * ACTIVE_SYSTEM_DETAIL_SCALE);
}

View File

@@ -274,10 +274,10 @@ export function resolveFocusedAnchorId(world: WorldState | undefined, selectedIt
return orbitBackedAnchor?.id;
}
if (selected.kind === "planet") {
return `${selected.systemId}-planet-${selected.planetIndex + 1}`;
return `node-${selected.systemId}-planet-${selected.planetIndex + 1}`;
}
if (selected.kind === "moon") {
return `${selected.systemId}-planet-${selected.planetIndex + 1}-moon-${selected.moonIndex + 1}`;
return `node-${selected.systemId}-planet-${selected.planetIndex + 1}-moon-${selected.moonIndex + 1}`;
}
return undefined;
@@ -429,8 +429,7 @@ export function describeShipBehavior(ship: ShipSnapshot): string {
}
export function describeShipOrder(ship: ShipSnapshot): string {
const activeOrder = [...ship.orderQueue]
.sort((left, right) => right.priority - left.priority || left.createdAtUtc.localeCompare(right.createdAtUtc))[0];
const activeOrder = ship.orderQueue.find((order) => order.status === "queued" || order.status === "active");
if (activeOrder) {
return activeOrder.label ?? getShipOrderLabel(activeOrder.kind);
}
@@ -439,10 +438,6 @@ export function describeShipOrder(ship: ShipSnapshot): string {
return describeShipObjective(ship.assignment.kind);
}
if (ship.activePlan) {
return ship.activePlan.summary || ship.activePlan.kind;
}
return getShipBehaviorLabel(ship.defaultBehavior.kind);
}

View File

@@ -1,6 +1,5 @@
import { fetchWorldSnapshot, openWorldStream } from "./api";
import type { ViewerHudState } from "./viewerHudState";
import { buildOpsStripState } from "./viewerOpsStrip";
import { useGmStore } from "./ui/stores/gmStore";
import { usePlayerFactionStore } from "./ui/stores/playerFactionStore";
import { viewerPinia } from "./ui/stores/pinia";
@@ -193,15 +192,6 @@ export class ViewerWorldLifecycle {
}
rebuildFactions(_factions: FactionSnapshot[]) {
this.context.hudState.opsStrip = buildOpsStripState(
this.context.getWorld(),
this.context.getSelectedItems(),
this.context.getCameraMode(),
this.context.getCameraTargetShipId(),
this.context.getPovLevel(),
this.context.getActiveSystemId(),
);
const world = this.context.getWorld();
if (world) {
useGmStore(viewerPinia).updateWorld(

View File

@@ -3,6 +3,7 @@ import {
DISPLAY_UNITS_PER_KILOMETER,
DISPLAY_UNITS_PER_LIGHT_YEAR,
KILOMETERS_PER_AU,
METERS_PER_KILOMETER,
computeMoonLocalPosition,
computePlanetLocalPosition,
currentWorldTimeSeconds,
@@ -137,8 +138,7 @@ export function updateWorldPresentation(context: WorldPresentationContext) {
}
const localPosition = getAnimatedShipLocalPosition(visual, now);
const displayPosition = context.toDisplayLocalPosition(localPosition);
visual.mesh.setPosition(displayPosition);
visual.mesh.setPosition(localPosition);
visual.icon.setPosition(rawObject(visual.mesh).position.clone());
visual.mesh.setVisible(renderMode === "local");
visual.icon.setVisible(renderMode === "local");
@@ -201,29 +201,28 @@ export function updateWorldPresentation(context: WorldPresentationContext) {
}
for (const visual of context.localNodeVisuals.values()) {
visual.mesh.setPosition(context.toDisplayLocalPosition(visual.localPosition.clone()));
visual.mesh.setPosition(visual.localPosition.clone());
visual.icon.setPosition(rawObject(visual.mesh).position.clone());
visual.mesh.setVisible(renderMode === "local");
visual.icon.setVisible(renderMode === "local");
}
for (const visual of context.localStationVisuals.values()) {
const displayPosition = context.toDisplayLocalPosition(visual.localPosition.clone());
visual.mesh.setPosition(displayPosition);
visual.mesh.setPosition(visual.localPosition.clone());
visual.icon.setPosition(rawObject(visual.mesh).position.clone());
visual.mesh.setVisible(renderMode === "local");
visual.icon.setVisible(renderMode === "local");
}
for (const visual of context.localClaimVisuals.values()) {
visual.mesh.setPosition(context.toDisplayLocalPosition(visual.localPosition.clone()));
visual.mesh.setPosition(visual.localPosition.clone());
visual.icon.setPosition(rawObject(visual.mesh).position.clone());
visual.mesh.setVisible(renderMode === "local");
visual.icon.setVisible(renderMode === "local");
}
for (const visual of context.localConstructionSiteVisuals.values()) {
visual.mesh.setPosition(context.toDisplayLocalPosition(visual.localPosition.clone()));
visual.mesh.setPosition(visual.localPosition.clone());
visual.icon.setPosition(rawObject(visual.mesh).position.clone());
visual.mesh.setVisible(renderMode === "local");
visual.icon.setVisible(renderMode === "local");
@@ -409,16 +408,12 @@ export function describeGameStatus(params: GameStatusParams) {
? `gal pos: ${fmtVec(galaxyAnchor.clone().divideScalar(DISPLAY_UNITS_PER_LIGHT_YEAR), 2)} ly`
: "";
// System space: systemAnchor in AU — changes only during system navigation
const sysPos = systemAnchor
const sysPos = povLevel !== "local" && systemAnchor
? `sys pos: ${fmtVec(systemAnchor.clone().divideScalar(KILOMETERS_PER_AU), 3)} AU`
: "";
// Local space: position relative to the focused celestial's orbital anchor in km
const focusedAnchorId = resolveFocusedAnchorId(world, selectedItems);
const celestialAnchor = focusedAnchorId
? (world?.anchors.get(focusedAnchorId)?.systemPosition ?? world?.celestials.get(focusedAnchorId)?.orbitalAnchor)
: undefined;
const locPos = systemAnchor && celestialAnchor
? `loc pos: ${fmtVec(systemAnchor.clone().sub(toThreeVector(celestialAnchor)), 0)} km`
// Local space: local focus in meters relative to the focused anchor
const locPos = povLevel === "local" && systemAnchor
? `loc pos: ${fmtVec(systemAnchor, 0)} m`
: "";
return {
@@ -445,6 +440,33 @@ export function updateGameStatus(params: GameStatusParams & { statusEl: HTMLDivE
}
}
export function resolveLocalAnchorOffset(world: WorldState | undefined, focusedAnchorId?: string): THREE.Vector3 {
if (!world || !focusedAnchorId) {
return new THREE.Vector3(0, 0, 0);
}
const anchor = world.anchors.get(focusedAnchorId);
if (!anchor) {
return new THREE.Vector3(0, 0, 0);
}
if (anchor.kind === "lagrange-point" || anchor.kind === "resource-node") {
return new THREE.Vector3(0, 0, 0);
}
const bodyRadiusMeters = resolveAnchorBodyRadius(world, anchor) * METERS_PER_KILOMETER;
if (bodyRadiusMeters <= 1) {
return new THREE.Vector3(0, 0, 0);
}
const safeOffset = Math.min(
Math.max(bodyRadiusMeters * 1.08, 120),
Math.max(anchor.localSpaceRadius * 0.55, 300),
);
return new THREE.Vector3(-safeOffset, 0, 0);
}
export function deriveNodeOrbital(
context: WorldOrbitalContext,
node: ResourceNodeSnapshot | ResourceNodeDelta,
@@ -536,6 +558,32 @@ export function resolvePointPosition(context: WorldOrbitalContext, _systemId: st
return new THREE.Vector3(0, 0, 0);
}
function resolveAnchorBodyRadius(world: WorldState, anchor: { id: string; systemId: string; kind: string }) {
const system = world.systems.get(anchor.systemId);
if (!system) {
return 0;
}
if (anchor.kind === "star") {
return system.stars[0]?.size ?? 0;
}
const planetMatch = /^node-[^-]+-planet-(\d+)$/.exec(anchor.id);
if (planetMatch) {
const planetIndex = Number.parseInt(planetMatch[1], 10) - 1;
return system.planets[planetIndex]?.size ?? 0;
}
const moonMatch = /^node-[^-]+-planet-(\d+)-moon-(\d+)$/.exec(anchor.id);
if (moonMatch) {
const planetIndex = Number.parseInt(moonMatch[1], 10) - 1;
const moonIndex = Number.parseInt(moonMatch[2], 10) - 1;
return system.planets[planetIndex]?.moons[moonIndex]?.size ?? 0;
}
return 0;
}
export function computeCelestialLocalPosition(context: WorldOrbitalContext, visual: CelestialVisual, timeSeconds: number) {
return computeCelestialLocalPositionById(context, visual.id, timeSeconds) ?? visual.orbitalAnchor.clone();
}