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:
58
apps/game/AGENTS.md
Normal file
58
apps/game/AGENTS.md
Normal 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)
|
||||
@@ -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>
|
||||
|
||||
24
apps/game/src/scene/AGENTS.md
Normal file
24
apps/game/src/scene/AGENTS.md
Normal 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
|
||||
32
apps/game/src/spacetime/AGENTS.md
Normal file
32
apps/game/src/spacetime/AGENTS.md
Normal 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)
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
18
apps/game/src/ui/AGENTS.md
Normal file
18
apps/game/src/ui/AGENTS.md
Normal 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 |
|
||||
@@ -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³
|
||||
</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>
|
||||
))
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
149
apps/game/src/ui/MiniStarMap.tsx
Normal file
149
apps/game/src/ui/MiniStarMap.tsx
Normal 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,
|
||||
};
|
||||
}
|
||||
110
apps/game/src/ui/NpcMarketPanel.tsx
Normal file
110
apps/game/src/ui/NpcMarketPanel.tsx
Normal 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;
|
||||
}
|
||||
130
apps/game/src/ui/ShipStatusPanel.tsx
Normal file
130
apps/game/src/ui/ShipStatusPanel.tsx
Normal 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`;
|
||||
}
|
||||
68
apps/game/src/ui/useKeyboardShortcuts.ts
Normal file
68
apps/game/src/ui/useKeyboardShortcuts.ts
Normal 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]);
|
||||
}
|
||||
@@ -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"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user