//! 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, 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::() .init_resource::() .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; /// Cached description of the action set currently shown on the panel. /// /// [`update_contextual_actions`] only rebuilds the buttons when this changes, /// so the button entities (and their [`Interaction`] state) persist between /// frames and clicks actually register. Rebuilding every frame would despawn /// the pressed button before [`handle_action_buttons`] can read it — which is /// why the undock button previously did nothing. #[derive(Resource, Default)] struct ActionPanelCache { signature: Option, } /// The inputs that determine which buttons to show. #[derive(PartialEq)] struct ActionSignature { is_docked: bool, /// `(kind, name)` of the selected target, if any. target: Option<(TargetKind, String)>, } /// 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, mut cache: ResMut) { // Reset the cached signature so the buttons are rebuilt to match this // fresh panel on the next update. cache.signature = None; 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 the contextual action buttons to match the player's current state. /// /// The buttons are rebuilt **only when the action context changes** (docked ↔ /// flying, or the selected target changes). Rebuilding every frame would /// recreate each button entity and reset its [`Interaction`] before /// [`handle_action_buttons`] can observe a click — which is why the undock /// button previously did nothing. By keeping the buttons stable between /// changes, button presses register correctly. fn update_contextual_actions( docked_state: Res, player_query: Query, With>, action_ui_query: Query<(Entity, &Children), With>, mut commands: Commands, mut cache: ResMut, ) { let Ok(selected_target) = player_query.single() else { return; }; let signature = ActionSignature { is_docked: docked_state.is_docked, target: selected_target.map(|t| (t.kind, t.name.clone())), }; // Nothing to do — the on-screen buttons already match the current context, // so leave them (and their Interaction state) untouched. if cache.signature.as_ref() == Some(&signature) { return; } let actions = get_available_actions(&docked_state, selected_target); 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 the 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); } }); cache.signature = Some(signature); } /// Get available actions based on current state. fn get_available_actions( docked_state: &DockedState, selected_target: Option<&SelectedTarget>, ) -> Vec { 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>, mut events: EventWriter, ) { 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>) { for entity in query.iter() { commands.entity(entity).despawn(); } }