✨ 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:
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -115,6 +115,7 @@ pub struct GeneratedPlanet {
|
||||
pub radius: f32,
|
||||
pub mass: f32,
|
||||
pub population: u32,
|
||||
pub habitable: bool,
|
||||
}
|
||||
|
||||
/// 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,
|
||||
mass,
|
||||
population,
|
||||
habitable,
|
||||
});
|
||||
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 {
|
||||
0
|
||||
};
|
||||
// Distribute stations across the outer ring with unique orbits
|
||||
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 population = if ctx.is_core {
|
||||
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 {
|
||||
name,
|
||||
orbit: orbit_cursor,
|
||||
orbit,
|
||||
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,
|
||||
});
|
||||
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) ──────────────────
|
||||
@@ -776,6 +783,9 @@ pub fn spawn_system_contents(
|
||||
population: planet.population,
|
||||
});
|
||||
}
|
||||
if planet.habitable {
|
||||
entity.insert(HabitablePlanet);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Asteroid belts ──────────────────────────────────────────────────────
|
||||
@@ -1184,6 +1194,7 @@ mod tests {
|
||||
radius: 0.1,
|
||||
mass: 1.0,
|
||||
population: 0,
|
||||
habitable: false,
|
||||
});
|
||||
c.stations.push(GeneratedStation {
|
||||
name: "S1".into(),
|
||||
|
||||
@@ -23,7 +23,7 @@ use crate::camera::apply_orbit_reset;
|
||||
use crate::gameplay::campaign::{GeneratedGalaxy, GeneratedGalaxySystem};
|
||||
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::{GalaxyParams, SelectedStar};
|
||||
use params::{MIN_SYSTEM_SPACING, NEAREST_NEIGHBOR_CONNECTIONS, SPACING_ATTEMPTS};
|
||||
|
||||
@@ -308,6 +308,12 @@ pub struct Luminosity {
|
||||
#[reflect(Component)]
|
||||
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.
|
||||
#[derive(Component, Debug, Clone, Reflect, Default)]
|
||||
#[reflect(Component)]
|
||||
@@ -359,7 +365,7 @@ pub struct EventTimer {
|
||||
|
||||
#[derive(Component, Debug, Clone, Copy, Reflect)]
|
||||
#[reflect(Component)]
|
||||
#[require(Orbital, Massive, BoundingVolume, WarpTarget, MassLock, Identifiable)]
|
||||
#[require(Orbital, Massive, BoundingVolume, WarpTarget, MassLock, Dockable, Identifiable)]
|
||||
pub struct Planet;
|
||||
|
||||
#[derive(Component, Debug, Clone, Copy, Reflect)]
|
||||
|
||||
332
apps/game/src/gameplay/in_system/actions.rs
Normal file
332
apps/game/src/gameplay/in_system/actions.rs
Normal 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();
|
||||
}
|
||||
}
|
||||
174
apps/game/src/gameplay/in_system/flight.rs
Normal file
174
apps/game/src/gameplay/in_system/flight.rs
Normal 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();
|
||||
}
|
||||
170
apps/game/src/gameplay/in_system/flight_ui.rs
Normal file
170
apps/game/src/gameplay/in_system/flight_ui.rs
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,13 @@
|
||||
//! within their selected starting system. This is the entry point to actual
|
||||
//! gameplay after onboarding.
|
||||
|
||||
mod actions;
|
||||
mod docked;
|
||||
mod flight;
|
||||
mod flight_ui;
|
||||
mod operations;
|
||||
mod scene;
|
||||
mod target;
|
||||
mod ui;
|
||||
|
||||
use bevy::prelude::*;
|
||||
@@ -13,7 +18,11 @@ use bevy::prelude::*;
|
||||
use crate::state::AppState;
|
||||
|
||||
pub use docked::{DockedState, UndockEvent};
|
||||
pub use flight::{FlightState, FlightControlsPlugin};
|
||||
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;
|
||||
|
||||
@@ -22,14 +31,23 @@ impl Plugin for InSystemPlugin {
|
||||
app.init_resource::<DockedState>()
|
||||
.init_resource::<ActiveSystem>()
|
||||
.add_event::<UndockEvent>()
|
||||
.add_plugins(FlightControlsPlugin)
|
||||
.add_plugins(TargetSelectionPlugin)
|
||||
.add_plugins(ContextualActionPlugin)
|
||||
.add_plugins(TimedOperationPlugin)
|
||||
.add_systems(
|
||||
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(
|
||||
OnExit(AppState::InGame),
|
||||
(
|
||||
ui::despawn_docked_ui,
|
||||
flight_ui::despawn_flight_ui,
|
||||
scene::despawn_in_system_scene,
|
||||
).chain(),
|
||||
)
|
||||
@@ -38,9 +56,78 @@ impl Plugin for InSystemPlugin {
|
||||
(
|
||||
ui::refresh_docked_ui,
|
||||
ui::undock_button_handler,
|
||||
flight_ui::update_flight_ui,
|
||||
handle_action_triggered,
|
||||
)
|
||||
.chain()
|
||||
.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(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
176
apps/game/src/gameplay/in_system/operations.rs
Normal file
176
apps/game/src/gameplay/in_system/operations.rs
Normal 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;
|
||||
@@ -9,7 +9,7 @@ use crate::gameplay::campaign::CampaignDraft;
|
||||
use crate::gameplay::character_creation::STARTING_SHIPS;
|
||||
use crate::gameplay::galaxy::{
|
||||
contents, Massive, Luminosity, MassLock, BoundingVolume, Identifiable,
|
||||
SystemContents, GeneratedStation,
|
||||
SystemContents,
|
||||
};
|
||||
use crate::gameplay::in_system::{DockedState};
|
||||
use crate::state::AppState;
|
||||
@@ -51,6 +51,16 @@ 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).
|
||||
#[derive(Debug, Clone)]
|
||||
struct DockingTargetInfo {
|
||||
name: String,
|
||||
orbit: f32,
|
||||
phase: f32,
|
||||
population: u32,
|
||||
is_station: bool,
|
||||
}
|
||||
|
||||
pub fn setup_in_system_view(
|
||||
mut commands: Commands,
|
||||
mut meshes: ResMut<Assets<Mesh>>,
|
||||
@@ -58,6 +68,7 @@ pub fn setup_in_system_view(
|
||||
campaign: Res<CampaignDraft>,
|
||||
mut docked_state: ResMut<DockedState>,
|
||||
mut active_system: ResMut<ActiveSystem>,
|
||||
mut camera_state: ResMut<crate::camera::CameraState>,
|
||||
) {
|
||||
// Get the selected starting base
|
||||
let Some(starting_base) = &campaign.starting_base else {
|
||||
@@ -89,11 +100,11 @@ pub fn setup_in_system_view(
|
||||
system.id
|
||||
);
|
||||
|
||||
// Select docking station (highest population, or first if none)
|
||||
let docking_station = select_docking_station(system_contents);
|
||||
// Select docking target (station or habitable planet with highest population)
|
||||
let docking_target = select_docking_target(system_contents);
|
||||
|
||||
let Some(docking_station) = docking_station else {
|
||||
bevy::log::warn!("No stations found in system {}, spawning without docking", system.id);
|
||||
let Some(docking_target) = docking_target else {
|
||||
bevy::log::warn!("No dockable targets found in system {}, spawning without docking", system.id);
|
||||
// Spawn scene without docking
|
||||
spawn_system_scene(
|
||||
&mut commands,
|
||||
@@ -107,9 +118,9 @@ pub fn setup_in_system_view(
|
||||
};
|
||||
|
||||
bevy::log::info!(
|
||||
"Selected docking station: {} (population: {})",
|
||||
docking_station.name,
|
||||
docking_station.population
|
||||
"Selected docking target: {} (population: {})",
|
||||
docking_target.name,
|
||||
docking_target.population
|
||||
);
|
||||
|
||||
// Spawn the full scene with docking
|
||||
@@ -119,7 +130,7 @@ pub fn setup_in_system_view(
|
||||
&mut materials,
|
||||
system,
|
||||
system_contents,
|
||||
Some(docking_station),
|
||||
Some(&docking_target),
|
||||
);
|
||||
|
||||
// Update resources
|
||||
@@ -133,15 +144,44 @@ pub fn setup_in_system_view(
|
||||
|
||||
// Position camera for cinematic docked view
|
||||
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.
|
||||
fn select_docking_station(contents: &SystemContents) -> Option<&GeneratedStation> {
|
||||
contents
|
||||
/// Select the docking target with highest population (station or habitable planet).
|
||||
fn select_docking_target(contents: &SystemContents) -> Option<DockingTargetInfo> {
|
||||
// First try stations (highest population)
|
||||
let best_station = contents
|
||||
.stations
|
||||
.iter()
|
||||
.max_by_key(|s| s.population)
|
||||
.or_else(|| contents.stations.first())
|
||||
.max_by_key(|s| s.population);
|
||||
|
||||
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.
|
||||
@@ -151,14 +191,14 @@ fn orbital_position(orbit: f32, phase: f32) -> Vec3 {
|
||||
}
|
||||
|
||||
/// 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(
|
||||
commands: &mut Commands,
|
||||
meshes: &mut Assets<Mesh>,
|
||||
materials: &mut Assets<StandardMaterial>,
|
||||
system: &crate::gameplay::campaign::GeneratedGalaxySystem,
|
||||
contents: &SystemContents,
|
||||
docking_station: Option<&GeneratedStation>,
|
||||
docking_target: Option<&DockingTargetInfo>,
|
||||
) -> (Entity, Entity) {
|
||||
// Create content assets for spawning
|
||||
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
|
||||
let station_entity = if let Some(station) = docking_station {
|
||||
// Calculate station position
|
||||
let station_position = orbital_position(station.orbit, station.phase);
|
||||
// If we have a docking target, spawn player ship docked at it
|
||||
let station_entity = if let Some(target) = docking_target {
|
||||
// Calculate target position
|
||||
let target_position = orbital_position(target.orbit, target.phase);
|
||||
|
||||
// Spawn player ship at docked offset
|
||||
spawn_player_ship_docked(
|
||||
commands,
|
||||
meshes,
|
||||
materials,
|
||||
&station.name,
|
||||
station_position,
|
||||
&target.name,
|
||||
target_position,
|
||||
system_root,
|
||||
)
|
||||
} else {
|
||||
|
||||
119
apps/game/src/gameplay/in_system/target.rs
Normal file
119
apps/game/src/gameplay/in_system/target.rs
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -184,23 +184,19 @@ pub fn refresh_docked_ui(
|
||||
pub fn undock_button_handler(
|
||||
mut commands: Commands,
|
||||
mut events: EventWriter<UndockEvent>,
|
||||
mut docked_state: ResMut<DockedState>,
|
||||
docked_state: Res<DockedState>,
|
||||
query: Query<(&Interaction, &UndockButton), Changed<Interaction>>,
|
||||
) {
|
||||
for (interaction, _) in &query {
|
||||
if *interaction == Interaction::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 {
|
||||
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)");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,11 +4,20 @@ use bevy::window::PrimaryWindow;
|
||||
|
||||
use super::components::{MoveTarget, Player};
|
||||
use crate::camera::MainCamera;
|
||||
use crate::gameplay::in_system::DockedState;
|
||||
|
||||
/// Y coordinate of the ground plane. Cursor rays are projected onto this plane.
|
||||
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.
|
||||
/// 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(
|
||||
mouse_input: Res<ButtonInput<MouseButton>>,
|
||||
primary_window: Query<&Window, With<PrimaryWindow>>,
|
||||
|
||||
@@ -5,7 +5,7 @@ mod ui;
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
use camera::orbit_camera_control;
|
||||
use camera::{orbit_camera_control, follow_camera_system, CameraState};
|
||||
use gameplay::campaign::CampaignDraft;
|
||||
use gameplay::{
|
||||
character_creation::CharacterCreationPlugin, galaxy::GalaxyPlugin, in_system::InSystemPlugin,
|
||||
@@ -14,6 +14,7 @@ use gameplay::{
|
||||
};
|
||||
use state::AppState;
|
||||
use ui::main_menu;
|
||||
use bevy::prelude::State;
|
||||
|
||||
fn main() {
|
||||
App::new()
|
||||
@@ -26,14 +27,17 @@ fn main() {
|
||||
})
|
||||
.init_state::<AppState>()
|
||||
.init_resource::<CampaignDraft>()
|
||||
.init_resource::<CameraState>()
|
||||
.add_systems(Startup, camera::spawn_camera)
|
||||
// Orbit controls only in inspection-style scenes. In-game will use a
|
||||
// follow camera instead (not yet implemented).
|
||||
// Follow camera for in-flight gameplay
|
||||
.add_systems(
|
||||
Update,
|
||||
orbit_camera_control.run_if(
|
||||
in_state(AppState::Galaxy).or(in_state(AppState::InGame))
|
||||
),
|
||||
follow_camera_system.run_if(in_follow_mode),
|
||||
)
|
||||
// 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(OnExit(AppState::MainMenu), main_menu::despawn_main_menu)
|
||||
@@ -49,3 +53,19 @@ fn main() {
|
||||
))
|
||||
.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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user