From 4240c2b2ef9b8871352561be25ae7c7eb653883f Mon Sep 17 00:00:00 2001 From: francy51 Date: Sat, 6 Jun 2026 17:57:05 -0400 Subject: [PATCH] Render asteroid belts as instanced jittered icospheres MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the flat-grey torus asteroid belt with a swarm of 60 individual rocks per belt — one shared jittered-icosphere mesh asset scaled, rotated, and palette-tinted per instance (5-entry greyscale/brown material set). - New GeneratedAsteroid data (position/scale/rotation/material_index) generated deterministically alongside the belt, preserving the same-seed-same-galaxy invariant - New build_asteroid_mesh + asteroid_material_palette helpers - ContentAssets drops belt_material, adds asteroid_mesh + asteroid_materials - spawn_system_contents signature simplified (no more &mut Assets) - Belt entity is now a no-mesh parent (Mineable/Identifiable/...) with rock meshes as children — sets the stage for per-asteroid mining - Bundles the POI component/marker scaffolding (poi.rs) and per-system contents generator (contents.rs: planets, stations, anomalies, stargates, gas clouds) that the asteroid rendering sits on top of - Tests: asteroid_generation_is_deterministic, asteroid_positions_stay_inside_annulus --- .../src/gameplay/galaxy_creation/contents.rs | 1219 +++++++++++++++++ apps/game/src/gameplay/galaxy_creation/mod.rs | 246 +++- apps/game/src/gameplay/galaxy_creation/poi.rs | 488 +++++++ 3 files changed, 1910 insertions(+), 43 deletions(-) create mode 100644 apps/game/src/gameplay/galaxy_creation/contents.rs create mode 100644 apps/game/src/gameplay/galaxy_creation/poi.rs diff --git a/apps/game/src/gameplay/galaxy_creation/contents.rs b/apps/game/src/gameplay/galaxy_creation/contents.rs new file mode 100644 index 0000000..458e2d1 --- /dev/null +++ b/apps/game/src/gameplay/galaxy_creation/contents.rs @@ -0,0 +1,1219 @@ +//! Per-system contents generation: planets, asteroid belts, stations, +//! anomalies, gas clouds, and stargates. +//! +//! Invoked by [`super`] during galaxy generation. Data-only at first +//! ([`SystemContents`]), then spawned as child entities of each +//! [`super::StarSystem`] with the appropriate POI components from +//! [`super::poi`]. +//! +//! ## Scale +//! +//! All distances in this file are in **galaxy units** (the same scale as the +//! system positions themselves). Contents are kept in the 1–10 unit range so +//! they fit inside the typical system-to-system spacing (~10–24 units) without +//! overflowing into neighbouring systems. +//! +//! ## Determinism +//! +//! Each call to [`generate_system_contents`] advances the provided RNG. +//! Re-running with the same seed and the same iteration order produces +//! identical contents. + +use bevy::prelude::*; +use bevy::render::mesh::VertexAttributeValues; +use rand::rngs::StdRng; +use rand::Rng; +use std::time::Duration; + +use super::poi::*; + +// ─── POI variant data ─────────────────────────────────────────────────────── + +/// Classification of a planet's surface composition. Drives visual color and +/// habitability. +/// +/// Variant order is significant: [`ContentAssets::planet_material`] indexes +/// the material cache by `as usize`. Adding / reordering variants requires +/// updating the cache. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PlanetType { + Barren = 0, + Rocky = 1, + Terrestrial = 2, + Gas = 3, + Ice = 4, + Lava = 5, + Oceanic = 6, +} + +impl PlanetType { + /// Visual color (sRGB) used by the spawning code to pick a material. + pub fn color(self) -> [f32; 3] { + match self { + Self::Barren => [0.54, 0.48, 0.42], + Self::Rocky => [0.66, 0.62, 0.58], + Self::Terrestrial => [0.20, 0.77, 0.33], + Self::Gas => [0.83, 0.65, 0.38], + Self::Ice => [0.58, 0.77, 0.99], + Self::Lava => [0.94, 0.27, 0.16], + Self::Oceanic => [0.22, 0.74, 0.97], + } + } + + pub fn display_name(self) -> &'static str { + match self { + Self::Barren => "Barren", + Self::Rocky => "Rocky", + Self::Terrestrial => "Terrestrial", + Self::Gas => "Gas Giant", + Self::Ice => "Ice", + Self::Lava => "Lava", + Self::Oceanic => "Oceanic", + } + } + + pub fn is_gas(self) -> bool { + matches!(self, Self::Gas) + } + + pub fn is_habitable(self) -> bool { + matches!(self, Self::Terrestrial | Self::Oceanic) + } + + fn random(rng: &mut StdRng) -> Self { + match rng.gen_range(0..7) { + 0 => Self::Barren, + 1 => Self::Rocky, + 2 => Self::Terrestrial, + 3 => Self::Gas, + 4 => Self::Ice, + 5 => Self::Lava, + _ => Self::Oceanic, + } + } + + /// All variants in declared order — used by [`ContentAssets::new`] to + /// allocate one material per type. + pub const ALL: [Self; 7] = [ + Self::Barren, + Self::Rocky, + Self::Terrestrial, + Self::Gas, + Self::Ice, + Self::Lava, + Self::Oceanic, + ]; +} + +#[derive(Debug, Clone)] +pub struct GeneratedPlanet { + pub name: String, + pub planet_type: PlanetType, + pub orbit: f32, + pub period: f32, + pub phase: f32, + pub radius: f32, + pub mass: f32, + pub population: u32, +} + +/// One rock instance inside a [`GeneratedAsteroidBelt`]. Generated at galaxy +/// build time (deterministic from the system RNG) so the same seed always +/// yields the same belt layout. +#[derive(Debug, Clone)] +pub struct GeneratedAsteroid { + /// Position relative to the star (XZ plane, with small Y jitter). + pub position: Vec3, + /// Uniform scale. + pub scale: f32, + /// Random rotation — gives visual variety even though every rock shares + /// the same mesh asset. + pub rotation: Quat, + /// Index into [`ContentAssets::asteroid_materials`]. + pub material_index: usize, +} + +#[derive(Debug, Clone)] +pub struct GeneratedAsteroidBelt { + pub name: String, + pub inner_orbit: f32, + pub outer_orbit: f32, + pub yield_remaining: f32, + /// Per-rock visual instances — spawned as children of the belt entity. + pub asteroids: Vec, +} + +#[derive(Debug, Clone)] +pub struct GeneratedStation { + pub name: String, + pub orbit: f32, + pub phase: f32, + pub population: u32, +} + +#[derive(Debug, Clone)] +pub struct GeneratedAnomaly { + pub name: String, + pub orbit: f32, + pub phase: f32, + pub difficulty: Difficulty, + pub signal_kind: SignalKind, + pub requires_probe: bool, + pub remaining: Duration, +} + +#[derive(Debug, Clone)] +pub struct GeneratedStargate { + pub name: String, + /// [`Identifiable::id`] of the destination system. + pub destination_id: String, + pub destination_name: String, + pub orbit: f32, + pub phase: f32, +} + +#[derive(Debug, Clone)] +pub struct GeneratedGasCloud { + pub name: String, + pub orbit: f32, + pub phase: f32, + pub gas_kind: GasKind, + pub flow_rate: f32, +} + +#[derive(Debug, Clone, Default)] +pub struct SystemContents { + pub planets: Vec, + pub belts: Vec, + pub stations: Vec, + pub anomalies: Vec, + pub stargates: Vec, + pub gas_clouds: Vec, +} + +impl SystemContents { + /// Total number of POIs (planets + everything else) in this system. + pub fn total(&self) -> usize { + self.planets.len() + + self.belts.len() + + self.stations.len() + + self.anomalies.len() + + self.stargates.len() + + self.gas_clouds.len() + } + + /// Outermost orbit radius across all non-stargate POIs. Stargates are + /// excluded because they're placed *after* this is computed (their orbit + /// is derived from this value). Used by [`generate_stargates`] to put + /// gates clearly outside the inner POI ring. + pub fn max_orbit(&self) -> f32 { + let mut max = 0.0_f32; + for p in &self.planets { + max = max.max(p.orbit); + } + for b in &self.belts { + max = max.max(b.outer_orbit); + } + for s in &self.stations { + max = max.max(s.orbit); + } + for a in &self.anomalies { + max = max.max(a.orbit); + } + for g in &self.gas_clouds { + max = max.max(g.orbit); + } + max + } +} + +// ─── Input context ────────────────────────────────────────────────────────── + +/// Read-only view of a [`super::GeneratedSystem`] consumed by the contents +/// generator. Kept separate so this module doesn't need to name its parent's +/// private system struct (and so the generator stays unit-testable). +#[derive(Debug, Clone, Copy)] +pub struct SystemContext<'a> { + pub id: &'a str, + pub name: &'a str, + pub faction: &'a str, + pub security: f32, + pub is_core: bool, +} + +/// Minimal read-only summary used by [`generate_stargates`]. The caller +/// (galaxy generator) constructs one per system alongside its contents. +#[derive(Debug, Clone)] +pub struct SystemSummary { + pub id: String, + pub name: String, +} + +// ─── Tunables ─────────────────────────────────────────────────────────────── + +/// Orbit of the first planet. Keeps planets clear of the central star visual. +const PLANET_ORBIT_START: f32 = 1.8; +/// Minimum gap between successive planets. Actual gap is sampled above this. +/// Kept compact to mirror the TS reference (planet display orbit grows by +/// ~0.42 per planet) and to leave breathing room for the outer POI ring. +const PLANET_ORBIT_STEP: f32 = 0.4; +/// Radius beyond which non-planet POIs (belts, stations, …) live. +const OUTER_RING_START: f32 = 5.0; +/// Minimum stargate orbit — lower bound even for sparse systems. Picked to +/// stay inside the typical system bubble (~12 units, half the inter-system +/// spacing) so gates from neighboring systems never overlap. +const STARGATE_MIN_ORBIT: f32 = 7.0; +/// Padding added to a system's outermost POI orbit before placing stargates. +/// Keeps gates clearly outside the inner POI ring so they never visually +/// overlap planets / stations / anomalies / gas clouds. +const STARGATE_PADDING: f32 = 3.0; +/// Number of asteroid instances to scatter per belt. Tuned for visual density +/// at galaxy-inspection scale (~1 unit per belt) without crowding the renderer +/// (60 rocks × ~120 belts ≈ 7,200 entities — well under Bevy's comfort zone). +const ASTEROIDS_PER_BELT: usize = 60; +/// Size of the [`ContentAssets::asteroid_materials`] palette. Picked to give +/// visible per-rock color variation without exploding material asset count. +const ASTEROID_COLOR_COUNT: usize = 5; + +// ─── Generation ───────────────────────────────────────────────────────────── + +/// Procedurally populate one star system. +/// +/// Stargates are NOT generated here — they depend on the connection graph +/// which is computed globally. Use [`generate_stargates`] after generating +/// base contents for every system. +pub fn generate_system_contents(rng: &mut StdRng, ctx: &SystemContext) -> SystemContents { + let mut contents = SystemContents::default(); + let mut orbit_cursor = PLANET_ORBIT_START; + + // ── Planets ───────────────────────────────────────────────────────────── + let planet_count = planet_count_for_security(rng, ctx.security, ctx.is_core); + for i in 0..planet_count { + let planet_type = PlanetType::random(rng); + let radius = if planet_type.is_gas() { + rng.gen_range(0.18..=0.28) + } 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 phase = rng.gen::() * std::f32::consts::TAU; + let mass = radius * 100.0; + let habitable = planet_type.is_habitable() && rng.gen_bool(0.6); + let population = if habitable && rng.gen_bool(0.7) { + rng.gen_range(100..1_000_000) + } else { + 0 + }; + let name = format!("{} {}", ctx.name, romanize(i + 1)); + + contents.planets.push(GeneratedPlanet { + name, + planet_type, + orbit: orbit_cursor, + period, + phase, + radius, + mass, + population, + }); + orbit_cursor += PLANET_ORBIT_STEP + rng.gen_range(0.1..0.4); + } + + // ── Asteroid belt ─────────────────────────────────────────────────────── + let belt_chance = if ctx.security < 0.4 { + 0.85 + } else if ctx.security < 0.7 { + 0.55 + } else { + 0.25 + }; + if rng.gen::() < belt_chance { + let inner = orbit_cursor.max(OUTER_RING_START); + let outer = inner + rng.gen_range(0.6..=1.2); + let asteroids = generate_asteroids(rng, inner, outer); + contents.belts.push(GeneratedAsteroidBelt { + name: format!("{} Belt", ctx.name), + inner_orbit: inner, + outer_orbit: outer, + yield_remaining: rng.gen_range(1000.0..5000.0), + asteroids, + }); + orbit_cursor = outer + 0.4; + } + + // ── Stations ──────────────────────────────────────────────────────────── + let station_count = if ctx.is_core { + rng.gen_range(2..=3) + } else if ctx.security > 0.7 { + rng.gen_range(1..=2) + } else if ctx.security > 0.4 { + usize::from(rng.gen_bool(0.5)) + } else { + 0 + }; + for i in 0..station_count { + orbit_cursor = orbit_cursor.max(OUTER_RING_START) + rng.gen_range(0.2..0.5); + let phase = rng.gen::() * std::f32::consts::TAU; + let population = if ctx.is_core { + rng.gen_range(1_000_000..10_000_000) + } else { + rng.gen_range(10_000..1_000_000) + }; + let name = if station_count == 1 { + format!("{} Hub", ctx.name) + } else { + format!("{} Station {}", ctx.name, romanize(i + 1)) + }; + contents.stations.push(GeneratedStation { + name, + orbit: orbit_cursor, + phase, + population, + }); + orbit_cursor += 0.3; + } + + // ── Gas clouds (rare, slightly more common in low-sec) ────────────────── + let gas_chance = if ctx.security < 0.4 { 0.35 } else { 0.20 }; + if rng.gen::() < gas_chance { + let count = rng.gen_range(1..=2); + for _ in 0..count { + orbit_cursor = orbit_cursor.max(OUTER_RING_START) + rng.gen_range(0.2..0.6); + let phase = rng.gen::() * std::f32::consts::TAU; + let gas_kind = match rng.gen_range(0..4) { + 0 => GasKind::Hydrogen, + 1 => GasKind::Helium, + 2 => GasKind::Nitrogen, + _ => GasKind::Exotic, + }; + contents.gas_clouds.push(GeneratedGasCloud { + name: format!("{} Cloud", gas_kind_display(gas_kind)), + orbit: orbit_cursor, + phase, + gas_kind, + flow_rate: rng.gen_range(1.0..5.0), + }); + orbit_cursor += 0.3; + } + } + + // ── Anomalies (low-sec only) ──────────────────────────────────────────── + if ctx.security < 0.4 { + let count = rng.gen_range(1..=3); + for i in 0..count { + orbit_cursor = orbit_cursor.max(OUTER_RING_START) + rng.gen_range(0.3..0.7); + let phase = rng.gen::() * std::f32::consts::TAU; + let difficulty = match rng.gen_range(0..5) { + 0 => Difficulty::Trivial, + 1 => Difficulty::Easy, + 2 => Difficulty::Moderate, + 3 => Difficulty::Hard, + _ => Difficulty::Expert, + }; + let signal_kind = match rng.gen_range(0..4) { + 0 => SignalKind::Infrared, + 1 => SignalKind::Gravimetric, + 2 => SignalKind::Radar, + _ => SignalKind::Magnetometric, + }; + let requires_probe = matches!(difficulty, Difficulty::Hard | Difficulty::Expert); + contents.anomalies.push(GeneratedAnomaly { + name: anomaly_name(ctx.security, i), + orbit: orbit_cursor, + phase, + difficulty, + signal_kind, + requires_probe, + remaining: Duration::from_secs(rng.gen_range(600..3600)), + }); + orbit_cursor += 0.3; + } + } + + // Stargates are added later — see [`generate_stargates`]. + contents +} + +/// Planet count bias: high-sec / core systems have more charted planets, +/// low-sec systems are sparser. +fn planet_count_for_security(rng: &mut StdRng, security: f32, is_core: bool) -> usize { + if is_core { + rng.gen_range(5..=8) + } else if security > 0.7 { + rng.gen_range(3..=7) + } else if security > 0.4 { + rng.gen_range(2..=5) + } else { + rng.gen_range(1..=4) + } +} + +/// Scatter [`ASTEROIDS_PER_BELT`] rock instances uniformly across the annulus +/// between `inner_orbit` and `outer_orbit`. Returned data is consumed verbatim +/// by the spawner — keeping generation and rendering decoupled preserves the +/// "same seed = same galaxy" invariant. +/// +/// Each rock gets a random radius within the belt, a random angle around the +/// star, and small Y jitter proportional to belt width for thickness. Scale +/// and rotation are randomized so the single shared mesh asset reads as a +/// field of unique rocks at dev scale. +fn generate_asteroids( + rng: &mut StdRng, + inner_orbit: f32, + outer_orbit: f32, +) -> Vec { + let mut out = Vec::with_capacity(ASTEROIDS_PER_BELT); + // Vertical half-thickness: 20% of the radial width. Reads as a flat ring + // at inspection scale without being perfectly planar. + let half_thickness = (outer_orbit - inner_orbit) * 0.2; + + for _ in 0..ASTEROIDS_PER_BELT { + let t: f32 = rng.gen(); + let radius = inner_orbit + t * (outer_orbit - inner_orbit); + let angle = rng.gen::() * std::f32::consts::TAU; + let y = (rng.gen::() - 0.5) * half_thickness; + let position = Vec3::new(angle.cos() * radius, y, angle.sin() * radius); + let scale = rng.gen_range(0.06..=0.14); + let rotation = Quat::from_euler( + EulerRot::XYZ, + rng.gen::() * std::f32::consts::TAU, + rng.gen::() * std::f32::consts::TAU, + rng.gen::() * std::f32::consts::TAU, + ); + let material_index = rng.gen_range(0..ASTEROID_COLOR_COUNT); + out.push(GeneratedAsteroid { + position, + scale, + rotation, + material_index, + }); + } + out +} + +fn anomaly_name(security: f32, index: usize) -> String { + if security < 0.2 { + format!("Unstable Deadspace Rift {}", romanize(index + 1)) + } else { + format!("Hidden Combat Site {}", romanize(index + 1)) + } +} + +fn gas_kind_display(kind: GasKind) -> &'static str { + match kind { + GasKind::Hydrogen => "Hydrogen", + GasKind::Helium => "Helium", + GasKind::Nitrogen => "Nitrogen", + GasKind::Exotic => "Exotic", + } +} + +/// Tiny int → roman numeral helper for numbered planet/station names. +/// Covers I–IX which is enough for any plausible per-system count. +fn romanize(n: usize) -> &'static str { + match n { + 1 => "I", + 2 => "II", + 3 => "III", + 4 => "IV", + 5 => "V", + 6 => "VI", + 7 => "VII", + 8 => "VIII", + _ => "IX+", + } +} + +// ─── Stargate generation ──────────────────────────────────────────────────── + +/// Add a pair of stargates (one in each endpoint system) for every connection. +/// +/// Stargates sit on an outer ring placed just outside the system's outermost +/// POI (see [`SystemContents::max_orbit`] + [`STARGATE_PADDING`], floored at +/// [`STARGATE_MIN_ORBIT`]) so they never collide with inner-system POIs. +/// Their phase angle is derived deterministically from `(system_id, +/// gate_index)` so the same galaxy seed yields the same gate layout across +/// reruns. +/// +/// Takes parallel `summaries` / `contents` slices rather than a slice of +/// tuples — this keeps the caller's data layout free and avoids split-borrow +/// gymnastics at the call site. +pub fn generate_stargates( + summaries: &[SystemSummary], + contents: &mut [SystemContents], + connections: &[(usize, usize)], +) { + assert_eq!(summaries.len(), contents.len()); + // Track how many gates have already been placed in each system so we can + // distribute them around the ring instead of stacking on top of each other. + let mut gate_counts = vec![0usize; summaries.len()]; + + for &(a, b) in connections { + if a >= summaries.len() || b >= summaries.len() || a == b { + continue; + } + + // Snapshot the identities of both endpoints so we never have two + // simultaneous borrows into the same slice index. + let a_id = summaries[a].id.clone(); + let a_name = summaries[a].name.clone(); + let b_id = summaries[b].id.clone(); + let b_name = summaries[b].name.clone(); + + let idx_a = gate_counts[a]; + let idx_b = gate_counts[b]; + gate_counts[a] += 1; + gate_counts[b] += 1; + + // Dynamic outer orbit per system so stargates never overlap the + // inner POI ring (planets / stations / anomalies / gas clouds). + let max_orbit_a = contents[a].max_orbit(); + let max_orbit_b = contents[b].max_orbit(); + + // Gate in A → B (destination = b_id) + let (orbit_a, phase_a) = stargate_orbit_phase(idx_a, &a_id, max_orbit_a); + contents[a].stargates.push(GeneratedStargate { + name: format!("{a_name} → {b_name} Gate"), + destination_id: b_id.clone(), + destination_name: b_name.clone(), + orbit: orbit_a, + phase: phase_a, + }); + + // Gate in B → A (destination = a_id) + let (orbit_b, phase_b) = stargate_orbit_phase(idx_b, &b_id, max_orbit_b); + contents[b].stargates.push(GeneratedStargate { + name: format!("{b_name} → {a_name} Gate"), + destination_id: a_id, + destination_name: a_name, + orbit: orbit_b, + phase: phase_b, + }); + } +} + +/// Orbit + phase angle for a stargate at index `gate_index` in system +/// `system_id`. Golden-angle distribution avoids clumping when there are few +/// gates; deterministic hash keeps layout stable across reruns. +/// +/// The orbit is derived from `system_max_orbit` (the outermost non-stargate +/// POI) plus [`STARGATE_PADDING`], floored at [`STARGATE_MIN_ORBIT`] for +/// sparse systems. This guarantees gates sit clearly outside the inner POI +/// ring regardless of how busy the system is. +fn stargate_orbit_phase(gate_index: usize, system_id: &str, system_max_orbit: f32) -> (f32, f32) { + let orbit = (system_max_orbit + STARGATE_PADDING).max(STARGATE_MIN_ORBIT); + let golden = std::f32::consts::TAU * 0.381_966; + let base = (system_hash(system_id) % 360) as f32 * std::f32::consts::TAU / 360.0; + let phase = (base + gate_index as f32 * golden) % std::f32::consts::TAU; + (orbit, phase) +} + +fn system_hash(s: &str) -> u64 { + let mut h: u64 = 0; + for byte in s.bytes() { + h = h.wrapping_mul(31).wrapping_add(byte as u64); + } + h +} + +// ─── Spawn-time asset cache ───────────────────────────────────────────────── + +/// Per-system visual asset cache. One mesh + one material per *kind* of +/// POI; per-entity variation comes from Transform scale and (for asteroids) +/// per-instance material picks from a small palette. +/// +/// Built once per galaxy regeneration. Sized meshes are scaled per-entity via +/// `Transform::scale` from a unit primitive; stargate torus meshes use the +/// same trick. Asteroid rocks share a single jittered icosphere mesh. +pub struct ContentAssets { + // Meshes + pub planet_mesh: Handle, // unit sphere + pub station_mesh: Handle, // unit cube + pub anomaly_mesh: Handle, // low-poly sphere (angular/crystalline) + pub gas_mesh: Handle, // unit sphere (used with translucent mat) + pub stargate_mesh: Handle, // unit torus + /// Jittered icosphere shared by every asteroid instance across all belts. + /// Per-rock variation comes from per-instance Transform + material pick. + pub asteroid_mesh: Handle, + + // Materials + /// Indexed by `PlanetType as usize`. Length == 7. + pub planet_materials: Vec>, + pub station_material: Handle, + pub anomaly_material: Handle, + pub stargate_material: Handle, + /// Indexed by `GasKind as usize`. Length == 4. + pub gas_materials: Vec>, + /// Greyscale / brown palette indexed by [`GeneratedAsteroid::material_index`]. + /// Length == [`ASTEROID_COLOR_COUNT`] (5). + pub asteroid_materials: Vec>, +} + +impl ContentAssets { + pub fn new(meshes: &mut Assets, materials: &mut Assets) -> Self { + let planet_mesh = meshes.add(Sphere::new(1.0).mesh().ico(2).unwrap()); + let station_mesh = meshes.add(Cuboid::new(1.0, 1.0, 1.0).mesh()); + let anomaly_mesh = meshes.add(Sphere::new(1.0).mesh().ico(1).unwrap()); + let gas_mesh = meshes.add(Sphere::new(1.0).mesh().ico(2).unwrap()); + // Unit torus shared by every stargate. Scaled per entity to a small + // ring marker (~0.45 final radius) at the gate's orbital position — + // NOT a giant ring around the star. Chunkier tube (0.18) so the ring + // still reads at the small marker scale. + let stargate_mesh = meshes.add(Torus::new(1.0, 0.18).mesh()); + + let planet_materials = PlanetType::ALL + .iter() + .map(|t| unlit_material(materials, t.color())) + .collect(); + let gas_materials = vec![ + translucent_material(materials, [0.85, 0.85, 0.95]), // Hydrogen + translucent_material(materials, [0.95, 0.85, 0.65]), // Helium + translucent_material(materials, [0.65, 0.85, 0.95]), // Nitrogen + translucent_material(materials, [0.85, 0.45, 0.95]), // Exotic + ]; + + let asteroid_mesh = build_asteroid_mesh(meshes); + let asteroid_materials = asteroid_material_palette(materials); + + Self { + planet_mesh, + station_mesh, + anomaly_mesh, + gas_mesh, + stargate_mesh, + asteroid_mesh, + station_material: unlit_material(materials, [0.13, 0.83, 0.93]), + anomaly_material: unlit_material(materials, [0.94, 0.63, 0.19]), + stargate_material: unlit_material(materials, [0.66, 0.33, 0.97]), + planet_materials, + gas_materials, + asteroid_materials, + } + } + + fn planet_material(&self, planet_type: PlanetType) -> Handle { + self.planet_materials[planet_type as usize].clone() + } + + fn gas_material(&self, kind: GasKind) -> Handle { + self.gas_materials[kind as usize].clone() + } +} + +// ─── Spawning ─────────────────────────────────────────────────────────────── + +/// Spawn all POI children inside a [`super::StarSystem`] parent. `star_entity` +/// is the entity ID of the system's [`super::Star`] child — used as the +/// `parent` field of [`Orbital`] so orbital systems can resolve the body +/// being orbited. +pub fn spawn_system_contents( + parent: &mut ChildSpawnerCommands, + sys: &SystemContext, + contents: &SystemContents, + star_entity: Entity, + assets: &ContentAssets, +) { + // ── Planets ───────────────────────────────────────────────────────────── + for planet in &contents.planets { + let local = orbital_position(planet.orbit, planet.phase); + let mut entity = parent.spawn(( + Planet, + Orbital { + semi_major_axis: planet.orbit, + period: planet.period, + phase: planet.phase, + parent: star_entity, + }, + Massive { mass: planet.mass }, + BoundingVolume { + radius: planet.radius, + }, + Identifiable { + id: format!("{}-{}", sys.id, slug(&planet.name)), + display_name: planet.name.clone(), + classification: Classification::Celestial, + }, + Mesh3d(assets.planet_mesh.clone()), + MeshMaterial3d(assets.planet_material(planet.planet_type)), + Transform::from_translation(local).with_scale(Vec3::splat(planet.radius)), + )); + if planet.population > 0 { + entity.insert(Inhabited { + population: planet.population, + }); + } + } + + // ── Asteroid belts ────────────────────────────────────────────────────── + // Belt entity = no mesh, just the logical POI (Mineable, Identifiable, + // BoundingVolume for selection). Individual rock meshes are spawned as + // children inheriting the (currently static) belt Transform. + for belt in &contents.belts { + let mid = (belt.inner_orbit + belt.outer_orbit) * 0.5; + let half_width = ((belt.outer_orbit - belt.inner_orbit) * 0.5).max(0.05); + parent + .spawn(( + Orbital { + semi_major_axis: mid, + period: 0.0, + phase: 0.0, + parent: star_entity, + }, + // Outer extent of the belt ring from the star (belt entity + // sits at the star; rocks reach out to `outer_orbit`). + BoundingVolume { + radius: mid + half_width, + }, + Mineable { + yield_remaining: belt.yield_remaining, + }, + Exhaustible::default(), + Identifiable { + id: format!("{}-belt", sys.id), + display_name: belt.name.clone(), + classification: Classification::Celestial, + }, + Transform::default(), + )) + .with_children(|belt_parent| { + for rock in &belt.asteroids { + belt_parent.spawn(( + Mesh3d(assets.asteroid_mesh.clone()), + MeshMaterial3d(assets.asteroid_materials[rock.material_index].clone()), + Transform::from_translation(rock.position) + .with_scale(Vec3::splat(rock.scale)) + .with_rotation(rock.rotation), + )); + } + }); + } + + // ── Stations ──────────────────────────────────────────────────────────── + for station in &contents.stations { + let local = orbital_position(station.orbit, station.phase); + let cube_size = 0.16; + parent.spawn(( + Station, + Orbital { + semi_major_axis: station.orbit, + period: 0.0, + phase: station.phase, + parent: star_entity, + }, + BoundingVolume { radius: cube_size }, + Inhabited { + population: station.population, + }, + Identifiable { + id: format!("{}-{}", sys.id, slug(&station.name)), + display_name: station.name.clone(), + classification: Classification::Structure, + }, + Mesh3d(assets.station_mesh.clone()), + MeshMaterial3d(assets.station_material.clone()), + Transform::from_translation(local).with_scale(Vec3::splat(cube_size)), + )); + } + + // ── Anomalies ─────────────────────────────────────────────────────────── + for anomaly in &contents.anomalies { + let local = orbital_position(anomaly.orbit, anomaly.phase); + let radius = 0.14; + parent.spawn(( + Anomaly, + Orbital { + semi_major_axis: anomaly.orbit, + period: 0.0, + phase: anomaly.phase, + parent: star_entity, + }, + BoundingVolume { radius }, + SensorSignature { + signature_radius: radius, + signal_type: anomaly.signal_kind, + }, + Scannable { + difficulty: anomaly.difficulty, + requires_probe: anomaly.requires_probe, + }, + EventTimer { + remaining: anomaly.remaining, + }, + Identifiable { + id: format!("{}-{}", sys.id, slug(&anomaly.name)), + display_name: anomaly.name.clone(), + classification: Classification::Anomaly, + }, + Mesh3d(assets.anomaly_mesh.clone()), + MeshMaterial3d(assets.anomaly_material.clone()), + Transform::from_translation(local).with_scale(Vec3::splat(radius)), + )); + } + + // ── Stargates ─────────────────────────────────────────────────────────── + // Visual scale of the stargate ring marker — matches the TS reference's + // `markerSize × 1.25 ≈ 0.4-0.55` range. The unit-torus mesh is scaled to + // this size at the gate's orbital position, NOT a giant ring around the + // star. Final torus: major radius ≈ 0.45, tube thickness ≈ 0.08. + const STARGATE_MARKER_SCALE: f32 = 0.45; + for gate in &contents.stargates { + let local = orbital_position(gate.orbit, gate.phase); + parent.spawn(( + Stargate, + Orbital { + semi_major_axis: gate.orbit, + period: 0.0, + phase: gate.phase, + parent: star_entity, + }, + // Outer extent of the marker ring (major + minor tube). + BoundingVolume { + radius: STARGATE_MARKER_SCALE * (1.0 + 0.18), + }, + Transit { + destination: Some(gate.destination_id.clone()), + lifetime: None, + }, + Identifiable { + id: format!("{}-gate-{}", sys.id, slug(&gate.destination_name)), + display_name: gate.name.clone(), + classification: Classification::Structure, + }, + Mesh3d(assets.stargate_mesh.clone()), + MeshMaterial3d(assets.stargate_material.clone()), + // Small ring marker at the gate's orbital position. Scaling by + // STARGATE_MARKER_SCALE yields a compact jump-gate ring that + // reads as a distinct POI without dominating the system. + Transform::from_translation(local) + .with_scale(Vec3::splat(STARGATE_MARKER_SCALE)) + .with_rotation(Quat::from_rotation_x(std::f32::consts::FRAC_PI_2)), + )); + } + + // ── Gas clouds ────────────────────────────────────────────────────────── + for cloud in &contents.gas_clouds { + let local = orbital_position(cloud.orbit, cloud.phase); + let radius = 0.25; + parent.spawn(( + GasCloud, + Orbital { + semi_major_axis: cloud.orbit, + period: 0.0, + phase: cloud.phase, + parent: star_entity, + }, + BoundingVolume { radius }, + Harvestable { + resource: cloud.gas_kind, + flow_rate: cloud.flow_rate, + }, + Exhaustible::default(), + Identifiable { + id: format!("{}-{}", sys.id, slug(&cloud.name)), + display_name: cloud.name.clone(), + classification: Classification::Anomaly, + }, + Mesh3d(assets.gas_mesh.clone()), + MeshMaterial3d(assets.gas_material(cloud.gas_kind)), + Transform::from_translation(local).with_scale(Vec3::splat(radius)), + )); + } +} + +/// Initial local-space position of an orbiting body at `t=0`. +/// Orbits are in the XZ plane (Y up). +fn orbital_position(orbit: f32, phase: f32) -> Vec3 { + Vec3::new(phase.cos() * orbit, 0.0, phase.sin() * orbit) +} + +/// Build the single shared asteroid mesh: a subdivided icosphere with +/// per-vertex radial jitter, giving it an irregular rocky silhouette. +/// +/// One mesh is shared across every asteroid instance (one asset handle for +/// thousands of entities). Variation comes from per-entity Transform (scale, +/// rotation) and material pick. Deterministic LCG seed so the shared shape is +/// stable across runs. +fn build_asteroid_mesh(meshes: &mut Assets) -> Handle { + let mut mesh = Sphere::new(1.0).mesh().ico(2).unwrap(); + + // Numerical Recipes LCG — fast, deterministic, no extra crate deps. + let mut state: u32 = 0xcafe_1234; + let next_unit = |state: &mut u32| -> f32 { + *state = state.wrapping_mul(1664525).wrapping_add(1013904223); + (*state as f32) / (u32::MAX as f32) * 2.0 - 1.0 // [-1, 1] + }; + + if let Some(VertexAttributeValues::Float32x3(positions)) = + mesh.attribute_mut(Mesh::ATTRIBUTE_POSITION) + { + for pos in positions.iter_mut() { + // Per-vertex radial jitter (±25%) — keeps the silhouette convex + // and irregular without degenerating into a noise blob. + let len = (pos[0] * pos[0] + pos[1] * pos[1] + pos[2] * pos[2]).sqrt(); + if len > 1e-6 { + let factor = 1.0 + next_unit(&mut state) * 0.25; + pos[0] *= factor; + pos[1] *= factor; + pos[2] *= factor; + } + } + } + + meshes.add(mesh) +} + +/// Greyscale / brown palette for asteroid instances. Indexed by +/// [`GeneratedAsteroid::material_index`] which is sampled per-rock from +/// `0..ASTEROID_COLOR_COUNT`. +fn asteroid_material_palette( + materials: &mut Assets, +) -> Vec> { + const PALETTE: [[f32; 3]; ASTEROID_COLOR_COUNT] = [ + [0.50, 0.46, 0.40], // warm grey + [0.42, 0.40, 0.38], // neutral grey + [0.55, 0.50, 0.43], // tan + [0.38, 0.36, 0.40], // cool grey + [0.48, 0.42, 0.36], // brown-grey + ]; + PALETTE + .iter() + .map(|c| unlit_material(materials, *c)) + .collect() +} + +fn unlit_material( + materials: &mut Assets, + [r, g, b]: [f32; 3], +) -> Handle { + materials.add(StandardMaterial { + base_color: Color::srgb(r, g, b), + emissive: LinearRgba::new(r * 1.4, g * 1.4, b * 1.4, 1.0), + unlit: true, + ..default() + }) +} + +fn translucent_material( + materials: &mut Assets, + [r, g, b]: [f32; 3], +) -> Handle { + materials.add(StandardMaterial { + base_color: Color::srgba(r, g, b, 0.45), + emissive: LinearRgba::new(r * 1.2, g * 1.2, b * 1.2, 0.6), + unlit: true, + alpha_mode: AlphaMode::Blend, + ..default() + }) +} + +/// Slugify a display name for use as part of an [`Identifiable::id`]: +/// lowercase, spaces → hyphens. Good enough for stable, human-readable IDs. +fn slug(s: &str) -> String { + s.chars() + .map(|c| { + if c.is_whitespace() { + '-' + } else { + c.to_ascii_lowercase() + } + }) + .collect() +} + +// ─── Tests ────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use rand::SeedableRng; + + fn ctx<'a>(id: &'a str, name: &'a str, security: f32) -> SystemContext<'a> { + SystemContext { + id, + name, + faction: "Concord", + security, + is_core: false, + } + } + + #[test] + fn generation_is_deterministic_for_same_seed() { + let mut rng_a = StdRng::seed_from_u64(42); + let mut rng_b = StdRng::seed_from_u64(42); + let c = ctx("g-0", "COR-100", 0.9); + let a = generate_system_contents(&mut rng_a, &c); + let b = generate_system_contents(&mut rng_b, &c); + assert_eq!(a.planets.len(), b.planets.len()); + assert_eq!( + a.planets.first().map(|p| p.name.clone()), + b.planets.first().map(|p| p.name.clone()) + ); + } + + #[test] + fn low_sec_systems_spawn_anomalies() { + let mut rng = StdRng::seed_from_u64(7); + let c = ctx("g-1", "XYZ-101", 0.1); + let contents = generate_system_contents(&mut rng, &c); + assert!( + !contents.anomalies.is_empty(), + "low-sec should have anomalies" + ); + } + + #[test] + fn high_sec_systems_spawn_no_anomalies() { + let mut rng = StdRng::seed_from_u64(7); + let c = ctx("g-2", "COR-101", 0.95); + let contents = generate_system_contents(&mut rng, &c); + assert!( + contents.anomalies.is_empty(), + "high-sec should have no anomalies" + ); + } + + #[test] + fn planet_orbits_are_strictly_increasing() { + let mut rng = StdRng::seed_from_u64(99); + let c = ctx("g-3", "ABC-100", 0.8); + let contents = generate_system_contents(&mut rng, &c); + for w in contents.planets.windows(2) { + assert!(w[1].orbit > w[0].orbit, "orbits must increase"); + } + } + + #[test] + fn planet_type_colors_are_distinct() { + for (i, a) in PlanetType::ALL.iter().enumerate() { + for b in &PlanetType::ALL[(i + 1)..] { + assert_ne!(a.color(), b.color(), "{:?} and {:?} share a color", a, b); + } + } + } + + #[test] + fn stargate_orbit_phase_is_deterministic() { + let a = stargate_orbit_phase(0, "g-0", 5.0); + let b = stargate_orbit_phase(0, "g-0", 5.0); + assert_eq!(a, b); + } + + #[test] + fn stargate_orbit_phase_advances_with_index() { + let a = stargate_orbit_phase(0, "g-0", 5.0); + let b = stargate_orbit_phase(1, "g-0", 5.0); + assert_ne!(a.1, b.1); + } + + #[test] + fn stargate_orbit_respects_system_max() { + // Sparse system: max_orbit below the min → gates sit at min. + let (orbit_sparse, _) = stargate_orbit_phase(0, "g-0", 2.0); + assert!(orbit_sparse >= STARGATE_MIN_ORBIT); + + // Busy system: max_orbit pushes gates beyond the min. + let (orbit_busy, _) = stargate_orbit_phase(0, "g-0", 12.0); + assert!((orbit_busy - (12.0 + STARGATE_PADDING)).abs() < 1e-6); + } + + #[test] + fn max_orbit_returns_outermost_poi() { + let mut c = SystemContents::default(); + assert_eq!(c.max_orbit(), 0.0); + c.planets.push(GeneratedPlanet { + name: "P1".into(), + planet_type: PlanetType::Rocky, + orbit: 3.0, + period: 1.0, + phase: 0.0, + radius: 0.1, + mass: 1.0, + population: 0, + }); + c.stations.push(GeneratedStation { + name: "S1".into(), + orbit: 6.0, + phase: 0.0, + population: 0, + }); + assert_eq!(c.max_orbit(), 6.0); + c.belts.push(GeneratedAsteroidBelt { + name: "B1".into(), + inner_orbit: 7.0, + outer_orbit: 8.5, + yield_remaining: 0.0, + asteroids: Vec::new(), + }); + assert_eq!(c.max_orbit(), 8.5); + } + + #[test] + fn stargates_pair_across_connections() { + let summaries = vec![ + SystemSummary { + id: "g-0".into(), + name: "Sol".into(), + }, + SystemSummary { + id: "g-1".into(), + name: "Amarr".into(), + }, + ]; + let mut contents = vec![SystemContents::default(), SystemContents::default()]; + generate_stargates(&summaries, &mut contents, &[(0, 1)]); + assert_eq!(contents[0].stargates.len(), 1); + assert_eq!(contents[1].stargates.len(), 1); + assert_eq!(contents[0].stargates[0].destination_id, "g-1"); + assert_eq!(contents[1].stargates[0].destination_id, "g-0"); + } + + #[test] + fn romanize_handles_basics() { + assert_eq!(romanize(1), "I"); + assert_eq!(romanize(4), "IV"); + assert_eq!(romanize(9), "IX+"); + } + + #[test] + fn slug_replaces_spaces_and_lowercases() { + assert_eq!(slug("Jita Hub"), "jita-hub"); + assert_eq!(slug("COR-100 II"), "cor-100-ii"); + } + + #[test] + fn system_hash_is_stable() { + assert_eq!(system_hash("g-0"), system_hash("g-0")); + assert_ne!(system_hash("g-0"), system_hash("g-1")); + } + + #[test] + fn asteroid_generation_is_deterministic() { + let mut rng_a = StdRng::seed_from_u64(123); + let mut rng_b = StdRng::seed_from_u64(123); + let a = generate_asteroids(&mut rng_a, 5.0, 6.0); + let b = generate_asteroids(&mut rng_b, 5.0, 6.0); + assert_eq!(a.len(), b.len()); + assert_eq!(a.len(), ASTEROIDS_PER_BELT); + assert_eq!(a[0].position, b[0].position); + assert_eq!(a[0].scale, b[0].scale); + assert_eq!(a[0].material_index, b[0].material_index); + } + + #[test] + fn asteroid_positions_stay_inside_annulus() { + let mut rng = StdRng::seed_from_u64(7); + let inner = 5.0_f32; + let outer = 6.0_f32; + for rock in generate_asteroids(&mut rng, inner, outer) { + let horizontal = Vec3::new(rock.position.x, 0.0, rock.position.z).length(); + assert!( + horizontal >= inner && horizontal <= outer, + "rock at {:?} outside annulus [{}, {}]", + rock.position, + inner, + outer + ); + } + } +} diff --git a/apps/game/src/gameplay/galaxy_creation/mod.rs b/apps/game/src/gameplay/galaxy_creation/mod.rs index 73a2e1e..64809b5 100644 --- a/apps/game/src/gameplay/galaxy_creation/mod.rs +++ b/apps/game/src/gameplay/galaxy_creation/mod.rs @@ -6,10 +6,14 @@ //! live in [`super::ui`]. mod axes; +mod contents; mod params; +mod poi; mod selection; mod ui; +pub use poi::*; + use bevy::prelude::*; use rand::rngs::StdRng; use rand::{Rng, SeedableRng}; @@ -17,6 +21,7 @@ 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}; @@ -26,8 +31,14 @@ 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( + OnEnter(AppState::GalaxyCreation), + (setup_galaxy_scene, ui::setup_galaxy_ui), + ) + .add_systems( + OnExit(AppState::GalaxyCreation), + (despawn_galaxy_creation, reset_selection), + ) .add_systems( Update, ( @@ -59,15 +70,35 @@ pub struct GalaxyCreationSpawned; #[derive(Component)] pub struct GalaxyScene; -/// A star system in the procedural galaxy. +/// 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])] = &[ @@ -86,16 +117,70 @@ fn setup_galaxy_scene( mut materials: ResMut>, params: Res, ) { - let systems = generate_galaxy(¶ms); - spawn_galaxy_scene(&mut commands, &mut meshes, &mut materials, &systems); + 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`. -fn generate_galaxy(params: &GalaxyParams) -> Vec { +/// `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. @@ -129,8 +214,16 @@ fn generate_galaxy(params: &GalaxyParams) -> Vec { } 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 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 { @@ -147,7 +240,11 @@ fn generate_galaxy(params: &GalaxyParams) -> Vec { 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) + ( + angle.cos() * r, + (rng.gen::() - 0.5) * 20.0, + angle.sin() * r, + ) }; let candidate = Vec3::new(x, y, z); final_radius = r; @@ -170,8 +267,7 @@ fn generate_galaxy(params: &GalaxyParams) -> Vec { } } - let security = - ((1.0 - (final_radius / params.size) * 2.0) * 100.0).round() / 100.0; + let security = ((1.0 - (final_radius / params.size) * 2.0) * 100.0).round() / 100.0; let name = if core { format!("COR-{}", 100 + index) } else { @@ -186,6 +282,7 @@ fn generate_galaxy(params: &GalaxyParams) -> Vec { faction_index, color, security, + is_core: core, }); } @@ -200,9 +297,12 @@ struct GeneratedSystem { 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. - #[allow(dead_code)] 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( @@ -210,10 +310,19 @@ fn spawn_galaxy_scene( meshes: &mut Assets, materials: &mut Assets, systems: &[GeneratedSystem], + contents: &[SystemContents], + connections: &[(usize, usize)], ) { - // Shared meshes (Bevy 0.16 required-components model — no bundle needed). - let star_mesh = meshes.add(Sphere::new(1.5).mesh().ico(3).unwrap()); - let core_star_mesh = meshes.add(Sphere::new(3.0).mesh().ico(4).unwrap()); + 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() @@ -233,45 +342,89 @@ fn spawn_galaxy_scene( ..default() }); - let connections = build_connections(systems); + // 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, - )) + .spawn((Transform::default(), GalaxyScene, GalaxyCreationSpawned)) .with_children(|parent| { // XYZ reference axes through the origin. axes::spawn_axes(parent, meshes, materials); - // Star systems. - for sys in systems { - let (mesh, material) = if sys.faction_index == 0 && sys.position.length() < 40.0 - { + // 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()) + (core_star_mesh.clone(), faction_materials[0].clone(), 1.1) } else { - (star_mesh.clone(), faction_materials[sys.faction_index].clone()) + ( + star_mesh.clone(), + faction_materials[sys.faction_index].clone(), + 1.0, + ) }; - parent.spawn(( - Mesh3d(mesh), - MeshMaterial3d(material), - Transform::from_translation(sys.position), - StarSystem { - id: sys.id.clone(), - name: sys.name.clone(), - faction: sys.faction, - security: sys.security, - }, - )); + + 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 from = systems[*a].position; + let to = systems[*b].position; let delta = to - from; let length = delta.length(); if length < 0.01 { @@ -371,6 +524,13 @@ fn regenerate_galaxy_on_param_change( commands.entity(entity).despawn(); } - let systems = generate_galaxy(¶ms); - spawn_galaxy_scene(&mut commands, &mut meshes, &mut materials, &systems); + let (systems, contents, connections) = generate_galaxy(¶ms); + spawn_galaxy_scene( + &mut commands, + &mut meshes, + &mut materials, + &systems, + &contents, + &connections, + ); } diff --git a/apps/game/src/gameplay/galaxy_creation/poi.rs b/apps/game/src/gameplay/galaxy_creation/poi.rs new file mode 100644 index 0000000..d361490 --- /dev/null +++ b/apps/game/src/gameplay/galaxy_creation/poi.rs @@ -0,0 +1,488 @@ +//! Points of interest (POIs): components, marker tags, and events. +//! +//! In Bevy, entity "capabilities" are expressed as **data components** that +//! systems query — not methods on objects. This file defines the data layout; +//! behavior lives in systems (`mining_system`, `scan_system`, …) that read and +//! write these components and react to events. +//! +//! ## Anatomy of a POI +//! +//! Every POI entity gets: +//! +//! 1. **One variant marker** (e.g. [`Planet`], [`AsteroidBelt`], [`Wormhole`]). +//! Markers use Bevy 0.16 `#[require(...)]` to declare the components they +//! are composed of. Bevy enforces this at spawn time — you can't make a +//! `Planet` without also providing `Orbital`, `Massive`, etc. +//! 2. **All required components** declared by the marker, either with explicit +//! values or `Default::default()`. +//! 3. **Optional capability components** added dynamically — e.g. a `Planet` +//! that gets colonized gains [`Inhabited`]; a [`Wormhole`] that destabilizes +//! loses [`Transit`]. +//! +//! World-space position comes from Bevy's built-in `Transform`/`GlobalTransform` +//! — there is no separate `Positioned` component. +//! +//! ## Why components, not traits +//! +//! - **Queries are how Bevy dispatches**: `Query<&Mineable, With>` +//! is the equivalent of a downcast, but vectorized, cache-friendly, and +//! parallelizable across the schedule. +//! - **Composition over inheritance**: a POI's behavior is the sum of the +//! components attached to it. Adding/removing a capability at runtime is +//! `commands.insert(...)` / `commands.remove::<...>()`. +//! - **State changes flow through events** ([`Mined`], [`Scanned`], [`Depleted`], +//! …). Systems react rather than invoke. Multiple systems can observe the +//! same event (UI, audio, mission tracking, achievements) without coupling. +//! - **`Reflect` enables save/load, hot-reload, and editor inspectors** for +//! free. Trait objects can't do this without manual plumbing. + +use bevy::prelude::*; +use bevy::reflect::Reflect; +use std::time::Duration; + +// ─── Supporting enums ─────────────────────────────────────────────────────── + +/// EVE-style sensor quartet — drives which probe type can resolve a signal. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Reflect, Default)] +pub enum SignalKind { + #[default] + Infrared, + Gravimetric, + Radar, + Magnetometric, +} + +/// Difficulty of resolving a scannable target with probes. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Reflect, Default)] +pub enum Difficulty { + #[default] + Trivial, + Easy, + Moderate, + Hard, + /// Requires multi-probe triangulation. + Expert, +} + +/// High-level classification shown on HUD / sensor panels. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Reflect, Default)] +pub enum Classification { + #[default] + Celestial, + Structure, + Anomaly, + Signature, + Ship, + Debris, +} + +/// Damage kind for environmental hazards. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Reflect, Default)] +pub enum DamageKind { + #[default] + Radiation, + Emp, + Heat, + Kinetic, +} + +/// Harvestable gas varieties. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Reflect, Default)] +pub enum GasKind { + #[default] + Hydrogen, + Helium, + Nitrogen, + /// Rare/anomalous gas — typically only found in anomalies. + Exotic, +} + +/// What triggers an NPC spawn at a POI. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Reflect, Default)] +pub enum SpawnTrigger { + /// Player crosses the POI's "aggro bubble". + #[default] + OnPlayerEnter, + /// World tick — happens regardless of player presence. + WorldTick, + /// Triggered by an event or mission script. + EventDriven, +} + +/// Kind of interaction a player can initiate with an [`Interactable`] POI. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Reflect, Default)] +pub enum InteractionKind { + #[default] + Dialogue, + Lore, + Decode, + Investigate, +} + +// ─── Placeholder types (promote to real modules when consumers arrive) ────── + +pub type SystemId = String; + +// ─── 1. Spatial / physical ────────────────────────────────────────────────── + +/// A body that orbits a parent entity (star, planet, moon, belt center). +#[derive(Component, Debug, Clone, Reflect)] +#[reflect(Component)] +pub struct Orbital { + pub semi_major_axis: f32, + pub period: f32, + pub phase: f32, + /// Parent entity — the body being orbited. Override + /// `Default::default()` (`Entity::PLACEHOLDER`) at spawn time. + pub parent: Entity, +} + +impl Default for Orbital { + fn default() -> Self { + Self { + semi_major_axis: 0.0, + period: 0.0, + phase: 0.0, + parent: Entity::PLACEHOLDER, + } + } +} + +/// Spherical bound — used for collision, camera framing, sensor-blip scaling. +#[derive(Component, Debug, Clone, Reflect, Default)] +#[reflect(Component)] +pub struct BoundingVolume { + pub radius: f32, +} + +/// Massive body — drives gravity wells and (with [`MassLock`]) warp disruption. +#[derive(Component, Debug, Clone, Reflect, Default)] +#[reflect(Component)] +pub struct Massive { + pub mass: f32, +} + +// ─── 2. Detection / sensors ───────────────────────────────────────────────── + +/// Passive sensor profile of a POI. +#[derive(Component, Debug, Clone, Reflect, Default)] +#[reflect(Component)] +pub struct SensorSignature { + pub signature_radius: f32, + pub signal_type: SignalKind, +} + +/// A POI that can be pinned down with scan probes. +#[derive(Component, Debug, Clone, Reflect, Default)] +#[reflect(Component)] +pub struct Scannable { + pub difficulty: Difficulty, + /// If true, shipboard sensors aren't enough — a probe must be launched. + pub requires_probe: bool, +} + +/// Stable identity + display metadata for HUDs and target lists. +#[derive(Component, Debug, Clone, Reflect, Default)] +#[reflect(Component)] +pub struct Identifiable { + pub id: String, + pub display_name: String, + pub classification: Classification, +} + +// ─── 3. Navigation / approach ─────────────────────────────────────────────── + +/// A POI the ship can warp to. +#[derive(Component, Debug, Clone, Reflect, Default)] +#[reflect(Component)] +pub struct WarpTarget { + pub warp_distance: f32, + pub landing_radius: f32, +} + +/// A POI the ship can dock with. +#[derive(Component, Debug, Clone, Reflect, Default)] +#[reflect(Component)] +pub struct Dockable { + pub dock_range: f32, +} + +/// A POI that moves the ship to another system (wormhole, jump gate). +#[derive(Component, Debug, Clone, Reflect, Default)] +#[reflect(Component)] +pub struct Transit { + pub destination: Option, + /// `None` = permanent. `Some(d)` = expires after `d`. + pub lifetime: Option, +} + +/// Massive enough to prevent warp within a radius. Stars, gas giants, etc. +#[derive(Component, Debug, Clone, Reflect, Default)] +#[reflect(Component)] +pub struct MassLock { + pub radius: f32, +} + +// ─── 4. Resource / harvesting ─────────────────────────────────────────────── + +/// Solid resource deposit — ore in asteroids, minerals in planetoids. +/// +/// Depletion happens via [`Mined`] events in a system, not via a method. +#[derive(Component, Debug, Clone, Reflect, Default)] +#[reflect(Component)] +pub struct Mineable { + //Add loot table later + pub yield_remaining: f32, +} + +/// Gaseous resource — scoopable by harvesters. +#[derive(Component, Debug, Clone, Reflect, Default)] +#[reflect(Component)] +pub struct Harvestable { + pub resource: GasKind, + /// Units per second a harvester can extract under ideal conditions. + pub flow_rate: f32, +} + +/// Wreck or ruin that can be stripped for components. +#[derive(Component, Debug, Clone, Reflect, Default)] +#[reflect(Component)] +pub struct Salvageable { + // add loot table later + pub condition: f32, +} + +/// A POI whose resources can run out. Usually composed with one of the +/// harvesting components above. +#[derive(Component, Debug, Clone, Reflect, Default)] +#[reflect(Component)] +pub struct Exhaustible { + pub depleted: bool, + /// `None` = never respawns. `Some(d)` = replenished after `d`. + pub respawn_after: Option, +} + +// ─── 5. Hazards ───────────────────────────────────────────────────────────── + +/// Deals passive damage to anything within range. +#[derive(Component, Debug, Clone, Reflect, Default)] +#[reflect(Component)] +pub struct Hazardous { + pub damage_per_sec: f32, + pub damage_type: DamageKind, +} + +/// Reduces effective sensor / probe range for nearby ships. +#[derive(Component, Debug, Clone, Reflect, Default)] +#[reflect(Component)] +pub struct SensorJamming { + /// Strength of the jamming field. Higher = shorter effective range. + pub strength: f32, +} + +/// Tag component — entity is currently cloaked. Pair with [`CloakField`] for +/// the decloak radius. +#[derive(Component, Debug, Clone, Copy, Reflect, Default)] +#[reflect(Component)] +pub struct Cloaked; + +#[derive(Component, Debug, Clone, Reflect, Default)] +#[reflect(Component)] +pub struct CloakField { + /// Distance at which the cloak flickers / drops. + pub decloak_radius: f32, +} + +// ─── 6. Habitability / story ──────────────────────────────────────────────── + +/// Emits light — typically a star, used to compute habitable zones for +/// orbiting planets. +#[derive(Component, Debug, Clone, Reflect, Default)] +#[reflect(Component)] +pub struct Luminosity { + pub value: f32, +} + +/// Tag — this body can support life. +#[derive(Component, Debug, Clone, Copy, Reflect, Default)] +#[reflect(Component)] +pub struct Habitable; + +/// Tag — this body currently hosts a population. +#[derive(Component, Debug, Clone, Reflect, Default)] +#[reflect(Component)] +pub struct Inhabited { + pub population: u32, +} + +/// Player can trigger a non-combat interaction (dialogue, lore, decode, …). +#[derive(Component, Debug, Clone, Reflect, Default)] +#[reflect(Component)] +pub struct Interactable { + pub kind: InteractionKind, +} + +// ─── 7. World simulation ──────────────────────────────────────────────────── + +/// Spawns hostile NPCs under some condition. +#[derive(Component, Debug, Clone, Reflect, Default)] +#[reflect(Component)] +pub struct SpawnsHostiles; //implement later + +/// A POI with a finite lifespan — anomaly, wormhole, world event. +#[derive(Component, Debug, Clone, Reflect, Default)] +#[reflect(Component)] +pub struct EventTimer { + pub remaining: Duration, +} + +// ─── POI variant markers ──────────────────────────────────────────────────── +// +// Replace the old `PointOfInterest` enum. Each marker declares the components +// it requires via `#[require(...)]` — Bevy enforces composition at spawn time. +// +// Spawn example: +// +// commands.spawn(( +// Planet, +// Orbital { semi_major_axis: 5.0, period: 365.0, phase: 0.0, parent: sun }, +// BoundingVolume { radius: 6371.0 }, +// Massive { mass: 5.97e24 }, +// Identifiable { id: "sol-3".into(), display_name: "Earth".into(), ..default() }, +// )); +// +// `WarpTarget`, `MassLock`, etc. are auto-inserted via `Default` since the +// marker requires them. +// +// Note: stars are NOT POIs — they are the anchor body of a star system and +// live next to [`super::StarSystem`] in `mod.rs`. + +#[derive(Component, Debug, Clone, Copy, Reflect)] +#[reflect(Component)] +#[require(Orbital, Massive, BoundingVolume, WarpTarget, MassLock, Identifiable)] +pub struct Planet; + +#[derive(Component, Debug, Clone, Copy, Reflect)] +#[reflect(Component)] +#[require( + Orbital, + BoundingVolume, + WarpTarget, + Mineable, + Exhaustible, + Identifiable +)] +pub struct AsteroidBelt; + +#[derive(Component, Debug, Clone, Copy, Reflect)] +#[reflect(Component)] +#[require(BoundingVolume, WarpTarget, Harvestable, Exhaustible, Identifiable)] +pub struct GasCloud; + +#[derive(Component, Debug, Clone, Copy, Reflect)] +#[reflect(Component)] +#[require( + BoundingVolume, + WarpTarget, + SensorSignature, + Scannable, + EventTimer, + Identifiable +)] +pub struct Anomaly; + +#[derive(Component, Debug, Clone, Copy, Reflect)] +#[reflect(Component)] +#[require( + BoundingVolume, + WarpTarget, + Transit, + SensorSignature, + EventTimer, + Identifiable +)] +pub struct Wormhole; + +/// A constructed jump gate — paired with another stargate in the destination +/// system. Distinct from [`Wormhole`] (which is a natural, transient phenomenon). +#[derive(Component, Debug, Clone, Copy, Reflect)] +#[reflect(Component)] +#[require(BoundingVolume, WarpTarget, Transit, Identifiable)] +pub struct Stargate; + +/// A functioning, inhabited station (docking, market, missions). +/// Distinct from [`DerelictStation`] which is a wreck you can salvage. +#[derive(Component, Debug, Clone, Copy, Reflect)] +#[reflect(Component)] +#[require(BoundingVolume, WarpTarget, Dockable, Identifiable)] +pub struct Station; + +#[derive(Component, Debug, Clone, Copy, Reflect)] +#[reflect(Component)] +#[require( + BoundingVolume, + WarpTarget, + SensorSignature, + Scannable, + Salvageable, + Identifiable +)] +pub struct DerelictStation; + +// ─── Events ───────────────────────────────────────────────────────────────── +// +// State changes that should be observed by other systems. Prefer events over +// direct mutation when: +// - Multiple systems might react (UI, audio, mission tracking, achievements). +// - You want to decouple the producer from the consumer. + +/// A miner extracted `amount` from `deposit`. Mining systems read this and +/// mutate `&mut Mineable`; UI plays the yield popup; audio plays the laser hit. +#[derive(Event, Debug, Clone)] +pub struct Mined { + pub deposit: Entity, + pub miner: Entity, + pub amount: f32, +} + +#[derive(Event, Debug, Clone)] +pub struct Harvested { + pub source: Entity, + pub harvester: Entity, + pub kind: GasKind, + pub amount: f32, +} + +#[derive(Event, Debug, Clone)] +pub struct Salvaged { + pub target: Entity, + pub salvager: Entity, +} + +/// A scan completed successfully — the target is now revealed to the scanner. +#[derive(Event, Debug, Clone)] +pub struct Scanned { + pub target: Entity, + pub scanner: Entity, +} + +/// A resource deposit (or anomaly, or wormhole) has been fully consumed. +/// Systems react by clearing blips, queueing respawn timers, etc. +#[derive(Event, Debug, Clone)] +pub struct Depleted { + pub entity: Entity, +} + +#[derive(Event, Debug, Clone)] +pub struct Docked { + pub ship: Entity, + pub station: Entity, +} + +/// A ship used a [`Transit`] POI (wormhole, jump gate) to change systems. +#[derive(Event, Debug, Clone)] +pub struct Transited { + pub traveler: Entity, + pub gate: Entity, + pub destination: SystemId, +}