//! 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 axes; mod contents; mod orbits; mod params; mod poi; mod selection; mod ui; pub use poi::*; use bevy::prelude::*; use rand::rngs::StdRng; use rand::{Rng, SeedableRng}; use crate::camera::apply_orbit_reset; use crate::state::AppState; pub use contents::{SystemContents, SystemContext, SystemSummary}; 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, ui::reset_view_button_handler, regenerate_galaxy_on_param_change, selection::select_star_on_click, selection::animate_selected_star, selection::refresh_info_panel, apply_orbit_reset, orbits::advance_orbital_paths, ) .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. Acts as a parent entity for the /// system's [`Star`] (visual marker) and all POI children (planets, belts, /// stations, anomalies, stargates, gas clouds). #[derive(Component, Debug)] pub struct StarSystem { pub id: String, pub name: String, pub faction: &'static str, pub security: f32, /// Cached POI count for quick display in the info panel without walking /// the child hierarchy. Updated whenever [`contents::SystemContents`] is /// regenerated. pub poi_count: usize, } /// The anchor celestial of a star system — its primary star. Distinct from /// POIs (which are destinations within a system): a star is the body the /// system is named after and the parent that planets/moons/belts orbit. /// /// Requires components that describe its physical nature; mass-locks nearby /// warp drives, emits light (used to compute habitable zones for orbiting /// planets). #[derive(Component, Debug, Clone, Copy, Reflect)] #[reflect(Component)] #[require(Massive, Luminosity, MassLock, BoundingVolume, Identifiable)] pub struct Star; // POI components, markers, and events live in [`poi`] (re-exported as `pub use`). // ── 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, contents, connections) = generate_galaxy(¶ms); spawn_galaxy_scene( &mut commands, &mut meshes, &mut materials, &systems, &contents, &connections, ); } /// 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`, /// extended with per-system POI generation (planets, belts, stations, /// anomalies, gas clouds, stargates). /// /// Returns `(systems, contents_per_system, connections)`. The contents are /// returned in parallel with the systems (same index) so the spawner can /// attach them as children of each [`StarSystem`] entity. fn generate_galaxy( params: &GalaxyParams, ) -> ( Vec, Vec, Vec<(usize, usize)>, ) { let mut rng = StdRng::seed_from_u64(params.seed); let systems = generate_system_positions(params, &mut rng); let connections = build_connections(&systems); // Per-system contents (planets, belts, stations, anomalies, gas clouds). let mut contents: Vec = systems .iter() .map(|sys| { let ctx = SystemContext { id: &sys.id, name: &sys.name, faction: sys.faction, security: sys.security, is_core: sys.is_core, }; contents::generate_system_contents(&mut rng, &ctx) }) .collect(); // Stargates — paired across each connection. let summaries: Vec = systems .iter() .map(|sys| SystemSummary { id: sys.id.clone(), name: sys.name.clone(), }) .collect(); contents::generate_stargates(&summaries, &mut contents, &connections); (systems, contents, connections) } /// Position-only galaxy generation. Pure faithful port of the TS reference /// in `apps/docs/src/prototypes/r3f/galaxy/GalaxyScene.tsx::generateGalaxy`. /// Split out so [`generate_galaxy`] can compose it with POI generation. fn generate_system_positions(params: &GalaxyParams, rng: &mut StdRng) -> Vec { 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, is_core: core, }); } 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. color: [f32; 3], security: f32, /// True for the [`CORE_COUNT`] Concord systems clustered near the origin. /// Used by [`contents::generate_system_contents`] to bias planet / station /// counts toward core systems. is_core: bool, } fn spawn_galaxy_scene( commands: &mut Commands, meshes: &mut Assets, materials: &mut Assets, systems: &[GeneratedSystem], contents: &[SystemContents], connections: &[(usize, usize)], ) { debug_assert_eq!( systems.len(), contents.len(), "systems and contents must be parallel arrays of the same length" ); // Shared star visuals (Bevy 0.16 required-components model — no bundle needed). // Unit spheres, scaled per-faction at the spawn site. let star_mesh = meshes.add(Sphere::new(1.0).mesh().ico(3).unwrap()); let core_star_mesh = meshes.add(Sphere::new(1.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() }); // Cached POI meshes/materials (one per type — entities scale via Transform). let content_assets = contents::ContentAssets::new(meshes, materials); // Parent group so all galaxy contents despawn together. commands .spawn((Transform::default(), GalaxyScene, GalaxyCreationSpawned)) .with_children(|parent| { // XYZ reference axes through the origin. axes::spawn_axes(parent, meshes, materials); // Star systems — each is a parent entity that owns its Star + POI children. for (sys, sys_contents) in systems.iter().zip(contents.iter()) { let is_core = sys.faction_index == 0 && sys.is_core; let (mesh, material, scale) = if is_core { // Visual differentiation for the 7 Concord core systems. (core_star_mesh.clone(), faction_materials[0].clone(), 1.1) } else { ( star_mesh.clone(), faction_materials[sys.faction_index].clone(), 1.0, ) }; let poi_count = sys_contents.total(); parent .spawn(( Transform::from_translation(sys.position), StarSystem { id: sys.id.clone(), name: sys.name.clone(), faction: sys.faction, security: sys.security, poi_count, }, )) .with_children(|sys_parent| { // The anchor celestial — system's primary star. let star_entity = sys_parent .spawn(( Star, Massive { mass: if sys.is_core { 10000.0 } else { 5000.0 }, }, Luminosity { value: if sys.is_core { 100.0 } else { 50.0 }, }, MassLock { radius: 5.0 }, BoundingVolume { radius: 1.5 }, Identifiable { id: format!("{}-star", sys.id), display_name: sys.name.clone(), classification: Classification::Celestial, }, Mesh3d(mesh), MeshMaterial3d(material), Transform::default().with_scale(Vec3::splat(scale)), )) .id(); // POI children (planets, belts, stations, anomalies, stargates, gas). let ctx = SystemContext { id: &sys.id, name: &sys.name, faction: sys.faction, security: sys.security, is_core: sys.is_core, }; contents::spawn_system_contents( sys_parent, &ctx, sys_contents, star_entity, &content_assets, ); }); } // 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, contents, connections) = generate_galaxy(¶ms); spawn_galaxy_scene( &mut commands, &mut meshes, &mut materials, &systems, &contents, &connections, ); }