✨ 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:
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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user