diff --git a/src/prototypes/game-slice/SeamlessGameLoopSlice.tsx b/src/prototypes/game-slice/SeamlessGameLoopSlice.tsx index 5bc1d92..d7e72a1 100644 --- a/src/prototypes/game-slice/SeamlessGameLoopSlice.tsx +++ b/src/prototypes/game-slice/SeamlessGameLoopSlice.tsx @@ -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 = { - 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 ; } - const runCommand = (command: SliceCommand) => { - const actions: Record void> = { + const runAction = (actionId: SliceActionId) => { + const actions: Record 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 ( } @@ -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={<>} + right={<>} bottom={
- - - {reason && {reason}} - {session.activeService && } - +
} diff --git a/src/prototypes/game-slice/gameSliceState.ts b/src/prototypes/game-slice/gameSliceState.ts index ffd8ab9..5c372aa 100644 --- a/src/prototypes/game-slice/gameSliceState.ts +++ b/src/prototypes/game-slice/gameSliceState.ts @@ -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): 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 { 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; diff --git a/src/prototypes/game-slice/sliceController.ts b/src/prototypes/game-slice/sliceController.ts index efec0dc..848fcee 100644 --- a/src/prototypes/game-slice/sliceController.ts +++ b/src/prototypes/game-slice/sliceController.ts @@ -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 { - 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; -} +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 = { - 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; } diff --git a/src/prototypes/game-slice/sliceWorld.ts b/src/prototypes/game-slice/sliceWorld.ts index 5d23cb5..421c894 100644 --- a/src/prototypes/game-slice/sliceWorld.ts +++ b/src/prototypes/game-slice/sliceWorld.ts @@ -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 = { [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 = { + [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; +} diff --git a/src/prototypes/game-slice/types.ts b/src/prototypes/game-slice/types.ts index 2345062..5e4233e 100644 --- a/src/prototypes/game-slice/types.ts +++ b/src/prototypes/game-slice/types.ts @@ -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[]; diff --git a/src/prototypes/game-slice/ui/SliceActionRail.tsx b/src/prototypes/game-slice/ui/SliceActionRail.tsx index 5bd1601..2656ce6 100644 --- a/src/prototypes/game-slice/ui/SliceActionRail.tsx +++ b/src/prototypes/game-slice/ui/SliceActionRail.tsx @@ -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 ( -
- {children} +
+
+
Selected Target
+
+ {selected ? selected.name : "None selected"} +
+ {session.navigationMessage &&
{session.navigationMessage}
} +
+
+ {actions.map((action) => ( + + ))} + {disabledReasons.length > 0 && ( + + {disabledReasons[0]} + + )} +
); } diff --git a/src/prototypes/game-slice/ui/SliceCombatStage.tsx b/src/prototypes/game-slice/ui/SliceCombatStage.tsx index ee055af..bec4186 100644 --- a/src/prototypes/game-slice/ui/SliceCombatStage.tsx +++ b/src/prototypes/game-slice/ui/SliceCombatStage.tsx @@ -50,7 +50,7 @@ export function SliceCombatStage({ emit }: { emit: (event: SliceEvent) => void } - +
{state.modules.map((module) => ( diff --git a/src/prototypes/game-slice/ui/SliceFlightStage.tsx b/src/prototypes/game-slice/ui/SliceFlightStage.tsx index df5f3cc..50f91f3 100644 --- a/src/prototypes/game-slice/ui/SliceFlightStage.tsx +++ b/src/prototypes/game-slice/ui/SliceFlightStage.tsx @@ -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 = { 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(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[0]["onPoiPick"]; + onSystemPoiTarget: (systemId: string, poi: SystemPointOfInterest, position: Vec3) => void; + onSelectTarget: (target: SliceTarget) => void; + onManualTarget: Parameters[0]["onWaypointPick"]; + onApproachTarget: (target: SliceTarget) => void; + onDockAtTarget: (target: SliceTarget) => void; + onMineTarget: (target: SliceTarget) => void; +}) { + const [systems, setSystems] = useState([]); + const [elapsed, setElapsed] = useState(0); + const [contextMenu, setContextMenu] = useState(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 ( -
+
event.preventDefault()} + onClick={() => setContextMenu(null)} + style={{ ...slicePanel, position: "relative", height: "100%", minHeight: 0, overflow: "hidden" }} + > {}} - 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)} /> -
- Flight Mode - {SLICE_POI_NAMES[session.currentPoiId ?? ""] ?? "Open space"} - Use the primary action rail to plot local travel. + +
+ Flight + {status} + {session.activeOperation?.kind === "travel" && {Math.round(operationProgress * 100)}%}
+ +
+
Jump Network
+
+ {travelSystems.map((system) => { + const primaryPoi = system.pointsOfInterest.find((poi) => poi.type === "station") ?? system.pointsOfInterest[0]; + const active = system.id === session.currentSystemId; + return ( + + ); + })} +
+
+ +
+
Local Contacts
+ {(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 ( + + ); + })} + {localEntities.map((entity) => ( + + ))} +
+ + {contextMenu && ( +
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 }}> +
+
{contextMenu.title}
+
{contextMenu.subtitle}
+
+ {contextMenu.actions.map((action) => ( + + ))} +
+ )}
); } diff --git a/src/prototypes/game-slice/ui/SliceMiningStage.tsx b/src/prototypes/game-slice/ui/SliceMiningStage.tsx index 419a9a9..b5509cd 100644 --- a/src/prototypes/game-slice/ui/SliceMiningStage.tsx +++ b/src/prototypes/game-slice/ui/SliceMiningStage.tsx @@ -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 (
Mining Operation
-

{SLICE_BELT.name}

+

{operation?.targetName ?? SLICE_BELT.name}

- 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.

-
- {operation ? `${Math.round(progress * 100)}% · ${operation.quantity.toLocaleString()} ${operation.ore}` : `${session.cargo.length} cargo stacks`} +
+ {operation ? `${Math.round(progress * 100)}% · ${operation.quantity.toLocaleString()} ${operation.ore}` : `${session.cargo.length} cargo stacks`} + {operation?.repeat ? "AUTO-CYCLE ENABLED" : "SINGLE CYCLE"} · Cargo {projectedCargo.toLocaleString()} / {session.cargoCapacity.toLocaleString()}
diff --git a/src/prototypes/game-slice/ui/SliceObjectiveTracker.tsx b/src/prototypes/game-slice/ui/SliceObjectiveTracker.tsx index c465449..f1008d7 100644 --- a/src/prototypes/game-slice/ui/SliceObjectiveTracker.tsx +++ b/src/prototypes/game-slice/ui/SliceObjectiveTracker.tsx @@ -7,7 +7,7 @@ export function SliceObjectiveTracker({ session }: { session: GameSliceSession } return (
- Objectives + Guidance {progress.percent}%
diff --git a/src/prototypes/game-slice/ui/SliceShell.tsx b/src/prototypes/game-slice/ui/SliceShell.tsx index ff44252..5318d39 100644 --- a/src/prototypes/game-slice/ui/SliceShell.tsx +++ b/src/prototypes/game-slice/ui/SliceShell.tsx @@ -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; } diff --git a/src/prototypes/game-slice/ui/SliceStage.tsx b/src/prototypes/game-slice/ui/SliceStage.tsx index 05e93b2..9ea3969 100644 --- a/src/prototypes/game-slice/ui/SliceStage.tsx +++ b/src/prototypes/game-slice/ui/SliceStage.tsx @@ -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 ; } - if (session.mode === "travel") return ; if (session.mode === "mining") return ; if (session.mode === "undocking" || session.mode === "docking") return ; if (session.mode === "combat") return ; if (session.mode === "station") return ; - return ; + return ; } diff --git a/src/prototypes/game-slice/ui/SliceStationStage.tsx b/src/prototypes/game-slice/ui/SliceStationStage.tsx index 828c0cb..1d3e8b7 100644 --- a/src/prototypes/game-slice/ui/SliceStationStage.tsx +++ b/src/prototypes/game-slice/ui/SliceStationStage.tsx @@ -18,7 +18,7 @@ export function SliceStationStage({
Docked Facility

{session.dockedStationName ?? SLICE_STATION.name}

- 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.

@@ -36,9 +36,9 @@ export function SliceStationStage({
- + - +
); diff --git a/src/prototypes/game-slice/ui/SliceTopBar.tsx b/src/prototypes/game-slice/ui/SliceTopBar.tsx index 9f9853b..d70dd3d 100644 --- a/src/prototypes/game-slice/ui/SliceTopBar.tsx +++ b/src/prototypes/game-slice/ui/SliceTopBar.tsx @@ -8,8 +8,7 @@ export function SliceTopBar({ session, onReset }: { session: GameSliceSession; o const objective = SLICE_OBJECTIVES.find((item) => item.id === session.activeObjectiveId); return (
- - MVP LOOP SLICE + VOID::NAV {SYSTEM_NAMES[session.currentSystemId] ?? session.currentSystemId} {session.dockedStationName ? `Docked: ${session.dockedStationName}` : "In space"} Mode: {session.mode.toUpperCase()} diff --git a/src/prototypes/game-slice/useSliceController.ts b/src/prototypes/game-slice/useSliceController.ts index f25c508..7b4e208 100644 --- a/src/prototypes/game-slice/useSliceController.ts +++ b/src/prototypes/game-slice/useSliceController.ts @@ -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 = { refining: "openRefining", fitting: "openFitting", market: "openMarket" }; + const commandByService: Record = { 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, diff --git a/src/prototypes/r3f/movement/MovementScene.tsx b/src/prototypes/r3f/movement/MovementScene.tsx index c62749d..f8279d8 100644 --- a/src/prototypes/r3f/movement/MovementScene.tsx +++ b/src/prototypes/r3f/movement/MovementScene.tsx @@ -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) => void; + onEntityPick?: (entity: LocalEntity, position: Vec3) => void; + onEntityContext?: (entity: LocalEntity, position: Vec3, event: ThreeEvent) => 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(null); useFrame((state, dt) => { onFrame(dt, state.clock.elapsedTime); @@ -40,7 +44,7 @@ function MovementWorld({ currentSystemId, currentSystem, localEntities, localWay return ( <> - + { + 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); + }} /> )} - !waypoint.arrived)} /> + !waypoint.arrived)} /> - {localEntities.map((entity) => ( - - {entity.type === "asteroid" ? : entity.type === "station" ? : } - - ))} + {localEntities.map((entity) => { + const position = localEntityPosition(entity); + return ( + + { + 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" ? : entity.type === "station" ? : } + + + ); + })} {localWaypoints.length > 0 && waypoint.position)]} color="#f0a030" width={2} />} {localWaypoints.map((waypoint) => ( @@ -109,7 +146,7 @@ function MovementWorld({ currentSystemId, currentSystem, localEntities, localWay export function MovementScene(props: MovementSceneProps) { return ( - + ); diff --git a/src/prototypes/r3f/shared/CameraRig.tsx b/src/prototypes/r3f/shared/CameraRig.tsx index 5fddd60..a24f9d4 100644 --- a/src/prototypes/r3f/shared/CameraRig.tsx +++ b/src/prototypes/r3f/shared/CameraRig.tsx @@ -14,13 +14,22 @@ export function CameraRig({ target, orbit = false, distance = 90 }: CameraRigPro const controlsRef = useRef(null); const { camera } = useThree(); const desired = useRef(new THREE.Vector3(0, 0, 0)); + const lastTarget = useRef(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)); diff --git a/src/prototypes/r3f/shared/StarSystemContents.tsx b/src/prototypes/r3f/shared/StarSystemContents.tsx index 4cbc814..cb5515d 100644 --- a/src/prototypes/r3f/shared/StarSystemContents.tsx +++ b/src/prototypes/r3f/shared/StarSystemContents.tsx @@ -17,6 +17,7 @@ type StarSystemContentsProps = { expanded?: boolean; scale?: number; onPoiSelect?: (poi: SystemPointOfInterest, event: ThreeEvent) => void; + onPoiContext?: (poi: SystemPointOfInterest, event: ThreeEvent) => void; }; const poiColors: Record = { @@ -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) => void; + onContext?: (poi: SystemPointOfInterest, event: ThreeEvent) => void; }) { const color = poiColors[poi.type]; const markerRef = useRef(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({ {expanded && ( - + {planet.name} @@ -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 ( ); })} {expanded && ( - + {systemName} gravity well