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.
This commit is contained in:
2026-06-07 17:03:06 -04:00
parent 4240c2b2ef
commit 031a674bd0
3 changed files with 234 additions and 4 deletions

View File

@@ -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::<f32>() * 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);

View File

@@ -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)),

View File

@@ -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<Time>,
mut orbitals: Query<(&Orbital, &mut Transform)>,
) {
let t = time.elapsed_secs();
for (orbital, mut transform) in &mut orbitals {
if orbital.period <= 0.0 {
continue;
}
let target = orbital_position(orbital.semi_major_axis, orbital.phase, orbital.period, t);
// Avoid mutating when within float-tolerance — keeps change detection
// quiet on frames where the position hasn't visibly changed (e.g.
// when time is paused or the orbit is very slow).
if (transform.translation.x - target.x).abs() > 1e-6
|| (transform.translation.z - target.z).abs() > 1e-6
{
transform.translation.x = target.x;
transform.translation.z = target.z;
}
}
}
/// Local-space position of an orbiting body at simulation time `t`.
///
/// Pure function — exposed for unit tests and for callers that want to
/// preview a POI's position without spawning it (e.g. drawing predicted
/// orbital paths).
///
/// - `semi_major_axis` — orbital radius in world units.
/// - `phase` — initial angle in radians at `t = 0`.
/// - `period` — seconds for one full revolution. Must be > 0.
/// - `t` — elapsed simulation time in seconds.
pub fn orbital_position(semi_major_axis: f32, phase: f32, period: f32, t: f32) -> Vec3 {
debug_assert!(
period > 0.0,
"orbital_position requires period > 0; static POIs should be handled by the caller"
);
let angle = phase + (std::f32::consts::TAU / period) * t;
Vec3::new(angle.cos() * semi_major_axis, 0.0, angle.sin() * semi_major_axis)
}
// ─── Tests ──────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
const TAU: f32 = std::f32::consts::TAU;
#[test]
fn position_at_time_zero_uses_phase() {
let pos = orbital_position(2.0, 0.5, 10.0, 0.0);
// angle = phase + 0 = 0.5 rad
assert!((pos.x - 0.5_f32.cos() * 2.0).abs() < 1e-6);
assert!((pos.z - 0.5_f32.sin() * 2.0).abs() < 1e-6);
assert!(pos.y.abs() < 1e-6);
}
#[test]
fn position_returns_to_start_after_one_period() {
let start = orbital_position(3.0, 1.0, 8.0, 0.0);
let after_one = orbital_position(3.0, 1.0, 8.0, 8.0);
assert!((start.x - after_one.x).abs() < 1e-5);
assert!((start.z - after_one.z).abs() < 1e-5);
}
#[test]
fn position_is_quarter_turn_after_quarter_period() {
let pos = orbital_position(1.0, 0.0, TAU, TAU / 4.0);
// period == TAU ⇒ angular velocity == 1 rad/s.
// After TAU/4 seconds: angle = TAU/4 = π/2 ⇒ (cos, sin) = (0, 1).
assert!(pos.x.abs() < 1e-5);
assert!((pos.z - 1.0).abs() < 1e-5);
}
#[test]
fn position_lies_on_circle_of_radius_semi_major_axis() {
// Sample one full revolution; every point must lie exactly on the
// orbital circle (radius = semi_major_axis) in the XZ plane.
let radius = 4.5_f32;
let phase = 1.25_f32;
let period = 12.0_f32;
let samples = 64;
for i in 0..samples {
let t = (i as f32 / samples as f32) * period;
let pos = orbital_position(radius, phase, period, t);
let r = (pos.x * pos.x + pos.z * pos.z).sqrt();
assert!((r - radius).abs() < 1e-5, "radius drift at sample {i}: {r}");
assert!(pos.y.abs() < 1e-6, "non-zero y at sample {i}: {}", pos.y);
}
}
#[test]
fn position_advances_with_time() {
let a = orbital_position(1.0, 0.0, 10.0, 0.0);
let b = orbital_position(1.0, 0.0, 10.0, 0.5);
assert!((a.x - b.x).abs() > 1e-4 || (a.z - b.z).abs() > 1e-4);
}
#[test]
fn position_respects_phase_offset() {
let t = 0.0_f32;
let a = orbital_position(1.0, 0.0, 10.0, t);
let b = orbital_position(1.0, 1.0, 10.0, t);
assert!((a.x - b.x).abs() > 1e-4 || (a.z - b.z).abs() > 1e-4);
}
#[test]
#[should_panic(expected = "orbital_position requires period > 0")]
fn orbital_position_panics_on_non_positive_period() {
let _ = orbital_position(1.0, 0.0, 0.0, 0.0);
}
}