diff --git a/apps/game/src/gameplay/galaxy_creation/contents.rs b/apps/game/src/gameplay/galaxy_creation/contents.rs index 458e2d1..5a4c287 100644 --- a/apps/game/src/gameplay/galaxy_creation/contents.rs +++ b/apps/game/src/gameplay/galaxy_creation/contents.rs @@ -148,6 +148,8 @@ pub struct GeneratedStation { pub name: String, pub orbit: f32, pub phase: f32, + /// Seconds for one full revolution around the star. `0.0` = stationary. + pub period: f32, pub population: u32, } @@ -156,6 +158,8 @@ pub struct GeneratedAnomaly { pub name: String, pub orbit: f32, pub phase: f32, + /// Seconds for one full revolution around the star. `0.0` = stationary. + pub period: f32, pub difficulty: Difficulty, pub signal_kind: SignalKind, pub requires_probe: bool, @@ -177,6 +181,8 @@ pub struct GeneratedGasCloud { pub name: String, pub orbit: f32, pub phase: f32, + /// Seconds for one full revolution around the star. `0.0` = stationary. + pub period: f32, pub gas_kind: GasKind, pub flow_rate: f32, } @@ -275,6 +281,25 @@ const ASTEROIDS_PER_BELT: usize = 60; /// visible per-rock color variation without exploding material asset count. const ASTEROID_COLOR_COUNT: usize = 5; +// ─── Orbital math ─────────────────────────────────────────────────────────── + +/// Approximate orbital period (seconds) for a body at radius `orbit`. +/// +/// Linear in radius — outer bodies orbit slower, but not as dramatically +/// as Kepler's T² ∝ r³ would dictate. Adequate for visual orbit motion at +/// galaxy-inspection scale. The caller supplies a jitter value (typically +/// sampled from the system RNG) so the period stays deterministic from the +/// seed. +/// +/// Used by planets, stations, anomalies, gas clouds, and individual +/// asteroid rocks. Stargates are intentionally excluded — they stay +/// stationary as navigational aids. Periods are floored at +/// [`MIN_ORBITAL_PERIOD`] so very tight orbits don't become a blur. +const MIN_ORBITAL_PERIOD: f32 = 2.0; +fn orbital_period(orbit: f32, jitter: f32) -> f32 { + (orbit * 2.5 + jitter).max(MIN_ORBITAL_PERIOD) +} + // ─── Generation ───────────────────────────────────────────────────────────── /// Procedurally populate one star system. @@ -295,7 +320,7 @@ pub fn generate_system_contents(rng: &mut StdRng, ctx: &SystemContext) -> System } else { rng.gen_range(0.08..=0.16) }; - let period = (orbit_cursor * 2.5 + rng.gen_range(0.0..3.0)).max(2.0); + let period = orbital_period(orbit_cursor, rng.gen_range(0.0..3.0)); let phase = rng.gen::() * std::f32::consts::TAU; let mass = radius * 100.0; let habitable = planet_type.is_habitable() && rng.gen_bool(0.6); @@ -368,6 +393,7 @@ pub fn generate_system_contents(rng: &mut StdRng, ctx: &SystemContext) -> System name, orbit: orbit_cursor, phase, + period: orbital_period(orbit_cursor, rng.gen_range(0.0..3.0)), population, }); orbit_cursor += 0.3; @@ -390,6 +416,7 @@ pub fn generate_system_contents(rng: &mut StdRng, ctx: &SystemContext) -> System name: format!("{} Cloud", gas_kind_display(gas_kind)), orbit: orbit_cursor, phase, + period: orbital_period(orbit_cursor, rng.gen_range(0.0..3.0)), gas_kind, flow_rate: rng.gen_range(1.0..5.0), }); @@ -421,6 +448,7 @@ pub fn generate_system_contents(rng: &mut StdRng, ctx: &SystemContext) -> System name: anomaly_name(ctx.security, i), orbit: orbit_cursor, phase, + period: orbital_period(orbit_cursor, rng.gen_range(0.0..3.0)), difficulty, signal_kind, requires_probe, @@ -755,6 +783,9 @@ pub fn spawn_system_contents( .spawn(( Orbital { semi_major_axis: mid, + // Belt entity itself is stationary — it's the abstract + // mining-site POI at the star. Individual rocks orbit on + // their own (see rock spawn below). period: 0.0, phase: 0.0, parent: star_entity, @@ -777,7 +808,22 @@ pub fn spawn_system_contents( )) .with_children(|belt_parent| { for rock in &belt.asteroids { + // Each rock orbits the star at its own radius. Inner + // rocks complete a revolution faster than outer ones, + // producing visible shearing of the belt over time + // (mild — linear formula, not Kepler). Y-jitter from + // `rock.position.y` is preserved because the orbital + // system only overwrites x/z. + let rock_radius = + Vec2::new(rock.position.x, rock.position.z).length(); + let rock_phase = rock.position.z.atan2(rock.position.x); belt_parent.spawn(( + Orbital { + semi_major_axis: rock_radius, + period: orbital_period(rock_radius, 0.0), + phase: rock_phase, + parent: star_entity, + }, Mesh3d(assets.asteroid_mesh.clone()), MeshMaterial3d(assets.asteroid_materials[rock.material_index].clone()), Transform::from_translation(rock.position) @@ -796,7 +842,7 @@ pub fn spawn_system_contents( Station, Orbital { semi_major_axis: station.orbit, - period: 0.0, + period: station.period, phase: station.phase, parent: star_entity, }, @@ -823,7 +869,7 @@ pub fn spawn_system_contents( Anomaly, Orbital { semi_major_axis: anomaly.orbit, - period: 0.0, + period: anomaly.period, phase: anomaly.phase, parent: star_entity, }, @@ -862,6 +908,9 @@ pub fn spawn_system_contents( Stargate, Orbital { semi_major_axis: gate.orbit, + // Stargates are stationary by design — navigational aids + // must stay at fixed positions so warp-in routes remain + // stable across sessions. period: 0.0, phase: gate.phase, parent: star_entity, @@ -898,7 +947,7 @@ pub fn spawn_system_contents( GasCloud, Orbital { semi_major_axis: cloud.orbit, - period: 0.0, + period: cloud.period, phase: cloud.phase, parent: star_entity, }, @@ -1135,6 +1184,7 @@ mod tests { name: "S1".into(), orbit: 6.0, phase: 0.0, + period: 0.0, population: 0, }); assert_eq!(c.max_orbit(), 6.0); @@ -1187,6 +1237,28 @@ mod tests { assert_ne!(system_hash("g-0"), system_hash("g-1")); } + #[test] + fn orbital_period_grows_linearly_with_radius() { + // No jitter → period = 2.5 × orbit. + assert!((orbital_period(0.0, 0.0) - MIN_ORBITAL_PERIOD).abs() < 1e-6); + assert!((orbital_period(2.0, 0.0) - 5.0).abs() < 1e-6); + assert!((orbital_period(8.0, 0.0) - 20.0).abs() < 1e-6); + } + + #[test] + fn orbital_period_floors_at_minimum() { + // Very tight orbit + negative jitter still respects the floor. + assert!((orbital_period(0.1, -10.0) - MIN_ORBITAL_PERIOD).abs() < 1e-6); + assert!((orbital_period(0.0, 0.0) - MIN_ORBITAL_PERIOD).abs() < 1e-6); + } + + #[test] + fn orbital_period_applies_jitter_additively() { + let base = orbital_period(5.0, 0.0); + let jittered = orbital_period(5.0, 2.0); + assert!((jittered - base - 2.0).abs() < 1e-6); + } + #[test] fn asteroid_generation_is_deterministic() { let mut rng_a = StdRng::seed_from_u64(123); diff --git a/apps/game/src/gameplay/galaxy_creation/mod.rs b/apps/game/src/gameplay/galaxy_creation/mod.rs index 64809b5..b2155a3 100644 --- a/apps/game/src/gameplay/galaxy_creation/mod.rs +++ b/apps/game/src/gameplay/galaxy_creation/mod.rs @@ -7,6 +7,7 @@ mod axes; mod contents; +mod orbits; mod params; mod poi; mod selection; @@ -51,6 +52,7 @@ impl Plugin for GalaxyCreationPlugin { selection::animate_selected_star, selection::refresh_info_panel, apply_orbit_reset, + orbits::advance_orbital_paths, ) .chain() .run_if(in_state(AppState::GalaxyCreation)), diff --git a/apps/game/src/gameplay/galaxy_creation/orbits.rs b/apps/game/src/gameplay/galaxy_creation/orbits.rs new file mode 100644 index 0000000..1573de1 --- /dev/null +++ b/apps/game/src/gameplay/galaxy_creation/orbits.rs @@ -0,0 +1,156 @@ +//! Orbital path integration for POIs (planets, stations, …). +//! +//! Each frame, recomputes the local-space position of every [`Orbital`] +//! entity based on its `semi_major_axis`, `period`, and `phase`, plus +//! accumulated sim time. The orbit lies in the XZ plane (Y up), matching the +//! spawn-time layout in [`super::contents::orbital_position`]. +//! +//! ## Stateless +//! +//! The position is recomputed from component data + elapsed time — there is +//! no per-entity angle accumulator. This means: +//! +//! - **No drift** over long sessions (the math is exact, not integrated). +//! - **Trivial save/load**: only the component data needs to persist. +//! - **Time manipulation Just Works™**: pause, slow-mo, and scrub are free +//! side-effects of Bevy's `Time` speed multiplier, which scales both +//! `elapsed_secs()` and `delta_secs()`. +//! +//! ## Static POIs +//! +//! POIs with `period <= 0` (asteroid belts, stations, anomalies, stargates, +//! gas clouds in the current generator) keep their spawn-time position. We +//! skip them entirely to avoid spurious change-detection ticks every frame. +//! +//! ## Hierarchy assumption +//! +//! Each [`Orbital`] is currently a child of [`super::StarSystem`] while its +//! `Orbital.parent` is the system's [`super::Star`], which sits at the +//! `StarSystem`'s local origin. The orbital offset is therefore written +//! directly to the POI's local `Transform`. +//! +//! To support nested orbits later (moons orbiting planets), either reparent +//! POIs under their orbital parent in the Bevy hierarchy, or look up the +//! parent's `GlobalTransform` here and compute the local offset against the +//! POI's Bevy parent. + +use bevy::prelude::*; + +use super::poi::Orbital; + +/// Advance every [`Orbital`] along its path. +/// +/// See module docs for the math and the hierarchy assumption. +pub fn advance_orbital_paths( + time: Res