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`
// 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::*;

View File

@@ -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<NextState<crate::state::AppState>>,
draft: Res<CharacterDraft>,
mut campaign: ResMut<CampaignDraft>,
query: Query<(&Interaction, &CharacterCreationButton), Changed<Interaction>>,
) {
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 => {

View File

@@ -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<Assets<StandardMaterial>>,
params: Res<GalaxyParams>,
) {
let (systems, contents, connections) = generate_galaxy(&params);
let galaxy = generate_galaxy(&params);
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<GeneratedSystem>,
Vec<SystemContents>,
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<Mesh>,
materials: &mut Assets<StandardMaterial>,
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(&params);
let galaxy = generate_galaxy(&params);
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(&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 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<GalaxyParams>,
mut selected_disk: ResMut<SelectedDisk>,
mut next_state: ResMut<NextState<AppState>>,
mut campaign: ResMut<CampaignDraft>,
query: Query<(&Interaction, &ParamButton), Changed<Interaction>>,
) {
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(&params);
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);
}
}

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 galaxy;
pub mod in_system;
pub mod movement;
pub mod physics;
pub mod star_map;

View File

@@ -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::<StartingBaseDraft>()
.init_resource::<StartingBaseFocusGoal>()
.init_resource::<scene::SpawnedPoiSystem>()
.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;

View File

@@ -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<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(
mut commands: Commands,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<StandardMaterial>>,
params: Res<GalaxyParams>,
campaign: Res<CampaignDraft>,
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.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<ButtonInput<MouseButton>>,
primary_window: Query<&Window, With<PrimaryWindow>>,
@@ -157,86 +349,113 @@ pub fn starting_base_orbit_camera_control(
mut scroll_events: EventReader<MouseWheel>,
mut camera_query: Query<(&mut Transform, &mut OrbitCamera), With<MainCamera>>,
ui_nodes: Query<(&ComputedNode, &GlobalTransform), With<StartingBaseInputBlocker>>,
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<ButtonInput<MouseButton>>,
primary_window: Query<&Window, With<PrimaryWindow>>,
camera_query: Query<(&Camera, &GlobalTransform), With<MainCamera>>,
pub fn focus_starting_base_camera(
mut commands: Commands,
focus: Option<Res<StartingBaseFocusRequest>>,
systems: Query<(&GlobalTransform, &StartingBaseSystemVisual)>,
ui_nodes: Query<(&ComputedNode, &GlobalTransform), With<StartingBaseInputBlocker>>,
mut draft: ResMut<StartingBaseDraft>,
mut goal: ResMut<StartingBaseFocusGoal>,
) {
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::<StartingBaseFocusRequest>();
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;
}
}

View File

@@ -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<StartingBaseDra
Node {
width: Val::Percent(100.0),
height: Val::Percent(100.0),
display: Display::Flex,
flex_direction: FlexDirection::Column,
align_items: AlignItems::Center,
padding: UiRect::all(Val::Px(24.0)),
row_gap: Val::Px(12.0),
position_type: PositionType::Absolute,
..default()
},
StartingBaseSpawned,
))
.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"),
TextFont {
font_size: TITLE_FONT_SIZE,
@@ -72,7 +91,7 @@ pub fn setup_starting_base_ui(mut commands: Commands, draft: Res<StartingBaseDra
},
TextColor(TEXT_BRIGHT),
));
root.spawn((
panel.spawn((
Text::new("Outer-galaxy systems only. Inspect local POIs before committing."),
TextFont {
font_size: SUBTITLE_FONT_SIZE,
@@ -80,65 +99,6 @@ pub fn setup_starting_base_ui(mut commands: Commands, draft: Res<StartingBaseDra
},
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((
Text::new(format!("Candidate Systems ({})", candidates.len())),
TextFont {
@@ -183,7 +143,14 @@ fn spawn_candidate_panel(parent: &mut ChildSpawnerCommands, candidates: &[Starti
fn spawn_details_panel(parent: &mut ChildSpawnerCommands) {
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| {
panel.spawn((
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(
width: Val,
mut node: Node,
) -> (
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<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(
mut commands: Commands,
mut draft: ResMut<StartingBaseDraft>,
query: Query<(&Interaction, &StartingBaseButton), Changed<Interaction>>,
) {
@@ -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<NextState<AppState>>,
draft: Res<StartingBaseDraft>,
mut campaign: ResMut<CampaignDraft>,
query: Query<(&Interaction, &StartingBaseButton), Changed<Interaction>>,
) {
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);
}

View File

@@ -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::<AppState>()
.init_resource::<CampaignDraft>()
.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();
}

View File

@@ -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<Entity, With<MainM
pub fn main_menu_buttons(
mut next_state: ResMut<NextState<AppState>>,
mut campaign: ResMut<CampaignDraft>,
mut exit: EventWriter<AppExit>,
interaction_query: Query<(&Interaction, &MenuButton), Changed<Interaction>>,
) {
@@ -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);