bevy_tnua/builtins/
jump.rs

1use crate::basis_capabilities::{
2    TnuaBasisWithDisplacement, TnuaBasisWithEffectiveVelocity, TnuaBasisWithGround,
3};
4use crate::util::{
5    SegmentedJumpDurationCalculator, SegmentedJumpInitialVelocityCalculator, VelocityBoundary,
6    calc_angular_velchange_to_force_forward,
7};
8use crate::{TnuaAction, TnuaActionContext, TnuaBasis};
9use crate::{
10    TnuaActionInitiationDirective, TnuaActionLifecycleDirective, TnuaActionLifecycleStatus, math::*,
11};
12use bevy::prelude::*;
13use bevy::time::Stopwatch;
14use serde::{Deserialize, Serialize};
15
16/// The basic jump [action](TnuaAction).
17///
18/// This action implements jump physics explained in <https://youtu.be/hG9SzQxaCm8> and
19/// <https://youtu.be/eeLPL3Y9jjA>. Most of its configuration fields have sane defaults - the only
20/// field that must be set is [`height`](TnuaBuiltinJumpConfig::height), which controls the jump
21/// height.
22///
23/// The action must be fed for as long as the player holds the jump button. Once the action stops
24/// being fed, it'll apply extra gravity to shorten the jump. If the game desires fixed height
25/// jumps instead (where the player cannot make lower jumps by tapping the jump button)
26/// [`shorten_extra_gravity`](TnuaBuiltinJumpConfig::shorten_extra_gravity) should be set to `0.0`.
27#[derive(Default)]
28#[cfg_attr(feature = "serialize", derive(Serialize, Deserialize))]
29pub struct TnuaBuiltinJump {
30    /// In addition to the upward motion, add a vertical component to the jump so that the peak of
31    /// the jump will be at this vector (multiplied by
32    /// [`TnuaBuiltinJumpConfig::horizontal_distance`]) from where it'd be otherwise.
33    pub horizontal_displacement: Option<Vector3>,
34
35    /// Allow this action to start even if the character is not touching ground nor in coyote time.
36    pub allow_in_air: bool,
37
38    /// Force the character to face in a particular direction.
39    ///
40    /// Note that there are no acceleration limits because unlike
41    /// [crate::builtins::TnuaBuiltinWalk::desired_forward] this field will attempt to force the
42    /// direction during a single frame. It is useful for when the jump animation needs to be
43    /// aligned with the [`horizontal_displacement`](Self::horizontal_displacement).
44    pub force_forward: Option<Dir3>,
45}
46
47#[derive(Clone, Serialize, Deserialize)]
48pub struct TnuaBuiltinJumpConfig {
49    /// The height the character will jump to.
50    ///
51    /// If [`shorten_extra_gravity`](Self::shorten_extra_gravity) is higher than `0.0`, the
52    /// character may stop the jump in the middle if the jump action is no longer fed (usually when
53    /// the player releases the jump button) and the character may not reach its full jump height.
54    ///
55    /// The jump height is calculated from the center of the character at float_height to the
56    /// center of the character at the top of the jump. It _does not_ mean the height from the
57    /// ground. The float height is calculated by the inspecting the character's current position
58    /// and the basis' [`displacement`](TnuaBasisWithDisplacement::displacement).
59    pub height: Float,
60
61    /// Extra gravity for breaking too fast jump from running up a slope.
62    ///
63    /// When running up a slope, the character gets more jump strength to avoid slamming into the
64    /// slope. This may cause the jump to be too high, so this value is used to brake it.
65    ///
66    /// **NOTE**: This force will be added to the normal gravity.
67    pub upslope_extra_gravity: Float,
68
69    /// Extra gravity for fast takeoff.
70    ///
71    /// Without this, jumps feel painfully slow. Adding this will apply extra gravity until the
72    /// vertical velocity reaches below [`takeoff_above_velocity`](Self::takeoff_above_velocity),
73    /// and increase the initial jump boost in order to compensate. This will make the jump feel
74    /// more snappy.
75    pub takeoff_extra_gravity: Float,
76
77    /// The range of upward velocity during [`takeoff_extra_gravity`](Self::takeoff_extra_gravity)
78    /// is applied.
79    ///
80    /// To disable, set this to [`Float::INFINITY`] rather than zero.
81    pub takeoff_above_velocity: Float,
82
83    /// Extra gravity for falling down after reaching the top of the jump.
84    ///
85    /// **NOTE**: This force will be added to the normal gravity.
86    pub fall_extra_gravity: Float,
87
88    /// Extra gravity for shortening a jump when the player releases the jump button.
89    ///
90    /// **NOTE**: This force will be added to the normal gravity.
91    pub shorten_extra_gravity: Float,
92
93    /// Used to decrease the time the character spends "floating" at the peak of the jump.
94    ///
95    /// When the character's upward velocity is above this value,
96    /// [`peak_prevention_extra_gravity`](Self::peak_prevention_extra_gravity) will be added to the
97    /// gravity in order to shorten the float time.
98    ///
99    /// This extra gravity is taken into account when calculating the initial jump speed, so the
100    /// character is still supposed to reach its full jump [`height`](Self::height).
101    pub peak_prevention_at_upward_velocity: Float,
102
103    /// Extra gravity for decreasing the time the character spends at the peak of the jump.
104    ///
105    /// **NOTE**: This force will be added to the normal gravity.
106    pub peak_prevention_extra_gravity: Float,
107
108    /// A duration, in seconds, after which the character would jump if the jump button was already
109    /// pressed when the jump became available.
110    ///
111    /// The duration is measured from the moment the jump became available - not from the moment
112    /// the jump button was pressed.
113    ///
114    /// When set to `None`, the character will not jump no matter how long the player holds the
115    /// jump button.
116    ///
117    /// If the jump button is held but the jump input is still buffered (see
118    /// [`input_buffer_time`](Self::input_buffer_time)), this setting will have no effect because
119    /// the character will simply jump immediately.
120    pub reschedule_cooldown: Option<Float>,
121
122    /// A duration, in seconds, where a player can press a jump button before a jump becomes
123    /// possible (typically when a character is still in the air and about the land) and the jump
124    /// action would still get registered and be executed once the jump is possible.
125    pub input_buffer_time: Float,
126
127    /// When [`horizontal_displacement`](TnuaBuiltinJump::horizontal_displacement) is given in the
128    /// action input, multiply it by this number.
129    pub horizontal_distance: Float,
130
131    /// When [`force_forward`](TnuaBuiltinJump::force_forward) is given in the action input, only
132    /// enforce it during the first part of the jump (rising up) and once the peak is reached and
133    /// the character falls down allow its direction to be determined by the basis again.
134    pub disable_force_forward_after_peak: bool,
135}
136
137impl Default for TnuaBuiltinJumpConfig {
138    fn default() -> Self {
139        Self {
140            height: 0.0,
141            upslope_extra_gravity: 30.0,
142            takeoff_extra_gravity: 30.0,
143            takeoff_above_velocity: 2.0,
144            fall_extra_gravity: 20.0,
145            shorten_extra_gravity: 60.0,
146            peak_prevention_at_upward_velocity: 1.0,
147            peak_prevention_extra_gravity: 20.0,
148            reschedule_cooldown: None,
149            input_buffer_time: 0.2,
150            horizontal_distance: 1.0,
151            disable_force_forward_after_peak: true,
152        }
153    }
154}
155
156impl TnuaBuiltinJumpConfig {
157    fn finish_or_reschedule(&self) -> TnuaActionLifecycleDirective {
158        if let Some(cooldown) = self.reschedule_cooldown {
159            TnuaActionLifecycleDirective::Reschedule {
160                after_seconds: cooldown,
161            }
162        } else {
163            TnuaActionLifecycleDirective::Finished
164        }
165    }
166
167    fn directive_simple_or_reschedule(
168        &self,
169        lifecycle_status: TnuaActionLifecycleStatus,
170    ) -> TnuaActionLifecycleDirective {
171        if let Some(cooldown) = self.reschedule_cooldown {
172            lifecycle_status.directive_simple_reschedule(cooldown)
173        } else {
174            lifecycle_status.directive_simple()
175        }
176    }
177}
178
179#[derive(Default)]
180#[cfg_attr(feature = "serialize", derive(Serialize, Deserialize))]
181pub enum TnuaBuiltinJumpMemory {
182    #[default]
183    NoJump,
184    // FreeFall,
185    StartingJump {
186        origin: Vector3,
187        /// The potential energy at the top of the jump, when:
188        /// * The potential energy at the bottom of the jump is defined as 0
189        /// * The mass is 1
190        ///
191        /// Calculating the desired velocity based on energy is easier than using the ballistic
192        /// formulas.
193        desired_energy: Float,
194    },
195    SlowDownTooFastSlopeJump {
196        origin: Vector3,
197        desired_energy: Float,
198        zero_potential_energy_at: Vector3,
199    },
200    MaintainingJump {
201        wait_one_frame_before_updating_velocity_boundary: bool,
202        velocity_boundary: Option<VelocityBoundary>,
203    },
204    StoppedMaintainingJump,
205    FallSection,
206}
207
208impl<B: TnuaBasis> TnuaAction<B> for TnuaBuiltinJump
209where
210    B: TnuaBasisWithEffectiveVelocity,
211    B: TnuaBasisWithDisplacement,
212    B: TnuaBasisWithGround,
213{
214    type Config = TnuaBuiltinJumpConfig;
215    type Memory = TnuaBuiltinJumpMemory;
216
217    fn initiation_decision(
218        &self,
219        config: &Self::Config,
220        _sensors: &B::Sensors<'_>,
221        ctx: TnuaActionContext<B>,
222        being_fed_for: &Stopwatch,
223    ) -> TnuaActionInitiationDirective {
224        if self.allow_in_air || !B::is_airborne(ctx.basis) {
225            // Either not airborne, or air jumps are allowed
226            TnuaActionInitiationDirective::Allow
227        } else if (being_fed_for.elapsed().as_secs_f64() as Float) < config.input_buffer_time {
228            TnuaActionInitiationDirective::Delay
229        } else {
230            TnuaActionInitiationDirective::Reject
231        }
232    }
233
234    fn apply(
235        &self,
236        config: &Self::Config,
237        memory: &mut Self::Memory,
238        _sensors: &B::Sensors<'_>,
239        ctx: TnuaActionContext<B>,
240        lifecycle_status: TnuaActionLifecycleStatus,
241        motor: &mut bevy_tnua_physics_integration_layer::data_for_backends::TnuaMotor,
242    ) -> TnuaActionLifecycleDirective {
243        let up = ctx.up_direction.adjust_precision();
244
245        if lifecycle_status.just_started() {
246            let mut calculator = SegmentedJumpInitialVelocityCalculator::new(config.height);
247            let gravity = ctx.tracker.gravity.dot(-up);
248            let kinetic_energy = calculator
249                .add_segment(
250                    gravity + config.peak_prevention_extra_gravity,
251                    config.peak_prevention_at_upward_velocity,
252                )
253                .add_segment(gravity, config.takeoff_above_velocity)
254                .add_final_segment(gravity + config.takeoff_extra_gravity)
255                .kinetic_energy()
256                .expect("`add_final_segment` should have covered remaining height");
257            *memory = TnuaBuiltinJumpMemory::StartingJump {
258                origin: ctx.tracker.translation,
259                desired_energy: kinetic_energy,
260            };
261        }
262
263        let effective_velocity = B::effective_velocity(ctx.basis);
264
265        if let Some(force_forward) = self.force_forward {
266            let disable_force_forward = config.disable_force_forward_after_peak
267                && match memory {
268                    TnuaBuiltinJumpMemory::NoJump => true,
269                    TnuaBuiltinJumpMemory::StartingJump { .. } => false,
270                    TnuaBuiltinJumpMemory::SlowDownTooFastSlopeJump { .. } => false,
271                    TnuaBuiltinJumpMemory::MaintainingJump { .. } => false,
272                    TnuaBuiltinJumpMemory::StoppedMaintainingJump => true,
273                    TnuaBuiltinJumpMemory::FallSection => true,
274                };
275            if !disable_force_forward {
276                motor
277                    .ang
278                    .cancel_on_axis(ctx.up_direction.adjust_precision());
279                motor.ang += calc_angular_velchange_to_force_forward(
280                    force_forward,
281                    ctx.tracker.rotation,
282                    ctx.tracker.angvel,
283                    ctx.up_direction,
284                    ctx.frame_duration,
285                );
286            }
287        }
288
289        // TODO: Once `std::mem::variant_count` gets stabilized, use that instead. The idea is to
290        // allow jumping through multiple states but failing if we get into loop.
291        for _ in 0..7 {
292            return match memory {
293                TnuaBuiltinJumpMemory::NoJump => panic!(),
294                TnuaBuiltinJumpMemory::StartingJump {
295                    origin,
296                    desired_energy,
297                } => {
298                    let extra_height = if let Some(displacement) = B::displacement(ctx.basis) {
299                        displacement.dot(up)
300                    } else if !self.allow_in_air && B::is_airborne(ctx.basis) {
301                        return config.directive_simple_or_reschedule(lifecycle_status);
302                    } else {
303                        // This means we are at Coyote time, so just jump from place.
304                        0.0
305                    };
306                    let gravity = ctx.tracker.gravity.dot(-up);
307                    let energy_from_extra_height = extra_height * gravity;
308                    let desired_kinetic_energy = *desired_energy - energy_from_extra_height;
309                    let desired_upward_velocity =
310                        SegmentedJumpInitialVelocityCalculator::kinetic_energy_to_velocity(
311                            desired_kinetic_energy,
312                        );
313
314                    let relative_velocity =
315                        effective_velocity.dot(up) - B::vertical_velocity(ctx.basis).max(0.0);
316
317                    motor.lin.cancel_on_axis(up);
318                    motor.lin.boost += (desired_upward_velocity - relative_velocity) * up;
319                    if 0.0 <= extra_height {
320                        *memory = TnuaBuiltinJumpMemory::SlowDownTooFastSlopeJump {
321                            origin: *origin,
322                            desired_energy: *desired_energy,
323                            zero_potential_energy_at: ctx.tracker.translation - extra_height * up,
324                        };
325                    }
326                    config.directive_simple_or_reschedule(lifecycle_status)
327                }
328                TnuaBuiltinJumpMemory::SlowDownTooFastSlopeJump {
329                    origin,
330                    desired_energy,
331                    zero_potential_energy_at,
332                } => {
333                    let upward_velocity = up.dot(effective_velocity);
334                    if upward_velocity <= B::vertical_velocity(ctx.basis) {
335                        *memory = TnuaBuiltinJumpMemory::FallSection;
336                        continue;
337                    } else if !lifecycle_status.is_active() {
338                        *memory = TnuaBuiltinJumpMemory::StoppedMaintainingJump;
339                        continue;
340                    }
341                    let relative_velocity = effective_velocity.dot(up);
342                    let extra_height =
343                        (ctx.tracker.translation - *zero_potential_energy_at).dot(up);
344                    let gravity = ctx.tracker.gravity.dot(-up);
345                    let energy_from_extra_height = extra_height * gravity;
346                    let desired_kinetic_energy = *desired_energy - energy_from_extra_height;
347                    let desired_upward_velocity =
348                        SegmentedJumpInitialVelocityCalculator::kinetic_energy_to_velocity(
349                            desired_kinetic_energy,
350                        );
351                    if relative_velocity <= desired_upward_velocity {
352                        let mut velocity_boundary = None;
353                        if let Some(horizontal_displacement) = self.horizontal_displacement {
354                            let horizontal_displacement = config.horizontal_distance
355                                * horizontal_displacement
356                                    .reject_from(ctx.up_direction.adjust_precision());
357                            let already_moved = (ctx.tracker.translation - *origin)
358                                .project_onto(horizontal_displacement.normalize_or_zero());
359                            let duration_to_top =
360                                SegmentedJumpDurationCalculator::new(relative_velocity)
361                                    .add_segment(
362                                        gravity + config.takeoff_extra_gravity,
363                                        config.takeoff_above_velocity,
364                                    )
365                                    .add_segment(gravity, config.peak_prevention_at_upward_velocity)
366                                    .add_segment(
367                                        gravity + config.peak_prevention_extra_gravity,
368                                        0.0,
369                                    )
370                                    .duration();
371                            let desired_vertical_velocity =
372                                (horizontal_displacement - already_moved) / duration_to_top;
373                            let desired_boost = (desired_vertical_velocity - effective_velocity)
374                                .reject_from(ctx.up_direction.adjust_precision());
375                            motor.lin.boost += desired_boost;
376                            velocity_boundary = VelocityBoundary::new(
377                                effective_velocity.reject_from(ctx.up_direction.adjust_precision()),
378                                desired_vertical_velocity,
379                                0.0,
380                            );
381                        }
382                        *memory = TnuaBuiltinJumpMemory::MaintainingJump {
383                            wait_one_frame_before_updating_velocity_boundary: true,
384                            velocity_boundary,
385                        };
386                        continue;
387                    } else {
388                        let mut extra_gravity = config.upslope_extra_gravity;
389                        if config.takeoff_above_velocity <= relative_velocity {
390                            extra_gravity += config.takeoff_extra_gravity;
391                        }
392                        motor.lin.cancel_on_axis(up);
393                        motor.lin.acceleration = -extra_gravity * up;
394                        config.directive_simple_or_reschedule(lifecycle_status)
395                    }
396                }
397                TnuaBuiltinJumpMemory::MaintainingJump {
398                    wait_one_frame_before_updating_velocity_boundary,
399                    velocity_boundary,
400                } => {
401                    if let Some(velocity_boundary) = velocity_boundary {
402                        if *wait_one_frame_before_updating_velocity_boundary {
403                            *wait_one_frame_before_updating_velocity_boundary = false;
404                        } else {
405                            velocity_boundary.update(
406                                B::effective_velocity(ctx.basis),
407                                ctx.frame_duration_as_duration(),
408                            );
409                        }
410                        if let Some((component_direction, component_limit)) = velocity_boundary
411                            .calc_boost_part_on_boundary_axis_after_limit(
412                                B::effective_velocity(ctx.basis),
413                                motor.lin.calc_boost(ctx.frame_duration),
414                                // TODO: make these parameters?
415                                0.0,
416                                1.0,
417                            )
418                        {
419                            motor.lin.apply_boost_limit(
420                                ctx.frame_duration,
421                                component_direction,
422                                component_limit,
423                            );
424                        }
425                    }
426
427                    let relevant_upward_velocity = effective_velocity.dot(up);
428                    if relevant_upward_velocity <= 0.0 {
429                        *memory = TnuaBuiltinJumpMemory::FallSection;
430                        motor.lin.cancel_on_axis(up);
431                    } else {
432                        motor.lin.cancel_on_axis(up);
433                        if relevant_upward_velocity < config.peak_prevention_at_upward_velocity {
434                            motor.lin.acceleration -= config.peak_prevention_extra_gravity * up;
435                        } else if config.takeoff_above_velocity <= relevant_upward_velocity {
436                            motor.lin.acceleration -= config.takeoff_extra_gravity * up;
437                        }
438                    }
439                    match lifecycle_status {
440                        TnuaActionLifecycleStatus::Initiated
441                        | TnuaActionLifecycleStatus::CancelledFrom
442                        | TnuaActionLifecycleStatus::StillFed => {
443                            TnuaActionLifecycleDirective::StillActive
444                        }
445                        TnuaActionLifecycleStatus::CancelledInto => config.finish_or_reschedule(),
446                        TnuaActionLifecycleStatus::NoLongerFed => {
447                            *memory = TnuaBuiltinJumpMemory::StoppedMaintainingJump;
448                            TnuaActionLifecycleDirective::StillActive
449                        }
450                    }
451                }
452                TnuaBuiltinJumpMemory::StoppedMaintainingJump => {
453                    if matches!(lifecycle_status, TnuaActionLifecycleStatus::CancelledInto) {
454                        config.finish_or_reschedule()
455                    } else {
456                        let landed = B::displacement(ctx.basis)
457                            .is_some_and(|displacement| displacement.dot(up) <= 0.0);
458                        if landed {
459                            config.finish_or_reschedule()
460                        } else {
461                            let upward_velocity = up.dot(effective_velocity);
462                            if upward_velocity <= 0.0 {
463                                *memory = TnuaBuiltinJumpMemory::FallSection;
464                                continue;
465                            }
466
467                            let extra_gravity = if config.takeoff_above_velocity <= upward_velocity
468                            {
469                                config.shorten_extra_gravity + config.takeoff_extra_gravity
470                            } else {
471                                config.shorten_extra_gravity
472                            };
473
474                            motor.lin.cancel_on_axis(up);
475                            motor.lin.acceleration -= extra_gravity * up;
476                            TnuaActionLifecycleDirective::StillActive
477                        }
478                    }
479                }
480                TnuaBuiltinJumpMemory::FallSection => {
481                    let landed = B::displacement(ctx.basis)
482                        .is_some_and(|displacement| displacement.dot(up) <= 0.0);
483                    if landed
484                        || matches!(lifecycle_status, TnuaActionLifecycleStatus::CancelledInto)
485                    {
486                        config.finish_or_reschedule()
487                    } else {
488                        motor.lin.cancel_on_axis(up);
489                        motor.lin.acceleration -= config.fall_extra_gravity * up;
490                        TnuaActionLifecycleDirective::StillActive
491                    }
492                }
493            };
494        }
495        error!("Tnua could not decide on jump state");
496        TnuaActionLifecycleDirective::Finished
497    }
498
499    fn influence_basis(
500        &self,
501        _config: &Self::Config,
502        _memory: &Self::Memory,
503        _ctx: crate::TnuaBasisContext,
504        _basis_input: &B,
505        _basis_config: &<B as TnuaBasis>::Config,
506        basis_memory: &mut <B as TnuaBasis>::Memory,
507    ) {
508        B::violate_coyote_time(basis_memory);
509    }
510}