Files
Space-Game/apps/game/src/gameplay/starting_base/scene.rs
francy51 57633addfe chore: sync codebase remediation, gameplay systems, and docs
Security & infrastructure:
- Remove unused services/ (auth, spacetimedb) and auth.db
- Add .env.example template, expand .gitignore for env/db files
- Add GitHub Actions CI + commitlint config and workflows
- Add manual vendor chunking and source maps to docs/site vite configs

Shared UI & docs app:
- Add ARIA props and focus-visible rings to Button/Panel
- Add ButtonAsLink primitive; use shared Button in NotFound
- Wire @void-nav/ui into docs app; refresh content pages
- Replace Todo page with Kanban board

Gameplay (Bevy):
- Add ai module (behavior, faction, navigation, perception, spawning, states)
- Add narrative module (events, history, synthesis, ui)
- Refine galaxy contents and in-system flight/scene systems
2026-06-16 11:49:13 -04:00

510 lines
16 KiB
Rust

//! 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<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,
&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<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>>,
campaign: Res<CampaignDraft>,
mut draft: ResMut<StartingBaseDraft>,
mut next_state: ResMut<NextState<AppState>>,
) {
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<Handle<StandardMaterial>> = 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<Mesh>,
candidate_material: &Handle<StandardMaterial>,
fogged_material: &Handle<StandardMaterial>,
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<Mesh>,
material: &Handle<StandardMaterial>,
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<ButtonInput<MouseButton>>,
primary_window: Query<&Window, With<PrimaryWindow>>,
mut mouse_motion: EventReader<MouseMotion>,
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 {
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<Res<StartingBaseFocusRequest>>,
systems: Query<(&GlobalTransform, &StartingBaseSystemVisual)>,
mut goal: ResMut<StartingBaseFocusGoal>,
) {
let Some(focus) = focus else {
return;
};
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;
};
// 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<StartingBaseInputBlocker>>,
) -> 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<StartingBaseDraft>,
time: Res<Time>,
mut systems: Query<(&StartingBaseSystemVisual, &mut Transform)>,
) {
let dt = time.delta_secs().min(0.1);
let alpha = (dt * SELECTION_LERP_SPEED).clamp(0.0, 1.0);
for (visual, mut transform) in &mut systems {
let target = match visual.candidate_index {
Some(index) if Some(index) == draft.selected_index => SELECTED_SCALE,
Some(_) => CANDIDATE_SCALE,
None => FOGGED_SCALE,
};
let current = transform.scale.x;
let next = current + (target - current) * alpha;
if (next - current).abs() > 1e-4 {
transform.scale = Vec3::splat(next);
}
}
}