Files
Space-Game/apps/game/src/camera.rs
francy51 57633addfe chore: sync codebase remediation, gameplay systems, and docs
Security & infrastructure:
- Remove unused services/ (auth, spacetimedb) and auth.db
- Add .env.example template, expand .gitignore for env/db files
- Add GitHub Actions CI + commitlint config and workflows
- Add manual vendor chunking and source maps to docs/site vite configs

Shared UI & docs app:
- Add ARIA props and focus-visible rings to Button/Panel
- Add ButtonAsLink primitive; use shared Button in NotFound
- Wire @void-nav/ui into docs app; refresh content pages
- Replace Todo page with Kanban board

Gameplay (Bevy):
- Add ai module (behavior, faction, navigation, perception, spawning, states)
- Add narrative module (events, history, synthesis, ui)
- Refine galaxy contents and in-system flight/scene systems
2026-06-16 11:49:13 -04:00

325 lines
12 KiB
Rust

use bevy::input::mouse::MouseButton;
use bevy::prelude::*;
use bevy::window::PrimaryWindow;
use crate::ui::util::cursor_over_ui;
#[derive(Component)]
pub struct MainCamera;
/// Camera mode determines how the camera behaves.
/// - Orbit: Free-look inspection around a target (Galaxy view, docked inspection)
/// - Follow: Tracks behind a moving entity (player ship during flight)
/// - Cinematic: Fixed cinematic shot (docked view, cutscenes)
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub enum CameraMode {
#[default]
Orbit,
Follow,
Cinematic,
}
impl CameraMode {
pub fn is_orbit(&self) -> bool {
matches!(self, Self::Orbit)
}
pub fn is_follow(&self) -> bool {
matches!(self, Self::Follow)
}
pub fn is_cinematic(&self) -> bool {
matches!(self, Self::Cinematic)
}
}
/// Global camera state resource. Controls which camera mode is active
/// and which entity the camera should follow (if any).
#[derive(Resource, Default, Debug)]
pub struct CameraState {
pub mode: CameraMode,
pub target_entity: Option<Entity>,
pub follow_distance: f32,
pub follow_height: f32,
}
/// Follow camera component. Attached to the MainCamera when in Follow mode,
/// this configures how the camera tracks its target.
#[derive(Component, Debug, Clone)]
pub struct FollowCamera {
pub target: Entity,
pub distance: f32,
pub height: f32,
pub stiffness: f32,
pub damping: f32,
}
/// Orbit-style camera: rotates around `target` at `distance`, controlled by mouse.
/// Used for inspection scenes like Galaxy where there is no player to follow.
///
/// Orientation is stored as a quaternion (`rotation`) to allow true 360° motion
/// with no gimbal lock. The base orientation is camera at `target + (0, 0, distance)`
/// looking toward `target` with up `+Y`. The quaternion rotates this base around
/// `target`. Mouse drag applies incremental rotations:
/// - horizontal delta → rotate around world `+Y` (yaw)
/// - vertical delta → rotate around the camera's local `+X` (its right vector)
///
/// This yaw-around-world / pitch-around-local split is the standard free-look
/// construction; it never produces a degenerate "up" vector at the poles.
#[derive(Component, Debug, Clone, Copy)]
pub struct OrbitCamera {
pub target: Vec3,
pub distance: f32,
pub rotation: Quat,
}
impl OrbitCamera {
/// Reset to default orientation (camera at the canonical "starting" view).
pub fn reset(&mut self) {
*self = Self::default();
}
}
impl Default for OrbitCamera {
fn default() -> Self {
// ~36° above horizontal, slightly rotated, roughly matching the docs
// GalaxyScene opening shot. Built as yaw * pitch so the angles compose
// the same way the incremental drag rotations do.
let yaw = Quat::from_rotation_y(0.15);
let pitch = Quat::from_rotation_x(0.625);
Self {
target: Vec3::ZERO,
distance: 420.0,
rotation: yaw * pitch,
}
}
}
/// Initial camera spawn. The same entity is reused across states; control systems
/// decide how it moves depending on which state is active.
pub fn spawn_camera(mut commands: Commands) {
let orbit = OrbitCamera::default();
let offset = orbit.rotation * Vec3::new(0.0, 0.0, orbit.distance);
commands.spawn((
Camera3d::default(),
Transform::from_translation(orbit.target + offset)
.with_rotation(orbit.rotation)
.looking_at(orbit.target, Vec3::Y),
MainCamera,
orbit,
Camera {
// Customize clear color for space background
clear_color: ClearColorConfig::Custom(Color::srgb(0.02, 0.02, 0.05)),
..default()
},
));
}
const ORBIT_ROTATE_SENSITIVITY: f32 = 0.005;
const ORBIT_ZOOM_SENSITIVITY: f32 = 0.1;
const ORBIT_MIN_DISTANCE: f32 = 40.0;
const ORBIT_MAX_DISTANCE: f32 = 1500.0;
/// Left-drag rotates around the orbit target; scroll wheel zooms.
/// No pitch clamping — the camera can tumble a full 360°.
///
/// Drag is suppressed when the cursor is over any UI node — otherwise clicking
/// buttons or panels would also rotate the camera.
///
/// Only runs when camera mode is Orbit or Follow (for tactical repositioning).
pub fn orbit_camera_control(
camera_state: Res<CameraState>,
mouse_input: Res<ButtonInput<MouseButton>>,
primary_window: Query<&Window, With<PrimaryWindow>>,
mut mouse_motion: EventReader<bevy::input::mouse::MouseMotion>,
mut scroll_events: EventReader<bevy::input::mouse::MouseWheel>,
mut query: Query<(&mut Transform, &mut OrbitCamera), With<MainCamera>>,
ui_nodes: Query<(&bevy::ui::ComputedNode, &GlobalTransform)>,
) {
// Only run orbit controls in Orbit or Follow mode (not Cinematic)
if !camera_state.mode.is_orbit() && !camera_state.mode.is_follow() {
mouse_motion.clear();
scroll_events.clear();
return;
}
let Ok((mut transform, mut orbit)) = query.single_mut() else {
// Drain pending input to avoid stale buildup when there's no camera.
mouse_motion.clear();
scroll_events.clear();
return;
};
let cursor_over_ui = primary_window
.single()
.ok()
.map(|w| cursor_over_ui(w, &ui_nodes))
.unwrap_or(false);
if mouse_input.pressed(MouseButton::Left) && !cursor_over_ui {
for event in mouse_motion.read() {
// iPhone-style: content follows the finger horizontally.
let yaw = Quat::from_rotation_y(-event.delta.x * ORBIT_ROTATE_SENSITIVITY);
// Vertical: drag down raises the camera so the scene appears to
// shift down with the cursor.
let pitch = Quat::from_rotation_x(event.delta.y * ORBIT_ROTATE_SENSITIVITY);
// Pre-multiply yaw (world-axis rotation), post-multiply pitch
// (local-axis rotation) — this preserves a stable horizon line as
// long as `rotation` doesn't already pitch past 90°.
orbit.rotation = yaw * orbit.rotation * pitch;
// Re-normalize to counteract floating-point drift from repeated
// multiplications. Without this the quaternion gradually loses unit
// length, introducing an implicit scale that manifests as visual
// stretching / skewing of the scene.
orbit.rotation = orbit.rotation.normalize();
}
} else {
mouse_motion.clear();
}
for event in scroll_events.read() {
if cursor_over_ui {
continue;
}
// Scroll up (positive y) → decrease distance (zoom in).
orbit.distance *= 1.0 - event.y * ORBIT_ZOOM_SENSITIVITY;
}
orbit.distance = orbit.distance.clamp(ORBIT_MIN_DISTANCE, ORBIT_MAX_DISTANCE);
// Position = target + rotation * (camera-forward * distance).
// In the base orientation the camera sits on +Z looking at the origin, so
// the offset is `rotation * +Z * distance`.
let offset = orbit.rotation * Vec3::new(0.0, 0.0, orbit.distance);
let position = orbit.target + offset;
// Use `orbit.rotation` directly — it already encodes the correct look
// direction (rotation * -Z points toward the target). Do NOT call
// `looking_at` then `with_rotation`; that computes a correct rotation
// only to discard it, and hides the fact that the raw quaternion is the
// sole source of truth.
*transform = Transform::from_translation(position).with_rotation(orbit.rotation);
}
/// Resource: set by UI buttons (e.g. "Center View") to request the orbit camera
/// be reset to its default orientation on the next frame. Consumed by
/// [`apply_orbit_reset`].
#[derive(Resource, Default, Debug)]
pub struct ResetOrbitCamera;
/// If [`ResetOrbitCamera`] is present, snap the orbit camera back to default
/// and consume the resource. Lives in `Update` so UI button presses (which
/// insert the resource via `Commands`) take effect on the following frame.
pub fn apply_orbit_reset(
mut commands: Commands,
flag: Option<Res<ResetOrbitCamera>>,
mut query: Query<&mut OrbitCamera, With<MainCamera>>,
) {
let Some(_flag) = flag else { return };
let Ok(mut orbit) = query.single_mut() else {
commands.remove_resource::<ResetOrbitCamera>();
return;
};
orbit.reset();
commands.remove_resource::<ResetOrbitCamera>();
}
/// Follow camera system. Tracks behind the target entity (player ship) during flight.
/// The camera maintains a fixed distance and height behind the target, smoothly
/// interpolating to the ideal position each frame.
pub fn follow_camera_system(
time: Res<Time>,
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 camera_state.mode != CameraMode::Follow {
return;
}
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.
/// This system adds the FollowCamera component to the MainCamera entity.
pub fn setup_follow_camera(
mut commands: Commands,
camera_state: Res<CameraState>,
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
if camera_state.mode != CameraMode::Follow {
return;
}
let Some(target_entity) = camera_state.target_entity else {
return;
};
let Ok(camera_entity) = camera_query.single() else {
return;
};
// Check if FollowCamera already exists, if so don't add it again
if follow_cam_query.single().is_ok() {
return;
}
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,
});
}