From 07316dbcd7bb7171843502ee1125124876249b36 Mon Sep 17 00:00:00 2001 From: francy51 Date: Sun, 14 Jun 2026 15:19:18 -0400 Subject: [PATCH] feat(gameplay): implement in-system onboarding with docked station view Implement the final onboarding step where the player loads into their selected starting system docked at a station. New features: - Create in_system module for system-scale gameplay - Spawn player ship docked at highest-population station - Display station info panel with undock button - Position camera for cinematic docked view with orbit controls Implementation details: - in_system/mod.rs: Plugin setup with DockedState and ActiveSystem resources - in_system/scene.rs: System/POI spawning and player ship docked positioning - in_system/docked.rs: Docked state management and UndockEvent - in_system/ui.rs: Docked UI with station details and undock button - Reuse existing POI spawning patterns from galaxy/contents.rs - Select docking station by highest population (better for new players) Modified files: - Add in_system module exports to gameplay/mod.rs - Register InSystemPlugin in main.rs - Update orbit camera control for InGame state - Re-export GeneratedStation and STARTING_SHIPS for use by in_system The player now completes onboarding by loading into a system view with their ship docked at a station, ready for gameplay. Co-Authored-By: Claude Opus 4.8 --- apps/game/src/gameplay/campaign.rs | 112 ++++++ .../src/gameplay/character_creation/mod.rs | 2 +- .../src/gameplay/character_creation/ui.rs | 4 +- apps/game/src/gameplay/galaxy/mod.rs | 199 +++++++---- apps/game/src/gameplay/galaxy/ui.rs | 12 +- apps/game/src/gameplay/in_system/docked.rs | 46 +++ apps/game/src/gameplay/in_system/mod.rs | 46 +++ apps/game/src/gameplay/in_system/scene.rs | 328 +++++++++++++++++ apps/game/src/gameplay/in_system/ui.rs | 212 +++++++++++ apps/game/src/gameplay/mod.rs | 2 + apps/game/src/gameplay/starting_base/mod.rs | 36 +- apps/game/src/gameplay/starting_base/scene.rs | 330 +++++++++++++++--- apps/game/src/gameplay/starting_base/ui.rs | 211 ++++++----- apps/game/src/main.rs | 12 +- apps/game/src/ui/main_menu.rs | 7 +- 15 files changed, 1332 insertions(+), 227 deletions(-) create mode 100644 apps/game/src/gameplay/campaign.rs create mode 100644 apps/game/src/gameplay/in_system/docked.rs create mode 100644 apps/game/src/gameplay/in_system/mod.rs create mode 100644 apps/game/src/gameplay/in_system/scene.rs create mode 100644 apps/game/src/gameplay/in_system/ui.rs diff --git a/apps/game/src/gameplay/campaign.rs b/apps/game/src/gameplay/campaign.rs new file mode 100644 index 0000000..514b6ec --- /dev/null +++ b/apps/game/src/gameplay/campaign.rs @@ -0,0 +1,112 @@ +//! In-memory new-game campaign draft. +//! +//! This is the current persistence boundary for the Rust client: the accepted +//! galaxy, character draft, and starting-base choice survive state transitions +//! during new-game setup. A later SpacetimeDB integration can replace the +//! storage backend while keeping this data shape as the handoff contract. + +use bevy::prelude::*; + +use crate::gameplay::character_creation::CharacterDraft; +use crate::gameplay::galaxy::{GalaxyParams, SystemContents}; +use crate::gameplay::starting_base::StartingBaseSelection; + +#[derive(Resource, Debug, Clone, Default)] +pub struct CampaignDraft { + pub galaxy: Option, + pub character: Option, + pub starting_base: Option, +} + +#[derive(Debug, Clone)] +pub struct GeneratedGalaxy { + pub params: GalaxyParams, + pub systems: Vec, + pub contents: Vec, + pub connections: Vec<(usize, usize)>, +} + +#[derive(Debug, Clone)] +pub struct GeneratedGalaxySystem { + pub id: String, + pub name: String, + pub position: Vec3, + pub faction: &'static str, + pub faction_index: usize, + pub color: [f32; 3], + pub security: f32, + pub is_core: bool, + pub(crate) is_beam: bool, +} + +impl CampaignDraft { + pub fn save_galaxy(&mut self, galaxy: GeneratedGalaxy) { + self.galaxy = Some(galaxy); + self.character = None; + self.starting_base = None; + } + + pub fn save_character(&mut self, character: CharacterDraft) { + self.character = Some(character); + self.starting_base = None; + } + + pub fn save_starting_base(&mut self, selection: StartingBaseSelection) { + self.starting_base = Some(selection); + } + + pub fn reset_new_game(&mut self) { + *self = Self::default(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::gameplay::galaxy::generate_galaxy; + + #[test] + fn campaign_save_galaxy_clears_downstream_choices() { + let galaxy = generate_galaxy(&GalaxyParams::default()); + let character = CharacterDraft::default(); + let starting_base = StartingBaseSelection { + system_id: "g-0".to_string(), + system_name: "COR-100".to_string(), + faction: "Concord", + security: 1.0, + }; + let mut draft = CampaignDraft { + galaxy: None, + character: Some(character), + starting_base: Some(starting_base), + }; + + draft.save_galaxy(galaxy); + + assert!(draft.galaxy.is_some()); + assert!(draft.character.is_none()); + assert!(draft.starting_base.is_none()); + } + + #[test] + fn campaign_save_character_clears_starting_base() { + let galaxy = generate_galaxy(&GalaxyParams::default()); + let character = CharacterDraft::default(); + let starting_base = StartingBaseSelection { + system_id: "g-0".to_string(), + system_name: "COR-100".to_string(), + faction: "Concord", + security: 1.0, + }; + let mut draft = CampaignDraft { + galaxy: Some(galaxy), + character: None, + starting_base: Some(starting_base), + }; + + draft.save_character(character); + + assert!(draft.character.is_some()); + assert!(draft.starting_base.is_none()); + } +} diff --git a/apps/game/src/gameplay/character_creation/mod.rs b/apps/game/src/gameplay/character_creation/mod.rs index 2b2f519..161ff75 100644 --- a/apps/game/src/gameplay/character_creation/mod.rs +++ b/apps/game/src/gameplay/character_creation/mod.rs @@ -16,7 +16,7 @@ mod ui; // exported so consumers can `use crate::gameplay::character_creation::Origin` // without us remembering to re-add the re-export the day they're needed. #[allow(unused_imports)] -pub use params::{CharacterDraft, Origin, StartingShip}; +pub use params::{CharacterDraft, Origin, StartingShip, STARTING_SHIPS}; use bevy::prelude::*; diff --git a/apps/game/src/gameplay/character_creation/ui.rs b/apps/game/src/gameplay/character_creation/ui.rs index 8b4ac50..b4c03b7 100644 --- a/apps/game/src/gameplay/character_creation/ui.rs +++ b/apps/game/src/gameplay/character_creation/ui.rs @@ -14,6 +14,7 @@ use bevy::{input::mouse::MouseWheel, prelude::*, ui::ScrollPosition, window::PrimaryWindow}; use super::{CharacterCreationButton, CharacterCreationSpawned}; +use crate::gameplay::campaign::CampaignDraft; use crate::gameplay::character_creation::params::{ CharacterDraft, BACKSTORY_QUESTIONS, BACKSTORY_QUESTION_COUNT, CHARACTER_NAMES, ORIGINS, STARTING_SHIPS, @@ -636,6 +637,7 @@ pub fn picker_button_handler( pub fn action_button_handler( mut next_state: ResMut>, draft: Res, + mut campaign: ResMut, query: Query<(&Interaction, &CharacterCreationButton), Changed>, ) { for (interaction, button) in &query { @@ -644,7 +646,6 @@ pub fn action_button_handler( } match button { CharacterCreationButton::Confirm => { - // TODO: persist `*draft` to SpacetimeDB before entering the game. bevy::log::info!( "Confirming character: name={}, origin={}, ship={}, backstory={}, stats={}", draft.name(), @@ -653,6 +654,7 @@ pub fn action_button_handler( format_backstory_log(&draft), draft.stats().summary(), ); + campaign.save_character(*draft); next_state.set(crate::state::AppState::StartingBaseSelection); } CharacterCreationButton::Back => { diff --git a/apps/game/src/gameplay/galaxy/mod.rs b/apps/game/src/gameplay/galaxy/mod.rs index afe784e..845d200 100644 --- a/apps/game/src/gameplay/galaxy/mod.rs +++ b/apps/game/src/gameplay/galaxy/mod.rs @@ -6,10 +6,10 @@ //! live in [`super::ui`]. mod axes; -mod contents; -mod orbits; +pub mod contents; +pub mod orbits; mod params; -mod poi; +pub mod poi; mod selection; mod ui; @@ -20,9 +20,10 @@ use rand::rngs::StdRng; use rand::{Rng, SeedableRng}; use crate::camera::apply_orbit_reset; +use crate::gameplay::campaign::{GeneratedGalaxy, GeneratedGalaxySystem}; use crate::state::AppState; -pub use contents::{SystemContents, SystemContext, SystemSummary}; +pub use contents::{GeneratedStation, SystemContents, SystemContext, SystemSummary}; pub use params::{BeamParams, CoreParams, DiskParams}; pub use params::{GalaxyParams, SelectedStar}; use params::{MIN_SYSTEM_SPACING, NEAREST_NEIGHBOR_CONNECTIONS, SPACING_ATTEMPTS}; @@ -146,14 +147,14 @@ fn setup_galaxy_scene( mut materials: ResMut>, params: Res, ) { - let (systems, contents, connections) = generate_galaxy(¶ms); + let galaxy = generate_galaxy(¶ms); spawn_galaxy_scene( &mut commands, &mut meshes, &mut materials, - &systems, - &contents, - &connections, + &galaxy.systems, + &galaxy.contents, + &galaxy.connections, ); } @@ -164,16 +165,10 @@ fn setup_galaxy_scene( /// extended with per-system POI generation (planets, belts, stations, /// anomalies, gas clouds, stargates). /// -/// Returns `(systems, contents_per_system, connections)`. The contents are -/// returned in parallel with the systems (same index) so the spawner can -/// attach them as children of each [`StarSystem`] entity. -fn generate_galaxy( - params: &GalaxyParams, -) -> ( - Vec, - Vec, - Vec<(usize, usize)>, -) { +/// Returns a reusable generated galaxy. The contents are returned in parallel +/// with the systems (same index) so the spawner can attach them as children of +/// each [`StarSystem`] entity and later setup screens can reuse the same data. +pub fn generate_galaxy(params: &GalaxyParams) -> GeneratedGalaxy { let mut rng = StdRng::seed_from_u64(params.seed); let systems = generate_system_positions(params, &mut rng); let connections = build_connections(&systems); @@ -203,56 +198,90 @@ fn generate_galaxy( .collect(); contents::generate_stargates(&summaries, &mut contents, &connections); - (systems, contents, connections) + let systems = systems + .into_iter() + .map(|system| GeneratedGalaxySystem { + id: system.id, + name: system.name, + position: system.position, + faction: system.faction, + faction_index: system.faction_index, + color: system.color, + security: system.security, + is_core: system.is_core, + is_beam: matches!(system.origin, SystemOrigin::Beam { .. }), + }) + .collect(); + + GeneratedGalaxy { + params: params.clone(), + systems, + contents, + connections, + } } -pub fn generate_starting_base_map(params: &GalaxyParams) -> StartingBaseMap { - let (systems, contents, connections) = generate_galaxy(params); - let outer_radius = params.size * OUTER_STARTING_SYSTEM_RADIUS; - let mut candidates = Vec::new(); - let mut map_systems = Vec::with_capacity(systems.len()); +impl GeneratedGalaxy { + pub fn starting_base_map(&self) -> StartingBaseMap { + let outer_radius = self.params.size * OUTER_STARTING_SYSTEM_RADIUS; + let mut candidates = Vec::new(); + let mut map_systems = Vec::with_capacity(self.systems.len()); - for (system, contents) in systems.into_iter().zip(contents) { - let distance_from_core = system.position.length(); - if !system.is_core && distance_from_core >= outer_radius { - candidates.push(StartingBaseCandidate { + if self.systems.len() != self.contents.len() { + bevy::log::error!( + "Saved galaxy has mismatched systems/content arrays: systems={}, contents={}", + self.systems.len(), + self.contents.len() + ); + return StartingBaseMap { + systems: map_systems, + candidates, + connections: Vec::new(), + }; + } + + for (system, contents) in self.systems.iter().zip(self.contents.iter()) { + let distance_from_core = system.position.length(); + if !system.is_core && distance_from_core >= outer_radius { + candidates.push(StartingBaseCandidate { + id: system.id.clone(), + name: system.name.clone(), + faction: system.faction, + security: system.security, + distance_from_core, + contents: contents.clone(), + }); + } + + map_systems.push(StartingBaseMapSystem { id: system.id.clone(), - name: system.name.clone(), - faction: system.faction, - security: system.security, - distance_from_core, - contents, + position: system.position, + color: system.color, + candidate_index: None, }); } - map_systems.push(StartingBaseMapSystem { - id: system.id, - position: system.position, - color: system.color, - candidate_index: None, + candidates.sort_by(|a, b| { + b.distance_from_core + .partial_cmp(&a.distance_from_core) + .unwrap_or(std::cmp::Ordering::Equal) + .then_with(|| a.name.cmp(&b.name)) }); - } - candidates.sort_by(|a, b| { - b.distance_from_core - .partial_cmp(&a.distance_from_core) - .unwrap_or(std::cmp::Ordering::Equal) - .then_with(|| a.name.cmp(&b.name)) - }); - - for (index, candidate) in candidates.iter().enumerate() { - if let Some(system) = map_systems - .iter_mut() - .find(|system| system.id == candidate.id) - { - system.candidate_index = Some(index); + for (index, candidate) in candidates.iter().enumerate() { + if let Some(system) = map_systems + .iter_mut() + .find(|system| system.id == candidate.id) + { + system.candidate_index = Some(index); + } } - } - StartingBaseMap { - systems: map_systems, - candidates, - connections, + StartingBaseMap { + systems: map_systems, + candidates, + connections: self.connections.clone(), + } } } @@ -644,7 +673,7 @@ fn spawn_galaxy_scene( commands: &mut Commands, meshes: &mut Assets, materials: &mut Assets, - systems: &[GeneratedSystem], + systems: &[GeneratedGalaxySystem], contents: &[SystemContents], connections: &[(usize, usize)], ) { @@ -707,7 +736,7 @@ fn spawn_galaxy_scene( let (mesh, material, scale) = if is_core { // Visual differentiation for the 7 Concord core systems. (core_star_mesh.clone(), faction_materials[0].clone(), 1.1) - } else if matches!(sys.origin, SystemOrigin::Beam { .. }) { + } else if sys.is_beam { // Beam systems: distinct bright tint + slightly smaller // so they read as a separate structural layer. (star_mesh.clone(), beam_material.clone(), 0.8) @@ -876,13 +905,57 @@ fn regenerate_galaxy_on_param_change( commands.entity(entity).despawn(); } - let (systems, contents, connections) = generate_galaxy(¶ms); + let galaxy = generate_galaxy(¶ms); spawn_galaxy_scene( &mut commands, &mut meshes, &mut materials, - &systems, - &contents, - &connections, + &galaxy.systems, + &galaxy.contents, + &galaxy.connections, ); } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn generate_galaxy_persists_params_and_parallel_arrays() { + let params = GalaxyParams::default(); + let galaxy = generate_galaxy(¶ms); + + assert_eq!(galaxy.params.seed, params.seed); + assert_eq!(galaxy.systems.len(), galaxy.contents.len()); + assert!(!galaxy.systems.is_empty()); + assert!(galaxy + .connections + .iter() + .all(|(a, b)| *a < galaxy.systems.len() && *b < galaxy.systems.len())); + } + + #[test] + fn starting_base_map_uses_saved_galaxy_without_regeneration() { + let galaxy = generate_galaxy(&GalaxyParams::default()); + let map = galaxy.starting_base_map(); + let outer_radius = galaxy.params.size * OUTER_STARTING_SYSTEM_RADIUS; + + assert!(map.candidates.iter().all(|candidate| { + galaxy + .systems + .iter() + .any(|system| system.id == candidate.id) + })); + assert!(map + .candidates + .iter() + .all(|candidate| candidate.distance_from_core >= outer_radius)); + assert!(map.candidates.iter().all(|candidate| { + let index = galaxy + .systems + .iter() + .position(|system| system.id == candidate.id); + index.is_some_and(|index| galaxy.contents[index].total() == candidate.contents.total()) + })); + } +} diff --git a/apps/game/src/gameplay/galaxy/ui.rs b/apps/game/src/gameplay/galaxy/ui.rs index 97c640d..a92836d 100644 --- a/apps/game/src/gameplay/galaxy/ui.rs +++ b/apps/game/src/gameplay/galaxy/ui.rs @@ -19,7 +19,8 @@ use bevy::{input::mouse::MouseWheel, prelude::*, ui::ScrollPosition, window::PrimaryWindow}; -use super::GalaxySpawned; +use super::{generate_galaxy, GalaxySpawned}; +use crate::gameplay::campaign::CampaignDraft; use crate::gameplay::galaxy::params::*; use crate::state::AppState; @@ -487,6 +488,7 @@ pub fn param_button_handler( mut params: ResMut, mut selected_disk: ResMut, mut next_state: ResMut>, + mut campaign: ResMut, query: Query<(&Interaction, &ParamButton), Changed>, ) { for (interaction, button) in &query { @@ -752,11 +754,15 @@ pub fn param_button_handler( // CenterView is handled by `reset_view_button_handler` (needs Commands). ParamButton::CenterView => {} ParamButton::CreateGalaxy => { + let galaxy = generate_galaxy(¶ms); bevy::log::info!( - "Created galaxy: seed={}, generation={}", + "Created galaxy: seed={}, generation={}, systems={}, connections={}", params.seed, - params.generation + params.generation, + galaxy.systems.len(), + galaxy.connections.len(), ); + campaign.save_galaxy(galaxy); next_state.set(AppState::CharacterCreation); } } diff --git a/apps/game/src/gameplay/in_system/docked.rs b/apps/game/src/gameplay/in_system/docked.rs new file mode 100644 index 0000000..42b4cac --- /dev/null +++ b/apps/game/src/gameplay/in_system/docked.rs @@ -0,0 +1,46 @@ +//! Docked state management. +//! +//! Tracks the player's current docking status and the station they're docked at. + +use bevy::prelude::*; + +/// Tracks the player's docked state within a system. +#[derive(Resource, Debug, Clone, Default)] +pub struct DockedState { + /// The entity ID of the station the player is docked at. + pub station_entity: Option, + /// The ID of the system the player is in. + pub system_id: String, + /// Whether the player is currently docked. + pub is_docked: bool, +} + +impl DockedState { + /// Create a new docked state for the given system. + pub fn new(system_id: String) -> Self { + Self { + station_entity: None, + system_id, + is_docked: true, + } + } + + /// Dock at a specific station. + pub fn dock_at(&mut self, station: Entity) { + self.station_entity = Some(station); + self.is_docked = true; + } + + /// Undock from the current station. + pub fn undock(&mut self) { + self.station_entity = None; + self.is_docked = false; + } +} + +/// Event fired when the player undocks from a station. +#[derive(Event, Debug, Clone)] +pub struct UndockEvent { + /// The station entity the player is undocking from. + pub station_entity: Entity, +} diff --git a/apps/game/src/gameplay/in_system/mod.rs b/apps/game/src/gameplay/in_system/mod.rs new file mode 100644 index 0000000..0bc3cd9 --- /dev/null +++ b/apps/game/src/gameplay/in_system/mod.rs @@ -0,0 +1,46 @@ +//! In-system gameplay module. +//! +//! Handles the system-scale view where the player is docked at a station +//! within their selected starting system. This is the entry point to actual +//! gameplay after onboarding. + +mod docked; +mod scene; +mod ui; + +use bevy::prelude::*; + +use crate::state::AppState; + +pub use docked::{DockedState, UndockEvent}; +pub use scene::ActiveSystem; + +pub struct InSystemPlugin; + +impl Plugin for InSystemPlugin { + fn build(&self, app: &mut App) { + app.init_resource::() + .init_resource::() + .add_event::() + .add_systems( + OnEnter(AppState::InGame), + (scene::setup_in_system_view, ui::setup_docked_ui).chain(), + ) + .add_systems( + OnExit(AppState::InGame), + ( + ui::despawn_docked_ui, + scene::despawn_in_system_scene, + ).chain(), + ) + .add_systems( + Update, + ( + ui::refresh_docked_ui, + ui::undock_button_handler, + ) + .chain() + .run_if(in_state(AppState::InGame)), + ); + } +} diff --git a/apps/game/src/gameplay/in_system/scene.rs b/apps/game/src/gameplay/in_system/scene.rs new file mode 100644 index 0000000..a29472c --- /dev/null +++ b/apps/game/src/gameplay/in_system/scene.rs @@ -0,0 +1,328 @@ +//! In-system scene setup and spawning. +//! +//! Handles spawning the star, POIs, and player ship docked at a station. + +use bevy::prelude::*; + +use crate::camera::{MainCamera, OrbitCamera}; +use crate::gameplay::campaign::CampaignDraft; +use crate::gameplay::character_creation::STARTING_SHIPS; +use crate::gameplay::galaxy::{ + contents, Massive, Luminosity, MassLock, BoundingVolume, Identifiable, + SystemContents, GeneratedStation, +}; +use crate::gameplay::in_system::{DockedState}; +use crate::state::AppState; + +/// Tracks the currently active system for gameplay. +#[derive(Resource, Debug, Clone, Default)] +pub struct ActiveSystem { + pub system_id: String, + pub system_name: String, + pub star_entity: Option, +} + +/// Marker for entities spawned in the in-system view. +#[derive(Component)] +pub struct InSystemSpawned; + +/// Marker for the player ship entity. +#[derive(Component)] +pub struct PlayerShip; + +/// Marker for docked state on the player. +#[derive(Component)] +pub struct Docked { + pub station_entity: Entity, +} + +/// Station marker component for tracking which station player is at. +#[derive(Component)] +pub struct DockingTarget { + pub station_name: String, +} + +/// Offset from station where player ship spawns when docked. +const DOCKED_OFFSET: Vec3 = Vec3::new(1.5, 0.0, 0.0); + +/// Distance for camera to view the docked scene cinematically. +const DOCKED_CAMERA_DISTANCE: f32 = 8.0; + +/// Slight upward tilt for cinematic docked view. +const DOCKED_CAMERA_PITCH: f32 = 0.3; + +pub fn setup_in_system_view( + mut commands: Commands, + mut meshes: ResMut>, + mut materials: ResMut>, + campaign: Res, + mut docked_state: ResMut, + mut active_system: ResMut, +) { + // Get the selected starting base + let Some(starting_base) = &campaign.starting_base else { + bevy::log::error!("No starting base selected in CampaignDraft"); + return; + }; + + // Get the galaxy data + let Some(galaxy) = &campaign.galaxy else { + bevy::log::error!("No galaxy data in CampaignDraft"); + return; + }; + + // Find the selected system + let system_index = match galaxy.systems.iter().position(|s| s.id == starting_base.system_id) { + Some(idx) => idx, + None => { + bevy::log::error!("Selected system {} not found in galaxy", starting_base.system_id); + return; + } + }; + + let system = &galaxy.systems[system_index]; + let system_contents = &galaxy.contents[system_index]; + + bevy::log::info!( + "Setting up in-system view for: {} ({})", + system.name, + system.id + ); + + // Select docking station (highest population, or first if none) + let docking_station = select_docking_station(system_contents); + + let Some(docking_station) = docking_station else { + bevy::log::warn!("No stations found in system {}, spawning without docking", system.id); + // Spawn scene without docking + spawn_system_scene( + &mut commands, + &mut meshes, + &mut materials, + system, + system_contents, + None, + ); + return; + }; + + bevy::log::info!( + "Selected docking station: {} (population: {})", + docking_station.name, + docking_station.population + ); + + // Spawn the full scene with docking + let (star_entity, station_entity) = spawn_system_scene( + &mut commands, + &mut meshes, + &mut materials, + system, + system_contents, + Some(docking_station), + ); + + // Update resources + docked_state.system_id = system.id.clone(); + docked_state.is_docked = true; + docked_state.station_entity = Some(station_entity); + + active_system.system_id = system.id.clone(); + active_system.system_name = system.name.clone(); + active_system.star_entity = Some(star_entity); + + // Position camera for cinematic docked view + setup_docked_camera(&mut commands); +} + +/// Select the station with highest population for docking. +fn select_docking_station(contents: &SystemContents) -> Option<&GeneratedStation> { + contents + .stations + .iter() + .max_by_key(|s| s.population) + .or_else(|| contents.stations.first()) +} + +/// Calculate orbital position from orbit radius and phase. +/// Matches the logic in galaxy/contents.rs but defined here since that's private. +fn orbital_position(orbit: f32, phase: f32) -> Vec3 { + Vec3::new(phase.cos() * orbit, 0.0, phase.sin() * orbit) +} + +/// Spawn the system scene including star, POIs, and player ship. +/// Returns (star_entity, docking_station_entity). +fn spawn_system_scene( + commands: &mut Commands, + meshes: &mut Assets, + materials: &mut Assets, + system: &crate::gameplay::campaign::GeneratedGalaxySystem, + contents: &SystemContents, + docking_station: Option<&GeneratedStation>, +) -> (Entity, Entity) { + // Create content assets for spawning + let content_assets = contents::ContentAssets::new(meshes, materials); + + // Spawn the system root + let system_root = commands + .spawn(( + Transform::default(), + Visibility::default(), + InheritedVisibility::default(), + InSystemSpawned, + GlobalTransform::default(), + )) + .id(); + + // Spawn the star + let star_entity = commands + .spawn(( + crate::gameplay::galaxy::Star, + Massive { mass: 5000.0 }, + Luminosity { value: 50.0 }, + MassLock { radius: 5.0 }, + BoundingVolume { radius: 1.5 }, + Identifiable { + id: format!("{}-star", system.id), + display_name: system.name.clone(), + classification: crate::gameplay::galaxy::poi::Classification::Celestial, + }, + Transform::from_scale(Vec3::splat(1.5)), + )) + .set_parent(system_root) + .id(); + + // Spawn all POI children (planets, stations, etc.) + commands.entity(system_root).with_children(|parent| { + let ctx = contents::SystemContext { + id: &system.id, + name: &system.name, + faction: system.faction, + security: system.security, + is_core: system.is_core, + }; + + contents::spawn_system_contents( + parent, + &ctx, + contents, + star_entity, + &content_assets, + ); + }); + + // If we have a docking station, spawn player ship docked at it + let station_entity = if let Some(station) = docking_station { + // Calculate station position + let station_position = orbital_position(station.orbit, station.phase); + + // Spawn player ship at docked offset + spawn_player_ship_docked( + commands, + meshes, + materials, + &station.name, + station_position, + system_root, + ) + } else { + Entity::PLACEHOLDER + }; + + (star_entity, station_entity) +} + +/// Spawn the player ship docked at a station. +/// Returns the player ship entity. +fn spawn_player_ship_docked( + commands: &mut Commands, + meshes: &mut Assets, + materials: &mut Assets, + station_name: &str, + station_position: Vec3, + system_root: Entity, +) -> Entity { + // Ship position: docked offset from station + let ship_position = station_position + DOCKED_OFFSET; + + // Get the first starting ship type (for now, always use the frigate) + let ship_data = STARTING_SHIPS.first().unwrap_or(&STARTING_SHIPS[0]); + + bevy::log::info!( + "Spawning player ship: {} docked at {}", + ship_data.name, + station_name + ); + + // Create a simple ship visual (using a scaled, colored cube for now) + let ship_entity = commands + .spawn(( + PlayerShip, + Docked { + station_entity: Entity::PLACEHOLDER, // Will be set when we find the actual station + }, + Transform::from_translation(ship_position).with_scale(Vec3::splat(0.12)), + Visibility::default(), + InheritedVisibility::default(), + InSystemSpawned, + )) + .set_parent(system_root) + .id(); + + // Add mesh and material + commands.entity(ship_entity).insert(( + Mesh3d(meshes.add(Cuboid::new(1.0, 0.4, 1.5))), + MeshMaterial3d(materials.add(StandardMaterial { + base_color: Color::srgb(0.3, 0.5, 0.7), + emissive: LinearRgba::new(0.1, 0.15, 0.2, 1.0), + metallic: 0.8, + perceptual_roughness: 0.3, + ..default() + })), + )); + + ship_entity +} + +/// Setup camera for cinematic docked view. +fn setup_docked_camera(commands: &mut Commands) { + // Position camera for a cinematic view looking at the docked scene + // Offset back and up, angled down slightly + let camera_distance = DOCKED_CAMERA_DISTANCE; + let camera_pitch = DOCKED_CAMERA_PITCH; + let yaw = Quat::from_rotation_y(0.0); + let pitch = Quat::from_rotation_x(camera_pitch); + let rotation = yaw * pitch; + + let offset = rotation * Vec3::new(0.0, 0.0, camera_distance); + let position = Vec3::new(DOCKED_OFFSET.x * 0.5, 1.0, 0.0) + offset; + + commands.spawn(( + Camera3d::default(), + Transform::from_translation(position) + .with_rotation(rotation) + .looking_at(Vec3::new(DOCKED_OFFSET.x * 0.5, 0.0, 0.0), Vec3::Y), + MainCamera, + OrbitCamera { + target: Vec3::new(DOCKED_OFFSET.x * 0.5, 0.0, 0.0), + distance: camera_distance, + rotation, + }, + )); +} + +/// Despawn all entities spawned for the in-system view. +pub fn despawn_in_system_scene( + mut commands: Commands, + query: Query>, + camera_query: Query>, +) { + for entity in &query { + commands.entity(entity).despawn_recursive(); + } + + // Also despawn the main camera (it gets recreated on next enter) + for entity in &camera_query { + commands.entity(entity).despawn(); + } +} diff --git a/apps/game/src/gameplay/in_system/ui.rs b/apps/game/src/gameplay/in_system/ui.rs new file mode 100644 index 0000000..46ec7ce --- /dev/null +++ b/apps/game/src/gameplay/in_system/ui.rs @@ -0,0 +1,212 @@ +//! UI for the docked state within a station. + +use bevy::prelude::*; + +use crate::gameplay::in_system::{DockedState, UndockEvent}; +use crate::gameplay::campaign::CampaignDraft; + +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. +#[derive(Component)] +pub struct DockedUi; + +/// Marker for the station details text. +#[derive(Component)] +pub struct StationDetailsText; + +/// Marker for the undock button. +#[derive(Component)] +pub struct UndockButton; + +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, + left: Val::Px(SIDE_MARGIN), + top: Val::Px(TOP_MARGIN), + width: Val::Px(PANEL_WIDTH), + flex_direction: FlexDirection::Column, + row_gap: Val::Px(12.0), + padding: UiRect::all(Val::Px(16.0)), + border: UiRect::all(Val::Px(1.0)), + ..default() + }, + BackgroundColor(PANEL_BG), + BorderColor(PANEL_BORDER), + BorderRadius::all(Val::Px(8.0)), + DockedUi, + )) + .with_children(|panel| { + // Title + panel.spawn(( + Text::new("Docked"), + TextFont { + font_size: TITLE_FONT_SIZE, + ..default() + }, + TextColor(TEXT_BRIGHT), + )); + + // Station details placeholder (will be updated by refresh_docked_ui) + panel.spawn(( + Text::new("Loading station data..."), + TextFont { + font_size: BODY_FONT_SIZE, + ..default() + }, + 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), + )); + }); + }); +} + +pub fn refresh_docked_ui( + campaign: Res, + docked_state: Res, + mut query: Query<&mut Text, With>, +) { + // 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 Some(starting_base) = &campaign.starting_base else { + text.0 = "No station data available.".to_string(); + return; + }; + + let Some(galaxy) = &campaign.galaxy else { + text.0 = "No galaxy data available.".to_string(); + 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; + } + }; + + let system = &galaxy.systems[system_index]; + let contents = &galaxy.contents[system_index]; + + // Find the docking station (highest 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_pop = station.map(|s| s.population).unwrap_or(0); + + text.0 = format!( + "{}\n{}\n\nSystem: {} ({})\nFaction: {}\nSecurity: {:.2}\nStation Pop: {}", + station_name, + "─".repeat(24), + system.name, + system.id, + system.faction, + system.security, + station_pop + ); +} + +pub fn undock_button_handler( + mut commands: Commands, + mut events: EventWriter, + mut docked_state: ResMut, + query: Query<(&Interaction, &UndockButton), Changed>, +) { + for (interaction, _) in &query { + if *interaction == Interaction::Pressed { + bevy::log::info!("Undock button pressed"); + + // Fire the undock event + if let Some(station_entity) = docked_state.station_entity { + events.send(UndockEvent { station_entity }); + } + + // Update docked state + docked_state.undock(); + + // For now, just log - actual undocking gameplay will come later + bevy::log::info!("Player undocked (placeholder - full undocking TBD)"); + } + } +} + +pub fn despawn_docked_ui(mut commands: Commands, query: Query>) { + for entity in &query { + commands.entity(entity).despawn_recursive(); + } +} diff --git a/apps/game/src/gameplay/mod.rs b/apps/game/src/gameplay/mod.rs index 5b7554e..481c378 100644 --- a/apps/game/src/gameplay/mod.rs +++ b/apps/game/src/gameplay/mod.rs @@ -1,5 +1,7 @@ +pub mod campaign; pub mod character_creation; pub mod galaxy; +pub mod in_system; pub mod movement; pub mod physics; pub mod star_map; diff --git a/apps/game/src/gameplay/starting_base/mod.rs b/apps/game/src/gameplay/starting_base/mod.rs index b1f10c2..4277717 100644 --- a/apps/game/src/gameplay/starting_base/mod.rs +++ b/apps/game/src/gameplay/starting_base/mod.rs @@ -9,33 +9,40 @@ mod ui; use bevy::prelude::*; -use crate::gameplay::galaxy::StartingBaseCandidate; +use crate::gameplay::galaxy::{StartingBaseCandidate, orbits}; use crate::state::AppState; +// Public exports for POI spawning +pub use scene::{SpawnedPoiSystem, StartingBasePoi}; + pub struct StartingBasePlugin; impl Plugin for StartingBasePlugin { fn build(&self, app: &mut App) { app.init_resource::() + .init_resource::() + .init_resource::() .add_systems( OnEnter(AppState::StartingBaseSelection), (scene::setup_starting_base_scene, ui::setup_starting_base_ui).chain(), ) .add_systems( OnExit(AppState::StartingBaseSelection), - despawn_starting_base_ui, + (despawn_starting_base_ui, scene::despawn_pois_on_exit).chain(), ) .add_systems( Update, ( escape_to_character_creation, scene::starting_base_orbit_camera_control, - scene::select_starting_base_on_click, + ui::candidate_button_handler, + scene::focus_starting_base_camera, scene::animate_starting_base_selection, ui::scroll_starting_base_panels, - ui::candidate_button_handler, ui::refresh_starting_base_ui, ui::action_button_handler, + scene::spawn_selected_pois, + orbits::advance_orbital_paths, ) .chain() .run_if(in_state(AppState::StartingBaseSelection)), @@ -57,6 +64,27 @@ pub struct StartingBaseSelection { pub security: f32, } +#[derive(Resource, Debug, Clone, Copy)] +pub struct StartingBaseFocusRequest { + pub candidate_index: usize, +} + +/// Resolved focus target the starting-base camera tweens toward. Set by +/// [`crate::gameplay::starting_base::scene::focus_starting_base_camera`] when a +/// candidate is selected, then approached gradually by the orbit control +/// system, which clears `active` once the camera arrives (or the user takes +/// manual control by dragging / scrolling). +/// +/// Persistent rather than inserted/removed on demand so the consumer can mutate +/// it via a single `ResMut` and keep the orbit control system's parameter count +/// manageable. +#[derive(Resource, Debug, Clone, Copy, Default)] +pub struct StartingBaseFocusGoal { + pub target: Vec3, + pub distance: f32, + pub active: bool, +} + #[derive(Component)] pub struct StartingBaseSpawned; diff --git a/apps/game/src/gameplay/starting_base/scene.rs b/apps/game/src/gameplay/starting_base/scene.rs index 9d40797..d54232b 100644 --- a/apps/game/src/gameplay/starting_base/scene.rs +++ b/apps/game/src/gameplay/starting_base/scene.rs @@ -1,12 +1,18 @@ //! 3D galaxy view for starting-base selection. +use bevy::ecs::system::SystemParam; use bevy::input::mouse::{MouseMotion, MouseWheel}; use bevy::prelude::*; use bevy::window::PrimaryWindow; -use super::{StartingBaseDraft, StartingBaseInputBlocker, StartingBaseSpawned}; +use super::{ + StartingBaseDraft, StartingBaseFocusGoal, StartingBaseFocusRequest, StartingBaseInputBlocker, + StartingBaseSpawned, +}; use crate::camera::{MainCamera, OrbitCamera}; -use crate::gameplay::galaxy::{generate_starting_base_map, GalaxyParams, StartingBaseMapSystem}; +use crate::gameplay::campaign::CampaignDraft; +use crate::gameplay::galaxy::{StartingBaseMapSystem, contents}; +use crate::state::AppState; const SELECTION_PIXEL_THRESHOLD: f32 = 18.0; const SELECTED_SCALE: f32 = 2.4; @@ -17,6 +23,13 @@ const ORBIT_ROTATE_SENSITIVITY: f32 = 0.005; const ORBIT_ZOOM_SENSITIVITY: f32 = 0.1; const ORBIT_MIN_DISTANCE: f32 = 40.0; const ORBIT_MAX_DISTANCE: f32 = 1500.0; +const STARTING_BASE_FOCUS_DISTANCE: f32 = 90.0; +/// Exponential-damp speed for the camera focus tween. Matches the idiom used +/// by `animate_starting_base_selection`; ~6.0 settles over roughly a second. +const CAMERA_LERP_SPEED: f32 = 6.0; +/// Snap-to-exact and release the focus goal once target and distance are both +/// within this tolerance. Without it the asymptotic damp would never release. +const CAMERA_FOCUS_EPSILON: f32 = 0.5; #[derive(Component)] struct StartingBaseSceneRoot; @@ -26,14 +39,184 @@ pub(super) struct StartingBaseSystemVisual { candidate_index: Option, } +/// Marker for POI entities spawned in starting base view +#[derive(Component)] +pub struct StartingBasePoi; + +/// Resource tracking the currently spawned POI system entity +#[derive(Resource, Default)] +pub struct SpawnedPoiSystem { + entity: Option, +} + +/// System to spawn POIs for the selected candidate +pub fn spawn_selected_pois( + mut commands: Commands, + mut meshes: ResMut>, + mut materials: ResMut>, + campaign: Res, + draft: Res, + mut spawned: ResMut, + systems: Query<(&GlobalTransform, &StartingBaseSystemVisual)>, + existing_pois: Query<&GlobalTransform, With>, +) { + let Some(candidate_index) = draft.selected_index else { + // No selection, despawn any existing POIs + if let Some(entity) = spawned.entity.take() { + commands.entity(entity).despawn(); + } + return; + }; + + let Some(candidate) = draft.candidates.get(candidate_index) else { + return; + }; + + let Some(galaxy) = campaign.galaxy.as_ref() else { + return; + }; + + // Find the system position for this candidate + let system_position = systems.iter().find_map(|(transform, visual)| { + if visual.candidate_index == Some(candidate_index) { + Some(transform.translation()) + } else { + None + } + }); + + let target_position = match system_position { + Some(pos) => pos, + None => return, + }; + + // Check if we already spawned POIs for this system and if the position matches + let should_respawn = if let Some(entity) = spawned.entity { + // Check if the entity still exists by querying for it + if let Ok(gt) = existing_pois.get(entity) { + // If the entity still exists and position matches, keep it + if gt.translation().distance(target_position) < 1.0 { + false + } else { + // Position changed, need to respawn + commands.entity(entity).despawn(); + true + } + } else { + // Entity doesn't exist, need to spawn + true + } + } else { + // No existing POI system, need to spawn + true + }; + + if !should_respawn { + return; + } + + // Find the system index in the galaxy + let system_index = galaxy.systems.iter().position(|s| s.id == candidate.id); + let Some(system_index) = system_index else { + return; + }; + + let Some(system_contents) = galaxy.contents.get(system_index) else { + return; + }; + + let system = &galaxy.systems[system_index]; + + // Create content assets for spawning + let content_assets = contents::ContentAssets::new(&mut meshes, &mut materials); + + // Spawn the POI system entity + let ctx = contents::SystemContext { + id: &system.id, + name: &system.name, + faction: system.faction, + security: system.security, + is_core: system.is_core, + }; + + // Spawn the POI system root + let poi_root = commands + .spawn(( + Transform::from_translation(target_position), + Visibility::default(), + InheritedVisibility::default(), + StartingBasePoi, + GlobalTransform::default(), + )) + .id(); + + // Spawn a star entity for the POIs to orbit around + let star_entity = commands + .spawn(( + crate::gameplay::galaxy::Star, + crate::gameplay::galaxy::Massive { mass: 5000.0 }, + crate::gameplay::galaxy::Luminosity { value: 50.0 }, + crate::gameplay::galaxy::MassLock { radius: 5.0 }, + crate::gameplay::galaxy::BoundingVolume { radius: 1.5 }, + crate::gameplay::galaxy::poi::Identifiable { + id: format!("{}-star", system.id), + display_name: system.name.clone(), + classification: crate::gameplay::galaxy::poi::Classification::Celestial, + }, + Transform::default(), + )) + .set_parent(poi_root) + .id(); + + // Spawn all POI children + commands.entity(poi_root).with_children(|parent| { + contents::spawn_system_contents( + parent, + &ctx, + system_contents, + star_entity, + &content_assets, + ); + }); + + spawned.entity = Some(poi_root); +} + +/// System to despawn POIs when exiting the starting base scene +pub fn despawn_pois_on_exit( + mut commands: Commands, + mut spawned: ResMut, + pois: Query>, +) { + for entity in &pois { + commands.entity(entity).despawn(); + } + spawned.entity = None; +} + pub fn setup_starting_base_scene( mut commands: Commands, mut meshes: ResMut>, mut materials: ResMut>, - params: Res, + campaign: Res, mut draft: ResMut, + mut next_state: ResMut>, ) { - let map = generate_starting_base_map(¶ms); + let Some(galaxy) = campaign.galaxy.as_ref() else { + bevy::log::error!("Starting base selection entered without an accepted galaxy"); + next_state.set(AppState::Galaxy); + return; + }; + if galaxy.systems.len() != galaxy.contents.len() { + bevy::log::error!( + "Starting base selection entered with an invalid galaxy: systems={}, contents={}", + galaxy.systems.len(), + galaxy.contents.len() + ); + next_state.set(AppState::Galaxy); + return; + } + let map = galaxy.starting_base_map(); draft.candidates = map.candidates; draft.selected_index = None; @@ -150,6 +333,15 @@ fn spawn_connection( )); } +/// Bundles the frame clock and the (mutable) focus goal so the orbit control +/// system stays under clippy's argument-count threshold while keeping the +/// per-frame tween co-located with manual-input cancellation. +#[derive(SystemParam)] +pub(super) struct FocusParams<'w> { + time: Res<'w, Time>, + goal: ResMut<'w, StartingBaseFocusGoal>, +} + pub fn starting_base_orbit_camera_control( mouse_input: Res>, primary_window: Query<&Window, With>, @@ -157,86 +349,113 @@ pub fn starting_base_orbit_camera_control( mut scroll_events: EventReader, mut camera_query: Query<(&mut Transform, &mut OrbitCamera), With>, ui_nodes: Query<(&ComputedNode, &GlobalTransform), With>, + mut focus: FocusParams, ) { let Ok((mut transform, mut orbit)) = camera_query.single_mut() else { - mouse_motion.clear(); - scroll_events.clear(); return; }; - let cursor_over_ui = primary_window - .single() - .ok() - .map(|window| cursor_over_starting_base_ui(window, &ui_nodes)) - .unwrap_or(false); + let Ok(window) = primary_window.single() else { + return; + }; + let cursor_over_ui = cursor_over_starting_base_ui(window, &ui_nodes); + + // Track whether the user actively manipulated the camera this frame so an + // in-flight focus tween can be cancelled. A bare click (no motion delta) + // does not count — only real drag/scroll takes over. + let mut dragged = false; if mouse_input.pressed(MouseButton::Left) && !cursor_over_ui { for event in mouse_motion.read() { - let yaw = Quat::from_rotation_y(-event.delta.x * ORBIT_ROTATE_SENSITIVITY); - let pitch = Quat::from_rotation_x(event.delta.y * ORBIT_ROTATE_SENSITIVITY); - orbit.rotation = (yaw * orbit.rotation * pitch).normalize(); + dragged = true; + + // iPhone-style orbit controls: dragging in a direction moves the camera that way + // around the target + let yaw_delta = event.delta.x * ORBIT_ROTATE_SENSITIVITY; + let pitch_delta = event.delta.y * ORBIT_ROTATE_SENSITIVITY; + + // Get current euler angles from the quaternion + let (current_yaw, current_pitch, current_roll) = orbit.rotation.to_euler(EulerRot::YXZ); + + // Apply deltas (inverted for iPhone-style: drag left = orbit left) + // No clamping - allow full 360° rotation + let new_yaw = current_yaw - yaw_delta; + let new_pitch = current_pitch + pitch_delta; + + // Reconstruct quaternion from euler angles + orbit.rotation = Quat::from_euler(EulerRot::YXZ, new_yaw, new_pitch, current_roll); } } else { mouse_motion.clear(); } + let mut scrolled = false; for event in scroll_events.read() { if cursor_over_ui { continue; } + scrolled = true; orbit.distance *= 1.0 - event.y * ORBIT_ZOOM_SENSITIVITY; } + // Manual camera input cancels any pending focus tween so control returns + // immediately to the user. + if dragged || scrolled { + focus.goal.active = false; + } + + // Tween toward the focus goal using the same exponential-damp idiom as the + // selection-scale animation. + if focus.goal.active { + let dt = focus.time.delta_secs().min(0.1); + let alpha = (dt * CAMERA_LERP_SPEED).clamp(0.0, 1.0); + let target_delta = focus.goal.target - orbit.target; + let distance_delta = focus.goal.distance - orbit.distance; + orbit.target += target_delta * alpha; + orbit.distance += distance_delta * alpha; + + let reached = target_delta.length() < CAMERA_FOCUS_EPSILON + && distance_delta.abs() < CAMERA_FOCUS_EPSILON; + if reached { + orbit.target = focus.goal.target; + orbit.distance = focus.goal.distance; + focus.goal.active = false; + } + } + orbit.distance = orbit.distance.clamp(ORBIT_MIN_DISTANCE, ORBIT_MAX_DISTANCE); let position = orbit.target + orbit.rotation * Vec3::new(0.0, 0.0, orbit.distance); *transform = Transform::from_translation(position).with_rotation(orbit.rotation); } -pub fn select_starting_base_on_click( - mouse_input: Res>, - primary_window: Query<&Window, With>, - camera_query: Query<(&Camera, &GlobalTransform), With>, +pub fn focus_starting_base_camera( + mut commands: Commands, + focus: Option>, systems: Query<(&GlobalTransform, &StartingBaseSystemVisual)>, - ui_nodes: Query<(&ComputedNode, &GlobalTransform), With>, - mut draft: ResMut, + mut goal: ResMut, ) { - if !mouse_input.just_pressed(MouseButton::Left) { - return; - } - let Ok(window) = primary_window.single() else { + let Some(focus) = focus else { return; }; - if cursor_over_starting_base_ui(window, &ui_nodes) { - return; - } - let Some(cursor) = window.cursor_position() else { - return; - }; - let Ok((camera, camera_gt)) = camera_query.single() else { + let focus_index = focus.candidate_index; + commands.remove_resource::(); + + let Some(target) = systems.iter().find_map(|(transform, visual)| { + if visual.candidate_index == Some(focus_index) { + Some(transform.translation()) + } else { + None + } + }) else { return; }; - let mut best: Option<(usize, f32)> = None; - for (transform, visual) in &systems { - let Some(candidate_index) = visual.candidate_index else { - continue; - }; - let Ok(viewport) = camera.world_to_viewport(camera_gt, transform.translation()) else { - continue; - }; - let distance = viewport.distance(cursor); - if distance >= SELECTION_PIXEL_THRESHOLD { - continue; - } - if best.is_none_or(|(_, current)| distance < current) { - best = Some((candidate_index, distance)); - } - } - - let new_selection = best.map(|(candidate_index, _)| candidate_index); - if draft.selected_index != new_selection { - draft.selected_index = new_selection; - } + // Don't snap the camera — arm a goal the orbit control system tweens toward. + // Re-clicking the same candidate overwrites it, smoothly retargeting a + // still-in-flight tween. + goal.target = target; + goal.distance = STARTING_BASE_FOCUS_DISTANCE.clamp(ORBIT_MIN_DISTANCE, ORBIT_MAX_DISTANCE); + goal.active = true; } fn cursor_over_starting_base_ui( @@ -247,7 +466,6 @@ fn cursor_over_starting_base_ui( return false; }; - let cursor = cursor_logical * window.scale_factor(); for (node, transform) in nodes { if node.is_empty() { continue; @@ -256,7 +474,11 @@ fn cursor_over_starting_base_ui( let half = node.size() * 0.5; let min = center - half; let max = center + half; - if cursor.x >= min.x && cursor.x <= max.x && cursor.y >= min.y && cursor.y <= max.y { + if cursor_logical.x >= min.x + && cursor_logical.x <= max.x + && cursor_logical.y >= min.y + && cursor_logical.y <= max.y + { return true; } } diff --git a/apps/game/src/gameplay/starting_base/ui.rs b/apps/game/src/gameplay/starting_base/ui.rs index 336a000..42812af 100644 --- a/apps/game/src/gameplay/starting_base/ui.rs +++ b/apps/game/src/gameplay/starting_base/ui.rs @@ -3,9 +3,10 @@ use bevy::{input::mouse::MouseWheel, prelude::*, ui::ScrollPosition, window::PrimaryWindow}; use super::{ - StartingBaseButton, StartingBaseDraft, StartingBaseInputBlocker, StartingBaseSelection, - StartingBaseSpawned, + StartingBaseButton, StartingBaseDraft, StartingBaseFocusRequest, StartingBaseInputBlocker, + StartingBaseSelection, StartingBaseSpawned, }; +use crate::gameplay::campaign::CampaignDraft; use crate::gameplay::galaxy::{ Difficulty, GasKind, SignalKind, StartingBaseCandidate, SystemContents, }; @@ -15,7 +16,6 @@ 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 TEXT_FADED: Color = Color::srgb(0.42, 0.52, 0.68); const BUTTON_BG: Color = Color::srgb(0.10, 0.14, 0.22); const BUTTON_SELECTED_BG: Color = Color::srgb(0.16, 0.28, 0.44); const BUTTON_CONFIRM_BG: Color = Color::srgb(0.10, 0.28, 0.22); @@ -28,7 +28,13 @@ const PANEL_TITLE_FONT_SIZE: f32 = 18.0; const BODY_FONT_SIZE: f32 = 14.0; const SMALL_FONT_SIZE: f32 = 12.0; const BUTTON_FONT_SIZE: f32 = 16.0; -const CARD_WIDTH: f32 = 1040.0; + +const SIDE_MARGIN: f32 = 16.0; +const TOP_MARGIN: f32 = 16.0; +const BOTTOM_MARGIN: f32 = 16.0; +const LEFT_PANEL_WIDTH: f32 = 320.0; +const RIGHT_PANEL_WIDTH: f32 = 420.0; +const ACTION_ROW_HEIGHT: f32 = 56.0; #[derive(Component)] pub struct StartingBaseScrollViewport; @@ -54,17 +60,30 @@ pub fn setup_starting_base_ui(mut commands: Commands, draft: Res ( Node, BackgroundColor, @@ -232,16 +236,13 @@ fn panel_node( BorderRadius, StartingBaseInputBlocker, ) { + node.flex_direction = FlexDirection::Column; + node.row_gap = Val::Px(10.0); + node.padding = UiRect::all(Val::Px(16.0)); + node.border = UiRect::all(Val::Px(1.0)); + ( - Node { - width, - height: Val::Percent(100.0), - flex_direction: FlexDirection::Column, - row_gap: Val::Px(10.0), - padding: UiRect::all(Val::Px(16.0)), - border: UiRect::all(Val::Px(1.0)), - ..default() - }, + node, BackgroundColor(PANEL_BG), BorderColor(PANEL_BORDER), BorderRadius::all(Val::Px(8.0)), @@ -310,7 +311,7 @@ fn spawn_action_button( let mut entity = parent.spawn(( Button, Node { - width: Val::Px(210.0), + width: Val::Px(204.0), height: Val::Px(48.0), justify_content: JustifyContent::Center, align_items: AlignItems::Center, @@ -361,21 +362,9 @@ pub fn scroll_starting_base_panels( return; }; - let hovered = viewport_nodes.iter().find_map(|(node, transform)| { - let pos = transform.translation().truncate(); - let size = node.size(); - let min = pos - size * 0.5; - let max = pos + size * 0.5; - if cursor_pos.x >= min.x - && cursor_pos.x <= max.x - && cursor_pos.y >= min.y - && cursor_pos.y <= max.y - { - Some(transform.translation()) - } else { - None - } - }); + let hovered = viewport_nodes + .iter() + .find_map(|(node, transform)| cursor_over_node(cursor_pos, node, transform)); let Some(hovered_position) = hovered else { return; @@ -392,7 +381,30 @@ pub fn scroll_starting_base_panels( } } +fn cursor_over_node( + cursor_pos: Vec2, + node: &ComputedNode, + transform: &GlobalTransform, +) -> Option { + let position = transform.translation(); + let center = position.truncate(); + let half = node.size() * 0.5; + let min = center - half; + let max = center + half; + + if cursor_pos.x >= min.x + && cursor_pos.x <= max.x + && cursor_pos.y >= min.y + && cursor_pos.y <= max.y + { + Some(position) + } else { + None + } +} + pub fn candidate_button_handler( + mut commands: Commands, mut draft: ResMut, query: Query<(&Interaction, &StartingBaseButton), Changed>, ) { @@ -403,6 +415,9 @@ pub fn candidate_button_handler( if let StartingBaseButton::Candidate(index) = *button { if index < draft.candidates.len() { draft.selected_index = Some(index); + commands.insert_resource(StartingBaseFocusRequest { + candidate_index: index, + }); } } } @@ -471,6 +486,7 @@ pub fn action_button_handler( mut commands: Commands, mut next_state: ResMut>, draft: Res, + mut campaign: ResMut, query: Query<(&Interaction, &StartingBaseButton), Changed>, ) { for (interaction, button) in &query { @@ -497,6 +513,7 @@ pub fn action_button_handler( selection.security, candidate.contents.total(), ); + campaign.save_starting_base(selection.clone()); commands.insert_resource(selection); next_state.set(AppState::InGame); } diff --git a/apps/game/src/main.rs b/apps/game/src/main.rs index 933428c..2f3afaf 100644 --- a/apps/game/src/main.rs +++ b/apps/game/src/main.rs @@ -6,9 +6,11 @@ mod ui; use bevy::prelude::*; use camera::orbit_camera_control; +use gameplay::campaign::CampaignDraft; use gameplay::{ - character_creation::CharacterCreationPlugin, galaxy::GalaxyPlugin, movement::MovementPlugin, - physics::PhysicsPlugin, star_map::StarMapPlugin, starting_base::StartingBasePlugin, + character_creation::CharacterCreationPlugin, galaxy::GalaxyPlugin, in_system::InSystemPlugin, + movement::MovementPlugin, physics::PhysicsPlugin, star_map::StarMapPlugin, + starting_base::StartingBasePlugin, }; use state::AppState; use ui::main_menu; @@ -23,12 +25,15 @@ fn main() { ..default() }) .init_state::() + .init_resource::() .add_systems(Startup, camera::spawn_camera) // Orbit controls only in inspection-style scenes. In-game will use a // follow camera instead (not yet implemented). .add_systems( Update, - orbit_camera_control.run_if(in_state(AppState::Galaxy)), + orbit_camera_control.run_if( + in_state(AppState::Galaxy).or(in_state(AppState::InGame)) + ), ) .add_systems(OnEnter(AppState::MainMenu), main_menu::setup_main_menu) .add_systems(OnExit(AppState::MainMenu), main_menu::despawn_main_menu) @@ -40,6 +45,7 @@ fn main() { StarMapPlugin, CharacterCreationPlugin, StartingBasePlugin, + InSystemPlugin, )) .run(); } diff --git a/apps/game/src/ui/main_menu.rs b/apps/game/src/ui/main_menu.rs index 1745a02..4fe6cf9 100644 --- a/apps/game/src/ui/main_menu.rs +++ b/apps/game/src/ui/main_menu.rs @@ -1,5 +1,6 @@ use bevy::prelude::*; +use crate::gameplay::campaign::CampaignDraft; use crate::state::AppState; // ── Markers ───────────────────────────────────────────────────────────────── @@ -138,6 +139,7 @@ pub fn despawn_main_menu(mut commands: Commands, query: Query>, + mut campaign: ResMut, mut exit: EventWriter, interaction_query: Query<(&Interaction, &MenuButton), Changed>, ) { @@ -145,7 +147,10 @@ pub fn main_menu_buttons( if *interaction == Interaction::Pressed { match button { MenuButton::ContinueGame => next_state.set(AppState::InGame), // Placeholder for now - MenuButton::NewGame => next_state.set(AppState::Galaxy), + MenuButton::NewGame => { + campaign.reset_new_game(); + next_state.set(AppState::Galaxy); + } MenuButton::Options => next_state.set(AppState::Options), MenuButton::Exit => { exit.write(AppExit::Success);