diff --git a/Cargo.toml b/Cargo.toml index 9df74e3..8b4f1da 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,26 +15,59 @@ 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 # library. "bevy/dynamic_linking", "bevy/bevy_dev_tools", - "bevy/sysinfo_plugin", ] dev_native = [ "dev", # 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 = "0.15.0" +bevy = { version = "0.15.0", default-features = false, features = [ + "bevy_asset", + "bevy_state", + "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" } -iyes_perf_ui = { git = "https://github.com/JohnathanFL/iyes_perf_ui.git", branch = "main" } - +bevy-inspector-egui = { version = "0.28", optional = true, features = ["highlight_changes"] } # ----------------------------------------------------------------------------- # Some Bevy optimizations # ----------------------------------------------------------------------------- diff --git a/LICENSE b/LICENSE-APACHE similarity index 100% rename from LICENSE rename to LICENSE-APACHE diff --git a/LICENSE-MIT b/LICENSE-MIT new file mode 100644 index 0000000..9cf1062 --- /dev/null +++ b/LICENSE-MIT @@ -0,0 +1,19 @@ +MIT License + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 4747f81..10a7b0f 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,22 @@ # 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) + +## License + +Except where noted (below and/or in individual files), all code in this +repository is dual-licensed under either: + +* MIT License ([LICENSE-MIT](LICENSE-MIT) or + [http://opensource.org/licenses/MIT](http://opensource.org/licenses/MIT)) +* Apache License, Version 2.0 ([LICENSE-APACHE](LICENSE-APACHE) or + [http://www.apache.org/licenses/LICENSE-2.0](http://www.apache.org/licenses/LICENSE-2.0)) + +at your option. This means you can select the license you prefer! This +dual-licensing approach is the de-facto standard in the Rust ecosystem and there +are [very good reasons](https://github.com/bevyengine/bevy/issues/2373) to +include both. diff --git a/docs/devlog.md b/docs/devlog.md index e892ab1..32ae5ee 100644 --- a/docs/devlog.md +++ b/docs/devlog.md @@ -8,6 +8,38 @@ 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 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