Files
Space-Game/apps/game/src/gameplay/galaxy_creation/mod.rs
francy51 031a674bd0 Add orbital path integration for POIs in galaxy map
Stateless orbital system: each Orbital's local position is recomputed
each frame from phase + (TAU/period) * elapsed_secs. No per-entity
angle accumulator means no drift, trivial save/load (component data
only), and free pause/slow-mo via Bevy's time speed multiplier.

Extended to planets, stations, anomalies, gas clouds, and individual
asteroid rocks. Inner rocks orbit proportionally faster than outer
ones, producing visible belt shearing over time. Stargates stay
stationary as navigational aids. The orbital_period(orbit, jitter)
helper consolidates the period formula in one place.

10 new unit tests (7 for orbital_position, 3 for orbital_period).
All 32 tests pass.
2026-06-07 17:03:06 -04:00

539 lines
20 KiB
Rust

//! 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::<GalaxyParams>()
.init_resource::<SelectedStar>()
.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<Assets<Mesh>>,
mut materials: ResMut<Assets<StandardMaterial>>,
params: Res<GalaxyParams>,
) {
let (systems, contents, connections) = generate_galaxy(&params);
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<GeneratedSystem>,
Vec<SystemContents>,
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<SystemContents> = 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<SystemSummary> = 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<GeneratedSystem> {
let mut systems: Vec<GeneratedSystem> = 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::<f32>() * 30.0
} else {
rng.gen::<f32>().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::<f32>() * std::f32::consts::TAU
} else {
std::f32::consts::TAU * arm as f32 / arm_count as f32
+ (r / params.size) * arm_twist
+ (rng.gen::<f32>() - 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::<f32>() - 0.5) * 12.0;
(vx, vy, vz)
} else {
(
angle.cos() * r,
(rng.gen::<f32>() - 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<Mesh>,
materials: &mut Assets<StandardMaterial>,
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<Handle<StandardMaterial>> = 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<Entity, With<GalaxyCreationSpawned>>,
) {
for entity in &query {
// Bevy 0.16: despawn() is recursive by default.
commands.entity(entity).despawn();
}
}
fn reset_selection(mut selected: ResMut<SelectedStar>) {
selected.0 = None;
}
fn escape_to_main_menu(
keys: Res<ButtonInput<KeyCode>>,
mut next_state: ResMut<NextState<AppState>>,
) {
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<Assets<Mesh>>,
mut materials: ResMut<Assets<StandardMaterial>>,
params: Res<GalaxyParams>,
scene_roots: Query<Entity, With<GalaxyScene>>,
mut selected: ResMut<SelectedStar>,
mut last_seen: Local<Option<u64>>,
) {
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(&params);
spawn_galaxy_scene(
&mut commands,
&mut meshes,
&mut materials,
&systems,
&contents,
&connections,
);
}