diff --git a/apps/game/src/gameplay/galaxy/mod.rs b/apps/game/src/gameplay/galaxy/mod.rs index 304396f..8083e83 100644 --- a/apps/game/src/gameplay/galaxy/mod.rs +++ b/apps/game/src/gameplay/galaxy/mod.rs @@ -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 { // 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 = Vec::with_capacity(total); @@ -209,14 +211,19 @@ fn generate_system_positions(params: &GalaxyParams, rng: &mut StdRng) -> Vec, 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::::None; let mut final_radius = 0.0f32; for attempt in 0..SPACING_ATTEMPTS { - let r = rng.gen::().powf(0.62) * galaxy_size; + let r = inner + rng.gen::().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::() - 0.5) * 0.72; let y = (rng.gen::() - 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 }, } diff --git a/apps/game/src/gameplay/galaxy/params.rs b/apps/game/src/gameplay/galaxy/params.rs index 9c23dce..0fb8c7d 100644 --- a/apps/game/src/gameplay/galaxy/params.rs +++ b/apps/game/src/gameplay/galaxy/params.rs @@ -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, 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, diff --git a/apps/game/src/gameplay/galaxy/ui.rs b/apps/game/src/gameplay/galaxy/ui.rs index 674f36c..1077771 100644 --- a/apps/game/src/gameplay/galaxy/ui.rs +++ b/apps/game/src/gameplay/galaxy/ui.rs @@ -8,6 +8,14 @@ //! Rows are grouped under section headers (Galaxy, Core, Disk, Top Beam, //! Bottom Beam) for visual clarity now that the panel spans five conceptual //! layers. +//! +//! ## Multi-disk UI +//! +//! Disk layers use a **tab selector** pattern: a row of numbered buttons +//! (1 2 3 …) picks which disk the parameter rows below edit. An "Add Disk" +//! button appears when below [`MAX_DISKS`]; a "Remove" button appears for +//! non-primary disks. This keeps the UI at constant height regardless of +//! how many disks are active. use bevy::{input::mouse::MouseWheel, prelude::*, ui::ScrollPosition, window::PrimaryWindow}; @@ -38,13 +46,25 @@ pub enum ParamButton { CoreCountIncr, CoreRadiusDecr, CoreRadiusIncr, - // Disk + // Disk — tab selection + add/remove + DiskTab(usize), + DiskAdd, + DiskRemove, + // Disk — per-field controls (target the currently-selected disk) DiskCountDecr, DiskCountIncr, DiskArmsDecr, DiskArmsIncr, DiskTwistDecr, DiskTwistIncr, + DiskTiltDecr, + DiskTiltIncr, + DiskRotationDecr, + DiskRotationIncr, + DiskInnerRadiusDecr, + DiskInnerRadiusIncr, + DiskOuterRadiusDecr, + DiskOuterRadiusIncr, // Top beam (+Y) BeamTopEnabled, BeamTopThicknessDecr, @@ -77,6 +97,23 @@ pub enum ParamButton { CenterView, } +/// Resource tracking which disk layer is currently selected in the tab bar. +/// Always clamped to a valid index when the panel is rebuilt. +#[derive(Resource, Debug, Clone, Copy)] +pub struct SelectedDisk(pub usize); + +impl Default for SelectedDisk { + fn default() -> Self { + Self(0) + } +} + +/// Flag resource inserted when the disk count changes (add/remove). The +/// [`rebuild_scroll_content`] system reads and removes it, re-spawning +/// the scroll area with the correct number of tab buttons. +#[derive(Resource)] +pub(super) struct RebuildScrollContent; + // ── Styling constants ─────────────────────────────────────────────────────── const PANEL_BG: Color = Color::srgb(0.05, 0.07, 0.12); @@ -85,6 +122,8 @@ const TEXT_BRIGHT: Color = Color::srgb(0.82, 0.90, 1.0); const TEXT_DIM: Color = Color::srgb(0.55, 0.65, 0.82); const SECTION_TEXT: Color = Color::srgb(0.42, 0.62, 0.86); const BUTTON_BG: Color = Color::srgb(0.10, 0.14, 0.22); +const TAB_ACTIVE_BG: Color = Color::srgb(0.18, 0.28, 0.46); +const TAB_INACTIVE_BG: Color = Color::srgb(0.08, 0.11, 0.18); const PANEL_WIDTH: f32 = 300.0; const PANEL_PADDING: f32 = 14.0; @@ -94,15 +133,17 @@ const LABEL_FONT_SIZE: f32 = 15.0; const VALUE_FONT_SIZE: f32 = 15.0; const BUTTON_FONT_SIZE: f32 = 16.0; const HELP_FONT_SIZE: f32 = 12.0; +const TAB_FONT_SIZE: f32 = 14.0; // ── Setup ─────────────────────────────────────────────────────────────────── -pub fn setup_galaxy_ui(mut commands: Commands) { - spawn_control_panel(&mut commands); +pub fn setup_galaxy_ui(mut commands: Commands, params: Res) { + commands.init_resource::(); + spawn_control_panel(&mut commands, ¶ms); spawn_info_panel_empty(&mut commands); } -fn spawn_control_panel(commands: &mut Commands) { +fn spawn_control_panel(commands: &mut Commands, params: &GalaxyParams) { commands .spawn(( Node { @@ -171,7 +212,12 @@ fn spawn_control_panel(commands: &mut Commands) { flex_grow: 1.0, flex_direction: FlexDirection::Column, row_gap: Val::Px(6.0), - padding: UiRect::px(PANEL_PADDING, PANEL_PADDING, PANEL_PADDING, PANEL_PADDING), + padding: UiRect::px( + PANEL_PADDING, + PANEL_PADDING, + PANEL_PADDING, + PANEL_PADDING, + ), overflow: Overflow::scroll_y(), ..default() }, @@ -179,212 +225,31 @@ fn spawn_control_panel(commands: &mut Commands) { GalaxyScrollContent, )) .with_children(|scroll| { - // ── Galaxy (seed + size) ──────────────────────────────── - spawn_section(scroll, "Galaxy"); - spawn_param_row(scroll, "Seed", "seed", ParamButton::SeedDecr, ParamButton::SeedIncr); - spawn_param_row(scroll, "Size", "size", ParamButton::SizeDecr, ParamButton::SizeIncr); - - // ── Core ──────────────────────────────────────────────── - spawn_section(scroll, "Core"); - spawn_param_row( - scroll, - "Core Count", - "core_count", - ParamButton::CoreCountDecr, - ParamButton::CoreCountIncr, - ); - spawn_param_row( - scroll, - "Core Radius", - "core_radius", - ParamButton::CoreRadiusDecr, - ParamButton::CoreRadiusIncr, - ); - - // ── Disk ──────────────────────────────────────────────── - spawn_section(scroll, "Disk"); - spawn_param_row( - scroll, - "Disk Count", - "disk_count", - ParamButton::DiskCountDecr, - ParamButton::DiskCountIncr, - ); - spawn_param_row( - scroll, - "Disk Arms", - "disk_arms", - ParamButton::DiskArmsDecr, - ParamButton::DiskArmsIncr, - ); - spawn_param_row( - scroll, - "Disk Twist", - "disk_twist", - ParamButton::DiskTwistDecr, - ParamButton::DiskTwistIncr, - ); - - // ── Top Beam (+Y) ─────────────────────────────────────── - spawn_section(scroll, "Top Beam (+Y)"); - spawn_toggle_row(scroll, "Enabled", "beam_top_enabled", ParamButton::BeamTopEnabled); - spawn_param_row( - scroll, - "Thickness", - "beam_top_thickness", - ParamButton::BeamTopThicknessDecr, - ParamButton::BeamTopThicknessIncr, - ); - spawn_param_row( - scroll, - "Length", - "beam_top_length", - ParamButton::BeamTopLengthDecr, - ParamButton::BeamTopLengthIncr, - ); - spawn_param_row( - scroll, - "Density", - "beam_top_density", - ParamButton::BeamTopDensityDecr, - ParamButton::BeamTopDensityIncr, - ); - spawn_param_row( - scroll, - "Taper", - "beam_top_taper", - ParamButton::BeamTopTaperDecr, - ParamButton::BeamTopTaperIncr, - ); - spawn_param_row( - scroll, - "Gravity", - "beam_top_gravity", - ParamButton::BeamTopGravityDecr, - ParamButton::BeamTopGravityIncr, - ); - - // ── Bottom Beam (-Y) ──────────────────────────────────── - spawn_section(scroll, "Bottom Beam (-Y)"); - spawn_toggle_row( - scroll, - "Enabled", - "beam_bottom_enabled", - ParamButton::BeamBottomEnabled, - ); - spawn_param_row( - scroll, - "Thickness", - "beam_bottom_thickness", - ParamButton::BeamBottomThicknessDecr, - ParamButton::BeamBottomThicknessIncr, - ); - spawn_param_row( - scroll, - "Length", - "beam_bottom_length", - ParamButton::BeamBottomLengthDecr, - ParamButton::BeamBottomLengthIncr, - ); - spawn_param_row( - scroll, - "Density", - "beam_bottom_density", - ParamButton::BeamBottomDensityDecr, - ParamButton::BeamBottomDensityIncr, - ); - spawn_param_row( - scroll, - "Taper", - "beam_bottom_taper", - ParamButton::BeamBottomTaperDecr, - ParamButton::BeamBottomTaperIncr, - ); - spawn_param_row( - scroll, - "Gravity", - "beam_bottom_gravity", - ParamButton::BeamBottomGravityDecr, - ParamButton::BeamBottomGravityIncr, - ); - - // ── Action buttons ────────────────────────────────────── - - // Regenerate (randomize seed) button. - scroll - .spawn(( - Button, - Node { - width: Val::Percent(100.0), - height: Val::Px(32.0), - margin: UiRect::top(Val::Px(8.0)), - justify_content: JustifyContent::Center, - align_items: AlignItems::Center, - border: UiRect::all(Val::Px(1.0)), - ..default() - }, - BackgroundColor(BUTTON_BG), - BorderColor(PANEL_BORDER), - BorderRadius::all(Val::Px(6.0)), - ParamButton::Regenerate, - )) - .with_children(|btn| { - btn.spawn(( - Text::new("Regenerate"), - TextFont { - font_size: BUTTON_FONT_SIZE, - ..default() - }, - TextColor(TEXT_BRIGHT), - )); - }); - - // Center View (reset orbit camera) button. - scroll - .spawn(( - Button, - Node { - width: Val::Percent(100.0), - height: Val::Px(32.0), - margin: UiRect::top(Val::Px(4.0)), - justify_content: JustifyContent::Center, - align_items: AlignItems::Center, - border: UiRect::all(Val::Px(1.0)), - ..default() - }, - BackgroundColor(BUTTON_BG), - BorderColor(PANEL_BORDER), - BorderRadius::all(Val::Px(6.0)), - ParamButton::CenterView, - )) - .with_children(|btn| { - btn.spawn(( - Text::new("Center View"), - TextFont { - font_size: BUTTON_FONT_SIZE, - ..default() - }, - TextColor(TEXT_BRIGHT), - )); - }); - - // Help text. - scroll.spawn(( - Text::new("Drag to orbit (360°) • Scroll to zoom • Click a star • Esc to return"), - TextFont { - font_size: HELP_FONT_SIZE, - ..default() - }, - TextColor(TEXT_DIM), - Node { - margin: UiRect::top(Val::Px(10.0)), - ..default() - }, - )); + spawn_scroll_contents_with_tabs(scroll, params, 0); }); }); } +/// Build all rows inside the scroll area. This is the version used by +/// [`spawn_control_panel`] for the initial build. The full rebuild variant +/// that knows the current params / active tab is [`spawn_scroll_contents_with_tabs`]. +fn spawn_scroll_contents(scroll: &mut ChildSpawnerCommands) { + // Delegate to the tab-aware builder with default params context. + // The initial build uses a single default disk. + let default_params = GalaxyParams::default(); + spawn_scroll_contents_with_tabs(scroll, &default_params, 0); +} + +/// Marker for the disk "Remove" button so we can toggle its visibility. +#[derive(Component)] +struct DiskRemoveButton; + +/// Marker for disk tab buttons so we can restyle the active tab. +#[derive(Component)] +struct DiskTabButton(usize); + +// ── Section / row helpers ─────────────────────────────────────────────────── + /// Section header row — small dim label with a top margin to separate groups. fn spawn_section(parent: &mut ChildSpawnerCommands, label: &str) { parent.spawn(( @@ -430,7 +295,7 @@ fn spawn_param_row( }, TextColor(TEXT_DIM), Node { - width: Val::Px(128.0), + width: Val::Px(100.0), ..default() }, )); @@ -483,7 +348,7 @@ fn spawn_toggle_row( }, TextColor(TEXT_DIM), Node { - width: Val::Px(128.0), + width: Val::Px(100.0), ..default() }, )); @@ -597,7 +462,9 @@ fn spawn_info_panel_empty(commands: &mut Commands) { // ── Button handler ────────────────────────────────────────────────────────── pub fn param_button_handler( + mut commands: Commands, mut params: ResMut, + mut selected_disk: ResMut, query: Query<(&Interaction, &ParamButton), Changed>, ) { for (interaction, button) in &query { @@ -636,40 +503,110 @@ pub fn param_button_handler( params.bump_generation(); } ParamButton::CoreRadiusDecr => { - params.core.radius = (params.core.radius - CORE_RADIUS_STEP).max(CORE_RADIUS_MIN); + params.core.radius = + (params.core.radius - CORE_RADIUS_STEP).max(CORE_RADIUS_MIN); params.bump_generation(); } ParamButton::CoreRadiusIncr => { - params.core.radius = (params.core.radius + CORE_RADIUS_STEP).min(CORE_RADIUS_MAX); + params.core.radius = + (params.core.radius + CORE_RADIUS_STEP).min(CORE_RADIUS_MAX); params.bump_generation(); } - // ── Disk ────────────────────────────────────────────────────── + // ── Disk tab management ─────────────────────────────────────── + ParamButton::DiskTab(n) => { + selected_disk.0 = *n; + } + ParamButton::DiskAdd => { + if params.disks.len() < MAX_DISKS { + params.disks.push(DiskParams::default()); + selected_disk.0 = params.disks.len() - 1; + params.bump_generation(); + commands.insert_resource(RebuildScrollContent); + } + } + ParamButton::DiskRemove => { + if params.disks.len() > 1 && selected_disk.0 < params.disks.len() { + params.disks.remove(selected_disk.0); + selected_disk.0 = (selected_disk.0).min(params.disks.len() - 1); + params.bump_generation(); + commands.insert_resource(RebuildScrollContent); + } + } + // ── Disk params (target selected_disk) ──────────────────────── ParamButton::DiskCountDecr => { - params.disk.count = params - .disk - .count - .saturating_sub(DISK_COUNT_STEP) - .max(DISK_COUNT_MIN); + let disk = selected_disk_mut(&mut params, selected_disk.0); + disk.count = disk.count.saturating_sub(DISK_COUNT_STEP).max(DISK_COUNT_MIN); params.bump_generation(); } ParamButton::DiskCountIncr => { - params.disk.count = (params.disk.count + DISK_COUNT_STEP).min(DISK_COUNT_MAX); + let disk = selected_disk_mut(&mut params, selected_disk.0); + disk.count = (disk.count + DISK_COUNT_STEP).min(DISK_COUNT_MAX); params.bump_generation(); } ParamButton::DiskArmsDecr => { - params.disk.arms = params.disk.arms.saturating_sub(1).max(DISK_ARMS_MIN); + let disk = selected_disk_mut(&mut params, selected_disk.0); + disk.arms = disk.arms.saturating_sub(1).max(DISK_ARMS_MIN); params.bump_generation(); } ParamButton::DiskArmsIncr => { - params.disk.arms = params.disk.arms.saturating_add(1).min(DISK_ARMS_MAX); + let disk = selected_disk_mut(&mut params, selected_disk.0); + disk.arms = disk.arms.saturating_add(1).min(DISK_ARMS_MAX); params.bump_generation(); } ParamButton::DiskTwistDecr => { - params.disk.twist = (params.disk.twist - DISK_TWIST_STEP).max(DISK_TWIST_MIN); + let disk = selected_disk_mut(&mut params, selected_disk.0); + disk.twist = (disk.twist - DISK_TWIST_STEP).max(DISK_TWIST_MIN); params.bump_generation(); } ParamButton::DiskTwistIncr => { - params.disk.twist = (params.disk.twist + DISK_TWIST_STEP).min(DISK_TWIST_MAX); + let disk = selected_disk_mut(&mut params, selected_disk.0); + disk.twist = (disk.twist + DISK_TWIST_STEP).min(DISK_TWIST_MAX); + params.bump_generation(); + } + ParamButton::DiskTiltDecr => { + let disk = selected_disk_mut(&mut params, selected_disk.0); + disk.tilt = (disk.tilt - DISK_TILT_STEP).max(DISK_TILT_MIN); + params.bump_generation(); + } + ParamButton::DiskTiltIncr => { + let disk = selected_disk_mut(&mut params, selected_disk.0); + disk.tilt = (disk.tilt + DISK_TILT_STEP).min(DISK_TILT_MAX); + params.bump_generation(); + } + ParamButton::DiskRotationDecr => { + let disk = selected_disk_mut(&mut params, selected_disk.0); + disk.rotation_offset = (disk.rotation_offset - DISK_ROTATION_STEP) + .max(DISK_ROTATION_MIN); + params.bump_generation(); + } + ParamButton::DiskRotationIncr => { + let disk = selected_disk_mut(&mut params, selected_disk.0); + disk.rotation_offset = (disk.rotation_offset + DISK_ROTATION_STEP) + .min(DISK_ROTATION_MAX); + params.bump_generation(); + } + ParamButton::DiskInnerRadiusDecr => { + let disk = selected_disk_mut(&mut params, selected_disk.0); + disk.inner_radius = + (disk.inner_radius - DISK_INNER_RADIUS_STEP).max(DISK_INNER_RADIUS_MIN); + params.bump_generation(); + } + ParamButton::DiskInnerRadiusIncr => { + let disk = selected_disk_mut(&mut params, selected_disk.0); + disk.inner_radius = + (disk.inner_radius + DISK_INNER_RADIUS_STEP).min(DISK_INNER_RADIUS_MAX); + params.bump_generation(); + } + ParamButton::DiskOuterRadiusDecr => { + let disk = selected_disk_mut(&mut params, selected_disk.0); + disk.outer_radius = + (disk.outer_radius - DISK_OUTER_RADIUS_STEP).max(DISK_OUTER_RADIUS_MIN); + params.bump_generation(); + } + ParamButton::DiskOuterRadiusIncr => { + let disk = selected_disk_mut(&mut params, selected_disk.0); + disk.outer_radius = + (disk.outer_radius + DISK_OUTER_RADIUS_STEP).min(DISK_OUTER_RADIUS_MAX); params.bump_generation(); } // ── Top Beam (+Y) ───────────────────────────────────────────── @@ -736,13 +673,15 @@ pub fn param_button_handler( params.bump_generation(); } ParamButton::BeamBottomThicknessDecr => { - params.beam_bottom.thickness = - (params.beam_bottom.thickness - BEAM_THICKNESS_STEP).max(BEAM_THICKNESS_MIN); + params.beam_bottom.thickness = (params.beam_bottom.thickness + - BEAM_THICKNESS_STEP) + .max(BEAM_THICKNESS_MIN); params.bump_generation(); } ParamButton::BeamBottomThicknessIncr => { - params.beam_bottom.thickness = - (params.beam_bottom.thickness + BEAM_THICKNESS_STEP).min(BEAM_THICKNESS_MAX); + params.beam_bottom.thickness = (params.beam_bottom.thickness + + BEAM_THICKNESS_STEP) + .min(BEAM_THICKNESS_MAX); params.bump_generation(); } ParamButton::BeamBottomLengthDecr => { @@ -779,13 +718,15 @@ pub fn param_button_handler( params.bump_generation(); } ParamButton::BeamBottomGravityDecr => { - params.beam_bottom.gravity = - (params.beam_bottom.gravity - BEAM_GRAVITY_STEP).max(BEAM_GRAVITY_MIN); + params.beam_bottom.gravity = (params.beam_bottom.gravity + - BEAM_GRAVITY_STEP) + .max(BEAM_GRAVITY_MIN); params.bump_generation(); } ParamButton::BeamBottomGravityIncr => { - params.beam_bottom.gravity = - (params.beam_bottom.gravity + BEAM_GRAVITY_STEP).min(BEAM_GRAVITY_MAX); + params.beam_bottom.gravity = (params.beam_bottom.gravity + + BEAM_GRAVITY_STEP) + .min(BEAM_GRAVITY_MAX); params.bump_generation(); } ParamButton::RandomSettings => { @@ -797,15 +738,19 @@ pub fn param_button_handler( params.size = (params.size / SIZE_STEP).round() * SIZE_STEP; params.core.count = rng.gen_range(CORE_COUNT_MIN..=CORE_COUNT_MAX); params.core.radius = rng.gen_range(CORE_RADIUS_MIN..=CORE_RADIUS_MAX); - params.core.radius = (params.core.radius / CORE_RADIUS_STEP).round() * CORE_RADIUS_STEP; - params.disk.count = rng.gen_range(DISK_COUNT_MIN..=DISK_COUNT_MAX); - params.disk.count = (params.disk.count / DISK_COUNT_STEP) * DISK_COUNT_STEP; - params.disk.arms = rng.gen_range(DISK_ARMS_MIN..=DISK_ARMS_MAX); - params.disk.twist = rng.gen_range(DISK_TWIST_MIN..=DISK_TWIST_MAX); - params.disk.twist = (params.disk.twist / DISK_TWIST_STEP).round() * DISK_TWIST_STEP; + params.core.radius = + (params.core.radius / CORE_RADIUS_STEP).round() * CORE_RADIUS_STEP; + // Randomize disks: 1–3 layers. + let disk_count = rng.gen_range(1..=3.min(MAX_DISKS)); + params.disks.clear(); + for _ in 0..disk_count { + params.disks.push(randomize_disk(&mut rng)); + } + selected_disk.0 = 0; randomize_beam(&mut params.beam_top, &mut rng); randomize_beam(&mut params.beam_bottom, &mut rng); params.bump_generation(); + commands.insert_resource(RebuildScrollContent); } ParamButton::Regenerate => params.reseed_and_bump(), // CenterView is handled by `reset_view_button_handler` (needs Commands). @@ -814,6 +759,13 @@ pub fn param_button_handler( } } +/// Helper: get a mutable reference to the currently-selected disk. +/// Clamps the index to the valid range as a safety measure. +fn selected_disk_mut(params: &mut GalaxyParams, index: usize) -> &mut DiskParams { + let idx = index.min(params.disks.len() - 1); + &mut params.disks[idx] +} + /// Handle the CenterView button: insert [`ResetOrbitCamera`] so /// [`crate::camera::apply_orbit_reset`] can snap the orbit camera back to /// its default orientation next frame. Kept in a separate system because it @@ -835,11 +787,19 @@ pub fn reset_view_button_handler( /// Refresh the displayed parameter values every frame so the UI stays in sync. /// Bevy UI has no native data binding, so we manually patch the `Text` children -/// — cheap (≤25 nodes) and avoids re-spawning the whole panel. +/// — cheap (~30 nodes) and avoids re-spawning the whole panel. +/// +/// Disk-related values read from the currently-selected disk +/// ([`SelectedDisk`]) so the tab selector works correctly. pub fn refresh_control_panel_values( params: Res, + selected_disk: Res, mut values: Query<(&ParamValue, &mut Text)>, ) { + // Resolve the selected disk, clamped to a valid index. + let disk_idx = selected_disk.0.min(params.disks.len() - 1); + let disk = ¶ms.disks[disk_idx]; + for (marker, mut text) in &mut values { let new = match marker.0.as_str() { // Galaxy @@ -848,22 +808,53 @@ pub fn refresh_control_panel_values( // Core "core_count" => format!("{}", params.core.count), "core_radius" => format!("{:.0}", params.core.radius), - // Disk - "disk_count" => format!("{}", params.disk.count), - "disk_arms" => format!("{}", params.disk.arms), - "disk_twist" => format!("{:.1}", params.disk.twist), + // Disk — reads from selected disk + "disk_count" => format!("{}", disk.count), + "disk_arms" => format!("{}", disk.arms), + "disk_twist" => format!("{:.1}", disk.twist), + "disk_tilt" => format!("{:.0}°", disk.tilt.to_degrees()), + "disk_rotation" => format!("{:.0}°", disk.rotation_offset.to_degrees()), + "disk_inner_radius" => { + if disk.inner_radius > 0.0 { + format!("{:.0}", disk.inner_radius) + } else { + "0".to_string() + } + } + "disk_outer_radius" => { + if disk.outer_radius > 0.0 { + format!("{:.0}", disk.outer_radius) + } else { + "auto".to_string() + } + } // Top beam - "beam_top_enabled" => format!("{}", if params.beam_top.enabled { "On" } else { "Off" }), + "beam_top_enabled" => { + format!( + "{}", + if params.beam_top.enabled { + "On" + } else { + "Off" + } + ) + } "beam_top_thickness" => format!("{:.0}", params.beam_top.thickness), "beam_top_length" => format!("{:.0}", params.beam_top.length), "beam_top_density" => format!("{}", params.beam_top.count), "beam_top_taper" => format!("{:.1}", params.beam_top.taper), "beam_top_gravity" => format!("{:.1}", params.beam_top.gravity), // Bottom beam - "beam_bottom_enabled" => format!( - "{}", - if params.beam_bottom.enabled { "On" } else { "Off" } - ), + "beam_bottom_enabled" => { + format!( + "{}", + if params.beam_bottom.enabled { + "On" + } else { + "Off" + } + ) + } "beam_bottom_thickness" => format!("{:.0}", params.beam_bottom.thickness), "beam_bottom_length" => format!("{:.0}", params.beam_bottom.length), "beam_bottom_density" => format!("{}", params.beam_bottom.count), @@ -877,11 +868,463 @@ pub fn refresh_control_panel_values( } } -// ── State wiring ──────────────────────────────────────────────────────────── -// -// `setup_galaxy_ui` runs on OnEnter(Galaxy) via the plugin in `mod.rs`. -// Despawning happens through the shared `GalaxySpawned` marker in the -// plugin's OnExit handler — no separate UI cleanup needed. +// ── Scroll content rebuild ────────────────────────────────────────────────── + +/// When the disk count changes (add/remove/randomize), the tab bar and the +/// remove button need to be rebuilt. This system detects the flag, despawns +/// the old scroll content entity, and re-spawns it with the correct tab count. +pub fn rebuild_scroll_content( + mut commands: Commands, + flag: Option>, + scroll_content: Query>, + control_panel: Query>, + params: Res, + selected_disk: Res, +) { + let Some(_) = flag else { + return; + }; + // Consume the flag. + commands.remove_resource::(); + + let Ok(scroll_entity) = scroll_content.single() else { + return; + }; + let Ok(panel_entity) = control_panel.single() else { + return; + }; + + // Despawn the old scroll content entity (and all its children). + commands.entity(scroll_entity).despawn(); + + // Re-spawn a fresh scroll content as a child of the panel. + commands + .entity(panel_entity) + .with_children(|parent| { + parent + .spawn(( + Node { + flex_grow: 1.0, + flex_direction: FlexDirection::Column, + row_gap: Val::Px(6.0), + padding: UiRect::px( + PANEL_PADDING, + PANEL_PADDING, + PANEL_PADDING, + PANEL_PADDING, + ), + overflow: Overflow::scroll_y(), + ..default() + }, + ScrollPosition::default(), + GalaxyScrollContent, + )) + .with_children(|scroll| { + spawn_scroll_contents_with_tabs(scroll, ¶ms, selected_disk.0); + }); + }); +} + +/// Full scroll content builder that includes the actual tab buttons for the +/// current disk count. This is the authoritative spawn function — the simpler +/// [`spawn_scroll_contents`] delegates to this when we need dynamic tabs. +fn spawn_scroll_contents_with_tabs( + scroll: &mut ChildSpawnerCommands, + params: &GalaxyParams, + active_disk: usize, +) { + // ── Galaxy (seed + size) ──────────────────────────────────────── + spawn_section(scroll, "Galaxy"); + spawn_param_row( + scroll, + "Seed", + "seed", + ParamButton::SeedDecr, + ParamButton::SeedIncr, + ); + spawn_param_row( + scroll, + "Size", + "size", + ParamButton::SizeDecr, + ParamButton::SizeIncr, + ); + + // ── Core ──────────────────────────────────────────────────────── + spawn_section(scroll, "Core"); + spawn_param_row( + scroll, + "Core Count", + "core_count", + ParamButton::CoreCountDecr, + ParamButton::CoreCountIncr, + ); + spawn_param_row( + scroll, + "Core Radius", + "core_radius", + ParamButton::CoreRadiusDecr, + ParamButton::CoreRadiusIncr, + ); + + // ── Disk (tab bar + shared parameter rows) ────────────────────── + spawn_section(scroll, "Disk"); + + // Tab bar: [1] [2] ... [+] + let tab_bar_active = active_disk.min(params.disks.len() - 1); + scroll + .spawn(Node { + width: Val::Percent(100.0), + flex_direction: FlexDirection::Row, + column_gap: Val::Px(4.0), + align_items: AlignItems::Center, + ..default() + }) + .with_children(|row| { + for i in 0..params.disks.len() { + let is_active = i == tab_bar_active; + let bg = if is_active { + TAB_ACTIVE_BG + } else { + TAB_INACTIVE_BG + }; + let border = if is_active { + Color::srgb(0.40, 0.60, 0.90) + } else { + PANEL_BORDER + }; + let text_color = if is_active { + TEXT_BRIGHT + } else { + TEXT_DIM + }; + row.spawn(( + Button, + Node { + width: Val::Px(30.0), + height: Val::Px(24.0), + justify_content: JustifyContent::Center, + align_items: AlignItems::Center, + border: UiRect::all(Val::Px(1.0)), + ..default() + }, + BackgroundColor(bg), + BorderColor(border), + BorderRadius::all(Val::Px(4.0)), + ParamButton::DiskTab(i), + DiskTabButton(i), + )) + .with_children(|btn| { + btn.spawn(( + Text::new(format!("{}", i + 1)), + TextFont { + font_size: TAB_FONT_SIZE, + ..default() + }, + TextColor(text_color), + )); + }); + } + + // [+ Add Disk] button — only if below max. + if params.disks.len() < MAX_DISKS { + row.spawn(( + Button, + Node { + width: Val::Px(26.0), + height: Val::Px(24.0), + justify_content: JustifyContent::Center, + align_items: AlignItems::Center, + border: UiRect::all(Val::Px(1.0)), + ..default() + }, + BackgroundColor(BUTTON_BG), + BorderColor(PANEL_BORDER), + BorderRadius::all(Val::Px(4.0)), + ParamButton::DiskAdd, + )) + .with_children(|btn| { + btn.spawn(( + Text::new("+"), + TextFont { + font_size: TAB_FONT_SIZE, + ..default() + }, + TextColor(TEXT_DIM), + )); + }); + } + }); + + // Shared disk parameter rows. + spawn_param_row( + scroll, + "Disk Count", + "disk_count", + ParamButton::DiskCountDecr, + ParamButton::DiskCountIncr, + ); + spawn_param_row( + scroll, + "Disk Arms", + "disk_arms", + ParamButton::DiskArmsDecr, + ParamButton::DiskArmsIncr, + ); + spawn_param_row( + scroll, + "Disk Twist", + "disk_twist", + ParamButton::DiskTwistDecr, + ParamButton::DiskTwistIncr, + ); + spawn_param_row( + scroll, + "Tilt", + "disk_tilt", + ParamButton::DiskTiltDecr, + ParamButton::DiskTiltIncr, + ); + spawn_param_row( + scroll, + "Rotation", + "disk_rotation", + ParamButton::DiskRotationDecr, + ParamButton::DiskRotationIncr, + ); + spawn_param_row( + scroll, + "Inner Rad", + "disk_inner_radius", + ParamButton::DiskInnerRadiusDecr, + ParamButton::DiskInnerRadiusIncr, + ); + spawn_param_row( + scroll, + "Outer Rad", + "disk_outer_radius", + ParamButton::DiskOuterRadiusDecr, + ParamButton::DiskOuterRadiusIncr, + ); + + // Remove button — only show if more than one disk. + if params.disks.len() > 1 { + scroll + .spawn(( + Button, + Node { + width: Val::Percent(100.0), + height: Val::Px(24.0), + justify_content: JustifyContent::Center, + align_items: AlignItems::Center, + border: UiRect::all(Val::Px(1.0)), + ..default() + }, + BackgroundColor(Color::srgb(0.25, 0.10, 0.10)), + BorderColor(Color::srgb(0.50, 0.20, 0.20)), + BorderRadius::all(Val::Px(4.0)), + ParamButton::DiskRemove, + DiskRemoveButton, + )) + .with_children(|btn| { + btn.spawn(( + Text::new("Remove This Disk"), + TextFont { + font_size: BUTTON_FONT_SIZE, + ..default() + }, + TextColor(Color::srgb(0.90, 0.60, 0.60)), + )); + }); + } + + // ── Top Beam (+Y) ─────────────────────────────────────────────── + spawn_section(scroll, "Top Beam (+Y)"); + spawn_toggle_row( + scroll, + "Enabled", + "beam_top_enabled", + ParamButton::BeamTopEnabled, + ); + spawn_param_row( + scroll, + "Thickness", + "beam_top_thickness", + ParamButton::BeamTopThicknessDecr, + ParamButton::BeamTopThicknessIncr, + ); + spawn_param_row( + scroll, + "Length", + "beam_top_length", + ParamButton::BeamTopLengthDecr, + ParamButton::BeamTopLengthIncr, + ); + spawn_param_row( + scroll, + "Density", + "beam_top_density", + ParamButton::BeamTopDensityDecr, + ParamButton::BeamTopDensityIncr, + ); + spawn_param_row( + scroll, + "Taper", + "beam_top_taper", + ParamButton::BeamTopTaperDecr, + ParamButton::BeamTopTaperIncr, + ); + spawn_param_row( + scroll, + "Gravity", + "beam_top_gravity", + ParamButton::BeamTopGravityDecr, + ParamButton::BeamTopGravityIncr, + ); + + // ── Bottom Beam (-Y) ──────────────────────────────────────────── + spawn_section(scroll, "Bottom Beam (-Y)"); + spawn_toggle_row( + scroll, + "Enabled", + "beam_bottom_enabled", + ParamButton::BeamBottomEnabled, + ); + spawn_param_row( + scroll, + "Thickness", + "beam_bottom_thickness", + ParamButton::BeamBottomThicknessDecr, + ParamButton::BeamBottomThicknessIncr, + ); + spawn_param_row( + scroll, + "Length", + "beam_bottom_length", + ParamButton::BeamBottomLengthDecr, + ParamButton::BeamBottomLengthIncr, + ); + spawn_param_row( + scroll, + "Density", + "beam_bottom_density", + ParamButton::BeamBottomDensityDecr, + ParamButton::BeamBottomDensityIncr, + ); + spawn_param_row( + scroll, + "Taper", + "beam_bottom_taper", + ParamButton::BeamBottomTaperDecr, + ParamButton::BeamBottomTaperIncr, + ); + spawn_param_row( + scroll, + "Gravity", + "beam_bottom_gravity", + ParamButton::BeamBottomGravityDecr, + ParamButton::BeamBottomGravityIncr, + ); + + // ── Action buttons ────────────────────────────────────────────── + scroll + .spawn(( + Button, + Node { + width: Val::Percent(100.0), + height: Val::Px(32.0), + margin: UiRect::top(Val::Px(8.0)), + justify_content: JustifyContent::Center, + align_items: AlignItems::Center, + border: UiRect::all(Val::Px(1.0)), + ..default() + }, + BackgroundColor(BUTTON_BG), + BorderColor(PANEL_BORDER), + BorderRadius::all(Val::Px(6.0)), + ParamButton::Regenerate, + )) + .with_children(|btn| { + btn.spawn(( + Text::new("Regenerate"), + TextFont { + font_size: BUTTON_FONT_SIZE, + ..default() + }, + TextColor(TEXT_BRIGHT), + )); + }); + + scroll + .spawn(( + Button, + Node { + width: Val::Percent(100.0), + height: Val::Px(32.0), + margin: UiRect::top(Val::Px(4.0)), + justify_content: JustifyContent::Center, + align_items: AlignItems::Center, + border: UiRect::all(Val::Px(1.0)), + ..default() + }, + BackgroundColor(BUTTON_BG), + BorderColor(PANEL_BORDER), + BorderRadius::all(Val::Px(6.0)), + ParamButton::CenterView, + )) + .with_children(|btn| { + btn.spawn(( + Text::new("Center View"), + TextFont { + font_size: BUTTON_FONT_SIZE, + ..default() + }, + TextColor(TEXT_BRIGHT), + )); + }); + + scroll.spawn(( + Text::new("Drag to orbit (360°) • Scroll to zoom • Click a star • Esc to return"), + TextFont { + font_size: HELP_FONT_SIZE, + ..default() + }, + TextColor(TEXT_DIM), + Node { + margin: UiRect::top(Val::Px(10.0)), + ..default() + }, + )); +} + +// ── Helpers ───────────────────────────────────────────────────────────────── + +/// Randomize all fields of a [`DiskParams`] to random valid values within +/// their respective bounds, rounded to the UI step. +fn randomize_disk(rng: &mut impl rand::Rng) -> DiskParams { + let mut disk = DiskParams::default(); + disk.count = rng.gen_range(DISK_COUNT_MIN..=DISK_COUNT_MAX); + disk.count = (disk.count / DISK_COUNT_STEP) * DISK_COUNT_STEP; + disk.arms = rng.gen_range(DISK_ARMS_MIN..=DISK_ARMS_MAX); + disk.twist = rng.gen_range(DISK_TWIST_MIN..=DISK_TWIST_MAX); + disk.twist = (disk.twist / DISK_TWIST_STEP).round() * DISK_TWIST_STEP; + disk.tilt = rng.gen_range(DISK_TILT_MIN..=DISK_TILT_MAX); + disk.tilt = (disk.tilt / DISK_TILT_STEP).round() * DISK_TILT_STEP; + disk.rotation_offset = rng.gen_range(DISK_ROTATION_MIN..=DISK_ROTATION_MAX); + disk.rotation_offset = + (disk.rotation_offset / DISK_ROTATION_STEP).round() * DISK_ROTATION_STEP; + disk.inner_radius = rng.gen_range(DISK_INNER_RADIUS_MIN..=DISK_INNER_RADIUS_MAX); + disk.inner_radius = + (disk.inner_radius / DISK_INNER_RADIUS_STEP).round() * DISK_INNER_RADIUS_STEP; + disk.outer_radius = rng.gen_range(DISK_OUTER_RADIUS_MIN..=DISK_OUTER_RADIUS_MAX); + disk.outer_radius = + (disk.outer_radius / DISK_OUTER_RADIUS_STEP).round() * DISK_OUTER_RADIUS_STEP; + // Allow 0 (auto) ~50% of the time. + if rng.gen_bool(0.5) { + disk.outer_radius = 0.0; + } + disk +} /// Randomize all fields of a [`BeamParams`] to random valid values within /// their respective bounds, rounded to the UI step. @@ -924,7 +1367,10 @@ pub fn scroll_control_panel( let size = node.size(); let min = pos - size * 0.5; let max = pos + size * 0.5; - cursor_pos.x >= min.x && cursor_pos.x <= max.x && cursor_pos.y >= min.y && cursor_pos.y <= max.y + cursor_pos.x >= min.x + && cursor_pos.x <= max.x + && cursor_pos.y >= min.y + && cursor_pos.y <= max.y }); if !over_panel {