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 { SliceCargoPanel } from "./ui/SliceCargoPanel";
import { SliceDemoLinks } from "./ui/SliceDemoLinks";
import { SliceEventLog } from "./ui/SliceEventLog";
import { SliceModuleRack } from "./ui/SliceModuleRack";
import { SliceObjectiveTracker } from "./ui/SliceObjectiveTracker";
@@ -9,21 +8,9 @@ import { SliceShipStatus } from "./ui/SliceShipStatus";
import { SliceStage } from "./ui/SliceStage";
import { SliceStationPanel } from "./ui/SliceStationPanel";
import { SliceTopBar } from "./ui/SliceTopBar";
import { sliceButton, slicePanel, slicePrimaryButton } from "./ui/sliceStyles";
import { slicePanel, slicePrimaryButton } from "./ui/sliceStyles";
import { useSliceController } from "./useSliceController";
import type { SliceCommand } 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",
};
import type { SliceActionId } from "./sliceController";
function LoadingSlice({ message, onReset }: { message: string; onReset: () => void }) {
return (
@@ -42,16 +29,22 @@ export function SeamlessGameLoopSlice() {
const {
session,
facts,
primaryCommand,
contextualActions,
operationProgress,
reset,
missingRequestedSession,
blockedReason,
startUndock,
travelToBelt,
travelToStation,
selectTarget,
selectPoiTarget,
selectSystemPoiTarget,
selectManualTarget,
approachSelectedTarget,
approachTarget,
dock,
mine,
dockAtTarget,
startMining,
mineTarget,
stopMining,
openService,
closeService,
startCombat,
@@ -61,28 +54,25 @@ export function SeamlessGameLoopSlice() {
emit,
} = controller;
if (!session || !facts || !primaryCommand) {
if (!session || !facts || !contextualActions) {
return <LoadingSlice message={missingRequestedSession ? `Slice session ${missingRequestedSession} was not found.` : "Creating slice session..."} onReset={reset} />;
}
const runCommand = (command: SliceCommand) => {
const actions: Record<SliceCommand, () => void> = {
const runAction = (actionId: SliceActionId) => {
const actions: Record<SliceActionId, () => void> = {
undock: startUndock,
travelToBelt,
travelToStation,
approachTarget: approachSelectedTarget,
dock,
mine,
startMining,
stopMining,
openRefining: () => openService("refining"),
openFitting: () => openService("fitting"),
openMarket: () => openService("market"),
startCombat,
};
actions[command]();
actions[actionId]();
};
const reason = blockedReason(primaryCommand);
const canRunPrimary = reason === null;
return (
<SliceShell
top={<SliceTopBar session={session} onReset={reset} />}
@@ -98,18 +88,19 @@ export function SeamlessGameLoopSlice() {
onRefine={refineVeldsparStack}
onUpdateFit={updateFitting}
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={
<div className="slice-bottom-content" style={{ height: "100%", minHeight: 0, display: "grid", gridTemplateColumns: "minmax(220px, 0.8fr) minmax(260px, 1.2fr)", gap: 8 }}>
<SliceActionRail>
<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>
<SliceActionRail session={session} actions={contextualActions} onAction={runAction} />
<SliceModuleRack session={session} />
</div>
}

View File

@@ -1,6 +1,7 @@
import { addCargoClamped, removeCargo, upsertCargo } from "./sliceEconomy";
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.";
@@ -52,18 +53,79 @@ function migrateMode(mode: LegacyGameSliceMode | string | undefined): GameSliceM
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 {
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 {
id: args.id ?? createGameSliceSessionId(),
mode: migrateMode(args.mode),
currentSystemId: args.currentSystemId ?? "sol",
currentPoiId: args.currentPoiId ?? "sol-station-0",
dockedStationPoiId: args.dockedStationPoiId ?? "sol-station-0",
dockedStationName: args.dockedStationName ?? "Jita IV - Moon 4",
currentPoiId,
dockedStationPoiId,
dockedStationName,
activeTravelSessionId: args.activeTravelSessionId ?? null,
activeService: args.activeService ?? null,
activeOperation: args.activeOperation ?? null,
activeOperation,
selectedTarget: args.selectedTarget ?? null,
shipPosition,
navigationMessage: args.navigationMessage ?? null,
wallet: args.wallet ?? 25000,
cargoCapacity: args.cargoCapacity ?? 2500,
cargo: args.cargo ?? [],
@@ -93,6 +155,9 @@ export function loadGameSliceSession(id: string): GameSliceSession | null {
activeTravelSessionId: parsed.activeTravelSessionId ?? null,
activeService: parsed.activeService ?? null,
activeOperation: parsed.activeOperation ?? null,
selectedTarget: parsed.selectedTarget ?? null,
shipPosition: parsed.shipPosition ?? positionForPoi(parsed.currentPoiId ?? null),
navigationMessage: parsed.navigationMessage ?? null,
cargo: parsed.cargo ?? [],
fittedModules: parsed.fittedModules ?? [],
zoraModules: parsed.zoraModules ?? ["comms"],
@@ -154,6 +219,12 @@ function messageForEvent(event: SliceEvent): string {
return `Docking approach started for ${event.stationName}.`;
case "navigation.started":
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":
return `Arrived at ${event.poiName}.`;
case "station.docked":
@@ -164,6 +235,8 @@ function messageForEvent(event: SliceEvent): string {
return `Mining laser cycling on ${event.ore}.`;
case "mining.completed":
return `Mined ${event.quantity.toLocaleString()} ${event.ore}.`;
case "mining.stopped":
return "Mining lasers stopped.";
case "station.serviceOpened":
return `${event.service[0].toUpperCase()}${event.service.slice(1)} service opened.`;
case "station.serviceClosed":
@@ -217,63 +290,169 @@ export function applySliceEvent(session: GameSliceSession, event: SliceEvent): G
};
break;
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,
mode: "travel",
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,
dockedStationName: null,
selectedTarget: event.target,
navigationMessage: `Approaching ${event.target.name}`,
};
break;
case "navigation.arrived":
next = {
...next,
mode: "flight",
currentSystemId: event.systemId,
currentPoiId: event.poiId,
dockedStationPoiId: null,
dockedStationName: null,
activeOperation: null,
};
if (event.poiId.includes("belt")) next = withCompletedObjective(next, "navigate_to_belt");
{
const arrivedTarget = next.activeOperation?.kind === "travel"
? next.activeOperation.target
: targetForPoi(event.poiId, event.poiName, event.systemId);
const currentPoiId = arrivedTarget?.poiId ?? null;
const shipPosition = arrivedTarget?.position ?? positionForPoi(currentPoiId);
next = {
...next,
mode: "flight",
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;
case "station.docked":
next = withCompletedObjective({
...next,
mode: "station",
currentSystemId: event.systemId,
currentPoiId: event.stationPoiId,
dockedStationPoiId: event.stationPoiId,
dockedStationName: event.stationName,
activeService: null,
activeOperation: null,
}, "dock_at_station");
{
const dockTarget = next.selectedTarget?.poiId === event.stationPoiId
? next.selectedTarget
: targetForPoi(event.stationPoiId, event.stationName, event.systemId);
next = {
...next,
currentSystemId: event.systemId,
currentPoiId: event.stationPoiId,
shipPosition: dockTarget?.position ?? positionForPoi(event.stationPoiId),
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;
case "station.undocked":
next = withCompletedObjective({
...next,
mode: "flight",
currentSystemId: event.systemId,
dockedStationPoiId: null,
dockedStationName: null,
activeService: null,
activeOperation: null,
}, "undock");
{
const currentTarget = next.selectedTarget?.poiId === next.currentPoiId
? next.selectedTarget
: targetForPoi(next.currentPoiId, undefined, next.currentSystemId);
next = withCompletedObjective({
...next,
mode: "flight",
currentSystemId: event.systemId,
dockedStationPoiId: null,
dockedStationName: null,
activeService: null,
activeOperation: null,
shipPosition: currentTarget?.position ?? positionForPoi(next.currentPoiId),
selectedTarget: null,
navigationMessage: "Undocked. Select a local target.",
}, "undock");
}
break;
case "mining.started":
next = {
...next,
mode: "mining",
activeService: null,
activeOperation: { kind: "mining", ore: event.ore, quantity: event.quantity, startedAt: event.startedAt, durationMs: event.durationMs },
};
{
const miningTarget = next.selectedTarget?.poiId === event.targetPoiId
? next.selectedTarget
: targetForPoi(event.targetPoiId, event.targetName, next.currentSystemId);
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;
case "mining.completed": {
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;
}
case "mining.stopped":
next = { ...next, mode: "flight", activeOperation: null, navigationMessage: "Mining stopped." };
break;
case "station.serviceOpened":
next = { ...next, mode: "services", activeService: event.service, activeOperation: null };
break;

View File

@@ -1,18 +1,26 @@
import { getCargoUsed, sellableCargo } from "./sliceEconomy";
import type { GameSliceSession, SliceObjectiveId, SliceService } from "./types";
import { SLICE_BELT, SLICE_STATION } from "./sliceWorld";
import type { GameSliceSession, SliceService } from "./types";
import { isBeltPoi, isStationPoi, readablePoiName } from "./sliceWorld";
export type SliceCommand =
export type SliceActionId =
| "undock"
| "travelToBelt"
| "travelToStation"
| "approachTarget"
| "dock"
| "mine"
| "startMining"
| "stopMining"
| "openRefining"
| "openFitting"
| "openMarket"
| "startCombat";
export type SliceAction = {
id: SliceActionId;
label: string;
enabled: boolean;
reason: string | null;
tone?: "primary" | "normal" | "danger" | "debug";
};
export type SliceFacts = {
activePoiName: string;
cargoUsed: number;
@@ -27,15 +35,25 @@ export type SliceFacts = {
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 {
const cargoUsed = getCargoUsed(session);
const oreQuantity = session.cargo.find((item) => item.item === "Veldspar")?.quantity ?? 0;
const isDocked = Boolean(session.dockedStationPoiId);
const isAtBelt = session.currentPoiId === SLICE_BELT.poiId;
const isAtStation = session.currentPoiId === SLICE_STATION.poiId;
const isAtBelt = isBeltPoi(session.currentPoiId);
const isAtStation = isStationPoi(session.currentPoiId);
const isBusy = Boolean(session.activeOperation);
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,
freeCargo: Math.max(0, session.cargoCapacity - cargoUsed),
oreQuantity,
@@ -57,57 +75,82 @@ export function canUseStationService(session: GameSliceSession, service: SliceSe
return true;
}
export function getBlockedReason(session: GameSliceSession, command: SliceCommand): string | null {
export function getActionBlockedReason(session: GameSliceSession, actionId: SliceActionId): string | null {
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":
if (busy) return busy;
return facts.isDocked ? null : "Docking clamps are already released.";
case "travelToBelt":
if (facts.isDocked) return "Undock before plotting local travel.";
if (facts.isAtBelt) return "Already holding at the belt.";
return null;
case "travelToStation":
if (facts.isDocked) return "Already docked at the station.";
if (facts.isAtStation) return null;
case "approachTarget":
if (busy) return busy;
if (facts.isDocked) return "Undock before local navigation.";
if (!session.selectedTarget) return "Select a local target first.";
if (session.selectedTarget.poiId && session.currentPoiId === session.selectedTarget.poiId) return `Already holding at ${session.selectedTarget.name}.`;
return null;
case "dock":
if (busy) return busy;
if (facts.isDocked) return "Already docked.";
return facts.isAtStation ? null : "Return to the station grid before docking.";
case "mine":
if (!facts.isAtBelt) return "Mining requires the asteroid belt.";
return facts.isAtStation ? null : "Approach the station grid before docking.";
case "startMining":
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.freeCargo <= 0) return "Cargo hold is full.";
return null;
case "stopMining":
return session.activeOperation?.kind === "mining" ? null : "No mining cycle is active.";
case "openRefining":
if (busy) return busy;
if (!facts.isDocked) return "Dock at the station to use refining.";
return facts.hasRefinableOre ? null : "Refining needs at least 333 Veldspar.";
case "openFitting":
if (busy) return busy;
return facts.isDocked ? null : "Dock at the station to use fitting.";
case "openMarket":
if (busy) return busy;
if (!facts.isDocked) return "Dock at the station to use the market.";
return facts.hasSellableCargo ? null : "No sellable cargo is available.";
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 commands: SliceCommand[] = ["undock", "travelToBelt", "travelToStation", "dock", "mine", "openRefining", "openFitting", "openMarket", "startCombat"];
return Object.fromEntries(commands.map((command) => [command, getBlockedReason(session, command) === null])) as Record<SliceCommand, boolean>;
}
const action = (session: GameSliceSession, id: SliceActionId, label: string, tone: SliceAction["tone"] = "normal"): SliceAction => {
const reason = getActionBlockedReason(session, id);
return { id, label, enabled: reason === null, reason, tone };
};
export function commandForObjective(session: GameSliceSession): SliceCommand {
const byObjective: Record<SliceObjectiveId, SliceCommand> = {
undock: "undock",
navigate_to_belt: "travelToBelt",
mine_ore: "mine",
dock_at_station: getSliceFacts(session).isAtStation ? "dock" : "travelToStation",
refine_ore: "openRefining",
fit_module: "openFitting",
sell_goods: "openMarket",
combat_trial: "startCombat",
};
return byObjective[session.activeObjectiveId];
export function getContextualSliceActions(session: GameSliceSession): SliceAction[] {
const facts = getSliceFacts(session);
if (session.activeOperation?.kind === "mining") {
return [action(session, "stopMining", "Stop Mining", "danger")];
}
if (session.activeOperation) {
const baseId: SliceActionId = session.activeOperation.kind === "travel" ? "approachTarget" : session.activeOperation.kind === "docking" ? "dock" : "undock";
const label = session.activeOperation.kind === "travel" ? "Approach Target" : session.activeOperation.kind === "docking" ? "Dock" : "Undock";
return [action(session, baseId, label, "primary")];
}
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_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 = {
systemId: SLICE_SYSTEM_ID,
poiId: "sol-station-0",
@@ -23,3 +29,47 @@ export const SLICE_POI_NAMES: Record<string, string> = {
[SLICE_STATION.poiId]: SLICE_STATION.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 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 =
| { 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: "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 =
| "undock"
@@ -56,11 +79,15 @@ export type SliceEvent =
| { type: "station.undockStarted"; systemId: 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.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: "station.docked"; systemId: string; stationPoiId: string; stationName: 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.stopped" }
| { type: "station.serviceOpened"; service: SliceService }
| { type: "station.serviceClosed" }
| { type: "refining.completed"; ore: string; inputQuantity: number; minerals: SliceCargoItem[] }
@@ -81,6 +108,9 @@ export type GameSliceSession = {
activeTravelSessionId: string | null;
activeService: SliceService | null;
activeOperation: SliceOperation | null;
selectedTarget: SliceTarget | null;
shipPosition: SliceVec3;
navigationMessage: string | null;
wallet: number;
cargoCapacity: number;
cargo: SliceCargoItem[];

View File

@@ -1,10 +1,46 @@
import type { ReactNode } from "react";
import { slicePanel } from "./sliceStyles";
import type { SliceAction, SliceActionId } from "../sliceController";
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 (
<section style={{ ...slicePanel, height: "100%", minHeight: 0, padding: 12, display: "flex", gap: 8, alignItems: "center", justifyContent: "center", flexWrap: "wrap", boxSizing: "border-box", overflow: "auto" }}>
{children}
<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" }}>
<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>
);
}

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="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: 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 style={{ ...slicePanel, padding: 10, display: "flex", gap: 8, justifyContent: "center", flexWrap: "wrap", pointerEvents: "auto", gridColumn: "1 / -1", alignSelf: "end" }}>
{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 { getSystemPoiPosition } from "../../r3f/shared/poiOrbit";
import type { GalaxySystem, Vec3 } from "../../r3f/shared/types";
import { MOVEMENT_SYSTEM_SCALE, MovementScene } from "../../r3f/movement/MovementScene";
import type { GalaxySystem, SystemPointOfInterest, Vec3 } from "../../r3f/shared/types";
import { localEntityPosition, MOVEMENT_SYSTEM_SCALE, MovementScene } from "../../r3f/movement/MovementScene";
import type { LocalEntity, LocalWaypoint } from "../../r3f/movement/movementState";
import type { GameSliceSession } from "../types";
import { SLICE_BELT, SLICE_POI_NAMES, SLICE_STATION } from "../sliceWorld";
import { metricLabel, slicePanel } from "./sliceStyles";
import type { GameSliceSession, SliceTarget, SliceTargetKind } from "../types";
import { SLICE_BELT, SLICE_BELT_POSITION, SLICE_POI_NAMES, SLICE_STATION, SLICE_STATION_POSITION, SLICE_TRAVEL_SYSTEM_IDS } from "../sliceWorld";
import { metricLabel, sliceButton, slicePanel, slicePrimaryButton } from "./sliceStyles";
const fallbackEntities: LocalEntity[] = [
{ id: "slice-belt", name: SLICE_BELT.name, type: "asteroid", x: 620, y: 320, distance: 42 },
{ id: "slice-station", name: SLICE_STATION.name, type: "station", x: 430, y: 260, distance: 8 },
];
type ContextAction = {
label: string;
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 {
if (poiId === SLICE_BELT.poiId) return [26, 0, -8];
if (poiId === SLICE_STATION.poiId) return [-10, 0, 10];
if (poiId === SLICE_BELT.poiId) return SLICE_BELT_POSITION;
if (poiId === SLICE_STATION.poiId) return SLICE_STATION_POSITION;
return [0, 0, 0];
}
export function SliceFlightStage({ session }: { session: GameSliceSession }) {
const [currentSystem, setCurrentSystem] = useState<GalaxySystem | null>(null);
function lerpVec3(from: Vec3, to: Vec3, progress: number): Vec3 {
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(() => {
loadGalaxyData().then(({ systems }) => {
setCurrentSystem(systems.find((system) => system.id === session.currentSystemId) ?? systems.find((system) => system.id === "sol") ?? null);
});
}, [session.currentSystemId]);
loadGalaxyData().then(({ systems }) => setSystems(systems));
}, []);
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);
return getSystemPoiPosition({
systemId: currentSystem.id,
planets: currentSystem.planets,
pointsOfInterest: currentSystem.pointsOfInterest,
poiId: session.currentPoiId,
scale: MOVEMENT_SYSTEM_SCALE,
expanded: true,
});
return poiPosition(currentSystem, session.currentPoiId);
}, [currentSystem, session.currentPoiId]);
const waypoint: LocalWaypoint[] = session.currentPoiId
? [{ id: session.currentPoiId, name: SLICE_POI_NAMES[session.currentPoiId] ?? session.currentPoiId, type: "poi", systemId: session.currentSystemId, poiId: session.currentPoiId, position: shipPosition, arrived: true }]
: [];
const shipPosition = useMemo(() => {
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 (
<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
currentSystemId={session.currentSystemId}
currentSystem={currentSystem}
localEntities={fallbackEntities}
localEntities={localEntities}
localWaypoints={waypoint}
shipPosition={shipPosition}
onFrame={() => {}}
onWaypointPick={() => {}}
onPoiPick={() => {}}
onWaypointPick={onManualTarget}
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>
<strong style={{ color: "#f0a030" }}>{SLICE_POI_NAMES[session.currentPoiId ?? ""] ?? "Open space"}</strong>
<span style={{ color: "#94a3b8" }}>Use the primary action rail to plot local travel.</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)" }}>
<span style={metricLabel}>Flight</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 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>
);
}

View File

@@ -2,20 +2,24 @@ import type { GameSliceSession } from "../types";
import { SLICE_BELT } from "../sliceWorld";
import { metricLabel, slicePanel } from "./sliceStyles";
import { SliceProgressBar } from "./SliceProgressBar";
import { getCargoUsed } from "../sliceEconomy";
export function SliceMiningStage({ session, progress }: { session: GameSliceSession; progress: number }) {
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 (
<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={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" }}>
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>
<SliceProgressBar value={progress} color="#22c55e" />
<div style={{ marginTop: 12, color: "#f0a030", fontFamily: "var(--font-mono)", fontSize: 12 }}>
{operation ? `${Math.round(progress * 100)}% · ${operation.quantity.toLocaleString()} ${operation.ore}` : `${session.cargo.length} cargo stacks`}
<div style={{ marginTop: 12, color: "#f0a030", fontFamily: "var(--font-mono)", fontSize: 12, display: "grid", gap: 4 }}>
<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>
</section>

View File

@@ -7,7 +7,7 @@ export function SliceObjectiveTracker({ session }: { session: GameSliceSession }
return (
<section style={{ ...slicePanel, padding: 12, minHeight: 248 }}>
<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>
</div>
<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-center { overflow: visible !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-loop-panel { min-height: 420px !important; }
.slice-loop-state { min-height: 220px !important; }

View File

@@ -1,10 +1,11 @@
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 { SliceProgressBar } from "./SliceProgressBar";
import { SliceStationStage } from "./SliceStationStage";
import { SliceFlightStage } from "./SliceFlightStage";
import { SliceTravelStage } from "./SliceTravelStage";
import { SliceMiningStage } from "./SliceMiningStage";
import { SliceServicesStage } from "./SliceServicesStage";
import { SliceCombatStage } from "./SliceCombatStage";
@@ -35,6 +36,13 @@ export function SliceStage({
onRefine,
onUpdateFit,
onSell,
onPoiTarget,
onSystemPoiTarget,
onSelectTarget,
onManualTarget,
onApproachTarget,
onDockAtTarget,
onMineTarget,
}: {
session: GameSliceSession;
facts: SliceFacts;
@@ -45,14 +53,20 @@ export function SliceStage({
onRefine: (quantity: number) => void;
onUpdateFit: (modules: SliceFittedModule[]) => 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") {
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 === "undocking" || session.mode === "docking") return <OperationStage session={session} progress={operationProgress} />;
if (session.mode === "combat") return <SliceCombatStage emit={emit} />;
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>
<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 }}>
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>
</div>
@@ -36,9 +36,9 @@ export function SliceStationStage({
</div>
<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} 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>
</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);
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" }}>
<button style={sliceButton} onClick={() => { window.location.href = "/docs/demo-gallery"; }}>Gallery</button>
<strong style={{ color: "#f0a030", letterSpacing: 0 }}>MVP LOOP SLICE</strong>
<strong style={{ color: "#f0a030", letterSpacing: 0 }}>VOID::NAV</strong>
<span>{SYSTEM_NAMES[session.currentSystemId] ?? session.currentSystemId}</span>
<span>{session.dockedStationName ? `Docked: ${session.dockedStationName}` : "In space"}</span>
<span>Mode: {session.mode.toUpperCase()}</span>

View File

@@ -1,9 +1,11 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { refineVeldspar } from "./sliceEconomy";
import { commandForObjective, getAvailableSliceCommands, getBlockedReason, getSliceFacts, type SliceCommand } from "./sliceController";
import { getCargoUsed, refineVeldspar } from "./sliceEconomy";
import { getActionBlockedReason, getContextualSliceActions, getSliceFacts, type SliceActionId } from "./sliceController";
import { useGameSliceSession } from "./useGameSliceSession";
import { SLICE_BELT, SLICE_DURATIONS, SLICE_STATION } from "./sliceWorld";
import type { GameSliceSession, SliceEvent, SliceFittedModule, SliceService } from "./types";
import { isBeltPoi, isStationPoi, SLICE_DURATIONS } from "./sliceWorld";
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 {
const operation = session.activeOperation;
@@ -11,7 +13,7 @@ function completionEventFor(session: GameSliceSession): SliceEvent | null {
if (Date.now() - operation.startedAt < operation.durationMs) return null;
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 === "mining") return { type: "mining.completed", ore: operation.ore, quantity: operation.quantity };
return null;
@@ -34,32 +36,51 @@ export function useSliceController() {
useEffect(() => {
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);
if (completion) {
emit(completion);
if (completion.type === "mining.completed") emit({ type: "xp.awarded", skill: "Mining", amount: 25 });
completeOperation(session, completion);
return;
}
const remaining = Math.max(0, session.activeOperation.startedAt + session.activeOperation.durationMs - Date.now());
const timeout = window.setTimeout(() => {
const nextCompletion = completionEventFor(session);
if (nextCompletion) {
emit(nextCompletion);
if (nextCompletion.type === "mining.completed") emit({ type: "xp.awarded", skill: "Mining", amount: 25 });
completeOperation(session, nextCompletion);
}
}, remaining + 20);
return () => window.clearTimeout(timeout);
}, [emit, session]);
const facts = useMemo(() => session ? getSliceFacts(session) : null, [session]);
const availableCommands = useMemo(() => session ? getAvailableSliceCommands(session) : null, [session]);
const primaryCommand = useMemo(() => session ? commandForObjective(session) : null, [session]);
const contextualActions = useMemo(() => session ? getContextualSliceActions(session) : null, [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) => {
if (!session || getBlockedReason(session, command)) return;
const guarded = useCallback((actionId: SliceActionId, run: (session: GameSliceSession) => void) => {
if (!session || getActionBlockedReason(session, actionId)) return;
run(session);
}, [session]);
@@ -67,25 +88,80 @@ export function useSliceController() {
emit({ type: "station.undockStarted", systemId: current.currentSystemId, startedAt: Date.now(), durationMs: SLICE_DURATIONS.undock });
}), [emit, guarded]);
const travelToBelt = useCallback(() => guarded("travelToBelt", () => {
emit({ type: "navigation.started", targetSystemId: SLICE_BELT.systemId, targetPoiId: SLICE_BELT.poiId, targetPoiName: SLICE_BELT.name, startedAt: Date.now(), durationMs: SLICE_DURATIONS.localTravel });
const approachSelectedTarget = useCallback(() => guarded("approachTarget", (current) => {
if (!current.selectedTarget) return;
emit({ type: "navigation.approachStarted", target: current.selectedTarget, fromPosition: current.shipPosition, startedAt: Date.now(), durationMs: SLICE_DURATIONS.localTravel });
}), [emit, guarded]);
const travelToStation = useCallback(() => guarded("travelToStation", () => {
emit({ type: "navigation.started", targetSystemId: SLICE_STATION.systemId, targetPoiId: SLICE_STATION.poiId, targetPoiName: SLICE_STATION.name, startedAt: Date.now(), durationMs: SLICE_DURATIONS.localTravel });
const approachTarget = useCallback((target: SliceTarget) => {
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]);
const dock = useCallback(() => guarded("dock", () => {
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 startMining = useCallback(({ repeat = true }: { repeat?: boolean } = {}) => guarded("startMining", (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]);
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 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 }));
}, [emit, guarded]);
@@ -94,11 +170,41 @@ export function useSliceController() {
}, [emit, session?.activeService]);
const startCombat = useCallback(() => {
if (!session || getBlockedReason(session, "startCombat")) return;
if (!session || getActionBlockedReason(session, "startCombat")) return;
emit({ type: "combat.started" });
emit({ type: "zora.observed", trigger: "combat.started" });
}, [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) => {
if (!session) return;
const ore = session.cargo.find((item) => item.item === "Veldspar");
@@ -126,15 +232,21 @@ export function useSliceController() {
reset,
missingRequestedSession,
facts,
availableCommands,
primaryCommand,
contextualActions,
operationProgress,
blockedReason,
startUndock,
travelToBelt,
travelToStation,
selectTarget,
selectPoiTarget,
selectSystemPoiTarget,
selectManualTarget,
approachSelectedTarget,
approachTarget,
dock,
mine,
dockAtTarget,
startMining,
mineTarget,
stopMining,
openService,
closeService,
startCombat,

View File

@@ -1,5 +1,6 @@
import { useRef } from "react";
import { useFrame } from "@react-three/fiber";
import type { ThreeEvent } from "@react-three/fiber";
import * as THREE from "three";
import { SpaceCanvas } from "../shared/SpaceCanvas";
import { SpaceEnvironment } from "../shared/SpaceEnvironment";
@@ -22,12 +23,15 @@ type MovementSceneProps = {
onFrame: (dt: number, elapsedTime: number) => void;
onWaypointPick: (waypoint: LocalWaypoint) => 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;
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);
useFrame((state, dt) => {
onFrame(dt, state.clock.elapsedTime);
@@ -40,7 +44,7 @@ function MovementWorld({ currentSystemId, currentSystem, localEntities, localWay
return (
<>
<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]} />
<mesh
rotation={[-Math.PI / 2, 0, 0]}
@@ -80,16 +84,49 @@ function MovementWorld({ currentSystemId, currentSystem, localEntities, localWay
});
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}>
<ShipMesh scale={0.52} engineActive={localWaypoints.some((waypoint) => !waypoint.arrived)} />
<ShipMesh scale={0.18} engineActive={localWaypoints.some((waypoint) => !waypoint.arrived)} />
</group>
{localEntities.map((entity) => (
<group key={entity.id} position={entityPosition(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"} />}
</group>
))}
{localEntities.map((entity) => {
const position = localEntityPosition(entity);
return (
<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.map((waypoint) => (
<mesh key={waypoint.id} position={waypoint.position}>
@@ -109,7 +146,7 @@ function MovementWorld({ currentSystemId, currentSystem, localEntities, localWay
export function MovementScene(props: MovementSceneProps) {
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} />
</SpaceCanvas>
);

View File

@@ -14,13 +14,22 @@ export function CameraRig({ target, orbit = false, distance = 90 }: CameraRigPro
const controlsRef = useRef<any>(null);
const { camera } = useThree();
const desired = useRef(new THREE.Vector3(0, 0, 0));
const lastTarget = useRef<THREE.Vector3 | null>(null);
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]);
useFrame((_, dt) => {
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;
if (current) {
current.lerp(desired.current, Math.min(1, dt * 3));

View File

@@ -17,6 +17,7 @@ type StarSystemContentsProps = {
expanded?: boolean;
scale?: number;
onPoiSelect?: (poi: SystemPointOfInterest, event: ThreeEvent<MouseEvent>) => void;
onPoiContext?: (poi: SystemPointOfInterest, event: ThreeEvent<MouseEvent>) => void;
};
const poiColors: Record<SystemPointOfInterest["type"], string> = {
@@ -78,6 +79,7 @@ function PoiMarker({
expanded,
selected,
onSelect,
onContext,
}: {
poi: SystemPointOfInterest;
position: [number, number, number];
@@ -85,6 +87,7 @@ function PoiMarker({
expanded?: boolean;
selected?: boolean;
onSelect?: (poi: SystemPointOfInterest, event: ThreeEvent<MouseEvent>) => void;
onContext?: (poi: SystemPointOfInterest, event: ThreeEvent<MouseEvent>) => void;
}) {
const color = poiColors[poi.type];
const markerRef = useRef<THREE.Group>(null);
@@ -108,6 +111,10 @@ function PoiMarker({
event.stopPropagation();
onSelect?.(poi, event);
}}
onContextMenu={(event) => {
event.stopPropagation();
onContext?.(poi, event);
}}
onPointerOver={(event) => {
event.stopPropagation();
document.body.style.cursor = "pointer";
@@ -144,9 +151,11 @@ export function StarSystemContents({
expanded = false,
scale = 1,
onPoiSelect,
onPoiContext,
}: StarSystemContentsProps) {
const displayPlanets = expanded ? planets : planets.slice(0, 5);
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 poiRingStart = maxOrbit + (expanded ? 2.2 : 1.45) * scale;
const poiRingGap = (expanded ? 1.25 : 0.95) * scale;
@@ -174,7 +183,7 @@ export function StarSystemContents({
</mesh>
{expanded && (
<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}
</Text>
</Billboard>
@@ -185,22 +194,24 @@ export function StarSystemContents({
})}
{displayPois.map((poi) => {
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 (
<OrbitingGroup key={poi.id} radius={orbit.radius} initialAngle={orbit.initialAngle} speed={orbit.speed}>
<PoiMarker
poi={poi}
position={[0, orbit.y, 0]}
size={0.34 * scale * (expanded ? 1.15 : 0.9)}
size={markerSize}
expanded={expanded}
selected={poi.id === selectedPoiId}
onSelect={onPoiSelect}
onContext={onPoiContext}
/>
</OrbitingGroup>
);
})}
{expanded && (
<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
</Text>
</Billboard>