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

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

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

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

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

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

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

View File

@@ -0,0 +1,46 @@
//! Docked state management.
//!
//! Tracks the player's current docking status and the station they're docked at.
use bevy::prelude::*;
/// Tracks the player's docked state within a system.
#[derive(Resource, Debug, Clone, Default)]
pub struct DockedState {
/// The entity ID of the station the player is docked at.
pub station_entity: Option<Entity>,
/// The ID of the system the player is in.
pub system_id: String,
/// Whether the player is currently docked.
pub is_docked: bool,
}
impl DockedState {
/// Create a new docked state for the given system.
pub fn new(system_id: String) -> Self {
Self {
station_entity: None,
system_id,
is_docked: true,
}
}
/// Dock at a specific station.
pub fn dock_at(&mut self, station: Entity) {
self.station_entity = Some(station);
self.is_docked = true;
}
/// Undock from the current station.
pub fn undock(&mut self) {
self.station_entity = None;
self.is_docked = false;
}
}
/// Event fired when the player undocks from a station.
#[derive(Event, Debug, Clone)]
pub struct UndockEvent {
/// The station entity the player is undocking from.
pub station_entity: Entity,
}

View File

@@ -0,0 +1,46 @@
//! In-system gameplay module.
//!
//! Handles the system-scale view where the player is docked at a station
//! within their selected starting system. This is the entry point to actual
//! gameplay after onboarding.
mod docked;
mod scene;
mod ui;
use bevy::prelude::*;
use crate::state::AppState;
pub use docked::{DockedState, UndockEvent};
pub use scene::ActiveSystem;
pub struct InSystemPlugin;
impl Plugin for InSystemPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<DockedState>()
.init_resource::<ActiveSystem>()
.add_event::<UndockEvent>()
.add_systems(
OnEnter(AppState::InGame),
(scene::setup_in_system_view, ui::setup_docked_ui).chain(),
)
.add_systems(
OnExit(AppState::InGame),
(
ui::despawn_docked_ui,
scene::despawn_in_system_scene,
).chain(),
)
.add_systems(
Update,
(
ui::refresh_docked_ui,
ui::undock_button_handler,
)
.chain()
.run_if(in_state(AppState::InGame)),
);
}
}

View File

