bevy_tnua/builtins/
walk.rs

1use std::time::Duration;
2
3use crate::math::{float_consts, AdjustPrecision, AsF32, Float, Quaternion, Vector3};
4use bevy::prelude::*;
5
6use crate::util::rotation_arc_around_axis;
7use crate::TnuaBasisContext;
8use crate::{TnuaBasis, TnuaVelChange};
9
10/// The most common [basis](TnuaBasis) - walk around as a floating capsule.
11///
12/// This basis implements the floating capsule character controller explained in
13/// <https://youtu.be/qdskE8PJy6Q>. It controls both the floating and the movement. Most of its
14/// fields have sane defaults, except:
15///
16/// * [`float_height`](Self::float_height) - this field defaults to 0.0, which means the character
17///   will not float. Set it to be higher than the distance from the center of the entity to the
18///   bottom of the collider.
19/// * [`desired_velocity`](Self::desired_velocity) - while leaving this as as the default
20///   `Vector3::ZERO`, doing so would mean that the character will not move.
21/// * [`desired_forward`](Self::desired_forward) - leaving this is the default `None` will mean
22///   that Tnua will not attempt to fix the character's rotation along the up axis.
23///
24///   This is fine if rotation along the up axis is locked (Rapier and Avian only support locking
25///   cardinal axes, but the up direction is based on the gravity which means it defaults to the Y
26///   axis so it usually works out).
27///
28///   This is also fine for 2D games (or games with 3D graphics and 2D physics) played from side
29///   view where the physics engine cannot rotate the character along the up axis.
30///
31///   But if the physics engine is free to rotate the character's rigid body along the up axis,
32///   leaving `desired_forward` as the default `Vector3::ZERO` may cause the character to spin
33///   uncontrollably when it contacts other colliders. Unless, of course, some other mechanism
34///   prevents that.
35#[derive(Clone, Debug)]
36pub struct TnuaBuiltinWalk {
37    /// The direction (in the world space) and speed to accelerate to.
38    ///
39    /// Tnua assumes that this vector is orthogonal to the up dierction.
40    pub desired_velocity: Vector3,
41
42    /// If non-zero, Tnua will rotate the character so that its negative Z will face in that
43    /// direction.
44    ///
45    /// Tnua assumes that this vector is orthogonal to the up direction.
46    pub desired_forward: Option<Dir3>,
47
48    /// The height at which the character will float above ground at rest.
49    ///
50    /// Note that this is the height of the character's center of mass - not the distance from its
51    /// collision mesh.
52    ///
53    /// To make a character crouch, instead of altering this field, prefer to use the
54    /// [`TnuaBuiltinCrouch`](crate::builtins::TnuaBuiltinCrouch) action.
55    pub float_height: Float,
56
57    /// Extra distance above the `float_height` where the spring is still in effect.
58    ///
59    /// When the character is at at most this distance above the
60    /// [`float_height`](Self::float_height), the spring force will kick in and move it to the
61    /// float height - even if that means pushing it down. If the character is above that distance
62    /// above the `float_height`, Tnua will consider it to be in the air.
63    pub cling_distance: Float,
64
65    /// The force that pushes the character to the float height.
66    ///
67    /// The actual force applied is in direct linear relationship to the displacement from the
68    /// `float_height`.
69    pub spring_strength: Float,
70
71    /// A force that slows down the characters vertical spring motion.
72    ///
73    /// The actual dampening is in direct linear relationship to the vertical velocity it tries to
74    /// dampen.
75    ///
76    /// Note that as this approaches 2.0, the character starts to shake violently and eventually
77    /// get launched upward at great speed.
78    pub spring_dampening: Float,
79
80    /// The acceleration for horizontal movement.
81    ///
82    /// Note that this is the acceleration for starting the horizontal motion and for reaching the
83    /// top speed. When braking or changing direction the acceleration is greater, up to 2 times
84    /// `acceleration` when doing a 180 turn.
85    pub acceleration: Float,
86
87    /// The acceleration for horizontal movement while in the air.
88    ///
89    /// Set to 0.0 to completely disable air movement.
90    pub air_acceleration: Float,
91
92    /// The time, in seconds, the character can still jump after losing their footing.
93    pub coyote_time: Float,
94
95    /// Extra gravity for free fall (fall that's not initiated by a jump or some other action that
96    /// provides its own fall gravity)
97    ///
98    /// **NOTE**: This force will be added to the normal gravity.
99    ///
100    /// **NOTE**: If the parameter set to this option is too low, the character may be able to run
101    /// up a slope and "jump" potentially even higher than a regular jump, even without pressing
102    /// the jump button.
103    pub free_fall_extra_gravity: Float,
104
105    /// The maximum angular velocity used for keeping the character standing upright.
106    ///
107    /// NOTE: The character's rotation can also be locked to prevent it from being tilted, in which
108    /// case this paramter is redundant and can be set to 0.0.
109    pub tilt_offset_angvel: Float,
110
111    /// The maximum angular acceleration used for reaching `tilt_offset_angvel`.
112    ///
113    /// NOTE: The character's rotation can also be locked to prevent it from being tilted, in which
114    /// case this paramter is redundant and can be set to 0.0.
115    pub tilt_offset_angacl: Float,
116
117    /// The maximum angular velocity used for turning the character when the direction changes.
118    pub turning_angvel: Float,
119
120    /// The maximum slope, in radians, that the character can stand on without slipping.
121    pub max_slope: Float,
122}
123
124impl Default for TnuaBuiltinWalk {
125    fn default() -> Self {
126        Self {
127            desired_velocity: Vector3::ZERO,
128            desired_forward: None,
129            float_height: 0.0,
130            cling_distance: 1.0,
131            spring_strength: 400.0,
132            spring_dampening: 1.2,
133            acceleration: 60.0,
134            air_acceleration: 20.0,
135            coyote_time: 0.15,
136            free_fall_extra_gravity: 60.0,
137            tilt_offset_angvel: 5.0,
138            tilt_offset_angacl: 500.0,
139            turning_angvel: 10.0,
140            max_slope: float_consts::FRAC_PI_2,
141        }
142    }
143}
144
145impl TnuaBasis for TnuaBuiltinWalk {
146    const NAME: &'static str = "TnuaBuiltinWalk";
147    type State = TnuaBuiltinWalkState;
148
149    fn apply(&self, state: &mut Self::State, ctx: TnuaBasisContext, motor: &mut crate::TnuaMotor) {
150        if let Some(stopwatch) = &mut state.airborne_timer {
151            #[allow(clippy::unnecessary_cast)]
152            stopwatch.tick(Duration::from_secs_f64(ctx.frame_duration as f64));
153        }
154
155        let climb_vectors: Option<ClimbVectors>;
156        let considered_in_air: bool;
157        let impulse_to_offset: Vector3;
158        let slipping_vector: Option<Vector3>;
159
160        if let Some(sensor_output) = &ctx.proximity_sensor.output {
161            state.effective_velocity = ctx.tracker.velocity - sensor_output.entity_linvel;
162            let sideways_unnormalized = sensor_output
163                .normal
164                .cross(*ctx.up_direction)
165                .adjust_precision();
166            if sideways_unnormalized == Vector3::ZERO {
167                climb_vectors = None;
168            } else {
169                climb_vectors = Some(ClimbVectors {
170                    direction: sideways_unnormalized
171                        .cross(sensor_output.normal.adjust_precision())
172                        .normalize_or_zero()
173                        .adjust_precision(),
174                    sideways: sideways_unnormalized.normalize_or_zero().adjust_precision(),
175                });
176            }
177
178            slipping_vector = {
179                let angle_with_floor = sensor_output
180                    .normal
181                    .angle_between(*ctx.up_direction)
182                    .adjust_precision();
183                if angle_with_floor <= self.max_slope {
184                    None
185                } else {
186                    Some(
187                        sensor_output
188                            .normal
189                            .reject_from(*ctx.up_direction)
190                            .adjust_precision(),
191                    )
192                }
193            };
194
195            if state.airborne_timer.is_some() {
196                considered_in_air = true;
197                impulse_to_offset = Vector3::ZERO;
198                state.standing_on = None;
199            } else {
200                if let Some(standing_on_state) = &state.standing_on {
201                    if standing_on_state.entity != sensor_output.entity {
202                        impulse_to_offset = Vector3::ZERO;
203                    } else {
204                        impulse_to_offset =
205                            sensor_output.entity_linvel - standing_on_state.entity_linvel;
206                    }
207                } else {
208                    impulse_to_offset = Vector3::ZERO;
209                }
210
211                if slipping_vector.is_none() {
212                    considered_in_air = false;
213                    state.standing_on = Some(StandingOnState {
214                        entity: sensor_output.entity,
215                        entity_linvel: sensor_output.entity_linvel,
216                    });
217                } else {
218                    considered_in_air = true;
219                    state.standing_on = None;
220                }
221            }
222        } else {
223            state.effective_velocity = ctx.tracker.velocity;
224            climb_vectors = None;
225            considered_in_air = true;
226            impulse_to_offset = Vector3::ZERO;
227            slipping_vector = None;
228            state.standing_on = None;
229        }
230        state.effective_velocity += impulse_to_offset;
231
232        let velocity_on_plane = state
233            .effective_velocity
234            .reject_from(ctx.up_direction.adjust_precision());
235
236        let desired_boost = self.desired_velocity - velocity_on_plane;
237
238        let safe_direction_coefficient = self
239            .desired_velocity
240            .normalize_or_zero()
241            .dot(velocity_on_plane.normalize_or_zero());
242        let direction_change_factor = 1.5 - 0.5 * safe_direction_coefficient;
243
244        let relevant_acceleration_limit = if considered_in_air {
245            self.air_acceleration
246        } else {
247            self.acceleration
248        };
249        let max_acceleration = direction_change_factor * relevant_acceleration_limit;
250
251        state.vertical_velocity = if let Some(climb_vectors) = &climb_vectors {
252            state.effective_velocity.dot(climb_vectors.direction)
253                * climb_vectors
254                    .direction
255                    .dot(ctx.up_direction.adjust_precision())
256        } else {
257            0.0
258        };
259
260        let walk_vel_change = if self.desired_velocity == Vector3::ZERO && slipping_vector.is_none()
261        {
262            // When stopping, prefer a boost to be able to reach a precise stop (see issue #39)
263            let walk_boost = desired_boost.clamp_length_max(ctx.frame_duration * max_acceleration);
264            let walk_boost = if let Some(climb_vectors) = &climb_vectors {
265                climb_vectors.project(walk_boost)
266            } else {
267                walk_boost
268            };
269            TnuaVelChange::boost(walk_boost)
270        } else {
271            // When accelerating, prefer an acceleration because the physics backends treat it
272            // better (see issue #34)
273            let walk_acceleration =
274                (desired_boost / ctx.frame_duration).clamp_length_max(max_acceleration);
275            let walk_acceleration =
276                if let (Some(climb_vectors), None) = (&climb_vectors, slipping_vector) {
277                    climb_vectors.project(walk_acceleration)
278                } else {
279                    walk_acceleration
280                };
281
282            let slipping_boost = 'slipping_boost: {
283                let Some(slipping_vector) = slipping_vector else {
284                    break 'slipping_boost Vector3::ZERO;
285                };
286                let vertical_velocity = if 0.0 <= state.vertical_velocity {
287                    ctx.tracker.gravity.dot(ctx.up_direction.adjust_precision())
288                        * ctx.frame_duration
289                } else {
290                    state.vertical_velocity
291                };
292
293                let Ok((slipping_direction, slipping_per_vertical_unit)) =
294                    Dir3::new_and_length(slipping_vector.f32())
295                else {
296                    break 'slipping_boost Vector3::ZERO;
297                };
298
299                let required_veloicty_in_slipping_direction =
300                    slipping_per_vertical_unit.adjust_precision() * -vertical_velocity;
301                let expected_velocity = velocity_on_plane + walk_acceleration * ctx.frame_duration;
302                let expected_velocity_in_slipping_direction =
303                    expected_velocity.dot(slipping_direction.adjust_precision());
304
305                let diff = required_veloicty_in_slipping_direction
306                    - expected_velocity_in_slipping_direction;
307
308                if diff <= 0.0 {
309                    break 'slipping_boost Vector3::ZERO;
310                }
311
312                slipping_direction.adjust_precision() * diff
313            };
314            TnuaVelChange {
315                acceleration: walk_acceleration,
316                boost: slipping_boost,
317            }
318        };
319
320        let upward_impulse: TnuaVelChange = 'upward_impulse: {
321            let should_disable_due_to_slipping =
322                slipping_vector.is_some() && state.vertical_velocity <= 0.0;
323            for _ in 0..2 {
324                #[allow(clippy::unnecessary_cast)]
325                match &mut state.airborne_timer {
326                    None => {
327                        if let (false, Some(sensor_output)) =
328                            (should_disable_due_to_slipping, &ctx.proximity_sensor.output)
329                        {
330                            // not doing the jump calculation here
331                            let spring_offset =
332                                self.float_height - sensor_output.proximity.adjust_precision();
333                            state.standing_offset =
334                                -spring_offset * ctx.up_direction.adjust_precision();
335                            break 'upward_impulse self.spring_force(state, &ctx, spring_offset);
336                        } else {
337                            state.airborne_timer = Some(Timer::from_seconds(
338                                self.coyote_time as f32,
339                                TimerMode::Once,
340                            ));
341                            continue;
342                        }
343                    }
344                    Some(_) => {
345                        if let (false, Some(sensor_output)) =
346                            (should_disable_due_to_slipping, &ctx.proximity_sensor.output)
347                        {
348                            if sensor_output.proximity.adjust_precision() <= self.float_height {
349                                state.airborne_timer = None;
350                                continue;
351                            }
352                        }
353                        if state.vertical_velocity <= 0.0 {
354                            break 'upward_impulse TnuaVelChange::acceleration(
355                                -self.free_fall_extra_gravity * ctx.up_direction.adjust_precision(),
356                            );
357                        } else {
358                            break 'upward_impulse TnuaVelChange::ZERO;
359                        }
360                    }
361                }
362            }
363            error!("Tnua could not decide on jump state");
364            TnuaVelChange::ZERO
365        };
366
367        motor.lin = walk_vel_change + TnuaVelChange::boost(impulse_to_offset) + upward_impulse;
368        let new_velocity = state.effective_velocity
369            + motor.lin.boost
370            + ctx.frame_duration * motor.lin.acceleration
371            - impulse_to_offset;
372        state.running_velocity = new_velocity.reject_from(ctx.up_direction.adjust_precision());
373
374        // Tilt
375
376        let torque_to_fix_tilt = {
377            let tilted_up = ctx.tracker.rotation.mul_vec3(Vector3::Y);
378
379            let rotation_required_to_fix_tilt =
380                Quaternion::from_rotation_arc(tilted_up, ctx.up_direction.adjust_precision());
381
382            let desired_angvel = (rotation_required_to_fix_tilt.xyz() / ctx.frame_duration)
383                .clamp_length_max(self.tilt_offset_angvel);
384            let angular_velocity_diff = desired_angvel - ctx.tracker.angvel;
385            angular_velocity_diff.clamp_length_max(ctx.frame_duration * self.tilt_offset_angacl)
386        };
387
388        // Turning
389
390        let desired_angvel = if let Some(desired_forward) = self.desired_forward {
391            let current_forward = ctx.tracker.rotation.mul_vec3(Vector3::NEG_Z);
392            let rotation_along_up_axis = rotation_arc_around_axis(
393                ctx.up_direction,
394                current_forward,
395                desired_forward.adjust_precision(),
396            )
397            .unwrap_or(0.0);
398            (rotation_along_up_axis / ctx.frame_duration)
399                .clamp(-self.turning_angvel, self.turning_angvel)
400        } else {
401            0.0
402        };
403
404        // NOTE: This is the regular axis system so we used the configured up.
405        let existing_angvel = ctx.tracker.angvel.dot(ctx.up_direction.adjust_precision());
406
407        // This is the torque. Should it be clamped by an acceleration? From experimenting with
408        // this I think it's meaningless and only causes bugs.
409        let torque_to_turn = desired_angvel - existing_angvel;
410
411        let existing_turn_torque = torque_to_fix_tilt.dot(ctx.up_direction.adjust_precision());
412        let torque_to_turn = torque_to_turn - existing_turn_torque;
413
414        motor.ang = TnuaVelChange::boost(
415            torque_to_fix_tilt + torque_to_turn * ctx.up_direction.adjust_precision(),
416        );
417    }
418
419    fn proximity_sensor_cast_range(&self, _state: &Self::State) -> Float {
420        self.float_height + self.cling_distance
421    }
422
423    fn displacement(&self, state: &Self::State) -> Option<Vector3> {
424        match state.airborne_timer {
425            None => Some(state.standing_offset),
426            Some(_) => None,
427        }
428    }
429
430    fn effective_velocity(&self, state: &Self::State) -> Vector3 {
431        state.effective_velocity
432    }
433
434    fn vertical_velocity(&self, state: &Self::State) -> Float {
435        state.vertical_velocity
436    }
437
438    fn neutralize(&mut self) {
439        self.desired_velocity = Vector3::ZERO;
440        self.desired_forward = None;
441    }
442
443    fn is_airborne(&self, state: &Self::State) -> bool {
444        state
445            .airborne_timer
446            .as_ref()
447            .is_some_and(|timer| timer.finished())
448    }
449
450    fn violate_coyote_time(&self, state: &mut Self::State) {
451        if let Some(timer) = &mut state.airborne_timer {
452            timer.set_duration(Duration::ZERO);
453        }
454    }
455}
456
457impl TnuaBuiltinWalk {
458    /// Calculate the vertical spring force that this basis would need to apply assuming its
459    /// vertical distance from the vertical distance it needs to be at equals the `spring_offset`
460    /// argument.
461    ///
462    /// Note: this is exposed so that actions like
463    /// [`TnuaBuiltinCrouch`](crate::builtins::TnuaBuiltinCrouch) may rely on it.
464    pub fn spring_force(
465        &self,
466        state: &TnuaBuiltinWalkState,
467        ctx: &TnuaBasisContext,
468        spring_offset: Float,
469    ) -> TnuaVelChange {
470        let spring_force: Float = spring_offset * self.spring_strength;
471
472        let relative_velocity = state
473            .effective_velocity
474            .dot(ctx.up_direction.adjust_precision())
475            - state.vertical_velocity;
476
477        let gravity_compensation = -ctx.tracker.gravity;
478
479        let dampening_boost = relative_velocity * self.spring_dampening;
480
481        TnuaVelChange {
482            acceleration: ctx.up_direction.adjust_precision() * spring_force + gravity_compensation,
483            boost: ctx.up_direction.adjust_precision() * -dampening_boost,
484        }
485    }
486}
487
488#[derive(Debug, Clone)]
489struct StandingOnState {
490    entity: Entity,
491    entity_linvel: Vector3,
492}
493
494#[derive(Default, Clone, Debug)]
495pub struct TnuaBuiltinWalkState {
496    airborne_timer: Option<Timer>,
497    /// The current distance of the character from the distance its supposed to float at.
498    pub standing_offset: Vector3,
499    standing_on: Option<StandingOnState>,
500    effective_velocity: Vector3,
501    vertical_velocity: Float,
502    /// The velocity, perpendicular to the up direction, that the character is supposed to move at.
503    ///
504    /// If the character is standing on something else
505    /// ([`standing_on_entity`](Self::standing_on_entity) returns `Some`) then the
506    /// `running_velocity` will be relative to the velocity of that entity.
507    pub running_velocity: Vector3,
508}
509
510impl TnuaBuiltinWalkState {
511    /// Returns the entity that the character currently stands on.
512    pub fn standing_on_entity(&self) -> Option<Entity> {
513        Some(self.standing_on.as_ref()?.entity)
514    }
515
516    pub fn reset_airborne_timer(&mut self) {
517        self.airborne_timer = None;
518    }
519}
520
521#[derive(Debug, Clone)]
522struct ClimbVectors {
523    direction: Vector3,
524    sideways: Vector3,
525}
526
527impl ClimbVectors {
528    fn project(&self, vector: Vector3) -> Vector3 {
529        let axis_direction = vector.dot(self.direction) * self.direction;
530        let axis_sideways = vector.dot(self.sideways) * self.sideways;
531        axis_direction + axis_sideways
532    }
533}