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:
212
apps/game/src/gameplay/galaxy_creation/selection.rs
Normal file
212
apps/game/src/gameplay/galaxy_creation/selection.rs
Normal 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)),
|
||||
));
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user