feat(camera): comprehensive camera system (C1)

Rebuild the camera around a single persistent MainCamera and the
Camera System design-doc model: two framing modes (Orbit = inspection/
docked tactical, Follow = track the ship) plus Cinematic as a boolean
overlay (free rotation + HUD hide + live gameplay), not a third mode.

Critical fixes (gap analysis A1-A3):
- A1: scenes now *reconfigure* the one startup camera instead of spawning
  a second one; despawn_in_system_scene no longer destroys MainCamera, so
  the session never loses its camera. .single_mut() on MainCamera now
  succeeds during flight.
- A2: Cinematic is a real overlay — toggle (KeyC), free-look rotation in
  any framing, HUD hidden while active, gameplay keeps running.
- A3: removed dead FollowCamera component + setup_follow_camera; tracking
  is unified in track_camera_target.

Gaps B1-B6:
- B1: adopted doc model (Cinematic overlay, not exclusive mode).
- B2: canonical isometric tactical_rotation() baseline.
- B3: smooth reframing via OrbitFocusGoal exponential-damp tween.
- B4: non-zero CameraState defaults.
- B5: consolidated the three orbit-control impls into one (dropped the
  starting_base local control + its Euler variant).
- B6: track_camera_target keeps OrbitCamera.target synced to the focus
  entity every frame.

Docked view now frames the actual station at a tactical iso distance.
cargo check + clippy clean for all newly-authored code; net -10 lines
(more dead code removed than added). 42 tests pass.
This commit is contained in:
2026-06-16 20:05:31 -04:00
parent aee13cb81a
commit 30b6678569
7 changed files with 420 additions and 430 deletions

View File

