feat(gameplay): add starting base module and refine galaxy/character creation systems

Co-Authored-By: Oz <oz-agent@warp.dev>
This commit is contained in:
2026-06-12 23:40:33 -04:00
parent 71c6d18817
commit d139dc08d9
20 changed files with 1789 additions and 310 deletions

View File

@@ -0,0 +1,286 @@
//! 3D galaxy view for starting-base selection.
use bevy::input::mouse::{MouseMotion, MouseWheel};
use bevy::prelude::*;
use bevy::window::PrimaryWindow;
use super::{StartingBaseDraft, StartingBaseInputBlocker, StartingBaseSpawned};
use crate::camera::{MainCamera, OrbitCamera};
use crate::gameplay::galaxy::{generate_starting_base_map, GalaxyParams, StartingBaseMapSystem};
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;
#[derive(Component)]
struct StartingBaseSceneRoot;
#[derive(Component)]
pub(super) struct StartingBaseSystemVisual {
candidate_index: Option<usize>,
}
pub fn setup_starting_base_scene(
mut commands: Commands,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<StandardMaterial>>,
params: Res<GalaxyParams>,
mut draft: ResMut<StartingBaseDraft>,
) {
let map = generate_starting_base_map(&params);
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),
));
}
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>>,
) {
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);
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();
}
} else {
mouse_motion.clear();
}
for event in scroll_events.read() {
if cursor_over_ui {
continue;
}
orbit.distance *= 1.0 - event.y * ORBIT_ZOOM_SENSITIVITY;
}
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>>,
systems: Query<(&GlobalTransform, &StartingBaseSystemVisual)>,
ui_nodes: Query<(&ComputedNode, &GlobalTransform), With<StartingBaseInputBlocker>>,
mut draft: ResMut<StartingBaseDraft>,
) {
if !mouse_input.just_pressed(MouseButton::Left) {
return;
}
let Ok(window) = primary_window.single() 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 {
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;
}
}
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;
};
let cursor = cursor_logical * window.scale_factor();
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.x >= min.x && cursor.x <= max.x && cursor.y >= min.y && cursor.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);
}
}
}