From 031a674bd033809ebc2ea867d7483f774766dfff Mon Sep 17 00:00:00 2001 From: francy51 Date: Sun, 7 Jun 2026 17:03:06 -0400 Subject: [PATCH] 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. --- .../src/gameplay/galaxy_creation/contents.rs | 80 ++++++++- apps/game/src/gameplay/galaxy_creation/mod.rs | 2 + .../src/gameplay/galaxy_creation/orbits.rs | 156 ++++++++++++++++++ 3 files changed, 234 insertions(+), 4 deletions(-) create mode 100644 apps/game/src/gameplay/galaxy_creation/orbits.rs 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