@@ -0,0 +1,328 @@
//! In-system scene setup and spawning.
//!
//! Handles spawning the star, POIs, and player ship docked at a station.
use bevy::prelude::*;
use crate::camera::{MainCamera, OrbitCamera};
use crate::gameplay::campaign::CampaignDraft;
use crate::gameplay::character_creation::STARTING_SHIPS;
use crate::gameplay::galaxy::{
contents, Massive, Luminosity, MassLock, BoundingVolume, Identifiable,
SystemContents, GeneratedStation,
};
use crate::gameplay::in_system::{DockedState};
use crate::state::AppState;
/// Tracks the currently active system for gameplay.
#[derive(Resource, Debug, Clone, Default)]
pub struct ActiveSystem {
pub system_id: String,
pub system_name: String,
pub star_entity: Option<Entity>,
}
/// Marker for entities spawned in the in-system view.
#[derive(Component)]
pub struct InSystemSpawned;
/// Marker for the player ship entity.
#[derive(Component)]
pub struct PlayerShip;
/// Marker for docked state on the player.
#[derive(Component)]
pub struct Docked {
pub station_entity: Entity,
}
/// Station marker component for tracking which station player is at.
#[derive(Component)]
pub struct DockingTarget {
pub station_name: String,
}
/// Offset from station where player ship spawns when docked.
const DOCKED_OFFSET: Vec3 = Vec3::new(1.5, 0.0, 0.0);
/// Distance for camera to view the docked scene cinematically.
const DOCKED_CAMERA_DISTANCE: f32 = 8.0;
/// Slight upward tilt for cinematic docked view.
const DOCKED_CAMERA_PITCH: f32 = 0.3;
pub fn setup_in_system_view(
mut commands: Commands,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<StandardMaterial>>,
campaign: Res<CampaignDraft>,
mut docked_state: ResMut<DockedState>,
mut active_system: ResMut<ActiveSystem>,
) {
// Get the selected starting base
let Some(starting_base) = &campaign.starting_base else {
bevy::log::error!("No starting base selected in CampaignDraft");
return;
};
// Get the galaxy data
let Some(galaxy) = &campaign.galaxy else {
bevy::log::error!("No galaxy data in CampaignDraft");
return;
};
// Find the selected system
let system_index = match galaxy.systems.iter().position(|s| s.id == starting_base.system_id) {
Some(idx) => idx,
None => {
bevy::log::error!("Selected system {} not found in galaxy", starting_base.system_id);
return;
}
};
let system = &galaxy.systems[system_index];
let system_contents = &galaxy.contents[system_index];
bevy::log::info!(
"Setting up in-system view for: {} ({})",
system.name,
system.id
);
// Select docking station (highest population, or first if none)
let docking_station = select_docking_station(system_contents);
let Some(docking_station) = docking_station else {
bevy::log::warn!("No stations found in system {}, spawning without docking", system.id);
// Spawn scene without docking
spawn_system_scene(
&mut commands,
&mut meshes,
&mut materials,
system,
system_contents,
None,
);
return;
};
bevy::log::info!(
"Selected docking station: {} (population: {})",
docking_station.name,
docking_station.population
);
// Spawn the full scene with docking
let (star_entity, station_entity) = spawn_system_scene(
&mut commands,
&mut meshes,
&mut materials,
system,
system_contents,
Some(docking_station),
);
// Update resources
docked_state.system_id = system.id.clone();
docked_state.is_docked = true;
docked_state.station_entity = Some(station_entity);
active_system.system_id = system.id.clone();
active_system.system_name = system.name.clone();
active_system.star_entity = Some(star_entity);
// Position camera for cinematic docked view
setup_docked_camera(&mut commands);
}
/// Select the station with highest population for docking.
fn select_docking_station(contents: &SystemContents) -> Option<&GeneratedStation> {
contents
.stations
.iter()
.max_by_key(|s| s.population)
.or_else(|| contents.stations.first())
}
/// Calculate orbital position from orbit radius and phase.
/// Matches the logic in galaxy/contents.rs but defined here since that's private.
fn orbital_position(orbit: f32, phase: f32) -> Vec3 {
Vec3::new(phase.cos() * orbit, 0.0, phase.sin() * orbit)
}
/// Spawn the system scene including star, POIs, and player ship.
/// Returns (star_entity, docking_station_entity).
fn spawn_system_scene(
commands: &mut Commands,
meshes: &mut Assets<Mesh>,
materials: &mut Assets<StandardMaterial>,
system: &crate::gameplay::campaign::GeneratedGalaxySystem,
contents: &SystemContents,
docking_station: Option<&GeneratedStation>,
) -> (Entity, Entity) {
// Create content assets for spawning
let content_assets = contents::ContentAssets::new(meshes, materials);
// Spawn the system root
let system_root = commands
.spawn((
Transform::default(),
Visibility::default(),
InheritedVisibility::default(),
InSystemSpawned,
GlobalTransform::default(),
))
.id();
// Spawn the star
let star_entity = commands
.spawn((
crate::gameplay::galaxy::Star,
Massive { mass: 5000.0 },
Luminosity { value: 50.0 },
MassLock { radius: 5.0 },
BoundingVolume { radius: 1.5 },
Identifiable {
id: format!("{}-star", system.id),
display_name: system.name.clone(),
classification: crate::gameplay::galaxy::poi::Classification::Celestial,
},
Transform::from_scale(Vec3::splat(1.5)),
))
.set_parent(system_root)
.id();
// Spawn all POI children (planets, stations, etc.)
commands.entity(system_root).with_children(|parent| {
let ctx = contents::SystemContext {
id: &system.id,
name: &system.name,
faction: system.faction,
security: system.security,
is_core: system.is_core,
};
contents::spawn_system_contents(
parent,
&ctx,
contents,
star_entity,
&content_assets,
);
});
// If we have a docking station, spawn player ship docked at it
let station_entity = if let Some(station) = docking_station {
// Calculate station position
let station_position = orbital_position(station.orbit, station.phase);
// Spawn player ship at docked offset
spawn_player_ship_docked(
commands,
meshes,
materials,
&station.name,
station_position,
system_root,
)
} else {
Entity::PLACEHOLDER
};
(star_entity, station_entity)
}
/// Spawn the player ship docked at a station.
/// Returns the player ship entity.
fn spawn_player_ship_docked(
commands: &mut Commands,
meshes: &mut Assets<Mesh>,
materials: &mut Assets<StandardMaterial>,
station_name: &str,
station_position: Vec3,
system_root: Entity,
) -> Entity {
// Ship position: docked offset from station
let ship_position = station_position + DOCKED_OFFSET;
// Get the first starting ship type (for now, always use the frigate)
let ship_data = STARTING_SHIPS.first().unwrap_or(&STARTING_SHIPS[0]);
bevy::log::info!(
"Spawning player ship: {} docked at {}",
ship_data.name,
station_name
);
// Create a simple ship visual (using a scaled, colored cube for now)
let ship_entity = commands
.spawn((
PlayerShip,
Docked {
station_entity: Entity::PLACEHOLDER, // Will be set when we find the actual station
},
Transform::from_translation(ship_position).with_scale(Vec3::splat(0.12)),
Visibility::default(),
InheritedVisibility::default(),
InSystemSpawned,
))
.set_parent(system_root)
.id();
// Add mesh and material
commands.entity(ship_entity).insert((
Mesh3d(meshes.add(Cuboid::new(1.0, 0.4, 1.5))),
MeshMaterial3d(materials.add(StandardMaterial {
base_color: Color::srgb(0.3, 0.5, 0.7),
emissive: LinearRgba::new(0.1, 0.15, 0.2, 1.0),
metallic: 0.8,
perceptual_roughness: 0.3,
..default()
})),
));
ship_entity
}
/// Setup camera for cinematic docked view.
fn setup_docked_camera(commands: &mut Commands) {
// Position camera for a cinematic view looking at the docked scene
// Offset back and up, angled down slightly
let camera_distance = DOCKED_CAMERA_DISTANCE;
let camera_pitch = DOCKED_CAMERA_PITCH;
let yaw = Quat::from_rotation_y(0.0);
let pitch = Quat::from_rotation_x(camera_pitch);
let rotation = yaw * pitch;
let offset = rotation * Vec3::new(0.0, 0.0, camera_distance);
let position = Vec3::new(DOCKED_OFFSET.x * 0.5, 1.0, 0.0) + offset;
commands.spawn((
Camera3d::default(),
Transform::from_translation(position)
.with_rotation(rotation)
.looking_at(Vec3::new(DOCKED_OFFSET.x * 0.5, 0.0, 0.0), Vec3::Y),
MainCamera,
OrbitCamera {
target: Vec3::new(DOCKED_OFFSET.x * 0.5, 0.0, 0.0),
distance: camera_distance,
rotation,
},
));
}
/// Despawn all entities spawned for the in-system view.
pub fn despawn_in_system_scene(
mut commands: Commands,
query: Query<Entity, With<InSystemSpawned>>,
camera_query: Query<Entity, With<MainCamera>>,
) {
for entity in &query {
commands.entity(entity).despawn_recursive();
}
// Also despawn the main camera (it gets recreated on next enter)
for entity in &camera_query {
commands.entity(entity).despawn();
}
}

