Expand sandbox game loop prototype

This commit is contained in:
2026-05-31 12:38:21 -04:00
parent 7d9095b933
commit 436f282fa8
18 changed files with 1028 additions and 225 deletions

View File

@@ -1,6 +1,5 @@
import { SliceActionRail } from "./ui/SliceActionRail"; import { SliceActionRail } from "./ui/SliceActionRail";
import { SliceCargoPanel } from "./ui/SliceCargoPanel"; import { SliceCargoPanel } from "./ui/SliceCargoPanel";
import { SliceDemoLinks } from "./ui/SliceDemoLinks";
import { SliceEventLog } from "./ui/SliceEventLog"; import { SliceEventLog } from "./ui/SliceEventLog";
import { SliceModuleRack } from "./ui/SliceModuleRack"; import { SliceModuleRack } from "./ui/SliceModuleRack";
import { SliceObjectiveTracker } from "./ui/SliceObjectiveTracker"; import { SliceObjectiveTracker } from "./ui/SliceObjectiveTracker";
@@ -9,21 +8,9 @@ import { SliceShipStatus } from "./ui/SliceShipStatus";
import { SliceStage } from "./ui/SliceStage"; import { SliceStage } from "./ui/SliceStage";
import { SliceStationPanel } from "./ui/SliceStationPanel"; import { SliceStationPanel } from "./ui/SliceStationPanel";
import { SliceTopBar } from "./ui/SliceTopBar"; import { SliceTopBar } from "./ui/SliceTopBar";
import { sliceButton, slicePanel, slicePrimaryButton } from "./ui/sliceStyles"; import { slicePanel, slicePrimaryButton } from "./ui/sliceStyles";
import { useSliceController } from "./useSliceController"; import { useSliceController } from "./useSliceController";
import type { SliceCommand } from "./sliceController"; import type { SliceActionId } from "./sliceController";
const COMMAND_LABELS: Record<SliceCommand, string> = {
undock: "Undock",
travelToBelt: "Set Course to Belt",
travelToStation: "Return to Station",
dock: "Dock",
mine: "Activate Mining Laser",
openRefining: "Open Refining",
openFitting: "Open Fitting",
openMarket: "Open Market",
startCombat: "Start Combat Trial",
};
function LoadingSlice({ message, onReset }: { message: string; onReset: () => void }) { function LoadingSlice({ message, onReset }: { message: string; onReset: () => void }) {
return ( return (
@@ -42,16 +29,22 @@ export function SeamlessGameLoopSlice() {
const { const {
session, session,
facts, facts,
primaryCommand, contextualActions,
operationProgress, operationProgress,
reset, reset,
missingRequestedSession, missingRequestedSession,
blockedReason,
startUndock, startUndock,
travelToBelt, selectTarget,
travelToStation, selectPoiTarget,
selectSystemPoiTarget,
selectManualTarget,
approachSelectedTarget,
approachTarget,
dock, dock,
mine, dockAtTarget,
startMining,
mineTarget,
stopMining,
openService, openService,
closeService, closeService,
startCombat, startCombat,
@@ -61,28 +54,25 @@ export function SeamlessGameLoopSlice() {
emit, emit,
} = controller; } = controller;
if (!session || !facts || !primaryCommand) { if (!session || !facts || !contextualActions) {
return <LoadingSlice message={missingRequestedSession ? `Slice session ${missingRequestedSession} was not found.` : "Creating slice session..."} onReset={reset} />; return <LoadingSlice message={missingRequestedSession ? `Slice session ${missingRequestedSession} was not found.` : "Creating slice session..."} onReset={reset} />;
} }
const runCommand = (command: SliceCommand) => { const runAction = (actionId: SliceActionId) => {
const actions: Record<SliceCommand, () => void> = { const actions: Record<SliceActionId, () => void> = {
undock: startUndock, undock: startUndock,
travelToBelt, approachTarget: approachSelectedTarget,
travelToStation,
dock, dock,
mine, startMining,
stopMining,
openRefining: () => openService("refining"), openRefining: () => openService("refining"),
openFitting: () => openService("fitting"), openFitting: () => openService("fitting"),
openMarket: () => openService("market"), openMarket: () => openService("market"),
startCombat, startCombat,
}; };
actions[command](); actions[actionId]();
}; };
const reason = blockedReason(primaryCommand);
const canRunPrimary = reason === null;
return ( return (
<SliceShell <SliceShell
top={<SliceTopBar session={session} onReset={reset} />} top={<SliceTopBar session={session} onReset={reset} />}
@@ -98,18 +88,19 @@ export function SeamlessGameLoopSlice() {
onRefine={refineVeldsparStack} onRefine={refineVeldsparStack}
onUpdateFit={updateFitting} onUpdateFit={updateFitting}
onSell={sellStack} onSell={sellStack}
onPoiTarget={selectPoiTarget}
onSystemPoiTarget={selectSystemPoiTarget}
onSelectTarget={selectTarget}
onManualTarget={selectManualTarget}
onApproachTarget={approachTarget}
onDockAtTarget={dockAtTarget}
onMineTarget={mineTarget}
/> />
} }
right={<><SliceObjectiveTracker session={session} /><SliceEventLog session={session} /><SliceDemoLinks session={session} /></>} right={<><SliceObjectiveTracker session={session} /><SliceEventLog session={session} /></>}
bottom={ bottom={
<div className="slice-bottom-content" style={{ height: "100%", minHeight: 0, display: "grid", gridTemplateColumns: "minmax(220px, 0.8fr) minmax(260px, 1.2fr)", gap: 8 }}> <div className="slice-bottom-content" style={{ height: "100%", minHeight: 0, display: "grid", gridTemplateColumns: "minmax(220px, 0.8fr) minmax(260px, 1.2fr)", gap: 8 }}>
<SliceActionRail> <SliceActionRail session={session} actions={contextualActions} onAction={runAction} />
<button style={canRunPrimary ? slicePrimaryButton : sliceButton} disabled={!canRunPrimary} title={reason ?? undefined} onClick={() => runCommand(primaryCommand)}>
{COMMAND_LABELS[primaryCommand]}
</button>
{reason && <span style={{ color: "#94a3b8", fontSize: 11, fontFamily: "var(--font-mono)" }}>{reason}</span>}
{session.activeService && <button style={sliceButton} onClick={closeService}>Close Service</button>}
</SliceActionRail>
<SliceModuleRack session={session} /> <SliceModuleRack session={session} />
</div> </div>
} }

View File

@@ -1,6 +1,7 @@
import { addCargoClamped, removeCargo, upsertCargo } from "./sliceEconomy"; import { addCargoClamped, removeCargo, upsertCargo } from "./sliceEconomy";
import { getNextObjective, withCompletedObjective } from "./sliceObjectives"; import { getNextObjective, withCompletedObjective } from "./sliceObjectives";
import type { GameSliceMode, GameSliceSession, LegacyGameSliceMode, SliceCargoItem, SliceEvent, SliceFittedModule } from "./types"; import { getSlicePoiTarget, getSystemIdFromPoiId, SLICE_BELT, SLICE_BELT_POSITION, SLICE_OPEN_SPACE_POSITION, SLICE_STATION, SLICE_STATION_POSITION } from "./sliceWorld";
import type { GameSliceMode, GameSliceSession, LegacyGameSliceMode, SliceCargoItem, SliceEvent, SliceFittedModule, SliceOperation, SliceTarget, SliceVec3 } from "./types";
const STORAGE_PREFIX = "void-slice.gameSession."; const STORAGE_PREFIX = "void-slice.gameSession.";
@@ -52,18 +53,79 @@ function migrateMode(mode: LegacyGameSliceMode | string | undefined): GameSliceM
return "station"; return "station";
} }
function positionForPoi(poiId: string | null | undefined): SliceVec3 {
if (poiId === SLICE_BELT.poiId) return SLICE_BELT_POSITION;
if (poiId === SLICE_STATION.poiId) return SLICE_STATION_POSITION;
return SLICE_OPEN_SPACE_POSITION;
}
function targetForPoi(poiId: string | null | undefined, fallbackName?: string, systemId = "sol"): SliceTarget | null {
const known = getSlicePoiTarget(poiId);
if (known) return known;
if (!poiId) return null;
const inferredSystemId = getSystemIdFromPoiId(poiId, systemId);
return {
id: poiId,
name: fallbackName ?? poiId,
kind: poiId.includes("-station-") ? "station" : poiId.includes("-belt-") ? "asteroid_belt" : "manual",
systemId: inferredSystemId,
poiId,
position: positionForPoi(poiId),
};
}
function migrateOperation(operation: SliceOperation | null | undefined, sessionLike: Partial<GameSliceSession>): SliceOperation | null {
if (!operation) return null;
if (operation.kind === "travel") {
const target = operation.target ?? targetForPoi(operation.targetPoiId, operation.targetPoiName, operation.targetSystemId) ?? {
id: operation.targetPoiId,
name: operation.targetPoiName,
kind: "manual",
systemId: operation.targetSystemId,
poiId: operation.targetPoiId,
position: positionForPoi(operation.targetPoiId),
};
return {
...operation,
target,
fromPosition: operation.fromPosition ?? sessionLike.shipPosition ?? positionForPoi(sessionLike.currentPoiId),
targetPosition: operation.targetPosition ?? target.position,
};
}
if (operation.kind === "mining") {
return {
...operation,
targetPoiId: operation.targetPoiId ?? SLICE_BELT.poiId,
targetName: operation.targetName ?? SLICE_BELT.name,
repeat: operation.repeat ?? false,
};
}
return operation;
}
export function createGameSliceSession(args: Partial<GameSliceSession> = {}): GameSliceSession { export function createGameSliceSession(args: Partial<GameSliceSession> = {}): GameSliceSession {
const completedObjectives = args.completedObjectives ?? []; const completedObjectives = args.completedObjectives ?? [];
const hasCurrentPoi = Object.prototype.hasOwnProperty.call(args, "currentPoiId");
const hasDockedStationPoi = Object.prototype.hasOwnProperty.call(args, "dockedStationPoiId");
const hasDockedStationName = Object.prototype.hasOwnProperty.call(args, "dockedStationName");
const currentPoiId = hasCurrentPoi ? args.currentPoiId ?? null : "sol-station-0";
const dockedStationPoiId = hasDockedStationPoi ? args.dockedStationPoiId ?? null : "sol-station-0";
const dockedStationName = hasDockedStationName ? args.dockedStationName ?? null : "Jita IV - Moon 4";
const shipPosition = args.shipPosition ?? positionForPoi(currentPoiId);
const activeOperation = migrateOperation(args.activeOperation, { ...args, currentPoiId, shipPosition });
return { return {
id: args.id ?? createGameSliceSessionId(), id: args.id ?? createGameSliceSessionId(),
mode: migrateMode(args.mode), mode: migrateMode(args.mode),
currentSystemId: args.currentSystemId ?? "sol", currentSystemId: args.currentSystemId ?? "sol",
currentPoiId: args.currentPoiId ?? "sol-station-0", currentPoiId,
dockedStationPoiId: args.dockedStationPoiId ?? "sol-station-0", dockedStationPoiId,
dockedStationName: args.dockedStationName ?? "Jita IV - Moon 4", dockedStationName,
activeTravelSessionId: args.activeTravelSessionId ?? null, activeTravelSessionId: args.activeTravelSessionId ?? null,
activeService: args.activeService ?? null, activeService: args.activeService ?? null,
activeOperation: args.activeOperation ?? null, activeOperation,
selectedTarget: args.selectedTarget ?? null,
shipPosition,
navigationMessage: args.navigationMessage ?? null,
wallet: args.wallet ?? 25000, wallet: args.wallet ?? 25000,
cargoCapacity: args.cargoCapacity ?? 2500, cargoCapacity: args.cargoCapacity ?? 2500,
cargo: args.cargo ?? [], cargo: args.cargo ?? [],
@@ -93,6 +155,9 @@ export function loadGameSliceSession(id: string): GameSliceSession | null {
activeTravelSessionId: parsed.activeTravelSessionId ?? null, activeTravelSessionId: parsed.activeTravelSessionId ?? null,
activeService: parsed.activeService ?? null, activeService: parsed.activeService ?? null,
activeOperation: parsed.activeOperation ?? null, activeOperation: parsed.activeOperation ?? null,
selectedTarget: parsed.selectedTarget ?? null,
shipPosition: parsed.shipPosition ?? positionForPoi(parsed.currentPoiId ?? null),
navigationMessage: parsed.navigationMessage ?? null,
cargo: parsed.cargo ?? [], cargo: parsed.cargo ?? [],
fittedModules: parsed.fittedModules ?? [], fittedModules: parsed.fittedModules ?? [],
zoraModules: parsed.zoraModules ?? ["comms"], zoraModules: parsed.zoraModules ?? ["comms"],
@@ -154,6 +219,12 @@ function messageForEvent(event: SliceEvent): string {
return `Docking approach started for ${event.stationName}.`; return `Docking approach started for ${event.stationName}.`;
case "navigation.started": case "navigation.started":
return `Set course to ${event.targetPoiName}.`; return `Set course to ${event.targetPoiName}.`;
case "navigation.targetSelected":
return `Selected ${event.target.name}.`;
case "navigation.manualWaypointSelected":
return `Selected manual waypoint ${event.target.name}.`;
case "navigation.approachStarted":
return `Approaching ${event.target.name}.`;
case "navigation.arrived": case "navigation.arrived":
return `Arrived at ${event.poiName}.`; return `Arrived at ${event.poiName}.`;
case "station.docked": case "station.docked":
@@ -164,6 +235,8 @@ function messageForEvent(event: SliceEvent): string {
return `Mining laser cycling on ${event.ore}.`; return `Mining laser cycling on ${event.ore}.`;
case "mining.completed": case "mining.completed":
return `Mined ${event.quantity.toLocaleString()} ${event.ore}.`; return `Mined ${event.quantity.toLocaleString()} ${event.ore}.`;
case "mining.stopped":
return "Mining lasers stopped.";
case "station.serviceOpened": case "station.serviceOpened":
return `${event.service[0].toUpperCase()}${event.service.slice(1)} service opened.`; return `${event.service[0].toUpperCase()}${event.service.slice(1)} service opened.`;
case "station.serviceClosed": case "station.serviceClosed":
@@ -217,63 +290,169 @@ export function applySliceEvent(session: GameSliceSession, event: SliceEvent): G
}; };
break; break;
case "navigation.started": case "navigation.started":
{
const target = targetForPoi(event.targetPoiId, event.targetPoiName, event.targetSystemId) ?? {
id: event.targetPoiId,
name: event.targetPoiName,
kind: "manual",
systemId: event.targetSystemId,
poiId: event.targetPoiId,
position: positionForPoi(event.targetPoiId),
};
next = {
...next,
mode: "travel",
activeService: null,
activeOperation: {
kind: "travel",
targetSystemId: event.targetSystemId,
targetPoiId: event.targetPoiId,
targetPoiName: event.targetPoiName,
target,
fromPosition: next.shipPosition,
targetPosition: target.position,
startedAt: event.startedAt,
durationMs: event.durationMs,
},
dockedStationPoiId: null,
dockedStationName: null,
selectedTarget: target,
navigationMessage: `Approaching ${target.name}`,
};
}
break;
case "navigation.targetSelected":
next = {
...next,
selectedTarget: event.target,
navigationMessage: `Selected ${event.target.name}`,
};
break;
case "navigation.manualWaypointSelected":
next = {
...next,
selectedTarget: event.target,
navigationMessage: `Selected ${event.target.name}`,
};
break;
case "navigation.approachStarted":
next = { next = {
...next, ...next,
mode: "travel", mode: "travel",
activeService: null, activeService: null,
activeOperation: { kind: "travel", targetSystemId: event.targetSystemId, targetPoiId: event.targetPoiId, targetPoiName: event.targetPoiName, startedAt: event.startedAt, durationMs: event.durationMs }, activeOperation: {
kind: "travel",
targetSystemId: event.target.systemId,
targetPoiId: event.target.poiId ?? event.target.id,
targetPoiName: event.target.name,
target: event.target,
fromPosition: event.fromPosition,
targetPosition: event.target.position,
startedAt: event.startedAt,
durationMs: event.durationMs,
},
dockedStationPoiId: null, dockedStationPoiId: null,
dockedStationName: null, dockedStationName: null,
selectedTarget: event.target,
navigationMessage: `Approaching ${event.target.name}`,
}; };
break; break;
case "navigation.arrived": case "navigation.arrived":
next = { {
...next, const arrivedTarget = next.activeOperation?.kind === "travel"
mode: "flight", ? next.activeOperation.target
currentSystemId: event.systemId, : targetForPoi(event.poiId, event.poiName, event.systemId);
currentPoiId: event.poiId, const currentPoiId = arrivedTarget?.poiId ?? null;
dockedStationPoiId: null, const shipPosition = arrivedTarget?.position ?? positionForPoi(currentPoiId);
dockedStationName: null, next = {
activeOperation: null, ...next,
}; mode: "flight",
if (event.poiId.includes("belt")) next = withCompletedObjective(next, "navigate_to_belt"); currentSystemId: event.systemId,
currentPoiId,
shipPosition,
selectedTarget: arrivedTarget,
navigationMessage: `Holding at ${event.poiName}.`,
dockedStationPoiId: null,
dockedStationName: null,
activeOperation: null,
};
if (currentPoiId?.includes("belt")) next = withCompletedObjective(next, "navigate_to_belt");
}
break; break;
case "station.docked": case "station.docked":
next = withCompletedObjective({ {
...next, const dockTarget = next.selectedTarget?.poiId === event.stationPoiId
mode: "station", ? next.selectedTarget
currentSystemId: event.systemId, : targetForPoi(event.stationPoiId, event.stationName, event.systemId);
currentPoiId: event.stationPoiId, next = {
dockedStationPoiId: event.stationPoiId, ...next,
dockedStationName: event.stationName, currentSystemId: event.systemId,
activeService: null, currentPoiId: event.stationPoiId,
activeOperation: null, shipPosition: dockTarget?.position ?? positionForPoi(event.stationPoiId),
}, "dock_at_station"); selectedTarget: dockTarget,
navigationMessage: `Docked at ${event.stationName}.`,
mode: "station",
dockedStationPoiId: event.stationPoiId,
dockedStationName: event.stationName,
activeService: null,
activeOperation: null,
};
}
next = withCompletedObjective(next, "dock_at_station");
break; break;
case "station.undocked": case "station.undocked":
next = withCompletedObjective({ {
...next, const currentTarget = next.selectedTarget?.poiId === next.currentPoiId
mode: "flight", ? next.selectedTarget
currentSystemId: event.systemId, : targetForPoi(next.currentPoiId, undefined, next.currentSystemId);
dockedStationPoiId: null, next = withCompletedObjective({
dockedStationName: null, ...next,
activeService: null, mode: "flight",
activeOperation: null, currentSystemId: event.systemId,
}, "undock"); dockedStationPoiId: null,
dockedStationName: null,
activeService: null,
activeOperation: null,
shipPosition: currentTarget?.position ?? positionForPoi(next.currentPoiId),
selectedTarget: null,
navigationMessage: "Undocked. Select a local target.",
}, "undock");
}
break; break;
case "mining.started": case "mining.started":
next = { {
...next, const miningTarget = next.selectedTarget?.poiId === event.targetPoiId
mode: "mining", ? next.selectedTarget
activeService: null, : targetForPoi(event.targetPoiId, event.targetName, next.currentSystemId);
activeOperation: { kind: "mining", ore: event.ore, quantity: event.quantity, startedAt: event.startedAt, durationMs: event.durationMs }, next = {
}; ...next,
mode: "mining",
activeService: null,
currentPoiId: event.targetPoiId,
shipPosition: miningTarget?.position ?? positionForPoi(event.targetPoiId),
selectedTarget: miningTarget,
navigationMessage: `Mining ${event.targetName}.`,
activeOperation: {
kind: "mining",
ore: event.ore,
quantity: event.quantity,
targetPoiId: event.targetPoiId,
targetName: event.targetName,
repeat: event.repeat,
startedAt: event.startedAt,
durationMs: event.durationMs,
},
};
}
break; break;
case "mining.completed": { case "mining.completed": {
const added = addCargoClamped(next, { item: event.ore, category: "ore", quantity: event.quantity, unitPrice: 12 }); const added = addCargoClamped(next, { item: event.ore, category: "ore", quantity: event.quantity, unitPrice: 12 });
next = withCompletedObjective({ ...added.session, mode: "flight", activeOperation: null }, "mine_ore"); next = withCompletedObjective({ ...added.session, mode: "flight", activeOperation: null, navigationMessage: `Cargo received: ${event.quantity.toLocaleString()} ${event.ore}.` }, "mine_ore");
break; break;
} }
case "mining.stopped":
next = { ...next, mode: "flight", activeOperation: null, navigationMessage: "Mining stopped." };
break;
case "station.serviceOpened": case "station.serviceOpened":
next = { ...next, mode: "services", activeService: event.service, activeOperation: null }; next = { ...next, mode: "services", activeService: event.service, activeOperation: null };
break; break;

View File

@@ -1,18 +1,26 @@
import { getCargoUsed, sellableCargo } from "./sliceEconomy"; import { getCargoUsed, sellableCargo } from "./sliceEconomy";
import type { GameSliceSession, SliceObjectiveId, SliceService } from "./types"; import type { GameSliceSession, SliceService } from "./types";
import { SLICE_BELT, SLICE_STATION } from "./sliceWorld"; import { isBeltPoi, isStationPoi, readablePoiName } from "./sliceWorld";
export type SliceCommand = export type SliceActionId =
| "undock" | "undock"
| "travelToBelt" | "approachTarget"
| "travelToStation"
| "dock" | "dock"
| "mine" | "startMining"
| "stopMining"
| "openRefining" | "openRefining"
| "openFitting" | "openFitting"
| "openMarket" | "openMarket"
| "startCombat"; | "startCombat";
export type SliceAction = {
id: SliceActionId;
label: string;
enabled: boolean;
reason: string | null;
tone?: "primary" | "normal" | "danger" | "debug";
};
export type SliceFacts = { export type SliceFacts = {
activePoiName: string; activePoiName: string;
cargoUsed: number; cargoUsed: number;
@@ -27,15 +35,25 @@ export type SliceFacts = {
canMine: boolean; canMine: boolean;
}; };
const busyReason = (session: GameSliceSession) => {
const operation = session.activeOperation;
if (!operation) return "Another ship operation is already in progress.";
if (operation.kind === "travel") return `Approaching ${operation.target.name}.`;
if (operation.kind === "mining") return `Mining cycle active at ${operation.targetName}.`;
if (operation.kind === "docking") return `Docking at ${operation.stationName}.`;
if (operation.kind === "undocking") return "Undocking sequence active.";
return "Another ship operation is already in progress.";
};
export function getSliceFacts(session: GameSliceSession): SliceFacts { export function getSliceFacts(session: GameSliceSession): SliceFacts {
const cargoUsed = getCargoUsed(session); const cargoUsed = getCargoUsed(session);
const oreQuantity = session.cargo.find((item) => item.item === "Veldspar")?.quantity ?? 0; const oreQuantity = session.cargo.find((item) => item.item === "Veldspar")?.quantity ?? 0;
const isDocked = Boolean(session.dockedStationPoiId); const isDocked = Boolean(session.dockedStationPoiId);
const isAtBelt = session.currentPoiId === SLICE_BELT.poiId; const isAtBelt = isBeltPoi(session.currentPoiId);
const isAtStation = session.currentPoiId === SLICE_STATION.poiId; const isAtStation = isStationPoi(session.currentPoiId);
const isBusy = Boolean(session.activeOperation); const isBusy = Boolean(session.activeOperation);
return { return {
activePoiName: session.currentPoiId === SLICE_BELT.poiId ? SLICE_BELT.name : session.currentPoiId === SLICE_STATION.poiId ? SLICE_STATION.name : session.currentPoiId ?? "Open space", activePoiName: session.selectedTarget?.poiId === session.currentPoiId ? session.selectedTarget.name : readablePoiName(session.currentPoiId),
cargoUsed, cargoUsed,
freeCargo: Math.max(0, session.cargoCapacity - cargoUsed), freeCargo: Math.max(0, session.cargoCapacity - cargoUsed),
oreQuantity, oreQuantity,
@@ -57,57 +75,82 @@ export function canUseStationService(session: GameSliceSession, service: SliceSe
return true; return true;
} }
export function getBlockedReason(session: GameSliceSession, command: SliceCommand): string | null { export function getActionBlockedReason(session: GameSliceSession, actionId: SliceActionId): string | null {
const facts = getSliceFacts(session); const facts = getSliceFacts(session);
if (facts.isBusy) return "Another ship operation is already in progress."; const busy = session.activeOperation ? busyReason(session) : null;
switch (command) { switch (actionId) {
case "undock": case "undock":
if (busy) return busy;
return facts.isDocked ? null : "Docking clamps are already released."; return facts.isDocked ? null : "Docking clamps are already released.";
case "travelToBelt": case "approachTarget":
if (facts.isDocked) return "Undock before plotting local travel."; if (busy) return busy;
if (facts.isAtBelt) return "Already holding at the belt."; if (facts.isDocked) return "Undock before local navigation.";
return null; if (!session.selectedTarget) return "Select a local target first.";
case "travelToStation": if (session.selectedTarget.poiId && session.currentPoiId === session.selectedTarget.poiId) return `Already holding at ${session.selectedTarget.name}.`;
if (facts.isDocked) return "Already docked at the station.";
if (facts.isAtStation) return null;
return null; return null;
case "dock": case "dock":
if (busy) return busy;
if (facts.isDocked) return "Already docked."; if (facts.isDocked) return "Already docked.";
return facts.isAtStation ? null : "Return to the station grid before docking."; return facts.isAtStation ? null : "Approach the station grid before docking.";
case "mine": case "startMining":
if (!facts.isAtBelt) return "Mining requires the asteroid belt."; if (busy) return busy;
if (!facts.isAtBelt) return "Approach the asteroid belt before mining.";
if (facts.isDocked) return "Undock before activating mining lasers."; if (facts.isDocked) return "Undock before activating mining lasers.";
if (facts.freeCargo <= 0) return "Cargo hold is full."; if (facts.freeCargo <= 0) return "Cargo hold is full.";
return null; return null;
case "stopMining":
return session.activeOperation?.kind === "mining" ? null : "No mining cycle is active.";
case "openRefining": case "openRefining":
if (busy) return busy;
if (!facts.isDocked) return "Dock at the station to use refining."; if (!facts.isDocked) return "Dock at the station to use refining.";
return facts.hasRefinableOre ? null : "Refining needs at least 333 Veldspar."; return facts.hasRefinableOre ? null : "Refining needs at least 333 Veldspar.";
case "openFitting": case "openFitting":
if (busy) return busy;
return facts.isDocked ? null : "Dock at the station to use fitting."; return facts.isDocked ? null : "Dock at the station to use fitting.";
case "openMarket": case "openMarket":
if (busy) return busy;
if (!facts.isDocked) return "Dock at the station to use the market."; if (!facts.isDocked) return "Dock at the station to use the market.";
return facts.hasSellableCargo ? null : "No sellable cargo is available."; return facts.hasSellableCargo ? null : "No sellable cargo is available.";
case "startCombat": case "startCombat":
return null; if (busy) return busy;
return facts.isDocked ? "Undock before starting a combat trial." : null;
} }
} }
export function getAvailableSliceCommands(session: GameSliceSession): Record<SliceCommand, boolean> { const action = (session: GameSliceSession, id: SliceActionId, label: string, tone: SliceAction["tone"] = "normal"): SliceAction => {
const commands: SliceCommand[] = ["undock", "travelToBelt", "travelToStation", "dock", "mine", "openRefining", "openFitting", "openMarket", "startCombat"]; const reason = getActionBlockedReason(session, id);
return Object.fromEntries(commands.map((command) => [command, getBlockedReason(session, command) === null])) as Record<SliceCommand, boolean>; return { id, label, enabled: reason === null, reason, tone };
} };
export function commandForObjective(session: GameSliceSession): SliceCommand { export function getContextualSliceActions(session: GameSliceSession): SliceAction[] {
const byObjective: Record<SliceObjectiveId, SliceCommand> = { const facts = getSliceFacts(session);
undock: "undock",
navigate_to_belt: "travelToBelt", if (session.activeOperation?.kind === "mining") {
mine_ore: "mine", return [action(session, "stopMining", "Stop Mining", "danger")];
dock_at_station: getSliceFacts(session).isAtStation ? "dock" : "travelToStation", }
refine_ore: "openRefining",
fit_module: "openFitting", if (session.activeOperation) {
sell_goods: "openMarket", const baseId: SliceActionId = session.activeOperation.kind === "travel" ? "approachTarget" : session.activeOperation.kind === "docking" ? "dock" : "undock";
combat_trial: "startCombat", const label = session.activeOperation.kind === "travel" ? "Approach Target" : session.activeOperation.kind === "docking" ? "Dock" : "Undock";
}; return [action(session, baseId, label, "primary")];
return byObjective[session.activeObjectiveId]; }
if (facts.isDocked) {
return [
action(session, "undock", "Undock", "primary"),
action(session, "openRefining", "Refining"),
action(session, "openFitting", "Fitting"),
action(session, "openMarket", "Market"),
];
}
const actions: SliceAction[] = [];
if (session.selectedTarget && (!session.selectedTarget.poiId || session.currentPoiId !== session.selectedTarget.poiId)) {
actions.push(action(session, "approachTarget", `Approach ${session.selectedTarget.name}`, "primary"));
}
if (facts.isAtStation) actions.push(action(session, "dock", "Dock", "primary"));
if (facts.isAtBelt) actions.push(action(session, "startMining", "Start Mining", "primary"));
actions.push(action(session, "startCombat", "Start Combat Trial"));
return actions;
} }

View File

@@ -1,5 +1,11 @@
import type { SliceTarget, SliceVec3 } from "./types";
export const SLICE_SYSTEM_ID = "sol"; export const SLICE_SYSTEM_ID = "sol";
export const SLICE_STATION_POSITION: SliceVec3 = [-10, 0, 10];
export const SLICE_BELT_POSITION: SliceVec3 = [26, 0, -8];
export const SLICE_OPEN_SPACE_POSITION: SliceVec3 = [0, 0, 0];
export const SLICE_STATION = { export const SLICE_STATION = {
systemId: SLICE_SYSTEM_ID, systemId: SLICE_SYSTEM_ID,
poiId: "sol-station-0", poiId: "sol-station-0",
@@ -23,3 +29,47 @@ export const SLICE_POI_NAMES: Record<string, string> = {
[SLICE_STATION.poiId]: SLICE_STATION.name, [SLICE_STATION.poiId]: SLICE_STATION.name,
[SLICE_BELT.poiId]: SLICE_BELT.name, [SLICE_BELT.poiId]: SLICE_BELT.name,
}; };
export const SLICE_TRAVEL_SYSTEM_IDS = ["sol", "amarr", "heinoo", "rens", "dodixie"] as const;
export function getSystemIdFromPoiId(poiId: string | null | undefined, fallback = SLICE_SYSTEM_ID): string {
if (!poiId) return fallback;
const match = /^(.+)-(station|belt|stargate|anomaly)/.exec(poiId);
return match?.[1] ?? fallback;
}
export function isStationPoi(poiId: string | null | undefined): boolean {
return Boolean(poiId?.includes("-station-"));
}
export function isBeltPoi(poiId: string | null | undefined): boolean {
return Boolean(poiId?.includes("-belt-"));
}
export function readablePoiName(poiId: string | null | undefined): string {
if (!poiId) return "Open space";
return SLICE_POI_NAMES[poiId] ?? poiId.replace(/-/g, " ").replace(/\b\w/g, (char) => char.toUpperCase());
}
export const SLICE_POI_TARGETS: Record<string, SliceTarget> = {
[SLICE_STATION.poiId]: {
id: SLICE_STATION.poiId,
name: SLICE_STATION.name,
kind: "station",
systemId: SLICE_STATION.systemId,
poiId: SLICE_STATION.poiId,
position: SLICE_STATION_POSITION,
},
[SLICE_BELT.poiId]: {
id: SLICE_BELT.poiId,
name: SLICE_BELT.name,
kind: "asteroid_belt",
systemId: SLICE_BELT.systemId,
poiId: SLICE_BELT.poiId,
position: SLICE_BELT_POSITION,
},
};
export function getSlicePoiTarget(poiId: string | null | undefined): SliceTarget | null {
return poiId ? SLICE_POI_TARGETS[poiId] ?? null : null;
}

View File

@@ -19,11 +19,34 @@ export type LegacyGameSliceMode =
export type SliceService = "refining" | "fitting" | "market"; export type SliceService = "refining" | "fitting" | "market";
export type SliceVec3 = [number, number, number];
export type SliceTargetKind = "station" | "asteroid_belt" | "manual" | "hostile";
export type SliceTarget = {
id: string;
name: string;
kind: SliceTargetKind;
systemId: string;
poiId?: string;
position: SliceVec3;
};
export type SliceOperation = export type SliceOperation =
| { kind: "undocking"; startedAt: number; durationMs: number } | { kind: "undocking"; startedAt: number; durationMs: number }
| { kind: "travel"; targetSystemId: string; targetPoiId: string; targetPoiName: string; startedAt: number; durationMs: number } | {
kind: "travel";
targetSystemId: string;
targetPoiId: string;
targetPoiName: string;
target: SliceTarget;
fromPosition: SliceVec3;
targetPosition: SliceVec3;
startedAt: number;
durationMs: number;
}
| { kind: "docking"; stationPoiId: string; stationName: string; startedAt: number; durationMs: number } | { kind: "docking"; stationPoiId: string; stationName: string; startedAt: number; durationMs: number }
| { kind: "mining"; ore: string; quantity: number; startedAt: number; durationMs: number }; | { kind: "mining"; ore: string; quantity: number; targetPoiId: string; targetName: string; repeat: boolean; startedAt: number; durationMs: number };
export type SliceObjectiveId = export type SliceObjectiveId =
| "undock" | "undock"
@@ -56,11 +79,15 @@ export type SliceEvent =
| { type: "station.undockStarted"; systemId: string; startedAt: number; durationMs: number } | { type: "station.undockStarted"; systemId: string; startedAt: number; durationMs: number }
| { type: "station.dockStarted"; stationPoiId: string; stationName: string; startedAt: number; durationMs: number } | { type: "station.dockStarted"; stationPoiId: string; stationName: string; startedAt: number; durationMs: number }
| { type: "navigation.started"; targetSystemId: string; targetPoiId: string; targetPoiName: string; startedAt: number; durationMs: number } | { type: "navigation.started"; targetSystemId: string; targetPoiId: string; targetPoiName: string; startedAt: number; durationMs: number }
| { type: "navigation.targetSelected"; target: SliceTarget }
| { type: "navigation.manualWaypointSelected"; target: SliceTarget }
| { type: "navigation.approachStarted"; target: SliceTarget; fromPosition: SliceVec3; startedAt: number; durationMs: number }
| { type: "navigation.arrived"; systemId: string; poiId: string; poiName: string } | { type: "navigation.arrived"; systemId: string; poiId: string; poiName: string }
| { type: "station.docked"; systemId: string; stationPoiId: string; stationName: string } | { type: "station.docked"; systemId: string; stationPoiId: string; stationName: string }
| { type: "station.undocked"; systemId: string } | { type: "station.undocked"; systemId: string }
| { type: "mining.started"; ore: string; quantity: number; startedAt: number; durationMs: number } | { type: "mining.started"; ore: string; quantity: number; targetPoiId: string; targetName: string; repeat: boolean; startedAt: number; durationMs: number }
| { type: "mining.completed"; ore: string; quantity: number } | { type: "mining.completed"; ore: string; quantity: number }
| { type: "mining.stopped" }
| { type: "station.serviceOpened"; service: SliceService } | { type: "station.serviceOpened"; service: SliceService }
| { type: "station.serviceClosed" } | { type: "station.serviceClosed" }
| { type: "refining.completed"; ore: string; inputQuantity: number; minerals: SliceCargoItem[] } | { type: "refining.completed"; ore: string; inputQuantity: number; minerals: SliceCargoItem[] }
@@ -81,6 +108,9 @@ export type GameSliceSession = {
activeTravelSessionId: string | null; activeTravelSessionId: string | null;
activeService: SliceService | null; activeService: SliceService | null;
activeOperation: SliceOperation | null; activeOperation: SliceOperation | null;
selectedTarget: SliceTarget | null;
shipPosition: SliceVec3;
navigationMessage: string | null;
wallet: number; wallet: number;
cargoCapacity: number; cargoCapacity: number;
cargo: SliceCargoItem[]; cargo: SliceCargoItem[];

View File

@@ -1,10 +1,46 @@
import type { ReactNode } from "react"; import type { SliceAction, SliceActionId } from "../sliceController";
import { slicePanel } from "./sliceStyles"; import type { GameSliceSession } from "../types";
import { metricLabel, sliceButton, slicePanel, slicePrimaryButton } from "./sliceStyles";
export function SliceActionRail({ children }: { children: ReactNode }) { const toneStyle = (action: SliceAction) => {
if (action.tone === "primary") return action.enabled ? slicePrimaryButton : sliceButton;
if (action.tone === "danger") return { ...sliceButton, border: "1px solid rgba(239,68,68,0.72)", color: action.enabled ? "#fca5a5" : "#64748b" };
if (action.tone === "debug") return { ...sliceButton, border: "1px dashed rgba(148,163,184,0.42)", color: action.enabled ? "#94a3b8" : "#64748b" };
return sliceButton;
};
export function SliceActionRail({
session,
actions,
onAction,
}: {
session: GameSliceSession;
actions: SliceAction[];
onAction: (actionId: SliceActionId) => void;
}) {
const selected = session.selectedTarget;
const disabledReasons = actions.filter((action) => !action.enabled && action.reason).map((action) => `${action.label}: ${action.reason}`);
return ( return (
<section style={{ ...slicePanel, height: "100%", minHeight: 0, padding: 12, display: "flex", gap: 8, alignItems: "center", justifyContent: "center", flexWrap: "wrap", boxSizing: "border-box", overflow: "auto" }}> <section className="slice-action-palette" style={{ ...slicePanel, height: "100%", minHeight: 0, padding: 12, display: "grid", gridTemplateColumns: "minmax(160px, 0.8fr) minmax(180px, 1.2fr)", gap: 10, alignItems: "center", boxSizing: "border-box", overflow: "auto" }}>
{children} <div style={{ minWidth: 0 }}>
<div style={metricLabel}>Selected Target</div>
<div style={{ marginTop: 4, color: selected ? "#f8fafc" : "#94a3b8", fontSize: 13, overflowWrap: "anywhere" }}>
{selected ? selected.name : "None selected"}
</div>
{session.navigationMessage && <div style={{ marginTop: 4, color: "#64748b", fontSize: 10, fontFamily: "var(--font-mono)", overflowWrap: "anywhere" }}>{session.navigationMessage}</div>}
</div>
<div style={{ minWidth: 0, display: "flex", gap: 8, alignItems: "center", justifyContent: "center", flexWrap: "wrap" }}>
{actions.map((action) => (
<button key={action.id} data-slice-action={action.id} style={toneStyle(action)} disabled={!action.enabled} title={action.reason ?? undefined} onClick={() => onAction(action.id)}>
{action.label}
</button>
))}
{disabledReasons.length > 0 && (
<span style={{ flexBasis: "100%", color: "#94a3b8", fontSize: 10, fontFamily: "var(--font-mono)", textAlign: "center", overflowWrap: "anywhere" }}>
{disabledReasons[0]}
</span>
)}
</div>
</section> </section>
); );
} }

View File

@@ -50,7 +50,7 @@ export function SliceCombatStage({ emit }: { emit: (event: SliceEvent) => void }
<MiniBar label="ARMOR" value={state.enemy.armor} color="#f0a030" /> <MiniBar label="ARMOR" value={state.enemy.armor} color="#f0a030" />
<MiniBar label="HULL" value={state.enemy.hull} color="#ef4444" /> <MiniBar label="HULL" value={state.enemy.hull} color="#ef4444" />
<button style={{ ...sliceButton, width: "100%", marginTop: 10 }} onClick={() => dispatch({ type: "startLock" })}>Lock Target</button> <button style={{ ...sliceButton, width: "100%", marginTop: 10 }} onClick={() => dispatch({ type: "startLock" })}>Lock Target</button>
<button style={{ ...sliceButton, width: "100%", marginTop: 8, border: "1px solid rgba(240,160,48,0.72)", color: "#f0a030" }} onClick={emitVictory}>Resolve Trial</button> <button style={{ ...sliceButton, width: "100%", marginTop: 8, border: "1px dashed rgba(148,163,184,0.52)", color: "#94a3b8", fontSize: 10 }} onClick={emitVictory}>Debug Resolve</button>
</div> </div>
<div style={{ ...slicePanel, padding: 10, display: "flex", gap: 8, justifyContent: "center", flexWrap: "wrap", pointerEvents: "auto", gridColumn: "1 / -1", alignSelf: "end" }}> <div style={{ ...slicePanel, padding: 10, display: "flex", gap: 8, justifyContent: "center", flexWrap: "wrap", pointerEvents: "auto", gridColumn: "1 / -1", alignSelf: "end" }}>
{state.modules.map((module) => ( {state.modules.map((module) => (

View File

@@ -1,66 +1,353 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState, type CSSProperties } from "react";
import { loadGalaxyData } from "../../r3f/shared/galaxyData"; import { loadGalaxyData } from "../../r3f/shared/galaxyData";
import { getSystemPoiPosition } from "../../r3f/shared/poiOrbit"; import { getSystemPoiPosition } from "../../r3f/shared/poiOrbit";
import type { GalaxySystem, Vec3 } from "../../r3f/shared/types"; import type { GalaxySystem, SystemPointOfInterest, Vec3 } from "../../r3f/shared/types";
import { MOVEMENT_SYSTEM_SCALE, MovementScene } from "../../r3f/movement/MovementScene"; import { localEntityPosition, MOVEMENT_SYSTEM_SCALE, MovementScene } from "../../r3f/movement/MovementScene";
import type { LocalEntity, LocalWaypoint } from "../../r3f/movement/movementState"; import type { LocalEntity, LocalWaypoint } from "../../r3f/movement/movementState";
import type { GameSliceSession } from "../types"; import type { GameSliceSession, SliceTarget, SliceTargetKind } from "../types";
import { SLICE_BELT, SLICE_POI_NAMES, SLICE_STATION } from "../sliceWorld"; import { SLICE_BELT, SLICE_BELT_POSITION, SLICE_POI_NAMES, SLICE_STATION, SLICE_STATION_POSITION, SLICE_TRAVEL_SYSTEM_IDS } from "../sliceWorld";
import { metricLabel, slicePanel } from "./sliceStyles"; import { metricLabel, sliceButton, slicePanel, slicePrimaryButton } from "./sliceStyles";
const fallbackEntities: LocalEntity[] = [ type ContextAction = {
{ id: "slice-belt", name: SLICE_BELT.name, type: "asteroid", x: 620, y: 320, distance: 42 }, label: string;
{ id: "slice-station", name: SLICE_STATION.name, type: "station", x: 430, y: 260, distance: 8 }, run: () => void;
]; disabled?: boolean;
};
type ContextMenu = {
x: number;
y: number;
title: string;
subtitle: string;
actions: ContextAction[];
};
const SYSTEM_NAMES: Record<string, string> = { sol: "Sol", amarr: "Amarr", heinoo: "Hek", rens: "Rens", dodixie: "Dodixie" };
const menuButtonStyle: CSSProperties = {
...sliceButton,
width: "100%",
justifyContent: "flex-start",
border: "0",
borderRadius: 0,
background: "transparent",
};
function fallbackPosition(poiId: string | null): Vec3 { function fallbackPosition(poiId: string | null): Vec3 {
if (poiId === SLICE_BELT.poiId) return [26, 0, -8]; if (poiId === SLICE_BELT.poiId) return SLICE_BELT_POSITION;
if (poiId === SLICE_STATION.poiId) return [-10, 0, 10]; if (poiId === SLICE_STATION.poiId) return SLICE_STATION_POSITION;
return [0, 0, 0]; return [0, 0, 0];
} }
export function SliceFlightStage({ session }: { session: GameSliceSession }) { function lerpVec3(from: Vec3, to: Vec3, progress: number): Vec3 {
const [currentSystem, setCurrentSystem] = useState<GalaxySystem | null>(null); return [
from[0] + (to[0] - from[0]) * progress,
from[1] + (to[1] - from[1]) * progress,
from[2] + (to[2] - from[2]) * progress,
];
}
function poiPosition(system: GalaxySystem, poiId: string): Vec3 {
return getSystemPoiPosition({
systemId: system.id,
planets: system.planets,
pointsOfInterest: system.pointsOfInterest,
poiId,
scale: MOVEMENT_SYSTEM_SCALE,
expanded: true,
});
}
function targetKind(poi: SystemPointOfInterest): SliceTargetKind {
if (poi.type === "station") return "station";
if (poi.type === "asteroid_belt") return "asteroid_belt";
return "manual";
}
function targetForPoi(systemId: string, poi: SystemPointOfInterest, position: Vec3): SliceTarget {
return {
id: poi.id,
name: poi.name,
kind: targetKind(poi),
systemId,
poiId: poi.id,
position,
};
}
function ambientTraffic(systemId: string, elapsed: number): LocalEntity[] {
return Array.from({ length: 4 }, (_, index) => {
const hostile = index === 2;
const seed = index * 1.9 + systemId.length * 0.37;
const radius = 135 + index * 34;
return {
id: `${systemId}-${hostile ? "hostile" : "traffic"}-${index}`,
name: hostile ? "Guristas Scout" : ["Survey Skiff", "Customs Cutter", "Ore Hauler", "Patrol Wing"][index],
type: hostile ? "hostile" : "friendly",
x: 400 + Math.cos(elapsed * (0.24 + index * 0.04) + seed) * radius,
y: 300 + Math.sin(elapsed * (0.2 + index * 0.03) + seed) * (radius * 0.62),
distance: Math.round(radius / 6),
};
});
}
export function SliceFlightStage({
session,
operationProgress,
onPoiTarget,
onSystemPoiTarget,
onSelectTarget,
onManualTarget,
onApproachTarget,
onDockAtTarget,
onMineTarget,
}: {
session: GameSliceSession;
operationProgress: number;
onPoiTarget: Parameters<typeof MovementScene>[0]["onPoiPick"];
onSystemPoiTarget: (systemId: string, poi: SystemPointOfInterest, position: Vec3) => void;
onSelectTarget: (target: SliceTarget) => void;
onManualTarget: Parameters<typeof MovementScene>[0]["onWaypointPick"];
onApproachTarget: (target: SliceTarget) => void;
onDockAtTarget: (target: SliceTarget) => void;
onMineTarget: (target: SliceTarget) => void;
}) {
const [systems, setSystems] = useState<GalaxySystem[]>([]);
const [elapsed, setElapsed] = useState(0);
const [contextMenu, setContextMenu] = useState<ContextMenu | null>(null);
useEffect(() => { useEffect(() => {
loadGalaxyData().then(({ systems }) => { loadGalaxyData().then(({ systems }) => setSystems(systems));
setCurrentSystem(systems.find((system) => system.id === session.currentSystemId) ?? systems.find((system) => system.id === "sol") ?? null); }, []);
});
}, [session.currentSystemId]);
const shipPosition = useMemo(() => { useEffect(() => {
const started = performance.now();
const id = window.setInterval(() => setElapsed((performance.now() - started) / 1000), 120);
return () => window.clearInterval(id);
}, []);
const currentSystem = useMemo(() => systems.find((system) => system.id === session.currentSystemId) ?? systems.find((system) => system.id === "sol") ?? null, [session.currentSystemId, systems]);
const travelSystems = useMemo(() => SLICE_TRAVEL_SYSTEM_IDS.map((id) => systems.find((system) => system.id === id)).filter(Boolean) as GalaxySystem[], [systems]);
const currentPoiPosition = useMemo(() => {
if (!currentSystem || !session.currentPoiId) return fallbackPosition(session.currentPoiId); if (!currentSystem || !session.currentPoiId) return fallbackPosition(session.currentPoiId);
return getSystemPoiPosition({ return poiPosition(currentSystem, session.currentPoiId);
systemId: currentSystem.id,
planets: currentSystem.planets,
pointsOfInterest: currentSystem.pointsOfInterest,
poiId: session.currentPoiId,
scale: MOVEMENT_SYSTEM_SCALE,
expanded: true,
});
}, [currentSystem, session.currentPoiId]); }, [currentSystem, session.currentPoiId]);
const waypoint: LocalWaypoint[] = session.currentPoiId const shipPosition = useMemo(() => {
? [{ id: session.currentPoiId, name: SLICE_POI_NAMES[session.currentPoiId] ?? session.currentPoiId, type: "poi", systemId: session.currentSystemId, poiId: session.currentPoiId, position: shipPosition, arrived: true }] if (session.activeOperation?.kind === "travel") {
: []; return lerpVec3(session.activeOperation.fromPosition, session.activeOperation.targetPosition, operationProgress);
}
return session.shipPosition ?? currentPoiPosition;
}, [currentPoiPosition, operationProgress, session.activeOperation, session.shipPosition]);
const localEntities = useMemo(() => ambientTraffic(session.currentSystemId, elapsed), [elapsed, session.currentSystemId]);
const waypoint: LocalWaypoint[] = useMemo(() => {
if (session.activeOperation?.kind === "travel") {
const target = session.activeOperation.target;
return [{
id: target.id,
name: target.name,
type: target.poiId ? "poi" : "manual",
systemId: target.systemId,
poiId: target.poiId,
position: target.position,
arrived: false,
}];
}
if (session.selectedTarget) {
return [{
id: session.selectedTarget.id,
name: session.selectedTarget.name,
type: session.selectedTarget.poiId ? "poi" : "manual",
systemId: session.selectedTarget.systemId,
poiId: session.selectedTarget.poiId,
position: session.selectedTarget.position,
arrived: session.selectedTarget.poiId === session.currentPoiId,
}];
}
if (session.currentPoiId) {
return [{ id: session.currentPoiId, name: SLICE_POI_NAMES[session.currentPoiId] ?? session.currentPoiId, type: "poi", systemId: session.currentSystemId, poiId: session.currentPoiId, position: currentPoiPosition, arrived: true }];
}
return [];
}, [currentPoiPosition, session.activeOperation, session.currentPoiId, session.currentSystemId, session.selectedTarget]);
const currentPoiName = useMemo(() => {
if (session.selectedTarget?.poiId === session.currentPoiId) return session.selectedTarget.name;
return currentSystem?.pointsOfInterest.find((poi) => poi.id === session.currentPoiId)?.name ?? SLICE_POI_NAMES[session.currentPoiId ?? ""] ?? "open space";
}, [currentSystem, session.currentPoiId, session.selectedTarget]);
const status = session.activeOperation?.kind === "travel"
? `Approaching ${session.activeOperation.target.name}`
: session.selectedTarget
? `Selected ${session.selectedTarget.name}`
: `Holding at ${currentPoiName}`;
const openTargetMenu = (x: number, y: number, target: SliceTarget, subtitle: string) => {
const atTarget = target.poiId && session.currentPoiId === target.poiId;
const actions: ContextAction[] = [
{ label: "Select", run: () => onSelectTarget(target) },
{ label: "Approach", run: () => onApproachTarget(target), disabled: Boolean(session.dockedStationPoiId || session.activeOperation || atTarget) },
];
if (target.kind === "station") actions.push({ label: "Dock", run: () => onDockAtTarget(target), disabled: !atTarget || Boolean(session.activeOperation || session.dockedStationPoiId) });
if (target.kind === "asteroid_belt") actions.push({ label: "Mine", run: () => onMineTarget(target), disabled: !atTarget || Boolean(session.activeOperation || session.dockedStationPoiId) });
if (target.kind === "hostile") actions.push({ label: "Target", run: () => onManualTarget({ id: target.id, name: target.name, type: "entity", position: target.position }) });
setContextMenu({ x, y, title: target.name, subtitle, actions });
};
const selectPoi = (system: GalaxySystem, poi: SystemPointOfInterest) => {
const position = poiPosition(system, poi.id);
if (system.id === session.currentSystemId) onPoiTarget(poi, position);
else onSystemPoiTarget(system.id, poi, position);
};
const openPoiMenu = (x: number, y: number, system: GalaxySystem, poi: SystemPointOfInterest) => {
const position = poiPosition(system, poi.id);
openTargetMenu(x, y, targetForPoi(system.id, poi, position), `${SYSTEM_NAMES[system.id] ?? system.name} ${poi.type.replace(/_/g, " ")}`);
};
const selectEntity = (entity: LocalEntity) => {
const position = localEntityPosition(entity);
onManualTarget({ id: entity.id, name: entity.name, type: "entity", position });
};
const openEntityMenu = (x: number, y: number, entity: LocalEntity) => {
const position = localEntityPosition(entity);
openTargetMenu(x, y, {
id: entity.id,
name: entity.name,
kind: entity.type === "hostile" ? "hostile" : "manual",
systemId: session.currentSystemId,
position,
}, entity.type === "hostile" ? "hostile contact" : "local traffic");
};
return ( return (
<section className="slice-loop-panel" style={{ ...slicePanel, position: "relative", height: "100%", minHeight: 0, overflow: "hidden" }}> <section
className="slice-loop-panel"
onContextMenu={(event) => event.preventDefault()}
onClick={() => setContextMenu(null)}
style={{ ...slicePanel, position: "relative", height: "100%", minHeight: 0, overflow: "hidden" }}
>
<MovementScene <MovementScene
currentSystemId={session.currentSystemId} currentSystemId={session.currentSystemId}
currentSystem={currentSystem} currentSystem={currentSystem}
localEntities={fallbackEntities} localEntities={localEntities}
localWaypoints={waypoint} localWaypoints={waypoint}
shipPosition={shipPosition} shipPosition={shipPosition}
onFrame={() => {}} onFrame={() => {}}
onWaypointPick={() => {}} onWaypointPick={onManualTarget}
onPoiPick={() => {}} onPoiPick={onPoiTarget}
onPoiContext={(poi, position, event) => {
if (!currentSystem) return;
openTargetMenu(event.nativeEvent.clientX, event.nativeEvent.clientY, targetForPoi(currentSystem.id, poi, position), poi.type.replace(/_/g, " "));
}}
onEntityPick={selectEntity}
onEntityContext={(entity, _position, event) => openEntityMenu(event.nativeEvent.clientX, event.nativeEvent.clientY, entity)}
/> />
<div style={{ position: "absolute", left: 14, top: 14, right: 14, zIndex: 1, display: "flex", gap: 10, alignItems: "center", flexWrap: "wrap", padding: 12, border: "1px solid rgba(148,163,184,0.22)", borderRadius: 8, background: "rgba(8,12,20,0.82)" }}>
<span style={metricLabel}>Flight Mode</span> <div style={{ position: "absolute", left: 14, top: 14, right: 14, zIndex: 1, display: "flex", gap: 10, alignItems: "center", flexWrap: "wrap", padding: "9px 10px", border: "1px solid rgba(148,163,184,0.22)", borderRadius: 8, background: "rgba(8,12,20,0.78)" }}>
<strong style={{ color: "#f0a030" }}>{SLICE_POI_NAMES[session.currentPoiId ?? ""] ?? "Open space"}</strong> <span style={metricLabel}>Flight</span>
<span style={{ color: "#94a3b8" }}>Use the primary action rail to plot local travel.</span> <strong style={{ color: "#f0a030" }}>{status}</strong>
{session.activeOperation?.kind === "travel" && <span style={{ marginLeft: "auto", color: "#22d3ee", fontFamily: "var(--font-mono)", fontSize: 11 }}>{Math.round(operationProgress * 100)}%</span>}
</div> </div>
<div style={{ position: "absolute", left: 14, bottom: 14, zIndex: 1, width: "min(280px, calc(50% - 22px))", padding: 10, border: "1px solid rgba(148,163,184,0.22)", borderRadius: 8, background: "rgba(8,12,20,0.82)", display: "grid", gap: 8 }}>
<div style={metricLabel}>Jump Network</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(5, minmax(0, 1fr))", gap: 6 }}>
{travelSystems.map((system) => {
const primaryPoi = system.pointsOfInterest.find((poi) => poi.type === "station") ?? system.pointsOfInterest[0];
const active = system.id === session.currentSystemId;
return (
<button
key={system.id}
style={{ ...sliceButton, minWidth: 0, padding: "7px 4px", border: active ? "1px solid rgba(34,197,94,0.62)" : sliceButton.border, color: active ? "#22c55e" : sliceButton.color }}
disabled={!primaryPoi}
title={primaryPoi ? `Set route to ${primaryPoi.name}` : undefined}
onClick={(event) => {
event.stopPropagation();
if (primaryPoi) selectPoi(system, primaryPoi);
}}
onContextMenu={(event) => {
event.preventDefault();
event.stopPropagation();
if (primaryPoi) openPoiMenu(event.clientX, event.clientY, system, primaryPoi);
}}
>
{SYSTEM_NAMES[system.id] ?? system.name}
</button>
);
})}
</div>
</div>
<div style={{ position: "absolute", right: 14, bottom: 14, zIndex: 1, width: "min(300px, calc(50% - 22px))", maxHeight: "min(58%, 380px)", padding: 10, border: "1px solid rgba(148,163,184,0.22)", borderRadius: 8, background: "rgba(8,12,20,0.82)", display: "grid", gap: 8, overflow: "auto" }}>
<div style={metricLabel}>Local Contacts</div>
{(currentSystem?.pointsOfInterest ?? []).slice(0, 6).map((poi) => {
const position = currentSystem ? poiPosition(currentSystem, poi.id) : fallbackPosition(poi.id);
const selected = session.selectedTarget?.poiId === poi.id;
return (
<button
key={poi.id}
data-slice-poi={poi.id}
style={{ ...sliceButton, justifyContent: "space-between", border: selected ? "1px solid rgba(240,160,48,0.82)" : sliceButton.border, color: selected ? "#f0a030" : sliceButton.color }}
onClick={(event) => {
event.stopPropagation();
onPoiTarget(poi, position);
}}
onContextMenu={(event) => {
event.preventDefault();
event.stopPropagation();
if (currentSystem) openPoiMenu(event.clientX, event.clientY, currentSystem, poi);
}}
>
<span>{poi.name}</span>
<span style={{ color: "#64748b", marginLeft: 8 }}>{poi.type.replace(/_/g, " ")}</span>
</button>
);
})}
{localEntities.map((entity) => (
<button
key={entity.id}
style={{ ...sliceButton, justifyContent: "space-between", border: session.selectedTarget?.id === entity.id ? "1px solid rgba(240,160,48,0.82)" : sliceButton.border, color: entity.type === "hostile" ? "#fca5a5" : sliceButton.color }}
onClick={(event) => {
event.stopPropagation();
selectEntity(entity);
}}
onContextMenu={(event) => {
event.preventDefault();
event.stopPropagation();
openEntityMenu(event.clientX, event.clientY, entity);
}}
>
<span>{entity.name}</span>
<span style={{ color: "#64748b", marginLeft: 8 }}>{entity.type}</span>
</button>
))}
</div>
{contextMenu && (
<div onClick={(event) => event.stopPropagation()} style={{ position: "fixed", left: contextMenu.x, top: contextMenu.y, zIndex: 10, minWidth: 210, background: "rgba(10,16,28,0.97)", border: "1px solid rgba(148,163,184,0.36)", borderRadius: 8, overflow: "hidden", boxShadow: "0 18px 50px rgba(0,0,0,0.35)", fontFamily: "var(--font-mono)", fontSize: 11 }}>
<div style={{ padding: 10, borderBottom: "1px solid rgba(148,163,184,0.18)" }}>
<div style={{ color: "#f8fafc", overflowWrap: "anywhere" }}>{contextMenu.title}</div>
<div style={{ ...metricLabel, marginTop: 3 }}>{contextMenu.subtitle}</div>
</div>
{contextMenu.actions.map((action) => (
<button
key={action.label}
style={action.label === "Approach" && !action.disabled ? { ...slicePrimaryButton, width: "100%", justifyContent: "flex-start", border: "0", borderRadius: 0 } : menuButtonStyle}
disabled={action.disabled}
onClick={() => {
action.run();
setContextMenu(null);
}}
>
{action.label}
</button>
))}
</div>
)}
</section> </section>
); );
} }

View File

@@ -2,20 +2,24 @@ import type { GameSliceSession } from "../types";
import { SLICE_BELT } from "../sliceWorld"; import { SLICE_BELT } from "../sliceWorld";
import { metricLabel, slicePanel } from "./sliceStyles"; import { metricLabel, slicePanel } from "./sliceStyles";
import { SliceProgressBar } from "./SliceProgressBar"; import { SliceProgressBar } from "./SliceProgressBar";
import { getCargoUsed } from "../sliceEconomy";
export function SliceMiningStage({ session, progress }: { session: GameSliceSession; progress: number }) { export function SliceMiningStage({ session, progress }: { session: GameSliceSession; progress: number }) {
const operation = session.activeOperation?.kind === "mining" ? session.activeOperation : null; const operation = session.activeOperation?.kind === "mining" ? session.activeOperation : null;
const cargoUsed = getCargoUsed(session);
const projectedCargo = operation ? Math.min(session.cargoCapacity, cargoUsed + operation.quantity) : cargoUsed;
return ( return (
<section className="slice-loop-panel" style={{ ...slicePanel, height: "100%", minHeight: 0, padding: 18, display: "grid", placeItems: "center", boxSizing: "border-box", overflow: "hidden", background: "radial-gradient(circle at 50% 35%, rgba(240,160,48,0.2), transparent 36%), rgba(9,15,27,0.88)" }}> <section className="slice-loop-panel" style={{ ...slicePanel, height: "100%", minHeight: 0, padding: 18, display: "grid", placeItems: "center", boxSizing: "border-box", overflow: "hidden", background: "radial-gradient(circle at 50% 35%, rgba(240,160,48,0.2), transparent 36%), rgba(9,15,27,0.88)" }}>
<div style={{ width: "min(620px, 100%)", textAlign: "center" }}> <div style={{ width: "min(620px, 100%)", textAlign: "center" }}>
<div style={metricLabel}>Mining Operation</div> <div style={metricLabel}>Mining Operation</div>
<h1 style={{ margin: "8px 0", color: "#f8fafc", fontSize: 30, letterSpacing: 0 }}>{SLICE_BELT.name}</h1> <h1 style={{ margin: "8px 0", color: "#f8fafc", fontSize: 30, letterSpacing: 0 }}>{operation?.targetName ?? SLICE_BELT.name}</h1>
<p style={{ margin: "0 0 18px", color: "#94a3b8" }}> <p style={{ margin: "0 0 18px", color: "#94a3b8" }}>
Mining laser cycle in progress. Cargo transfer will post to the event log when the cycle finishes. Mining laser cycle active. Repeating cycles continue while cargo space remains; stop mining from the action palette.
</p> </p>
<SliceProgressBar value={progress} color="#22c55e" /> <SliceProgressBar value={progress} color="#22c55e" />
<div style={{ marginTop: 12, color: "#f0a030", fontFamily: "var(--font-mono)", fontSize: 12 }}> <div style={{ marginTop: 12, color: "#f0a030", fontFamily: "var(--font-mono)", fontSize: 12, display: "grid", gap: 4 }}>
{operation ? `${Math.round(progress * 100)}% · ${operation.quantity.toLocaleString()} ${operation.ore}` : `${session.cargo.length} cargo stacks`} <span>{operation ? `${Math.round(progress * 100)}% · ${operation.quantity.toLocaleString()} ${operation.ore}` : `${session.cargo.length} cargo stacks`}</span>
<span style={{ color: "#94a3b8" }}>{operation?.repeat ? "AUTO-CYCLE ENABLED" : "SINGLE CYCLE"} · Cargo {projectedCargo.toLocaleString()} / {session.cargoCapacity.toLocaleString()}</span>
</div> </div>
</div> </div>
</section> </section>

View File

@@ -7,7 +7,7 @@ export function SliceObjectiveTracker({ session }: { session: GameSliceSession }
return ( return (
<section style={{ ...slicePanel, padding: 12, minHeight: 248 }}> <section style={{ ...slicePanel, padding: 12, minHeight: 248 }}>
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: 10 }}> <div style={{ display: "flex", justifyContent: "space-between", marginBottom: 10 }}>
<strong style={{ color: "#f0a030", fontSize: 12 }}>Objectives</strong> <strong style={{ color: "#f0a030", fontSize: 12 }}>Guidance</strong>
<span style={metricLabel}>{progress.percent}%</span> <span style={metricLabel}>{progress.percent}%</span>
</div> </div>
<div style={{ height: 6, background: "rgba(255,255,255,0.06)", borderRadius: 999, overflow: "hidden", marginBottom: 10 }}> <div style={{ height: 6, background: "rgba(255,255,255,0.06)", borderRadius: 999, overflow: "hidden", marginBottom: 10 }}>

View File

@@ -31,6 +31,7 @@ export function SliceShell({
.slice-left, .slice-right, .slice-bottom { grid-column: 1 !important; } .slice-left, .slice-right, .slice-bottom { grid-column: 1 !important; }
.slice-left, .slice-right, .slice-center { overflow: visible !important; } .slice-left, .slice-right, .slice-center { overflow: visible !important; }
.slice-bottom-content { grid-template-columns: 1fr !important; } .slice-bottom-content { grid-template-columns: 1fr !important; }
.slice-action-palette { grid-template-columns: 1fr !important; }
.slice-center { min-height: min(420px, calc(100dvh - 140px)) !important; } .slice-center { min-height: min(420px, calc(100dvh - 140px)) !important; }
.slice-loop-panel { min-height: 420px !important; } .slice-loop-panel { min-height: 420px !important; }
.slice-loop-state { min-height: 220px !important; } .slice-loop-state { min-height: 220px !important; }

View File

@@ -1,10 +1,11 @@
import type { SliceFacts } from "../sliceController"; import type { SliceFacts } from "../sliceController";
import type { GameSliceSession, SliceEvent, SliceFittedModule } from "../types"; import type { GameSliceSession, SliceEvent, SliceFittedModule, SliceTarget } from "../types";
import type { SystemPointOfInterest, Vec3 } from "../../r3f/shared/types";
import type { LocalWaypoint } from "../../r3f/movement/movementState";
import { metricLabel, slicePanel } from "./sliceStyles"; import { metricLabel, slicePanel } from "./sliceStyles";
import { SliceProgressBar } from "./SliceProgressBar"; import { SliceProgressBar } from "./SliceProgressBar";
import { SliceStationStage } from "./SliceStationStage"; import { SliceStationStage } from "./SliceStationStage";
import { SliceFlightStage } from "./SliceFlightStage"; import { SliceFlightStage } from "./SliceFlightStage";
import { SliceTravelStage } from "./SliceTravelStage";
import { SliceMiningStage } from "./SliceMiningStage"; import { SliceMiningStage } from "./SliceMiningStage";
import { SliceServicesStage } from "./SliceServicesStage"; import { SliceServicesStage } from "./SliceServicesStage";
import { SliceCombatStage } from "./SliceCombatStage"; import { SliceCombatStage } from "./SliceCombatStage";
@@ -35,6 +36,13 @@ export function SliceStage({
onRefine, onRefine,
onUpdateFit, onUpdateFit,
onSell, onSell,
onPoiTarget,
onSystemPoiTarget,
onSelectTarget,
onManualTarget,
onApproachTarget,
onDockAtTarget,
onMineTarget,
}: { }: {
session: GameSliceSession; session: GameSliceSession;
facts: SliceFacts; facts: SliceFacts;
@@ -45,14 +53,20 @@ export function SliceStage({
onRefine: (quantity: number) => void; onRefine: (quantity: number) => void;
onUpdateFit: (modules: SliceFittedModule[]) => void; onUpdateFit: (modules: SliceFittedModule[]) => void;
onSell: (item: string, quantity: number, unitPrice: number) => void; onSell: (item: string, quantity: number, unitPrice: number) => void;
onPoiTarget: (poi: SystemPointOfInterest, position: Vec3) => void;
onSystemPoiTarget: (systemId: string, poi: SystemPointOfInterest, position: Vec3) => void;
onSelectTarget: (target: SliceTarget) => void;
onManualTarget: (waypoint: LocalWaypoint) => void;
onApproachTarget: (target: SliceTarget) => void;
onDockAtTarget: (target: SliceTarget) => void;
onMineTarget: (target: SliceTarget) => void;
}) { }) {
if (session.mode === "services") { if (session.mode === "services") {
return <SliceServicesStage session={session} onClose={onCloseService} onRefine={onRefine} onUpdateFit={onUpdateFit} onSell={onSell} />; return <SliceServicesStage session={session} onClose={onCloseService} onRefine={onRefine} onUpdateFit={onUpdateFit} onSell={onSell} />;
} }
if (session.mode === "travel") return <SliceTravelStage session={session} progress={operationProgress} />;
if (session.mode === "mining") return <SliceMiningStage session={session} progress={operationProgress} />; if (session.mode === "mining") return <SliceMiningStage session={session} progress={operationProgress} />;
if (session.mode === "undocking" || session.mode === "docking") return <OperationStage session={session} progress={operationProgress} />; if (session.mode === "undocking" || session.mode === "docking") return <OperationStage session={session} progress={operationProgress} />;
if (session.mode === "combat") return <SliceCombatStage emit={emit} />; if (session.mode === "combat") return <SliceCombatStage emit={emit} />;
if (session.mode === "station") return <SliceStationStage session={session} facts={facts} onOpenService={onOpenService} />; if (session.mode === "station") return <SliceStationStage session={session} facts={facts} onOpenService={onOpenService} />;
return <SliceFlightStage session={session} />; return <SliceFlightStage session={session} operationProgress={operationProgress} onPoiTarget={onPoiTarget} onSystemPoiTarget={onSystemPoiTarget} onSelectTarget={onSelectTarget} onManualTarget={onManualTarget} onApproachTarget={onApproachTarget} onDockAtTarget={onDockAtTarget} onMineTarget={onMineTarget} />;
} }

View File

@@ -18,7 +18,7 @@ export function SliceStationStage({
<div style={metricLabel}>Docked Facility</div> <div style={metricLabel}>Docked Facility</div>
<h1 className="slice-loop-title" style={{ margin: "6px 0 0", color: "#f8fafc", fontSize: 28, letterSpacing: 0 }}>{session.dockedStationName ?? SLICE_STATION.name}</h1> <h1 className="slice-loop-title" style={{ margin: "6px 0 0", color: "#f8fafc", fontSize: 28, letterSpacing: 0 }}>{session.dockedStationName ?? SLICE_STATION.name}</h1>
<p className="slice-loop-copy" style={{ margin: "8px 0 0", color: "#94a3b8", maxWidth: 720 }}> <p className="slice-loop-copy" style={{ margin: "8px 0 0", color: "#94a3b8", maxWidth: 720 }}>
Station services are live in this shell. Undock, work the belt, then return here to refine, fit, and sell without leaving the loop. Station services are available while docked. Use them in any order that matches your cargo and fitting needs, or undock back into local space.
</p> </p>
</div> </div>
@@ -36,9 +36,9 @@ export function SliceStationStage({
</div> </div>
<div style={{ display: "flex", gap: 8, flexWrap: "wrap", justifyContent: "center" }}> <div style={{ display: "flex", gap: 8, flexWrap: "wrap", justifyContent: "center" }}>
<button style={sliceButton} disabled={!facts.hasRefinableOre} onClick={() => onOpenService("refining")}>Refining</button> <button style={sliceButton} disabled={!facts.hasRefinableOre} title={facts.hasRefinableOre ? undefined : "Refining needs at least 333 Veldspar."} onClick={() => onOpenService("refining")}>Refining</button>
<button style={sliceButton} onClick={() => onOpenService("fitting")}>Fitting</button> <button style={sliceButton} onClick={() => onOpenService("fitting")}>Fitting</button>
<button style={sliceButton} disabled={!facts.hasSellableCargo} onClick={() => onOpenService("market")}>Market</button> <button style={sliceButton} disabled={!facts.hasSellableCargo} title={facts.hasSellableCargo ? undefined : "No sellable cargo is available."} onClick={() => onOpenService("market")}>Market</button>
</div> </div>
</section> </section>
); );

View File

@@ -8,8 +8,7 @@ export function SliceTopBar({ session, onReset }: { session: GameSliceSession; o
const objective = SLICE_OBJECTIVES.find((item) => item.id === session.activeObjectiveId); const objective = SLICE_OBJECTIVES.find((item) => item.id === session.activeObjectiveId);
return ( return (
<div style={{ ...slicePanel, height: "100%", minWidth: 0, display: "flex", alignItems: "center", gap: 14, padding: "0 12px", boxSizing: "border-box", fontFamily: "var(--font-mono)", fontSize: 11, overflowX: "auto", overflowY: "hidden", whiteSpace: "nowrap" }}> <div style={{ ...slicePanel, height: "100%", minWidth: 0, display: "flex", alignItems: "center", gap: 14, padding: "0 12px", boxSizing: "border-box", fontFamily: "var(--font-mono)", fontSize: 11, overflowX: "auto", overflowY: "hidden", whiteSpace: "nowrap" }}>
<button style={sliceButton} onClick={() => { window.location.href = "/docs/demo-gallery"; }}>Gallery</button> <strong style={{ color: "#f0a030", letterSpacing: 0 }}>VOID::NAV</strong>
<strong style={{ color: "#f0a030", letterSpacing: 0 }}>MVP LOOP SLICE</strong>
<span>{SYSTEM_NAMES[session.currentSystemId] ?? session.currentSystemId}</span> <span>{SYSTEM_NAMES[session.currentSystemId] ?? session.currentSystemId}</span>
<span>{session.dockedStationName ? `Docked: ${session.dockedStationName}` : "In space"}</span> <span>{session.dockedStationName ? `Docked: ${session.dockedStationName}` : "In space"}</span>
<span>Mode: {session.mode.toUpperCase()}</span> <span>Mode: {session.mode.toUpperCase()}</span>

View File

@@ -1,9 +1,11 @@
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { refineVeldspar } from "./sliceEconomy"; import { getCargoUsed, refineVeldspar } from "./sliceEconomy";
import { commandForObjective, getAvailableSliceCommands, getBlockedReason, getSliceFacts, type SliceCommand } from "./sliceController"; import { getActionBlockedReason, getContextualSliceActions, getSliceFacts, type SliceActionId } from "./sliceController";
import { useGameSliceSession } from "./useGameSliceSession"; import { useGameSliceSession } from "./useGameSliceSession";
import { SLICE_BELT, SLICE_DURATIONS, SLICE_STATION } from "./sliceWorld"; import { isBeltPoi, isStationPoi, SLICE_DURATIONS } from "./sliceWorld";
import type { GameSliceSession, SliceEvent, SliceFittedModule, SliceService } from "./types"; import type { SystemPointOfInterest, Vec3 } from "../r3f/shared/types";
import type { LocalWaypoint } from "../r3f/movement/movementState";
import type { GameSliceSession, SliceEvent, SliceFittedModule, SliceService, SliceTarget, SliceTargetKind } from "./types";
function completionEventFor(session: GameSliceSession): SliceEvent | null { function completionEventFor(session: GameSliceSession): SliceEvent | null {
const operation = session.activeOperation; const operation = session.activeOperation;
@@ -11,7 +13,7 @@ function completionEventFor(session: GameSliceSession): SliceEvent | null {
if (Date.now() - operation.startedAt < operation.durationMs) return null; if (Date.now() - operation.startedAt < operation.durationMs) return null;
if (operation.kind === "undocking") return { type: "station.undocked", systemId: session.currentSystemId }; if (operation.kind === "undocking") return { type: "station.undocked", systemId: session.currentSystemId };
if (operation.kind === "travel") return { type: "navigation.arrived", systemId: operation.targetSystemId, poiId: operation.targetPoiId, poiName: operation.targetPoiName }; if (operation.kind === "travel") return { type: "navigation.arrived", systemId: operation.targetSystemId, poiId: operation.target.poiId ?? "", poiName: operation.targetPoiName };
if (operation.kind === "docking") return { type: "station.docked", systemId: session.currentSystemId, stationPoiId: operation.stationPoiId, stationName: operation.stationName }; if (operation.kind === "docking") return { type: "station.docked", systemId: session.currentSystemId, stationPoiId: operation.stationPoiId, stationName: operation.stationName };
if (operation.kind === "mining") return { type: "mining.completed", ore: operation.ore, quantity: operation.quantity }; if (operation.kind === "mining") return { type: "mining.completed", ore: operation.ore, quantity: operation.quantity };
return null; return null;
@@ -34,32 +36,51 @@ export function useSliceController() {
useEffect(() => { useEffect(() => {
if (!session?.activeOperation) return; if (!session?.activeOperation) return;
const completeOperation = (current: GameSliceSession, completion: SliceEvent) => {
const operation = current.activeOperation;
emit(completion);
if (completion.type === "mining.completed") {
emit({ type: "xp.awarded", skill: "Mining", amount: 25 });
if (operation?.kind === "mining" && operation.repeat) {
const freeAfterCycle = Math.max(0, current.cargoCapacity - getCargoUsed(current) - operation.quantity);
if (freeAfterCycle > 0) {
emit({
type: "mining.started",
ore: operation.ore,
quantity: Math.min(1000, freeAfterCycle),
targetPoiId: operation.targetPoiId,
targetName: operation.targetName,
repeat: true,
startedAt: Date.now(),
durationMs: SLICE_DURATIONS.mining,
});
}
}
}
};
const completion = completionEventFor(session); const completion = completionEventFor(session);
if (completion) { if (completion) {
emit(completion); completeOperation(session, completion);
if (completion.type === "mining.completed") emit({ type: "xp.awarded", skill: "Mining", amount: 25 });
return; return;
} }
const remaining = Math.max(0, session.activeOperation.startedAt + session.activeOperation.durationMs - Date.now()); const remaining = Math.max(0, session.activeOperation.startedAt + session.activeOperation.durationMs - Date.now());
const timeout = window.setTimeout(() => { const timeout = window.setTimeout(() => {
const nextCompletion = completionEventFor(session); const nextCompletion = completionEventFor(session);
if (nextCompletion) { if (nextCompletion) {
emit(nextCompletion); completeOperation(session, nextCompletion);
if (nextCompletion.type === "mining.completed") emit({ type: "xp.awarded", skill: "Mining", amount: 25 });
} }
}, remaining + 20); }, remaining + 20);
return () => window.clearTimeout(timeout); return () => window.clearTimeout(timeout);
}, [emit, session]); }, [emit, session]);
const facts = useMemo(() => session ? getSliceFacts(session) : null, [session]); const facts = useMemo(() => session ? getSliceFacts(session) : null, [session]);
const availableCommands = useMemo(() => session ? getAvailableSliceCommands(session) : null, [session]); const contextualActions = useMemo(() => session ? getContextualSliceActions(session) : null, [session]);
const primaryCommand = useMemo(() => session ? commandForObjective(session) : null, [session]);
const operationProgress = useMemo(() => session ? getOperationProgress(session, now) : 0, [now, session]); const operationProgress = useMemo(() => session ? getOperationProgress(session, now) : 0, [now, session]);
const blockedReason = useCallback((command: SliceCommand) => session ? getBlockedReason(session, command) : "Session is not ready.", [session]); const blockedReason = useCallback((actionId: SliceActionId) => session ? getActionBlockedReason(session, actionId) : "Session is not ready.", [session]);
const guarded = useCallback((command: SliceCommand, run: (session: GameSliceSession) => void) => { const guarded = useCallback((actionId: SliceActionId, run: (session: GameSliceSession) => void) => {
if (!session || getBlockedReason(session, command)) return; if (!session || getActionBlockedReason(session, actionId)) return;
run(session); run(session);
}, [session]); }, [session]);
@@ -67,25 +88,80 @@ export function useSliceController() {
emit({ type: "station.undockStarted", systemId: current.currentSystemId, startedAt: Date.now(), durationMs: SLICE_DURATIONS.undock }); emit({ type: "station.undockStarted", systemId: current.currentSystemId, startedAt: Date.now(), durationMs: SLICE_DURATIONS.undock });
}), [emit, guarded]); }), [emit, guarded]);
const travelToBelt = useCallback(() => guarded("travelToBelt", () => { const approachSelectedTarget = useCallback(() => guarded("approachTarget", (current) => {
emit({ type: "navigation.started", targetSystemId: SLICE_BELT.systemId, targetPoiId: SLICE_BELT.poiId, targetPoiName: SLICE_BELT.name, startedAt: Date.now(), durationMs: SLICE_DURATIONS.localTravel }); if (!current.selectedTarget) return;
emit({ type: "navigation.approachStarted", target: current.selectedTarget, fromPosition: current.shipPosition, startedAt: Date.now(), durationMs: SLICE_DURATIONS.localTravel });
}), [emit, guarded]); }), [emit, guarded]);
const travelToStation = useCallback(() => guarded("travelToStation", () => { const approachTarget = useCallback((target: SliceTarget) => {
emit({ type: "navigation.started", targetSystemId: SLICE_STATION.systemId, targetPoiId: SLICE_STATION.poiId, targetPoiName: SLICE_STATION.name, startedAt: Date.now(), durationMs: SLICE_DURATIONS.localTravel }); if (!session) return;
if (!session.dockedStationPoiId && !session.activeOperation && (!target.poiId || session.currentPoiId !== target.poiId)) {
emit({ type: "navigation.targetSelected", target });
emit({ type: "navigation.approachStarted", target, fromPosition: session.shipPosition, startedAt: Date.now(), durationMs: SLICE_DURATIONS.localTravel });
} else {
emit({ type: "navigation.targetSelected", target });
}
}, [emit, session]);
const dock = useCallback(() => guarded("dock", (current) => {
const target = current.selectedTarget?.poiId === current.currentPoiId && current.selectedTarget.kind === "station"
? current.selectedTarget
: current.currentPoiId && isStationPoi(current.currentPoiId)
? { id: current.currentPoiId, name: current.selectedTarget?.name ?? current.currentPoiId, kind: "station" as const, systemId: current.currentSystemId, poiId: current.currentPoiId, position: current.shipPosition }
: null;
if (!target?.poiId) return;
emit({ type: "station.dockStarted", stationPoiId: target.poiId, stationName: target.name, startedAt: Date.now(), durationMs: SLICE_DURATIONS.docking });
}), [emit, guarded]); }), [emit, guarded]);
const dock = useCallback(() => guarded("dock", () => { const startMining = useCallback(({ repeat = true }: { repeat?: boolean } = {}) => guarded("startMining", (current) => {
emit({ type: "station.dockStarted", stationPoiId: SLICE_STATION.poiId, stationName: SLICE_STATION.name, startedAt: Date.now(), durationMs: SLICE_DURATIONS.docking });
}), [emit, guarded]);
const mine = useCallback(() => guarded("mine", (current) => {
const facts = getSliceFacts(current); const facts = getSliceFacts(current);
emit({ type: "mining.started", ore: "Veldspar", quantity: Math.min(1000, facts.freeCargo), startedAt: Date.now(), durationMs: SLICE_DURATIONS.mining }); const target = current.selectedTarget?.poiId === current.currentPoiId && current.selectedTarget.kind === "asteroid_belt"
? current.selectedTarget
: current.currentPoiId && isBeltPoi(current.currentPoiId)
? { id: current.currentPoiId, name: current.selectedTarget?.name ?? current.currentPoiId, kind: "asteroid_belt" as const, systemId: current.currentSystemId, poiId: current.currentPoiId, position: current.shipPosition }
: null;
if (!target?.poiId) return;
emit({
type: "mining.started",
ore: "Veldspar",
quantity: Math.min(1000, facts.freeCargo),
targetPoiId: target.poiId,
targetName: target.name,
repeat,
startedAt: Date.now(),
durationMs: SLICE_DURATIONS.mining,
});
}), [emit, guarded]); }), [emit, guarded]);
const mineTarget = useCallback((target: SliceTarget) => {
if (!session || session.activeOperation || session.dockedStationPoiId || target.kind !== "asteroid_belt" || session.currentPoiId !== target.poiId || !target.poiId) return;
const facts = getSliceFacts(session);
if (facts.freeCargo <= 0) return;
emit({ type: "navigation.targetSelected", target });
emit({
type: "mining.started",
ore: "Veldspar",
quantity: Math.min(1000, facts.freeCargo),
targetPoiId: target.poiId,
targetName: target.name,
repeat: true,
startedAt: Date.now(),
durationMs: SLICE_DURATIONS.mining,
});
}, [emit, session]);
const dockAtTarget = useCallback((target: SliceTarget) => {
if (!session || session.activeOperation || session.dockedStationPoiId || target.kind !== "station" || session.currentPoiId !== target.poiId || !target.poiId) return;
emit({ type: "navigation.targetSelected", target });
emit({ type: "station.dockStarted", stationPoiId: target.poiId, stationName: target.name, startedAt: Date.now(), durationMs: SLICE_DURATIONS.docking });
}, [emit, session]);
const stopMining = useCallback(() => {
if (session?.activeOperation?.kind === "mining") emit({ type: "mining.stopped" });
}, [emit, session?.activeOperation]);
const openService = useCallback((service: SliceService) => { const openService = useCallback((service: SliceService) => {
const commandByService: Record<SliceService, SliceCommand> = { refining: "openRefining", fitting: "openFitting", market: "openMarket" }; const commandByService: Record<SliceService, SliceActionId> = { refining: "openRefining", fitting: "openFitting", market: "openMarket" };
guarded(commandByService[service], () => emit({ type: "station.serviceOpened", service })); guarded(commandByService[service], () => emit({ type: "station.serviceOpened", service }));
}, [emit, guarded]); }, [emit, guarded]);
@@ -94,11 +170,41 @@ export function useSliceController() {
}, [emit, session?.activeService]); }, [emit, session?.activeService]);
const startCombat = useCallback(() => { const startCombat = useCallback(() => {
if (!session || getBlockedReason(session, "startCombat")) return; if (!session || getActionBlockedReason(session, "startCombat")) return;
emit({ type: "combat.started" }); emit({ type: "combat.started" });
emit({ type: "zora.observed", trigger: "combat.started" }); emit({ type: "zora.observed", trigger: "combat.started" });
}, [emit, session]); }, [emit, session]);
const selectTarget = useCallback((target: SliceTarget, manual = false) => {
emit({ type: manual ? "navigation.manualWaypointSelected" : "navigation.targetSelected", target });
}, [emit]);
const selectSystemPoiTarget = useCallback((systemId: string, poi: SystemPointOfInterest, position: Vec3) => {
const kind: SliceTargetKind = poi.type === "station" ? "station" : poi.type === "asteroid_belt" ? "asteroid_belt" : "manual";
selectTarget({
id: poi.id,
name: poi.name,
kind,
systemId,
poiId: poi.id,
position,
});
}, [selectTarget]);
const selectPoiTarget = useCallback((poi: SystemPointOfInterest, position: Vec3) => {
selectSystemPoiTarget(session?.currentSystemId ?? "sol", poi, position);
}, [selectSystemPoiTarget, session?.currentSystemId]);
const selectManualTarget = useCallback((waypoint: LocalWaypoint) => {
selectTarget({
id: waypoint.id,
name: waypoint.name,
kind: waypoint.type === "entity" && waypoint.id.includes("hostile") ? "hostile" : "manual",
systemId: session?.currentSystemId ?? "sol",
position: waypoint.position,
}, true);
}, [selectTarget, session?.currentSystemId]);
const refineVeldsparStack = useCallback((inputQuantity: number) => { const refineVeldsparStack = useCallback((inputQuantity: number) => {
if (!session) return; if (!session) return;
const ore = session.cargo.find((item) => item.item === "Veldspar"); const ore = session.cargo.find((item) => item.item === "Veldspar");
@@ -126,15 +232,21 @@ export function useSliceController() {
reset, reset,
missingRequestedSession, missingRequestedSession,
facts, facts,
availableCommands, contextualActions,
primaryCommand,
operationProgress, operationProgress,
blockedReason, blockedReason,
startUndock, startUndock,
travelToBelt, selectTarget,
travelToStation, selectPoiTarget,
selectSystemPoiTarget,
selectManualTarget,
approachSelectedTarget,
approachTarget,
dock, dock,
mine, dockAtTarget,
startMining,
mineTarget,
stopMining,
openService, openService,
closeService, closeService,
startCombat, startCombat,

View File

@@ -1,5 +1,6 @@
import { useRef } from "react"; import { useRef } from "react";
import { useFrame } from "@react-three/fiber"; import { useFrame } from "@react-three/fiber";
import type { ThreeEvent } from "@react-three/fiber";
import * as THREE from "three"; import * as THREE from "three";
import { SpaceCanvas } from "../shared/SpaceCanvas"; import { SpaceCanvas } from "../shared/SpaceCanvas";
import { SpaceEnvironment } from "../shared/SpaceEnvironment"; import { SpaceEnvironment } from "../shared/SpaceEnvironment";
@@ -22,12 +23,15 @@ type MovementSceneProps = {
onFrame: (dt: number, elapsedTime: number) => void; onFrame: (dt: number, elapsedTime: number) => void;
onWaypointPick: (waypoint: LocalWaypoint) => void; onWaypointPick: (waypoint: LocalWaypoint) => void;
onPoiPick: (poi: SystemPointOfInterest, position: Vec3) => void; onPoiPick: (poi: SystemPointOfInterest, position: Vec3) => void;
onPoiContext?: (poi: SystemPointOfInterest, position: Vec3, event: ThreeEvent<MouseEvent>) => void;
onEntityPick?: (entity: LocalEntity, position: Vec3) => void;
onEntityContext?: (entity: LocalEntity, position: Vec3, event: ThreeEvent<MouseEvent>) => void;
}; };
const entityPosition = (entity: LocalEntity): Vec3 => [(entity.x - 400) * 0.15, 0, (entity.y - 300) * 0.15]; export const localEntityPosition = (entity: LocalEntity): Vec3 => [(entity.x - 400) * 0.15, 0, (entity.y - 300) * 0.15];
export const MOVEMENT_SYSTEM_SCALE = 3.2; export const MOVEMENT_SYSTEM_SCALE = 3.2;
function MovementWorld({ currentSystemId, currentSystem, localEntities, localWaypoints, shipPosition, onFrame, onWaypointPick, onPoiPick }: MovementSceneProps) { function MovementWorld({ currentSystemId, currentSystem, localEntities, localWaypoints, shipPosition, onFrame, onWaypointPick, onPoiPick, onPoiContext, onEntityPick, onEntityContext }: MovementSceneProps) {
const shipRef = useRef<THREE.Group>(null); const shipRef = useRef<THREE.Group>(null);
useFrame((state, dt) => { useFrame((state, dt) => {
onFrame(dt, state.clock.elapsedTime); onFrame(dt, state.clock.elapsedTime);
@@ -40,7 +44,7 @@ function MovementWorld({ currentSystemId, currentSystem, localEntities, localWay
return ( return (
<> <>
<SpaceEnvironment density={3200} spread={2200} /> <SpaceEnvironment density={3200} spread={2200} />
<CameraRig orbit distance={92} /> <CameraRig target={shipPosition} orbit distance={58} />
<gridHelper args={[220, 22, "#0d1520", "#0d1520"]} position={[0, -2, 0]} /> <gridHelper args={[220, 22, "#0d1520", "#0d1520"]} position={[0, -2, 0]} />
<mesh <mesh
rotation={[-Math.PI / 2, 0, 0]} rotation={[-Math.PI / 2, 0, 0]}
@@ -80,16 +84,49 @@ function MovementWorld({ currentSystemId, currentSystem, localEntities, localWay
}); });
onPoiPick(poi, position); onPoiPick(poi, position);
}} }}
onPoiContext={(poi, event) => {
event.stopPropagation();
const position = getSystemPoiPosition({
systemId: currentSystem.id,
planets: currentSystem.planets,
pointsOfInterest: currentSystem.pointsOfInterest,
poiId: poi.id,
scale: MOVEMENT_SYSTEM_SCALE,
expanded: true,
});
onPoiContext?.(poi, position, event);
}}
/> />
)} )}
<group ref={shipRef} position={shipPosition}> <group ref={shipRef} position={shipPosition}>
<ShipMesh scale={0.52} engineActive={localWaypoints.some((waypoint) => !waypoint.arrived)} /> <ShipMesh scale={0.18} engineActive={localWaypoints.some((waypoint) => !waypoint.arrived)} />
</group> </group>
{localEntities.map((entity) => ( {localEntities.map((entity) => {
<group key={entity.id} position={entityPosition(entity)}> const position = localEntityPosition(entity);
{entity.type === "asteroid" ? <AsteroidMesh scale={1.7} /> : entity.type === "station" ? <StationMesh scale={0.8} /> : <ShipMesh scale={0.36} hostile={entity.type === "hostile"} color={entity.type === "hostile" ? "#7f1d1d" : "#1a3a2a"} emissive={entity.type === "hostile" ? "#ef4444" : "#22c55e"} />} return (
</group> <group key={entity.id} position={position}>
))} <group
onClick={(event) => {
event.stopPropagation();
onEntityPick?.(entity, position);
}}
onContextMenu={(event) => {
event.stopPropagation();
onEntityContext?.(entity, position, event);
}}
onPointerOver={(event) => {
event.stopPropagation();
document.body.style.cursor = "pointer";
}}
onPointerOut={() => {
document.body.style.cursor = "";
}}
>
{entity.type === "asteroid" ? <AsteroidMesh scale={1.7} /> : entity.type === "station" ? <StationMesh scale={0.8} /> : <ShipMesh scale={0.14} hostile={entity.type === "hostile"} color={entity.type === "hostile" ? "#7f1d1d" : "#1a3a2a"} emissive={entity.type === "hostile" ? "#ef4444" : "#22c55e"} />}
</group>
</group>
);
})}
{localWaypoints.length > 0 && <RouteLine points={[shipPosition, ...localWaypoints.map((waypoint) => waypoint.position)]} color="#f0a030" width={2} />} {localWaypoints.length > 0 && <RouteLine points={[shipPosition, ...localWaypoints.map((waypoint) => waypoint.position)]} color="#f0a030" width={2} />}
{localWaypoints.map((waypoint) => ( {localWaypoints.map((waypoint) => (
<mesh key={waypoint.id} position={waypoint.position}> <mesh key={waypoint.id} position={waypoint.position}>
@@ -109,7 +146,7 @@ function MovementWorld({ currentSystemId, currentSystem, localEntities, localWay
export function MovementScene(props: MovementSceneProps) { export function MovementScene(props: MovementSceneProps) {
return ( return (
<SpaceCanvas camera={{ position: [0, 42, 62], fov: 55, near: 0.1, far: 5000 }}> <SpaceCanvas camera={{ position: [18, 18, 32], fov: 55, near: 0.1, far: 5000 }}>
<MovementWorld {...props} /> <MovementWorld {...props} />
</SpaceCanvas> </SpaceCanvas>
); );

View File

@@ -14,13 +14,22 @@ export function CameraRig({ target, orbit = false, distance = 90 }: CameraRigPro
const controlsRef = useRef<any>(null); const controlsRef = useRef<any>(null);
const { camera } = useThree(); const { camera } = useThree();
const desired = useRef(new THREE.Vector3(0, 0, 0)); const desired = useRef(new THREE.Vector3(0, 0, 0));
const lastTarget = useRef<THREE.Vector3 | null>(null);
useEffect(() => { useEffect(() => {
if (target) desired.current.set(target[0], target[1], target[2]); if (target) {
desired.current.set(target[0], target[1], target[2]);
lastTarget.current ??= desired.current.clone();
}
}, [target]); }, [target]);
useFrame((_, dt) => { useFrame((_, dt) => {
if (!target) return; if (!target) return;
const previous = lastTarget.current ?? desired.current;
const movement = desired.current.clone().sub(previous);
if (movement.lengthSq() > 0) camera.position.add(movement);
lastTarget.current = desired.current.clone();
const current = controlsRef.current?.target; const current = controlsRef.current?.target;
if (current) { if (current) {
current.lerp(desired.current, Math.min(1, dt * 3)); current.lerp(desired.current, Math.min(1, dt * 3));

View File

@@ -17,6 +17,7 @@ type StarSystemContentsProps = {
expanded?: boolean; expanded?: boolean;
scale?: number; scale?: number;
onPoiSelect?: (poi: SystemPointOfInterest, event: ThreeEvent<MouseEvent>) => void; onPoiSelect?: (poi: SystemPointOfInterest, event: ThreeEvent<MouseEvent>) => void;
onPoiContext?: (poi: SystemPointOfInterest, event: ThreeEvent<MouseEvent>) => void;
}; };
const poiColors: Record<SystemPointOfInterest["type"], string> = { const poiColors: Record<SystemPointOfInterest["type"], string> = {
@@ -78,6 +79,7 @@ function PoiMarker({
expanded, expanded,
selected, selected,
onSelect, onSelect,
onContext,
}: { }: {
poi: SystemPointOfInterest; poi: SystemPointOfInterest;
position: [number, number, number]; position: [number, number, number];
@@ -85,6 +87,7 @@ function PoiMarker({
expanded?: boolean; expanded?: boolean;
selected?: boolean; selected?: boolean;
onSelect?: (poi: SystemPointOfInterest, event: ThreeEvent<MouseEvent>) => void; onSelect?: (poi: SystemPointOfInterest, event: ThreeEvent<MouseEvent>) => void;
onContext?: (poi: SystemPointOfInterest, event: ThreeEvent<MouseEvent>) => void;
}) { }) {
const color = poiColors[poi.type]; const color = poiColors[poi.type];
const markerRef = useRef<THREE.Group>(null); const markerRef = useRef<THREE.Group>(null);
@@ -108,6 +111,10 @@ function PoiMarker({
event.stopPropagation(); event.stopPropagation();
onSelect?.(poi, event); onSelect?.(poi, event);
}} }}
onContextMenu={(event) => {
event.stopPropagation();
onContext?.(poi, event);
}}
onPointerOver={(event) => { onPointerOver={(event) => {
event.stopPropagation(); event.stopPropagation();
document.body.style.cursor = "pointer"; document.body.style.cursor = "pointer";
@@ -144,9 +151,11 @@ export function StarSystemContents({
expanded = false, expanded = false,
scale = 1, scale = 1,
onPoiSelect, onPoiSelect,
onPoiContext,
}: StarSystemContentsProps) { }: StarSystemContentsProps) {
const displayPlanets = expanded ? planets : planets.slice(0, 5); const displayPlanets = expanded ? planets : planets.slice(0, 5);
const displayPois = expanded ? pointsOfInterest : pointsOfInterest.slice(0, 4); const displayPois = expanded ? pointsOfInterest : pointsOfInterest.slice(0, 4);
const labelScale = Math.min(scale, 1.45);
const maxOrbit = Math.max(4.8, ...displayPlanets.map((planet) => 2 + planet.orbit * 0.42)) * scale; const maxOrbit = Math.max(4.8, ...displayPlanets.map((planet) => 2 + planet.orbit * 0.42)) * scale;
const poiRingStart = maxOrbit + (expanded ? 2.2 : 1.45) * scale; const poiRingStart = maxOrbit + (expanded ? 2.2 : 1.45) * scale;
const poiRingGap = (expanded ? 1.25 : 0.95) * scale; const poiRingGap = (expanded ? 1.25 : 0.95) * scale;
@@ -174,7 +183,7 @@ export function StarSystemContents({
</mesh> </mesh>
{expanded && ( {expanded && (
<Billboard position={[0, planetSize * 3.2, 0]}> <Billboard position={[0, planetSize * 3.2, 0]}>
<Text color={color} fontSize={0.58 * scale} anchorX="center" anchorY="middle" outlineColor="#040810" outlineWidth={0.04}> <Text color={color} fontSize={0.58 * labelScale} anchorX="center" anchorY="middle" outlineColor="#040810" outlineWidth={0.04}>
{planet.name} {planet.name}
</Text> </Text>
</Billboard> </Billboard>
@@ -185,22 +194,24 @@ export function StarSystemContents({
})} })}
{displayPois.map((poi) => { {displayPois.map((poi) => {
const orbit = getSystemPoiOrbitSpec({ systemId, planets, pointsOfInterest, poiId: poi.id, scale, expanded }); const orbit = getSystemPoiOrbitSpec({ systemId, planets, pointsOfInterest, poiId: poi.id, scale, expanded });
const markerSize = Math.min(0.34 * scale * (expanded ? 1.15 : 0.9), expanded ? 0.62 : 0.42);
return ( return (
<OrbitingGroup key={poi.id} radius={orbit.radius} initialAngle={orbit.initialAngle} speed={orbit.speed}> <OrbitingGroup key={poi.id} radius={orbit.radius} initialAngle={orbit.initialAngle} speed={orbit.speed}>
<PoiMarker <PoiMarker
poi={poi} poi={poi}
position={[0, orbit.y, 0]} position={[0, orbit.y, 0]}
size={0.34 * scale * (expanded ? 1.15 : 0.9)} size={markerSize}
expanded={expanded} expanded={expanded}
selected={poi.id === selectedPoiId} selected={poi.id === selectedPoiId}
onSelect={onPoiSelect} onSelect={onPoiSelect}
onContext={onPoiContext}
/> />
</OrbitingGroup> </OrbitingGroup>
); );
})} })}
{expanded && ( {expanded && (
<Billboard position={[0, -2.6 * scale, 0]}> <Billboard position={[0, -2.6 * scale, 0]}>
<Text color="#94a3b8" fontSize={0.62 * scale} anchorX="center" anchorY="middle" outlineColor="#040810" outlineWidth={0.04}> <Text color="#94a3b8" fontSize={0.62 * labelScale} anchorX="center" anchorY="middle" outlineColor="#040810" outlineWidth={0.04}>
{systemName} gravity well {systemName} gravity well
</Text> </Text>
</Billboard> </Billboard>