feat(galaxy): multi-disk galaxy generation with tab-based UI

- Add multi-disk support (up to 4 independent disk layers) with per-disk
  arms, twist, tilt, rotation, inner/outer radius
- Add polar beams (top/bottom) with taper, gravity, thickness controls
- Implement tab-based disk selector UI with add/remove disk
- Add Randomize Settings button for full param randomization
- Add Regenerate (reseed) and Center View buttons
- Add per-system contents generation (planets, belts, stations, anomalies,
  gas clouds, stargates) with orbital mechanics
- Refactor control panel into scrollable sections with value display
This commit is contained in:
2026-06-10 00:11:30 -04:00
parent f83a0c5e59
commit 80060be5ba
3 changed files with 821 additions and 284 deletions

View File

@@ -45,6 +45,7 @@ impl Plugin for GalaxyPlugin {
Update,
(
escape_to_main_menu,
ui::rebuild_scroll_content,
ui::param_button_handler,
ui::refresh_control_panel_values,
ui::scroll_control_panel,
@@ -181,21 +182,22 @@ fn generate_galaxy(
(systems, contents, connections)
}
/// Position-only galaxy generation. Composes three structural layers —
/// a [`CoreParams`] cluster, a [`DiskParams`] of horizontal spiral arms,
/// and two [`BeamParams`] columns along ±Y — into a single flat list of
/// systems ready for the connection graph and the spawner.
/// Position-only galaxy generation. Composes multiple structural layers —
/// a [`CoreParams`] cluster, up to [`MAX_DISKS`] independent [`DiskParams`]
/// spiral disks, and two [`BeamParams`] columns along ±Y — into a single
/// flat list of systems ready for the connection graph and the spawner.
///
/// Each layer owns its own system count; the total galaxy population is
/// `core.count + disk.count + beam_top.count + beam_bottom.count`. System
/// indices are global across all layers (used for `g-{n}` IDs and name
/// suffixes) so the connection graph and POI generator see one consistent
/// vector.
/// `core.count + Σ disks[].count + beam_top.count + beam_bottom.count`.
/// System indices are global across all layers (used for `g-{n}` IDs and
/// name suffixes) so the connection graph and POI generator see one
/// consistent vector.
fn generate_system_positions(params: &GalaxyParams, rng: &mut StdRng) -> Vec<GeneratedSystem> {
// Total budget — pre-allocation only; each pass appends as many systems
// as it manages to place.
let disk_total: usize = params.disks.iter().map(|d| d.count).sum();
let total = params.core.count
+ params.disk.count
+ disk_total
+ params.beam_top.count
+ params.beam_bottom.count;
let mut systems: Vec<GeneratedSystem> = Vec::with_capacity(total);
@@ -209,14 +211,19 @@ fn generate_system_positions(params: &GalaxyParams, rng: &mut StdRng) -> Vec<Gen
let mut next_index = 0usize;
generate_core(&mut systems, rng, &params.core, params.size, base_spacing, &mut next_index);
for (disk_index, disk) in params.disks.iter().enumerate() {
generate_disk(
&mut systems,
rng,
&params.disk,
disk,
params.size,
base_spacing,
&mut next_index,
disk_index,
);
}
if params.beam_top.enabled {
generate_beam(
&mut systems,
@@ -302,9 +309,10 @@ fn generate_core(
}
}
/// Generate the horizontal disk of spiral arms in the XZ plane. Faithful to
/// the original generator's density bias (`pow(0.62)` → packed near origin)
/// and arm twist.
/// Generate one disk layer of spiral arms. Faithful to the original
/// generator's density bias (`pow(0.62)` → packed near origin) and arm twist.
/// Supports tilt (rotation around X, capped at 45°), Y-axis rotation offset,
/// and independent inner/outer radii.
fn generate_disk(
systems: &mut Vec<GeneratedSystem>,
rng: &mut StdRng,
@@ -312,24 +320,53 @@ fn generate_disk(
galaxy_size: f32,
base_spacing: f32,
next_index: &mut usize,
disk_index: usize,
) {
let arm_count = disk.arms.max(1);
// Effective outer radius: explicit value if set, else galaxy size.
let outer = if disk.outer_radius > 0.0 {
disk.outer_radius
} else {
galaxy_size
};
let inner = disk.inner_radius;
let span = (outer - inner).max(1.0);
// Pre-compute the rotation quaternion for this disk's tilt + Y rotation.
// Tilt is applied first (around X), then the Y rotation offset.
let disk_rotation = if disk.tilt.abs() > 0.001 || disk.rotation_offset.abs() > 0.001 {
let tilt = Quat::from_rotation_x(disk.tilt);
let yaw = Quat::from_rotation_y(disk.rotation_offset);
Some(yaw * tilt)
} else {
None
};
for i in 0..disk.count {
let arm = (i as u32) % arm_count;
// Non-core disk factions cycle through Amarr/Minmatar/Gallente/Caldari.
let faction_index = 1 + (arm as usize) % (FACTIONS.len() - 1);
// Offset by disk_index so different disks don't all share the same
// faction assignment pattern.
let faction_index =
1 + ((arm as usize + disk_index) % (FACTIONS.len() - 1));
let (faction, color) = FACTIONS[faction_index];
let mut position = Option::<Vec3>::None;
let mut final_radius = 0.0f32;
for attempt in 0..SPACING_ATTEMPTS {
let r = rng.gen::<f32>().powf(0.62) * galaxy_size;
let r = inner + rng.gen::<f32>().powf(0.62) * span;
let angle = std::f32::consts::TAU * arm as f32 / arm_count as f32
+ (r / galaxy_size) * disk.twist
+ ((r - inner) / span) * disk.twist
+ (rng.gen::<f32>() - 0.5) * 0.72;
let y = (rng.gen::<f32>() - 0.5) * 20.0;
let candidate = Vec3::new(angle.cos() * r, y, angle.sin() * r);
final_radius = r;
let mut candidate = Vec3::new(angle.cos() * r, y, angle.sin() * r);
// Apply tilt + rotation for non-flat disks.
if let Some(rot) = disk_rotation {
candidate = rot * candidate;
}
final_radius = candidate.length();
let spacing = relax_spacing(base_spacing, attempt).max(MIN_SYSTEM_SPACING);
if systems
.iter()
@@ -356,7 +393,10 @@ fn generate_disk(
color,
security,
is_core: false,
origin: SystemOrigin::Disk { arm },
origin: SystemOrigin::Disk {
disk: disk_index,
arm,
},
});
}
}
@@ -478,7 +518,12 @@ fn security_for_radius(radius: f32, galaxy_size: f32) -> f32 {
#[derive(Debug, Clone, Copy)]
enum SystemOrigin {
Core,
Disk { arm: u32 },
Disk {
/// Which disk layer this system belongs to (index into [`GalaxyParams::disks`]).
disk: usize,
/// Which arm within the disk.
arm: u32,
},
Beam { side: BeamTag },
}

View File

@@ -6,9 +6,10 @@
//!
//! ## Structure
//!
//! A galaxy is composed of three layers, each owning its own system count:
//! A galaxy is composed of three structural layer types:
//! - [`CoreParams`] — Concord systems clustered near the origin.
//! - [`DiskParams`] — horizontal spiral arms in the XZ plane.
//! - [`DiskParams`] (up to [`MAX_DISKS`]) — independent spiral disks, each
//! with its own arm count, twist, tilt, rotation, and radial extent.
//! - [`BeamParams`] (×2, top and bottom) — vertical columns of systems along
//! ±Y, the "polar beams". Each beam is its own population of selectable
//! star systems, fully equal citizens to disk arms.
@@ -57,6 +58,9 @@ pub const CORE_RADIUS_MIN: f32 = 10.0;
pub const CORE_RADIUS_MAX: f32 = 120.0;
pub const CORE_RADIUS_STEP: f32 = 5.0;
/// Maximum number of independent disk layers.
pub const MAX_DISKS: usize = 4;
pub const DISK_COUNT_MIN: usize = 20;
pub const DISK_COUNT_MAX: usize = 220;
pub const DISK_COUNT_STEP: usize = 4;
@@ -66,6 +70,24 @@ pub const DISK_TWIST_MIN: f32 = 1.0;
pub const DISK_TWIST_MAX: f32 = 6.0;
pub const DISK_TWIST_STEP: f32 = 0.2;
/// Max tilt is 45° — disks should never be parallel to beams.
pub const DISK_TILT_MIN: f32 = 0.0;
pub const DISK_TILT_MAX: f32 = std::f32::consts::FRAC_PI_4;
/// ~5° steps (45° / 9).
pub const DISK_TILT_STEP: f32 = std::f32::consts::FRAC_PI_4 / 9.0;
pub const DISK_ROTATION_MIN: f32 = 0.0;
pub const DISK_ROTATION_MAX: f32 = std::f32::consts::TAU;
/// 30° steps.
pub const DISK_ROTATION_STEP: f32 = std::f32::consts::TAU / 12.0;
pub const DISK_INNER_RADIUS_MIN: f32 = 0.0;
pub const DISK_INNER_RADIUS_MAX: f32 = 400.0;
pub const DISK_INNER_RADIUS_STEP: f32 = 10.0;
pub const DISK_OUTER_RADIUS_MIN: f32 = 40.0;
pub const DISK_OUTER_RADIUS_MAX: f32 = 420.0;
pub const DISK_OUTER_RADIUS_STEP: f32 = 10.0;
pub const BEAM_COUNT_MIN: usize = 0;
pub const BEAM_COUNT_MAX: usize = 120;
pub const BEAM_COUNT_STEP: usize = 4;
@@ -115,12 +137,31 @@ impl Default for CoreParams {
}
}
/// Horizontal disk of spiral arms in the XZ plane.
/// One independent disk layer of spiral arms.
///
/// Disks are generated in the XZ plane and then tilted by [`Self::tilt`]
/// (rotation around the X axis) and rotated by [`Self::rotation_offset`]
/// (around the Y axis). Systems are placed between [`Self::inner_radius`]
/// and [`Self::outer_radius`]; if `outer_radius` is 0 the galaxy-wide
/// [`GalaxyParams::size`] is used instead.
#[derive(Debug, Clone)]
pub struct DiskParams {
pub count: usize,
pub arms: u32,
pub twist: f32,
/// Tilt angle in radians (rotation around X axis).
/// Capped at 45° so disks never become parallel to beams.
/// 0 = flat in XZ plane, π/4 = maximum tilt.
pub tilt: f32,
/// Rotation offset around Y axis in radians.
/// Rotates the disk in the horizontal plane, determining where the
/// tilted disk's "high side" points.
pub rotation_offset: f32,
/// Minimum distance from the origin for systems in this disk.
/// 0 = systems can start at the origin (overlapping the core).
pub inner_radius: f32,
/// Maximum distance from the origin. 0 = use [`GalaxyParams::size`].
pub outer_radius: f32,
}
impl Default for DiskParams {
@@ -129,6 +170,10 @@ impl Default for DiskParams {
count: DEFAULT_DISK_COUNT,
arms: DEFAULT_DISK_ARMS,
twist: DEFAULT_DISK_TWIST,
tilt: 0.0,
rotation_offset: 0.0,
inner_radius: 0.0,
outer_radius: 0.0, // means "use galaxy size"
}
}
}
@@ -189,7 +234,8 @@ pub struct GalaxyParams {
/// Overall galaxy extent — drives the disk radius.
pub size: f32,
pub core: CoreParams,
pub disk: DiskParams,
/// Independent disk layers. Always contains at least one entry.
pub disks: Vec<DiskParams>,
pub beam_top: BeamParams,
pub beam_bottom: BeamParams,
/// Monotonic counter — any mutation must bump this via [`Self::bump_generation`].
@@ -204,7 +250,7 @@ impl Default for GalaxyParams {
seed: DEFAULT_SEED,
size: DEFAULT_SIZE,
core: CoreParams::default(),
disk: DiskParams::default(),
disks: vec![DiskParams::default()],
beam_top: BeamParams::default(),
beam_bottom: BeamParams::default(),
generation: 0,

File diff suppressed because it is too large Load Diff