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:
@@ -1,12 +1,18 @@
|
||||
//! 3D galaxy view for starting-base selection.
|
||||
|
||||
use bevy::ecs::system::SystemParam;
|
||||
use bevy::input::mouse::{MouseMotion, MouseWheel};
|
||||
use bevy::prelude::*;
|
||||
use bevy::window::PrimaryWindow;
|
||||
|
||||
use super::{StartingBaseDraft, StartingBaseInputBlocker, StartingBaseSpawned};
|
||||
use super::{
|
||||
StartingBaseDraft, StartingBaseFocusGoal, StartingBaseFocusRequest, StartingBaseInputBlocker,
|
||||
StartingBaseSpawned,
|
||||
};
|
||||
use crate::camera::{MainCamera, OrbitCamera};
|
||||
use crate::gameplay::galaxy::{generate_starting_base_map, GalaxyParams, StartingBaseMapSystem};
|
||||
use crate::gameplay::campaign::CampaignDraft;
|
||||
use crate::gameplay::galaxy::{StartingBaseMapSystem, contents};
|
||||
use crate::state::AppState;
|
||||
|
||||
const SELECTION_PIXEL_THRESHOLD: f32 = 18.0;
|
||||
const SELECTED_SCALE: f32 = 2.4;
|
||||
@@ -17,6 +23,13 @@ const ORBIT_ROTATE_SENSITIVITY: f32 = 0.005;
|
||||
const ORBIT_ZOOM_SENSITIVITY: f32 = 0.1;
|
||||
const ORBIT_MIN_DISTANCE: f32 = 40.0;
|
||||
const ORBIT_MAX_DISTANCE: f32 = 1500.0;
|
||||
const STARTING_BASE_FOCUS_DISTANCE: f32 = 90.0;
|
||||
/// Exponential-damp speed for the camera focus tween. Matches the idiom used
|
||||
/// by `animate_starting_base_selection`; ~6.0 settles over roughly a second.
|
||||
const CAMERA_LERP_SPEED: f32 = 6.0;
|
||||
/// Snap-to-exact and release the focus goal once target and distance are both
|
||||
/// within this tolerance. Without it the asymptotic damp would never release.
|
||||
const CAMERA_FOCUS_EPSILON: f32 = 0.5;
|
||||
|
||||
#[derive(Component)]
|
||||
struct StartingBaseSceneRoot;
|
||||
@@ -26,14 +39,184 @@ pub(super) struct StartingBaseSystemVisual {
|
||||
candidate_index: Option<usize>,
|
||||
}
|
||||
|
||||
/// Marker for POI entities spawned in starting base view
|
||||
#[derive(Component)]
|
||||
pub struct StartingBasePoi;
|
||||
|
||||
/// Resource tracking the currently spawned POI system entity
|
||||
#[derive(Resource, Default)]
|
||||
pub struct SpawnedPoiSystem {
|
||||
entity: Option<Entity>,
|
||||
}
|
||||
|
||||
/// System to spawn POIs for the selected candidate
|
||||
pub fn spawn_selected_pois(
|
||||
mut commands: Commands,
|
||||
mut meshes: ResMut<Assets<Mesh>>,
|
||||
mut materials: ResMut<Assets<StandardMaterial>>,
|
||||
campaign: Res<CampaignDraft>,
|
||||
draft: Res<StartingBaseDraft>,
|
||||
mut spawned: ResMut<SpawnedPoiSystem>,
|
||||
systems: Query<(&GlobalTransform, &StartingBaseSystemVisual)>,
|
||||
existing_pois: Query<&GlobalTransform, With<StartingBasePoi>>,
|
||||
) {
|
||||
let Some(candidate_index) = draft.selected_index else {
|
||||
// No selection, despawn any existing POIs
|
||||
if let Some(entity) = spawned.entity.take() {
|
||||
commands.entity(entity).despawn();
|
||||
}
|
||||
return;
|
||||
};
|
||||
|
||||
let Some(candidate) = draft.candidates.get(candidate_index) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let Some(galaxy) = campaign.galaxy.as_ref() else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Find the system position for this candidate
|
||||
let system_position = systems.iter().find_map(|(transform, visual)| {
|
||||
if visual.candidate_index == Some(candidate_index) {
|
||||
Some(transform.translation())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
|
||||
let target_position = match system_position {
|
||||
Some(pos) => pos,
|
||||
None => return,
|
||||
};
|
||||
|
||||
// Check if we already spawned POIs for this system and if the position matches
|
||||
let should_respawn = if let Some(entity) = spawned.entity {
|
||||
// Check if the entity still exists by querying for it
|
||||
if let Ok(gt) = existing_pois.get(entity) {
|
||||
// If the entity still exists and position matches, keep it
|
||||
if gt.translation().distance(target_position) < 1.0 {
|
||||
false
|
||||
} else {
|
||||
// Position changed, need to respawn
|
||||
commands.entity(entity).despawn();
|
||||
true
|
||||
}
|
||||
} else {
|
||||
// Entity doesn't exist, need to spawn
|
||||
true
|
||||
}
|
||||
} else {
|
||||
// No existing POI system, need to spawn
|
||||
true
|
||||
};
|
||||
|
||||
if !should_respawn {
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the system index in the galaxy
|
||||
let system_index = galaxy.systems.iter().position(|s| s.id == candidate.id);
|
||||
let Some(system_index) = system_index else {
|
||||
return;
|
||||
};
|
||||
|
||||
let Some(system_contents) = galaxy.contents.get(system_index) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let system = &galaxy.systems[system_index];
|
||||
|
||||
// Create content assets for spawning
|
||||
let content_assets = contents::ContentAssets::new(&mut meshes, &mut materials);
|
||||
|
||||
// Spawn the POI system entity
|
||||
let ctx = contents::SystemContext {
|
||||
id: &system.id,
|
||||
name: &system.name,
|
||||
faction: system.faction,
|
||||
security: system.security,
|
||||
is_core: system.is_core,
|
||||
};
|
||||
|
||||
// Spawn the POI system root
|
||||
let poi_root = commands
|
||||
.spawn((
|
||||
Transform::from_translation(target_position),
|
||||
Visibility::default(),
|
||||
InheritedVisibility::default(),
|
||||
StartingBasePoi,
|
||||
GlobalTransform::default(),
|
||||
))
|
||||
.id();
|
||||
|
||||
// Spawn a star entity for the POIs to orbit around
|
||||
let star_entity = commands
|
||||
.spawn((
|
||||
crate::gameplay::galaxy::Star,
|
||||
crate::gameplay::galaxy::Massive { mass: 5000.0 },
|
||||
crate::gameplay::galaxy::Luminosity { value: 50.0 },
|
||||
crate::gameplay::galaxy::MassLock { radius: 5.0 },
|
||||
crate::gameplay::galaxy::BoundingVolume { radius: 1.5 },
|
||||
crate::gameplay::galaxy::poi::Identifiable {
|
||||
id: format!("{}-star", system.id),
|
||||
display_name: system.name.clone(),
|
||||
classification: crate::gameplay::galaxy::poi::Classification::Celestial,
|
||||
},
|
||||
Transform::default(),
|
||||
))
|
||||
.set_parent(poi_root)
|
||||
.id();
|
||||
|
||||
// Spawn all POI children
|
||||
commands.entity(poi_root).with_children(|parent| {
|
||||
contents::spawn_system_contents(
|
||||
parent,
|
||||
&ctx,
|
||||
system_contents,
|
||||
star_entity,
|
||||
&content_assets,
|
||||
);
|
||||
});
|
||||
|
||||
spawned.entity = Some(poi_root);
|
||||
}
|
||||
|
||||
/// System to despawn POIs when exiting the starting base scene
|
||||
pub fn despawn_pois_on_exit(
|
||||
mut commands: Commands,
|
||||
mut spawned: ResMut<SpawnedPoiSystem>,
|
||||
pois: Query<Entity, With<StartingBasePoi>>,
|
||||
) {
|
||||
for entity in &pois {
|
||||
commands.entity(entity).despawn();
|
||||
}
|
||||
spawned.entity = None;
|
||||
}
|
||||
|
||||
pub fn setup_starting_base_scene(
|
||||
mut commands: Commands,
|
||||
mut meshes: ResMut<Assets<Mesh>>,
|
||||
mut materials: ResMut<Assets<StandardMaterial>>,
|
||||
params: Res<GalaxyParams>,
|
||||
campaign: Res<CampaignDraft>,
|
||||
mut draft: ResMut<StartingBaseDraft>,
|
||||
mut next_state: ResMut<NextState<AppState>>,
|
||||
) {
|
||||
let map = generate_starting_base_map(¶ms);
|
||||
let Some(galaxy) = campaign.galaxy.as_ref() else {
|
||||
bevy::log::error!("Starting base selection entered without an accepted galaxy");
|
||||
next_state.set(AppState::Galaxy);
|
||||
return;
|
||||
};
|
||||
if galaxy.systems.len() != galaxy.contents.len() {
|
||||
bevy::log::error!(
|
||||
"Starting base selection entered with an invalid galaxy: systems={}, contents={}",
|
||||
galaxy.systems.len(),
|
||||
galaxy.contents.len()
|
||||
);
|
||||
next_state.set(AppState::Galaxy);
|
||||
return;
|
||||
}
|
||||
let map = galaxy.starting_base_map();
|
||||
draft.candidates = map.candidates;
|
||||
draft.selected_index = None;
|
||||
|
||||
@@ -150,6 +333,15 @@ fn spawn_connection(
|
||||
));
|
||||
}
|
||||
|
||||
/// Bundles the frame clock and the (mutable) focus goal so the orbit control
|
||||
/// system stays under clippy's argument-count threshold while keeping the
|
||||
/// per-frame tween co-located with manual-input cancellation.
|
||||
#[derive(SystemParam)]
|
||||
pub(super) struct FocusParams<'w> {
|
||||
time: Res<'w, Time>,
|
||||
goal: ResMut<'w, StartingBaseFocusGoal>,
|
||||
}
|
||||
|
||||
pub fn starting_base_orbit_camera_control(
|
||||
mouse_input: Res<ButtonInput<MouseButton>>,
|
||||
primary_window: Query<&Window, With<PrimaryWindow>>,
|
||||
@@ -157,86 +349,113 @@ pub fn starting_base_orbit_camera_control(
|
||||
mut scroll_events: EventReader<MouseWheel>,
|
||||
mut camera_query: Query<(&mut Transform, &mut OrbitCamera), With<MainCamera>>,
|
||||
ui_nodes: Query<(&ComputedNode, &GlobalTransform), With<StartingBaseInputBlocker>>,
|
||||
mut focus: FocusParams,
|
||||
) {
|
||||
let Ok((mut transform, mut orbit)) = camera_query.single_mut() else {
|
||||
mouse_motion.clear();
|
||||
scroll_events.clear();
|
||||
return;
|
||||
};
|
||||
|
||||
let cursor_over_ui = primary_window
|
||||
.single()
|
||||
.ok()
|
||||
.map(|window| cursor_over_starting_base_ui(window, &ui_nodes))
|
||||
.unwrap_or(false);
|
||||
let Ok(window) = primary_window.single() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let cursor_over_ui = cursor_over_starting_base_ui(window, &ui_nodes);
|
||||
|
||||
// Track whether the user actively manipulated the camera this frame so an
|
||||
// in-flight focus tween can be cancelled. A bare click (no motion delta)
|
||||
// does not count — only real drag/scroll takes over.
|
||||
let mut dragged = false;
|
||||
if mouse_input.pressed(MouseButton::Left) && !cursor_over_ui {
|
||||
for event in mouse_motion.read() {
|
||||
let yaw = Quat::from_rotation_y(-event.delta.x * ORBIT_ROTATE_SENSITIVITY);
|
||||
let pitch = Quat::from_rotation_x(event.delta.y * ORBIT_ROTATE_SENSITIVITY);
|
||||
orbit.rotation = (yaw * orbit.rotation * pitch).normalize();
|
||||
dragged = true;
|
||||
|
||||
// iPhone-style orbit controls: dragging in a direction moves the camera that way
|
||||
// around the target
|
||||
let yaw_delta = event.delta.x * ORBIT_ROTATE_SENSITIVITY;
|
||||
let pitch_delta = event.delta.y * ORBIT_ROTATE_SENSITIVITY;
|
||||
|
||||
// Get current euler angles from the quaternion
|
||||
let (current_yaw, current_pitch, current_roll) = orbit.rotation.to_euler(EulerRot::YXZ);
|
||||
|
||||
// Apply deltas (inverted for iPhone-style: drag left = orbit left)
|
||||
// No clamping - allow full 360° rotation
|
||||
let new_yaw = current_yaw - yaw_delta;
|
||||
let new_pitch = current_pitch + pitch_delta;
|
||||
|
||||
// Reconstruct quaternion from euler angles
|
||||
orbit.rotation = Quat::from_euler(EulerRot::YXZ, new_yaw, new_pitch, current_roll);
|
||||
}
|
||||
} else {
|
||||
mouse_motion.clear();
|
||||
}
|
||||
|
||||
let mut scrolled = false;
|
||||
for event in scroll_events.read() {
|
||||
if cursor_over_ui {
|
||||
continue;
|
||||
}
|
||||
scrolled = true;
|
||||
orbit.distance *= 1.0 - event.y * ORBIT_ZOOM_SENSITIVITY;
|
||||
}
|
||||
|
||||
// Manual camera input cancels any pending focus tween so control returns
|
||||
// immediately to the user.
|
||||
if dragged || scrolled {
|
||||
focus.goal.active = false;
|
||||
}
|
||||
|
||||
// Tween toward the focus goal using the same exponential-damp idiom as the
|
||||
// selection-scale animation.
|
||||
if focus.goal.active {
|
||||
let dt = focus.time.delta_secs().min(0.1);
|
||||
let alpha = (dt * CAMERA_LERP_SPEED).clamp(0.0, 1.0);
|
||||
let target_delta = focus.goal.target - orbit.target;
|
||||
let distance_delta = focus.goal.distance - orbit.distance;
|
||||
orbit.target += target_delta * alpha;
|
||||
orbit.distance += distance_delta * alpha;
|
||||
|
||||
let reached = target_delta.length() < CAMERA_FOCUS_EPSILON
|
||||
&& distance_delta.abs() < CAMERA_FOCUS_EPSILON;
|
||||
if reached {
|
||||
orbit.target = focus.goal.target;
|
||||
orbit.distance = focus.goal.distance;
|
||||
focus.goal.active = false;
|
||||
}
|
||||
}
|
||||
|
||||
orbit.distance = orbit.distance.clamp(ORBIT_MIN_DISTANCE, ORBIT_MAX_DISTANCE);
|
||||
let position = orbit.target + orbit.rotation * Vec3::new(0.0, 0.0, orbit.distance);
|
||||
*transform = Transform::from_translation(position).with_rotation(orbit.rotation);
|
||||
}
|
||||
|
||||
pub fn select_starting_base_on_click(
|
||||
mouse_input: Res<ButtonInput<MouseButton>>,
|
||||
primary_window: Query<&Window, With<PrimaryWindow>>,
|
||||
camera_query: Query<(&Camera, &GlobalTransform), With<MainCamera>>,
|
||||
pub fn focus_starting_base_camera(
|
||||
mut commands: Commands,
|
||||
focus: Option<Res<StartingBaseFocusRequest>>,
|
||||
systems: Query<(&GlobalTransform, &StartingBaseSystemVisual)>,
|
||||
ui_nodes: Query<(&ComputedNode, &GlobalTransform), With<StartingBaseInputBlocker>>,
|
||||
mut draft: ResMut<StartingBaseDraft>,
|
||||
mut goal: ResMut<StartingBaseFocusGoal>,
|
||||
) {
|
||||
if !mouse_input.just_pressed(MouseButton::Left) {
|
||||
return;
|
||||
}
|
||||
let Ok(window) = primary_window.single() else {
|
||||
let Some(focus) = focus else {
|
||||
return;
|
||||
};
|
||||
if cursor_over_starting_base_ui(window, &ui_nodes) {
|
||||
return;
|
||||
}
|
||||
let Some(cursor) = window.cursor_position() else {
|
||||
return;
|
||||
};
|
||||
let Ok((camera, camera_gt)) = camera_query.single() else {
|
||||
let focus_index = focus.candidate_index;
|
||||
commands.remove_resource::<StartingBaseFocusRequest>();
|
||||
|
||||
let Some(target) = systems.iter().find_map(|(transform, visual)| {
|
||||
if visual.candidate_index == Some(focus_index) {
|
||||
Some(transform.translation())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let mut best: Option<(usize, f32)> = None;
|
||||
for (transform, visual) in &systems {
|
||||
let Some(candidate_index) = visual.candidate_index else {
|
||||
continue;
|
||||
};
|
||||
let Ok(viewport) = camera.world_to_viewport(camera_gt, transform.translation()) else {
|
||||
continue;
|
||||
};
|
||||
let distance = viewport.distance(cursor);
|
||||
if distance >= SELECTION_PIXEL_THRESHOLD {
|
||||
continue;
|
||||
}
|
||||
if best.is_none_or(|(_, current)| distance < current) {
|
||||
best = Some((candidate_index, distance));
|
||||
}
|
||||
}
|
||||
|
||||
let new_selection = best.map(|(candidate_index, _)| candidate_index);
|
||||
if draft.selected_index != new_selection {
|
||||
draft.selected_index = new_selection;
|
||||
}
|
||||
// Don't snap the camera — arm a goal the orbit control system tweens toward.
|
||||
// Re-clicking the same candidate overwrites it, smoothly retargeting a
|
||||
// still-in-flight tween.
|
||||
goal.target = target;
|
||||
goal.distance = STARTING_BASE_FOCUS_DISTANCE.clamp(ORBIT_MIN_DISTANCE, ORBIT_MAX_DISTANCE);
|
||||
goal.active = true;
|
||||
}
|
||||
|
||||
fn cursor_over_starting_base_ui(
|
||||
@@ -247,7 +466,6 @@ fn cursor_over_starting_base_ui(
|
||||
return false;
|
||||
};
|
||||
|
||||
let cursor = cursor_logical * window.scale_factor();
|
||||
for (node, transform) in nodes {
|
||||
if node.is_empty() {
|
||||
continue;
|
||||
@@ -256,7 +474,11 @@ fn cursor_over_starting_base_ui(
|
||||
let half = node.size() * 0.5;
|
||||
let min = center - half;
|
||||
let max = center + half;
|
||||
if cursor.x >= min.x && cursor.x <= max.x && cursor.y >= min.y && cursor.y <= max.y {
|
||||
if cursor_logical.x >= min.x
|
||||
&& cursor_logical.x <= max.x
|
||||
&& cursor_logical.y >= min.y
|
||||
&& cursor_logical.y <= max.y
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user