Expand sandbox game loop prototype
This commit is contained in:
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user