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

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