Files
Space-Game/apps/game/src/gameplay/galaxy_creation/selection.rs
francy51 c14f684b09 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)
2026-06-04 12:29:33 -04:00

213 lines
7.5 KiB
Rust

//! 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)),
));
});
}