feat(gameplay): implement in-system onboarding with docked station view

Implement the final onboarding step where the player loads into their
selected starting system docked at a station.

New features:
- Create in_system module for system-scale gameplay
- Spawn player ship docked at highest-population station
- Display station info panel with undock button
- Position camera for cinematic docked view with orbit controls

Implementation details:
- in_system/mod.rs: Plugin setup with DockedState and ActiveSystem resources
- in_system/scene.rs: System/POI spawning and player ship docked positioning
- in_system/docked.rs: Docked state management and UndockEvent
- in_system/ui.rs: Docked UI with station details and undock button
- Reuse existing POI spawning patterns from galaxy/contents.rs
- Select docking station by highest population (better for new players)

Modified files:
- Add in_system module exports to gameplay/mod.rs
- Register InSystemPlugin in main.rs
- Update orbit camera control for InGame state
- Re-export GeneratedStation and STARTING_SHIPS for use by in_system

The player now completes onboarding by loading into a system view with
their ship docked at a station, ready for gameplay.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-14 15:19:18 -04:00
parent d139dc08d9
commit 07316dbcd7
15 changed files with 1332 additions and 227 deletions

View File

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