The player ship was never wired to its own movement systems — it lacked MaxSpeed/TurnRate (so steer_to_target skipped it) and the Player marker (so click-to-move skipped it). Only integrate_velocity matched, leaving the ship drifting on a hardcoded undock vector and ignoring all input. Left-click was also bound to camera drag, selection, and move at once, and "Approach" started a cosmetic timer that moved nothing. New control scheme (single ship, like Stellaris minus fleet selection): - Right-click issues a move order (was left-click, which conflicted with selection + camera drag). - Left-click cursor-selects the target under the cursor (was nearest-to-camera) and deselects on empty space. - "Approach" now actually flies to the selected target, homing onto it via ApproachTarget even as it orbits. - Smooth acceleration + stopping-distance arrival deceleration + turn-rate-limited banking replace instant velocity snapping. - A pulsing destination waypoint ring gives on-world move-order feedback. - Undock holds position (zero velocity + at-ship MoveTarget) instead of drifting off on a stale vector. - Follow-cam now allows free-look rotate/zoom so the player can find and click targets while the camera tracks the ship. Steering is shared: the player ship and AI ships use one physics model. AI spawn bundle gets the new required components (Acceleration, ArrivalRadius). Also fixes a B0001 panic in the destination-marker system: the marker query now carries Without<PlayerShip> so it is provably disjoint from the player Transform read. Includes the in-system action-panel rebuild-on-change optimization (buttons and their Interaction state persist between frames so clicks register), which the Approach/Dock flow depends on. Flight tuning lives in in_system/scene.rs (PLAYER_MAX_SPEED, PLAYER_ACCELERATION, PLAYER_TURN_RATE, PLAYER_ARRIVAL_RADIUS).
376 lines
12 KiB
Rust
376 lines
12 KiB
Rust
//! 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::<ActionTriggeredEvent>()
|
|
.init_resource::<ActionPanelCache>()
|
|
.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<ActionSignature>,
|
|
}
|
|
|
|
/// 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<ActionPanelCache>) {
|
|
// 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<DockedState>,
|
|
player_query: Query<Option<&SelectedTarget>, With<PlayerShip>>,
|
|
action_ui_query: Query<(Entity, &Children), With<ActionUi>>,
|
|
mut commands: Commands,
|
|
mut cache: ResMut<ActionPanelCache>,
|
|
) {
|
|
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<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();
|
|
}
|
|
}
|