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)] #[derive(Component)]
pub struct MainCamera; 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. /// Orbit-style camera: rotates around `target` at `distance`, controlled by mouse.
/// Used for inspection scenes like Galaxy where there is no player to follow. /// 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 /// Drag is suppressed when the cursor is over any UI node — otherwise clicking
/// buttons or panels would also rotate the camera. /// buttons or panels would also rotate the camera.
///
/// Only runs when camera mode is Orbit.
pub fn orbit_camera_control( pub fn orbit_camera_control(
camera_state: Res<CameraState>,
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<bevy::input::mouse::MouseMotion>,
@@ -81,6 +131,12 @@ pub fn orbit_camera_control(
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<(&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 { 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();
@@ -162,3 +218,102 @@ pub fn apply_orbit_reset(
orbit.reset(); orbit.reset();
commands.remove_resource::<ResetOrbitCamera>(); 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,
});
}

View File

@@ -115,6 +115,7 @@ pub struct GeneratedPlanet {
pub radius: f32, pub radius: f32,
pub mass: f32, pub mass: f32,
pub population: u32, pub population: u32,
pub habitable: bool,
} }
/// One rock instance inside a [`GeneratedAsteroidBelt`]. Generated at galaxy /// One rock instance inside a [`GeneratedAsteroidBelt`]. Generated at galaxy
@@ -346,6 +347,7 @@ pub fn generate_system_contents(rng: &mut StdRng, ctx: &SystemContext) -> System
radius, radius,
mass, mass,
population, population,
habitable,
}); });
orbit_cursor += PLANET_ORBIT_STEP + rng.gen_range(0.1..0.4); orbit_cursor += PLANET_ORBIT_STEP + rng.gen_range(0.1..0.4);
} }
@@ -382,8 +384,12 @@ pub fn generate_system_contents(rng: &mut StdRng, ctx: &SystemContext) -> System
} else { } else {
0 0
}; };
// Distribute stations across the outer ring with unique orbits
for i in 0..station_count { for i in 0..station_count {
orbit_cursor = orbit_cursor.max(OUTER_RING_START) + rng.gen_range(0.2..0.5); // Start stations at OUTER_RING_START, spread them evenly
let base_orbit = orbit_cursor.max(OUTER_RING_START);
// Add increasing offset for each station to ensure spacing
let orbit = base_orbit + (i as f32 * 0.5) + rng.gen_range(0.1..0.3);
let phase = rng.gen::<f32>() * std::f32::consts::TAU; let phase = rng.gen::<f32>() * std::f32::consts::TAU;
let population = if ctx.is_core { let population = if ctx.is_core {
rng.gen_range(1_000_000..10_000_000) rng.gen_range(1_000_000..10_000_000)
@@ -397,12 +403,13 @@ pub fn generate_system_contents(rng: &mut StdRng, ctx: &SystemContext) -> System
}; };
contents.stations.push(GeneratedStation { contents.stations.push(GeneratedStation {
name, name,
orbit: orbit_cursor, orbit,
phase, phase,
period: orbital_period(orbit_cursor, rng.gen_range(0.0..3.0)), period: orbital_period(orbit, rng.gen_range(0.0..3.0)),
population, population,
}); });
orbit_cursor += 0.3; // Update orbit_cursor to after the last station for subsequent POIs
orbit_cursor = orbit + 0.4;
} }
// ── Gas clouds (rare, slightly more common in low-sec) ────────────────── // ── Gas clouds (rare, slightly more common in low-sec) ──────────────────
@@ -776,6 +783,9 @@ pub fn spawn_system_contents(
population: planet.population, population: planet.population,
}); });
} }
if planet.habitable {
entity.insert(HabitablePlanet);
}
} }
// ── Asteroid belts ────────────────────────────────────────────────────── // ── Asteroid belts ──────────────────────────────────────────────────────
@@ -1184,6 +1194,7 @@ mod tests {
radius: 0.1, radius: 0.1,
mass: 1.0, mass: 1.0,
population: 0, population: 0,
habitable: false,
}); });
c.stations.push(GeneratedStation { c.stations.push(GeneratedStation {
name: "S1".into(), name: "S1".into(),

View File

@@ -23,7 +23,7 @@ use crate::camera::apply_orbit_reset;
use crate::gameplay::campaign::{GeneratedGalaxy, GeneratedGalaxySystem}; use crate::gameplay::campaign::{GeneratedGalaxy, GeneratedGalaxySystem};
use crate::state::AppState; use crate::state::AppState;
pub use contents::{GeneratedStation, SystemContents, SystemContext, SystemSummary}; pub use contents::{GeneratedPlanet, GeneratedStation, SystemContents, SystemContext, SystemSummary};
pub use params::{BeamParams, CoreParams, DiskParams}; pub use params::{BeamParams, CoreParams, DiskParams};
pub use params::{GalaxyParams, SelectedStar}; pub use params::{GalaxyParams, SelectedStar};
use params::{MIN_SYSTEM_SPACING, NEAREST_NEIGHBOR_CONNECTIONS, SPACING_ATTEMPTS}; use params::{MIN_SYSTEM_SPACING, NEAREST_NEIGHBOR_CONNECTIONS, SPACING_ATTEMPTS};

View File

@@ -308,6 +308,12 @@ pub struct Luminosity {
#[reflect(Component)] #[reflect(Component)]
pub struct Habitable; pub struct Habitable;
/// Tag — this is a planet that can be docked to (e.g., terrestrial or oceanic).
/// Gas giants, ice giants, lava worlds, etc. should not have this component.
#[derive(Component, Debug, Clone, Copy, Reflect, Default)]
#[reflect(Component)]
pub struct HabitablePlanet;
/// Tag — this body currently hosts a population. /// Tag — this body currently hosts a population.
#[derive(Component, Debug, Clone, Reflect, Default)] #[derive(Component, Debug, Clone, Reflect, Default)]
#[reflect(Component)] #[reflect(Component)]
@@ -359,7 +365,7 @@ pub struct EventTimer {
#[derive(Component, Debug, Clone, Copy, Reflect)] #[derive(Component, Debug, Clone, Copy, Reflect)]
#[reflect(Component)] #[reflect(Component)]
#[require(Orbital, Massive, BoundingVolume, WarpTarget, MassLock, Identifiable)] #[require(Orbital, Massive, BoundingVolume, WarpTarget, MassLock, Dockable, Identifiable)]
pub struct Planet; pub struct Planet;
#[derive(Component, Debug, Clone, Copy, Reflect)] #[derive(Component, Debug, Clone, Copy, Reflect)]

View File

@@ -0,0 +1,332 @@
//! Contextual action buttons for tactical navigation.
//!
//! Provides action buttons based on current state (docked, in-flight, at belt, etc.)
use bevy::prelude::*;
use super::{DockedState, FlightState, SelectedTarget};
use super::scene::PlayerShip;
use super::target::TargetKind;
/// Available action types based on game state.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ActionType {
Undock,
Approach,
Dock,
StartMining,
StopMining,
OpenRefining,
OpenFitting,
OpenMarket,
StartCombat,
}
/// Contextual action that can be shown to the player.
#[derive(Component, Debug, Clone)]
pub struct ContextualAction {
pub action_type: ActionType,
pub label: String,
pub enabled: bool,
pub tone: ActionTone,
}
/// Visual tone for action buttons.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ActionTone {
Primary,
Normal,
Danger,
}
/// Event fired when an action is triggered.
#[derive(Event, Debug)]
pub struct ActionTriggeredEvent {
pub action_type: ActionType,
}
/// Plugin for contextual action system.
pub struct ContextualActionPlugin;
impl Plugin for ContextualActionPlugin {
fn build(&self, app: &mut App) {
app.add_event::<ActionTriggeredEvent>()
.add_systems(
Update,
(
update_contextual_actions,
handle_action_buttons,
).run_if(in_state(crate::state::AppState::InGame)),
)
.add_systems(
OnEnter(crate::state::AppState::InGame),
setup_action_ui,
)
.add_systems(
OnExit(crate::state::AppState::InGame),
despawn_action_ui,
);
}
}
/// Marker for action UI entities.
#[derive(Component)]
pub struct ActionUi;
/// Marker for action button entities.
#[derive(Component)]
pub struct ActionButton {
pub action_type: ActionType,
}
/// Setup action UI panel.
fn setup_action_ui(mut commands: Commands) {
commands
.spawn((
Node {
position_type: PositionType::Absolute,
right: Val::Px(20.0),
bottom: Val::Px(20.0),
width: Val::Px(250.0),
flex_direction: FlexDirection::Column,
row_gap: Val::Px(8.0),
padding: UiRect::all(Val::Px(12.0)),
border: UiRect::all(Val::Px(1.0)),
..default()
},
BackgroundColor(Color::srgba(0.05, 0.07, 0.12, 0.9)),
BorderColor(Color::srgb(0.25, 0.40, 0.62)),
BorderRadius::all(Val::Px(8.0)),
ActionUi,
))
.with_children(|panel| {
// Title
panel.spawn((
Text::new("ACTIONS"),
TextFont {
font_size: 12.0,
..default()
},
TextColor(Color::srgb(0.55, 0.65, 0.82)),
));
// Actions will be spawned/updated dynamically
});
}
/// Update contextual actions based on current state.
fn update_contextual_actions(
docked_state: Res<DockedState>,
player_query: Query<(Entity, Option<&SelectedTarget>, Option<&FlightState>), With<PlayerShip>>,
action_ui_query: Query<(Entity, &Children), With<ActionUi>>,
mut commands: Commands,
) {
let Ok((player_entity, selected_target, flight_state)) = player_query.single() else {
return;
};
// Determine available actions based on state
let actions = get_available_actions(&docked_state, selected_target, flight_state);
// Update UI
let Ok((ui_entity, children)) = action_ui_query.single() else {
return;
};
// Clear existing action buttons (keep the title)
for child in children.iter().skip(1) {
commands.entity(child).despawn();
}
// Spawn new action buttons
let mut new_actions = actions;
commands.entity(ui_entity).with_children(|parent| {
for action in new_actions.drain(..) {
spawn_action_button(parent, action);
}
});
}
/// Get available actions based on current state.
fn get_available_actions(
docked_state: &DockedState,
selected_target: Option<&SelectedTarget>,
flight_state: Option<&FlightState>,
) -> Vec<ContextualAction> {
let mut actions = Vec::new();
if docked_state.is_docked {
// When docked
actions.push(ContextualAction {
action_type: ActionType::Undock,
label: "Undock".to_string(),
enabled: true,
tone: ActionTone::Primary,
});
actions.push(ContextualAction {
action_type: ActionType::OpenFitting,
label: "Fitting".to_string(),
enabled: true,
tone: ActionTone::Normal,
});
actions.push(ContextualAction {
action_type: ActionType::OpenMarket,
label: "Market".to_string(),
enabled: true,
tone: ActionTone::Normal,
});
} else {
// When in flight
if let Some(target) = selected_target {
match target.kind {
TargetKind::Station => {
actions.push(ContextualAction {
action_type: ActionType::Approach,
label: format!("Approach {}", target.name),
enabled: true,
tone: ActionTone::Primary,
});
actions.push(ContextualAction {
action_type: ActionType::Dock,
label: "Dock".to_string(),
enabled: true,
tone: ActionTone::Primary,
});
}
TargetKind::AsteroidBelt => {
actions.push(ContextualAction {
action_type: ActionType::Approach,
label: format!("Approach {}", target.name),
enabled: true,
tone: ActionTone::Primary,
});
actions.push(ContextualAction {
action_type: ActionType::StartMining,
label: "Start Mining".to_string(),
enabled: true,
tone: ActionTone::Primary,
});
}
TargetKind::Hostile => {
actions.push(ContextualAction {
action_type: ActionType::Approach,
label: format!("Engage {}", target.name),
enabled: true,
tone: ActionTone::Danger,
});
actions.push(ContextualAction {
action_type: ActionType::StartCombat,
label: "Start Combat".to_string(),
enabled: true,
tone: ActionTone::Danger,
});
}
TargetKind::Manual => {
actions.push(ContextualAction {
action_type: ActionType::Approach,
label: "Approach Waypoint".to_string(),
enabled: true,
tone: ActionTone::Primary,
});
}
TargetKind::HabitablePlanet => {
actions.push(ContextualAction {
action_type: ActionType::Approach,
label: format!("Approach {}", target.name),
enabled: true,
tone: ActionTone::Primary,
});
actions.push(ContextualAction {
action_type: ActionType::Dock,
label: "Dock".to_string(),
enabled: true,
tone: ActionTone::Primary,
});
}
}
} else {
// No target selected
actions.push(ContextualAction {
action_type: ActionType::Approach,
label: "Select a target".to_string(),
enabled: false,
tone: ActionTone::Normal,
});
}
}
actions
}
/// Spawn an action button.
fn spawn_action_button(parent: &mut ChildSpawnerCommands, action: ContextualAction) {
let (bg_color, border_color) = match action.tone {
ActionTone::Primary => (
Color::srgb(0.10, 0.28, 0.22),
Color::srgb(0.30, 0.72, 0.45),
),
ActionTone::Normal => (
Color::srgb(0.10, 0.15, 0.20),
Color::srgb(0.30, 0.40, 0.55),
),
ActionTone::Danger => (
Color::srgb(0.28, 0.10, 0.10),
Color::srgb(0.72, 0.30, 0.30),
),
};
parent
.spawn((
Button,
Node {
width: Val::Percent(100.0),
height: Val::Px(36.0),
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
border: UiRect::all(Val::Px(1.0)),
..default()
},
BackgroundColor(bg_color),
BorderColor(border_color),
BorderRadius::all(Val::Px(4.0)),
ActionButton {
action_type: action.action_type,
},
))
.with_children(|button| {
button.spawn((
Text::new(&action.label),
TextFont {
font_size: 14.0,
..default()
},
TextColor(if action.enabled {
Color::srgb(0.82, 0.90, 1.0)
} else {
Color::srgb(0.40, 0.45, 0.55)
}),
));
});
}
/// Handle action button clicks.
fn handle_action_buttons(
mut query: Query<(&Interaction, &ActionButton), Changed<Interaction>>,
mut events: EventWriter<ActionTriggeredEvent>,
) {
for (interaction, button) in query.iter_mut() {
if *interaction == Interaction::Pressed {
bevy::log::info!("Action triggered: {:?}", button.action_type);
events.write(ActionTriggeredEvent {
action_type: button.action_type,
});
}
}
}
/// Despawn action UI.
fn despawn_action_ui(mut commands: Commands, query: Query<Entity, With<ActionUi>>) {
for entity in query.iter() {
commands.entity(entity).despawn();
}
}

View File

@@ -0,0 +1,174 @@
//! Flight state and controls for in-system gameplay.
//!
//! Handles the transition from docked to active flight mode, including
//! ship controls, camera transitions, and flight state management.
//!
//! Navigation is point-and-click tactical mode: select targets and click
//! "Approach" to auto-navigate. No WASD controls.
use bevy::prelude::*;
use crate::camera::{CameraState, CameraMode};
use crate::gameplay::movement::components::{Velocity, MoveTarget};
use crate::gameplay::galaxy::Identifiable;
use super::{DockedState, UndockEvent};
use super::scene::{Docked, PlayerShip};
use super::flight_ui::setup_flight_ui;
/// Flight state component attached to the player ship when actively flying.
#[derive(Component, Debug, Clone, Default)]
pub struct FlightState {
pub is_flying: bool,
pub current_speed: f32,
}
/// Event fired when the player docks at a station.
#[derive(Event, Debug, Clone)]
pub struct DockEvent {
pub ship: Entity,
pub station: Entity,
}
/// Plugin for flight controls and state management.
pub struct FlightControlsPlugin;
impl Plugin for FlightControlsPlugin {
fn build(&self, app: &mut App) {
app.add_event::<DockEvent>()
.add_systems(
Update,
(
handle_undock,
handle_docking,
flight_input_system,
).run_if(in_state(crate::state::AppState::InGame)),
);
}
}
/// Handle undock event: transition from docked to flight mode.
fn handle_undock(
mut commands: Commands,
mut events: EventReader<UndockEvent>,
mut docked_state: ResMut<DockedState>,
mut camera_state: ResMut<CameraState>,
player_query: Query<(Entity, &Transform), (With<PlayerShip>, With<Docked>)>,
docked_ui_query: Query<Entity, With<super::ui::DockedUi>>,
) {
for event in events.read() {
bevy::log::info!("Handling undock from station {:?}", event.station_entity);
// Find player ship
let Ok((player_entity, ship_transform)) = player_query.single() else {
bevy::log::warn!("No docked player ship found");
continue;
};
// Remove docked component
commands.entity(player_entity).remove::<Docked>();
// Add flight state with initial velocity away from station
let initial_speed = 10.0; // Start with slow drift
commands.entity(player_entity).insert((
FlightState {
is_flying: true,
current_speed: initial_speed,
},
Velocity(Vec3::new(0.0, 0.0, -1.0) * initial_speed),
MoveTarget(Vec3::new(0.0, 0.0, -50.0)), // Initial target away from station
super::SelectedTarget {
entity: Entity::PLACEHOLDER,
kind: super::TargetKind::Manual,
name: "None".to_string(),
},
));
// Update docked state resource
docked_state.undock();
// Transition camera to follow mode
camera_state.mode = CameraMode::Follow;
camera_state.target_entity = Some(player_entity);
camera_state.follow_distance = 15.0;
camera_state.follow_height = 5.0;
// Spawn flight HUD
setup_flight_ui(commands.reborrow());
// Despawn docked UI
for entity in docked_ui_query.iter() {
commands.entity(entity).despawn();
}
bevy::log::info!("Transitioned to flight mode");
}
}
/// Handle docking event: transition from flight to docked mode.
fn handle_docking(
mut commands: Commands,
mut events: EventReader<DockEvent>,
mut docked_state: ResMut<DockedState>,
mut camera_state: ResMut<CameraState>,
identifiable_query: Query<&Identifiable>,
flight_ui_query: Query<Entity, With<super::flight_ui::FlightUi>>,
) {
for event in events.read() {
bevy::log::info!("Handling docking at target {:?}", event.station);
let Ok(identifiable) = identifiable_query.get(event.station) else {
bevy::log::warn!("Docking target has no Identifiable component");
continue;
};
// Add docked component
commands.entity(event.ship).insert(Docked {
station_entity: event.station,
});
// Remove flight state
commands.entity(event.ship)
.remove::<FlightState>();
// Update docked state resource
docked_state.dock_at(event.station);
// Transition camera to cinematic mode
camera_state.mode = CameraMode::Cinematic;
camera_state.target_entity = Some(event.station);
// Despawn flight HUD
for entity in flight_ui_query.iter() {
commands.entity(entity).despawn();
}
// Respawn docked UI
super::ui::setup_docked_ui(commands.reborrow());
bevy::log::info!("Docked at {}", identifiable.display_name);
}
}
/// Flight input system: point-and-click navigation only.
///
/// This system handles updating the flight state based on movement.
/// Actual navigation is handled by click-to-move (setting MoveTarget)
/// and the kinematic systems (steer_to_target, integrate_velocity).
///
/// This is a minimal system - the real action happens in the movement systems.
fn flight_input_system(
mut player_query: Query<(&Velocity, &mut FlightState), With<PlayerShip>>,
docked_state: Res<DockedState>,
) {
// Only process flight input when not docked
if docked_state.is_docked {
return;
}
let Ok((velocity, mut flight_state)) = player_query.single_mut() else {
return;
};
// Update current speed from actual velocity
flight_state.current_speed = velocity.0.length();
}

View File

@@ -0,0 +1,170 @@
//! Flight HUD and UI elements for in-flight gameplay.
use bevy::prelude::*;
use super::FlightState;
const PANEL_BG: Color = Color::srgba(0.05, 0.07, 0.12, 0.8);
const PANEL_BORDER: Color = Color::srgb(0.25, 0.40, 0.62);
const TEXT_BRIGHT: Color = Color::srgb(0.82, 0.90, 1.0);
const TEXT_DIM: Color = Color::srgb(0.55, 0.65, 0.82);
const ACCENT: Color = Color::srgb(0.30, 0.72, 0.45);
/// Marker for flight HUD entities.
#[derive(Component)]
pub struct FlightUi;
/// Marker for the speed display text.
#[derive(Component)]
pub struct SpeedDisplay;
/// Marker for the status panel.
#[derive(Component)]
pub struct StatusPanel;
/// Setup the flight HUD when entering flight mode.
pub fn setup_flight_ui(mut commands: Commands) {
bevy::log::info!("Setting up flight HUD");
commands
.spawn((
Node {
width: Val::Percent(100.0),
height: Val::Percent(100.0),
position_type: PositionType::Absolute,
..default()
},
Interaction::None, // Disable interaction - let clicks pass through
FlightUi,
))
.with_children(|root| {
// Speed indicator (bottom left)
spawn_speed_indicator(root);
// Status panel (top left)
spawn_status_panel(root);
});
}
fn spawn_speed_indicator(parent: &mut ChildSpawnerCommands) {
parent
.spawn((
Node {
position_type: PositionType::Absolute,
left: Val::Px(20.0),
bottom: Val::Px(20.0),
width: Val::Px(200.0),
padding: UiRect::all(Val::Px(12.0)),
border: UiRect::all(Val::Px(1.0)),
..default()
},
BackgroundColor(PANEL_BG),
BorderColor(PANEL_BORDER),
BorderRadius::all(Val::Px(8.0)),
FlightUi,
))
.with_children(|panel| {
// Label
panel.spawn((
Text::new("SPEED"),
TextFont {
font_size: 12.0,
..default()
},
TextColor(TEXT_DIM),
));
// Speed value
panel.spawn((
Text::new("0 m/s"),
TextFont {
font_size: 24.0,
..default()
},
TextColor(TEXT_BRIGHT),
SpeedDisplay,
));
});
}
fn spawn_status_panel(parent: &mut ChildSpawnerCommands) {
parent
.spawn((
Node {
position_type: PositionType::Absolute,
left: Val::Px(20.0),
top: Val::Px(20.0),
width: Val::Px(250.0),
padding: UiRect::all(Val::Px(12.0)),
border: UiRect::all(Val::Px(1.0)),
..default()
},
BackgroundColor(PANEL_BG),
BorderColor(PANEL_BORDER),
BorderRadius::all(Val::Px(8.0)),
FlightUi,
StatusPanel,
))
.with_children(|panel| {
// Status label
panel.spawn((
Text::new("FLIGHT MODE"),
TextFont {
font_size: 12.0,
..default()
},
TextColor(ACCENT),
));
// Flight status text
panel.spawn((
Text::new("Active"),
TextFont {
font_size: 16.0,
..default()
},
TextColor(TEXT_BRIGHT),
));
// Controls hint
panel.spawn((
Node {
margin: UiRect::top(Val::Px(12.0)),
..default()
},
FlightUi,
))
.with_children(|hint| {
hint.spawn((
Text::new("Click: Set destination\nOrbit camera: Drag to rotate"),
TextFont {
font_size: 11.0,
..default()
},
TextColor(TEXT_DIM),
));
});
});
}
/// Update flight HUD elements with current flight state.
pub fn update_flight_ui(
flight_query: Query<&FlightState>,
mut speed_query: Query<&mut Text, With<SpeedDisplay>>,
) {
let Ok(flight_state) = flight_query.single() else {
return;
};
// Update speed display
if let Ok(mut speed_text) = speed_query.single_mut() {
speed_text.0 = format!("{:.0} m/s", flight_state.current_speed);
}
}
/// Despawn flight HUD when exiting flight mode.
pub fn despawn_flight_ui(mut commands: Commands, query: Query<Entity, With<FlightUi>>) {
for entity in &query {
commands.entity(entity).despawn();
}
}

View File

@@ -4,8 +4,13 @@
//! within their selected starting system. This is the entry point to actual //! within their selected starting system. This is the entry point to actual
//! gameplay after onboarding. //! gameplay after onboarding.
mod actions;
mod docked; mod docked;
mod flight;
mod flight_ui;
mod operations;
mod scene; mod scene;
mod target;
mod ui; mod ui;
use bevy::prelude::*; use bevy::prelude::*;
@@ -13,7 +18,11 @@ use bevy::prelude::*;
use crate::state::AppState; use crate::state::AppState;
pub use docked::{DockedState, UndockEvent}; pub use docked::{DockedState, UndockEvent};
pub use flight::{FlightState, FlightControlsPlugin};
pub use scene::ActiveSystem; pub use scene::ActiveSystem;
pub use target::{Targetable, TargetKind, SelectedTarget, TargetSelectionPlugin};
pub use actions::{ContextualActionPlugin, ActionType, ActionTriggeredEvent};
pub use operations::{TimedOperationPlugin, OperationKind, ActiveOperation};
pub struct InSystemPlugin; pub struct InSystemPlugin;
@@ -22,14 +31,23 @@ impl Plugin for InSystemPlugin {
app.init_resource::<DockedState>() app.init_resource::<DockedState>()
.init_resource::<ActiveSystem>() .init_resource::<ActiveSystem>()
.add_event::<UndockEvent>() .add_event::<UndockEvent>()
.add_plugins(FlightControlsPlugin)
.add_plugins(TargetSelectionPlugin)
.add_plugins(ContextualActionPlugin)
.add_plugins(TimedOperationPlugin)
.add_systems( .add_systems(
OnEnter(AppState::InGame), OnEnter(AppState::InGame),
(scene::setup_in_system_view, ui::setup_docked_ui).chain(), (
scene::setup_in_system_view,
ui::setup_docked_ui,
add_targetable_to_pois,
).chain(),
) )
.add_systems( .add_systems(
OnExit(AppState::InGame), OnExit(AppState::InGame),
( (
ui::despawn_docked_ui, ui::despawn_docked_ui,
flight_ui::despawn_flight_ui,
scene::despawn_in_system_scene, scene::despawn_in_system_scene,
).chain(), ).chain(),
) )
@@ -38,9 +56,78 @@ impl Plugin for InSystemPlugin {
( (
ui::refresh_docked_ui, ui::refresh_docked_ui,
ui::undock_button_handler, ui::undock_button_handler,
flight_ui::update_flight_ui,
handle_action_triggered,
) )
.chain() .chain()
.run_if(in_state(AppState::InGame)), .run_if(in_state(AppState::InGame)),
); );
} }
} }
/// Handle action triggered events from the action buttons.
fn handle_action_triggered(
mut commands: Commands,
mut events: EventReader<ActionTriggeredEvent>,
player_query: Query<Entity, With<scene::PlayerShip>>,
durations: Res<operations::OperationDurations>,
mut operation_events: EventWriter<operations::OperationStartedEvent>,
selected_target_query: Query<&SelectedTarget, With<scene::PlayerShip>>,
) {
let Ok(player_entity) = player_query.single() else {
return;
};
for event in events.read() {
bevy::log::info!("Action triggered: {:?}", event.action_type);
match event.action_type {
ActionType::Undock => {
operations::start_undocking(&mut commands, player_entity, &durations, &mut operation_events);
}
ActionType::Approach => {
// Get selected target
if let Ok(selected) = selected_target_query.single() {
operations::start_travel(&mut commands, player_entity, selected.name.clone(), &durations, &mut operation_events);
}
}
ActionType::Dock => {
// TODO: Implement docking operation
bevy::log::info!("Dock action triggered");
}
ActionType::StartMining => {
// TODO: Implement mining operation
bevy::log::info!("Start mining action triggered");
}
_ => {
bevy::log::info!("Other action: {:?}", event.action_type);
}
}
}
}
/// Add Targetable components to spawned POIs (stations and asteroid belts)
/// so they can be selected by the target selection system.
fn add_targetable_to_pois(
mut commands: Commands,
station_query: Query<(Entity, &crate::gameplay::galaxy::Identifiable), (With<crate::gameplay::galaxy::Station>, Without<Targetable>)>,
belt_query: Query<(Entity, &crate::gameplay::galaxy::Identifiable), (With<crate::gameplay::galaxy::AsteroidBelt>, Without<Targetable>)>,
) {
// Add Targetable to stations
for (entity, identifiable) in station_query.iter() {
bevy::log::debug!("Adding Targetable component to station: {}", identifiable.display_name);
commands.entity(entity).insert(Targetable {
kind: TargetKind::Station,
name: identifiable.display_name.clone(),
});
}
// Add Targetable to asteroid belts
for (entity, identifiable) in belt_query.iter() {
bevy::log::debug!("Adding Targetable component to asteroid belt: {}", identifiable.display_name);
commands.entity(entity).insert(Targetable {
kind: TargetKind::AsteroidBelt,
name: identifiable.display_name.clone(),
});
}
}

