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 <noreply@anthropic.com>
This commit is contained in:
2026-06-14 15:19:18 -04:00
parent d139dc08d9
commit 07316dbcd7
15 changed files with 1332 additions and 227 deletions

View File

@@ -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<GeneratedGalaxy>,
pub character: Option<CharacterDraft>,
pub starting_base: Option<StartingBaseSelection>,
}
#[derive(Debug, Clone)]
pub struct GeneratedGalaxy {
pub params: GalaxyParams,
pub systems: Vec<GeneratedGalaxySystem>,
pub contents: Vec<SystemContents>,
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());
}
}

View File

@@ -16,7 +16,7 @@ mod ui;
// exported so consumers can `use crate::gameplay::character_creation::Origin` // exported so consumers can `use crate::gameplay::character_creation::Origin`
// without us remembering to re-add the re-export the day they're needed. // without us remembering to re-add the re-export the day they're needed.
#[allow(unused_imports)] #[allow(unused_imports)]
pub use params::{CharacterDraft, Origin, StartingShip}; pub use params::{CharacterDraft, Origin, StartingShip, STARTING_SHIPS};
use bevy::prelude::*; use bevy::prelude::*;

View File

@@ -14,6 +14,7 @@
use bevy::{input::mouse::MouseWheel, prelude::*, ui::ScrollPosition, window::PrimaryWindow}; use bevy::{input::mouse::MouseWheel, prelude::*, ui::ScrollPosition, window::PrimaryWindow};
use super::{CharacterCreationButton, CharacterCreationSpawned}; use super::{CharacterCreationButton, CharacterCreationSpawned};
use crate::gameplay::campaign::CampaignDraft;
use crate::gameplay::character_creation::params::{ use crate::gameplay::character_creation::params::{
CharacterDraft, BACKSTORY_QUESTIONS, BACKSTORY_QUESTION_COUNT, CHARACTER_NAMES, ORIGINS, CharacterDraft, BACKSTORY_QUESTIONS, BACKSTORY_QUESTION_COUNT, CHARACTER_NAMES, ORIGINS,
STARTING_SHIPS, STARTING_SHIPS,
@@ -636,6 +637,7 @@ pub fn picker_button_handler(
pub fn action_button_handler( pub fn action_button_handler(
mut next_state: ResMut<NextState<crate::state::AppState>>, mut next_state: ResMut<NextState<crate::state::AppState>>,
draft: Res<CharacterDraft>, draft: Res<CharacterDraft>,
mut campaign: ResMut<CampaignDraft>,
query: Query<(&Interaction, &CharacterCreationButton), Changed<Interaction>>, query: Query<(&Interaction, &CharacterCreationButton), Changed<Interaction>>,
) { ) {
for (interaction, button) in &query { for (interaction, button) in &query {
@@ -644,7 +646,6 @@ pub fn action_button_handler(
} }
match button { match button {
CharacterCreationButton::Confirm => { CharacterCreationButton::Confirm => {
// TODO: persist `*draft` to SpacetimeDB before entering the game.
bevy::log::info!( bevy::log::info!(
"Confirming character: name={}, origin={}, ship={}, backstory={}, stats={}", "Confirming character: name={}, origin={}, ship={}, backstory={}, stats={}",
draft.name(), draft.name(),
@@ -653,6 +654,7 @@ pub fn action_button_handler(
format_backstory_log(&draft), format_backstory_log(&draft),
draft.stats().summary(), draft.stats().summary(),
); );
campaign.save_character(*draft);
next_state.set(crate::state::AppState::StartingBaseSelection); next_state.set(crate::state::AppState::StartingBaseSelection);
} }
CharacterCreationButton::Back => { CharacterCreationButton::Back => {

View File

@@ -6,10 +6,10 @@
//! live in [`super::ui`]. //! live in [`super::ui`].
mod axes; mod axes;
mod contents; pub mod contents;
mod orbits; pub mod orbits;
mod params; mod params;
mod poi; pub mod poi;
mod selection; mod selection;
mod ui; mod ui;
@@ -20,9 +20,10 @@ use rand::rngs::StdRng;
use rand::{Rng, SeedableRng}; use rand::{Rng, SeedableRng};
use crate::camera::apply_orbit_reset; use crate::camera::apply_orbit_reset;
use crate::gameplay::campaign::{GeneratedGalaxy, GeneratedGalaxySystem};
use crate::state::AppState; 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::{BeamParams, CoreParams, DiskParams};
pub use params::{GalaxyParams, SelectedStar}; pub use params::{GalaxyParams, SelectedStar};
use params::{MIN_SYSTEM_SPACING, NEAREST_NEIGHBOR_CONNECTIONS, SPACING_ATTEMPTS}; use params::{MIN_SYSTEM_SPACING, NEAREST_NEIGHBOR_CONNECTIONS, SPACING_ATTEMPTS};
@@ -146,14 +147,14 @@ fn setup_galaxy_scene(
mut materials: ResMut<Assets<StandardMaterial>>, mut materials: ResMut<Assets<StandardMaterial>>,
params: Res<GalaxyParams>, params: Res<GalaxyParams>,
) { ) {
let (systems, contents, connections) = generate_galaxy(&params); let galaxy = generate_galaxy(&params);
spawn_galaxy_scene( spawn_galaxy_scene(
&mut commands, &mut commands,
&mut meshes, &mut meshes,
&mut materials, &mut materials,
&systems, &galaxy.systems,
&contents, &galaxy.contents,
&connections, &galaxy.connections,
); );
} }
@@ -164,16 +165,10 @@ fn setup_galaxy_scene(
/// extended with per-system POI generation (planets, belts, stations, /// extended with per-system POI generation (planets, belts, stations,
/// anomalies, gas clouds, stargates). /// anomalies, gas clouds, stargates).
/// ///
/// Returns `(systems, contents_per_system, connections)`. The contents are /// Returns a reusable generated galaxy. The contents are returned in parallel
/// returned in parallel with the systems (same index) so the spawner can /// with the systems (same index) so the spawner can attach them as children of
/// attach them as children of each [`StarSystem`] entity. /// each [`StarSystem`] entity and later setup screens can reuse the same data.
fn generate_galaxy( pub fn generate_galaxy(params: &GalaxyParams) -> GeneratedGalaxy {
params: &GalaxyParams,
) -> (
Vec<GeneratedSystem>,
Vec<SystemContents>,
Vec<(usize, usize)>,
) {
let mut rng = StdRng::seed_from_u64(params.seed); let mut rng = StdRng::seed_from_u64(params.seed);
let systems = generate_system_positions(params, &mut rng); let systems = generate_system_positions(params, &mut rng);
let connections = build_connections(&systems); let connections = build_connections(&systems);
@@ -203,16 +198,49 @@ fn generate_galaxy(
.collect(); .collect();
contents::generate_stargates(&summaries, &mut contents, &connections); 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 { impl GeneratedGalaxy {
let (systems, contents, connections) = generate_galaxy(params); pub fn starting_base_map(&self) -> StartingBaseMap {
let outer_radius = params.size * OUTER_STARTING_SYSTEM_RADIUS; let outer_radius = self.params.size * OUTER_STARTING_SYSTEM_RADIUS;
let mut candidates = Vec::new(); let mut candidates = Vec::new();
let mut map_systems = Vec::with_capacity(systems.len()); let mut map_systems = Vec::with_capacity(self.systems.len());
for (system, contents) in systems.into_iter().zip(contents) { 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(); let distance_from_core = system.position.length();
if !system.is_core && distance_from_core >= outer_radius { if !system.is_core && distance_from_core >= outer_radius {
candidates.push(StartingBaseCandidate { candidates.push(StartingBaseCandidate {
@@ -221,12 +249,12 @@ pub fn generate_starting_base_map(params: &GalaxyParams) -> StartingBaseMap {
faction: system.faction, faction: system.faction,
security: system.security, security: system.security,
distance_from_core, distance_from_core,
contents, contents: contents.clone(),
}); });
} }
map_systems.push(StartingBaseMapSystem { map_systems.push(StartingBaseMapSystem {
id: system.id, id: system.id.clone(),
position: system.position, position: system.position,
color: system.color, color: system.color,
candidate_index: None, candidate_index: None,
@@ -252,7 +280,8 @@ pub fn generate_starting_base_map(params: &GalaxyParams) -> StartingBaseMap {
StartingBaseMap { StartingBaseMap {
systems: map_systems, systems: map_systems,
candidates, candidates,
connections, connections: self.connections.clone(),
}
} }
} }
@@ -644,7 +673,7 @@ fn spawn_galaxy_scene(
commands: &mut Commands, commands: &mut Commands,
meshes: &mut Assets<Mesh>, meshes: &mut Assets<Mesh>,
materials: &mut Assets<StandardMaterial>, materials: &mut Assets<StandardMaterial>,
systems: &[GeneratedSystem], systems: &[GeneratedGalaxySystem],
contents: &[SystemContents], contents: &[SystemContents],
connections: &[(usize, usize)], connections: &[(usize, usize)],
) { ) {
@@ -707,7 +736,7 @@ fn spawn_galaxy_scene(
let (mesh, material, scale) = if is_core { let (mesh, material, scale) = if is_core {
// Visual differentiation for the 7 Concord core systems. // Visual differentiation for the 7 Concord core systems.
(core_star_mesh.clone(), faction_materials[0].clone(), 1.1) (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 // Beam systems: distinct bright tint + slightly smaller
// so they read as a separate structural layer. // so they read as a separate structural layer.
(star_mesh.clone(), beam_material.clone(), 0.8) (star_mesh.clone(), beam_material.clone(), 0.8)
@@ -876,13 +905,57 @@ fn regenerate_galaxy_on_param_change(
commands.entity(entity).despawn(); commands.entity(entity).despawn();
} }
let (systems, contents, connections) = generate_galaxy(&params); let galaxy = generate_galaxy(&params);
spawn_galaxy_scene( spawn_galaxy_scene(
&mut commands, &mut commands,
&mut meshes, &mut meshes,
&mut materials, &mut materials,
&systems, &galaxy.systems,
&contents, &galaxy.contents,
&connections, &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(&params);
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())
}));
}
}

View File

@@ -19,7 +19,8 @@
use bevy::{input::mouse::MouseWheel, prelude::*, ui::ScrollPosition, window::PrimaryWindow}; 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::gameplay::galaxy::params::*;
use crate::state::AppState; use crate::state::AppState;
@@ -487,6 +488,7 @@ pub fn param_button_handler(
mut params: ResMut<GalaxyParams>, mut params: ResMut<GalaxyParams>,
mut selected_disk: ResMut<SelectedDisk>, mut selected_disk: ResMut<SelectedDisk>,
mut next_state: ResMut<NextState<AppState>>, mut next_state: ResMut<NextState<AppState>>,
mut campaign: ResMut<CampaignDraft>,
query: Query<(&Interaction, &ParamButton), Changed<Interaction>>, query: Query<(&Interaction, &ParamButton), Changed<Interaction>>,
) { ) {
for (interaction, button) in &query { for (interaction, button) in &query {
@@ -752,11 +754,15 @@ pub fn param_button_handler(
// CenterView is handled by `reset_view_button_handler` (needs Commands). // CenterView is handled by `reset_view_button_handler` (needs Commands).
ParamButton::CenterView => {} ParamButton::CenterView => {}
ParamButton::CreateGalaxy => { ParamButton::CreateGalaxy => {
let galaxy = generate_galaxy(&params);
bevy::log::info!( bevy::log::info!(
"Created galaxy: seed={}, generation={}", "Created galaxy: seed={}, generation={}, systems={}, connections={}",
params.seed, params.seed,
params.generation params.generation,
galaxy.systems.len(),
galaxy.connections.len(),
); );
campaign.save_galaxy(galaxy);
next_state.set(AppState::CharacterCreation); next_state.set(AppState::CharacterCreation);
} }
} }

View File

@@ -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<Entity>,
/// 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,
}

View File

@@ -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::<DockedState>()
.init_resource::<ActiveSystem>()
.add_event::<UndockEvent>()
.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)),
);
}
}

View File

@@ -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<Entity>,
}
/// 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<Assets<Mesh>>,
mut materials: ResMut<Assets<StandardMaterial>>,
campaign: Res<CampaignDraft>,
mut docked_state: ResMut<DockedState>,
mut active_system: ResMut<ActiveSystem>,
) {
// 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<Mesh>,
materials: &mut Assets<StandardMaterial>,
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<Mesh>,
materials: &mut Assets<StandardMaterial>,
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<Entity, With<InSystemSpawned>>,
camera_query: Query<Entity, With<MainCamera>>,
) {
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();
}
}

View File

@@ -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<CampaignDraft>,
docked_state: Res<DockedState>,
mut query: Query<&mut Text, With<StationDetailsText>>,
) {
// 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<UndockEvent>,
mut docked_state: ResMut<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
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<Entity, With<DockedUi>>) {
for entity in &query {
commands.entity(entity).despawn_recursive();
}
}

View File

@@ -1,5 +1,7 @@
pub mod campaign;
pub mod character_creation; pub mod character_creation;
pub mod galaxy; pub mod galaxy;
pub mod in_system;
pub mod movement; pub mod movement;
pub mod physics; pub mod physics;
pub mod star_map; pub mod star_map;

View File

@@ -9,33 +9,40 @@ mod ui;
use bevy::prelude::*; use bevy::prelude::*;
use crate::gameplay::galaxy::StartingBaseCandidate; use crate::gameplay::galaxy::{StartingBaseCandidate, orbits};
use crate::state::AppState; use crate::state::AppState;
// Public exports for POI spawning
pub use scene::{SpawnedPoiSystem, StartingBasePoi};
pub struct StartingBasePlugin; pub struct StartingBasePlugin;
impl Plugin for StartingBasePlugin { impl Plugin for StartingBasePlugin {
fn build(&self, app: &mut App) { fn build(&self, app: &mut App) {
app.init_resource::<StartingBaseDraft>() app.init_resource::<StartingBaseDraft>()
.init_resource::<StartingBaseFocusGoal>()
.init_resource::<scene::SpawnedPoiSystem>()
.add_systems( .add_systems(
OnEnter(AppState::StartingBaseSelection), OnEnter(AppState::StartingBaseSelection),
(scene::setup_starting_base_scene, ui::setup_starting_base_ui).chain(), (scene::setup_starting_base_scene, ui::setup_starting_base_ui).chain(),
) )
.add_systems( .add_systems(
OnExit(AppState::StartingBaseSelection), OnExit(AppState::StartingBaseSelection),
despawn_starting_base_ui, (despawn_starting_base_ui, scene::despawn_pois_on_exit).chain(),
) )
.add_systems( .add_systems(
Update, Update,
( (
escape_to_character_creation, escape_to_character_creation,
scene::starting_base_orbit_camera_control, 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, scene::animate_starting_base_selection,
ui::scroll_starting_base_panels, ui::scroll_starting_base_panels,
ui::candidate_button_handler,
ui::refresh_starting_base_ui, ui::refresh_starting_base_ui,
ui::action_button_handler, ui::action_button_handler,
scene::spawn_selected_pois,
orbits::advance_orbital_paths,
) )
.chain() .chain()
.run_if(in_state(AppState::StartingBaseSelection)), .run_if(in_state(AppState::StartingBaseSelection)),
@@ -57,6 +64,27 @@ pub struct StartingBaseSelection {
pub security: f32, 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)] #[derive(Component)]
pub struct StartingBaseSpawned; pub struct StartingBaseSpawned;

View File

@@ -1,12 +1,18 @@
//! 3D galaxy view for starting-base selection. //! 3D galaxy view for starting-base selection.
use bevy::ecs::system::SystemParam;
use bevy::input::mouse::{MouseMotion, MouseWheel}; use bevy::input::mouse::{MouseMotion, MouseWheel};
use bevy::prelude::*; use bevy::prelude::*;
use bevy::window::PrimaryWindow; use bevy::window::PrimaryWindow;
use super::{StartingBaseDraft, StartingBaseInputBlocker, StartingBaseSpawned}; use super::{
StartingBaseDraft, StartingBaseFocusGoal, StartingBaseFocusRequest, StartingBaseInputBlocker,
StartingBaseSpawned,
};
use crate::camera::{MainCamera, OrbitCamera}; 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 SELECTION_PIXEL_THRESHOLD: f32 = 18.0;
const SELECTED_SCALE: f32 = 2.4; 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_ZOOM_SENSITIVITY: f32 = 0.1;
const ORBIT_MIN_DISTANCE: f32 = 40.0; const ORBIT_MIN_DISTANCE: f32 = 40.0;
const ORBIT_MAX_DISTANCE: f32 = 1500.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)] #[derive(Component)]
struct StartingBaseSceneRoot; struct StartingBaseSceneRoot;
@@ -26,14 +39,184 @@ pub(super) struct StartingBaseSystemVisual {
candidate_index: Option<usize>, candidate_index: Option<usize>,
} }
/// 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<Entity>,
}
/// System to spawn POIs for the selected candidate
pub fn spawn_selected_pois(
mut commands: Commands,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<StandardMaterial>>,
campaign: Res<CampaignDraft>,
draft: Res<StartingBaseDraft>,
mut spawned: ResMut<SpawnedPoiSystem>,
systems: Query<(&GlobalTransform, &StartingBaseSystemVisual)>,
existing_pois: Query<&GlobalTransform, With<StartingBasePoi>>,
) {
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<SpawnedPoiSystem>,
pois: Query<Entity, With<StartingBasePoi>>,
) {
for entity in &pois {
commands.entity(entity).despawn();
}
spawned.entity = None;
}
pub fn setup_starting_base_scene( pub fn setup_starting_base_scene(
mut commands: Commands, mut commands: Commands,
mut meshes: ResMut<Assets<Mesh>>, mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<StandardMaterial>>, mut materials: ResMut<Assets<StandardMaterial>>,
params: Res<GalaxyParams>, campaign: Res<CampaignDraft>,
mut draft: ResMut<StartingBaseDraft>, mut draft: ResMut<StartingBaseDraft>,
mut next_state: ResMut<NextState<AppState>>,
) { ) {
let map = generate_starting_base_map(&params); 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.candidates = map.candidates;
draft.selected_index = None; 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( pub fn starting_base_orbit_camera_control(
mouse_input: Res<ButtonInput<MouseButton>>, mouse_input: Res<ButtonInput<MouseButton>>,
primary_window: Query<&Window, With<PrimaryWindow>>, primary_window: Query<&Window, With<PrimaryWindow>>,
@@ -157,86 +349,113 @@ pub fn starting_base_orbit_camera_control(
mut scroll_events: EventReader<MouseWheel>, mut scroll_events: EventReader<MouseWheel>,
mut camera_query: Query<(&mut Transform, &mut OrbitCamera), With<MainCamera>>, mut camera_query: Query<(&mut Transform, &mut OrbitCamera), With<MainCamera>>,
ui_nodes: Query<(&ComputedNode, &GlobalTransform), With<StartingBaseInputBlocker>>, ui_nodes: Query<(&ComputedNode, &GlobalTransform), With<StartingBaseInputBlocker>>,
mut focus: FocusParams,
) { ) {
let Ok((mut transform, mut orbit)) = camera_query.single_mut() else { let Ok((mut transform, mut orbit)) = camera_query.single_mut() else {
mouse_motion.clear();
scroll_events.clear();
return; return;
}; };
let cursor_over_ui = primary_window let Ok(window) = primary_window.single() else {
.single() return;
.ok() };
.map(|window| cursor_over_starting_base_ui(window, &ui_nodes))
.unwrap_or(false);
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 { if mouse_input.pressed(MouseButton::Left) && !cursor_over_ui {
for event in mouse_motion.read() { for event in mouse_motion.read() {
let yaw = Quat::from_rotation_y(-event.delta.x * ORBIT_ROTATE_SENSITIVITY); dragged = true;
let pitch = Quat::from_rotation_x(event.delta.y * ORBIT_ROTATE_SENSITIVITY);
orbit.rotation = (yaw * orbit.rotation * pitch).normalize(); // 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 { } else {
mouse_motion.clear(); mouse_motion.clear();
} }
let mut scrolled = false;
for event in scroll_events.read() { for event in scroll_events.read() {
if cursor_over_ui { if cursor_over_ui {
continue; continue;
} }
scrolled = true;
orbit.distance *= 1.0 - event.y * ORBIT_ZOOM_SENSITIVITY; 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); 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); let position = orbit.target + orbit.rotation * Vec3::new(0.0, 0.0, orbit.distance);
*transform = Transform::from_translation(position).with_rotation(orbit.rotation); *transform = Transform::from_translation(position).with_rotation(orbit.rotation);
} }
pub fn select_starting_base_on_click( pub fn focus_starting_base_camera(
mouse_input: Res<ButtonInput<MouseButton>>, mut commands: Commands,
primary_window: Query<&Window, With<PrimaryWindow>>, focus: Option<Res<StartingBaseFocusRequest>>,
camera_query: Query<(&Camera, &GlobalTransform), With<MainCamera>>,
systems: Query<(&GlobalTransform, &StartingBaseSystemVisual)>, systems: Query<(&GlobalTransform, &StartingBaseSystemVisual)>,
ui_nodes: Query<(&ComputedNode, &GlobalTransform), With<StartingBaseInputBlocker>>, mut goal: ResMut<StartingBaseFocusGoal>,
mut draft: ResMut<StartingBaseDraft>,
) { ) {
if !mouse_input.just_pressed(MouseButton::Left) { let Some(focus) = focus else {
return;
}
let Ok(window) = primary_window.single() else {
return; return;
}; };
if cursor_over_starting_base_ui(window, &ui_nodes) { let focus_index = focus.candidate_index;
return; commands.remove_resource::<StartingBaseFocusRequest>();
let Some(target) = systems.iter().find_map(|(transform, visual)| {
if visual.candidate_index == Some(focus_index) {
Some(transform.translation())
} else {
None
} }
let Some(cursor) = window.cursor_position() else { }) else {
return;
};
let Ok((camera, camera_gt)) = camera_query.single() else {
return; return;
}; };
let mut best: Option<(usize, f32)> = None; // Don't snap the camera — arm a goal the orbit control system tweens toward.
for (transform, visual) in &systems { // Re-clicking the same candidate overwrites it, smoothly retargeting a
let Some(candidate_index) = visual.candidate_index else { // still-in-flight tween.
continue; goal.target = target;
}; goal.distance = STARTING_BASE_FOCUS_DISTANCE.clamp(ORBIT_MIN_DISTANCE, ORBIT_MAX_DISTANCE);
let Ok(viewport) = camera.world_to_viewport(camera_gt, transform.translation()) else { goal.active = true;
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;
}
} }
fn cursor_over_starting_base_ui( fn cursor_over_starting_base_ui(
@@ -247,7 +466,6 @@ fn cursor_over_starting_base_ui(
return false; return false;
}; };
let cursor = cursor_logical * window.scale_factor();
for (node, transform) in nodes { for (node, transform) in nodes {
if node.is_empty() { if node.is_empty() {
continue; continue;
@@ -256,7 +474,11 @@ fn cursor_over_starting_base_ui(
let half = node.size() * 0.5; let half = node.size() * 0.5;
let min = center - half; let min = center - half;
let max = 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; return true;
} }
} }

View File

@@ -3,9 +3,10 @@
use bevy::{input::mouse::MouseWheel, prelude::*, ui::ScrollPosition, window::PrimaryWindow}; use bevy::{input::mouse::MouseWheel, prelude::*, ui::ScrollPosition, window::PrimaryWindow};
use super::{ use super::{
StartingBaseButton, StartingBaseDraft, StartingBaseInputBlocker, StartingBaseSelection, StartingBaseButton, StartingBaseDraft, StartingBaseFocusRequest, StartingBaseInputBlocker,
StartingBaseSpawned, StartingBaseSelection, StartingBaseSpawned,
}; };
use crate::gameplay::campaign::CampaignDraft;
use crate::gameplay::galaxy::{ use crate::gameplay::galaxy::{
Difficulty, GasKind, SignalKind, StartingBaseCandidate, SystemContents, 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 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 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_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_SELECTED_BG: Color = Color::srgb(0.16, 0.28, 0.44);
const BUTTON_CONFIRM_BG: Color = Color::srgb(0.10, 0.28, 0.22); 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 BODY_FONT_SIZE: f32 = 14.0;
const SMALL_FONT_SIZE: f32 = 12.0; const SMALL_FONT_SIZE: f32 = 12.0;
const BUTTON_FONT_SIZE: f32 = 16.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)] #[derive(Component)]
pub struct StartingBaseScrollViewport; pub struct StartingBaseScrollViewport;
@@ -54,17 +60,30 @@ pub fn setup_starting_base_ui(mut commands: Commands, draft: Res<StartingBaseDra
Node { Node {
width: Val::Percent(100.0), width: Val::Percent(100.0),
height: Val::Percent(100.0), height: Val::Percent(100.0),
display: Display::Flex, position_type: PositionType::Absolute,
flex_direction: FlexDirection::Column,
align_items: AlignItems::Center,
padding: UiRect::all(Val::Px(24.0)),
row_gap: Val::Px(12.0),
..default() ..default()
}, },
StartingBaseSpawned, StartingBaseSpawned,
)) ))
.with_children(|root| { .with_children(|root| {
root.spawn(( spawn_candidate_panel(root, &draft.candidates);
spawn_details_panel(root);
spawn_action_row(root);
});
}
fn spawn_candidate_panel(parent: &mut ChildSpawnerCommands, candidates: &[StartingBaseCandidate]) {
parent
.spawn(panel_node(Node {
position_type: PositionType::Absolute,
left: Val::Px(SIDE_MARGIN),
top: Val::Px(TOP_MARGIN),
bottom: Val::Px(BOTTOM_MARGIN),
width: Val::Px(LEFT_PANEL_WIDTH),
..default()
}))
.with_children(|panel| {
panel.spawn((
Text::new("Choose Starting Base"), Text::new("Choose Starting Base"),
TextFont { TextFont {
font_size: TITLE_FONT_SIZE, font_size: TITLE_FONT_SIZE,
@@ -72,7 +91,7 @@ pub fn setup_starting_base_ui(mut commands: Commands, draft: Res<StartingBaseDra
}, },
TextColor(TEXT_BRIGHT), TextColor(TEXT_BRIGHT),
)); ));
root.spawn(( panel.spawn((
Text::new("Outer-galaxy systems only. Inspect local POIs before committing."), Text::new("Outer-galaxy systems only. Inspect local POIs before committing."),
TextFont { TextFont {
font_size: SUBTITLE_FONT_SIZE, font_size: SUBTITLE_FONT_SIZE,
@@ -80,65 +99,6 @@ pub fn setup_starting_base_ui(mut commands: Commands, draft: Res<StartingBaseDra
}, },
TextColor(TEXT_DIM), TextColor(TEXT_DIM),
)); ));
root.spawn(Node {
width: Val::Percent(100.0),
max_width: Val::Px(CARD_WIDTH),
flex_grow: 1.0,
flex_direction: FlexDirection::Row,
column_gap: Val::Px(16.0),
..default()
})
.with_children(|columns| {
spawn_candidate_panel(columns, &draft.candidates);
spawn_details_panel(columns);
});
root.spawn(Node {
width: Val::Percent(100.0),
max_width: Val::Px(CARD_WIDTH),
flex_direction: FlexDirection::Row,
flex_wrap: FlexWrap::Wrap,
justify_content: JustifyContent::Center,
column_gap: Val::Px(16.0),
row_gap: Val::Px(8.0),
..default()
})
.insert(StartingBaseInputBlocker)
.with_children(|row| {
spawn_action_button(
row,
"Confirm Base",
BUTTON_DISABLED_BG,
BUTTON_CONFIRM_BORDER,
StartingBaseButton::Confirm,
Some((ConfirmButtonBg, ConfirmButtonText)),
);
spawn_action_button(
row,
"Back",
BUTTON_BG,
PANEL_BORDER,
StartingBaseButton::Back,
None,
);
});
root.spawn((
Text::new("Esc to return to character creation"),
TextFont {
font_size: SMALL_FONT_SIZE,
..default()
},
TextColor(TEXT_FADED),
));
});
}
fn spawn_candidate_panel(parent: &mut ChildSpawnerCommands, candidates: &[StartingBaseCandidate]) {
parent
.spawn(panel_node(Val::Percent(38.0)))
.with_children(|panel| {
panel.spawn(( panel.spawn((
Text::new(format!("Candidate Systems ({})", candidates.len())), Text::new(format!("Candidate Systems ({})", candidates.len())),
TextFont { TextFont {
@@ -183,7 +143,14 @@ fn spawn_candidate_panel(parent: &mut ChildSpawnerCommands, candidates: &[Starti
fn spawn_details_panel(parent: &mut ChildSpawnerCommands) { fn spawn_details_panel(parent: &mut ChildSpawnerCommands) {
parent parent
.spawn(panel_node(Val::Percent(62.0))) .spawn(panel_node(Node {
position_type: PositionType::Absolute,
right: Val::Px(SIDE_MARGIN),
top: Val::Px(TOP_MARGIN),
bottom: Val::Px(BOTTOM_MARGIN + ACTION_ROW_HEIGHT + 12.0),
width: Val::Px(RIGHT_PANEL_WIDTH),
..default()
}))
.with_children(|panel| { .with_children(|panel| {
panel.spawn(( panel.spawn((
Text::new("System Inspection"), Text::new("System Inspection"),
@@ -223,8 +190,45 @@ fn spawn_details_panel(parent: &mut ChildSpawnerCommands) {
}); });
} }
fn spawn_action_row(parent: &mut ChildSpawnerCommands) {
parent
.spawn((
Node {
position_type: PositionType::Absolute,
right: Val::Px(SIDE_MARGIN),
bottom: Val::Px(BOTTOM_MARGIN),
width: Val::Px(RIGHT_PANEL_WIDTH),
height: Val::Px(ACTION_ROW_HEIGHT),
flex_direction: FlexDirection::Row,
column_gap: Val::Px(12.0),
justify_content: JustifyContent::SpaceBetween,
align_items: AlignItems::Center,
..default()
},
StartingBaseInputBlocker,
))
.with_children(|row| {
spawn_action_button(
row,
"Confirm Base",
BUTTON_DISABLED_BG,
BUTTON_CONFIRM_BORDER,
StartingBaseButton::Confirm,
Some((ConfirmButtonBg, ConfirmButtonText)),
);
spawn_action_button(
row,
"Back",
BUTTON_BG,
PANEL_BORDER,
StartingBaseButton::Back,
None,
);
});
}
fn panel_node( fn panel_node(
width: Val, mut node: Node,
) -> ( ) -> (
Node, Node,
BackgroundColor, BackgroundColor,
@@ -232,16 +236,13 @@ fn panel_node(
BorderRadius, BorderRadius,
StartingBaseInputBlocker, 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 { 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()
},
BackgroundColor(PANEL_BG), BackgroundColor(PANEL_BG),
BorderColor(PANEL_BORDER), BorderColor(PANEL_BORDER),
BorderRadius::all(Val::Px(8.0)), BorderRadius::all(Val::Px(8.0)),
@@ -310,7 +311,7 @@ fn spawn_action_button(
let mut entity = parent.spawn(( let mut entity = parent.spawn((
Button, Button,
Node { Node {
width: Val::Px(210.0), width: Val::Px(204.0),
height: Val::Px(48.0), height: Val::Px(48.0),
justify_content: JustifyContent::Center, justify_content: JustifyContent::Center,
align_items: AlignItems::Center, align_items: AlignItems::Center,
@@ -361,21 +362,9 @@ pub fn scroll_starting_base_panels(
return; return;
}; };
let hovered = viewport_nodes.iter().find_map(|(node, transform)| { let hovered = viewport_nodes
let pos = transform.translation().truncate(); .iter()
let size = node.size(); .find_map(|(node, transform)| cursor_over_node(cursor_pos, node, transform));
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 Some(hovered_position) = hovered else { let Some(hovered_position) = hovered else {
return; return;
@@ -392,7 +381,30 @@ pub fn scroll_starting_base_panels(
} }
} }
fn cursor_over_node(
cursor_pos: Vec2,
node: &ComputedNode,
transform: &GlobalTransform,
) -> Option<Vec3> {
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( pub fn candidate_button_handler(
mut commands: Commands,
mut draft: ResMut<StartingBaseDraft>, mut draft: ResMut<StartingBaseDraft>,
query: Query<(&Interaction, &StartingBaseButton), Changed<Interaction>>, query: Query<(&Interaction, &StartingBaseButton), Changed<Interaction>>,
) { ) {
@@ -403,6 +415,9 @@ pub fn candidate_button_handler(
if let StartingBaseButton::Candidate(index) = *button { if let StartingBaseButton::Candidate(index) = *button {
if index < draft.candidates.len() { if index < draft.candidates.len() {
draft.selected_index = Some(index); 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 commands: Commands,
mut next_state: ResMut<NextState<AppState>>, mut next_state: ResMut<NextState<AppState>>,
draft: Res<StartingBaseDraft>, draft: Res<StartingBaseDraft>,
mut campaign: ResMut<CampaignDraft>,
query: Query<(&Interaction, &StartingBaseButton), Changed<Interaction>>, query: Query<(&Interaction, &StartingBaseButton), Changed<Interaction>>,
) { ) {
for (interaction, button) in &query { for (interaction, button) in &query {
@@ -497,6 +513,7 @@ pub fn action_button_handler(
selection.security, selection.security,
candidate.contents.total(), candidate.contents.total(),
); );
campaign.save_starting_base(selection.clone());
commands.insert_resource(selection); commands.insert_resource(selection);
next_state.set(AppState::InGame); next_state.set(AppState::InGame);
} }

View File

@@ -6,9 +6,11 @@ mod ui;
use bevy::prelude::*; use bevy::prelude::*;
use camera::orbit_camera_control; use camera::orbit_camera_control;
use gameplay::campaign::CampaignDraft;
use gameplay::{ use gameplay::{
character_creation::CharacterCreationPlugin, galaxy::GalaxyPlugin, movement::MovementPlugin, character_creation::CharacterCreationPlugin, galaxy::GalaxyPlugin, in_system::InSystemPlugin,
physics::PhysicsPlugin, star_map::StarMapPlugin, starting_base::StartingBasePlugin, movement::MovementPlugin, physics::PhysicsPlugin, star_map::StarMapPlugin,
starting_base::StartingBasePlugin,
}; };
use state::AppState; use state::AppState;
use ui::main_menu; use ui::main_menu;
@@ -23,12 +25,15 @@ fn main() {
..default() ..default()
}) })
.init_state::<AppState>() .init_state::<AppState>()
.init_resource::<CampaignDraft>()
.add_systems(Startup, camera::spawn_camera) .add_systems(Startup, camera::spawn_camera)
// Orbit controls only in inspection-style scenes. In-game will use a // Orbit controls only in inspection-style scenes. In-game will use a
// follow camera instead (not yet implemented). // follow camera instead (not yet implemented).
.add_systems( .add_systems(
Update, 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(OnEnter(AppState::MainMenu), main_menu::setup_main_menu)
.add_systems(OnExit(AppState::MainMenu), main_menu::despawn_main_menu) .add_systems(OnExit(AppState::MainMenu), main_menu::despawn_main_menu)
@@ -40,6 +45,7 @@ fn main() {
StarMapPlugin, StarMapPlugin,
CharacterCreationPlugin, CharacterCreationPlugin,
StartingBasePlugin, StartingBasePlugin,
InSystemPlugin,
)) ))
.run(); .run();
} }

View File

@@ -1,5 +1,6 @@
use bevy::prelude::*; use bevy::prelude::*;
use crate::gameplay::campaign::CampaignDraft;
use crate::state::AppState; use crate::state::AppState;
// ── Markers ───────────────────────────────────────────────────────────────── // ── Markers ─────────────────────────────────────────────────────────────────
@@ -138,6 +139,7 @@ pub fn despawn_main_menu(mut commands: Commands, query: Query<Entity, With<MainM
pub fn main_menu_buttons( pub fn main_menu_buttons(
mut next_state: ResMut<NextState<AppState>>, mut next_state: ResMut<NextState<AppState>>,
mut campaign: ResMut<CampaignDraft>,
mut exit: EventWriter<AppExit>, mut exit: EventWriter<AppExit>,
interaction_query: Query<(&Interaction, &MenuButton), Changed<Interaction>>, interaction_query: Query<(&Interaction, &MenuButton), Changed<Interaction>>,
) { ) {
@@ -145,7 +147,10 @@ pub fn main_menu_buttons(
if *interaction == Interaction::Pressed { if *interaction == Interaction::Pressed {
match button { match button {
MenuButton::ContinueGame => next_state.set(AppState::InGame), // Placeholder for now 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::Options => next_state.set(AppState::Options),
MenuButton::Exit => { MenuButton::Exit => {
exit.write(AppExit::Success); exit.write(AppExit::Success);