- Add multi-disk support (up to 4 independent disk layers) with per-disk arms, twist, tilt, rotation, inner/outer radius - Add polar beams (top/bottom) with taper, gravity, thickness controls - Implement tab-based disk selector UI with add/remove disk - Add Randomize Settings button for full param randomization - Add Regenerate (reseed) and Center View buttons - Add per-system contents generation (planets, belts, stations, anomalies, gas clouds, stargates) with orbital mechanics - Refactor control panel into scrollable sections with value display
1388 lines
51 KiB
Rust
1388 lines
51 KiB
Rust
//! Galaxy UI: parameter slider panel (left) and selected-system
|
||
//! info panel (right).
|
||
//!
|
||
//! 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.
|
||
//!
|
||
//! ## Multi-disk UI
|
||
//!
|
||
//! Disk layers use a **tab selector** pattern: a row of numbered buttons
|
||
//! (1 2 3 …) picks which disk the parameter rows below edit. An "Add Disk"
|
||
//! button appears when below [`MAX_DISKS`]; a "Remove" button appears for
|
||
//! non-primary disks. This keeps the UI at constant height regardless of
|
||
//! how many disks are active.
|
||
|
||
use bevy::{input::mouse::MouseWheel, prelude::*, ui::ScrollPosition, window::PrimaryWindow};
|
||
|
||
use super::GalaxySpawned;
|
||
use crate::gameplay::galaxy::params::*;
|
||
|
||
// ── Markers ─────────────────────────────────────────────────────────────────
|
||
|
||
#[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;
|
||
|
||
/// Identifies which parameter a `+/-` button mutates and which direction.
|
||
#[derive(Component, Clone, Copy)]
|
||
pub enum ParamButton {
|
||
SeedDecr,
|
||
SeedIncr,
|
||
SizeDecr,
|
||
SizeIncr,
|
||
// Core
|
||
CoreCountDecr,
|
||
CoreCountIncr,
|
||
CoreRadiusDecr,
|
||
CoreRadiusIncr,
|
||
// Disk — tab selection + add/remove
|
||
DiskTab(usize),
|
||
DiskAdd,
|
||
DiskRemove,
|
||
// Disk — per-field controls (target the currently-selected disk)
|
||
DiskCountDecr,
|
||
DiskCountIncr,
|
||
DiskArmsDecr,
|
||
DiskArmsIncr,
|
||
DiskTwistDecr,
|
||
DiskTwistIncr,
|
||
DiskTiltDecr,
|
||
DiskTiltIncr,
|
||
DiskRotationDecr,
|
||
DiskRotationIncr,
|
||
DiskInnerRadiusDecr,
|
||
DiskInnerRadiusIncr,
|
||
DiskOuterRadiusDecr,
|
||
DiskOuterRadiusIncr,
|
||
// Top beam (+Y)
|
||
BeamTopEnabled,
|
||
BeamTopThicknessDecr,
|
||
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.
|
||
CenterView,
|
||
}
|
||
|
||
/// Resource tracking which disk layer is currently selected in the tab bar.
|
||
/// Always clamped to a valid index when the panel is rebuilt.
|
||
#[derive(Resource, Debug, Clone, Copy)]
|
||
pub struct SelectedDisk(pub usize);
|
||
|
||
impl Default for SelectedDisk {
|
||
fn default() -> Self {
|
||
Self(0)
|
||
}
|
||
}
|
||
|
||
/// Flag resource inserted when the disk count changes (add/remove). The
|
||
/// [`rebuild_scroll_content`] system reads and removes it, re-spawning
|
||
/// the scroll area with the correct number of tab buttons.
|
||
#[derive(Resource)]
|
||
pub(super) struct RebuildScrollContent;
|
||
|
||
// ── Styling constants ───────────────────────────────────────────────────────
|
||
|
||
const PANEL_BG: Color = Color::srgb(0.05, 0.07, 0.12);
|
||
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 TAB_ACTIVE_BG: Color = Color::srgb(0.18, 0.28, 0.46);
|
||
const TAB_INACTIVE_BG: Color = Color::srgb(0.08, 0.11, 0.18);
|
||
|
||
const PANEL_WIDTH: f32 = 300.0;
|
||
const PANEL_PADDING: f32 = 14.0;
|
||
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;
|
||
const HELP_FONT_SIZE: f32 = 12.0;
|
||
const TAB_FONT_SIZE: f32 = 14.0;
|
||
|
||
// ── Setup ───────────────────────────────────────────────────────────────────
|
||
|
||
pub fn setup_galaxy_ui(mut commands: Commands, params: Res<GalaxyParams>) {
|
||
commands.init_resource::<SelectedDisk>();
|
||
spawn_control_panel(&mut commands, ¶ms);
|
||
spawn_info_panel_empty(&mut commands);
|
||
}
|
||
|
||
fn spawn_control_panel(commands: &mut Commands, params: &GalaxyParams) {
|
||
commands
|
||
.spawn((
|
||
Node {
|
||
position_type: PositionType::Absolute,
|
||
left: Val::Px(12.0),
|
||
top: Val::Px(12.0),
|
||
bottom: Val::Px(12.0),
|
||
width: Val::Px(PANEL_WIDTH),
|
||
flex_direction: FlexDirection::Column,
|
||
border: UiRect::all(Val::Px(1.0)),
|
||
..default()
|
||
},
|
||
BackgroundColor(PANEL_BG),
|
||
BorderColor(PANEL_BORDER),
|
||
BorderRadius::all(Val::Px(8.0)),
|
||
GalaxyControlPanel,
|
||
GalaxySpawned,
|
||
))
|
||
.with_children(|parent| {
|
||
// Sticky title — pinned above the scroll area.
|
||
parent.spawn((
|
||
Text::new("Galaxy Parameters"),
|
||
TextFont {
|
||
font_size: TITLE_FONT_SIZE,
|
||
..default()
|
||
},
|
||
TextColor(TEXT_BRIGHT),
|
||
Node {
|
||
padding: UiRect::px(PANEL_PADDING, PANEL_PADDING, PANEL_PADDING, 4.0),
|
||
..default()
|
||
},
|
||
));
|
||
|
||
// Randomize button — pinned above the scroll area.
|
||
parent
|
||
.spawn((
|
||
Button,
|
||
Node {
|
||
height: Val::Px(32.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)),
|
||
..default()
|
||
},
|
||
BackgroundColor(BUTTON_BG),
|
||
BorderColor(PANEL_BORDER),
|
||
BorderRadius::all(Val::Px(6.0)),
|
||
ParamButton::RandomSettings,
|
||
))
|
||
.with_children(|btn| {
|
||
btn.spawn((
|
||
Text::new("Randomize Settings"),
|
||
TextFont {
|
||
font_size: BUTTON_FONT_SIZE,
|
||
..default()
|
||
},
|
||
TextColor(TEXT_BRIGHT),
|
||
));
|
||
});
|
||
|
||
// Scrollable content area.
|
||
parent
|
||
.spawn((
|
||
Node {
|
||
flex_grow: 1.0,
|
||
flex_direction: FlexDirection::Column,
|
||
row_gap: Val::Px(6.0),
|
||
padding: UiRect::px(
|
||
PANEL_PADDING,
|
||
PANEL_PADDING,
|
||
PANEL_PADDING,
|
||
PANEL_PADDING,
|
||
),
|
||
overflow: Overflow::scroll_y(),
|
||
..default()
|
||
},
|
||
ScrollPosition::default(),
|
||
GalaxyScrollContent,
|
||
))
|
||
.with_children(|scroll| {
|
||
spawn_scroll_contents_with_tabs(scroll, params, 0);
|
||
});
|
||
});
|
||
}
|
||
|
||
/// Build all rows inside the scroll area. This is the version used by
|
||
/// [`spawn_control_panel`] for the initial build. The full rebuild variant
|
||
/// that knows the current params / active tab is [`spawn_scroll_contents_with_tabs`].
|
||
fn spawn_scroll_contents(scroll: &mut ChildSpawnerCommands) {
|
||
// Delegate to the tab-aware builder with default params context.
|
||
// The initial build uses a single default disk.
|
||
let default_params = GalaxyParams::default();
|
||
spawn_scroll_contents_with_tabs(scroll, &default_params, 0);
|
||
}
|
||
|
||
/// Marker for the disk "Remove" button so we can toggle its visibility.
|
||
#[derive(Component)]
|
||
struct DiskRemoveButton;
|
||
|
||
/// Marker for disk tab buttons so we can restyle the active tab.
|
||
#[derive(Component)]
|
||
struct DiskTabButton(usize);
|
||
|
||
// ── Section / row helpers ───────────────────────────────────────────────────
|
||
|
||
/// Section header row — small dim label with a top margin to separate groups.
|
||
fn spawn_section(parent: &mut ChildSpawnerCommands, label: &str) {
|
||
parent.spawn((
|
||
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
|
||
/// `refresh_control_panel_values` each frame.
|
||
fn spawn_param_row(
|
||
parent: &mut ChildSpawnerCommands,
|
||
label: &str,
|
||
value_key: &str,
|
||
decr: ParamButton,
|
||
incr: 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| {
|
||
// Label — fixed width so values align.
|
||
row.spawn((
|
||
Text::new(label),
|
||
TextFont {
|
||
font_size: LABEL_FONT_SIZE,
|
||
..default()
|
||
},
|
||
TextColor(TEXT_DIM),
|
||
Node {
|
||
width: Val::Px(100.0),
|
||
..default()
|
||
},
|
||
));
|
||
|
||
// Value — flexible width, right-aligned textually by spacer below.
|
||
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()),
|
||
));
|
||
|
||
// [-] button.
|
||
spawn_icon_button(row, "−", decr);
|
||
// [+] button.
|
||
spawn_icon_button(row, "+", incr);
|
||
});
|
||
}
|
||
|
||
/// `[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(100.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(width),
|
||
height: Val::Px(26.0),
|
||
justify_content: JustifyContent::Center,
|
||
align_items: AlignItems::Center,
|
||
border: UiRect::all(Val::Px(1.0)),
|
||
..default()
|
||
},
|
||
BackgroundColor(BUTTON_BG),
|
||
BorderColor(PANEL_BORDER),
|
||
BorderRadius::all(Val::Px(4.0)),
|
||
marker,
|
||
))
|
||
.with_children(|btn| {
|
||
btn.spawn((
|
||
Text::new(label),
|
||
TextFont {
|
||
font_size: BUTTON_FONT_SIZE,
|
||
..default()
|
||
},
|
||
TextColor(TEXT_BRIGHT),
|
||
));
|
||
});
|
||
}
|
||
|
||
/// Spawn the info panel in its "no selection" state. The selection module
|
||
/// replaces its contents whenever [`super::SelectedStar`] changes.
|
||
fn spawn_info_panel_empty(commands: &mut Commands) {
|
||
commands
|
||
.spawn((
|
||
Node {
|
||
position_type: PositionType::Absolute,
|
||
right: Val::Px(12.0),
|
||
top: Val::Px(12.0),
|
||
width: Val::Px(PANEL_WIDTH),
|
||
padding: UiRect::all(Val::Px(PANEL_PADDING)),
|
||
flex_direction: FlexDirection::Column,
|
||
row_gap: Val::Px(4.0),
|
||
border: UiRect::all(Val::Px(1.0)),
|
||
..default()
|
||
},
|
||
BackgroundColor(PANEL_BG),
|
||
BorderColor(PANEL_BORDER),
|
||
BorderRadius::all(Val::Px(8.0)),
|
||
GalaxyInfoPanel,
|
||
GalaxySpawned,
|
||
))
|
||
.with_children(|parent| {
|
||
parent.spawn((
|
||
Text::new("Selected System"),
|
||
TextFont {
|
||
font_size: TITLE_FONT_SIZE,
|
||
..default()
|
||
},
|
||
TextColor(TEXT_BRIGHT),
|
||
Node {
|
||
margin: UiRect::bottom(Val::Px(8.0)),
|
||
..default()
|
||
},
|
||
));
|
||
parent.spawn((
|
||
Text::new("Click a star to inspect."),
|
||
TextFont {
|
||
font_size: LABEL_FONT_SIZE,
|
||
..default()
|
||
},
|
||
TextColor(TEXT_DIM),
|
||
));
|
||
});
|
||
}
|
||
|
||
// ── Button handler ──────────────────────────────────────────────────────────
|
||
|
||
pub fn param_button_handler(
|
||
mut commands: Commands,
|
||
mut params: ResMut<GalaxyParams>,
|
||
mut selected_disk: ResMut<SelectedDisk>,
|
||
query: Query<(&Interaction, &ParamButton), Changed<Interaction>>,
|
||
) {
|
||
for (interaction, button) in &query {
|
||
let &Interaction::Pressed = interaction else {
|
||
continue;
|
||
};
|
||
match button {
|
||
// ── Galaxy ────────────────────────────────────────────────────
|
||
ParamButton::SeedDecr => {
|
||
params.seed = params.seed.wrapping_sub(SEED_STEP);
|
||
params.bump_generation();
|
||
}
|
||
ParamButton::SeedIncr => {
|
||
params.seed = params.seed.wrapping_add(SEED_STEP);
|
||
params.bump_generation();
|
||
}
|
||
ParamButton::SizeDecr => {
|
||
params.size = (params.size - SIZE_STEP).max(SIZE_MIN);
|
||
params.bump_generation();
|
||
}
|
||
ParamButton::SizeIncr => {
|
||
params.size = (params.size + SIZE_STEP).min(SIZE_MAX);
|
||
params.bump_generation();
|
||
}
|
||
// ── Core ──────────────────────────────────────────────────────
|
||
ParamButton::CoreCountDecr => {
|
||
params.core.count = params
|
||
.core
|
||
.count
|
||
.saturating_sub(CORE_COUNT_STEP)
|
||
.max(CORE_COUNT_MIN);
|
||
params.bump_generation();
|
||
}
|
||
ParamButton::CoreCountIncr => {
|
||
params.core.count = (params.core.count + CORE_COUNT_STEP).min(CORE_COUNT_MAX);
|
||
params.bump_generation();
|
||
}
|
||
ParamButton::CoreRadiusDecr => {
|
||
params.core.radius =
|
||
(params.core.radius - CORE_RADIUS_STEP).max(CORE_RADIUS_MIN);
|
||
params.bump_generation();
|
||
}
|
||
ParamButton::CoreRadiusIncr => {
|
||
params.core.radius =
|
||
(params.core.radius + CORE_RADIUS_STEP).min(CORE_RADIUS_MAX);
|
||
params.bump_generation();
|
||
}
|
||
// ── Disk tab management ───────────────────────────────────────
|
||
ParamButton::DiskTab(n) => {
|
||
selected_disk.0 = *n;
|
||
}
|
||
ParamButton::DiskAdd => {
|
||
if params.disks.len() < MAX_DISKS {
|
||
params.disks.push(DiskParams::default());
|
||
selected_disk.0 = params.disks.len() - 1;
|
||
params.bump_generation();
|
||
commands.insert_resource(RebuildScrollContent);
|
||
}
|
||
}
|
||
ParamButton::DiskRemove => {
|
||
if params.disks.len() > 1 && selected_disk.0 < params.disks.len() {
|
||
params.disks.remove(selected_disk.0);
|
||
selected_disk.0 = (selected_disk.0).min(params.disks.len() - 1);
|
||
params.bump_generation();
|
||
commands.insert_resource(RebuildScrollContent);
|
||
}
|
||
}
|
||
// ── Disk params (target selected_disk) ────────────────────────
|
||
ParamButton::DiskCountDecr => {
|
||
let disk = selected_disk_mut(&mut params, selected_disk.0);
|
||
disk.count = disk.count.saturating_sub(DISK_COUNT_STEP).max(DISK_COUNT_MIN);
|
||
params.bump_generation();
|
||
}
|
||
ParamButton::DiskCountIncr => {
|
||
let disk = selected_disk_mut(&mut params, selected_disk.0);
|
||
disk.count = (disk.count + DISK_COUNT_STEP).min(DISK_COUNT_MAX);
|
||
params.bump_generation();
|
||
}
|
||
ParamButton::DiskArmsDecr => {
|
||
let disk = selected_disk_mut(&mut params, selected_disk.0);
|
||
disk.arms = disk.arms.saturating_sub(1).max(DISK_ARMS_MIN);
|
||
params.bump_generation();
|
||
}
|
||
ParamButton::DiskArmsIncr => {
|
||
let disk = selected_disk_mut(&mut params, selected_disk.0);
|
||
disk.arms = disk.arms.saturating_add(1).min(DISK_ARMS_MAX);
|
||
params.bump_generation();
|
||
}
|
||
ParamButton::DiskTwistDecr => {
|
||
let disk = selected_disk_mut(&mut params, selected_disk.0);
|
||
disk.twist = (disk.twist - DISK_TWIST_STEP).max(DISK_TWIST_MIN);
|
||
params.bump_generation();
|
||
}
|
||
ParamButton::DiskTwistIncr => {
|
||
let disk = selected_disk_mut(&mut params, selected_disk.0);
|
||
disk.twist = (disk.twist + DISK_TWIST_STEP).min(DISK_TWIST_MAX);
|
||
params.bump_generation();
|
||
}
|
||
ParamButton::DiskTiltDecr => {
|
||
let disk = selected_disk_mut(&mut params, selected_disk.0);
|
||
disk.tilt = (disk.tilt - DISK_TILT_STEP).max(DISK_TILT_MIN);
|
||
params.bump_generation();
|
||
}
|
||
ParamButton::DiskTiltIncr => {
|
||
let disk = selected_disk_mut(&mut params, selected_disk.0);
|
||
disk.tilt = (disk.tilt + DISK_TILT_STEP).min(DISK_TILT_MAX);
|
||
params.bump_generation();
|
||
}
|
||
ParamButton::DiskRotationDecr => {
|
||
let disk = selected_disk_mut(&mut params, selected_disk.0);
|
||
disk.rotation_offset = (disk.rotation_offset - DISK_ROTATION_STEP)
|
||
.max(DISK_ROTATION_MIN);
|
||
params.bump_generation();
|
||
}
|
||
ParamButton::DiskRotationIncr => {
|
||
let disk = selected_disk_mut(&mut params, selected_disk.0);
|
||
disk.rotation_offset = (disk.rotation_offset + DISK_ROTATION_STEP)
|
||
.min(DISK_ROTATION_MAX);
|
||
params.bump_generation();
|
||
}
|
||
ParamButton::DiskInnerRadiusDecr => {
|
||
let disk = selected_disk_mut(&mut params, selected_disk.0);
|
||
disk.inner_radius =
|
||
(disk.inner_radius - DISK_INNER_RADIUS_STEP).max(DISK_INNER_RADIUS_MIN);
|
||
params.bump_generation();
|
||
}
|
||
ParamButton::DiskInnerRadiusIncr => {
|
||
let disk = selected_disk_mut(&mut params, selected_disk.0);
|
||
disk.inner_radius =
|
||
(disk.inner_radius + DISK_INNER_RADIUS_STEP).min(DISK_INNER_RADIUS_MAX);
|
||
params.bump_generation();
|
||
}
|
||
ParamButton::DiskOuterRadiusDecr => {
|
||
let disk = selected_disk_mut(&mut params, selected_disk.0);
|
||
disk.outer_radius =
|
||
(disk.outer_radius - DISK_OUTER_RADIUS_STEP).max(DISK_OUTER_RADIUS_MIN);
|
||
params.bump_generation();
|
||
}
|
||
ParamButton::DiskOuterRadiusIncr => {
|
||
let disk = selected_disk_mut(&mut params, selected_disk.0);
|
||
disk.outer_radius =
|
||
(disk.outer_radius + DISK_OUTER_RADIUS_STEP).min(DISK_OUTER_RADIUS_MAX);
|
||
params.bump_generation();
|
||
}
|
||
// ── Top Beam (+Y) ─────────────────────────────────────────────
|
||
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;
|
||
// Randomize disks: 1–3 layers.
|
||
let disk_count = rng.gen_range(1..=3.min(MAX_DISKS));
|
||
params.disks.clear();
|
||
for _ in 0..disk_count {
|
||
params.disks.push(randomize_disk(&mut rng));
|
||
}
|
||
selected_disk.0 = 0;
|
||
randomize_beam(&mut params.beam_top, &mut rng);
|
||
randomize_beam(&mut params.beam_bottom, &mut rng);
|
||
params.bump_generation();
|
||
commands.insert_resource(RebuildScrollContent);
|
||
}
|
||
ParamButton::Regenerate => params.reseed_and_bump(),
|
||
// CenterView is handled by `reset_view_button_handler` (needs Commands).
|
||
ParamButton::CenterView => {}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Helper: get a mutable reference to the currently-selected disk.
|
||
/// Clamps the index to the valid range as a safety measure.
|
||
fn selected_disk_mut(params: &mut GalaxyParams, index: usize) -> &mut DiskParams {
|
||
let idx = index.min(params.disks.len() - 1);
|
||
&mut params.disks[idx]
|
||
}
|
||
|
||
/// Handle the CenterView button: insert [`ResetOrbitCamera`] so
|
||
/// [`crate::camera::apply_orbit_reset`] can snap the orbit camera back to
|
||
/// its default orientation next frame. Kept in a separate system because it
|
||
/// needs `Commands` (not just `ResMut<GalaxyParams>`).
|
||
pub fn reset_view_button_handler(
|
||
mut commands: Commands,
|
||
reset_flag: Option<Res<crate::camera::ResetOrbitCamera>>,
|
||
query: Query<(&Interaction, &ParamButton), Changed<Interaction>>,
|
||
) {
|
||
for (interaction, button) in &query {
|
||
if *interaction != Interaction::Pressed {
|
||
continue;
|
||
}
|
||
if matches!(button, ParamButton::CenterView) && reset_flag.is_none() {
|
||
commands.insert_resource(crate::camera::ResetOrbitCamera);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 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 (~30 nodes) and avoids re-spawning the whole panel.
|
||
///
|
||
/// Disk-related values read from the currently-selected disk
|
||
/// ([`SelectedDisk`]) so the tab selector works correctly.
|
||
pub fn refresh_control_panel_values(
|
||
params: Res<GalaxyParams>,
|
||
selected_disk: Res<SelectedDisk>,
|
||
mut values: Query<(&ParamValue, &mut Text)>,
|
||
) {
|
||
// Resolve the selected disk, clamped to a valid index.
|
||
let disk_idx = selected_disk.0.min(params.disks.len() - 1);
|
||
let disk = ¶ms.disks[disk_idx];
|
||
|
||
for (marker, mut text) in &mut values {
|
||
let new = match marker.0.as_str() {
|
||
// Galaxy
|
||
"seed" => format!("{}", params.seed),
|
||
"size" => format!("{:.0}", params.size),
|
||
// Core
|
||
"core_count" => format!("{}", params.core.count),
|
||
"core_radius" => format!("{:.0}", params.core.radius),
|
||
// Disk — reads from selected disk
|
||
"disk_count" => format!("{}", disk.count),
|
||
"disk_arms" => format!("{}", disk.arms),
|
||
"disk_twist" => format!("{:.1}", disk.twist),
|
||
"disk_tilt" => format!("{:.0}°", disk.tilt.to_degrees()),
|
||
"disk_rotation" => format!("{:.0}°", disk.rotation_offset.to_degrees()),
|
||
"disk_inner_radius" => {
|
||
if disk.inner_radius > 0.0 {
|
||
format!("{:.0}", disk.inner_radius)
|
||
} else {
|
||
"0".to_string()
|
||
}
|
||
}
|
||
"disk_outer_radius" => {
|
||
if disk.outer_radius > 0.0 {
|
||
format!("{:.0}", disk.outer_radius)
|
||
} else {
|
||
"auto".to_string()
|
||
}
|
||
}
|
||
// Top beam
|
||
"beam_top_enabled" => {
|
||
format!(
|
||
"{}",
|
||
if params.beam_top.enabled {
|
||
"On"
|
||
} else {
|
||
"Off"
|
||
}
|
||
)
|
||
}
|
||
"beam_top_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 {
|
||
text.0 = new;
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Scroll content rebuild ──────────────────────────────────────────────────
|
||
|
||
/// When the disk count changes (add/remove/randomize), the tab bar and the
|
||
/// remove button need to be rebuilt. This system detects the flag, despawns
|
||
/// the old scroll content entity, and re-spawns it with the correct tab count.
|
||
pub fn rebuild_scroll_content(
|
||
mut commands: Commands,
|
||
flag: Option<Res<RebuildScrollContent>>,
|
||
scroll_content: Query<Entity, With<GalaxyScrollContent>>,
|
||
control_panel: Query<Entity, With<GalaxyControlPanel>>,
|
||
params: Res<GalaxyParams>,
|
||
selected_disk: Res<SelectedDisk>,
|
||
) {
|
||
let Some(_) = flag else {
|
||
return;
|
||
};
|
||
// Consume the flag.
|
||
commands.remove_resource::<RebuildScrollContent>();
|
||
|
||
let Ok(scroll_entity) = scroll_content.single() else {
|
||
return;
|
||
};
|
||
let Ok(panel_entity) = control_panel.single() else {
|
||
return;
|
||
};
|
||
|
||
// Despawn the old scroll content entity (and all its children).
|
||
commands.entity(scroll_entity).despawn();
|
||
|
||
// Re-spawn a fresh scroll content as a child of the panel.
|
||
commands
|
||
.entity(panel_entity)
|
||
.with_children(|parent| {
|
||
parent
|
||
.spawn((
|
||
Node {
|
||
flex_grow: 1.0,
|
||
flex_direction: FlexDirection::Column,
|
||
row_gap: Val::Px(6.0),
|
||
padding: UiRect::px(
|
||
PANEL_PADDING,
|
||
PANEL_PADDING,
|
||
PANEL_PADDING,
|
||
PANEL_PADDING,
|
||
),
|
||
overflow: Overflow::scroll_y(),
|
||
..default()
|
||
},
|
||
ScrollPosition::default(),
|
||
GalaxyScrollContent,
|
||
))
|
||
.with_children(|scroll| {
|
||
spawn_scroll_contents_with_tabs(scroll, ¶ms, selected_disk.0);
|
||
});
|
||
});
|
||
}
|
||
|
||
/// Full scroll content builder that includes the actual tab buttons for the
|
||
/// current disk count. This is the authoritative spawn function — the simpler
|
||
/// [`spawn_scroll_contents`] delegates to this when we need dynamic tabs.
|
||
fn spawn_scroll_contents_with_tabs(
|
||
scroll: &mut ChildSpawnerCommands,
|
||
params: &GalaxyParams,
|
||
active_disk: usize,
|
||
) {
|
||
// ── Galaxy (seed + size) ────────────────────────────────────────
|
||
spawn_section(scroll, "Galaxy");
|
||
spawn_param_row(
|
||
scroll,
|
||
"Seed",
|
||
"seed",
|
||
ParamButton::SeedDecr,
|
||
ParamButton::SeedIncr,
|
||
);
|
||
spawn_param_row(
|
||
scroll,
|
||
"Size",
|
||
"size",
|
||
ParamButton::SizeDecr,
|
||
ParamButton::SizeIncr,
|
||
);
|
||
|
||
// ── Core ────────────────────────────────────────────────────────
|
||
spawn_section(scroll, "Core");
|
||
spawn_param_row(
|
||
scroll,
|
||
"Core Count",
|
||
"core_count",
|
||
ParamButton::CoreCountDecr,
|
||
ParamButton::CoreCountIncr,
|
||
);
|
||
spawn_param_row(
|
||
scroll,
|
||
"Core Radius",
|
||
"core_radius",
|
||
ParamButton::CoreRadiusDecr,
|
||
ParamButton::CoreRadiusIncr,
|
||
);
|
||
|
||
// ── Disk (tab bar + shared parameter rows) ──────────────────────
|
||
spawn_section(scroll, "Disk");
|
||
|
||
// Tab bar: [1] [2] ... [+]
|
||
let tab_bar_active = active_disk.min(params.disks.len() - 1);
|
||
scroll
|
||
.spawn(Node {
|
||
width: Val::Percent(100.0),
|
||
flex_direction: FlexDirection::Row,
|
||
column_gap: Val::Px(4.0),
|
||
align_items: AlignItems::Center,
|
||
..default()
|
||
})
|
||
.with_children(|row| {
|
||
for i in 0..params.disks.len() {
|
||
let is_active = i == tab_bar_active;
|
||
let bg = if is_active {
|
||
TAB_ACTIVE_BG
|
||
} else {
|
||
TAB_INACTIVE_BG
|
||
};
|
||
let border = if is_active {
|
||
Color::srgb(0.40, 0.60, 0.90)
|
||
} else {
|
||
PANEL_BORDER
|
||
};
|
||
let text_color = if is_active {
|
||
TEXT_BRIGHT
|
||
} else {
|
||
TEXT_DIM
|
||
};
|
||
row.spawn((
|
||
Button,
|
||
Node {
|
||
width: Val::Px(30.0),
|
||
height: Val::Px(24.0),
|
||
justify_content: JustifyContent::Center,
|
||
align_items: AlignItems::Center,
|
||
border: UiRect::all(Val::Px(1.0)),
|
||
..default()
|
||
},
|
||
BackgroundColor(bg),
|
||
BorderColor(border),
|
||
BorderRadius::all(Val::Px(4.0)),
|
||
ParamButton::DiskTab(i),
|
||
DiskTabButton(i),
|
||
))
|
||
.with_children(|btn| {
|
||
btn.spawn((
|
||
Text::new(format!("{}", i + 1)),
|
||
TextFont {
|
||
font_size: TAB_FONT_SIZE,
|
||
..default()
|
||
},
|
||
TextColor(text_color),
|
||
));
|
||
});
|
||
}
|
||
|
||
// [+ Add Disk] button — only if below max.
|
||
if params.disks.len() < MAX_DISKS {
|
||
row.spawn((
|
||
Button,
|
||
Node {
|
||
width: Val::Px(26.0),
|
||
height: Val::Px(24.0),
|
||
justify_content: JustifyContent::Center,
|
||
align_items: AlignItems::Center,
|
||
border: UiRect::all(Val::Px(1.0)),
|
||
..default()
|
||
},
|
||
BackgroundColor(BUTTON_BG),
|
||
BorderColor(PANEL_BORDER),
|
||
BorderRadius::all(Val::Px(4.0)),
|
||
ParamButton::DiskAdd,
|
||
))
|
||
.with_children(|btn| {
|
||
btn.spawn((
|
||
Text::new("+"),
|
||
TextFont {
|
||
font_size: TAB_FONT_SIZE,
|
||
..default()
|
||
},
|
||
TextColor(TEXT_DIM),
|
||
));
|
||
});
|
||
}
|
||
});
|
||
|
||
// Shared disk parameter rows.
|
||
spawn_param_row(
|
||
scroll,
|
||
"Disk Count",
|
||
"disk_count",
|
||
ParamButton::DiskCountDecr,
|
||
ParamButton::DiskCountIncr,
|
||
);
|
||
spawn_param_row(
|
||
scroll,
|
||
"Disk Arms",
|
||
"disk_arms",
|
||
ParamButton::DiskArmsDecr,
|
||
ParamButton::DiskArmsIncr,
|
||
);
|
||
spawn_param_row(
|
||
scroll,
|
||
"Disk Twist",
|
||
"disk_twist",
|
||
ParamButton::DiskTwistDecr,
|
||
ParamButton::DiskTwistIncr,
|
||
);
|
||
spawn_param_row(
|
||
scroll,
|
||
"Tilt",
|
||
"disk_tilt",
|
||
ParamButton::DiskTiltDecr,
|
||
ParamButton::DiskTiltIncr,
|
||
);
|
||
spawn_param_row(
|
||
scroll,
|
||
"Rotation",
|
||
"disk_rotation",
|
||
ParamButton::DiskRotationDecr,
|
||
ParamButton::DiskRotationIncr,
|
||
);
|
||
spawn_param_row(
|
||
scroll,
|
||
"Inner Rad",
|
||
"disk_inner_radius",
|
||
ParamButton::DiskInnerRadiusDecr,
|
||
ParamButton::DiskInnerRadiusIncr,
|
||
);
|
||
spawn_param_row(
|
||
scroll,
|
||
"Outer Rad",
|
||
"disk_outer_radius",
|
||
ParamButton::DiskOuterRadiusDecr,
|
||
ParamButton::DiskOuterRadiusIncr,
|
||
);
|
||
|
||
// Remove button — only show if more than one disk.
|
||
if params.disks.len() > 1 {
|
||
scroll
|
||
.spawn((
|
||
Button,
|
||
Node {
|
||
width: Val::Percent(100.0),
|
||
height: Val::Px(24.0),
|
||
justify_content: JustifyContent::Center,
|
||
align_items: AlignItems::Center,
|
||
border: UiRect::all(Val::Px(1.0)),
|
||
..default()
|
||
},
|
||
BackgroundColor(Color::srgb(0.25, 0.10, 0.10)),
|
||
BorderColor(Color::srgb(0.50, 0.20, 0.20)),
|
||
BorderRadius::all(Val::Px(4.0)),
|
||
ParamButton::DiskRemove,
|
||
DiskRemoveButton,
|
||
))
|
||
.with_children(|btn| {
|
||
btn.spawn((
|
||
Text::new("Remove This Disk"),
|
||
TextFont {
|
||
font_size: BUTTON_FONT_SIZE,
|
||
..default()
|
||
},
|
||
TextColor(Color::srgb(0.90, 0.60, 0.60)),
|
||
));
|
||
});
|
||
}
|
||
|
||
// ── Top Beam (+Y) ───────────────────────────────────────────────
|
||
spawn_section(scroll, "Top Beam (+Y)");
|
||
spawn_toggle_row(
|
||
scroll,
|
||
"Enabled",
|
||
"beam_top_enabled",
|
||
ParamButton::BeamTopEnabled,
|
||
);
|
||
spawn_param_row(
|
||
scroll,
|
||
"Thickness",
|
||
"beam_top_thickness",
|
||
ParamButton::BeamTopThicknessDecr,
|
||
ParamButton::BeamTopThicknessIncr,
|
||
);
|
||
spawn_param_row(
|
||
scroll,
|
||
"Length",
|
||
"beam_top_length",
|
||
ParamButton::BeamTopLengthDecr,
|
||
ParamButton::BeamTopLengthIncr,
|
||
);
|
||
spawn_param_row(
|
||
scroll,
|
||
"Density",
|
||
"beam_top_density",
|
||
ParamButton::BeamTopDensityDecr,
|
||
ParamButton::BeamTopDensityIncr,
|
||
);
|
||
spawn_param_row(
|
||
scroll,
|
||
"Taper",
|
||
"beam_top_taper",
|
||
ParamButton::BeamTopTaperDecr,
|
||
ParamButton::BeamTopTaperIncr,
|
||
);
|
||
spawn_param_row(
|
||
scroll,
|
||
"Gravity",
|
||
"beam_top_gravity",
|
||
ParamButton::BeamTopGravityDecr,
|
||
ParamButton::BeamTopGravityIncr,
|
||
);
|
||
|
||
// ── Bottom Beam (-Y) ────────────────────────────────────────────
|
||
spawn_section(scroll, "Bottom Beam (-Y)");
|
||
spawn_toggle_row(
|
||
scroll,
|
||
"Enabled",
|
||
"beam_bottom_enabled",
|
||
ParamButton::BeamBottomEnabled,
|
||
);
|
||
spawn_param_row(
|
||
scroll,
|
||
"Thickness",
|
||
"beam_bottom_thickness",
|
||
ParamButton::BeamBottomThicknessDecr,
|
||
ParamButton::BeamBottomThicknessIncr,
|
||
);
|
||
spawn_param_row(
|
||
scroll,
|
||
"Length",
|
||
"beam_bottom_length",
|
||
ParamButton::BeamBottomLengthDecr,
|
||
ParamButton::BeamBottomLengthIncr,
|
||
);
|
||
spawn_param_row(
|
||
scroll,
|
||
"Density",
|
||
"beam_bottom_density",
|
||
ParamButton::BeamBottomDensityDecr,
|
||
ParamButton::BeamBottomDensityIncr,
|
||
);
|
||
spawn_param_row(
|
||
scroll,
|
||
"Taper",
|
||
"beam_bottom_taper",
|
||
ParamButton::BeamBottomTaperDecr,
|
||
ParamButton::BeamBottomTaperIncr,
|
||
);
|
||
spawn_param_row(
|
||
scroll,
|
||
"Gravity",
|
||
"beam_bottom_gravity",
|
||
ParamButton::BeamBottomGravityDecr,
|
||
ParamButton::BeamBottomGravityIncr,
|
||
);
|
||
|
||
// ── Action buttons ──────────────────────────────────────────────
|
||
scroll
|
||
.spawn((
|
||
Button,
|
||
Node {
|
||
width: Val::Percent(100.0),
|
||
height: Val::Px(32.0),
|
||
margin: UiRect::top(Val::Px(8.0)),
|
||
justify_content: JustifyContent::Center,
|
||
align_items: AlignItems::Center,
|
||
border: UiRect::all(Val::Px(1.0)),
|
||
..default()
|
||
},
|
||
BackgroundColor(BUTTON_BG),
|
||
BorderColor(PANEL_BORDER),
|
||
BorderRadius::all(Val::Px(6.0)),
|
||
ParamButton::Regenerate,
|
||
))
|
||
.with_children(|btn| {
|
||
btn.spawn((
|
||
Text::new("Regenerate"),
|
||
TextFont {
|
||
font_size: BUTTON_FONT_SIZE,
|
||
..default()
|
||
},
|
||
TextColor(TEXT_BRIGHT),
|
||
));
|
||
});
|
||
|
||
scroll
|
||
.spawn((
|
||
Button,
|
||
Node {
|
||
width: Val::Percent(100.0),
|
||
height: Val::Px(32.0),
|
||
margin: UiRect::top(Val::Px(4.0)),
|
||
justify_content: JustifyContent::Center,
|
||
align_items: AlignItems::Center,
|
||
border: UiRect::all(Val::Px(1.0)),
|
||
..default()
|
||
},
|
||
BackgroundColor(BUTTON_BG),
|
||
BorderColor(PANEL_BORDER),
|
||
BorderRadius::all(Val::Px(6.0)),
|
||
ParamButton::CenterView,
|
||
))
|
||
.with_children(|btn| {
|
||
btn.spawn((
|
||
Text::new("Center View"),
|
||
TextFont {
|
||
font_size: BUTTON_FONT_SIZE,
|
||
..default()
|
||
},
|
||
TextColor(TEXT_BRIGHT),
|
||
));
|
||
});
|
||
|
||
scroll.spawn((
|
||
Text::new("Drag to orbit (360°) • Scroll to zoom • Click a star • Esc to return"),
|
||
TextFont {
|
||
font_size: HELP_FONT_SIZE,
|
||
..default()
|
||
},
|
||
TextColor(TEXT_DIM),
|
||
Node {
|
||
margin: UiRect::top(Val::Px(10.0)),
|
||
..default()
|
||
},
|
||
));
|
||
}
|
||
|
||
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||
|
||
/// Randomize all fields of a [`DiskParams`] to random valid values within
|
||
/// their respective bounds, rounded to the UI step.
|
||
fn randomize_disk(rng: &mut impl rand::Rng) -> DiskParams {
|
||
let mut disk = DiskParams::default();
|
||
disk.count = rng.gen_range(DISK_COUNT_MIN..=DISK_COUNT_MAX);
|
||
disk.count = (disk.count / DISK_COUNT_STEP) * DISK_COUNT_STEP;
|
||
disk.arms = rng.gen_range(DISK_ARMS_MIN..=DISK_ARMS_MAX);
|
||
disk.twist = rng.gen_range(DISK_TWIST_MIN..=DISK_TWIST_MAX);
|
||
disk.twist = (disk.twist / DISK_TWIST_STEP).round() * DISK_TWIST_STEP;
|
||
disk.tilt = rng.gen_range(DISK_TILT_MIN..=DISK_TILT_MAX);
|
||
disk.tilt = (disk.tilt / DISK_TILT_STEP).round() * DISK_TILT_STEP;
|
||
disk.rotation_offset = rng.gen_range(DISK_ROTATION_MIN..=DISK_ROTATION_MAX);
|
||
disk.rotation_offset =
|
||
(disk.rotation_offset / DISK_ROTATION_STEP).round() * DISK_ROTATION_STEP;
|
||
disk.inner_radius = rng.gen_range(DISK_INNER_RADIUS_MIN..=DISK_INNER_RADIUS_MAX);
|
||
disk.inner_radius =
|
||
(disk.inner_radius / DISK_INNER_RADIUS_STEP).round() * DISK_INNER_RADIUS_STEP;
|
||
disk.outer_radius = rng.gen_range(DISK_OUTER_RADIUS_MIN..=DISK_OUTER_RADIUS_MAX);
|
||
disk.outer_radius =
|
||
(disk.outer_radius / DISK_OUTER_RADIUS_STEP).round() * DISK_OUTER_RADIUS_STEP;
|
||
// Allow 0 (auto) ~50% of the time.
|
||
if rng.gen_bool(0.5) {
|
||
disk.outer_radius = 0.0;
|
||
}
|
||
disk
|
||
}
|
||
|
||
/// Randomize all fields of a [`BeamParams`] to random valid values within
|
||
/// their respective bounds, rounded to the UI step.
|
||
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;
|
||
}
|
||
}
|