feat(game): Stellaris-style single-ship control with real flight physics
Some checks failed
CI / TypeScript Check (docs) (push) Has been cancelled
CI / TypeScript Check (site) (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Audit (push) Has been cancelled

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:
2026-06-18 17:57:03 -04:00
parent 2330044ec3
commit 828ebf089a
12 changed files with 513 additions and 129 deletions

View File

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

View File

@@ -3,16 +3,21 @@
//! Handles the transition from docked to active flight mode, including
//! ship controls, camera transitions, and flight state management.
//!
//! Navigation is point-and-click tactical mode: select targets and click
//! "Approach" to auto-navigate. No WASD controls.
//! Navigation is Stellaris-style point-and-order: right-click (or the
//! "Approach" action on a selected target) issues a move order, and the
//! movement systems fly the ship there with smooth acceleration and arrival.
//! No WASD / direct throttle control — the player sets intent, the ship flies.
use std::f32::consts::FRAC_PI_2;
use bevy::prelude::*;
use crate::camera::{CameraState, CameraMode, OrbitFocusGoal};
use crate::gameplay::movement::components::{Velocity, MoveTarget};
use crate::gameplay::movement::components::{Velocity, MoveTarget, ApproachTarget};
use crate::gameplay::movement::input::MoveOrderEvent;
use crate::gameplay::galaxy::Identifiable;
use super::{DockedState, UndockEvent};
use super::scene::{Docked, PlayerShip};
use super::scene::{Docked, InSystemSpawned, PlayerShip};
/// Flight state component attached to the player ship when actively flying.
#[derive(Component, Debug, Clone, Default)]
@@ -40,6 +45,7 @@ impl Plugin for FlightControlsPlugin {
handle_undock,
handle_docking,
flight_input_system,
sync_destination_marker,
).run_if(in_state(crate::state::AppState::InGame)),
);
}
@@ -52,29 +58,32 @@ fn handle_undock(
mut docked_state: ResMut<DockedState>,
mut camera_state: ResMut<CameraState>,
mut focus_goal: ResMut<OrbitFocusGoal>,
player_query: Query<Entity, (With<PlayerShip>, With<Docked>)>,
player_query: Query<(Entity, &Transform), (With<PlayerShip>, With<Docked>)>,
) {
for event in events.read() {
bevy::log::info!("Handling undock from station {:?}", event.station_entity);
// Find player ship
let Ok(player_entity) = player_query.single() else {
let Ok((player_entity, ship_transform)) = player_query.single() else {
bevy::log::warn!("No docked player ship found");
continue;
};
// Remove docked component
// Remove docked component.
commands.entity(player_entity).remove::<Docked>();
// Add flight state with initial velocity away from station
let initial_speed = 10.0; // Start with slow drift
// Enter flight holding position: zero velocity and a MoveTarget at the
// ship's current location. The steering system sees a zero-distance
// target and holds still, so the ship waits for the player's first
// order instead of drifting off on a stale vector.
let ship_pos = ship_transform.translation;
commands.entity(player_entity).insert((
FlightState {
is_flying: true,
current_speed: initial_speed,
current_speed: 0.0,
},
Velocity(Vec3::new(0.0, 0.0, -1.0) * initial_speed),
MoveTarget(Vec3::new(0.0, 0.0, -50.0)), // Initial target away from station
Velocity(Vec3::ZERO),
MoveTarget(ship_pos),
super::SelectedTarget {
entity: Entity::PLACEHOLDER,
kind: super::TargetKind::Manual,
@@ -130,7 +139,7 @@ fn handle_docking(
});
commands
.entity(event.ship)
.remove::<(FlightState, Velocity, MoveTarget, super::SelectedTarget)>();
.remove::<(FlightState, Velocity, MoveTarget, ApproachTarget, super::SelectedTarget)>();
// Update docked state resource
docked_state.dock_at(event.station);
@@ -152,18 +161,15 @@ fn handle_docking(
}
}
/// Flight input system: point-and-click navigation only.
///
/// This system handles updating the flight state based on movement.
/// Actual navigation is handled by click-to-move (setting MoveTarget)
/// and the kinematic systems (steer_to_target, integrate_velocity).
///
/// This is a minimal system - the real action happens in the movement systems.
/// Flight input system: mirrors the ship's live speed into [`FlightState`] so
/// the HUD speed readout reflects the actual acceleration / arrival profile.
/// The real navigation happens in the movement systems (right-click →
/// [`MoveTarget`], `steer_to_target`, `integrate_velocity`).
fn flight_input_system(
mut player_query: Query<(&Velocity, &mut FlightState), With<PlayerShip>>,
docked_state: Res<DockedState>,
) {
// Only process flight input when not docked
// Nothing to mirror while docked.
if docked_state.is_docked {
return;
}
@@ -172,6 +178,118 @@ fn flight_input_system(
return;
};
// Update current speed from actual velocity
// Update current speed from actual velocity.
flight_state.current_speed = velocity.0.length();
}
// ── Destination waypoint marker ─────────────────────────────────────────
// A ground-plane ring shown at the player's [`MoveTarget`] while flying, so
// every move order has clear on-world feedback (Stellaris shows a move order
// pip the same way). It pulses continuously and “punches” outward on each new
// order via [`MoveOrderEvent`]. Hidden on arrival / dock / no target.
/// Y of the orbital plane the marker sits on.
const MARKER_PLANE_Y: f32 = 0.0;
/// Ship-to-target distance at which the marker hides (ship has arrived).
const MARKER_HIDE_RADIUS: f32 = 2.0;
/// Continuous pulse frequency (radians/sec).
const MARKER_PULSE_FREQ: f32 = 4.0;
/// How fast the per-order punch decays (units/sec).
const MARKER_PUNCH_DECAY: f32 = 2.5;
/// The on-world destination waypoint. `punch` is a 0..1 value that spikes to 1
/// on each new move order and decays, driving an extra scale kick.
#[derive(Component)]
struct DestinationMarker {
punch: f32,
}
/// Spawn the (initially hidden) waypoint ring. Tagged [`InSystemSpawned`] so
/// the scene teardown cleans it up on exit.
fn spawn_destination_marker(
commands: &mut Commands,
meshes: &mut Assets<Mesh>,
materials: &mut Assets<StandardMaterial>,
) -> Entity {
commands
.spawn((
DestinationMarker { punch: 0.0 },
InSystemSpawned,
Mesh3d(meshes.add(Torus::new(0.7, 0.9).mesh())),
MeshMaterial3d(materials.add(StandardMaterial {
base_color: Color::srgb(0.30, 0.72, 0.45),
emissive: LinearRgba::new(0.10, 0.34, 0.22, 1.0),
unlit: true,
..default()
})),
// Lay the torus flat on the orbital plane.
Transform::from_rotation(Quat::from_rotation_x(FRAC_PI_2)),
Visibility::Hidden,
InheritedVisibility::default(),
))
.id()
}
/// Keep the waypoint ring synced to the player's [`MoveTarget`] while flying.
///
/// - Visible + positioned at the target while the ship is flying and has not
/// yet arrived.
/// - Pulses continuously and kicks outward on each [`MoveOrderEvent`].
/// - Hidden once the ship is within [`MARKER_HIDE_RADIUS`], or when docked / no
/// active move target.
fn sync_destination_marker(
mut commands: Commands,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<StandardMaterial>>,
time: Res<Time>,
mut orders: EventReader<MoveOrderEvent>,
player: Query<(&Transform, &MoveTarget), (With<PlayerShip>, With<FlightState>)>,
mut marker: Query<
(&mut Transform, &mut Visibility, &mut DestinationMarker),
Without<PlayerShip>,
>,
) {
let dt = time.delta_secs();
let new_order = orders.read().last().is_some();
let flying = player.single().ok();
// Decide where the marker should be and whether to show it.
let (target_pos, show) = match flying {
Some((ship_tf, move_target)) => {
let arrived = ship_tf.translation.distance(move_target.0) <= MARKER_HIDE_RADIUS;
let pos = Vec3::new(move_target.0.x, MARKER_PLANE_Y, move_target.0.z);
(pos, !arrived)
}
None => (Vec3::ZERO, false),
};
// Ensure a marker entity exists, then update it.
if marker.is_empty() {
if !show {
return; // nothing to show and nothing to update
}
spawn_destination_marker(&mut commands, &mut meshes, &mut materials);
}
let Ok((mut transform, mut visibility, mut marker)) = marker.single_mut() else {
return;
};
transform.translation = target_pos;
// Continuous gentle pulse + a decaying kick on fresh orders.
if new_order {
marker.punch = 1.0;
} else {
marker.punch = (marker.punch - dt * MARKER_PUNCH_DECAY).max(0.0);
}
let pulse = 1.0 + 0.08 * (time.elapsed_secs() * MARKER_PULSE_FREQ).sin();
let kick = 1.0 + marker.punch * 0.6;
transform.scale = Vec3::splat(pulse * kick);
let target_vis = if show { Visibility::Visible } else { Visibility::Hidden };
if *visibility != target_vis {
*visibility = target_vis;
}
}

View File

@@ -112,7 +112,7 @@ fn spawn_status_panel(commands: &mut Commands) {
TextColor(TEXT_BRIGHT),
));
panel.spawn((
Text::new("Click: Set destination\nOrbit camera: Drag to rotate"),
Text::new("Right-click: Move order\nLeft-click: Select target\nDrag: Rotate camera"),
TextFont {
font_size: 11.0,
..default()

View File

@@ -18,6 +18,7 @@ use bevy::ecs::system::SystemParam;
use bevy::prelude::*;
use crate::state::AppState;
use crate::gameplay::movement::components::{ApproachTarget, MoveTarget};
pub use docked::{DockedState, UndockEvent};
pub use flight::{FlightState, FlightControlsPlugin};
@@ -82,6 +83,7 @@ fn handle_action_triggered(
mut events: EventReader<ActionTriggeredEvent>,
player_query: Query<Entity, With<scene::PlayerShip>>,
selected_target_query: Query<&SelectedTarget, With<scene::PlayerShip>>,
global_transforms: Query<&GlobalTransform>,
mut dispatch: ActionDispatch,
) {
let Ok(player_entity) = player_query.single() else {
@@ -103,15 +105,41 @@ fn handle_action_triggered(
);
}
ActionType::Approach => {
if let Ok(selected) = selected_target_query.single() {
operations::start_travel(
&mut commands,
player_entity,
selected.name.clone(),
&dispatch.durations,
&mut dispatch.started,
now_ms,
);
// Navigate to the selected target via the movement system.
// `ApproachTarget` makes the ship home onto the target even as
// it orbits; `track_approach_target` refreshes `MoveTarget`
// every frame until the ship arrives.
let Ok(selected) = selected_target_query.single() else {
bevy::log::info!("Approach with no selected target");
continue;
};
match global_transforms.get(selected.entity) {
Ok(gt) => {
commands
.entity(player_entity)
.insert(ApproachTarget(selected.entity))
.insert(MoveTarget(gt.translation()));
// Track the approach as a timed operation too. It drives
// no movement itself (the movement systems do that) but
// keeps the operation pipeline consistent and available
// for an ETA/progress readout.
operations::start_travel(
&mut commands,
player_entity,
selected.name.clone(),
&dispatch.durations,
&mut dispatch.started,
now_ms,
);
bevy::log::info!("Approaching {}", selected.name);
}
Err(_) => {
bevy::log::warn!(
"Approach target {:?} ({}) has no GlobalTransform",
selected.entity,
selected.name
);
}
}
}
ActionType::Dock => {

View File

@@ -13,6 +13,9 @@ use crate::gameplay::galaxy::{
SystemContents,
};
use crate::gameplay::in_system::DockedState;
use crate::gameplay::movement::components::{
Acceleration, ArrivalRadius, MaxSpeed, Player, TurnRate,
};
use crate::gameplay::physics::{BodyMass, ProximitySensor};
/// Tracks the currently active system for gameplay.
@@ -56,6 +59,15 @@ const PLAYER_COLLIDER_RADIUS: f32 = 0.15;
/// `ProximityEvent`s.
const PLAYER_SCAN_RADIUS: f32 = 8.0;
// ── Flight-physics tuning ───────────────────────────────────────────────
// Stellaris-leaning feel: a stately top speed, brisk acceleration so stops
// feel responsive, and a moderate turn rate so the ship banks visibly without
// pivoting on a dime. All inert while docked.
const PLAYER_MAX_SPEED: f32 = 40.0;
const PLAYER_ACCELERATION: f32 = 40.0;
const PLAYER_TURN_RATE: f32 = 2.5;
const PLAYER_ARRIVAL_RADIUS: f32 = 1.5;
/// Represents a docking target (either a station or a habitable planet).
#[derive(Debug, Clone)]
struct DockingTargetInfo {
@@ -360,6 +372,17 @@ fn spawn_player_ship_docked(
let ship_entity = commands
.spawn((
PlayerShip,
// Canonical movement marker. The flight input + steering systems
// key off this (not `PlayerShip`, an in-system-only concept) so the
// movement module stays self-contained.
Player,
// Flight-physics tuning. Inert while docked — the ship has no
// Velocity or MoveTarget until undock — so they're safe to attach
// up front and keep the dock → flight transition a pure add.
MaxSpeed(PLAYER_MAX_SPEED),
TurnRate(PLAYER_TURN_RATE),
Acceleration(PLAYER_ACCELERATION),
ArrivalRadius(PLAYER_ARRIVAL_RADIUS),
Docked {
station_entity: Entity::PLACEHOLDER, // Will be set when we find the actual station
},

View File

@@ -4,8 +4,14 @@
//! for point-and-click navigation.
use bevy::prelude::*;
use bevy::ui::ComputedNode;
use bevy::window::PrimaryWindow;
use super::scene::PlayerShip;
use crate::ui::util::cursor_over_ui;
/// Screen-space pick radius (logical px). A click within this many pixels of a
/// targetable's projected position selects it.
const PICK_RADIUS_PX: f32 = 40.0;
/// The kind of target being selected.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -55,16 +61,22 @@ impl Plugin for TargetSelectionPlugin {
}
}
/// Handle target selection via click events.
/// Handle target selection via left-click.
///
/// Picks the targetable whose screen-space position is closest to the cursor
/// (within [`PICK_RADIUS_PX`]) — i.e. what you actually clicked, not whatever
/// is nearest the camera. Clicking empty space deselects (Stellaris-style),
/// and clicks over UI panels are ignored so button presses never re-select.
fn handle_target_selection(
mouse_input: Res<ButtonInput<MouseButton>>,
primary_window: Query<&Window, With<PrimaryWindow>>,
camera_query: Query<(&Camera, &GlobalTransform), With<crate::camera::MainCamera>>,
targetable_query: Query<(Entity, &Targetable, &GlobalTransform)>,
ui_nodes: Query<(&ComputedNode, &GlobalTransform)>,
mut events: EventWriter<TargetSelectedEvent>,
mut player_query: Query<&mut SelectedTarget, With<PlayerShip>>,
) {
// Only handle left clicks
// Only handle left clicks.
if !mouse_input.just_pressed(MouseButton::Left) {
return;
}
@@ -72,48 +84,49 @@ fn handle_target_selection(
let Ok(window) = primary_window.single() else {
return;
};
if cursor_over_ui(window, &ui_nodes) {
return;
}
let Some(cursor_pos) = window.cursor_position() else {
return;
};
let Ok((camera, camera_gt)) = camera_query.single() else {
return;
};
// Check for clicks on targetable entities using raycasting
let mut closest_target: Option<(Entity, TargetKind, String, f32)> = None;
let mut closest_distance = f32::MAX;
// Find the targetable closest to the cursor in screen space.
let mut closest: Option<(Entity, &Targetable, f32)> = None;
for (entity, targetable, global_transform) in targetable_query.iter() {
let target_pos = global_transform.translation();
let distance = camera_gt.translation().distance(target_pos);
// Simple distance-based selection for now
// In a full implementation, you'd use proper raycasting with bounding volumes
if distance < closest_distance && distance < 100.0 {
// Within 100 units
closest_distance = distance;
closest_target = Some((entity, targetable.kind, targetable.name.clone(), distance));
let Ok(screen_pos) =
camera.world_to_viewport(camera_gt, global_transform.translation())
else {
continue;
};
let dist = screen_pos.distance(cursor_pos);
if dist <= PICK_RADIUS_PX && closest.map_or(true, |(_, _, best)| dist < best) {
closest = Some((entity, targetable, dist));
}
}
// If we found a target, select it
if let Some((entity, kind, name, _distance)) = closest_target {
bevy::log::info!("Selected target: {} ({:?})", name, kind);
let Ok(mut selected) = player_query.single_mut() else {
return;
};
// Update player's selected target
if let Ok(mut selected) = player_query.single_mut() {
selected.entity = entity;
selected.kind = kind;
selected.name = name.clone();
}
if let Some((entity, targetable, _)) = closest {
bevy::log::info!("Selected target: {} ({:?})", targetable.name, targetable.kind);
selected.entity = entity;
selected.kind = targetable.kind;
selected.name = targetable.name.clone();
// Fire event
events.write(TargetSelectedEvent {
entity,
kind,
name,
kind: targetable.kind,
name: targetable.name.clone(),
});
} else {
// Clicked empty space: deselect.
selected.entity = Entity::PLACEHOLDER;
selected.kind = TargetKind::Manual;
selected.name = "None".to_string();
}
}