diff --git a/apps/docs/src/pages/docs/ArchitecturePage.tsx b/apps/docs/src/pages/docs/ArchitecturePage.tsx index 51c1330..663b964 100644 --- a/apps/docs/src/pages/docs/ArchitecturePage.tsx +++ b/apps/docs/src/pages/docs/ArchitecturePage.tsx @@ -475,6 +475,130 @@ export function ArchitecturePage() { accessibility audit as part of CI. + {/* ═══ CUSTOM MOVEMENT & COLLISION ═══ */} + +
+ ARCH-9 +

Custom Movement & Collision (No Physics Engine)

+
+ +
+ Decision: The Bevy game client uses a hand-rolled kinematic movement system and distance-based + collision detection. We do not integrate Rapier, Avian, XPBD, or any other rigid-body physics engine. + Raycasting and collision are implemented as pure geometry functions over circles in 2D. +
+ +

Why No Physics Engine

+
+ + + + + + {[ + { c: 'Genre fit', custom: 'Native — FTL/Windward style is point-and-shoot, not rigid bodies.', engine: 'Overkill — solvers are for stacking, joints, real impulses.' }, + { c: 'Arcade feel', custom: 'Native — drag, snap turns, no inertia when undesired.', engine: 'Fight the solver — disabling bounce/friction is config hell.' }, + { c: 'Compile time', custom: '0 added', engine: '+30–90s on every clean build' }, + { c: 'Binary size', custom: '0 added', engine: '+2–5 MB' }, + { c: 'Determinism', custom: 'Trivial — linear math, no solver iterations.', engine: 'Hard — solver iterations + float ops in unpredictable order.' }, + { c: 'Network prediction', custom: 'Linear extrapolation of owned values.', engine: 'Replaying the solver is a nightmare.' }, + { c: 'Bug surface', custom: '~50 lines you wrote.', engine: 'Tens of thousands of lines you didn’t.' }, + ].map((row, i) => ( + + + + + + ))} + +
ConcernCustom KinematicPhysics Engine (Rapier/Avian)
{row.c}{row.custom}{row.engine}
+
+ +

What "Physics" Means in This Game

+
+ + + + + + {[ + { need: 'Ship traversal', impl: 'Transform updates with Velocity, MaxSpeed, TurnRate, optional Drag.', math: 'pos += velocity * dt' }, + { need: 'Planet/station orbiting', impl: 'Orbit component on child entity of star system.', math: 'pos = center + r * (cos(θ), sin(θ))' }, + { need: 'Projectile vs ship hits', impl: 'Distance check each tick (no raycast needed for fast projectiles).', math: '‖a − b‖ < r_a + r_b' }, + { need: 'Weapon targeting / LOS', impl: 'Ray-circle intersection, pick smallest t.', math: 'Quadratic: ‖o + t·d − c‖² = r²' }, + { need: 'Ship-ship separation', impl: 'Push apart by overlap distance.', math: 'delta = (a − b).normalize() * (r_a + r_b − dist)' }, + { need: 'Docking / proximity triggers', impl: 'Distance check, fire event when crossing threshold.', math: '‖a − b‖ < trigger_radius' }, + ].map((row, i) => ( + + + + + + ))} + +
Gameplay NeedImplementationMath
{row.need}{row.impl}{row.math}
+
+ +
+
+

Movement Module Layout

+
+ apps/game/src/gameplay/movement/
+   mod.rs        // MovementPlugin
+   components.rs  // Velocity, MaxSpeed, TurnRate, Drag
+   kinematic.rs   // move + drag + clamp systems
+   orbit.rs        // Orbit component + update_orbits +
+

+ All systems run on Bevy’s Time<Fixed> schedule for stable, deterministic ticks that + align cleanly with SpacetimeDB updates. +

+
+
+

Physics Module Layout

+
+ apps/game/src/gameplay/physics/
+   mod.rs        // PhysicsPlugin
+   geometry.rs   // ray_vs_circle, overlaps, separate
+   broad_phase.rs // (later) uniform grid
+   systems.rs    // projectile_hits, ship_separation +
+

+ Geometry functions are pure pub fns. Systems are thin Bevy wrappers that query entities and + call them. No third-party deps. +

+
+
+ +

Scaling Tiers

+
+ + + + + + + + + +
Concurrent EntitiesStrategyEstimated Cost
{'< 500'}Iterate all entities, linear scan.Trivial — no broad-phase needed.
500 – 10,000Flat array of (Entity, Vec2, radius), still linear.Cache-friendly, single microsecond per query.
{'> 10,000'}Add uniform grid or quadtree (≈50 LOC).Out of scope for FTL-style combat density.
+
+ +
+ Escape hatch — when to reconsider: If destructible ship chunks that tumble, rotate, and stack + become a core visual, Rapier can be introduced only for debris entities while ships and projectiles + stay kinematic. This is additive: the custom movement/collision code does not need to be rewritten. The three + things a real solver would buy — stacking, joints, continuous CCD — are explicitly out of scope for arcade + space combat. +
+ +
+ Network determinism note: Because movement and collision are pure functions of position, + velocity, and dt, every client given the same inputs produces the same outputs. This makes client-side + prediction and rollback against SpacetimeDB straightforward — a physics engine’s solver would make this + property very difficult to guarantee. +
+ ); } diff --git a/apps/game/src/camera.rs b/apps/game/src/camera.rs index 2489ebf..6ef9dad 100644 --- a/apps/game/src/camera.rs +++ b/apps/game/src/camera.rs @@ -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>, + primary_window: Query<&Window, With>, + mut mouse_motion: EventReader, + mut scroll_events: EventReader, + mut query: Query<(&mut Transform, &mut OrbitCamera), With>, + 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); } diff --git a/apps/game/src/gameplay/galaxy_creation.rs b/apps/game/src/gameplay/galaxy_creation.rs deleted file mode 100644 index 46442ff..0000000 --- a/apps/game/src/gameplay/galaxy_creation.rs +++ /dev/null @@ -1,18 +0,0 @@ -use bevy::prelude::*; - -// ── Markers ───────────────────────────────────────────────────────────────── - -#[derive(Component)] -pub struct GalaxyCreationUi; - -// ── Galaxy Creation Screen ────────────────────────────────────────────────── - -pub fn setup_galaxy_creation(_commands: Commands) { - // TODO: spawn galaxy creation UI -} - -pub fn despawn_galaxy_creation(mut commands: Commands, query: Query>) { - for entity in &query { - commands.entity(entity).despawn(); - } -} diff --git a/apps/game/src/gameplay/galaxy_creation/mod.rs b/apps/game/src/gameplay/galaxy_creation/mod.rs new file mode 100644 index 0000000..db8ec7e --- /dev/null +++ b/apps/game/src/gameplay/galaxy_creation/mod.rs @@ -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::() + .init_resource::() + .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>, + mut materials: ResMut>, + params: Res, +) { + let systems = generate_galaxy(¶ms); + 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 { + let mut rng = StdRng::seed_from_u64(params.seed); + let mut systems: Vec = 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::() * 30.0 + } else { + rng.gen::().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::() * std::f32::consts::TAU + } else { + std::f32::consts::TAU * arm as f32 / arm_count as f32 + + (r / params.size) * arm_twist + + (rng.gen::() - 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::() - 0.5) * 12.0; + (vx, vy, vz) + } else { + (angle.cos() * r, (rng.gen::() - 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, + materials: &mut Assets, + 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> = 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>, +) { + for entity in &query { + // Bevy 0.16: despawn() is recursive by default. + commands.entity(entity).despawn(); + } +} + +fn reset_selection(mut selected: ResMut) { + selected.0 = None; +} + +fn escape_to_main_menu( + keys: Res>, + mut next_state: ResMut>, +) { + 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>, + mut materials: ResMut>, + params: Res, + scene_roots: Query>, + mut selected: ResMut, + mut last_seen: Local>, +) { + 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(¶ms); + spawn_galaxy_scene(&mut commands, &mut meshes, &mut materials, &systems); +} diff --git a/apps/game/src/gameplay/galaxy_creation/params.rs b/apps/game/src/gameplay/galaxy_creation/params.rs new file mode 100644 index 0000000..29997e1 --- /dev/null +++ b/apps/game/src/gameplay/galaxy_creation/params.rs @@ -0,0 +1,105 @@ +//! Authoritative resources for the galaxy creation scene. +//! +//! [`GalaxyParams`] is the input to procedural generation; the UI mutates it, +//! the regeneration system detects changes via the `generation` counter, and +//! the generator reads it. [`SelectedStar`] tracks the currently-clicked star. + +use bevy::prelude::*; + +// ── Tunable defaults ──────────────────────────────────────────────────────── +// +// These mirror the docs prototype (`apps/docs/src/prototypes/existing-demos/GalaxyDemo.tsx`) +// so the Bevy scene opens with the same look as the web demo. + +const DEFAULT_SEED: u64 = 4242; +const DEFAULT_COUNT: usize = 120; +const DEFAULT_ARMS: u32 = 4; +const DEFAULT_VERTICAL_ARMS: u32 = 0; +const DEFAULT_SIZE: f32 = 280.0; +const DEFAULT_TWIST: f32 = 3.0; +const DEFAULT_VERTICAL_TWIST: f32 = 1.1; + +// ── Limits (also mirror the TS sliders) ───────────────────────────────────── + +pub const SEED_STEP: u64 = 1; +pub const COUNT_MIN: usize = 40; +pub const COUNT_MAX: usize = 220; +pub const COUNT_STEP: usize = 4; +pub const ARMS_MIN: u32 = 1; +pub const ARMS_MAX: u32 = 6; +/// u32 lower bound is 0; constant kept for symmetry/documentation with the +/// other `*_MIN` bounds. +#[allow(dead_code)] +pub const VERTICAL_ARMS_MIN: u32 = 0; +pub const VERTICAL_ARMS_MAX: u32 = 4; +pub const SIZE_MIN: f32 = 140.0; +pub const SIZE_MAX: f32 = 420.0; +pub const SIZE_STEP: f32 = 10.0; +pub const TWIST_MIN: f32 = 1.0; +pub const TWIST_MAX: f32 = 6.0; +pub const TWIST_STEP: f32 = 0.2; +pub const VERTICAL_TWIST_MIN: f32 = 0.0; +pub const VERTICAL_TWIST_MAX: f32 = 6.0; +pub const VERTICAL_TWIST_STEP: f32 = 0.2; + +/// Number of "core" Concord systems clustered at the galactic center. +/// Not exposed in the UI — kept as an internal constant for now. +pub(crate) const CORE_COUNT: usize = 7; + +/// Connect each system to its N nearest neighbors (deduplicated). +/// Docs prototype uses 2; kept constant until a UI control is added. +pub(crate) const NEAREST_NEIGHBOR_CONNECTIONS: usize = 2; + +/// Max attempts to find a clear spot for each system (Poisson-style spacing). +pub(crate) const SPACING_ATTEMPTS: usize = 180; + +/// Procedural galaxy parameters. UI mutates fields, then bumps `generation` +/// to signal the regeneration system to rebuild the scene. +#[derive(Resource, Debug, Clone)] +pub struct GalaxyParams { + pub seed: u64, + pub count: usize, + pub arms: u32, + pub vertical_arms: u32, + pub size: f32, + pub twist: f32, + pub vertical_twist: f32, + /// Monotonic counter — any mutation must bump this via [`Self::bump_generation`]. + /// The regeneration system triggers a rebuild whenever this differs from its + /// last-seen value. + pub generation: u64, +} + +impl Default for GalaxyParams { + fn default() -> Self { + Self { + seed: DEFAULT_SEED, + count: DEFAULT_COUNT, + arms: DEFAULT_ARMS, + vertical_arms: DEFAULT_VERTICAL_ARMS, + size: DEFAULT_SIZE, + twist: DEFAULT_TWIST, + vertical_twist: DEFAULT_VERTICAL_TWIST, + generation: 0, + } + } +} + +impl GalaxyParams { + /// Bump the generation counter, signalling the regeneration system. + /// Call after any field mutation that should trigger a scene rebuild. + pub fn bump_generation(&mut self) { + self.generation = self.generation.wrapping_add(1); + } + + /// Convenience for the "Regenerate" button: bump the seed and mark dirty. + pub fn reseed_and_bump(&mut self) { + self.seed = self.seed.wrapping_add(1); + self.bump_generation(); + } +} + +/// Currently selected star system entity in the galaxy view. +/// `None` means no selection. +#[derive(Resource, Default, Debug, Clone, Copy)] +pub struct SelectedStar(pub Option); diff --git a/apps/game/src/gameplay/galaxy_creation/selection.rs b/apps/game/src/gameplay/galaxy_creation/selection.rs new file mode 100644 index 0000000..d079429 --- /dev/null +++ b/apps/game/src/gameplay/galaxy_creation/selection.rs @@ -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>, + primary_window: Query<&Window, With>, + camera_query: Query<(&Camera, &GlobalTransform), With>, + stars: Query<(Entity, &Transform), With>, + ui_nodes: Query<(&bevy::ui::ComputedNode, &GlobalTransform)>, + mut selected: ResMut, +) { + 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, + mut stars: Query<(Entity, &mut Transform), With>, + time: Res