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
510 lines
16 KiB
Rust
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);
|
|
}
|
|
}
|
|
}
|