Files
Space-Game/apps/game/src/gameplay/galaxy/ui.rs
francy51 80060be5ba feat(galaxy): multi-disk galaxy generation with tab-based UI
- 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
2026-06-10 00:11:30 -04:00

1388 lines
51 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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, &params);
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: 13 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 = &params.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, &params, 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;
}
}