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

View File

@@ -1,5 +1,102 @@
use bevy::input::mouse::MouseButton;
use bevy::prelude::*;
use bevy::window::PrimaryWindow;
pub fn spawn_camera(mut commands: Commands) {
commands.spawn(Camera2d);
use crate::ui::util::cursor_over_ui;
#[derive(Component)]
pub struct MainCamera;
/// Orbit-style camera: rotates around `target` at `distance`, controlled by mouse.
/// Used for inspection scenes like GalaxyCreation where there is no player to follow.
#[derive(Component, Debug, Clone, Copy)]
pub struct OrbitCamera {
pub target: Vec3,
pub distance: f32,
/// Yaw around the Y axis (radians). 0 = camera at +Z looking toward origin.
pub yaw: f32,
/// Pitch above the horizontal plane (radians). 0 = horizontal, π/2 = straight down.
pub pitch: f32,
}
impl Default for OrbitCamera {
fn default() -> Self {
Self {
target: Vec3::ZERO,
distance: 420.0,
yaw: 0.0,
// ~36° above horizontal — roughly matches docs GalaxyScene camera position.
pitch: 0.625,
}
}
}
/// Initial camera spawn. The same entity is reused across states; control systems
/// decide how it moves depending on which state is active.
pub fn spawn_camera(mut commands: Commands) {
commands.spawn((
Camera3d::default(),
Transform::from_xyz(0.0, 260.0, 360.0).looking_at(Vec3::ZERO, Vec3::Y),
MainCamera,
OrbitCamera::default(),
));
}
const ORBIT_ROTATE_SENSITIVITY: f32 = 0.005;
const ORBIT_ZOOM_SENSITIVITY: f32 = 0.1;
const ORBIT_MIN_PITCH: f32 = 0.15; // ~9° above horizontal — never fully edge-on
const ORBIT_MAX_PITCH: f32 = 1.4; // ~80° — never straight down (gimbal safeguard)
const ORBIT_MIN_DISTANCE: f32 = 40.0;
const ORBIT_MAX_DISTANCE: f32 = 1500.0;
/// Left-drag rotates around the orbit target; scroll wheel zooms.
///
/// Drag is suppressed when the cursor is over any UI node — otherwise clicking
/// buttons or panels would also rotate the camera.
pub fn orbit_camera_control(
mouse_input: Res<ButtonInput<MouseButton>>,
primary_window: Query<&Window, With<PrimaryWindow>>,
mut mouse_motion: EventReader<bevy::input::mouse::MouseMotion>,
mut scroll_events: EventReader<bevy::input::mouse::MouseWheel>,
mut query: Query<(&mut Transform, &mut OrbitCamera), With<MainCamera>>,
ui_nodes: Query<(&bevy::ui::ComputedNode, &GlobalTransform)>,
) {
let Ok((mut transform, mut orbit)) = query.single_mut() else {
// Drain pending input to avoid stale buildup when there's no camera.
mouse_motion.clear();
scroll_events.clear();
return;
};
let cursor_over_ui = primary_window
.single()
.ok()
.map(|w| cursor_over_ui(w, &ui_nodes))
.unwrap_or(false);
if mouse_input.pressed(MouseButton::Left) && !cursor_over_ui {
for event in mouse_motion.read() {
orbit.yaw -= event.delta.x * ORBIT_ROTATE_SENSITIVITY;
orbit.pitch -= event.delta.y * ORBIT_ROTATE_SENSITIVITY;
}
} else {
mouse_motion.clear();
}
for event in scroll_events.read() {
// Scroll up (positive y) → decrease distance (zoom in).
orbit.distance *= 1.0 - event.y * ORBIT_ZOOM_SENSITIVITY;
}
orbit.pitch = orbit.pitch.clamp(ORBIT_MIN_PITCH, ORBIT_MAX_PITCH);
orbit.distance = orbit.distance.clamp(ORBIT_MIN_DISTANCE, ORBIT_MAX_DISTANCE);
let cos_p = orbit.pitch.cos();
let pos = orbit.target
+ Vec3::new(
orbit.distance * cos_p * orbit.yaw.sin(),
orbit.distance * orbit.pitch.sin(),
orbit.distance * cos_p * orbit.yaw.cos(),
);
*transform = Transform::from_translation(pos).looking_at(orbit.target, Vec3::Y);
}

View File

@@ -1,18 +0,0 @@
use bevy::prelude::*;
// ── Markers ─────────────────────────────────────────────────────────────────
#[derive(Component)]
pub struct GalaxyCreationUi;
// ── Galaxy Creation Screen ──────────────────────────────────────────────────
pub fn setup_galaxy_creation(_commands: Commands) {
// TODO: spawn galaxy creation UI
}
pub fn despawn_galaxy_creation(mut commands: Commands, query: Query<Entity, With<GalaxyCreationUi>>) {
for entity in &query {
commands.entity(entity).despawn();
}
}

View File

@@ -0,0 +1,369 @@
//! Galaxy creation inspection scene.
//!
//! Procedural spiral galaxy viewer with editable parameters. The 3D scene is
//! regenerated whenever [`GalaxyParams`] changes (via the `generation` counter);
//! click-to-select is handled in [`super::selection`]; the slider/info panels
//! live in [`super::ui`].
mod params;
mod selection;
mod ui;
use bevy::prelude::*;
use rand::rngs::StdRng;
use rand::{Rng, SeedableRng};
use crate::state::AppState;
pub use params::{GalaxyParams, SelectedStar};
use params::{CORE_COUNT, NEAREST_NEIGHBOR_CONNECTIONS, SPACING_ATTEMPTS};
pub struct GalaxyCreationPlugin;
impl Plugin for GalaxyCreationPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<GalaxyParams>()
.init_resource::<SelectedStar>()
.add_systems(OnEnter(AppState::GalaxyCreation), (setup_galaxy_scene, ui::setup_galaxy_ui))
.add_systems(OnExit(AppState::GalaxyCreation), (despawn_galaxy_creation, reset_selection))
.add_systems(
Update,
(
escape_to_main_menu,
ui::param_button_handler,
ui::refresh_control_panel_values,
regenerate_galaxy_on_param_change,
selection::select_star_on_click,
selection::animate_selected_star,
selection::refresh_info_panel,
)
.chain()
.run_if(in_state(AppState::GalaxyCreation)),
);
}
}
// ── Markers ─────────────────────────────────────────────────────────────────
/// Tag for *anything* spawned during galaxy creation so it can be cleanly
/// despawned on state exit. Applies to both 3D scene roots and UI panel roots.
#[derive(Component)]
pub struct GalaxyCreationSpawned;
/// Tag for the 3D scene root only — despawned on regeneration, so we can
/// rebuild the galaxy without disturbing the UI panels.
#[derive(Component)]
pub struct GalaxyScene;
/// A star system in the procedural galaxy.
#[derive(Component, Debug)]
pub struct StarSystem {
pub id: String,
pub name: String,
pub faction: &'static str,
pub security: f32,
}
// ── Faction palette (matches docs GalaxyScene) ──────────────────────────────
const FACTIONS: &[(&str, [f32; 3])] = &[
("Concord", [0.13, 0.83, 0.93]), // cyan — core faction
("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
];
// ── Setup ───────────────────────────────────────────────────────────────────
fn setup_galaxy_scene(
mut commands: Commands,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<StandardMaterial>>,
params: Res<GalaxyParams>,
) {
let systems = generate_galaxy(&params);
spawn_galaxy_scene(&mut commands, &mut meshes, &mut materials, &systems);
}
/// Generate the full galaxy layout (pure data — no Bevy types).
///
/// Faithful port of the TS reference in
/// `apps/docs/src/prototypes/r3f/galaxy/GalaxyScene.tsx::generateGalaxy`.
fn generate_galaxy(params: &GalaxyParams) -> Vec<GeneratedSystem> {
let mut rng = StdRng::seed_from_u64(params.seed);
let mut systems: Vec<GeneratedSystem> = Vec::with_capacity(params.count);
// Spacing scales with density: smaller for tight cores, larger spread overall.
let base_spacing = (params.size / (params.count as f32).sqrt() * 1.25).clamp(9.0, 24.0);
let horizontal_arms = params.arms.max(1);
let vertical_arms = params.vertical_arms;
let total_arm_slots = horizontal_arms + vertical_arms;
for index in 0..params.count {
let core = index < CORE_COUNT;
let arm_slot = (index as u32) % total_arm_slots.max(1);
let vertical = !core && arm_slot >= horizontal_arms;
let arm = if vertical {
arm_slot - horizontal_arms
} else {
arm_slot
};
let faction_index = if core {
0 // Concord
} else {
((arm_slot as usize) % (FACTIONS.len() - 1)) + 1
};
let (faction, color) = FACTIONS[faction_index];
let mut position = Vec3::ZERO;
let mut final_radius = 0.0f32;
for attempt in 0..SPACING_ATTEMPTS {
// `pow(0.62)` produces a density bias toward the core.
let r = if core {
8.0 + rng.gen::<f32>() * 30.0
} else {
rng.gen::<f32>().powf(0.62) * params.size
};
let arm_count = if vertical { vertical_arms.max(1) } else { horizontal_arms };
let arm_twist = if vertical { params.vertical_twist } else { params.twist };
let angle = if core {
rng.gen::<f32>() * std::f32::consts::TAU
} else {
std::f32::consts::TAU * arm as f32 / arm_count as f32
+ (r / params.size) * arm_twist
+ (rng.gen::<f32>() - 0.5) * 0.72
};
let (x, y, z) = if vertical {
// Vertical arms: stars orbit in a vertical plane rotated around the Y axis.
let plane_angle = std::f32::consts::TAU * arm as f32 / vertical_arms.max(1) as f32;
let arm_sweep = angle.cos() * r;
let vx = plane_angle.cos() * arm_sweep;
let vz = plane_angle.sin() * arm_sweep;
let vy = angle.sin() * r * 0.72 + (rng.gen::<f32>() - 0.5) * 12.0;
(vx, vy, vz)
} else {
(angle.cos() * r, (rng.gen::<f32>() - 0.5) * 20.0, angle.sin() * r)
};
let candidate = Vec3::new(x, y, z);
final_radius = r;
// Spacing relaxation: shrink required distance after many failed attempts.
let relaxed = if attempt > 120 {
base_spacing * 0.68
} else if attempt > 60 {
base_spacing * 0.82
} else {
base_spacing
};
let local_spacing = if core { relaxed * 0.9 } else { relaxed };
let clear = systems
.iter()
.all(|s| s.position.distance(candidate) >= local_spacing);
if clear {
position = candidate;
break;
}
}
let security =
((1.0 - (final_radius / params.size) * 2.0) * 100.0).round() / 100.0;
let name = if core {
format!("COR-{}", 100 + index)
} else {
format!("{}-{}", &faction[..3].to_uppercase(), 100 + index)
};
systems.push(GeneratedSystem {
id: format!("g-{index}"),
name,
position,
faction,
faction_index,
color,
security,
});
}
systems
}
struct GeneratedSystem {
id: String,
name: String,
position: Vec3,
faction: &'static str,
faction_index: usize,
/// Per-system tint; currently the renderer uses per-faction materials indexed
/// by `faction_index`, but this is kept for future per-system variation.
#[allow(dead_code)]
color: [f32; 3],
security: f32,
}
fn spawn_galaxy_scene(
commands: &mut Commands,
meshes: &mut Assets<Mesh>,
materials: &mut Assets<StandardMaterial>,
systems: &[GeneratedSystem],
) {
// Shared meshes (Bevy 0.16 required-components model — no bundle needed).
let star_mesh = meshes.add(Sphere::new(1.5).mesh().ico(3).unwrap());
let core_star_mesh = meshes.add(Sphere::new(3.0).mesh().ico(4).unwrap());
let faction_materials: Vec<Handle<StandardMaterial>> = FACTIONS
.iter()
.map(|(_, rgb)| {
materials.add(StandardMaterial {
base_color: Color::srgb(rgb[0], rgb[1], rgb[2]),
emissive: LinearRgba::new(rgb[0] * 1.6, rgb[1] * 1.6, rgb[2] * 1.6, 1.0),
unlit: true,
..default()
})
})
.collect();
let connection_material = materials.add(StandardMaterial {
base_color: Color::srgb(0.11, 0.16, 0.25),
emissive: LinearRgba::new(0.08, 0.13, 0.22, 1.0),
unlit: true,
..default()
});
let connections = build_connections(systems);
// Parent group so all galaxy contents despawn together.
commands
.spawn((
Transform::default(),
GalaxyScene,
GalaxyCreationSpawned,
))
.with_children(|parent| {
// Star systems.
for sys in systems {
let (mesh, material) = if sys.faction_index == 0 && sys.position.length() < 40.0
{
// Visual differentiation for the 7 Concord core systems.
(core_star_mesh.clone(), faction_materials[0].clone())
} else {
(star_mesh.clone(), faction_materials[sys.faction_index].clone())
};
parent.spawn((
Mesh3d(mesh),
MeshMaterial3d(material),
Transform::from_translation(sys.position),
StarSystem {
id: sys.id.clone(),
name: sys.name.clone(),
faction: sys.faction,
security: sys.security,
},
));
}
// Connections rendered as thin cylinders between linked systems.
for (a, b) in connections {
let from = systems[a].position;
let to = systems[b].position;
let delta = to - from;
let length = delta.length();
if length < 0.01 {
continue;
}
let midpoint = (from + to) * 0.5;
let direction = delta / length;
// Cylinder default axis is +Y; rotate so +Y aligns with the link direction.
let rotation = Quat::from_rotation_arc(Vec3::Y, direction);
parent.spawn((
Mesh3d(meshes.add(Cylinder::new(0.15, length).mesh())),
MeshMaterial3d(connection_material.clone()),
Transform::from_translation(midpoint).with_rotation(rotation),
));
}
});
}
fn build_connections(systems: &[GeneratedSystem]) -> Vec<(usize, usize)> {
let mut connections: Vec<(usize, usize)> = Vec::new();
for (i, system) in systems.iter().enumerate() {
let mut distances: Vec<(usize, f32)> = systems
.iter()
.enumerate()
.filter(|(j, _)| *j != i)
.map(|(j, other)| (j, system.position.distance(other.position)))
.collect();
distances.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
for &(j, _) in distances.iter().take(NEAREST_NEIGHBOR_CONNECTIONS) {
let key = if i < j { (i, j) } else { (j, i) };
if !connections.contains(&key) {
connections.push(key);
}
}
}
connections
}
// ── Lifecycle systems ───────────────────────────────────────────────────────
fn despawn_galaxy_creation(
mut commands: Commands,
query: Query<Entity, With<GalaxyCreationSpawned>>,
) {
for entity in &query {
// Bevy 0.16: despawn() is recursive by default.
commands.entity(entity).despawn();
}
}
fn reset_selection(mut selected: ResMut<SelectedStar>) {
selected.0 = None;
}
fn escape_to_main_menu(
keys: Res<ButtonInput<KeyCode>>,
mut next_state: ResMut<NextState<AppState>>,
) {
if keys.just_pressed(KeyCode::Escape) {
next_state.set(AppState::MainMenu);
}
}
// ── Regeneration ────────────────────────────────────────────────────────────
/// Detects [`GalaxyParams`] generation changes and rebuilds the 3D scene.
///
/// On first run, the OnEnter handler has already spawned the initial scene —
/// we just record the generation and bail. On subsequent changes we despawn
/// every `GalaxyScene` root (recursive by default — kills all star/connection
/// children) and respawn a fresh galaxy.
fn regenerate_galaxy_on_param_change(
mut commands: Commands,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<StandardMaterial>>,
params: Res<GalaxyParams>,
scene_roots: Query<Entity, With<GalaxyScene>>,
mut selected: ResMut<SelectedStar>,
mut last_seen: Local<Option<u64>>,
) {
match *last_seen {
Some(g) if g == params.generation => return,
None => {
// First run: OnEnter already spawned the scene. Just record + bail.
*last_seen = Some(params.generation);
return;
}
_ => {}
}
*last_seen = Some(params.generation);
// The previously-selected entity is about to be despawned; clear it so
// the info panel and animation system don't reference a dangling Entity.
selected.0 = None;
for entity in &scene_roots {
commands.entity(entity).despawn();
}
let systems = generate_galaxy(&params);
spawn_galaxy_scene(&mut commands, &mut meshes, &mut materials, &systems);
}

View File

@@ -0,0 +1,105 @@
//! Authoritative resources for the galaxy creation scene.
//!
//! [`GalaxyParams`] is the input to procedural generation; the UI mutates it,
//! the regeneration system detects changes via the `generation` counter, and
//! the generator reads it. [`SelectedStar`] tracks the currently-clicked star.
use bevy::prelude::*;
// ── Tunable defaults ────────────────────────────────────────────────────────
//
// These mirror the docs prototype (`apps/docs/src/prototypes/existing-demos/GalaxyDemo.tsx`)
// so the Bevy scene opens with the same look as the web demo.
const DEFAULT_SEED: u64 = 4242;
const DEFAULT_COUNT: usize = 120;
const DEFAULT_ARMS: u32 = 4;
const DEFAULT_VERTICAL_ARMS: u32 = 0;
const DEFAULT_SIZE: f32 = 280.0;
const DEFAULT_TWIST: f32 = 3.0;
const DEFAULT_VERTICAL_TWIST: f32 = 1.1;
// ── Limits (also mirror the TS sliders) ─────────────────────────────────────
pub const SEED_STEP: u64 = 1;
pub const COUNT_MIN: usize = 40;
pub const COUNT_MAX: usize = 220;
pub const COUNT_STEP: usize = 4;
pub const ARMS_MIN: u32 = 1;
pub const ARMS_MAX: u32 = 6;
/// u32 lower bound is 0; constant kept for symmetry/documentation with the
/// other `*_MIN` bounds.
#[allow(dead_code)]
pub const VERTICAL_ARMS_MIN: u32 = 0;
pub const VERTICAL_ARMS_MAX: u32 = 4;
pub const SIZE_MIN: f32 = 140.0;
pub const SIZE_MAX: f32 = 420.0;
pub const SIZE_STEP: f32 = 10.0;
pub const TWIST_MIN: f32 = 1.0;
pub const TWIST_MAX: f32 = 6.0;
pub const TWIST_STEP: f32 = 0.2;
pub const VERTICAL_TWIST_MIN: f32 = 0.0;
pub const VERTICAL_TWIST_MAX: f32 = 6.0;
pub const VERTICAL_TWIST_STEP: f32 = 0.2;
/// Number of "core" Concord systems clustered at the galactic center.
/// Not exposed in the UI — kept as an internal constant for now.
pub(crate) const CORE_COUNT: usize = 7;
/// Connect each system to its N nearest neighbors (deduplicated).
/// Docs prototype uses 2; kept constant until a UI control is added.
pub(crate) const NEAREST_NEIGHBOR_CONNECTIONS: usize = 2;
/// Max attempts to find a clear spot for each system (Poisson-style spacing).
pub(crate) const SPACING_ATTEMPTS: usize = 180;
/// Procedural galaxy parameters. UI mutates fields, then bumps `generation`
/// to signal the regeneration system to rebuild the scene.
#[derive(Resource, Debug, Clone)]
pub struct GalaxyParams {
pub seed: u64,
pub count: usize,
pub arms: u32,
pub vertical_arms: u32,
pub size: f32,
pub twist: f32,
pub vertical_twist: f32,
/// Monotonic counter — any mutation must bump this via [`Self::bump_generation`].
/// The regeneration system triggers a rebuild whenever this differs from its
/// last-seen value.
pub generation: u64,
}
impl Default for GalaxyParams {
fn default() -> Self {
Self {
seed: DEFAULT_SEED,
count: DEFAULT_COUNT,
arms: DEFAULT_ARMS,
vertical_arms: DEFAULT_VERTICAL_ARMS,
size: DEFAULT_SIZE,
twist: DEFAULT_TWIST,
vertical_twist: DEFAULT_VERTICAL_TWIST,
generation: 0,
}
}
}
impl GalaxyParams {
/// Bump the generation counter, signalling the regeneration system.
/// Call after any field mutation that should trigger a scene rebuild.
pub fn bump_generation(&mut self) {
self.generation = self.generation.wrapping_add(1);
}
/// Convenience for the "Regenerate" button: bump the seed and mark dirty.
pub fn reseed_and_bump(&mut self) {
self.seed = self.seed.wrapping_add(1);
self.bump_generation();
}
}
/// Currently selected star system entity in the galaxy view.
/// `None` means no selection.
#[derive(Resource, Default, Debug, Clone, Copy)]
pub struct SelectedStar(pub Option<Entity>);

View File

@@ -0,0 +1,212 @@
//! Click-to-select star systems + selection visuals + info panel refresh.
//!
//! Picking is done in **screen space**: each star's world position is projected
//! to viewport coordinates, and we find the closest one within a pixel
//! threshold. This is cheaper and more intuitive than raycasting against
//! individual sphere meshes for an unbounded number of tiny stars.
use bevy::prelude::*;
use bevy::window::PrimaryWindow;
use super::StarSystem;
use crate::camera::MainCamera;
use crate::gameplay::galaxy_creation::params::SelectedStar;
use crate::gameplay::galaxy_creation::ui::GalaxyInfoPanel;
use crate::ui::util::cursor_over_ui;
// ── Tunables ────────────────────────────────────────────────────────────────
/// Cursor-to-star screen distance (in logical pixels) below which a click is
/// treated as a hit. The docs demo uses similar ~16px tolerance.
const SELECTION_PIXEL_THRESHOLD: f32 = 18.0;
/// Target visual scale for the selected star; non-selected stars animate back to 1.0.
const SELECTED_SCALE: f32 = 2.2;
/// Lerp speed (per second) for the scale animation. Higher = snappier.
const SELECTION_LERP_SPEED: f32 = 10.0;
// ── Pick on click ───────────────────────────────────────────────────────────
pub fn select_star_on_click(
mouse_input: Res<ButtonInput<MouseButton>>,
primary_window: Query<&Window, With<PrimaryWindow>>,
camera_query: Query<(&Camera, &GlobalTransform), With<MainCamera>>,
stars: Query<(Entity, &Transform), With<StarSystem>>,
ui_nodes: Query<(&bevy::ui::ComputedNode, &GlobalTransform)>,
mut selected: ResMut<SelectedStar>,
) {
if !mouse_input.just_pressed(MouseButton::Left) {
return;
}
let Ok(window) = primary_window.single() else {
return;
};
if cursor_over_ui(window, &ui_nodes) {
return;
}
let Some(cursor) = window.cursor_position() else {
return;
};
let Ok((camera, camera_gt)) = camera_query.single() else {
return;
};
// Find the closest star (by screen-pixel distance) within threshold.
let mut best: Option<(Entity, f32)> = None;
for (entity, transform) in &stars {
let Ok(viewport) = camera.world_to_viewport(camera_gt, transform.translation) else {
continue;
};
let dist = viewport.distance(cursor);
if dist >= SELECTION_PIXEL_THRESHOLD {
continue;
}
if best.is_none_or(|(_, d)| dist < d) {
best = Some((entity, dist));
}
}
// Always assign so change detection fires even when clearing the selection.
let new_selection = best.map(|(entity, _)| entity);
if selected.0 != new_selection {
selected.0 = new_selection;
}
}
// ── Visual highlight ────────────────────────────────────────────────────────
/// Smoothly lerp every star's scale toward 1.0 (default) or [`SELECTED_SCALE`]
/// when it's the currently selected entity.
pub fn animate_selected_star(
selected: Res<SelectedStar>,
mut stars: Query<(Entity, &mut Transform), With<StarSystem>>,
time: Res<Time>,
) {
// Avoid mutating (and marking-changed) every Transform when nothing is selected
// and all stars are already at scale 1.0.
let all_default = selected.0.is_none() && stars.iter().all(|(_, t)| t.scale == Vec3::ONE);
if all_default {
return;
}
let dt = time.delta_secs().min(0.1);
let alpha = (dt * SELECTION_LERP_SPEED).clamp(0.0, 1.0);
for (entity, mut transform) in &mut stars {
let target = if selected.0 == Some(entity) {
SELECTED_SCALE
} else {
1.0
};
let current = transform.scale.x;
let next = current + (target - current) * alpha;
// Avoid spamming change detection when within float-tolerance of the target.
if (next - current).abs() > 1e-4 {
transform.scale = Vec3::splat(next);
}
}
}
// ── Info panel refresh ──────────────────────────────────────────────────────
/// Rebuild the contents of the right-side info panel whenever the selection
/// changes. The panel shell stays put; only its children are replaced via
/// `despawn_related::<Children>()`.
pub fn refresh_info_panel(
mut commands: Commands,
selected: Res<SelectedStar>,
stars: Query<&StarSystem>,
panel_roots: Query<Entity, With<GalaxyInfoPanel>>,
) {
if !selected.is_changed() {
return;
}
let Ok(panel_entity) = panel_roots.single() else {
return;
};
// Despawn existing children but keep the panel root itself.
commands.entity(panel_entity).despawn_related::<Children>();
let selected_entity = selected.0;
let sys = selected_entity.and_then(|e| stars.get(e).ok());
commands.entity(panel_entity).with_children(|parent| {
parent.spawn((
Text::new("Selected System"),
TextFont {
font_size: 20.0,
..default()
},
TextColor(Color::srgb(0.82, 0.90, 1.0)),
Node {
margin: UiRect::bottom(Val::Px(8.0)),
..default()
},
));
match sys {
Some(sys) => {
let title = sys.name.clone();
parent.spawn((
Text::new(title),
TextFont {
font_size: 17.0,
..default()
},
TextColor(Color::srgb(0.20, 0.83, 0.93)),
Node {
margin: UiRect::bottom(Val::Px(8.0)),
..default()
},
));
spawn_info_line(parent, "Faction", sys.faction);
spawn_info_line(parent, "Security", &format!("{:.2}", sys.security));
spawn_info_line(parent, "ID", &sys.id);
}
None => {
parent.spawn((
Text::new("Click a star to inspect."),
TextFont {
font_size: 15.0,
..default()
},
TextColor(Color::srgb(0.55, 0.65, 0.82)),
));
}
}
});
}
fn spawn_info_line(parent: &mut ChildSpawnerCommands, label: &str, value: &str) {
parent
.spawn(Node {
width: Val::Percent(100.0),
flex_direction: FlexDirection::Row,
column_gap: Val::Px(8.0),
..default()
})
.with_children(|row| {
row.spawn((
Text::new(label),
TextFont {
font_size: 14.0,
..default()
},
TextColor(Color::srgb(0.55, 0.65, 0.82)),
Node {
width: Val::Px(78.0),
..default()
},
));
row.spawn((
Text::new(value),
TextFont {
font_size: 14.0,
..default()
},
TextColor(Color::srgb(0.82, 0.90, 1.0)),
));
});
}

View File

@@ -0,0 +1,393 @@
//! Galaxy creation UI: parameter slider panel (left) and selected-system
//! info panel (right).
//!
//! Bevy 0.16 does not ship a native Slider widget, so each parameter is a
//! row of `Label Value [-] [+]` buttons. Each `+/-` button carries a
//! [`ParamButton`] marker identifying which field to mutate.
use bevy::prelude::*;
use super::GalaxyCreationSpawned;
use crate::gameplay::galaxy_creation::params::*;
// ── Markers ─────────────────────────────────────────────────────────────────
#[derive(Component)]
pub struct GalaxyControlPanel;
#[derive(Component)]
pub struct GalaxyInfoPanel;
/// Identifies which parameter a `+/-` button mutates and which direction.
#[derive(Component, Clone, Copy)]
pub enum ParamButton {
SeedDecr,
SeedIncr,
CountDecr,
CountIncr,
ArmsDecr,
ArmsIncr,
VerticalArmsDecr,
VerticalArmsIncr,
SizeDecr,
SizeIncr,
TwistDecr,
TwistIncr,
VerticalTwistDecr,
VerticalTwistIncr,
/// Randomize: bump seed by 1 (equivalent to the docs "Regenerate" button).
Regenerate,
}
// ── Styling constants ───────────────────────────────────────────────────────
const PANEL_BG: Color = Color::srgb(0.05, 0.07, 0.12);
const PANEL_BORDER: Color = Color::srgb(0.25, 0.40, 0.62);
const TEXT_BRIGHT: Color = Color::srgb(0.82, 0.90, 1.0);
const TEXT_DIM: Color = Color::srgb(0.55, 0.65, 0.82);
const BUTTON_BG: Color = Color::srgb(0.10, 0.14, 0.22);
const PANEL_WIDTH: f32 = 280.0;
const PANEL_PADDING: f32 = 14.0;
const TITLE_FONT_SIZE: f32 = 20.0;
const LABEL_FONT_SIZE: f32 = 15.0;
const VALUE_FONT_SIZE: f32 = 15.0;
const BUTTON_FONT_SIZE: f32 = 16.0;
const HELP_FONT_SIZE: f32 = 12.0;
// ── Setup ───────────────────────────────────────────────────────────────────
pub fn setup_galaxy_ui(mut commands: Commands) {
spawn_control_panel(&mut commands);
spawn_info_panel_empty(&mut commands);
}
fn spawn_control_panel(commands: &mut Commands) {
commands
.spawn((
Node {
position_type: PositionType::Absolute,
left: Val::Px(12.0),
top: Val::Px(12.0),
width: Val::Px(PANEL_WIDTH),
padding: UiRect::all(Val::Px(PANEL_PADDING)),
flex_direction: FlexDirection::Column,
row_gap: Val::Px(6.0),
border: UiRect::all(Val::Px(1.0)),
..default()
},
BackgroundColor(PANEL_BG),
BorderColor(PANEL_BORDER),
BorderRadius::all(Val::Px(8.0)),
GalaxyControlPanel,
GalaxyCreationSpawned,
))
.with_children(|parent| {
parent.spawn((
Text::new("Galaxy Parameters"),
TextFont {
font_size: TITLE_FONT_SIZE,
..default()
},
TextColor(TEXT_BRIGHT),
Node {
margin: UiRect::bottom(Val::Px(8.0)),
..default()
},
));
// Current params are read at button-press time in `param_button_handler`,
// so we don't need to thread `Res<GalaxyParams>` through setup.
spawn_param_row(parent, "Seed", "seed", ParamButton::SeedDecr, ParamButton::SeedIncr);
spawn_param_row(parent, "Systems", "count", ParamButton::CountDecr, ParamButton::CountIncr);
spawn_param_row(parent, "Disk Arms", "arms", ParamButton::ArmsDecr, ParamButton::ArmsIncr);
spawn_param_row(parent, "Vertical Arms", "varms", ParamButton::VerticalArmsDecr, ParamButton::VerticalArmsIncr);
spawn_param_row(parent, "Size", "size", ParamButton::SizeDecr, ParamButton::SizeIncr);
spawn_param_row(parent, "Twist", "twist", ParamButton::TwistDecr, ParamButton::TwistIncr);
spawn_param_row(parent, "Vertical Twist", "vtwist", ParamButton::VerticalTwistDecr, ParamButton::VerticalTwistIncr);
// Regenerate (randomize seed) button.
parent
.spawn((
Button,
Node {
width: Val::Percent(100.0),
height: Val::Px(32.0),
margin: UiRect::top(Val::Px(8.0)),
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
border: UiRect::all(Val::Px(1.0)),
..default()
},
BackgroundColor(BUTTON_BG),
BorderColor(PANEL_BORDER),
BorderRadius::all(Val::Px(6.0)),
ParamButton::Regenerate,
))
.with_children(|btn| {
btn.spawn((
Text::new("Regenerate"),
TextFont {
font_size: BUTTON_FONT_SIZE,
..default()
},
TextColor(TEXT_BRIGHT),
));
});
// Help text.
parent.spawn((
Text::new("Click a star to inspect • Esc to return"),
TextFont {
font_size: HELP_FONT_SIZE,
..default()
},
TextColor(TEXT_DIM),
Node {
margin: UiRect::top(Val::Px(10.0)),
..default()
},
));
});
}
/// Build a `[Label ........ value [-] [+]]` row. The `value_key` is stored in
/// the marker as a label for the value text child; the actual value is read
/// from `GalaxyParams` at button-press time and updated by
/// `refresh_control_panel_values` each frame.
fn spawn_param_row(
parent: &mut ChildSpawnerCommands,
label: &str,
value_key: &str,
decr: ParamButton,
incr: ParamButton,
) {
parent
.spawn(Node {
width: Val::Percent(100.0),
flex_direction: FlexDirection::Row,
align_items: AlignItems::Center,
column_gap: Val::Px(6.0),
..default()
})
.with_children(|row| {
// Label — fixed width so values align.
row.spawn((
Text::new(label),
TextFont {
font_size: LABEL_FONT_SIZE,
..default()
},
TextColor(TEXT_DIM),
Node {
width: Val::Px(118.0),
..default()
},
));
// Value — flexible width, right-aligned textually by spacer below.
row.spawn((
Text::new(""),
TextFont {
font_size: VALUE_FONT_SIZE,
..default()
},
TextColor(TEXT_BRIGHT),
Node {
flex_grow: 1.0,
..default()
},
ParamValue(value_key.to_string()),
));
// [-] button.
spawn_icon_button(row, "", decr);
// [+] button.
spawn_icon_button(row, "+", incr);
});
}
/// Marker on the value Text node so `refresh_control_panel_values` can find
/// and update it. The `String` is the param field name (e.g. "seed", "twist").
#[derive(Component)]
pub(crate) struct ParamValue(pub(crate) String);
fn spawn_icon_button(parent: &mut ChildSpawnerCommands, label: &str, marker: ParamButton) {
parent
.spawn((
Button,
Node {
width: Val::Px(26.0),
height: Val::Px(26.0),
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
border: UiRect::all(Val::Px(1.0)),
..default()
},
BackgroundColor(BUTTON_BG),
BorderColor(PANEL_BORDER),
BorderRadius::all(Val::Px(4.0)),
marker,
))
.with_children(|btn| {
btn.spawn((
Text::new(label),
TextFont {
font_size: BUTTON_FONT_SIZE,
..default()
},
TextColor(TEXT_BRIGHT),
));
});
}
/// Spawn the info panel in its "no selection" state. The selection module
/// replaces its contents whenever [`super::SelectedStar`] changes.
fn spawn_info_panel_empty(commands: &mut Commands) {
commands
.spawn((
Node {
position_type: PositionType::Absolute,
right: Val::Px(12.0),
top: Val::Px(12.0),
width: Val::Px(PANEL_WIDTH),
padding: UiRect::all(Val::Px(PANEL_PADDING)),
flex_direction: FlexDirection::Column,
row_gap: Val::Px(4.0),
border: UiRect::all(Val::Px(1.0)),
..default()
},
BackgroundColor(PANEL_BG),
BorderColor(PANEL_BORDER),
BorderRadius::all(Val::Px(8.0)),
GalaxyInfoPanel,
GalaxyCreationSpawned,
))
.with_children(|parent| {
parent.spawn((
Text::new("Selected System"),
TextFont {
font_size: TITLE_FONT_SIZE,
..default()
},
TextColor(TEXT_BRIGHT),
Node {
margin: UiRect::bottom(Val::Px(8.0)),
..default()
},
));
parent.spawn((
Text::new("Click a star to inspect."),
TextFont {
font_size: LABEL_FONT_SIZE,
..default()
},
TextColor(TEXT_DIM),
));
});
}
// ── Button handler ──────────────────────────────────────────────────────────
pub fn param_button_handler(
mut params: ResMut<GalaxyParams>,
query: Query<(&Interaction, &ParamButton), Changed<Interaction>>,
) {
for (interaction, button) in &query {
let &Interaction::Pressed = interaction else {
continue;
};
match button {
ParamButton::SeedDecr => {
params.seed = params.seed.wrapping_sub(SEED_STEP);
params.bump_generation();
}
ParamButton::SeedIncr => {
params.seed = params.seed.wrapping_add(SEED_STEP);
params.bump_generation();
}
ParamButton::CountDecr => {
params.count = params.count.saturating_sub(COUNT_STEP).max(COUNT_MIN);
params.bump_generation();
}
ParamButton::CountIncr => {
params.count = (params.count + COUNT_STEP).min(COUNT_MAX);
params.bump_generation();
}
ParamButton::ArmsDecr => {
params.arms = params.arms.saturating_sub(1).max(ARMS_MIN);
params.bump_generation();
}
ParamButton::ArmsIncr => {
params.arms = params.arms.saturating_add(1).min(ARMS_MAX);
params.bump_generation();
}
ParamButton::VerticalArmsDecr => {
params.vertical_arms = params.vertical_arms.saturating_sub(1);
params.bump_generation();
}
ParamButton::VerticalArmsIncr => {
params.vertical_arms = params
.vertical_arms
.saturating_add(1)
.min(VERTICAL_ARMS_MAX);
params.bump_generation();
}
ParamButton::SizeDecr => {
params.size = (params.size - SIZE_STEP).max(SIZE_MIN);
params.bump_generation();
}
ParamButton::SizeIncr => {
params.size = (params.size + SIZE_STEP).min(SIZE_MAX);
params.bump_generation();
}
ParamButton::TwistDecr => {
params.twist = (params.twist - TWIST_STEP).max(TWIST_MIN);
params.bump_generation();
}
ParamButton::TwistIncr => {
params.twist = (params.twist + TWIST_STEP).min(TWIST_MAX);
params.bump_generation();
}
ParamButton::VerticalTwistDecr => {
params.vertical_twist = (params.vertical_twist - VERTICAL_TWIST_STEP).max(VERTICAL_TWIST_MIN);
params.bump_generation();
}
ParamButton::VerticalTwistIncr => {
params.vertical_twist = (params.vertical_twist + VERTICAL_TWIST_STEP).min(VERTICAL_TWIST_MAX);
params.bump_generation();
}
ParamButton::Regenerate => params.reseed_and_bump(),
}
}
}
/// Refresh the displayed parameter values every frame so the UI stays in sync.
/// Bevy UI has no native data binding, so we manually patch the `Text` children
/// — cheap (≤14 nodes) and avoids re-spawning the whole panel.
pub fn refresh_control_panel_values(
params: Res<GalaxyParams>,
mut values: Query<(&ParamValue, &mut Text)>,
) {
for (marker, mut text) in &mut values {
let new = match marker.0.as_str() {
"seed" => format!("{}", params.seed),
"count" => format!("{}", params.count),
"arms" => format!("{}", params.arms),
"varms" => format!("{}", params.vertical_arms),
"size" => format!("{:.0}", params.size),
"twist" => format!("{:.1}", params.twist),
"vtwist" => format!("{:.1}", params.vertical_twist),
_ => continue,
};
if text.0 != new {
text.0 = new;
}
}
}
// ── State wiring ────────────────────────────────────────────────────────────
//
// `setup_galaxy_ui` runs on OnEnter(GalaxyCreation) via the plugin in `mod.rs`.
// Despawning happens through the shared `GalaxyCreationSpawned` marker in the
// plugin's OnExit handler — no separate UI cleanup needed.

View File

@@ -1 +1,4 @@
pub mod galaxy_creation;
pub mod movement;
pub mod physics;
pub mod star_map;

View File

@@ -0,0 +1,39 @@
use bevy::prelude::*;
/// Marker for the player-controlled ship. There should be exactly one in any playable scene.
#[derive(Component, Default)]
pub struct Player;
/// Linear velocity in world space (units/sec).
#[derive(Component, Default, Debug, Clone, Copy)]
pub struct Velocity(pub Vec3);
/// Maximum speed cap. Velocity magnitude will not exceed this.
#[derive(Component, Debug, Clone, Copy)]
pub struct MaxSpeed(pub f32);
impl Default for MaxSpeed {
fn default() -> Self {
Self(60.0)
}
}
/// Maximum rotation rate in radians per second. Used to smooth heading changes.
#[derive(Component, Debug, Clone, Copy)]
pub struct TurnRate(pub f32);
impl Default for TurnRate {
fn default() -> Self {
Self(3.0)
}
}
/// Exponential drag coefficient. Higher = stronger drag (slower drift when no thrust).
/// Currently unused by click-to-move; reserved for future throttle-based input.
#[derive(Component, Default, Debug, Clone, Copy)]
pub struct Drag(pub f32);
/// A world-space point the entity should move toward.
/// Set by input systems (e.g. click-to-move) and consumed by kinematic systems.
#[derive(Component, Default, Debug, Clone, Copy)]
pub struct MoveTarget(pub Vec3);

View File

@@ -0,0 +1,38 @@
use bevy::input::mouse::MouseButton;
use bevy::prelude::*;
use bevy::window::PrimaryWindow;
use super::components::{MoveTarget, Player};
use crate::camera::MainCamera;
/// Y coordinate of the ground plane. Cursor rays are projected onto this plane.
const GROUND_PLANE_Y: f32 = 0.0;
/// On left-click, project the cursor onto the ground plane and update the player's MoveTarget.
pub fn click_to_move(
mouse_input: Res<ButtonInput<MouseButton>>,
primary_window: Query<&Window, With<PrimaryWindow>>,
camera_query: Query<(&Camera, &GlobalTransform), With<MainCamera>>,
mut player: Query<&mut MoveTarget, With<Player>>,
) {
if !mouse_input.just_pressed(MouseButton::Left) {
return;
}
let Ok(window) = primary_window.single() else { return };
let Some(cursor_pos) = window.cursor_position() else { return };
let Ok((camera, camera_gt)) = camera_query.single() else { return };
let Ok(ray) = camera.viewport_to_world(camera_gt, cursor_pos) else { return };
// Intersect ray with the ground plane (y = GROUND_PLANE_Y).
if ray.direction.y.abs() < 1e-6 {
return; // ray is parallel to ground — no intersection
}
let t = (GROUND_PLANE_Y - ray.origin.y) / ray.direction.y;
if t < 0.0 {
return; // intersection is behind the camera
}
let target = ray.origin + ray.direction * t;
let Ok(mut move_target) = player.single_mut() else { return };
move_target.0 = target;
}

View File

@@ -0,0 +1,44 @@
use bevy::prelude::*;
use super::components::{MaxSpeed, MoveTarget, Velocity};
/// Distance (in world units) at which an entity is considered to have arrived at its target.
const ARRIVAL_DISTANCE: f32 = 0.5;
/// For each entity with a MoveTarget, set Velocity to face the target at MaxSpeed,
/// and rotate the transform to face direction of motion.
pub fn steer_to_target(
mut query: Query<(&mut Transform, &mut Velocity, &MoveTarget, &MaxSpeed)>,
time: Res<Time>,
) {
let dt = time.delta_secs();
for (mut transform, mut velocity, target, max_speed) in &mut query {
let to_target = target.0 - transform.translation;
let distance = to_target.length();
if distance <= ARRIVAL_DISTANCE {
velocity.0 = Vec3::ZERO;
continue;
}
let direction = to_target / distance;
velocity.0 = direction * max_speed.0;
// Rotate to face direction of motion, keeping Y as up (no pitch).
// This gives a "ship banking" feel rather than nosediving toward targets at different altitudes.
let flat = Vec3::new(direction.x, 0.0, direction.z);
if flat.length_squared() > 1e-4 {
let look = Transform::IDENTITY.looking_at(flat.normalize(), Vec3::Y);
transform.rotation = transform.rotation.slerp(look.rotation, dt * 4.0);
}
}
}
/// Apply Velocity to Transform.translation.
pub fn integrate_velocity(
mut query: Query<(&mut Transform, &Velocity)>,
time: Res<Time>,
) {
let dt = time.delta_secs();
for (mut transform, velocity) in &mut query {
transform.translation += velocity.0 * dt;
}
}

View File

@@ -0,0 +1,26 @@
use bevy::prelude::*;
pub mod components;
pub mod input;
pub mod kinematic;
pub mod orbit;
pub struct MovementPlugin;
impl Plugin for MovementPlugin {
fn build(&self, app: &mut App) {
// No state gating for slice 1: systems are no-ops when no Player exists.
// When AppState::InGame is wired up, gate these on `in_state(AppState::InGame)`
// and consider moving them to FixedUpdate for SpacetimeDB determinism.
app.add_systems(
Update,
(
input::click_to_move,
kinematic::steer_to_target,
kinematic::integrate_velocity,
orbit::update_orbits,
)
.chain(),
);
}
}

View File

@@ -0,0 +1,52 @@
use bevy::prelude::*;
/// Which plane the orbit lies on.
#[derive(Debug, Clone, Copy, Default)]
pub enum OrbitPlane {
/// XZ plane (Y up). Typical for planets around a star viewed from above.
#[default]
Horizontal,
/// XY plane (Z up). Useful for UI elements or top-down 2D-style orbits.
Vertical,
}
/// An entity that orbits around its parent's origin.
/// Attach as a child of the orbit center; local Transform is updated each frame.
#[derive(Component, Debug, Clone, Copy)]
pub struct Orbit {
pub angle: f32,
pub angular_velocity: f32,
pub radius: f32,
pub plane: OrbitPlane,
}
impl Default for Orbit {
fn default() -> Self {
Self {
angle: 0.0,
angular_velocity: 0.5,
radius: 10.0,
plane: OrbitPlane::Horizontal,
}
}
}
/// Advance each Orbit's angle and recompute local translation.
/// The orbit center is the parent entity (via Bevy's parent-child hierarchy).
pub fn update_orbits(
mut query: Query<(&mut Orbit, &mut Transform)>,
time: Res<Time>,
) {
let dt = time.delta_secs();
for (mut orbit, mut transform) in &mut query {
orbit.angle += orbit.angular_velocity * dt;
let (sin, cos) = orbit.angle.sin_cos();
let (x, y, z) = match orbit.plane {
OrbitPlane::Horizontal => (cos * orbit.radius, 0.0, sin * orbit.radius),
OrbitPlane::Vertical => (cos * orbit.radius, sin * orbit.radius, 0.0),
};
transform.translation.x = x;
transform.translation.y = y;
transform.translation.z = z;
}
}

View File

@@ -0,0 +1,157 @@
use bevy::prelude::*;
/// Ray-sphere intersection. Returns the smallest positive `t` where
/// `ray_origin + ray_direction * t` lies on the sphere's surface, or `None`.
///
/// `ray_direction` should be normalized.
pub fn ray_vs_sphere(
ray_origin: Vec3,
ray_direction: Vec3,
sphere_center: Vec3,
sphere_radius: f32,
) -> Option<f32> {
let to_sphere = sphere_center - ray_origin;
let proj = to_sphere.dot(ray_direction);
if proj < 0.0 {
return None; // sphere is behind the ray origin
}
let closest = ray_origin + ray_direction * proj;
let dist_sq = (closest - sphere_center).length_squared();
let r_sq = sphere_radius * sphere_radius;
if dist_sq > r_sq {
return None; // ray misses the sphere
}
let offset = (r_sq - dist_sq).sqrt();
Some(proj - offset)
}
/// Check if two spheres overlap. Use length-squared for performance (no sqrt).
pub fn overlaps(
a_center: Vec3,
a_radius: f32,
b_center: Vec3,
b_radius: f32,
) -> bool {
let combined = a_radius + b_radius;
(a_center - b_center).length_squared() < combined * combined
}
/// Compute the vector to push `a` out of `b` so they no longer overlap.
/// Returns `None` if they don't overlap, or if their centers coincide (ambiguous direction).
pub fn separate(
a_center: Vec3,
a_radius: f32,
b_center: Vec3,
b_radius: f32,
) -> Option<Vec3> {
let delta = a_center - b_center;
let combined = a_radius + b_radius;
let dist = delta.length();
if dist >= combined || dist == 0.0 {
return None;
}
Some(delta / dist * (combined - dist))
}
/// Segment (finite line from `seg_start` to `seg_end`) vs sphere intersection.
/// Returns `t` in `[0, segment_length]` measured from `seg_start`, or `None`.
///
/// Useful for projectile hit detection (treat projectile last-frame → current-frame
/// positions as the segment to avoid tunneling at high speeds).
pub fn segment_vs_sphere(
seg_start: Vec3,
seg_end: Vec3,
sphere_center: Vec3,
sphere_radius: f32,
) -> Option<f32> {
let seg = seg_end - seg_start;
let seg_len = seg.length();
if seg_len == 0.0 {
return if (seg_start - sphere_center).length_squared() < sphere_radius * sphere_radius {
Some(0.0)
} else {
None
};
}
let dir = seg / seg_len;
let t = ray_vs_sphere(seg_start, dir, sphere_center, sphere_radius)?;
if t > seg_len {
return None;
}
Some(t)
}
// ── Tests ───────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ray_hits_sphere_directly() {
let t = ray_vs_sphere(
Vec3::new(0.0, 0.0, -5.0),
Vec3::new(0.0, 0.0, 1.0),
Vec3::ZERO,
1.0,
);
assert_eq!(t, Some(4.0));
}
#[test]
fn ray_misses_sphere() {
let t = ray_vs_sphere(
Vec3::new(10.0, 0.0, -5.0),
Vec3::new(0.0, 0.0, 1.0),
Vec3::ZERO,
1.0,
);
assert_eq!(t, None);
}
#[test]
fn ray_with_sphere_behind_returns_none() {
let t = ray_vs_sphere(
Vec3::new(0.0, 0.0, 5.0),
Vec3::new(0.0, 0.0, 1.0),
Vec3::ZERO,
1.0,
);
assert_eq!(t, None);
}
#[test]
fn overlapping_spheres_detected() {
assert!(overlaps(Vec3::ZERO, 1.0, Vec3::new(1.5, 0.0, 0.0), 1.0));
assert!(!overlaps(Vec3::ZERO, 1.0, Vec3::new(3.0, 0.0, 0.0), 1.0));
}
#[test]
fn separate_pushes_a_out_of_b() {
let sep = separate(Vec3::new(0.5, 0.0, 0.0), 1.0, Vec3::ZERO, 1.0);
// Combined radius 2.0, current distance 0.5, so push by 1.5 along +X.
assert_eq!(sep, Some(Vec3::new(1.5, 0.0, 0.0)));
}
#[test]
fn segment_hits_sphere() {
let t = segment_vs_sphere(
Vec3::new(0.0, 0.0, -5.0),
Vec3::new(0.0, 0.0, 5.0),
Vec3::ZERO,
1.0,
);
assert_eq!(t, Some(4.0));
}
#[test]
fn segment_misses_when_sphere_off_line() {
let t = segment_vs_sphere(
Vec3::new(0.0, 10.0, -5.0),
Vec3::new(0.0, 10.0, 5.0),
Vec3::ZERO,
1.0,
);
assert_eq!(t, None);
}
}

