Merge agent branch agent/u1-8a3c7cf2 into main (U1: Basic UI Shell)

This commit is contained in:
2026-06-18 00:57:19 -04:00
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 super::{DockedState, UndockEvent};
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.
#[derive(Component, Debug, Clone, Default)]
@@ -55,7 +53,6 @@ fn handle_undock(
mut camera_state: ResMut<CameraState>,
mut focus_goal: ResMut<OrbitFocusGoal>,
player_query: Query<Entity, (With<PlayerShip>, With<Docked>)>,
// docked_ui_query removed - UI no longer needed
) {
for event in events.read() {
bevy::log::info!("Handling undock from station {:?}", event.station_entity);
@@ -102,13 +99,8 @@ fn handle_undock(
Some(crate::camera::tactical_rotation()),
);
// UI removed - gameplay only
// setup_flight_ui(commands.reborrow());
// Despawn docked UI (commented out - UI being removed)
// for entity in docked_ui_query.iter() {
// commands.entity(entity).despawn();
// }
// The flight HUD is spawned reactively by `hud::sync_hud_panels`
// once it sees the player's new `FlightState` component.
bevy::log::info!("Transitioned to flight mode");
}
@@ -122,7 +114,6 @@ fn handle_docking(
mut camera_state: ResMut<CameraState>,
mut focus_goal: ResMut<OrbitFocusGoal>,
identifiable_query: Query<&Identifiable>,
// flight_ui_query removed - UI no longer needed
) {
for event in events.read() {
bevy::log::info!("Handling docking at target {:?}", event.station);
@@ -132,14 +123,14 @@ fn handle_docking(
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 {
station_entity: event.station,
});
// Remove flight state
commands.entity(event.ship)
.remove::<FlightState>();
commands
.entity(event.ship)
.remove::<(FlightState, Velocity, MoveTarget, super::SelectedTarget)>();
// Update docked state resource
docked_state.dock_at(event.station);
@@ -154,13 +145,8 @@ fn handle_docking(
Some(crate::camera::tactical_rotation()),
);
// UI removed - no longer needed
// Despawn flight HUD
// for entity in flight_ui_query.iter() {
// commands.entity(entity).despawn();
// }
// Respawn docked UI
// super::ui::setup_docked_ui(commands.reborrow());
// The station info panel is spawned reactively by
// `hud::sync_hud_panels` once it sees the player's `Docked` component.
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::*;
@@ -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 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)]
pub struct FlightUi;
@@ -22,32 +29,16 @@ pub struct SpeedDisplay;
#[derive(Component)]
pub struct StatusPanel;
/// Setup the flight HUD when entering flight mode.
/// Spawn the flight HUD panels.
pub fn setup_flight_ui(mut commands: Commands) {
bevy::log::info!("Setting up flight HUD");
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);
});
spawn_speed_indicator(&mut commands);
spawn_status_panel(&mut commands);
}
fn spawn_speed_indicator(parent: &mut ChildSpawnerCommands) {
parent
/// Speed indicator (bottom-left).
fn spawn_speed_indicator(commands: &mut Commands) {
commands
.spawn((
Node {
position_type: PositionType::Absolute,
@@ -64,7 +55,6 @@ fn spawn_speed_indicator(parent: &mut ChildSpawnerCommands) {
FlightUi,
))
.with_children(|panel| {
// Label
panel.spawn((
Text::new("SPEED"),
TextFont {
@@ -73,8 +63,6 @@ fn spawn_speed_indicator(parent: &mut ChildSpawnerCommands) {
},
TextColor(TEXT_DIM),
));
// Speed value
panel.spawn((
Text::new("0 m/s"),
TextFont {
@@ -87,8 +75,9 @@ fn spawn_speed_indicator(parent: &mut ChildSpawnerCommands) {
});
}
fn spawn_status_panel(parent: &mut ChildSpawnerCommands) {
parent
/// Status panel (top-left).
fn spawn_status_panel(commands: &mut Commands) {
commands
.spawn((
Node {
position_type: PositionType::Absolute,
@@ -106,7 +95,6 @@ fn spawn_status_panel(parent: &mut ChildSpawnerCommands) {
StatusPanel,
))
.with_children(|panel| {
// Status label
panel.spawn((
Text::new("FLIGHT MODE"),
TextFont {
@@ -115,8 +103,6 @@ fn spawn_status_panel(parent: &mut ChildSpawnerCommands) {
},
TextColor(ACCENT),
));
// Flight status text
panel.spawn((
Text::new("Active"),
TextFont {
@@ -125,29 +111,18 @@ fn spawn_status_panel(parent: &mut ChildSpawnerCommands) {
},
TextColor(TEXT_BRIGHT),
));
// Controls hint
panel.spawn((
Node {
margin: UiRect::top(Val::Px(12.0)),
Text::new("Click: Set destination\nOrbit camera: Drag to rotate"),
TextFont {
font_size: 11.0,
..default()
},
FlightUi,
))
.with_children(|hint| {
hint.spawn((
Text::new("Click: Set destination\nOrbit camera: Drag to rotate"),
TextFont {
font_size: 11.0,
..default()
},
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(
flight_query: Query<&FlightState>,
mut speed_query: Query<&mut Text, With<SpeedDisplay>>,
@@ -156,15 +131,7 @@ pub fn update_flight_ui(
return;
};
// Update speed display
if let Ok(mut speed_text) = speed_query.single_mut() {
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 flight;
mod flight_ui;
mod hud;
mod operations;
mod scene;
mod target;
mod ui;
use bevy::ecs::system::SystemParam;
use bevy::prelude::*;
use crate::state::AppState;
@@ -22,7 +24,7 @@ pub use flight::{FlightState, FlightControlsPlugin};
pub use scene::ActiveSystem;
pub use target::{Targetable, TargetKind, SelectedTarget, TargetSelectionPlugin};
pub use actions::{ContextualActionPlugin, ActionType, ActionTriggeredEvent, ActionUi};
pub use operations::{TimedOperationPlugin, OperationKind, ActiveOperation};
pub use operations::TimedOperationPlugin;
pub struct InSystemPlugin;
@@ -37,29 +39,19 @@ impl Plugin for InSystemPlugin {
.add_plugins(TimedOperationPlugin)
.add_systems(
OnEnter(AppState::InGame),
(
scene::setup_in_system_view,
// UI removed - no longer needed
// ui::setup_docked_ui,
add_targetable_to_pois,
).chain(),
(scene::setup_in_system_view, add_targetable_to_pois).chain(),
)
.add_systems(
OnExit(AppState::InGame),
(
// UI removed - no longer needed
// ui::despawn_docked_ui,
// flight_ui::despawn_flight_ui,
scene::despawn_in_system_scene,
).chain(),
(hud::despawn_in_system_hud, scene::despawn_in_system_scene).chain(),
)
.add_systems(
Update,
(
// UI removed - no longer needed
// ui::refresh_docked_ui,
// ui::undock_button_handler,
// flight_ui::update_flight_ui,
hud::sync_hud_panels,
ui::refresh_docked_ui,
flight_ui::update_flight_ui,
complete_operations,
handle_action_triggered,
)
.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(
mut commands: Commands,
mut events: EventReader<ActionTriggeredEvent>,
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>>,
mut dispatch: ActionDispatch,
) {
let Ok(player_entity) = player_query.single() else {
return;
};
let now_ms = dispatch.time.elapsed_secs_f64() * 1000.0;
for event in events.read() {
bevy::log::info!("Action triggered: {:?}", event.action_type);
match event.action_type {
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 => {
// Get selected target
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 => {
// TODO: Implement docking operation
bevy::log::info!("Dock action triggered");
if let Ok(selected) = selected_target_query.single() {
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 => {
// TODO: Implement mining operation
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)
/// so they can be selected by the target selection system.
fn add_targetable_to_pois(

View File

@@ -83,13 +83,12 @@ impl Plugin for TimedOperationPlugin {
fn monitor_operations(
time: Res<Time>,
mut commands: Commands,
mut query: Query<(Entity, &mut ActiveOperation)>,
query: Query<(Entity, &ActiveOperation)>,
mut completed_events: EventWriter<OperationCompletedEvent>,
durations: Res<OperationDurations>,
) {
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;
if elapsed >= operation.duration_ms {
@@ -127,11 +126,12 @@ pub fn start_undocking(
commands: &mut Commands,
player_entity: Entity,
durations: &OperationDurations,
mut events: &mut EventWriter<OperationStartedEvent>,
events: &mut EventWriter<OperationStartedEvent>,
now_ms: f64,
) {
commands.entity(player_entity).insert(ActiveOperation {
kind: OperationKind::Undocking,
started_at: 0.0, // Will be set by the system
started_at: now_ms,
duration_ms: durations.undock,
target_name: "Undocking".to_string(),
});
@@ -149,11 +149,12 @@ pub fn start_travel(
player_entity: Entity,
target_name: String,
durations: &OperationDurations,
mut events: &mut EventWriter<OperationStartedEvent>,
events: &mut EventWriter<OperationStartedEvent>,
now_ms: f64,
) {
commands.entity(player_entity).insert(ActiveOperation {
kind: OperationKind::Travel,
started_at: 0.0,
started_at: now_ms,
duration_ms: durations.local_travel,
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 crate::gameplay::in_system::{DockedState, UndockEvent};
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_BORDER: Color = Color::srgb(0.25, 0.40, 0.62);
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 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 SUBTITLE_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 SIDE_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)]
pub struct DockedUi;
/// Marker for the station details text.
/// Marker for the station details text, refreshed each frame from the
/// [`CampaignDraft`] / [`DockedState`].
#[derive(Component)]
pub struct StationDetailsText;
/// Marker for the undock button.
#[derive(Component)]
pub struct UndockButton;
/// Spawn the station info panel as a top-level, corner-anchored overlay.
///
/// 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) {
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((
Node {
position_type: PositionType::Absolute,
@@ -80,7 +66,7 @@ fn spawn_station_panel(parent: &mut ChildSpawnerCommands) {
TextColor(TEXT_BRIGHT),
));
// Station details placeholder (will be updated by refresh_docked_ui)
// Station details (refreshed by `refresh_docked_ui`).
panel.spawn((
Text::new("Loading station data..."),
TextFont {
@@ -90,55 +76,25 @@ fn spawn_station_panel(parent: &mut ChildSpawnerCommands) {
TextColor(TEXT_DIM),
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(
campaign: Res<CampaignDraft>,
docked_state: Res<DockedState>,
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() {
return;
}
let mut text = match query.get_single_mut() {
Ok(t) => t,
Err(_) => return,
let Ok(mut text) = query.single_mut() else {
return;
};
let Some(starting_base) = &campaign.starting_base else {
@@ -151,22 +107,25 @@ pub fn refresh_docked_ui(
return;
};
// Find the system
let system_index = match galaxy.systems.iter().position(|s| s.id == starting_base.system_id) {
Some(idx) => idx,
None => {
text.0 = format!("System {} not found.", starting_base.system_id);
return;
}
// Find the system.
let Some(system_index) = galaxy
.systems
.iter()
.position(|s| s.id == starting_base.system_id)
else {
text.0 = format!("System {} not found.", starting_base.system_id);
return;
};
let system = &galaxy.systems[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_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);
text.0 = format!(
@@ -177,32 +136,6 @@ pub fn refresh_docked_ui(
system.id,
system.faction,
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();
}
}