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

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