diff --git a/apps/game/src/camera.rs b/apps/game/src/camera.rs index 22ec4ca..dfd3009 100644 --- a/apps/game/src/camera.rs +++ b/apps/game/src/camera.rs @@ -245,9 +245,13 @@ pub fn orbit_camera_control( return; }; - // Free-look input is allowed in Orbit framing, or in any framing while the - // cinematic overlay is on. - let allow_input = dynamics.state.mode.is_orbit() || dynamics.cinematic.active; + // Free-look input (drag = rotate, scroll = zoom) is allowed in every + // framing. In Follow the orbit target is still locked to the ship by + // `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 .single() diff --git a/apps/game/src/gameplay/ai/spawning.rs b/apps/game/src/gameplay/ai/spawning.rs index 07380ff..6ab3c62 100644 --- a/apps/game/src/gameplay/ai/spawning.rs +++ b/apps/game/src/gameplay/ai/spawning.rs @@ -8,7 +8,9 @@ use bevy::prelude::*; use crate::gameplay::ai::{AiState, BehaviorState}; use crate::gameplay::galaxy::{Identifiable, StarSystem}; 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. #[derive(Component, Debug, Clone)] @@ -241,6 +243,8 @@ fn spawn_npc( // Movement components MaxSpeed(50.0), TurnRate(2.0), + Acceleration(30.0), + ArrivalRadius(2.0), Velocity::default(), // Visual representation (simple cone for now) diff --git a/apps/game/src/gameplay/in_system/actions.rs b/apps/game/src/gameplay/in_system/actions.rs index e6301af..a0d5073 100644 --- a/apps/game/src/gameplay/in_system/actions.rs +++ b/apps/game/src/gameplay/in_system/actions.rs @@ -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::() + .init_resource::() .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, +} + +/// 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) { + // 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, - player_query: Query<(Entity, Option<&SelectedTarget>, Option<&FlightState>), With>, + player_query: Query, With>, action_ui_query: Query<(Entity, &Children), With>, mut commands: Commands, + mut cache: ResMut, ) { - 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 { let mut actions = Vec::new(); diff --git a/apps/game/src/gameplay/in_system/flight.rs b/apps/game/src/gameplay/in_system/flight.rs index 174daa2..627d28b 100644 --- a/apps/game/src/gameplay/in_system/flight.rs +++ b/apps/game/src/gameplay/in_system/flight.rs @@ -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, mut camera_state: ResMut, mut focus_goal: ResMut, - player_query: Query, With)>, + player_query: Query<(Entity, &Transform), (With, With)>, ) { 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::(); - // 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>, docked_state: Res, ) { - // 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, + materials: &mut Assets, +) -> 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>, + mut materials: ResMut>, + time: Res