Add galaxy parameter UI, star selection, and movement/physics scaffolding

Galaxy creation scene (Bevy 0.16):
- Split into module folder: params.rs, mod.rs (generation + spawn), ui.rs,
  selection.rs
- GalaxyParams resource (seed, count, arms, vertical_arms, size, twist,
  vertical_twist) with generation counter for change detection
- Control panel: 7 +/- slider rows + Regenerate button, no native Bevy slider
  widget so uses label + icon buttons
- Info panel: live-refreshes on selection change via despawn_related::<Children>
- Click-to-select: screen-space picking (project each star to viewport, closest
  within 18px threshold wins); selected star lerps scale to 2.2x
- Generation faithfully ports the docs TS reference including vertical arms
- Regeneration system: despawns GalaxyScene root only, preserves UI panels

Camera:
- Camera2d -> Camera3d + OrbitCamera (left-drag yaw/pitch, scroll zoom,
  pitch/distance clamped to avoid gimbal)
- Orbit drag suppressed when cursor over UI

New plugins (scaffolding):
- movement/: Velocity, MaxSpeed, TurnRate, Drag, MoveTarget, Player components
  + click-to-move (no player spawned yet)
- physics/: pure-data geometry (ray_vs_sphere, overlaps, separate,
  segment_vs_sphere) with 7 passing unit tests; no systems wired yet
- star_map/: plugin skeleton gated on AppState::InGame

Shared:
- ui/util.rs: cursor_over_ui helper for UI-hover detection
- docs: ARCH-9 decision record (custom movement & collision, no physics engine)
This commit is contained in:
2026-06-04 12:29:33 -04:00
parent a7796a1394
commit c14f684b09
20 changed files with 1798 additions and 23 deletions

View File

@@ -475,6 +475,130 @@ export function ArchitecturePage() {
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>
<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: '+3090s on every clean build' },
{ c: 'Binary size', custom: '0 added', engine: '+25 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 didnt.' },
].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>
</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/>
&nbsp;&nbsp;mod.rs&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span style={{ color: 'var(--muted)' }}>// MovementPlugin</span><br/>
&nbsp;&nbsp;components.rs&nbsp;&nbsp;<span style={{ color: 'var(--muted)' }}>// Velocity, MaxSpeed, TurnRate, Drag</span><br/>
&nbsp;&nbsp;kinematic.rs&nbsp;&nbsp;&nbsp;<span style={{ color: 'var(--muted)' }}>// move + drag + clamp systems</span><br/>
&nbsp;&nbsp;orbit.rs&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<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 Bevys <code>Time&lt;Fixed&gt;</code> schedule for stable, deterministic ticks that
align cleanly with SpacetimeDB updates.
</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(--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/>
&nbsp;&nbsp;mod.rs&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<span style={{ color: 'var(--muted)' }}>// PhysicsPlugin</span><br/>
&nbsp;&nbsp;geometry.rs&nbsp;&nbsp;&nbsp;<span style={{ color: 'var(--muted)' }}>// ray_vs_circle, overlaps, separate</span><br/>
&nbsp;&nbsp;broad_phase.rs&nbsp;<span style={{ color: 'var(--muted)' }}>// (later) uniform grid</span><br/>
&nbsp;&nbsp;systems.rs&nbsp;&nbsp;&nbsp;&nbsp;<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>
</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 engines solver would make this
property very difficult to guarantee.
</div>
</div>
);
}