feat(game): restore in-system HUD with reactive station↔flight panel swap (U1)

Basic UI Shell: bring back the in-game flight HUD and station info panel,
and implement the missing "station mode panel swap."

Implementation:
- New `in_system/hud.rs` with a reactive, idempotent `sync_hud_panels`
  system: it shows the station info panel while the player is `Docked` and
  the flight HUD while in `FlightState`, spawning/despawning only when the
  on-screen panel disagrees with the player's state. This replaces the old
  fragile event-driven spawn/despawn scattered through the transition systems.
- Re-enabled the docked info panel (`ui.rs`) and flight HUD (`flight_ui.rs`),
  restructured both as small corner-anchored overlays with NO full-screen
  root so they only block camera orbit input when the cursor is directly
  over a panel (orbit_camera_control suppresses input over any UI node).
- Made the docked panel info-only (actions live in the contextual action
  panel, which adapts to docked/flight modes).
- Fixed the broken undock chain: `start_undocking`/`start_travel` now take a
  real `now_ms` (was hardcoded `0.0`, completing instantly) and a new
  `complete_operations` bridge maps `OperationCompletedEvent::Undocking`
  back to `UndockEvent` so `handle_undock` actually runs.
- Wired `ActionType::Dock` to fire a `DockEvent` for a targeted station /
  habitable planet so the flight→docked swap is reachable too.
- `handle_docking` now also clears Velocity/MoveTarget/SelectedTarget so the
  docked ship stops cleanly.
- Fixed deprecated Bevy 0.16 APIs (single_mut, EventWriter::write,
  despawn) and removed now-dead code, dropping total warnings 183→137.

Compiles clean (`cargo check`); no new warnings in touched files.
This commit is contained in:
2026-06-17 19:43:24 -04:00
parent 408bdb6dd7
commit 7b9b892bfb
6 changed files with 241 additions and 216 deletions

View File

@@ -13,8 +13,6 @@ use crate::gameplay::movement::components::{Velocity, MoveTarget};
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, PlayerShip};
// UI removed - no longer needed
// use super::flight_ui::setup_flight_ui;
/// 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)]
@@ -55,7 +53,6 @@ fn handle_undock(
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, (With<PlayerShip>, With<Docked>)>,
// docked_ui_query removed - UI no longer needed
) { ) {
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);
@@ -102,13 +99,8 @@ fn handle_undock(
Some(crate::camera::tactical_rotation()), Some(crate::camera::tactical_rotation()),
); );
// UI removed - gameplay only // The flight HUD is spawned reactively by `hud::sync_hud_panels`
// setup_flight_ui(commands.reborrow()); // once it sees the player's new `FlightState` component.
// Despawn docked UI (commented out - UI being removed)
// for entity in docked_ui_query.iter() {
// commands.entity(entity).despawn();
// }
bevy::log::info!("Transitioned to flight mode"); bevy::log::info!("Transitioned to flight mode");
} }
@@ -122,7 +114,6 @@ fn handle_docking(
mut camera_state: ResMut<CameraState>, mut camera_state: ResMut<CameraState>,
mut focus_goal: ResMut<OrbitFocusGoal>, mut focus_goal: ResMut<OrbitFocusGoal>,
identifiable_query: Query<&Identifiable>, identifiable_query: Query<&Identifiable>,
// flight_ui_query removed - UI no longer needed
) { ) {
for event in events.read() { for event in events.read() {
bevy::log::info!("Handling docking at target {:?}", event.station); bevy::log::info!("Handling docking at target {:?}", event.station);
@@ -132,14 +123,14 @@ fn handle_docking(
continue; continue;
}; };
// Add docked component // Add docked component and clear all flight components so the
// reactive HUD swap hides the flight HUD (and the ship stops moving).
commands.entity(event.ship).insert(Docked { commands.entity(event.ship).insert(Docked {
station_entity: event.station, station_entity: event.station,
}); });
commands
// Remove flight state .entity(event.ship)
commands.entity(event.ship) .remove::<(FlightState, Velocity, MoveTarget, super::SelectedTarget)>();
.remove::<FlightState>();
// Update docked state resource // Update docked state resource
docked_state.dock_at(event.station); docked_state.dock_at(event.station);
@@ -154,13 +145,8 @@ fn handle_docking(
Some(crate::camera::tactical_rotation()), Some(crate::camera::tactical_rotation()),
); );
// UI removed - no longer needed // The station info panel is spawned reactively by
// Despawn flight HUD // `hud::sync_hud_panels` once it sees the player's `Docked` component.
// for entity in flight_ui_query.iter() {
// commands.entity(entity).despawn();
// }
// Respawn docked UI
// super::ui::setup_docked_ui(commands.reborrow());
bevy::log::info!("Docked at {}", identifiable.display_name); bevy::log::info!("Docked at {}", identifiable.display_name);
} }