View File

@@ -0,0 +1,14 @@
use bevy::prelude::*;
pub mod geometry;
pub mod systems;
pub struct PhysicsPlugin;
impl Plugin for PhysicsPlugin {
fn build(&self, _app: &mut App) {
// Geometry primitives in `geometry` are pure functions called directly by
// gameplay systems. No global systems yet — wire them up when combat /
// collision is implemented.
}
}

View File

@@ -0,0 +1,11 @@
// Slice 1 has no collision systems yet — geometry primitives in `geometry.rs` are
// defined and unit-tested but not yet wired into any Bevy systems.
//
// Future systems to add here:
// - `projectile_hits`: query projectiles against damageable entities using `overlaps`
// or `segment_vs_sphere` (for fast-moving projectiles that might tunnel).
// - `ship_separation`: push ships apart when they get too close using `separate`.
// - `proximity_triggers`: emit events when entities enter/exit a radius.
//
// When adding these, register them in `PhysicsPlugin::build()`. They should run on
// `FixedUpdate` for determinism (see ARCH-9 in the docs).

View File

@@ -0,0 +1,43 @@
use bevy::prelude::*;
use crate::state::AppState;
pub struct StarMapPlugin;
impl Plugin for StarMapPlugin {
fn build(&self, app: &mut App) {
app.add_systems(OnEnter(AppState::InGame), setup_star_map)
.add_systems(OnExit(AppState::InGame), despawn_star_map);
// .add_systems(
// Update,
// (/* update systems: pan/zoom, hover, click star systems */)
// .run_if(in_state(AppState::InGame)),
// );
}
}
// ── Markers ─────────────────────────────────────────────────────────────────
#[derive(Component)]
pub struct StarMapUi;
#[derive(Component)]
pub struct StarMapCamera;
#[derive(Component)]
pub struct StarSystemNode;
// ── Systems ─────────────────────────────────────────────────────────────────
fn setup_star_map(_commands: Commands) {
// TODO: spawn star map (star systems, hyperspace lanes, viewport camera, HUD)
}
fn despawn_star_map(
mut commands: Commands,
query: Query<Entity, Or<(With<StarMapUi>, With<StarMapCamera>, With<StarSystemNode>)>>,
) {
for entity in &query {
commands.entity(entity).despawn();
}
}

