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:
@@ -75,6 +75,18 @@ Follows [Rust RFC 344](https://rust-lang.github.io/api-guidelines/naming.html).
|
||||
| Functions, systems, locals | `snake_case`, verb-first | `spawn_camera`, `setup_main_menu` |
|
||||
| Constants, statics | `SCREAMING_SNAKE_CASE` | `MAX_HEALTH` |
|
||||
|
||||
## Warnings & Errors Policy
|
||||
|
||||
**Never suppress compiler warnings or errors.** This includes but is not limited to:
|
||||
|
||||
- Do **not** add `#[allow(...)]` / `#[allow(dead_code)]` / `#[allow(unused)]` / `#[allow(unused_variables)]` or any other lint suppression attribute.
|
||||
- Do **not** prefix unused variables with `_` to silence warnings (e.g. `_unused_var`).
|
||||
- Do **not** use `let _ = ...` to discard results that might carry meaningful errors.
|
||||
- Do **not** add `#[cfg_attr(..., allow(...))]` or equivalent conditional suppressions.
|
||||
- Do **not** use `#![allow(...)]` at the crate or module level.
|
||||
|
||||
If a warning or error fires, **fix the underlying issue** — remove dead code, use the variable, handle the error properly, or restructure the code. Suppressing warnings hides real problems and is not an acceptable fix.
|
||||
|
||||
## Architecture Notes
|
||||
|
||||
- **State machine driven**: each `AppState` variant has its own UI/systems wired via `OnEnter` / `OnExit` / `Update` (latter guarded by `in_state`).
|
||||
|
||||
@@ -111,6 +111,9 @@ pub fn orbit_camera_control(
|
||||
}
|
||||
|
||||
for event in scroll_events.read() {
|
||||
if cursor_over_ui {
|
||||
continue;
|
||||
}
|
||||
// Scroll up (positive y) → decrease distance (zoom in).
|
||||
orbit.distance *= 1.0 - event.y * ORBIT_ZOOM_SENSITIVITY;
|
||||
}
|
||||
|
||||
@@ -24,7 +24,8 @@ use crate::state::AppState;
|
||||
|
||||
pub use contents::{SystemContents, SystemContext, SystemSummary};
|
||||
pub use params::{GalaxyParams, SelectedStar};
|
||||
use params::{CORE_COUNT, NEAREST_NEIGHBOR_CONNECTIONS, SPACING_ATTEMPTS};
|
||||
pub use params::{BeamParams, CoreParams, DiskParams};
|
||||
use params::{NEAREST_NEIGHBOR_CONNECTIONS, SPACING_ATTEMPTS, MIN_SYSTEM_SPACING};
|
||||
|
||||
pub struct GalaxyPlugin;
|
||||
|
||||
@@ -46,6 +47,7 @@ impl Plugin for GalaxyPlugin {
|
||||
escape_to_main_menu,
|
||||
ui::param_button_handler,
|
||||
ui::refresh_control_panel_values,
|
||||
ui::scroll_control_panel,
|
||||
ui::reset_view_button_handler,
|
||||
regenerate_galaxy_on_param_change,
|
||||
selection::select_star_on_click,
|
||||
@@ -179,116 +181,312 @@ fn generate_galaxy(
|
||||
(systems, contents, connections)
|
||||
}
|
||||
|
||||
/// Position-only galaxy generation. Pure faithful port of the TS reference
|
||||
/// in `apps/docs/src/prototypes/r3f/galaxy/GalaxyScene.tsx::generateGalaxy`.
|
||||
/// Split out so [`generate_galaxy`] can compose it with POI generation.
|
||||
/// Position-only galaxy generation. Composes three structural layers —
|
||||
/// a [`CoreParams`] cluster, a [`DiskParams`] of horizontal spiral arms,
|
||||
/// and two [`BeamParams`] columns along ±Y — into a single flat list of
|
||||
/// systems ready for the connection graph and the spawner.
|
||||
///
|
||||
/// Each layer owns its own system count; the total galaxy population is
|
||||
/// `core.count + disk.count + beam_top.count + beam_bottom.count`. System
|
||||
/// indices are global across all layers (used for `g-{n}` IDs and name
|
||||
/// suffixes) so the connection graph and POI generator see one consistent
|
||||
/// vector.
|
||||
fn generate_system_positions(params: &GalaxyParams, rng: &mut StdRng) -> Vec<GeneratedSystem> {
|
||||
let mut systems: Vec<GeneratedSystem> = Vec::with_capacity(params.count);
|
||||
// Total budget — pre-allocation only; each pass appends as many systems
|
||||
// as it manages to place.
|
||||
let total = params.core.count
|
||||
+ params.disk.count
|
||||
+ params.beam_top.count
|
||||
+ params.beam_bottom.count;
|
||||
let mut systems: Vec<GeneratedSystem> = Vec::with_capacity(total);
|
||||
|
||||
// Spacing scales with density: smaller for tight cores, larger spread overall.
|
||||
let base_spacing = (params.size / (params.count as f32).sqrt() * 1.25).clamp(9.0, 24.0);
|
||||
let horizontal_arms = params.arms.max(1);
|
||||
let vertical_arms = params.vertical_arms;
|
||||
let total_arm_slots = horizontal_arms + vertical_arms;
|
||||
// Spacing scales with overall density: tighter for crowded galaxies,
|
||||
// looser for sparse ones. Same heuristic as the previous single-pass
|
||||
// generator, but keyed off the combined layer count.
|
||||
let base_spacing = (params.size / (total.max(1) as f32).sqrt() * 1.25).clamp(9.0, 24.0);
|
||||
|
||||
for index in 0..params.count {
|
||||
let core = index < CORE_COUNT;
|
||||
let arm_slot = (index as u32) % total_arm_slots.max(1);
|
||||
let vertical = !core && arm_slot >= horizontal_arms;
|
||||
let arm = if vertical {
|
||||
arm_slot - horizontal_arms
|
||||
} else {
|
||||
arm_slot
|
||||
};
|
||||
let faction_index = if core {
|
||||
0 // Concord
|
||||
} else {
|
||||
((arm_slot as usize) % (FACTIONS.len() - 1)) + 1
|
||||
// Counter shared across passes for `g-{n}` IDs and name suffixes.
|
||||
let mut next_index = 0usize;
|
||||
|
||||
generate_core(&mut systems, rng, ¶ms.core, params.size, base_spacing, &mut next_index);
|
||||
generate_disk(
|
||||
&mut systems,
|
||||
rng,
|
||||
¶ms.disk,
|
||||
params.size,
|
||||
base_spacing,
|
||||
&mut next_index,
|
||||
);
|
||||
if params.beam_top.enabled {
|
||||
generate_beam(
|
||||
&mut systems,
|
||||
rng,
|
||||
¶ms.beam_top,
|
||||
BeamSide::Top,
|
||||
params.size,
|
||||
base_spacing,
|
||||
&mut next_index,
|
||||
);
|
||||
}
|
||||
if params.beam_bottom.enabled {
|
||||
generate_beam(
|
||||
&mut systems,
|
||||
rng,
|
||||
¶ms.beam_bottom,
|
||||
BeamSide::Bottom,
|
||||
params.size,
|
||||
base_spacing,
|
||||
&mut next_index,
|
||||
);
|
||||
}
|
||||
|
||||
systems
|
||||
}
|
||||
|
||||
/// Which side of the galactic plane a beam occupies.
|
||||
enum BeamSide {
|
||||
Top,
|
||||
Bottom,
|
||||
}
|
||||
|
||||
/// Generate the Concord core cluster near the origin. Spherically sampled:
|
||||
/// uniform on a disk (XZ) plus modest Y jitter so the core has thickness.
|
||||
fn generate_core(
|
||||
systems: &mut Vec<GeneratedSystem>,
|
||||
rng: &mut StdRng,
|
||||
core: &CoreParams,
|
||||
galaxy_size: f32,
|
||||
base_spacing: f32,
|
||||
next_index: &mut usize,
|
||||
) {
|
||||
for _ in 0..core.count {
|
||||
let mut position = Option::<Vec3>::None;
|
||||
for attempt in 0..SPACING_ATTEMPTS {
|
||||
let r = rng.gen::<f32>().sqrt() * core.radius;
|
||||
let theta = rng.gen::<f32>() * std::f32::consts::TAU;
|
||||
// Modest vertical thickness — 20% of core radius — so the core
|
||||
// reads as a 3D blob rather than a flat puck.
|
||||
let y = (rng.gen::<f32>() - 0.5) * core.radius * 0.4;
|
||||
let candidate = Vec3::new(r * theta.cos(), y, r * theta.sin());
|
||||
let spacing = relax_spacing(base_spacing, attempt).max(MIN_SYSTEM_SPACING) * 0.9;
|
||||
if systems
|
||||
.iter()
|
||||
.all(|s| s.position.distance(candidate) >= spacing)
|
||||
{
|
||||
position = Some(candidate);
|
||||
break;
|
||||
}
|
||||
}
|
||||
let Some(position) = position else {
|
||||
// Could not find a clear spot — skip this system rather than
|
||||
// placing it at the origin on top of other systems.
|
||||
continue;
|
||||
};
|
||||
let final_radius = position.length();
|
||||
// Core is always Concord (faction_index 0).
|
||||
let (faction, color) = FACTIONS[0];
|
||||
let security = security_for_radius(final_radius, galaxy_size);
|
||||
let idx = *next_index;
|
||||
*next_index += 1;
|
||||
systems.push(GeneratedSystem {
|
||||
id: format!("g-{idx}"),
|
||||
name: format!("COR-{}", 100 + idx),
|
||||
position,
|
||||
faction,
|
||||
faction_index: 0,
|
||||
color,
|
||||
security,
|
||||
is_core: true,
|
||||
origin: SystemOrigin::Core,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate the horizontal disk of spiral arms in the XZ plane. Faithful to
|
||||
/// the original generator's density bias (`pow(0.62)` → packed near origin)
|
||||
/// and arm twist.
|
||||
fn generate_disk(
|
||||
systems: &mut Vec<GeneratedSystem>,
|
||||
rng: &mut StdRng,
|
||||
disk: &DiskParams,
|
||||
galaxy_size: f32,
|
||||
base_spacing: f32,
|
||||
next_index: &mut usize,
|
||||
) {
|
||||
let arm_count = disk.arms.max(1);
|
||||
for i in 0..disk.count {
|
||||
let arm = (i as u32) % arm_count;
|
||||
// Non-core disk factions cycle through Amarr/Minmatar/Gallente/Caldari.
|
||||
let faction_index = 1 + (arm as usize) % (FACTIONS.len() - 1);
|
||||
let (faction, color) = FACTIONS[faction_index];
|
||||
|
||||
let mut position = Vec3::ZERO;
|
||||
let mut position = Option::<Vec3>::None;
|
||||
let mut final_radius = 0.0f32;
|
||||
for attempt in 0..SPACING_ATTEMPTS {
|
||||
// `pow(0.62)` produces a density bias toward the core.
|
||||
let r = if core {
|
||||
8.0 + rng.gen::<f32>() * 30.0
|
||||
} else {
|
||||
rng.gen::<f32>().powf(0.62) * params.size
|
||||
};
|
||||
let arm_count = if vertical {
|
||||
vertical_arms.max(1)
|
||||
} else {
|
||||
horizontal_arms
|
||||
};
|
||||
let arm_twist = if vertical {
|
||||
params.vertical_twist
|
||||
} else {
|
||||
params.twist
|
||||
};
|
||||
let angle = if core {
|
||||
rng.gen::<f32>() * std::f32::consts::TAU
|
||||
} else {
|
||||
std::f32::consts::TAU * arm as f32 / arm_count as f32
|
||||
+ (r / params.size) * arm_twist
|
||||
+ (rng.gen::<f32>() - 0.5) * 0.72
|
||||
};
|
||||
let (x, y, z) = if vertical {
|
||||
// Vertical arms: stars orbit in a vertical plane rotated around the Y axis.
|
||||
let plane_angle = std::f32::consts::TAU * arm as f32 / vertical_arms.max(1) as f32;
|
||||
let arm_sweep = angle.cos() * r;
|
||||
let vx = plane_angle.cos() * arm_sweep;
|
||||
let vz = plane_angle.sin() * arm_sweep;
|
||||
let vy = angle.sin() * r * 0.72 + (rng.gen::<f32>() - 0.5) * 12.0;
|
||||
(vx, vy, vz)
|
||||
} else {
|
||||
(
|
||||
angle.cos() * r,
|
||||
(rng.gen::<f32>() - 0.5) * 20.0,
|
||||
angle.sin() * r,
|
||||
)
|
||||
};
|
||||
let candidate = Vec3::new(x, y, z);
|
||||
let r = rng.gen::<f32>().powf(0.62) * galaxy_size;
|
||||
let angle = std::f32::consts::TAU * arm as f32 / arm_count as f32
|
||||
+ (r / galaxy_size) * disk.twist
|
||||
+ (rng.gen::<f32>() - 0.5) * 0.72;
|
||||
let y = (rng.gen::<f32>() - 0.5) * 20.0;
|
||||
let candidate = Vec3::new(angle.cos() * r, y, angle.sin() * r);
|
||||
final_radius = r;
|
||||
|
||||
// Spacing relaxation: shrink required distance after many failed attempts.
|
||||
let relaxed = if attempt > 120 {
|
||||
base_spacing * 0.68
|
||||
} else if attempt > 60 {
|
||||
base_spacing * 0.82
|
||||
} else {
|
||||
base_spacing
|
||||
};
|
||||
let local_spacing = if core { relaxed * 0.9 } else { relaxed };
|
||||
let clear = systems
|
||||
let spacing = relax_spacing(base_spacing, attempt).max(MIN_SYSTEM_SPACING);
|
||||
if systems
|
||||
.iter()
|
||||
.all(|s| s.position.distance(candidate) >= local_spacing);
|
||||
if clear {
|
||||
position = candidate;
|
||||
.all(|s| s.position.distance(candidate) >= spacing)
|
||||
{
|
||||
position = Some(candidate);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let security = ((1.0 - (final_radius / params.size) * 2.0) * 100.0).round() / 100.0;
|
||||
let name = if core {
|
||||
format!("COR-{}", 100 + index)
|
||||
} else {
|
||||
format!("{}-{}", &faction[..3].to_uppercase(), 100 + index)
|
||||
let Some(position) = position else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let security = security_for_radius(final_radius, galaxy_size);
|
||||
let idx = *next_index;
|
||||
*next_index += 1;
|
||||
systems.push(GeneratedSystem {
|
||||
id: format!("g-{index}"),
|
||||
name,
|
||||
id: format!("g-{idx}"),
|
||||
name: format!("{}-{}", &faction[..3].to_uppercase(), 100 + idx),
|
||||
position,
|
||||
faction,
|
||||
faction_index,
|
||||
color,
|
||||
security,
|
||||
is_core: core,
|
||||
is_core: false,
|
||||
origin: SystemOrigin::Disk { arm },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
systems
|
||||
/// Generate one beam (top or bottom) along the Y axis. Height-first
|
||||
/// sampling produces a tapered cone: `t` is chosen first (biased toward
|
||||
/// the base via `pow(0.62)`), then the cross-section radius at that height
|
||||
/// is `thickness × (1 − t)^taper`. The `gravity` exponent concentrates
|
||||
/// systems toward the Y axis within each cross-section, producing a
|
||||
/// denser-brighter core. The result is a Vercidium-style diffraction
|
||||
/// spike: bright and wide near the galactic center, thinning to nothing
|
||||
/// at the tips.
|
||||
fn generate_beam(
|
||||
systems: &mut Vec<GeneratedSystem>,
|
||||
rng: &mut StdRng,
|
||||
beam: &BeamParams,
|
||||
side: BeamSide,
|
||||
galaxy_size: f32,
|
||||
base_spacing: f32,
|
||||
next_index: &mut usize,
|
||||
) {
|
||||
let sign = match side {
|
||||
BeamSide::Top => 1.0,
|
||||
BeamSide::Bottom => -1.0,
|
||||
};
|
||||
let name_prefix = match side {
|
||||
BeamSide::Top => "BMT",
|
||||
BeamSide::Bottom => "BMB",
|
||||
};
|
||||
let origin_tag = match side {
|
||||
BeamSide::Top => BeamTag::Top,
|
||||
BeamSide::Bottom => BeamTag::Bottom,
|
||||
};
|
||||
|
||||
for i in 0..beam.count {
|
||||
// Cycle through non-Concord factions so the beam isn't a single color.
|
||||
let faction_index = 1 + (i as usize) % (FACTIONS.len() - 1);
|
||||
let (faction, color) = FACTIONS[faction_index];
|
||||
|
||||
let mut position = Option::<Vec3>::None;
|
||||
let mut final_radius = 0.0f32;
|
||||
for attempt in 0..SPACING_ATTEMPTS {
|
||||
// Height-first sampling: pick t ∈ [0, 1] biased toward the base
|
||||
// (t=0 is the galactic plane, t=1 is the tip). `pow(0.62)` packs
|
||||
// ~62% of systems in the lower half of the beam.
|
||||
let t = rng.gen::<f32>().powf(0.62);
|
||||
let y = sign * t * beam.length;
|
||||
|
||||
// Tapered cross-section: radius shrinks with height as
|
||||
// `thickness × (1 − t)^taper`. taper=1 → linear cone,
|
||||
// taper>1 → sharper spike, taper<1 → bulbous.
|
||||
let max_r = beam.thickness * (1.0 - t).powf(beam.taper);
|
||||
|
||||
// Radial position within the cross-section, biased toward the
|
||||
// axis by the gravity exponent. Higher gravity → tighter core.
|
||||
let r = rng.gen::<f32>().powf(beam.gravity) * max_r;
|
||||
let theta = rng.gen::<f32>() * std::f32::consts::TAU;
|
||||
|
||||
// Small vertical jitter so the beam doesn't look like a perfect
|
||||
// cone when viewed from the side.
|
||||
let y_jitter = (rng.gen::<f32>() - 0.5) * 4.0;
|
||||
let candidate = Vec3::new(r * theta.cos(), y + y_jitter, r * theta.sin());
|
||||
final_radius = candidate.length();
|
||||
let spacing = relax_spacing(base_spacing, attempt).max(MIN_SYSTEM_SPACING);
|
||||
if systems
|
||||
.iter()
|
||||
.all(|s| s.position.distance(candidate) >= spacing)
|
||||
{
|
||||
position = Some(candidate);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let Some(position) = position else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let security = security_for_radius(final_radius, galaxy_size);
|
||||
let idx = *next_index;
|
||||
*next_index += 1;
|
||||
systems.push(GeneratedSystem {
|
||||
id: format!("g-{idx}"),
|
||||
name: format!("{name_prefix}-{}", 100 + idx),
|
||||
position,
|
||||
faction,
|
||||
faction_index,
|
||||
color,
|
||||
security,
|
||||
is_core: false,
|
||||
origin: SystemOrigin::Beam { side: origin_tag },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Spacing relaxation curve — same shape as the previous single-pass
|
||||
/// generator: full spacing for the first 60 attempts, 82% until attempt
|
||||
/// 120, then 68% as a fallback to guarantee placement.
|
||||
fn relax_spacing(base: f32, attempt: usize) -> f32 {
|
||||
if attempt > 120 {
|
||||
base * 0.68
|
||||
} else if attempt > 60 {
|
||||
base * 0.82
|
||||
} else {
|
||||
base
|
||||
}
|
||||
}
|
||||
|
||||
/// Security falls off linearly with distance from the origin. The same
|
||||
/// formula is used for all layers (core, disk, beams) — a beam system at
|
||||
/// the tip is far from the capital, so it should read as low-sec.
|
||||
fn security_for_radius(radius: f32, galaxy_size: f32) -> f32 {
|
||||
((1.0 - (radius / galaxy_size) * 2.0) * 100.0).round() / 100.0
|
||||
}
|
||||
|
||||
/// Which structural layer a system belongs to. Currently informational —
|
||||
/// nothing keys off it yet — but reserved for per-layer styling, POI
|
||||
/// biasing, or weighted connection graphs in later iterations.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
enum SystemOrigin {
|
||||
Core,
|
||||
Disk { arm: u32 },
|
||||
Beam { side: BeamTag },
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
enum BeamTag {
|
||||
Top,
|
||||
Bottom,
|
||||
}
|
||||
|
||||
struct GeneratedSystem {
|
||||
@@ -301,10 +499,14 @@ struct GeneratedSystem {
|
||||
/// by `faction_index`, but this is kept for future per-system variation.
|
||||
color: [f32; 3],
|
||||
security: f32,
|
||||
/// True for the [`CORE_COUNT`] Concord systems clustered near the origin.
|
||||
/// Used by [`contents::generate_system_contents`] to bias planet / station
|
||||
/// counts toward core systems.
|
||||
/// True for Concord core systems (see [`CoreParams`]). Used by
|
||||
/// [`contents::generate_system_contents`] to bias planet / station counts
|
||||
/// toward core systems, and by the spawner for visual differentiation.
|
||||
is_core: bool,
|
||||
/// Which structural layer this system came from. Informational — reserved
|
||||
/// for future per-layer styling or POI biasing.
|
||||
#[allow(dead_code)]
|
||||
origin: SystemOrigin,
|
||||
}
|
||||
|
||||
fn spawn_galaxy_scene(
|
||||
@@ -337,6 +539,14 @@ fn spawn_galaxy_scene(
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
// Beam material — distinct bright white-blue tint so beams read as
|
||||
// separate structural features from the disk.
|
||||
let beam_material = materials.add(StandardMaterial {
|
||||
base_color: Color::srgb(0.85, 0.88, 1.0),
|
||||
emissive: LinearRgba::new(1.2, 1.25, 1.5, 1.0),
|
||||
unlit: true,
|
||||
..default()
|
||||
});
|
||||
let connection_material = materials.add(StandardMaterial {
|
||||
base_color: Color::srgb(0.11, 0.16, 0.25),
|
||||
emissive: LinearRgba::new(0.08, 0.13, 0.22, 1.0),
|
||||
@@ -360,6 +570,10 @@ fn spawn_galaxy_scene(
|
||||
let (mesh, material, scale) = if is_core {
|
||||
// Visual differentiation for the 7 Concord core systems.
|
||||
(core_star_mesh.clone(), faction_materials[0].clone(), 1.1)
|
||||
} else if matches!(sys.origin, SystemOrigin::Beam { .. }) {
|
||||
// Beam systems: distinct bright tint + slightly smaller
|
||||
// so they read as a separate structural layer.
|
||||
(star_mesh.clone(), beam_material.clone(), 0.8)
|
||||
} else {
|
||||
(
|
||||
star_mesh.clone(),
|
||||
|
||||
@@ -3,6 +3,15 @@
|
||||
//! [`GalaxyParams`] is the input to procedural generation; the UI mutates it,
|
||||
//! the regeneration system detects changes via the `generation` counter, and
|
||||
//! the generator reads it. [`SelectedStar`] tracks the currently-clicked star.
|
||||
//!
|
||||
//! ## Structure
|
||||
//!
|
||||
//! A galaxy is composed of three layers, each owning its own system count:
|
||||
//! - [`CoreParams`] — Concord systems clustered near the origin.
|
||||
//! - [`DiskParams`] — horizontal spiral arms in the XZ plane.
|
||||
//! - [`BeamParams`] (×2, top and bottom) — vertical columns of systems along
|
||||
//! ±Y, the "polar beams". Each beam is its own population of selectable
|
||||
//! star systems, fully equal citizens to disk arms.
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
@@ -12,39 +21,68 @@ use bevy::prelude::*;
|
||||
// so the Bevy scene opens with the same look as the web demo.
|
||||
|
||||
const DEFAULT_SEED: u64 = 4242;
|
||||
const DEFAULT_COUNT: usize = 120;
|
||||
const DEFAULT_ARMS: u32 = 4;
|
||||
const DEFAULT_VERTICAL_ARMS: u32 = 0;
|
||||
const DEFAULT_SIZE: f32 = 280.0;
|
||||
const DEFAULT_TWIST: f32 = 3.0;
|
||||
const DEFAULT_VERTICAL_TWIST: f32 = 1.1;
|
||||
|
||||
// ── Limits (also mirror the TS sliders) ─────────────────────────────────────
|
||||
// Core defaults: 7 Concord systems inside a 38-unit sphere (matches the old
|
||||
// hardcoded `[8, 38]` core zone from the single-pass generator).
|
||||
const DEFAULT_CORE_COUNT: usize = 7;
|
||||
const DEFAULT_CORE_RADIUS: f32 = 38.0;
|
||||
|
||||
// Disk defaults: roughly the previous default (120 total) minus the core,
|
||||
// keeping the same arms/twist as the docs prototype.
|
||||
const DEFAULT_DISK_COUNT: usize = 100;
|
||||
const DEFAULT_DISK_ARMS: u32 = 4;
|
||||
const DEFAULT_DISK_TWIST: f32 = 3.0;
|
||||
|
||||
// Beam defaults: small population so the two polar columns read clearly
|
||||
// without dominating the scene. Equal top/bottom for symmetry.
|
||||
const DEFAULT_BEAM_ENABLED: bool = true;
|
||||
const DEFAULT_BEAM_COUNT: usize = 30;
|
||||
const DEFAULT_BEAM_THICKNESS: f32 = 16.0;
|
||||
const DEFAULT_BEAM_LENGTH: f32 = 180.0;
|
||||
const DEFAULT_BEAM_TAPER: f32 = 1.5;
|
||||
|
||||
// ── Limits ──────────────────────────────────────────────────────────────────
|
||||
|
||||
pub const SEED_STEP: u64 = 1;
|
||||
pub const COUNT_MIN: usize = 40;
|
||||
pub const COUNT_MAX: usize = 220;
|
||||
pub const COUNT_STEP: usize = 4;
|
||||
pub const ARMS_MIN: u32 = 1;
|
||||
pub const ARMS_MAX: u32 = 6;
|
||||
/// u32 lower bound is 0; constant kept for symmetry/documentation with the
|
||||
/// other `*_MIN` bounds.
|
||||
#[allow(dead_code)]
|
||||
pub const VERTICAL_ARMS_MIN: u32 = 0;
|
||||
pub const VERTICAL_ARMS_MAX: u32 = 4;
|
||||
|
||||
pub const SIZE_MIN: f32 = 140.0;
|
||||
pub const SIZE_MAX: f32 = 420.0;
|
||||
pub const SIZE_STEP: f32 = 10.0;
|
||||
pub const TWIST_MIN: f32 = 1.0;
|
||||
pub const TWIST_MAX: f32 = 6.0;
|
||||
pub const TWIST_STEP: f32 = 0.2;
|
||||
pub const VERTICAL_TWIST_MIN: f32 = 0.0;
|
||||
pub const VERTICAL_TWIST_MAX: f32 = 6.0;
|
||||
pub const VERTICAL_TWIST_STEP: f32 = 0.2;
|
||||
|
||||
/// Number of "core" Concord systems clustered at the galactic center.
|
||||
/// Not exposed in the UI — kept as an internal constant for now.
|
||||
pub(crate) const CORE_COUNT: usize = 7;
|
||||
pub const CORE_COUNT_MIN: usize = 0;
|
||||
pub const CORE_COUNT_MAX: usize = 30;
|
||||
pub const CORE_COUNT_STEP: usize = 1;
|
||||
pub const CORE_RADIUS_MIN: f32 = 10.0;
|
||||
pub const CORE_RADIUS_MAX: f32 = 120.0;
|
||||
pub const CORE_RADIUS_STEP: f32 = 5.0;
|
||||
|
||||
pub const DISK_COUNT_MIN: usize = 20;
|
||||
pub const DISK_COUNT_MAX: usize = 220;
|
||||
pub const DISK_COUNT_STEP: usize = 4;
|
||||
pub const DISK_ARMS_MIN: u32 = 1;
|
||||
pub const DISK_ARMS_MAX: u32 = 6;
|
||||
pub const DISK_TWIST_MIN: f32 = 1.0;
|
||||
pub const DISK_TWIST_MAX: f32 = 6.0;
|
||||
pub const DISK_TWIST_STEP: f32 = 0.2;
|
||||
|
||||
pub const BEAM_COUNT_MIN: usize = 0;
|
||||
pub const BEAM_COUNT_MAX: usize = 120;
|
||||
pub const BEAM_COUNT_STEP: usize = 4;
|
||||
pub const BEAM_THICKNESS_MIN: f32 = 1.0;
|
||||
pub const BEAM_THICKNESS_MAX: f32 = 80.0;
|
||||
pub const BEAM_THICKNESS_STEP: f32 = 1.0;
|
||||
pub const BEAM_LENGTH_MIN: f32 = 20.0;
|
||||
pub const BEAM_LENGTH_MAX: f32 = 600.0;
|
||||
pub const BEAM_LENGTH_STEP: f32 = 20.0;
|
||||
pub const BEAM_TAPER_MIN: f32 = 0.3;
|
||||
pub const BEAM_TAPER_MAX: f32 = 4.0;
|
||||
pub const BEAM_TAPER_STEP: f32 = 0.1;
|
||||
|
||||
pub const BEAM_GRAVITY_MIN: f32 = 0.5;
|
||||
pub const BEAM_GRAVITY_MAX: f32 = 4.0;
|
||||
pub const BEAM_GRAVITY_STEP: f32 = 0.1;
|
||||
const DEFAULT_BEAM_GRAVITY: f32 = 1.5;
|
||||
|
||||
/// Connect each system to its N nearest neighbors (deduplicated).
|
||||
/// Docs prototype uses 2; kept constant until a UI control is added.
|
||||
@@ -53,17 +91,107 @@ pub(crate) const NEAREST_NEIGHBOR_CONNECTIONS: usize = 2;
|
||||
/// Max attempts to find a clear spot for each system (Poisson-style spacing).
|
||||
pub(crate) const SPACING_ATTEMPTS: usize = 180;
|
||||
|
||||
/// Minimum distance between any two star systems, regardless of relaxation.
|
||||
/// Should be at least 2× the star visual radius (~1.0) so stars never visually
|
||||
/// overlap. Applied as a floor to the relaxed spacing in all layers.
|
||||
pub(crate) const MIN_SYSTEM_SPACING: f32 = 2.0;
|
||||
|
||||
// ── Sub-structs ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// Concord core — densely packed Concord-faction systems at the origin.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CoreParams {
|
||||
pub count: usize,
|
||||
/// Maximum distance from the origin for core systems.
|
||||
pub radius: f32,
|
||||
}
|
||||
|
||||
impl Default for CoreParams {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
count: DEFAULT_CORE_COUNT,
|
||||
radius: DEFAULT_CORE_RADIUS,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Horizontal disk of spiral arms in the XZ plane.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DiskParams {
|
||||
pub count: usize,
|
||||
pub arms: u32,
|
||||
pub twist: f32,
|
||||
}
|
||||
|
||||
impl Default for DiskParams {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
count: DEFAULT_DISK_COUNT,
|
||||
arms: DEFAULT_DISK_ARMS,
|
||||
twist: DEFAULT_DISK_TWIST,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One of the two polar beams (top = +Y, bottom = −Y). Beam systems are
|
||||
/// distributed in a tapered cone along the beam axis; they are fully
|
||||
/// selectable star systems equal to disk arms.
|
||||
///
|
||||
/// The cross-section at any height `t` (0 = base, 1 = tip) has radius
|
||||
/// `thickness × (1 − t)^taper`, producing a Vercidium-style diffraction
|
||||
/// spike: bright and concentrated near the galactic core, thinning to
|
||||
/// nothing at the tips.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BeamParams {
|
||||
pub enabled: bool,
|
||||
/// Number of star systems to spawn in this beam. UI label: "Density".
|
||||
pub count: usize,
|
||||
/// Maximum cylinder radius at the base (t=0). The actual radius tapers
|
||||
/// toward the tip according to `taper`.
|
||||
pub thickness: f32,
|
||||
/// Extent along the beam axis (one-sided; total beam length is 2 × length
|
||||
/// because there are two beams).
|
||||
pub length: f32,
|
||||
/// Shape exponent controlling how the beam tapers.
|
||||
/// < 1.0 → wide bulbous beam, barely narrows
|
||||
/// = 1.0 → linear double-cone
|
||||
/// = 1.5 → moderate spike (Vercidium default look)
|
||||
/// > 2.0 → sharp needle
|
||||
/// The cross-section radius at height `t` is `thickness × (1-t)^taper`.
|
||||
pub taper: f32,
|
||||
/// Radial density exponent for the beam cross-section.
|
||||
/// Higher values concentrate systems toward the Y axis (tighter, brighter
|
||||
/// core); lower values spread them out (more diffuse beam).
|
||||
/// 1.0 = uniform disk sampling
|
||||
/// 1.5 = moderate concentration (default)
|
||||
/// 3.0+ = very tight, needle-like core
|
||||
pub gravity: f32,
|
||||
}
|
||||
|
||||
impl Default for BeamParams {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: DEFAULT_BEAM_ENABLED,
|
||||
count: DEFAULT_BEAM_COUNT,
|
||||
thickness: DEFAULT_BEAM_THICKNESS,
|
||||
length: DEFAULT_BEAM_LENGTH,
|
||||
taper: DEFAULT_BEAM_TAPER,
|
||||
gravity: DEFAULT_BEAM_GRAVITY,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Procedural galaxy parameters. UI mutates fields, then bumps `generation`
|
||||
/// to signal the regeneration system to rebuild the scene.
|
||||
#[derive(Resource, Debug, Clone)]
|
||||
pub struct GalaxyParams {
|
||||
pub seed: u64,
|
||||
pub count: usize,
|
||||
pub arms: u32,
|
||||
pub vertical_arms: u32,
|
||||
/// Overall galaxy extent — drives the disk radius.
|
||||
pub size: f32,
|
||||
pub twist: f32,
|
||||
pub vertical_twist: f32,
|
||||
pub core: CoreParams,
|
||||
pub disk: DiskParams,
|
||||
pub beam_top: BeamParams,
|
||||
pub beam_bottom: BeamParams,
|
||||
/// Monotonic counter — any mutation must bump this via [`Self::bump_generation`].
|
||||
/// The regeneration system triggers a rebuild whenever this differs from its
|
||||
/// last-seen value.
|
||||
@@ -74,12 +202,11 @@ impl Default for GalaxyParams {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
seed: DEFAULT_SEED,
|
||||
count: DEFAULT_COUNT,
|
||||
arms: DEFAULT_ARMS,
|
||||
vertical_arms: DEFAULT_VERTICAL_ARMS,
|
||||
size: DEFAULT_SIZE,
|
||||
twist: DEFAULT_TWIST,
|
||||
vertical_twist: DEFAULT_VERTICAL_TWIST,
|
||||
core: CoreParams::default(),
|
||||
disk: DiskParams::default(),
|
||||
beam_top: BeamParams::default(),
|
||||
beam_bottom: BeamParams::default(),
|
||||
generation: 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,23 +131,187 @@ 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);
|
||||
// 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| {
|
||||
// ── 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.
|
||||
parent
|
||||
scroll
|
||||
.spawn((
|
||||
Button,
|
||||
Node {
|
||||
@@ -138,7 +340,7 @@ fn spawn_control_panel(commands: &mut Commands) {
|
||||
});
|
||||
|
||||
// Center View (reset orbit camera) button.
|
||||
parent
|
||||
scroll
|
||||
.spawn((
|
||||
Button,
|
||||
Node {
|
||||
@@ -167,7 +369,7 @@ fn spawn_control_panel(commands: &mut Commands) {
|
||||
});
|
||||
|
||||
// Help text.
|
||||
parent.spawn((
|
||||
scroll.spawn((
|
||||
Text::new("Drag to orbit (360°) • Scroll to zoom • Click a star • Esc to return"),
|
||||
TextFont {
|
||||
font_size: HELP_FONT_SIZE,
|
||||
@@ -180,6 +382,23 @@ fn spawn_control_panel(commands: &mut Commands) {
|
||||
},
|
||||
));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user