View File

@@ -0,0 +1,176 @@
//! Timed operation system for tactical navigation.
//!
//! Handles operations with durations like undocking, travel, docking, and mining.
//! Operations have progress tracking and fire completion events when done.
use bevy::prelude::*;
/// Active operation with timing information.
#[derive(Component, Debug, Clone)]
pub struct ActiveOperation {
pub kind: OperationKind,
pub started_at: f64,
pub duration_ms: f64,
pub target_name: String,
}
/// Kinds of operations that can be active.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OperationKind {
Undocking,
Travel,
Docking,
Mining,
Refining,
Fitting,
Combat,
}
/// Event fired when an operation completes.
#[derive(Event, Debug, Clone)]
pub struct OperationCompletedEvent {
pub kind: OperationKind,
pub entity: Entity,
}
/// Event fired when an operation is started.
#[derive(Event, Debug)]
pub struct OperationStartedEvent {
pub kind: OperationKind,
pub duration_ms: f64,
pub target_name: String,
}
/// Resource for operation duration constants.
#[derive(Resource, Debug, Clone)]
pub struct OperationDurations {
pub undock: f64,
pub local_travel: f64,
pub docking: f64,
pub mining: f64,
}
impl Default for OperationDurations {
fn default() -> Self {
Self {
undock: 3000.0, // 3 seconds
local_travel: 5000.0, // 5 seconds
docking: 4000.0, // 4 seconds
mining: 8000.0, // 8 seconds
}
}
}
/// Plugin for timed operation system.
pub struct TimedOperationPlugin;
impl Plugin for TimedOperationPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<OperationDurations>()
.add_event::<OperationCompletedEvent>()
.add_event::<OperationStartedEvent>()
.add_systems(
Update,
(
monitor_operations,
update_operation_progress,
).run_if(in_state(crate::state::AppState::InGame)),
);
}
}
/// Monitor active operations and fire completion events when done.
fn monitor_operations(
time: Res<Time>,
mut commands: Commands,
mut query: Query<(Entity, &mut ActiveOperation)>,
mut completed_events: EventWriter<OperationCompletedEvent>,
durations: Res<OperationDurations>,
) {
let now = time.elapsed_secs_f64() * 1000.0;
for (entity, mut operation) in query.iter_mut() {
let elapsed = now - operation.started_at;
if elapsed >= operation.duration_ms {
bevy::log::info!("Operation completed: {:?}", operation.kind);
// Fire completion event
completed_events.write(OperationCompletedEvent {
kind: operation.kind,
entity,
});
// Remove the operation component
commands.entity(entity).remove::<ActiveOperation>();
}
}
}
/// Update operation progress for UI display.
/// This updates any Progress components attached to operation entities.
fn update_operation_progress(
time: Res<Time>,
mut query: Query<(&ActiveOperation, &mut Progress)>,
) {
let now = time.elapsed_secs_f64() * 1000.0;
for (operation, mut progress) in query.iter_mut() {
let elapsed = now - operation.started_at;
let progress_value = (elapsed / operation.duration_ms).clamp(0.0, 1.0);
progress.value = progress_value as f32;
}
}
/// Start an undocking operation on the player ship.
pub fn start_undocking(
commands: &mut Commands,
player_entity: Entity,
durations: &OperationDurations,
mut events: &mut EventWriter<OperationStartedEvent>,
) {
commands.entity(player_entity).insert(ActiveOperation {
kind: OperationKind::Undocking,
started_at: 0.0, // Will be set by the system
duration_ms: durations.undock,
target_name: "Undocking".to_string(),
});
events.write(OperationStartedEvent {
kind: OperationKind::Undocking,
duration_ms: durations.undock,
target_name: "Undocking".to_string(),
});
}
/// Start a travel operation.
pub fn start_travel(
commands: &mut Commands,
player_entity: Entity,
target_name: String,
durations: &OperationDurations,
mut events: &mut EventWriter<OperationStartedEvent>,
) {
commands.entity(player_entity).insert(ActiveOperation {
kind: OperationKind::Travel,
started_at: 0.0,
duration_ms: durations.local_travel,
target_name: target_name.clone(),
});
events.write(OperationStartedEvent {
kind: OperationKind::Travel,
duration_ms: durations.local_travel,
target_name,
});
}
/// Progress bar component for showing operation progress.
#[derive(Component, Debug, Clone)]
pub struct Progress {
pub value: f32,
}
/// Marker for operation progress UI entities.
#[derive(Component)]
pub struct OperationProgressUi;

