diff --git a/Cargo.toml b/Cargo.toml index 5fb0f90..8b4f1da 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,7 @@ path = "src/main.rs" default = [ # Default to a native dev build. "dev_native", + "render", ] dev = [ # Improve compile times for dev builds by linking Bevy as a dynamic @@ -27,28 +28,46 @@ dev_native = [ # Enable system information plugin for native dev builds. "bevy/sysinfo_plugin", ] +headless = [ + # Exclude rendering features + # This feature does not include the "render" feature + # Thus, rendering-related dependencies are disabled +] +render = [ + # Enable features needed for visuals, windowed operation, and UIs. + "bevy/bevy_asset", + "bevy/bevy_color", + "bevy/bevy_core_pipeline", + "bevy/bevy_gizmos", + "bevy/ktx2", + "bevy/bevy_mesh_picking_backend", + "bevy/bevy_pbr", + "bevy/bevy_render", + "bevy/bevy_picking", + "bevy/bevy_text", + "bevy/bevy_ui", + "bevy/bevy_ui_picking_backend", + "bevy/bevy_window", + "bevy/bevy_winit", + "bevy/default_font", + "bevy/tonemapping_luts", + "bevy/png", + "bevy/webgl2", +] +inspect = [ + "default", + "bevy-inspector-egui", +] [dependencies] bevy = { version = "0.15.0", default-features = false, features = [ - "bevy_core_pipeline", - "bevy_gizmos", - "bevy_mesh_picking_backend", - "bevy_pbr", - "bevy_picking", - "bevy_render", + "bevy_asset", "bevy_state", - "bevy_text", - "bevy_ui", - "bevy_ui_picking_backend", - "bevy_window", - "bevy_winit", - "default_font", "multi_threaded", - ] } avian3d = { git = "https://github.com/Jondolf/avian.git", branch = "main", features = ["debug-plugin"] } bevy-trait-query = { git = "https://github.com/JoJoJet/bevy-trait-query.git", branch = "bevy-0.15-rc" } - +bevy-inspector-egui = { version = "0.28", optional = true, features = ["highlight_changes"] } # ----------------------------------------------------------------------------- # Some Bevy optimizations # ----------------------------------------------------------------------------- diff --git a/README.md b/README.md index 096f437..10a7b0f 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # yet another HAB simulator -A high altitude balloon flight simulator based on -[tkschuler/EarthSHAB](https://github.com/tkschuler/EarthSHAB) and -[brickworks/mfc-apps](https://github.com/Brickworks/mfc-apps), built on Rust. +A high altitude balloon flight simulator built in +[Bevy](https://bevyengine.org/) with Rust, inspired by +[tkschuler/EarthSHAB](https://github.com/tkschuler/EarthSHAB). [devlog](docs/devlog.md) diff --git a/docs/devlog.md b/docs/devlog.md index 744abbb..32ae5ee 100644 --- a/docs/devlog.md +++ b/docs/devlog.md @@ -8,12 +8,39 @@ Bevy 0.15 support. Looks like they haven't but there are branches for it. - [x] `avian3d` -> branch `main` - [x] `bevy-trait-query` -> branch `bevy-0.15-rc` - [x] `iyes_perf_ui` -> branch `main` +- [x] `bevy_egui` -> version `0.31` +- [x] `bevy_inspector_egui` -> version `0.28` I'm hoping to slim down the dependencies for the project to shorten compile times. I'm a little annoyed with the `iyes_perf_ui` crate. It is a great tool but there is a ton of boilerplate code to set up the Ui. I think the built-in text crate will be enough for what I'm doing and will simplify things. +Now, there are still a few things I am interested in doing soon: + +- [ ] Add a payload hanging from a tether. +- [ ] Add a skybox or some reference in the background to illustrate the + balloon's size and altitude. +- [ ] Style the UI like a retro radar display. +- [ ] Sort out the time scale multiplier (the physics becomes unstable when it + goes above 1.5). +- [ ] Add plots for altitude, pressure, temperature, volume, and density. + +I did a little work to group the rendering plugins into a separate plugin group +from the headless plugins. I don't know why I decided to do that, but it will +come in handy when I want to compile for wasm or terminal operation. + +Things I added: + +- [x] Rendering plugins in separate plugin group. `headless` runs without gfx. +- [x] Lighting based on Bevy's example. +- [x] Camera controller based on Bevy's `camera_controller` example. +- [x] Observer/trigger-based debug UI toggles. Only some of them are working. +- [x] Added targeting controls to the camera. + +I spent a few hours trying to get the skybox working but I couldn't get it to +load. + ## 2024-11-29 I should lean into the wireframe aesthetic and make this whole thing look like a diff --git a/src/app3d/camera.rs b/src/app3d/camera.rs index 760a72d..1319ba6 100644 --- a/src/app3d/camera.rs +++ b/src/app3d/camera.rs @@ -1,114 +1,369 @@ use bevy::{ - input::mouse::{MouseScrollUnit, MouseWheel}, + input::mouse::{AccumulatedMouseMotion, AccumulatedMouseScroll, MouseScrollUnit}, prelude::*, + window::CursorGrabMode, }; +use std::f32::consts::PI; -use super::controls::KeyBindingsConfig; +use super::controls::{CameraControls, KeyBindingsConfig}; use crate::simulator::Balloon; -const INVERT_ZOOM: bool = true; - pub struct CameraPlugin; impl Plugin for CameraPlugin { fn build(&self, app: &mut App) { - app.init_resource::(); app.add_systems(Startup, setup); - app.add_systems(Update, zoom_camera); - app.add_plugins(CameraFollowPlugin); + app.add_plugins(CameraTargetPlugin); + app.add_plugins(CameraControllerPlugin); } } -#[derive(Component, Default)] -#[require(Camera3d, PerspectiveProjection)] +/// The main camera component. +#[derive(Component, Default, Reflect)] +#[require(Camera3d, PerspectiveProjection, CameraController)] pub struct MainCamera; -/// A resource that stores the currently selected camera target. -#[derive(Resource)] -struct CameraSelection { - entity: Entity, - offset: Vec3, +fn setup(mut commands: Commands, keybinds: Res) { + commands.spawn(( + Name::new("Main Camera"), + MainCamera, + Transform::from_xyz(0.0, 0.0, 10.0), + )); + let controls = &keybinds.into_inner().camera_controls; + commands.spawn(( + Text::new(format!( + "Freecam Controls: + {:?} - Focus on target + {:?} - Cycle to next target & follow it + {:?} - Clear target + {:?} Mouse - Click & drag to rotate camera + {:?} / {:?} / {:?} / {:?} - Fly forward, backward, left, right + {:?} / {:?} - Fly up / down + {:?} - Hold to fly faster", + controls.tap_focus_target, + controls.tap_cycle_target, + controls.tap_clear_target, + controls.hold_look, + controls.tap_forward, + controls.tap_back, + controls.tap_left, + controls.tap_right, + controls.tap_up, + controls.tap_down, + controls.tap_run, + )), + Node { + position_type: PositionType::Absolute, + bottom: Val::Px(12.), + left: Val::Px(12.), + ..default() + }, + )); } -impl Default for CameraSelection { - fn default() -> Self { - Self { - entity: Entity::PLACEHOLDER, - offset: Vec3::new(0., 0., 10.), - } +struct CameraTargetPlugin; + +impl Plugin for CameraTargetPlugin { + fn build(&self, app: &mut App) { + app.add_event::(); + app.add_observer(cycle_to_next_target_system); + + app.add_event::(); + app.add_observer(focus_on_target); + + app.add_event::(); + app.add_observer(clear_target); + + app.add_systems( + Update, + ( + mark_new_targetables, + follow_selected_target, + handle_camera_targeting_inputs, + ), + ); } } +const DEFAULT_TARGET_OFFSET: Vec3 = Vec3::new(0.0, 0.0, 10.0); + +#[derive(Event)] +struct FocusOnTarget; + /// A marker component for entities that can be selected as a camera target. #[derive(Component, Default, Reflect)] -pub struct CameraTarget; +pub struct Targetable; -fn setup(mut commands: Commands) { - commands.spawn(( - Name::new("Main Camera"), - MainCamera, - Camera3d::default(), - Transform::from_xyz(0.0, 20., 50.0).looking_at(Vec3::new(0., 20., 0.), Vec3::Y), - )); +/// A marker component for entities that can be selected as a camera target. +#[derive(Component, Default, Reflect)] +pub struct Targeted; + +/// An event that is emitted when the camera should cycle to the next target. +#[derive(Event)] +struct CycleToNextTarget; + +/// An event that is emitted when the camera should clear its target. +#[derive(Event)] +struct ClearTarget; + +fn mark_new_targetables(mut commands: Commands, balloons: Query>) { + for entity in &balloons { + commands.entity(entity).insert(Targetable); + } } -fn zoom_camera( - mut camera: Query<&mut PerspectiveProjection, (With, With)>, - mut evr_scroll: EventReader, - key_bindings: Res, +fn handle_camera_targeting_inputs( + mut commands: Commands, + key_input: Res>, + keybinds: Res, ) { - let mut projection = camera.single_mut(); - let ctrl = &key_bindings.camera_controls; - let direction = if INVERT_ZOOM { -1.0 } else { 1.0 }; - for ev in evr_scroll.read() { - match ev.unit { - MouseScrollUnit::Line => { - projection.fov = projection.fov.clamp(ctrl.min_fov, ctrl.max_fov) - + ev.y * ctrl.zoom_step * direction; - } - MouseScrollUnit::Pixel => { - projection.fov = projection.fov.clamp(ctrl.min_fov, ctrl.max_fov) - + ev.y * ctrl.zoom_step * direction; - } - } + let controls = &keybinds.into_inner().camera_controls; + if key_input.just_pressed(controls.tap_focus_target) { + commands.trigger(FocusOnTarget); + } + if key_input.just_pressed(controls.tap_cycle_target) { + commands.trigger(CycleToNextTarget); + } + if key_input.just_pressed(controls.tap_clear_target) { + commands.trigger(ClearTarget); } } -struct CameraFollowPlugin; +fn clear_target( + _trigger: Trigger, + mut commands: Commands, + target: Option>>, +) { + if let Some(target) = target { + commands.entity(target.into_inner()).remove::(); + } +} -impl Plugin for CameraFollowPlugin { - fn build(&self, app: &mut App) { - app.add_systems(Update, (mark_new_targets, follow_selected_target)); +fn focus_on_target( + _trigger: Trigger, + target: Option, Without)>>, + camera: Single<&mut Transform, With>, +) { + if let Some(target) = target { + let target = target.into_inner(); + let mut cam = camera.into_inner(); + cam.look_at(target.translation, Vec3::Y); + } +} + +fn follow_selected_target( + target: Option, Without)>>, + camera: Single<&mut Transform, (With, Without)>, +) { + let mut cam = camera.into_inner(); + if let Some(target) = target { + let target = target.into_inner(); + cam.translation = cam.translation + target.translation; } } -fn mark_new_targets( +fn cycle_to_next_target_system( + _trigger: Trigger, mut commands: Commands, - balloons: Query>, - mut selection: ResMut, + old_target: Option>>, + targets: Query< + (Entity, &Transform), + (With, Without, Without), + >, + mut camera_query: Query<&mut Transform, With>, ) { - for entity in &balloons { - commands.entity(entity).insert(CameraTarget); - // Focus on the newest balloon - selection.entity = entity; + let mut camera = camera_query.single_mut(); + + let target_entities: Vec<(Entity, &Transform)> = targets.iter().collect(); + if target_entities.is_empty() { + return; + } + + let old_target = match old_target { + Some(target) => Some(target.into_inner()), + None => None, + }; + + // Determine the next target index + let next_target = if let Some(current_entity) = old_target { + let current_index = target_entities + .iter() + .position(|&(e, _)| e == current_entity) + .unwrap_or(0); + target_entities + .iter() + .cycle() + .nth(current_index + 1) + .map(|&(e, _)| e) + .unwrap_or(target_entities[0].0) + } else { + target_entities[0].0 + }; + + // Remove Targeted from the current entity, if any + if let Some(current_entity) = old_target { + commands.entity(current_entity).remove::(); + } + + // Add Targeted to the next entity + commands.entity(next_target).insert(Targeted); + + // Retrieve the Transform of the new target and update the camera position + if let Ok((_, target_transform)) = targets.get(next_target) { + camera.translation = target_transform.translation + DEFAULT_TARGET_OFFSET; + camera.look_at(target_transform.translation, Vec3::Y); } } -fn follow_selected_target( - selection: Res, - targets: Query<&Transform, (With, Without)>, - mut camera: Query<&mut Transform, With>, +struct CameraControllerPlugin; + +impl Plugin for CameraControllerPlugin { + fn build(&self, app: &mut App) { + app.add_systems(Update, run_camera_controller); + } +} + +#[derive(Component)] +struct CameraController { + pub enabled: bool, + pub initialized: bool, + pub sensitivity: f32, + pub walk_speed: f32, + pub run_speed: f32, + pub scroll_factor: f32, + pub friction: f32, + pub pitch: f32, + pub yaw: f32, + pub velocity: Vec3, + pub controls: CameraControls, +} + +impl Default for CameraController { + fn default() -> Self { + Self { + enabled: true, + initialized: false, + sensitivity: 1.0, + walk_speed: 10.0, + run_speed: 30.0, + scroll_factor: 0.1, + friction: 0.1, + pitch: 0.0, + yaw: 0.0, + velocity: Vec3::ZERO, + controls: CameraControls::default(), + } + } +} + +/// Based on Valorant's default sensitivity, not entirely sure why it is exactly +/// 1.0 / 180.0, but I'm guessing it is a misunderstanding between +/// degrees/radians and then sticking with it because it felt nice. +pub const RADIANS_PER_DOT: f32 = 1.0 / 180.0; + +#[allow(clippy::too_many_arguments)] +fn run_camera_controller( + time: Res