diff --git a/apps/game/src/camera.rs b/apps/game/src/camera.rs index 6af83ec..22ec4ca 100644 --- a/apps/game/src/camera.rs +++ b/apps/game/src/camera.rs @@ -1,68 +1,166 @@ -use bevy::input::mouse::MouseButton; +//! Camera system. +//! +//! One persistent camera (spawned at [`Startup`]) is the sole source of truth +//! across every [`AppState`](crate::state::AppState). Scenes *reconfigure* it +//! by writing its [`OrbitCamera`] component and the [`CameraState`] resource — +//! they never spawn or despawn a camera. This keeps the single-camera invariant +//! that downstream systems (which use `Query<...>::single_mut()` on +//! [`MainCamera`]) rely on. +//! +//! # Model (per the *Camera System* design doc) +//! There are two **framing modes** — what the camera points at: +//! - [`CameraMode::Orbit`]: tactical inspection around a fixed target. Used by +//! the Galaxy inspection scene, starting-base selection, and the docked +//! in-system view. +//! - [`CameraMode::Follow`]: tracks a moving entity (the player ship) during +//! flight. +//! +//! [`CinematicOverlay`] is **not** a framing mode — it is an optional overlay +//! (free rotation + hidden HUD + live gameplay) that can be toggled on top of +//! either framing at any time. This matches the doc's two operational states +//! (Docked / In-Transit) plus Cinematic-as-overlay, rather than three exclusive +//! modes. +//! +//! Smooth reframing is handled by [`OrbitFocusGoal`]: a scene arms it with the +//! desired target / distance / rotation and [`orbit_camera_control`] eases the +//! camera toward it, cancelling on user input. + +use bevy::ecs::system::SystemParam; +use bevy::input::mouse::{MouseMotion, MouseWheel, MouseButton}; use bevy::prelude::*; use bevy::window::PrimaryWindow; +use crate::gameplay::in_system::ActionUi; 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) +/// Framing mode: how the camera decides what to point at. +/// +/// See the module docs for the full model. [`CinematicOverlay`] is separate. #[derive(Debug, Clone, Copy, PartialEq, Default)] pub enum CameraMode { + /// Tactical inspection around a fixed target (Galaxy, starting-base, docked). #[default] Orbit, + /// Track a moving entity (the player ship) during flight. 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)] +/// Canonical isometric "tactical" framing. The Camera System doc frames the +/// camera as a command-grade isometric viewport; every framing shares this +/// baseline angle, and Cinematic "detaches" from it by letting the user +/// rotate freely. Composed as `yaw * pitch` so it composes the same way the +/// incremental drag rotations do. +const TACTICAL_YAW: f32 = 0.15; +const TACTICAL_PITCH: f32 = 0.625; +pub fn tactical_rotation() -> Quat { + Quat::from_rotation_y(TACTICAL_YAW) * Quat::from_rotation_x(TACTICAL_PITCH) +} + +/// Default follow-camera framing: a high iso angle behind the ship. +const FOLLOW_DISTANCE: f32 = 45.0; +const FOLLOW_HEIGHT: f32 = 35.0; + +/// Docked tactical framing: pull back far enough to see the station and the +/// docked ship together. +pub const DOCKED_FRAMING_DISTANCE: f32 = 18.0; + +/// Focus-tween speed (exponential-damp). ~6.0 settles over roughly a second, +/// matching the idiom used elsewhere in the codebase. +const FOCUS_TWEEN_SPEED: f32 = 6.0; +/// Snap-to-exact and release the focus goal once a field is within this +/// tolerance. Without it the asymptotic damp would never release. +const FOCUS_EPSILON: f32 = 0.5; + +const ORBIT_ROTATE_SENSITIVITY: f32 = 0.005; +const ORBIT_ZOOM_SENSITIVITY: f32 = 0.1; +/// Wide bounds so the same clamp serves both the galaxy scale (default distance +/// ~420) and the in-system scale (docked ~18, follow ~45). +const ORBIT_MIN_DISTANCE: f32 = 2.0; +const ORBIT_MAX_DISTANCE: f32 = 1500.0; + +/// Global camera state. Drives which framing is active and — for Follow mode — +/// which entity to track and at what offset. +#[derive(Resource, Debug, Clone, Copy)] pub struct CameraState { pub mode: CameraMode, + /// Entity the camera focuses on. In Orbit mode this is the inspected + /// target (station/planet); in Follow mode it is the tracked ship. + /// [`track_camera_target`] keeps [`OrbitCamera::target`] locked to it. 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, +impl Default for CameraState { + fn default() -> Self { + // Non-zero defaults so entering Follow without explicitly configuring + // the offsets never collapses the camera onto the ship's origin. + Self { + mode: CameraMode::Orbit, + target_entity: None, + follow_distance: FOLLOW_DISTANCE, + follow_height: FOLLOW_HEIGHT, + } + } } -/// Orbit-style camera: rotates around `target` at `distance`, controlled by mouse. -/// Used for inspection scenes like Galaxy where there is no player to follow. +/// Cinematic overlay toggle. When active the HUD is hidden and the camera can +/// orbit freely around the current focal target regardless of framing mode. +/// Underlying gameplay keeps running — this is a *viewing* mode, not a pause. +/// Toggled by [`toggle_cinematic`]. +#[derive(Resource, Debug, Clone, Copy, Default)] +pub struct CinematicOverlay { + pub active: bool, +} + +/// A goal [`orbit_camera_control`] eases the camera toward. Each field is +/// optional: only the `Some` fields are tweened, so a scene can refocus just +/// the distance (e.g.) without disturbing a user-chosen angle. /// -/// 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) +/// The goal auto-clears field-by-field as each arrives, and is cancelled +/// entirely the moment the user takes manual control (drag / scroll). +/// +/// Contract: in [`AppState::InGame`](crate::state::AppState::InGame) the +/// `target` field is left `None` because [`track_camera_target`] owns the +/// target there; inspection scenes (Galaxy / starting-base) set `target`. +#[derive(Resource, Debug, Clone, Copy, Default)] +pub struct OrbitFocusGoal { + pub target: Option, + pub distance: Option, + pub rotation: Option, + pub active: bool, +} + +impl OrbitFocusGoal { + /// Arm a new goal, fully replacing any previous one (so no stale field + /// carries over from an earlier scene). + pub fn arm(&mut self, target: Option, distance: Option, rotation: Option) { + *self = Self { + target, + distance, + rotation, + active: true, + }; + } +} + +/// Orbit-style camera: rotates around `target` at `distance`, controlled by +/// mouse. Orientation is stored as a quaternion (`rotation`) to allow true +/// 360° motion with no gimbal lock. +/// +/// The base orientation is the camera at `target + rotation * (0,0,distance)` +/// looking toward `target`. Mouse drag applies incremental rotations: +/// - horizontal delta → yaw around world `+Y` +/// - vertical delta → pitch around the camera's local `+X` /// /// This yaw-around-world / pitch-around-local split is the standard free-look /// construction; it never produces a degenerate "up" vector at the poles. @@ -74,7 +172,7 @@ pub struct OrbitCamera { } impl OrbitCamera { - /// Reset to default orientation (camera at the canonical "starting" view). + /// Reset to default orientation. pub fn reset(&mut self) { *self = Self::default(); } @@ -82,21 +180,16 @@ impl OrbitCamera { 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, + rotation: tactical_rotation(), } } } -/// Initial camera spawn. The same entity is reused across states; control systems -/// decide how it moves depending on which state is active. +/// The single persistent camera spawn. Runs once at [`Startup`]; this same +/// entity is reconfigured by every scene thereafter. pub fn spawn_camera(mut commands: Commands) { let orbit = OrbitCamera::default(); let offset = orbit.rotation * Vec3::new(0.0, 0.0, orbit.distance); @@ -108,40 +201,43 @@ pub fn spawn_camera(mut commands: Commands) { 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; +/// Bundles the read-mostly camera dynamics resources so [`orbit_camera_control`] +/// stays under clippy's argument-count threshold while still touching the focus +/// goal mutably. +#[derive(SystemParam)] +pub struct CameraDynamics<'w> { + state: Res<'w, CameraState>, + cinematic: Res<'w, CinematicOverlay>, + goal: Option>, + time: Res<'w, Time>, +} -/// Left-drag rotates around the orbit target; scroll wheel zooms. -/// No pitch clamping — the camera can tumble a full 360°. +/// The single camera positioning + control system. Runs in every 3D scene +/// (Galaxy, StartingBaseSelection, InGame). It: +/// 1. applies mouse input (drag → rotation, scroll → zoom) when input is +/// allowed — always in Orbit framing, or in any framing while the +/// [`CinematicOverlay`] is active; +/// 2. eases toward an armed [`OrbitFocusGoal`] (cancelled by manual input); +/// 3. clamps the zoom distance; and +/// 4. writes the camera [`Transform`] from `target + rotation * (0,0,distance)`. /// -/// 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). +/// Manual input is suppressed when the cursor is over any UI node, so clicking +/// buttons/panels never tumbles the camera. pub fn orbit_camera_control( - camera_state: Res, + mut dynamics: CameraDynamics, mouse_input: Res>, primary_window: Query<&Window, With>, - mut mouse_motion: EventReader, - mut scroll_events: EventReader, + mut mouse_motion: EventReader, + mut scroll_events: EventReader, mut query: Query<(&mut Transform, &mut OrbitCamera), With>, - ui_nodes: Query<(&bevy::ui::ComputedNode, &GlobalTransform)>, + ui_nodes: Query<(&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(); @@ -149,73 +245,146 @@ pub fn orbit_camera_control( return; }; + // Free-look input is allowed in Orbit framing, or in any framing while the + // cinematic overlay is on. + let allow_input = dynamics.state.mode.is_orbit() || dynamics.cinematic.active; + 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 { + // Track whether the user actively manipulated the camera so an in-flight + // focus tween can be cancelled. Input is only read when allowed. + let mut manual_input = false; + if allow_input && mouse_input.pressed(MouseButton::Left) && !cursor_over_ui { for event in mouse_motion.read() { + manual_input = true; // 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. + // Vertical: drag down raises the camera so the scene shifts down. 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°. + // Pre-multiply yaw (world axis), post-multiply pitch (local axis). 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. + // Re-normalize to counteract floating-point drift. orbit.rotation = orbit.rotation.normalize(); } } else { mouse_motion.clear(); } - for event in scroll_events.read() { - if cursor_over_ui { - continue; + if allow_input { + for event in scroll_events.read() { + if cursor_over_ui { + continue; + } + manual_input = true; + // Scroll up (positive y) → decrease distance (zoom in). + orbit.distance *= 1.0 - event.y * ORBIT_ZOOM_SENSITIVITY; + } + } else { + scroll_events.clear(); + } + + // Manual input immediately cancels any pending focus tween so control + // returns to the user. + if manual_input { + if let Some(goal) = dynamics.goal.as_deref_mut() { + goal.active = false; + } + } + + // Ease toward the focus goal (only the armed fields). + if let Some(goal) = dynamics.goal.as_deref_mut() { + if goal.active { + let dt = dynamics.time.delta_secs().min(0.1); + let alpha = (dt * FOCUS_TWEEN_SPEED).clamp(0.0, 1.0); + + if let Some(target) = goal.target { + let delta = target - orbit.target; + orbit.target += delta * alpha; + if delta.length() < FOCUS_EPSILON { + orbit.target = target; + goal.target = None; + } + } + if let Some(distance) = goal.distance { + let delta = distance - orbit.distance; + orbit.distance += delta * alpha; + if delta.abs() < FOCUS_EPSILON { + orbit.distance = distance; + goal.distance = None; + } + } + if let Some(rotation) = goal.rotation { + orbit.rotation = orbit.rotation.slerp(rotation, alpha); + // Quaternions q and -q are the same rotation; compare |dot|. + if orbit.rotation.dot(rotation).abs() > 1.0 - 1e-4 { + orbit.rotation = rotation; + goal.rotation = None; + } + } + + if goal.target.is_none() && goal.distance.is_none() && goal.rotation.is_none() { + goal.active = false; + } } - // 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`. + // `orbit.rotation` already encodes the correct look direction + // (`rotation * -Z` points toward the target); it is the sole source of + // truth, so do not recompute via `looking_at`. 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`]. +/// Keep [`OrbitCamera::target`] locked to [`CameraState::target_entity`]. +/// +/// This unifies follow (ship) and docked (station) tracking: whichever entity +/// the camera is focused on, its world position becomes the orbit target each +/// frame, so switching framings never leaves the target stale (gap B6). It runs +/// in [`PostUpdate`](bevy::app::PostUpdate) after transform propagation so the +/// target's [`GlobalTransform`] is current. +/// +/// In InGame the focus goal never tweens the target (scenes arm only +/// distance/rotation there), so there is no conflict with [`OrbitFocusGoal`]. +pub fn track_camera_target( + camera_state: Res, + mut camera_query: Query<&mut OrbitCamera, With>, + target_query: Query<&GlobalTransform>, +) { + let Some(target_entity) = camera_state.target_entity else { + return; + }; + let Ok(mut orbit) = camera_query.single_mut() else { + return; + }; + let Ok(target_transform) = target_query.get(target_entity) else { + return; + }; + orbit.target = target_transform.translation(); +} + +/// Resource: set by UI buttons (e.g. the Galaxy "Center View" button) to +/// request the orbit camera snap back to its default orientation 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. +/// and consume the resource. pub fn apply_orbit_reset( mut commands: Commands, flag: Option>, mut query: Query<&mut OrbitCamera, With>, ) { - let Some(_flag) = flag else { return }; + let Some(_flag) = flag else { + return; + }; let Ok(mut orbit) = query.single_mut() else { commands.remove_resource::(); return; @@ -224,101 +393,65 @@ pub fn apply_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