Add game UI panels, keyboard shortcuts, docs narrative overhaul, and unified dev script

- Add MiniStarMap, NpcMarketPanel, ShipStatusPanel, useKeyboardShortcuts
- Add progress bars for approach/mining operations and cargo fill indicator
- Rewrite docs from spreadsheet-first to exploration-first open-world RPG
- Replace dev:db + dev:standalone with unified dev script (scripts/dev.sh)
- Add Vite chunk splitting for three.js and spacetimedb
- Fix displayName dependency in useSpacetimeConnection
- Remove stale usePlayerSession.ts
- Add AGENTS.md files across all packages
This commit is contained in:
2026-06-02 17:32:15 -04:00
parent be8967d5fe
commit 7c93b8a1ae
40 changed files with 2263 additions and 266 deletions

View File

@@ -5,7 +5,11 @@ import { CommandRail } from "./ui/CommandRail";
import { CargoPanel } from "./ui/CargoPanel";
import { ConnectionPanel } from "./ui/ConnectionPanel";
import { EventFeed } from "./ui/EventFeed";
import { MiniStarMap } from "./ui/MiniStarMap";
import { NpcMarketPanel } from "./ui/NpcMarketPanel";
import { ShipStatusPanel } from "./ui/ShipStatusPanel";
import { TargetPanel } from "./ui/TargetPanel";
import { useKeyboardShortcuts } from "./ui/useKeyboardShortcuts";
import { WalletPanel } from "./ui/WalletPanel";
import { useGameSession } from "./spacetime/useGameSession";
import { useSpacetimeConnection } from "./spacetime/useSpacetimeConnection";
@@ -33,6 +37,22 @@ export function GameShell() {
const selectedPoi = session.pois.find((poi) => poi.poiId === session.ship?.selectedPoiId);
const pilotName = session.player?.displayName ?? displayName;
const keyboardActions = useMemo(
() => ({
onUndock: session.actions.undock,
onStartApproach: session.actions.startApproach,
onDock: session.actions.dock,
onStartMining: session.actions.startMining,
onSelectTarget: (index: number) => {
const poi = session.pois[index];
if (poi) session.actions.selectTarget(poi.poiId);
},
}),
[session.actions, session.pois],
);
useKeyboardShortcuts(session.ship, session.pois, session.operation, keyboardActions);
return (
<main className="relative min-h-screen overflow-hidden bg-bg text-fg">
<div className="absolute inset-0">
@@ -72,6 +92,7 @@ export function GameShell() {
<InfoRow label="System" value={session.system?.name ?? "Pending"} />
</dl>
</Panel>
<ShipStatusPanel ship={session.ship} operation={session.operation} />
<WalletPanel wallet={session.wallet} />
</aside>
@@ -79,7 +100,14 @@ export function GameShell() {
<aside className="pointer-events-auto grid content-start gap-4">
<TargetPanel ship={session.ship} pois={session.pois} selected={selectedPoi} onSelect={session.actions.selectTarget} />
<MiniStarMap ship={session.ship} pois={session.pois} operation={session.operation} onSelectPoi={session.actions.selectTarget} />
<CargoPanel cargo={session.cargo} ship={session.ship} />
<NpcMarketPanel
ship={session.ship}
cargo={session.cargo}
wallet={session.wallet}
onSellOre={(itemId, quantity) => session.actions.sellOreToNpcMarket(itemId, quantity)}
/>
<EventFeed events={session.events} />
</aside>
</div>

View File

@@ -0,0 +1,24 @@
# apps/game/src/scene/ — 3D Rendering (React Three Fiber)
The 3D viewport for the game client. Renders the player's ship, surrounding space environment, stations, asteroids, and visual effects.
## Files
| File | Purpose |
|------|---------|
| `SpaceCanvas.tsx` | R3F Canvas wrapper. Fixed camera at [8,9,44], FOV 48. No user camera controls yet. |
| `SpaceEnvironment.tsx` | Background (#040712), fog, ambient light, two point lights (cyan + amber), animated star field (3600 stars) |
| `GameSpaceScene.tsx` | Main orchestrator. Renders stations, asteroid clusters, ship. Manages approach/mining operations. Interpolates ship position. Draws approach line (dashed cyan) and mining line (solid amber). Auto-completes operations via 120ms polling interval. |
| `StationMesh.tsx` | Torus ring + cylinder core + crossbar. Clickable. Selection ring when targeted. |
| `AsteroidMesh.tsx` | Icosahedron with tumble animation. Clickable. Selection ring when targeted. |
| `ShipMesh.tsx` | Cone (hull) + box (wings) + sphere (engine glow). Engine glow changes by flight mode. Subtle bob + roll animation. |
## Visual Gaps
- No mining laser beam effect (flat Line only)
- No warp/travel animation
- No combat visuals (projectiles, shields, explosions)
- No NPC/other player ships
- No camera controls (orbit, zoom, pan)
- No sun/star light source
- No nebulae or system-specific skybox

View File

@@ -0,0 +1,32 @@
# apps/game/src/spacetime/ — SpacetimeDB Connection Layer
Bridge between the game client and the SpacetimeDB backend. Manages connection lifecycle, table subscriptions, and exposes a typed React hook for the game session.
## Files
| File | Purpose |
|------|---------|
| `client.ts` | Connection factory. Creates `DbConnection`, subscribes to all 9 tables (hardcoded to `solace` system), registers table-change listeners for React re-renders, manages auth token in localStorage. Auto-calls `seedWorld` and `connectPlayer` on every connection. |
| `useSpacetimeConnection.ts` | React hook wrapping `client.ts`. Exposes connection state (idle/connecting/connected/error), identity, and a 1.5s revision-poll trigger. |
| `useGameSession.ts` | Main data/action bridge. Reads all 9 SpacetimeDB tables into a typed `GameSession` object. Exposes action callbacks for all 12 reducers. Hardcodes system/station lookups to `solace`/`solace-prime`. |
## Subscriptions (hardcoded)
```sql
SELECT * FROM player
SELECT * FROM ship
SELECT * FROM system WHERE system_id = 'solace'
SELECT * FROM station WHERE station_id = 'solace-prime'
SELECT * FROM point_of_interest WHERE system_id = 'solace'
SELECT * FROM cargo_item
SELECT * FROM wallet
SELECT * FROM ship_operation
SELECT * FROM server_event
```
## Known Issues
- System/station subscriptions hardcoded to `solace`/`solace-prime` — must be dynamic for multi-system
- No reconnection logic (no exponential backoff)
- Auth token stored in localStorage (acceptable for auth, but design says no localStorage for game state)
- Events capped at 12 in useGameSession (no pagination)

View File

@@ -1,58 +0,0 @@
import { useMemo } from "react";
import type { DbConnection } from "../module_bindings";
import type { Player, ServerEvent, Ship, Station, System } from "../module_bindings/types";
import { invokeReducer } from "./client";
type ReducerReporter = (message: string, isError?: boolean) => void;
export function usePlayerSession(connection: DbConnection | null, revision: number, onReducerStatus: ReducerReporter) {
const rows = useMemo(() => readRows(connection), [connection, revision]);
return {
...rows,
renamePlayer(displayName: string) {
if (!connection) {
onReducerStatus("renamePlayer unavailable until connected", true);
return;
}
invokeReducer({ onReducerStatus }, "renamePlayer", () => connection.reducers.renamePlayer({ displayName }));
},
ping() {
if (!connection) {
onReducerStatus("ping unavailable until connected", true);
return;
}
invokeReducer({ onReducerStatus }, "ping", () => connection.reducers.ping({}));
},
};
}
function readRows(connection: DbConnection | null) {
const players = readTable<Player>(connection?.db.player);
const ships = readTable<Ship>(connection?.db.ship);
const systems = readTable<System>(connection?.db.system);
const stations = readTable<Station>(connection?.db.station);
const events = readTable<ServerEvent>(connection?.db.server_event).sort((a, b) => compareBigintsDesc(a.eventId, b.eventId));
return {
player: players[0],
ship: ships[0],
system: systems.find((row) => row.systemId === "solace") ?? systems[0],
station: stations.find((row) => row.stationId === "solace-prime") ?? stations[0],
events: events.slice(0, 6),
};
}
function readTable<Row>(table?: { iter?: () => Iterable<Row> }): Row[] {
if (!table?.iter) return [];
try {
return Array.from(table.iter());
} catch {
return [];
}
}
function compareBigintsDesc(left: bigint, right: bigint) {
if (left === right) return 0;
return left > right ? -1 : 1;
}

View File

@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import type { DbConnection } from "../module_bindings";
import { createSpacetimeConnection, type ConnectionStatus, type IdentityLike } from "./client";
@@ -22,10 +22,13 @@ export function useSpacetimeConnection(displayName: string) {
[],
);
const displayNameRef = useRef(displayName);
displayNameRef.current = displayName;
useEffect(() => {
const conn = createSpacetimeConnection({
...config,
displayName,
displayName: displayNameRef.current,
onStatus: (nextStatus, nextMessage) => {
setStatus(nextStatus);
setMessage(nextMessage);
@@ -45,7 +48,7 @@ export function useSpacetimeConnection(displayName: string) {
window.clearInterval(refresh);
conn?.disconnect?.();
};
}, [config, displayName]);
}, [config]);
return {
connection,

View File

@@ -0,0 +1,18 @@
# apps/game/src/ui/ — Game UI Panels
All UI components for the game client. Composed by `GameShell.tsx` into a three-column layout with a bottom command rail.
## Files
| File | Purpose | Status |
|------|---------|--------|
| `CommandRail.tsx` | Bottom-fixed contextual action bar. Shows progress bars during operations, context-appropriate buttons otherwise. Covers: undock, approach, dock, mine, sell. | Partial — missing abort, warp, combat, station service buttons |
| `ConnectionPanel.tsx` | Dev diagnostic panel: connection status, identity, endpoint, pilot name input, rename/ping buttons. | Done — but this is a dev tool, not player-facing |
| `CargoPanel.tsx` | Cargo hold display: fill bar + item list with name, quantity, category, unit price. | Partial — no jettison, no stacking/splitting |
| `NpcMarketPanel.tsx` | Sell ore to NPCs. Quantity input, max button, payout preview. Only visible when docked. Prices hardcoded client-side. | Partial — only Veldspar, hardcoded prices, sell-only |
| `WalletPanel.tsx` | ISK balance display. | Partial — no transaction history |
| `ShipStatusPanel.tsx` | Flight mode badge, hull/shield/speed bars, position, operation countdown. | Partial — hull always 100%, shields random, speed hardcoded |
| `TargetPanel.tsx` | Selected target info, clickable POI list. | Partial — no distance, no sorting |
| `MiniStarMap.tsx` | SVG 2D system map: POIs, ship position, operation lines, click-to-select. | Done — single system only |
| `EventFeed.tsx` | Server event log (last 12 events, type + timestamp + message). | Partial — no filtering, no pagination, no color coding |
| `useKeyboardShortcuts.ts` | U=undock, D=dock, A=approach, M=mine, 1-9=select target. | Partial — no sell/refine/fit/combat/warp shortcuts |

View File

@@ -4,15 +4,23 @@ import type { CargoItem, Ship } from "../module_bindings/types";
export function CargoPanel({ cargo, ship }: { cargo: CargoItem[]; ship?: Ship }) {
const used = cargo.reduce((total, item) => total + item.quantity, 0n);
const capacity = ship?.cargoCapacity ?? 0n;
const fillPct = capacity > 0n ? Math.min(100, Math.round((Number(used) / Number(capacity)) * 100)) : 0;
const barColor = fillPct >= 90 ? "#ef4444" : fillPct >= 60 ? "#f0a030" : "#22d3ee";
return (
<Panel className="p-4">
<div className="flex items-baseline justify-between gap-3">
<p className="m-0 font-mono text-xs uppercase tracking-[0.1em] text-muted">Cargo</p>
<span className="font-mono text-xs text-fg-dim">
{used.toString()} / {capacity.toString()}
{used.toString()} / {capacity.toString()} m&sup3;
</span>
</div>
<div className="mt-2 h-2 overflow-hidden rounded-full bg-surface-raised">
<div
className="h-full rounded-full transition-[width] duration-300"
style={{ width: `${fillPct}%`, backgroundColor: barColor }}
/>
</div>
<div className="mt-3 grid gap-2">
{cargo.length > 0 ? (
cargo.map((item) => (
@@ -22,7 +30,7 @@ export function CargoPanel({ cargo, ship }: { cargo: CargoItem[]; ship?: Ship })
<span className="font-mono text-xs text-cyan">{item.quantity.toString()}</span>
</div>
<div className="mt-1 font-mono text-xs uppercase tracking-[0.08em] text-muted">
{item.category} / {item.unitPrice.toString()} ISK
{item.category} / {item.unitPrice.toString()} ISK per unit
</div>
</div>
))

View File

@@ -1,3 +1,4 @@
import { useEffect, useState } from "react";
import { Button } from "@void-nav/ui";
import type { CargoItem, PointOfInterest, Ship, ShipOperation } from "../module_bindings/types";
@@ -25,14 +26,39 @@ export function CommandRail({
const selected = pois.find((poi) => poi.poiId === ship?.selectedPoiId);
const current = pois.find((poi) => poi.poiId === ship?.currentPoiId);
const ore = cargo.find((item) => item.category === "ore" && item.quantity > 0n);
const [nowMs, setNowMs] = useState(() => Date.now());
useEffect(() => {
if (!operation) return;
const timer = window.setInterval(() => setNowMs(Date.now()), 80);
return () => window.clearInterval(timer);
}, [operation]);
let content = <span className="text-sm text-fg-dim">Connect to SpacetimeDB to initialize the starter ship.</span>;
if (ship) {
if (operation?.operationType === "approach") {
content = <Button disabled>Approaching...</Button>;
const progress = getOperationProgress(operation, nowMs);
content = (
<div className="flex w-full flex-col gap-2">
<div className="flex items-center gap-3">
<span className="text-sm text-cyan">Approaching target...</span>
<span className="ml-auto font-mono text-xs text-fg-dim">{Math.round(progress * 100)}%</span>
</div>
<ProgressBar progress={progress} color="#22d3ee" />
</div>
);
} else if (operation?.operationType === "mining") {
content = <Button disabled>Mining...</Button>;
const progress = getOperationProgress(operation, nowMs);
content = (
<div className="flex w-full flex-col gap-2">
<div className="flex items-center gap-3">
<span className="text-sm text-accent">Mining in progress...</span>
<span className="ml-auto font-mono text-xs text-fg-dim">{Math.round(progress * 100)}%</span>
</div>
<ProgressBar progress={progress} color="#f0a030" />
</div>
);
} else if (ship.dockedStationId.length > 0) {
content = (
<>
@@ -72,6 +98,24 @@ export function CommandRail({
);
}
function ProgressBar({ progress, color }: { progress: number; color: string }) {
return (
<div className="h-2 w-full overflow-hidden rounded-full bg-surface-raised">
<div
className="h-full rounded-full transition-[width] duration-100"
style={{ width: `${Math.max(0, Math.min(1, progress)) * 100}%`, backgroundColor: color }}
/>
</div>
);
}
function getOperationProgress(operation: ShipOperation, nowMs: number) {
const startedAt = Number(operation.startedAt.toMillis());
const duration = Number(operation.durationMs);
if (duration <= 0) return 1;
return Math.max(0, Math.min(1, (nowMs - startedAt) / duration));
}
function cargoUsed(cargo: CargoItem[]) {
return cargo.reduce((total, item) => total + item.quantity, 0n);
}

View File

@@ -0,0 +1,149 @@
import { Panel } from "@void-nav/ui";
import type { PointOfInterest, Ship, ShipOperation } from "../module_bindings/types";
export function MiniStarMap({
ship,
pois,
operation,
onSelectPoi,
}: {
ship?: Ship;
pois: PointOfInterest[];
operation?: ShipOperation;
onSelectPoi: (poiId: string) => void;
}) {
const bounds = getBounds(pois);
const mapSize = 280;
const padding = 24;
return (
<Panel className="p-4">
<p className="m-0 font-mono text-xs uppercase tracking-[0.1em] text-muted">System Map</p>
<svg
viewBox={`0 0 ${mapSize} ${mapSize}`}
className="mt-3 w-full rounded-md border border-border bg-bg-subtle"
style={{ aspectRatio: "1" }}
>
<GridOverlay size={mapSize} />
{pois.map((poi) => {
const pos = projectPoi(poi, bounds, mapSize, padding);
const isCurrent = poi.poiId === ship?.currentPoiId;
const isSelected = poi.poiId === ship?.selectedPoiId;
return (
<g key={poi.poiId} onClick={() => onSelectPoi(poi.poiId)} style={{ cursor: "pointer" }}>
{isSelected ? (
<circle cx={pos.x} cy={pos.y} r={14} fill="none" stroke="#f0a030" strokeWidth={1.5} strokeDasharray="3 2" />
) : null}
{isCurrent ? (
<circle cx={pos.x} cy={pos.y} r={10} fill="none" stroke="#22d3ee" strokeWidth={1} opacity={0.6} />
) : null}
{poi.poiType === "station" ? (
<StationIcon x={pos.x} y={pos.y} />
) : (
<BeltIcon x={pos.x} y={pos.y} />
)}
<text
x={pos.x}
y={pos.y + 20}
textAnchor="middle"
fill={isSelected ? "#f0a030" : "#94a3b8"}
fontSize={9}
fontFamily="var(--font-mono, monospace)"
>
{poi.name}
</text>
</g>
);
})}
{ship ? (() => {
const pos = projectXY(ship.x, ship.y, bounds, mapSize, padding);
return (
<g>
<circle cx={pos.x} cy={pos.y} r={4} fill="#22d3ee" />
<circle cx={pos.x} cy={pos.y} r={7} fill="none" stroke="#22d3ee" strokeWidth={0.8} opacity={0.5} />
{operation && (() => {
const target = pois.find((p) => p.poiId === operation.targetPoiId);
if (!target) return null;
const tpos = projectPoi(target, bounds, mapSize, padding);
return (
<line
x1={pos.x} y1={pos.y}
x2={tpos.x} y2={tpos.y}
stroke={operation.operationType === "mining" ? "#f0a030" : "#22d3ee"}
strokeWidth={1}
strokeDasharray={operation.operationType === "approach" ? "4 3" : undefined}
opacity={0.7}
/>
);
})()}
</g>
);
})() : null}
</svg>
<div className="mt-2 flex items-center gap-4 text-xs text-fg-dim">
<span className="flex items-center gap-1.5">
<span className="inline-block h-2.5 w-2.5 rounded-full bg-cyan" />
Ship
</span>
<span className="flex items-center gap-1.5">
<span className="inline-block h-2.5 w-2.5 rounded bg-fg-dim" />
Station
</span>
<span className="flex items-center gap-1.5">
<span className="inline-block h-2.5 w-2.5 rotate-45 rounded-sm bg-[#8b8176]" />
Belt
</span>
</div>
</Panel>
);
}
function StationIcon({ x, y }: { x: number; y: number }) {
return <rect x={x - 4} y={y - 4} width={8} height={8} rx={1.5} fill="#9ad8ff" opacity={0.9} />;
}
function BeltIcon({ x, y }: { x: number; y: number }) {
return <rect x={x - 4} y={y - 4} width={8} height={8} rx={1} fill="#8b8176" transform={`rotate(45 ${x} ${y})`} />;
}
function GridOverlay({ size }: { size: number }) {
const lines = [];
const step = size / 6;
for (let i = 1; i < 6; i++) {
lines.push(
<line key={`h${i}`} x1={0} y1={step * i} x2={size} y2={step * i} stroke="#1c2a3f" strokeWidth={0.5} />,
<line key={`v${i}`} x1={step * i} y1={0} x2={step * i} y2={size} stroke="#1c2a3f" strokeWidth={0.5} />,
);
}
return <>{lines}</>;
}
type Bounds = { minX: number; maxX: number; minY: number; maxY: number };
function getBounds(pois: PointOfInterest[]): Bounds {
if (pois.length === 0) return { minX: -50, maxX: 50, minY: -50, maxY: 50 };
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
for (const poi of pois) {
if (poi.x < minX) minX = poi.x;
if (poi.x > maxX) maxX = poi.x;
if (poi.y < minY) minY = poi.y;
if (poi.y > maxY) maxY = poi.y;
}
const padX = Math.max(5, (maxX - minX) * 0.15);
const padY = Math.max(5, (maxY - minY) * 0.15);
return { minX: minX - padX, maxX: maxX + padX, minY: minY - padY, maxY: maxY + padY };
}
function projectPoi(poi: PointOfInterest, bounds: Bounds, size: number, padding: number) {
return projectXY(poi.x, poi.y, bounds, size, padding);
}
function projectXY(x: number, y: number, bounds: Bounds, size: number, padding: number) {
const rangeX = bounds.maxX - bounds.minX || 1;
const rangeY = bounds.maxY - bounds.minY || 1;
const drawSize = size - padding * 2;
return {
x: padding + ((x - bounds.minX) / rangeX) * drawSize,
y: padding + drawSize - ((y - bounds.minY) / rangeY) * drawSize,
};
}

View File

@@ -0,0 +1,110 @@
import { useState } from "react";
import { Button, Panel } from "@void-nav/ui";
import type { CargoItem, Ship, Wallet } from "../module_bindings/types";
const NPC_PRICES: Record<string, { name: string; buyPrice: bigint }> = {
"ore-veldspar": { name: "Veldspar", buyPrice: 12n },
};
export function NpcMarketPanel({
ship,
cargo,
wallet,
onSellOre,
}: {
ship?: Ship;
cargo: CargoItem[];
wallet?: Wallet;
onSellOre: (itemId: string, quantity: bigint) => void;
}) {
const [sellAmounts, setSellAmounts] = useState<Record<string, string>>({});
if (!ship || ship.dockedStationId.length === 0) {
return null;
}
const oreItems = cargo.filter((item) => item.category === "ore" && item.quantity > 0n);
if (oreItems.length === 0) {
return (
<Panel className="p-4">
<p className="m-0 font-mono text-xs uppercase tracking-[0.1em] text-muted">NPC Market</p>
<p className="m-0 mt-2 text-sm text-fg-dim">No ore to sell. Go mine some asteroids first.</p>
</Panel>
);
}
return (
<Panel className="p-4">
<div className="flex items-center justify-between gap-3">
<p className="m-0 font-mono text-xs uppercase tracking-[0.1em] text-muted">NPC Market</p>
<span className="font-mono text-xs text-fg-dim">Buy Orders</span>
</div>
<div className="mt-3 grid gap-3">
{oreItems.map((item) => {
const pricing = NPC_PRICES[item.itemId];
const unitPrice = pricing?.buyPrice ?? item.unitPrice;
const rawAmount = sellAmounts[item.itemId] ?? "";
const parsedAmount = parseBigint(rawAmount);
const sellQty = parsedAmount > 0n ? minBigint(parsedAmount, item.quantity) : 0n;
const payout = sellQty * unitPrice;
return (
<div key={String(item.cargoItemId)} className="rounded-md border border-border bg-bg-subtle p-3">
<div className="flex items-center justify-between gap-3">
<span className="text-sm font-semibold text-fg-bright">{item.itemName}</span>
<span className="font-mono text-xs text-cyan">{item.quantity.toString()} units</span>
</div>
<div className="mt-2 flex items-center gap-2">
<input
type="number"
min={0}
max={Number(item.quantity)}
placeholder="Qty"
value={rawAmount}
onChange={(e) => setSellAmounts((prev) => ({ ...prev, [item.itemId]: e.target.value }))}
className="min-h-8 w-24 rounded border border-border bg-surface-raised px-2 py-1 text-sm text-fg outline-none focus:border-cyan"
/>
<Button
className="min-h-8 px-3 text-xs"
onClick={() => {
setSellAmounts((prev) => ({ ...prev, [item.itemId]: item.quantity.toString() }));
}}
>
Max
</Button>
<span className="ml-auto font-mono text-xs text-accent">
{payout > 0n ? `+${payout.toLocaleString()} ISK` : `${unitPrice}/u`}
</span>
</div>
{sellQty > 0n ? (
<Button
tone="primary"
className="mt-2 w-full"
onClick={() => {
onSellOre(item.itemId, sellQty);
setSellAmounts((prev) => ({ ...prev, [item.itemId]: "" }));
}}
>
Sell {sellQty.toString()} {item.itemName}
</Button>
) : null}
</div>
);
})}
</div>
</Panel>
);
}
function parseBigint(value: string): bigint {
try {
const n = BigInt(value);
return n >= 0n ? n : 0n;
} catch {
return 0n;
}
}
function minBigint(left: bigint, right: bigint) {
return left < right ? left : right;
}

View File

@@ -0,0 +1,130 @@
import { useEffect, useState } from "react";
import { Panel } from "@void-nav/ui";
import type { Ship, ShipOperation } from "../module_bindings/types";
export function ShipStatusPanel({
ship,
operation,
}: {
ship?: Ship;
operation?: ShipOperation;
}) {
const [nowMs, setNowMs] = useState(() => Date.now());
useEffect(() => {
const timer = window.setInterval(() => setNowMs(Date.now()), 200);
return () => window.clearInterval(timer);
}, []);
if (!ship) {
return (
<Panel className="p-4">
<p className="m-0 font-mono text-xs uppercase tracking-[0.1em] text-muted">Ship Status</p>
<p className="m-0 mt-2 text-sm text-fg-dim">Awaiting ship data...</p>
</Panel>
);
}
const flightModeLabel = formatFlightMode(ship.flightMode);
const isOperating = !!operation;
let speedLabel = "0 m/s";
let speedPct = 0;
if (operation?.operationType === "approach") {
speedPct = 75;
speedLabel = "~2,450 m/s";
} else if (ship.flightMode === "flight") {
speedPct = 15;
speedLabel = "~180 m/s";
} else if (ship.flightMode === "mining") {
speedPct = 0;
speedLabel = "0 m/s";
}
const hullPct = 100;
const shieldPct = isOperating ? Math.max(0, 80 - Math.random() * 5) : 100;
const cargoPct = ship.cargoCapacity > 0n ? 0 : 0;
return (
<Panel className="p-4">
<div className="flex items-center justify-between gap-3">
<p className="m-0 font-mono text-xs uppercase tracking-[0.1em] text-muted">Ship Status</p>
<span className={`rounded-md border px-2 py-1 font-mono text-xs uppercase tracking-[0.08em] ${flightModeColor(ship.flightMode)}`}>
{flightModeLabel}
</span>
</div>
<div className="mt-3 grid gap-2.5">
<StatusBar label="Hull" value={hullPct} color="#22c55e" />
<StatusBar label="Shield" value={Math.round(shieldPct)} color="#22d3ee" />
<StatusBar label="Speed" value={speedPct} color="#f0a030" />
</div>
<dl className="mt-3 grid gap-2 text-sm">
<InfoRow label="Position" value={`[${ship.x.toFixed(1)}, ${ship.y.toFixed(1)}, ${ship.z.toFixed(1)}]`} />
<InfoRow label="Velocity" value={speedLabel} />
<InfoRow label="System" value={ship.currentSystemId} />
{isOperating && operation ? (
<>
<InfoRow label="Operation" value={operation.operationType} />
<InfoRow label="ETA" value={formatCountdown(operation, nowMs)} />
</>
) : null}
</dl>
</Panel>
);
}
function StatusBar({ label, value, color }: { label: string; value: number; color: string }) {
return (
<div className="grid grid-cols-[4.5rem_1fr] items-center gap-2">
<span className="font-mono text-xs uppercase tracking-[0.08em] text-muted">{label}</span>
<div className="flex items-center gap-2">
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-surface-raised">
<div
className="h-full rounded-full transition-[width] duration-300"
style={{ width: `${Math.max(0, Math.min(100, value))}%`, backgroundColor: color }}
/>
</div>
<span className="w-8 text-right font-mono text-xs text-fg-dim">{value}%</span>
</div>
</div>
);
}
function InfoRow({ label, value }: { label: string; value: string }) {
return (
<div className="grid grid-cols-[5.5rem_1fr] gap-2 border-t border-border pt-2">
<dt className="font-mono text-xs uppercase tracking-[0.08em] text-muted">{label}</dt>
<dd className="m-0 font-mono text-xs text-fg">{value}</dd>
</div>
);
}
function formatFlightMode(mode: string) {
switch (mode) {
case "docked": return "Docked";
case "flight": return "In Flight";
case "approaching": return "Approaching";
case "mining": return "Mining";
default: return mode || "Offline";
}
}
function flightModeColor(mode: string) {
switch (mode) {
case "docked": return "border-green text-green";
case "approaching": return "border-cyan text-cyan";
case "mining": return "border-accent text-accent";
case "flight": return "border-fg-dim text-fg-dim";
default: return "border-muted text-muted";
}
}
function formatCountdown(operation: ShipOperation, nowMs: number) {
const completesAt = Number(operation.completesAtMs);
const remaining = completesAt - nowMs;
if (remaining <= 0) return "Completing...";
const seconds = Math.ceil(remaining / 1000);
return `${seconds}s`;
}

View File

@@ -0,0 +1,68 @@
import { useEffect } from "react";
export type ShipActions = {
onUndock: () => void;
onStartApproach: () => void;
onDock: () => void;
onStartMining: () => void;
onSelectTarget?: (index: number) => void;
};
export function useKeyboardShortcuts(
ship: {
dockedStationId: string;
flightMode: string;
selectedPoiId: string;
currentPoiId: string;
} | undefined,
pois: Array<{ poiId: string; poiType: string }>,
operation: { operationType: string } | undefined,
actions: ShipActions,
) {
useEffect(() => {
function handleKeyDown(event: KeyboardEvent) {
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) return;
const key = event.key.toLowerCase();
if (key === "u" && !operation) {
if (ship && ship.dockedStationId.length > 0) {
event.preventDefault();
actions.onUndock();
}
}
if (key === "d" && !operation) {
if (ship && ship.dockedStationId.length === 0 && ship.flightMode === "flight") {
event.preventDefault();
actions.onDock();
}
}
if (key === "a" && !operation) {
if (ship && ship.dockedStationId.length === 0 && ship.selectedPoiId.length > 0 && ship.selectedPoiId !== ship.currentPoiId) {
event.preventDefault();
actions.onStartApproach();
}
}
if (key === "m" && !operation) {
if (ship && ship.dockedStationId.length === 0 && ship.flightMode === "flight") {
event.preventDefault();
actions.onStartMining();
}
}
if (key >= "1" && key <= "9") {
const index = parseInt(key) - 1;
if (index < pois.length && actions.onSelectTarget) {
event.preventDefault();
actions.onSelectTarget(index);
}
}
}
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [ship, pois, operation, actions]);
}