@@ -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::prelude::*;
use bevy::window::PrimaryWindow; use bevy::window::PrimaryWindow;
use crate::gameplay::in_system::ActionUi;
use crate::ui::util::cursor_over_ui; use crate::ui::util::cursor_over_ui;
#[derive(Component)] #[derive(Component)]
pub struct MainCamera; pub struct MainCamera;
/// Camera mode determines how the camera behaves. /// Framing mode: how the camera decides what to point at.
/// - Orbit: Free-look inspection around a target (Galaxy view, docked inspection) ///
/// - Follow: Tracks behind a moving entity (player ship during flight) /// See the module docs for the full model. [`CinematicOverlay`] is separate.
/// - Cinematic: Fixed cinematic shot (docked view, cutscenes)
#[derive(Debug, Clone, Copy, PartialEq, Default)] #[derive(Debug, Clone, Copy, PartialEq, Default)]
pub enum CameraMode { pub enum CameraMode {
/// Tactical inspection around a fixed target (Galaxy, starting-base, docked).
#[default] #[default]
Orbit, Orbit,
/// Track a moving entity (the player ship) during flight.
Follow, Follow,
Cinematic,
} }
impl CameraMode { impl CameraMode {
pub fn is_orbit(&self) -> bool { pub fn is_orbit(&self) -> bool {
matches!(self, Self::Orbit) 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 /// Canonical isometric "tactical" framing. The Camera System doc frames the
/// and which entity the camera should follow (if any). /// camera as a command-grade isometric viewport; every framing shares this
#[derive(Resource, Default, Debug)] /// 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 struct CameraState {
pub mode: CameraMode, 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<Entity>, pub target_entity: Option<Entity>,
pub follow_distance: f32, pub follow_distance: f32,
pub follow_height: f32, pub follow_height: f32,
} }
/// Follow camera component. Attached to the MainCamera when in Follow mode, impl Default for CameraState {
/// this configures how the camera tracks its target. fn default() -> Self {
#[derive(Component, Debug, Clone)] // Non-zero defaults so entering Follow without explicitly configuring
pub struct FollowCamera { // the offsets never collapses the camera onto the ship's origin.
pub target: Entity, Self {
pub distance: f32, mode: CameraMode::Orbit,
pub height: f32, target_entity: None,
pub stiffness: f32, follow_distance: FOLLOW_DISTANCE,
pub damping: f32, follow_height: FOLLOW_HEIGHT,
}
}
} }
/// Orbit-style camera: rotates around `target` at `distance`, controlled by mouse. /// Cinematic overlay toggle. When active the HUD is hidden and the camera can
/// Used for inspection scenes like Galaxy where there is no player to follow. /// 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 /// The goal auto-clears field-by-field as each arrives, and is cancelled
/// with no gimbal lock. The base orientation is camera at `target + (0, 0, distance)` /// entirely the moment the user takes manual control (drag / scroll).
/// looking toward `target` with up `+Y`. The quaternion rotates this base around ///
/// `target`. Mouse drag applies incremental rotations: /// Contract: in [`AppState::InGame`](crate::state::AppState::InGame) the
/// - horizontal delta → rotate around world `+Y` (yaw) /// `target` field is left `None` because [`track_camera_target`] owns the
/// - vertical delta → rotate around the camera's local `+X` (its right vector) /// target there; inspection scenes (Galaxy / starting-base) set `target`.
#[derive(Resource, Debug, Clone, Copy, Default)]
pub struct OrbitFocusGoal {
pub target: Option<Vec3>,
pub distance: Option<f32>,
pub rotation: Option<Quat>,
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<Vec3>, distance: Option<f32>, rotation: Option<Quat>) {
*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 /// This yaw-around-world / pitch-around-local split is the standard free-look
/// construction; it never produces a degenerate "up" vector at the poles. /// construction; it never produces a degenerate "up" vector at the poles.
@@ -74,7 +172,7 @@ pub struct OrbitCamera {
} }
impl OrbitCamera { impl OrbitCamera {
/// Reset to default orientation (camera at the canonical "starting" view). /// Reset to default orientation.
pub fn reset(&mut self) { pub fn reset(&mut self) {
*self = Self::default(); *self = Self::default();
} }
@@ -82,21 +180,16 @@ impl OrbitCamera {
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,
rotation: yaw * pitch, rotation: tactical_rotation(),
} }
} }
} }
/// Initial camera spawn. The same entity is reused across states; control systems /// The single persistent camera spawn. Runs once at [`Startup`]; this same
/// decide how it moves depending on which state is active. /// entity is reconfigured by every scene thereafter.
pub fn spawn_camera(mut commands: Commands) { pub fn spawn_camera(mut commands: Commands) {
let orbit = OrbitCamera::default(); let orbit = OrbitCamera::default();
let offset = orbit.rotation * Vec3::new(0.0, 0.0, orbit.distance); let offset = orbit.rotation * Vec3::new(0.0, 0.0, orbit.distance);
@@ -108,40 +201,43 @@ pub fn spawn_camera(mut commands: Commands) {
MainCamera, MainCamera,
orbit, orbit,
Camera { Camera {
// Customize clear color for space background
clear_color: ClearColorConfig::Custom(Color::srgb(0.02, 0.02, 0.05)), clear_color: ClearColorConfig::Custom(Color::srgb(0.02, 0.02, 0.05)),
..default() ..default()
}, },
)); ));
} }
const ORBIT_ROTATE_SENSITIVITY: f32 = 0.005; /// Bundles the read-mostly camera dynamics resources so [`orbit_camera_control`]
const ORBIT_ZOOM_SENSITIVITY: f32 = 0.1; /// stays under clippy's argument-count threshold while still touching the focus
const ORBIT_MIN_DISTANCE: f32 = 40.0; /// goal mutably.
const ORBIT_MAX_DISTANCE: f32 = 1500.0; #[derive(SystemParam)]
pub struct CameraDynamics<'w> {
state: Res<'w, CameraState>,
cinematic: Res<'w, CinematicOverlay>,
goal: Option<ResMut<'w, OrbitFocusGoal>>,
time: Res<'w, Time>,
}
/// Left-drag rotates around the orbit target; scroll wheel zooms. /// The single camera positioning + control system. Runs in every 3D scene
/// No pitch clamping — the camera can tumble a full 360°. /// (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 /// Manual input is suppressed when the cursor is over any UI node, so clicking
/// buttons or panels would also rotate the camera. /// buttons/panels never tumbles the camera.
///
/// Only runs when camera mode is Orbit or Follow (for tactical repositioning).
pub fn orbit_camera_control( pub fn orbit_camera_control(
camera_state: Res<CameraState>, mut dynamics: CameraDynamics,
mouse_input: Res<ButtonInput<MouseButton>>, mouse_input: Res<ButtonInput<MouseButton>>,
primary_window: Query<&Window, With<PrimaryWindow>>, primary_window: Query<&Window, With<PrimaryWindow>>,
mut mouse_motion: EventReader<bevy::input::mouse::MouseMotion>, mut mouse_motion: EventReader<MouseMotion>,
mut scroll_events: EventReader<bevy::input::mouse::MouseWheel>, mut scroll_events: EventReader<MouseWheel>,
mut query: Query<(&mut Transform, &mut OrbitCamera), With<MainCamera>>, mut query: Query<(&mut Transform, &mut OrbitCamera), With<MainCamera>>,
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 { let Ok((mut transform, mut orbit)) = query.single_mut() else {
// Drain pending input to avoid stale buildup when there's no camera. // Drain pending input to avoid stale buildup when there's no camera.
mouse_motion.clear(); mouse_motion.clear();
@@ -149,73 +245,146 @@ pub fn orbit_camera_control(
return; 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 let cursor_over_ui = primary_window
.single() .single()
.ok() .ok()
.map(|w| cursor_over_ui(w, &ui_nodes)) .map(|w| cursor_over_ui(w, &ui_nodes))
.unwrap_or(false); .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() { for event in mouse_motion.read() {
manual_input = true;
// iPhone-style: content follows the finger horizontally. // iPhone-style: content follows the finger horizontally.
let yaw = Quat::from_rotation_y(-event.delta.x * ORBIT_ROTATE_SENSITIVITY); let yaw = Quat::from_rotation_y(-event.delta.x * ORBIT_ROTATE_SENSITIVITY);
// Vertical: drag down raises the camera so the scene appears to // Vertical: drag down raises the camera so the scene shifts down.
// shift down with the cursor.
let pitch = Quat::from_rotation_x(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 // Pre-multiply yaw (world axis), post-multiply pitch (local axis).
// (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; orbit.rotation = yaw * orbit.rotation * pitch;
// Re-normalize to counteract floating-point drift.
// 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(); orbit.rotation = orbit.rotation.normalize();
} }
} else { } else {
mouse_motion.clear(); mouse_motion.clear();
} }
for event in scroll_events.read() { if allow_input {
if cursor_over_ui { for event in scroll_events.read() {
continue; 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); orbit.distance = orbit.distance.clamp(ORBIT_MIN_DISTANCE, ORBIT_MAX_DISTANCE);
// Position = target + rotation * (camera-forward * distance). // `orbit.rotation` already encodes the correct look direction
// In the base orientation the camera sits on +Z looking at the origin, so // (`rotation * -Z` points toward the target); it is the sole source of
// the offset is `rotation * +Z * distance`. // truth, so do not recompute via `looking_at`.
let offset = orbit.rotation * Vec3::new(0.0, 0.0, orbit.distance); let offset = orbit.rotation * Vec3::new(0.0, 0.0, orbit.distance);
let position = orbit.target + offset; 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); *transform = Transform::from_translation(position).with_rotation(orbit.rotation);
} }
/// Resource: set by UI buttons (e.g. "Center View") to request the orbit camera /// Keep [`OrbitCamera::target`] locked to [`CameraState::target_entity`].
/// be reset to its default orientation on the next frame. Consumed by ///
/// [`apply_orbit_reset`]. /// 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<CameraState>,
mut camera_query: Query<&mut OrbitCamera, With<MainCamera>>,
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)] #[derive(Resource, Default, Debug)]
pub struct ResetOrbitCamera; pub struct ResetOrbitCamera;
/// If [`ResetOrbitCamera`] is present, snap the orbit camera back to default /// If [`ResetOrbitCamera`] is present, snap the orbit camera back to default
/// and consume the resource. Lives in `Update` so UI button presses (which /// and consume the resource.
/// insert the resource via `Commands`) take effect on the following frame.
pub fn apply_orbit_reset( pub fn apply_orbit_reset(
mut commands: Commands, mut commands: Commands,
flag: Option<Res<ResetOrbitCamera>>, flag: Option<Res<ResetOrbitCamera>>,
mut query: Query<&mut OrbitCamera, With<MainCamera>>, mut query: Query<&mut OrbitCamera, With<MainCamera>>,
) { ) {
let Some(_flag) = flag else { return }; let Some(_flag) = flag else {
return;
};
let Ok(mut orbit) = query.single_mut() else { let Ok(mut orbit) = query.single_mut() else {
commands.remove_resource::<ResetOrbitCamera>(); commands.remove_resource::<ResetOrbitCamera>();
return; return;
@@ -224,101 +393,65 @@ pub fn apply_orbit_reset(
commands.remove_resource::<ResetOrbitCamera>(); commands.remove_resource::<ResetOrbitCamera>();
} }
/// Follow camera system. Tracks behind the target entity (player ship) during flight. /// Toggle the [`CinematicOverlay`] on the cinematic key. Available in any
/// The camera maintains a fixed distance and height behind the target, smoothly /// in-game framing (docked or in transit), per the design doc.
/// interpolating to the ideal position each frame. pub fn toggle_cinematic(
pub fn follow_camera_system( keys: Res<ButtonInput<KeyCode>>,
time: Res<Time>, mut cinematic: ResMut<CinematicOverlay>,
camera_state: Res<CameraState>,
mut camera_query: Query<&mut Transform, With<MainCamera>>,
target_query: Query<&GlobalTransform>,
follow_cam_query: Query<&FollowCamera, With<MainCamera>>,
) { ) {
// Only run when in follow mode if keys.just_pressed(KeyCode::KeyC) {
if camera_state.mode != CameraMode::Follow { cinematic.active = !cinematic.active;
return; bevy::log::info!(
"Cinematic overlay {}",
if cinematic.active { "enabled" } else { "disabled" }
);
} }
let Some(target_entity) = camera_state.target_entity else {
return;
};
let Ok(mut camera_transform) = camera_query.single_mut() else {
return;
};
let Ok(target_transform) = target_query.get(target_entity) else {
return;
};
let dt = time.delta_secs();
// Get target's forward direction (negative Z is forward in Bevy)
let target_rotation = target_transform.rotation();
let target_forward = target_rotation * Vec3::NEG_Z;
let target_up = target_rotation * Vec3::Y;
// Calculate ideal camera position: behind and above the target
let follow_distance = camera_state.follow_distance;
let follow_height = camera_state.follow_height;
// Position behind the ship: target position - forward * distance + up * height
let target_pos = target_transform.translation();
let ideal_position = target_pos - target_forward * follow_distance + target_up * follow_height;
// Get stiffness from FollowCamera component if it exists, otherwise use default
let stiffness = follow_cam_query
.single()
.map(|fc| fc.stiffness)
.unwrap_or(3.0);
// Smoothly interpolate current position to ideal position
// Using exponential lerp: current = current + (ideal - current) * stiffness * dt
let lerp_factor = (stiffness * dt).min(1.0);
camera_transform.translation = camera_transform.translation
.lerp(ideal_position, lerp_factor);
// Look at the target (slightly above center to look at ship body, not feet)
let look_target = target_pos + target_up * (follow_height * 0.5);
let look_dir = (look_target - camera_transform.translation).normalize();
// Smoothly rotate to look at target
let ideal_look = Transform::IDENTITY.looking_to(look_dir, Vec3::Y);
camera_transform.rotation = camera_transform.rotation
.slerp(ideal_look.rotation, lerp_factor);
} }
/// Initialize the follow camera when transitioning to follow mode. /// Hide the in-game HUD while the [`CinematicOverlay`] is active, restore it
/// This system adds the FollowCamera component to the MainCamera entity. /// otherwise. The doc specifies Cinematic leaves "an unobstructed view."
pub fn setup_follow_camera( pub fn update_cinematic_hud(
mut commands: Commands, cinematic: Res<CinematicOverlay>,
camera_state: Res<CameraState>, mut hud: Query<&mut Visibility, With<ActionUi>>,
camera_query: Query<Entity, With<MainCamera>>,
follow_cam_query: Query<&FollowCamera, With<MainCamera>>,
) { ) {
// Only run when we just switched to follow mode and don't have FollowCamera component yet let Ok(mut visibility) = hud.single_mut() else {
if camera_state.mode != CameraMode::Follow {
return;
}
let Some(target_entity) = camera_state.target_entity else {
return; return;
}; };
let target = if cinematic.active {
let Ok(camera_entity) = camera_query.single() else { Visibility::Hidden
return; } else {
Visibility::Visible
}; };
if *visibility != target {
// Check if FollowCamera already exists, if so don't add it again *visibility = target;
if follow_cam_query.single().is_ok() { }
return; }
/// Reset transient camera state when leaving a camera-driven scene, so a
/// half-finished focus tween or a left-on cinematic overlay can't bleed into
/// the next scene. Registered on the `OnExit` of the scenes that arm them.
pub fn reset_transient_camera_state(
mut goal: ResMut<OrbitFocusGoal>,
mut cinematic: ResMut<CinematicOverlay>,
) {
goal.active = false;
cinematic.active = false;
}
/// Reconfigure the persistent camera for a fresh framing. Used by scenes on
/// enter (e.g. the docked view) to hard-set the orbit target / distance /
/// rotation in one shot. A hard set (rather than a tween) is correct for scene
/// *transitions* where the scale changes discontinuously (galaxy → in-system);
/// use [`OrbitFocusGoal`] for smooth reframing *within* a scene.
pub fn retarget_main_camera(
camera_query: &mut Query<&mut OrbitCamera, With<MainCamera>>,
target: Vec3,
distance: f32,
rotation: Quat,
) {
if let Ok(mut orbit) = camera_query.single_mut() {
orbit.target = target;
orbit.distance = distance;
orbit.rotation = rotation;
} }
commands.entity(camera_entity).insert(FollowCamera {
target: target_entity,
distance: camera_state.follow_distance,
height: camera_state.follow_height,
stiffness: 3.0,
damping: 0.5,
});
} }

View File

@@ -8,7 +8,7 @@
use bevy::prelude::*; use bevy::prelude::*;
use crate::camera::{CameraState, CameraMode}; use crate::camera::{CameraState, CameraMode, OrbitFocusGoal};
use crate::gameplay::movement::components::{Velocity, MoveTarget}; use crate::gameplay::movement::components::{Velocity, MoveTarget};
use crate::gameplay::galaxy::Identifiable; use crate::gameplay::galaxy::Identifiable;
use super::{DockedState, UndockEvent}; use super::{DockedState, UndockEvent};
@@ -53,14 +53,15 @@ fn handle_undock(
mut events: EventReader<UndockEvent>, mut events: EventReader<UndockEvent>,
mut docked_state: ResMut<DockedState>, mut docked_state: ResMut<DockedState>,
mut camera_state: ResMut<CameraState>, mut camera_state: ResMut<CameraState>,
player_query: Query<(Entity, &Transform), (With<PlayerShip>, With<Docked>)>, mut focus_goal: ResMut<OrbitFocusGoal>,
player_query: Query<Entity, (With<PlayerShip>, With<Docked>)>,
// docked_ui_query removed - UI no longer needed // docked_ui_query removed - UI no longer needed
) { ) {
for event in events.read() { for event in events.read() {
bevy::log::info!("Handling undock from station {:?}", event.station_entity); bevy::log::info!("Handling undock from station {:?}", event.station_entity);
// Find player ship // Find player ship
let Ok((player_entity, ship_transform)) = player_query.single() else { let Ok(player_entity) = player_query.single() else {
bevy::log::warn!("No docked player ship found"); bevy::log::warn!("No docked player ship found");
continue; continue;
}; };
@@ -87,11 +88,19 @@ fn handle_undock(
// Update docked state resource // Update docked state resource
docked_state.undock(); docked_state.undock();
// Transition camera to tactical follow mode (isometric view) // Transition camera to follow mode: a tactical iso view tracking the
// ship. Arm a focus goal so the distance eases from the docked framing
// to the follow distance instead of hard-snapping. The orbit target is
// left to `track_camera_target`, which locks it to the ship.
camera_state.mode = CameraMode::Follow; camera_state.mode = CameraMode::Follow;
camera_state.target_entity = Some(player_entity); camera_state.target_entity = Some(player_entity);
camera_state.follow_distance = 45.0; // Higher for tactical view camera_state.follow_distance = 45.0; // Higher for tactical view
camera_state.follow_height = 35.0; // Isometric angle camera_state.follow_height = 35.0; // Isometric angle
focus_goal.arm(
None,
Some(camera_state.follow_distance),
Some(crate::camera::tactical_rotation()),
);
// UI removed - gameplay only // UI removed - gameplay only
// setup_flight_ui(commands.reborrow()); // setup_flight_ui(commands.reborrow());
@@ -111,6 +120,7 @@ fn handle_docking(
mut events: EventReader<DockEvent>, mut events: EventReader<DockEvent>,
mut docked_state: ResMut<DockedState>, mut docked_state: ResMut<DockedState>,
mut camera_state: ResMut<CameraState>, mut camera_state: ResMut<CameraState>,
mut focus_goal: ResMut<OrbitFocusGoal>,
identifiable_query: Query<&Identifiable>, identifiable_query: Query<&Identifiable>,
// flight_ui_query removed - UI no longer needed // flight_ui_query removed - UI no longer needed
) { ) {
@@ -134,9 +144,15 @@ fn handle_docking(
// Update docked state resource // Update docked state resource
docked_state.dock_at(event.station); docked_state.dock_at(event.station);
// Transition camera to cinematic mode // Transition camera back to the docked tactical framing (Orbit on the
camera_state.mode = CameraMode::Cinematic; // station), easing the distance in.
camera_state.mode = CameraMode::Orbit;
camera_state.target_entity = Some(event.station); camera_state.target_entity = Some(event.station);
focus_goal.arm(
None,
Some(crate::camera::DOCKED_FRAMING_DISTANCE),
Some(crate::camera::tactical_rotation()),
);
// UI removed - no longer needed // UI removed - no longer needed
// Despawn flight HUD // Despawn flight HUD

View File

@@ -21,7 +21,7 @@ pub use docked::{DockedState, UndockEvent};
pub use flight::{FlightState, FlightControlsPlugin}; pub use flight::{FlightState, FlightControlsPlugin};
pub use scene::ActiveSystem; pub use scene::ActiveSystem;
pub use target::{Targetable, TargetKind, SelectedTarget, TargetSelectionPlugin}; pub use target::{Targetable, TargetKind, SelectedTarget, TargetSelectionPlugin};
pub use actions::{ContextualActionPlugin, ActionType, ActionTriggeredEvent}; pub use actions::{ContextualActionPlugin, ActionType, ActionTriggeredEvent, ActionUi};
pub use operations::{TimedOperationPlugin, OperationKind, ActiveOperation}; pub use operations::{TimedOperationPlugin, OperationKind, ActiveOperation};
pub struct InSystemPlugin; pub struct InSystemPlugin;

View File

@@ -2,6 +2,7 @@
//! //!
//! Handles spawning the star, POIs, and player ship docked at a station. //! Handles spawning the star, POIs, and player ship docked at a station.
use bevy::ecs::system::SystemParam;
use bevy::prelude::*; use bevy::prelude::*;
use crate::camera::{MainCamera, OrbitCamera}; use crate::camera::{MainCamera, OrbitCamera};
@@ -11,8 +12,7 @@ use crate::gameplay::galaxy::{
contents, Massive, Luminosity, MassLock, BoundingVolume, Identifiable, contents, Massive, Luminosity, MassLock, BoundingVolume, Identifiable,
SystemContents, SystemContents,
}; };
use crate::gameplay::in_system::{DockedState}; use crate::gameplay::in_system::DockedState;
use crate::state::AppState;
/// Tracks the currently active system for gameplay. /// Tracks the currently active system for gameplay.
#[derive(Resource, Debug, Clone, Default)] #[derive(Resource, Debug, Clone, Default)]
@@ -45,12 +45,6 @@ pub struct DockingTarget {
/// Offset from station where player ship spawns when docked. /// Offset from station where player ship spawns when docked.
const DOCKED_OFFSET: Vec3 = Vec3::new(1.5, 0.0, 0.0); const DOCKED_OFFSET: Vec3 = Vec3::new(1.5, 0.0, 0.0);
/// Distance for camera to view the docked scene cinematically.
const DOCKED_CAMERA_DISTANCE: f32 = 8.0;
/// Slight upward tilt for cinematic docked view.
const DOCKED_CAMERA_PITCH: f32 = 0.3;
/// Represents a docking target (either a station or a habitable planet). /// Represents a docking target (either a station or a habitable planet).
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
struct DockingTargetInfo { struct DockingTargetInfo {
@@ -61,14 +55,22 @@ struct DockingTargetInfo {
is_station: bool, is_station: bool,
} }
/// Bundles the in-system runtime state so [`setup_in_system_view`] stays
/// under clippy's argument-count threshold while also taking a camera query.
#[derive(SystemParam)]
pub(crate) struct InSystemRuntimeState<'w> {
docked: ResMut<'w, DockedState>,
active: ResMut<'w, ActiveSystem>,
}
pub fn setup_in_system_view( pub fn setup_in_system_view(
mut commands: Commands, mut commands: Commands,
mut meshes: ResMut<Assets<Mesh>>, mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<StandardMaterial>>, mut materials: ResMut<Assets<StandardMaterial>>,
campaign: Res<CampaignDraft>, campaign: Res<CampaignDraft>,
mut docked_state: ResMut<DockedState>, mut runtime: InSystemRuntimeState,
mut active_system: ResMut<ActiveSystem>,
mut camera_state: ResMut<crate::camera::CameraState>, mut camera_state: ResMut<crate::camera::CameraState>,
mut camera_query: Query<&mut OrbitCamera, With<MainCamera>>,
) { ) {
// Get the selected starting base // Get the selected starting base
let Some(starting_base) = &campaign.starting_base else { let Some(starting_base) = &campaign.starting_base else {
@@ -134,19 +136,29 @@ pub fn setup_in_system_view(
); );
// Update resources // Update resources
docked_state.system_id = system.id.clone(); runtime.docked.system_id = system.id.clone();
docked_state.is_docked = true; runtime.docked.is_docked = true;
docked_state.station_entity = Some(station_entity); runtime.docked.station_entity = Some(station_entity);
active_system.system_id = system.id.clone(); runtime.active.system_id = system.id.clone();
active_system.system_name = system.name.clone(); runtime.active.system_name = system.name.clone();
active_system.star_entity = Some(star_entity); runtime.active.star_entity = Some(star_entity);
// Position camera for cinematic docked view // Reconfigure the persistent camera for a tactical framing of the docked
setup_docked_camera(&mut commands); // station. Scenes must never spawn a new camera (see `camera` module docs);
// a hard set is correct here because the coordinate scale changes
// discontinuously (galaxy → in-system).
let station_pos = orbital_position(docking_target.orbit, docking_target.phase);
crate::camera::retarget_main_camera(
&mut camera_query,
station_pos,
crate::camera::DOCKED_FRAMING_DISTANCE,
crate::camera::tactical_rotation(),
);
// Set camera mode to cinematic // Orbit framing focused on the station; `track_camera_target` keeps the
camera_state.mode = crate::camera::CameraMode::Cinematic; // orbit target locked to the station each frame.
camera_state.mode = crate::camera::CameraMode::Orbit;
camera_state.target_entity = Some(station_entity); camera_state.target_entity = Some(station_entity);
} }
@@ -363,45 +375,17 @@ fn spawn_player_ship_docked(
ship_entity ship_entity
} }
/// Setup camera for cinematic docked view.
fn setup_docked_camera(commands: &mut Commands) {
// Position camera for a cinematic view looking at the docked scene
// Offset back and up, angled down slightly
let camera_distance = DOCKED_CAMERA_DISTANCE;
let camera_pitch = DOCKED_CAMERA_PITCH;
let yaw = Quat::from_rotation_y(0.0);
let pitch = Quat::from_rotation_x(camera_pitch);
let rotation = yaw * pitch;
let offset = rotation * Vec3::new(0.0, 0.0, camera_distance);
let position = Vec3::new(DOCKED_OFFSET.x * 0.5, 1.0, 0.0) + offset;
commands.spawn((
Camera3d::default(),
Transform::from_translation(position)
.with_rotation(rotation)
.looking_at(Vec3::new(DOCKED_OFFSET.x * 0.5, 0.0, 0.0), Vec3::Y),
MainCamera,
OrbitCamera {
target: Vec3::new(DOCKED_OFFSET.x * 0.5, 0.0, 0.0),
distance: camera_distance,
rotation,
},
));
}
/// Despawn all entities spawned for the in-system view. /// Despawn all entities spawned for the in-system view.
///
/// The [`MainCamera`] is persistent across states (spawned once at [`Startup`])
/// and is intentionally **not** despawned here — scenes reconfigure it.
/// Despawning it would leave the session with no camera, since [`Startup`] does
/// not re-run.
pub fn despawn_in_system_scene( pub fn despawn_in_system_scene(
mut commands: Commands, mut commands: Commands,
query: Query<Entity, With<InSystemSpawned>>, query: Query<Entity, With<InSystemSpawned>>,
camera_query: Query<Entity, With<MainCamera>>,
) { ) {
for entity in &query { for entity in &query {
commands.entity(entity).despawn_recursive();
}
// Also despawn the main camera (it gets recreated on next enter)
for entity in &camera_query {
commands.entity(entity).despawn(); commands.entity(entity).despawn();
} }
} }

View File

@@ -20,7 +20,6 @@ pub struct StartingBasePlugin;
impl Plugin for StartingBasePlugin { impl Plugin for StartingBasePlugin {
fn build(&self, app: &mut App) { fn build(&self, app: &mut App) {
app.init_resource::<StartingBaseDraft>() app.init_resource::<StartingBaseDraft>()
.init_resource::<StartingBaseFocusGoal>()
.init_resource::<scene::SpawnedPoiSystem>() .init_resource::<scene::SpawnedPoiSystem>()
.add_systems( .add_systems(
OnEnter(AppState::StartingBaseSelection), OnEnter(AppState::StartingBaseSelection),
@@ -34,7 +33,6 @@ impl Plugin for StartingBasePlugin {
Update, Update,
( (
escape_to_character_creation, escape_to_character_creation,
scene::starting_base_orbit_camera_control,
ui::candidate_button_handler, ui::candidate_button_handler,
scene::focus_starting_base_camera, scene::focus_starting_base_camera,
scene::animate_starting_base_selection, scene::animate_starting_base_selection,
@@ -69,22 +67,6 @@ pub struct StartingBaseFocusRequest {
pub candidate_index: usize, pub candidate_index: usize,
} }
/// Resolved focus target the starting-base camera tweens toward. Set by
/// [`crate::gameplay::starting_base::scene::focus_starting_base_camera`] when a
/// candidate is selected, then approached gradually by the orbit control
/// system, which clears `active` once the camera arrives (or the user takes
/// manual control by dragging / scrolling).
///
/// Persistent rather than inserted/removed on demand so the consumer can mutate
/// it via a single `ResMut` and keep the orbit control system's parameter count
/// manageable.
#[derive(Resource, Debug, Clone, Copy, Default)]
pub struct StartingBaseFocusGoal {
pub target: Vec3,
pub distance: f32,
pub active: bool,
}
#[derive(Component)] #[derive(Component)]
pub struct StartingBaseSpawned; pub struct StartingBaseSpawned;

View File

@@ -1,15 +1,8 @@
//! 3D galaxy view for starting-base selection. //! 3D galaxy view for starting-base selection.
use bevy::ecs::system::SystemParam;
use bevy::input::mouse::{MouseMotion, MouseWheel};
use bevy::prelude::*; use bevy::prelude::*;
use bevy::window::PrimaryWindow;
use super::{ use super::{StartingBaseDraft, StartingBaseFocusRequest, StartingBaseSpawned};
StartingBaseDraft, StartingBaseFocusGoal, StartingBaseFocusRequest, StartingBaseInputBlocker,
StartingBaseSpawned,
};
use crate::camera::{MainCamera, OrbitCamera};
use crate::gameplay::campaign::CampaignDraft; use crate::gameplay::campaign::CampaignDraft;
use crate::gameplay::galaxy::{StartingBaseMapSystem, contents}; use crate::gameplay::galaxy::{StartingBaseMapSystem, contents};
use crate::state::AppState; use crate::state::AppState;
@@ -19,17 +12,7 @@ const SELECTED_SCALE: f32 = 2.4;
const CANDIDATE_SCALE: f32 = 1.25; const CANDIDATE_SCALE: f32 = 1.25;
const FOGGED_SCALE: f32 = 0.58; const FOGGED_SCALE: f32 = 0.58;
const SELECTION_LERP_SPEED: f32 = 10.0; const SELECTION_LERP_SPEED: f32 = 10.0;
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;
const STARTING_BASE_FOCUS_DISTANCE: f32 = 90.0; const STARTING_BASE_FOCUS_DISTANCE: f32 = 90.0;
/// Exponential-damp speed for the camera focus tween. Matches the idiom used
/// by `animate_starting_base_selection`; ~6.0 settles over roughly a second.
const CAMERA_LERP_SPEED: f32 = 6.0;
/// Snap-to-exact and release the focus goal once target and distance are both
/// within this tolerance. Without it the asymptotic damp would never release.
const CAMERA_FOCUS_EPSILON: f32 = 0.5;
#[derive(Component)] #[derive(Component)]
struct StartingBaseSceneRoot; struct StartingBaseSceneRoot;
@@ -334,106 +317,11 @@ fn spawn_connection(
)); ));
} }
/// Bundles the frame clock and the (mutable) focus goal so the orbit control
/// system stays under clippy's argument-count threshold while keeping the
/// per-frame tween co-located with manual-input cancellation.
#[derive(SystemParam)]
pub(super) struct FocusParams<'w> {
time: Res<'w, Time>,
goal: ResMut<'w, StartingBaseFocusGoal>,
}
pub fn starting_base_orbit_camera_control(
mouse_input: Res<ButtonInput<MouseButton>>,
primary_window: Query<&Window, With<PrimaryWindow>>,
mut mouse_motion: EventReader<MouseMotion>,
mut scroll_events: EventReader<MouseWheel>,
mut camera_query: Query<(&mut Transform, &mut OrbitCamera), With<MainCamera>>,
ui_nodes: Query<(&ComputedNode, &GlobalTransform), With<StartingBaseInputBlocker>>,
mut focus: FocusParams,
) {
let Ok((mut transform, mut orbit)) = camera_query.single_mut() else {
return;
};
let Ok(window) = primary_window.single() else {
return;
};
let cursor_over_ui = cursor_over_starting_base_ui(window, &ui_nodes);
// Track whether the user actively manipulated the camera this frame so an
// in-flight focus tween can be cancelled. A bare click (no motion delta)
// does not count — only real drag/scroll takes over.
let mut dragged = false;
if mouse_input.pressed(MouseButton::Left) && !cursor_over_ui {
for event in mouse_motion.read() {
dragged = true;
// iPhone-style orbit controls: dragging in a direction moves the camera that way
// around the target
let yaw_delta = event.delta.x * ORBIT_ROTATE_SENSITIVITY;
let pitch_delta = event.delta.y * ORBIT_ROTATE_SENSITIVITY;
// Get current euler angles from the quaternion
let (current_yaw, current_pitch, current_roll) = orbit.rotation.to_euler(EulerRot::YXZ);
// Apply deltas (inverted for iPhone-style: drag left = orbit left)
// No clamping - allow full 360° rotation
let new_yaw = current_yaw - yaw_delta;
let new_pitch = current_pitch + pitch_delta;
// Reconstruct quaternion from euler angles
orbit.rotation = Quat::from_euler(EulerRot::YXZ, new_yaw, new_pitch, current_roll);
}
} else {
mouse_motion.clear();
}
let mut scrolled = false;
for event in scroll_events.read() {
if cursor_over_ui {
continue;
}
scrolled = true;
orbit.distance *= 1.0 - event.y * ORBIT_ZOOM_SENSITIVITY;
}
// Manual camera input cancels any pending focus tween so control returns
// immediately to the user.
if dragged || scrolled {
focus.goal.active = false;
}
// Tween toward the focus goal using the same exponential-damp idiom as the
// selection-scale animation.
if focus.goal.active {
let dt = focus.time.delta_secs().min(0.1);
let alpha = (dt * CAMERA_LERP_SPEED).clamp(0.0, 1.0);
let target_delta = focus.goal.target - orbit.target;
let distance_delta = focus.goal.distance - orbit.distance;
orbit.target += target_delta * alpha;
orbit.distance += distance_delta * alpha;
let reached = target_delta.length() < CAMERA_FOCUS_EPSILON
&& distance_delta.abs() < CAMERA_FOCUS_EPSILON;
if reached {
orbit.target = focus.goal.target;
orbit.distance = focus.goal.distance;
focus.goal.active = false;
}
}
orbit.distance = orbit.distance.clamp(ORBIT_MIN_DISTANCE, ORBIT_MAX_DISTANCE);
let position = orbit.target + orbit.rotation * Vec3::new(0.0, 0.0, orbit.distance);
*transform = Transform::from_translation(position).with_rotation(orbit.rotation);
}
pub fn focus_starting_base_camera( pub fn focus_starting_base_camera(
mut commands: Commands, mut commands: Commands,
focus: Option<Res<StartingBaseFocusRequest>>, focus: Option<Res<StartingBaseFocusRequest>>,
systems: Query<(&GlobalTransform, &StartingBaseSystemVisual)>, systems: Query<(&GlobalTransform, &StartingBaseSystemVisual)>,
mut goal: ResMut<StartingBaseFocusGoal>, mut goal: ResMut<crate::camera::OrbitFocusGoal>,
) { ) {
let Some(focus) = focus else { let Some(focus) = focus else {
return; return;
@@ -451,39 +339,12 @@ pub fn focus_starting_base_camera(
return; return;
}; };
// Don't snap the camera — arm a goal the orbit control system tweens toward. // This is an inspection scene (no `track_camera_target`), so it owns the
// Re-clicking the same candidate overwrites it, smoothly retargeting a // orbit target directly: arm target + distance and let the shared
// still-in-flight tween. // `orbit_camera_control` tween toward them. The angle is left untouched so
goal.target = target; // a user-chosen rotation persists across candidate switches. Re-clicking a
goal.distance = STARTING_BASE_FOCUS_DISTANCE.clamp(ORBIT_MIN_DISTANCE, ORBIT_MAX_DISTANCE); // candidate overwrites the goal, smoothly retargeting an in-flight tween.
goal.active = true; goal.arm(Some(target), Some(STARTING_BASE_FOCUS_DISTANCE), None);
}
fn cursor_over_starting_base_ui(
window: &Window,
nodes: &Query<(&ComputedNode, &GlobalTransform), With<StartingBaseInputBlocker>>,
) -> bool {
let Some(cursor_logical) = window.cursor_position() else {
return false;
};
for (node, transform) in nodes {
if node.is_empty() {
continue;
}
let center = transform.translation().truncate();
let half = node.size() * 0.5;
let min = center - half;
let max = center + half;
if cursor_logical.x >= min.x
&& cursor_logical.x <= max.x
&& cursor_logical.y >= min.y
&& cursor_logical.y <= max.y
{
return true;
}
}
false
} }
pub fn animate_starting_base_selection( pub fn animate_starting_base_selection(

View File

@@ -4,8 +4,9 @@ mod state;
mod ui; mod ui;
use bevy::prelude::*; use bevy::prelude::*;
use bevy::transform::TransformSystem;
use camera::{orbit_camera_control, follow_camera_system, CameraState}; use camera::{orbit_camera_control, CameraState, CinematicOverlay, OrbitFocusGoal};
use gameplay::campaign::CampaignDraft; use gameplay::campaign::CampaignDraft;
use gameplay::{ use gameplay::{
ai::AiPlugin, character_creation::CharacterCreationPlugin, galaxy::GalaxyPlugin, ai::AiPlugin, character_creation::CharacterCreationPlugin, galaxy::GalaxyPlugin,
@@ -14,7 +15,6 @@ use gameplay::{
}; };
use state::AppState; use state::AppState;
use ui::main_menu; use ui::main_menu;
use bevy::prelude::State;
fn main() { fn main() {
App::new() App::new()
@@ -28,16 +28,38 @@ fn main() {
.init_state::<AppState>() .init_state::<AppState>()
.init_resource::<CampaignDraft>() .init_resource::<CampaignDraft>()
.init_resource::<CameraState>() .init_resource::<CameraState>()
.init_resource::<CinematicOverlay>()
.init_resource::<OrbitFocusGoal>()
.add_systems(Startup, camera::spawn_camera) .add_systems(Startup, camera::spawn_camera)
// Follow camera for in-flight gameplay // Single camera positioning + control system for every 3D scene.
.add_systems( .add_systems(
Update, Update,
follow_camera_system.run_if(in_follow_mode), orbit_camera_control.run_if(in_camera_scene),
) )
// Orbit camera for inspection scenes (Galaxy view) // Cinematic overlay: toggle + HUD hide. In-game only.
.add_systems( .add_systems(
Update, Update,
orbit_camera_control.run_if(in_orbit_mode), (camera::toggle_cinematic, camera::update_cinematic_hud)
.run_if(in_state(AppState::InGame)),
)
// Keep the orbit target locked to the focus entity (ship/station).
// Runs after transform propagation so the target's GlobalTransform is
// current.
.add_systems(
PostUpdate,
camera::track_camera_target
.run_if(in_state(AppState::InGame))
.after(TransformSystem::TransformPropagate),
)
// Drop half-finished focus tweens / a left-on cinematic overlay when
// leaving a camera-driven scene so they can't bleed into the next one.
.add_systems(
OnExit(AppState::InGame),
camera::reset_transient_camera_state,
)
.add_systems(
OnExit(AppState::StartingBaseSelection),
camera::reset_transient_camera_state,
) )
.add_systems(OnEnter(AppState::MainMenu), main_menu::setup_main_menu) .add_systems(OnEnter(AppState::MainMenu), main_menu::setup_main_menu)
.add_systems(OnExit(AppState::MainMenu), main_menu::despawn_main_menu) .add_systems(OnExit(AppState::MainMenu), main_menu::despawn_main_menu)
@@ -56,18 +78,10 @@ fn main() {
.run(); .run();
} }
/// Run condition: true when camera is in follow mode. /// Run condition: true in any 3D scene the camera actively frames.
fn in_follow_mode( fn in_camera_scene(state: Res<State<AppState>>) -> bool {
state: Res<State<AppState>>, matches!(
camera_state: Res<CameraState>, *state.get(),
) -> bool { AppState::Galaxy | AppState::StartingBaseSelection | AppState::InGame
*state == AppState::InGame && camera_state.mode == camera::CameraMode::Follow )
}
/// Run condition: true when camera is in orbit mode (either Galaxy state or Orbit mode).
fn in_orbit_mode(
state: Res<State<AppState>>,
camera_state: Res<CameraState>,
) -> bool {
*state == AppState::Galaxy || camera_state.mode == camera::CameraMode::Orbit
} }