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
This commit is contained in:
2026-06-09 23:07:55 -04:00
parent b372a75a84
commit f83a0c5e59
5 changed files with 1080 additions and 228 deletions

View File

@@ -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`).

View File

@@ -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;
}

View File

@@ -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<GeneratedSystem> {
let mut systems: Vec<GeneratedSystem> = 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<GeneratedSystem> = 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, &params.core, params.size, base_spacing, &mut next_index);
generate_disk(
&mut systems,
rng,
&params.disk,
params.size,
base_spacing,
&mut next_index,
);
if params.beam_top.enabled {
generate_beam(
&mut systems,
rng,
&params.beam_top,
BeamSide::Top,
params.size,
base_spacing,
&mut next_index,
);
}
if params.beam_bottom.enabled {
generate_beam(
&mut systems,
rng,
&params.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<GeneratedSystem>,
rng: &mut StdRng,
core: &CoreParams,
galaxy_size: f32,
base_spacing: f32,
next_index: &mut usize,
) {
for _ in 0..core.count {
let mut position = Option::<Vec3>::None;
for attempt in 0..SPACING_ATTEMPTS {
let r = rng.gen::<f32>().sqrt() * core.radius;
let theta = rng.gen::<f32>() * 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::<f32>() - 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<GeneratedSystem>,
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::<Vec3>::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::<f32>() * 30.0
} else {
rng.gen::<f32>().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::<f32>() * std::f32::consts::TAU
} else {
std::f32::consts::TAU * arm as f32 / arm_count as f32
+ (r / params.size) * arm_twist
+ (rng.gen::<f32>() - 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::<f32>() - 0.5) * 12.0;
(vx, vy, vz)
} else {
(
angle.cos() * r,
(rng.gen::<f32>() - 0.5) * 20.0,
angle.sin() * r,
)
};
let candidate = Vec3::new(x, y, z);
let r = rng.gen::<f32>().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::<f32>() - 0.5) * 0.72;
let y = (rng.gen::<f32>() - 0.5) * 20.0;
let candidate = Vec3::new(angle.cos() * r, y, angle.sin() * r);
final_radius = r;
// 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<GeneratedSystem>,
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::<Vec3>::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::<f32>().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::<f32>().powf(beam.gravity) * max_r;
let theta = rng.gen::<f32>() * 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::<f32>() - 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(),

View File

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

View File

@@ -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<GalaxyParams>` 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<GalaxyParams>,
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<MouseWheel>,
primary_window: Query<&Window, With<PrimaryWindow>>,
panel_nodes: Query<(&ComputedNode, &GlobalTransform), With<GalaxyControlPanel>>,
mut scroll_content: Query<&mut ScrollPosition, With<GalaxyScrollContent>>,
) {
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;
}
}