chore: sync codebase remediation, gameplay systems, and docs
Security & infrastructure: - Remove unused services/ (auth, spacetimedb) and auth.db - Add .env.example template, expand .gitignore for env/db files - Add GitHub Actions CI + commitlint config and workflows - Add manual vendor chunking and source maps to docs/site vite configs Shared UI & docs app: - Add ARIA props and focus-visible rings to Button/Panel - Add ButtonAsLink primitive; use shared Button in NotFound - Wire @void-nav/ui into docs app; refresh content pages - Replace Todo page with Kanban board Gameplay (Bevy): - Add ai module (behavior, faction, navigation, perception, spawning, states) - Add narrative module (events, history, synthesis, ui) - Refine galaxy contents and in-system flight/scene systems
This commit is contained in:
@@ -12,6 +12,7 @@
|
||||
"dependencies": {
|
||||
"@react-three/drei": "^9.122.0",
|
||||
"@react-three/fiber": "^8.17.10",
|
||||
"@void-nav/ui": "workspace:*",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.30.1",
|
||||
|
||||
@@ -17,7 +17,7 @@ import { DemoGalleryPage } from "./pages/docs/DemoGalleryPage";
|
||||
import { GapAnalysisPage } from "./pages/docs/GapAnalysisPage";
|
||||
import { VerticalSliceEvaluationPage } from "./pages/docs/VerticalSliceEvaluationPage";
|
||||
import { DesignDocPage } from "./pages/docs/DesignDocPage";
|
||||
import { TodoPage } from "./pages/docs/TodoPage";
|
||||
import { KanbanBoardPage } from "./pages/docs/KanbanBoardPage";
|
||||
import { StarMapDemo } from "./prototypes/existing-demos/StarMapDemo";
|
||||
import { ShipMovementDemo } from "./prototypes/existing-demos/ShipMovementDemo";
|
||||
import { WarpTravelDemo } from "./prototypes/existing-demos/WarpTravelDemo";
|
||||
@@ -57,7 +57,7 @@ export function App() {
|
||||
<Route path="gap-analysis" element={<GapAnalysisPage />} />
|
||||
<Route path="vertical-slice-evaluation" element={<VerticalSliceEvaluationPage />} />
|
||||
<Route path="design-doc" element={<DesignDocPage />} />
|
||||
<Route path="todo" element={<TodoPage />} />
|
||||
<Route path="kanban-board" element={<KanbanBoardPage />} />
|
||||
|
||||
<Route path="demos/starmap" element={<StarMapDemo />} />
|
||||
<Route path="demos/game-loop" element={<GameLoopSliceDemo />} />
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { ButtonAsLink } from "@void-nav/ui";
|
||||
|
||||
export function NotFound() {
|
||||
return (
|
||||
<div className="mx-auto max-w-content">
|
||||
<h1>Page Not Found</h1>
|
||||
<p className="text-fg-dim">The requested route does not exist.</p>
|
||||
<Link className="inline-flex items-center gap-2 rounded-lg border border-accent bg-accent px-4 py-2 text-[0.8rem] font-semibold text-bg transition-all duration-150 hover:bg-accent-hover" to="/docs">Back to documentation</Link>
|
||||
<ButtonAsLink to="/docs" tone="primary">Back to documentation</ButtonAsLink>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ export const navSections: NavSection[] = [
|
||||
{ path: "/docs/social", icon: "✧", label: "Progression & World" },
|
||||
{ path: "/docs/ship-ai", icon: "◈", label: "Ship AI - Zora" },
|
||||
{ path: "/docs/roadmap", icon: "⊞", label: "Roadmap" },
|
||||
{ path: "/docs/todo", icon: "⊡", label: "Implementation Status" },
|
||||
{ path: "/docs/kanban-board", icon: "▦", label: "Implementation Board" },
|
||||
{ path: "/docs/risks", icon: "◬", label: "Risks & Questions" },
|
||||
{ path: "/docs/gap-analysis", icon: "□", label: "Gap Analysis" },
|
||||
{ path: "/docs/vertical-slice-evaluation", icon: "▤", label: "Slice Evaluation" },
|
||||
|
||||
@@ -6,9 +6,9 @@ export function ArchitecturePage() {
|
||||
<div className="mx-auto max-w-content">
|
||||
<h1 style={{ marginBottom: '8px' }}>Architecture Overview</h1>
|
||||
<p style={{ color: 'var(--fg-dim)', fontSize: '0.95rem', maxWidth: '680px' }}>
|
||||
<strong>Key principle:</strong> SpacetimeDB owns
|
||||
authoritative game state. Everything else derives from it. There is no localStorage — persistence is always through
|
||||
SpacetimeDB, even in the single-player core game where SpacetimeDB runs locally.
|
||||
<strong>Key principle:</strong> Local-first architecture for single-player narrative experience.
|
||||
The game client manages all state locally with persistent storage. Your story lives on your machine —
|
||||
no cloud required, no always-online, no subscriptions.
|
||||
</p>
|
||||
|
||||
{/* Architecture diagram */}
|
||||
@@ -20,15 +20,16 @@ export function ArchitecturePage() {
|
||||
</div>
|
||||
<div style={{ padding: 'var(--sp-5)' }}>
|
||||
{[
|
||||
{ name: 'React UI', color: 'var(--cyan)', desc: 'Inventory, market, chat, station screens, ship status, debug panel. Owns UI layout and user workflow.' },
|
||||
{ name: 'React UI', color: 'var(--cyan)', desc: 'Inventory, market, station screens, ship status, debug panel. Owns UI layout and user workflow.' },
|
||||
{ name: 'Local Stores', color: 'var(--green)', desc: 'Selected entity, active panels, camera preferences, filters, sorting. Zustand/React state.' },
|
||||
{ name: 'Renderer Adapter', color: 'var(--purple)', desc: 'Receives view models and emits events: select, move, mine, dock. Boundary that keeps R3F replaceable.' },
|
||||
{ name: 'R3F Scene', color: 'var(--accent)', desc: 'Ships, stations, asteroids, anomalies, fauna, camera, targeting lines, world event effects. Visual layer only.' },
|
||||
{ name: 'Ship AI (Zora)', color: 'var(--purple)', desc: 'Companion AI system with soul state, module gates, and autonomous agent behavior. See the Ship AI page for full design.' },
|
||||
{ name: 'SpacetimeDB SDK', color: 'var(--cyan)', desc: 'Reducer calls and subscriptions. Client bridge to backend.' },
|
||||
{ name: 'SpacetimeDB Module', color: 'var(--red)', desc: 'Tables, reducers, validation, persistence, authoritative game state. Source of truth.' },
|
||||
{ name: 'R3F Scene', color: 'var(--accent)', desc: 'Ships, stations, asteroids, anomalies, camera, targeting lines. Visual layer only.' },
|
||||
{ name: 'AI Story Director', color: 'var(--purple)', desc: 'Narrative engine that tracks player actions and generates story events. Weaves choices into saga.' },
|
||||
{ name: 'Ship AI (Zora)', color: 'var(--purple)', desc: 'Companion AI system with soul state, module gates, and autonomous behavior. See Ship AI page.' },
|
||||
{ name: 'Game Client', color: 'var(--red)', desc: 'Core game logic, state management, and local persistence. Source of truth.' },
|
||||
{ name: 'Local Storage', color: 'var(--cyan)', desc: 'Persistent state survives restart. Ships, inventory, story log, faction relations.' },
|
||||
].map((layer, i) => (
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'stretch', gap: 'var(--sp-3)', marginBottom: i < 5 ? '2px' : 0 }}>
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'stretch', gap: 'var(--sp-3)', marginBottom: i < 6 ? '2px' : 0 }}>
|
||||
<div style={{ width: '3px', background: layer.color, borderRadius: '2px', flexShrink: 0 }} />
|
||||
<div style={{ padding: 'var(--sp-2) 0', flex: 1, display: 'flex', gap: 'var(--sp-4)', alignItems: 'baseline' }}>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: '0.8rem', color: layer.color, minWidth: '140px', fontWeight: 600 }}>
|
||||
@@ -49,16 +50,16 @@ export function ArchitecturePage() {
|
||||
<h3>React UI Responsibilities</h3>
|
||||
<ul style={{ color: 'var(--fg-dim)', fontSize: '0.9rem', marginBottom: 'var(--sp-5)' }}>
|
||||
<li>Global shell, HUD layout, docking/station screens, and route-like page states.</li>
|
||||
<li>Inventory table, market orders table, chat, selected target panel, ship status, system overview.</li>
|
||||
<li>User commands that call reducers: mine, dock, sell, place order, send chat.</li>
|
||||
<li>Inventory table, market orders table, selected target panel, ship status, system overview.</li>
|
||||
<li>User commands that trigger game actions: mine, dock, sell, place order.</li>
|
||||
<li>Local-only concerns: panel layout, sorting, filters, tabs, keyboard shortcuts, tooltips.</li>
|
||||
</ul>
|
||||
|
||||
<h3>R3F Responsibilities</h3>
|
||||
<ul style={{ color: 'var(--fg-dim)', fontSize: '0.9rem', marginBottom: 'var(--sp-5)' }}>
|
||||
<li>Render star-system scene with ships, stations, asteroids, anomalies, waypoints, fauna, and event effects.</li>
|
||||
<li>Render star-system scene with ships, stations, asteroids, anomalies, and waypoints.</li>
|
||||
<li>Camera controls, entity picking, hover states, click-to-move command creation.</li>
|
||||
<li>Interpolated movement between authoritative state updates.</li>
|
||||
<li>Interpolated movement between state updates.</li>
|
||||
<li>Expose renderer events upward — never push game logic downward into the renderer.</li>
|
||||
</ul>
|
||||
|
||||
@@ -73,13 +74,13 @@ export function ArchitecturePage() {
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><span className="inline-flex items-center gap-1 whitespace-nowrap rounded-full px-2.5 py-0.5 font-mono text-[0.7rem] border border-red/25 bg-red/8 text-red">Authoritative</span></td>
|
||||
<td>SpacetimeDB tables/subscriptions</td>
|
||||
<td>Ship position, inventory, market orders, asteroid resources.</td>
|
||||
<td><span className="inline-flex items-center gap-1 whitespace-nowrap rounded-full px-2.5 py-0.5 font-mono text-[0.7rem] border border-red/25 bg-red/8 text-red">Persistent</span></td>
|
||||
<td>Local storage</td>
|
||||
<td>Ship position, inventory, market orders, story log, faction relations.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span className="inline-flex items-center gap-1 whitespace-nowrap rounded-full px-2.5 py-0.5 font-mono text-[0.7rem] border border-cyan/25 bg-cyan/8 text-cyan">Derived view</span></td>
|
||||
<td>Client game store/view models</td>
|
||||
<td>Client game store</td>
|
||||
<td>Selected ship details, rendered position, distance to target.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
@@ -95,165 +96,32 @@ export function ArchitecturePage() {
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div className="mb-5 flex items-center gap-3 border-b border-border pb-3 max-md:flex-col max-md:items-start max-md:gap-2 max-md:[&_h1]:text-[1.35rem] max-md:[&_h2]:text-[1.35rem]">
|
||||
<div className="mb-5 flex items-center gap-3 border-b border-border pb-3 max-md:flex-col max-md:items-start max-md:gap-2 max-md:[&_h1]:text-[1.35rem] max-md:[&_h2]:text-[1.35rem]'" style={{ marginTop: 'var(--sp-8)' }}>
|
||||
<span className="rounded-full bg-[rgba(240,160,48,0.08)] px-2 py-0.5 font-mono text-[0.7rem] text-accent">ARCH-3</span>
|
||||
<h2 style={{ margin: 0 }}>Renderer Replaceability</h2>
|
||||
</div>
|
||||
|
||||
<div className="mb-5 rounded-lg px-5 py-4 text-[0.85rem] leading-[1.5] border border-[rgba(240,160,48,0.25)] bg-[rgba(240,160,48,0.08)] text-accent" style={{ marginBottom: 'var(--sp-5)' }}>
|
||||
<strong>Key principle:</strong> Use React Three Fiber for speed now, but prevent it from becoming
|
||||
the game architecture. The renderer consumes view models and emits input events.
|
||||
</div>
|
||||
|
||||
<div className="mb-5 overflow-x-auto rounded-lg border border-border bg-surface px-5 py-4 [&_code]:bg-transparent [&_code]:p-0 [&_code]:font-mono [&_code]:text-[0.82rem] [&_code]:leading-[1.6] [&_code]:text-fg-dim">
|
||||
<code>
|
||||
<span className="text-muted italic">// Renderer interface — implementation-agnostic</span><br/>
|
||||
<span className="text-purple">type</span> <span className="text-cyan">GameRendererInput</span> = {<br/>
|
||||
ships: <span className="text-cyan">ShipViewModel</span>[];<br/>
|
||||
stations: <span className="text-cyan">StationViewModel</span>[];<br/>
|
||||
asteroids: <span className="text-cyan">AsteroidViewModel</span>[];<br/>
|
||||
anomalies: <span className="text-cyan">AnomalyViewModel</span>[];<br/>
|
||||
worldEvents: <span className="text-cyan">WorldEventViewModel</span>[];<br/>
|
||||
selectedEntityId: <span className="text-cyan">string</span> | <span className="text-purple">null</span>;<br/>
|
||||
};<br/>
|
||||
<br/>
|
||||
<span className="text-purple">type</span> <span className="text-cyan">GameRendererEvents</span> = {<br/>
|
||||
<span className="text-accent">onSelectEntity</span>(entityId: <span className="text-cyan">string</span>): <span className="text-cyan">void</span>;<br/>
|
||||
<span className="text-accent">onMoveCommand</span>(position: <span className="text-cyan">Vec3</span>): <span className="text-cyan">void</span>;<br/>
|
||||
<span className="text-accent">onMineCommand</span>(asteroidId: <span className="text-cyan">string</span>): <span className="text-cyan">void</span>;<br/>
|
||||
<span className="text-accent">onDockCommand</span>(stationId: <span className="text-cyan">string</span>): <span className="text-cyan">void</span>;<br/>
|
||||
};
|
||||
</code>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-5 max-[900px]:grid-cols-1" style={{ marginTop: 'var(--sp-5)' }}>
|
||||
<div className="mb-6 rounded-xl border border-border bg-surface p-6 max-md:rounded-lg max-md:p-4 border-l-[3px] border-l-accent">
|
||||
<h4 style={{ color: 'var(--green)' }}>Keep renderer-specific</h4>
|
||||
<ul style={{ color: 'var(--fg-dim)', fontSize: '0.82rem', margin: 0, paddingLeft: 'var(--sp-5)' }}>
|
||||
<li>Meshes, materials, lights, particles</li>
|
||||
<li>Camera controls</li>
|
||||
<li>Raycasting and pointer interactions</li>
|
||||
<li>Visual interpolation implementation</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="mb-6 rounded-xl border border-border bg-surface p-6 max-md:rounded-lg max-md:p-4 border-l-[3px] border-l-accent">
|
||||
<h4 style={{ color: 'var(--cyan)' }}>Keep renderer-independent</h4>
|
||||
<ul style={{ color: 'var(--fg-dim)', fontSize: '0.82rem', margin: 0, paddingLeft: 'var(--sp-5)' }}>
|
||||
<li>Game types, reducers, subscriptions</li>
|
||||
<li>Domain rules: mining, docking, selling</li>
|
||||
<li>Inventory logic and market validation</li>
|
||||
<li>Shared movement math and view models</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ═══ RECONNECTION & ERROR HANDLING ═══ */}
|
||||
|
||||
<div className="mb-5 flex items-center gap-3 border-b border-border pb-3 max-md:flex-col max-md:items-start max-md:gap-2 max-md:[&_h1]:text-[1.35rem] max-md:[&_h2]:text-[1.35rem]" style={{ marginTop: 'var(--sp-8)' }}>
|
||||
<span className="rounded-full bg-[rgba(240,160,48,0.08)] px-2 py-0.5 font-mono text-[0.7rem] text-accent">ARCH-4</span>
|
||||
<h2 style={{ margin: 0 }}>Error Handling & Reconnection</h2>
|
||||
</div>
|
||||
|
||||
<div className="mb-5 rounded-lg px-5 py-4 text-[0.85rem] leading-[1.5] border border-cyan/20 bg-cyan/8 text-cyan" style={{ marginBottom: 'var(--sp-5)' }}>
|
||||
<strong>Players should never lose progress due to a disconnect.</strong>
|
||||
SpacetimeDB is the source of truth and persists all authoritative state server-side.
|
||||
A disconnected player's ship continues to exist in the world, subject to the same rules as every other ship.
|
||||
Reconnection restores the player to their last authoritative state — nothing is lost.
|
||||
</div>
|
||||
|
||||
<h3 style={{ marginBottom: 'var(--sp-4)' }}>Disconnection Scenarios</h3>
|
||||
<div style={{ overflowX: 'auto', marginBottom: 'var(--sp-6)' }}>
|
||||
<table className="w-full border-collapse text-[0.85rem] max-md:block max-md:overflow-x-auto max-md:rounded-lg max-md:border max-md:border-border max-md:[&_thead]:table max-md:[&_tbody]:table max-md:[&_thead]:min-w-[680px] max-md:[&_tbody]:min-w-[680px] [&_th]:whitespace-nowrap [&_th]:border-b [&_th]:border-border [&_th]:px-4 [&_th]:py-3 [&_th]:text-left [&_th]:font-mono [&_th]:text-[0.7rem] [&_th]:uppercase [&_th]:tracking-[0.06em] [&_th]:text-muted [&_td]:border-b [&_td]:border-border [&_td]:px-4 [&_td]:py-3 [&_td]:font-mono [&_td]:text-[0.8rem] [&_td]:text-fg [&_tr:hover_td]:bg-surface-raised">
|
||||
<thead>
|
||||
<tr><th>Scenario</th><th>Server Behavior</th><th>On Reconnect</th><th>Player Impact</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{[
|
||||
{ scene: 'Idle in space', server: 'Ship remains in world. Continues orbit or holds position. No auto-actions.', reconnect: 'Full state restore. Ship where it was. No losses.', impact: 'None. Seamless.' },
|
||||
{ scene: 'Mid-mining cycle', server: 'Active mining action continues to completion. Ore deposited to cargo.', reconnect: 'Mining cycle may have completed. Ore in cargo as expected. Partial cycles yield partial ore.', impact: 'Minimal. At worst, lost time on partial cycle.' },
|
||||
{ scene: 'Mid-combat (PvE)', server: 'Ship continues with last known power allocation. Auto-defends with passive tank. If destroyed, insurance applies.', reconnect: 'If ship survived: full combat restore with current HP. If destroyed: respawn at home station, insurance payout queued.', impact: 'Ship may be destroyed. Insurance covers hull. Standard death penalty applies — this is the risk of disconnecting in danger.' },
|
||||
{ scene: 'Mid-warp', server: 'Warp completes normally. Ship arrives at destination.', reconnect: 'Ship at destination. No interruption to warp.', impact: 'None.' },
|
||||
{ scene: 'Docked at station', server: 'No risk. Station is safe. Player remains docked.', reconnect: 'Restored to station. All panels and inventory intact.', impact: 'None.' },
|
||||
{ scene: 'Mid-market order', server: 'If reducer was received, order is placed. If not, no order.', reconnect: 'Check market panel for order state. ISK and inventory reflect server truth.', impact: 'At worst, order was not placed. No double-spend possible (atomic reducers).' },
|
||||
{ scene: 'Mid-combat (PvP — co-op)', server: 'Ship continues with last power allocation. Enemy continues attacking.', reconnect: 'Same as PvE: ship may be destroyed. No special protection for PvP disconnect.', impact: 'Ship may be destroyed. Disconnecting during PvP carries the same risk as staying.' },
|
||||
].map((row, i) => (
|
||||
<tr key={i}>
|
||||
<td style={{ fontWeight: 600, color: 'var(--fg)' }}>{row.scene}</td>
|
||||
<td style={{ color: 'var(--fg-dim)', fontSize: '0.82rem' }}>{row.server}</td>
|
||||
<td style={{ color: 'var(--fg-dim)', fontSize: '0.82rem' }}>{row.reconnect}</td>
|
||||
<td style={{ color: 'var(--fg-dim)', fontSize: '0.82rem' }}>{row.impact}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-5 max-[900px]:grid-cols-1" style={{ marginBottom: 'var(--sp-6)' }}>
|
||||
<div className="mb-6 rounded-xl border border-border bg-surface p-6 max-md:rounded-lg max-md:p-4" style={{ borderLeft: '3px solid var(--cyan)' }}>
|
||||
<h4 style={{ color: 'var(--cyan)' }}>Reconnection Flow</h4>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: '0.82rem', color: 'var(--fg-dim)', lineHeight: 2 }}>
|
||||
1. <span style={{ color: 'var(--red)' }}>Detect disconnect</span> — WebSocket close event or heartbeat timeout (10s no response)<br/>
|
||||
2. <span style={{ color: 'var(--accent)' }}>Show reconnect banner</span> — "Connection lost. Reconnecting..." with spinning indicator. UI freezes input but continues rendering last known state.<br/>
|
||||
3. <span style={{ color: 'var(--green)' }}>Auto-retry</span> — Exponential backoff: 1s, 2s, 4s, 8s, max 30s. Up to 10 attempts over ~5 minutes.<br/>
|
||||
4. <span style={{ color: 'var(--cyan)' }}>Re-establish subscription</span> — On reconnect, re-subscribe to all relevant SpacetimeDB tables. Server sends full state diff.<br/>
|
||||
5. <span style={{ color: 'var(--purple)' }}>State reconciliation</span> — Client merges server state into local state. Visual positions snap to authoritative positions. Inventory, market, chat all refresh.<br/>
|
||||
6. <span style={{ color: 'var(--green)' }}>Resume gameplay</span> — Banner disappears. All inputs re-enabled. Player is back in the game.
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-6 rounded-xl border border-border bg-surface p-6 max-md:rounded-lg max-md:p-4" style={{ borderLeft: '3px solid var(--red)' }}>
|
||||
<h4 style={{ color: 'var(--red)' }}>Failed Reconnection</h4>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: '0.82rem', color: 'var(--fg-dim)', lineHeight: 2 }}>
|
||||
<p>If all 10 attempts fail (5+ minutes of no connection):</p>
|
||||
<p>
|
||||
<span style={{ color: 'var(--accent)' }}>{'Core game (local SpacetimeDB):'}</span>
|
||||
{' This should never happen. If it does, it\'s a bug. Show error screen with "restart game" button. Local SpacetimeDB state is intact.'}
|
||||
</p>
|
||||
<p>
|
||||
<span style={{ color: 'var(--cyan)' }}>{'Co-op server (remote SpacetimeDB):'}</span>
|
||||
{' Show "Connection lost" screen with two options: [Retry Now] (immediate reconnect attempt) and [Return to Login]. Ship persists on server. No data lost.'}
|
||||
</p>
|
||||
<p>
|
||||
<span style={{ color: 'var(--muted)' }}>{'The ship never vanishes on disconnect. It stays in the world, obeying server rules. This is intentional — on co-op servers, disconnecting to escape combat is not allowed.'}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-5 rounded-lg px-5 py-4 text-[0.85rem] leading-[1.5] border border-[rgba(240,160,48,0.25)] bg-[rgba(240,160,48,0.08)] text-accent" style={{ marginBottom: 'var(--sp-6)' }}>
|
||||
<strong>Anti-exploit: combat disconnect.</strong> On co-op servers, players who disconnect during PvP combat receive no special protection.
|
||||
Their ship remains in the world, continuing to fight with last-known power allocation. If destroyed, normal death
|
||||
penalties apply (loot drop, insurance). This prevents "combat logging" as an escape mechanism. Zora may send
|
||||
a message on reconnect: "I sustained damage while you were away. Shields at 40%. The enemy disengaged."
|
||||
</div>
|
||||
|
||||
{/* ═══ SESSION PERSISTENCE ═══ */}
|
||||
|
||||
<div className="mb-5 flex items-center gap-3 border-b border-border pb-3 max-md:flex-col max-md:items-start max-md:gap-2 max-md:[&_h1]:text-[1.35rem] max-md:[&_h2]:text-[1.35rem]">
|
||||
<span className="rounded-full bg-[rgba(240,160,48,0.08)] px-2 py-0.5 font-mono text-[0.7rem] text-accent">ARCH-5</span>
|
||||
<h2 style={{ margin: 0 }}>Session Persistence & Save/Load</h2>
|
||||
</div>
|
||||
|
||||
<div className="mb-5 rounded-lg px-5 py-4 text-[0.85rem] leading-[1.5] border border-cyan/20 bg-cyan/8 text-cyan" style={{ marginBottom: 'var(--sp-5)' }}>
|
||||
<strong>There is no save button and no manual save/load.</strong>
|
||||
SpacetimeDB persists all authoritative state continuously — every reducer call, every tick update, every position change
|
||||
is written to the database as it happens. "Saving" is not a player action; it is the natural consequence of playing the game.
|
||||
Closing the browser and returning tomorrow restores the player to exactly where they left off.
|
||||
The game client persists state continuously — every action, position change, and story event is saved locally.
|
||||
"Saving" is not a player action; it's the natural consequence of playing the game.
|
||||
Closing the game and returning tomorrow restores you to exactly where you left off.
|
||||
</div>
|
||||
|
||||
<h3 style={{ marginBottom: 'var(--sp-4)' }}>What Gets Persisted</h3>
|
||||
<div style={{ overflowX: 'auto', marginBottom: 'var(--sp-6)' }}>
|
||||
<table className="w-full border-collapse text-[0.85rem] max-md:block max-md:overflow-x-auto max-md:rounded-lg max-md:border max-md:border-border max-md:[&_thead]:table max-md:[&_tbody]:table max-md:[&_thead]:min-w-[680px] max-md:[&_tbody]:min-w-[680px] [&_th]:whitespace-nowrap [&_th]:border-b [&_th]:border-border [&_th]:px-4 [&_th]:py-3 [&_th]:text-left [&_th]:font-mono [&_th]:text-[0.7rem] [&_th]:uppercase [&_th]:tracking-[0.06em] [&_th]:text-muted [&_td]:border-b [&_td]:border-border [&_td]:px-4 [&_td]:py-3 [&_td]:font-mono [&_td]:text-[0.8rem] [&_td]:text-fg [&_tr:hover_td]:bg-surface-raised">
|
||||
<thead>
|
||||
<tr><th>Category</th><th>Tables</th><th>Persistence Guarantee</th></tr>
|
||||
<tr><th>Category</th><th>Data</th><th>Persistence Guarantee</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{[
|
||||
{ cat: 'Player Identity', tables: 'players, player_skills, player_standing, player_loyalty_points', guarantee: 'Permanent. Never lost. Bound to SpacetimeDB identity.' },
|
||||
{ cat: 'Ship State', tables: 'ships, ship_fittings, ship_ai_soul, ship_ai_modules, ship_ai_memory, ship_ai_directives', guarantee: 'Permanent. Ship position, fitting, AI state all persist. If destroyed, destroyed state persists until replaced.' },
|
||||
{ cat: 'Economy', tables: 'inventory_items, market_orders, manufacturing_jobs, insurance_policies', guarantee: 'Permanent. Items, orders, and jobs survive restart. In-progress jobs continue server-side during offline time.' },
|
||||
{ cat: 'Navigation', tables: 'bookmarks, waypoints', guarantee: 'Permanent. Saved locations and routes survive indefinitely.' },
|
||||
{ cat: 'Social', tables: 'chat_messages (recent), bounties, kill_feed', guarantee: 'Messages expire after 30 days. Bounties persist until collected or decayed. Kill feed is permanent (story log).' },
|
||||
{ cat: 'World State', tables: 'world_events, faction_relations, galaxy_story_log, anomalies, space_fauna', guarantee: 'Server-owned. Continues evolving while player is offline. Player returns to a changed galaxy.' },
|
||||
{ cat: 'Player Identity', tables: 'Captain name, skills, standing, story progress', guarantee: 'Permanent. Never lost.' },
|
||||
{ cat: 'Ship State', tables: 'Position, fitting, AI companion state, cargo', guarantee: 'Permanent. If destroyed, death state persists until respawn.' },
|
||||
{ cat: 'Economy', tables: 'Inventory items, market orders, manufacturing jobs', guarantee: 'Permanent. Items and orders survive restart.' },
|
||||
{ cat: 'Navigation', tables: 'Bookmarks, waypoints', guarantee: 'Permanent. Saved locations survive indefinitely.' },
|
||||
{ cat: 'Story Log', tables: 'AI Story events, discoveries, choices, faction relations', guarantee: 'Permanent. Your saga is preserved.' },
|
||||
{ cat: 'World State', tables: 'Factions, world events, galaxy state', guarantee: 'Saved with your campaign. Continues where you left off.' },
|
||||
].map((row, i) => (
|
||||
<tr key={i}>
|
||||
<td style={{ fontWeight: 600, color: 'var(--fg)' }}>{row.cat}</td>
|
||||
@@ -266,339 +134,177 @@ export function ArchitecturePage() {
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-5 max-[900px]:grid-cols-1" style={{ marginBottom: 'var(--sp-6)' }}>
|
||||
<div className="mb-6 rounded-xl border border-border bg-surface p-6 max-md:rounded-lg max-md:p-4" style={{ borderLeft: '3px solid var(--green)' }}>
|
||||
<h4 style={{ color: 'var(--green)' }}>Offline Progression</h4>
|
||||
<p style={{ color: 'var(--fg-dim)', fontSize: '0.85rem', margin: '0 0 var(--sp-3) 0' }}>
|
||||
The server continues simulating the galaxy while the player is offline. Faction borders shift,
|
||||
world events spawn and resolve, the economy adjusts. Manufacturing jobs placed before logging off
|
||||
complete on schedule. Market orders can be filled while the player is away.
|
||||
</p>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: '0.72rem', color: 'var(--muted)' }}>
|
||||
"Come back tomorrow" is always a valid answer — your manufacturing job finishes whether you watch it or not.
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-6 rounded-xl border border-border bg-surface p-6 max-md:rounded-lg max-md:p-4" style={{ borderLeft: '3px solid var(--accent)' }}>
|
||||
<h4 style={{ color: 'var(--accent)' }}>What Does NOT Persist</h4>
|
||||
<ul style={{ color: 'var(--fg-dim)', fontSize: '0.85rem', margin: 0, paddingLeft: 'var(--sp-5)' }}>
|
||||
<li><strong style={{ color: 'var(--fg)' }}>UI layout:</strong> Panel positions, sorting preferences, open tabs. These reset on reload. (Future: saved layout profiles.)</li>
|
||||
<li><strong style={{ color: 'var(--fg)' }}>Camera state:</strong> Zoom level, orbit angle. Reset to default on reconnection.</li>
|
||||
<li><strong style={{ color: 'var(--fg)' }}>In-progress inputs:</strong> Half-typed chat messages, unfinalized market orders. Lost on disconnect.</li>
|
||||
<li><strong style={{ color: 'var(--fg)' }}>Transient effects:</strong> Active weapon animations, explosion particles, HUD flash effects. Visual only — not gameplay state.</li>
|
||||
<li><strong style={{ color: 'var(--fg)' }}>UI layout:</strong> Panel positions, sorting preferences, open tabs. These reset on reload.</li>
|
||||
<li><strong style={{ color: 'var(--fg)' }}>Camera state:</strong> Zoom level, orbit angle. Reset to default on load.</li>
|
||||
<li><strong style={{ color: 'var(--fg)' }}>In-progress inputs:</strong> Half-typed messages, unfinalized orders. Lost on exit.</li>
|
||||
<li><strong style={{ color: 'var(--fg)' }}>Transient effects:</strong> Active weapon animations, explosion particles. Visual only.</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="mb-6 rounded-xl border border-border bg-surface p-6 max-md:rounded-lg max-md:p-4" style={{ borderLeft: '3px solid var(--purple)' }}>
|
||||
<h4 style={{ color: 'var(--purple)' }}>Story-Driven Persistence</h4>
|
||||
<p style={{ color: 'var(--fg-dim)', fontSize: '0.85rem', margin: '0 0 var(--sp-3) 0' }}>
|
||||
Every choice you make feeds the AI Story Director. Your discoveries, alliances, conflicts, and triumphs
|
||||
are woven into an ongoing narrative that persists across sessions. The story log is your personal
|
||||
chronicle — preserved locally, always accessible, never lost.
|
||||
</p>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: '0.72rem', color: 'var(--muted)' }}>
|
||||
"Come back tomorrow" continues your story where you left off.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-5 rounded-lg px-5 py-4 text-[0.85rem] leading-[1.5] border border-cyan/20 bg-cyan/8 text-cyan" style={{ marginBottom: 'var(--sp-6)' }}>
|
||||
<strong>Persistence model:</strong> The core game and co-op servers both use SpacetimeDB exclusively — there is no localStorage.
|
||||
In the core game, SpacetimeDB runs locally on the player's machine. "Persistence" means the local database file survives browser restart.
|
||||
On co-op servers, SpacetimeDB runs on a remote server. Persistence is permanent and shared. The only difference is where the database
|
||||
process runs; the persistence model is identical.
|
||||
<strong>Future multiplayer considerations:</strong> If multiplayer servers are added post-V1, the architecture will
|
||||
extend to support remote state synchronization. The single-player foundation ensures all systems work locally first —
|
||||
multiplayer would be an optional enhancement, not a requirement.
|
||||
</div>
|
||||
|
||||
{/* ═══ SOUND & AUDIO ═══ */}
|
||||
|
||||
<div className="mb-5 flex items-center gap-3 border-b border-border pb-3 max-md:flex-col max-md:items-start max-md:gap-2 max-md:[&_h1]:text-[1.35rem] max-md:[&_h2]:text-[1.35rem]">
|
||||
<span className="rounded-full bg-[rgba(240,160,48,0.08)] px-2 py-0.5 font-mono text-[0.7rem] text-accent">ARCH-6</span>
|
||||
<span className="rounded-full bg-[rgba(240,160,48,0.08)] px-2 py-0.5 font-mono text-[0.7rem] text-accent">ARCH-4</span>
|
||||
<h2 style={{ margin: 0 }}>Sound & Audio Design</h2>
|
||||
</div>
|
||||
|
||||
<div className="mb-5 rounded-lg px-5 py-4 text-[0.85rem] leading-[1.5] border border-cyan/20 bg-cyan/8 text-cyan" style={{ marginBottom: 'var(--sp-5)' }}>
|
||||
<strong>Audio reinforces the spreadsheet.</strong> This game is not a flight sim and audio should not pretend it is.
|
||||
Sound design serves three purposes: (1) information delivery — alerts, status changes, notifications;
|
||||
(2) atmosphere — ambient space sounds that make the galaxy feel alive; (3) feedback — clicks, confirmations,
|
||||
and economic sounds that make spreadsheet actions feel satisfying. Audio is always optional — the game is fully
|
||||
playable with sound muted, but richer with it.
|
||||
<strong>Audio reinforces the narrative.</strong> Sound design serves three purposes: (1) information delivery — alerts,
|
||||
status changes, notifications; (2) atmosphere — ambient space sounds that make the galaxy feel alive;
|
||||
(3) feedback — clicks, confirmations, and sounds that make actions feel satisfying. Audio is always optional.
|
||||
</div>
|
||||
|
||||
<h3 style={{ marginBottom: 'var(--sp-4)' }}>Audio Categories</h3>
|
||||
<div style={{ overflowX: 'auto', marginBottom: 'var(--sp-6)' }}>
|
||||
<table className="w-full border-collapse text-[0.85rem] max-md:block max-md:overflow-x-auto max-md:rounded-lg max-md:border max-md:border-border max-md:[&_thead]:table max-md:[&_tbody]:table max-md:[&_thead]:min-w-[680px] max-md:[&_tbody]:min-w-[680px] [&_th]:whitespace-nowrap [&_th]:border-b [&_th]:border-border [&_th]:px-4 [&_th]:py-3 [&_th]:text-left [&_th]:font-mono [&_th]:text-[0.7rem] [&_th]:uppercase [&_th]:tracking-[0.06em] [&_th]:text-muted [&_td]:border-b [&_td]:border-border [&_td]:px-4 [&_td]:py-3 [&_td]:font-mono [&_td]:text-[0.8rem] [&_td]:text-fg [&_tr:hover_td]:bg-surface-raised">
|
||||
<thead>
|
||||
<tr><th>Category</th><th>Purpose</th><th>Examples</th><th>Priority</th></tr>
|
||||
<tr><th>Category</th><th>Purpose</th><th>Examples</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{[
|
||||
{ cat: 'Alerts', purpose: 'Critical state changes that demand attention', examples: 'Red Alert klaxon (shields <25%), target lock acquired, CONCORD warning, incoming damage alarm, disconnect sound', priority: 'Critical — always audible, respects master volume only' },
|
||||
{ cat: 'UI Feedback', purpose: 'Confirm player actions in panels', examples: 'Order placed (cash register ding), module fitted (mechanical click), skill leveled up (ascending tone), ISK received (soft chime), insurance purchased (stamp sound)', priority: 'High — respects UI volume slider' },
|
||||
{ cat: 'Ambient', purpose: 'Atmosphere and spatial awareness', examples: 'Station hum (docked), solar wind (in space), mining laser drone, market chatter (station background), warp tunnel whoosh', priority: 'Medium — respects ambient volume slider' },
|
||||
{ cat: 'Combat', purpose: 'Combat state feedback', examples: 'Weapon firing (per type: beam hum, bolt crack, missile launch), shield hit (energy crackle), armor hit (metallic impact), hull hit (structural groan), capacitor warning (low power hum), weapon offline (power-down whine)', priority: 'High — respects combat volume slider' },
|
||||
{ cat: 'World Events', purpose: 'Environment storytelling', examples: 'Anomaly detection ping, faction broadcast (radio static + voice), fauna migration rumble, explosion (distant), wormhole opening', priority: 'Medium — respects world volume slider' },
|
||||
{ cat: 'Zora Voice', purpose: 'Ship AI spoken responses', examples: 'Status reports, combat warnings, market tips, tutorial hints. Voice synthesis via Voice Synthesizer module. See Ship AI → Modules.', priority: 'High — respects voice volume slider. Can be disabled entirely.' },
|
||||
{ cat: 'Alerts', purpose: 'Critical state changes', examples: 'Shields low, target lock, incoming damage, story event notification' },
|
||||
{ cat: 'UI Feedback', purpose: 'Confirm player actions', examples: 'Order placed, module fitted, skill leveled, ISK received' },
|
||||
{ cat: 'Ambient', purpose: 'Atmosphere and immersion', examples: 'Station hum (docked), solar wind, mining laser, warp tunnel' },
|
||||
{ cat: 'Combat', purpose: 'Combat state feedback', examples: 'Weapon firing, shield hit, armor hit, capacitor warning' },
|
||||
{ cat: 'Story Events', purpose: 'Narrative moments', examples: 'Discovery ping, faction broadcast, AI Story Director narration' },
|
||||
{ cat: 'Voice', purpose: 'Character dialogue', examples: 'Zora responses, NPC agent dialogue, story narration' },
|
||||
].map((row, i) => (
|
||||
<tr key={i}>
|
||||
<td style={{ fontWeight: 600, color: 'var(--fg)' }}>{row.cat}</td>
|
||||
<td style={{ color: 'var(--fg-dim)', fontSize: '0.82rem' }}>{row.purpose}</td>
|
||||
<td style={{ color: 'var(--fg-dim)', fontSize: '0.82rem' }}>{row.examples}</td>
|
||||
<td style={{ color: 'var(--fg-dim)', fontSize: '0.82rem' }}>{row.priority}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-5 max-[900px]:grid-cols-1" style={{ marginBottom: 'var(--sp-6)' }}>
|
||||
<div className="mb-6 rounded-xl border border-border bg-surface p-6 max-md:rounded-lg max-md:p-4" style={{ borderLeft: '3px solid var(--cyan)' }}>
|
||||
<h4 style={{ color: 'var(--cyan)' }}>Volume Controls</h4>
|
||||
<ul style={{ color: 'var(--fg-dim)', fontSize: '0.85rem', margin: 0, paddingLeft: 'var(--sp-5)' }}>
|
||||
<li><strong style={{ color: 'var(--fg)' }}>Master:</strong> 0–100%. Controls all audio. Default 80%.</li>
|
||||
<li><strong style={{ color: 'var(--fg)' }}>UI:</strong> Panel sounds, market dings, fitting clicks. Default 70%.</li>
|
||||
<li><strong style={{ color: 'var(--fg)' }}>Combat:</strong> Weapons, impacts, Red Alert. Default 80%.</li>
|
||||
<li><strong style={{ color: 'var(--fg)' }}>Ambient:</strong> Background atmosphere. Default 50%.</li>
|
||||
<li><strong style={{ color: 'var(--fg)' }}>World:</strong> Event sounds, fauna, anomalies. Default 60%.</li>
|
||||
<li><strong style={{ color: 'var(--fg)' }}>Voice:</strong> Zora and NPC dialogue. Default 90%. Has separate mute toggle.</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="mb-6 rounded-xl border border-border bg-surface p-6 max-md:rounded-lg max-md:p-4" style={{ borderLeft: '3px solid var(--green)' }}>
|
||||
<h4 style={{ color: 'var(--green)' }}>Spatial Audio Rules</h4>
|
||||
<ul style={{ color: 'var(--fg-dim)', fontSize: '0.85rem', margin: 0, paddingLeft: 'var(--sp-5)' }}>
|
||||
<li>Combat sounds are directional — weapon fire comes from the direction of the source</li>
|
||||
<li>Distant events are muffled (low-pass filter scales with distance)</li>
|
||||
<li>Station ambient sounds only play when docked (cross-fade on dock/undock)</li>
|
||||
<li>Warp tunnel audio fades in during acceleration, peaks at cruise, fades out on deceleration</li>
|
||||
<li>Zora's voice always comes from "center" — she's inside your head, not in space</li>
|
||||
<li>Audio never provides exclusive gameplay information — everything audible has a visual equivalent</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-5 rounded-lg px-5 py-4 text-[0.85rem] leading-[1.5] border border-cyan/20 bg-cyan/8 text-cyan" style={{ marginBottom: 'var(--sp-6)' }}>
|
||||
<strong>Implementation note:</strong> Audio is Phase 7 scope (Single-Player Polish). Phase 0–6 can ship with placeholder
|
||||
sounds or no sounds at all. The audio system should be built on the Web Audio API with a thin abstraction layer
|
||||
that maps game events to sound triggers. Sound assets can be procedurally generated or placeholder bleeps until
|
||||
a proper sound design pass is done.
|
||||
</div>
|
||||
|
||||
{/* ═══ LOCALIZATION ═══ */}
|
||||
|
||||
<div className="mb-5 flex items-center gap-3 border-b border-border pb-3 max-md:flex-col max-md:items-start max-md:gap-2 max-md:[&_h1]:text-[1.35rem] max-md:[&_h2]:text-[1.35rem]">
|
||||
<span className="rounded-full bg-[rgba(240,160,48,0.08)] px-2 py-0.5 font-mono text-[0.7rem] text-accent">ARCH-7</span>
|
||||
<span className="rounded-full bg-[rgba(240,160,48,0.08)] px-2 py-0.5 font-mono text-[0.7rem] text-accent">ARCH-5</span>
|
||||
<h2 style={{ margin: 0 }}>Localization & Internationalization</h2>
|
||||
</div>
|
||||
|
||||
<div className="mb-5 rounded-lg px-5 py-4 text-[0.85rem] leading-[1.5] border border-cyan/20 bg-cyan/8 text-cyan" style={{ marginBottom: 'var(--sp-5)' }}>
|
||||
<strong>MVP is English-only. This section documents the decision and the architecture that makes future localization non-breaking.</strong>
|
||||
Adding languages post-launch should require translators and asset work, not code changes. All user-facing strings
|
||||
flow through a lookup layer from day one — even if that layer only returns English.
|
||||
<strong>MVP is English-only with day-one i18n architecture.</strong> Adding languages post-launch should require translators
|
||||
and asset work, not code changes. All user-facing strings flow through a lookup layer from day one.
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-5 max-[900px]:grid-cols-1" style={{ marginBottom: 'var(--sp-6)' }}>
|
||||
<div className="mb-6 rounded-xl border border-border bg-surface p-6 max-md:rounded-lg max-md:p-4" style={{ borderLeft: '3px solid var(--cyan)' }}>
|
||||
<h4 style={{ color: 'var(--cyan)' }}>What Ships in English Only (MVP)</h4>
|
||||
<h4 style={{ color: 'var(--cyan)' }}>i18n Architecture</h4>
|
||||
<ul style={{ color: 'var(--fg-dim)', fontSize: '0.85rem', margin: 0, paddingLeft: 'var(--sp-5)' }}>
|
||||
<li>All UI labels, button text, and panel headers</li>
|
||||
<li>Tutorial mission dialogue</li>
|
||||
<li>Zora personality templates (Tier 0)</li>
|
||||
<li>NPC agent dialogue</li>
|
||||
<li>Error messages and status notifications</li>
|
||||
<li>Item, module, and ship names</li>
|
||||
<li><strong style={{ color: 'var(--fg)' }}>String keys:</strong> All user-facing text uses lookup keys: <code>t("market.order.placed")</code></li>
|
||||
<li><strong style={{ color: 'var(--fg)' }}>Number formatting:</strong> ISK values use <code>Intl.NumberFormat</code></li>
|
||||
<li><strong style={{ color: 'var(--fg)' }}>Date/time:</strong> Timestamps use <code>Intl.DateTimeFormat</code></li>
|
||||
<li><strong style={{ color: 'var(--fg)' }}>Pluralization:</strong> ICU message format for count-aware strings</li>
|
||||
<li><strong style={{ color: 'var(--fg)' }}>Layout:</strong> CSS flexbox/grid with logical properties for RTL readiness</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="mb-6 rounded-xl border border-border bg-surface p-6 max-md:rounded-lg max-md:p-4" style={{ borderLeft: '3px solid var(--green)' }}>
|
||||
<h4 style={{ color: 'var(--green)' }}>i18n Architecture (Day-One Foundation)</h4>
|
||||
<ul style={{ color: 'var(--fg-dim)', fontSize: '0.85rem', margin: 0, paddingLeft: 'var(--sp-5)' }}>
|
||||
<li><strong style={{ color: 'var(--fg)' }}>String keys:</strong> All user-facing text uses lookup keys, not hardcoded strings. <code>t("market.order.placed")</code> not <code>"Order placed"</code></li>
|
||||
<li><strong style={{ color: 'var(--fg)' }}>Number formatting:</strong> ISK values, quantities, percentages use <code>Intl.NumberFormat</code> from day one</li>
|
||||
<li><strong style={{ color: 'var(--fg)' }}>Date/time:</strong> Timestamps use <code>Intl.DateTimeFormat</code>. Relative time ("5 minutes ago") via <code>Intl.RelativeTimeFormat</code></li>
|
||||
<li><strong style={{ color: 'var(--fg)' }}>Pluralization:</strong> Use ICU message format for count-aware strings ("1 item" vs "5 items")</li>
|
||||
<li><strong style={{ color: 'var(--fg)' }}>Layout:</strong> UI components use CSS flexbox/grid. No hardcoded pixel widths that assume English string lengths</li>
|
||||
<li><strong style={{ color: 'var(--fg)' }}>RTL ready:</strong> CSS logical properties (start/end, not left/right) for future Arabic/Hebrew support</li>
|
||||
</ul>
|
||||
<h4 style={{ color: 'var(--green)' }}>Post-MVP Priority</h4>
|
||||
<p style={{ color: 'var(--fg-dim)', fontSize: '0.85rem', margin: '0 0 var(--sp-3) 0' }}>
|
||||
First languages after English determined by player population. Estimated effort per language:
|
||||
1–2 weeks for translation + 2–3 days for QA with RTL layout testing if applicable.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-5 rounded-lg px-5 py-4 text-[0.85rem] leading-[1.5] border border-[rgba(240,160,48,0.25)] bg-[rgba(240,160,48,0.08)] text-accent" style={{ marginBottom: 'var(--sp-5)' }}>
|
||||
<strong>Not localized (by design):</strong> Player names, chat messages, corporation names, and galaxy story log entries
|
||||
are user-generated content that is never translated. Zora Tier 1+ (LLM-assisted) dialogue would need per-language
|
||||
prompting — that's a Tier 2 scope concern, not MVP.
|
||||
</div>
|
||||
|
||||
<div className="mb-5 rounded-lg px-5 py-4 text-[0.85rem] leading-[1.5] border border-cyan/20 bg-cyan/8 text-cyan" style={{ marginBottom: 'var(--sp-6)' }}>
|
||||
<strong>Post-MVP language priority:</strong> The first languages after English would be determined by player population.
|
||||
The i18n architecture supports adding a new language by dropping in a translation file — no code changes.
|
||||
Estimated effort per language: 1–2 weeks for translation + 2–3 days for QA with RTL layout testing if applicable.
|
||||
</div>
|
||||
|
||||
{/* ═══ ACCESSIBILITY ═══ */}
|
||||
|
||||
<div className="mb-5 flex items-center gap-3 border-b border-border pb-3 max-md:flex-col max-md:items-start max-md:gap-2 max-md:[&_h1]:text-[1.35rem] max-md:[&_h2]:text-[1.35rem]">
|
||||
<span className="rounded-full bg-[rgba(240,160,48,0.08)] px-2 py-0.5 font-mono text-[0.7rem] text-accent">ARCH-8</span>
|
||||
<span className="rounded-full bg-[rgba(240,160,48,0.08)] px-2 py-0.5 font-mono text-[0.7rem] text-accent">ARCH-6</span>
|
||||
<h2 style={{ margin: 0 }}>Accessibility</h2>
|
||||
</div>
|
||||
|
||||
<div className="mb-5 rounded-lg px-5 py-4 text-[0.85rem] leading-[1.5] border border-cyan/20 bg-cyan/8 text-cyan" style={{ marginBottom: 'var(--sp-5)' }}>
|
||||
<strong>A spreadsheet game should be the most accessible genre in the world.</strong>
|
||||
The core gameplay involves reading tables, managing numbers, and clicking buttons — activities that web browsers
|
||||
already excel at supporting. The following accessibility targets are baseline requirements, not nice-to-haves.
|
||||
Every feature listed here ships in Phase 7 (Single-Player Polish).
|
||||
<strong>A narrative-driven game should be the most accessible genre.</strong>
|
||||
Reading tables, managing numbers, and clicking buttons — activities browsers excel at supporting.
|
||||
The following accessibility targets are baseline requirements.
|
||||
</div>
|
||||
|
||||
<h3 style={{ marginBottom: 'var(--sp-4)' }}>Accessibility Requirements</h3>
|
||||
<div style={{ overflowX: 'auto', marginBottom: 'var(--sp-6)' }}>
|
||||
<table className="w-full border-collapse text-[0.85rem] max-md:block max-md:overflow-x-auto max-md:rounded-lg max-md:border max-md:border-border max-md:[&_thead]:table max-md:[&_tbody]:table max-md:[&_thead]:min-w-[680px] max-md:[&_tbody]:min-w-[680px] [&_th]:whitespace-nowrap [&_th]:border-b [&_th]:border-border [&_th]:px-4 [&_th]:py-3 [&_th]:text-left [&_th]:font-mono [&_th]:text-[0.7rem] [&_th]:uppercase [&_th]:tracking-[0.06em] [&_th]:text-muted [&_td]:border-b [&_td]:border-border [&_td]:px-4 [&_td]:py-3 [&_td]:font-mono [&_td]:text-[0.8rem] [&_td]:text-fg [&_tr:hover_td]:bg-surface-raised">
|
||||
<thead>
|
||||
<tr><th>Area</th><th>Requirement</th><th>Implementation</th><th>Phase</th></tr>
|
||||
<tr><th>Area</th><th>Requirement</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{[
|
||||
{ area: 'Color Blindness', req: 'All color-coded information must have a secondary indicator (pattern, icon, label, or shape)', impl: 'Shield (cyan) → label "SHD". Armor (yellow) → label "ARM". Hull (red) → label "HUL". Security levels use text labels + icons, not just color. Market price changes use ▲/▼ arrows alongside green/red.', phase: '7' },
|
||||
{ area: 'Keyboard Navigation', req: 'Every action reachable by keyboard. No mouse-only workflows.', impl: 'Tab order follows logical panel flow. Enter activates focused element. Arrow keys navigate table rows. Escape closes panels. Number keys for power allocation (1=weapons, 2=shields, 3=engines, 4=aux). F1-F8 for module activation.', phase: '7' },
|
||||
{ area: 'Screen Reader', req: 'All panels and data tables announce state changes. Live regions for combat updates.', impl: 'ARIA labels on all interactive elements. role="grid" on data tables with aria-rowcount. aria-live="polite" on ISK balance, cargo capacity, skill XP. aria-live="assertive" on combat damage and Red Alert. Screen reader announcements for market order fills.', phase: '7' },
|
||||
{ area: 'Text Scaling', req: 'UI remains usable at 200% browser zoom and with large font settings.', impl: 'All font sizes in rem. All layouts use CSS grid/flexbox with min/max sizing. Tables scroll horizontally rather than overflow. Panel widths are percentage-based, not fixed pixels.', phase: '7' },
|
||||
{ area: 'Reduced Motion', req: 'Respect prefers-reduced-motion. No animations that could cause discomfort.', impl: 'CSS media query: disable particle effects, smooth scrolling, and HUD animations. Red Alert uses static red border instead of pulsing. Power allocation bars snap instead of animating. Mining cycle uses progress bar, not spinning animation.', phase: '7' },
|
||||
{ area: 'Contrast', req: 'All text meets WCAG AA contrast ratio (4.5:1 for normal text, 3:1 for large text).', impl: 'Var(--fg) on var(--bg) already exceeds 7:1. Dim text (--fg-dim) checked to meet 4.5:1. Interactive elements have visible focus indicators with 3:1 contrast against adjacent colors. Red Alert border is high-contrast red (#ff0000) against dark background.', phase: '7' },
|
||||
{ area: 'Cognitive Load', req: 'Information density is manageable. Players can hide complexity.', impl: 'Collapsible panels. Summary view vs. detail view toggle. Zora provides guided assistance. Red Alert collapses non-essential HUD elements. Tutorial hints can be disabled. Settings persist per player.', phase: '7' },
|
||||
{ area: 'Input Timing', req: 'No time-critical inputs required for core gameplay. Combat is manageable at any APM.', impl: "Power allocation has no per-second requirements — the skill is in choosing distribution, not speed. Mining is click-and-wait. Market orders don't expire mid-interaction. Only exception: PvP combat on co-op servers, which is inherently competitive.", phase: '7' },
|
||||
{ area: 'Color Blindness', req: 'All color-coded information has secondary indicator (icon, label, shape)' },
|
||||
{ area: 'Keyboard Navigation', req: 'Every action reachable by keyboard. Tab order, Enter, Escape, arrow keys' },
|
||||
{ area: 'Screen Reader', req: 'All panels and data tables announce state changes. ARIA labels on interactive elements' },
|
||||
{ area: 'Text Scaling', req: 'UI remains usable at 200% browser zoom. All layouts use responsive sizing' },
|
||||
{ area: 'Reduced Motion', req: 'Respect prefers-reduced-motion. Disable animations when requested' },
|
||||
{ area: 'Contrast', req: 'All text meets WCAG AA contrast ratio (4.5:1 for normal text)' },
|
||||
{ area: 'Cognitive Load', req: 'Collapsible panels. Summary vs detail view. Tutorial hints can be disabled' },
|
||||
].map((row, i) => (
|
||||
<tr key={i}>
|
||||
<td style={{ fontWeight: 600, color: 'var(--fg)' }}>{row.area}</td>
|
||||
<td style={{ color: 'var(--fg-dim)', fontSize: '0.82rem' }}>{row.req}</td>
|
||||
<td style={{ color: 'var(--fg-dim)', fontSize: '0.82rem' }}>{row.impl}</td>
|
||||
<td className="font-mono tabular-nums">{row.phase}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="mb-5 rounded-lg px-5 py-4 text-[0.85rem] leading-[1.5] border border-[rgba(240,160,48,0.25)] bg-[rgba(240,160,48,0.08)] text-accent" style={{ marginBottom: 'var(--sp-5)' }}>
|
||||
<strong>PvP accessibility note:</strong> PvP combat on co-op servers inherently involves time pressure — rerouting power,
|
||||
selecting targets, and reacting to damage. These cannot be fully de-timed without removing the competitive element.
|
||||
Players with motor impairments can (1) stay in high-sec where PvP is punished, (2) focus on PvE, industry,
|
||||
and market gameplay which are fully accessible, or (3) use fleet roles that require less real-time input
|
||||
(logistics, scouting). The game should never require PvP to progress.
|
||||
</div>
|
||||
{/* ═══ MOVEMENT & COLLISION ═══ */}
|
||||
|
||||
<div className="mb-5 rounded-lg px-5 py-4 text-[0.85rem] leading-[1.5] border border-cyan/20 bg-cyan/8 text-cyan" style={{ marginBottom: 'var(--sp-6)' }}>
|
||||
<strong>Testing:</strong> Accessibility validation is part of Gate 4 (Core Game Complete). The acceptance test is:
|
||||
(1) navigate all panels via keyboard only, (2) complete a mining-sell cycle with screen reader enabled,
|
||||
(3) verify all color-coded info has secondary indicators in grayscale. Automated: run axe-core or Lighthouse
|
||||
accessibility audit as part of CI.
|
||||
</div>
|
||||
|
||||
{/* ═══ CUSTOM MOVEMENT & COLLISION ═══ */}
|
||||
|
||||
<div className="mb-5 flex items-center gap-3 border-b border-border pb-3 max-md:flex-col max-md:items-start max-md:gap-2 max-md:[&_h1]:text-[1.35rem] max-md:[&_h2]:text-[1.35rem]" style={{ marginTop: 'var(--sp-8)' }}>
|
||||
<span className="rounded-full bg-[rgba(240,160,48,0.08)] px-2 py-0.5 font-mono text-[0.7rem] text-accent">ARCH-9</span>
|
||||
<h2 style={{ margin: 0 }}>Custom Movement & Collision (No Physics Engine)</h2>
|
||||
<div className="mb-5 flex items-center gap-3 border-b border-border pb-3 max-md:flex-col max-md:items-start max-md:gap-2 max-md:[&_h1]:text-[1.35rem] max-md:[&_h2]:text-[1.35rem]'" style={{ marginTop: 'var(--sp-8)' }}>
|
||||
<span className="rounded-full bg-[rgba(240,160,48,0.08)] px-2 py-0.5 font-mono text-[0.7rem] text-accent">ARCH-7</span>
|
||||
<h2 style={{ margin: 0 }}>Movement & Collision (No Physics Engine)</h2>
|
||||
</div>
|
||||
|
||||
<div className="mb-5 rounded-lg px-5 py-4 text-[0.85rem] leading-[1.5] border border-cyan/20 bg-cyan/8 text-cyan" style={{ marginBottom: 'var(--sp-5)' }}>
|
||||
<strong>Decision:</strong> The Bevy game client uses a hand-rolled kinematic movement system and distance-based
|
||||
collision detection. We do <strong>not</strong> integrate Rapier, Avian, XPBD, or any other rigid-body physics engine.
|
||||
Raycasting and collision are implemented as pure geometry functions over circles in 2D.
|
||||
</div>
|
||||
|
||||
<h3 style={{ marginBottom: 'var(--sp-4)' }}>Why No Physics Engine</h3>
|
||||
<div style={{ overflowX: 'auto', marginBottom: 'var(--sp-6)' }}>
|
||||
<table className="w-full border-collapse text-[0.85rem] max-md:block max-md:overflow-x-auto max-md:rounded-lg max-md:border max-md:border-border max-md:[&_thead]:table max-md:[&_tbody]:table max-md:[&_thead]:min-w-[680px] max-md:[&_tbody]:min-w-[680px] [&_th]:whitespace-nowrap [&_th]:border-b [&_th]:border-border [&_th]:px-4 [&_th]:py-3 [&_th]:text-left [&_th]:font-mono [&_th]:text-[0.7rem] [&_th]:uppercase [&_th]:tracking-[0.06em] [&_th]:text-muted [&_td]:border-b [&_td]:border-border [&_td]:px-4 [&_td]:py-3 [&_td]:font-mono [&_td]:text-[0.8rem] [&_td]:text-fg [&_tr:hover_td]:bg-surface-raised">
|
||||
<thead>
|
||||
<tr><th>Concern</th><th>Custom Kinematic</th><th>Physics Engine (Rapier/Avian)</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{[
|
||||
{ c: 'Genre fit', custom: 'Native — FTL/Windward style is point-and-shoot, not rigid bodies.', engine: 'Overkill — solvers are for stacking, joints, real impulses.' },
|
||||
{ c: 'Arcade feel', custom: 'Native — drag, snap turns, no inertia when undesired.', engine: 'Fight the solver — disabling bounce/friction is config hell.' },
|
||||
{ c: 'Compile time', custom: '0 added', engine: '+30–90s on every clean build' },
|
||||
{ c: 'Binary size', custom: '0 added', engine: '+2–5 MB' },
|
||||
{ c: 'Determinism', custom: 'Trivial — linear math, no solver iterations.', engine: 'Hard — solver iterations + float ops in unpredictable order.' },
|
||||
{ c: 'Network prediction', custom: 'Linear extrapolation of owned values.', engine: 'Replaying the solver is a nightmare.' },
|
||||
{ c: 'Bug surface', custom: '~50 lines you wrote.', engine: 'Tens of thousands of lines you didn’t.' },
|
||||
].map((row, i) => (
|
||||
<tr key={i}>
|
||||
<td style={{ fontWeight: 600, color: 'var(--fg)' }}>{row.c}</td>
|
||||
<td style={{ color: 'var(--fg-dim)', fontSize: '0.82rem' }}>{row.custom}</td>
|
||||
<td style={{ color: 'var(--fg-dim)', fontSize: '0.82rem' }}>{row.engine}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h3 style={{ marginBottom: 'var(--sp-4)' }}>What "Physics" Means in This Game</h3>
|
||||
<div style={{ overflowX: 'auto', marginBottom: 'var(--sp-6)' }}>
|
||||
<table className="w-full border-collapse text-[0.85rem] max-md:block max-md:overflow-x-auto max-md:rounded-lg max-md:border max-md:border-border max-md:[&_thead]:table max-md:[&_tbody]:table max-md:[&_thead]:min-w-[680px] max-md:[&_tbody]:min-w-[680px] [&_th]:whitespace-nowrap [&_th]:border-b [&_th]:border-border [&_th]:px-4 [&_th]:py-3 [&_th]:text-left [&_th]:font-mono [&_th]:text-[0.7rem] [&_th]:uppercase [&_th]:tracking-[0.06em] [&_th]:text-muted [&_td]:border-b [&_td]:border-border [&_td]:px-4 [&_td]:py-3 [&_td]:font-mono [&_td]:text-[0.8rem] [&_td]:text-fg [&_tr:hover_td]:bg-surface-raised">
|
||||
<thead>
|
||||
<tr><th>Gameplay Need</th><th>Implementation</th><th>Math</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{[
|
||||
{ need: 'Ship traversal', impl: 'Transform updates with Velocity, MaxSpeed, TurnRate, optional Drag.', math: 'pos += velocity * dt' },
|
||||
{ need: 'Planet/station orbiting', impl: 'Orbit component on child entity of star system.', math: 'pos = center + r * (cos(θ), sin(θ))' },
|
||||
{ need: 'Projectile vs ship hits', impl: 'Distance check each tick (no raycast needed for fast projectiles).', math: '‖a − b‖ < r_a + r_b' },
|
||||
{ need: 'Weapon targeting / LOS', impl: 'Ray-circle intersection, pick smallest t.', math: 'Quadratic: ‖o + t·d − c‖² = r²' },
|
||||
{ need: 'Ship-ship separation', impl: 'Push apart by overlap distance.', math: 'delta = (a − b).normalize() * (r_a + r_b − dist)' },
|
||||
{ need: 'Docking / proximity triggers', impl: 'Distance check, fire event when crossing threshold.', math: '‖a − b‖ < trigger_radius' },
|
||||
].map((row, i) => (
|
||||
<tr key={i}>
|
||||
<td style={{ fontWeight: 600, color: 'var(--fg)' }}>{row.need}</td>
|
||||
<td style={{ color: 'var(--fg-dim)', fontSize: '0.82rem' }}>{row.impl}</td>
|
||||
<td style={{ color: 'var(--cyan)', fontSize: '0.8rem', fontFamily: 'var(--font-mono)' }}>{row.math}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<strong>Decision:</strong> The Bevy game client uses hand-rolled kinematic movement and distance-based collision.
|
||||
We do <strong>not</strong> use a physics engine — movement is point-and-shoot, not rigid bodies.
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-5 max-[900px]:grid-cols-1" style={{ marginBottom: 'var(--sp-6)' }}>
|
||||
<div className="mb-6 rounded-xl border border-border bg-surface p-6 max-md:rounded-lg max-md:p-4" style={{ borderLeft: '3px solid var(--cyan)' }}>
|
||||
<h4 style={{ color: 'var(--cyan)' }}>Movement Module Layout</h4>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: '0.8rem', color: 'var(--fg-dim)', lineHeight: 1.7 }}>
|
||||
<span style={{ color: 'var(--purple)' }}>apps/game/src/gameplay/movement/</span><br/>
|
||||
mod.rs <span style={{ color: 'var(--muted)' }}>// MovementPlugin</span><br/>
|
||||
components.rs <span style={{ color: 'var(--muted)' }}>// Velocity, MaxSpeed, TurnRate, Drag</span><br/>
|
||||
kinematic.rs <span style={{ color: 'var(--muted)' }}>// move + drag + clamp systems</span><br/>
|
||||
orbit.rs <span style={{ color: 'var(--muted)' }}>// Orbit component + update_orbits</span>
|
||||
</div>
|
||||
<p style={{ color: 'var(--fg-dim)', fontSize: '0.82rem', margin: 'var(--sp-3) 0 0 0' }}>
|
||||
All systems run on Bevy’s <code>Time<Fixed></code> schedule for stable, deterministic ticks that
|
||||
align cleanly with SpacetimeDB updates.
|
||||
</p>
|
||||
<h4 style={{ color: 'var(--cyan)' }}>Why No Physics Engine</h4>
|
||||
<ul style={{ color: 'var(--fg-dim)', fontSize: '0.82rem', margin: 0, paddingLeft: 'var(--sp-5)' }}>
|
||||
<li>Native to FTL/Windward style — point-and-shoot, not rigid bodies</li>
|
||||
<li>Arcade feel — drag, snap turns, no unwanted inertia</li>
|
||||
<li>Zero compile time and binary size overhead</li>
|
||||
<li>Trivial determinism — linear math, no solver iterations</li>
|
||||
<li>Smaller bug surface — ~50 lines you wrote vs tens of thousands you didn't</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="mb-6 rounded-xl border border-border bg-surface p-6 max-md:rounded-lg max-md:p-4" style={{ borderLeft: '3px solid var(--green)' }}>
|
||||
<h4 style={{ color: 'var(--green)' }}>Physics Module Layout</h4>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: '0.8rem', color: 'var(--fg-dim)', lineHeight: 1.7 }}>
|
||||
<span style={{ color: 'var(--purple)' }}>apps/game/src/gameplay/physics/</span><br/>
|
||||
mod.rs <span style={{ color: 'var(--muted)' }}>// PhysicsPlugin</span><br/>
|
||||
geometry.rs <span style={{ color: 'var(--muted)' }}>// ray_vs_circle, overlaps, separate</span><br/>
|
||||
broad_phase.rs <span style={{ color: 'var(--muted)' }}>// (later) uniform grid</span><br/>
|
||||
systems.rs <span style={{ color: 'var(--muted)' }}>// projectile_hits, ship_separation</span>
|
||||
</div>
|
||||
<p style={{ color: 'var(--fg-dim)', fontSize: '0.82rem', margin: 'var(--sp-3) 0 0 0' }}>
|
||||
Geometry functions are pure <code>pub fn</code>s. Systems are thin Bevy wrappers that query entities and
|
||||
call them. No third-party deps.
|
||||
</p>
|
||||
<h4 style={{ color: 'var(--green)' }}>What Physics Means Here</h4>
|
||||
<ul style={{ color: 'var(--fg-dim)', fontSize: '0.82rem', margin: 0, paddingLeft: 'var(--sp-5)' }}>
|
||||
<li>Ship traversal: <code>pos += velocity * dt</code></li>
|
||||
<li>Projectiles vs ships: Distance check <code>‖a − b‖ < r_a + r_b</code></li>
|
||||
<li>Targeting: Ray-circle intersection</li>
|
||||
<li>Separation: Push apart by overlap distance</li>
|
||||
<li>Triggers: Distance check for docking/proximity</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 style={{ marginBottom: 'var(--sp-4)' }}>Scaling Tiers</h3>
|
||||
<div style={{ overflowX: 'auto', marginBottom: 'var(--sp-6)' }}>
|
||||
<table className="w-full border-collapse text-[0.85rem] max-md:block max-md:overflow-x-auto max-md:rounded-lg max-md:border max-md:border-border max-md:[&_thead]:table max-md:[&_tbody]:table max-md:[&_thead]:min-w-[680px] max-md:[&_tbody]:min-w-[680px] [&_th]:whitespace-nowrap [&_th]:border-b [&_th]:border-border [&_th]:px-4 [&_th]:py-3 [&_th]:text-left [&_th]:font-mono [&_th]:text-[0.7rem] [&_th]:uppercase [&_th]:tracking-[0.06em] [&_th]:text-muted [&_td]:border-b [&_td]:border-border [&_td]:px-4 [&_td]:py-3 [&_td]:font-mono [&_td]:text-[0.8rem] [&_td]:text-fg [&_tr:hover_td]:bg-surface-raised">
|
||||
<thead>
|
||||
<tr><th>Concurrent Entities</th><th>Strategy</th><th>Estimated Cost</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td>{'< 500'}</td><td>Iterate all entities, linear scan.</td><td style={{ color: 'var(--fg-dim)' }}>Trivial — no broad-phase needed.</td></tr>
|
||||
<tr><td>500 – 10,000</td><td>Flat array of (Entity, Vec2, radius), still linear.</td><td style={{ color: 'var(--fg-dim)' }}>Cache-friendly, single microsecond per query.</td></tr>
|
||||
<tr><td>{'> 10,000'}</td><td>Add uniform grid or quadtree (≈50 LOC).</td><td style={{ color: 'var(--fg-dim)' }}>Out of scope for FTL-style combat density.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="mb-5 rounded-lg px-5 py-4 text-[0.85rem] leading-[1.5] border border-[rgba(240,160,48,0.25)] bg-[rgba(240,160,48,0.08)] text-accent" style={{ marginBottom: 'var(--sp-5)' }}>
|
||||
<strong>Escape hatch — when to reconsider:</strong> If destructible ship chunks that tumble, rotate, and stack
|
||||
become a core visual, Rapier can be introduced <em>only for debris entities</em> while ships and projectiles
|
||||
stay kinematic. This is additive: the custom movement/collision code does not need to be rewritten. The three
|
||||
things a real solver would buy — stacking, joints, continuous CCD — are explicitly out of scope for arcade
|
||||
space combat.
|
||||
</div>
|
||||
|
||||
<div className="mb-5 rounded-lg px-5 py-4 text-[0.85rem] leading-[1.5] border border-cyan/20 bg-cyan/8 text-cyan" style={{ marginBottom: 'var(--sp-6)' }}>
|
||||
<strong>Network determinism note:</strong> Because movement and collision are pure functions of position,
|
||||
velocity, and dt, every client given the same inputs produces the same outputs. This makes client-side
|
||||
prediction and rollback against SpacetimeDB straightforward — a physics engine’s solver would make this
|
||||
property very difficult to guarantee.
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,10 +7,10 @@ export function BackendPage() {
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-content">
|
||||
<h1 style={{ marginBottom: '8px' }}>SpacetimeDB Backend Model</h1>
|
||||
<h1 style={{ marginBottom: '8px' }}>Game State & Data Model</h1>
|
||||
<p style={{ color: 'var(--fg-dim)', fontSize: '0.95rem', maxWidth: '680px' }}>
|
||||
The backend holds persistent, authoritative state and exposes server-side reducers for game
|
||||
actions. Clients subscribe to the rows they need and update reactively.
|
||||
The game client maintains persistent, authoritative state locally. Data structures define
|
||||
all game entities and their relationships. State updates are processed immediately and saved to local storage.
|
||||
</p>
|
||||
|
||||
<div style={{ display: 'flex', gap: 'var(--sp-2)', marginBottom: 'var(--sp-6)' }}>
|
||||
|
||||
958
apps/docs/src/pages/docs/KanbanBoardPage.tsx
Normal file
958
apps/docs/src/pages/docs/KanbanBoardPage.tsx
Normal file
@@ -0,0 +1,958 @@
|
||||
// @ts-nocheck
|
||||
import * as React from 'react';
|
||||
|
||||
const LAST_UPDATED = '2025-06-16';
|
||||
const STORAGE_KEY = 'void-nav-kanban-data';
|
||||
|
||||
type Column = 'done' | 'in-progress' | 'todo' | 'backlog';
|
||||
|
||||
interface FeatureCard {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
details: string;
|
||||
category: string;
|
||||
files: string;
|
||||
notes?: string;
|
||||
comments?: Comment[];
|
||||
tags?: string[];
|
||||
status?: string;
|
||||
}
|
||||
|
||||
interface Comment {
|
||||
id: string;
|
||||
text: string;
|
||||
timestamp: number;
|
||||
author: string;
|
||||
}
|
||||
|
||||
interface ColumnData {
|
||||
title: string;
|
||||
color: string;
|
||||
bgColor: string;
|
||||
items: FeatureCard[];
|
||||
}
|
||||
|
||||
// Initial static data
|
||||
const INITIAL_COLUMN_DATA: Record<Column, ColumnData> = {
|
||||
done: {
|
||||
title: 'DONE',
|
||||
color: 'var(--green)',
|
||||
bgColor: 'rgba(34,197,94,0.05)',
|
||||
items: [
|
||||
{
|
||||
id: 'G1',
|
||||
title: 'Galaxy Generation & Viewer',
|
||||
description: 'Complete procedural spiral galaxy with core cluster, disk layers, and beam structures. Interactive 3D viewer with star selection, parameter controls, and system connections.',
|
||||
details: 'Fully functional: generates star systems with faction distribution, nearest-neighbor connections, and POI contents. Control panel allows real-time parameter adjustment.',
|
||||
category: 'Onboarding',
|
||||
files: 'apps/game/src/gameplay/galaxy/mod.rs, apps/game/src/gameplay/galaxy/contents.rs',
|
||||
},
|
||||
{
|
||||
id: 'G2',
|
||||
title: 'Character Creation',
|
||||
description: 'Bannerlord-style character generation with name selection, 4 origin archetypes, 3 ship choices, and 5-step backstory questionnaire with stat bonuses.',
|
||||
details: 'Fully functional: complete UI with scrollable panels, stat calculation display, and character draft persistence for campaign.',
|
||||
category: 'Onboarding',
|
||||
files: 'apps/game/src/gameplay/character_creation/mod.rs',
|
||||
},
|
||||
{
|
||||
id: 'G3',
|
||||
title: 'Starting Base Selection',
|
||||
description: 'System selection from outer-galaxy candidates with 3D visualization, POI display, and interactive camera controls.',
|
||||
details: 'Fully functional: shows candidate systems from generated galaxy, with camera focus on selection and system contents preview.',
|
||||
category: 'Onboarding',
|
||||
files: 'apps/game/src/gameplay/starting_base/mod.rs',
|
||||
},
|
||||
{
|
||||
id: 'S1',
|
||||
title: 'Game State Management',
|
||||
description: 'AppState with clean transitions: MainMenu, Galaxy, CharacterCreation, StartingBaseSelection, InGame, Options.',
|
||||
details: 'Fully functional: state system works properly with OnEnter/OnExit/Update schedules for each game phase.',
|
||||
category: 'Core',
|
||||
files: 'apps/game/src/state.rs',
|
||||
},
|
||||
{
|
||||
id: 'S2',
|
||||
title: 'Campaign Persistence',
|
||||
description: 'Galaxy draft, character draft, and starting base selection persisted through game flow.',
|
||||
details: 'Fully functional: Resources carry data through state transitions. New game properly clears previous state.',
|
||||
category: 'Core',
|
||||
files: 'apps/game/src/gameplay/campaign.rs',
|
||||
},
|
||||
{
|
||||
id: 'C1',
|
||||
title: 'Camera System',
|
||||
description: 'Three camera modes: Orbit (for galaxy inspection), Follow (for flight), Cinematic (for docked views).',
|
||||
details: 'Fully functional: orbit camera with mouse controls, camera transitions between modes, proper target following.',
|
||||
category: 'Core',
|
||||
files: 'apps/game/src/camera.rs',
|
||||
},
|
||||
{
|
||||
id: 'P1',
|
||||
title: 'POI Data Structures',
|
||||
description: 'Complete POI type system: 7 planet types, asteroid belts, stations, stargates, anomalies, gas clouds.',
|
||||
details: 'Fully functional: POI generation with orbital mechanics, faction-based biasing, and system context awareness.',
|
||||
category: 'Content',
|
||||
files: 'apps/game/src/gameplay/galaxy/poi.rs, apps/game/src/gameplay/galaxy/contents.rs',
|
||||
},
|
||||
],
|
||||
},
|
||||
'in-progress': {
|
||||
title: 'IN PROGRESS',
|
||||
color: 'var(--accent)',
|
||||
bgColor: 'rgba(240,160,48,0.05)',
|
||||
items: [
|
||||
{
|
||||
id: 'I1',
|
||||
title: 'In-System Scene Framework',
|
||||
description: 'Scene setup with star, POIs, and player ship. Docked/Flight state management exists.',
|
||||
details: 'Framework exists: scene spawns correctly, state transitions work. But UI is commented out and most gameplay features are stubs.',
|
||||
category: 'In-System',
|
||||
files: 'apps/game/src/gameplay/in_system/mod.rs, apps/game/src/gameplay/in_system/scene.rs',
|
||||
notes: 'UI systems removed/commented. No flight HUD visible to player.',
|
||||
},
|
||||
{
|
||||
id: 'I2',
|
||||
title: 'Target Selection',
|
||||
description: 'Click-to-select POIs (stations, asteroid belts) with selection tracking.',
|
||||
details: 'Framework exists: target selection works, but no gameplay uses the target yet. No distance display or spatial grouping.',
|
||||
category: 'In-System',
|
||||
files: 'apps/game/src/gameplay/in_system/target.rs',
|
||||
notes: 'Selection system works but no gameplay features depend on it yet.',
|
||||
},
|
||||
{
|
||||
id: 'I3',
|
||||
title: 'Operations Framework',
|
||||
description: 'Timed operations system with undock (3s), travel (5s). Framework for docking (4s) and mining (8s).',
|
||||
details: 'Partial: undock and travel work. Docking and mining are skeleton operations marked TODO. No actual gameplay consequences.',
|
||||
category: 'In-System',
|
||||
files: 'apps/game/src/gameplay/in_system/operations.rs, apps/game/src/gameplay/in_system/flight.rs',
|
||||
notes: 'Only undock and approach are implemented. Docking and mining are TODO.',
|
||||
},
|
||||
{
|
||||
id: 'M1',
|
||||
title: 'Basic Movement',
|
||||
description: 'Click-to-move navigation with kinematic steering and velocity integration.',
|
||||
details: 'Partial: ship moves to target position, but no orbital mechanics, no physics-based movement, no collision avoidance.',
|
||||
category: 'In-System',
|
||||
files: 'apps/game/src/gameplay/movement/mod.rs',
|
||||
notes: 'Basic steering works. No actual gameplay uses movement yet.',
|
||||
},
|
||||
{
|
||||
id: 'U1',
|
||||
title: 'Basic UI Shell',
|
||||
description: 'Main menu, galaxy control panel, info panels. Foundation for UI exists.',
|
||||
details: 'Partial: main menu and galaxy UI work. In-game flight UI is commented out. No station mode panel swap.',
|
||||
category: 'UI',
|
||||
files: 'apps/game/src/ui/',
|
||||
notes: 'Flight HUD and station UI are removed/commented in code.',
|
||||
},
|
||||
],
|
||||
},
|
||||
todo: {
|
||||
title: 'TODO',
|
||||
color: 'var(--red)',
|
||||
bgColor: 'rgba(239,68,68,0.05)',
|
||||
items: [
|
||||
{
|
||||
id: 'I4',
|
||||
title: 'Complete In-System Gameplay',
|
||||
description: 'Docking operation, mining operation with output, cargo collection, station interactions.',
|
||||
details: 'Missing: docking only undocks, mining has no resource output. No station services, no refueling, no repairs.',
|
||||
category: 'In-System',
|
||||
files: 'apps/game/src/gameplay/in_system/operations.rs',
|
||||
notes: 'Docks and mines marked as TODO in operations.rs.',
|
||||
},
|
||||
{
|
||||
id: 'U2',
|
||||
title: 'Flight HUD / In-Game UI',
|
||||
description: 'Target reticle, ship status display, operation progress bars, contextual action buttons.',
|
||||
details: 'Missing: all in-game UI is commented out. Player has no visual feedback during flight.',
|
||||
category: 'UI',
|
||||
files: 'apps/game/src/gameplay/in_system/',
|
||||
notes: 'UI setup and update systems are commented out in mod.rs.',
|
||||
},
|
||||
{
|
||||
id: 'M2',
|
||||
title: 'Star Map Plugin',
|
||||
description: 'Galaxy-scale navigation showing all systems, hyperspace lanes, and current position.',
|
||||
details: 'Missing: plugin structure exists but implementation is empty. No camera, no rendering, no interaction.',
|
||||
category: 'Navigation',
|
||||
files: 'apps/game/src/gameplay/star_map/mod.rs',
|
||||
notes: 'File contains only TODO comments and empty plugin struct.',
|
||||
},
|
||||
{
|
||||
id: 'P2',
|
||||
title: 'Physics & Collision',
|
||||
description: 'Mass-based interactions, collision detection between ships and objects, spatial queries.',
|
||||
details: 'Missing: only basic geometry primitives exist. No collision system, no physics simulation.',
|
||||
category: 'Core',
|
||||
files: 'apps/game/src/gameplay/physics/mod.rs',
|
||||
notes: 'Module exports marker components only. No actual physics systems.',
|
||||
},
|
||||
{
|
||||
id: 'R1',
|
||||
title: 'Resource System',
|
||||
description: 'Ore types, cargo inventory, resource collection during mining.',
|
||||
details: 'Missing: no resource types defined, no inventory system, no cargo hold.',
|
||||
category: 'Economy',
|
||||
files: 'N/A',
|
||||
notes: 'No resource or inventory modules exist in codebase.',
|
||||
},
|
||||
{
|
||||
id: 'E1',
|
||||
title: 'Economy System',
|
||||
description: 'Credits, market prices, trading, station markets.',
|
||||
details: 'Missing: no economy simulation, no pricing, no trade mechanics.',
|
||||
category: 'Economy',
|
||||
files: 'N/A',
|
||||
notes: 'No economy modules exist in codebase.',
|
||||
},
|
||||
{
|
||||
id: 'C2',
|
||||
title: 'Ship Components',
|
||||
description: 'Shields, armor, hull, capacitor, power grid, CPU.',
|
||||
details: 'Missing: ships have no stats beyond position. No component systems.',
|
||||
category: 'Ships',
|
||||
files: 'N/A',
|
||||
notes: 'Ship is just a mesh with position. No stats or systems.',
|
||||
},
|
||||
],
|
||||
},
|
||||
backlog: {
|
||||
title: 'BACKLOG',
|
||||
color: 'var(--muted)',
|
||||
bgColor: 'var(--surface-raised)',
|
||||
items: [
|
||||
{
|
||||
id: 'C3',
|
||||
title: 'Combat System',
|
||||
description: 'Weapons, damage resolution, shield/armor/hull pools, combat AI, target locking.',
|
||||
details: 'Not started: no weapon systems, no damage calculations, no combat state. FTL-style auto-combat planned.',
|
||||
category: 'Combat',
|
||||
files: 'N/A',
|
||||
notes: 'No combat modules exist. Design specifies FTL-style power allocation combat.',
|
||||
},
|
||||
{
|
||||
id: 'S3',
|
||||
title: 'Multiple Ship Types',
|
||||
description: 'Frigate, hauler, scout, warship, battleship classes with different stats.',
|
||||
details: 'Not started: only generic starter ship mesh exists. No ship catalog or stat variations.',
|
||||
category: 'Ships',
|
||||
files: 'N/A',
|
||||
notes: 'Single cone+box+sphere ship. No classes or variations.',
|
||||
},
|
||||
{
|
||||
id: 'F1',
|
||||
title: 'Module Fitting',
|
||||
description: 'High/Med/Low slots, CPU/PG constraints, module catalog, fitting screen.',
|
||||
details: 'Not started: no fitting system. Design specifies full fitting with drag-and-drop.',
|
||||
category: 'Ships',
|
||||
files: 'N/A',
|
||||
notes: 'Reference: apps/docs/src/prototypes/existing-demos/FittingDemo.tsx',
|
||||
},
|
||||
{
|
||||
id: 'N1',
|
||||
title: 'NPC Ships & AI',
|
||||
description: 'NPC spawning, behavior state machines, ambient traffic, hostile pirates.',
|
||||
details: 'Not started: no NPC entities, no AI systems. Design describes full NPC AI.',
|
||||
category: 'NPCs',
|
||||
files: 'N/A',
|
||||
notes: 'No NPC modules exist in codebase.',
|
||||
},
|
||||
{
|
||||
id: 'Q1',
|
||||
title: 'Quest & Mission System',
|
||||
description: 'NPC agents, mission templates (kill, courier, mining), active mission tracking.',
|
||||
details: 'Not started: no quest system. Design specifies 6 mission types with rewards.',
|
||||
category: 'Content',
|
||||
files: 'N/A',
|
||||
notes: 'Reference: design docs describe faction quests and mission templates.',
|
||||
},
|
||||
{
|
||||
id: 'A1',
|
||||
title: 'Faction System',
|
||||
description: 'Faction standing, loyalty points, reputation effects, faction-controlled space.',
|
||||
details: 'Not started: factions are colors only. No standing or reputation tracking.',
|
||||
category: 'Social',
|
||||
files: 'N/A',
|
||||
notes: 'Factions exist as labels only. No gameplay effects.',
|
||||
},
|
||||
{
|
||||
id: 'W1',
|
||||
title: 'Dynamic World Events',
|
||||
description: 'Random events, anomalies, faction conflicts, world state changes.',
|
||||
details: 'Not started: no event system. Design describes 6 event categories.',
|
||||
category: 'Content',
|
||||
files: 'N/A',
|
||||
notes: 'No world event modules exist.',
|
||||
},
|
||||
{
|
||||
id: 'PR1',
|
||||
title: 'Skill & Progression',
|
||||
description: 'XP awards, skill catalog (Mining, Industry, Trade, Gunnery, Navigation), level 0-5.',
|
||||
details: 'Not started: no XP or skills. Design specifies 5 skills with tier bonuses.',
|
||||
category: 'Progression',
|
||||
files: 'N/A',
|
||||
notes: 'Reference: apps/docs/src/prototypes/existing-demos/ProgressionDemo.tsx',
|
||||
},
|
||||
{
|
||||
id: 'MF1',
|
||||
title: 'Manufacturing',
|
||||
description: 'Blueprints, production jobs, material requirements, output queues.',
|
||||
details: 'Not started: no manufacturing. Design: Ore→Mineral→Component→Module→Ship chain.',
|
||||
category: 'Industry',
|
||||
files: 'N/A',
|
||||
notes: 'Reference: apps/docs/src/prototypes/existing-demos/RefiningDemo.tsx',
|
||||
},
|
||||
{
|
||||
id: 'Z1',
|
||||
title: 'Zora Ship AI',
|
||||
description: 'AI companion with personality, soul depth, tier progression (0-5).',
|
||||
details: 'Not started: no Zora implementation. Design specifies full AI companion system.',
|
||||
category: 'Ships',
|
||||
files: 'N/A',
|
||||
notes: 'Reference: apps/docs/src/prototypes/existing-demos/ZoraDemo.tsx',
|
||||
},
|
||||
{
|
||||
id: 'ST1.1',
|
||||
title: 'Event Logging System',
|
||||
description: 'Track all player actions (mining, trading, combat, exploration) as structured events.',
|
||||
details: 'Missing: no event logging exists. Need event types, event bus, persistence.',
|
||||
category: 'Narrative',
|
||||
files: 'apps/game/src/gameplay/narrative/events.rs',
|
||||
notes: 'Foundation for narrative synthesis. Must capture all player actions.',
|
||||
},
|
||||
{
|
||||
id: 'ST1.2',
|
||||
title: 'Narrative Synthesis Engine',
|
||||
description: 'LLM-based system that weaves events into coherent narrative text.',
|
||||
details: 'Missing: no narrative generation. Need event aggregation, story templates, LLM integration.',
|
||||
category: 'Narrative',
|
||||
files: 'apps/game/src/gameplay/narrative/synthesis.rs',
|
||||
notes: 'Core differentiator. Use Claude API for story generation.',
|
||||
},
|
||||
{
|
||||
id: 'ST1.3',
|
||||
title: 'Campaign History Tracking',
|
||||
description: 'Persistent story log with chapter divisions and key moments.',
|
||||
details: 'Missing: no campaign history. Need timeline, chapter detection, milestone tracking.',
|
||||
category: 'Narrative',
|
||||
files: 'apps/game/src/gameplay/narrative/history.rs',
|
||||
notes: 'Player should access full campaign story at any time.',
|
||||
},
|
||||
{
|
||||
id: 'ST1.4',
|
||||
title: 'Story Log UI',
|
||||
description: 'In-game interface for reading campaign narrative with chapter navigation.',
|
||||
details: 'Missing: no UI for narrative. Need story viewer, chapter list, search.',
|
||||
category: 'Narrative',
|
||||
files: 'apps/game/src/gameplay/narrative/ui.rs',
|
||||
notes: 'Integrates with existing UI system. Should support export/save.',
|
||||
},
|
||||
{
|
||||
id: 'T1',
|
||||
title: 'Tutorial & Onboarding',
|
||||
description: 'Guided mission sequence, skip option, tutorial hints, objective tracking.',
|
||||
details: 'Not started: no tutorial system. New players have no guidance.',
|
||||
category: 'UX',
|
||||
files: 'N/A',
|
||||
notes: 'Design specifies 7-objective tutorial chain.',
|
||||
},
|
||||
{
|
||||
id: 'MP1',
|
||||
title: 'Multiplayer / Backend',
|
||||
description: 'Server auth, multiplayer sessions, cloud persistence.',
|
||||
details: 'Removed: multiplayer services deleted. Game is now single-player only with local persistence.',
|
||||
category: 'Infrastructure',
|
||||
files: 'Removed (services/spacetimedb, services/auth)',
|
||||
notes: 'Multiplayer infrastructure has been removed to focus on single-player experience.',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const CATEGORIES = ['Onboarding', 'Core', 'Content', 'In-System', 'UI', 'Navigation', 'Economy', 'Ships', 'Combat', 'NPCs', 'Social', 'Progression', 'Industry', 'Narrative', 'UX', 'Infrastructure'];
|
||||
|
||||
export function KanbanBoardPage() {
|
||||
// Load saved data from localStorage or use initial data
|
||||
const [columnData, setColumnData] = React.useState<Record<Column, ColumnData>>(() => {
|
||||
const saved = localStorage.getItem(STORAGE_KEY);
|
||||
if (saved) {
|
||||
try {
|
||||
return JSON.parse(saved);
|
||||
} catch (e) {
|
||||
console.error('Failed to parse saved kanban data:', e);
|
||||
return INITIAL_COLUMN_DATA;
|
||||
}
|
||||
}
|
||||
return INITIAL_COLUMN_DATA;
|
||||
});
|
||||
|
||||
// Save to localStorage whenever columnData changes
|
||||
React.useEffect(() => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(columnData));
|
||||
}, [columnData]);
|
||||
|
||||
// Expanded card state
|
||||
const [expandedCard, setExpandedCard] = React.useState<string | null>(null);
|
||||
|
||||
// Comment input state
|
||||
const [commentInputs, setCommentInputs] = React.useState<Record<string, string>>({});
|
||||
|
||||
// New tag input state
|
||||
const [tagInputs, setTagInputs] = React.useState<Record<string, string>>({});
|
||||
|
||||
const counts = React.useMemo(() => {
|
||||
return {
|
||||
done: columnData.done.items.length,
|
||||
'in-progress': columnData['in-progress'].items.length,
|
||||
todo: columnData.todo.items.length,
|
||||
backlog: columnData.backlog.items.length,
|
||||
total: Object.values(columnData).reduce((sum, col) => sum + col.items.length, 0),
|
||||
};
|
||||
}, [columnData]);
|
||||
|
||||
const completionRate = React.useMemo(() => {
|
||||
const weighted = counts.done + (counts['in-progress'] * 0.3);
|
||||
return Math.round((weighted / counts.total) * 100);
|
||||
}, [counts]);
|
||||
|
||||
// Move card to different column
|
||||
const moveCard = (cardId: string, fromColumn: Column, toColumn: Column) => {
|
||||
setColumnData(prev => {
|
||||
const newPrev = { ...prev };
|
||||
const cardIndex = newPrev[fromColumn].items.findIndex(c => c.id === cardId);
|
||||
if (cardIndex === -1) return prev;
|
||||
|
||||
const [card] = newPrev[fromColumn].items.splice(cardIndex, 1);
|
||||
newPrev[toColumn].items.push(card);
|
||||
return newPrev;
|
||||
});
|
||||
};
|
||||
|
||||
// Add comment to card
|
||||
const addComment = (cardId: string, text: string) => {
|
||||
if (!text.trim()) return;
|
||||
|
||||
setColumnData(prev => {
|
||||
const newPrev = { ...prev };
|
||||
for (const col of Object.keys(newPrev) as Column[]) {
|
||||
const card = newPrev[col].items.find(c => c.id === cardId);
|
||||
if (card) {
|
||||
if (!card.comments) card.comments = [];
|
||||
card.comments.push({
|
||||
id: `comment-${Date.now()}`,
|
||||
text: text.trim(),
|
||||
timestamp: Date.now(),
|
||||
author: 'User',
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
return newPrev;
|
||||
});
|
||||
|
||||
setCommentInputs(prev => ({ ...prev, [cardId]: '' }));
|
||||
};
|
||||
|
||||
// Add tag to card
|
||||
const addTag = (cardId: string, tag: string) => {
|
||||
if (!tag.trim()) return;
|
||||
|
||||
setColumnData(prev => {
|
||||
const newPrev = { ...prev };
|
||||
for (const col of Object.keys(newPrev) as Column[]) {
|
||||
const card = newPrev[col].items.find(c => c.id === cardId);
|
||||
if (card) {
|
||||
if (!card.tags) card.tags = [];
|
||||
if (!card.tags.includes(tag.trim())) {
|
||||
card.tags.push(tag.trim());
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
return newPrev;
|
||||
});
|
||||
|
||||
setTagInputs(prev => ({ ...prev, [cardId]: '' }));
|
||||
};
|
||||
|
||||
// Remove tag from card
|
||||
const removeTag = (cardId: string, tagToRemove: string) => {
|
||||
setColumnData(prev => {
|
||||
const newPrev = { ...prev };
|
||||
for (const col of Object.keys(newPrev) as Column[]) {
|
||||
const card = newPrev[col].items.find(c => c.id === cardId);
|
||||
if (card && card.tags) {
|
||||
card.tags = card.tags.filter(t => t !== tagToRemove);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return newPrev;
|
||||
});
|
||||
};
|
||||
|
||||
// Reset to initial data
|
||||
const resetData = () => {
|
||||
if (confirm('Reset all kanban data to defaults? This will delete all comments and tags.')) {
|
||||
setColumnData(INITIAL_COLUMN_DATA);
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
};
|
||||
|
||||
// Export data
|
||||
const exportData = () => {
|
||||
const dataStr = JSON.stringify(columnData, null, 2);
|
||||
const blob = new Blob([dataStr], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `kanban-export-${new Date().toISOString().split('T')[0]}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-content">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '8px' }}>
|
||||
<h1 style={{ marginBottom: '0' }}>Implementation Board</h1>
|
||||
<div style={{ display: 'flex', gap: 'var(--sp-2)' }}>
|
||||
<button
|
||||
onClick={exportData}
|
||||
style={{
|
||||
padding: '4px 12px',
|
||||
fontSize: '0.8rem',
|
||||
background: 'var(--surface)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Export Data
|
||||
</button>
|
||||
<button
|
||||
onClick={resetData}
|
||||
style={{
|
||||
padding: '4px 12px',
|
||||
fontSize: '0.8rem',
|
||||
background: 'var(--surface)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Reset Data
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p style={{ color: 'var(--fg-dim)', fontSize: '0.95rem', maxWidth: '720px' }}>
|
||||
Interactive kanban board. Updated on {LAST_UPDATED}.
|
||||
Click cards to expand for comments and tags. Drag cards or use dropdowns to change status.
|
||||
Data persists in localStorage.
|
||||
</p>
|
||||
|
||||
{/* Stats overview */}
|
||||
<div className="mb-6 grid grid-cols-[repeat(auto-fit,minmax(140px,1fr))] gap-4" style={{ marginTop: 'var(--sp-5)' }}>
|
||||
<div className="rounded-xl border border-border bg-surface p-4 max-md:rounded-lg max-md:p-3" style={{ borderTop: '3px solid var(--green)' }}>
|
||||
<div className="font-mono text-[1.4rem] font-bold tabular-nums" style={{ color: 'var(--green)' }}>{counts.done}</div>
|
||||
<div className="mt-1 text-[0.7rem] uppercase tracking-[0.05em] text-muted">Done</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border bg-surface p-4 max-md:rounded-lg max-md:p-3" style={{ borderTop: '3px solid var(--accent)' }}>
|
||||
<div className="font-mono text-[1.4rem] font-bold tabular-nums" style={{ color: 'var(--accent)' }}>{counts['in-progress']}</div>
|
||||
<div className="mt-1 text-[0.7rem] uppercase tracking-[0.05em] text-muted">In Progress</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border bg-surface p-4 max-md:rounded-lg max-md:p-3" style={{ borderTop: '3px solid var(--red)' }}>
|
||||
<div className="font-mono text-[1.4rem] font-bold tabular-nums" style={{ color: 'var(--red)' }}>{counts.todo}</div>
|
||||
<div className="mt-1 text-[0.7rem] uppercase tracking-[0.05em] text-muted">Todo</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border bg-surface p-4 max-md:rounded-lg max-md:p-3" style={{ borderTop: '3px solid var(--muted)' }}>
|
||||
<div className="font-mono text-[1.4rem] font-bold tabular-nums" style={{ color: 'var(--muted)' }}>{counts.backlog}</div>
|
||||
<div className="mt-1 text-[0.7rem] uppercase tracking-[0.05em] text-muted">Backlog</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Overall progress */}
|
||||
<div className="mb-6 rounded-lg border border-border bg-surface p-4 max-md:rounded-lg max-md:p-3">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--sp-3)', marginBottom: 'var(--sp-2)' }}>
|
||||
<span style={{ fontSize: '0.85rem', color: 'var(--fg)', fontWeight: 500 }}>Overall Implementation Progress</span>
|
||||
<span style={{ marginLeft: 'auto', fontFamily: 'var(--font-mono)', fontSize: '0.85rem', color: 'var(--fg-dim)' }}>
|
||||
{completionRate}%
|
||||
</span>
|
||||
</div>
|
||||
<div style={{
|
||||
width: '100%',
|
||||
height: '8px',
|
||||
borderRadius: '4px',
|
||||
background: 'var(--surface-raised)',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<div style={{
|
||||
width: `${completionRate}%`,
|
||||
height: '100%',
|
||||
borderRadius: '4px',
|
||||
background: completionRate > 50 ? 'var(--green)' : completionRate > 25 ? 'var(--accent)' : 'var(--red)',
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Critical insight banner */}
|
||||
<div className="mb-5 rounded-lg px-5 py-4 text-[0.85rem] leading-[1.5] border border-amber/30 bg-amber/10 text-amber" style={{ maxWidth: '720px' }}>
|
||||
<strong>Current State:</strong> The game has a complete <em>onboarding experience</em> (galaxy → character → base) but lacks a playable game loop.
|
||||
The core systems (mining, trading, combat, economy) are frameworks without gameplay. Most in-System UI is commented out.
|
||||
This reflects a focus on foundational architecture before gameplay implementation.
|
||||
</div>
|
||||
|
||||
{/* Kanban board */}
|
||||
<div style={{ marginTop: 'var(--sp-6)' }}>
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(4, minmax(280px, 1fr))',
|
||||
gap: 'var(--sp-4)',
|
||||
overflowX: 'auto',
|
||||
paddingBottom: 'var(--sp-4)',
|
||||
}}>
|
||||
{(Object.keys(columnData) as Column[]).map((columnKey) => {
|
||||
const column = columnData[columnKey];
|
||||
return (
|
||||
<div
|
||||
key={columnKey}
|
||||
style={{
|
||||
background: column.bgColor,
|
||||
borderRadius: 'var(--radius-md)',
|
||||
padding: 'var(--sp-3)',
|
||||
border: '1px solid var(--border)',
|
||||
minHeight: '400px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
{/* Column header */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 'var(--sp-2)',
|
||||
marginBottom: 'var(--sp-3)',
|
||||
paddingBottom: 'var(--sp-2)',
|
||||
borderBottom: '2px solid var(--border)',
|
||||
}}>
|
||||
<div style={{
|
||||
width: '10px',
|
||||
height: '10px',
|
||||
borderRadius: '50%',
|
||||
background: column.color,
|
||||
boxShadow: `0 0 8px ${column.color}66`,
|
||||
}} />
|
||||
<h3 style={{ margin: 0, fontSize: '0.9rem', fontWeight: 600, color: 'var(--fg)' }}>
|
||||
{column.title}
|
||||
</h3>
|
||||
<span style={{
|
||||
marginLeft: 'auto',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: '0.75rem',
|
||||
color: 'var(--muted)',
|
||||
background: 'var(--surface)',
|
||||
padding: '2px 8px',
|
||||
borderRadius: 'var(--radius-pill)',
|
||||
}}>
|
||||
{column.items.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Cards */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--sp-2)', flex: 1 }}>
|
||||
{column.items.map((item) => {
|
||||
const isExpanded = expandedCard === item.id;
|
||||
const hasComments = item.comments && item.comments.length > 0;
|
||||
const hasTags = item.tags && item.tags.length > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
style={{
|
||||
background: 'var(--surface)',
|
||||
border: `1px solid ${isExpanded ? 'var(--accent)' : 'var(--border)'}`,
|
||||
borderRadius: 'var(--radius-md)',
|
||||
padding: 'var(--sp-3)',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s ease',
|
||||
boxShadow: isExpanded ? '0 2px 8px rgba(0,0,0,0.1)' : 'none',
|
||||
}}
|
||||
onClick={() => setExpandedCard(isExpanded ? null : item.id)}
|
||||
className="hover:shadow-sm"
|
||||
>
|
||||
{/* Card header */}
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 'var(--sp-2)', marginBottom: 'var(--sp-2)' }}>
|
||||
<span style={{
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: '0.65rem',
|
||||
color: 'var(--muted)',
|
||||
background: 'var(--surface-raised)',
|
||||
padding: '2px 6px',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
}}>
|
||||
{item.id}
|
||||
</span>
|
||||
<span style={{
|
||||
fontSize: '0.7rem',
|
||||
color: 'var(--fg-dim)',
|
||||
background: 'rgba(100,100,100,0.1)',
|
||||
padding: '2px 6px',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
}}>
|
||||
{item.category}
|
||||
</span>
|
||||
<span style={{ marginLeft: 'auto', fontSize: '0.7rem' }}>
|
||||
{hasComments && `💬 ${item.comments!.length} `}
|
||||
{hasTags && `🏷️ ${item.tags!.length}`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Card title */}
|
||||
<h4 style={{ margin: '0 0 var(--sp-1) 0', fontSize: '0.9rem', fontWeight: 500, color: 'var(--fg)' }}>
|
||||
{item.title}
|
||||
</h4>
|
||||
|
||||
{/* Card description */}
|
||||
<p style={{ margin: '0 0 var(--sp-1) 0', fontSize: '0.8rem', color: 'var(--fg-dim)', lineHeight: '1.4' }}>
|
||||
{item.description}
|
||||
</p>
|
||||
|
||||
{/* Card details */}
|
||||
<p style={{ margin: '0 0 var(--sp-2) 0', fontSize: '0.75rem', color: 'var(--fg-dim)', lineHeight: '1.4', fontStyle: 'italic' }}>
|
||||
{item.details}
|
||||
</p>
|
||||
|
||||
{/* Card footer */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--sp-1)' }}>
|
||||
<div style={{ fontSize: '0.65rem', color: 'var(--muted)', fontFamily: 'var(--font-mono)' }}>
|
||||
📁 {item.files}
|
||||
</div>
|
||||
{item.notes && (
|
||||
<div style={{
|
||||
fontSize: '0.7rem',
|
||||
color: 'var(--accent)',
|
||||
padding: '4px 8px',
|
||||
background: 'rgba(240,160,48,0.08)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
borderLeft: '2px solid var(--accent)',
|
||||
}}>
|
||||
💡 {item.notes}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tags display */}
|
||||
{hasTags && (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '4px', marginTop: '4px' }}>
|
||||
{item.tags!.map(tag => (
|
||||
<span
|
||||
key={tag}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeTag(item.id, tag);
|
||||
}}
|
||||
style={{
|
||||
fontSize: '0.65rem',
|
||||
padding: '2px 6px',
|
||||
background: 'rgba(100,150,255,0.1)',
|
||||
border: '1px solid rgba(100,150,255,0.3)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
title="Click to remove tag"
|
||||
>
|
||||
🏷️ {tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Expanded section */}
|
||||
{isExpanded && (
|
||||
<div style={{ marginTop: 'var(--sp-3)', paddingTop: 'var(--sp-3)', borderTop: '1px solid var(--border)' }}>
|
||||
{/* Status change dropdown */}
|
||||
<div style={{ marginBottom: 'var(--sp-3)' }}>
|
||||
<label style={{ fontSize: '0.75rem', color: 'var(--fg-dim)', marginRight: '8px' }}>Move to:</label>
|
||||
<select
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onChange={(e) => {
|
||||
const newColumn = e.target.value as Column;
|
||||
if (newColumn !== columnKey) {
|
||||
moveCard(item.id, columnKey, newColumn);
|
||||
}
|
||||
}}
|
||||
value={columnKey}
|
||||
style={{
|
||||
padding: '4px 8px',
|
||||
fontSize: '0.8rem',
|
||||
background: 'var(--surface)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
color: 'var(--fg)',
|
||||
}}
|
||||
>
|
||||
<option value="done">Done</option>
|
||||
<option value="in-progress">In Progress</option>
|
||||
<option value="todo">Todo</option>
|
||||
<option value="backlog">Backlog</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Add tag */}
|
||||
<div style={{ marginBottom: 'var(--sp-3)', display: 'flex', gap: '8px' }}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Add tag..."
|
||||
value={tagInputs[item.id] || ''}
|
||||
onChange={(e) => setTagInputs(prev => ({ ...prev, [item.id]: e.target.value }))}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.stopPropagation();
|
||||
addTag(item.id, tagInputs[item.id] || '');
|
||||
}
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '4px 8px',
|
||||
fontSize: '0.8rem',
|
||||
background: 'var(--surface)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
color: 'var(--fg)',
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
addTag(item.id, tagInputs[item.id] || '');
|
||||
}}
|
||||
style={{
|
||||
padding: '4px 12px',
|
||||
fontSize: '0.8rem',
|
||||
background: 'var(--surface)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Add Tag
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Comments section */}
|
||||
<div>
|
||||
<h5 style={{ margin: '0 0 var(--sp-2) 0', fontSize: '0.85rem', color: 'var(--fg)' }}>Comments</h5>
|
||||
|
||||
{/* Existing comments */}
|
||||
{hasComments ? (
|
||||
<div style={{ marginBottom: 'var(--sp-2)', maxHeight: '200px', overflowY: 'auto' }}>
|
||||
{item.comments!.map(comment => (
|
||||
<div
|
||||
key={comment.id}
|
||||
style={{
|
||||
padding: '8px',
|
||||
marginBottom: '8px',
|
||||
background: 'var(--surface-raised)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
fontSize: '0.8rem',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '4px' }}>
|
||||
<span style={{ fontWeight: 500, color: 'var(--fg)' }}>{comment.author}</span>
|
||||
<span style={{ fontSize: '0.7rem', color: 'var(--muted)' }}>
|
||||
{new Date(comment.timestamp).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ color: 'var(--fg-dim)', lineHeight: '1.4' }}>{comment.text}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p style={{ fontSize: '0.8rem', color: 'var(--muted)', fontStyle: 'italic' }}>No comments yet</p>
|
||||
)}
|
||||
|
||||
{/* Add comment */}
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Add a comment..."
|
||||
value={commentInputs[item.id] || ''}
|
||||
onChange={(e) => setCommentInputs(prev => ({ ...prev, [item.id]: e.target.value }))}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.stopPropagation();
|
||||
addComment(item.id, commentInputs[item.id] || '');
|
||||
}
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '8px',
|
||||
fontSize: '0.8rem',
|
||||
background: 'var(--surface)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
color: 'var(--fg)',
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
addComment(item.id, commentInputs[item.id] || '');
|
||||
}}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
fontSize: '0.8rem',
|
||||
background: 'var(--accent)',
|
||||
border: 'none',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
cursor: 'pointer',
|
||||
color: 'var(--surface)',
|
||||
}}
|
||||
>
|
||||
Post
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div style={{ marginTop: 'var(--sp-6)', padding: 'var(--sp-3)', background: 'var(--surface-raised)', borderRadius: 'var(--radius-md)', border: '1px solid var(--border)' }}>
|
||||
<h4 style={{ margin: '0 0 var(--sp-2) 0', fontSize: '0.85rem', color: 'var(--fg)' }}>Categories</h4>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 'var(--sp-2)', fontSize: '0.75rem', color: 'var(--fg-dim)' }}>
|
||||
{CATEGORIES.map(cat => (
|
||||
<span key={cat} style={{
|
||||
padding: '2px 8px',
|
||||
background: 'var(--surface)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
border: '1px solid var(--border)',
|
||||
}}>
|
||||
{cat}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
<div className="mb-5 rounded-lg px-5 py-4 text-[0.85rem] leading-[1.5] border border-cyan/20 bg-cyan/8 text-cyan" style={{ marginTop: 'var(--sp-6)', maxWidth: '720px' }}>
|
||||
<strong>Implementation note:</strong> This board reflects code analysis of <code>apps/game/</code> as of {LAST_UPDATED}.
|
||||
"Done" means fully functional. "In Progress" means framework exists but gameplay incomplete. "Todo" means next priority items.
|
||||
See <a href="/docs/roadmap" style={{ color: 'var(--cyan)', textDecoration: 'underline' }}>Roadmap</a> for planned phases and
|
||||
<a href="/docs/gameplay" style={{ color: 'var(--cyan)', textDecoration: 'underline' }}> Gameplay</a> for design details.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,36 +4,36 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
export function OverviewPage() {
|
||||
return (
|
||||
<div className="mx-auto max-w-content">
|
||||
<h1 style={{ marginBottom: '8px' }}>Single-Player Space Exploration RPG</h1>
|
||||
<h1 style={{ marginBottom: '8px' }}>Narrative-Driven Space Exploration Saga</h1>
|
||||
<p style={{ color: 'var(--fg-dim)', fontSize: '1.1rem', maxWidth: '680px', marginBottom: 'var(--sp-6)' }}>
|
||||
Trade, quest, fight, or simply drift and enjoy the ambiance. A charming <strong style={{ color: 'var(--fg-bright)' }}>single-player open-world space RPG</strong> inspired
|
||||
by the freedom and charm of Windward Horizon — set in a <strong style={{ color: 'var(--fg-bright)' }}>procedurally generated galaxy</strong> where
|
||||
every campaign is unique. Explore sectors, trade between stations, take on faction quests, mine asteroids,
|
||||
or simply cruise the void. Combat is <strong style={{ color: 'var(--fg-bright)' }}>FTL-style tactical resource management</strong> —
|
||||
no dogfighting. You are the captain who decides <em>what</em> to power and <em>when</em>, not the pilot who dodges.
|
||||
The architecture supports optional co-op servers in the future, but the core experience is your solo adventure.
|
||||
Every player becomes the author of their own space opera mini-series. A <strong style={{ color: 'var(--fg-bright)' }}>single-player narrative experience</strong>
|
||||
inspired by the open-world freedom of <strong style={{ color: 'var(--fg-bright)' }}>Windward</strong>, the strategic depth of <strong style={{ color: 'var(--fg-bright)' }}>Stellaris</strong>,
|
||||
and AI-generated storytelling reminiscent of <strong style={{ color: 'var(--fg-bright)' }}>The Templin Institute</strong>. Explore procedurally generated galaxies,
|
||||
engage in diplomacy and commerce, wage wars or broker peace — and watch as the AI Story Director weaves your choices
|
||||
into an evolving narrative. V1 focuses on emergent storytelling, with future updates generating episodic mini-series
|
||||
from your campaign history.
|
||||
</p>
|
||||
|
||||
<div className="mb-6 grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-4">
|
||||
<div className="rounded-xl border border-border bg-surface p-5 max-md:rounded-lg max-md:p-4">
|
||||
<div className="font-mono text-[1.6rem] font-bold tabular-nums text-fg-bright" style={{ color: 'var(--purple)' }}>Story</div>
|
||||
<div className="mt-1 text-[0.75rem] uppercase tracking-[0.05em] text-muted">AI Director</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border bg-surface p-5 max-md:rounded-lg max-md:p-4">
|
||||
<div className="font-mono text-[1.6rem] font-bold tabular-nums text-fg-bright" style={{ color: 'var(--accent)' }}>Explore</div>
|
||||
<div className="mt-1 text-[0.75rem] uppercase tracking-[0.05em] text-muted">Core Pillar</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border bg-surface p-5 max-md:rounded-lg max-md:p-4">
|
||||
<div className="font-mono text-[1.6rem] font-bold tabular-nums text-fg-bright" style={{ color: 'var(--cyan)' }}>SpacetimeDB</div>
|
||||
<div className="mt-1 text-[0.75rem] uppercase tracking-[0.05em] text-muted">Backend</div>
|
||||
<div className="font-mono text-[1.6rem] font-bold tabular-nums text-fg-bright" style={{ color: 'var(--cyan)' }}>Diplomacy</div>
|
||||
<div className="mt-1 text-[0.75rem] uppercase tracking-[0.05em] text-muted">Trade & Talk</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border bg-surface p-5 max-md:rounded-lg max-md:p-4">
|
||||
<div className="font-mono text-[1.6rem] font-bold tabular-nums text-fg-bright" style={{ color: 'var(--green)' }}>FTL Combat</div>
|
||||
<div className="mt-1 text-[0.75rem] uppercase tracking-[0.05em] text-muted">Combat Model</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border bg-surface p-5 max-md:rounded-lg max-md:p-4">
|
||||
<div className="font-mono text-[1.6rem] font-bold tabular-nums text-fg-bright" style={{ color: 'var(--purple)' }}>Dynamic</div>
|
||||
<div className="mt-1 text-[0.75rem] uppercase tracking-[0.05em] text-muted">World Model</div>
|
||||
<div className="font-mono text-[1.6rem] font-bold tabular-nums text-fg-bright" style={{ color: 'var(--green)' }}>Dynamic</div>
|
||||
<div className="mt-1 text-[0.75rem] uppercase tracking-[0.05em] text-muted">Living Galaxy</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border bg-surface p-5 max-md:rounded-lg max-md:p-4">
|
||||
<div className="font-mono text-[1.6rem] font-bold tabular-nums text-fg-bright" style={{ color: 'var(--red)' }}>Solo</div>
|
||||
<div className="mt-1 text-[0.75rem] uppercase tracking-[0.05em] text-muted">Adventure</div>
|
||||
<div className="mt-1 text-[0.75rem] uppercase tracking-[0.05em] text-muted">Your Saga</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -43,10 +43,11 @@ export function OverviewPage() {
|
||||
</div>
|
||||
|
||||
<div className="mb-5 rounded-lg px-5 py-4 text-[0.85rem] leading-[1.5] border border-cyan/20 bg-cyan/8 text-cyan">
|
||||
<strong>Primary inspiration:</strong> Windward Horizon's charm and freedom in a space setting. The game is an
|
||||
<em> open-world exploration RPG</em> where the player charts their own path — trader, mercenary, explorer, or drifter.
|
||||
<strong>Primary inspirations:</strong> Windward's open-world freedom, Stellaris's strategic diplomacy and commerce depth, and The Templin Institute's narrative-driven storytelling style.
|
||||
The game is a <em>story-driven exploration saga</em> where the player authors their own mini-series — trader, diplomat, warmaker, or explorer.
|
||||
The 3D scene is the primary viewport for spatial immersion. Panels and UI overlays handle station services, fitting, and market.
|
||||
Combat uses FTL-style power management — no dogfighting. The galaxy is procedurally generated each campaign.
|
||||
Combat, when it occurs, uses FTL-style power management — no dogfighting. The galaxy is procedurally generated each campaign.
|
||||
The <strong>AI Story Director</strong> weaves your choices into an ongoing narrative.
|
||||
</div>
|
||||
|
||||
<h3>Core Pillars</h3>
|
||||
@@ -60,34 +61,39 @@ export function OverviewPage() {
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><span className="inline-flex items-center gap-1 whitespace-nowrap rounded-full px-2.5 py-0.5 font-mono text-[0.7rem] border border-[rgba(240,160,48,0.25)] bg-[rgba(240,160,48,0.08)] text-accent">Open-world exploration</span></td>
|
||||
<td style={{ color: 'var(--fg-dim)' }}>Procedurally generated galaxy with unique sectors every campaign. Points of interest: stations, asteroid belts, anomalies, derelicts, signal sources. <strong style={{ color: 'var(--accent)' }}>Explore to discover resources, quests, and hidden stories.</strong> The map reveals as you travel — fog of war drives the urge to see what's out there.</td>
|
||||
<td><span className="inline-flex items-center gap-1 whitespace-nowrap rounded-full px-2.5 py-0.5 font-mono text-[0.7rem] border border-green/25 bg-green/8 text-green">Core</span> <span style={{ fontSize: '0.75rem', color: 'var(--fg-dim)' }}>Single system with POIs, procedural generation seed</span></td>
|
||||
<td><span className="inline-flex items-center gap-1 whitespace-nowrap rounded-full px-2.5 py-0.5 font-mono text-[0.7rem] border border-purple/25 bg-purple/8 text-purple">AI Story Director</span></td>
|
||||
<td style={{ color: 'var(--fg-dim)' }}>NEW PRIMARY — Emergent narrative generation tracks your choices, alliances, conflicts, and discoveries. The AI weaves your actions into an ongoing saga. <strong style={{ color: 'var(--accent)' }}>In V1: real-time emergent storytelling. Future: episodic mini-series generation from campaign history.</strong> Every decision feeds the narrative engine.</td>
|
||||
<td><span className="inline-flex items-center gap-1 whitespace-nowrap rounded-full px-2.5 py-0.5 font-mono text-[0.7rem] border border-green/25 bg-green/8 text-green">Core</span> <span style={{ fontSize: '0.75rem', color: 'var(--fg-dim)' }}>Basic event tracking + narrative synthesis</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span className="inline-flex items-center gap-1 whitespace-nowrap rounded-full px-2.5 py-0.5 font-mono text-[0.7rem] border border-cyan/25 bg-cyan/8 text-cyan">Trade & commerce</span></td>
|
||||
<td style={{ color: 'var(--fg-dim)' }}>Buy low at one station, sell high at another. Regional price differences driven by supply/demand and faction needs. <strong style={{ color: 'var(--accent)' }}>Trade routes are the economic backbone.</strong> Towns need resources to grow — supplying them leads to prosperity.</td>
|
||||
<td><span className="inline-flex items-center gap-1 whitespace-nowrap rounded-full px-2.5 py-0.5 font-mono text-[0.7rem] border border-green/25 bg-green/8 text-green">Core</span> <span style={{ fontSize: '0.75rem', color: 'var(--fg-dim)' }}>NPC economy, regional prices, trade routes</span><br/><span className="inline-flex items-center gap-1 whitespace-nowrap rounded-full px-2.5 py-0.5 font-mono text-[0.7rem] border border-cyan/25 bg-cyan/8 text-cyan" style={{ fontSize: '0.65rem' }}>Future</span> <span style={{ fontSize: '0.75rem', color: 'var(--fg-dim)' }}>Optional player-to-player market on co-op servers</span></td>
|
||||
<td><span className="inline-flex items-center gap-1 whitespace-nowrap rounded-full px-2.5 py-0.5 font-mono text-[0.7rem] border border-[rgba(240,160,48,0.25)] bg-[rgba(240,160,48,0.08)] text-accent">Exploration & Discovery</span></td>
|
||||
<td style={{ color: 'var(--fg-dim)' }}>Procedurally generated galaxy with fog of war. Discover anomalies, derelicts, new factions. <strong style={{ color: 'var(--accent)' }}>Every discovery becomes part of your story.</strong> The map reveals as you travel — fog of war drives the urge to see what's out there.</td>
|
||||
<td><span className="inline-flex items-center gap-1 whitespace-nowrap rounded-full px-2.5 py-0.5 font-mono text-[0.7rem] border border-green/25 bg-green/8 text-green">Core</span> <span style={{ fontSize: '0.75rem', color: 'var(--fg-dim)' }}>POIs, procedural generation, discovery logging</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span className="inline-flex items-center gap-1 whitespace-nowrap rounded-full px-2.5 py-0.5 font-mono text-[0.7rem] border border-green/25 bg-green/8 text-green">FTL-style combat</span></td>
|
||||
<td style={{ color: 'var(--fg-dim)' }}>No dogfighting. Click hostile → ship auto-engages → manage <strong style={{ color: 'var(--fg)' }}>reactor power allocation</strong> (FTL-style) between Weapons/Shields/Engines/Aux. The skill is in <strong style={{ color: 'var(--fg)' }}>what</strong> you power, not <strong style={{ color: 'var(--fg)' }}>how fast</strong> you click. Combat creates consequences — ship damage, loot, insurance — but it's not the focus.</td>
|
||||
<td><span className="inline-flex items-center gap-1 whitespace-nowrap rounded-full px-2.5 py-0.5 font-mono text-[0.7rem] border border-green/25 bg-green/8 text-green">Core</span> <span style={{ fontSize: '0.75rem', color: 'var(--fg-dim)' }}>Core combat & power management</span></td>
|
||||
<td><span className="inline-flex items-center gap-1 whitespace-nowrap rounded-full px-2.5 py-0.5 font-mono text-[0.7rem] border border-cyan/25 bg-cyan/8 text-cyan">Trade & Diplomacy</span></td>
|
||||
<td style={{ color: 'var(--fg-dim)' }}>Stellaris-inspired economic and political systems. Supply factions, broker alliances, negotiate borders. <strong style={{ color: 'var(--accent)' }}>Commerce and conversation are equally viable paths.</strong> Trade routes and diplomatic relations shape your narrative.</td>
|
||||
<td><span className="inline-flex items-center gap-1 whitespace-nowrap rounded-full px-2.5 py-0.5 font-mono text-[0.7rem] border border-green/25 bg-green/8 text-green">Core</span> <span style={{ fontSize: '0.75rem', color: 'var(--fg-dim)' }}>Regional prices, faction standings, basic diplomacy</span><br/><span className="inline-flex items-center gap-1 whitespace-nowrap rounded-full px-2.5 py-0.5 font-mono text-[0.7rem] border border-cyan/25 bg-cyan/8 text-cyan" style={{ fontSize: '0.65rem' }}>Future</span> <span style={{ fontSize: '0.75rem', color: 'var(--fg-dim)' }}>Optional player-to-player market on co-op servers</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span className="inline-flex items-center gap-1 whitespace-nowrap rounded-full px-2.5 py-0.5 font-mono text-[0.7rem] border border-purple/25 bg-purple/8 text-purple">Ship customization</span></td>
|
||||
<td style={{ color: 'var(--fg-dim)' }}>Build the ship that suits your style — tough juggernaut that can take a beating, nimble scout that rains fire from afar, or cargo hauler keeping trade routes supplied. Swap modules to change role. <strong style={{ color: 'var(--fg)' }}>Multiple ship classes from nimble interceptors to powerful cruisers.</strong></td>
|
||||
<td><span className="inline-flex items-center gap-1 whitespace-nowrap rounded-full px-2.5 py-0.5 font-mono text-[0.7rem] border border-purple/25 bg-purple/8 text-purple">Ship Customization</span></td>
|
||||
<td style={{ color: 'var(--fg-dim)' }}>Your ship is your narrative vessel. Customize for trade, exploration, or conflict. Swap modules to change role. <strong style={{ color: 'var(--fg)' }}>Multiple ship classes from nimble interceptors to powerful cruisers.</strong></td>
|
||||
<td><span className="inline-flex items-center gap-1 whitespace-nowrap rounded-full px-2.5 py-0.5 font-mono text-[0.7rem] border border-green/25 bg-green/8 text-green">Core</span> <span style={{ fontSize: '0.75rem', color: 'var(--fg-dim)' }}>Basic fitting, multiple ship classes</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span className="inline-flex items-center gap-1 whitespace-nowrap rounded-full px-2.5 py-0.5 font-mono text-[0.7rem] border border-[rgba(240,160,48,0.25)] bg-[rgba(240,160,48,0.08)] text-accent">Dynamic world & factions</span></td>
|
||||
<td style={{ color: 'var(--fg-dim)' }}>Stations and provinces need resources to grow. Supplying them leads to prosperity. Faction leaders involve you in their agendas — helping one may hinder another. <strong style={{ color: 'var(--fg)' }}>Enough support can let a faction leader take control of a province without a single shot fired.</strong> A living galaxy that evolves through player actions and world events.</td>
|
||||
<td><span className="inline-flex items-center gap-1 whitespace-nowrap rounded-full px-2.5 py-0.5 font-mono text-[0.7rem] border border-green/25 bg-green/8 text-green">Strategic Conflict</span></td>
|
||||
<td style={{ color: 'var(--fg-dim)' }}>Combat exists but is not the focus. When conflict occurs, it's FTL-style tactical resource management — a dramatic beat in your story, not the gameplay loop. Click hostile → ship auto-engages → manage <strong style={{ color: 'var(--fg)' }}>reactor power allocation</strong> (FTL-style) between Weapons/Shields/Engines/Aux. <strong style={{ color: 'var(--fg-dim)' }}>Secondary feature, not a core pillar.</strong></td>
|
||||
<td><span className="inline-flex items-center gap-1 whitespace-nowrap rounded-full px-2.5 py-0.5 font-mono text-[0.7rem] border border-cyan/25 bg-cyan/8 text-cyan">Secondary</span> <span style={{ fontSize: '0.75rem', color: 'var(--fg-dim)' }}>Basic combat system, minimized emphasis</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span className="inline-flex items-center gap-1 whitespace-nowrap rounded-full px-2.5 py-0.5 font-mono text-[0.7rem] border border-[rgba(240,160,48,0.25)] bg-[rgba(240,160,48,0.08)] text-accent">Dynamic Galaxy</span></td>
|
||||
<td style={{ color: 'var(--fg-dim)' }}>Your actions shape both the world AND the story. Stations and provinces need resources to grow. Faction leaders involve you in their agendas — helping one may hinder another. <strong style={{ color: 'var(--fg)' }}>Enough support can let a faction leader take control without a single shot fired.</strong> A living galaxy that evolves through player choices and world events.</td>
|
||||
<td><span className="inline-flex items-center gap-1 whitespace-nowrap rounded-full px-2.5 py-0.5 font-mono text-[0.7rem] border border-green/25 bg-green/8 text-green">Core</span> <span style={{ fontSize: '0.75rem', color: 'var(--fg-dim)' }}>Factions, NPC agents, quests, territory influence</span><br/><span className="inline-flex items-center gap-1 whitespace-nowrap rounded-full px-2.5 py-0.5 font-mono text-[0.7rem] border border-cyan/25 bg-cyan/8 text-cyan" style={{ fontSize: '0.65rem' }}>Future</span> <span style={{ fontSize: '0.75rem', color: 'var(--fg-dim)' }}>Living galaxy with world agents on co-op servers</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span className="inline-flex items-center gap-1 whitespace-nowrap rounded-full px-2.5 py-0.5 font-mono text-[0.7rem] border border-red/25 bg-red/8 text-red">Solo Adventure</span></td>
|
||||
<td style={{ color: 'var(--fg-dim)' }}>Your personal galaxy to explore at your own pace. The game is <strong style={{ color: 'var(--fg)' }}>designed as a solo adventure first</strong> — every system works for one captain. The SpacetimeDB architecture naturally supports optional co-op servers later, but that's an enhancement, not the core.</td>
|
||||
<td><span className="inline-flex items-center gap-1 whitespace-nowrap rounded-full px-2.5 py-0.5 font-mono text-[0.7rem] border border-green/25 bg-green/8 text-green">Core</span> <span style={{ fontSize: '0.75rem', color: 'var(--fg-dim)' }}>Solo adventure with full game loop</span><br/><span className="inline-flex items-center gap-1 whitespace-nowrap rounded-full px-2.5 py-0.5 font-mono text-[0.7rem] border border-cyan/25 bg-cyan/8 text-cyan" style={{ fontSize: '0.65rem' }}>Future</span> <span style={{ fontSize: '0.75rem', color: 'var(--fg-dim)' }}>Optional co-op servers</span></td>
|
||||
<td><span className="inline-flex items-center gap-1 whitespace-nowrap rounded-full px-2.5 py-0.5 font-mono text-[0.7rem] border border-red/25 bg-red/8 text-red">Solo Saga</span></td>
|
||||
<td style={{ color: 'var(--fg-dim)' }}>Your personal galaxy to explore at your own pace. The game is <strong style={{ color: 'var(--fg)' }}>designed as a solo adventure first</strong> — every system works for one captain. <strong style={{ color: 'var(--fg)' }}>Your story, your pace.</strong> Future multiplayer servers will be an optional enhancement, not the core.</td>
|
||||
<td><span className="inline-flex items-center gap-1 whitespace-nowrap rounded-full px-2.5 py-0.5 font-mono text-[0.7rem] border border-green/25 bg-green/8 text-green">Core</span> <span style={{ fontSize: '0.75rem', color: 'var(--fg-dim)' }}>Solo adventure with full narrative loop</span><br/><span className="inline-flex items-center gap-1 whitespace-nowrap rounded-full px-2.5 py-0.5 font-mono text-[0.7rem] border border-cyan/25 bg-cyan/8 text-cyan" style={{ fontSize: '0.65rem' }}>Future</span> <span style={{ fontSize: '0.75rem', color: 'var(--fg-dim)' }}>Optional co-op servers</span></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -98,35 +104,49 @@ export function OverviewPage() {
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-5 max-[900px]:grid-cols-1">
|
||||
<div className="mb-6 rounded-xl border border-border bg-surface p-6 max-md:rounded-lg max-md:p-4" style={{ borderLeft: '3px solid var(--purple)' }}>
|
||||
<h4 style={{ color: 'var(--purple)' }}>Story-driven, not grind-driven</h4>
|
||||
<p style={{ color: 'var(--fg-dim)', fontSize: '0.85rem', margin: 0 }}>
|
||||
Progression creates narrative moments, not stat increases. Level ups unlock story branches,
|
||||
new ship classes open new narrative possibilities, and faction relationships shape your saga's plot.
|
||||
<strong style={{ color: 'var(--fg)' }}>Your choices generate your mini-series.</strong>
|
||||
</p>
|
||||
</div>
|
||||
<div className="mb-6 rounded-xl border border-border bg-surface p-6 max-md:rounded-lg max-md:p-4" style={{ borderLeft: '3px solid var(--accent)' }}>
|
||||
<h4 style={{ color: 'var(--accent)' }}>Exploration-first, not spreadsheet-first</h4>
|
||||
<p style={{ color: 'var(--fg-dim)', fontSize: '0.85rem', margin: 0 }}>
|
||||
The 3D viewport IS the game. Players spend their time flying between points of interest, discovering new sectors,
|
||||
and engaging with the world. Station panels handle trading, fitting, and services — but the joy is in
|
||||
the journey, not the ledger. <strong style={{ color: 'var(--fg)' }}>Trade, quest, fight, or simply set sail and enjoy the ambiance.</strong>
|
||||
the journey, not the ledger. <strong style={{ color: 'var(--fg)' }}>Trade, negotiate, fight, or simply set sail and enjoy the ambiance.</strong>
|
||||
</p>
|
||||
</div>
|
||||
<div className="mb-6 rounded-xl border border-border bg-surface p-6 max-md:rounded-lg max-md:p-4">
|
||||
<h4 style={{ color: 'var(--cyan)' }}>Authoritative backend</h4>
|
||||
<h4 style={{ color: 'var(--cyan)' }}>Local-first architecture</h4>
|
||||
<p style={{ color: 'var(--fg-dim)', fontSize: '0.85rem', margin: 0 }}>
|
||||
SpacetimeDB owns authoritative game state. The browser renders state and sends player
|
||||
intentions. The renderer should never become the source of truth.
|
||||
The game client runs locally with local persistence for V1. Your story lives on your machine.
|
||||
Future multiplayer servers will be optional — the single-player experience is complete and standalone.
|
||||
</p>
|
||||
</div>
|
||||
<div className="mb-6 rounded-xl border border-border bg-surface p-6 max-md:rounded-lg max-md:p-4">
|
||||
<h4 style={{ color: 'var(--green)' }}>Every campaign is unique</h4>
|
||||
<p style={{ color: 'var(--fg-dim)', fontSize: '0.85rem', margin: 0 }}>
|
||||
Procedurally generated galaxy maps ensure no two games are alike. Fog of war hides unexplored sectors.
|
||||
Factions, resource distributions, and quest chains vary per seed. Replayability through discovery, not grind.
|
||||
Factions, resource distributions, and narrative arcs vary per seed. Replayability through discovery and storytelling, not grind.
|
||||
</p>
|
||||
</div>
|
||||
<div className="mb-6 rounded-xl border border-border bg-surface p-6 max-md:rounded-lg max-md:p-4">
|
||||
<h4 style={{ color: 'var(--purple)' }}>FTL combat — captain, not pilot</h4>
|
||||
<div className="mb-6 rounded-xl border border-border bg-surface p-6 max-md:rounded-lg max-md:p-4" style={{ borderLeft: '3px solid var(--orange)' }}>
|
||||
<h4 style={{ color: 'var(--orange)' }}>Diplomacy and trade are viable paths</h4>
|
||||
<p style={{ color: 'var(--fg-dim)', fontSize: '0.85rem', margin: 0 }}>
|
||||
The player never directly flies or aims. Click a destination → ship autopilots. Click a hostile → ship auto-engages.
|
||||
During combat, the player manages <strong style={{ color: 'var(--fg)' }}>reactor power allocation</strong> (FTL-style: weapons/shields/engines/aux)
|
||||
and <strong style={{ color: 'var(--fg)' }}>subsystem targeting</strong>. You are the chief engineer deciding where
|
||||
the reactor output goes — not the pilot or gunner.
|
||||
Inspired by Stellaris, commerce and conversation are equally valid ways to shape your galaxy. Supply factions to gain influence,
|
||||
broker alliances between enemies, negotiate borders without firing a shot. <strong style={{ color: 'var(--fg)' }}>War is a choice, not the default.</strong>
|
||||
</p>
|
||||
</div>
|
||||
<div className="mb-6 rounded-xl border border-border bg-surface p-6 max-md:rounded-lg max-md:p-4" style={{ borderLeft: '3px solid var(--red)' }}>
|
||||
<h4 style={{ color: 'var(--red)' }}>Conflict serves the story</h4>
|
||||
<p style={{ color: 'var(--fg-dim)', fontSize: '0.85rem', margin: 0 }}>
|
||||
Combat exists but is not the focus. When conflict occurs, it's FTL-style tactical resource management — a dramatic beat in your narrative.
|
||||
The player never directly flies or aims. Click a hostile → ship auto-engages. Manage reactor power allocation (weapons/shields/engines/aux).
|
||||
You are the captain making strategic decisions, not the pilot dodging fire.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -138,19 +158,19 @@ export function OverviewPage() {
|
||||
|
||||
<div className="mb-6 rounded-xl border border-border bg-surface p-6 max-md:rounded-lg max-md:p-4 border-l-[3px] border-l-accent" style={{ padding: 'var(--sp-6) var(--sp-8)' }}>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: '0.85rem', color: 'var(--fg-dim)', lineHeight: 2 }}>
|
||||
<span style={{ color: 'var(--cyan)' }}>Spawn</span> → <span style={{ color: 'var(--accent)' }}>Explore</span> →{' '}
|
||||
<span style={{ color: 'var(--green)' }}>Trade</span> →{' '}
|
||||
<span style={{ color: 'var(--purple)' }}>Quest</span> →{' '}
|
||||
<span style={{ color: 'var(--fg-bright)' }}>Fight</span> →{' '}
|
||||
<span style={{ color: 'var(--cyan)' }}>Upgrade Ship</span> →{' '}
|
||||
<span style={{ color: 'var(--accent)' }}>Sail Deeper</span>
|
||||
<span style={{ color: 'var(--cyan)' }}>Spawn</span> → <span style={{ color: 'var(--accent)' }}>Discover</span> →{' '}
|
||||
<span style={{ color: 'var(--green)' }}>Engage (Trade/Diplomacy/War)</span> →{' '}
|
||||
<span style={{ color: 'var(--purple)' }}>AI Story Event</span> →{' '}
|
||||
<span style={{ color: 'var(--fg-bright)' }}>Shape Galaxy</span> →{' '}
|
||||
<span style={{ color: 'var(--cyan)' }}>Next Chapter</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-5 rounded-lg px-5 py-4 text-[0.85rem] leading-[1.5] border border-cyan/20 bg-cyan/8 text-cyan" style={{ marginBottom: 'var(--sp-5)' }}>
|
||||
<strong>The real loop is adventure.</strong> Spawn → explore the unknown → discover opportunities → act on them → grow your ship and reputation → sail into deeper, riskier space.
|
||||
The "mine → sell" cycle is the entry point. The endgame is about faction influence, rare discoveries, commanding powerful ships,
|
||||
and shaping the galaxy through your choices — <strong>whether by trade, combat, or diplomacy</strong>.
|
||||
<strong>The real loop is your story.</strong> Spawn → explore the unknown → make choices that matter → watch the AI weave your actions into narrative threads →
|
||||
shape factions and territories → sail into deeper chapters. The "mine → sell" cycle is the entry point. The endgame is about your unique saga,
|
||||
faction influence shaped by your decisions, rare discoveries that become plot points, and commanding powerful ships as your narrative vessel.
|
||||
<strong>Whether by trade, combat, or diplomacy — your choices generate your mini-series.</strong>
|
||||
</div>
|
||||
|
||||
<h3 style={{ marginTop: 'var(--sp-6)' }}>Minimum Viable Screens</h3>
|
||||
|
||||
@@ -4,16 +4,16 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
export function RoadmapPage() {
|
||||
const eras = [
|
||||
{
|
||||
id: 'solo',
|
||||
title: 'VOID::NAV — Single-Player Open-World RPG',
|
||||
subtitle: 'Build the core adventure game locally. SpacetimeDB runs on the local machine from Phase 0 — there is no localStorage. One browser window, one captain, one procedurally generated galaxy to explore. Every feature is designed to be fun solo first.',
|
||||
id: 'core',
|
||||
title: 'VOID::NAV — Narrative-Driven Space Exploration Saga',
|
||||
subtitle: 'Build a single-player experience where you author your own mini-series. Inspired by Windward\'s freedom, Stellaris\'s strategic depth, and AI-generated storytelling. Every campaign generates a unique tale. Local persistence from day one — your story lives on your machine.',
|
||||
accent: 'var(--accent)',
|
||||
phases: [
|
||||
{
|
||||
num: '0',
|
||||
title: 'Local Skeleton & Star System',
|
||||
goal: 'Vite app with local SpacetimeDB instance, rendered star system with station and asteroids. Player can connect and see the void.',
|
||||
doneWhen: 'App boots, connects to local SpacetimeDB instance. Shows a star system with a station and 3 asteroids. Ship visible in 3D viewport. Game state persists in SpacetimeDB — no localStorage.',
|
||||
goal: 'Game client with rendered star system, station, and asteroids. Player can spawn and see the void.',
|
||||
doneWhen: 'Game boots. Shows a star system with a station and 3 asteroids. Ship visible in 3D viewport. Game state persists locally — closing and reopening restores state.',
|
||||
status: 'current',
|
||||
},
|
||||
{
|
||||
@@ -32,101 +32,37 @@ export function RoadmapPage() {
|
||||
},
|
||||
{
|
||||
num: '3',
|
||||
title: 'Combat — FTL Power Allocation',
|
||||
goal: 'Auto-engage combat with reactor power management between weapons / shields / engines. Encounter NPC pirates at belts and anomalies.',
|
||||
doneWhen: 'Target a hostile NPC at a belt or anomaly. Ship auto-engages. Player shifts reactor power between subsystems (FTL-style). Power allocation visibly changes combat outcome. Ship can be destroyed. NPC pirates drop loot and bounty.',
|
||||
title: 'AI Story Director Foundation',
|
||||
goal: 'Event tracking system. Player actions logged for narrative synthesis. Basic AI-generated story events trigger based on discoveries and choices.',
|
||||
doneWhen: 'Player actions (discover POI, trade, fight, ally with faction) are logged. AI generates narrative text describing events. Story log accessible showing campaign history. Discoveries generate story beats.',
|
||||
status: 'upcoming',
|
||||
},
|
||||
{
|
||||
num: '4',
|
||||
title: 'Ship Customization & Upgrades',
|
||||
goal: 'Multiple ship classes (scout, hauler, warship). Module fitting with High/Med/Low slots. CPU/PG constraints. Buy new ships at stations.',
|
||||
doneWhen: 'Dock at station. Open fitting screen. Equip weapons in high slots, shield booster in mid, cargo expander in low. Fitting affects combat and trade stats. Can buy a new ship class. Invalid fits rejected (insufficient CPU/PG).',
|
||||
title: 'Diplomacy & Factions',
|
||||
goal: 'NPC agents offer quests. Faction standing system. Stellaris-inspired diplomatic options. Trade routes and alliances shape both world and narrative.',
|
||||
doneWhen: 'Talk to NPC agent at station. Accept a quest (deliver cargo, explore anomaly). Complete quest for ISK + standing. Standing unlocks better quests and prices. Supplying a station increases its prosperity. Faction relationships visible and affected by choices.',
|
||||
status: 'upcoming',
|
||||
},
|
||||
{
|
||||
num: '5',
|
||||
title: 'Faction Quests & Dynamic World',
|
||||
goal: 'NPC agents offer quests (kill, courier, mining, exploration). Faction standing system. Station prosperity responds to player supply. Dynamic world events spawn.',
|
||||
doneWhen: 'Talk to NPC agent at station. Accept a quest (deliver cargo, kill pirates, explore anomaly). Complete quest for ISK + standing. Standing unlocks better quests and prices. Supplying a station increases its prosperity. World events spawn in explored systems.',
|
||||
title: 'Ship Customization & Upgrades',
|
||||
goal: 'Multiple ship classes (scout, hauler, warship). Module fitting with High/Med/Low slots. CPU/PG constraints. Buy new ships at stations.',
|
||||
doneWhen: 'Dock at station. Open fitting screen. Equip weapons in high slots, shield booster in mid, cargo expander in low. Fitting affects stats. Can buy a new ship class. Invalid fits rejected (insufficient CPU/PG).',
|
||||
status: 'upcoming',
|
||||
},
|
||||
{
|
||||
num: '6',
|
||||
title: 'Industry & Economy Depth',
|
||||
goal: 'Refining ore into minerals. Manufacturing modules and items. Full NPC market with supply/demand. Price history and regional differences.',
|
||||
doneWhen: 'Dock with ore. Refine at station. Minerals appear in hangar. Open manufacturing tab, select a blueprint, queue a job. Job completes. Product appears. Sell on NPC market. Prices react to trades. Different stations have different prices.',
|
||||
title: 'Strategic Conflict (Secondary)',
|
||||
goal: 'FTL-style combat as dramatic story beats, not gameplay loop. Auto-engage with power management. Consequences feed narrative.',
|
||||
doneWhen: 'Target hostile NPC. Ship auto-engages. Manage reactor power (weapons/shields/engines). Combat outcome affects story (loot, bounty, reputation changes). Defeats and victories logged in story.',
|
||||
status: 'upcoming',
|
||||
},
|
||||
{
|
||||
num: '7',
|
||||
title: 'Single-Player Polish & Procedural Galaxy',
|
||||
goal: 'Procedurally generated multi-system galaxy. Fog of war. Full HUD. Notifications. Tutorial sequence. Ship AI companion (Zora Tier 0). Save/load.',
|
||||
doneWhen: 'Full game loop is playable solo: explore → mine → trade → quest → fight → upgrade → explore deeper. Procedural galaxy generates unique systems each campaign. Fog of war reveals as you travel. HUD shows all relevant info. Tutorial guides new players through first 30 minutes. SpacetimeDB persists all state.',
|
||||
status: 'future',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'multi',
|
||||
title: 'Optional — Co-op Servers',
|
||||
subtitle: 'Promote local SpacetimeDB to a shared server for small co-op crews. Same adventure, shared with friends. Entirely optional — the core game is complete without this.',
|
||||
accent: 'var(--cyan)',
|
||||
phases: [
|
||||
{
|
||||
num: '8',
|
||||
title: 'Shared Server Skeleton',
|
||||
goal: 'Promote local SpacetimeDB to a shared server. Multiple clients connect and see the same state.',
|
||||
doneWhen: 'Two browser windows connect to the same SpacetimeDB instance. Both see the same star system state. One client issues a move command, the other sees it. Connection status indicator works.',
|
||||
status: 'future',
|
||||
},
|
||||
{
|
||||
num: '9',
|
||||
title: 'Co-op Presence & Movement',
|
||||
goal: 'Players see each other in real time. Movement is server-authoritative with client-side interpolation. Small crew exploration.',
|
||||
doneWhen: 'Two players in the same system. Each sees the other\'s ship. Click-to-move sends reducer, server validates, all clients interpolate movement. No desync under normal latency.',
|
||||
status: 'future',
|
||||
},
|
||||
{
|
||||
num: '10',
|
||||
title: 'Shared Market & Trading',
|
||||
goal: 'Player-to-player trading. Buy/sell orders between co-op crew members. Shared price discovery.',
|
||||
doneWhen: 'Player A places a sell order. Player B sees it in the market table and buys. ISK and items transfer atomically. Order book shows depth. Market history is shared.',
|
||||
status: 'future',
|
||||
},
|
||||
{
|
||||
num: '11',
|
||||
title: 'Chat & Communication',
|
||||
goal: 'Local chat for co-op crew, delayed comms for immersion.',
|
||||
doneWhen: 'Players in the same system see chat in real time. PMs arrive with light-speed delay. Crew can coordinate exploration.',
|
||||
status: 'future',
|
||||
},
|
||||
{
|
||||
num: '12',
|
||||
title: 'Living Galaxy — World Agents + Ship AI Tier 1',
|
||||
goal: 'Background agent scheduler (BitCraft model). NPC trade convoys, faction skirmishes, anomaly spawns, migration routes. Ship AI promoted to LLM-assisted dialogue.',
|
||||
doneWhen: 'Server spawns world events without player input. Events appear in the galaxy story log. Faction borders shift over time. Anomalies appear and expire. A returning player sees the galaxy has changed. Tier 1 Zora: soul.md as real system prompt, LLM-generated dialogue, comms module enables natural language responses.',
|
||||
status: 'future',
|
||||
},
|
||||
{
|
||||
num: '13',
|
||||
title: 'Co-op Combat',
|
||||
goal: 'Crew members fight together. Multiple ships in an engagement. Coordinate against tough NPCs.',
|
||||
doneWhen: 'Two players engage each other. Both manage power allocation. Server resolves combat ticks authoritatively. Both clients see damage applied. Loser\'s ship drops loot (or wreck). Kill log records the event.',
|
||||
status: 'future',
|
||||
},
|
||||
{
|
||||
num: '14',
|
||||
title: 'Crews & Shared Progress',
|
||||
goal: 'Form a persistent crew. Shared wallet, coordinated objectives, crew-level progression.',
|
||||
doneWhen: 'Players form a corp. Corp can anchor a structure in a system. Structure provides bonuses (refining yield, market tax). Sovereignty map shows corp-held systems. Rival corps can contest.',
|
||||
status: 'future',
|
||||
},
|
||||
{
|
||||
num: '15',
|
||||
title: 'Co-op Polish',
|
||||
goal: 'Polish pass on co-op features. Error handling, reconnection, scaling tests for small crews.',
|
||||
doneWhen: 'Fresh player can create account, complete the tutorial, explore, mine, trade, fight, and customize their ship — all without hitting a dead-end or a crash. Optionally, invite a friend to join the same server for co-op. Server handles a small crew of friends.',
|
||||
goal: 'Procedurally generated multi-system galaxy. Fog of war. Full HUD. Notifications. Tutorial sequence. Ship AI companion (Zora Tier 0). Story-driven progression.',
|
||||
doneWhen: 'Full game loop: discover → engage → AI story event → shape galaxy → next chapter. Procedural galaxy generates unique systems each campaign. Fog of war reveals as you travel. HUD shows all relevant info. Tutorial guides first 30 minutes. State persists locally.',
|
||||
status: 'future',
|
||||
},
|
||||
],
|
||||
@@ -181,18 +117,16 @@ export function RoadmapPage() {
|
||||
<div className="mx-auto max-w-content">
|
||||
<h1 style={{ marginBottom: '8px' }}>Development Roadmap</h1>
|
||||
<p style={{ color: 'var(--fg-dim)', fontSize: '0.95rem', maxWidth: '720px' }}>
|
||||
Two eras, sixteen phases. <strong style={{ color: 'var(--fg)' }}>The core game</strong> is a single-player open-world space RPG
|
||||
with a local SpacetimeDB instance — exploration, trading, FTL-style combat, ship customization, and faction quests.
|
||||
<strong style={{ color: 'var(--fg)' }}>Optional co-op servers</strong> can be added later by promoting the local SpacetimeDB to a shared server.
|
||||
Each phase has a verifiable done-when condition. Integration gates between phase groups ensure every system works together before advancing.
|
||||
Eight phases to a complete single-player narrative experience. <strong style={{ color: 'var(--fg)' }}>Core game</strong> is a story-driven
|
||||
space exploration saga — inspired by Windward's freedom, Stellaris's strategic depth, and AI-generated storytelling.
|
||||
Every phase has a verifiable done-when condition. Integration gates ensure all systems work together before advancing.
|
||||
</p>
|
||||
|
||||
<div className="mb-5 rounded-lg px-5 py-4 text-[0.85rem] leading-[1.5] border border-cyan/20 bg-cyan/8 text-cyan" style={{ marginTop: 'var(--sp-4)', maxWidth: '720px' }}>
|
||||
<strong>Why single-player with local SpacetimeDB?</strong> The game is a solo adventure — Windward Horizon in space. Networking is the biggest source of bugs and complexity.
|
||||
By validating that exploration, trading, combat, fitting, and faction quests are fun locally — using the <em>same</em> SpacetimeDB
|
||||
persistence that would serve co-op — we de-risk the entire project. There is no localStorage; SpacetimeDB is the
|
||||
persistence layer from day 1. If co-op is added later, the question is only "how do we share this server?" — not
|
||||
"is this game fun?" or "will the persistence migration work?"
|
||||
<div className="mb-5 rounded-lg px-5 py-4 text-[0.85rem] leading-[1.5] border border-purple/20 bg-purple/8 text-purple" style={{ marginTop: 'var(--sp-4)', maxWidth: '720px' }}>
|
||||
<strong>Why narrative-driven and single-player?</strong> The game is about <em>your story</em> — Windward meets Stellaris with AI-generated
|
||||
narrative in the style of The Templin Institute. Every choice feeds the AI Story Director, weaving your actions into an ongoing saga.
|
||||
Local persistence means your story lives on your machine — no cloud required, no subscriptions, no always-online. Just you and the void.
|
||||
Future enhancements may add multiplayer, but the core experience is complete and standalone.
|
||||
</div>
|
||||
|
||||
<div className="mb-5 flex items-center gap-3 border-b border-border pb-3 max-md:flex-col max-md:items-start max-md:gap-2 max-md:[&_h1]:text-[1.35rem] max-md:[&_h2]:text-[1.35rem]" style={{ marginTop: 'var(--sp-8)' }}>
|
||||
@@ -209,53 +143,35 @@ export function RoadmapPage() {
|
||||
<p style={{ color: 'var(--fg-dim)', fontSize: '0.85rem', margin: '0 0 var(--sp-2) 0' }}>
|
||||
Navigate to asteroid → mine → fill cargo → dock → sell ore → navigate to another station → discover price difference → trade.
|
||||
The complete exploration and trade loop runs in a single session without errors. The galaxy feels alive and explorable.
|
||||
SpacetimeDB persists the session — closing the browser and reopening restores state.
|
||||
Local persistence works — closing and reopening restores state.
|
||||
</p>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: '0.72rem', color: 'var(--muted)' }}>Phases covered: 0, 1, 2</div>
|
||||
</div>
|
||||
<div className="mb-6 rounded-xl border border-border bg-surface p-6 max-md:rounded-lg max-md:p-4" style={{ borderLeft: '3px solid var(--red)' }}>
|
||||
<h4 style={{ color: 'var(--red)' }}>Gate 2 — Combat + Customization (after Phase 4)</h4>
|
||||
<div className="mb-6 rounded-xl border border-border bg-surface p-6 max-md:rounded-lg max-md:p-4" style={{ borderLeft: '3px solid var(--purple)' }}>
|
||||
<h4 style={{ color: 'var(--purple)' }}>Gate 2 — Story Foundation (after Phase 3)</h4>
|
||||
<p style={{ color: 'var(--fg-dim)', fontSize: '0.85rem', margin: '0 0 var(--sp-2) 0' }}>
|
||||
Customize a ship at station → undock → encounter NPC pirate → manage FTL power allocation → destroy or be destroyed →
|
||||
collect loot → buy upgrades → try a different ship class. Combat and ship customization form a satisfying adventure loop.
|
||||
Player actions generate story events. AI Story Director weaves discoveries, trades, and choices into narrative text.
|
||||
Story log accessible showing campaign history. The game feels like it's telling YOUR story.
|
||||
</p>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: '0.72rem', color: 'var(--muted)' }}>Phases covered: 0–4</div>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: '0.72rem', color: 'var(--muted)' }}>Phases covered: 0–3</div>
|
||||
</div>
|
||||
<div className="mb-6 rounded-xl border border-border bg-surface p-6 max-md:rounded-lg max-md:p-4" style={{ borderLeft: '3px solid var(--green)' }}>
|
||||
<h4 style={{ color: 'var(--green)' }}>Gate 3 — Full Adventure Loop (after Phase 6)</h4>
|
||||
<h4 style={{ color: 'var(--green)' }}>Gate 3 — Full Adventure Loop (after Phase 5)</h4>
|
||||
<p style={{ color: 'var(--fg-dim)', fontSize: '0.85rem', margin: '0 0 var(--sp-2) 0' }}>
|
||||
Explore → mine ore → refine → manufacture a module → fit it → fight pirates → accept faction quest → deliver goods →
|
||||
earn standing → unlock better missions → sell excess across stations at different prices.
|
||||
The complete adventure: exploration, economy, industry, and faction systems work as an integrated whole.
|
||||
Explore → discover → AI story event → engage faction → complete quest → earn standing → unlock new narrative branches.
|
||||
Diplomacy, trade, and story systems work as an integrated whole. Choices matter.
|
||||
</p>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: '0.72rem', color: 'var(--muted)' }}>Phases covered: 0–6</div>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: '0.72rem', color: 'var(--muted)' }}>Phases covered: 0–5</div>
|
||||
</div>
|
||||
<div className="mb-6 rounded-xl border border-border bg-surface p-6 max-md:rounded-lg max-md:p-4" style={{ borderLeft: '3px solid var(--cyan)' }}>
|
||||
<h4 style={{ color: 'var(--cyan)' }}>Gate 4 — Core Game Complete (after Phase 7)</h4>
|
||||
<p style={{ color: 'var(--fg-dim)', fontSize: '0.85rem', margin: '0 0 var(--sp-2) 0' }}>
|
||||
Full solo game with all systems integrated: explore → mine → trade → quest → fight → customize → sail deeper.
|
||||
Procedurally generated galaxy. Fog of war. HUD, notifications, Zora Tier 0, world events, tutorial sequence.
|
||||
A new player can learn the game in one session and want to keep going. SpacetimeDB state survives restart.
|
||||
Full solo game: discover → engage → AI story event → shape galaxy → next chapter.
|
||||
Procedurally generated galaxy, fog of war, HUD, notifications, Zora Tier 0, world events, tutorial, conflict as story beats.
|
||||
A new player can learn the game in one session and want to keep going. State persists locally.
|
||||
</p>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: '0.72rem', color: 'var(--muted)' }}>Phases covered: 0–7 (core game)</div>
|
||||
</div>
|
||||
<div className="mb-6 rounded-xl border border-border bg-surface p-6 max-md:rounded-lg max-md:p-4" style={{ borderLeft: '3px solid var(--purple)' }}>
|
||||
<h4 style={{ color: 'var(--purple)' }}>Gate 5 — Co-op Core (after Phase 10)</h4>
|
||||
<p style={{ color: 'var(--fg-dim)', fontSize: '0.85rem', margin: '0 0 var(--sp-2) 0' }}>
|
||||
Two players on the same server. Both see each other. Both can trade on the shared market. ISK and items
|
||||
transfer atomically. Movement is synced. Connection loss and reconnection work. The same solo adventure, shared with a friend.
|
||||
</p>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: '0.72rem', color: 'var(--muted)' }}>Phases covered: 8–10</div>
|
||||
</div>
|
||||
<div className="mb-6 rounded-xl border border-border bg-surface p-6 max-md:rounded-lg max-md:p-4" style={{ borderLeft: '3px solid var(--fg-dim)' }}>
|
||||
<h4 style={{ color: 'var(--fg-dim)' }}>Gate 6 — Co-op Ready (after Phase 15)</h4>
|
||||
<p style={{ color: 'var(--fg-dim)', fontSize: '0.85rem', margin: '0 0 var(--sp-2) 0' }}>
|
||||
Fresh player can: create account, complete tutorial, explore their first system, mine and trade, survive a PvE encounter,
|
||||
customize their ship, accept and complete a faction quest — and optionally invite a friend to share the adventure — all without crashes or dead ends.
|
||||
Server handles a small crew of friends.
|
||||
</p>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: '0.72rem', color: 'var(--muted)' }}>Phases covered: 0–15 (all)</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 'var(--sp-8)' }}>
|
||||
@@ -281,7 +197,7 @@ export function RoadmapPage() {
|
||||
color: era.accent,
|
||||
border: `1px solid ${era.accent === 'var(--accent)' ? 'var(--accent-border)' : 'rgba(34,211,238,0.3)'}`,
|
||||
}}>
|
||||
ERA {ei + 1}
|
||||
CORE GAME
|
||||
</span>
|
||||
<div>
|
||||
<h2 style={{ margin: 0, fontSize: '1.1rem' }}>{era.title}</h2>
|
||||
@@ -300,6 +216,12 @@ export function RoadmapPage() {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mb-5 rounded-lg px-5 py-4 text-[0.85rem] leading-[1.5] border border-cyan/20 bg-cyan/8 text-cyan" style={{ marginTop: 'var(--sp-8)', maxWidth: '720px' }}>
|
||||
<strong>Future Enhancements</strong> — Post-V1 features may include episodic mini-series generation (AI looks back at campaign history
|
||||
and generates "episodes" like a Templin Institute video), multiplayer co-op servers, and expanded narrative systems.
|
||||
These are optional enhancements — the core single-player narrative experience is complete and standalone.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ export function TechStackPage() {
|
||||
frontend: {
|
||||
choice: 'Vite + React + TypeScript',
|
||||
reason: 'Fast client-side app setup, no server-rendering complexity, good for a real-time game UI.',
|
||||
whyNot: "Next.js is not necessary for the MVP. The prototype is a client-side real-time game, not a content site. Next.js can be introduced later for marketing pages, account pages, SSR, or API routes outside SpacetimeDB.",
|
||||
whyNot: "Next.js is not necessary for the MVP. The prototype is a client-side real-time game, not a content site. Next.js can be introduced later for marketing pages, account pages, SSR, or API routes.",
|
||||
},
|
||||
rendering: {
|
||||
choice: 'React Three Fiber',
|
||||
@@ -30,9 +30,9 @@ export function TechStackPage() {
|
||||
whyNot: "Redux would add ceremony without benefit at prototype scale. Context API re-renders would hurt performance with frequent state updates.",
|
||||
},
|
||||
backend: {
|
||||
choice: 'SpacetimeDB',
|
||||
reason: 'Real-time backend/database with server-side reducers and live client subscriptions. Authoritative state, persistence, and optional co-op all in one.',
|
||||
whyNot: "A custom Node.js + PostgreSQL backend would require building real-time sync, subscriptions, and authoritative game logic from scratch.",
|
||||
choice: 'Local Persistence (Rust/Bevy)',
|
||||
reason: 'Game client manages state locally with persistent storage. No cloud dependency, no always-online requirement. Your story lives on your machine.',
|
||||
whyNot: "Server-based backends would add complexity, hosting costs, and always-online requirements. Local-first is simpler and respects player ownership.",
|
||||
},
|
||||
styling: {
|
||||
choice: 'Tailwind CSS',
|
||||
@@ -40,9 +40,9 @@ export function TechStackPage() {
|
||||
whyNot: "CSS-in-JS adds runtime overhead. Vanilla CSS at this scale would slow panel iteration.",
|
||||
},
|
||||
auth: {
|
||||
choice: 'SpacetimeDB Identity (MVP)',
|
||||
reason: 'Keep early identity/session handling simple. Add external auth only after core loop works.',
|
||||
whyNot: "Full OAuth/JWT would add complexity before we know the right identity model for the game.",
|
||||
choice: 'Local Profile (MVP)',
|
||||
reason: 'Simple local profile management. Player creates captain name and saves locally. Add external auth only if multiplayer is added later.',
|
||||
whyNot: "Full OAuth/JWT would add complexity before we know the right identity model. Local is sufficient for single-player narrative experience.",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -116,8 +116,8 @@ export function TechStackPage() {
|
||||
</tr>
|
||||
<tr>
|
||||
<td style={{ color: 'var(--accent)' }}>Backend</td>
|
||||
<td>SpacetimeDB</td>
|
||||
<td style={{ color: 'var(--fg-dim)' }}>Real-time backend with reducers and subscriptions.</td>
|
||||
<td>Local Persistence (Rust/Bevy)</td>
|
||||
<td style={{ color: 'var(--fg-dim)' }}>Game client manages state locally with persistent storage.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style={{ color: 'var(--fg-dim)' }}>Styling</td>
|
||||
@@ -126,8 +126,8 @@ export function TechStackPage() {
|
||||
</tr>
|
||||
<tr>
|
||||
<td style={{ color: 'var(--muted)' }}>Auth</td>
|
||||
<td>SpacetimeDB identity</td>
|
||||
<td style={{ color: 'var(--fg-dim)' }}>Simple identity first; add auth later.</td>
|
||||
<td>Local Profile</td>
|
||||
<td style={{ color: 'var(--fg-dim)' }}>Simple local profile management. Single-player experience.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -146,14 +146,11 @@ export function TechStackPage() {
|
||||
|
||||
<div className="mb-5 overflow-x-auto rounded-lg border border-border bg-surface px-5 py-4 [&_code]:bg-transparent [&_code]:p-0 [&_code]:font-mono [&_code]:text-[0.82rem] [&_code]:leading-[1.6] [&_code]:text-fg-dim">
|
||||
<code>
|
||||
<span className="text-muted italic">{'//'} Starter project layout</span><br/>
|
||||
<span className="text-purple">/client/src/app</span> <span className="text-muted italic">{' //'} App shell and providers</span><br/>
|
||||
<span className="text-purple">/client/src/network</span> <span className="text-muted italic">{' //'} SpacetimeDB client, subscriptions</span><br/>
|
||||
<span className="text-purple">/client/src/game</span> <span className="text-muted italic">{' //'} Renderer-independent types, view models</span><br/>
|
||||
<span className="text-purple">/client/src/store</span> <span className="text-muted italic">{' //'} Zustand stores</span><br/>
|
||||
<span className="text-purple">/client/src/renderers/r3f</span> <span className="text-muted italic">{'//'} R3F scene, meshes, camera</span><br/>
|
||||
<span className="text-purple">/client/src/ui</span> <span className="text-muted italic">{' //'} HUD, inventory, market, chat</span><br/>
|
||||
<span className="text-purple">/server-spacetime/src</span> <span className="text-muted italic">{' //'} SpacetimeDB module, reducers</span><br/>
|
||||
<span className="text-muted italic">{'//'} Project layout</span><br/>
|
||||
<span className="text-purple">/apps/game/src</span> <span className="text-muted italic">{' //'} Rust + Bevy game client</span><br/>
|
||||
<span className="text-purple">/apps/docs/src</span> <span className="text-muted italic">{' //'} Design documentation</span><br/>
|
||||
<span className="text-purple">/apps/site/src</span> <span className="text-muted italic">{' //'} Landing page website</span><br/>
|
||||
<span className="text-purple">/packages/ui/src</span> <span className="text-muted italic">{' //'} Shared UI components</span><br/>
|
||||
</code>
|
||||
</div>
|
||||
|
||||
@@ -161,11 +158,11 @@ export function TechStackPage() {
|
||||
<table className="w-full border-collapse text-[0.85rem] max-md:block max-md:overflow-x-auto max-md:rounded-lg max-md:border max-md:border-border max-md:[&_thead]:table max-md:[&_tbody]:table max-md:[&_thead]:min-w-[680px] max-md:[&_tbody]:min-w-[680px] [&_th]:whitespace-nowrap [&_th]:border-b [&_th]:border-border [&_th]:px-4 [&_th]:py-3 [&_th]:text-left [&_th]:font-mono [&_th]:text-[0.7rem] [&_th]:uppercase [&_th]:tracking-[0.06em] [&_th]:text-muted [&_td]:border-b [&_td]:border-border [&_td]:px-4 [&_td]:py-3 [&_td]:font-mono [&_td]:text-[0.8rem] [&_td]:text-fg [&_tr:hover_td]:bg-surface-raised">
|
||||
<thead><tr><th>Package</th><th>Purpose</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>vite, react, react-dom, typescript</td><td>Core frontend.</td></tr>
|
||||
<tr><td>three, @react-three/fiber, @react-three/drei</td><td>3D scene and helper controls.</td></tr>
|
||||
<tr><td>vite, react, react-dom, typescript</td><td>Core frontend (docs, site).</td></tr>
|
||||
<tr><td>three, @react-three/fiber, @react-three/drei</td><td>3D scene and helper controls (docs demos).</td></tr>
|
||||
<tr><td>zustand</td><td>Local UI/game view state.</td></tr>
|
||||
<tr><td>tailwindcss</td><td>Panel and HUD styling.</td></tr>
|
||||
<tr><td>spacetimedb TS client</td><td>Backend connection, reducers, subscriptions.</td></tr>
|
||||
<tr><td>bevy, rust</td><td>Game client engine and language.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -1,922 +0,0 @@
|
||||
// @ts-nocheck
|
||||
import * as React from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
const LAST_UPDATED = '2026-06-02';
|
||||
const BACKEND_TABLES_IMPLEMENTED = 9;
|
||||
const BACKEND_TABLES_DESIGNED = 56;
|
||||
const BACKEND_REDUCERS_IMPLEMENTED = 12;
|
||||
const BACKEND_REDUCERS_DESIGNED = 40;
|
||||
|
||||
type Phase = '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7';
|
||||
type Layer = 'backend' | 'frontend' | 'scene' | 'prototype';
|
||||
type Status = 'done' | 'partial' | 'missing' | 'blocked';
|
||||
|
||||
interface TodoItem {
|
||||
id: string;
|
||||
title: string;
|
||||
phase: Phase;
|
||||
layer: Layer;
|
||||
status: Status;
|
||||
desc: string;
|
||||
detail?: string;
|
||||
files?: string;
|
||||
blockedBy?: string[];
|
||||
}
|
||||
|
||||
const STATUS_COLORS: Record<Status, string> = {
|
||||
done: 'var(--green)',
|
||||
partial: 'var(--accent)',
|
||||
missing: 'var(--red)',
|
||||
blocked: 'var(--muted)',
|
||||
};
|
||||
|
||||
const STATUS_LABELS: Record<Status, string> = {
|
||||
done: 'DONE',
|
||||
partial: 'PARTIAL',
|
||||
missing: 'MISSING',
|
||||
blocked: 'BLOCKED',
|
||||
};
|
||||
|
||||
const LAYER_LABELS: Record<Layer, string> = {
|
||||
backend: 'SpacetimeDB',
|
||||
frontend: 'Game UI',
|
||||
scene: '3D Scene',
|
||||
prototype: 'Prototype Ready',
|
||||
};
|
||||
|
||||
const items: TodoItem[] = [
|
||||
// ── PHASE 0: LOCAL SKELETON ──
|
||||
{
|
||||
id: 'P0-B1',
|
||||
title: 'Player identity & connection',
|
||||
phase: '0',
|
||||
layer: 'backend',
|
||||
status: 'done',
|
||||
desc: 'Player table, connectPlayer, renamePlayer, ping reducers. Auth token persistence.',
|
||||
files: 'services/spacetimedb/src/index.ts:143-218',
|
||||
},
|
||||
{
|
||||
id: 'P0-B2',
|
||||
title: 'World seeding (single system)',
|
||||
phase: '0',
|
||||
layer: 'backend',
|
||||
status: 'done',
|
||||
desc: 'seedWorld creates Solace system, station, 2 POIs. Hardcoded constants.',
|
||||
files: 'services/spacetimedb/src/index.ts:138-141, 458-500',
|
||||
},
|
||||
{
|
||||
id: 'P0-B3',
|
||||
title: 'Ship state & wallet',
|
||||
phase: '0',
|
||||
layer: 'backend',
|
||||
status: 'done',
|
||||
desc: 'Ship table (position, flightMode, dockedStationId, cargoCapacity). Wallet table with ISK.',
|
||||
files: 'services/spacetimedb/src/index.ts:28-45, 93-100',
|
||||
},
|
||||
{
|
||||
id: 'P0-B4',
|
||||
title: 'Server event logging',
|
||||
phase: '0',
|
||||
layer: 'backend',
|
||||
status: 'done',
|
||||
desc: 'ServerEvent table. Every reducer writes a typed event.',
|
||||
files: 'services/spacetimedb/src/index.ts:114-123, 580-588',
|
||||
},
|
||||
{
|
||||
id: 'P0-F1',
|
||||
title: 'SpacetimeDB client connection',
|
||||
phase: '0',
|
||||
layer: 'frontend',
|
||||
status: 'done',
|
||||
desc: 'Connection lifecycle, table subscriptions, auth token, re-render triggers.',
|
||||
files: 'apps/game/src/spacetime/client.ts, useSpacetimeConnection.ts, useGameSession.ts',
|
||||
},
|
||||
{
|
||||
id: 'P0-F2',
|
||||
title: 'Game shell layout',
|
||||
phase: '0',
|
||||
layer: 'frontend',
|
||||
status: 'partial',
|
||||
desc: 'Three-column responsive layout with left/right sidebars and center 3D viewport. Missing: center panel area is empty (hidden on large screens). No loading/splash screen.',
|
||||
detail: 'Middle column <section> is hidden lg:block with no content. No station mode panel swap. Inline "Pilot" info should be its own component.',
|
||||
files: 'apps/game/src/GameShell.tsx:70',
|
||||
},
|
||||
{
|
||||
id: 'P0-F3',
|
||||
title: 'Connection panel',
|
||||
phase: '0',
|
||||
layer: 'frontend',
|
||||
status: 'done',
|
||||
desc: 'Connection status, identity, transport/reducer messages, pilot name input, rename/ping.',
|
||||
files: 'apps/game/src/ui/ConnectionPanel.tsx',
|
||||
},
|
||||
{
|
||||
id: 'P0-S1',
|
||||
title: '3D space scene foundation',
|
||||
phase: '0',
|
||||
layer: 'scene',
|
||||
status: 'partial',
|
||||
desc: 'R3F Canvas, environment (stars, fog, lights), station mesh, asteroid mesh, ship mesh. All clickable.',
|
||||
detail: 'Missing: camera controls (no orbit/zoom/pan — fixed position). No sun/star light source. No nebulae. Grid plane is debug-only.',
|
||||
files: 'apps/game/src/scene/',
|
||||
},
|
||||
{
|
||||
id: 'P0-S2',
|
||||
title: 'Ship mesh with engine glow',
|
||||
phase: '0',
|
||||
layer: 'scene',
|
||||
status: 'partial',
|
||||
desc: 'Cone+box+sphere ship. Engine glow changes by flightMode. Bob/roll animation.',
|
||||
detail: 'Missing: no damage state visualization, no shield bubble, no warp animation, no mining laser beam (only flat Line), no module hardpoints visible.',
|
||||
files: 'apps/game/src/scene/ShipMesh.tsx',
|
||||
},
|
||||
|
||||
// ── PHASE 1: NAVIGATION & EXPLORATION ──
|
||||
{
|
||||
id: 'P1-B1',
|
||||
title: 'Approach operation (start + complete)',
|
||||
phase: '1',
|
||||
layer: 'backend',
|
||||
status: 'done',
|
||||
desc: 'Timed approach with 5s duration. Validates undocked, target selected, target in system. Position update on complete.',
|
||||
files: 'services/spacetimedb/src/index.ts:248-299',
|
||||
},
|
||||
{
|
||||
id: 'P1-B2',
|
||||
title: 'Target selection',
|
||||
phase: '1',
|
||||
layer: 'backend',
|
||||
status: 'done',
|
||||
desc: 'selectTarget validates POI exists and is in current system.',
|
||||
files: 'services/spacetimedb/src/index.ts:236-246',
|
||||
},
|
||||
{
|
||||
id: 'P1-B3',
|
||||
title: 'Warp travel between systems',
|
||||
phase: '1',
|
||||
layer: 'backend',
|
||||
status: 'missing',
|
||||
desc: 'No stargate table, no warp sequence, no jump mechanics. Ship is locked to single system (Solace).',
|
||||
blockedBy: ['P5-B3'],
|
||||
},
|
||||
{
|
||||
id: 'P1-B4',
|
||||
title: 'Stargate network',
|
||||
phase: '1',
|
||||
layer: 'backend',
|
||||
status: 'missing',
|
||||
desc: 'No stargate table. No gate activation range, jump cooldown, gate cloak. Design describes full stargate mechanics.',
|
||||
},
|
||||
{
|
||||
id: 'P1-F1',
|
||||
title: 'Mini star map (SVG)',
|
||||
phase: '1',
|
||||
layer: 'frontend',
|
||||
status: 'done',
|
||||
desc: '2D SVG top-down system map showing POIs, ship position, operation lines, click-to-select.',
|
||||
files: 'apps/game/src/ui/MiniStarMap.tsx',
|
||||
},
|
||||
{
|
||||
id: 'P1-F2',
|
||||
title: 'Target panel with POI list',
|
||||
phase: '1',
|
||||
layer: 'frontend',
|
||||
status: 'partial',
|
||||
desc: 'Shows selected target and clickable POI list.',
|
||||
detail: 'Missing: no distance to target, no spatial grouping, no range info, no sortable overview-style list.',
|
||||
files: 'apps/game/src/ui/TargetPanel.tsx',
|
||||
},
|
||||
{
|
||||
id: 'P1-F3',
|
||||
title: 'Command rail (contextual actions)',
|
||||
phase: '1',
|
||||
layer: 'frontend',
|
||||
status: 'partial',
|
||||
desc: 'Bottom-fixed action bar with contextual buttons and progress bars for operations.',
|
||||
detail: 'Missing: no abort/cancel operation, no warp/jump commands, no combat commands, no station service buttons, keyboard shortcut hints not visible.',
|
||||
files: 'apps/game/src/ui/CommandRail.tsx',
|
||||
},
|
||||
{
|
||||
id: 'P1-F4',
|
||||
title: 'Keyboard shortcuts',
|
||||
phase: '1',
|
||||
layer: 'frontend',
|
||||
status: 'partial',
|
||||
desc: 'U=undock, D=dock, A=approach, M=mine, 1-9=select target.',
|
||||
detail: 'Missing: no shortcut for sell, refine, fit, combat, warp. No modifier keys. No shortcut discovery overlay.',
|
||||
files: 'apps/game/src/ui/useKeyboardShortcuts.ts',
|
||||
},
|
||||
{
|
||||
id: 'P1-S1',
|
||||
title: 'Ship position interpolation during approach',
|
||||
phase: '1',
|
||||
layer: 'scene',
|
||||
status: 'done',
|
||||
desc: 'Lerp-based smooth movement from current position to target during approach operation.',
|
||||
files: 'apps/game/src/scene/GameSpaceScene.tsx',
|
||||
},
|
||||
{
|
||||
id: 'P1-S2',
|
||||
title: 'Camera controls (orbit, zoom, pan)',
|
||||
phase: '1',
|
||||
layer: 'scene',
|
||||
status: 'missing',
|
||||
desc: 'Camera is fixed at [8,9,44] with gentle sine drift. No OrbitControls, no zoom, no user camera interaction.',
|
||||
},
|
||||
{
|
||||
id: 'P1-S3',
|
||||
title: 'Warp/travel visual effects',
|
||||
phase: '1',
|
||||
layer: 'scene',
|
||||
status: 'missing',
|
||||
desc: 'No warp tunnel effect, no gate jump animation, no system transition. Prototype has a full warp sequence demo.',
|
||||
files: 'Prototype ref: apps/docs/src/prototypes/existing-demos/WarpTravelDemo.tsx',
|
||||
},
|
||||
|
||||
// ── PHASE 2: MINING, INVENTORY & TRADE ──
|
||||
{
|
||||
id: 'P2-B1',
|
||||
title: 'Mining operation (start + complete)',
|
||||
phase: '2',
|
||||
layer: 'backend',
|
||||
status: 'done',
|
||||
desc: 'Timed mining with 6s duration. Validates undocked, at asteroid belt, cargo not full. Adds 1000 Veldspar to cargo.',
|
||||
files: 'services/spacetimedb/src/index.ts:326-409',
|
||||
},
|
||||
{
|
||||
id: 'P2-B2',
|
||||
title: 'Cargo/inventory system',
|
||||
phase: '2',
|
||||
layer: 'backend',
|
||||
status: 'partial',
|
||||
desc: 'CargoItem table with stacking. Capacity tracking.',
|
||||
detail: 'Missing: only "ore" category used. No mineral, module, loot, commodity categories. No jettison. No item splitting.',
|
||||
files: 'services/spacetimedb/src/index.ts:79-91',
|
||||
},
|
||||
{
|
||||
id: 'P2-B3',
|
||||
title: 'NPC ore market (sell only)',
|
||||
phase: '2',
|
||||
layer: 'backend',
|
||||
status: 'partial',
|
||||
desc: 'sellOreToNpcMarket validates docked, ore category, quantity. Credits wallet.',
|
||||
detail: 'Missing: only 1 ore type (Veldspar) at fixed 12 ISK. No NPC buy orders for minerals/modules. No sell-side NPC. No dynamic pricing. No regional variation. Prices hardcoded in both backend AND frontend.',
|
||||
files: 'services/spacetimedb/src/index.ts:411-456',
|
||||
},
|
||||
{
|
||||
id: 'P2-B4',
|
||||
title: 'Dynamic NPC pricing (supply/demand)',
|
||||
phase: '2',
|
||||
layer: 'backend',
|
||||
status: 'missing',
|
||||
desc: 'Design describes full NPC pricing algorithm with EMA, demand pressure, regional seeds, anti-arbitrage. Nothing implemented.',
|
||||
},
|
||||
{
|
||||
id: 'P2-B5',
|
||||
title: 'Regional price differences',
|
||||
phase: '2',
|
||||
layer: 'backend',
|
||||
status: 'missing',
|
||||
desc: 'Single system only. No way for prices to differ between stations.',
|
||||
blockedBy: ['P1-B3', 'P5-B3'],
|
||||
},
|
||||
{
|
||||
id: 'P2-F1',
|
||||
title: 'Cargo panel',
|
||||
phase: '2',
|
||||
layer: 'frontend',
|
||||
status: 'partial',
|
||||
desc: 'Displays cargo items with name, quantity, category, unit price.',
|
||||
detail: 'Missing: no jettison button, no item stacking/splitting, no item details, no ore-type filtering.',
|
||||
files: 'apps/game/src/ui/CargoPanel.tsx',
|
||||
},
|
||||
{
|
||||
id: 'P2-F2',
|
||||
title: 'NPC market panel',
|
||||
phase: '2',
|
||||
layer: 'frontend',
|
||||
status: 'partial',
|
||||
desc: 'Sell ore UI with quantity input, max button, payout preview. Only visible when docked.',
|
||||
detail: 'Missing: only Veldspar. Prices hardcoded client-side in NPC_PRICES constant. No buy orders, no order book, no price history, no charts.',
|
||||
files: 'apps/game/src/ui/NpcMarketPanel.tsx:5-7',
|
||||
},
|
||||
{
|
||||
id: 'P2-F3',
|
||||
title: 'Wallet panel',
|
||||
phase: '2',
|
||||
layer: 'frontend',
|
||||
status: 'partial',
|
||||
desc: 'Shows ISK balance.',
|
||||
detail: 'Missing: no transaction history, no income/expenditure breakdown.',
|
||||
files: 'apps/game/src/ui/WalletPanel.tsx',
|
||||
},
|
||||
{
|
||||
id: 'P2-F4',
|
||||
title: 'Ship status panel',
|
||||
phase: '2',
|
||||
layer: 'frontend',
|
||||
status: 'partial',
|
||||
desc: 'Flight mode badge, hull/shield/speed bars, position, system, operation countdown.',
|
||||
detail: 'HARDCODED: hullPct=100 always (line 44), shieldPct=Math.random() (line 45), cargoPct always 0 (line 46), speed labels are hardcoded strings (lines 32-42). No armor layer. No capacitor. No power allocation. No module activation indicators.',
|
||||
files: 'apps/game/src/ui/ShipStatusPanel.tsx:44-46',
|
||||
},
|
||||
{
|
||||
id: 'P2-S1',
|
||||
title: 'Asteroid rendering (per-belt clusters)',
|
||||
phase: '2',
|
||||
layer: 'scene',
|
||||
status: 'partial',
|
||||
desc: '3 asteroids per belt with hardcoded offsets, tumble animation, clickable.',
|
||||
detail: 'Missing: all identical geometry/color. No ore type differentiation. No depletion state. No mining laser beam (flat Line only).',
|
||||
files: 'apps/game/src/scene/AsteroidMesh.tsx, GameSpaceScene.tsx:120-124',
|
||||
},
|
||||
{
|
||||
id: 'P2-S2',
|
||||
title: 'Mining laser beam visual',
|
||||
phase: '2',
|
||||
layer: 'scene',
|
||||
status: 'missing',
|
||||
desc: 'Only a flat amber Line drawn during mining. No beam/particle effect connecting ship to asteroid.',
|
||||
},
|
||||
|
||||
// ── PHASE 3: COMBAT — FTL POWER ALLOCATION ──
|
||||
{
|
||||
id: 'P3-B1',
|
||||
title: 'NPC pirate entity system',
|
||||
phase: '3',
|
||||
layer: 'backend',
|
||||
status: 'missing',
|
||||
desc: 'No npc_entities, npc_class_templates, or loot_tables. Design describes full NPC spawning, AI behavior, state machine (IDLE→AGGRO→COMBAT→FLEE→DEAD).',
|
||||
},
|
||||
{
|
||||
id: 'P3-B2',
|
||||
title: 'Combat encounter state',
|
||||
phase: '3',
|
||||
layer: 'backend',
|
||||
status: 'missing',
|
||||
desc: 'No combat session, no damage resolution, no shield/armor/hull tracking, no weapon cycling.',
|
||||
},
|
||||
{
|
||||
id: 'P3-B3',
|
||||
title: 'FTL power allocation (weapons/shields/engines/aux)',
|
||||
phase: '3',
|
||||
layer: 'backend',
|
||||
status: 'missing',
|
||||
desc: 'Design describes 4 subsystems with powered/unpowered behavior, reroute timing (1.5-3s), and failure modes. Nothing implemented.',
|
||||
},
|
||||
{
|
||||
id: 'P3-B4',
|
||||
title: 'Ship health model (shield/armor/hull)',
|
||||
phase: '3',
|
||||
layer: 'backend',
|
||||
status: 'missing',
|
||||
desc: 'Ship table has no health columns. Design describes 3 HP pools with different damage resistance.',
|
||||
},
|
||||
{
|
||||
id: 'P3-B5',
|
||||
title: 'Bounty & loot drop system',
|
||||
phase: '3',
|
||||
layer: 'backend',
|
||||
status: 'missing',
|
||||
desc: 'No bounty ISK awards, no loot tables, no wreck/loot containers. Design describes full bounty tiers and module drops.',
|
||||
},
|
||||
{
|
||||
id: 'P3-B6',
|
||||
title: 'Ship destruction & respawn',
|
||||
phase: '3',
|
||||
layer: 'backend',
|
||||
status: 'missing',
|
||||
desc: 'No death mechanic, no respawn, no insurance payout. Design describes rookie frigate respawn, loot drops, insurance.',
|
||||
},
|
||||
{
|
||||
id: 'P3-F1',
|
||||
title: 'Combat HUD (target lock, power allocation, damage)',
|
||||
phase: '3',
|
||||
layer: 'frontend',
|
||||
status: 'missing',
|
||||
desc: 'No combat UI at all. Design describes target lock reticle, power allocation bars (W/S/E/Aux), shield/armor/hull wheel, capacitor gauge, weapon timer, combat log.',
|
||||
files: 'Prototype ref: apps/docs/src/prototypes/existing-demos/CombatDemo.tsx, game-slice/ui/SliceCombatStage.tsx',
|
||||
},
|
||||
{
|
||||
id: 'P3-S1',
|
||||
title: 'Combat 3D scene (projectiles, shields, explosions)',
|
||||
phase: '3',
|
||||
layer: 'scene',
|
||||
status: 'missing',
|
||||
desc: 'No NPC ships rendered, no projectiles, no shield effects, no explosions, no damage numbers.',
|
||||
files: 'Prototype ref: apps/docs/src/prototypes/r3f/combat/CombatScene.tsx',
|
||||
},
|
||||
|
||||
// ── PHASE 4: SHIP CUSTOMIZATION & UPGRADES ──
|
||||
{
|
||||
id: 'P4-B1',
|
||||
title: 'Ship types catalog (multiple classes)',
|
||||
phase: '4',
|
||||
layer: 'backend',
|
||||
status: 'missing',
|
||||
desc: 'Only 1 hardcoded "Starter Frigate". Design describes 5 classes (Frigate through Battleship) with full stat tables.',
|
||||
},
|
||||
{
|
||||
id: 'P4-B2',
|
||||
title: 'Module catalog & fitting system',
|
||||
phase: '4',
|
||||
layer: 'backend',
|
||||
status: 'missing',
|
||||
desc: 'No modules_catalog, no ship_fittings table. Design describes High/Med/Low slot types, CPU/PG constraints, module categories.',
|
||||
},
|
||||
{
|
||||
id: 'P4-B3',
|
||||
title: 'Fit/unfit module reducers',
|
||||
phase: '4',
|
||||
layer: 'backend',
|
||||
status: 'missing',
|
||||
desc: 'No reducers for module management. Design validates CPU/PG budget, slot type matching.',
|
||||
},
|
||||
{
|
||||
id: 'P4-B4',
|
||||
title: 'Ship purchase/acquisition',
|
||||
phase: '4',
|
||||
layer: 'backend',
|
||||
status: 'missing',
|
||||
desc: 'No ship buying, no hangar storage, no ship switching. Design describes NPC market, hangar, and rookie frigate policy.',
|
||||
},
|
||||
{
|
||||
id: 'P4-F1',
|
||||
title: 'Fitting screen UI',
|
||||
phase: '4',
|
||||
layer: 'frontend',
|
||||
status: 'missing',
|
||||
desc: 'No fitting UI. Design describes slot layout with drag-and-drop, CPU/PG bars, module catalog.',
|
||||
files: 'Prototype ref: apps/docs/src/prototypes/existing-demos/FittingDemo.tsx, game-slice/ui/SliceFittingService.tsx',
|
||||
},
|
||||
{
|
||||
id: 'P4-F2',
|
||||
title: 'Ship purchase screen',
|
||||
phase: '4',
|
||||
layer: 'frontend',
|
||||
status: 'missing',
|
||||
desc: 'No ship shop, no hangar view, no ship comparison UI.',
|
||||
},
|
||||
|
||||
// ── PHASE 5: FACTION QUESTS & DYNAMIC WORLD ──
|
||||
{
|
||||
id: 'P5-B1',
|
||||
title: 'Faction & standing system',
|
||||
phase: '5',
|
||||
layer: 'backend',
|
||||
status: 'missing',
|
||||
desc: 'No factions table, no player standing, no loyalty points. Design describes -10 to +10 standing with faction consequences.',
|
||||
},
|
||||
{
|
||||
id: 'P5-B2',
|
||||
title: 'NPC agents & mission templates',
|
||||
phase: '5',
|
||||
layer: 'backend',
|
||||
status: 'missing',
|
||||
desc: 'No npc_agents, mission_templates, active_missions tables. Design describes 6 mission types (kill, courier, mining, survey, escort, trade) with 4 reward tiers.',
|
||||
},
|
||||
{
|
||||
id: 'P5-B3',
|
||||
title: 'Procedural galaxy generation',
|
||||
phase: '5',
|
||||
layer: 'backend',
|
||||
status: 'missing',
|
||||
desc: 'Single hardcoded system (Solace). Design describes seeded RNG galaxy with MST stargate topology, regions, constellations.',
|
||||
files: 'Prototype ref: apps/docs/src/prototypes/existing-demos/GalaxyDemo.tsx',
|
||||
},
|
||||
{
|
||||
id: 'P5-B4',
|
||||
title: 'Station prosperity (dynamic world impact)',
|
||||
phase: '5',
|
||||
layer: 'backend',
|
||||
status: 'missing',
|
||||
desc: 'No station prosperity, no resource demand model. Design describes stations growing when supplied, faction leaders taking provinces.',
|
||||
},
|
||||
{
|
||||
id: 'P5-B5',
|
||||
title: 'World events (anomalies, faction conflicts)',
|
||||
phase: '5',
|
||||
layer: 'backend',
|
||||
status: 'missing',
|
||||
desc: 'No event spawning, no participation tracking, no galaxy story log. Design describes 6 event categories with 3 notification tiers.',
|
||||
},
|
||||
{
|
||||
id: 'P5-F1',
|
||||
title: 'Agent/mission panel',
|
||||
phase: '5',
|
||||
layer: 'frontend',
|
||||
status: 'missing',
|
||||
desc: 'No NPC agent list, no available missions, no mission tracker, no standing display.',
|
||||
},
|
||||
{
|
||||
id: 'P5-F2',
|
||||
title: 'Galaxy map (multi-system)',
|
||||
phase: '5',
|
||||
layer: 'frontend',
|
||||
status: 'missing',
|
||||
desc: 'Only single-system SVG map exists. No multi-system navigation, no stargate routes, no faction overlay.',
|
||||
files: 'Prototype ref: apps/docs/src/prototypes/existing-demos/StarMapDemo.tsx',
|
||||
},
|
||||
|
||||
// ── PHASE 6: INDUSTRY & ECONOMY DEPTH ──
|
||||
{
|
||||
id: 'P6-B1',
|
||||
title: 'Refining (ore → minerals)',
|
||||
phase: '6',
|
||||
layer: 'backend',
|
||||
status: 'missing',
|
||||
desc: 'No refineOre reducer, no mineral item types. Design describes 8 ore types refining to 8 minerals with skill-based yield (50-95%).',
|
||||
files: 'Prototype ref: apps/docs/src/prototypes/game-slice/sliceEconomy.ts',
|
||||
},
|
||||
{
|
||||
id: 'P6-B2',
|
||||
title: 'Manufacturing (minerals → modules/ships)',
|
||||
phase: '6',
|
||||
layer: 'backend',
|
||||
status: 'missing',
|
||||
desc: 'No blueprints, no manufacturing_jobs, no production chain. Design describes 5-tier chain: Ore → Mineral → Component → Module → Ship.',
|
||||
},
|
||||
{
|
||||
id: 'P6-B3',
|
||||
title: 'Multiple ore types',
|
||||
phase: '6',
|
||||
layer: 'backend',
|
||||
status: 'missing',
|
||||
desc: 'Only Veldspar. Design describes 8 ore types (Veldspar, Scordite, Pyroxeres, Kernite, Omber, Jaspet, Hemorphite, Arkonor).',
|
||||
},
|
||||
{
|
||||
id: 'P6-F1',
|
||||
title: 'Refining UI',
|
||||
phase: '6',
|
||||
layer: 'frontend',
|
||||
status: 'missing',
|
||||
desc: 'No refining interface. Design describes batch processing with yield preview.',
|
||||
files: 'Prototype ref: apps/docs/src/prototypes/existing-demos/RefiningDemo.tsx, game-slice/ui/SliceRefiningService.tsx',
|
||||
},
|
||||
{
|
||||
id: 'P6-F2',
|
||||
title: 'Manufacturing UI',
|
||||
phase: '6',
|
||||
layer: 'frontend',
|
||||
status: 'missing',
|
||||
desc: 'No manufacturing tab. Design describes blueprint selection, job queue, material requirements.',
|
||||
},
|
||||
{
|
||||
id: 'P6-F3',
|
||||
title: 'Market with order book & price history',
|
||||
phase: '6',
|
||||
layer: 'frontend',
|
||||
status: 'missing',
|
||||
desc: 'No order book, no charts, no bid/ask spread. Only flat NPC buy for ore.',
|
||||
files: 'Prototype ref: apps/docs/src/prototypes/existing-demos/MarketDemo.tsx',
|
||||
},
|
||||
|
||||
// ── PHASE 7: POLISH & PROCEDURAL GALAXY ──
|
||||
{
|
||||
id: 'P7-B1',
|
||||
title: 'XP & skill progression',
|
||||
phase: '7',
|
||||
layer: 'backend',
|
||||
status: 'missing',
|
||||
desc: 'No skills_catalog, no XP awards. Design describes 5 skills (Mining, Industry, Trade, Gunnery, Navigation) with level 0-5.',
|
||||
files: 'Prototype ref: apps/docs/src/prototypes/existing-demos/ProgressionDemo.tsx',
|
||||
},
|
||||
{
|
||||
id: 'P7-B2',
|
||||
title: 'Tutorial objective chain',
|
||||
phase: '7',
|
||||
layer: 'backend',
|
||||
status: 'missing',
|
||||
desc: 'No objective tracking. Prototype has 7-objective tutorial chain.',
|
||||
files: 'Prototype ref: apps/docs/src/prototypes/game-slice/sliceObjectives.ts',
|
||||
},
|
||||
{
|
||||
id: 'P7-B3',
|
||||
title: 'Zora ship AI (Tier 0)',
|
||||
phase: '7',
|
||||
layer: 'backend',
|
||||
status: 'missing',
|
||||
desc: 'No Zora state, no soul depth, no personality axes, no module gating.',
|
||||
files: 'Prototype ref: apps/docs/src/prototypes/existing-demos/ZoraDemo.tsx',
|
||||
},
|
||||
{
|
||||
id: 'P7-B4',
|
||||
title: 'CONCORD / law enforcement',
|
||||
phase: '7',
|
||||
layer: 'backend',
|
||||
status: 'missing',
|
||||
desc: 'No security status, no CONCORD response, no criminal flagging.',
|
||||
},
|
||||
{
|
||||
id: 'P7-B5',
|
||||
title: 'Insurance system',
|
||||
phase: '7',
|
||||
layer: 'backend',
|
||||
status: 'missing',
|
||||
desc: 'No insurance policies, no coverage tiers, no payout on death.',
|
||||
},
|
||||
{
|
||||
id: 'P7-F1',
|
||||
title: 'Tutorial / onboarding flow',
|
||||
phase: '7',
|
||||
layer: 'frontend',
|
||||
status: 'missing',
|
||||
desc: 'No guided mission sequence, no skip option, no Zora hints.',
|
||||
},
|
||||
{
|
||||
id: 'P7-F2',
|
||||
title: 'Full Flight Mode HUD (diegetic)',
|
||||
phase: '7',
|
||||
layer: 'frontend',
|
||||
status: 'missing',
|
||||
desc: 'No diegetic HUD overlays on 3D viewport. Design describes curved shield/armor bars, module rack, overview panel, targeting reticle, capacitor gauge.',
|
||||
files: 'Prototype ref: apps/docs/src/prototypes/existing-demos/GameHudDemo.tsx',
|
||||
},
|
||||
{
|
||||
id: 'P7-F3',
|
||||
title: 'Station Mode panel UI',
|
||||
phase: '7',
|
||||
layer: 'frontend',
|
||||
status: 'missing',
|
||||
desc: 'No station service menu. No docked-mode panel swap. GameShell shows same panels regardless of dock/flight state.',
|
||||
},
|
||||
{
|
||||
id: 'P7-F4',
|
||||
title: 'Event feed with categories',
|
||||
phase: '7',
|
||||
layer: 'frontend',
|
||||
status: 'partial',
|
||||
desc: 'Basic event log exists (12 items, no pagination).',
|
||||
detail: 'Missing: no color coding by event type, no filtering, no combat vs market vs system categorization, no click-to-zoom.',
|
||||
files: 'apps/game/src/ui/EventFeed.tsx',
|
||||
},
|
||||
{
|
||||
id: 'P7-F5',
|
||||
title: 'Fog of war / map reveal',
|
||||
phase: '7',
|
||||
layer: 'frontend',
|
||||
status: 'missing',
|
||||
desc: 'No fog of war mechanic. All POIs visible immediately in current system.',
|
||||
},
|
||||
|
||||
// ── CROSS-CUTTING / INFRASTRUCTURE ──
|
||||
{
|
||||
id: 'X-B1',
|
||||
title: 'Scheduled agents (timed server logic)',
|
||||
phase: '0',
|
||||
layer: 'backend',
|
||||
status: 'missing',
|
||||
desc: 'All game logic is reducer-call driven. No timed agents for: NPC spawning, combat ticks, economy ticks, world events, balancing.',
|
||||
},
|
||||
{
|
||||
id: 'X-F1',
|
||||
title: 'Hardcoded system/station references',
|
||||
phase: '1',
|
||||
layer: 'frontend',
|
||||
status: 'partial',
|
||||
desc: 'Client subscribes to hardcoded "solace" system and "solace-prime" station. useGameSession hardcodes these lookups.',
|
||||
detail: 'client.ts:77-78, useGameSession.ts:129-130. Must be replaced with dynamic system lookup once multi-system exists.',
|
||||
files: 'apps/game/src/spacetime/client.ts:77-78',
|
||||
},
|
||||
{
|
||||
id: 'X-F2',
|
||||
title: 'Operation auto-completion (client polling)',
|
||||
phase: '1',
|
||||
layer: 'frontend',
|
||||
status: 'partial',
|
||||
desc: 'GameSpaceScene polls Date.now() at 120ms intervals to detect operation completion and fire completeApproach/completeMiningCycle.',
|
||||
detail: 'Works but is client-driven. Design recommends server-authoritative completion via scheduled reducers.',
|
||||
files: 'apps/game/src/scene/GameSpaceScene.tsx',
|
||||
},
|
||||
{
|
||||
id: 'X-F3',
|
||||
title: 'Ambient traffic / other entities',
|
||||
phase: '2',
|
||||
layer: 'frontend',
|
||||
status: 'missing',
|
||||
desc: 'No other ships visible in space. Prototype has 4 ambient entities (3 friendly + 1 hostile) with orbital movement.',
|
||||
files: 'Prototype ref: apps/docs/src/prototypes/game-slice/ui/SliceFlightStage.tsx:78-92',
|
||||
},
|
||||
];
|
||||
|
||||
const PHASE_LABELS: Record<Phase, string> = {
|
||||
'0': 'Phase 0 — Local Skeleton',
|
||||
'1': 'Phase 1 — Navigation & Exploration',
|
||||
'2': 'Phase 2 — Mining, Inventory & Trade',
|
||||
'3': 'Phase 3 — Combat (FTL Power Allocation)',
|
||||
'4': 'Phase 4 — Ship Customization & Upgrades',
|
||||
'5': 'Phase 5 — Faction Quests & Dynamic World',
|
||||
'6': 'Phase 6 — Industry & Economy Depth',
|
||||
'7': 'Phase 7 — Polish & Procedural Galaxy',
|
||||
};
|
||||
|
||||
export function TodoPage() {
|
||||
const [filterPhase, setFilterPhase] = useState<string>('all');
|
||||
const [filterLayer, setFilterLayer] = useState<string>('all');
|
||||
const [filterStatus, setFilterStatus] = useState<string>('all');
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return items.filter(item => {
|
||||
if (filterPhase !== 'all' && item.phase !== filterPhase) return false;
|
||||
if (filterLayer !== 'all' && item.layer !== filterLayer) return false;
|
||||
if (filterStatus !== 'all' && item.status !== filterStatus) return false;
|
||||
return true;
|
||||
});
|
||||
}, [filterPhase, filterLayer, filterStatus]);
|
||||
|
||||
const counts = useMemo(() => {
|
||||
const byStatus = { done: 0, partial: 0, missing: 0, blocked: 0 };
|
||||
const byPhase: Record<string, { done: number; partial: number; missing: number; blocked: number; total: number }> = {};
|
||||
items.forEach(item => {
|
||||
byStatus[item.status]++;
|
||||
if (!byPhase[item.phase]) byPhase[item.phase] = { done: 0, partial: 0, missing: 0, blocked: 0, total: 0 };
|
||||
byPhase[item.phase][item.status]++;
|
||||
byPhase[item.phase].total++;
|
||||
});
|
||||
return { byStatus, byPhase };
|
||||
}, []);
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
const groups: Record<string, TodoItem[]> = {};
|
||||
filtered.forEach(item => {
|
||||
const key = item.phase === '0' && item.id.startsWith('X-') ? 'cross' : item.phase;
|
||||
if (!groups[key]) groups[key] = [];
|
||||
groups[key].push(item);
|
||||
});
|
||||
return groups;
|
||||
}, [filtered]);
|
||||
|
||||
const phaseOrder = ['0', '1', '2', '3', '4', '5', '6', '7', 'cross'];
|
||||
const sortedGroups = phaseOrder.filter(p => grouped[p]).map(p => ({
|
||||
key: p,
|
||||
label: p === 'cross' ? 'Cross-Cutting / Infrastructure' : PHASE_LABELS[p as Phase],
|
||||
items: grouped[p],
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-content">
|
||||
<h1 style={{ marginBottom: '8px' }}>Implementation Status</h1>
|
||||
<p style={{ color: 'var(--fg-dim)', fontSize: '0.95rem', maxWidth: '720px' }}>
|
||||
Living TODO document tracking what's built vs. what's designed. Auto-generated from codebase review on {LAST_UPDATED}.
|
||||
Each item maps to a specific phase, layer, and file reference.
|
||||
</p>
|
||||
|
||||
<div className="mb-6 grid grid-cols-[repeat(auto-fit,minmax(150px,1fr))] gap-4" style={{ marginTop: 'var(--sp-5)' }}>
|
||||
<div className="rounded-xl border border-border bg-surface p-5 max-md:rounded-lg max-md:p-4">
|
||||
<div className="font-mono text-[1.6rem] font-bold tabular-nums" style={{ color: 'var(--green)' }}>{counts.byStatus.done}</div>
|
||||
<div className="mt-1 text-[0.75rem] uppercase tracking-[0.05em] text-muted">Done</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border bg-surface p-5 max-md:rounded-lg max-md:p-4">
|
||||
<div className="font-mono text-[1.6rem] font-bold tabular-nums" style={{ color: 'var(--accent)' }}>{counts.byStatus.partial}</div>
|
||||
<div className="mt-1 text-[0.75rem] uppercase tracking-[0.05em] text-muted">Partial</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border bg-surface p-5 max-md:rounded-lg max-md:p-4">
|
||||
<div className="font-mono text-[1.6rem] font-bold tabular-nums" style={{ color: 'var(--red)' }}>{counts.byStatus.missing}</div>
|
||||
<div className="mt-1 text-[0.75rem] uppercase tracking-[0.05em] text-muted">Missing</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border bg-surface p-5 max-md:rounded-lg max-md:p-4">
|
||||
<div className="font-mono text-[1.6rem] font-bold tabular-nums" style={{ color: 'var(--muted)' }}>{counts.byStatus.blocked}</div>
|
||||
<div className="mt-1 text-[0.75rem] uppercase tracking-[0.05em] text-muted">Blocked</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-6 rounded-lg px-5 py-4 text-[0.85rem] leading-[1.5] border border-cyan/20 bg-cyan/8 text-cyan">
|
||||
<strong>Backend coverage:</strong> {BACKEND_TABLES_IMPLEMENTED} / {BACKEND_TABLES_DESIGNED} tables ({Math.round(BACKEND_TABLES_IMPLEMENTED / BACKEND_TABLES_DESIGNED * 100)}%).
|
||||
{BACKEND_REDUCERS_IMPLEMENTED} / {BACKEND_REDUCERS_DESIGNED} reducers ({Math.round(BACKEND_REDUCERS_IMPLEMENTED / BACKEND_REDUCERS_DESIGNED * 100)}%).
|
||||
All 14 interactive demos and the full game-slice prototype live in <code>apps/docs/src/prototypes/</code> using localStorage — these serve as reference implementations for migration into the SpacetimeDB backend.
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 'var(--sp-2)', marginBottom: 'var(--sp-6)', flexWrap: 'wrap' }}>
|
||||
<select
|
||||
value={filterPhase}
|
||||
onChange={e => setFilterPhase(e.target.value)}
|
||||
className="rounded-lg border border-border bg-surface-raised px-3 py-1.5 font-mono text-[0.75rem] text-fg"
|
||||
>
|
||||
<option value="all">All Phases</option>
|
||||
{Object.entries(PHASE_LABELS).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
|
||||
<option value="cross">Cross-Cutting</option>
|
||||
</select>
|
||||
<select
|
||||
value={filterLayer}
|
||||
onChange={e => setFilterLayer(e.target.value)}
|
||||
className="rounded-lg border border-border bg-surface-raised px-3 py-1.5 font-mono text-[0.75rem] text-fg"
|
||||
>
|
||||
<option value="all">All Layers</option>
|
||||
{Object.entries(LAYER_LABELS).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
|
||||
</select>
|
||||
<select
|
||||
value={filterStatus}
|
||||
onChange={e => setFilterStatus(e.target.value)}
|
||||
className="rounded-lg border border-border bg-surface-raised px-3 py-1.5 font-mono text-[0.75rem] text-fg"
|
||||
>
|
||||
<option value="all">All Status</option>
|
||||
{Object.entries(STATUS_LABELS).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
|
||||
</select>
|
||||
<span style={{ color: 'var(--muted)', fontSize: '0.75rem', lineHeight: '2' }}>
|
||||
{filtered.length} / {items.length} items
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{sortedGroups.map(group => {
|
||||
const phaseCounts = group.key !== 'cross' ? counts.byPhase[group.key] : null;
|
||||
const donePct = phaseCounts ? Math.round(((phaseCounts.done + phaseCounts.partial * 0.5) / phaseCounts.total) * 100) : null;
|
||||
|
||||
return (
|
||||
<div key={group.key} style={{ marginBottom: 'var(--sp-8)' }}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 'var(--sp-3)',
|
||||
marginBottom: 'var(--sp-4)',
|
||||
paddingBottom: 'var(--sp-2)',
|
||||
borderBottom: '2px solid var(--border)',
|
||||
}}>
|
||||
<h2 style={{ margin: 0, fontSize: '1rem', flex: 1 }}>{group.label}</h2>
|
||||
{phaseCounts && (
|
||||
<div style={{ display: 'flex', gap: 'var(--sp-3)', alignItems: 'center' }}>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: '0.7rem', color: 'var(--green)' }}>{phaseCounts.done} done</span>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: '0.7rem', color: 'var(--accent)' }}>{phaseCounts.partial} partial</span>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: '0.7rem', color: 'var(--red)' }}>{phaseCounts.missing + phaseCounts.blocked} missing</span>
|
||||
<div style={{
|
||||
width: '80px',
|
||||
height: '6px',
|
||||
borderRadius: '3px',
|
||||
background: 'var(--surface-raised)',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<div style={{
|
||||
width: `${donePct}%`,
|
||||
height: '100%',
|
||||
borderRadius: '3px',
|
||||
background: donePct > 70 ? 'var(--green)' : donePct > 30 ? 'var(--accent)' : 'var(--red)',
|
||||
}} />
|
||||
</div>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: '0.7rem', color: 'var(--fg-dim)' }}>{donePct}%</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="w-full border-collapse" style={{ overflowX: 'auto' }}>
|
||||
<table className="w-full border-collapse text-[0.85rem] max-md:block max-md:overflow-x-auto max-md:rounded-lg max-md:border max-md:border-border max-md:[&_thead]:table max-md:[&_tbody]:table max-md:[&_thead]:min-w-[680px] max-md:[&_tbody]:min-w-[680px] [&_th]:whitespace-nowrap [&_th]:border-b [&_th]:border-border [&_th]:px-4 [&_th]:py-3 [&_th]:text-left [&_th]:font-mono [&_th]:text-[0.7rem] [&_th]:uppercase [&_th]:tracking-[0.06em] [&_th]:text-muted [&_td]:border-b [&_td]:border-border [&_td]:px-4 [&_td]:py-3 [&_td]:font-mono [&_td]:text-[0.8rem] [&_td]:text-fg [&_tr:hover_td]:bg-surface-raised">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Status</th>
|
||||
<th>Layer</th>
|
||||
<th>Title</th>
|
||||
<th>Details</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{group.items.map(item => (
|
||||
<tr key={item.id}>
|
||||
<td style={{ color: 'var(--muted)', fontSize: '0.7rem' }}>{item.id}</td>
|
||||
<td>
|
||||
<span style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
padding: '2px 8px',
|
||||
borderRadius: 'var(--radius-pill)',
|
||||
fontSize: '0.65rem',
|
||||
fontWeight: 600,
|
||||
fontFamily: 'var(--font-mono)',
|
||||
background: item.status === 'done' ? 'rgba(34,197,94,0.1)' : item.status === 'partial' ? 'rgba(240,160,48,0.1)' : item.status === 'blocked' ? 'var(--surface-raised)' : 'rgba(239,68,68,0.1)',
|
||||
color: STATUS_COLORS[item.status],
|
||||
border: `1px solid ${STATUS_COLORS[item.status]}33`,
|
||||
}}>
|
||||
{STATUS_LABELS[item.status]}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ color: 'var(--fg-dim)', fontSize: '0.7rem' }}>{LAYER_LABELS[item.layer]}</td>
|
||||
<td style={{ fontWeight: 500, fontFamily: 'var(--font-body)', color: 'var(--fg)' }}>{item.title}</td>
|
||||
<td>
|
||||
<div style={{ color: 'var(--fg-dim)', fontSize: '0.8rem', fontFamily: 'var(--font-body)' }}>{item.desc}</div>
|
||||
{item.detail && (
|
||||
<div style={{ color: 'var(--accent)', fontSize: '0.75rem', marginTop: '4px', padding: '4px 8px', background: 'rgba(240,160,48,0.06)', borderRadius: '4px', borderLeft: '2px solid var(--accent)' }}>
|
||||
{item.detail}
|
||||
</div>
|
||||
)}
|
||||
{item.files && (
|
||||
<div style={{ color: 'var(--muted)', fontSize: '0.65rem', marginTop: '4px', fontFamily: 'var(--font-mono)' }}>
|
||||
{item.files}
|
||||
</div>
|
||||
)}
|
||||
{item.blockedBy && (
|
||||
<div style={{ color: 'var(--muted)', fontSize: '0.65rem', marginTop: '4px' }}>
|
||||
Blocked by: {item.blockedBy.join(', ')}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,4 +7,25 @@ export default defineConfig({
|
||||
jsx: "automatic",
|
||||
jsxImportSource: "react",
|
||||
},
|
||||
build: {
|
||||
// Generate source maps for production debugging
|
||||
sourcemap: true,
|
||||
// Optimize chunk splitting for better caching
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: {
|
||||
// Bundle three.js and related libraries separately
|
||||
"three-vendor": ["three", "@react-three/fiber", "@react-three/drei"],
|
||||
// Bundle React separately (more stable across updates)
|
||||
"react-vendor": ["react", "react-dom", "react-router-dom"],
|
||||
},
|
||||
},
|
||||
},
|
||||
// Chunk size warning limit (in kB)
|
||||
chunkSizeWarningLimit: 1000,
|
||||
},
|
||||
// Optimize dependency pre-bundling
|
||||
optimizeDeps: {
|
||||
include: ["three", "@react-three/fiber", "@react-three/drei"],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -107,6 +107,11 @@ pub fn spawn_camera(mut commands: Commands) {
|
||||
.looking_at(orbit.target, Vec3::Y),
|
||||
MainCamera,
|
||||
orbit,
|
||||
Camera {
|
||||
// Customize clear color for space background
|
||||
clear_color: ClearColorConfig::Custom(Color::srgb(0.02, 0.02, 0.05)),
|
||||
..default()
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
@@ -121,7 +126,7 @@ const ORBIT_MAX_DISTANCE: f32 = 1500.0;
|
||||
/// Drag is suppressed when the cursor is over any UI node — otherwise clicking
|
||||
/// buttons or panels would also rotate the camera.
|
||||
///
|
||||
/// Only runs when camera mode is Orbit.
|
||||
/// Only runs when camera mode is Orbit or Follow (for tactical repositioning).
|
||||
pub fn orbit_camera_control(
|
||||
camera_state: Res<CameraState>,
|
||||
mouse_input: Res<ButtonInput<MouseButton>>,
|
||||
@@ -131,8 +136,8 @@ pub fn orbit_camera_control(
|
||||
mut query: Query<(&mut Transform, &mut OrbitCamera), With<MainCamera>>,
|
||||
ui_nodes: Query<(&bevy::ui::ComputedNode, &GlobalTransform)>,
|
||||
) {
|
||||
// Only run orbit controls in Orbit mode
|
||||
if camera_state.mode != CameraMode::Orbit {
|
||||
// Only run orbit controls in Orbit or Follow mode (not Cinematic)
|
||||
if !camera_state.mode.is_orbit() && !camera_state.mode.is_follow() {
|
||||
mouse_motion.clear();
|
||||
scroll_events.clear();
|
||||
return;
|
||||
|
||||
355
apps/game/src/gameplay/ai/behavior.rs
Normal file
355
apps/game/src/gameplay/ai/behavior.rs
Normal file
@@ -0,0 +1,355 @@
|
||||
//! AI behavior execution systems.
|
||||
//!
|
||||
//! Systems that execute specific behaviors for NPCs: patrol, combat,
|
||||
//! flee, mining, and trading.
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
use crate::gameplay::ai::{
|
||||
AiConfig, AiNavigation, AiState, BehaviorState, CombatState, FactionAiBehavior,
|
||||
FleeState, MiningState, PatrolState, StateTransition, TradingState,
|
||||
};
|
||||
use crate::gameplay::movement::components::MoveTarget;
|
||||
use crate::gameplay::ai::perception::{Perception, PerceptionEvent, PerceptionEventType};
|
||||
|
||||
/// Update state transitions for all AI entities.
|
||||
///
|
||||
/// This system evaluates conditions and triggers state transitions
|
||||
/// based on AI state, perception, and faction behavior.
|
||||
pub fn update_state_transitions(
|
||||
mut commands: Commands,
|
||||
ai_config: Res<AiConfig>,
|
||||
mut ai_query: Query<(
|
||||
Entity,
|
||||
&mut AiState,
|
||||
&Perception,
|
||||
&FactionAiBehavior,
|
||||
)>,
|
||||
mut state_events: EventWriter<StateTransition>,
|
||||
mut perception_events: EventReader<PerceptionEvent>,
|
||||
time: Res<Time>,
|
||||
) {
|
||||
// First, handle perception events that might trigger state changes
|
||||
for event in perception_events.read() {
|
||||
if let Ok((_, mut ai_state, perception, faction_behavior)) =
|
||||
ai_query.get_mut(event.observer)
|
||||
{
|
||||
match event.event_type {
|
||||
PerceptionEventType::HostileDetected => {
|
||||
// Transition to combat if we're not already fleeing
|
||||
if ai_state.behavior != BehaviorState::Combat
|
||||
&& ai_state.behavior != BehaviorState::Flee
|
||||
{
|
||||
let old_state = ai_state.behavior;
|
||||
ai_state.behavior = BehaviorState::Combat;
|
||||
ai_state.target_entity = Some(event.perceived);
|
||||
|
||||
state_events.send(StateTransition {
|
||||
entity: event.observer,
|
||||
from_state: old_state,
|
||||
to_state: BehaviorState::Combat,
|
||||
reason: "Hostile detected".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
PerceptionEventType::Lost => {
|
||||
// Lost track of target - return to idle or patrol
|
||||
if Some(event.perceived) == ai_state.target_entity {
|
||||
let old_state = ai_state.behavior;
|
||||
|
||||
// Return to patrol if we have patrol waypoints, else idle
|
||||
ai_state.behavior = BehaviorState::Idle;
|
||||
ai_state.target_entity = None;
|
||||
ai_state.target_position = None;
|
||||
|
||||
state_events.send(StateTransition {
|
||||
entity: event.observer,
|
||||
from_state: old_state,
|
||||
to_state: BehaviorState::Idle,
|
||||
reason: "Target lost".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Then, update time-based transitions
|
||||
for (entity, mut ai_state, perception, faction_behavior) in ai_query.iter_mut() {
|
||||
ai_state.time_in_state += time.delta_secs();
|
||||
|
||||
match ai_state.behavior {
|
||||
BehaviorState::Idle => {
|
||||
// Check for threats in idle state
|
||||
if let Some(threat) = perception.primary_threat {
|
||||
if faction_behavior.aggressiveness > 0.5 {
|
||||
let old_state = ai_state.behavior;
|
||||
ai_state.behavior = BehaviorState::Combat;
|
||||
ai_state.target_entity = Some(threat);
|
||||
|
||||
state_events.send(StateTransition {
|
||||
entity,
|
||||
from_state: old_state,
|
||||
to_state: BehaviorState::Combat,
|
||||
reason: "Threat detected in idle".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
BehaviorState::Combat => {
|
||||
// Check hull threshold for fleeing
|
||||
if ai_state.hull_percentage < faction_behavior.flee_threshold() {
|
||||
let old_state = ai_state.behavior;
|
||||
ai_state.behavior = BehaviorState::Flee;
|
||||
|
||||
state_events.send(StateTransition {
|
||||
entity,
|
||||
from_state: old_state,
|
||||
to_state: BehaviorState::Flee,
|
||||
reason: "Hull critical".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
BehaviorState::Flee => {
|
||||
// Check if we've reached safe distance
|
||||
// TODO: Implement distance check from threat
|
||||
// For now, NPCs stay in flee until they detect a safe distance
|
||||
}
|
||||
BehaviorState::Patrol | BehaviorState::Mining | BehaviorState::Trading => {
|
||||
// Continue current behavior until interrupted
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Execute patrol behavior for NPCs.
|
||||
///
|
||||
/// NPCs with patrol behavior follow waypoints through their assigned area.
|
||||
pub fn execute_patrol(
|
||||
mut commands: Commands,
|
||||
mut ai_query: Query<(
|
||||
Entity,
|
||||
&mut AiState,
|
||||
&mut PatrolState,
|
||||
&mut AiNavigation,
|
||||
&Transform,
|
||||
)>,
|
||||
time: Res<Time>,
|
||||
) {
|
||||
for (entity, mut ai_state, mut patrol_state, mut navigation, transform) in ai_query.iter_mut()
|
||||
{
|
||||
// Update time at waypoint if we're waiting
|
||||
if navigation.movement_target.is_none() && !patrol_state.waypoints.is_empty() {
|
||||
patrol_state.time_at_waypoint += time.delta_secs();
|
||||
|
||||
if patrol_state.time_at_waypoint >= patrol_state.waypoint_wait_time {
|
||||
// Move to next waypoint
|
||||
patrol_state.time_at_waypoint = 0.0;
|
||||
patrol_state.current_waypoint = (patrol_state.current_waypoint + 1)
|
||||
% patrol_state.waypoints.len();
|
||||
|
||||
let target = patrol_state.waypoints[patrol_state.current_waypoint];
|
||||
ai_state.target_position = Some(target);
|
||||
|
||||
// Assign movement target
|
||||
commands.entity(entity).insert(MoveTarget(target));
|
||||
navigation.movement_target = Some(target);
|
||||
navigation.is_moving = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Execute combat behavior for NPCs.
|
||||
///
|
||||
/// NPCs in combat engage their targets with appropriate tactics.
|
||||
pub fn execute_combat(
|
||||
mut commands: Commands,
|
||||
ai_config: Res<AiConfig>,
|
||||
mut ai_query: Query<(
|
||||
Entity,
|
||||
&mut AiState,
|
||||
&mut CombatState,
|
||||
&mut AiNavigation,
|
||||
&FactionAiBehavior,
|
||||
&Transform,
|
||||
)>,
|
||||
target_query: Query<&Transform>,
|
||||
time: Res<Time>,
|
||||
) {
|
||||
for (entity, mut ai_state, mut combat_state, mut navigation, faction_behavior, transform) in
|
||||
ai_query.iter_mut()
|
||||
{
|
||||
// Get target transform if we have one
|
||||
let target_transform = if let Some(target_entity) = combat_state.target {
|
||||
target_query.get(target_entity).ok()
|
||||
} else if let Some(target_entity) = ai_state.target_entity {
|
||||
combat_state.target = Some(target_entity);
|
||||
target_query.get(target_entity).ok()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if let Some(target_tf) = target_transform {
|
||||
let distance = transform.translation.distance(target_tf.translation);
|
||||
|
||||
// Move to preferred engagement distance
|
||||
let preferred_distance = faction_behavior.preferred_engagement_distance();
|
||||
let distance_diff = distance - preferred_distance;
|
||||
|
||||
if distance_diff.abs() > 20.0 {
|
||||
// Need to adjust distance
|
||||
let direction = (target_tf.translation - transform.translation).normalize();
|
||||
let target_pos = if distance > preferred_distance {
|
||||
// Move closer
|
||||
transform.translation + direction * (distance - preferred_distance) * 0.5
|
||||
} else {
|
||||
// Move away
|
||||
transform.translation - direction * (preferred_distance - distance) * 0.5
|
||||
};
|
||||
|
||||
commands.entity(entity).insert(MoveTarget(target_pos));
|
||||
navigation.movement_target = Some(target_pos);
|
||||
navigation.is_moving = true;
|
||||
} else {
|
||||
// At optimal distance, stop moving
|
||||
navigation.is_moving = false;
|
||||
commands.entity(entity).remove::<MoveTarget>();
|
||||
}
|
||||
|
||||
// Update attack timing
|
||||
combat_state.time_since_last_attack += time.delta_secs();
|
||||
|
||||
if combat_state.time_since_last_attack >= combat_state.attack_cooldown {
|
||||
// Attack!
|
||||
combat_state.time_since_last_attack = 0.0;
|
||||
combat_state.is_attacking = true;
|
||||
|
||||
// In full implementation, this would trigger weapon fire
|
||||
bevy::log::debug!("Entity {:?} attacks!", entity);
|
||||
} else {
|
||||
combat_state.is_attacking = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Execute flee behavior for NPCs.
|
||||
///
|
||||
/// NPCs in flee behavior move away from threats at high speed.
|
||||
pub fn execute_flee(
|
||||
mut commands: Commands,
|
||||
ai_config: Res<AiConfig>,
|
||||
mut ai_query: Query<(
|
||||
Entity,
|
||||
&mut AiState,
|
||||
&mut FleeState,
|
||||
&mut AiNavigation,
|
||||
&Transform,
|
||||
)>,
|
||||
threat_query: Query<&Transform>,
|
||||
) {
|
||||
for (entity, mut ai_state, mut flee_state, mut navigation, transform) in ai_query.iter_mut() {
|
||||
// Get threat position
|
||||
let threat_transform = if let Some(threat_entity) = flee_state.threat {
|
||||
threat_query.get(threat_entity).ok()
|
||||
} else if let Some(threat_entity) = ai_state.target_entity {
|
||||
flee_state.threat = Some(threat_entity);
|
||||
threat_query.get(threat_entity).ok()
|
||||
} else {
|
||||
// No threat - return to idle
|
||||
ai_state.behavior = BehaviorState::Idle;
|
||||
continue;
|
||||
};
|
||||
|
||||
if let Some(threat_tf) = threat_transform {
|
||||
// Calculate flee direction (away from threat)
|
||||
let away_from_threat = (transform.translation - threat_tf.translation).normalize();
|
||||
flee_state.flee_direction = away_from_threat;
|
||||
|
||||
// Move away at high speed
|
||||
let flee_distance = ai_config.safe_flee_distance;
|
||||
let target_pos = transform.translation + away_from_threat * flee_distance;
|
||||
|
||||
commands.entity(entity).insert(MoveTarget(target_pos));
|
||||
navigation.movement_target = Some(target_pos);
|
||||
navigation.is_moving = true;
|
||||
navigation.speed_factor = 1.2; // 120% speed when fleeing
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Execute mining behavior for NPCs.
|
||||
///
|
||||
/// NPCs with mining behavior stay at asteroid belts and mine.
|
||||
pub fn execute_mining(
|
||||
mut ai_query: Query<(
|
||||
Entity,
|
||||
&mut AiState,
|
||||
&mut MiningState,
|
||||
&mut AiNavigation,
|
||||
)>,
|
||||
time: Res<Time>,
|
||||
) {
|
||||
for (entity, mut ai_state, mut mining_state, mut navigation) in ai_query.iter_mut() {
|
||||
// Update mining time
|
||||
mining_state.mining_time += time.delta_secs();
|
||||
|
||||
// Mining is complete when cargo is full or cycle duration is reached
|
||||
if mining_state.mining_time >= mining_state.mining_cycle_duration
|
||||
|| mining_state.cargo_remaining <= 0.0
|
||||
{
|
||||
// Mining cycle complete
|
||||
mining_state.mining_time = 0.0;
|
||||
|
||||
if mining_state.cargo_remaining > 0.0 {
|
||||
// Continue mining
|
||||
bevy::log::debug!("Entity {:?} continues mining", entity);
|
||||
} else {
|
||||
// Cargo full - return to station
|
||||
ai_state.behavior = BehaviorState::Trading;
|
||||
bevy::log::debug!("Entity {:?} cargo full, returning to station", entity);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Execute trading behavior for NPCs.
|
||||
///
|
||||
/// NPCs with trading behavior travel between stations.
|
||||
pub fn execute_trading(
|
||||
mut commands: Commands,
|
||||
mut ai_query: Query<(
|
||||
Entity,
|
||||
&mut AiState,
|
||||
&mut TradingState,
|
||||
&mut AiNavigation,
|
||||
&Transform,
|
||||
)>,
|
||||
) {
|
||||
for (entity, mut ai_state, mut trading_state, mut navigation, transform) in ai_query.iter_mut() {
|
||||
// If we have waypoints, follow them
|
||||
if !trading_state.trade_route.is_empty() {
|
||||
let current_waypoint = trading_state.trade_route[trading_state.current_waypoint];
|
||||
let distance = transform.translation.distance(current_waypoint);
|
||||
|
||||
if distance < 20.0 {
|
||||
// Reached waypoint - move to next
|
||||
trading_state.current_waypoint =
|
||||
(trading_state.current_waypoint + 1) % trading_state.trade_route.len();
|
||||
|
||||
let next_waypoint = trading_state.trade_route[trading_state.current_waypoint];
|
||||
|
||||
commands.entity(entity).insert(MoveTarget(next_waypoint));
|
||||
navigation.movement_target = Some(next_waypoint);
|
||||
navigation.is_moving = true;
|
||||
} else if navigation.movement_target.is_none() {
|
||||
// Start moving
|
||||
commands.entity(entity).insert(MoveTarget(current_waypoint));
|
||||
navigation.movement_target = Some(current_waypoint);
|
||||
navigation.is_moving = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
115
apps/game/src/gameplay/ai/faction.rs
Normal file
115
apps/game/src/gameplay/ai/faction.rs
Normal file
@@ -0,0 +1,115 @@
|
||||
//! Faction-based AI behavior profiles.
|
||||
//!
|
||||
//! Different factions have different AI behaviors and response patterns.
|
||||
//! This module defines faction-specific AI characteristics.
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
/// AI behavior profile for a faction.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum FactionAiProfile {
|
||||
/// Concord: Strict law enforcement, immediate response to hostiles
|
||||
Concord,
|
||||
/// Amarr: Hierarchical, organized patrols
|
||||
Amarr,
|
||||
/// Minmatar: Aggressive, fast response
|
||||
Minmatar,
|
||||
/// Gallente: Balanced, diplomatic preference
|
||||
Gallente,
|
||||
/// Caldari: Calculated, strategic positioning
|
||||
Caldari,
|
||||
/// Pirates: Opportunistic, ambush tactics
|
||||
Pirates,
|
||||
/// Independent: Variable behavior
|
||||
Independent,
|
||||
}
|
||||
|
||||
/// Faction-specific AI behavior settings.
|
||||
#[derive(Component, Debug, Clone)]
|
||||
pub struct FactionAiBehavior {
|
||||
/// Faction this behavior belongs to
|
||||
pub faction: String,
|
||||
/// Behavior profile for this faction
|
||||
pub profile: FactionAiProfile,
|
||||
/// Combat aggressiveness (0.0 to 1.0)
|
||||
pub aggressiveness: f32,
|
||||
/// Response time to threats (seconds)
|
||||
pub response_time: f32,
|
||||
/// Preference for diplomatic solutions (0.0 to 1.0)
|
||||
pub diplomatic_preference: f32,
|
||||
/// Trade openness (0.0 to 1.0)
|
||||
pub trade_openness: f32,
|
||||
}
|
||||
|
||||
impl Default for FactionAiBehavior {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
faction: "Independent".to_string(),
|
||||
profile: FactionAiProfile::Independent,
|
||||
aggressiveness: 0.5,
|
||||
response_time: 5.0,
|
||||
diplomatic_preference: 0.5,
|
||||
trade_openness: 0.5,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FactionAiBehavior {
|
||||
/// Create faction-specific AI behavior from faction name.
|
||||
pub fn from_faction(faction: &str) -> Self {
|
||||
let profile = match faction {
|
||||
"Concord" => FactionAiProfile::Concord,
|
||||
"Amarr" => FactionAiProfile::Amarr,
|
||||
"Minmatar" => FactionAiProfile::Minmatar,
|
||||
"Gallente" => FactionAiProfile::Gallente,
|
||||
"Caldari" => FactionAiProfile::Caldari,
|
||||
"Pirates" => FactionAiProfile::Pirates,
|
||||
_ => FactionAiProfile::Independent,
|
||||
};
|
||||
|
||||
let (aggressiveness, response_time, diplomatic_preference, trade_openness) = match profile {
|
||||
FactionAiProfile::Concord => (0.9, 2.0, 0.3, 0.5), // Quick to respond, lawful
|
||||
FactionAiProfile::Amarr => (0.7, 4.0, 0.2, 0.6), // Organized but slower
|
||||
FactionAiProfile::Minmatar => (0.8, 3.0, 0.4, 0.7), // Aggressive but reasonable
|
||||
FactionAiProfile::Gallente => (0.5, 5.0, 0.8, 0.9), // Diplomatic, trade-friendly
|
||||
FactionAiProfile::Caldari => (0.6, 4.0, 0.5, 0.7), // Calculated, balanced
|
||||
FactionAiProfile::Pirates => (0.9, 1.0, 0.0, 0.1), // Very aggressive, hostile
|
||||
FactionAiProfile::Independent => (0.5, 5.0, 0.6, 0.8), // Variable
|
||||
};
|
||||
|
||||
Self {
|
||||
faction: faction.to_string(),
|
||||
profile,
|
||||
aggressiveness,
|
||||
response_time,
|
||||
diplomatic_preference,
|
||||
trade_openness,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the preferred engagement distance for this faction.
|
||||
pub fn preferred_engagement_distance(&self) -> f32 {
|
||||
match self.profile {
|
||||
FactionAiProfile::Concord => 80.0, // Close range for law enforcement
|
||||
FactionAiProfile::Amarr => 120.0, // Mid-range, organized
|
||||
FactionAiProfile::Minmatar => 60.0, // Close range, aggressive
|
||||
FactionAiProfile::Gallente => 100.0, // Mid-range, balanced
|
||||
FactionAiProfile::Caldari => 150.0, // Long range, strategic
|
||||
FactionAiProfile::Pirates => 50.0, // Very close, ambush
|
||||
FactionAiProfile::Independent => 100.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the flee threshold for this faction.
|
||||
pub fn flee_threshold(&self) -> f32 {
|
||||
match self.profile {
|
||||
FactionAiProfile::Concord => 0.1, // Rarely flee (law enforcement)
|
||||
FactionAiProfile::Amarr => 0.25, // Flee at 25% hull
|
||||
FactionAiProfile::Minmatar => 0.3, // Flee at 30% hull
|
||||
FactionAiProfile::Gallente => 0.35,
|
||||
FactionAiProfile::Caldari => 0.2, // Calculated retreat
|
||||
FactionAiProfile::Pirates => 0.5, // Flee easily (opportunistic)
|
||||
FactionAiProfile::Independent => 0.35,
|
||||
}
|
||||
}
|
||||
}
|
||||
87
apps/game/src/gameplay/ai/mod.rs
Normal file
87
apps/game/src/gameplay/ai/mod.rs
Normal file
@@ -0,0 +1,87 @@
|
||||
//! AI system for NPC behavior.
|
||||
//!
|
||||
//! This module provides a state machine-based AI system for NPC ships
|
||||
//! and entities. It includes:
|
||||
//!
|
||||
//! - **State Machine**: Hierarchical behavior states (Idle, Patrol, Combat, Flee, Mining, Trading)
|
||||
//! - **Navigation**: Pathfinding and movement using the existing movement system
|
||||
//! - **Perception**: Event-based detection of player and other entities
|
||||
//! - **Spawning**: NPC ship spawning based on system security and faction
|
||||
//! - **Faction AI**: Faction-specific behavior profiles
|
||||
|
||||
mod behavior;
|
||||
mod faction;
|
||||
mod navigation;
|
||||
mod perception;
|
||||
mod spawning;
|
||||
mod states;
|
||||
|
||||
pub use faction::{FactionAiBehavior, FactionAiProfile};
|
||||
pub use navigation::{AiNavigation, assign_movement_target};
|
||||
pub use perception::{Perceptible, Perception, PerceptionEvent, update_perception};
|
||||
pub use spawning::{NpcMetadata, NpcSpawner, spawn_station_traffic, spawn_system_npcs};
|
||||
pub use states::{
|
||||
AiState, AiStateMachine, BehaviorState, CombatState, FleeState,
|
||||
MiningState, PatrolState, StateTransition, TradingState,
|
||||
};
|
||||
pub use behavior::{execute_patrol, execute_combat, execute_flee, execute_mining, execute_trading};
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
/// Main AI plugin that orchestrates all AI systems.
|
||||
pub struct AiPlugin;
|
||||
|
||||
impl Plugin for AiPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.init_resource::<AiStateMachine>()
|
||||
.add_event::<PerceptionEvent>()
|
||||
.add_event::<StateTransition>()
|
||||
// AI spawning systems - run when entering in-game state
|
||||
.add_systems(OnEnter(AppState::InGame), spawning::spawn_system_npcs)
|
||||
// AI update systems - run every frame during gameplay
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
perception::update_perception,
|
||||
navigation::update_ai_navigation,
|
||||
behavior::update_state_transitions,
|
||||
behavior::execute_patrol,
|
||||
behavior::execute_combat,
|
||||
behavior::execute_flee,
|
||||
behavior::execute_mining,
|
||||
behavior::execute_trading,
|
||||
)
|
||||
.chain()
|
||||
.run_if(in_state(AppState::InGame)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Configuration resource for AI behavior tuning.
|
||||
#[derive(Resource, Debug, Clone)]
|
||||
pub struct AiConfig {
|
||||
/// Detection range for perception (in world units)
|
||||
pub perception_range: f32,
|
||||
/// How often AI updates their decisions (in seconds)
|
||||
pub decision_interval: f32,
|
||||
/// Range at which NPCs engage in combat
|
||||
pub combat_range: f32,
|
||||
/// Hull percentage at which NPCs flee
|
||||
pub flee_hull_threshold: f32,
|
||||
/// Distance considered "safe" when fleeing
|
||||
pub safe_flee_distance: f32,
|
||||
}
|
||||
|
||||
impl Default for AiConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
perception_range: 500.0,
|
||||
decision_interval: 0.5,
|
||||
combat_range: 200.0,
|
||||
flee_hull_threshold: 0.3, // 30% hull
|
||||
safe_flee_distance: 800.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
120
apps/game/src/gameplay/ai/navigation.rs
Normal file
120
apps/game/src/gameplay/ai/navigation.rs
Normal file
@@ -0,0 +1,120 @@
|
||||
//! AI navigation system.
|
||||
//!
|
||||
//! Handles AI movement and pathfinding using the existing movement system.
|
||||
//! NPCs use the same movement components as the player but are driven by
|
||||
//! AI decisions rather than player input.
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
use crate::gameplay::movement::components::MoveTarget;
|
||||
use crate::gameplay::ai::{AiConfig, AiState, BehaviorState};
|
||||
|
||||
/// Component for AI-controlled navigation.
|
||||
#[derive(Component, Debug, Clone)]
|
||||
pub struct AiNavigation {
|
||||
/// Current movement target (world position)
|
||||
pub movement_target: Option<Vec3>,
|
||||
/// Distance threshold to consider target reached
|
||||
pub arrival_distance: f32,
|
||||
/// Whether we're currently moving toward target
|
||||
pub is_moving: bool,
|
||||
/// Preferred speed (as fraction of max speed)
|
||||
pub speed_factor: f32,
|
||||
}
|
||||
|
||||
impl Default for AiNavigation {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
movement_target: None,
|
||||
arrival_distance: 10.0,
|
||||
is_moving: false,
|
||||
speed_factor: 0.8,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Assign a movement target to an AI entity.
|
||||
///
|
||||
/// This function adds or updates the MoveTarget component for an NPC
|
||||
/// and updates the AiNavigation component accordingly.
|
||||
pub fn assign_movement_target(
|
||||
commands: &mut Commands,
|
||||
entity: Entity,
|
||||
target: Vec3,
|
||||
arrival_distance: f32,
|
||||
speed_factor: f32,
|
||||
) {
|
||||
commands.entity(entity).insert(MoveTarget(target));
|
||||
commands.entity(entity).insert(AiNavigation {
|
||||
movement_target: Some(target),
|
||||
arrival_distance,
|
||||
is_moving: true,
|
||||
speed_factor,
|
||||
});
|
||||
}
|
||||
|
||||
/// Clear movement target for an AI entity.
|
||||
pub fn clear_movement_target(commands: &mut Commands, entity: Entity) {
|
||||
commands.entity(entity).remove::<MoveTarget>();
|
||||
commands.entity(entity).insert(AiNavigation {
|
||||
movement_target: None,
|
||||
arrival_distance: 10.0,
|
||||
is_moving: false,
|
||||
speed_factor: 0.8,
|
||||
});
|
||||
}
|
||||
|
||||
/// Update AI navigation based on current behavior state.
|
||||
///
|
||||
/// This system runs every frame and updates movement targets based on
|
||||
/// the NPC's current behavior state and state-specific data.
|
||||
pub fn update_ai_navigation(
|
||||
mut commands: Commands,
|
||||
ai_config: Res<AiConfig>,
|
||||
mut ai_query: Query<(
|
||||
Entity,
|
||||
&mut AiState,
|
||||
&mut AiNavigation,
|
||||
&Transform,
|
||||
)>,
|
||||
) {
|
||||
for (entity, mut ai_state, mut navigation, transform) in ai_query.iter_mut() {
|
||||
// Check if we've arrived at our current target
|
||||
if let Some(target) = navigation.movement_target {
|
||||
let distance_to_target = transform.translation.distance(target);
|
||||
if distance_to_target < navigation.arrival_distance {
|
||||
// We've arrived - clear the movement target
|
||||
navigation.is_moving = false;
|
||||
navigation.movement_target = None;
|
||||
commands.entity(entity).remove::<MoveTarget>();
|
||||
}
|
||||
}
|
||||
|
||||
// Update navigation based on behavior state
|
||||
match ai_state.behavior {
|
||||
BehaviorState::Idle => {
|
||||
// No movement in idle state
|
||||
if navigation.is_moving {
|
||||
clear_movement_target(&mut commands, entity);
|
||||
}
|
||||
}
|
||||
BehaviorState::Flee => {
|
||||
// Flee movement is handled by flee behavior system
|
||||
// Just update speed factor for emergency
|
||||
navigation.speed_factor = 1.2; // 120% speed when fleeing
|
||||
}
|
||||
BehaviorState::Combat => {
|
||||
// Combat maintains distance to target
|
||||
navigation.speed_factor = 0.9; // 90% speed in combat
|
||||
}
|
||||
BehaviorState::Patrol | BehaviorState::Trading => {
|
||||
// Normal movement speed
|
||||
navigation.speed_factor = 0.7; // 70% speed for routine operations
|
||||
}
|
||||
BehaviorState::Mining => {
|
||||
// No movement while mining - we stay at the belt
|
||||
navigation.speed_factor = 0.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
162
apps/game/src/gameplay/ai/perception.rs
Normal file
162
apps/game/src/gameplay/ai/perception.rs
Normal file
@@ -0,0 +1,162 @@
|
||||
//! AI perception system.
|
||||
//!
|
||||
//! Event-based detection system for AI entities to perceive the player
|
||||
//! and other entities in the game world.
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
/// Marks an entity as perceptible to AI (can be detected).
|
||||
#[derive(Component, Debug, Clone)]
|
||||
pub struct Perceptible {
|
||||
/// Detection priority (higher = more likely to be noticed)
|
||||
pub priority: f32,
|
||||
/// Whether this entity is hostile
|
||||
pub is_hostile: bool,
|
||||
/// Faction of this entity (for faction-based perception)
|
||||
pub faction: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for Perceptible {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
priority: 1.0,
|
||||
is_hostile: false,
|
||||
faction: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Perception component for AI entities.
|
||||
#[derive(Component, Debug, Clone)]
|
||||
pub struct Perception {
|
||||
/// Entities currently perceived by this AI
|
||||
pub perceived_entities: Vec<Entity>,
|
||||
/// The most threatening perceived entity
|
||||
pub primary_threat: Option<Entity>,
|
||||
/// The most interesting perceived entity (for non-hostile interactions)
|
||||
pub primary_interest: Option<Entity>,
|
||||
/// Time since last perception update
|
||||
pub time_since_last_update: f32,
|
||||
}
|
||||
|
||||
impl Default for Perception {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
perceived_entities: Vec::new(),
|
||||
primary_threat: None,
|
||||
primary_interest: None,
|
||||
time_since_last_update: 0.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Event sent when an AI perceives something notable.
|
||||
#[derive(Event, Debug, Clone)]
|
||||
pub struct PerceptionEvent {
|
||||
/// AI entity that perceived something
|
||||
pub observer: Entity,
|
||||
/// Entity that was perceived
|
||||
pub perceived: Entity,
|
||||
/// Type of perception event
|
||||
pub event_type: PerceptionEventType,
|
||||
}
|
||||
|
||||
/// Types of perception events.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PerceptionEventType {
|
||||
/// Detected a new entity
|
||||
Detected,
|
||||
/// Lost track of an entity
|
||||
Lost,
|
||||
/// Entity became hostile
|
||||
HostileDetected,
|
||||
/// Entity is in trouble (low health, etc.)
|
||||
DistressSignal,
|
||||
/// Entity has valuable cargo
|
||||
ValuableTarget,
|
||||
}
|
||||
|
||||
/// Update perception for all AI entities.
|
||||
///
|
||||
/// This system runs every frame and updates each AI's perception
|
||||
/// based on nearby entities and their perceptible components.
|
||||
pub fn update_perception(
|
||||
mut perception_query: Query<(
|
||||
Entity,
|
||||
&mut Perception,
|
||||
&Transform,
|
||||
)>,
|
||||
perceptible_query: Query<(Entity, &Perceptible, &Transform)>,
|
||||
mut perception_events: EventWriter<PerceptionEvent>,
|
||||
time: Res<Time>,
|
||||
) {
|
||||
for (observer_entity, mut perception, observer_transform) in perception_query.iter_mut() {
|
||||
perception.time_since_last_update += time.delta_secs();
|
||||
|
||||
// Update perception every 0.5 seconds
|
||||
if perception.time_since_last_update < 0.5 {
|
||||
continue;
|
||||
}
|
||||
|
||||
perception.time_since_last_update = 0.0;
|
||||
let previous_entities = std::mem::take(&mut perception.perceived_entities);
|
||||
|
||||
// Find all perceptible entities within range
|
||||
for (perceived_entity, perceptible, perceived_transform) in perceptible_query.iter() {
|
||||
// Don't perceive ourselves
|
||||
if perceived_entity == observer_entity {
|
||||
continue;
|
||||
}
|
||||
|
||||
let distance = observer_transform
|
||||
.translation
|
||||
.distance(perceived_transform.translation);
|
||||
|
||||
// Detection range based on priority
|
||||
let detection_range = 500.0 * perceptible.priority;
|
||||
|
||||
if distance < detection_range {
|
||||
// Entity is perceived
|
||||
perception.perceived_entities.push(perceived_entity);
|
||||
|
||||
// Check if this is a newly detected entity
|
||||
if !previous_entities.contains(&perceived_entity) {
|
||||
perception_events.send(PerceptionEvent {
|
||||
observer: observer_entity,
|
||||
perceived: perceived_entity,
|
||||
event_type: if perceptible.is_hostile {
|
||||
PerceptionEventType::HostileDetected
|
||||
} else {
|
||||
PerceptionEventType::Detected
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Update primary threat
|
||||
if perceptible.is_hostile {
|
||||
if perception.primary_threat.is_none()
|
||||
|| distance < observer_transform.translation.distance(
|
||||
// Can't access this easily without another query, simplify for now
|
||||
Vec3::ZERO,
|
||||
)
|
||||
{
|
||||
perception.primary_threat = Some(perceived_entity);
|
||||
}
|
||||
} else {
|
||||
perception.primary_interest = Some(perceived_entity);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for lost entities
|
||||
for lost_entity in previous_entities {
|
||||
if !perception.perceived_entities.contains(&lost_entity) {
|
||||
perception_events.send(PerceptionEvent {
|
||||
observer: observer_entity,
|
||||
perceived: lost_entity,
|
||||
event_type: PerceptionEventType::Lost,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
311
apps/game/src/gameplay/ai/spawning.rs
Normal file
311
apps/game/src/gameplay/ai/spawning.rs
Normal file
@@ -0,0 +1,311 @@
|
||||
//! NPC spawning system.
|
||||
//!
|
||||
//! Handles spawning of NPC ships based on system security level,
|
||||
//! faction presence, and gameplay requirements.
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
use crate::gameplay::ai::{AiState, BehaviorState};
|
||||
use crate::gameplay::galaxy::{Identifiable, StarSystem};
|
||||
use crate::gameplay::in_system::ActiveSystem;
|
||||
use crate::gameplay::movement::components::{MaxSpeed, TurnRate, Velocity};
|
||||
|
||||
/// Metadata for spawned NPCs.
|
||||
#[derive(Component, Debug, Clone)]
|
||||
pub struct NpcMetadata {
|
||||
/// NPC identifier
|
||||
pub id: String,
|
||||
/// Display name
|
||||
pub name: String,
|
||||
/// Faction this NPC belongs to
|
||||
pub faction: String,
|
||||
/// NPC type (patrol, pirate, trader, miner)
|
||||
pub npc_type: NpcType,
|
||||
/// Spawn level (affects stats)
|
||||
pub spawn_level: u32,
|
||||
}
|
||||
|
||||
/// Types of NPCs that can be spawned.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum NpcType {
|
||||
/// Faction patrol ship
|
||||
Patrol,
|
||||
/// Pirate / hostile
|
||||
Pirate,
|
||||
/// Trading vessel
|
||||
Trader,
|
||||
/// Mining vessel
|
||||
Miner,
|
||||
/// Station traffic (shuttles, etc.)
|
||||
StationTraffic,
|
||||
}
|
||||
|
||||
/// NPC spawner resource.
|
||||
#[derive(Resource, Debug, Clone)]
|
||||
pub struct NpcSpawner {
|
||||
/// Whether to spawn NPCs in the current system
|
||||
pub spawn_enabled: bool,
|
||||
/// Maximum NPCs per system
|
||||
pub max_npcs_per_system: u32,
|
||||
/// Spawn multiplier (for difficulty scaling)
|
||||
pub spawn_multiplier: f32,
|
||||
}
|
||||
|
||||
impl Default for NpcSpawner {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
spawn_enabled: true,
|
||||
max_npcs_per_system: 15,
|
||||
spawn_multiplier: 1.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn NPCs for the current system when entering gameplay.
|
||||
///
|
||||
/// This runs on AppState::InGame enter and spawns appropriate NPCs
|
||||
/// based on the active system's security level and faction.
|
||||
pub fn spawn_system_npcs(
|
||||
mut commands: Commands,
|
||||
active_system: Res<ActiveSystem>,
|
||||
mut meshes: ResMut<Assets<Mesh>>,
|
||||
mut materials: ResMut<Assets<StandardMaterial>>,
|
||||
) {
|
||||
// For now, use a default security level since ActiveSystem doesn't store it
|
||||
let security = 0.5; // Default mid-sec
|
||||
let faction = "Concord"; // Default faction
|
||||
|
||||
// Determine spawn count based on security level
|
||||
let base_spawn_count = match security {
|
||||
sec if sec >= 0.8 => 8, // High-sec: more patrols
|
||||
sec if sec >= 0.5 => 5, // Mid-sec: moderate
|
||||
_ => 3, // Low-sec: fewer but more dangerous
|
||||
};
|
||||
|
||||
let spawn_count = (base_spawn_count as f32) * 1.0; // Use spawn multiplier from resource if available
|
||||
|
||||
bevy::log::info!(
|
||||
"Spawning {} NPCs for system {} (security: {})",
|
||||
spawn_count as u32,
|
||||
active_system.system_name,
|
||||
security
|
||||
);
|
||||
|
||||
// Spawn faction patrols for high-sec systems
|
||||
if security >= 0.5 {
|
||||
let patrol_count = (spawn_count * 0.6) as u32;
|
||||
for i in 0..patrol_count {
|
||||
spawn_npc(
|
||||
&mut commands,
|
||||
&mut meshes,
|
||||
&mut materials,
|
||||
&active_system.system_name,
|
||||
faction,
|
||||
NpcType::Patrol,
|
||||
i,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Spawn pirates for low-sec systems
|
||||
if security < 0.5 {
|
||||
let pirate_count = (spawn_count * 0.4) as u32 + 1;
|
||||
for i in 0..pirate_count {
|
||||
spawn_npc(
|
||||
&mut commands,
|
||||
&mut meshes,
|
||||
&mut materials,
|
||||
&active_system.system_name,
|
||||
faction,
|
||||
NpcType::Pirate,
|
||||
i,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Spawn traders for all systems
|
||||
let trader_count = (spawn_count * 0.2) as u32 + 1;
|
||||
for i in 0..trader_count {
|
||||
spawn_npc(
|
||||
&mut commands,
|
||||
&mut meshes,
|
||||
&mut materials,
|
||||
&active_system.system_name,
|
||||
faction,
|
||||
NpcType::Trader,
|
||||
i,
|
||||
);
|
||||
}
|
||||
|
||||
// Spawn miners for systems with asteroid belts
|
||||
let miner_count = (spawn_count * 0.2) as u32 + 1;
|
||||
for i in 0..miner_count {
|
||||
spawn_npc(
|
||||
&mut commands,
|
||||
&mut meshes,
|
||||
&mut materials,
|
||||
&active_system.system_name,
|
||||
faction,
|
||||
NpcType::Miner,
|
||||
i,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn a single NPC ship.
|
||||
fn spawn_npc(
|
||||
commands: &mut Commands,
|
||||
meshes: &mut Assets<Mesh>,
|
||||
materials: &mut Assets<StandardMaterial>,
|
||||
system_name: &str,
|
||||
faction: &str,
|
||||
npc_type: NpcType,
|
||||
index: u32,
|
||||
) {
|
||||
// Determine faction and appearance based on NPC type
|
||||
let (npc_faction, color, name_prefix) = match npc_type {
|
||||
NpcType::Patrol => (
|
||||
faction.to_string(),
|
||||
get_faction_color(faction),
|
||||
"PATROL",
|
||||
),
|
||||
NpcType::Pirate => (
|
||||
"Pirates".to_string(),
|
||||
[0.8, 0.2, 0.2], // Red
|
||||
"PIRATE",
|
||||
),
|
||||
NpcType::Trader => (
|
||||
"Commerce".to_string(),
|
||||
[0.2, 0.6, 0.8], // Blue
|
||||
"TRADER",
|
||||
),
|
||||
NpcType::Miner => (
|
||||
"MiningGuild".to_string(),
|
||||
[0.6, 0.6, 0.2], // Yellow
|
||||
"MINER",
|
||||
),
|
||||
NpcType::StationTraffic => (
|
||||
"Station".to_string(),
|
||||
[0.5, 0.5, 0.5], // Gray
|
||||
"TRAFFIC",
|
||||
),
|
||||
};
|
||||
|
||||
// Generate spawn position (random position within system bounds)
|
||||
let angle = (index as f32) * 0.5; // Spread out spawns
|
||||
let radius = 150.0 + (index as f32) * 50.0;
|
||||
let spawn_pos = Vec3::new(
|
||||
angle.cos() * radius,
|
||||
0.0,
|
||||
angle.sin() * radius,
|
||||
);
|
||||
|
||||
// Create NPC entity
|
||||
let npc_id = format!("npc-{}-{}", name_prefix, index);
|
||||
let npc_name = format!("{} {}", name_prefix, index + 1);
|
||||
|
||||
let mut entity = commands.spawn((
|
||||
// Transform
|
||||
Transform::from_translation(spawn_pos),
|
||||
Visibility::default(),
|
||||
InheritedVisibility::default(),
|
||||
|
||||
// NPC metadata
|
||||
NpcMetadata {
|
||||
id: npc_id.clone(),
|
||||
name: npc_name.clone(),
|
||||
faction: npc_faction.clone(),
|
||||
npc_type,
|
||||
spawn_level: 1,
|
||||
},
|
||||
|
||||
// AI state
|
||||
AiState {
|
||||
behavior: match npc_type {
|
||||
NpcType::Patrol => BehaviorState::Patrol,
|
||||
NpcType::Pirate => BehaviorState::Idle,
|
||||
NpcType::Trader => BehaviorState::Trading,
|
||||
NpcType::Miner => BehaviorState::Mining,
|
||||
NpcType::StationTraffic => BehaviorState::Idle,
|
||||
},
|
||||
..default()
|
||||
},
|
||||
|
||||
// Identifiable for targeting system
|
||||
Identifiable {
|
||||
id: npc_id,
|
||||
display_name: npc_name.clone(),
|
||||
classification: crate::gameplay::galaxy::Classification::Ship,
|
||||
},
|
||||
|
||||
// Movement components
|
||||
MaxSpeed(50.0),
|
||||
TurnRate(2.0),
|
||||
Velocity::default(),
|
||||
|
||||
// Visual representation (simple cone for now)
|
||||
Mesh3d(meshes.add(Cone::new(2.0, 8.0).mesh())),
|
||||
MeshMaterial3d(materials.add(StandardMaterial {
|
||||
base_color: Color::srgb(color[0], color[1], color[2]),
|
||||
emissive: LinearRgba::new(color[0] * 0.3, color[1] * 0.3, color[2] * 0.3, 1.0),
|
||||
..default()
|
||||
})),
|
||||
));
|
||||
|
||||
// Add state-specific components
|
||||
match npc_type {
|
||||
NpcType::Patrol => {
|
||||
entity.insert(crate::gameplay::ai::PatrolState::default());
|
||||
}
|
||||
NpcType::Pirate => {
|
||||
// Pirates don't have special state - they use base AiState
|
||||
}
|
||||
NpcType::Trader => {
|
||||
entity.insert(crate::gameplay::ai::TradingState::default());
|
||||
}
|
||||
NpcType::Miner => {
|
||||
entity.insert(crate::gameplay::ai::MiningState::default());
|
||||
}
|
||||
NpcType::StationTraffic => {
|
||||
// Traffic is idle most of the time
|
||||
}
|
||||
}
|
||||
|
||||
bevy::log::debug!("Spawned NPC: {} at {:?}", npc_name.clone(), spawn_pos);
|
||||
}
|
||||
|
||||
/// Spawn station traffic (NPCs that dock/undock at stations).
|
||||
pub fn spawn_station_traffic(
|
||||
commands: &mut Commands,
|
||||
meshes: &mut Assets<Mesh>,
|
||||
materials: &mut Assets<StandardMaterial>,
|
||||
active_system: &ActiveSystem,
|
||||
) {
|
||||
// Spawn 2-3 traffic NPCs per station
|
||||
let traffic_count = 3;
|
||||
let faction = "Concord"; // Default faction
|
||||
|
||||
for i in 0..traffic_count {
|
||||
spawn_npc(
|
||||
commands,
|
||||
meshes,
|
||||
materials,
|
||||
&active_system.system_name,
|
||||
faction,
|
||||
NpcType::StationTraffic,
|
||||
i,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the color for a faction.
|
||||
fn get_faction_color(faction: &str) -> [f32; 3] {
|
||||
match faction {
|
||||
"Concord" => [0.13, 0.83, 0.93], // cyan
|
||||
"Amarr" => [0.96, 0.62, 0.04], // amber
|
||||
"Minmatar" => [0.94, 0.27, 0.27], // red
|
||||
"Gallente" => [0.66, 0.33, 0.97], // purple
|
||||
"Caldari" => [0.22, 0.74, 0.97], // blue
|
||||
_ => [0.5, 0.5, 0.5], // gray (default)
|
||||
}
|
||||
}
|
||||
207
apps/game/src/gameplay/ai/states.rs
Normal file
207
apps/game/src/gameplay/ai/states.rs
Normal file
@@ -0,0 +1,207 @@
|
||||
//! AI state machine definitions.
|
||||
//!
|
||||
//! Defines the hierarchical behavior state machine for NPC entities.
|
||||
//! States include Idle, Patrol, Combat, Flee, Mining, and Trading.
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
/// The main behavior states for NPC AI.
|
||||
#[derive(Component, Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum BehaviorState {
|
||||
/// No active behavior - waiting for stimulus
|
||||
#[default]
|
||||
Idle,
|
||||
/// Following a patrol route or pattern
|
||||
Patrol,
|
||||
/// Engaging in combat with a hostile target
|
||||
Combat,
|
||||
/// Fleeing from a threat
|
||||
Flee,
|
||||
/// Mining at an asteroid belt
|
||||
Mining,
|
||||
/// Trading between stations
|
||||
Trading,
|
||||
}
|
||||
|
||||
/// Current AI state component - attached to all NPC entities.
|
||||
#[derive(Component, Debug, Clone)]
|
||||
pub struct AiState {
|
||||
/// Current behavior state
|
||||
pub behavior: BehaviorState,
|
||||
/// Time since last state change
|
||||
pub time_in_state: f32,
|
||||
/// Current target entity (if any)
|
||||
pub target_entity: Option<Entity>,
|
||||
/// Current target position (if any)
|
||||
pub target_position: Option<Vec3>,
|
||||
/// Hull percentage (0.0 to 1.0) - affects state transitions
|
||||
pub hull_percentage: f32,
|
||||
}
|
||||
|
||||
impl Default for AiState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
behavior: BehaviorState::Idle,
|
||||
time_in_state: 0.0,
|
||||
target_entity: None,
|
||||
target_position: None,
|
||||
hull_percentage: 1.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// State-specific data for patrol behavior.
|
||||
#[derive(Component, Debug, Clone)]
|
||||
pub struct PatrolState {
|
||||
/// List of waypoints in the patrol route
|
||||
pub waypoints: Vec<Vec3>,
|
||||
/// Current waypoint index
|
||||
pub current_waypoint: usize,
|
||||
/// Whether to loop the patrol route
|
||||
pub loop_route: bool,
|
||||
/// Wait time at each waypoint (seconds)
|
||||
pub waypoint_wait_time: f32,
|
||||
/// Time spent at current waypoint
|
||||
pub time_at_waypoint: f32,
|
||||
}
|
||||
|
||||
impl Default for PatrolState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
waypoints: Vec::new(),
|
||||
current_waypoint: 0,
|
||||
loop_route: true,
|
||||
waypoint_wait_time: 2.0,
|
||||
time_at_waypoint: 0.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// State-specific data for combat behavior.
|
||||
#[derive(Component, Debug, Clone)]
|
||||
pub struct CombatState {
|
||||
/// Target entity we're fighting
|
||||
pub target: Option<Entity>,
|
||||
/// Preferred combat distance
|
||||
pub preferred_distance: f32,
|
||||
/// Time since last attack
|
||||
pub time_since_last_attack: f32,
|
||||
/// Attack cooldown (seconds)
|
||||
pub attack_cooldown: f32,
|
||||
/// Whether we're currently attacking
|
||||
pub is_attacking: bool,
|
||||
}
|
||||
|
||||
impl Default for CombatState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
target: None,
|
||||
preferred_distance: 100.0,
|
||||
time_since_last_attack: 0.0,
|
||||
attack_cooldown: 3.0,
|
||||
is_attacking: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// State-specific data for flee behavior.
|
||||
#[derive(Component, Debug, Clone)]
|
||||
pub struct FleeState {
|
||||
/// Entity we're fleeing from
|
||||
pub threat: Option<Entity>,
|
||||
/// Direction we're fleeing (normalized)
|
||||
pub flee_direction: Vec3,
|
||||
/// Distance we've fled
|
||||
pub distance_fled: f32,
|
||||
/// Whether we've reached safe distance
|
||||
pub reached_safe_distance: bool,
|
||||
}
|
||||
|
||||
impl Default for FleeState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
threat: None,
|
||||
flee_direction: Vec3::ZERO,
|
||||
distance_fled: 0.0,
|
||||
reached_safe_distance: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// State-specific data for mining behavior.
|
||||
#[derive(Component, Debug, Clone)]
|
||||
pub struct MiningState {
|
||||
/// Asteroid belt we're mining at
|
||||
pub belt_entity: Option<Entity>,
|
||||
/// Time spent mining
|
||||
pub mining_time: f32,
|
||||
/// Mining cycle duration (seconds)
|
||||
pub mining_cycle_duration: f32,
|
||||
/// Cargo capacity remaining
|
||||
pub cargo_remaining: f32,
|
||||
}
|
||||
|
||||
impl Default for MiningState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
belt_entity: None,
|
||||
mining_time: 0.0,
|
||||
mining_cycle_duration: 8.0,
|
||||
cargo_remaining: 100.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// State-specific data for trading behavior.
|
||||
#[derive(Component, Debug, Clone)]
|
||||
pub struct TradingState {
|
||||
/// Current destination station
|
||||
pub destination_station: Option<Entity>,
|
||||
/// Current trade route waypoints
|
||||
pub trade_route: Vec<Vec3>,
|
||||
/// Current waypoint in trade route
|
||||
pub current_waypoint: usize,
|
||||
/// Time until next trade decision
|
||||
pub time_until_next_decision: f32,
|
||||
}
|
||||
|
||||
impl Default for TradingState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
destination_station: None,
|
||||
trade_route: Vec::new(),
|
||||
current_waypoint: 0,
|
||||
time_until_next_decision: 5.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Event sent when an AI entity transitions to a new state.
|
||||
#[derive(Event, Debug, Clone)]
|
||||
pub struct StateTransition {
|
||||
/// Entity that changed state
|
||||
pub entity: Entity,
|
||||
/// Previous state
|
||||
pub from_state: BehaviorState,
|
||||
/// New state
|
||||
pub to_state: BehaviorState,
|
||||
/// Reason for transition (for debugging/logging)
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
/// Resource that tracks all AI state machines for debugging and monitoring.
|
||||
#[derive(Resource, Debug, Default)]
|
||||
pub struct AiStateMachine {
|
||||
/// Count of NPCs in each behavior state
|
||||
pub state_counts: [usize; 6],
|
||||
}
|
||||
|
||||
impl AiStateMachine {
|
||||
/// Record a state transition for statistics.
|
||||
pub fn record_transition(&mut self, from: BehaviorState, to: BehaviorState) {
|
||||
// Decrement old state count
|
||||
self.state_counts[from as usize] = self.state_counts[from as usize].saturating_sub(1);
|
||||
// Increment new state count
|
||||
self.state_counts[to as usize] += 1;
|
||||
}
|
||||
}
|
||||
@@ -753,6 +753,7 @@ pub fn spawn_system_contents(
|
||||
contents: &SystemContents,
|
||||
star_entity: Entity,
|
||||
assets: &ContentAssets,
|
||||
materials: &mut Assets<StandardMaterial>,
|
||||
) {
|
||||
// ── Planets ─────────────────────────────────────────────────────────────
|
||||
for planet in &contents.planets {
|
||||
@@ -786,6 +787,18 @@ pub fn spawn_system_contents(
|
||||
if planet.habitable {
|
||||
entity.insert(HabitablePlanet);
|
||||
}
|
||||
|
||||
// Add city lights for high-population planets
|
||||
if let Some(city_lights_mat) = city_lights_material(materials, planet.planet_type, planet.population) {
|
||||
entity.with_children(|planet| {
|
||||
planet.spawn((
|
||||
Mesh3d(assets.planet_mesh.clone()),
|
||||
MeshMaterial3d(city_lights_mat),
|
||||
// Slightly larger sphere for city lights glow
|
||||
Transform::from_scale(Vec3::splat(1.02)),
|
||||
));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Asteroid belts ──────────────────────────────────────────────────────
|
||||
@@ -853,6 +866,8 @@ pub fn spawn_system_contents(
|
||||
for station in &contents.stations {
|
||||
let local = orbital_position(station.orbit, station.phase);
|
||||
let cube_size = 0.16;
|
||||
// Use population-based material for visual indicator
|
||||
let station_mat = population_station_material(materials, station.population);
|
||||
parent.spawn((
|
||||
Station,
|
||||
Orbital {
|
||||
@@ -871,7 +886,7 @@ pub fn spawn_system_contents(
|
||||
classification: Classification::Structure,
|
||||
},
|
||||
Mesh3d(assets.station_mesh.clone()),
|
||||
MeshMaterial3d(assets.station_material.clone()),
|
||||
MeshMaterial3d(station_mat),
|
||||
Transform::from_translation(local).with_scale(Vec3::splat(cube_size)),
|
||||
));
|
||||
}
|
||||
@@ -1070,6 +1085,56 @@ fn translucent_material(
|
||||
})
|
||||
}
|
||||
|
||||
/// Create a population-based emissive material for stations.
|
||||
/// Higher population = brighter, more cyan glow (civilization lights).
|
||||
fn population_station_material(
|
||||
materials: &mut Assets<StandardMaterial>,
|
||||
population: u32,
|
||||
) -> Handle<StandardMaterial> {
|
||||
// Base cyan color for stations
|
||||
let (base_r, base_g, base_b) = (0.13, 0.83, 0.93);
|
||||
|
||||
// Scale emissive intensity by population (logarithmic scale)
|
||||
// Pop 10k -> 1.0x, Pop 10M -> 2.5x
|
||||
let pop_factor = 1.0 + (population as f32).log10() * 0.5;
|
||||
let intensity = (pop_factor * 1.4).min(3.5);
|
||||
|
||||
materials.add(StandardMaterial {
|
||||
base_color: Color::srgb(base_r, base_g, base_b),
|
||||
emissive: LinearRgba::new(base_r * intensity, base_g * intensity, base_b * intensity, 1.0),
|
||||
unlit: true,
|
||||
..default()
|
||||
})
|
||||
}
|
||||
|
||||
/// Create city lights material for high-population planets.
|
||||
/// Returns Some material if population > 100,000, None otherwise.
|
||||
fn city_lights_material(
|
||||
materials: &mut Assets<StandardMaterial>,
|
||||
planet_type: PlanetType,
|
||||
population: u32,
|
||||
) -> Option<Handle<StandardMaterial>> {
|
||||
// Only show city lights on high-population habitable worlds
|
||||
const CITY_LIGHTS_THRESHOLD: u32 = 100_000;
|
||||
if population < CITY_LIGHTS_THRESHOLD {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Warm yellow-white lights for cities
|
||||
let (r, g, b) = (1.0, 0.95, 0.8);
|
||||
// Scale by population
|
||||
let pop_factor = 1.0 + (population as f32).log10() * 0.3;
|
||||
let intensity = (pop_factor * 0.8).min(2.0).min(population as f32 / 500_000.0);
|
||||
|
||||
Some(materials.add(StandardMaterial {
|
||||
base_color: Color::srgba(r, g, b, 0.0), // Transparent base
|
||||
emissive: LinearRgba::new(r * intensity, g * intensity, b * intensity, 1.0),
|
||||
unlit: true,
|
||||
alpha_mode: AlphaMode::Blend,
|
||||
..default()
|
||||
}))
|
||||
}
|
||||
|
||||
/// Slugify a display name for use as part of an [`Identifiable::id`]:
|
||||
/// lowercase, spaces → hyphens. Good enough for stable, human-readable IDs.
|
||||
fn slug(s: &str) -> String {
|
||||
|
||||
@@ -112,8 +112,6 @@ const FACTIONS: &[(&str, [f32; 3])] = &[
|
||||
("Caldari", [0.22, 0.74, 0.97]), // blue
|
||||
];
|
||||
|
||||
const OUTER_STARTING_SYSTEM_RADIUS: f32 = 0.65;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct StartingBaseCandidate {
|
||||
pub id: String,
|
||||
@@ -223,7 +221,6 @@ pub fn generate_galaxy(params: &GalaxyParams) -> GeneratedGalaxy {
|
||||
|
||||
impl GeneratedGalaxy {
|
||||
pub fn starting_base_map(&self) -> StartingBaseMap {
|
||||
let outer_radius = self.params.size * OUTER_STARTING_SYSTEM_RADIUS;
|
||||
let mut candidates = Vec::new();
|
||||
let mut map_systems = Vec::with_capacity(self.systems.len());
|
||||
|
||||
@@ -242,7 +239,13 @@ impl GeneratedGalaxy {
|
||||
|
||||
for (system, contents) in self.systems.iter().zip(self.contents.iter()) {
|
||||
let distance_from_core = system.position.length();
|
||||
if !system.is_core && distance_from_core >= outer_radius {
|
||||
// A system is valid for starting if it has:
|
||||
// - At least one station (stations are always dockable)
|
||||
// - OR at least one inhabited planet (population > 0)
|
||||
let has_dockable = !contents.stations.is_empty()
|
||||
|| contents.planets.iter().any(|p| p.population > 0);
|
||||
|
||||
if !system.is_core && has_dockable {
|
||||
candidates.push(StartingBaseCandidate {
|
||||
id: system.id.clone(),
|
||||
name: system.name.clone(),
|
||||
@@ -801,6 +804,7 @@ fn spawn_galaxy_scene(
|
||||
sys_contents,
|
||||
star_entity,
|
||||
&content_assets,
|
||||
materials,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -938,7 +942,6 @@ mod tests {
|
||||
fn starting_base_map_uses_saved_galaxy_without_regeneration() {
|
||||
let galaxy = generate_galaxy(&GalaxyParams::default());
|
||||
let map = galaxy.starting_base_map();
|
||||
let outer_radius = galaxy.params.size * OUTER_STARTING_SYSTEM_RADIUS;
|
||||
|
||||
assert!(map.candidates.iter().all(|candidate| {
|
||||
galaxy
|
||||
@@ -946,10 +949,20 @@ mod tests {
|
||||
.iter()
|
||||
.any(|system| system.id == candidate.id)
|
||||
}));
|
||||
assert!(map
|
||||
.candidates
|
||||
.iter()
|
||||
.all(|candidate| candidate.distance_from_core >= outer_radius));
|
||||
// Candidates must have dockable locations (stations or inhabited planets) and not be core systems
|
||||
assert!(map.candidates.iter().all(|candidate| {
|
||||
let index = galaxy
|
||||
.systems
|
||||
.iter()
|
||||
.position(|system| system.id == candidate.id);
|
||||
index.is_some_and(|index| {
|
||||
let system = &galaxy.systems[index];
|
||||
let contents = &galaxy.contents[index];
|
||||
!system.is_core
|
||||
&& (!contents.stations.is_empty()
|
||||
|| contents.planets.iter().any(|p| p.population > 0))
|
||||
})
|
||||
}));
|
||||
assert!(map.candidates.iter().all(|candidate| {
|
||||
let index = galaxy
|
||||
.systems
|
||||
|
||||
@@ -13,7 +13,8 @@ use crate::gameplay::movement::components::{Velocity, MoveTarget};
|
||||
use crate::gameplay::galaxy::Identifiable;
|
||||
use super::{DockedState, UndockEvent};
|
||||
use super::scene::{Docked, PlayerShip};
|
||||
use super::flight_ui::setup_flight_ui;
|
||||
// UI removed - no longer needed
|
||||
// use super::flight_ui::setup_flight_ui;
|
||||
|
||||
/// Flight state component attached to the player ship when actively flying.
|
||||
#[derive(Component, Debug, Clone, Default)]
|
||||
@@ -53,7 +54,7 @@ fn handle_undock(
|
||||
mut docked_state: ResMut<DockedState>,
|
||||
mut camera_state: ResMut<CameraState>,
|
||||
player_query: Query<(Entity, &Transform), (With<PlayerShip>, With<Docked>)>,
|
||||
docked_ui_query: Query<Entity, With<super::ui::DockedUi>>,
|
||||
// docked_ui_query removed - UI no longer needed
|
||||
) {
|
||||
for event in events.read() {
|
||||
bevy::log::info!("Handling undock from station {:?}", event.station_entity);
|
||||
@@ -86,19 +87,19 @@ fn handle_undock(
|
||||
// Update docked state resource
|
||||
docked_state.undock();
|
||||
|
||||
// Transition camera to follow mode
|
||||
// Transition camera to tactical follow mode (isometric view)
|
||||
camera_state.mode = CameraMode::Follow;
|
||||
camera_state.target_entity = Some(player_entity);
|
||||
camera_state.follow_distance = 15.0;
|
||||
camera_state.follow_height = 5.0;
|
||||
camera_state.follow_distance = 45.0; // Higher for tactical view
|
||||
camera_state.follow_height = 35.0; // Isometric angle
|
||||
|
||||
// Spawn flight HUD
|
||||
setup_flight_ui(commands.reborrow());
|
||||
// UI removed - gameplay only
|
||||
// setup_flight_ui(commands.reborrow());
|
||||
|
||||
// Despawn docked UI
|
||||
for entity in docked_ui_query.iter() {
|
||||
commands.entity(entity).despawn();
|
||||
}
|
||||
// Despawn docked UI (commented out - UI being removed)
|
||||
// for entity in docked_ui_query.iter() {
|
||||
// commands.entity(entity).despawn();
|
||||
// }
|
||||
|
||||
bevy::log::info!("Transitioned to flight mode");
|
||||
}
|
||||
@@ -111,7 +112,7 @@ fn handle_docking(
|
||||
mut docked_state: ResMut<DockedState>,
|
||||
mut camera_state: ResMut<CameraState>,
|
||||
identifiable_query: Query<&Identifiable>,
|
||||
flight_ui_query: Query<Entity, With<super::flight_ui::FlightUi>>,
|
||||
// flight_ui_query removed - UI no longer needed
|
||||
) {
|
||||
for event in events.read() {
|
||||
bevy::log::info!("Handling docking at target {:?}", event.station);
|
||||
@@ -137,13 +138,13 @@ fn handle_docking(
|
||||
camera_state.mode = CameraMode::Cinematic;
|
||||
camera_state.target_entity = Some(event.station);
|
||||
|
||||
// UI removed - no longer needed
|
||||
// Despawn flight HUD
|
||||
for entity in flight_ui_query.iter() {
|
||||
commands.entity(entity).despawn();
|
||||
}
|
||||
|
||||
// for entity in flight_ui_query.iter() {
|
||||
// commands.entity(entity).despawn();
|
||||
// }
|
||||
// Respawn docked UI
|
||||
super::ui::setup_docked_ui(commands.reborrow());
|
||||
// super::ui::setup_docked_ui(commands.reborrow());
|
||||
|
||||
bevy::log::info!("Docked at {}", identifiable.display_name);
|
||||
}
|
||||
|
||||
@@ -39,24 +39,27 @@ impl Plugin for InSystemPlugin {
|
||||
OnEnter(AppState::InGame),
|
||||
(
|
||||
scene::setup_in_system_view,
|
||||
ui::setup_docked_ui,
|
||||
// UI removed - no longer needed
|
||||
// ui::setup_docked_ui,
|
||||
add_targetable_to_pois,
|
||||
).chain(),
|
||||
)
|
||||
.add_systems(
|
||||
OnExit(AppState::InGame),
|
||||
(
|
||||
ui::despawn_docked_ui,
|
||||
flight_ui::despawn_flight_ui,
|
||||
// UI removed - no longer needed
|
||||
// ui::despawn_docked_ui,
|
||||
// flight_ui::despawn_flight_ui,
|
||||
scene::despawn_in_system_scene,
|
||||
).chain(),
|
||||
)
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
ui::refresh_docked_ui,
|
||||
ui::undock_button_handler,
|
||||
flight_ui::update_flight_ui,
|
||||
// UI removed - no longer needed
|
||||
// ui::refresh_docked_ui,
|
||||
// ui::undock_button_handler,
|
||||
// flight_ui::update_flight_ui,
|
||||
handle_action_triggered,
|
||||
)
|
||||
.chain()
|
||||
|
||||
@@ -248,9 +248,48 @@ fn spawn_system_scene(
|
||||
contents,
|
||||
star_entity,
|
||||
&content_assets,
|
||||
materials,
|
||||
);
|
||||
});
|
||||
|
||||
// Spawn tactical grid helper for spatial reference
|
||||
// Matches the movement demo styling: subtle dark grid
|
||||
const GRID_SIZE: f32 = 200.0;
|
||||
const GRID_DIVISIONS: usize = 20;
|
||||
const GRID_COLOR: Color = Color::srgba(0.05, 0.08, 0.13, 0.5); // #0d1520 with transparency
|
||||
|
||||
// Create grid lines
|
||||
let step = GRID_SIZE / GRID_DIVISIONS as f32;
|
||||
let half_size = GRID_SIZE * 0.5;
|
||||
|
||||
for i in 0..=GRID_DIVISIONS {
|
||||
let offset = (i as f32 * step) - half_size;
|
||||
|
||||
// X-axis line
|
||||
commands.spawn((
|
||||
Mesh3d(meshes.add(Cuboid::new(GRID_SIZE, 0.02, 0.02))),
|
||||
MeshMaterial3d(materials.add(StandardMaterial {
|
||||
base_color: GRID_COLOR,
|
||||
unlit: true,
|
||||
..default()
|
||||
})),
|
||||
Transform::from_translation(Vec3::new(0.0, -2.0, offset)),
|
||||
InSystemSpawned,
|
||||
));
|
||||
|
||||
// Z-axis line
|
||||
commands.spawn((
|
||||
Mesh3d(meshes.add(Cuboid::new(0.02, 0.02, GRID_SIZE))),
|
||||
MeshMaterial3d(materials.add(StandardMaterial {
|
||||
base_color: GRID_COLOR,
|
||||
unlit: true,
|
||||
..default()
|
||||
})),
|
||||
Transform::from_translation(Vec3::new(offset, -2.0, 0.0)),
|
||||
InSystemSpawned,
|
||||
));
|
||||
}
|
||||
|
||||
// If we have a docking target, spawn player ship docked at it
|
||||
let station_entity = if let Some(target) = docking_target {
|
||||
// Calculate target position
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
pub mod ai;
|
||||
pub mod campaign;
|
||||
pub mod character_creation;
|
||||
pub mod galaxy;
|
||||
pub mod in_system;
|
||||
pub mod movement;
|
||||
pub mod narrative;
|
||||
pub mod physics;
|
||||
pub mod star_map;
|
||||
pub mod starting_base;
|
||||
|
||||
212
apps/game/src/gameplay/narrative/events.rs
Normal file
212
apps/game/src/gameplay/narrative/events.rs
Normal file
@@ -0,0 +1,212 @@
|
||||
//! Event logging system.
|
||||
//!
|
||||
//! Captures all player actions as structured events that become
|
||||
//! the foundation for narrative generation.
|
||||
|
||||
use bevy::prelude::*;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::gameplay::narrative::CampaignHistory;
|
||||
|
||||
/// All possible game events that can occur in a campaign.
|
||||
#[derive(Event, Debug, Clone)]
|
||||
pub enum GameEvent {
|
||||
/// Player mined ore from an asteroid belt
|
||||
Mining(MiningEvent),
|
||||
/// Player bought or sold items at a station
|
||||
Trade(TradeEvent),
|
||||
/// Player engaged in combat
|
||||
Combat(CombatEvent),
|
||||
/// Player discovered a new system or POI
|
||||
Exploration(ExplorationEvent),
|
||||
/// Player docked at a station
|
||||
Docking(DockingEvent),
|
||||
/// Player accepted/abandoned/failed a mission
|
||||
Mission(MissionEvent),
|
||||
/// Custom event for extensible storytelling
|
||||
Custom(CustomEvent),
|
||||
}
|
||||
|
||||
/// Event from mining activities.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MiningEvent {
|
||||
pub timestamp: f64,
|
||||
pub location: String,
|
||||
pub ore_type: String,
|
||||
pub quantity: u32,
|
||||
pub duration_seconds: f32,
|
||||
}
|
||||
|
||||
/// Event from trading activities.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TradeEvent {
|
||||
pub timestamp: f64,
|
||||
pub station: String,
|
||||
pub faction: String,
|
||||
pub is_purchase: bool,
|
||||
pub item_name: String,
|
||||
pub quantity: u32,
|
||||
pub unit_price: f32,
|
||||
pub total_value: f32,
|
||||
}
|
||||
|
||||
/// Event from combat activities.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CombatEvent {
|
||||
pub timestamp: f64,
|
||||
pub location: String,
|
||||
pub opponent: String,
|
||||
pub opponent_faction: Option<String>,
|
||||
pub outcome: CombatOutcome,
|
||||
pub hull_remaining: f32,
|
||||
pub rewards_earned: Option<f32>,
|
||||
}
|
||||
|
||||
/// Outcome of a combat engagement.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum CombatOutcome {
|
||||
Victory,
|
||||
Defeat,
|
||||
Retreat,
|
||||
Draw,
|
||||
}
|
||||
|
||||
/// Event from exploration activities.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ExplorationEvent {
|
||||
pub timestamp: f64,
|
||||
pub location: String,
|
||||
pub discovery_type: DiscoveryType,
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
/// Types of discoveries.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum DiscoveryType {
|
||||
NewSystem,
|
||||
NewStation,
|
||||
Anomaly,
|
||||
Derelict,
|
||||
ResourceDeposit,
|
||||
}
|
||||
|
||||
/// Event from docking activities.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DockingEvent {
|
||||
pub timestamp: f64,
|
||||
pub station: String,
|
||||
pub system: String,
|
||||
pub faction: String,
|
||||
pub visit_duration_seconds: f32,
|
||||
}
|
||||
|
||||
/// Event from mission activities.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MissionEvent {
|
||||
pub timestamp: f64,
|
||||
pub mission_id: String,
|
||||
pub mission_type: String,
|
||||
pub event_type: MissionEventType,
|
||||
pub outcome: Option<MissionOutcome>,
|
||||
}
|
||||
|
||||
/// Type of mission event.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum MissionEventType {
|
||||
Accepted,
|
||||
Completed,
|
||||
Abandoned,
|
||||
Failed,
|
||||
}
|
||||
|
||||
/// Outcome of a completed mission.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum MissionOutcome {
|
||||
Success,
|
||||
PartialSuccess,
|
||||
Failure,
|
||||
}
|
||||
|
||||
/// Custom event for extensible storytelling.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CustomEvent {
|
||||
pub timestamp: f64,
|
||||
pub event_type: String,
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
pub metadata: HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl GameEvent {
|
||||
/// Get the timestamp of this event.
|
||||
pub fn timestamp(&self) -> f64 {
|
||||
match self {
|
||||
GameEvent::Mining(e) => e.timestamp,
|
||||
GameEvent::Trade(e) => e.timestamp,
|
||||
GameEvent::Combat(e) => e.timestamp,
|
||||
GameEvent::Exploration(e) => e.timestamp,
|
||||
GameEvent::Docking(e) => e.timestamp,
|
||||
GameEvent::Mission(e) => e.timestamp,
|
||||
GameEvent::Custom(e) => e.timestamp,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get a short title for this event.
|
||||
pub fn title(&self) -> String {
|
||||
match self {
|
||||
GameEvent::Mining(e) => format!("Mined {} {}", e.quantity, e.ore_type),
|
||||
GameEvent::Trade(e) => {
|
||||
if e.is_purchase {
|
||||
format!("Purchased {} {}", e.quantity, e.item_name)
|
||||
} else {
|
||||
format!("Sold {} {}", e.quantity, e.item_name)
|
||||
}
|
||||
}
|
||||
GameEvent::Combat(e) => match e.outcome {
|
||||
CombatOutcome::Victory => format!("Victory over {}", e.opponent),
|
||||
CombatOutcome::Defeat => format!("Defeated by {}", e.opponent),
|
||||
CombatOutcome::Retreat => format!("Retreated from {}", e.opponent),
|
||||
CombatOutcome::Draw => format!("Draw with {}", e.opponent),
|
||||
},
|
||||
GameEvent::Exploration(e) => match e.discovery_type {
|
||||
DiscoveryType::NewSystem => format!("Discovered system: {}", e.location),
|
||||
DiscoveryType::NewStation => format!("Discovered station: {}", e.location),
|
||||
DiscoveryType::Anomaly => format!("Found anomaly: {}", e.description),
|
||||
DiscoveryType::Derelict => format!("Found derelict: {}", e.description),
|
||||
DiscoveryType::ResourceDeposit => format!("Found resource: {}", e.description),
|
||||
},
|
||||
GameEvent::Docking(e) => format!("Docked at {}", e.station),
|
||||
GameEvent::Mission(e) => format!(
|
||||
"{:?} mission: {}",
|
||||
e.event_type, e.mission_id
|
||||
),
|
||||
GameEvent::Custom(e) => e.title.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the location where this event occurred.
|
||||
pub fn location(&self) -> String {
|
||||
match self {
|
||||
GameEvent::Mining(e) => e.location.clone(),
|
||||
GameEvent::Trade(e) => e.station.clone(),
|
||||
GameEvent::Combat(e) => e.location.clone(),
|
||||
GameEvent::Exploration(e) => e.location.clone(),
|
||||
GameEvent::Docking(e) => e.system.clone(),
|
||||
GameEvent::Mission(e) => "Unknown".to_string(),
|
||||
GameEvent::Custom(e) => e.metadata.get("location").cloned().unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Log game events to campaign history.
|
||||
///
|
||||
/// This system listens for GameEvents and adds them to the CampaignHistory.
|
||||
pub fn log_game_events(
|
||||
mut events: EventReader<GameEvent>,
|
||||
mut history: ResMut<CampaignHistory>,
|
||||
) {
|
||||
for event in events.read() {
|
||||
bevy::log::debug!("Logging game event: {}", event.title());
|
||||
history.add_event(event.clone());
|
||||
}
|
||||
}
|
||||
313
apps/game/src/gameplay/narrative/history.rs
Normal file
313
apps/game/src/gameplay/narrative/history.rs
Normal file
@@ -0,0 +1,313 @@
|
||||
//! Campaign history tracking.
|
||||
//!
|
||||
//! Maintains the timeline of all events in a campaign with chapter
|
||||
//! divisions and key moment detection.
|
||||
|
||||
use bevy::prelude::*;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::gameplay::narrative::{GameEvent, NarrativeConfig};
|
||||
|
||||
/// Complete campaign history with timeline and chapters.
|
||||
#[derive(Resource, Debug, Clone)]
|
||||
pub struct CampaignHistory {
|
||||
/// All events in chronological order
|
||||
pub events: Vec<GameEvent>,
|
||||
/// Detected chapters in the campaign
|
||||
pub chapters: Vec<Chapter>,
|
||||
/// Key moments (significant events)
|
||||
pub key_moments: Vec<KeyMoment>,
|
||||
/// Campaign statistics
|
||||
pub statistics: CampaignStatistics,
|
||||
/// Time since last chapter detection
|
||||
pub time_since_chapter_check: f32,
|
||||
}
|
||||
|
||||
impl Default for CampaignHistory {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
events: Vec::new(),
|
||||
chapters: Vec::new(),
|
||||
key_moments: Vec::new(),
|
||||
statistics: CampaignStatistics::default(),
|
||||
time_since_chapter_check: 0.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CampaignHistory {
|
||||
/// Add a new event to the campaign history.
|
||||
pub fn add_event(&mut self, event: GameEvent) {
|
||||
// Update statistics before consuming the event
|
||||
self.update_statistics(&event);
|
||||
|
||||
// Check if this is a key moment
|
||||
if self.is_key_moment(&event) {
|
||||
self.key_moments.push(KeyMoment {
|
||||
event_index: self.events.len(),
|
||||
timestamp: event.timestamp(),
|
||||
significance: self.calculate_significance(&event),
|
||||
description: event.title(),
|
||||
});
|
||||
}
|
||||
|
||||
// Finally, consume the event by pushing it to the history
|
||||
self.events.push(event);
|
||||
}
|
||||
|
||||
/// Get the most recent N events.
|
||||
pub fn recent_events(&self, count: usize) -> &[GameEvent] {
|
||||
let start = self.events.len().saturating_sub(count);
|
||||
&self.events[start..]
|
||||
}
|
||||
|
||||
/// Get all events since a given timestamp.
|
||||
pub fn events_since(&self, timestamp: f64) -> &[GameEvent] {
|
||||
let start = self
|
||||
.events
|
||||
.partition_point(|e| e.timestamp() < timestamp);
|
||||
&self.events[start..]
|
||||
}
|
||||
|
||||
/// Check if an event is a key moment.
|
||||
fn is_key_moment(&self, event: &GameEvent) -> bool {
|
||||
match event {
|
||||
GameEvent::Combat(e) => {
|
||||
matches!(e.outcome, crate::gameplay::narrative::CombatOutcome::Victory)
|
||||
&& e.hull_remaining < 0.3
|
||||
}
|
||||
GameEvent::Exploration(e) => {
|
||||
matches!(
|
||||
e.discovery_type,
|
||||
crate::gameplay::narrative::DiscoveryType::NewSystem
|
||||
)
|
||||
}
|
||||
GameEvent::Mission(e) => {
|
||||
matches!(
|
||||
e.event_type,
|
||||
crate::gameplay::narrative::MissionEventType::Completed
|
||||
)
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate the significance score of an event (0.0 to 1.0).
|
||||
fn calculate_significance(&self, event: &GameEvent) -> f32 {
|
||||
match event {
|
||||
GameEvent::Combat(e) => {
|
||||
let base = 0.7;
|
||||
let bonus = if e.hull_remaining < 0.3 { 0.2 } else { 0.0 };
|
||||
base + bonus
|
||||
}
|
||||
GameEvent::Exploration(_) => 0.6,
|
||||
GameEvent::Mission(_) => 0.5,
|
||||
GameEvent::Trade(_) => 0.2,
|
||||
GameEvent::Mining(_) => 0.1,
|
||||
GameEvent::Docking(_) => 0.05,
|
||||
GameEvent::Custom(_) => 0.4,
|
||||
}
|
||||
}
|
||||
|
||||
/// Update campaign statistics based on a new event.
|
||||
fn update_statistics(&mut self, event: &GameEvent) {
|
||||
match event {
|
||||
GameEvent::Mining(_) => self.statistics.mining_operations += 1,
|
||||
GameEvent::Trade(_) => self.statistics.trade_operations += 1,
|
||||
GameEvent::Combat(e) => {
|
||||
self.statistics.combat_operations += 1;
|
||||
match e.outcome {
|
||||
crate::gameplay::narrative::CombatOutcome::Victory => {
|
||||
self.statistics.combat_victories += 1
|
||||
}
|
||||
crate::gameplay::narrative::CombatOutcome::Defeat => {
|
||||
self.statistics.combat_defeats += 1
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
GameEvent::Exploration(_) => self.statistics.discoveries += 1,
|
||||
GameEvent::Mission(_) => self.statistics.missions_completed += 1,
|
||||
GameEvent::Docking(_) => self.statistics.docking_operations += 1,
|
||||
GameEvent::Custom(_) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A chapter in the campaign narrative.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Chapter {
|
||||
/// Chapter index (0-based)
|
||||
pub index: usize,
|
||||
/// Chapter title
|
||||
pub title: String,
|
||||
/// Start event index
|
||||
pub start_event: usize,
|
||||
/// End event index (exclusive)
|
||||
pub end_event: usize,
|
||||
/// Primary theme of this chapter
|
||||
pub theme: ChapterTheme,
|
||||
/// Summary of events in this chapter
|
||||
pub summary: String,
|
||||
/// Timestamp when chapter started
|
||||
pub start_timestamp: f64,
|
||||
}
|
||||
|
||||
/// Themes that chapters can have.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ChapterTheme {
|
||||
Exploration,
|
||||
Commerce,
|
||||
Conflict,
|
||||
Diplomacy,
|
||||
Mystery,
|
||||
Survival,
|
||||
Growth,
|
||||
}
|
||||
|
||||
/// A key moment in the campaign.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct KeyMoment {
|
||||
/// Index of the event in the campaign history
|
||||
pub event_index: usize,
|
||||
/// When this occurred
|
||||
pub timestamp: f64,
|
||||
/// How significant (0.0 to 1.0)
|
||||
pub significance: f32,
|
||||
/// Description
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
/// Campaign statistics.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct CampaignStatistics {
|
||||
/// Total events logged
|
||||
pub total_events: usize,
|
||||
/// Mining operations performed
|
||||
pub mining_operations: u32,
|
||||
/// Trade operations performed
|
||||
pub trade_operations: u32,
|
||||
/// Combat operations performed
|
||||
pub combat_operations: u32,
|
||||
/// Combat victories
|
||||
pub combat_victories: u32,
|
||||
/// Combat defeats
|
||||
pub combat_defeats: u32,
|
||||
/// Discoveries made
|
||||
pub discoveries: u32,
|
||||
/// Missions completed
|
||||
pub missions_completed: u32,
|
||||
/// Docking operations
|
||||
pub docking_operations: u32,
|
||||
}
|
||||
|
||||
/// Detect chapters in the campaign history.
|
||||
///
|
||||
/// This system runs periodically and analyzes the event stream to
|
||||
/// identify natural chapter boundaries based on event patterns.
|
||||
pub fn detect_chapters(
|
||||
mut history: ResMut<CampaignHistory>,
|
||||
config: Res<NarrativeConfig>,
|
||||
time: Res<Time>,
|
||||
) {
|
||||
history.time_since_chapter_check += time.delta_secs();
|
||||
|
||||
// Only check every 10 seconds
|
||||
if history.time_since_chapter_check < 10.0 {
|
||||
return;
|
||||
}
|
||||
|
||||
history.time_since_chapter_check = 0.0;
|
||||
|
||||
// Only detect chapters if we have enough events
|
||||
if history.events.len() < config.events_per_chapter {
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate expected chapter count
|
||||
let expected_chapters = history.events.len() / config.events_per_chapter;
|
||||
|
||||
// Add new chapter if needed
|
||||
if history.chapters.len() < expected_chapters {
|
||||
let start_index = if let Some(last_chapter) = history.chapters.last() {
|
||||
last_chapter.end_event
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
let end_index = (start_index + config.events_per_chapter).min(history.events.len());
|
||||
|
||||
// Determine theme from events in this range
|
||||
let theme = detect_chapter_theme(&history.events[start_index..end_index]);
|
||||
|
||||
let chapter = Chapter {
|
||||
index: history.chapters.len(),
|
||||
title: format!("Chapter {}: {:?}", history.chapters.len() + 1, theme),
|
||||
start_event: start_index,
|
||||
end_event: end_index,
|
||||
theme,
|
||||
summary: generate_chapter_summary(&history.events[start_index..end_index]),
|
||||
start_timestamp: history
|
||||
.events
|
||||
.get(start_index)
|
||||
.map(|e| e.timestamp())
|
||||
.unwrap_or(0.0),
|
||||
};
|
||||
|
||||
bevy::log::info!("Detected new chapter: {}", chapter.title);
|
||||
history.chapters.push(chapter);
|
||||
}
|
||||
}
|
||||
|
||||
/// Detect the theme of a chapter from its events.
|
||||
fn detect_chapter_theme(events: &[GameEvent]) -> ChapterTheme {
|
||||
let mut counts = HashMap::new();
|
||||
|
||||
for event in events {
|
||||
let category = match event {
|
||||
GameEvent::Mining(_) => "commerce",
|
||||
GameEvent::Trade(_) => "commerce",
|
||||
GameEvent::Combat(_) => "conflict",
|
||||
GameEvent::Exploration(_) => "exploration",
|
||||
GameEvent::Docking(_) => "commerce",
|
||||
GameEvent::Mission(_) => "growth",
|
||||
GameEvent::Custom(_) => "mystery",
|
||||
};
|
||||
|
||||
*counts.entry(category).or_insert(0) += 1;
|
||||
}
|
||||
|
||||
let max_category = counts
|
||||
.into_iter()
|
||||
.max_by_key(|(_, count)| *count)
|
||||
.map(|(cat, _)| cat)
|
||||
.unwrap_or("exploration");
|
||||
|
||||
match max_category {
|
||||
"exploration" => ChapterTheme::Exploration,
|
||||
"commerce" => ChapterTheme::Commerce,
|
||||
"conflict" => ChapterTheme::Conflict,
|
||||
"growth" => ChapterTheme::Growth,
|
||||
"mystery" => ChapterTheme::Mystery,
|
||||
_ => ChapterTheme::Exploration,
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate a summary for a chapter from its events.
|
||||
fn generate_chapter_summary(events: &[GameEvent]) -> String {
|
||||
if events.is_empty() {
|
||||
return "Empty chapter".to_string();
|
||||
}
|
||||
|
||||
let start = events.first().unwrap();
|
||||
let end = events.last().unwrap();
|
||||
|
||||
let duration_hours = (end.timestamp() - start.timestamp()) / 3600.0;
|
||||
|
||||
format!(
|
||||
"Events from {:.1} hours of gameplay. Started with {}, ended with {}.",
|
||||
duration_hours,
|
||||
start.title(),
|
||||
end.title()
|
||||
)
|
||||
}
|
||||
70
apps/game/src/gameplay/narrative/mod.rs
Normal file
70
apps/game/src/gameplay/narrative/mod.rs
Normal file
@@ -0,0 +1,70 @@
|
||||
//! Narrative system for AI-generated storytelling.
|
||||
//!
|
||||
//! This module provides the foundation for the AI Story Director,
|
||||
//! which tracks player actions and weaves them into an ongoing narrative.
|
||||
//!
|
||||
//! ## Components
|
||||
//!
|
||||
//! - **Event Logging**: Capture all player actions as structured events
|
||||
//! - **Campaign History**: Maintain timeline of events with chapter divisions
|
||||
//! - **Narrative Synthesis**: Generate story text from events (LLM-powered)
|
||||
//! - **Story UI**: Display campaign narrative to players
|
||||
|
||||
mod events;
|
||||
mod history;
|
||||
mod synthesis;
|
||||
mod ui;
|
||||
|
||||
pub use events::*;
|
||||
pub use history::*;
|
||||
pub use synthesis::*;
|
||||
pub use ui::*;
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
/// Main narrative plugin.
|
||||
pub struct NarrativePlugin;
|
||||
|
||||
impl Plugin for NarrativePlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.init_resource::<NarrativeConfig>()
|
||||
.init_resource::<CampaignHistory>()
|
||||
.add_event::<GameEvent>()
|
||||
// Narrative systems run during gameplay
|
||||
.add_systems(
|
||||
Update,
|
||||
events::log_game_events.run_if(in_state(AppState::InGame)),
|
||||
)
|
||||
// Chapter detection runs periodically
|
||||
.add_systems(
|
||||
Update,
|
||||
history::detect_chapters.run_if(in_state(AppState::InGame)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Configuration for narrative generation.
|
||||
#[derive(Resource, Debug, Clone)]
|
||||
pub struct NarrativeConfig {
|
||||
/// Whether narrative generation is enabled
|
||||
pub enabled: bool,
|
||||
/// Events per chapter (approximate)
|
||||
pub events_per_chapter: usize,
|
||||
/// Whether to use LLM for synthesis (when available)
|
||||
pub use_llm_synthesis: bool,
|
||||
/// Update interval for story generation (seconds)
|
||||
pub synthesis_interval: f32,
|
||||
}
|
||||
|
||||
impl Default for NarrativeConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
events_per_chapter: 25,
|
||||
use_llm_synthesis: false, // Disabled until LLM integration
|
||||
synthesis_interval: 60.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
51
apps/game/src/gameplay/narrative/synthesis.rs
Normal file
51
apps/game/src/gameplay/narrative/synthesis.rs
Normal file
@@ -0,0 +1,51 @@
|
||||
//! Narrative synthesis engine.
|
||||
//!
|
||||
//! TODO: LLM-based narrative generation that weaves events into
|
||||
//! coherent story text.
|
||||
//!
|
||||
//! This module will:
|
||||
//! - Aggregate events into story beats
|
||||
//! - Generate narrative text using LLM API (Claude)
|
||||
//! - Maintain story coherence and continuity
|
||||
//! - Handle character dialogue and scene descriptions
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
/// Placeholder for narrative synthesis functionality.
|
||||
///
|
||||
/// Full implementation will require:
|
||||
/// - Event aggregation and pattern detection
|
||||
/// - LLM API integration (Claude API)
|
||||
/// - Story template system
|
||||
/// - Character personality modeling
|
||||
pub struct NarrativeSynthesis {
|
||||
/// LLM API endpoint (when available)
|
||||
pub api_endpoint: Option<String>,
|
||||
/// Whether synthesis is available
|
||||
pub available: bool,
|
||||
}
|
||||
|
||||
impl Default for NarrativeSynthesis {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
api_endpoint: None,
|
||||
available: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate narrative text from events.
|
||||
///
|
||||
/// TODO: Implement LLM-based synthesis.
|
||||
pub fn generate_narrative(_events: &[crate::gameplay::narrative::GameEvent]) -> String {
|
||||
// Placeholder: returns a simple summary
|
||||
// Full implementation will use LLM to generate engaging narrative
|
||||
"Narrative synthesis not yet implemented. This will use the Claude API to generate compelling story text from campaign events.".to_string()
|
||||
}
|
||||
|
||||
/// Generate chapter summary.
|
||||
///
|
||||
/// TODO: Implement with LLM synthesis.
|
||||
pub fn generate_chapter_summary(_chapter: &super::Chapter) -> String {
|
||||
"Chapter summary generation not yet implemented.".to_string()
|
||||
}
|
||||
64
apps/game/src/gameplay/narrative/ui.rs
Normal file
64
apps/game/src/gameplay/narrative/ui.rs
Normal file
@@ -0,0 +1,64 @@
|
||||
//! Story log UI system.
|
||||
//!
|
||||
//! TODO: In-game interface for reading campaign narrative with
|
||||
//! chapter navigation and search functionality.
|
||||
//!
|
||||
//! This module will:
|
||||
//! - Display story log with chapter divisions
|
||||
//! - Support chapter navigation
|
||||
//! - Provide search and filtering
|
||||
//! - Allow export/save of campaign story
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
/// Component for the story log UI root.
|
||||
#[derive(Component)]
|
||||
pub struct StoryLogUi;
|
||||
|
||||
/// Component for chapter list items.
|
||||
#[derive(Component)]
|
||||
pub struct ChapterListItem {
|
||||
/// Chapter index
|
||||
pub index: usize,
|
||||
}
|
||||
|
||||
/// Component for event detail items.
|
||||
#[derive(Component)]
|
||||
pub struct EventDetailItem {
|
||||
/// Event index
|
||||
pub event_index: usize,
|
||||
}
|
||||
|
||||
/// Setup the story log UI.
|
||||
///
|
||||
/// TODO: Implement full UI with:
|
||||
/// - Chapter list with titles and themes
|
||||
/// - Event timeline display
|
||||
/// - Chapter navigation buttons
|
||||
/// - Search/filter controls
|
||||
/// - Export button
|
||||
pub fn setup_story_log_ui(_commands: &mut Commands) {
|
||||
// Placeholder: UI setup to be implemented
|
||||
bevy::log::info!("Story log UI setup not yet implemented");
|
||||
}
|
||||
|
||||
/// Update the story log UI with current narrative data.
|
||||
///
|
||||
/// TODO: Implement UI update logic.
|
||||
pub fn update_story_log_ui() {
|
||||
// Placeholder: UI update to be implemented
|
||||
}
|
||||
|
||||
/// Handle chapter navigation clicks.
|
||||
///
|
||||
/// TODO: Implement chapter navigation.
|
||||
pub fn handle_chapter_navigation() {
|
||||
// Placeholder: Navigation handling to be implemented
|
||||
}
|
||||
|
||||
/// Export campaign story to file.
|
||||
///
|
||||
/// TODO: Implement export functionality.
|
||||
pub fn export_campaign_story(_history: &super::CampaignHistory) -> Result<(), String> {
|
||||
Err("Export not yet implemented".to_string())
|
||||
}
|
||||
@@ -176,6 +176,7 @@ pub fn spawn_selected_pois(
|
||||
system_contents,
|
||||
star_entity,
|
||||
&content_assets,
|
||||
&mut materials,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -8,9 +8,9 @@ use bevy::prelude::*;
|
||||
use camera::{orbit_camera_control, follow_camera_system, CameraState};
|
||||
use gameplay::campaign::CampaignDraft;
|
||||
use gameplay::{
|
||||
character_creation::CharacterCreationPlugin, galaxy::GalaxyPlugin, in_system::InSystemPlugin,
|
||||
movement::MovementPlugin, physics::PhysicsPlugin, star_map::StarMapPlugin,
|
||||
starting_base::StartingBasePlugin,
|
||||
ai::AiPlugin, character_creation::CharacterCreationPlugin, galaxy::GalaxyPlugin,
|
||||
in_system::InSystemPlugin, movement::MovementPlugin, narrative::NarrativePlugin,
|
||||
physics::PhysicsPlugin, star_map::StarMapPlugin, starting_base::StartingBasePlugin,
|
||||
};
|
||||
use state::AppState;
|
||||
use ui::main_menu;
|
||||
@@ -50,6 +50,8 @@ fn main() {
|
||||
CharacterCreationPlugin,
|
||||
StartingBasePlugin,
|
||||
InSystemPlugin,
|
||||
AiPlugin,
|
||||
NarrativePlugin,
|
||||
))
|
||||
.run();
|
||||
}
|
||||
|
||||
@@ -7,4 +7,23 @@ export default defineConfig({
|
||||
jsx: "automatic",
|
||||
jsxImportSource: "react",
|
||||
},
|
||||
build: {
|
||||
// Generate source maps for production debugging
|
||||
sourcemap: true,
|
||||
// Optimize chunk splitting for better caching
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: {
|
||||
// Bundle React separately (more stable across updates)
|
||||
"react-vendor": ["react", "react-dom"],
|
||||
},
|
||||
},
|
||||
},
|
||||
// Chunk size warning limit (in kB)
|
||||
chunkSizeWarningLimit: 500,
|
||||
},
|
||||
// Optimize dependency pre-bundling
|
||||
optimizeDeps: {
|
||||
include: ["react", "react-dom"],
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user