Last active
February 4, 2026 20:11
-
-
Save Hermitao/0a908f8af19b11132e3bdb5ba4ef99f0 to your computer and use it in GitHub Desktop.
Bevy flight model - prototype
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| use avian3d::parry::na::clamp; | |
| use avian3d::prelude::*; | |
| use bevy::prelude::*; | |
| use bevy::text::FontSmoothing; | |
| use bevy::window::PresentMode; | |
| use bevy::dev_tools::fps_overlay::{ | |
| FpsOverlayConfig, | |
| FpsOverlayPlugin, | |
| FrameTimeGraphConfig | |
| }; | |
| use bevy::time::Fixed; | |
| // use bevy_sky_gradient::prelude::*; | |
| use std::thread; | |
| use chrono::Local; | |
| pub fn run() { | |
| App::new() | |
| // .insert_resource(Time::<Fixed>::from_seconds(1.0 / 60.0)) | |
| .add_plugins(( | |
| DefaultPlugins.set(WindowPlugin { | |
| primary_window: Some(Window { | |
| // Set the present mode to AutoNoVsync to disable V-Sync | |
| present_mode: PresentMode::Immediate, // or PresentMode::Immediate | |
| ..default() | |
| }), | |
| ..default() | |
| }), | |
| FpsOverlayPlugin { | |
| config: FpsOverlayConfig { | |
| text_config: TextFont { | |
| // Here we define size of our overlay | |
| font_size: 42.0, | |
| // If we want, we can use a custom font | |
| font: default(), | |
| // We could also disable font smoothing, | |
| font_smoothing: FontSmoothing::default(), | |
| ..default() | |
| }, | |
| // We can also change color of the overlay | |
| text_color: Color::srgb(1.0, 1.0, 1.0), | |
| // We can also set the refresh interval for the FPS counter | |
| refresh_interval: core::time::Duration::from_millis(100), | |
| enabled: true, | |
| frame_time_graph_config: FrameTimeGraphConfig { | |
| enabled: true, | |
| // The minimum acceptable fps | |
| min_fps: 30.0, | |
| // The target fps | |
| target_fps: 1000.0, | |
| }, | |
| }, | |
| }, | |
| PhysicsPlugins::default(), | |
| // PhysicsDebugPlugin, | |
| // SkyPlugin::default() | |
| )) | |
| .add_systems(Startup, setup) | |
| // .add_systems(Update, orbit_camera) | |
| .add_systems(FixedUpdate, (flight_camera, apply_jump_force)) | |
| .add_systems(Update, (keyboard_input, camera_stabilizer, camera_fov_system, debug_text)) | |
| .add_message::<JumpEvent>() | |
| .add_message::<ThrottleUpEvent>() | |
| .add_message::<ThrottleDownEvent>() | |
| .add_message::<ThrottleCutEvent>() | |
| .add_message::<PullUpEvent>() | |
| .add_message::<PullDownEvent>() | |
| .add_message::<RollLeftEvent>() | |
| .add_message::<RollRightEvent>() | |
| .add_message::<YawLeftEvent>() | |
| .add_message::<YawRightEvent>() | |
| .run(); | |
| } | |
| fn keyboard_input( | |
| keys: Res<ButtonInput<KeyCode>>, | |
| mut exit: MessageWriter<AppExit>, | |
| mut jump_writer: MessageWriter<JumpEvent>, | |
| mut throttle_up_writer: MessageWriter<ThrottleUpEvent>, | |
| mut throttle_down_writer: MessageWriter<ThrottleDownEvent>, | |
| mut throttle_cut_writer: MessageWriter<ThrottleCutEvent>, | |
| mut pull_up_writer: MessageWriter<PullUpEvent>, | |
| mut pull_down_writer: MessageWriter<PullDownEvent>, | |
| mut yaw_left_writer: MessageWriter<YawLeftEvent>, | |
| mut yaw_right_writer: MessageWriter<YawRightEvent>, | |
| mut roll_left_writer: MessageWriter<RollLeftEvent>, | |
| mut roll_right_writer: MessageWriter<RollRightEvent>, | |
| mut query: Query<Forces, With<PlayerBody>>, | |
| ) { | |
| if keys.just_pressed(KeyCode::Escape) { | |
| exit.write(AppExit::Success); | |
| } | |
| if keys.just_pressed(KeyCode::Space) { | |
| jump_writer.write(JumpEvent); | |
| } | |
| if keys.pressed(KeyCode::ShiftLeft) { | |
| throttle_up_writer.write(ThrottleUpEvent); | |
| } | |
| if keys.pressed(KeyCode::ControlLeft) { | |
| throttle_down_writer.write(ThrottleDownEvent); | |
| } | |
| if keys.pressed(KeyCode::KeyX) { | |
| throttle_cut_writer.write(ThrottleCutEvent); | |
| } | |
| if keys.pressed(KeyCode::KeyS) { | |
| pull_up_writer.write(PullUpEvent); | |
| } | |
| if keys.pressed(KeyCode::KeyW) { | |
| pull_down_writer.write(PullDownEvent); | |
| } | |
| if keys.pressed(KeyCode::KeyA) { | |
| yaw_left_writer.write(YawLeftEvent); | |
| } | |
| if keys.pressed(KeyCode::KeyD) { | |
| yaw_right_writer.write(YawRightEvent); | |
| } | |
| if keys.pressed(KeyCode::KeyQ) { | |
| roll_left_writer.write(RollLeftEvent); | |
| } | |
| if keys.pressed(KeyCode::KeyE) { | |
| roll_right_writer.write(RollRightEvent); | |
| } | |
| } | |
| #[derive(Message)] | |
| struct JumpEvent; | |
| #[derive(Message)] | |
| struct ThrottleUpEvent; | |
| #[derive(Message)] | |
| struct ThrottleDownEvent; | |
| #[derive(Message)] | |
| struct ThrottleCutEvent; | |
| #[derive(Message)] | |
| struct PullUpEvent; | |
| #[derive(Message)] | |
| struct PullDownEvent; | |
| #[derive(Message)] | |
| struct RollLeftEvent; | |
| #[derive(Message)] | |
| struct RollRightEvent; | |
| #[derive(Message)] | |
| struct YawLeftEvent; | |
| #[derive(Message)] | |
| struct YawRightEvent; | |
| fn apply_jump_force( | |
| events: MessageReader<JumpEvent>, | |
| mut query: Query<Forces, With<PlayerBody>>, | |
| ) { | |
| if events.is_empty() { | |
| return; | |
| } | |
| for mut forces in &mut query { | |
| forces.apply_linear_impulse(Vec3::new(0.0, 1.0, 0.0)); | |
| } | |
| } | |
| #[derive(Component)] | |
| struct OrbitCamera; | |
| #[derive(Component)] | |
| struct BentoText; | |
| #[derive(Component)] | |
| struct FlightCamera{ | |
| grounded: bool, | |
| grounded_distance: f32, | |
| max_fuel: f32, | |
| fuel: f32, | |
| consumption_rate: f32, | |
| last_grounded: bool, | |
| last_compression: f32, | |
| throttle: f32, | |
| pull_strength: f32, | |
| roll_strength: f32, | |
| yaw_strength: f32, | |
| } | |
| #[derive(Component)] | |
| struct FollowCamera { | |
| target: Entity, | |
| lerp_factor: f32, | |
| min_distance: f32, // Distance at 0 speed | |
| max_distance: f32, // Distance at max speed | |
| } | |
| #[derive(Component)] | |
| struct Wheel; | |
| #[derive(Component)] | |
| struct SpeedText; | |
| impl FlightCamera { | |
| fn default() -> Self { | |
| let max_fuel = 80.0; | |
| Self { | |
| grounded: false, | |
| grounded_distance: 0.0, | |
| max_fuel, | |
| fuel: max_fuel, | |
| consumption_rate: 0.05, | |
| last_grounded: false, | |
| last_compression: 0.0, | |
| throttle: 0.0, | |
| pull_strength: 0.0, | |
| roll_strength: 0.0, | |
| yaw_strength: 0.0, | |
| } | |
| } | |
| fn add_throttle(&mut self, amount: f32) { | |
| self.throttle += amount; | |
| self.throttle = clamp(self.throttle, 0.0, 1.0); | |
| } | |
| fn cut_throttle(&mut self) { | |
| self.throttle = 0.0; | |
| } | |
| } | |
| fn orbit_camera( | |
| time: Res<Time>, | |
| mut query: Query<&mut Transform, With<OrbitCamera>>, | |
| ) { | |
| let speed = 0.5; // radians per second | |
| for mut transform in &mut query { | |
| // Rotate around Y axis at the origin | |
| transform.rotate_around( | |
| Vec3::ZERO, | |
| Quat::from_rotation_y(speed * time.delta_secs()), | |
| ); | |
| // Always look at the center | |
| transform.look_at(Vec3::ZERO, Vec3::Y); | |
| } | |
| } | |
| fn camera_fov_system( | |
| plane_query: Query<&LinearVelocity, With<FlightCamera>>, | |
| // Use mut here to allow modifying the projection | |
| mut camera_query: Query<&mut Projection, With<FollowCamera>>, | |
| ) { | |
| // 1. Get the plane's velocity (Using LinearVelocity directly is cleaner) | |
| let Ok(velocity) = plane_query.single() else { return }; | |
| // 2. Get the camera's projection | |
| let Ok(mut projection) = camera_query.single_mut() else { return }; | |
| if let Projection::Perspective(ref mut perspective) = *projection { | |
| let speed = velocity.length(); // LinearVelocity has .length() directly in Avian | |
| let min_fov = 90.0_f32.to_radians(); | |
| let max_fov = 100.0_f32.to_radians(); | |
| let speed_threshold = 55.0; | |
| let speed_factor = (speed / speed_threshold).min(1.0); | |
| let target_fov = min_fov + (max_fov - min_fov) * speed_factor; | |
| perspective.fov = target_fov; | |
| } | |
| } | |
| fn camera_stabilizer( | |
| time: Res<Time>, | |
| mut camera_query: Query<(&mut Transform, &FollowCamera)>, | |
| target_query: Query<(&Transform, &LinearVelocity), (With<FlightCamera>, Without<FollowCamera>)>, | |
| ) { | |
| for (mut cam_trans, follow) in &mut camera_query { | |
| if let Ok((target_trans, velocity)) = target_query.get(follow.target) { | |
| // 1. Calculate speed factor | |
| let speed = velocity.length(); | |
| let speed_threshold = 55.0; | |
| let speed_factor = (speed / speed_threshold).min(1.0); | |
| // 2. Handle Rotation (Your existing logic) | |
| let (yaw, pitch, _) = target_trans.rotation.to_euler(EulerRot::YXZ); | |
| let target_rotation = Quat::from_euler(EulerRot::YXZ, yaw, pitch * 0.2, 0.0); | |
| let rotation_speed = 5.0; | |
| cam_trans.rotation = cam_trans.rotation.slerp(target_rotation, rotation_speed * time.delta_secs()); | |
| // 3. Dynamic Distance calculation | |
| // Linearly interpolate between min and max distance based on speed | |
| let dynamic_distance = follow.min_distance + (follow.max_distance - follow.min_distance) * speed_factor; | |
| // 4. Position the camera using the dynamic distance | |
| // We use the Y value 1.0 (height) and our new dynamic_distance for Z (depth) | |
| let offset = cam_trans.rotation.mul_vec3(Vec3::new(0.0, 1.0, dynamic_distance)); | |
| cam_trans.translation = target_trans.translation + offset; | |
| } | |
| } | |
| } | |
| const M_TO_FT: f32 = 3.28084; | |
| const MPS_TO_KT: f32 = 1.94384; | |
| const ASPECT_RATIO: f32 = 7.0; | |
| const OSWALD_E: f32 = 0.8; | |
| const PLANE_MASS_EMPTY: f32 = 580.0; | |
| const WHEEL_RADIUS: f32 = 0.25; | |
| const GRAVITY: f32 = 9.81; | |
| const GROUND_DISTANCE: f32 = 1.50; | |
| const PLANE_BODY_HEIGHT: f32 = 1.0; | |
| const WWEATHERVANE_PITCH_ENABLED: bool = true; | |
| fn flight_camera( | |
| time: Res<Time<Fixed>>, | |
| throttle_up_events: MessageReader<ThrottleUpEvent>, | |
| throttle_down_events: MessageReader<ThrottleDownEvent>, | |
| throttle_cut_events: MessageReader<ThrottleCutEvent>, | |
| pull_up_events: MessageReader<PullUpEvent>, | |
| pull_down_events: MessageReader<PullDownEvent>, | |
| roll_left_events: MessageReader<RollLeftEvent>, | |
| roll_right_events: MessageReader<RollRightEvent>, | |
| yaw_left_events: MessageReader<YawLeftEvent>, | |
| yaw_right_events: MessageReader<YawRightEvent>, | |
| mut query: Query<(Forces, &mut FlightCamera, &Transform, &mut Mass, &ComputedMass)>, | |
| mut query_wheels: Query<(&Wheel, &mut Transform), Without<FlightCamera>>, | |
| mut query_text: Query<Entity, With<SpeedText>>, | |
| mut query_hits: Query<(&RayCaster, &RayHits)>, | |
| mut writer: TextUiWriter, | |
| mut gizmos: Gizmos, | |
| ) { | |
| let dt = time.delta_secs(); | |
| // Throttle --- | |
| if !throttle_up_events.is_empty() { | |
| for (_, mut flight_camera, _, _, _) in &mut query { | |
| flight_camera.add_throttle(0.005); | |
| } | |
| } | |
| if !throttle_down_events.is_empty() { | |
| for (_, mut flight_camera, _, _, _) in &mut query { | |
| flight_camera.add_throttle(-0.005); | |
| } | |
| } | |
| if !throttle_cut_events.is_empty() { | |
| for (_, mut flight_camera, _, _, _) in &mut query { | |
| flight_camera.cut_throttle(); | |
| } | |
| } | |
| const PULL_ACCELERATION: f32 = 1.0; | |
| const ROLL_ACCELERATION: f32 = 1.0; | |
| const YAW_ACCELERATION: f32 = 1.0; | |
| if !pull_up_events.is_empty () { | |
| for (mut forces, mut flight_camera, _, _, _) in &mut query { | |
| let angular_acc = Vec3::new(PULL_ACCELERATION, 0.0, 0.0); | |
| forces.apply_local_angular_acceleration(angular_acc); | |
| } | |
| } | |
| if !pull_down_events.is_empty () { | |
| for (mut forces, mut flight_camera, _, _, _) in &mut query { | |
| let angular_acc = Vec3::new(-PULL_ACCELERATION, 0.0, 0.0); | |
| forces.apply_local_angular_acceleration(angular_acc); | |
| } | |
| } | |
| if !yaw_left_events.is_empty () { | |
| for (mut forces, mut flight_camera, _, _, _) in &mut query { | |
| let angular_acc = Vec3::new(0.0, YAW_ACCELERATION, 0.0); | |
| forces.apply_local_angular_acceleration(angular_acc); | |
| } | |
| } | |
| if !yaw_right_events.is_empty () { | |
| for (mut forces, mut flight_camera, _, _, _) in &mut query { | |
| let angular_acc = Vec3::new(0.0, -YAW_ACCELERATION, 0.0); | |
| forces.apply_local_angular_acceleration(angular_acc); | |
| } | |
| } | |
| if !roll_left_events.is_empty () { | |
| for (mut forces, mut flight_camera, _, _, _) in &mut query { | |
| let angular_acc = Vec3::new(0.0, 0.0, ROLL_ACCELERATION); | |
| forces.apply_local_angular_acceleration(angular_acc); | |
| } | |
| } | |
| if !roll_right_events.is_empty () { | |
| for (mut forces, mut flight_camera, _, _, _) in &mut query { | |
| let angular_acc = Vec3::new(0.0, 0.0, -ROLL_ACCELERATION); | |
| forces.apply_local_angular_acceleration(angular_acc); | |
| } | |
| } | |
| // ------- | |
| // thrust, drag | |
| const MAX_THRUST: f32 = 1600.0; | |
| for (mut forces, mut camera, transform, mut mass, computed_mass) in &mut query { | |
| for (ray, hits) in &query_hits { | |
| let mut hit_ground_flag = false; | |
| for hit in hits.iter_sorted() { | |
| // println!( | |
| // "Hit entity {} at {} with normal {}", | |
| // hit.entity, | |
| // ray.origin + *ray.direction * hit.distance, | |
| // hit.normal, | |
| // ); | |
| if hit.distance <= GROUND_DISTANCE { | |
| camera.grounded_distance = hit.distance; | |
| hit_ground_flag = true; | |
| } | |
| } | |
| camera.grounded = hit_ground_flag; | |
| } | |
| let mut thrust = camera.throttle * MAX_THRUST; | |
| if camera.fuel <= 0.0 { | |
| thrust = 0.0; | |
| } | |
| else { | |
| camera.fuel -= camera.throttle * camera.consumption_rate * dt; | |
| } | |
| camera.fuel = camera.fuel.max(0.0); | |
| mass.0 = PLANE_MASS_EMPTY + camera.fuel; | |
| let forward = transform.forward(); | |
| let thrust_vector = forward * thrust; | |
| forces.apply_force(thrust_vector); | |
| let velocity = forces.linear_velocity(); | |
| let velocity_dir = velocity.normalize_or_zero(); | |
| let speed: f32 = velocity.length(); | |
| let forward = transform.forward(); | |
| let right = transform.right(); | |
| let sin = forward.cross(velocity_dir).dot(right.as_vec3()); | |
| let cos = forward.dot(velocity_dir); | |
| let aoa_rad = -sin.atan2(cos); | |
| let aoa_deg = aoa_rad.to_degrees(); | |
| let grounded_dist = camera.grounded_distance; | |
| let gravity_force: f32 = mass.0 * GRAVITY; | |
| let gravity_vector = Vec3::new(0.0, -gravity_force, 0.0); | |
| forces.apply_force(gravity_vector); | |
| // suspension | |
| if camera.grounded { | |
| let rest_length = GROUND_DISTANCE * 0.8; | |
| let compression = (rest_length - grounded_dist).max(0.0); | |
| // 2. Damping (C) | |
| // Use the actual velocity of the body projected onto the spring axis (up/down) | |
| let velocity = forces.linear_velocity(); | |
| // Empirically, I found that 14.793103448 is a good multiplier for the spring | |
| // damping, relative to plane mass. | |
| let magic_damping_coefficent_multiplier = 14.793103448; | |
| let damping_coefficient = PLANE_MASS_EMPTY * magic_damping_coefficent_multiplier; | |
| let damping_force = -velocity.y * damping_coefficient; | |
| // 1. Stiffness (K) | |
| // Empirically, found that a stiffness of 15.625 * the damping coeffiicent is a good number, | |
| // independent of plane mass. | |
| let magic_stiffness_multiplier = 15.625; | |
| let stiffness = damping_coefficient * magic_stiffness_multiplier; | |
| let spring_force = compression * stiffness; | |
| // 3. Total Force | |
| // We add a "Spring Law" force. | |
| // We also ensure we don't pull the plane DOWN if the damping is too high | |
| let total_force_magnitude = (spring_force + damping_force).max(0.0); | |
| // Apply force at the raycast origin (or just to the body for now) | |
| forces.apply_force(Vec3::Y * total_force_magnitude); | |
| // Update wheel visual | |
| for (_wheel, mut wheel_transform) in &mut query_wheels { | |
| let target_y = -grounded_dist - WHEEL_RADIUS * 0.5; | |
| let smoothness = 0.45; | |
| wheel_transform.translation.y = | |
| wheel_transform.translation.y.lerp(target_y, smoothness); | |
| } | |
| camera.last_compression = compression; | |
| } else { | |
| camera.last_compression = 0.0; | |
| } | |
| if camera.grounded && !camera.last_grounded { | |
| camera.last_grounded = true; | |
| } | |
| // Fd = 0.5 * p * u^2 * cd * A | |
| // Drag Force = 0.5 * mass density * flow velocity^2 * drag coefficient * Area | |
| let p: f32 = 1.2041; | |
| let u_squared: f32 = speed*speed; | |
| let cd0: f32 = 0.55; | |
| let cl = match aoa_deg { | |
| d if d < 15.0 => d / 15.0 * 1.0 + 0.5, | |
| d if d < 20.0 => 1.2 * (1.0 - (d - 15.0) / 5.0), | |
| _ => 0.2, // stalled | |
| }; | |
| // add induced drag to the standard drag coefficient (cd) | |
| // Induced drag generated in the real world as a byproduct of generating lift. | |
| // More lift = more drag. | |
| let cd = cd0 + cl*cl / (std::f32::consts::PI * ASPECT_RATIO * OSWALD_E); | |
| let area = 1.6; | |
| let drag_force = 0.5 * p * u_squared * cd * area; | |
| let move_dir = forces.linear_velocity().normalize_or_zero(); | |
| let drag_vector = -move_dir * drag_force; | |
| forces.apply_force(-move_dir * drag_force); | |
| let forward = transform.forward(); | |
| let right = transform.right(); | |
| let up = transform.up(); | |
| // Calculate velocity components | |
| let side_speed = velocity.dot(right.as_vec3()); | |
| // 1. Kill "Skidding" (Lateral Drag) | |
| // This force pushes back against any sideways movement. | |
| // The higher the multiplier, the more the plane "carves" through the air. | |
| let lateral_friction_strength = 5.0; | |
| let side_drag_vector = -right.as_vec3() * side_speed * lateral_friction_strength * speed; | |
| forces.apply_force(side_drag_vector); | |
| // Weathervane effect -- rotate towards moving direction | |
| if speed > 2.0 { | |
| let velocity_dir = velocity.normalize(); | |
| let forward = transform.forward().as_vec3(); | |
| // 1. Find the rotation difference | |
| let stability_error = forward.cross(velocity_dir); | |
| // 2. Convert the error into LOCAL space so we can isolate Pitch | |
| // We transform the world-space error vector by the inverse of the plane's rotation | |
| let local_error = transform.rotation.inverse().mul_vec3(stability_error); | |
| // 3. ZERO OUT the Pitch (X) component | |
| // This ensures the weathervane effect doesn't try to pull the nose up or down | |
| let stabilized_local_error = Vec3::new(if WWEATHERVANE_PITCH_ENABLED {local_error.x} else {0.0}, local_error.y, local_error.z); | |
| // 4. Define the strength | |
| let snap_intensity = 0.0015; | |
| let stability_accel = stabilized_local_error * speed * speed * snap_intensity; | |
| // 5. Apply as LOCAL angular acceleration | |
| forces.apply_local_angular_acceleration(stability_accel); | |
| } | |
| // L = Cl * p * (v^2/2) * A | |
| // Lift = coefficient * density * (airspeed^2 / 2) * wing area | |
| let wing_area = 15.0; | |
| let dot_air = transform.forward().dot(move_dir); | |
| let dot_air_clamped = clamp(dot_air, 0.0, 1.0); | |
| let airspeed = dot_air_clamped * forces.linear_velocity().length(); | |
| let lift_force = cl * p * ((airspeed*airspeed)*0.5) * wing_area; | |
| let lift_dir = transform.up(); | |
| let lift_vector = lift_dir * lift_force; | |
| forces.apply_force(lift_vector); | |
| let (pitch, yaw, roll) = forces.rotation().to_euler(EulerRot::XYZ); | |
| let pitch_deg = pitch.to_degrees(); | |
| let yaw_deg = yaw.to_degrees(); | |
| let roll_deg = roll.to_degrees(); | |
| let grounded = camera.grounded; | |
| let total_mass = computed_mass.value(); | |
| for entity in &mut query_text { | |
| *writer.text(entity, 2) = | |
| format!("Drag force: {drag_force:.4}N\n"); | |
| *writer.text(entity, 4) = | |
| format!("Pitch: {pitch_deg:.1}o Roll: {roll_deg:.1}o Yaw: {yaw_deg:.1}o\nAoA: {aoa_deg:.1}o\nTotal mass: {total_mass:.1}kg\n"); | |
| *writer.text(entity, 6) = | |
| format!("Lift force: {lift_force:.4}N\n"); | |
| *writer.text(entity, 7) = | |
| format!("grounded: {grounded}\n"); | |
| } | |
| const GIZMOS_LENGTH_MULTIPLIER: f32 = 0.0005; | |
| // gravity gizmo | |
| gizmos.arrow( | |
| transform.translation, | |
| transform.translation + gravity_vector * GIZMOS_LENGTH_MULTIPLIER, | |
| Color::linear_rgb(0.5, 0.5, 0.5), | |
| ); | |
| // movement gizmo | |
| gizmos.arrow( | |
| transform.translation, | |
| transform.translation + forces.linear_velocity().normalize_or_zero() * 2.0, | |
| Color::linear_rgb(0.0, 1.0, 0.0), | |
| ); | |
| // thrust gizmo | |
| gizmos.arrow( | |
| transform.translation, | |
| transform.translation + thrust_vector *GIZMOS_LENGTH_MULTIPLIER, | |
| Color::linear_rgb(0.4, 0.0, 0.8), | |
| ); | |
| // lift gizmo | |
| gizmos.arrow( | |
| transform.translation, | |
| transform.translation + lift_vector *GIZMOS_LENGTH_MULTIPLIER, | |
| Color::linear_rgb(0.0, 0.5, 0.5), | |
| ); | |
| // side resistance gizmo | |
| gizmos.arrow( | |
| transform.translation, | |
| transform.translation + side_drag_vector *GIZMOS_LENGTH_MULTIPLIER, | |
| Color::linear_rgb(0.7, 0.15, 0.0), | |
| ); | |
| // drag gizmo | |
| gizmos.arrow( | |
| transform.translation, | |
| transform.translation + drag_vector *GIZMOS_LENGTH_MULTIPLIER, | |
| Color::linear_rgb(1.0, 0.0, 0.0), | |
| ); | |
| // true horizon gizmo | |
| gizmos.circle( | |
| Isometry3d::new(transform.translation, Quat::from_rotation_x(90.0_f32.to_radians())), | |
| 100.0, | |
| Color::linear_rgba(1.0, 1.0, 1.0, 0.02), | |
| ); | |
| } | |
| // ----- | |
| } | |
| fn debug_text( | |
| mut query: Query<Entity, With<SpeedText>>, | |
| mut flight_camera_query: Query<(Forces, &FlightCamera, &mut Transform)>, | |
| mut writer: TextUiWriter, | |
| ) { | |
| for entity in &mut query { | |
| for (force, camera, transform) in &mut flight_camera_query { | |
| let speed = force.linear_velocity().length(); | |
| let speed_kt = speed * MPS_TO_KT; | |
| let descent = force.linear_velocity().y; | |
| let descent_fpm = descent * M_TO_FT * 60.0; | |
| let throttle = camera.throttle * 100.0; | |
| let fuel = camera.fuel; | |
| let max_fuel = camera.max_fuel; | |
| let time = Local::now().format("%H:%M:%S").to_string(); | |
| let (x, y, z) = ( | |
| transform.translation.x, | |
| transform.translation.y, | |
| transform.translation.z | |
| ); | |
| let altitude = y * M_TO_FT; | |
| let mut fuel_time_left_secs = camera.fuel / (1.0 * camera.consumption_rate); | |
| if camera.throttle > 0.01 { | |
| fuel_time_left_secs = camera.fuel / (camera.throttle * camera.consumption_rate); | |
| } | |
| // Calculate formatting components | |
| let hours = (fuel_time_left_secs / 3600.0) as u32; | |
| let minutes = ((fuel_time_left_secs % 3600.0) / 60.0) as u32; | |
| let seconds = fuel_time_left_secs % 60.0; // Keep as f32 for the trailing digit | |
| // :04.1 means: 4 characters wide total, padded with 0, 1 decimal place. | |
| // This prevents the text from "shaking" when seconds go from 9.9 to 10.0 | |
| let fuel_timer = format!("{:02}:{:02}:{:04.1}", hours, minutes, seconds); | |
| *writer.text(entity, 0) = | |
| format!("Speed: {speed_kt:.2}kt | {speed:.1}m/s\nAltitude: {altitude:.0}ft | Climb: {descent_fpm:.0}ft/min | {descent:.1}m/s\n",); | |
| *writer.text(entity, 1) = | |
| format!("Throttle: {throttle:.2}% | Fuel: {fuel:.1}kg/{max_fuel:.1}kg | Fuel time: {fuel_timer}\n"); | |
| *writer.text(entity, 3) = | |
| format!("Time: {time}\n"); | |
| *writer.text(entity, 5) = | |
| format!("XYZ: {x:.3} / {y:.3} / {z:.3}\n"); | |
| } | |
| } | |
| } | |
| #[derive(Component)] | |
| struct PlayerBody; | |
| /// set up a simple 3D scene | |
| fn setup( | |
| mut commands: Commands, | |
| mut meshes: ResMut<Assets<Mesh>>, | |
| mut materials: ResMut<Assets<StandardMaterial>>, | |
| ) { | |
| // runway1 | |
| commands.spawn(( | |
| RigidBody::Static, | |
| SweptCcd::new_with_mode(SweepMode::NonLinear), | |
| Friction::new(0.0), | |
| ColliderConstructor::ConvexHullFromMesh, | |
| Mesh3d(meshes.add(Cuboid::new(40.0, 2.0, 2400.0))), | |
| MeshMaterial3d(materials.add(Color::linear_rgb(0.42, 0.42, 0.45))), | |
| Transform { | |
| translation: Vec3::new(0.0, 0.0, -1195.0), | |
| ..default() | |
| }, | |
| )); | |
| // runway2 | |
| commands.spawn(( | |
| RigidBody::Static, | |
| SweptCcd::new_with_mode(SweepMode::NonLinear), | |
| Friction::new(0.0), | |
| ColliderConstructor::ConvexHullFromMesh, | |
| Mesh3d(meshes.add(Cuboid::new(40.0, 2.0, 2400.0))), | |
| MeshMaterial3d(materials.add(Color::linear_rgb(0.13, 0.13, 0.16))), | |
| Transform { | |
| translation: Vec3::new(2000.0, 0.0, -8000.0), | |
| rotation: Quat::from_rotation_y(-22.5_f32.to_radians()), | |
| ..default() | |
| }, | |
| )); | |
| // cube | |
| commands.spawn(( | |
| RigidBody::Dynamic, | |
| Collider::cuboid(1.0, 1.0, 1.0), | |
| PlayerBody, | |
| Mesh3d(meshes.add(Cuboid::new(1.0, 1.0, 1.0))), | |
| AngularVelocity(Vec3::new(2.5, 3.5, 1.5)), | |
| MeshMaterial3d(materials.add(StandardMaterial { | |
| base_color: Color::srgb(0.5, 0.1, 0.2), | |
| ..default() | |
| })), | |
| Transform::from_xyz(5.0, 15.0, -10.0), | |
| )); | |
| // light | |
| commands.spawn(( | |
| PointLight { | |
| shadows_enabled: true, | |
| ..default() | |
| }, | |
| Transform::from_xyz(4.0, 8.0, -4.0), | |
| )); | |
| // directional light | |
| commands.spawn(( | |
| DirectionalLight { | |
| illuminance: 1_200.0, | |
| shadows_enabled: true, | |
| ..default() | |
| }, | |
| Transform::from_xyz(20000.0, 100000.0, 0.0).looking_at(Vec3::new(0.0, 0.0, 0.0), Vec3::Y), | |
| )); | |
| // plane | |
| let aircraft_id = commands.spawn(( | |
| RigidBody::Dynamic, | |
| SweptCcd::new_with_mode(SweepMode::NonLinear), | |
| AngularDamping(1.6), | |
| ColliderConstructor::ConvexHullFromMesh, | |
| Mesh3d(meshes.add(Cuboid::new(1.0, PLANE_BODY_HEIGHT, 2.0))), | |
| Friction::new(0.0), | |
| FlightCamera::default(), | |
| GravityScale(0.0), | |
| Mass(PLANE_MASS_EMPTY), | |
| Transform::from_xyz(0.0, 10.0, 0.0), | |
| MeshMaterial3d(materials.add(StandardMaterial { | |
| base_color: Color::srgba(0.1, 0.1, 0.5, 0.75), | |
| alpha_mode: AlphaMode::Blend, | |
| ..default() | |
| })), | |
| )).id(); | |
| commands.spawn(( | |
| Camera3d::default(), | |
| // SkyboxMagnetTag, | |
| Projection::from(PerspectiveProjection { | |
| fov: 95.0_f32.to_radians(), | |
| ..default() | |
| }), | |
| FollowCamera { | |
| target: aircraft_id, | |
| lerp_factor: 0.1, | |
| min_distance: 3.0, | |
| max_distance: 4.0, | |
| }, | |
| )); | |
| let wheel_y: f32 = -(PLANE_BODY_HEIGHT/2.0) - GROUND_DISTANCE; | |
| commands.entity(aircraft_id).with_children( |p| { | |
| // p.spawn(( | |
| // Camera3d::default(), | |
| // Projection::from(PerspectiveProjection { | |
| // fov: 95.0_f32.to_radians(), | |
| // ..default() | |
| // }), | |
| // )); | |
| p.spawn(( | |
| RayCaster::new(Vec3::ZERO, Dir3::NEG_Y) | |
| .with_query_filter( | |
| SpatialQueryFilter::default().with_excluded_entities([aircraft_id]) | |
| ), | |
| Transform::from_xyz(0.0, -(PLANE_BODY_HEIGHT/2.0), 0.0), | |
| )); | |
| p.spawn(( | |
| Mesh3d(meshes.add(Cylinder::new(WHEEL_RADIUS, 0.1))), | |
| Transform { | |
| translation: Vec3::new(0.5, wheel_y, 0.25), | |
| rotation: Quat::from_rotation_z(90.0_f32.to_radians()), | |
| ..default() | |
| }, | |
| Wheel, | |
| MeshMaterial3d(materials.add(StandardMaterial { | |
| base_color: Color::srgba(0.05, 0.05, 0.06, 0.75), | |
| alpha_mode: AlphaMode::Blend, | |
| ..default() | |
| })), | |
| )); | |
| p.spawn(( | |
| Mesh3d(meshes.add(Cylinder::new(WHEEL_RADIUS, 0.1))), | |
| Transform { | |
| translation: Vec3::new(-0.5, wheel_y, 0.25), | |
| rotation: Quat::from_rotation_z(-90.0_f32.to_radians()), | |
| ..default() | |
| }, | |
| Wheel, | |
| MeshMaterial3d(materials.add(StandardMaterial { | |
| base_color: Color::srgba(0.05, 0.05, 0.06, 0.75), | |
| alpha_mode: AlphaMode::Blend, | |
| ..default() | |
| })), | |
| )); | |
| p.spawn(( | |
| Mesh3d(meshes.add(Cylinder::new(WHEEL_RADIUS, 0.1))), | |
| Transform { | |
| translation: Vec3::new(0.0, wheel_y, -0.25), | |
| rotation: Quat::from_rotation_z(90.0_f32.to_radians()), | |
| ..default() | |
| }, | |
| Wheel, | |
| MeshMaterial3d(materials.add(StandardMaterial { | |
| base_color: Color::srgba(0.05, 0.05, 0.06, 0.75), | |
| alpha_mode: AlphaMode::Blend, | |
| ..default() | |
| })), | |
| )); | |
| }); | |
| // text | |
| commands.spawn(( | |
| Text::new("Shouldnt show up like this\n"), | |
| SpeedText, | |
| TextLayout::new_with_justify(Justify::Right), | |
| Node { | |
| position_type: PositionType::Absolute, | |
| bottom: px(5), | |
| right: px(5), | |
| ..default() | |
| } | |
| )) | |
| .with_children( |p| { | |
| p.spawn(( | |
| TextSpan::new("This is the throttle text -- shouldnt show up like this"), | |
| )); | |
| }) | |
| .with_children( |p| { | |
| p.spawn(( | |
| TextSpan::new("This is the drag text -- shouldnt show up like this"), | |
| )); | |
| }) | |
| .with_children( |p| { | |
| p.spawn(( | |
| TextSpan::new("This is the clock text -- shouldnt show up like this"), | |
| )); | |
| }) | |
| .with_children( |p| { | |
| p.spawn(( | |
| TextSpan::new("This is the angles text -- shouldnt show up like this"), | |
| )); | |
| }) | |
| .with_children( |p| { | |
| p.spawn(( | |
| TextSpan::new("This is the positions text -- shouldnt show up like this"), | |
| )); | |
| }) | |
| .with_children( |p| { | |
| p.spawn(( | |
| TextSpan::new("This is the lift force text -- shouldnt show up like this"), | |
| )); | |
| }) | |
| .with_children( |p| { | |
| p.spawn(( | |
| TextSpan::new("This is the grounded text -- shouldnt show up like this"), | |
| )); | |
| }); | |
| // Bento text | |
| commands.spawn(( | |
| Text::new("Bento text -- Shouldnt show up like this\n"), | |
| BentoText, | |
| TextLayout::new_with_justify(Justify::Right), | |
| Node { | |
| position_type: PositionType::Absolute, | |
| bottom: px(5), | |
| left: px(5), | |
| ..default() | |
| } | |
| )); | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment