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:
112
apps/game/src/gameplay/campaign.rs
Normal file
112
apps/game/src/gameplay/campaign.rs
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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::*;
|
||||||
|
|
||||||
|
|||||||
@@ -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 => {
|
||||||
|
|||||||
@@ -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(¶ms);
|
let galaxy = generate_galaxy(¶ms);
|
||||||
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(¶ms);
|
let galaxy = generate_galaxy(¶ms);
|
||||||
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(¶ms);
|
||||||
|
|
||||||
|
assert_eq!(galaxy.params.seed, params.seed);
|
||||||
|
assert_eq!(galaxy.systems.len(), galaxy.contents.len());
|
||||||
|
assert!(!galaxy.systems.is_empty());
|
||||||
|
assert!(galaxy
|
||||||
|
.connections
|
||||||
|
.iter()
|
||||||
|
.all(|(a, b)| *a < galaxy.systems.len() && *b < galaxy.systems.len()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn starting_base_map_uses_saved_galaxy_without_regeneration() {
|
||||||
|
let galaxy = generate_galaxy(&GalaxyParams::default());
|
||||||
|
let map = galaxy.starting_base_map();
|
||||||
|
let outer_radius = galaxy.params.size * OUTER_STARTING_SYSTEM_RADIUS;
|
||||||
|
|
||||||
|
assert!(map.candidates.iter().all(|candidate| {
|
||||||
|
galaxy
|
||||||
|
.systems
|
||||||
|
.iter()
|
||||||
|
.any(|system| system.id == candidate.id)
|
||||||
|
}));
|
||||||
|
assert!(map
|
||||||
|
.candidates
|
||||||
|
.iter()
|
||||||
|
.all(|candidate| candidate.distance_from_core >= outer_radius));
|
||||||
|
assert!(map.candidates.iter().all(|candidate| {
|
||||||
|
let index = galaxy
|
||||||
|
.systems
|
||||||
|
.iter()
|
||||||
|
.position(|system| system.id == candidate.id);
|
||||||
|
index.is_some_and(|index| galaxy.contents[index].total() == candidate.contents.total())
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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(¶ms);
|
||||||
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
46
apps/game/src/gameplay/in_system/docked.rs
Normal file
46
apps/game/src/gameplay/in_system/docked.rs
Normal 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,
|
||||||
|
}
|
||||||
46
apps/game/src/gameplay/in_system/mod.rs
Normal file
46
apps/game/src/gameplay/in_system/mod.rs
Normal 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)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
328
apps/game/src/gameplay/in_system/scene.rs
Normal file
328
apps/game/src/gameplay/in_system/scene.rs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
212
apps/game/src/gameplay/in_system/ui.rs
Normal file
212
apps/game/src/gameplay/in_system/ui.rs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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(¶ms);
|
let Some(galaxy) = campaign.galaxy.as_ref() else {
|
||||||
|
bevy::log::error!("Starting base selection entered without an accepted galaxy");
|
||||||
|
next_state.set(AppState::Galaxy);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if galaxy.systems.len() != galaxy.contents.len() {
|
||||||
|
bevy::log::error!(
|
||||||
|
"Starting base selection entered with an invalid galaxy: systems={}, contents={}",
|
||||||
|
galaxy.systems.len(),
|
||||||
|
galaxy.contents.len()
|
||||||
|
);
|
||||||
|
next_state.set(AppState::Galaxy);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let map = galaxy.starting_base_map();
|
||||||
draft.candidates = map.candidates;
|
draft.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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user