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` |
|
| Functions, systems, locals | `snake_case`, verb-first | `spawn_camera`, `setup_main_menu` |
|
||||||
| Constants, statics | `SCREAMING_SNAKE_CASE` | `MAX_HEALTH` |
|
| 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
|
## Architecture Notes
|
||||||
|
|
||||||
- **State machine driven**: each `AppState` variant has its own UI/systems wired via `OnEnter` / `OnExit` / `Update` (latter guarded by `in_state`).
|
- **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() {
|
for event in scroll_events.read() {
|
||||||
|
if cursor_over_ui {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
// Scroll up (positive y) → decrease distance (zoom in).
|
// Scroll up (positive y) → decrease distance (zoom in).
|
||||||
orbit.distance *= 1.0 - event.y * ORBIT_ZOOM_SENSITIVITY;
|
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 contents::{SystemContents, SystemContext, SystemSummary};
|
||||||
pub use params::{GalaxyParams, SelectedStar};
|
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;
|
pub struct GalaxyPlugin;
|
||||||
|
|
||||||
@@ -46,6 +47,7 @@ impl Plugin for GalaxyPlugin {
|
|||||||
escape_to_main_menu,
|
escape_to_main_menu,
|
||||||
ui::param_button_handler,
|
ui::param_button_handler,
|
||||||
ui::refresh_control_panel_values,
|
ui::refresh_control_panel_values,
|
||||||
|
ui::scroll_control_panel,
|
||||||
ui::reset_view_button_handler,
|
ui::reset_view_button_handler,
|
||||||
regenerate_galaxy_on_param_change,
|
regenerate_galaxy_on_param_change,
|
||||||
selection::select_star_on_click,
|
selection::select_star_on_click,
|
||||||
@@ -179,116 +181,312 @@ fn generate_galaxy(
|
|||||||
(systems, contents, connections)
|
(systems, contents, connections)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Position-only galaxy generation. Pure faithful port of the TS reference
|
/// Position-only galaxy generation. Composes three structural layers —
|
||||||
/// in `apps/docs/src/prototypes/r3f/galaxy/GalaxyScene.tsx::generateGalaxy`.
|
/// a [`CoreParams`] cluster, a [`DiskParams`] of horizontal spiral arms,
|
||||||
/// Split out so [`generate_galaxy`] can compose it with POI generation.
|
/// 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> {
|
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.
|
// Spacing scales with overall density: tighter for crowded galaxies,
|
||||||
let base_spacing = (params.size / (params.count as f32).sqrt() * 1.25).clamp(9.0, 24.0);
|
// looser for sparse ones. Same heuristic as the previous single-pass
|
||||||
let horizontal_arms = params.arms.max(1);
|
// generator, but keyed off the combined layer count.
|
||||||
let vertical_arms = params.vertical_arms;
|
let base_spacing = (params.size / (total.max(1) as f32).sqrt() * 1.25).clamp(9.0, 24.0);
|
||||||
let total_arm_slots = horizontal_arms + vertical_arms;
|
|
||||||
|
|
||||||
for index in 0..params.count {
|
// Counter shared across passes for `g-{n}` IDs and name suffixes.
|
||||||
let core = index < CORE_COUNT;
|
let mut next_index = 0usize;
|
||||||
let arm_slot = (index as u32) % total_arm_slots.max(1);
|
|
||||||
let vertical = !core && arm_slot >= horizontal_arms;
|
generate_core(&mut systems, rng, ¶ms.core, params.size, base_spacing, &mut next_index);
|
||||||
let arm = if vertical {
|
generate_disk(
|
||||||
arm_slot - horizontal_arms
|
&mut systems,
|
||||||
} else {
|
rng,
|
||||||
arm_slot
|
¶ms.disk,
|
||||||
};
|
params.size,
|
||||||
let faction_index = if core {
|
base_spacing,
|
||||||
0 // Concord
|
&mut next_index,
|
||||||
} else {
|
);
|
||||||
((arm_slot as usize) % (FACTIONS.len() - 1)) + 1
|
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 (faction, color) = FACTIONS[faction_index];
|
||||||
|
|
||||||
let mut position = Vec3::ZERO;
|
let mut position = Option::<Vec3>::None;
|
||||||
let mut final_radius = 0.0f32;
|
let mut final_radius = 0.0f32;
|
||||||
for attempt in 0..SPACING_ATTEMPTS {
|
for attempt in 0..SPACING_ATTEMPTS {
|
||||||
// `pow(0.62)` produces a density bias toward the core.
|
let r = rng.gen::<f32>().powf(0.62) * galaxy_size;
|
||||||
let r = if core {
|
let angle = std::f32::consts::TAU * arm as f32 / arm_count as f32
|
||||||
8.0 + rng.gen::<f32>() * 30.0
|
+ (r / galaxy_size) * disk.twist
|
||||||
} else {
|
+ (rng.gen::<f32>() - 0.5) * 0.72;
|
||||||
rng.gen::<f32>().powf(0.62) * params.size
|
let y = (rng.gen::<f32>() - 0.5) * 20.0;
|
||||||
};
|
let candidate = Vec3::new(angle.cos() * r, y, angle.sin() * r);
|
||||||
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);
|
|
||||||
final_radius = r;
|
final_radius = r;
|
||||||
|
let spacing = relax_spacing(base_spacing, attempt).max(MIN_SYSTEM_SPACING);
|
||||||
// Spacing relaxation: shrink required distance after many failed attempts.
|
if systems
|
||||||
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
|
|
||||||
.iter()
|
.iter()
|
||||||
.all(|s| s.position.distance(candidate) >= local_spacing);
|
.all(|s| s.position.distance(candidate) >= spacing)
|
||||||
if clear {
|
{
|
||||||
position = candidate;
|
position = Some(candidate);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let security = ((1.0 - (final_radius / params.size) * 2.0) * 100.0).round() / 100.0;
|
let Some(position) = position else {
|
||||||
let name = if core {
|
continue;
|
||||||
format!("COR-{}", 100 + index)
|
|
||||||
} else {
|
|
||||||
format!("{}-{}", &faction[..3].to_uppercase(), 100 + index)
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let security = security_for_radius(final_radius, galaxy_size);
|
||||||
|
let idx = *next_index;
|
||||||
|
*next_index += 1;
|
||||||
systems.push(GeneratedSystem {
|
systems.push(GeneratedSystem {
|
||||||
id: format!("g-{index}"),
|
id: format!("g-{idx}"),
|
||||||
name,
|
name: format!("{}-{}", &faction[..3].to_uppercase(), 100 + idx),
|
||||||
position,
|
position,
|
||||||
faction,
|
faction,
|
||||||
faction_index,
|
faction_index,
|
||||||
color,
|
color,
|
||||||
security,
|
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 {
|
struct GeneratedSystem {
|
||||||
@@ -301,10 +499,14 @@ struct GeneratedSystem {
|
|||||||
/// by `faction_index`, but this is kept for future per-system variation.
|
/// by `faction_index`, but this is kept for future per-system variation.
|
||||||
color: [f32; 3],
|
color: [f32; 3],
|
||||||
security: f32,
|
security: f32,
|
||||||
/// True for the [`CORE_COUNT`] Concord systems clustered near the origin.
|
/// True for Concord core systems (see [`CoreParams`]). Used by
|
||||||
/// Used by [`contents::generate_system_contents`] to bias planet / station
|
/// [`contents::generate_system_contents`] to bias planet / station counts
|
||||||
/// counts toward core systems.
|
/// toward core systems, and by the spawner for visual differentiation.
|
||||||
is_core: bool,
|
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(
|
fn spawn_galaxy_scene(
|
||||||
@@ -337,6 +539,14 @@ fn spawn_galaxy_scene(
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
.collect();
|
.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 {
|
let connection_material = materials.add(StandardMaterial {
|
||||||
base_color: Color::srgb(0.11, 0.16, 0.25),
|
base_color: Color::srgb(0.11, 0.16, 0.25),
|
||||||
emissive: LinearRgba::new(0.08, 0.13, 0.22, 1.0),
|
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 {
|
let (mesh, material, scale) = if is_core {
|
||||||
// Visual differentiation for the 7 Concord core systems.
|
// Visual differentiation for the 7 Concord core systems.
|
||||||
(core_star_mesh.clone(), faction_materials[0].clone(), 1.1)
|
(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 {
|
} else {
|
||||||
(
|
(
|
||||||
star_mesh.clone(),
|
star_mesh.clone(),
|
||||||
|
|||||||
@@ -3,6 +3,15 @@
|
|||||||
//! [`GalaxyParams`] is the input to procedural generation; the UI mutates it,
|
//! [`GalaxyParams`] is the input to procedural generation; the UI mutates it,
|
||||||
//! the regeneration system detects changes via the `generation` counter, and
|
//! the regeneration system detects changes via the `generation` counter, and
|
||||||
//! the generator reads it. [`SelectedStar`] tracks the currently-clicked star.
|
//! 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::*;
|
use bevy::prelude::*;
|
||||||
|
|
||||||
@@ -12,39 +21,68 @@ use bevy::prelude::*;
|
|||||||
// so the Bevy scene opens with the same look as the web demo.
|
// so the Bevy scene opens with the same look as the web demo.
|
||||||
|
|
||||||
const DEFAULT_SEED: u64 = 4242;
|
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_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 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_MIN: f32 = 140.0;
|
||||||
pub const SIZE_MAX: f32 = 420.0;
|
pub const SIZE_MAX: f32 = 420.0;
|
||||||
pub const SIZE_STEP: f32 = 10.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.
|
pub const CORE_COUNT_MIN: usize = 0;
|
||||||
/// Not exposed in the UI — kept as an internal constant for now.
|
pub const CORE_COUNT_MAX: usize = 30;
|
||||||
pub(crate) const CORE_COUNT: usize = 7;
|
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).
|
/// Connect each system to its N nearest neighbors (deduplicated).
|
||||||
/// Docs prototype uses 2; kept constant until a UI control is added.
|
/// 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).
|
/// Max attempts to find a clear spot for each system (Poisson-style spacing).
|
||||||
pub(crate) const SPACING_ATTEMPTS: usize = 180;
|
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`
|
/// Procedural galaxy parameters. UI mutates fields, then bumps `generation`
|
||||||
/// to signal the regeneration system to rebuild the scene.
|
/// to signal the regeneration system to rebuild the scene.
|
||||||
#[derive(Resource, Debug, Clone)]
|
#[derive(Resource, Debug, Clone)]
|
||||||
pub struct GalaxyParams {
|
pub struct GalaxyParams {
|
||||||
pub seed: u64,
|
pub seed: u64,
|
||||||
pub count: usize,
|
/// Overall galaxy extent — drives the disk radius.
|
||||||
pub arms: u32,
|
|
||||||
pub vertical_arms: u32,
|
|
||||||
pub size: f32,
|
pub size: f32,
|
||||||
pub twist: f32,
|
pub core: CoreParams,
|
||||||
pub vertical_twist: f32,
|
pub disk: DiskParams,
|
||||||
|
pub beam_top: BeamParams,
|
||||||
|
pub beam_bottom: BeamParams,
|
||||||
/// Monotonic counter — any mutation must bump this via [`Self::bump_generation`].
|
/// Monotonic counter — any mutation must bump this via [`Self::bump_generation`].
|
||||||
/// The regeneration system triggers a rebuild whenever this differs from its
|
/// The regeneration system triggers a rebuild whenever this differs from its
|
||||||
/// last-seen value.
|
/// last-seen value.
|
||||||
@@ -74,12 +202,11 @@ impl Default for GalaxyParams {
|
|||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
seed: DEFAULT_SEED,
|
seed: DEFAULT_SEED,
|
||||||
count: DEFAULT_COUNT,
|
|
||||||
arms: DEFAULT_ARMS,
|
|
||||||
vertical_arms: DEFAULT_VERTICAL_ARMS,
|
|
||||||
size: DEFAULT_SIZE,
|
size: DEFAULT_SIZE,
|
||||||
twist: DEFAULT_TWIST,
|
core: CoreParams::default(),
|
||||||
vertical_twist: DEFAULT_VERTICAL_TWIST,
|
disk: DiskParams::default(),
|
||||||
|
beam_top: BeamParams::default(),
|
||||||
|
beam_bottom: BeamParams::default(),
|
||||||
generation: 0,
|
generation: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,12 @@
|
|||||||
//! Bevy 0.16 does not ship a native Slider widget, so each parameter is a
|
//! Bevy 0.16 does not ship a native Slider widget, so each parameter is a
|
||||||
//! row of `Label Value [-] [+]` buttons. Each `+/-` button carries a
|
//! row of `Label Value [-] [+]` buttons. Each `+/-` button carries a
|
||||||
//! [`ParamButton`] marker identifying which field to mutate.
|
//! [`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 super::GalaxySpawned;
|
||||||
use crate::gameplay::galaxy::params::*;
|
use crate::gameplay::galaxy::params::*;
|
||||||
@@ -15,6 +19,10 @@ use crate::gameplay::galaxy::params::*;
|
|||||||
#[derive(Component)]
|
#[derive(Component)]
|
||||||
pub struct GalaxyControlPanel;
|
pub struct GalaxyControlPanel;
|
||||||
|
|
||||||
|
/// Marker for the scrollable inner container of the control panel.
|
||||||
|
#[derive(Component)]
|
||||||
|
pub struct GalaxyScrollContent;
|
||||||
|
|
||||||
#[derive(Component)]
|
#[derive(Component)]
|
||||||
pub struct GalaxyInfoPanel;
|
pub struct GalaxyInfoPanel;
|
||||||
|
|
||||||
@@ -23,18 +31,46 @@ pub struct GalaxyInfoPanel;
|
|||||||
pub enum ParamButton {
|
pub enum ParamButton {
|
||||||
SeedDecr,
|
SeedDecr,
|
||||||
SeedIncr,
|
SeedIncr,
|
||||||
CountDecr,
|
|
||||||
CountIncr,
|
|
||||||
ArmsDecr,
|
|
||||||
ArmsIncr,
|
|
||||||
VerticalArmsDecr,
|
|
||||||
VerticalArmsIncr,
|
|
||||||
SizeDecr,
|
SizeDecr,
|
||||||
SizeIncr,
|
SizeIncr,
|
||||||
TwistDecr,
|
// Core
|
||||||
TwistIncr,
|
CoreCountDecr,
|
||||||
VerticalTwistDecr,
|
CoreCountIncr,
|
||||||
VerticalTwistIncr,
|
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).
|
/// Randomize: bump seed by 1 (equivalent to the docs "Regenerate" button).
|
||||||
Regenerate,
|
Regenerate,
|
||||||
/// Reset orbit camera to default orientation.
|
/// 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 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_BRIGHT: Color = Color::srgb(0.82, 0.90, 1.0);
|
||||||
const TEXT_DIM: Color = Color::srgb(0.55, 0.65, 0.82);
|
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 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 PANEL_PADDING: f32 = 14.0;
|
||||||
const TITLE_FONT_SIZE: f32 = 20.0;
|
const TITLE_FONT_SIZE: f32 = 20.0;
|
||||||
|
const SECTION_FONT_SIZE: f32 = 12.0;
|
||||||
const LABEL_FONT_SIZE: f32 = 15.0;
|
const LABEL_FONT_SIZE: f32 = 15.0;
|
||||||
const VALUE_FONT_SIZE: f32 = 15.0;
|
const VALUE_FONT_SIZE: f32 = 15.0;
|
||||||
const BUTTON_FONT_SIZE: f32 = 16.0;
|
const BUTTON_FONT_SIZE: f32 = 16.0;
|
||||||
@@ -71,10 +109,9 @@ fn spawn_control_panel(commands: &mut Commands) {
|
|||||||
position_type: PositionType::Absolute,
|
position_type: PositionType::Absolute,
|
||||||
left: Val::Px(12.0),
|
left: Val::Px(12.0),
|
||||||
top: Val::Px(12.0),
|
top: Val::Px(12.0),
|
||||||
|
bottom: Val::Px(12.0),
|
||||||
width: Val::Px(PANEL_WIDTH),
|
width: Val::Px(PANEL_WIDTH),
|
||||||
padding: UiRect::all(Val::Px(PANEL_PADDING)),
|
|
||||||
flex_direction: FlexDirection::Column,
|
flex_direction: FlexDirection::Column,
|
||||||
row_gap: Val::Px(6.0),
|
|
||||||
border: UiRect::all(Val::Px(1.0)),
|
border: UiRect::all(Val::Px(1.0)),
|
||||||
..default()
|
..default()
|
||||||
},
|
},
|
||||||
@@ -85,6 +122,7 @@ fn spawn_control_panel(commands: &mut Commands) {
|
|||||||
GalaxySpawned,
|
GalaxySpawned,
|
||||||
))
|
))
|
||||||
.with_children(|parent| {
|
.with_children(|parent| {
|
||||||
|
// Sticky title — pinned above the scroll area.
|
||||||
parent.spawn((
|
parent.spawn((
|
||||||
Text::new("Galaxy Parameters"),
|
Text::new("Galaxy Parameters"),
|
||||||
TextFont {
|
TextFont {
|
||||||
@@ -93,29 +131,18 @@ fn spawn_control_panel(commands: &mut Commands) {
|
|||||||
},
|
},
|
||||||
TextColor(TEXT_BRIGHT),
|
TextColor(TEXT_BRIGHT),
|
||||||
Node {
|
Node {
|
||||||
margin: UiRect::bottom(Val::Px(8.0)),
|
padding: UiRect::px(PANEL_PADDING, PANEL_PADDING, PANEL_PADDING, 4.0),
|
||||||
..default()
|
..default()
|
||||||
},
|
},
|
||||||
));
|
));
|
||||||
|
|
||||||
// Current params are read at button-press time in `param_button_handler`,
|
// Randomize button — pinned above the scroll area.
|
||||||
// so we don't need to thread `Res<GalaxyParams>` through setup.
|
|
||||||
spawn_param_row(parent, "Seed", "seed", ParamButton::SeedDecr, ParamButton::SeedIncr);
|
|
||||||
spawn_param_row(parent, "Systems", "count", ParamButton::CountDecr, ParamButton::CountIncr);
|
|
||||||
spawn_param_row(parent, "Disk Arms", "arms", ParamButton::ArmsDecr, ParamButton::ArmsIncr);
|
|
||||||
spawn_param_row(parent, "Vertical Arms", "varms", ParamButton::VerticalArmsDecr, ParamButton::VerticalArmsIncr);
|
|
||||||
spawn_param_row(parent, "Size", "size", ParamButton::SizeDecr, ParamButton::SizeIncr);
|
|
||||||
spawn_param_row(parent, "Twist", "twist", ParamButton::TwistDecr, ParamButton::TwistIncr);
|
|
||||||
spawn_param_row(parent, "Vertical Twist", "vtwist", ParamButton::VerticalTwistDecr, ParamButton::VerticalTwistIncr);
|
|
||||||
|
|
||||||
// Regenerate (randomize seed) button.
|
|
||||||
parent
|
parent
|
||||||
.spawn((
|
.spawn((
|
||||||
Button,
|
Button,
|
||||||
Node {
|
Node {
|
||||||
width: Val::Percent(100.0),
|
|
||||||
height: Val::Px(32.0),
|
height: Val::Px(32.0),
|
||||||
margin: UiRect::top(Val::Px(8.0)),
|
margin: UiRect::px(PANEL_PADDING, PANEL_PADDING, 4.0, PANEL_PADDING),
|
||||||
justify_content: JustifyContent::Center,
|
justify_content: JustifyContent::Center,
|
||||||
align_items: AlignItems::Center,
|
align_items: AlignItems::Center,
|
||||||
border: UiRect::all(Val::Px(1.0)),
|
border: UiRect::all(Val::Px(1.0)),
|
||||||
@@ -124,11 +151,11 @@ fn spawn_control_panel(commands: &mut Commands) {
|
|||||||
BackgroundColor(BUTTON_BG),
|
BackgroundColor(BUTTON_BG),
|
||||||
BorderColor(PANEL_BORDER),
|
BorderColor(PANEL_BORDER),
|
||||||
BorderRadius::all(Val::Px(6.0)),
|
BorderRadius::all(Val::Px(6.0)),
|
||||||
ParamButton::Regenerate,
|
ParamButton::RandomSettings,
|
||||||
))
|
))
|
||||||
.with_children(|btn| {
|
.with_children(|btn| {
|
||||||
btn.spawn((
|
btn.spawn((
|
||||||
Text::new("Regenerate"),
|
Text::new("Randomize Settings"),
|
||||||
TextFont {
|
TextFont {
|
||||||
font_size: BUTTON_FONT_SIZE,
|
font_size: BUTTON_FONT_SIZE,
|
||||||
..default()
|
..default()
|
||||||
@@ -137,51 +164,243 @@ fn spawn_control_panel(commands: &mut Commands) {
|
|||||||
));
|
));
|
||||||
});
|
});
|
||||||
|
|
||||||
// Center View (reset orbit camera) button.
|
// Scrollable content area.
|
||||||
parent
|
parent
|
||||||
.spawn((
|
.spawn((
|
||||||
Button,
|
|
||||||
Node {
|
Node {
|
||||||
width: Val::Percent(100.0),
|
flex_grow: 1.0,
|
||||||
height: Val::Px(32.0),
|
flex_direction: FlexDirection::Column,
|
||||||
margin: UiRect::top(Val::Px(4.0)),
|
row_gap: Val::Px(6.0),
|
||||||
justify_content: JustifyContent::Center,
|
padding: UiRect::px(PANEL_PADDING, PANEL_PADDING, PANEL_PADDING, PANEL_PADDING),
|
||||||
align_items: AlignItems::Center,
|
overflow: Overflow::scroll_y(),
|
||||||
border: UiRect::all(Val::Px(1.0)),
|
|
||||||
..default()
|
..default()
|
||||||
},
|
},
|
||||||
BackgroundColor(BUTTON_BG),
|
ScrollPosition::default(),
|
||||||
BorderColor(PANEL_BORDER),
|
GalaxyScrollContent,
|
||||||
BorderRadius::all(Val::Px(6.0)),
|
|
||||||
ParamButton::CenterView,
|
|
||||||
))
|
))
|
||||||
.with_children(|btn| {
|
.with_children(|scroll| {
|
||||||
btn.spawn((
|
// ── Galaxy (seed + size) ────────────────────────────────
|
||||||
Text::new("Center View"),
|
spawn_section(scroll, "Galaxy");
|
||||||
|
spawn_param_row(scroll, "Seed", "seed", ParamButton::SeedDecr, ParamButton::SeedIncr);
|
||||||
|
spawn_param_row(scroll, "Size", "size", ParamButton::SizeDecr, ParamButton::SizeIncr);
|
||||||
|
|
||||||
|
// ── Core ────────────────────────────────────────────────
|
||||||
|
spawn_section(scroll, "Core");
|
||||||
|
spawn_param_row(
|
||||||
|
scroll,
|
||||||
|
"Core Count",
|
||||||
|
"core_count",
|
||||||
|
ParamButton::CoreCountDecr,
|
||||||
|
ParamButton::CoreCountIncr,
|
||||||
|
);
|
||||||
|
spawn_param_row(
|
||||||
|
scroll,
|
||||||
|
"Core Radius",
|
||||||
|
"core_radius",
|
||||||
|
ParamButton::CoreRadiusDecr,
|
||||||
|
ParamButton::CoreRadiusIncr,
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Disk ────────────────────────────────────────────────
|
||||||
|
spawn_section(scroll, "Disk");
|
||||||
|
spawn_param_row(
|
||||||
|
scroll,
|
||||||
|
"Disk Count",
|
||||||
|
"disk_count",
|
||||||
|
ParamButton::DiskCountDecr,
|
||||||
|
ParamButton::DiskCountIncr,
|
||||||
|
);
|
||||||
|
spawn_param_row(
|
||||||
|
scroll,
|
||||||
|
"Disk Arms",
|
||||||
|
"disk_arms",
|
||||||
|
ParamButton::DiskArmsDecr,
|
||||||
|
ParamButton::DiskArmsIncr,
|
||||||
|
);
|
||||||
|
spawn_param_row(
|
||||||
|
scroll,
|
||||||
|
"Disk Twist",
|
||||||
|
"disk_twist",
|
||||||
|
ParamButton::DiskTwistDecr,
|
||||||
|
ParamButton::DiskTwistIncr,
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Top Beam (+Y) ───────────────────────────────────────
|
||||||
|
spawn_section(scroll, "Top Beam (+Y)");
|
||||||
|
spawn_toggle_row(scroll, "Enabled", "beam_top_enabled", ParamButton::BeamTopEnabled);
|
||||||
|
spawn_param_row(
|
||||||
|
scroll,
|
||||||
|
"Thickness",
|
||||||
|
"beam_top_thickness",
|
||||||
|
ParamButton::BeamTopThicknessDecr,
|
||||||
|
ParamButton::BeamTopThicknessIncr,
|
||||||
|
);
|
||||||
|
spawn_param_row(
|
||||||
|
scroll,
|
||||||
|
"Length",
|
||||||
|
"beam_top_length",
|
||||||
|
ParamButton::BeamTopLengthDecr,
|
||||||
|
ParamButton::BeamTopLengthIncr,
|
||||||
|
);
|
||||||
|
spawn_param_row(
|
||||||
|
scroll,
|
||||||
|
"Density",
|
||||||
|
"beam_top_density",
|
||||||
|
ParamButton::BeamTopDensityDecr,
|
||||||
|
ParamButton::BeamTopDensityIncr,
|
||||||
|
);
|
||||||
|
spawn_param_row(
|
||||||
|
scroll,
|
||||||
|
"Taper",
|
||||||
|
"beam_top_taper",
|
||||||
|
ParamButton::BeamTopTaperDecr,
|
||||||
|
ParamButton::BeamTopTaperIncr,
|
||||||
|
);
|
||||||
|
spawn_param_row(
|
||||||
|
scroll,
|
||||||
|
"Gravity",
|
||||||
|
"beam_top_gravity",
|
||||||
|
ParamButton::BeamTopGravityDecr,
|
||||||
|
ParamButton::BeamTopGravityIncr,
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Bottom Beam (-Y) ────────────────────────────────────
|
||||||
|
spawn_section(scroll, "Bottom Beam (-Y)");
|
||||||
|
spawn_toggle_row(
|
||||||
|
scroll,
|
||||||
|
"Enabled",
|
||||||
|
"beam_bottom_enabled",
|
||||||
|
ParamButton::BeamBottomEnabled,
|
||||||
|
);
|
||||||
|
spawn_param_row(
|
||||||
|
scroll,
|
||||||
|
"Thickness",
|
||||||
|
"beam_bottom_thickness",
|
||||||
|
ParamButton::BeamBottomThicknessDecr,
|
||||||
|
ParamButton::BeamBottomThicknessIncr,
|
||||||
|
);
|
||||||
|
spawn_param_row(
|
||||||
|
scroll,
|
||||||
|
"Length",
|
||||||
|
"beam_bottom_length",
|
||||||
|
ParamButton::BeamBottomLengthDecr,
|
||||||
|
ParamButton::BeamBottomLengthIncr,
|
||||||
|
);
|
||||||
|
spawn_param_row(
|
||||||
|
scroll,
|
||||||
|
"Density",
|
||||||
|
"beam_bottom_density",
|
||||||
|
ParamButton::BeamBottomDensityDecr,
|
||||||
|
ParamButton::BeamBottomDensityIncr,
|
||||||
|
);
|
||||||
|
spawn_param_row(
|
||||||
|
scroll,
|
||||||
|
"Taper",
|
||||||
|
"beam_bottom_taper",
|
||||||
|
ParamButton::BeamBottomTaperDecr,
|
||||||
|
ParamButton::BeamBottomTaperIncr,
|
||||||
|
);
|
||||||
|
spawn_param_row(
|
||||||
|
scroll,
|
||||||
|
"Gravity",
|
||||||
|
"beam_bottom_gravity",
|
||||||
|
ParamButton::BeamBottomGravityDecr,
|
||||||
|
ParamButton::BeamBottomGravityIncr,
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Action buttons ──────────────────────────────────────
|
||||||
|
|
||||||
|
// Regenerate (randomize seed) button.
|
||||||
|
scroll
|
||||||
|
.spawn((
|
||||||
|
Button,
|
||||||
|
Node {
|
||||||
|
width: Val::Percent(100.0),
|
||||||
|
height: Val::Px(32.0),
|
||||||
|
margin: UiRect::top(Val::Px(8.0)),
|
||||||
|
justify_content: JustifyContent::Center,
|
||||||
|
align_items: AlignItems::Center,
|
||||||
|
border: UiRect::all(Val::Px(1.0)),
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
BackgroundColor(BUTTON_BG),
|
||||||
|
BorderColor(PANEL_BORDER),
|
||||||
|
BorderRadius::all(Val::Px(6.0)),
|
||||||
|
ParamButton::Regenerate,
|
||||||
|
))
|
||||||
|
.with_children(|btn| {
|
||||||
|
btn.spawn((
|
||||||
|
Text::new("Regenerate"),
|
||||||
|
TextFont {
|
||||||
|
font_size: BUTTON_FONT_SIZE,
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
TextColor(TEXT_BRIGHT),
|
||||||
|
));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Center View (reset orbit camera) button.
|
||||||
|
scroll
|
||||||
|
.spawn((
|
||||||
|
Button,
|
||||||
|
Node {
|
||||||
|
width: Val::Percent(100.0),
|
||||||
|
height: Val::Px(32.0),
|
||||||
|
margin: UiRect::top(Val::Px(4.0)),
|
||||||
|
justify_content: JustifyContent::Center,
|
||||||
|
align_items: AlignItems::Center,
|
||||||
|
border: UiRect::all(Val::Px(1.0)),
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
BackgroundColor(BUTTON_BG),
|
||||||
|
BorderColor(PANEL_BORDER),
|
||||||
|
BorderRadius::all(Val::Px(6.0)),
|
||||||
|
ParamButton::CenterView,
|
||||||
|
))
|
||||||
|
.with_children(|btn| {
|
||||||
|
btn.spawn((
|
||||||
|
Text::new("Center View"),
|
||||||
|
TextFont {
|
||||||
|
font_size: BUTTON_FONT_SIZE,
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
TextColor(TEXT_BRIGHT),
|
||||||
|
));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Help text.
|
||||||
|
scroll.spawn((
|
||||||
|
Text::new("Drag to orbit (360°) • Scroll to zoom • Click a star • Esc to return"),
|
||||||
TextFont {
|
TextFont {
|
||||||
font_size: BUTTON_FONT_SIZE,
|
font_size: HELP_FONT_SIZE,
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
TextColor(TEXT_DIM),
|
||||||
|
Node {
|
||||||
|
margin: UiRect::top(Val::Px(10.0)),
|
||||||
..default()
|
..default()
|
||||||
},
|
},
|
||||||
TextColor(TEXT_BRIGHT),
|
|
||||||
));
|
));
|
||||||
});
|
});
|
||||||
|
|
||||||
// Help text.
|
|
||||||
parent.spawn((
|
|
||||||
Text::new("Drag to orbit (360°) • Scroll to zoom • Click a star • Esc to return"),
|
|
||||||
TextFont {
|
|
||||||
font_size: HELP_FONT_SIZE,
|
|
||||||
..default()
|
|
||||||
},
|
|
||||||
TextColor(TEXT_DIM),
|
|
||||||
Node {
|
|
||||||
margin: UiRect::top(Val::Px(10.0)),
|
|
||||||
..default()
|
|
||||||
},
|
|
||||||
));
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Section header row — small dim label with a top margin to separate groups.
|
||||||
|
fn spawn_section(parent: &mut ChildSpawnerCommands, label: &str) {
|
||||||
|
parent.spawn((
|
||||||
|
Text::new(label.to_uppercase()),
|
||||||
|
TextFont {
|
||||||
|
font_size: SECTION_FONT_SIZE,
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
TextColor(SECTION_TEXT),
|
||||||
|
Node {
|
||||||
|
margin: UiRect::top(Val::Px(4.0)),
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
/// Build a `[Label ........ value [-] [+]]` row. The `value_key` is stored in
|
/// 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
|
/// the marker as a label for the value text child; the actual value is read
|
||||||
/// from `GalaxyParams` at button-press time and updated by
|
/// from `GalaxyParams` at button-press time and updated by
|
||||||
@@ -211,7 +430,7 @@ fn spawn_param_row(
|
|||||||
},
|
},
|
||||||
TextColor(TEXT_DIM),
|
TextColor(TEXT_DIM),
|
||||||
Node {
|
Node {
|
||||||
width: Val::Px(118.0),
|
width: Val::Px(128.0),
|
||||||
..default()
|
..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
|
/// 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").
|
/// and update it. The `String` is the param field name (e.g. "seed", "twist").
|
||||||
#[derive(Component)]
|
#[derive(Component)]
|
||||||
pub(crate) struct ParamValue(pub(crate) String);
|
pub(crate) struct ParamValue(pub(crate) String);
|
||||||
|
|
||||||
fn spawn_icon_button(parent: &mut ChildSpawnerCommands, label: &str, marker: ParamButton) {
|
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
|
parent
|
||||||
.spawn((
|
.spawn((
|
||||||
Button,
|
Button,
|
||||||
Node {
|
Node {
|
||||||
width: Val::Px(26.0),
|
width: Val::Px(width),
|
||||||
height: Val::Px(26.0),
|
height: Val::Px(26.0),
|
||||||
justify_content: JustifyContent::Center,
|
justify_content: JustifyContent::Center,
|
||||||
align_items: AlignItems::Center,
|
align_items: AlignItems::Center,
|
||||||
@@ -329,6 +605,7 @@ pub fn param_button_handler(
|
|||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
match button {
|
match button {
|
||||||
|
// ── Galaxy ────────────────────────────────────────────────────
|
||||||
ParamButton::SeedDecr => {
|
ParamButton::SeedDecr => {
|
||||||
params.seed = params.seed.wrapping_sub(SEED_STEP);
|
params.seed = params.seed.wrapping_sub(SEED_STEP);
|
||||||
params.bump_generation();
|
params.bump_generation();
|
||||||
@@ -337,33 +614,6 @@ pub fn param_button_handler(
|
|||||||
params.seed = params.seed.wrapping_add(SEED_STEP);
|
params.seed = params.seed.wrapping_add(SEED_STEP);
|
||||||
params.bump_generation();
|
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 => {
|
ParamButton::SizeDecr => {
|
||||||
params.size = (params.size - SIZE_STEP).max(SIZE_MIN);
|
params.size = (params.size - SIZE_STEP).max(SIZE_MIN);
|
||||||
params.bump_generation();
|
params.bump_generation();
|
||||||
@@ -372,20 +622,189 @@ pub fn param_button_handler(
|
|||||||
params.size = (params.size + SIZE_STEP).min(SIZE_MAX);
|
params.size = (params.size + SIZE_STEP).min(SIZE_MAX);
|
||||||
params.bump_generation();
|
params.bump_generation();
|
||||||
}
|
}
|
||||||
ParamButton::TwistDecr => {
|
// ── Core ──────────────────────────────────────────────────────
|
||||||
params.twist = (params.twist - TWIST_STEP).max(TWIST_MIN);
|
ParamButton::CoreCountDecr => {
|
||||||
|
params.core.count = params
|
||||||
|
.core
|
||||||
|
.count
|
||||||
|
.saturating_sub(CORE_COUNT_STEP)
|
||||||
|
.max(CORE_COUNT_MIN);
|
||||||
params.bump_generation();
|
params.bump_generation();
|
||||||
}
|
}
|
||||||
ParamButton::TwistIncr => {
|
ParamButton::CoreCountIncr => {
|
||||||
params.twist = (params.twist + TWIST_STEP).min(TWIST_MAX);
|
params.core.count = (params.core.count + CORE_COUNT_STEP).min(CORE_COUNT_MAX);
|
||||||
params.bump_generation();
|
params.bump_generation();
|
||||||
}
|
}
|
||||||
ParamButton::VerticalTwistDecr => {
|
ParamButton::CoreRadiusDecr => {
|
||||||
params.vertical_twist = (params.vertical_twist - VERTICAL_TWIST_STEP).max(VERTICAL_TWIST_MIN);
|
params.core.radius = (params.core.radius - CORE_RADIUS_STEP).max(CORE_RADIUS_MIN);
|
||||||
params.bump_generation();
|
params.bump_generation();
|
||||||
}
|
}
|
||||||
ParamButton::VerticalTwistIncr => {
|
ParamButton::CoreRadiusIncr => {
|
||||||
params.vertical_twist = (params.vertical_twist + VERTICAL_TWIST_STEP).min(VERTICAL_TWIST_MAX);
|
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();
|
params.bump_generation();
|
||||||
}
|
}
|
||||||
ParamButton::Regenerate => params.reseed_and_bump(),
|
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.
|
/// 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
|
/// 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(
|
pub fn refresh_control_panel_values(
|
||||||
params: Res<GalaxyParams>,
|
params: Res<GalaxyParams>,
|
||||||
mut values: Query<(&ParamValue, &mut Text)>,
|
mut values: Query<(&ParamValue, &mut Text)>,
|
||||||
) {
|
) {
|
||||||
for (marker, mut text) in &mut values {
|
for (marker, mut text) in &mut values {
|
||||||
let new = match marker.0.as_str() {
|
let new = match marker.0.as_str() {
|
||||||
|
// Galaxy
|
||||||
"seed" => format!("{}", params.seed),
|
"seed" => format!("{}", params.seed),
|
||||||
"count" => format!("{}", params.count),
|
|
||||||
"arms" => format!("{}", params.arms),
|
|
||||||
"varms" => format!("{}", params.vertical_arms),
|
|
||||||
"size" => format!("{:.0}", params.size),
|
"size" => format!("{:.0}", params.size),
|
||||||
"twist" => format!("{:.1}", params.twist),
|
// Core
|
||||||
"vtwist" => format!("{:.1}", params.vertical_twist),
|
"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,
|
_ => continue,
|
||||||
};
|
};
|
||||||
if text.0 != new {
|
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`.
|
// `setup_galaxy_ui` runs on OnEnter(Galaxy) via the plugin in `mod.rs`.
|
||||||
// Despawning happens through the shared `GalaxySpawned` marker in the
|
// Despawning happens through the shared `GalaxySpawned` marker in the
|
||||||
// plugin's OnExit handler — no separate UI cleanup needed.
|
// 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