View File

@@ -5,7 +5,13 @@ mod ui;
use bevy::prelude::*;
use gameplay::galaxy_creation;
use camera::orbit_camera_control;
use gameplay::{
galaxy_creation::GalaxyCreationPlugin,
movement::MovementPlugin,
physics::PhysicsPlugin,
star_map::StarMapPlugin,
};
use state::AppState;
use ui::main_menu;
@@ -13,12 +19,27 @@ fn main() {
App::new()
.add_plugins(DefaultPlugins)
.insert_resource(ClearColor(Color::srgb(0.02, 0.02, 0.06)))
.insert_resource(AmbientLight {
color: Color::srgb(0.25, 0.3, 0.5),
brightness: 300.0,
..default()
})
.init_state::<AppState>()
.add_systems(Startup, camera::spawn_camera)
// Orbit controls only in inspection-style scenes. In-game will use a
// follow camera instead (not yet implemented).
.add_systems(
Update,
orbit_camera_control.run_if(in_state(AppState::GalaxyCreation)),
)
.add_systems(OnEnter(AppState::MainMenu), main_menu::setup_main_menu)
.add_systems(OnExit(AppState::MainMenu), main_menu::despawn_main_menu)
.add_systems(Update, main_menu::main_menu_buttons)
.add_systems(OnEnter(AppState::GalaxyCreation), galaxy_creation::setup_galaxy_creation)
.add_systems(OnExit(AppState::GalaxyCreation), galaxy_creation::despawn_galaxy_creation)
.add_plugins((
MovementPlugin,
PhysicsPlugin,
GalaxyCreationPlugin,
StarMapPlugin,
))
.run();
}

