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:
@@ -245,9 +245,13 @@ pub fn orbit_camera_control(
|
|||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Free-look input is allowed in Orbit framing, or in any framing while the
|
// Free-look input (drag = rotate, scroll = zoom) is allowed in every
|
||||||
// cinematic overlay is on.
|
// framing. In Follow the orbit target is still locked to the ship by
|
||||||
let allow_input = dynamics.state.mode.is_orbit() || dynamics.cinematic.active;
|
// `track_camera_target`, so the camera orbits *around* the ship while the
|
||||||
|
// player controls the angle/zoom — the Stellaris-style follow cam. The
|
||||||
|
// cinematic overlay additionally hides the HUD.
|
||||||
|
let allow_input = matches!(dynamics.state.mode, CameraMode::Orbit | CameraMode::Follow)
|
||||||
|
|| dynamics.cinematic.active;
|
||||||
|
|
||||||
let cursor_over_ui = primary_window
|
let cursor_over_ui = primary_window
|
||||||
.single()
|
.single()
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ use bevy::prelude::*;
|
|||||||
use crate::gameplay::ai::{AiState, BehaviorState};
|
use crate::gameplay::ai::{AiState, BehaviorState};
|
||||||
use crate::gameplay::galaxy::{Identifiable, StarSystem};
|
use crate::gameplay::galaxy::{Identifiable, StarSystem};
|
||||||
use crate::gameplay::in_system::ActiveSystem;
|
use crate::gameplay::in_system::ActiveSystem;
|
||||||
use crate::gameplay::movement::components::{MaxSpeed, TurnRate, Velocity};
|
use crate::gameplay::movement::components::{
|
||||||
|
Acceleration, ArrivalRadius, MaxSpeed, TurnRate, Velocity,
|
||||||
|
};
|
||||||
|
|
||||||
/// Metadata for spawned NPCs.
|
/// Metadata for spawned NPCs.
|
||||||
#[derive(Component, Debug, Clone)]
|
#[derive(Component, Debug, Clone)]
|
||||||
@@ -241,6 +243,8 @@ fn spawn_npc(
|
|||||||
// Movement components
|
// Movement components
|
||||||
MaxSpeed(50.0),
|
MaxSpeed(50.0),
|
||||||
TurnRate(2.0),
|
TurnRate(2.0),
|
||||||
|
Acceleration(30.0),
|
||||||
|
ArrivalRadius(2.0),
|
||||||
Velocity::default(),
|
Velocity::default(),
|
||||||
|
|
||||||
// Visual representation (simple cone for now)
|
// Visual representation (simple cone for now)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
|
|
||||||
use super::{DockedState, FlightState, SelectedTarget};
|
use super::{DockedState, SelectedTarget};
|
||||||
use super::scene::PlayerShip;
|
use super::scene::PlayerShip;
|
||||||
use super::target::TargetKind;
|
use super::target::TargetKind;
|
||||||
|
|
||||||
@@ -51,6 +51,7 @@ pub struct ContextualActionPlugin;
|
|||||||
impl Plugin for ContextualActionPlugin {
|
impl Plugin for ContextualActionPlugin {
|
||||||
fn build(&self, app: &mut App) {
|
fn build(&self, app: &mut App) {
|
||||||
app.add_event::<ActionTriggeredEvent>()
|
app.add_event::<ActionTriggeredEvent>()
|
||||||
|
.init_resource::<ActionPanelCache>()
|
||||||
.add_systems(
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
(
|
(
|
||||||
@@ -73,6 +74,26 @@ impl Plugin for ContextualActionPlugin {
|
|||||||
#[derive(Component)]
|
#[derive(Component)]
|
||||||
pub struct ActionUi;
|
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.
|
/// Marker for action button entities.
|
||||||
#[derive(Component)]
|
#[derive(Component)]
|
||||||
pub struct ActionButton {
|
pub struct ActionButton {
|
||||||
@@ -80,7 +101,11 @@ pub struct ActionButton {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Setup action UI panel.
|
/// 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
|
commands
|
||||||
.spawn((
|
.spawn((
|
||||||
Node {
|
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(
|
fn update_contextual_actions(
|
||||||
docked_state: Res<DockedState>,
|
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>>,
|
action_ui_query: Query<(Entity, &Children), With<ActionUi>>,
|
||||||
mut commands: Commands,
|
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;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Determine available actions based on state
|
let signature = ActionSignature {
|
||||||
let actions = get_available_actions(&docked_state, selected_target, flight_state);
|
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 {
|
let Ok((ui_entity, children)) = action_ui_query.single() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Clear existing action buttons (keep the title)
|
// Clear existing action buttons (keep the title).
|
||||||
for child in children.iter().skip(1) {
|
for child in children.iter().skip(1) {
|
||||||
commands.entity(child).despawn();
|
commands.entity(child).despawn();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Spawn new action buttons
|
// Spawn the new action buttons.
|
||||||
let mut new_actions = actions;
|
let mut new_actions = actions;
|
||||||
commands.entity(ui_entity).with_children(|parent| {
|
commands.entity(ui_entity).with_children(|parent| {
|
||||||
for action in new_actions.drain(..) {
|
for action in new_actions.drain(..) {
|
||||||
spawn_action_button(parent, action);
|
spawn_action_button(parent, action);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
cache.signature = Some(signature);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get available actions based on current state.
|
/// Get available actions based on current state.
|
||||||
fn get_available_actions(
|
fn get_available_actions(
|
||||||
docked_state: &DockedState,
|
docked_state: &DockedState,
|
||||||
selected_target: Option<&SelectedTarget>,
|
selected_target: Option<&SelectedTarget>,
|
||||||
flight_state: Option<&FlightState>,
|
|
||||||
) -> Vec<ContextualAction> {
|
) -> Vec<ContextualAction> {
|
||||||
let mut actions = Vec::new();
|
let mut actions = Vec::new();
|
||||||
|
|
||||||
|
|||||||
@@ -3,16 +3,21 @@
|
|||||||
//! Handles the transition from docked to active flight mode, including
|
//! Handles the transition from docked to active flight mode, including
|
||||||
//! ship controls, camera transitions, and flight state management.
|
//! ship controls, camera transitions, and flight state management.
|
||||||
//!
|
//!
|
||||||
//! Navigation is point-and-click tactical mode: select targets and click
|
//! Navigation is Stellaris-style point-and-order: right-click (or the
|
||||||
//! "Approach" to auto-navigate. No WASD controls.
|
//! "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 bevy::prelude::*;
|
||||||
|
|
||||||
use crate::camera::{CameraState, CameraMode, OrbitFocusGoal};
|
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 crate::gameplay::galaxy::Identifiable;
|
||||||
use super::{DockedState, UndockEvent};
|
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.
|
/// Flight state component attached to the player ship when actively flying.
|
||||||
#[derive(Component, Debug, Clone, Default)]
|
#[derive(Component, Debug, Clone, Default)]
|
||||||
@@ -40,6 +45,7 @@ impl Plugin for FlightControlsPlugin {
|
|||||||
handle_undock,
|
handle_undock,
|
||||||
handle_docking,
|
handle_docking,
|
||||||
flight_input_system,
|
flight_input_system,
|
||||||
|
sync_destination_marker,
|
||||||
).run_if(in_state(crate::state::AppState::InGame)),
|
).run_if(in_state(crate::state::AppState::InGame)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -52,29 +58,32 @@ fn handle_undock(
|
|||||||
mut docked_state: ResMut<DockedState>,
|
mut docked_state: ResMut<DockedState>,
|
||||||
mut camera_state: ResMut<CameraState>,
|
mut camera_state: ResMut<CameraState>,
|
||||||
mut focus_goal: ResMut<OrbitFocusGoal>,
|
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() {
|
for event in events.read() {
|
||||||
bevy::log::info!("Handling undock from station {:?}", event.station_entity);
|
bevy::log::info!("Handling undock from station {:?}", event.station_entity);
|
||||||
|
|
||||||
// Find player ship
|
// 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");
|
bevy::log::warn!("No docked player ship found");
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Remove docked component
|
// Remove docked component.
|
||||||
commands.entity(player_entity).remove::<Docked>();
|
commands.entity(player_entity).remove::<Docked>();
|
||||||
|
|
||||||
// Add flight state with initial velocity away from station
|
// Enter flight holding position: zero velocity and a MoveTarget at the
|
||||||
let initial_speed = 10.0; // Start with slow drift
|
// 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((
|
commands.entity(player_entity).insert((
|
||||||
FlightState {
|
FlightState {
|
||||||
is_flying: true,
|
is_flying: true,
|
||||||
current_speed: initial_speed,
|
current_speed: 0.0,
|
||||||
},
|
},
|
||||||
Velocity(Vec3::new(0.0, 0.0, -1.0) * initial_speed),
|
Velocity(Vec3::ZERO),
|
||||||
MoveTarget(Vec3::new(0.0, 0.0, -50.0)), // Initial target away from station
|
MoveTarget(ship_pos),
|
||||||
super::SelectedTarget {
|
super::SelectedTarget {
|
||||||
entity: Entity::PLACEHOLDER,
|
entity: Entity::PLACEHOLDER,
|
||||||
kind: super::TargetKind::Manual,
|
kind: super::TargetKind::Manual,
|
||||||
@@ -130,7 +139,7 @@ fn handle_docking(
|
|||||||
});
|
});
|
||||||
commands
|
commands
|
||||||
.entity(event.ship)
|
.entity(event.ship)
|
||||||
.remove::<(FlightState, Velocity, MoveTarget, super::SelectedTarget)>();
|
.remove::<(FlightState, Velocity, MoveTarget, ApproachTarget, super::SelectedTarget)>();
|
||||||
|
|
||||||
// Update docked state resource
|
// Update docked state resource
|
||||||
docked_state.dock_at(event.station);
|
docked_state.dock_at(event.station);
|
||||||
@@ -152,18 +161,15 @@ fn handle_docking(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Flight input system: point-and-click navigation only.
|
/// Flight input system: mirrors the ship's live speed into [`FlightState`] so
|
||||||
///
|
/// the HUD speed readout reflects the actual acceleration / arrival profile.
|
||||||
/// This system handles updating the flight state based on movement.
|
/// The real navigation happens in the movement systems (right-click →
|
||||||
/// Actual navigation is handled by click-to-move (setting MoveTarget)
|
/// [`MoveTarget`], `steer_to_target`, `integrate_velocity`).
|
||||||
/// and the kinematic systems (steer_to_target, integrate_velocity).
|
|
||||||
///
|
|
||||||
/// This is a minimal system - the real action happens in the movement systems.
|
|
||||||
fn flight_input_system(
|
fn flight_input_system(
|
||||||
mut player_query: Query<(&Velocity, &mut FlightState), With<PlayerShip>>,
|
mut player_query: Query<(&Velocity, &mut FlightState), With<PlayerShip>>,
|
||||||
docked_state: Res<DockedState>,
|
docked_state: Res<DockedState>,
|
||||||
) {
|
) {
|
||||||
// Only process flight input when not docked
|
// Nothing to mirror while docked.
|
||||||
if docked_state.is_docked {
|
if docked_state.is_docked {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -172,6 +178,118 @@ fn flight_input_system(
|
|||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Update current speed from actual velocity
|
// Update current speed from actual velocity.
|
||||||
flight_state.current_speed = velocity.0.length();
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ fn spawn_status_panel(commands: &mut Commands) {
|
|||||||
TextColor(TEXT_BRIGHT),
|
TextColor(TEXT_BRIGHT),
|
||||||
));
|
));
|
||||||
panel.spawn((
|
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 {
|
TextFont {
|
||||||
font_size: 11.0,
|
font_size: 11.0,
|
||||||
..default()
|
..default()
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ use bevy::ecs::system::SystemParam;
|
|||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
|
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
use crate::gameplay::movement::components::{ApproachTarget, MoveTarget};
|
||||||
|
|
||||||
pub use docked::{DockedState, UndockEvent};
|
pub use docked::{DockedState, UndockEvent};
|
||||||
pub use flight::{FlightState, FlightControlsPlugin};
|
pub use flight::{FlightState, FlightControlsPlugin};
|
||||||
@@ -82,6 +83,7 @@ fn handle_action_triggered(
|
|||||||
mut events: EventReader<ActionTriggeredEvent>,
|
mut events: EventReader<ActionTriggeredEvent>,
|
||||||
player_query: Query<Entity, With<scene::PlayerShip>>,
|
player_query: Query<Entity, With<scene::PlayerShip>>,
|
||||||
selected_target_query: Query<&SelectedTarget, With<scene::PlayerShip>>,
|
selected_target_query: Query<&SelectedTarget, With<scene::PlayerShip>>,
|
||||||
|
global_transforms: Query<&GlobalTransform>,
|
||||||
mut dispatch: ActionDispatch,
|
mut dispatch: ActionDispatch,
|
||||||
) {
|
) {
|
||||||
let Ok(player_entity) = player_query.single() else {
|
let Ok(player_entity) = player_query.single() else {
|
||||||
@@ -103,7 +105,24 @@ fn handle_action_triggered(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
ActionType::Approach => {
|
ActionType::Approach => {
|
||||||
if let Ok(selected) = selected_target_query.single() {
|
// 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(
|
operations::start_travel(
|
||||||
&mut commands,
|
&mut commands,
|
||||||
player_entity,
|
player_entity,
|
||||||
@@ -112,6 +131,15 @@ fn handle_action_triggered(
|
|||||||
&mut dispatch.started,
|
&mut dispatch.started,
|
||||||
now_ms,
|
now_ms,
|
||||||
);
|
);
|
||||||
|
bevy::log::info!("Approaching {}", selected.name);
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
bevy::log::warn!(
|
||||||
|
"Approach target {:?} ({}) has no GlobalTransform",
|
||||||
|
selected.entity,
|
||||||
|
selected.name
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ActionType::Dock => {
|
ActionType::Dock => {
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ use crate::gameplay::galaxy::{
|
|||||||
SystemContents,
|
SystemContents,
|
||||||
};
|
};
|
||||||
use crate::gameplay::in_system::DockedState;
|
use crate::gameplay::in_system::DockedState;
|
||||||
|
use crate::gameplay::movement::components::{
|
||||||
|
Acceleration, ArrivalRadius, MaxSpeed, Player, TurnRate,
|
||||||
|
};
|
||||||
use crate::gameplay::physics::{BodyMass, ProximitySensor};
|
use crate::gameplay::physics::{BodyMass, ProximitySensor};
|
||||||
|
|
||||||
/// Tracks the currently active system for gameplay.
|
/// Tracks the currently active system for gameplay.
|
||||||
@@ -56,6 +59,15 @@ const PLAYER_COLLIDER_RADIUS: f32 = 0.15;
|
|||||||
/// `ProximityEvent`s.
|
/// `ProximityEvent`s.
|
||||||
const PLAYER_SCAN_RADIUS: f32 = 8.0;
|
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).
|
/// Represents a docking target (either a station or a habitable planet).
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
struct DockingTargetInfo {
|
struct DockingTargetInfo {
|
||||||
@@ -360,6 +372,17 @@ fn spawn_player_ship_docked(
|
|||||||
let ship_entity = commands
|
let ship_entity = commands
|
||||||
.spawn((
|
.spawn((
|
||||||
PlayerShip,
|
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 {
|
Docked {
|
||||||
station_entity: Entity::PLACEHOLDER, // Will be set when we find the actual station
|
station_entity: Entity::PLACEHOLDER, // Will be set when we find the actual station
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,8 +4,14 @@
|
|||||||
//! for point-and-click navigation.
|
//! for point-and-click navigation.
|
||||||
|
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
|
use bevy::ui::ComputedNode;
|
||||||
use bevy::window::PrimaryWindow;
|
use bevy::window::PrimaryWindow;
|
||||||
use super::scene::PlayerShip;
|
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.
|
/// The kind of target being selected.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[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(
|
fn handle_target_selection(
|
||||||
mouse_input: Res<ButtonInput<MouseButton>>,
|
mouse_input: Res<ButtonInput<MouseButton>>,
|
||||||
primary_window: Query<&Window, With<PrimaryWindow>>,
|
primary_window: Query<&Window, With<PrimaryWindow>>,
|
||||||
camera_query: Query<(&Camera, &GlobalTransform), With<crate::camera::MainCamera>>,
|
camera_query: Query<(&Camera, &GlobalTransform), With<crate::camera::MainCamera>>,
|
||||||
targetable_query: Query<(Entity, &Targetable, &GlobalTransform)>,
|
targetable_query: Query<(Entity, &Targetable, &GlobalTransform)>,
|
||||||
|
ui_nodes: Query<(&ComputedNode, &GlobalTransform)>,
|
||||||
mut events: EventWriter<TargetSelectedEvent>,
|
mut events: EventWriter<TargetSelectedEvent>,
|
||||||
mut player_query: Query<&mut SelectedTarget, With<PlayerShip>>,
|
mut player_query: Query<&mut SelectedTarget, With<PlayerShip>>,
|
||||||
) {
|
) {
|
||||||
// Only handle left clicks
|
// Only handle left clicks.
|
||||||
if !mouse_input.just_pressed(MouseButton::Left) {
|
if !mouse_input.just_pressed(MouseButton::Left) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -72,48 +84,49 @@ fn handle_target_selection(
|
|||||||
let Ok(window) = primary_window.single() else {
|
let Ok(window) = primary_window.single() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
if cursor_over_ui(window, &ui_nodes) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
let Some(cursor_pos) = window.cursor_position() else {
|
let Some(cursor_pos) = window.cursor_position() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
let Ok((camera, camera_gt)) = camera_query.single() else {
|
let Ok((camera, camera_gt)) = camera_query.single() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Check for clicks on targetable entities using raycasting
|
// Find the targetable closest to the cursor in screen space.
|
||||||
let mut closest_target: Option<(Entity, TargetKind, String, f32)> = None;
|
let mut closest: Option<(Entity, &Targetable, f32)> = None;
|
||||||
let mut closest_distance = f32::MAX;
|
|
||||||
|
|
||||||
for (entity, targetable, global_transform) in targetable_query.iter() {
|
for (entity, targetable, global_transform) in targetable_query.iter() {
|
||||||
let target_pos = global_transform.translation();
|
let Ok(screen_pos) =
|
||||||
let distance = camera_gt.translation().distance(target_pos);
|
camera.world_to_viewport(camera_gt, global_transform.translation())
|
||||||
|
else {
|
||||||
// Simple distance-based selection for now
|
continue;
|
||||||
// In a full implementation, you'd use proper raycasting with bounding volumes
|
};
|
||||||
if distance < closest_distance && distance < 100.0 {
|
let dist = screen_pos.distance(cursor_pos);
|
||||||
// Within 100 units
|
if dist <= PICK_RADIUS_PX && closest.map_or(true, |(_, _, best)| dist < best) {
|
||||||
closest_distance = distance;
|
closest = Some((entity, targetable, dist));
|
||||||
closest_target = Some((entity, targetable.kind, targetable.name.clone(), distance));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we found a target, select it
|
let Ok(mut selected) = player_query.single_mut() else {
|
||||||
if let Some((entity, kind, name, _distance)) = closest_target {
|
return;
|
||||||
bevy::log::info!("Selected target: {} ({:?})", name, kind);
|
};
|
||||||
|
|
||||||
// Update player's selected target
|
if let Some((entity, targetable, _)) = closest {
|
||||||
if let Ok(mut selected) = player_query.single_mut() {
|
bevy::log::info!("Selected target: {} ({:?})", targetable.name, targetable.kind);
|
||||||
selected.entity = entity;
|
selected.entity = entity;
|
||||||
selected.kind = kind;
|
selected.kind = targetable.kind;
|
||||||
selected.name = name.clone();
|
selected.name = targetable.name.clone();
|
||||||
}
|
|
||||||
|
|
||||||
// Fire event
|
|
||||||
events.write(TargetSelectedEvent {
|
events.write(TargetSelectedEvent {
|
||||||
entity,
|
entity,
|
||||||
kind,
|
kind: targetable.kind,
|
||||||
name,
|
name: targetable.name.clone(),
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
// Clicked empty space: deselect.
|
||||||
|
selected.entity = Entity::PLACEHOLDER;
|
||||||
|
selected.kind = TargetKind::Manual;
|
||||||
|
selected.name = "None".to_string();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ impl Default for MaxSpeed {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Maximum rotation rate in radians per second. Used to smooth heading changes.
|
/// Maximum rotation rate in radians per second. Caps how fast the ship can
|
||||||
|
/// bank toward a new heading — the lower this is, the heavier the ship feels.
|
||||||
#[derive(Component, Debug, Clone, Copy)]
|
#[derive(Component, Debug, Clone, Copy)]
|
||||||
pub struct TurnRate(pub f32);
|
pub struct TurnRate(pub f32);
|
||||||
|
|
||||||
@@ -28,12 +29,43 @@ impl Default for TurnRate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Linear acceleration in units/sec². Applied symmetrically to throttling up
|
||||||
|
/// and braking, so the ship eases in and out of its top speed instead of
|
||||||
|
/// snapping. Drives the arrival (deceleration) behaviour in `steer_to_target`.
|
||||||
|
#[derive(Component, Debug, Clone, Copy)]
|
||||||
|
pub struct Acceleration(pub f32);
|
||||||
|
|
||||||
|
impl Default for Acceleration {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self(30.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Distance from the move target at which the ship is considered to have
|
||||||
|
/// arrived and eases its velocity to zero.
|
||||||
|
#[derive(Component, Debug, Clone, Copy)]
|
||||||
|
pub struct ArrivalRadius(pub f32);
|
||||||
|
|
||||||
|
impl Default for ArrivalRadius {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self(1.5)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Exponential drag coefficient. Higher = stronger drag (slower drift when no thrust).
|
/// Exponential drag coefficient. Higher = stronger drag (slower drift when no thrust).
|
||||||
/// Currently unused by click-to-move; reserved for future throttle-based input.
|
/// Currently unused by the steering model; reserved for future throttle-based input.
|
||||||
#[derive(Component, Default, Debug, Clone, Copy)]
|
#[derive(Component, Default, Debug, Clone, Copy)]
|
||||||
pub struct Drag(pub f32);
|
pub struct Drag(pub f32);
|
||||||
|
|
||||||
/// A world-space point the entity should move toward.
|
/// A world-space point the entity should move toward.
|
||||||
/// Set by input systems (e.g. click-to-move) and consumed by kinematic systems.
|
/// Set by input systems (e.g. right-click-to-move) and consumed by the
|
||||||
|
/// kinematic steering system.
|
||||||
#[derive(Component, Default, Debug, Clone, Copy)]
|
#[derive(Component, Default, Debug, Clone, Copy)]
|
||||||
pub struct MoveTarget(pub Vec3);
|
pub struct MoveTarget(pub Vec3);
|
||||||
|
|
||||||
|
/// An entity to intercept. While present, `track_approach_target` overwrites
|
||||||
|
/// [`MoveTarget`] with this entity's live world position each frame, so the
|
||||||
|
/// ship homes onto a moving target (station, asteroid belt, hostile). Removed
|
||||||
|
/// automatically on arrival, or when the player issues a manual move order.
|
||||||
|
#[derive(Component, Debug, Clone, Copy)]
|
||||||
|
pub struct ApproachTarget(pub Entity);
|
||||||
|
|||||||
@@ -1,35 +1,59 @@
|
|||||||
use bevy::input::mouse::MouseButton;
|
use bevy::input::mouse::MouseButton;
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
|
use bevy::ui::ComputedNode;
|
||||||
use bevy::window::PrimaryWindow;
|
use bevy::window::PrimaryWindow;
|
||||||
|
|
||||||
use super::components::{MoveTarget, Player};
|
use super::components::{ApproachTarget, MoveTarget, Player};
|
||||||
use crate::camera::MainCamera;
|
use crate::camera::MainCamera;
|
||||||
use crate::gameplay::in_system::DockedState;
|
use crate::gameplay::in_system::DockedState;
|
||||||
|
use crate::ui::util::cursor_over_ui;
|
||||||
|
|
||||||
/// Y coordinate of the ground plane. Cursor rays are projected onto this plane.
|
/// Y coordinate of the orbital plane. Cursor rays are projected onto it so a
|
||||||
const GROUND_PLANE_Y: f32 = 0.0;
|
/// right-click maps to a flat-world destination the ship can actually reach.
|
||||||
|
const ORBITAL_PLANE_Y: f32 = 0.0;
|
||||||
|
|
||||||
/// Helper function to check if the player is in flight mode (not docked).
|
/// Event fired whenever the player issues a move order (right-click). Carries
|
||||||
/// This can be used as a run condition for systems that should only run during flight.
|
/// no payload — it is a pure "new order" ping consumed by the destination
|
||||||
|
/// marker to trigger a fresh pulse. The marker reads `MoveTarget` itself for
|
||||||
|
/// its on-world position.
|
||||||
|
#[derive(Event, Debug, Clone, Copy)]
|
||||||
|
pub struct MoveOrderEvent;
|
||||||
|
|
||||||
|
/// Run condition: move orders are only accepted while actively flying (not
|
||||||
|
/// docked). Combined with `in_state(AppState::InGame)` where the system is
|
||||||
|
/// registered.
|
||||||
pub fn when_in_flight(docked: Res<DockedState>) -> bool {
|
pub fn when_in_flight(docked: Res<DockedState>) -> bool {
|
||||||
!docked.is_docked
|
!docked.is_docked
|
||||||
}
|
}
|
||||||
|
|
||||||
/// On left-click, project the cursor onto the ground plane and update the player's MoveTarget.
|
/// Right-click issues a Stellaris-style move order: project the cursor onto the
|
||||||
/// This works in both docked and flight modes - the ground plane targeting is useful for
|
/// orbital plane and set the player ship's [`MoveTarget`].
|
||||||
/// setting movement targets regardless of camera mode.
|
///
|
||||||
pub fn click_to_move(
|
/// Left-click is intentionally left untouched here — it belongs to target
|
||||||
|
/// selection and camera orbit — so the three inputs (select / orbit / move)
|
||||||
|
/// never fight over the same button. A manual move order also cancels any
|
||||||
|
/// active [`ApproachTarget`], so the player can override an auto-approach.
|
||||||
|
///
|
||||||
|
/// Orders issued while the cursor is over a UI panel are ignored.
|
||||||
|
pub fn right_click_to_move(
|
||||||
|
mut commands: Commands,
|
||||||
mouse_input: Res<ButtonInput<MouseButton>>,
|
mouse_input: Res<ButtonInput<MouseButton>>,
|
||||||
primary_window: Query<&Window, With<PrimaryWindow>>,
|
primary_window: Query<&Window, With<PrimaryWindow>>,
|
||||||
camera_query: Query<(&Camera, &GlobalTransform), With<MainCamera>>,
|
camera_query: Query<(&Camera, &GlobalTransform), With<MainCamera>>,
|
||||||
mut player: Query<&mut MoveTarget, With<Player>>,
|
ui_nodes: Query<(&ComputedNode, &GlobalTransform)>,
|
||||||
|
mut player: Query<(Entity, &mut MoveTarget), With<Player>>,
|
||||||
|
mut orders: EventWriter<MoveOrderEvent>,
|
||||||
) {
|
) {
|
||||||
if !mouse_input.just_pressed(MouseButton::Left) {
|
if !mouse_input.just_pressed(MouseButton::Right) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let Ok(window) = primary_window.single() else {
|
let Ok(window) = primary_window.single() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
if cursor_over_ui(window, &ui_nodes) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
let Some(cursor_pos) = window.cursor_position() else {
|
let Some(cursor_pos) = window.cursor_position() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
@@ -40,18 +64,21 @@ pub fn click_to_move(
|
|||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Intersect ray with the ground plane (y = GROUND_PLANE_Y).
|
// Intersect the ray with the orbital plane (y = ORBITAL_PLANE_Y).
|
||||||
if ray.direction.y.abs() < 1e-6 {
|
if ray.direction.y.abs() < 1e-6 {
|
||||||
return; // ray is parallel to ground — no intersection
|
return; // ray parallel to the plane — no intersection
|
||||||
}
|
}
|
||||||
let t = (GROUND_PLANE_Y - ray.origin.y) / ray.direction.y;
|
let t = (ORBITAL_PLANE_Y - ray.origin.y) / ray.direction.y;
|
||||||
if t < 0.0 {
|
if t < 0.0 {
|
||||||
return; // intersection is behind the camera
|
return; // intersection behind the camera
|
||||||
}
|
}
|
||||||
let target = ray.origin + ray.direction * t;
|
let destination = ray.origin + ray.direction * t;
|
||||||
|
|
||||||
let Ok(mut move_target) = player.single_mut() else {
|
let Ok((player_entity, mut move_target)) = player.single_mut() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
move_target.0 = target;
|
// A manual order overrides any active auto-approach.
|
||||||
|
commands.entity(player_entity).remove::<ApproachTarget>();
|
||||||
|
move_target.0 = destination;
|
||||||
|
orders.write(MoveOrderEvent);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,33 +1,107 @@
|
|||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
|
|
||||||
use super::components::{MaxSpeed, MoveTarget, Velocity};
|
use super::components::{
|
||||||
|
Acceleration, ArrivalRadius, ApproachTarget, MaxSpeed, MoveTarget, TurnRate, Velocity,
|
||||||
|
};
|
||||||
|
|
||||||
/// Distance (in world units) at which an entity is considered to have arrived at its target.
|
/// Track a moving [`ApproachTarget`]: copy its live world position into the
|
||||||
const ARRIVAL_DISTANCE: f32 = 0.5;
|
/// entity's [`MoveTarget`] each frame so the ship homes onto it. Drops the
|
||||||
|
/// component once the entity has arrived (so it brakes to a hold) or if the
|
||||||
|
/// target was despawned.
|
||||||
|
///
|
||||||
|
/// Runs before [`steer_to_target`] in the movement chain so the destination is
|
||||||
|
/// current for this frame's steering.
|
||||||
|
pub fn track_approach_target(
|
||||||
|
mut commands: Commands,
|
||||||
|
mut query: Query<(
|
||||||
|
Entity,
|
||||||
|
&ApproachTarget,
|
||||||
|
&mut MoveTarget,
|
||||||
|
&Transform,
|
||||||
|
&ArrivalRadius,
|
||||||
|
)>,
|
||||||
|
transforms: Query<&GlobalTransform>,
|
||||||
|
) {
|
||||||
|
for (entity, approach, mut move_target, transform, arrival) in &mut query {
|
||||||
|
match transforms.get(approach.0) {
|
||||||
|
Ok(target_gt) => {
|
||||||
|
let target_pos = target_gt.translation();
|
||||||
|
move_target.0 = target_pos;
|
||||||
|
if transform.translation.distance(target_pos) <= arrival.0 {
|
||||||
|
// Arrived: stop homing. MoveTarget holds the last position
|
||||||
|
// so the ship brakes to a stop on top of it.
|
||||||
|
commands.entity(entity).remove::<ApproachTarget>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
// Target despawned mid-approach — stop homing and hold.
|
||||||
|
commands.entity(entity).remove::<ApproachTarget>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// For each entity with a MoveTarget, set Velocity to face the target at MaxSpeed,
|
/// Steer every entity that has the full flight-physics component set toward its
|
||||||
/// and rotate the transform to face direction of motion.
|
/// [`MoveTarget`], with smooth acceleration, arrival deceleration, and
|
||||||
|
/// turn-rate-limited banking.
|
||||||
|
///
|
||||||
|
/// The model is deliberately simple and frame-rate independent:
|
||||||
|
/// - **Desired velocity** points at the target and eases to zero inside the
|
||||||
|
/// arrival radius. A "slow radius" derived from the stopping distance
|
||||||
|
/// (`v² / 2a`) decides where braking begins, so the ship arrives at zero
|
||||||
|
/// speed with no overshoot.
|
||||||
|
/// - **Velocity** is moved toward the desired velocity, clamped to the
|
||||||
|
/// per-step acceleration budget (`a·dt`) — the same limit covers throttle-up
|
||||||
|
/// and braking.
|
||||||
|
/// - **Heading** banks toward the travel direction on the horizontal plane
|
||||||
|
/// (never pitching into the ground), advanced at most `turn_rate·dt` per
|
||||||
|
/// frame so heavier ships turn sluggishly.
|
||||||
|
///
|
||||||
|
/// This single system drives both the player ship and AI ships, giving the
|
||||||
|
/// whole fleet the same physics.
|
||||||
pub fn steer_to_target(
|
pub fn steer_to_target(
|
||||||
mut query: Query<(&mut Transform, &mut Velocity, &MoveTarget, &MaxSpeed)>,
|
mut query: Query<(
|
||||||
|
&mut Transform,
|
||||||
|
&mut Velocity,
|
||||||
|
&MoveTarget,
|
||||||
|
&MaxSpeed,
|
||||||
|
&TurnRate,
|
||||||
|
&Acceleration,
|
||||||
|
&ArrivalRadius,
|
||||||
|
)>,
|
||||||
time: Res<Time>,
|
time: Res<Time>,
|
||||||
) {
|
) {
|
||||||
let dt = time.delta_secs();
|
let dt = time.delta_secs();
|
||||||
for (mut transform, mut velocity, target, max_speed) in &mut query {
|
for (mut transform, mut velocity, target, max_speed, turn_rate, accel, arrival) in &mut query {
|
||||||
let to_target = target.0 - transform.translation;
|
let to_target = target.0 - transform.translation;
|
||||||
let distance = to_target.length();
|
let distance = to_target.length();
|
||||||
if distance <= ARRIVAL_DISTANCE {
|
|
||||||
velocity.0 = Vec3::ZERO;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let direction = to_target / distance;
|
|
||||||
velocity.0 = direction * max_speed.0;
|
|
||||||
|
|
||||||
// Rotate to face direction of motion, keeping Y as up (no pitch).
|
// Desired velocity: full speed toward the target, easing to zero inside
|
||||||
// This gives a "ship banking" feel rather than nosediving toward targets at different altitudes.
|
// the arrival radius and along the braking run-up (slow radius).
|
||||||
let flat = Vec3::new(direction.x, 0.0, direction.z);
|
let desired_velocity = if distance <= arrival.0 {
|
||||||
if flat.length_squared() > 1e-4 {
|
Vec3::ZERO
|
||||||
let look = Transform::IDENTITY.looking_at(flat.normalize(), Vec3::Y);
|
} else {
|
||||||
transform.rotation = transform.rotation.slerp(look.rotation, dt * 4.0);
|
let direction = to_target / distance;
|
||||||
|
let stopping_distance =
|
||||||
|
(max_speed.0 * max_speed.0) / (2.0 * accel.0).max(1e-2);
|
||||||
|
let slow_radius = stopping_distance.max(arrival.0 * 2.0);
|
||||||
|
let ramp = (distance / slow_radius).clamp(0.0, 1.0);
|
||||||
|
direction * (max_speed.0 * ramp)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Step the velocity toward the desired velocity, capped by the
|
||||||
|
// acceleration budget for this frame.
|
||||||
|
let delta = desired_velocity - velocity.0;
|
||||||
|
velocity.0 += limit_length(delta, accel.0 * dt);
|
||||||
|
|
||||||
|
// Bank toward the travel heading on the horizontal plane. Only update
|
||||||
|
// when actually moving so a stationary ship holds its facing.
|
||||||
|
let flat = Vec3::new(velocity.0.x, 0.0, velocity.0.z);
|
||||||
|
if flat.length_squared() > 1e-2 {
|
||||||
|
let dir = flat.normalize();
|
||||||
|
let look = Transform::IDENTITY.looking_at(dir, Vec3::Y);
|
||||||
|
let step = (turn_rate.0 * dt).clamp(0.0, 1.0);
|
||||||
|
transform.rotation = transform.rotation.slerp(look.rotation, step);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -39,3 +113,13 @@ pub fn integrate_velocity(mut query: Query<(&mut Transform, &Velocity)>, time: R
|
|||||||
transform.translation += velocity.0 * dt;
|
transform.translation += velocity.0 * dt;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Scale `v` down so its length never exceeds `max`, preserving direction.
|
||||||
|
fn limit_length(v: Vec3, max: f32) -> Vec3 {
|
||||||
|
let len = v.length();
|
||||||
|
if len > max && len > 1e-6 {
|
||||||
|
v * (max / len)
|
||||||
|
} else {
|
||||||
|
v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
|
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
pub mod components;
|
pub mod components;
|
||||||
pub mod input;
|
pub mod input;
|
||||||
pub mod kinematic;
|
pub mod kinematic;
|
||||||
@@ -9,18 +11,24 @@ pub struct MovementPlugin;
|
|||||||
|
|
||||||
impl Plugin for MovementPlugin {
|
impl Plugin for MovementPlugin {
|
||||||
fn build(&self, app: &mut App) {
|
fn build(&self, app: &mut App) {
|
||||||
// No state gating for slice 1: systems are no-ops when no Player exists.
|
app.add_event::<input::MoveOrderEvent>()
|
||||||
// When AppState::InGame is wired up, gate these on `in_state(AppState::InGame)`
|
// Orbital motion is scene-wide (used by the galaxy inspection scene
|
||||||
// and consider moving them to FixedUpdate for SpacetimeDB determinism.
|
// too), so it runs in every state.
|
||||||
app.add_systems(
|
.add_systems(Update, orbit::update_orbits)
|
||||||
|
// Flight steering chain: order → track approach → steer → integrate.
|
||||||
|
// Gated to InGame — these only do something while entities with the
|
||||||
|
// flight-physics components exist. Right-click input additionally
|
||||||
|
// requires the player to be in flight, not docked.
|
||||||
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
(
|
(
|
||||||
input::click_to_move,
|
input::right_click_to_move.run_if(input::when_in_flight),
|
||||||
|
kinematic::track_approach_target,
|
||||||
kinematic::steer_to_target,
|
kinematic::steer_to_target,
|
||||||
kinematic::integrate_velocity,
|
kinematic::integrate_velocity,
|
||||||
orbit::update_orbits,
|
|
||||||
)
|
)
|
||||||
.chain(),
|
.chain()
|
||||||
|
.run_if(in_state(AppState::InGame)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user