From f83a0c5e591ee3bc57245efb314faa5a40307d35 Mon Sep 17 00:00:00 2001 From: francy51 Date: Tue, 9 Jun 2026 23:07:55 -0400 Subject: [PATCH] feat(galaxy): refactor params into core/disk/beam layers, fix randomize button scaling - Split GalaxyParams into CoreParams, DiskParams, and BeamParams sub-structs - Add polar beam systems (top/bottom) with visual differentiation - Add scrollable control panel with section headers for each layer - Fix Randomize Settings button: remove explicit width (use flex auto) and bump height from 28px to 32px to match other action buttons - Add mouse-wheel scroll routing for the control panel - Add 'Randomize Settings' button that randomizes all params within bounds - Add SystemOrigin enum for per-layer tracking - Update AGENTS.md with new module layout and plugin pattern --- apps/game/AGENTS.md | 12 + apps/game/src/camera.rs | 3 + apps/game/src/gameplay/galaxy/mod.rs | 392 ++++++++++--- apps/game/src/gameplay/galaxy/params.rs | 197 +++++-- apps/game/src/gameplay/galaxy/ui.rs | 704 ++++++++++++++++++++---- 5 files changed, 1080 insertions(+), 228 deletions(-) diff --git a/apps/game/AGENTS.md b/apps/game/AGENTS.md index b390bc2..f2c56f5 100644 --- a/apps/game/AGENTS.md +++ b/apps/game/AGENTS.md @@ -75,6 +75,18 @@ Follows [Rust RFC 344](https://rust-lang.github.io/api-guidelines/naming.html). | Functions, systems, locals | `snake_case`, verb-first | `spawn_camera`, `setup_main_menu` | | Constants, statics | `SCREAMING_SNAKE_CASE` | `MAX_HEALTH` | +## Warnings & Errors Policy + +**Never suppress compiler warnings or errors.** This includes but is not limited to: + +- Do **not** add `#[allow(...)]` / `#[allow(dead_code)]` / `#[allow(unused)]` / `#[allow(unused_variables)]` or any other lint suppression attribute. +- Do **not** prefix unused variables with `_` to silence warnings (e.g. `_unused_var`). +- Do **not** use `let _ = ...` to discard results that might carry meaningful errors. +- Do **not** add `#[cfg_attr(..., allow(...))]` or equivalent conditional suppressions. +- Do **not** use `#![allow(...)]` at the crate or module level. + +If a warning or error fires, **fix the underlying issue** — remove dead code, use the variable, handle the error properly, or restructure the code. Suppressing warnings hides real problems and is not an acceptable fix. + ## Architecture Notes - **State machine driven**: each `AppState` variant has its own UI/systems wired via `OnEnter` / `OnExit` / `Update` (latter guarded by `in_state`). diff --git a/apps/game/src/camera.rs b/apps/game/src/camera.rs index 76ebe2b..bb5b687 100644 --- a/apps/game/src/camera.rs +++ b/apps/game/src/camera.rs @@ -111,6 +111,9 @@ pub fn orbit_camera_control( } for event in scroll_events.read() { + if cursor_over_ui { + continue; + } // Scroll up (positive y) → decrease distance (zoom in). orbit.distance *= 1.0 - event.y * ORBIT_ZOOM_SENSITIVITY; } diff --git a/apps/game/src/gameplay/galaxy/mod.rs b/apps/game/src/gameplay/galaxy/mod.rs index 976738c..304396f 100644 --- a/apps/game/src/gameplay/galaxy/mod.rs +++ b/apps/game/src/gameplay/galaxy/mod.rs @@ -24,7 +24,8 @@ use crate::state::AppState; pub use contents::{SystemContents, SystemContext, SystemSummary}; pub use params::{GalaxyParams, SelectedStar}; -use params::{CORE_COUNT, NEAREST_NEIGHBOR_CONNECTIONS, SPACING_ATTEMPTS}; +pub use params::{BeamParams, CoreParams, DiskParams}; +use params::{NEAREST_NEIGHBOR_CONNECTIONS, SPACING_ATTEMPTS, MIN_SYSTEM_SPACING}; pub struct GalaxyPlugin; @@ -46,6 +47,7 @@ impl Plugin for GalaxyPlugin { escape_to_main_menu, ui::param_button_handler, ui::refresh_control_panel_values, + ui::scroll_control_panel, ui::reset_view_button_handler, regenerate_galaxy_on_param_change, selection::select_star_on_click, @@ -179,116 +181,312 @@ fn generate_galaxy( (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. +/// 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. +/// +/// 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. fn generate_system_positions(params: &GalaxyParams, rng: &mut StdRng) -> Vec { - let mut systems: Vec = Vec::with_capacity(params.count); + // Total budget — pre-allocation only; each pass appends as many systems + // as it manages to place. + let total = params.core.count + + params.disk.count + + params.beam_top.count + + params.beam_bottom.count; + let mut systems: Vec = Vec::with_capacity(total); - // Spacing scales with density: smaller for tight cores, larger spread overall. - let base_spacing = (params.size / (params.count as f32).sqrt() * 1.25).clamp(9.0, 24.0); - let horizontal_arms = params.arms.max(1); - let vertical_arms = params.vertical_arms; - let total_arm_slots = horizontal_arms + vertical_arms; + // Spacing scales with overall density: tighter for crowded galaxies, + // looser for sparse ones. Same heuristic as the previous single-pass + // generator, but keyed off the combined layer count. + let base_spacing = (params.size / (total.max(1) as f32).sqrt() * 1.25).clamp(9.0, 24.0); - for index in 0..params.count { - let core = index < CORE_COUNT; - let arm_slot = (index as u32) % total_arm_slots.max(1); - let vertical = !core && arm_slot >= horizontal_arms; - let arm = if vertical { - arm_slot - horizontal_arms - } else { - arm_slot - }; - let faction_index = if core { - 0 // Concord - } else { - ((arm_slot as usize) % (FACTIONS.len() - 1)) + 1 + // Counter shared across passes for `g-{n}` IDs and name suffixes. + let mut next_index = 0usize; + + generate_core(&mut systems, rng, ¶ms.core, params.size, base_spacing, &mut next_index); + generate_disk( + &mut systems, + rng, + ¶ms.disk, + params.size, + base_spacing, + &mut next_index, + ); + if params.beam_top.enabled { + generate_beam( + &mut systems, + rng, + ¶ms.beam_top, + BeamSide::Top, + params.size, + base_spacing, + &mut next_index, + ); + } + if params.beam_bottom.enabled { + generate_beam( + &mut systems, + rng, + ¶ms.beam_bottom, + BeamSide::Bottom, + params.size, + base_spacing, + &mut next_index, + ); + } + + systems +} + +/// Which side of the galactic plane a beam occupies. +enum BeamSide { + Top, + Bottom, +} + +/// Generate the Concord core cluster near the origin. Spherically sampled: +/// uniform on a disk (XZ) plus modest Y jitter so the core has thickness. +fn generate_core( + systems: &mut Vec, + rng: &mut StdRng, + core: &CoreParams, + galaxy_size: f32, + base_spacing: f32, + next_index: &mut usize, +) { + for _ in 0..core.count { + let mut position = Option::::None; + for attempt in 0..SPACING_ATTEMPTS { + let r = rng.gen::().sqrt() * core.radius; + let theta = rng.gen::() * std::f32::consts::TAU; + // Modest vertical thickness — 20% of core radius — so the core + // reads as a 3D blob rather than a flat puck. + let y = (rng.gen::() - 0.5) * core.radius * 0.4; + let candidate = Vec3::new(r * theta.cos(), y, r * theta.sin()); + let spacing = relax_spacing(base_spacing, attempt).max(MIN_SYSTEM_SPACING) * 0.9; + if systems + .iter() + .all(|s| s.position.distance(candidate) >= spacing) + { + position = Some(candidate); + break; + } + } + let Some(position) = position else { + // Could not find a clear spot — skip this system rather than + // placing it at the origin on top of other systems. + continue; }; + let final_radius = position.length(); + // Core is always Concord (faction_index 0). + let (faction, color) = FACTIONS[0]; + let security = security_for_radius(final_radius, galaxy_size); + let idx = *next_index; + *next_index += 1; + systems.push(GeneratedSystem { + id: format!("g-{idx}"), + name: format!("COR-{}", 100 + idx), + position, + faction, + faction_index: 0, + color, + security, + is_core: true, + origin: SystemOrigin::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. +fn generate_disk( + systems: &mut Vec, + rng: &mut StdRng, + disk: &DiskParams, + galaxy_size: f32, + base_spacing: f32, + next_index: &mut usize, +) { + let arm_count = disk.arms.max(1); + 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); let (faction, color) = FACTIONS[faction_index]; - let mut position = Vec3::ZERO; + let mut position = Option::::None; let mut final_radius = 0.0f32; for attempt in 0..SPACING_ATTEMPTS { - // `pow(0.62)` produces a density bias toward the core. - let r = if core { - 8.0 + rng.gen::() * 30.0 - } 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 angle = if core { - rng.gen::() * std::f32::consts::TAU - } else { - std::f32::consts::TAU * arm as f32 / arm_count as f32 - + (r / params.size) * arm_twist - + (rng.gen::() - 0.5) * 0.72 - }; - let (x, y, z) = if vertical { - // Vertical arms: stars orbit in a vertical plane rotated around the Y axis. - let plane_angle = std::f32::consts::TAU * arm as f32 / vertical_arms.max(1) as f32; - let arm_sweep = angle.cos() * r; - let vx = plane_angle.cos() * arm_sweep; - let vz = plane_angle.sin() * arm_sweep; - 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, - ) - }; - let candidate = Vec3::new(x, y, z); + let r = rng.gen::().powf(0.62) * galaxy_size; + let angle = std::f32::consts::TAU * arm as f32 / arm_count as f32 + + (r / galaxy_size) * 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; - - // Spacing relaxation: shrink required distance after many failed attempts. - let relaxed = if attempt > 120 { - base_spacing * 0.68 - } else if attempt > 60 { - base_spacing * 0.82 - } else { - base_spacing - }; - let local_spacing = if core { relaxed * 0.9 } else { relaxed }; - let clear = systems + let spacing = relax_spacing(base_spacing, attempt).max(MIN_SYSTEM_SPACING); + if systems .iter() - .all(|s| s.position.distance(candidate) >= local_spacing); - if clear { - position = candidate; + .all(|s| s.position.distance(candidate) >= spacing) + { + position = Some(candidate); break; } } - let security = ((1.0 - (final_radius / params.size) * 2.0) * 100.0).round() / 100.0; - let name = if core { - format!("COR-{}", 100 + index) - } else { - format!("{}-{}", &faction[..3].to_uppercase(), 100 + index) + let Some(position) = position else { + continue; }; + let security = security_for_radius(final_radius, galaxy_size); + let idx = *next_index; + *next_index += 1; systems.push(GeneratedSystem { - id: format!("g-{index}"), - name, + id: format!("g-{idx}"), + name: format!("{}-{}", &faction[..3].to_uppercase(), 100 + idx), position, faction, faction_index, color, security, - is_core: core, + is_core: false, + origin: SystemOrigin::Disk { arm }, }); } +} - systems +/// Generate one beam (top or bottom) along the Y axis. Height-first +/// sampling produces a tapered cone: `t` is chosen first (biased toward +/// the base via `pow(0.62)`), then the cross-section radius at that height +/// is `thickness × (1 − t)^taper`. The `gravity` exponent concentrates +/// systems toward the Y axis within each cross-section, producing a +/// denser-brighter core. The result is a Vercidium-style diffraction +/// spike: bright and wide near the galactic center, thinning to nothing +/// at the tips. +fn generate_beam( + systems: &mut Vec, + rng: &mut StdRng, + beam: &BeamParams, + side: BeamSide, + galaxy_size: f32, + base_spacing: f32, + next_index: &mut usize, +) { + let sign = match side { + BeamSide::Top => 1.0, + BeamSide::Bottom => -1.0, + }; + let name_prefix = match side { + BeamSide::Top => "BMT", + BeamSide::Bottom => "BMB", + }; + let origin_tag = match side { + BeamSide::Top => BeamTag::Top, + BeamSide::Bottom => BeamTag::Bottom, + }; + + for i in 0..beam.count { + // Cycle through non-Concord factions so the beam isn't a single color. + let faction_index = 1 + (i as usize) % (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 { + // Height-first sampling: pick t ∈ [0, 1] biased toward the base + // (t=0 is the galactic plane, t=1 is the tip). `pow(0.62)` packs + // ~62% of systems in the lower half of the beam. + let t = rng.gen::().powf(0.62); + let y = sign * t * beam.length; + + // Tapered cross-section: radius shrinks with height as + // `thickness × (1 − t)^taper`. taper=1 → linear cone, + // taper>1 → sharper spike, taper<1 → bulbous. + let max_r = beam.thickness * (1.0 - t).powf(beam.taper); + + // Radial position within the cross-section, biased toward the + // axis by the gravity exponent. Higher gravity → tighter core. + let r = rng.gen::().powf(beam.gravity) * max_r; + let theta = rng.gen::() * std::f32::consts::TAU; + + // Small vertical jitter so the beam doesn't look like a perfect + // cone when viewed from the side. + let y_jitter = (rng.gen::() - 0.5) * 4.0; + let candidate = Vec3::new(r * theta.cos(), y + y_jitter, r * theta.sin()); + final_radius = candidate.length(); + let spacing = relax_spacing(base_spacing, attempt).max(MIN_SYSTEM_SPACING); + if systems + .iter() + .all(|s| s.position.distance(candidate) >= spacing) + { + position = Some(candidate); + break; + } + } + + let Some(position) = position else { + continue; + }; + + let security = security_for_radius(final_radius, galaxy_size); + let idx = *next_index; + *next_index += 1; + systems.push(GeneratedSystem { + id: format!("g-{idx}"), + name: format!("{name_prefix}-{}", 100 + idx), + position, + faction, + faction_index, + color, + security, + is_core: false, + origin: SystemOrigin::Beam { side: origin_tag }, + }); + } +} + +/// Spacing relaxation curve — same shape as the previous single-pass +/// generator: full spacing for the first 60 attempts, 82% until attempt +/// 120, then 68% as a fallback to guarantee placement. +fn relax_spacing(base: f32, attempt: usize) -> f32 { + if attempt > 120 { + base * 0.68 + } else if attempt > 60 { + base * 0.82 + } else { + base + } +} + +/// Security falls off linearly with distance from the origin. The same +/// formula is used for all layers (core, disk, beams) — a beam system at +/// the tip is far from the capital, so it should read as low-sec. +fn security_for_radius(radius: f32, galaxy_size: f32) -> f32 { + ((1.0 - (radius / galaxy_size) * 2.0) * 100.0).round() / 100.0 +} + +/// Which structural layer a system belongs to. Currently informational — +/// nothing keys off it yet — but reserved for per-layer styling, POI +/// biasing, or weighted connection graphs in later iterations. +#[allow(dead_code)] +#[derive(Debug, Clone, Copy)] +enum SystemOrigin { + Core, + Disk { arm: u32 }, + Beam { side: BeamTag }, +} + +#[allow(dead_code)] +#[derive(Debug, Clone, Copy)] +enum BeamTag { + Top, + Bottom, } struct GeneratedSystem { @@ -301,10 +499,14 @@ struct GeneratedSystem { /// by `faction_index`, but this is kept for future per-system variation. 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. + /// True for Concord core systems (see [`CoreParams`]). Used by + /// [`contents::generate_system_contents`] to bias planet / station counts + /// toward core systems, and by the spawner for visual differentiation. is_core: bool, + /// Which structural layer this system came from. Informational — reserved + /// for future per-layer styling or POI biasing. + #[allow(dead_code)] + origin: SystemOrigin, } fn spawn_galaxy_scene( @@ -337,6 +539,14 @@ fn spawn_galaxy_scene( }) }) .collect(); + // Beam material — distinct bright white-blue tint so beams read as + // separate structural features from the disk. + let beam_material = materials.add(StandardMaterial { + base_color: Color::srgb(0.85, 0.88, 1.0), + emissive: LinearRgba::new(1.2, 1.25, 1.5, 1.0), + unlit: true, + ..default() + }); let connection_material = materials.add(StandardMaterial { base_color: Color::srgb(0.11, 0.16, 0.25), emissive: LinearRgba::new(0.08, 0.13, 0.22, 1.0), @@ -360,6 +570,10 @@ fn spawn_galaxy_scene( let (mesh, material, scale) = if is_core { // Visual differentiation for the 7 Concord core systems. (core_star_mesh.clone(), faction_materials[0].clone(), 1.1) + } else if matches!(sys.origin, SystemOrigin::Beam { .. }) { + // Beam systems: distinct bright tint + slightly smaller + // so they read as a separate structural layer. + (star_mesh.clone(), beam_material.clone(), 0.8) } else { ( star_mesh.clone(), diff --git a/apps/game/src/gameplay/galaxy/params.rs b/apps/game/src/gameplay/galaxy/params.rs index 29997e1..9c23dce 100644 --- a/apps/game/src/gameplay/galaxy/params.rs +++ b/apps/game/src/gameplay/galaxy/params.rs @@ -3,6 +3,15 @@ //! [`GalaxyParams`] is the input to procedural generation; the UI mutates it, //! the regeneration system detects changes via the `generation` counter, and //! the generator reads it. [`SelectedStar`] tracks the currently-clicked star. +//! +//! ## Structure +//! +//! A galaxy is composed of three layers, each owning its own system count: +//! - [`CoreParams`] — Concord systems clustered near the origin. +//! - [`DiskParams`] — horizontal spiral arms in the XZ plane. +//! - [`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. use bevy::prelude::*; @@ -12,39 +21,68 @@ use bevy::prelude::*; // so the Bevy scene opens with the same look as the web demo. const DEFAULT_SEED: u64 = 4242; -const DEFAULT_COUNT: usize = 120; -const DEFAULT_ARMS: u32 = 4; -const DEFAULT_VERTICAL_ARMS: u32 = 0; const DEFAULT_SIZE: f32 = 280.0; -const DEFAULT_TWIST: f32 = 3.0; -const DEFAULT_VERTICAL_TWIST: f32 = 1.1; -// ── Limits (also mirror the TS sliders) ───────────────────────────────────── +// Core defaults: 7 Concord systems inside a 38-unit sphere (matches the old +// hardcoded `[8, 38]` core zone from the single-pass generator). +const DEFAULT_CORE_COUNT: usize = 7; +const DEFAULT_CORE_RADIUS: f32 = 38.0; + +// Disk defaults: roughly the previous default (120 total) minus the core, +// keeping the same arms/twist as the docs prototype. +const DEFAULT_DISK_COUNT: usize = 100; +const DEFAULT_DISK_ARMS: u32 = 4; +const DEFAULT_DISK_TWIST: f32 = 3.0; + +// Beam defaults: small population so the two polar columns read clearly +// without dominating the scene. Equal top/bottom for symmetry. +const DEFAULT_BEAM_ENABLED: bool = true; +const DEFAULT_BEAM_COUNT: usize = 30; +const DEFAULT_BEAM_THICKNESS: f32 = 16.0; +const DEFAULT_BEAM_LENGTH: f32 = 180.0; +const DEFAULT_BEAM_TAPER: f32 = 1.5; + +// ── Limits ────────────────────────────────────────────────────────────────── pub const SEED_STEP: u64 = 1; -pub const COUNT_MIN: usize = 40; -pub const COUNT_MAX: usize = 220; -pub const COUNT_STEP: usize = 4; -pub const ARMS_MIN: u32 = 1; -pub const ARMS_MAX: u32 = 6; -/// u32 lower bound is 0; constant kept for symmetry/documentation with the -/// other `*_MIN` bounds. -#[allow(dead_code)] -pub const VERTICAL_ARMS_MIN: u32 = 0; -pub const VERTICAL_ARMS_MAX: u32 = 4; + pub const SIZE_MIN: f32 = 140.0; pub const SIZE_MAX: f32 = 420.0; pub const SIZE_STEP: f32 = 10.0; -pub const TWIST_MIN: f32 = 1.0; -pub const TWIST_MAX: f32 = 6.0; -pub const TWIST_STEP: f32 = 0.2; -pub const VERTICAL_TWIST_MIN: f32 = 0.0; -pub const VERTICAL_TWIST_MAX: f32 = 6.0; -pub const VERTICAL_TWIST_STEP: f32 = 0.2; -/// Number of "core" Concord systems clustered at the galactic center. -/// Not exposed in the UI — kept as an internal constant for now. -pub(crate) const CORE_COUNT: usize = 7; +pub const CORE_COUNT_MIN: usize = 0; +pub const CORE_COUNT_MAX: usize = 30; +pub const CORE_COUNT_STEP: usize = 1; +pub const CORE_RADIUS_MIN: f32 = 10.0; +pub const CORE_RADIUS_MAX: f32 = 120.0; +pub const CORE_RADIUS_STEP: f32 = 5.0; + +pub const DISK_COUNT_MIN: usize = 20; +pub const DISK_COUNT_MAX: usize = 220; +pub const DISK_COUNT_STEP: usize = 4; +pub const DISK_ARMS_MIN: u32 = 1; +pub const DISK_ARMS_MAX: u32 = 6; +pub const DISK_TWIST_MIN: f32 = 1.0; +pub const DISK_TWIST_MAX: f32 = 6.0; +pub const DISK_TWIST_STEP: f32 = 0.2; + +pub const BEAM_COUNT_MIN: usize = 0; +pub const BEAM_COUNT_MAX: usize = 120; +pub const BEAM_COUNT_STEP: usize = 4; +pub const BEAM_THICKNESS_MIN: f32 = 1.0; +pub const BEAM_THICKNESS_MAX: f32 = 80.0; +pub const BEAM_THICKNESS_STEP: f32 = 1.0; +pub const BEAM_LENGTH_MIN: f32 = 20.0; +pub const BEAM_LENGTH_MAX: f32 = 600.0; +pub const BEAM_LENGTH_STEP: f32 = 20.0; +pub const BEAM_TAPER_MIN: f32 = 0.3; +pub const BEAM_TAPER_MAX: f32 = 4.0; +pub const BEAM_TAPER_STEP: f32 = 0.1; + +pub const BEAM_GRAVITY_MIN: f32 = 0.5; +pub const BEAM_GRAVITY_MAX: f32 = 4.0; +pub const BEAM_GRAVITY_STEP: f32 = 0.1; +const DEFAULT_BEAM_GRAVITY: f32 = 1.5; /// Connect each system to its N nearest neighbors (deduplicated). /// Docs prototype uses 2; kept constant until a UI control is added. @@ -53,17 +91,107 @@ pub(crate) const NEAREST_NEIGHBOR_CONNECTIONS: usize = 2; /// Max attempts to find a clear spot for each system (Poisson-style spacing). pub(crate) const SPACING_ATTEMPTS: usize = 180; +/// Minimum distance between any two star systems, regardless of relaxation. +/// Should be at least 2× the star visual radius (~1.0) so stars never visually +/// overlap. Applied as a floor to the relaxed spacing in all layers. +pub(crate) const MIN_SYSTEM_SPACING: f32 = 2.0; + +// ── Sub-structs ───────────────────────────────────────────────────────────── + +/// Concord core — densely packed Concord-faction systems at the origin. +#[derive(Debug, Clone)] +pub struct CoreParams { + pub count: usize, + /// Maximum distance from the origin for core systems. + pub radius: f32, +} + +impl Default for CoreParams { + fn default() -> Self { + Self { + count: DEFAULT_CORE_COUNT, + radius: DEFAULT_CORE_RADIUS, + } + } +} + +/// Horizontal disk of spiral arms in the XZ plane. +#[derive(Debug, Clone)] +pub struct DiskParams { + pub count: usize, + pub arms: u32, + pub twist: f32, +} + +impl Default for DiskParams { + fn default() -> Self { + Self { + count: DEFAULT_DISK_COUNT, + arms: DEFAULT_DISK_ARMS, + twist: DEFAULT_DISK_TWIST, + } + } +} + +/// One of the two polar beams (top = +Y, bottom = −Y). Beam systems are +/// distributed in a tapered cone along the beam axis; they are fully +/// selectable star systems equal to disk arms. +/// +/// The cross-section at any height `t` (0 = base, 1 = tip) has radius +/// `thickness × (1 − t)^taper`, producing a Vercidium-style diffraction +/// spike: bright and concentrated near the galactic core, thinning to +/// nothing at the tips. +#[derive(Debug, Clone)] +pub struct BeamParams { + pub enabled: bool, + /// Number of star systems to spawn in this beam. UI label: "Density". + pub count: usize, + /// Maximum cylinder radius at the base (t=0). The actual radius tapers + /// toward the tip according to `taper`. + pub thickness: f32, + /// Extent along the beam axis (one-sided; total beam length is 2 × length + /// because there are two beams). + pub length: f32, + /// Shape exponent controlling how the beam tapers. + /// < 1.0 → wide bulbous beam, barely narrows + /// = 1.0 → linear double-cone + /// = 1.5 → moderate spike (Vercidium default look) + /// > 2.0 → sharp needle + /// The cross-section radius at height `t` is `thickness × (1-t)^taper`. + pub taper: f32, + /// Radial density exponent for the beam cross-section. + /// Higher values concentrate systems toward the Y axis (tighter, brighter + /// core); lower values spread them out (more diffuse beam). + /// 1.0 = uniform disk sampling + /// 1.5 = moderate concentration (default) + /// 3.0+ = very tight, needle-like core + pub gravity: f32, +} + +impl Default for BeamParams { + fn default() -> Self { + Self { + enabled: DEFAULT_BEAM_ENABLED, + count: DEFAULT_BEAM_COUNT, + thickness: DEFAULT_BEAM_THICKNESS, + length: DEFAULT_BEAM_LENGTH, + taper: DEFAULT_BEAM_TAPER, + gravity: DEFAULT_BEAM_GRAVITY, + } + } +} + /// Procedural galaxy parameters. UI mutates fields, then bumps `generation` /// to signal the regeneration system to rebuild the scene. #[derive(Resource, Debug, Clone)] pub struct GalaxyParams { pub seed: u64, - pub count: usize, - pub arms: u32, - pub vertical_arms: u32, + /// Overall galaxy extent — drives the disk radius. pub size: f32, - pub twist: f32, - pub vertical_twist: f32, + pub core: CoreParams, + pub disk: DiskParams, + pub beam_top: BeamParams, + pub beam_bottom: BeamParams, /// Monotonic counter — any mutation must bump this via [`Self::bump_generation`]. /// The regeneration system triggers a rebuild whenever this differs from its /// last-seen value. @@ -74,12 +202,11 @@ impl Default for GalaxyParams { fn default() -> Self { Self { seed: DEFAULT_SEED, - count: DEFAULT_COUNT, - arms: DEFAULT_ARMS, - vertical_arms: DEFAULT_VERTICAL_ARMS, size: DEFAULT_SIZE, - twist: DEFAULT_TWIST, - vertical_twist: DEFAULT_VERTICAL_TWIST, + core: CoreParams::default(), + disk: 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 c89eecf..674f36c 100644 --- a/apps/game/src/gameplay/galaxy/ui.rs +++ b/apps/game/src/gameplay/galaxy/ui.rs @@ -4,8 +4,12 @@ //! Bevy 0.16 does not ship a native Slider widget, so each parameter is a //! row of `Label Value [-] [+]` buttons. Each `+/-` button carries a //! [`ParamButton`] marker identifying which field to mutate. +//! +//! Rows are grouped under section headers (Galaxy, Core, Disk, Top Beam, +//! Bottom Beam) for visual clarity now that the panel spans five conceptual +//! layers. -use bevy::prelude::*; +use bevy::{input::mouse::MouseWheel, prelude::*, ui::ScrollPosition, window::PrimaryWindow}; use super::GalaxySpawned; use crate::gameplay::galaxy::params::*; @@ -15,6 +19,10 @@ use crate::gameplay::galaxy::params::*; #[derive(Component)] pub struct GalaxyControlPanel; +/// Marker for the scrollable inner container of the control panel. +#[derive(Component)] +pub struct GalaxyScrollContent; + #[derive(Component)] pub struct GalaxyInfoPanel; @@ -23,18 +31,46 @@ pub struct GalaxyInfoPanel; pub enum ParamButton { SeedDecr, SeedIncr, - CountDecr, - CountIncr, - ArmsDecr, - ArmsIncr, - VerticalArmsDecr, - VerticalArmsIncr, SizeDecr, SizeIncr, - TwistDecr, - TwistIncr, - VerticalTwistDecr, - VerticalTwistIncr, + // Core + CoreCountDecr, + CoreCountIncr, + CoreRadiusDecr, + CoreRadiusIncr, + // Disk + DiskCountDecr, + DiskCountIncr, + DiskArmsDecr, + DiskArmsIncr, + DiskTwistDecr, + DiskTwistIncr, + // Top beam (+Y) + BeamTopEnabled, + BeamTopThicknessDecr, + BeamTopThicknessIncr, + BeamTopLengthDecr, + BeamTopLengthIncr, + BeamTopDensityDecr, + BeamTopDensityIncr, + BeamTopTaperDecr, + BeamTopTaperIncr, + BeamTopGravityDecr, + BeamTopGravityIncr, + // Bottom beam (-Y) + BeamBottomEnabled, + BeamBottomThicknessDecr, + BeamBottomThicknessIncr, + BeamBottomLengthDecr, + BeamBottomLengthIncr, + BeamBottomDensityDecr, + BeamBottomDensityIncr, + BeamBottomTaperDecr, + BeamBottomTaperIncr, + BeamBottomGravityDecr, + BeamBottomGravityIncr, + /// Randomize all params to random valid values within their bounds. + RandomSettings, /// Randomize: bump seed by 1 (equivalent to the docs "Regenerate" button). Regenerate, /// Reset orbit camera to default orientation. @@ -47,11 +83,13 @@ const PANEL_BG: Color = Color::srgb(0.05, 0.07, 0.12); const PANEL_BORDER: Color = Color::srgb(0.25, 0.40, 0.62); 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 PANEL_WIDTH: f32 = 280.0; +const PANEL_WIDTH: f32 = 300.0; const PANEL_PADDING: f32 = 14.0; const TITLE_FONT_SIZE: f32 = 20.0; +const SECTION_FONT_SIZE: f32 = 12.0; const LABEL_FONT_SIZE: f32 = 15.0; const VALUE_FONT_SIZE: f32 = 15.0; const BUTTON_FONT_SIZE: f32 = 16.0; @@ -71,10 +109,9 @@ fn spawn_control_panel(commands: &mut Commands) { position_type: PositionType::Absolute, left: Val::Px(12.0), top: Val::Px(12.0), + bottom: Val::Px(12.0), width: Val::Px(PANEL_WIDTH), - padding: UiRect::all(Val::Px(PANEL_PADDING)), flex_direction: FlexDirection::Column, - row_gap: Val::Px(6.0), border: UiRect::all(Val::Px(1.0)), ..default() }, @@ -85,6 +122,7 @@ fn spawn_control_panel(commands: &mut Commands) { GalaxySpawned, )) .with_children(|parent| { + // Sticky title — pinned above the scroll area. parent.spawn(( Text::new("Galaxy Parameters"), TextFont { @@ -93,29 +131,18 @@ fn spawn_control_panel(commands: &mut Commands) { }, TextColor(TEXT_BRIGHT), Node { - margin: UiRect::bottom(Val::Px(8.0)), + padding: UiRect::px(PANEL_PADDING, PANEL_PADDING, PANEL_PADDING, 4.0), ..default() }, )); - // Current params are read at button-press time in `param_button_handler`, - // so we don't need to thread `Res` through setup. - spawn_param_row(parent, "Seed", "seed", ParamButton::SeedDecr, ParamButton::SeedIncr); - spawn_param_row(parent, "Systems", "count", ParamButton::CountDecr, ParamButton::CountIncr); - spawn_param_row(parent, "Disk Arms", "arms", ParamButton::ArmsDecr, ParamButton::ArmsIncr); - spawn_param_row(parent, "Vertical Arms", "varms", ParamButton::VerticalArmsDecr, ParamButton::VerticalArmsIncr); - spawn_param_row(parent, "Size", "size", ParamButton::SizeDecr, ParamButton::SizeIncr); - spawn_param_row(parent, "Twist", "twist", ParamButton::TwistDecr, ParamButton::TwistIncr); - spawn_param_row(parent, "Vertical Twist", "vtwist", ParamButton::VerticalTwistDecr, ParamButton::VerticalTwistIncr); - - // Regenerate (randomize seed) button. + // Randomize button — pinned above the scroll area. parent .spawn(( Button, Node { - width: Val::Percent(100.0), height: Val::Px(32.0), - margin: UiRect::top(Val::Px(8.0)), + margin: UiRect::px(PANEL_PADDING, PANEL_PADDING, 4.0, PANEL_PADDING), justify_content: JustifyContent::Center, align_items: AlignItems::Center, border: UiRect::all(Val::Px(1.0)), @@ -124,11 +151,11 @@ fn spawn_control_panel(commands: &mut Commands) { BackgroundColor(BUTTON_BG), BorderColor(PANEL_BORDER), BorderRadius::all(Val::Px(6.0)), - ParamButton::Regenerate, + ParamButton::RandomSettings, )) .with_children(|btn| { btn.spawn(( - Text::new("Regenerate"), + Text::new("Randomize Settings"), TextFont { font_size: BUTTON_FONT_SIZE, ..default() @@ -137,51 +164,243 @@ fn spawn_control_panel(commands: &mut Commands) { )); }); - // Center View (reset orbit camera) button. + // Scrollable content area. parent .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)), + 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() }, - BackgroundColor(BUTTON_BG), - BorderColor(PANEL_BORDER), - BorderRadius::all(Val::Px(6.0)), - ParamButton::CenterView, + ScrollPosition::default(), + GalaxyScrollContent, )) - .with_children(|btn| { - btn.spawn(( - Text::new("Center View"), + .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: BUTTON_FONT_SIZE, + font_size: HELP_FONT_SIZE, + ..default() + }, + TextColor(TEXT_DIM), + Node { + margin: UiRect::top(Val::Px(10.0)), ..default() }, - TextColor(TEXT_BRIGHT), )); }); - - // Help text. - parent.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() - }, - )); }); } +/// Section header row — small dim label with a top margin to separate groups. +fn spawn_section(parent: &mut ChildSpawnerCommands, label: &str) { + parent.spawn(( + Text::new(label.to_uppercase()), + TextFont { + font_size: SECTION_FONT_SIZE, + ..default() + }, + TextColor(SECTION_TEXT), + Node { + margin: UiRect::top(Val::Px(4.0)), + ..default() + }, + )); +} + /// Build a `[Label ........ value [-] [+]]` row. The `value_key` is stored in /// the marker as a label for the value text child; the actual value is read /// from `GalaxyParams` at button-press time and updated by @@ -211,7 +430,7 @@ fn spawn_param_row( }, TextColor(TEXT_DIM), Node { - width: Val::Px(118.0), + width: Val::Px(128.0), ..default() }, )); @@ -238,17 +457,74 @@ fn spawn_param_row( }); } +/// `[Label ........ value [ø]]` — a single-button row for boolean toggles +/// (Enabled / Disabled). The button cycles the field on each press; the value +/// text displays "On"/"Off" via [`refresh_control_panel_values`]. +fn spawn_toggle_row( + parent: &mut ChildSpawnerCommands, + label: &str, + value_key: &str, + toggle: ParamButton, +) { + parent + .spawn(Node { + width: Val::Percent(100.0), + flex_direction: FlexDirection::Row, + align_items: AlignItems::Center, + column_gap: Val::Px(6.0), + ..default() + }) + .with_children(|row| { + row.spawn(( + Text::new(label), + TextFont { + font_size: LABEL_FONT_SIZE, + ..default() + }, + TextColor(TEXT_DIM), + Node { + width: Val::Px(128.0), + ..default() + }, + )); + row.spawn(( + Text::new("—"), + TextFont { + font_size: VALUE_FONT_SIZE, + ..default() + }, + TextColor(TEXT_BRIGHT), + Node { + flex_grow: 1.0, + ..default() + }, + ParamValue(value_key.to_string()), + )); + // Wider button so the toggle label "ø" / "✓" reads clearly. + parent_button(row, toggle, "ø", 34.0); + }); +} + /// Marker on the value Text node so `refresh_control_panel_values` can find /// and update it. The `String` is the param field name (e.g. "seed", "twist"). #[derive(Component)] pub(crate) struct ParamValue(pub(crate) String); fn spawn_icon_button(parent: &mut ChildSpawnerCommands, label: &str, marker: ParamButton) { + parent_button(parent, marker, label, 26.0); +} + +fn parent_button( + parent: &mut ChildSpawnerCommands, + marker: ParamButton, + label: &str, + width: f32, +) { parent .spawn(( Button, Node { - width: Val::Px(26.0), + width: Val::Px(width), height: Val::Px(26.0), justify_content: JustifyContent::Center, align_items: AlignItems::Center, @@ -329,6 +605,7 @@ pub fn param_button_handler( continue; }; match button { + // ── Galaxy ──────────────────────────────────────────────────── ParamButton::SeedDecr => { params.seed = params.seed.wrapping_sub(SEED_STEP); params.bump_generation(); @@ -337,33 +614,6 @@ pub fn param_button_handler( params.seed = params.seed.wrapping_add(SEED_STEP); params.bump_generation(); } - ParamButton::CountDecr => { - params.count = params.count.saturating_sub(COUNT_STEP).max(COUNT_MIN); - params.bump_generation(); - } - ParamButton::CountIncr => { - params.count = (params.count + COUNT_STEP).min(COUNT_MAX); - params.bump_generation(); - } - ParamButton::ArmsDecr => { - params.arms = params.arms.saturating_sub(1).max(ARMS_MIN); - params.bump_generation(); - } - ParamButton::ArmsIncr => { - params.arms = params.arms.saturating_add(1).min(ARMS_MAX); - params.bump_generation(); - } - ParamButton::VerticalArmsDecr => { - params.vertical_arms = params.vertical_arms.saturating_sub(1); - params.bump_generation(); - } - ParamButton::VerticalArmsIncr => { - params.vertical_arms = params - .vertical_arms - .saturating_add(1) - .min(VERTICAL_ARMS_MAX); - params.bump_generation(); - } ParamButton::SizeDecr => { params.size = (params.size - SIZE_STEP).max(SIZE_MIN); params.bump_generation(); @@ -372,20 +622,189 @@ pub fn param_button_handler( params.size = (params.size + SIZE_STEP).min(SIZE_MAX); params.bump_generation(); } - ParamButton::TwistDecr => { - params.twist = (params.twist - TWIST_STEP).max(TWIST_MIN); + // ── Core ────────────────────────────────────────────────────── + ParamButton::CoreCountDecr => { + params.core.count = params + .core + .count + .saturating_sub(CORE_COUNT_STEP) + .max(CORE_COUNT_MIN); params.bump_generation(); } - ParamButton::TwistIncr => { - params.twist = (params.twist + TWIST_STEP).min(TWIST_MAX); + ParamButton::CoreCountIncr => { + params.core.count = (params.core.count + CORE_COUNT_STEP).min(CORE_COUNT_MAX); params.bump_generation(); } - ParamButton::VerticalTwistDecr => { - params.vertical_twist = (params.vertical_twist - VERTICAL_TWIST_STEP).max(VERTICAL_TWIST_MIN); + ParamButton::CoreRadiusDecr => { + params.core.radius = (params.core.radius - CORE_RADIUS_STEP).max(CORE_RADIUS_MIN); params.bump_generation(); } - ParamButton::VerticalTwistIncr => { - params.vertical_twist = (params.vertical_twist + VERTICAL_TWIST_STEP).min(VERTICAL_TWIST_MAX); + ParamButton::CoreRadiusIncr => { + params.core.radius = (params.core.radius + CORE_RADIUS_STEP).min(CORE_RADIUS_MAX); + params.bump_generation(); + } + // ── Disk ────────────────────────────────────────────────────── + ParamButton::DiskCountDecr => { + params.disk.count = params + .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); + params.bump_generation(); + } + ParamButton::DiskArmsDecr => { + params.disk.arms = params.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); + params.bump_generation(); + } + ParamButton::DiskTwistDecr => { + params.disk.twist = (params.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); + params.bump_generation(); + } + // ── Top Beam (+Y) ───────────────────────────────────────────── + ParamButton::BeamTopEnabled => { + params.beam_top.enabled = !params.beam_top.enabled; + params.bump_generation(); + } + ParamButton::BeamTopThicknessDecr => { + params.beam_top.thickness = + (params.beam_top.thickness - BEAM_THICKNESS_STEP).max(BEAM_THICKNESS_MIN); + params.bump_generation(); + } + ParamButton::BeamTopThicknessIncr => { + params.beam_top.thickness = + (params.beam_top.thickness + BEAM_THICKNESS_STEP).min(BEAM_THICKNESS_MAX); + params.bump_generation(); + } + ParamButton::BeamTopLengthDecr => { + params.beam_top.length = + (params.beam_top.length - BEAM_LENGTH_STEP).max(BEAM_LENGTH_MIN); + params.bump_generation(); + } + ParamButton::BeamTopLengthIncr => { + params.beam_top.length = + (params.beam_top.length + BEAM_LENGTH_STEP).min(BEAM_LENGTH_MAX); + params.bump_generation(); + } + ParamButton::BeamTopDensityDecr => { + params.beam_top.count = params + .beam_top + .count + .saturating_sub(BEAM_COUNT_STEP) + .max(BEAM_COUNT_MIN); + params.bump_generation(); + } + ParamButton::BeamTopDensityIncr => { + params.beam_top.count = + (params.beam_top.count + BEAM_COUNT_STEP).min(BEAM_COUNT_MAX); + params.bump_generation(); + } + ParamButton::BeamTopTaperDecr => { + params.beam_top.taper = + (params.beam_top.taper - BEAM_TAPER_STEP).max(BEAM_TAPER_MIN); + params.bump_generation(); + } + ParamButton::BeamTopTaperIncr => { + params.beam_top.taper = + (params.beam_top.taper + BEAM_TAPER_STEP).min(BEAM_TAPER_MAX); + params.bump_generation(); + } + ParamButton::BeamTopGravityDecr => { + params.beam_top.gravity = + (params.beam_top.gravity - BEAM_GRAVITY_STEP).max(BEAM_GRAVITY_MIN); + params.bump_generation(); + } + ParamButton::BeamTopGravityIncr => { + params.beam_top.gravity = + (params.beam_top.gravity + BEAM_GRAVITY_STEP).min(BEAM_GRAVITY_MAX); + params.bump_generation(); + } + // ── Bottom Beam (-Y) ────────────────────────────────────────── + ParamButton::BeamBottomEnabled => { + params.beam_bottom.enabled = !params.beam_bottom.enabled; + params.bump_generation(); + } + ParamButton::BeamBottomThicknessDecr => { + 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.bump_generation(); + } + ParamButton::BeamBottomLengthDecr => { + params.beam_bottom.length = + (params.beam_bottom.length - BEAM_LENGTH_STEP).max(BEAM_LENGTH_MIN); + params.bump_generation(); + } + ParamButton::BeamBottomLengthIncr => { + params.beam_bottom.length = + (params.beam_bottom.length + BEAM_LENGTH_STEP).min(BEAM_LENGTH_MAX); + params.bump_generation(); + } + ParamButton::BeamBottomDensityDecr => { + params.beam_bottom.count = params + .beam_bottom + .count + .saturating_sub(BEAM_COUNT_STEP) + .max(BEAM_COUNT_MIN); + params.bump_generation(); + } + ParamButton::BeamBottomDensityIncr => { + params.beam_bottom.count = + (params.beam_bottom.count + BEAM_COUNT_STEP).min(BEAM_COUNT_MAX); + params.bump_generation(); + } + ParamButton::BeamBottomTaperDecr => { + params.beam_bottom.taper = + (params.beam_bottom.taper - BEAM_TAPER_STEP).max(BEAM_TAPER_MIN); + params.bump_generation(); + } + ParamButton::BeamBottomTaperIncr => { + params.beam_bottom.taper = + (params.beam_bottom.taper + BEAM_TAPER_STEP).min(BEAM_TAPER_MAX); + params.bump_generation(); + } + ParamButton::BeamBottomGravityDecr => { + 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.bump_generation(); + } + ParamButton::RandomSettings => { + use rand::Rng; + let mut rng = rand::thread_rng(); + params.seed = rng.gen(); + params.size = rng.gen_range(SIZE_MIN..=SIZE_MAX); + // Round size to step. + 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; + randomize_beam(&mut params.beam_top, &mut rng); + randomize_beam(&mut params.beam_bottom, &mut rng); params.bump_generation(); } ParamButton::Regenerate => params.reseed_and_bump(), @@ -416,20 +835,40 @@ 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 (≤14 nodes) and avoids re-spawning the whole panel. +/// — cheap (≤25 nodes) and avoids re-spawning the whole panel. pub fn refresh_control_panel_values( params: Res, mut values: Query<(&ParamValue, &mut Text)>, ) { for (marker, mut text) in &mut values { let new = match marker.0.as_str() { + // Galaxy "seed" => format!("{}", params.seed), - "count" => format!("{}", params.count), - "arms" => format!("{}", params.arms), - "varms" => format!("{}", params.vertical_arms), "size" => format!("{:.0}", params.size), - "twist" => format!("{:.1}", params.twist), - "vtwist" => format!("{:.1}", params.vertical_twist), + // 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), + // Top beam + "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_thickness" => format!("{:.0}", params.beam_bottom.thickness), + "beam_bottom_length" => format!("{:.0}", params.beam_bottom.length), + "beam_bottom_density" => format!("{}", params.beam_bottom.count), + "beam_bottom_taper" => format!("{:.1}", params.beam_bottom.taper), + "beam_bottom_gravity" => format!("{:.1}", params.beam_bottom.gravity), _ => continue, }; if text.0 != new { @@ -443,3 +882,60 @@ pub fn refresh_control_panel_values( // `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. + +/// Randomize all fields of a [`BeamParams`] to random valid values within +/// their respective bounds, rounded to the UI step. +fn randomize_beam(beam: &mut BeamParams, rng: &mut impl rand::Rng) { + beam.enabled = rng.gen(); + beam.count = rng.gen_range(BEAM_COUNT_MIN..=BEAM_COUNT_MAX); + beam.count = (beam.count / BEAM_COUNT_STEP) * BEAM_COUNT_STEP; + beam.thickness = rng.gen_range(BEAM_THICKNESS_MIN..=BEAM_THICKNESS_MAX); + beam.thickness = (beam.thickness / BEAM_THICKNESS_STEP).round() * BEAM_THICKNESS_STEP; + beam.length = rng.gen_range(BEAM_LENGTH_MIN..=BEAM_LENGTH_MAX); + beam.length = (beam.length / BEAM_LENGTH_STEP).round() * BEAM_LENGTH_STEP; + beam.taper = rng.gen_range(BEAM_TAPER_MIN..=BEAM_TAPER_MAX); + beam.taper = (beam.taper / BEAM_TAPER_STEP).round() * BEAM_TAPER_STEP; + beam.gravity = rng.gen_range(BEAM_GRAVITY_MIN..=BEAM_GRAVITY_MAX); + beam.gravity = (beam.gravity / BEAM_GRAVITY_STEP).round() * BEAM_GRAVITY_STEP; +} + +/// Route mouse-wheel events to the control panel's scroll container when the +/// cursor is over the panel. Bevy 0.16 does not wire `MouseWheel` to +/// `ScrollPosition` automatically — this system bridges the gap. +pub fn scroll_control_panel( + mut scroll_events: EventReader, + primary_window: Query<&Window, With>, + panel_nodes: Query<(&ComputedNode, &GlobalTransform), With>, + mut scroll_content: Query<&mut ScrollPosition, With>, +) { + let Ok(window) = primary_window.single() else { + scroll_events.clear(); + return; + }; + + let Some(cursor_pos) = window.cursor_position() else { + scroll_events.clear(); + return; + }; + + // Check if cursor is within any control panel node's rect. + let over_panel = panel_nodes.iter().any(|(node, transform)| { + let pos = transform.translation().truncate(); + 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 + }); + + if !over_panel { + return; + } + + let Ok(mut scroll_pos) = scroll_content.single_mut() else { + return; + }; + + for event in scroll_events.read() { + scroll_pos.offset_y -= event.y * 20.0; + } +}