View File

@@ -1 +1,2 @@
pub mod main_menu;
pub mod util;

44
apps/game/src/ui/util.rs Normal file
View File

@@ -0,0 +1,44 @@
//! UI helper utilities.
use bevy::prelude::*;
use bevy::ui::ComputedNode;
/// Returns `true` if the cursor is currently hovering any UI node.
///
/// Used to suppress scene-level mouse input (orbit camera drag, star picking)
/// when the user is interacting with overlay UI. The check is a manual rect
/// containment test against every UI node's computed layout rect — this is
/// the same approach Bevy's own `ui_focus_system` uses internally.
///
/// Coordinates: Bevy UI layout runs in **physical** pixels, while
/// [`Window::cursor_position`] returns **logical** pixels. We multiply by the
/// window's scale factor to convert.
pub fn cursor_over_ui(
window: &Window,
nodes: &Query<(&ComputedNode, &GlobalTransform)>,
) -> bool {
let Some(cursor_logical) = window.cursor_position() else {
return false;
};
let scale = window.scale_factor();
let cursor = cursor_logical * scale;
for (node, gt) in nodes {
if node.is_empty() {
continue;
}
let center = gt.translation().truncate();
let half = node.size() * 0.5;
let min = center - half;
let max = center + half;
if cursor.x >= min.x
&& cursor.x <= max.x
&& cursor.y >= min.y
&& cursor.y <= max.y
{
return true;
}
}
false
}