//! 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, StartingBaseFocusGoal, StartingBaseFocusRequest, StartingBaseInputBlocker, StartingBaseSpawned, }; use crate::camera::{MainCamera, OrbitCamera}; 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; const CANDIDATE_SCALE: f32 = 1.25; const FOGGED_SCALE: f32 = 0.58; const SELECTION_LERP_SPEED: f32 = 10.0; 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; #[derive(Component)] pub(super) struct StartingBaseSystemVisual { candidate_index: Option, } /// Marker for POI entities spawned in starting base view #[derive(Component)] pub struct StartingBasePoi; /// Resource tracking the currently spawned POI system entity #[derive(Resource, Default)] pub struct SpawnedPoiSystem { entity: Option, } /// System to spawn POIs for the selected candidate pub fn spawn_selected_pois( mut commands: Commands, mut meshes: ResMut>, mut materials: ResMut>, campaign: Res, draft: Res, mut spawned: ResMut, systems: Query<(&GlobalTransform, &StartingBaseSystemVisual)>, existing_pois: Query<&GlobalTransform, With>, ) { let Some(candidate_index) = draft.selected_index else { // No selection, despawn any existing POIs if let Some(entity) = spawned.entity.take() { commands.entity(entity).despawn(); } return; }; let Some(candidate) = draft.candidates.get(candidate_index) else { return; }; let Some(galaxy) = campaign.galaxy.as_ref() else { return; }; // Find the system position for this candidate let system_position = systems.iter().find_map(|(transform, visual)| { if visual.candidate_index == Some(candidate_index) { Some(transform.translation()) } else { None } }); let target_position = match system_position { Some(pos) => pos, None => return, }; // Check if we already spawned POIs for this system and if the position matches let should_respawn = if let Some(entity) = spawned.entity { // Check if the entity still exists by querying for it if let Ok(gt) = existing_pois.get(entity) { // If the entity still exists and position matches, keep it if gt.translation().distance(target_position) < 1.0 { false } else { // Position changed, need to respawn commands.entity(entity).despawn(); true } } else { // Entity doesn't exist, need to spawn true } } else { // No existing POI system, need to spawn true }; if !should_respawn { return; } // Find the system index in the galaxy let system_index = galaxy.systems.iter().position(|s| s.id == candidate.id); let Some(system_index) = system_index else { return; }; let Some(system_contents) = galaxy.contents.get(system_index) else { return; }; let system = &galaxy.systems[system_index]; // Create content assets for spawning let content_assets = contents::ContentAssets::new(&mut meshes, &mut materials); // Spawn the POI system entity let ctx = contents::SystemContext { id: &system.id, name: &system.name, faction: system.faction, security: system.security, is_core: system.is_core, }; // Spawn the POI system root let poi_root = commands .spawn(( Transform::from_translation(target_position), Visibility::default(), InheritedVisibility::default(), StartingBasePoi, GlobalTransform::default(), )) .id(); // Spawn a star entity for the POIs to orbit around let star_entity = commands .spawn(( crate::gameplay::galaxy::Star, crate::gameplay::galaxy::Massive { mass: 5000.0 }, crate::gameplay::galaxy::Luminosity { value: 50.0 }, crate::gameplay::galaxy::MassLock { radius: 5.0 }, crate::gameplay::galaxy::BoundingVolume { radius: 1.5 }, crate::gameplay::galaxy::poi::Identifiable { id: format!("{}-star", system.id), display_name: system.name.clone(), classification: crate::gameplay::galaxy::poi::Classification::Celestial, }, Transform::default(), )) .set_parent(poi_root) .id(); // Spawn all POI children commands.entity(poi_root).with_children(|parent| { contents::spawn_system_contents( parent, &ctx, system_contents, star_entity, &content_assets, &mut materials, ); }); spawned.entity = Some(poi_root); } /// System to despawn POIs when exiting the starting base scene pub fn despawn_pois_on_exit( mut commands: Commands, mut spawned: ResMut, pois: Query>, ) { for entity in &pois { commands.entity(entity).despawn(); } spawned.entity = None; } pub fn setup_starting_base_scene( mut commands: Commands, mut meshes: ResMut>, mut materials: ResMut>, campaign: Res, mut draft: ResMut, mut next_state: ResMut>, ) { 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; let star_mesh = meshes.add(Sphere::new(1.0).mesh().ico(3).unwrap()); let fogged_material = materials.add(StandardMaterial { base_color: Color::srgb(0.10, 0.12, 0.16), emissive: LinearRgba::new(0.02, 0.025, 0.035, 1.0), unlit: true, ..default() }); let connection_material = materials.add(StandardMaterial { base_color: Color::srgb(0.07, 0.09, 0.13), emissive: LinearRgba::new(0.025, 0.035, 0.055, 1.0), unlit: true, ..default() }); let candidate_materials: Vec> = map .systems .iter() .map(|system| { materials.add(StandardMaterial { base_color: Color::srgb(system.color[0], system.color[1], system.color[2]), emissive: LinearRgba::new( system.color[0] * 1.6, system.color[1] * 1.6, system.color[2] * 1.6, 1.0, ), unlit: true, ..default() }) }) .collect(); commands .spawn(( Transform::default(), Visibility::default(), InheritedVisibility::default(), StartingBaseSceneRoot, StartingBaseSpawned, )) .with_children(|parent| { for (index, system) in map.systems.iter().enumerate() { spawn_system( parent, &star_mesh, &candidate_materials[index], &fogged_material, system, ); } for (a, b) in &map.connections { let Some(from) = map.systems.get(*a).map(|system| system.position) else { continue; }; let Some(to) = map.systems.get(*b).map(|system| system.position) else { continue; }; spawn_connection(parent, &mut meshes, &connection_material, from, to); } }); } fn spawn_system( parent: &mut ChildSpawnerCommands, star_mesh: &Handle, candidate_material: &Handle, fogged_material: &Handle, system: &StartingBaseMapSystem, ) { let scale = if system.candidate_index.is_some() { CANDIDATE_SCALE } else { FOGGED_SCALE }; let material = if system.candidate_index.is_some() { candidate_material.clone() } else { fogged_material.clone() }; parent.spawn(( Mesh3d(star_mesh.clone()), MeshMaterial3d(material), Transform::from_translation(system.position).with_scale(Vec3::splat(scale)), StartingBaseSystemVisual { candidate_index: system.candidate_index, }, )); } fn spawn_connection( parent: &mut ChildSpawnerCommands, meshes: &mut Assets, material: &Handle, from: Vec3, to: Vec3, ) { let delta = to - from; let length = delta.length(); if length < 0.01 { return; } let midpoint = (from + to) * 0.5; let direction = delta / length; let rotation = Quat::from_rotation_arc(Vec3::Y, direction); parent.spawn(( Mesh3d(meshes.add(Cylinder::new(0.08, length).mesh())), MeshMaterial3d(material.clone()), Transform::from_translation(midpoint).with_rotation(rotation), )); } /// Bundles the frame clock and the (mutable) focus goal so the orbit control /// system stays under clippy's argument-count threshold while keeping the /// per-frame tween co-located with manual-input cancellation. #[derive(SystemParam)] pub(super) struct FocusParams<'w> { time: Res<'w, Time>, goal: ResMut<'w, StartingBaseFocusGoal>, } pub fn starting_base_orbit_camera_control( mouse_input: Res>, primary_window: Query<&Window, With>, mut mouse_motion: EventReader, mut scroll_events: EventReader, mut camera_query: Query<(&mut Transform, &mut OrbitCamera), With>, ui_nodes: Query<(&ComputedNode, &GlobalTransform), With>, mut focus: FocusParams, ) { let Ok((mut transform, mut orbit)) = camera_query.single_mut() else { return; }; 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() { 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 focus_starting_base_camera( mut commands: Commands, focus: Option>, systems: Query<(&GlobalTransform, &StartingBaseSystemVisual)>, mut goal: ResMut, ) { let Some(focus) = focus else { return; }; let focus_index = focus.candidate_index; commands.remove_resource::(); let Some(target) = systems.iter().find_map(|(transform, visual)| { if visual.candidate_index == Some(focus_index) { Some(transform.translation()) } else { None } }) else { return; }; // 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( window: &Window, nodes: &Query<(&ComputedNode, &GlobalTransform), With>, ) -> bool { let Some(cursor_logical) = window.cursor_position() else { return false; }; for (node, transform) in nodes { if node.is_empty() { continue; } let center = transform.translation().truncate(); let half = node.size() * 0.5; let min = center - half; let max = center + half; if cursor_logical.x >= min.x && cursor_logical.x <= max.x && cursor_logical.y >= min.y && cursor_logical.y <= max.y { return true; } } false } pub fn animate_starting_base_selection( draft: Res, time: Res