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
325 lines
12 KiB
Rust
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,
|
|
});
|
|
}
|