View File

@@ -1,4 +1,11 @@
//! Flight HUD and UI elements for in-flight gameplay. //! Flight HUD for in-flight gameplay.
//!
//! Shown while the player ship has a [`FlightState`](super::FlightState)
//! component. Spawned/despawned reactively by the [`super::hud`] module.
//!
//! The speed indicator (bottom-left) and status panel (top-left) are spawned
//! as independent top-level overlays with no full-screen root, so they only
//! block camera input when the cursor is directly over a panel.
use bevy::prelude::*; use bevy::prelude::*;
@@ -10,7 +17,7 @@ const TEXT_BRIGHT: Color = Color::srgb(0.82, 0.90, 1.0);
const TEXT_DIM: Color = Color::srgb(0.55, 0.65, 0.82); const TEXT_DIM: Color = Color::srgb(0.55, 0.65, 0.82);
const ACCENT: Color = Color::srgb(0.30, 0.72, 0.45); const ACCENT: Color = Color::srgb(0.30, 0.72, 0.45);
/// Marker for flight HUD entities. /// Marker for every flight HUD panel entity (spawned while flying).
#[derive(Component)] #[derive(Component)]
pub struct FlightUi; pub struct FlightUi;
@@ -22,32 +29,16 @@ pub struct SpeedDisplay;
#[derive(Component)] #[derive(Component)]
pub struct StatusPanel; pub struct StatusPanel;
/// Setup the flight HUD when entering flight mode. /// Spawn the flight HUD panels.
pub fn setup_flight_ui(mut commands: Commands) { pub fn setup_flight_ui(mut commands: Commands) {
bevy::log::info!("Setting up flight HUD"); bevy::log::info!("Setting up flight HUD");
spawn_speed_indicator(&mut commands);
commands spawn_status_panel(&mut commands);
.spawn((
Node {
width: Val::Percent(100.0),
height: Val::Percent(100.0),
position_type: PositionType::Absolute,
..default()
},
Interaction::None, // Disable interaction - let clicks pass through
FlightUi,
))
.with_children(|root| {
// Speed indicator (bottom left)
spawn_speed_indicator(root);
// Status panel (top left)
spawn_status_panel(root);
});
} }
fn spawn_speed_indicator(parent: &mut ChildSpawnerCommands) { /// Speed indicator (bottom-left).
parent fn spawn_speed_indicator(commands: &mut Commands) {
commands
.spawn(( .spawn((
Node { Node {
position_type: PositionType::Absolute, position_type: PositionType::Absolute,
@@ -64,7 +55,6 @@ fn spawn_speed_indicator(parent: &mut ChildSpawnerCommands) {
FlightUi, FlightUi,
)) ))
.with_children(|panel| { .with_children(|panel| {
// Label
panel.spawn(( panel.spawn((
Text::new("SPEED"), Text::new("SPEED"),
TextFont { TextFont {
@@ -73,8 +63,6 @@ fn spawn_speed_indicator(parent: &mut ChildSpawnerCommands) {
}, },
TextColor(TEXT_DIM), TextColor(TEXT_DIM),
)); ));
// Speed value
panel.spawn(( panel.spawn((
Text::new("0 m/s"), Text::new("0 m/s"),
TextFont { TextFont {
@@ -87,8 +75,9 @@ fn spawn_speed_indicator(parent: &mut ChildSpawnerCommands) {
}); });
} }
fn spawn_status_panel(parent: &mut ChildSpawnerCommands) { /// Status panel (top-left).
parent fn spawn_status_panel(commands: &mut Commands) {
commands
.spawn(( .spawn((
Node { Node {
position_type: PositionType::Absolute, position_type: PositionType::Absolute,
@@ -106,7 +95,6 @@ fn spawn_status_panel(parent: &mut ChildSpawnerCommands) {
StatusPanel, StatusPanel,
)) ))
.with_children(|panel| { .with_children(|panel| {
// Status label
panel.spawn(( panel.spawn((
Text::new("FLIGHT MODE"), Text::new("FLIGHT MODE"),
TextFont { TextFont {
@@ -115,8 +103,6 @@ fn spawn_status_panel(parent: &mut ChildSpawnerCommands) {
}, },
TextColor(ACCENT), TextColor(ACCENT),
)); ));
// Flight status text
panel.spawn(( panel.spawn((
Text::new("Active"), Text::new("Active"),
TextFont { TextFont {
@@ -125,17 +111,7 @@ fn spawn_status_panel(parent: &mut ChildSpawnerCommands) {
}, },
TextColor(TEXT_BRIGHT), TextColor(TEXT_BRIGHT),
)); ));
// Controls hint
panel.spawn(( panel.spawn((
Node {
margin: UiRect::top(Val::Px(12.0)),
..default()
},
FlightUi,
))
.with_children(|hint| {
hint.spawn((
Text::new("Click: Set destination\nOrbit camera: Drag to rotate"), Text::new("Click: Set destination\nOrbit camera: Drag to rotate"),
TextFont { TextFont {
font_size: 11.0, font_size: 11.0,
@@ -144,10 +120,9 @@ fn spawn_status_panel(parent: &mut ChildSpawnerCommands) {
TextColor(TEXT_DIM), TextColor(TEXT_DIM),
)); ));
}); });
});
} }
/// Update flight HUD elements with current flight state. /// Update the flight HUD speed readout from the player's [`FlightState`].
pub fn update_flight_ui( pub fn update_flight_ui(
flight_query: Query<&FlightState>, flight_query: Query<&FlightState>,
mut speed_query: Query<&mut Text, With<SpeedDisplay>>, mut speed_query: Query<&mut Text, With<SpeedDisplay>>,
@@ -156,15 +131,7 @@ pub fn update_flight_ui(
return; return;
}; };
// Update speed display
if let Ok(mut speed_text) = speed_query.single_mut() { if let Ok(mut speed_text) = speed_query.single_mut() {
speed_text.0 = format!("{:.0} m/s", flight_state.current_speed); speed_text.0 = format!("{:.0} m/s", flight_state.current_speed);
} }
} }
/// Despawn flight HUD when exiting flight mode.
pub fn despawn_flight_ui(mut commands: Commands, query: Query<Entity, With<FlightUi>>) {
for entity in &query {
commands.entity(entity).despawn();
}
}

View File

@@ -0,0 +1,84 @@
//! In-system HUD orchestration: the panel swap between the station (docked)
//! info panel and the flight HUD.
//!
//! Inside a system the player is always in exactly one mode —
//! [`Docked`](super::scene::Docked) at a station, or in active
//! [`FlightState`](super::FlightState). This module keeps the on-screen HUD in
//! sync with that state: the station info panel ([`super::ui`]) is shown while
//! docked, the flight HUD ([`super::flight_ui`]) while flying. The contextual
//! action panel ([`super::actions`]) is always present and adapts its buttons
//! to the current mode.
//!
//! The swap is **reactive and idempotent**: a single [`Update`] system compares
//! the player's mode against the panels currently on screen and only
//! spawns/despawns when they disagree. This is more robust than scattering
//! spawn/despawn calls through the undock/dock transition systems, because any
//! code path that flips the player's `Docked`/`FlightState` components is
//! automatically reflected in the UI a frame later.
//!
//! Both panels are spawned as small, corner-anchored overlays with no
//! full-screen root, so they never block camera orbit input except when the
//! cursor is directly over a panel (see `cursor_over_ui` in `camera.rs`).
use bevy::prelude::*;
use super::scene::{Docked, PlayerShip};
use super::{FlightState, flight_ui, ui};
/// Keep the on-screen HUD matched to the player's docking/flight mode.
///
/// - Docked → the station info panel ([`ui::DockedUi`]) is shown.
/// - Flying → the flight HUD ([`flight_ui::FlightUi`]) is shown.
///
/// Cheap to run every frame: it only mutates the world when the on-screen
/// panel disagrees with the player's state.
pub fn sync_hud_panels(
mut commands: Commands,
docked_player: Query<&Docked, With<PlayerShip>>,
flying_player: Query<&FlightState, With<PlayerShip>>,
docked_panels: Query<Entity, With<ui::DockedUi>>,
flight_panels: Query<Entity, With<flight_ui::FlightUi>>,
) {
let is_docked = docked_player.single().is_ok();
let is_flying = flying_player.single().is_ok();
// Station info panel ↔ docked mode.
if is_docked {
if docked_panels.is_empty() {
ui::setup_docked_ui(commands.reborrow());
}
} else {
for entity in &docked_panels {
commands.entity(entity).despawn();
}
}
// Flight HUD ↔ flight mode.
if is_flying {
if flight_panels.is_empty() {
flight_ui::setup_flight_ui(commands.reborrow());
}
} else {
for entity in &flight_panels {
commands.entity(entity).despawn();
}
}
}
/// Tear down every in-system HUD panel when leaving the scene.
///
/// The reactive [`sync_hud_panels`] system stops running once we leave
/// [`AppState::InGame`](crate::state::AppState::InGame), so any panel still on
/// screen is removed explicitly here.
pub fn despawn_in_system_hud(
mut commands: Commands,
docked_panels: Query<Entity, With<ui::DockedUi>>,
flight_panels: Query<Entity, With<flight_ui::FlightUi>>,
) {
for entity in &docked_panels {
commands.entity(entity).despawn();
}
for entity in &flight_panels {
commands.entity(entity).despawn();
}
}

View File

@@ -8,11 +8,13 @@ mod actions;
mod docked; mod docked;
mod flight; mod flight;
mod flight_ui; mod flight_ui;
mod hud;
mod operations; mod operations;
mod scene; mod scene;
mod target; mod target;
mod ui; mod ui;
use bevy::ecs::system::SystemParam;
use bevy::prelude::*; use bevy::prelude::*;
use crate::state::AppState; use crate::state::AppState;
@@ -22,7 +24,7 @@ pub use flight::{FlightState, FlightControlsPlugin};
pub use scene::ActiveSystem; pub use scene::ActiveSystem;
pub use target::{Targetable, TargetKind, SelectedTarget, TargetSelectionPlugin}; pub use target::{Targetable, TargetKind, SelectedTarget, TargetSelectionPlugin};
pub use actions::{ContextualActionPlugin, ActionType, ActionTriggeredEvent, ActionUi}; pub use actions::{ContextualActionPlugin, ActionType, ActionTriggeredEvent, ActionUi};
pub use operations::{TimedOperationPlugin, OperationKind, ActiveOperation}; pub use operations::TimedOperationPlugin;
pub struct InSystemPlugin; pub struct InSystemPlugin;
@@ -37,29 +39,19 @@ impl Plugin for InSystemPlugin {
.add_plugins(TimedOperationPlugin) .add_plugins(TimedOperationPlugin)
.add_systems( .add_systems(
OnEnter(AppState::InGame), OnEnter(AppState::InGame),
( (scene::setup_in_system_view, add_targetable_to_pois).chain(),
scene::setup_in_system_view,
// UI removed - no longer needed
// ui::setup_docked_ui,
add_targetable_to_pois,
).chain(),
) )
.add_systems( .add_systems(
OnExit(AppState::InGame), OnExit(AppState::InGame),
( (hud::despawn_in_system_hud, scene::despawn_in_system_scene).chain(),
// UI removed - no longer needed
// ui::despawn_docked_ui,
// flight_ui::despawn_flight_ui,
scene::despawn_in_system_scene,
).chain(),
) )
.add_systems( .add_systems(
Update, Update,
( (
// UI removed - no longer needed hud::sync_hud_panels,
// ui::refresh_docked_ui, ui::refresh_docked_ui,
// ui::undock_button_handler, flight_ui::update_flight_ui,
// flight_ui::update_flight_ui, complete_operations,
handle_action_triggered, handle_action_triggered,
) )
.chain() .chain()
@@ -68,38 +60,77 @@ impl Plugin for InSystemPlugin {
} }
} }
/// Handle action triggered events from the action buttons. /// Bundles the resources/event-writers [`handle_action_triggered`] dispatches
/// through, keeping its parameter list under the system-argument threshold.
#[derive(SystemParam)]
struct ActionDispatch<'w> {
time: Res<'w, Time>,
durations: Res<'w, operations::OperationDurations>,
started: EventWriter<'w, operations::OperationStartedEvent>,
dock: EventWriter<'w, flight::DockEvent>,
}
/// Handle action triggered events from the contextual action buttons.
///
/// - **Undock** starts a timed operation; on completion [`complete_operations`]
/// fires the [`UndockEvent`] that drives the docked → flight transition.
/// - **Dock** fires a [`flight::DockEvent`] directly (instant) for the selected
/// station / habitable planet so the flight → docked transition (and the HUD
/// swap back to the station panel) is reachable.
fn handle_action_triggered( fn handle_action_triggered(
mut commands: Commands, mut commands: Commands,
mut events: EventReader<ActionTriggeredEvent>, mut events: EventReader<ActionTriggeredEvent>,
player_query: Query<Entity, With<scene::PlayerShip>>, player_query: Query<Entity, With<scene::PlayerShip>>,
durations: Res<operations::OperationDurations>,
mut operation_events: EventWriter<operations::OperationStartedEvent>,
selected_target_query: Query<&SelectedTarget, With<scene::PlayerShip>>, selected_target_query: Query<&SelectedTarget, With<scene::PlayerShip>>,
mut dispatch: ActionDispatch,
) { ) {
let Ok(player_entity) = player_query.single() else { let Ok(player_entity) = player_query.single() else {
return; return;
}; };
let now_ms = dispatch.time.elapsed_secs_f64() * 1000.0;
for event in events.read() { for event in events.read() {
bevy::log::info!("Action triggered: {:?}", event.action_type); bevy::log::info!("Action triggered: {:?}", event.action_type);
match event.action_type { match event.action_type {
ActionType::Undock => { ActionType::Undock => {
operations::start_undocking(&mut commands, player_entity, &durations, &mut operation_events); operations::start_undocking(
&mut commands,
player_entity,
&dispatch.durations,
&mut dispatch.started,
now_ms,
);
} }
ActionType::Approach => { ActionType::Approach => {
// Get selected target
if let Ok(selected) = selected_target_query.single() { if let Ok(selected) = selected_target_query.single() {
operations::start_travel(&mut commands, player_entity, selected.name.clone(), &durations, &mut operation_events); operations::start_travel(
&mut commands,
player_entity,
selected.name.clone(),
&dispatch.durations,
&mut dispatch.started,
now_ms,
);
} }
} }
ActionType::Dock => { ActionType::Dock => {
// TODO: Implement docking operation if let Ok(selected) = selected_target_query.single() {
bevy::log::info!("Dock action triggered"); match selected.kind {
TargetKind::Station | TargetKind::HabitablePlanet => {
bevy::log::info!("Docking at {}", selected.name);
dispatch.dock.write(flight::DockEvent {
ship: player_entity,
station: selected.entity,
});
}
kind => {
bevy::log::info!("Cannot dock at {:?} target", kind);
}
}
}
} }
ActionType::StartMining => { ActionType::StartMining => {
// TODO: Implement mining operation
bevy::log::info!("Start mining action triggered"); bevy::log::info!("Start mining action triggered");
} }
_ => { _ => {
@@ -109,6 +140,29 @@ fn handle_action_triggered(
} }
} }
/// Bridge completed timed operations back into the docked ↔ flight state
/// machine. Today only undocking is wired — when the timed undock completes it
/// fires the [`UndockEvent`] consumed by [`flight::handle_undock`]. Other
/// operation kinds are logged for future work.
fn complete_operations(
mut completed: EventReader<operations::OperationCompletedEvent>,
docked: Res<DockedState>,
mut undock_events: EventWriter<UndockEvent>,
) {
for event in completed.read() {
bevy::log::info!(
"Operation completed: {:?} on entity {:?}",
event.kind,
event.entity
);
if let operations::OperationKind::Undocking = event.kind {
undock_events.write(UndockEvent {
station_entity: docked.station_entity.unwrap_or(Entity::PLACEHOLDER),
});
}
}
}
/// Add Targetable components to spawned POIs (stations and asteroid belts) /// Add Targetable components to spawned POIs (stations and asteroid belts)
/// so they can be selected by the target selection system. /// so they can be selected by the target selection system.
fn add_targetable_to_pois( fn add_targetable_to_pois(

View File

@@ -83,13 +83,12 @@ impl Plugin for TimedOperationPlugin {
fn monitor_operations( fn monitor_operations(
time: Res<Time>, time: Res<Time>,
mut commands: Commands, mut commands: Commands,
mut query: Query<(Entity, &mut ActiveOperation)>, query: Query<(Entity, &ActiveOperation)>,
mut completed_events: EventWriter<OperationCompletedEvent>, mut completed_events: EventWriter<OperationCompletedEvent>,
durations: Res<OperationDurations>,
) { ) {
let now = time.elapsed_secs_f64() * 1000.0; let now = time.elapsed_secs_f64() * 1000.0;
for (entity, mut operation) in query.iter_mut() { for (entity, operation) in &query {
let elapsed = now - operation.started_at; let elapsed = now - operation.started_at;
if elapsed >= operation.duration_ms { if elapsed >= operation.duration_ms {
@@ -127,11 +126,12 @@ pub fn start_undocking(
commands: &mut Commands, commands: &mut Commands,
player_entity: Entity, player_entity: Entity,
durations: &OperationDurations, durations: &OperationDurations,
mut events: &mut EventWriter<OperationStartedEvent>, events: &mut EventWriter<OperationStartedEvent>,
now_ms: f64,
) { ) {
commands.entity(player_entity).insert(ActiveOperation { commands.entity(player_entity).insert(ActiveOperation {
kind: OperationKind::Undocking, kind: OperationKind::Undocking,
started_at: 0.0, // Will be set by the system started_at: now_ms,
duration_ms: durations.undock, duration_ms: durations.undock,
target_name: "Undocking".to_string(), target_name: "Undocking".to_string(),
}); });
@@ -149,11 +149,12 @@ pub fn start_travel(
player_entity: Entity, player_entity: Entity,
target_name: String, target_name: String,
durations: &OperationDurations, durations: &OperationDurations,
mut events: &mut EventWriter<OperationStartedEvent>, events: &mut EventWriter<OperationStartedEvent>,
now_ms: f64,
) { ) {
commands.entity(player_entity).insert(ActiveOperation { commands.entity(player_entity).insert(ActiveOperation {
kind: OperationKind::Travel, kind: OperationKind::Travel,
started_at: 0.0, started_at: now_ms,
duration_ms: durations.local_travel, duration_ms: durations.local_travel,
target_name: target_name.clone(), target_name: target_name.clone(),
}); });

View File

@@ -1,57 +1,43 @@
//! UI for the docked state within a station. //! Station info panel for the docked state.
//!
//! Shown while the player is [`Docked`](super::scene::Docked) at a station.
//! This panel is info-only — all actions (undock, fitting, market, …) live in
//! the contextual action panel ([`super::actions`]), which adapts its buttons
//! to the current mode. The [`super::hud`] module spawns/despawns this panel
//! reactively based on the player's docking state.
use bevy::prelude::*; use bevy::prelude::*;
use crate::gameplay::in_system::{DockedState, UndockEvent};
use crate::gameplay::campaign::CampaignDraft; use crate::gameplay::campaign::CampaignDraft;
use crate::gameplay::in_system::DockedState;
const PANEL_BG: Color = Color::srgb(0.05, 0.07, 0.12); const PANEL_BG: Color = Color::srgb(0.05, 0.07, 0.12);
const PANEL_BORDER: Color = Color::srgb(0.25, 0.40, 0.62); const PANEL_BORDER: Color = Color::srgb(0.25, 0.40, 0.62);
const TEXT_BRIGHT: Color = Color::srgb(0.82, 0.90, 1.0); const TEXT_BRIGHT: Color = Color::srgb(0.82, 0.90, 1.0);
const TEXT_DIM: Color = Color::srgb(0.55, 0.65, 0.82); const TEXT_DIM: Color = Color::srgb(0.55, 0.65, 0.82);
const BUTTON_BG: Color = Color::srgb(0.10, 0.28, 0.22);
const BUTTON_BORDER: Color = Color::srgb(0.30, 0.72, 0.45);
const BUTTON_HOVER_BG: Color = Color::srgb(0.15, 0.35, 0.28);
const TITLE_FONT_SIZE: f32 = 24.0; const TITLE_FONT_SIZE: f32 = 24.0;
const SUBTITLE_FONT_SIZE: f32 = 14.0;
const BODY_FONT_SIZE: f32 = 14.0; const BODY_FONT_SIZE: f32 = 14.0;
const BUTTON_FONT_SIZE: f32 = 16.0;
const PANEL_WIDTH: f32 = 360.0; const PANEL_WIDTH: f32 = 360.0;
const SIDE_MARGIN: f32 = 20.0; const SIDE_MARGIN: f32 = 20.0;
const TOP_MARGIN: f32 = 20.0; const TOP_MARGIN: f32 = 20.0;
/// Marker for UI entities spawned in the docked view. /// Marker for the station info panel entity (spawned while docked).
#[derive(Component)] #[derive(Component)]
pub struct DockedUi; pub struct DockedUi;
/// Marker for the station details text. /// Marker for the station details text, refreshed each frame from the
/// [`CampaignDraft`] / [`DockedState`].
#[derive(Component)] #[derive(Component)]
pub struct StationDetailsText; pub struct StationDetailsText;
/// Marker for the undock button. /// Spawn the station info panel as a top-level, corner-anchored overlay.
#[derive(Component)] ///
pub struct UndockButton; /// No full-screen root: the panel is a small absolute-positioned node so it
/// only blocks camera input when the cursor is directly over it.
pub fn setup_docked_ui(mut commands: Commands) { pub fn setup_docked_ui(mut commands: Commands) {
commands commands
.spawn((
Node {
width: Val::Percent(100.0),
height: Val::Percent(100.0),
position_type: PositionType::Absolute,
..default()
},
DockedUi,
))
.with_children(|root| {
spawn_station_panel(root);
});
}
fn spawn_station_panel(parent: &mut ChildSpawnerCommands) {
parent
.spawn(( .spawn((
Node { Node {
position_type: PositionType::Absolute, position_type: PositionType::Absolute,
@@ -80,7 +66,7 @@ fn spawn_station_panel(parent: &mut ChildSpawnerCommands) {
TextColor(TEXT_BRIGHT), TextColor(TEXT_BRIGHT),
)); ));
// Station details placeholder (will be updated by refresh_docked_ui) // Station details (refreshed by `refresh_docked_ui`).
panel.spawn(( panel.spawn((
Text::new("Loading station data..."), Text::new("Loading station data..."),
TextFont { TextFont {
@@ -90,55 +76,25 @@ fn spawn_station_panel(parent: &mut ChildSpawnerCommands) {
TextColor(TEXT_DIM), TextColor(TEXT_DIM),
StationDetailsText, StationDetailsText,
)); ));
// Spacer
panel.spawn(Node {
flex_grow: 1.0,
..default()
});
// Undock button
panel.spawn((
Button,
Node {
width: Val::Px(160.0),
height: Val::Px(44.0),
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
border: UiRect::all(Val::Px(1.0)),
..default()
},
BackgroundColor(BUTTON_BG),
BorderColor(BUTTON_BORDER),
BorderRadius::all(Val::Px(6.0)),
UndockButton,
))
.with_children(|button| {
button.spawn((
Text::new("Undock"),
TextFont {
font_size: BUTTON_FONT_SIZE,
..default()
},
TextColor(TEXT_BRIGHT),
));
});
}); });
} }
/// Refresh the station details text from the current campaign / docked state.
///
/// Only writes when the underlying data actually changed, so it is cheap to
/// run every frame.
pub fn refresh_docked_ui( pub fn refresh_docked_ui(
campaign: Res<CampaignDraft>, campaign: Res<CampaignDraft>,
docked_state: Res<DockedState>, docked_state: Res<DockedState>,
mut query: Query<&mut Text, With<StationDetailsText>>, mut query: Query<&mut Text, With<StationDetailsText>>,
) { ) {
// Only update when something changed // Only update when something changed.
if !campaign.is_changed() && !docked_state.is_changed() { if !campaign.is_changed() && !docked_state.is_changed() {
return; return;
} }
let mut text = match query.get_single_mut() { let Ok(mut text) = query.single_mut() else {
Ok(t) => t, return;
Err(_) => return,
}; };
let Some(starting_base) = &campaign.starting_base else { let Some(starting_base) = &campaign.starting_base else {
@@ -151,22 +107,25 @@ pub fn refresh_docked_ui(
return; return;
}; };
// Find the system // Find the system.
let system_index = match galaxy.systems.iter().position(|s| s.id == starting_base.system_id) { let Some(system_index) = galaxy
Some(idx) => idx, .systems
None => { .iter()
.position(|s| s.id == starting_base.system_id)
else {
text.0 = format!("System {} not found.", starting_base.system_id); text.0 = format!("System {} not found.", starting_base.system_id);
return; return;
}
}; };
let system = &galaxy.systems[system_index]; let system = &galaxy.systems[system_index];
let contents = &galaxy.contents[system_index]; let contents = &galaxy.contents[system_index];
// Find the docking station (highest population) // Docking station = highest-population station in the system.
let station = contents.stations.iter().max_by_key(|s| s.population); let station = contents.stations.iter().max_by_key(|s| s.population);
let station_name = station.map(|s| s.name.as_str()).unwrap_or("Unknown Station"); let station_name = station
.map(|s| s.name.as_str())
.unwrap_or("Unknown Station");
let station_pop = station.map(|s| s.population).unwrap_or(0); let station_pop = station.map(|s| s.population).unwrap_or(0);
text.0 = format!( text.0 = format!(
@@ -177,32 +136,6 @@ pub fn refresh_docked_ui(
system.id, system.id,
system.faction, system.faction,
system.security, system.security,
station_pop station_pop,
); );
} }
pub fn undock_button_handler(
mut commands: Commands,
mut events: EventWriter<UndockEvent>,
docked_state: Res<DockedState>,
query: Query<(&Interaction, &UndockButton), Changed<Interaction>>,
) {
for (interaction, _) in &query {
if *interaction == Interaction::Pressed {
bevy::log::info!("Undock button pressed");
// Fire the undock event (handled by handle_undock system)
if let Some(station_entity) = docked_state.station_entity {
events.send(UndockEvent { station_entity });
} else {
bevy::log::warn!("No station entity to undock from");
}
}
}
}
pub fn despawn_docked_ui(mut commands: Commands, query: Query<Entity, With<DockedUi>>) {
for entity in &query {
commands.entity(entity).despawn_recursive();
}
}