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

@@ -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();
}
}