feat(gameplay): implement in-system gameplay with camera modes and flight controls

Add comprehensive in-system gameplay features including:

Camera System:
- Orbit mode for galaxy/inspection views
- Follow mode for tracking player ship during flight
- Cinematic mode for docked/cutscene views
- Smooth interpolation and orbit controls

In-System Gameplay:
- Docked state at stations with undock functionality
- Flight mode with velocity and target-based navigation
- Point-and-click movement via ground plane projection
- Target selection system for POIs

Flight Controls:
- Flight state tracking with speed monitoring
- Automatic camera transitions between modes
- Flight HUD with speed indicator and status panel
- Contextual action system for approach/dock/mining

UI Updates:
- Docked station panel with system information
- Flight mode controls and hints
- Dynamic population display

This implementation provides the foundation for tactical
space gameplay with smooth camera transitions and
intuitive point-and-click navigation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-15 20:02:19 -04:00
parent 07316dbcd7
commit 98c2ba59df
14 changed files with 1338 additions and 43 deletions

View File

@@ -7,6 +7,53 @@ 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.
///
@@ -73,7 +120,10 @@ const ORBIT_MAX_DISTANCE: f32 = 1500.0;
///
/// 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.
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>,
@@ -81,6 +131,12 @@ pub fn orbit_camera_control(
mut query: Query<(&mut Transform, &mut OrbitCamera), With<MainCamera>>,
ui_nodes: Query<(&bevy::ui::ComputedNode, &GlobalTransform)>,
) {
// Only run orbit controls in Orbit mode
if camera_state.mode != CameraMode::Orbit {
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();
@@ -162,3 +218,102 @@ pub fn apply_orbit_reset(
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,
});
}