bevy_tnua/builtins/
jump.rs

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