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:
@@ -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,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user