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:
286
apps/game/src/gameplay/starting_base/scene.rs
Normal file
286
apps/game/src/gameplay/starting_base/scene.rs
Normal 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(¶ms);
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user