Allow 360° orbit, add Center View button and XYZ reference axes
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.
This commit is contained in:
@@ -9,24 +9,41 @@ pub struct MainCamera;
|
|||||||
|
|
||||||
/// Orbit-style camera: rotates around `target` at `distance`, controlled by mouse.
|
/// Orbit-style camera: rotates around `target` at `distance`, controlled by mouse.
|
||||||
/// Used for inspection scenes like GalaxyCreation where there is no player to follow.
|
/// 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)]
|
#[derive(Component, Debug, Clone, Copy)]
|
||||||
pub struct OrbitCamera {
|
pub struct OrbitCamera {
|
||||||
pub target: Vec3,
|
pub target: Vec3,
|
||||||
pub distance: f32,
|
pub distance: f32,
|
||||||
/// Yaw around the Y axis (radians). 0 = camera at +Z looking toward origin.
|
pub rotation: Quat,
|
||||||
pub yaw: f32,
|
}
|
||||||
/// Pitch above the horizontal plane (radians). 0 = horizontal, π/2 = straight down.
|
|
||||||
pub pitch: f32,
|
impl OrbitCamera {
|
||||||
|
/// Reset to default orientation (camera at the canonical "starting" view).
|
||||||
|
pub fn reset(&mut self) {
|
||||||
|
*self = Self::default();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for OrbitCamera {
|
impl Default for OrbitCamera {
|
||||||
fn default() -> Self {
|
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 {
|
Self {
|
||||||
target: Vec3::ZERO,
|
target: Vec3::ZERO,
|
||||||
distance: 420.0,
|
distance: 420.0,
|
||||||
yaw: 0.0,
|
rotation: yaw * pitch,
|
||||||
// ~36° above horizontal — roughly matches docs GalaxyScene camera position.
|
|
||||||
pitch: 0.625,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -34,22 +51,25 @@ impl Default for OrbitCamera {
|
|||||||
/// Initial camera spawn. The same entity is reused across states; control systems
|
/// Initial camera spawn. The same entity is reused across states; control systems
|
||||||
/// decide how it moves depending on which state is active.
|
/// decide how it moves depending on which state is active.
|
||||||
pub fn spawn_camera(mut commands: Commands) {
|
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((
|
commands.spawn((
|
||||||
Camera3d::default(),
|
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,
|
MainCamera,
|
||||||
OrbitCamera::default(),
|
orbit,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
const ORBIT_ROTATE_SENSITIVITY: f32 = 0.005;
|
const ORBIT_ROTATE_SENSITIVITY: f32 = 0.005;
|
||||||
const ORBIT_ZOOM_SENSITIVITY: f32 = 0.1;
|
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_MIN_DISTANCE: f32 = 40.0;
|
||||||
const ORBIT_MAX_DISTANCE: f32 = 1500.0;
|
const ORBIT_MAX_DISTANCE: f32 = 1500.0;
|
||||||
|
|
||||||
/// Left-drag rotates around the orbit target; scroll wheel zooms.
|
/// 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
|
/// Drag is suppressed when the cursor is over any UI node — otherwise clicking
|
||||||
/// buttons or panels would also rotate the camera.
|
/// 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 {
|
if mouse_input.pressed(MouseButton::Left) && !cursor_over_ui {
|
||||||
for event in mouse_motion.read() {
|
for event in mouse_motion.read() {
|
||||||
orbit.yaw -= event.delta.x * ORBIT_ROTATE_SENSITIVITY;
|
// iPhone-style: content follows the finger horizontally.
|
||||||
// Vertical is flipped relative to a "trackball" feel so the content
|
let yaw = Quat::from_rotation_y(-event.delta.x * ORBIT_ROTATE_SENSITIVITY);
|
||||||
// follows the finger (iPhone-style): drag down → camera rises →
|
// Vertical: drag down raises the camera so the scene appears to
|
||||||
// scene appears to shift down with the cursor.
|
// shift down with the cursor.
|
||||||
orbit.pitch += event.delta.y * ORBIT_ROTATE_SENSITIVITY;
|
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 {
|
} else {
|
||||||
mouse_motion.clear();
|
mouse_motion.clear();
|
||||||
@@ -91,15 +115,39 @@ pub fn orbit_camera_control(
|
|||||||
orbit.distance *= 1.0 - event.y * ORBIT_ZOOM_SENSITIVITY;
|
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);
|
orbit.distance = orbit.distance.clamp(ORBIT_MIN_DISTANCE, ORBIT_MAX_DISTANCE);
|
||||||
|
|
||||||
let cos_p = orbit.pitch.cos();
|
// Position = target + rotation * (camera-forward * distance).
|
||||||
let pos = orbit.target
|
// In the base orientation the camera sits on +Z looking at the origin, so
|
||||||
+ Vec3::new(
|
// the offset is `rotation * +Z * distance`.
|
||||||
orbit.distance * cos_p * orbit.yaw.sin(),
|
let offset = orbit.rotation * Vec3::new(0.0, 0.0, orbit.distance);
|
||||||
orbit.distance * orbit.pitch.sin(),
|
let position = orbit.target + offset;
|
||||||
orbit.distance * cos_p * orbit.yaw.cos(),
|
*transform = Transform::from_translation(position)
|
||||||
);
|
.looking_at(orbit.target, Vec3::Y)
|
||||||
*transform = Transform::from_translation(pos).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<Res<ResetOrbitCamera>>,
|
||||||
|
mut query: Query<&mut OrbitCamera, With<MainCamera>>,
|
||||||
|
) {
|
||||||
|
let Some(_flag) = flag else { return };
|
||||||
|
let Ok(mut orbit) = query.single_mut() else {
|
||||||
|
commands.remove_resource::<ResetOrbitCamera>();
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
orbit.reset();
|
||||||
|
commands.remove_resource::<ResetOrbitCamera>();
|
||||||
}
|
}
|
||||||
|
|||||||
72
apps/game/src/gameplay/galaxy_creation/axes.rs
Normal file
72
apps/game/src/gameplay/galaxy_creation/axes.rs
Normal file
@@ -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<Mesh>,
|
||||||
|
materials: &mut Assets<StandardMaterial>,
|
||||||
|
) {
|
||||||
|
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<StandardMaterial>,
|
||||||
|
[r, g, b]: [f32; 3],
|
||||||
|
) -> Handle<StandardMaterial> {
|
||||||
|
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()
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
//! click-to-select is handled in [`super::selection`]; the slider/info panels
|
//! click-to-select is handled in [`super::selection`]; the slider/info panels
|
||||||
//! live in [`super::ui`].
|
//! live in [`super::ui`].
|
||||||
|
|
||||||
|
mod axes;
|
||||||
mod params;
|
mod params;
|
||||||
mod selection;
|
mod selection;
|
||||||
mod ui;
|
mod ui;
|
||||||
@@ -13,6 +14,7 @@ use bevy::prelude::*;
|
|||||||
use rand::rngs::StdRng;
|
use rand::rngs::StdRng;
|
||||||
use rand::{Rng, SeedableRng};
|
use rand::{Rng, SeedableRng};
|
||||||
|
|
||||||
|
use crate::camera::apply_orbit_reset;
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
pub use params::{GalaxyParams, SelectedStar};
|
pub use params::{GalaxyParams, SelectedStar};
|
||||||
@@ -32,10 +34,12 @@ impl Plugin for GalaxyCreationPlugin {
|
|||||||
escape_to_main_menu,
|
escape_to_main_menu,
|
||||||
ui::param_button_handler,
|
ui::param_button_handler,
|
||||||
ui::refresh_control_panel_values,
|
ui::refresh_control_panel_values,
|
||||||
|
ui::reset_view_button_handler,
|
||||||
regenerate_galaxy_on_param_change,
|
regenerate_galaxy_on_param_change,
|
||||||
selection::select_star_on_click,
|
selection::select_star_on_click,
|
||||||
selection::animate_selected_star,
|
selection::animate_selected_star,
|
||||||
selection::refresh_info_panel,
|
selection::refresh_info_panel,
|
||||||
|
apply_orbit_reset,
|
||||||
)
|
)
|
||||||
.chain()
|
.chain()
|
||||||
.run_if(in_state(AppState::GalaxyCreation)),
|
.run_if(in_state(AppState::GalaxyCreation)),
|
||||||
@@ -239,6 +243,9 @@ fn spawn_galaxy_scene(
|
|||||||
GalaxyCreationSpawned,
|
GalaxyCreationSpawned,
|
||||||
))
|
))
|
||||||
.with_children(|parent| {
|
.with_children(|parent| {
|
||||||
|
// XYZ reference axes through the origin.
|
||||||
|
axes::spawn_axes(parent, meshes, materials);
|
||||||
|
|
||||||
// Star systems.
|
// Star systems.
|
||||||
for sys in systems {
|
for sys in systems {
|
||||||
let (mesh, material) = if sys.faction_index == 0 && sys.position.length() < 40.0
|
let (mesh, material) = if sys.faction_index == 0 && sys.position.length() < 40.0
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ pub enum ParamButton {
|
|||||||
VerticalTwistIncr,
|
VerticalTwistIncr,
|
||||||
/// Randomize: bump seed by 1 (equivalent to the docs "Regenerate" button).
|
/// Randomize: bump seed by 1 (equivalent to the docs "Regenerate" button).
|
||||||
Regenerate,
|
Regenerate,
|
||||||
|
/// Reset orbit camera to default orientation.
|
||||||
|
CenterView,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Styling constants ───────────────────────────────────────────────────────
|
// ── 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.
|
// Help text.
|
||||||
parent.spawn((
|
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 {
|
TextFont {
|
||||||
font_size: HELP_FONT_SIZE,
|
font_size: HELP_FONT_SIZE,
|
||||||
..default()
|
..default()
|
||||||
@@ -358,6 +389,27 @@ pub fn param_button_handler(
|
|||||||
params.bump_generation();
|
params.bump_generation();
|
||||||
}
|
}
|
||||||
ParamButton::Regenerate => params.reseed_and_bump(),
|
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<GalaxyParams>`).
|
||||||
|
pub fn reset_view_button_handler(
|
||||||
|
mut commands: Commands,
|
||||||
|
reset_flag: Option<Res<crate::camera::ResetOrbitCamera>>,
|
||||||
|
query: Query<(&Interaction, &ParamButton), Changed<Interaction>>,
|
||||||
|
) {
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user