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

58
apps/game/AGENTS.md Normal file
View File

@@ -0,0 +1,58 @@
# apps/game — Playable Game Client
Package: `@void-nav/game`
Port: `http://localhost:5175`
The **standalone playable game** connected to a SpacetimeDB backend. This is the production game client — all other apps are documentation or marketing.
## Current State (Phase 0-2)
The game implements the basic loop: **undock → select target → approach asteroid → mine ore → dock → sell ore**. Keyboard shortcuts (U/D/A/M/1-9) and 3D click selection work. SVG mini-map and 3D scene stay synchronized through SpacetimeDB subscriptions.
## Structure
```
src/
main.tsx Entry point, renders GameShell into #root
GameShell.tsx Root layout: 3-column grid (left sidebar, center 3D scene, right sidebar, bottom command rail)
module_bindings/ Auto-generated SpacetimeDB TypeScript bindings (24 files)
types.ts All table row types and reducer call types
... One file per table + connection builder
scene/ 3D rendering (React Three Fiber)
SpaceCanvas.tsx R3F Canvas wrapper (camera at [8,9,44], FOV 48)
SpaceEnvironment.tsx Background, fog, lights, star field
GameSpaceScene.tsx Main orchestrator: places stations/asteroids/ship, manages operations, interpolates movement
StationMesh.tsx Torus + cylinder station, clickable, selection ring
AsteroidMesh.tsx Icosahedron asteroid, tumble animation, clickable
ShipMesh.tsx Cone + box + sphere ship, engine glow by mode, bob animation
spacetime/ SpacetimeDB connection layer
client.ts Connection factory, table subscriptions, auth token storage
useSpacetimeConnection.ts React hook: connection state, identity, revision polling
useGameSession.ts React hook: reads all tables into GameSession object, exposes action callbacks for all reducers
ui/ UI panels
CommandRail.tsx Bottom-fixed contextual action bar with progress bars
ConnectionPanel.tsx Dev diagnostic: connection status, identity, rename/ping
CargoPanel.tsx Cargo hold items with quantity and price
NpcMarketPanel.tsx Sell ore at fixed NPC price (docked only)
WalletPanel.tsx ISK balance display
ShipStatusPanel.tsx Hull/shield/speed bars, position, operation timer
TargetPanel.tsx Selected target info, clickable POI list
MiniStarMap.tsx SVG 2D system map with ship position and operation lines
EventFeed.tsx Server event log (last 12 events)
useKeyboardShortcuts.ts U=undock, D=dock, A=approach, M=mine, 1-9=select target
styles/
tailwind.css Tailwind v4 entry point
```
## SpacetimeDB Tables (9) and Reducers (12)
See `services/spacetimedb/AGENTS.md` for full backend details.
## Known Hardcoded Values
- `client.ts:77-78` — subscriptions hardcoded to `solace` system and `solace-prime` station
- `useGameSession.ts:129-130` — system/station lookup hardcoded to specific IDs
- `ShipStatusPanel.tsx:44` — hullPct always 100 (no damage model)
- `ShipStatusPanel.tsx:45` — shieldPct uses Math.random() (not server data)
- `NpcMarketPanel.tsx:5-7` — NPC_PRICES hardcoded client-side (only Veldspar at 12 ISK)
- `GameSpaceScene.tsx:120-124` — asteroid cluster offsets hardcoded (3 rocks per belt)

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]);
}

View File

@@ -7,4 +7,14 @@ export default defineConfig({
jsx: "automatic",
jsxImportSource: "react",
},
build: {
rollupOptions: {
output: {
manualChunks: {
three: ["three", "@react-three/fiber", "@react-three/drei"],
spacetimedb: ["spacetimedb"],
},
},
},
},
});