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

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