From bc82f01ac1d8ac9c1ee3e3aff89dbd18cee610be Mon Sep 17 00:00:00 2001 From: francy51 Date: Thu, 4 Jun 2026 13:08:47 -0400 Subject: [PATCH] =?UTF-8?q?Allow=20360=C2=B0=20orbit,=20add=20Center=20Vie?= =?UTF-8?q?w=20button=20and=20XYZ=20reference=20axes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Camera (apps/game/src/camera.rs): - Replace yaw/pitch with a single Quat rotation. Mouse drag now does yaw-around-world-Y * rotation * pitch-around-local-X, which is gimbal-free and allows true 360° tumbling in any direction. - Remove pitch clamps (ORBIT_MIN_PITCH / ORBIT_MAX_PITCH). - Add ResetOrbitCamera resource + apply_orbit_reset system that snaps the orbit camera back to default on demand. - OrbitCamera::reset() helper. Galaxy scene (apps/game/src/gameplay/galaxy_creation/): - New axes.rs: spawn_axes() draws three emissive cylinders through the origin (X=red, Y=green, Z=blue) as children of the scene root so they rebuild with the rest of the galaxy. - UI adds a 'Center View' button below Regenerate that inserts ResetOrbitCamera; handled by reset_view_button_handler. - Help text now mentions drag/zoom/escape. --- apps/game/src/camera.rs | 98 ++++++++++++++----- .../game/src/gameplay/galaxy_creation/axes.rs | 72 ++++++++++++++ apps/game/src/gameplay/galaxy_creation/mod.rs | 7 ++ apps/game/src/gameplay/galaxy_creation/ui.rs | 54 +++++++++- 4 files changed, 205 insertions(+), 26 deletions(-) create mode 100644 apps/game/src/gameplay/galaxy_creation/axes.rs diff --git a/apps/game/src/camera.rs b/apps/game/src/camera.rs index cd328d3..2ede3d7 100644 --- a/apps/game/src/camera.rs +++ b/apps/game/src/camera.rs @@ -9,24 +9,41 @@ 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. +/// +/// Orientation is stored as a quaternion (`rotation`) to allow true 360° motion +/// with no gimbal lock. The base orientation is camera at `target + (0, 0, distance)` +/// looking toward `target` with up `+Y`. The quaternion rotates this base around +/// `target`. Mouse drag applies incremental rotations: +/// - horizontal delta → rotate around world `+Y` (yaw) +/// - vertical delta → rotate around the camera's local `+X` (its right vector) +/// +/// This yaw-around-world / pitch-around-local split is the standard free-look +/// construction; it never produces a degenerate "up" vector at the poles. #[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, + pub rotation: Quat, +} + +impl OrbitCamera { + /// Reset to default orientation (camera at the canonical "starting" view). + pub fn reset(&mut self) { + *self = Self::default(); + } } impl Default for OrbitCamera { fn default() -> Self { + // ~36° above horizontal, slightly rotated, roughly matching the docs + // GalaxyScene opening shot. Built as yaw * pitch so the angles compose + // the same way the incremental drag rotations do. + let yaw = Quat::from_rotation_y(0.15); + let pitch = Quat::from_rotation_x(0.625); Self { target: Vec3::ZERO, distance: 420.0, - yaw: 0.0, - // ~36° above horizontal — roughly matches docs GalaxyScene camera position. - pitch: 0.625, + rotation: yaw * pitch, } } } @@ -34,22 +51,25 @@ impl Default for OrbitCamera { /// 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) { + let orbit = OrbitCamera::default(); + let offset = orbit.rotation * Vec3::new(0.0, 0.0, orbit.distance); commands.spawn(( Camera3d::default(), - Transform::from_xyz(0.0, 260.0, 360.0).looking_at(Vec3::ZERO, Vec3::Y), + Transform::from_translation(orbit.target + offset) + .with_rotation(orbit.rotation) + .looking_at(orbit.target, Vec3::Y), MainCamera, - OrbitCamera::default(), + orbit, )); } 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. +/// No pitch clamping — the camera can tumble a full 360°. /// /// Drag is suppressed when the cursor is over any UI node — otherwise clicking /// buttons or panels would also rotate the camera. @@ -76,11 +96,15 @@ pub fn orbit_camera_control( if mouse_input.pressed(MouseButton::Left) && !cursor_over_ui { for event in mouse_motion.read() { - orbit.yaw -= event.delta.x * ORBIT_ROTATE_SENSITIVITY; - // Vertical is flipped relative to a "trackball" feel so the content - // follows the finger (iPhone-style): drag down → camera rises → - // scene appears to shift down with the cursor. - orbit.pitch += event.delta.y * ORBIT_ROTATE_SENSITIVITY; + // iPhone-style: content follows the finger horizontally. + let yaw = Quat::from_rotation_y(-event.delta.x * ORBIT_ROTATE_SENSITIVITY); + // Vertical: drag down raises the camera so the scene appears to + // shift down with the cursor. + let pitch = Quat::from_rotation_x(event.delta.y * ORBIT_ROTATE_SENSITIVITY); + // Pre-multiply yaw (world-axis rotation), post-multiply pitch + // (local-axis rotation) — this preserves a stable horizon line as + // long as `rotation` doesn't already pitch past 90°. + orbit.rotation = yaw * orbit.rotation * pitch; } } else { mouse_motion.clear(); @@ -91,15 +115,39 @@ pub fn orbit_camera_control( 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); + // Position = target + rotation * (camera-forward * distance). + // In the base orientation the camera sits on +Z looking at the origin, so + // the offset is `rotation * +Z * distance`. + let offset = orbit.rotation * Vec3::new(0.0, 0.0, orbit.distance); + let position = orbit.target + offset; + *transform = Transform::from_translation(position) + .looking_at(orbit.target, Vec3::Y) + // Re-apply rotation to recover the roll component that `looking_at` + // would otherwise discard (matters when the camera is upside-down). + .with_rotation(orbit.rotation); +} + +/// Resource: set by UI buttons (e.g. "Center View") to request the orbit camera +/// be reset to its default orientation on the next frame. Consumed by +/// [`apply_orbit_reset`]. +#[derive(Resource, Default, Debug)] +pub struct ResetOrbitCamera; + +/// If [`ResetOrbitCamera`] is present, snap the orbit camera back to default +/// and consume the resource. Lives in `Update` so UI button presses (which +/// insert the resource via `Commands`) take effect on the following frame. +pub fn apply_orbit_reset( + mut commands: Commands, + flag: Option>, + mut query: Query<&mut OrbitCamera, With>, +) { + let Some(_flag) = flag else { return }; + let Ok(mut orbit) = query.single_mut() else { + commands.remove_resource::(); + return; + }; + orbit.reset(); + commands.remove_resource::(); } diff --git a/apps/game/src/gameplay/galaxy_creation/axes.rs b/apps/game/src/gameplay/galaxy_creation/axes.rs new file mode 100644 index 0000000..7ac3daa --- /dev/null +++ b/apps/game/src/gameplay/galaxy_creation/axes.rs @@ -0,0 +1,72 @@ +//! World-space XYZ axis indicator (red/green/blue cylinders through the origin). +//! +//! Provides spatial reference for the galaxy inspection scene. Drawn as three +//! thin emissive cylinders — mesh-based rather than gizmos so they participate +//! in normal scene rendering (occlusion, depth). + +use bevy::prelude::*; + +/// Length of each axis arrow. Picked to be visible from the default orbit +/// distance (~420) without dominating the scene. +const AXIS_LENGTH: f32 = 60.0; +/// Cylinder radius — same scale as connection lines so axes feel like part of +/// the galaxy scaffold. +const AXIS_RADIUS: f32 = 0.35; + +/// Spawn three emissive cylinders through the origin: +X red, +Y green, +Z blue. +/// Convention follows the standard "right-handed RGB = XYZ" mapping. +/// +/// Spawned as children of the caller-provided spawner so they're despawned +/// automatically when the parent scene is rebuilt. +pub fn spawn_axes( + parent: &mut ChildSpawnerCommands, + meshes: &mut Assets, + materials: &mut Assets, +) { + let mesh = meshes.add(Cylinder::new(AXIS_RADIUS, AXIS_LENGTH).mesh()); + + let x_mat = axis_material(materials, [0.94, 0.18, 0.18]); + let y_mat = axis_material(materials, [0.18, 0.94, 0.30]); + let z_mat = axis_material(materials, [0.18, 0.55, 0.98]); + + // Cylinder default axis is +Y. Rotate to align with each target axis. + let x_rot = Quat::from_rotation_arc(Vec3::Y, Vec3::X); + let y_rot = Quat::IDENTITY; + let z_rot = Quat::from_rotation_arc(Vec3::Y, Vec3::Z); + + parent.spawn(( + Mesh3d(mesh.clone()), + MeshMaterial3d(x_mat), + Transform::from_rotation(x_rot), + GalaxyAxis, + )); + parent.spawn(( + Mesh3d(mesh.clone()), + MeshMaterial3d(y_mat), + Transform::from_rotation(y_rot), + GalaxyAxis, + )); + parent.spawn(( + Mesh3d(mesh), + MeshMaterial3d(z_mat), + Transform::from_rotation(z_rot), + GalaxyAxis, + )); +} + +/// Tag for the axis indicator entities — used by external systems that want to +/// hide/respawn them. Currently nothing reads this; kept for future toggling. +#[derive(Component)] +pub struct GalaxyAxis; + +fn axis_material( + materials: &mut Assets, + [r, g, b]: [f32; 3], +) -> Handle { + materials.add(StandardMaterial { + base_color: Color::srgb(r, g, b), + emissive: LinearRgba::new(r * 1.4, g * 1.4, b * 1.4, 1.0), + unlit: true, + ..default() + }) +} diff --git a/apps/game/src/gameplay/galaxy_creation/mod.rs b/apps/game/src/gameplay/galaxy_creation/mod.rs index db8ec7e..73a2e1e 100644 --- a/apps/game/src/gameplay/galaxy_creation/mod.rs +++ b/apps/game/src/gameplay/galaxy_creation/mod.rs @@ -5,6 +5,7 @@ //! click-to-select is handled in [`super::selection`]; the slider/info panels //! live in [`super::ui`]. +mod axes; mod params; mod selection; mod ui; @@ -13,6 +14,7 @@ use bevy::prelude::*; use rand::rngs::StdRng; use rand::{Rng, SeedableRng}; +use crate::camera::apply_orbit_reset; use crate::state::AppState; pub use params::{GalaxyParams, SelectedStar}; @@ -32,10 +34,12 @@ impl Plugin for GalaxyCreationPlugin { escape_to_main_menu, ui::param_button_handler, ui::refresh_control_panel_values, + ui::reset_view_button_handler, regenerate_galaxy_on_param_change, selection::select_star_on_click, selection::animate_selected_star, selection::refresh_info_panel, + apply_orbit_reset, ) .chain() .run_if(in_state(AppState::GalaxyCreation)), @@ -239,6 +243,9 @@ fn spawn_galaxy_scene( GalaxyCreationSpawned, )) .with_children(|parent| { + // XYZ reference axes through the origin. + axes::spawn_axes(parent, meshes, materials); + // Star systems. for sys in systems { let (mesh, material) = if sys.faction_index == 0 && sys.position.length() < 40.0 diff --git a/apps/game/src/gameplay/galaxy_creation/ui.rs b/apps/game/src/gameplay/galaxy_creation/ui.rs index 61eaffc..7d81ec7 100644 --- a/apps/game/src/gameplay/galaxy_creation/ui.rs +++ b/apps/game/src/gameplay/galaxy_creation/ui.rs @@ -37,6 +37,8 @@ pub enum ParamButton { VerticalTwistIncr, /// Randomize: bump seed by 1 (equivalent to the docs "Regenerate" button). Regenerate, + /// Reset orbit camera to default orientation. + CenterView, } // ── Styling constants ─────────────────────────────────────────────────────── @@ -135,9 +137,38 @@ fn spawn_control_panel(commands: &mut Commands) { )); }); + // Center View (reset orbit camera) button. + parent + .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. parent.spawn(( - Text::new("Click a star to inspect • Esc to return"), + Text::new("Drag to orbit (360°) • Scroll to zoom • Click a star • Esc to return"), TextFont { font_size: HELP_FONT_SIZE, ..default() @@ -358,6 +389,27 @@ pub fn param_button_handler( params.bump_generation(); } ParamButton::Regenerate => params.reseed_and_bump(), + // CenterView is handled by `reset_view_button_handler` (needs Commands). + ParamButton::CenterView => {} + } + } +} + +/// Handle the CenterView button: insert [`ResetOrbitCamera`] so +/// [`crate::camera::apply_orbit_reset`] can snap the orbit camera back to +/// its default orientation next frame. Kept in a separate system because it +/// needs `Commands` (not just `ResMut`). +pub fn reset_view_button_handler( + mut commands: Commands, + reset_flag: Option>, + query: Query<(&Interaction, &ParamButton), Changed>, +) { + for (interaction, button) in &query { + if *interaction != Interaction::Pressed { + continue; + } + if matches!(button, ParamButton::CenterView) && reset_flag.is_none() { + commands.insert_resource(crate::camera::ResetOrbitCamera); } } }