View File

@@ -9,7 +9,7 @@ use crate::gameplay::campaign::CampaignDraft;
use crate::gameplay::character_creation::STARTING_SHIPS; use crate::gameplay::character_creation::STARTING_SHIPS;
use crate::gameplay::galaxy::{ use crate::gameplay::galaxy::{
contents, Massive, Luminosity, MassLock, BoundingVolume, Identifiable, contents, Massive, Luminosity, MassLock, BoundingVolume, Identifiable,
SystemContents, GeneratedStation, SystemContents,
}; };
use crate::gameplay::in_system::{DockedState}; use crate::gameplay::in_system::{DockedState};
use crate::state::AppState; use crate::state::AppState;
@@ -51,6 +51,16 @@ const DOCKED_CAMERA_DISTANCE: f32 = 8.0;
/// Slight upward tilt for cinematic docked view. /// Slight upward tilt for cinematic docked view.
const DOCKED_CAMERA_PITCH: f32 = 0.3; const DOCKED_CAMERA_PITCH: f32 = 0.3;
/// Represents a docking target (either a station or a habitable planet).
#[derive(Debug, Clone)]
struct DockingTargetInfo {
name: String,
orbit: f32,
phase: f32,
population: u32,
is_station: bool,
}
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>>,
@@ -58,6 +68,7 @@ pub fn setup_in_system_view(
campaign: Res<CampaignDraft>, campaign: Res<CampaignDraft>,
mut docked_state: ResMut<DockedState>, mut docked_state: ResMut<DockedState>,
mut active_system: ResMut<ActiveSystem>, mut active_system: ResMut<ActiveSystem>,
mut camera_state: ResMut<crate::camera::CameraState>,
) { ) {
// 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 {
@@ -89,11 +100,11 @@ pub fn setup_in_system_view(
system.id system.id
); );
// Select docking station (highest population, or first if none) // Select docking target (station or habitable planet with highest population)
let docking_station = select_docking_station(system_contents); let docking_target = select_docking_target(system_contents);
let Some(docking_station) = docking_station else { let Some(docking_target) = docking_target else {
bevy::log::warn!("No stations found in system {}, spawning without docking", system.id); bevy::log::warn!("No dockable targets found in system {}, spawning without docking", system.id);
// Spawn scene without docking // Spawn scene without docking
spawn_system_scene( spawn_system_scene(
&mut commands, &mut commands,
@@ -107,9 +118,9 @@ pub fn setup_in_system_view(
}; };
bevy::log::info!( bevy::log::info!(
"Selected docking station: {} (population: {})", "Selected docking target: {} (population: {})",
docking_station.name, docking_target.name,
docking_station.population docking_target.population
); );
// Spawn the full scene with docking // Spawn the full scene with docking
@@ -119,7 +130,7 @@ pub fn setup_in_system_view(
&mut materials, &mut materials,
system, system,
system_contents, system_contents,
Some(docking_station), Some(&docking_target),
); );
// Update resources // Update resources
@@ -133,15 +144,44 @@ pub fn setup_in_system_view(
// Position camera for cinematic docked view // Position camera for cinematic docked view
setup_docked_camera(&mut commands); setup_docked_camera(&mut commands);
// Set camera mode to cinematic
camera_state.mode = crate::camera::CameraMode::Cinematic;
camera_state.target_entity = Some(station_entity);
} }
/// Select the station with highest population for docking. /// Select the docking target with highest population (station or habitable planet).
fn select_docking_station(contents: &SystemContents) -> Option<&GeneratedStation> { fn select_docking_target(contents: &SystemContents) -> Option<DockingTargetInfo> {
contents // First try stations (highest population)
let best_station = contents
.stations .stations
.iter() .iter()
.max_by_key(|s| s.population) .max_by_key(|s| s.population);
.or_else(|| contents.stations.first())
if let Some(station) = best_station {
return Some(DockingTargetInfo {
name: station.name.clone(),
orbit: station.orbit,
phase: station.phase,
population: station.population,
is_station: true,
});
}
// Fall back to habitable planets
let best_planet = contents
.planets
.iter()
.filter(|p| p.habitable)
.max_by_key(|p| p.population);
best_planet.map(|planet| DockingTargetInfo {
name: planet.name.clone(),
orbit: planet.orbit,
phase: planet.phase,
population: planet.population,
is_station: false,
})
} }
/// Calculate orbital position from orbit radius and phase. /// Calculate orbital position from orbit radius and phase.
@@ -151,14 +191,14 @@ fn orbital_position(orbit: f32, phase: f32) -> Vec3 {
} }
/// Spawn the system scene including star, POIs, and player ship. /// Spawn the system scene including star, POIs, and player ship.
/// Returns (star_entity, docking_station_entity). /// Returns (star_entity, docking_target_entity).
fn spawn_system_scene( fn spawn_system_scene(
commands: &mut Commands, commands: &mut Commands,
meshes: &mut Assets<Mesh>, meshes: &mut Assets<Mesh>,
materials: &mut Assets<StandardMaterial>, materials: &mut Assets<StandardMaterial>,
system: &crate::gameplay::campaign::GeneratedGalaxySystem, system: &crate::gameplay::campaign::GeneratedGalaxySystem,
contents: &SystemContents, contents: &SystemContents,
docking_station: Option<&GeneratedStation>, docking_target: Option<&DockingTargetInfo>,
) -> (Entity, Entity) { ) -> (Entity, Entity) {
// Create content assets for spawning // Create content assets for spawning
let content_assets = contents::ContentAssets::new(meshes, materials); let content_assets = contents::ContentAssets::new(meshes, materials);
@@ -211,18 +251,18 @@ fn spawn_system_scene(
); );
}); });
// If we have a docking station, spawn player ship docked at it // If we have a docking target, spawn player ship docked at it
let station_entity = if let Some(station) = docking_station { let station_entity = if let Some(target) = docking_target {
// Calculate station position // Calculate target position
let station_position = orbital_position(station.orbit, station.phase); let target_position = orbital_position(target.orbit, target.phase);
// Spawn player ship at docked offset // Spawn player ship at docked offset
spawn_player_ship_docked( spawn_player_ship_docked(
commands, commands,
meshes, meshes,
materials, materials,
&station.name, &target.name,
station_position, target_position,
system_root, system_root,
) )
} else { } else {

View File

@@ -0,0 +1,119 @@
//! Target selection system for tactical navigation.
//!
//! Handles selecting and tracking targets (stations, asteroid belts, hostiles)
//! for point-and-click navigation.
use bevy::prelude::*;
use bevy::window::PrimaryWindow;
use super::scene::PlayerShip;
/// The kind of target being selected.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TargetKind {
Station,
AsteroidBelt,
Hostile,
Manual,
HabitablePlanet,
}
/// Selected target component. Can be attached to the player ship
/// to track what target the player has selected.
#[derive(Component, Debug, Clone)]
pub struct SelectedTarget {
pub entity: Entity,
pub kind: TargetKind,
pub name: String,
}
/// Targetable component. Entities that can be selected as navigation targets
/// should have this component.
#[derive(Component, Debug, Clone)]
pub struct Targetable {
pub kind: TargetKind,
pub name: String,
}
/// Event fired when a target is selected.
#[derive(Event, Debug)]
pub struct TargetSelectedEvent {
pub entity: Entity,
pub kind: TargetKind,
pub name: String,
}
/// Plugin for target selection system.
pub struct TargetSelectionPlugin;
impl Plugin for TargetSelectionPlugin {
fn build(&self, app: &mut App) {
app.add_event::<TargetSelectedEvent>()
.add_systems(
Update,
handle_target_selection.run_if(in_state(crate::state::AppState::InGame)),
);
}
}
/// Handle target selection via click events.
fn handle_target_selection(
mouse_input: Res<ButtonInput<MouseButton>>,
primary_window: Query<&Window, With<PrimaryWindow>>,
camera_query: Query<(&Camera, &GlobalTransform), With<crate::camera::MainCamera>>,
targetable_query: Query<(Entity, &Targetable, &GlobalTransform)>,
mut events: EventWriter<TargetSelectedEvent>,
mut player_query: Query<&mut SelectedTarget, With<PlayerShip>>,
) {
// Only handle left clicks
if !mouse_input.just_pressed(MouseButton::Left) {
return;
}
let Ok(window) = primary_window.single() else {
return;
};
let Some(cursor_pos) = window.cursor_position() else {
return;
};
let Ok((camera, camera_gt)) = camera_query.single() else {
return;
};
// Check for clicks on targetable entities using raycasting
let mut closest_target: Option<(Entity, TargetKind, String, f32)> = None;
let mut closest_distance = f32::MAX;
for (entity, targetable, global_transform) in targetable_query.iter() {
let target_pos = global_transform.translation();
let distance = camera_gt.translation().distance(target_pos);
// Simple distance-based selection for now
// In a full implementation, you'd use proper raycasting with bounding volumes
if distance < closest_distance && distance < 100.0 {
// Within 100 units
closest_distance = distance;
closest_target = Some((entity, targetable.kind, targetable.name.clone(), distance));
}
}
// If we found a target, select it
if let Some((entity, kind, name, _distance)) = closest_target {
bevy::log::info!("Selected target: {} ({:?})", name, kind);
// Update player's selected target
if let Ok(mut selected) = player_query.single_mut() {
selected.entity = entity;
selected.kind = kind;
selected.name = name.clone();
}
// Fire event
events.write(TargetSelectedEvent {
entity,
kind,
name,
});
}
}

View File

@@ -184,23 +184,19 @@ pub fn refresh_docked_ui(
pub fn undock_button_handler( pub fn undock_button_handler(
mut commands: Commands, mut commands: Commands,
mut events: EventWriter<UndockEvent>, mut events: EventWriter<UndockEvent>,
mut docked_state: ResMut<DockedState>, docked_state: Res<DockedState>,
query: Query<(&Interaction, &UndockButton), Changed<Interaction>>, query: Query<(&Interaction, &UndockButton), Changed<Interaction>>,
) { ) {
for (interaction, _) in &query { for (interaction, _) in &query {
if *interaction == Interaction::Pressed { if *interaction == Interaction::Pressed {
bevy::log::info!("Undock button pressed"); bevy::log::info!("Undock button pressed");
// Fire the undock event // Fire the undock event (handled by handle_undock system)
if let Some(station_entity) = docked_state.station_entity { if let Some(station_entity) = docked_state.station_entity {
events.send(UndockEvent { station_entity }); events.send(UndockEvent { station_entity });
} else {
bevy::log::warn!("No station entity to undock from");
} }
// Update docked state
docked_state.undock();
// For now, just log - actual undocking gameplay will come later
bevy::log::info!("Player undocked (placeholder - full undocking TBD)");
} }
} }
} }

View File

@@ -4,11 +4,20 @@ use bevy::window::PrimaryWindow;
use super::components::{MoveTarget, Player}; use super::components::{MoveTarget, Player};
use crate::camera::MainCamera; use crate::camera::MainCamera;
use crate::gameplay::in_system::DockedState;
/// Y coordinate of the ground plane. Cursor rays are projected onto this plane. /// Y coordinate of the ground plane. Cursor rays are projected onto this plane.
const GROUND_PLANE_Y: f32 = 0.0; const GROUND_PLANE_Y: f32 = 0.0;
/// Helper function to check if the player is in flight mode (not docked).
/// This can be used as a run condition for systems that should only run during flight.
pub fn when_in_flight(docked: Res<DockedState>) -> bool {
!docked.is_docked
}
/// On left-click, project the cursor onto the ground plane and update the player's MoveTarget. /// On left-click, project the cursor onto the ground plane and update the player's MoveTarget.
/// This works in both docked and flight modes - the ground plane targeting is useful for
/// setting movement targets regardless of camera mode.
pub fn click_to_move( pub fn click_to_move(
mouse_input: Res<ButtonInput<MouseButton>>, mouse_input: Res<ButtonInput<MouseButton>>,
primary_window: Query<&Window, With<PrimaryWindow>>, primary_window: Query<&Window, With<PrimaryWindow>>,

View File

@@ -5,7 +5,7 @@ mod ui;
use bevy::prelude::*; use bevy::prelude::*;
use camera::orbit_camera_control; use camera::{orbit_camera_control, follow_camera_system, CameraState};
use gameplay::campaign::CampaignDraft; use gameplay::campaign::CampaignDraft;
use gameplay::{ use gameplay::{
character_creation::CharacterCreationPlugin, galaxy::GalaxyPlugin, in_system::InSystemPlugin, character_creation::CharacterCreationPlugin, galaxy::GalaxyPlugin, in_system::InSystemPlugin,
@@ -14,6 +14,7 @@ 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()
@@ -26,14 +27,17 @@ fn main() {
}) })
.init_state::<AppState>() .init_state::<AppState>()
.init_resource::<CampaignDraft>() .init_resource::<CampaignDraft>()
.init_resource::<CameraState>()
.add_systems(Startup, camera::spawn_camera) .add_systems(Startup, camera::spawn_camera)
// Orbit controls only in inspection-style scenes. In-game will use a // Follow camera for in-flight gameplay
// follow camera instead (not yet implemented).
.add_systems( .add_systems(
Update, Update,
orbit_camera_control.run_if( follow_camera_system.run_if(in_follow_mode),
in_state(AppState::Galaxy).or(in_state(AppState::InGame)) )
), // Orbit camera for inspection scenes (Galaxy view)
.add_systems(
Update,
orbit_camera_control.run_if(in_orbit_mode),
) )
.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)
@@ -49,3 +53,19 @@ fn main() {
)) ))
.run(); .run();
} }
/// Run condition: true when camera is in follow mode.
fn in_follow_mode(
state: Res<State<AppState>>,
camera_state: Res<CameraState>,
) -> bool {
*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
}