use bevy::input::mouse::MouseButton; use bevy::prelude::*; use bevy::window::PrimaryWindow; use crate::ui::util::cursor_over_ui; #[derive(Component)] pub struct MainCamera; /// Camera mode determines how the camera behaves. /// - Orbit: Free-look inspection around a target (Galaxy view, docked inspection) /// - Follow: Tracks behind a moving entity (player ship during flight) /// - Cinematic: Fixed cinematic shot (docked view, cutscenes) #[derive(Debug, Clone, Copy, PartialEq, Default)] pub enum CameraMode { #[default] Orbit, Follow, Cinematic, } impl CameraMode { pub fn is_orbit(&self) -> bool { matches!(self, Self::Orbit) } pub fn is_follow(&self) -> bool { matches!(self, Self::Follow) } pub fn is_cinematic(&self) -> bool { matches!(self, Self::Cinematic) } } /// Global camera state resource. Controls which camera mode is active /// and which entity the camera should follow (if any). #[derive(Resource, Default, Debug)] pub struct CameraState { pub mode: CameraMode, pub target_entity: Option, pub follow_distance: f32, pub follow_height: f32, } /// Follow camera component. Attached to the MainCamera when in Follow mode, /// this configures how the camera tracks its target. #[derive(Component, Debug, Clone)] pub struct FollowCamera { pub target: Entity, pub distance: f32, pub height: f32, pub stiffness: f32, pub damping: f32, } /// Orbit-style camera: rotates around `target` at `distance`, controlled by mouse. /// Used for inspection scenes like Galaxy 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, 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, rotation: yaw * pitch, } } } /// 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_translation(orbit.target + offset) .with_rotation(orbit.rotation) .looking_at(orbit.target, Vec3::Y), MainCamera, orbit, Camera { // Customize clear color for space background clear_color: ClearColorConfig::Custom(Color::srgb(0.02, 0.02, 0.05)), ..default() }, )); } const ORBIT_ROTATE_SENSITIVITY: f32 = 0.005; const ORBIT_ZOOM_SENSITIVITY: f32 = 0.1; 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. /// /// Only runs when camera mode is Orbit or Follow (for tactical repositioning). pub fn orbit_camera_control( camera_state: Res, mouse_input: Res>, primary_window: Query<&Window, With>, mut mouse_motion: EventReader, mut scroll_events: EventReader, mut query: Query<(&mut Transform, &mut OrbitCamera), With>, ui_nodes: Query<(&bevy::ui::ComputedNode, &GlobalTransform)>, ) { // Only run orbit controls in Orbit or Follow mode (not Cinematic) if !camera_state.mode.is_orbit() && !camera_state.mode.is_follow() { mouse_motion.clear(); scroll_events.clear(); return; } 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() { // 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; // Re-normalize to counteract floating-point drift from repeated // multiplications. Without this the quaternion gradually loses unit // length, introducing an implicit scale that manifests as visual // stretching / skewing of the scene. orbit.rotation = orbit.rotation.normalize(); } } else { mouse_motion.clear(); } for event in scroll_events.read() { if cursor_over_ui { continue; } // Scroll up (positive y) → decrease distance (zoom in). orbit.distance *= 1.0 - event.y * ORBIT_ZOOM_SENSITIVITY; } orbit.distance = orbit.distance.clamp(ORBIT_MIN_DISTANCE, ORBIT_MAX_DISTANCE); // 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; // Use `orbit.rotation` directly — it already encodes the correct look // direction (rotation * -Z points toward the target). Do NOT call // `looking_at` then `with_rotation`; that computes a correct rotation // only to discard it, and hides the fact that the raw quaternion is the // sole source of truth. *transform = Transform::from_translation(position).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::(); } /// Follow camera system. Tracks behind the target entity (player ship) during flight. /// The camera maintains a fixed distance and height behind the target, smoothly /// interpolating to the ideal position each frame. pub fn follow_camera_system( time: Res