feat(game): Stellaris-style single-ship control with real flight physics
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).
This commit is contained in:
@@ -4,7 +4,7 @@
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
use super::{DockedState, FlightState, SelectedTarget};
|
||||
use super::{DockedState, SelectedTarget};
|
||||
use super::scene::PlayerShip;
|
||||
use super::target::TargetKind;
|
||||
|
||||
@@ -51,6 +51,7 @@ pub struct ContextualActionPlugin;
|
||||
impl Plugin for ContextualActionPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.add_event::<ActionTriggeredEvent>()
|
||||
.init_resource::<ActionPanelCache>()
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
@@ -73,6 +74,26 @@ impl Plugin for ContextualActionPlugin {
|
||||
#[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 {
|
||||
@@ -80,7 +101,11 @@ pub struct ActionButton {
|
||||
}
|
||||
|
||||
/// Setup action UI panel.
|
||||
fn setup_action_ui(mut commands: Commands) {
|
||||
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 {
|
||||
@@ -114,44 +139,62 @@ fn setup_action_ui(mut commands: Commands) {
|
||||
});
|
||||
}
|
||||
|
||||
/// Update contextual actions based on current state.
|
||||
/// 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<(Entity, Option<&SelectedTarget>, Option<&FlightState>), With<PlayerShip>>,
|
||||
player_query: Query<Option<&SelectedTarget>, With<PlayerShip>>,
|
||||
action_ui_query: Query<(Entity, &Children), With<ActionUi>>,
|
||||
mut commands: Commands,
|
||||
mut cache: ResMut<ActionPanelCache>,
|
||||
) {
|
||||
let Ok((player_entity, selected_target, flight_state)) = player_query.single() else {
|
||||
let Ok(selected_target) = player_query.single() else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Determine available actions based on state
|
||||
let actions = get_available_actions(&docked_state, selected_target, flight_state);
|
||||
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);
|
||||
|
||||
// Update UI
|
||||
let Ok((ui_entity, children)) = action_ui_query.single() else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Clear existing action buttons (keep the title)
|
||||
// Clear existing action buttons (keep the title).
|
||||
for child in children.iter().skip(1) {
|
||||
commands.entity(child).despawn();
|
||||
}
|
||||
|
||||
// Spawn new action buttons
|
||||
// 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>,
|
||||
flight_state: Option<&FlightState>,
|
||||
) -> Vec<ContextualAction> {
|
||||
let mut actions = Vec::new();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user