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:
@@ -475,6 +475,130 @@ export function ArchitecturePage() {
|
||||
accessibility audit as part of CI.
|
||||
</div>
|
||||
|
||||
{/* ═══ CUSTOM MOVEMENT & COLLISION ═══ */}
|
||||
|
||||
<div className="mb-5 flex items-center gap-3 border-b border-border pb-3 max-md:flex-col max-md:items-start max-md:gap-2 max-md:[&_h1]:text-[1.35rem] max-md:[&_h2]:text-[1.35rem]" style={{ marginTop: 'var(--sp-8)' }}>
|
||||
<span className="rounded-full bg-[rgba(240,160,48,0.08)] px-2 py-0.5 font-mono text-[0.7rem] text-accent">ARCH-9</span>
|
||||
<h2 style={{ margin: 0 }}>Custom Movement & Collision (No Physics Engine)</h2>
|
||||
</div>
|
||||
|
||||
<div className="mb-5 rounded-lg px-5 py-4 text-[0.85rem] leading-[1.5] border border-cyan/20 bg-cyan/8 text-cyan" style={{ marginBottom: 'var(--sp-5)' }}>
|
||||
<strong>Decision:</strong> The Bevy game client uses a hand-rolled kinematic movement system and distance-based
|
||||
collision detection. We do <strong>not</strong> integrate Rapier, Avian, XPBD, or any other rigid-body physics engine.
|
||||
Raycasting and collision are implemented as pure geometry functions over circles in 2D.
|
||||
</div>
|
||||
|
||||
<h3 style={{ marginBottom: 'var(--sp-4)' }}>Why No Physics Engine</h3>
|
||||
<div style={{ overflowX: 'auto', marginBottom: 'var(--sp-6)' }}>
|
||||
<table className="w-full border-collapse text-[0.85rem] max-md:block max-md:overflow-x-auto max-md:rounded-lg max-md:border max-md:border-border max-md:[&_thead]:table max-md:[&_tbody]:table max-md:[&_thead]:min-w-[680px] max-md:[&_tbody]:min-w-[680px] [&_th]:whitespace-nowrap [&_th]:border-b [&_th]:border-border [&_th]:px-4 [&_th]:py-3 [&_th]:text-left [&_th]:font-mono [&_th]:text-[0.7rem] [&_th]:uppercase [&_th]:tracking-[0.06em] [&_th]:text-muted [&_td]:border-b [&_td]:border-border [&_td]:px-4 [&_td]:py-3 [&_td]:font-mono [&_td]:text-[0.8rem] [&_td]:text-fg [&_tr:hover_td]:bg-surface-raised">
|
||||
<thead>
|
||||
<tr><th>Concern</th><th>Custom Kinematic</th><th>Physics Engine (Rapier/Avian)</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{[
|
||||
{ c: 'Genre fit', custom: 'Native — FTL/Windward style is point-and-shoot, not rigid bodies.', engine: 'Overkill — solvers are for stacking, joints, real impulses.' },
|
||||
{ c: 'Arcade feel', custom: 'Native — drag, snap turns, no inertia when undesired.', engine: 'Fight the solver — disabling bounce/friction is config hell.' },
|
||||
{ c: 'Compile time', custom: '0 added', engine: '+30–90s on every clean build' },
|
||||
{ c: 'Binary size', custom: '0 added', engine: '+2–5 MB' },
|
||||
{ c: 'Determinism', custom: 'Trivial — linear math, no solver iterations.', engine: 'Hard — solver iterations + float ops in unpredictable order.' },
|
||||
{ c: 'Network prediction', custom: 'Linear extrapolation of owned values.', engine: 'Replaying the solver is a nightmare.' },
|
||||
{ c: 'Bug surface', custom: '~50 lines you wrote.', engine: 'Tens of thousands of lines you didn’t.' },
|
||||
].map((row, i) => (
|
||||
<tr key={i}>
|
||||
<td style={{ fontWeight: 600, color: 'var(--fg)' }}>{row.c}</td>
|
||||
<td style={{ color: 'var(--fg-dim)', fontSize: '0.82rem' }}>{row.custom}</td>
|
||||
<td style={{ color: 'var(--fg-dim)', fontSize: '0.82rem' }}>{row.engine}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h3 style={{ marginBottom: 'var(--sp-4)' }}>What "Physics" Means in This Game</h3>
|
||||
<div style={{ overflowX: 'auto', marginBottom: 'var(--sp-6)' }}>
|
||||
<table className="w-full border-collapse text-[0.85rem] max-md:block max-md:overflow-x-auto max-md:rounded-lg max-md:border max-md:border-border max-md:[&_thead]:table max-md:[&_tbody]:table max-md:[&_thead]:min-w-[680px] max-md:[&_tbody]:min-w-[680px] [&_th]:whitespace-nowrap [&_th]:border-b [&_th]:border-border [&_th]:px-4 [&_th]:py-3 [&_th]:text-left [&_th]:font-mono [&_th]:text-[0.7rem] [&_th]:uppercase [&_th]:tracking-[0.06em] [&_th]:text-muted [&_td]:border-b [&_td]:border-border [&_td]:px-4 [&_td]:py-3 [&_td]:font-mono [&_td]:text-[0.8rem] [&_td]:text-fg [&_tr:hover_td]:bg-surface-raised">
|
||||
<thead>
|
||||
<tr><th>Gameplay Need</th><th>Implementation</th><th>Math</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{[
|
||||
{ need: 'Ship traversal', impl: 'Transform updates with Velocity, MaxSpeed, TurnRate, optional Drag.', math: 'pos += velocity * dt' },
|
||||
{ need: 'Planet/station orbiting', impl: 'Orbit component on child entity of star system.', math: 'pos = center + r * (cos(θ), sin(θ))' },
|
||||
{ need: 'Projectile vs ship hits', impl: 'Distance check each tick (no raycast needed for fast projectiles).', math: '‖a − b‖ < r_a + r_b' },
|
||||
{ need: 'Weapon targeting / LOS', impl: 'Ray-circle intersection, pick smallest t.', math: 'Quadratic: ‖o + t·d − c‖² = r²' },
|
||||
{ need: 'Ship-ship separation', impl: 'Push apart by overlap distance.', math: 'delta = (a − b).normalize() * (r_a + r_b − dist)' },
|
||||
{ need: 'Docking / proximity triggers', impl: 'Distance check, fire event when crossing threshold.', math: '‖a − b‖ < trigger_radius' },
|
||||
].map((row, i) => (
|
||||
<tr key={i}>
|
||||
<td style={{ fontWeight: 600, color: 'var(--fg)' }}>{row.need}</td>
|
||||
<td style={{ color: 'var(--fg-dim)', fontSize: '0.82rem' }}>{row.impl}</td>
|
||||
<td style={{ color: 'var(--cyan)', fontSize: '0.8rem', fontFamily: 'var(--font-mono)' }}>{row.math}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-5 max-[900px]:grid-cols-1" style={{ marginBottom: 'var(--sp-6)' }}>
|
||||
<div className="mb-6 rounded-xl border border-border bg-surface p-6 max-md:rounded-lg max-md:p-4" style={{ borderLeft: '3px solid var(--cyan)' }}>
|
||||
<h4 style={{ color: 'var(--cyan)' }}>Movement Module Layout</h4>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: '0.8rem', color: 'var(--fg-dim)', lineHeight: 1.7 }}>
|
||||
<span style={{ color: 'var(--purple)' }}>apps/game/src/gameplay/movement/</span><br/>
|
||||
mod.rs <span style={{ color: 'var(--muted)' }}>// MovementPlugin</span><br/>
|
||||
components.rs <span style={{ color: 'var(--muted)' }}>// Velocity, MaxSpeed, TurnRate, Drag</span><br/>
|
||||
kinematic.rs <span style={{ color: 'var(--muted)' }}>// move + drag + clamp systems</span><br/>
|
||||
orbit.rs <span style={{ color: 'var(--muted)' }}>// Orbit component + update_orbits</span>
|
||||
</div>
|
||||
<p style={{ color: 'var(--fg-dim)', fontSize: '0.82rem', margin: 'var(--sp-3) 0 0 0' }}>
|
||||
All systems run on Bevy’s <code>Time<Fixed></code> schedule for stable, deterministic ticks that
|
||||
align cleanly with SpacetimeDB updates.
|
||||
</p>
|
||||
</div>
|
||||
<div className="mb-6 rounded-xl border border-border bg-surface p-6 max-md:rounded-lg max-md:p-4" style={{ borderLeft: '3px solid var(--green)' }}>
|
||||
<h4 style={{ color: 'var(--green)' }}>Physics Module Layout</h4>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: '0.8rem', color: 'var(--fg-dim)', lineHeight: 1.7 }}>
|
||||
<span style={{ color: 'var(--purple)' }}>apps/game/src/gameplay/physics/</span><br/>
|
||||
mod.rs <span style={{ color: 'var(--muted)' }}>// PhysicsPlugin</span><br/>
|
||||
geometry.rs <span style={{ color: 'var(--muted)' }}>// ray_vs_circle, overlaps, separate</span><br/>
|
||||
broad_phase.rs <span style={{ color: 'var(--muted)' }}>// (later) uniform grid</span><br/>
|
||||
systems.rs <span style={{ color: 'var(--muted)' }}>// projectile_hits, ship_separation</span>
|
||||
</div>
|
||||
<p style={{ color: 'var(--fg-dim)', fontSize: '0.82rem', margin: 'var(--sp-3) 0 0 0' }}>
|
||||
Geometry functions are pure <code>pub fn</code>s. Systems are thin Bevy wrappers that query entities and
|
||||
call them. No third-party deps.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 style={{ marginBottom: 'var(--sp-4)' }}>Scaling Tiers</h3>
|
||||
<div style={{ overflowX: 'auto', marginBottom: 'var(--sp-6)' }}>
|
||||
<table className="w-full border-collapse text-[0.85rem] max-md:block max-md:overflow-x-auto max-md:rounded-lg max-md:border max-md:border-border max-md:[&_thead]:table max-md:[&_tbody]:table max-md:[&_thead]:min-w-[680px] max-md:[&_tbody]:min-w-[680px] [&_th]:whitespace-nowrap [&_th]:border-b [&_th]:border-border [&_th]:px-4 [&_th]:py-3 [&_th]:text-left [&_th]:font-mono [&_th]:text-[0.7rem] [&_th]:uppercase [&_th]:tracking-[0.06em] [&_th]:text-muted [&_td]:border-b [&_td]:border-border [&_td]:px-4 [&_td]:py-3 [&_td]:font-mono [&_td]:text-[0.8rem] [&_td]:text-fg [&_tr:hover_td]:bg-surface-raised">
|
||||
<thead>
|
||||
<tr><th>Concurrent Entities</th><th>Strategy</th><th>Estimated Cost</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td>{'< 500'}</td><td>Iterate all entities, linear scan.</td><td style={{ color: 'var(--fg-dim)' }}>Trivial — no broad-phase needed.</td></tr>
|
||||
<tr><td>500 – 10,000</td><td>Flat array of (Entity, Vec2, radius), still linear.</td><td style={{ color: 'var(--fg-dim)' }}>Cache-friendly, single microsecond per query.</td></tr>
|
||||
<tr><td>{'> 10,000'}</td><td>Add uniform grid or quadtree (≈50 LOC).</td><td style={{ color: 'var(--fg-dim)' }}>Out of scope for FTL-style combat density.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="mb-5 rounded-lg px-5 py-4 text-[0.85rem] leading-[1.5] border border-[rgba(240,160,48,0.25)] bg-[rgba(240,160,48,0.08)] text-accent" style={{ marginBottom: 'var(--sp-5)' }}>
|
||||
<strong>Escape hatch — when to reconsider:</strong> If destructible ship chunks that tumble, rotate, and stack
|
||||
become a core visual, Rapier can be introduced <em>only for debris entities</em> while ships and projectiles
|
||||
stay kinematic. This is additive: the custom movement/collision code does not need to be rewritten. The three
|
||||
things a real solver would buy — stacking, joints, continuous CCD — are explicitly out of scope for arcade
|
||||
space combat.
|
||||
</div>
|
||||
|
||||
<div className="mb-5 rounded-lg px-5 py-4 text-[0.85rem] leading-[1.5] border border-cyan/20 bg-cyan/8 text-cyan" style={{ marginBottom: 'var(--sp-6)' }}>
|
||||
<strong>Network determinism note:</strong> Because movement and collision are pure functions of position,
|
||||
velocity, and dt, every client given the same inputs produces the same outputs. This makes client-side
|
||||
prediction and rollback against SpacetimeDB straightforward — a physics engine’s solver would make this
|
||||
property very difficult to guarantee.
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,102 @@
|
||||
use bevy::input::mouse::MouseButton;
|
||||
use bevy::prelude::*;
|
||||
use bevy::window::PrimaryWindow;
|
||||
|
||||
pub fn spawn_camera(mut commands: Commands) {
|
||||
commands.spawn(Camera2d);
|
||||
use crate::ui::util::cursor_over_ui;
|
||||
|
||||
#[derive(Component)]
|
||||
pub struct MainCamera;
|
||||
|
||||
/// Orbit-style camera: rotates around `target` at `distance`, controlled by mouse.
|
||||
/// Used for inspection scenes like GalaxyCreation where there is no player to follow.
|
||||
#[derive(Component, Debug, Clone, Copy)]
|
||||
pub struct OrbitCamera {
|
||||
pub target: Vec3,
|
||||
pub distance: f32,
|
||||
/// Yaw around the Y axis (radians). 0 = camera at +Z looking toward origin.
|
||||
pub yaw: f32,
|
||||
/// Pitch above the horizontal plane (radians). 0 = horizontal, π/2 = straight down.
|
||||
pub pitch: f32,
|
||||
}
|
||||
|
||||
impl Default for OrbitCamera {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
target: Vec3::ZERO,
|
||||
distance: 420.0,
|
||||
yaw: 0.0,
|
||||
// ~36° above horizontal — roughly matches docs GalaxyScene camera position.
|
||||
pitch: 0.625,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Initial camera spawn. The same entity is reused across states; control systems
|
||||
/// decide how it moves depending on which state is active.
|
||||
pub fn spawn_camera(mut commands: Commands) {
|
||||
commands.spawn((
|
||||
Camera3d::default(),
|
||||
Transform::from_xyz(0.0, 260.0, 360.0).looking_at(Vec3::ZERO, Vec3::Y),
|
||||
MainCamera,
|
||||
OrbitCamera::default(),
|
||||
));
|
||||
}
|
||||
|
||||
const ORBIT_ROTATE_SENSITIVITY: f32 = 0.005;
|
||||
const ORBIT_ZOOM_SENSITIVITY: f32 = 0.1;
|
||||
const ORBIT_MIN_PITCH: f32 = 0.15; // ~9° above horizontal — never fully edge-on
|
||||
const ORBIT_MAX_PITCH: f32 = 1.4; // ~80° — never straight down (gimbal safeguard)
|
||||
const ORBIT_MIN_DISTANCE: f32 = 40.0;
|
||||
const ORBIT_MAX_DISTANCE: f32 = 1500.0;
|
||||
|
||||
/// Left-drag rotates around the orbit target; scroll wheel zooms.
|
||||
///
|
||||
/// Drag is suppressed when the cursor is over any UI node — otherwise clicking
|
||||
/// buttons or panels would also rotate the camera.
|
||||
pub fn orbit_camera_control(
|
||||
mouse_input: Res<ButtonInput<MouseButton>>,
|
||||
primary_window: Query<&Window, With<PrimaryWindow>>,
|
||||
mut mouse_motion: EventReader<bevy::input::mouse::MouseMotion>,
|
||||
mut scroll_events: EventReader<bevy::input::mouse::MouseWheel>,
|
||||
mut query: Query<(&mut Transform, &mut OrbitCamera), With<MainCamera>>,
|
||||
ui_nodes: Query<(&bevy::ui::ComputedNode, &GlobalTransform)>,
|
||||
) {
|
||||
let Ok((mut transform, mut orbit)) = query.single_mut() else {
|
||||
// Drain pending input to avoid stale buildup when there's no camera.
|
||||
mouse_motion.clear();
|
||||
scroll_events.clear();
|
||||
return;
|
||||
};
|
||||
|
||||
let cursor_over_ui = primary_window
|
||||
.single()
|
||||
.ok()
|
||||
.map(|w| cursor_over_ui(w, &ui_nodes))
|
||||
.unwrap_or(false);
|
||||
|
||||
if mouse_input.pressed(MouseButton::Left) && !cursor_over_ui {
|
||||
for event in mouse_motion.read() {
|
||||
orbit.yaw -= event.delta.x * ORBIT_ROTATE_SENSITIVITY;
|
||||
orbit.pitch -= event.delta.y * ORBIT_ROTATE_SENSITIVITY;
|
||||
}
|
||||
} else {
|
||||
mouse_motion.clear();
|
||||
}
|
||||
|
||||
for event in scroll_events.read() {
|
||||
// Scroll up (positive y) → decrease distance (zoom in).
|
||||
orbit.distance *= 1.0 - event.y * ORBIT_ZOOM_SENSITIVITY;
|
||||
}
|
||||
|
||||
orbit.pitch = orbit.pitch.clamp(ORBIT_MIN_PITCH, ORBIT_MAX_PITCH);
|
||||
orbit.distance = orbit.distance.clamp(ORBIT_MIN_DISTANCE, ORBIT_MAX_DISTANCE);
|
||||
|
||||
let cos_p = orbit.pitch.cos();
|
||||
let pos = orbit.target
|
||||
+ Vec3::new(
|
||||
orbit.distance * cos_p * orbit.yaw.sin(),
|
||||
orbit.distance * orbit.pitch.sin(),
|
||||
orbit.distance * cos_p * orbit.yaw.cos(),
|
||||
);
|
||||
*transform = Transform::from_translation(pos).looking_at(orbit.target, Vec3::Y);
|
||||
}
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
use bevy::prelude::*;
|
||||
|
||||
// ── Markers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Component)]
|
||||
pub struct GalaxyCreationUi;
|
||||
|
||||
// ── Galaxy Creation Screen ──────────────────────────────────────────────────
|
||||
|
||||
pub fn setup_galaxy_creation(_commands: Commands) {
|
||||
// TODO: spawn galaxy creation UI
|
||||
}
|
||||
|
||||
pub fn despawn_galaxy_creation(mut commands: Commands, query: Query<Entity, With<GalaxyCreationUi>>) {
|
||||
for entity in &query {
|
||||
commands.entity(entity).despawn();
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
105
apps/game/src/gameplay/galaxy_creation/params.rs
Normal file
105
apps/game/src/gameplay/galaxy_creation/params.rs
Normal file
@@ -0,0 +1,105 @@
|
||||
//! Authoritative resources for the galaxy creation scene.
|
||||
//!
|
||||
//! [`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.
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
// ── Tunable defaults ────────────────────────────────────────────────────────
|
||||
//
|
||||
// These mirror the docs prototype (`apps/docs/src/prototypes/existing-demos/GalaxyDemo.tsx`)
|
||||
// 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) ─────────────────────────────────────
|
||||
|
||||
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;
|
||||
|
||||
/// Connect each system to its N nearest neighbors (deduplicated).
|
||||
/// Docs prototype uses 2; kept constant until a UI control is added.
|
||||
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;
|
||||
|
||||
/// 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,
|
||||
pub size: f32,
|
||||
pub twist: f32,
|
||||
pub vertical_twist: f32,
|
||||
/// 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.
|
||||
pub generation: u64,
|
||||
}
|
||||
|
||||
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,
|
||||
generation: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl GalaxyParams {
|
||||
/// Bump the generation counter, signalling the regeneration system.
|
||||
/// Call after any field mutation that should trigger a scene rebuild.
|
||||
pub fn bump_generation(&mut self) {
|
||||
self.generation = self.generation.wrapping_add(1);
|
||||
}
|
||||
|
||||
/// Convenience for the "Regenerate" button: bump the seed and mark dirty.
|
||||
pub fn reseed_and_bump(&mut self) {
|
||||
self.seed = self.seed.wrapping_add(1);
|
||||
self.bump_generation();
|
||||
}
|
||||
}
|
||||
|
||||
/// Currently selected star system entity in the galaxy view.
|
||||
/// `None` means no selection.
|
||||
#[derive(Resource, Default, Debug, Clone, Copy)]
|
||||
pub struct SelectedStar(pub Option<Entity>);
|
||||
212
apps/game/src/gameplay/galaxy_creation/selection.rs
Normal file
212
apps/game/src/gameplay/galaxy_creation/selection.rs
Normal file
@@ -0,0 +1,212 @@
|
||||
//! Click-to-select star systems + selection visuals + info panel refresh.
|
||||
//!
|
||||
//! Picking is done in **screen space**: each star's world position is projected
|
||||
//! to viewport coordinates, and we find the closest one within a pixel
|
||||
//! threshold. This is cheaper and more intuitive than raycasting against
|
||||
//! individual sphere meshes for an unbounded number of tiny stars.
|
||||
|
||||
use bevy::prelude::*;
|
||||
use bevy::window::PrimaryWindow;
|
||||
|
||||
use super::StarSystem;
|
||||
use crate::camera::MainCamera;
|
||||
use crate::gameplay::galaxy_creation::params::SelectedStar;
|
||||
use crate::gameplay::galaxy_creation::ui::GalaxyInfoPanel;
|
||||
use crate::ui::util::cursor_over_ui;
|
||||
|
||||
// ── Tunables ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Cursor-to-star screen distance (in logical pixels) below which a click is
|
||||
/// treated as a hit. The docs demo uses similar ~16px tolerance.
|
||||
const SELECTION_PIXEL_THRESHOLD: f32 = 18.0;
|
||||
|
||||
/// Target visual scale for the selected star; non-selected stars animate back to 1.0.
|
||||
const SELECTED_SCALE: f32 = 2.2;
|
||||
/// Lerp speed (per second) for the scale animation. Higher = snappier.
|
||||
const SELECTION_LERP_SPEED: f32 = 10.0;
|
||||
|
||||
// ── Pick on click ───────────────────────────────────────────────────────────
|
||||
|
||||
pub fn select_star_on_click(
|
||||
mouse_input: Res<ButtonInput<MouseButton>>,
|
||||
primary_window: Query<&Window, With<PrimaryWindow>>,
|
||||
camera_query: Query<(&Camera, &GlobalTransform), With<MainCamera>>,
|
||||
stars: Query<(Entity, &Transform), With<StarSystem>>,
|
||||
ui_nodes: Query<(&bevy::ui::ComputedNode, &GlobalTransform)>,
|
||||
mut selected: ResMut<SelectedStar>,
|
||||
) {
|
||||
if !mouse_input.just_pressed(MouseButton::Left) {
|
||||
return;
|
||||
}
|
||||
let Ok(window) = primary_window.single() else {
|
||||
return;
|
||||
};
|
||||
if cursor_over_ui(window, &ui_nodes) {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(cursor) = window.cursor_position() else {
|
||||
return;
|
||||
};
|
||||
let Ok((camera, camera_gt)) = camera_query.single() else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Find the closest star (by screen-pixel distance) within threshold.
|
||||
let mut best: Option<(Entity, f32)> = None;
|
||||
for (entity, transform) in &stars {
|
||||
let Ok(viewport) = camera.world_to_viewport(camera_gt, transform.translation) else {
|
||||
continue;
|
||||
};
|
||||
let dist = viewport.distance(cursor);
|
||||
if dist >= SELECTION_PIXEL_THRESHOLD {
|
||||
continue;
|
||||
}
|
||||
if best.is_none_or(|(_, d)| dist < d) {
|
||||
best = Some((entity, dist));
|
||||
}
|
||||
}
|
||||
|
||||
// Always assign so change detection fires even when clearing the selection.
|
||||
let new_selection = best.map(|(entity, _)| entity);
|
||||
if selected.0 != new_selection {
|
||||
selected.0 = new_selection;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Visual highlight ────────────────────────────────────────────────────────
|
||||
|
||||
/// Smoothly lerp every star's scale toward 1.0 (default) or [`SELECTED_SCALE`]
|
||||
/// when it's the currently selected entity.
|
||||
pub fn animate_selected_star(
|
||||
selected: Res<SelectedStar>,
|
||||
mut stars: Query<(Entity, &mut Transform), With<StarSystem>>,
|
||||
time: Res<Time>,
|
||||
) {
|
||||
// Avoid mutating (and marking-changed) every Transform when nothing is selected
|
||||
// and all stars are already at scale 1.0.
|
||||
let all_default = selected.0.is_none() && stars.iter().all(|(_, t)| t.scale == Vec3::ONE);
|
||||
if all_default {
|
||||
return;
|
||||
}
|
||||
|
||||
let dt = time.delta_secs().min(0.1);
|
||||
let alpha = (dt * SELECTION_LERP_SPEED).clamp(0.0, 1.0);
|
||||
|
||||
for (entity, mut transform) in &mut stars {
|
||||
let target = if selected.0 == Some(entity) {
|
||||
SELECTED_SCALE
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
let current = transform.scale.x;
|
||||
let next = current + (target - current) * alpha;
|
||||
// Avoid spamming change detection when within float-tolerance of the target.
|
||||
if (next - current).abs() > 1e-4 {
|
||||
transform.scale = Vec3::splat(next);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Info panel refresh ──────────────────────────────────────────────────────
|
||||
|
||||
/// Rebuild the contents of the right-side info panel whenever the selection
|
||||
/// changes. The panel shell stays put; only its children are replaced via
|
||||
/// `despawn_related::<Children>()`.
|
||||
pub fn refresh_info_panel(
|
||||
mut commands: Commands,
|
||||
selected: Res<SelectedStar>,
|
||||
stars: Query<&StarSystem>,
|
||||
panel_roots: Query<Entity, With<GalaxyInfoPanel>>,
|
||||
) {
|
||||
if !selected.is_changed() {
|
||||
return;
|
||||
}
|
||||
let Ok(panel_entity) = panel_roots.single() else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Despawn existing children but keep the panel root itself.
|
||||
commands.entity(panel_entity).despawn_related::<Children>();
|
||||
|
||||
let selected_entity = selected.0;
|
||||
let sys = selected_entity.and_then(|e| stars.get(e).ok());
|
||||
|
||||
commands.entity(panel_entity).with_children(|parent| {
|
||||
parent.spawn((
|
||||
Text::new("Selected System"),
|
||||
TextFont {
|
||||
font_size: 20.0,
|
||||
..default()
|
||||
},
|
||||
TextColor(Color::srgb(0.82, 0.90, 1.0)),
|
||||
Node {
|
||||
margin: UiRect::bottom(Val::Px(8.0)),
|
||||
..default()
|
||||
},
|
||||
));
|
||||
|
||||
match sys {
|
||||
Some(sys) => {
|
||||
let title = sys.name.clone();
|
||||
parent.spawn((
|
||||
Text::new(title),
|
||||
TextFont {
|
||||
font_size: 17.0,
|
||||
..default()
|
||||
},
|
||||
TextColor(Color::srgb(0.20, 0.83, 0.93)),
|
||||
Node {
|
||||
margin: UiRect::bottom(Val::Px(8.0)),
|
||||
..default()
|
||||
},
|
||||
));
|
||||
spawn_info_line(parent, "Faction", sys.faction);
|
||||
spawn_info_line(parent, "Security", &format!("{:.2}", sys.security));
|
||||
spawn_info_line(parent, "ID", &sys.id);
|
||||
}
|
||||
None => {
|
||||
parent.spawn((
|
||||
Text::new("Click a star to inspect."),
|
||||
TextFont {
|
||||
font_size: 15.0,
|
||||
..default()
|
||||
},
|
||||
TextColor(Color::srgb(0.55, 0.65, 0.82)),
|
||||
));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn spawn_info_line(parent: &mut ChildSpawnerCommands, label: &str, value: &str) {
|
||||
parent
|
||||
.spawn(Node {
|
||||
width: Val::Percent(100.0),
|
||||
flex_direction: FlexDirection::Row,
|
||||
column_gap: Val::Px(8.0),
|
||||
..default()
|
||||
})
|
||||
.with_children(|row| {
|
||||
row.spawn((
|
||||
Text::new(label),
|
||||
TextFont {
|
||||
font_size: 14.0,
|
||||
..default()
|
||||
},
|
||||
TextColor(Color::srgb(0.55, 0.65, 0.82)),
|
||||
Node {
|
||||
width: Val::Px(78.0),
|
||||
..default()
|
||||
},
|
||||
));
|
||||
row.spawn((
|
||||
Text::new(value),
|
||||
TextFont {
|
||||
font_size: 14.0,
|
||||
..default()
|
||||
},
|
||||
TextColor(Color::srgb(0.82, 0.90, 1.0)),
|
||||
));
|
||||
});
|
||||
}
|
||||
393
apps/game/src/gameplay/galaxy_creation/ui.rs
Normal file
393
apps/game/src/gameplay/galaxy_creation/ui.rs
Normal file
@@ -0,0 +1,393 @@
|
||||
//! Galaxy creation UI: parameter slider panel (left) and selected-system
|
||||
//! info panel (right).
|
||||
//!
|
||||
//! Bevy 0.16 does not ship a native Slider widget, so each parameter is a
|
||||
//! row of `Label Value [-] [+]` buttons. Each `+/-` button carries a
|
||||
//! [`ParamButton`] marker identifying which field to mutate.
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
use super::GalaxyCreationSpawned;
|
||||
use crate::gameplay::galaxy_creation::params::*;
|
||||
|
||||
// ── Markers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Component)]
|
||||
pub struct GalaxyControlPanel;
|
||||
|
||||
#[derive(Component)]
|
||||
pub struct GalaxyInfoPanel;
|
||||
|
||||
/// Identifies which parameter a `+/-` button mutates and which direction.
|
||||
#[derive(Component, Clone, Copy)]
|
||||
pub enum ParamButton {
|
||||
SeedDecr,
|
||||
SeedIncr,
|
||||
CountDecr,
|
||||
CountIncr,
|
||||
ArmsDecr,
|
||||
ArmsIncr,
|
||||
VerticalArmsDecr,
|
||||
VerticalArmsIncr,
|
||||
SizeDecr,
|
||||
SizeIncr,
|
||||
TwistDecr,
|
||||
TwistIncr,
|
||||
VerticalTwistDecr,
|
||||
VerticalTwistIncr,
|
||||
/// Randomize: bump seed by 1 (equivalent to the docs "Regenerate" button).
|
||||
Regenerate,
|
||||
}
|
||||
|
||||
// ── Styling constants ───────────────────────────────────────────────────────
|
||||
|
||||
const PANEL_BG: Color = Color::srgb(0.05, 0.07, 0.12);
|
||||
const PANEL_BORDER: Color = Color::srgb(0.25, 0.40, 0.62);
|
||||
const TEXT_BRIGHT: Color = Color::srgb(0.82, 0.90, 1.0);
|
||||
const TEXT_DIM: Color = Color::srgb(0.55, 0.65, 0.82);
|
||||
const BUTTON_BG: Color = Color::srgb(0.10, 0.14, 0.22);
|
||||
|
||||
const PANEL_WIDTH: f32 = 280.0;
|
||||
const PANEL_PADDING: f32 = 14.0;
|
||||
const TITLE_FONT_SIZE: f32 = 20.0;
|
||||
const LABEL_FONT_SIZE: f32 = 15.0;
|
||||
const VALUE_FONT_SIZE: f32 = 15.0;
|
||||
const BUTTON_FONT_SIZE: f32 = 16.0;
|
||||
const HELP_FONT_SIZE: f32 = 12.0;
|
||||
|
||||
// ── Setup ───────────────────────────────────────────────────────────────────
|
||||
|
||||
pub fn setup_galaxy_ui(mut commands: Commands) {
|
||||
spawn_control_panel(&mut commands);
|
||||
spawn_info_panel_empty(&mut commands);
|
||||
}
|
||||
|
||||
fn spawn_control_panel(commands: &mut Commands) {
|
||||
commands
|
||||
.spawn((
|
||||
Node {
|
||||
position_type: PositionType::Absolute,
|
||||
left: Val::Px(12.0),
|
||||
top: Val::Px(12.0),
|
||||
width: Val::Px(PANEL_WIDTH),
|
||||
padding: UiRect::all(Val::Px(PANEL_PADDING)),
|
||||
flex_direction: FlexDirection::Column,
|
||||
row_gap: Val::Px(6.0),
|
||||
border: UiRect::all(Val::Px(1.0)),
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(PANEL_BG),
|
||||
BorderColor(PANEL_BORDER),
|
||||
BorderRadius::all(Val::Px(8.0)),
|
||||
GalaxyControlPanel,
|
||||
GalaxyCreationSpawned,
|
||||
))
|
||||
.with_children(|parent| {
|
||||
parent.spawn((
|
||||
Text::new("Galaxy Parameters"),
|
||||
TextFont {
|
||||
font_size: TITLE_FONT_SIZE,
|
||||
..default()
|
||||
},
|
||||
TextColor(TEXT_BRIGHT),
|
||||
Node {
|
||||
margin: UiRect::bottom(Val::Px(8.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);
|
||||
|
||||
// Regenerate (randomize seed) button.
|
||||
parent
|
||||
.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),
|
||||
));
|
||||
});
|
||||
|
||||
// Help text.
|
||||
parent.spawn((
|
||||
Text::new("Click a star to inspect • Esc to return"),
|
||||
TextFont {
|
||||
font_size: HELP_FONT_SIZE,
|
||||
..default()
|
||||
},
|
||||
TextColor(TEXT_DIM),
|
||||
Node {
|
||||
margin: UiRect::top(Val::Px(10.0)),
|
||||
..default()
|
||||
},
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
/// Build a `[Label ........ value [-] [+]]` row. The `value_key` is stored in
|
||||
/// the marker as a label for the value text child; the actual value is read
|
||||
/// from `GalaxyParams` at button-press time and updated by
|
||||
/// `refresh_control_panel_values` each frame.
|
||||
fn spawn_param_row(
|
||||
parent: &mut ChildSpawnerCommands,
|
||||
label: &str,
|
||||
value_key: &str,
|
||||
decr: ParamButton,
|
||||
incr: ParamButton,
|
||||
) {
|
||||
parent
|
||||
.spawn(Node {
|
||||
width: Val::Percent(100.0),
|
||||
flex_direction: FlexDirection::Row,
|
||||
align_items: AlignItems::Center,
|
||||
column_gap: Val::Px(6.0),
|
||||
..default()
|
||||
})
|
||||
.with_children(|row| {
|
||||
// Label — fixed width so values align.
|
||||
row.spawn((
|
||||
Text::new(label),
|
||||
TextFont {
|
||||
font_size: LABEL_FONT_SIZE,
|
||||
..default()
|
||||
},
|
||||
TextColor(TEXT_DIM),
|
||||
Node {
|
||||
width: Val::Px(118.0),
|
||||
..default()
|
||||
},
|
||||
));
|
||||
|
||||
// Value — flexible width, right-aligned textually by spacer below.
|
||||
row.spawn((
|
||||
Text::new("—"),
|
||||
TextFont {
|
||||
font_size: VALUE_FONT_SIZE,
|
||||
..default()
|
||||
},
|
||||
TextColor(TEXT_BRIGHT),
|
||||
Node {
|
||||
flex_grow: 1.0,
|
||||
..default()
|
||||
},
|
||||
ParamValue(value_key.to_string()),
|
||||
));
|
||||
|
||||
// [-] button.
|
||||
spawn_icon_button(row, "−", decr);
|
||||
// [+] button.
|
||||
spawn_icon_button(row, "+", incr);
|
||||
});
|
||||
}
|
||||
|
||||
/// 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
|
||||
.spawn((
|
||||
Button,
|
||||
Node {
|
||||
width: Val::Px(26.0),
|
||||
height: Val::Px(26.0),
|
||||
justify_content: JustifyContent::Center,
|
||||
align_items: AlignItems::Center,
|
||||
border: UiRect::all(Val::Px(1.0)),
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(BUTTON_BG),
|
||||
BorderColor(PANEL_BORDER),
|
||||
BorderRadius::all(Val::Px(4.0)),
|
||||
marker,
|
||||
))
|
||||
.with_children(|btn| {
|
||||
btn.spawn((
|
||||
Text::new(label),
|
||||
TextFont {
|
||||
font_size: BUTTON_FONT_SIZE,
|
||||
..default()
|
||||
},
|
||||
TextColor(TEXT_BRIGHT),
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
/// Spawn the info panel in its "no selection" state. The selection module
|
||||
/// replaces its contents whenever [`super::SelectedStar`] changes.
|
||||
fn spawn_info_panel_empty(commands: &mut Commands) {
|
||||
commands
|
||||
.spawn((
|
||||
Node {
|
||||
position_type: PositionType::Absolute,
|
||||
right: Val::Px(12.0),
|
||||
top: Val::Px(12.0),
|
||||
width: Val::Px(PANEL_WIDTH),
|
||||
padding: UiRect::all(Val::Px(PANEL_PADDING)),
|
||||
flex_direction: FlexDirection::Column,
|
||||
row_gap: Val::Px(4.0),
|
||||
border: UiRect::all(Val::Px(1.0)),
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(PANEL_BG),
|
||||
BorderColor(PANEL_BORDER),
|
||||
BorderRadius::all(Val::Px(8.0)),
|
||||
GalaxyInfoPanel,
|
||||
GalaxyCreationSpawned,
|
||||
))
|
||||
.with_children(|parent| {
|
||||
parent.spawn((
|
||||
Text::new("Selected System"),
|
||||
TextFont {
|
||||
font_size: TITLE_FONT_SIZE,
|
||||
..default()
|
||||
},
|
||||
TextColor(TEXT_BRIGHT),
|
||||
Node {
|
||||
margin: UiRect::bottom(Val::Px(8.0)),
|
||||
..default()
|
||||
},
|
||||
));
|
||||
parent.spawn((
|
||||
Text::new("Click a star to inspect."),
|
||||
TextFont {
|
||||
font_size: LABEL_FONT_SIZE,
|
||||
..default()
|
||||
},
|
||||
TextColor(TEXT_DIM),
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
// ── Button handler ──────────────────────────────────────────────────────────
|
||||
|
||||
pub fn param_button_handler(
|
||||
mut params: ResMut<GalaxyParams>,
|
||||
query: Query<(&Interaction, &ParamButton), Changed<Interaction>>,
|
||||
) {
|
||||
for (interaction, button) in &query {
|
||||
let &Interaction::Pressed = interaction else {
|
||||
continue;
|
||||
};
|
||||
match button {
|
||||
ParamButton::SeedDecr => {
|
||||
params.seed = params.seed.wrapping_sub(SEED_STEP);
|
||||
params.bump_generation();
|
||||
}
|
||||
ParamButton::SeedIncr => {
|
||||
params.seed = params.seed.wrapping_add(SEED_STEP);
|
||||
params.bump_generation();
|
||||
}
|
||||
ParamButton::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();
|
||||
}
|
||||
ParamButton::SizeIncr => {
|
||||
params.size = (params.size + SIZE_STEP).min(SIZE_MAX);
|
||||
params.bump_generation();
|
||||
}
|
||||
ParamButton::TwistDecr => {
|
||||
params.twist = (params.twist - TWIST_STEP).max(TWIST_MIN);
|
||||
params.bump_generation();
|
||||
}
|
||||
ParamButton::TwistIncr => {
|
||||
params.twist = (params.twist + TWIST_STEP).min(TWIST_MAX);
|
||||
params.bump_generation();
|
||||
}
|
||||
ParamButton::VerticalTwistDecr => {
|
||||
params.vertical_twist = (params.vertical_twist - VERTICAL_TWIST_STEP).max(VERTICAL_TWIST_MIN);
|
||||
params.bump_generation();
|
||||
}
|
||||
ParamButton::VerticalTwistIncr => {
|
||||
params.vertical_twist = (params.vertical_twist + VERTICAL_TWIST_STEP).min(VERTICAL_TWIST_MAX);
|
||||
params.bump_generation();
|
||||
}
|
||||
ParamButton::Regenerate => params.reseed_and_bump(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
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() {
|
||||
"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),
|
||||
_ => continue,
|
||||
};
|
||||
if text.0 != new {
|
||||
text.0 = new;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── State wiring ────────────────────────────────────────────────────────────
|
||||
//
|
||||
// `setup_galaxy_ui` runs on OnEnter(GalaxyCreation) via the plugin in `mod.rs`.
|
||||
// Despawning happens through the shared `GalaxyCreationSpawned` marker in the
|
||||
// plugin's OnExit handler — no separate UI cleanup needed.
|
||||
@@ -1 +1,4 @@
|
||||
pub mod galaxy_creation;
|
||||
pub mod movement;
|
||||
pub mod physics;
|
||||
pub mod star_map;
|
||||
|
||||
39
apps/game/src/gameplay/movement/components.rs
Normal file
39
apps/game/src/gameplay/movement/components.rs
Normal file
@@ -0,0 +1,39 @@
|
||||
use bevy::prelude::*;
|
||||
|
||||
/// Marker for the player-controlled ship. There should be exactly one in any playable scene.
|
||||
#[derive(Component, Default)]
|
||||
pub struct Player;
|
||||
|
||||
/// Linear velocity in world space (units/sec).
|
||||
#[derive(Component, Default, Debug, Clone, Copy)]
|
||||
pub struct Velocity(pub Vec3);
|
||||
|
||||
/// Maximum speed cap. Velocity magnitude will not exceed this.
|
||||
#[derive(Component, Debug, Clone, Copy)]
|
||||
pub struct MaxSpeed(pub f32);
|
||||
|
||||
impl Default for MaxSpeed {
|
||||
fn default() -> Self {
|
||||
Self(60.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Maximum rotation rate in radians per second. Used to smooth heading changes.
|
||||
#[derive(Component, Debug, Clone, Copy)]
|
||||
pub struct TurnRate(pub f32);
|
||||
|
||||
impl Default for TurnRate {
|
||||
fn default() -> Self {
|
||||
Self(3.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Exponential drag coefficient. Higher = stronger drag (slower drift when no thrust).
|
||||
/// Currently unused by click-to-move; reserved for future throttle-based input.
|
||||
#[derive(Component, Default, Debug, Clone, Copy)]
|
||||
pub struct Drag(pub f32);
|
||||
|
||||
/// A world-space point the entity should move toward.
|
||||
/// Set by input systems (e.g. click-to-move) and consumed by kinematic systems.
|
||||
#[derive(Component, Default, Debug, Clone, Copy)]
|
||||
pub struct MoveTarget(pub Vec3);
|
||||
38
apps/game/src/gameplay/movement/input.rs
Normal file
38
apps/game/src/gameplay/movement/input.rs
Normal file
@@ -0,0 +1,38 @@
|
||||
use bevy::input::mouse::MouseButton;
|
||||
use bevy::prelude::*;
|
||||
use bevy::window::PrimaryWindow;
|
||||
|
||||
use super::components::{MoveTarget, Player};
|
||||
use crate::camera::MainCamera;
|
||||
|
||||
/// Y coordinate of the ground plane. Cursor rays are projected onto this plane.
|
||||
const GROUND_PLANE_Y: f32 = 0.0;
|
||||
|
||||
/// On left-click, project the cursor onto the ground plane and update the player's MoveTarget.
|
||||
pub fn click_to_move(
|
||||
mouse_input: Res<ButtonInput<MouseButton>>,
|
||||
primary_window: Query<&Window, With<PrimaryWindow>>,
|
||||
camera_query: Query<(&Camera, &GlobalTransform), With<MainCamera>>,
|
||||
mut player: Query<&mut MoveTarget, With<Player>>,
|
||||
) {
|
||||
if !mouse_input.just_pressed(MouseButton::Left) {
|
||||
return;
|
||||
}
|
||||
let Ok(window) = primary_window.single() else { return };
|
||||
let Some(cursor_pos) = window.cursor_position() else { return };
|
||||
let Ok((camera, camera_gt)) = camera_query.single() else { return };
|
||||
let Ok(ray) = camera.viewport_to_world(camera_gt, cursor_pos) else { return };
|
||||
|
||||
// Intersect ray with the ground plane (y = GROUND_PLANE_Y).
|
||||
if ray.direction.y.abs() < 1e-6 {
|
||||
return; // ray is parallel to ground — no intersection
|
||||
}
|
||||
let t = (GROUND_PLANE_Y - ray.origin.y) / ray.direction.y;
|
||||
if t < 0.0 {
|
||||
return; // intersection is behind the camera
|
||||
}
|
||||
let target = ray.origin + ray.direction * t;
|
||||
|
||||
let Ok(mut move_target) = player.single_mut() else { return };
|
||||
move_target.0 = target;
|
||||
}
|
||||
44
apps/game/src/gameplay/movement/kinematic.rs
Normal file
44
apps/game/src/gameplay/movement/kinematic.rs
Normal file
@@ -0,0 +1,44 @@
|
||||
use bevy::prelude::*;
|
||||
|
||||
use super::components::{MaxSpeed, MoveTarget, Velocity};
|
||||
|
||||
/// Distance (in world units) at which an entity is considered to have arrived at its target.
|
||||
const ARRIVAL_DISTANCE: f32 = 0.5;
|
||||
|
||||
/// For each entity with a MoveTarget, set Velocity to face the target at MaxSpeed,
|
||||
/// and rotate the transform to face direction of motion.
|
||||
pub fn steer_to_target(
|
||||
mut query: Query<(&mut Transform, &mut Velocity, &MoveTarget, &MaxSpeed)>,
|
||||
time: Res<Time>,
|
||||
) {
|
||||
let dt = time.delta_secs();
|
||||
for (mut transform, mut velocity, target, max_speed) in &mut query {
|
||||
let to_target = target.0 - transform.translation;
|
||||
let distance = to_target.length();
|
||||
if distance <= ARRIVAL_DISTANCE {
|
||||
velocity.0 = Vec3::ZERO;
|
||||
continue;
|
||||
}
|
||||
let direction = to_target / distance;
|
||||
velocity.0 = direction * max_speed.0;
|
||||
|
||||
// Rotate to face direction of motion, keeping Y as up (no pitch).
|
||||
// This gives a "ship banking" feel rather than nosediving toward targets at different altitudes.
|
||||
let flat = Vec3::new(direction.x, 0.0, direction.z);
|
||||
if flat.length_squared() > 1e-4 {
|
||||
let look = Transform::IDENTITY.looking_at(flat.normalize(), Vec3::Y);
|
||||
transform.rotation = transform.rotation.slerp(look.rotation, dt * 4.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply Velocity to Transform.translation.
|
||||
pub fn integrate_velocity(
|
||||
mut query: Query<(&mut Transform, &Velocity)>,
|
||||
time: Res<Time>,
|
||||
) {
|
||||
let dt = time.delta_secs();
|
||||
for (mut transform, velocity) in &mut query {
|
||||
transform.translation += velocity.0 * dt;
|
||||
}
|
||||
}
|
||||
26
apps/game/src/gameplay/movement/mod.rs
Normal file
26
apps/game/src/gameplay/movement/mod.rs
Normal file
@@ -0,0 +1,26 @@
|
||||
use bevy::prelude::*;
|
||||
|
||||
pub mod components;
|
||||
pub mod input;
|
||||
pub mod kinematic;
|
||||
pub mod orbit;
|
||||
|
||||
pub struct MovementPlugin;
|
||||
|
||||
impl Plugin for MovementPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
// No state gating for slice 1: systems are no-ops when no Player exists.
|
||||
// When AppState::InGame is wired up, gate these on `in_state(AppState::InGame)`
|
||||
// and consider moving them to FixedUpdate for SpacetimeDB determinism.
|
||||
app.add_systems(
|
||||
Update,
|
||||
(
|
||||
input::click_to_move,
|
||||
kinematic::steer_to_target,
|
||||
kinematic::integrate_velocity,
|
||||
orbit::update_orbits,
|
||||
)
|
||||
.chain(),
|
||||
);
|
||||
}
|
||||
}
|
||||
52
apps/game/src/gameplay/movement/orbit.rs
Normal file
52
apps/game/src/gameplay/movement/orbit.rs
Normal file
@@ -0,0 +1,52 @@
|
||||
use bevy::prelude::*;
|
||||
|
||||
/// Which plane the orbit lies on.
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub enum OrbitPlane {
|
||||
/// XZ plane (Y up). Typical for planets around a star viewed from above.
|
||||
#[default]
|
||||
Horizontal,
|
||||
/// XY plane (Z up). Useful for UI elements or top-down 2D-style orbits.
|
||||
Vertical,
|
||||
}
|
||||
|
||||
/// An entity that orbits around its parent's origin.
|
||||
/// Attach as a child of the orbit center; local Transform is updated each frame.
|
||||
#[derive(Component, Debug, Clone, Copy)]
|
||||
pub struct Orbit {
|
||||
pub angle: f32,
|
||||
pub angular_velocity: f32,
|
||||
pub radius: f32,
|
||||
pub plane: OrbitPlane,
|
||||
}
|
||||
|
||||
impl Default for Orbit {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
angle: 0.0,
|
||||
angular_velocity: 0.5,
|
||||
radius: 10.0,
|
||||
plane: OrbitPlane::Horizontal,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Advance each Orbit's angle and recompute local translation.
|
||||
/// The orbit center is the parent entity (via Bevy's parent-child hierarchy).
|
||||
pub fn update_orbits(
|
||||
mut query: Query<(&mut Orbit, &mut Transform)>,
|
||||
time: Res<Time>,
|
||||
) {
|
||||
let dt = time.delta_secs();
|
||||
for (mut orbit, mut transform) in &mut query {
|
||||
orbit.angle += orbit.angular_velocity * dt;
|
||||
let (sin, cos) = orbit.angle.sin_cos();
|
||||
let (x, y, z) = match orbit.plane {
|
||||
OrbitPlane::Horizontal => (cos * orbit.radius, 0.0, sin * orbit.radius),
|
||||
OrbitPlane::Vertical => (cos * orbit.radius, sin * orbit.radius, 0.0),
|
||||
};
|
||||
transform.translation.x = x;
|
||||
transform.translation.y = y;
|
||||
transform.translation.z = z;
|
||||
}
|
||||
}
|
||||
157
apps/game/src/gameplay/physics/geometry.rs
Normal file
157
apps/game/src/gameplay/physics/geometry.rs
Normal file
@@ -0,0 +1,157 @@
|
||||
use bevy::prelude::*;
|
||||
|
||||
/// Ray-sphere intersection. Returns the smallest positive `t` where
|
||||
/// `ray_origin + ray_direction * t` lies on the sphere's surface, or `None`.
|
||||
///
|
||||
/// `ray_direction` should be normalized.
|
||||
pub fn ray_vs_sphere(
|
||||
ray_origin: Vec3,
|
||||
ray_direction: Vec3,
|
||||
sphere_center: Vec3,
|
||||
sphere_radius: f32,
|
||||
) -> Option<f32> {
|
||||
let to_sphere = sphere_center - ray_origin;
|
||||
let proj = to_sphere.dot(ray_direction);
|
||||
if proj < 0.0 {
|
||||
return None; // sphere is behind the ray origin
|
||||
}
|
||||
let closest = ray_origin + ray_direction * proj;
|
||||
let dist_sq = (closest - sphere_center).length_squared();
|
||||
let r_sq = sphere_radius * sphere_radius;
|
||||
if dist_sq > r_sq {
|
||||
return None; // ray misses the sphere
|
||||
}
|
||||
let offset = (r_sq - dist_sq).sqrt();
|
||||
Some(proj - offset)
|
||||
}
|
||||
|
||||
/// Check if two spheres overlap. Use length-squared for performance (no sqrt).
|
||||
pub fn overlaps(
|
||||
a_center: Vec3,
|
||||
a_radius: f32,
|
||||
b_center: Vec3,
|
||||
b_radius: f32,
|
||||
) -> bool {
|
||||
let combined = a_radius + b_radius;
|
||||
(a_center - b_center).length_squared() < combined * combined
|
||||
}
|
||||
|
||||
/// Compute the vector to push `a` out of `b` so they no longer overlap.
|
||||
/// Returns `None` if they don't overlap, or if their centers coincide (ambiguous direction).
|
||||
pub fn separate(
|
||||
a_center: Vec3,
|
||||
a_radius: f32,
|
||||
b_center: Vec3,
|
||||
b_radius: f32,
|
||||
) -> Option<Vec3> {
|
||||
let delta = a_center - b_center;
|
||||
let combined = a_radius + b_radius;
|
||||
let dist = delta.length();
|
||||
if dist >= combined || dist == 0.0 {
|
||||
return None;
|
||||
}
|
||||
Some(delta / dist * (combined - dist))
|
||||
}
|
||||
|
||||
/// Segment (finite line from `seg_start` to `seg_end`) vs sphere intersection.
|
||||
/// Returns `t` in `[0, segment_length]` measured from `seg_start`, or `None`.
|
||||
///
|
||||
/// Useful for projectile hit detection (treat projectile last-frame → current-frame
|
||||
/// positions as the segment to avoid tunneling at high speeds).
|
||||
pub fn segment_vs_sphere(
|
||||
seg_start: Vec3,
|
||||
seg_end: Vec3,
|
||||
sphere_center: Vec3,
|
||||
sphere_radius: f32,
|
||||
) -> Option<f32> {
|
||||
let seg = seg_end - seg_start;
|
||||
let seg_len = seg.length();
|
||||
if seg_len == 0.0 {
|
||||
return if (seg_start - sphere_center).length_squared() < sphere_radius * sphere_radius {
|
||||
Some(0.0)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
}
|
||||
let dir = seg / seg_len;
|
||||
let t = ray_vs_sphere(seg_start, dir, sphere_center, sphere_radius)?;
|
||||
if t > seg_len {
|
||||
return None;
|
||||
}
|
||||
Some(t)
|
||||
}
|
||||
|
||||
// ── Tests ───────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn ray_hits_sphere_directly() {
|
||||
let t = ray_vs_sphere(
|
||||
Vec3::new(0.0, 0.0, -5.0),
|
||||
Vec3::new(0.0, 0.0, 1.0),
|
||||
Vec3::ZERO,
|
||||
1.0,
|
||||
);
|
||||
assert_eq!(t, Some(4.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ray_misses_sphere() {
|
||||
let t = ray_vs_sphere(
|
||||
Vec3::new(10.0, 0.0, -5.0),
|
||||
Vec3::new(0.0, 0.0, 1.0),
|
||||
Vec3::ZERO,
|
||||
1.0,
|
||||
);
|
||||
assert_eq!(t, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ray_with_sphere_behind_returns_none() {
|
||||
let t = ray_vs_sphere(
|
||||
Vec3::new(0.0, 0.0, 5.0),
|
||||
Vec3::new(0.0, 0.0, 1.0),
|
||||
Vec3::ZERO,
|
||||
1.0,
|
||||
);
|
||||
assert_eq!(t, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlapping_spheres_detected() {
|
||||
assert!(overlaps(Vec3::ZERO, 1.0, Vec3::new(1.5, 0.0, 0.0), 1.0));
|
||||
assert!(!overlaps(Vec3::ZERO, 1.0, Vec3::new(3.0, 0.0, 0.0), 1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn separate_pushes_a_out_of_b() {
|
||||
let sep = separate(Vec3::new(0.5, 0.0, 0.0), 1.0, Vec3::ZERO, 1.0);
|
||||
// Combined radius 2.0, current distance 0.5, so push by 1.5 along +X.
|
||||
assert_eq!(sep, Some(Vec3::new(1.5, 0.0, 0.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn segment_hits_sphere() {
|
||||
let t = segment_vs_sphere(
|
||||
Vec3::new(0.0, 0.0, -5.0),
|
||||
Vec3::new(0.0, 0.0, 5.0),
|
||||
Vec3::ZERO,
|
||||
1.0,
|
||||
);
|
||||
assert_eq!(t, Some(4.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn segment_misses_when_sphere_off_line() {
|
||||
let t = segment_vs_sphere(
|
||||
Vec3::new(0.0, 10.0, -5.0),
|
||||
Vec3::new(0.0, 10.0, 5.0),
|
||||
Vec3::ZERO,
|
||||
1.0,
|
||||
);
|
||||
assert_eq!(t, None);
|
||||
}
|
||||
}
|
||||
14
apps/game/src/gameplay/physics/mod.rs
Normal file
14
apps/game/src/gameplay/physics/mod.rs
Normal file
@@ -0,0 +1,14 @@
|
||||
use bevy::prelude::*;
|
||||
|
||||
pub mod geometry;
|
||||
pub mod systems;
|
||||
|
||||
pub struct PhysicsPlugin;
|
||||
|
||||
impl Plugin for PhysicsPlugin {
|
||||
fn build(&self, _app: &mut App) {
|
||||
// Geometry primitives in `geometry` are pure functions called directly by
|
||||
// gameplay systems. No global systems yet — wire them up when combat /
|
||||
// collision is implemented.
|
||||
}
|
||||
}
|
||||
11
apps/game/src/gameplay/physics/systems.rs
Normal file
11
apps/game/src/gameplay/physics/systems.rs
Normal file
@@ -0,0 +1,11 @@
|
||||
// Slice 1 has no collision systems yet — geometry primitives in `geometry.rs` are
|
||||
// defined and unit-tested but not yet wired into any Bevy systems.
|
||||
//
|
||||
// Future systems to add here:
|
||||
// - `projectile_hits`: query projectiles against damageable entities using `overlaps`
|
||||
// or `segment_vs_sphere` (for fast-moving projectiles that might tunnel).
|
||||
// - `ship_separation`: push ships apart when they get too close using `separate`.
|
||||
// - `proximity_triggers`: emit events when entities enter/exit a radius.
|
||||
//
|
||||
// When adding these, register them in `PhysicsPlugin::build()`. They should run on
|
||||
// `FixedUpdate` for determinism (see ARCH-9 in the docs).
|
||||
43
apps/game/src/gameplay/star_map/mod.rs
Normal file
43
apps/game/src/gameplay/star_map/mod.rs
Normal file
@@ -0,0 +1,43 @@
|
||||
use bevy::prelude::*;
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
pub struct StarMapPlugin;
|
||||
|
||||
impl Plugin for StarMapPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.add_systems(OnEnter(AppState::InGame), setup_star_map)
|
||||
.add_systems(OnExit(AppState::InGame), despawn_star_map);
|
||||
// .add_systems(
|
||||
// Update,
|
||||
// (/* update systems: pan/zoom, hover, click star systems */)
|
||||
// .run_if(in_state(AppState::InGame)),
|
||||
// );
|
||||
}
|
||||
}
|
||||
|
||||
// ── Markers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Component)]
|
||||
pub struct StarMapUi;
|
||||
|
||||
#[derive(Component)]
|
||||
pub struct StarMapCamera;
|
||||
|
||||
#[derive(Component)]
|
||||
pub struct StarSystemNode;
|
||||
|
||||
// ── Systems ─────────────────────────────────────────────────────────────────
|
||||
|
||||
fn setup_star_map(_commands: Commands) {
|
||||
// TODO: spawn star map (star systems, hyperspace lanes, viewport camera, HUD)
|
||||
}
|
||||
|
||||
fn despawn_star_map(
|
||||
mut commands: Commands,
|
||||
query: Query<Entity, Or<(With<StarMapUi>, With<StarMapCamera>, With<StarSystemNode>)>>,
|
||||
) {
|
||||
for entity in &query {
|
||||
commands.entity(entity).despawn();
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,13 @@ mod ui;
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
use gameplay::galaxy_creation;
|
||||
use camera::orbit_camera_control;
|
||||
use gameplay::{
|
||||
galaxy_creation::GalaxyCreationPlugin,
|
||||
movement::MovementPlugin,
|
||||
physics::PhysicsPlugin,
|
||||
star_map::StarMapPlugin,
|
||||
};
|
||||
use state::AppState;
|
||||
use ui::main_menu;
|
||||
|
||||
@@ -13,12 +19,27 @@ fn main() {
|
||||
App::new()
|
||||
.add_plugins(DefaultPlugins)
|
||||
.insert_resource(ClearColor(Color::srgb(0.02, 0.02, 0.06)))
|
||||
.insert_resource(AmbientLight {
|
||||
color: Color::srgb(0.25, 0.3, 0.5),
|
||||
brightness: 300.0,
|
||||
..default()
|
||||
})
|
||||
.init_state::<AppState>()
|
||||
.add_systems(Startup, camera::spawn_camera)
|
||||
// Orbit controls only in inspection-style scenes. In-game will use a
|
||||
// follow camera instead (not yet implemented).
|
||||
.add_systems(
|
||||
Update,
|
||||
orbit_camera_control.run_if(in_state(AppState::GalaxyCreation)),
|
||||
)
|
||||
.add_systems(OnEnter(AppState::MainMenu), main_menu::setup_main_menu)
|
||||
.add_systems(OnExit(AppState::MainMenu), main_menu::despawn_main_menu)
|
||||
.add_systems(Update, main_menu::main_menu_buttons)
|
||||
.add_systems(OnEnter(AppState::GalaxyCreation), galaxy_creation::setup_galaxy_creation)
|
||||
.add_systems(OnExit(AppState::GalaxyCreation), galaxy_creation::despawn_galaxy_creation)
|
||||
.add_plugins((
|
||||
MovementPlugin,
|
||||
PhysicsPlugin,
|
||||
GalaxyCreationPlugin,
|
||||
StarMapPlugin,
|
||||
))
|
||||
.run();
|
||||
}
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
pub mod main_menu;
|
||||
pub mod util;
|
||||
|
||||
44
apps/game/src/ui/util.rs
Normal file
44
apps/game/src/ui/util.rs
Normal file
@@ -0,0 +1,44 @@
|
||||
//! UI helper utilities.
|
||||
|
||||
use bevy::prelude::*;
|
||||
use bevy::ui::ComputedNode;
|
||||
|
||||
/// Returns `true` if the cursor is currently hovering any UI node.
|
||||
///
|
||||
/// Used to suppress scene-level mouse input (orbit camera drag, star picking)
|
||||
/// when the user is interacting with overlay UI. The check is a manual rect
|
||||
/// containment test against every UI node's computed layout rect — this is
|
||||
/// the same approach Bevy's own `ui_focus_system` uses internally.
|
||||
///
|
||||
/// Coordinates: Bevy UI layout runs in **physical** pixels, while
|
||||
/// [`Window::cursor_position`] returns **logical** pixels. We multiply by the
|
||||
/// window's scale factor to convert.
|
||||
pub fn cursor_over_ui(
|
||||
window: &Window,
|
||||
nodes: &Query<(&ComputedNode, &GlobalTransform)>,
|
||||
) -> bool {
|
||||
let Some(cursor_logical) = window.cursor_position() else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let scale = window.scale_factor();
|
||||
let cursor = cursor_logical * scale;
|
||||
|
||||
for (node, gt) in nodes {
|
||||
if node.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let center = gt.translation().truncate();
|
||||
let half = node.size() * 0.5;
|
||||
let min = center - half;
|
||||
let max = center + half;
|
||||
if cursor.x >= min.x
|
||||
&& cursor.x <= max.x
|
||||
&& cursor.y >= min.y
|
||||
&& cursor.y <= max.y
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
Reference in New Issue
Block a user