Add galaxy parameter UI, star selection, and movement/physics scaffolding
Galaxy creation scene (Bevy 0.16): - Split into module folder: params.rs, mod.rs (generation + spawn), ui.rs, selection.rs - GalaxyParams resource (seed, count, arms, vertical_arms, size, twist, vertical_twist) with generation counter for change detection - Control panel: 7 +/- slider rows + Regenerate button, no native Bevy slider widget so uses label + icon buttons - Info panel: live-refreshes on selection change via despawn_related::<Children> - Click-to-select: screen-space picking (project each star to viewport, closest within 18px threshold wins); selected star lerps scale to 2.2x - Generation faithfully ports the docs TS reference including vertical arms - Regeneration system: despawns GalaxyScene root only, preserves UI panels Camera: - Camera2d -> Camera3d + OrbitCamera (left-drag yaw/pitch, scroll zoom, pitch/distance clamped to avoid gimbal) - Orbit drag suppressed when cursor over UI New plugins (scaffolding): - movement/: Velocity, MaxSpeed, TurnRate, Drag, MoveTarget, Player components + click-to-move (no player spawned yet) - physics/: pure-data geometry (ray_vs_sphere, overlaps, separate, segment_vs_sphere) with 7 passing unit tests; no systems wired yet - star_map/: plugin skeleton gated on AppState::InGame Shared: - ui/util.rs: cursor_over_ui helper for UI-hover detection - docs: ARCH-9 decision record (custom movement & collision, no physics engine)
This commit is contained in:
369
apps/game/src/gameplay/galaxy_creation/mod.rs
Normal file
369
apps/game/src/gameplay/galaxy_creation/mod.rs
Normal file
@@ -0,0 +1,369 @@
|
||||
//! Galaxy creation inspection scene.
|
||||
//!
|
||||
//! Procedural spiral galaxy viewer with editable parameters. The 3D scene is
|
||||
//! regenerated whenever [`GalaxyParams`] changes (via the `generation` counter);
|
||||
//! click-to-select is handled in [`super::selection`]; the slider/info panels
|
||||
//! live in [`super::ui`].
|
||||
|
||||
mod params;
|
||||
mod selection;
|
||||
mod ui;
|
||||
|
||||
use bevy::prelude::*;
|
||||
use rand::rngs::StdRng;
|
||||
use rand::{Rng, SeedableRng};
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
pub use params::{GalaxyParams, SelectedStar};
|
||||
use params::{CORE_COUNT, NEAREST_NEIGHBOR_CONNECTIONS, SPACING_ATTEMPTS};
|
||||
|
||||
pub struct GalaxyCreationPlugin;
|
||||
|
||||
impl Plugin for GalaxyCreationPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.init_resource::<GalaxyParams>()
|
||||
.init_resource::<SelectedStar>()
|
||||
.add_systems(OnEnter(AppState::GalaxyCreation), (setup_galaxy_scene, ui::setup_galaxy_ui))
|
||||
.add_systems(OnExit(AppState::GalaxyCreation), (despawn_galaxy_creation, reset_selection))
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
escape_to_main_menu,
|
||||
ui::param_button_handler,
|
||||
ui::refresh_control_panel_values,
|
||||
regenerate_galaxy_on_param_change,
|
||||
selection::select_star_on_click,
|
||||
selection::animate_selected_star,
|
||||
selection::refresh_info_panel,
|
||||
)
|
||||
.chain()
|
||||
.run_if(in_state(AppState::GalaxyCreation)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Markers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Tag for *anything* spawned during galaxy creation so it can be cleanly
|
||||
/// despawned on state exit. Applies to both 3D scene roots and UI panel roots.
|
||||
#[derive(Component)]
|
||||
pub struct GalaxyCreationSpawned;
|
||||
|
||||
/// Tag for the 3D scene root only — despawned on regeneration, so we can
|
||||
/// rebuild the galaxy without disturbing the UI panels.
|
||||
#[derive(Component)]
|
||||
pub struct GalaxyScene;
|
||||
|
||||
/// A star system in the procedural galaxy.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct StarSystem {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub faction: &'static str,
|
||||
pub security: f32,
|
||||
}
|
||||
|
||||
// ── Faction palette (matches docs GalaxyScene) ──────────────────────────────
|
||||
|
||||
const FACTIONS: &[(&str, [f32; 3])] = &[
|
||||
("Concord", [0.13, 0.83, 0.93]), // cyan — core faction
|
||||
("Amarr", [0.96, 0.62, 0.04]), // amber
|
||||
("Minmatar", [0.94, 0.27, 0.27]), // red
|
||||
("Gallente", [0.66, 0.33, 0.97]), // purple
|
||||
("Caldari", [0.22, 0.74, 0.97]), // blue
|
||||
];
|
||||
|
||||
// ── Setup ───────────────────────────────────────────────────────────────────
|
||||
|
||||
fn setup_galaxy_scene(
|
||||
mut commands: Commands,
|
||||
mut meshes: ResMut<Assets<Mesh>>,
|
||||
mut materials: ResMut<Assets<StandardMaterial>>,
|
||||
params: Res<GalaxyParams>,
|
||||
) {
|
||||
let systems = generate_galaxy(¶ms);
|
||||
spawn_galaxy_scene(&mut commands, &mut meshes, &mut materials, &systems);
|
||||
}
|
||||
|
||||
/// Generate the full galaxy layout (pure data — no Bevy types).
|
||||
///
|
||||
/// Faithful port of the TS reference in
|
||||
/// `apps/docs/src/prototypes/r3f/galaxy/GalaxyScene.tsx::generateGalaxy`.
|
||||
fn generate_galaxy(params: &GalaxyParams) -> Vec<GeneratedSystem> {
|
||||
let mut rng = StdRng::seed_from_u64(params.seed);
|
||||
let mut systems: Vec<GeneratedSystem> = Vec::with_capacity(params.count);
|
||||
|
||||
// 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;
|
||||
|
||||
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
|
||||
};
|
||||
let (faction, color) = FACTIONS[faction_index];
|
||||
|
||||
let mut position = Vec3::ZERO;
|
||||
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);
|
||||
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
|
||||
.iter()
|
||||
.all(|s| s.position.distance(candidate) >= local_spacing);
|
||||
if clear {
|
||||
position = 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)
|
||||
};
|
||||
|
||||
systems.push(GeneratedSystem {
|
||||
id: format!("g-{index}"),
|
||||
name,
|
||||
position,
|
||||
faction,
|
||||
faction_index,
|
||||
color,
|
||||
security,
|
||||
});
|
||||
}
|
||||
|
||||
systems
|
||||
}
|
||||
|
||||
struct GeneratedSystem {
|
||||
id: String,
|
||||
name: String,
|
||||
position: Vec3,
|
||||
faction: &'static str,
|
||||
faction_index: usize,
|
||||
/// Per-system tint; currently the renderer uses per-faction materials indexed
|
||||
/// by `faction_index`, but this is kept for future per-system variation.
|
||||
#[allow(dead_code)]
|
||||
color: [f32; 3],
|
||||
security: f32,
|
||||
}
|
||||
|
||||
fn spawn_galaxy_scene(
|
||||
commands: &mut Commands,
|
||||
meshes: &mut Assets<Mesh>,
|
||||
materials: &mut Assets<StandardMaterial>,
|
||||
systems: &[GeneratedSystem],
|
||||
) {
|
||||
// Shared meshes (Bevy 0.16 required-components model — no bundle needed).
|
||||
let star_mesh = meshes.add(Sphere::new(1.5).mesh().ico(3).unwrap());
|
||||
let core_star_mesh = meshes.add(Sphere::new(3.0).mesh().ico(4).unwrap());
|
||||
|
||||
let faction_materials: Vec<Handle<StandardMaterial>> = FACTIONS
|
||||
.iter()
|
||||
.map(|(_, rgb)| {
|
||||
materials.add(StandardMaterial {
|
||||
base_color: Color::srgb(rgb[0], rgb[1], rgb[2]),
|
||||
emissive: LinearRgba::new(rgb[0] * 1.6, rgb[1] * 1.6, rgb[2] * 1.6, 1.0),
|
||||
unlit: true,
|
||||
..default()
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
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),
|
||||
unlit: true,
|
||||
..default()
|
||||
});
|
||||
|
||||
let connections = build_connections(systems);
|
||||
|
||||
// Parent group so all galaxy contents despawn together.
|
||||
commands
|
||||
.spawn((
|
||||
Transform::default(),
|
||||
GalaxyScene,
|
||||
GalaxyCreationSpawned,
|
||||
))
|
||||
.with_children(|parent| {
|
||||
// Star systems.
|
||||
for sys in systems {
|
||||
let (mesh, material) = if sys.faction_index == 0 && sys.position.length() < 40.0
|
||||
{
|
||||
// Visual differentiation for the 7 Concord core systems.
|
||||
(core_star_mesh.clone(), faction_materials[0].clone())
|
||||
} else {
|
||||
(star_mesh.clone(), faction_materials[sys.faction_index].clone())
|
||||
};
|
||||
parent.spawn((
|
||||
Mesh3d(mesh),
|
||||
MeshMaterial3d(material),
|
||||
Transform::from_translation(sys.position),
|
||||
StarSystem {
|
||||
id: sys.id.clone(),
|
||||
name: sys.name.clone(),
|
||||
faction: sys.faction,
|
||||
security: sys.security,
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
// Connections rendered as thin cylinders between linked systems.
|
||||
for (a, b) in connections {
|
||||
let from = systems[a].position;
|
||||
let to = systems[b].position;
|
||||
let delta = to - from;
|
||||
let length = delta.length();
|
||||
if length < 0.01 {
|
||||
continue;
|
||||
}
|
||||
let midpoint = (from + to) * 0.5;
|
||||
let direction = delta / length;
|
||||
// Cylinder default axis is +Y; rotate so +Y aligns with the link direction.
|
||||
let rotation = Quat::from_rotation_arc(Vec3::Y, direction);
|
||||
parent.spawn((
|
||||
Mesh3d(meshes.add(Cylinder::new(0.15, length).mesh())),
|
||||
MeshMaterial3d(connection_material.clone()),
|
||||
Transform::from_translation(midpoint).with_rotation(rotation),
|
||||
));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn build_connections(systems: &[GeneratedSystem]) -> Vec<(usize, usize)> {
|
||||
let mut connections: Vec<(usize, usize)> = Vec::new();
|
||||
for (i, system) in systems.iter().enumerate() {
|
||||
let mut distances: Vec<(usize, f32)> = systems
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(j, _)| *j != i)
|
||||
.map(|(j, other)| (j, system.position.distance(other.position)))
|
||||
.collect();
|
||||
distances.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
|
||||
for &(j, _) in distances.iter().take(NEAREST_NEIGHBOR_CONNECTIONS) {
|
||||
let key = if i < j { (i, j) } else { (j, i) };
|
||||
if !connections.contains(&key) {
|
||||
connections.push(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
connections
|
||||
}
|
||||
|
||||
// ── Lifecycle systems ───────────────────────────────────────────────────────
|
||||
|
||||
fn despawn_galaxy_creation(
|
||||
mut commands: Commands,
|
||||
query: Query<Entity, With<GalaxyCreationSpawned>>,
|
||||
) {
|
||||
for entity in &query {
|
||||
// Bevy 0.16: despawn() is recursive by default.
|
||||
commands.entity(entity).despawn();
|
||||
}
|
||||
}
|
||||
|
||||
fn reset_selection(mut selected: ResMut<SelectedStar>) {
|
||||
selected.0 = None;
|
||||
}
|
||||
|
||||
fn escape_to_main_menu(
|
||||
keys: Res<ButtonInput<KeyCode>>,
|
||||
mut next_state: ResMut<NextState<AppState>>,
|
||||
) {
|
||||
if keys.just_pressed(KeyCode::Escape) {
|
||||
next_state.set(AppState::MainMenu);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Regeneration ────────────────────────────────────────────────────────────
|
||||
|
||||
/// Detects [`GalaxyParams`] generation changes and rebuilds the 3D scene.
|
||||
///
|
||||
/// On first run, the OnEnter handler has already spawned the initial scene —
|
||||
/// we just record the generation and bail. On subsequent changes we despawn
|
||||
/// every `GalaxyScene` root (recursive by default — kills all star/connection
|
||||
/// children) and respawn a fresh galaxy.
|
||||
fn regenerate_galaxy_on_param_change(
|
||||
mut commands: Commands,
|
||||
mut meshes: ResMut<Assets<Mesh>>,
|
||||
mut materials: ResMut<Assets<StandardMaterial>>,
|
||||
params: Res<GalaxyParams>,
|
||||
scene_roots: Query<Entity, With<GalaxyScene>>,
|
||||
mut selected: ResMut<SelectedStar>,
|
||||
mut last_seen: Local<Option<u64>>,
|
||||
) {
|
||||
match *last_seen {
|
||||
Some(g) if g == params.generation => return,
|
||||
None => {
|
||||
// First run: OnEnter already spawned the scene. Just record + bail.
|
||||
*last_seen = Some(params.generation);
|
||||
return;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
*last_seen = Some(params.generation);
|
||||
|
||||
// The previously-selected entity is about to be despawned; clear it so
|
||||
// the info panel and animation system don't reference a dangling Entity.
|
||||
selected.0 = None;
|
||||
|
||||
for entity in &scene_roots {
|
||||
commands.entity(entity).despawn();
|
||||
}
|
||||
|
||||
let systems = generate_galaxy(¶ms);
|
||||
spawn_galaxy_scene(&mut commands, &mut meshes, &mut materials, &systems);
|
||||
}
|
||||
Reference in New Issue
Block a user