View File

@@ -0,0 +1,212 @@
//! UI for the docked state within a station.
use bevy::prelude::*;
use crate::gameplay::in_system::{DockedState, UndockEvent};
use crate::gameplay::campaign::CampaignDraft;
const PANEL_BG: Color = Color::srgb(0.05, 0.07, 0.12);
const PANEL_BORDER: Color = Color::srgb(0.25, 0.40, 0.62);
const TEXT_BRIGHT: Color = Color::srgb(0.82, 0.90, 1.0);
const TEXT_DIM: Color = Color::srgb(0.55, 0.65, 0.82);
const BUTTON_BG: Color = Color::srgb(0.10, 0.28, 0.22);
const BUTTON_BORDER: Color = Color::srgb(0.30, 0.72, 0.45);
const BUTTON_HOVER_BG: Color = Color::srgb(0.15, 0.35, 0.28);
const TITLE_FONT_SIZE: f32 = 24.0;
const SUBTITLE_FONT_SIZE: f32 = 14.0;
const BODY_FONT_SIZE: f32 = 14.0;
const BUTTON_FONT_SIZE: f32 = 16.0;
const PANEL_WIDTH: f32 = 360.0;
const SIDE_MARGIN: f32 = 20.0;
const TOP_MARGIN: f32 = 20.0;
/// Marker for UI entities spawned in the docked view.
#[derive(Component)]
pub struct DockedUi;
/// Marker for the station details text.
#[derive(Component)]
pub struct StationDetailsText;
/// Marker for the undock button.
#[derive(Component)]
pub struct UndockButton;
pub fn setup_docked_ui(mut commands: Commands) {
commands
.spawn((
Node {
width: Val::Percent(100.0),
height: Val::Percent(100.0),
position_type: PositionType::Absolute,
..default()
},
DockedUi,
))
.with_children(|root| {
spawn_station_panel(root);
});
}
fn spawn_station_panel(parent: &mut ChildSpawnerCommands) {
parent
.spawn((
Node {
position_type: PositionType::Absolute,
left: Val::Px(SIDE_MARGIN),
top: Val::Px(TOP_MARGIN),
width: Val::Px(PANEL_WIDTH),
flex_direction: FlexDirection::Column,
row_gap: Val::Px(12.0),
padding: UiRect::all(Val::Px(16.0)),
border: UiRect::all(Val::Px(1.0)),
..default()
},
BackgroundColor(PANEL_BG),
BorderColor(PANEL_BORDER),
BorderRadius::all(Val::Px(8.0)),
DockedUi,
))
.with_children(|panel| {
// Title
panel.spawn((
Text::new("Docked"),
TextFont {
font_size: TITLE_FONT_SIZE,
..default()
},
TextColor(TEXT_BRIGHT),
));
// Station details placeholder (will be updated by refresh_docked_ui)
panel.spawn((
Text::new("Loading station data..."),
TextFont {
font_size: BODY_FONT_SIZE,
..default()
},
TextColor(TEXT_DIM),
StationDetailsText,
));
// Spacer
panel.spawn(Node {
flex_grow: 1.0,
..default()
});
// Undock button
panel.spawn((
Button,
Node {
width: Val::Px(160.0),
height: Val::Px(44.0),
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
border: UiRect::all(Val::Px(1.0)),
..default()
},
BackgroundColor(BUTTON_BG),
BorderColor(BUTTON_BORDER),
BorderRadius::all(Val::Px(6.0)),
UndockButton,
))
.with_children(|button| {
button.spawn((
Text::new("Undock"),
TextFont {
font_size: BUTTON_FONT_SIZE,
..default()
},
TextColor(TEXT_BRIGHT),
));
});
});
}
pub fn refresh_docked_ui(
campaign: Res<CampaignDraft>,
docked_state: Res<DockedState>,
mut query: Query<&mut Text, With<StationDetailsText>>,
) {
// Only update when something changed
if !campaign.is_changed() && !docked_state.is_changed() {
return;
}
let mut text = match query.get_single_mut() {
Ok(t) => t,
Err(_) => return,
};
let Some(starting_base) = &campaign.starting_base else {
text.0 = "No station data available.".to_string();
return;
};
let Some(galaxy) = &campaign.galaxy else {
text.0 = "No galaxy data available.".to_string();
return;
};
// Find the system
let system_index = match galaxy.systems.iter().position(|s| s.id == starting_base.system_id) {
Some(idx) => idx,
None => {
text.0 = format!("System {} not found.", starting_base.system_id);
return;
}
};
let system = &galaxy.systems[system_index];
let contents = &galaxy.contents[system_index];
// Find the docking station (highest population)
let station = contents.stations.iter().max_by_key(|s| s.population);
let station_name = station.map(|s| s.name.as_str()).unwrap_or("Unknown Station");
let station_pop = station.map(|s| s.population).unwrap_or(0);
text.0 = format!(
"{}\n{}\n\nSystem: {} ({})\nFaction: {}\nSecurity: {:.2}\nStation Pop: {}",
station_name,
"".repeat(24),
system.name,
system.id,
system.faction,
system.security,
station_pop
);
}
pub fn undock_button_handler(
mut commands: Commands,
mut events: EventWriter<UndockEvent>,
mut docked_state: ResMut<DockedState>,
query: Query<(&Interaction, &UndockButton), Changed<Interaction>>,
) {
for (interaction, _) in &query {
if *interaction == Interaction::Pressed {
bevy::log::info!("Undock button pressed");
// Fire the undock event
if let Some(station_entity) = docked_state.station_entity {
events.send(UndockEvent { station_entity });
}
// Update docked state
docked_state.undock();
// For now, just log - actual undocking gameplay will come later
bevy::log::info!("Player undocked (placeholder - full undocking TBD)");
}
}
}
pub fn despawn_docked_ui(mut commands: Commands, query: Query<Entity, With<DockedUi>>) {
for entity in &query {
commands.entity(entity).despawn_recursive();
}
}