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)
213 lines
7.5 KiB
Rust
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)),
|
|
));
|
|
});
|
|
}
|