bevy-tnua 0.29.0

A floating character controller for Bevy
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
use std::time::Duration;

use bevy::prelude::*;
use bevy_tnua_physics_integration_layer::data_for_backends::{
    TnuaGhostSensor, TnuaProximitySensor,
};
use serde::{Deserialize, Serialize};

use crate::TnuaBasis;
use crate::basis_action_traits::TnuaBasisAccess;
use crate::basis_capabilities::{
    TnuaBasisWithDisplacement, TnuaBasisWithFloating, TnuaBasisWithFrameOfReferenceSurface,
    TnuaBasisWithGround, TnuaBasisWithHeadroom, TnuaBasisWithSpring,
};
use crate::ghost_overrides::TnuaGhostOverwrite;
use crate::math::*;
use crate::sensor_sets::{ProximitySensorPreparationHelper, TnuaSensors};
use crate::util::rotation_arc_around_axis;
use crate::{TnuaBasisContext, TnuaMotor, TnuaVelChange};

use super::walk_sensors::TnuaBuiltinWalkSensors;

#[derive(Default)]
#[cfg_attr(feature = "serialize", derive(Serialize, Deserialize))]
pub struct TnuaBuiltinWalk {
    /// The direction (in the world space) and speed to accelerate to.
    ///
    /// Tnua assumes that this vector is orthogonal to the up dierction.
    pub desired_motion: Vector3,

    /// If non-zero, Tnua will rotate the character so that its negative Z will face in that
    /// direction.
    ///
    /// Tnua assumes that this vector is orthogonal to the up direction.
    pub desired_forward: Option<Dir3>,
}

#[derive(Clone, Serialize, Deserialize)]
pub struct TnuaBuiltinWalkConfig {
    // How fast the character will go.
    //
    // Note that this will be the speed when [`desired_motion`](TnuaBuiltinWalk::desired_motion)
    // is a unit vector - meaning that its length is 1.0. If its not 1.0, the speed will be a
    // multiply of that length.
    //
    // Also note that this is the full speed - the character will gradually accelerate to this
    // speed based on the acceleration configuration.
    pub speed: Float,

    /// The height at which the character will float above ground at rest.
    ///
    /// Note that this is the height of the character's center of mass - not the distance from its
    /// collision mesh.
    ///
    /// To make a character crouch, instead of altering this field, prefer to use the
    /// [`TnuaBuiltinCrouch`](crate::builtins::TnuaBuiltinCrouch) action.
    pub float_height: Float,

    /// Add an upward-facing proximity sensor that can check if the character has room above it.
    ///
    /// This is not (currently) used by `TnuaBuiltinWalk` itself, but
    /// [`TnuaBuiltinCrouch`](crate::builtins::TnuaBuiltinCrouch) uses it to determine
    pub headroom: Option<TnuaBuiltinWalkHeadroom>,

    /// Extra distance above the `float_height` where the spring is still in effect.
    ///
    /// When the character is at at most this distance above the
    /// [`float_height`](Self::float_height), the spring force will kick in and move it to the
    /// float height - even if that means pushing it down. If the character is above that distance
    /// above the `float_height`, Tnua will consider it to be in the air.
    pub cling_distance: Float,

    /// The force that pushes the character to the float height.
    ///
    /// The actual force applied is in direct linear relationship to the displacement from the
    /// `float_height`.
    pub spring_strength: Float,

    /// A force that slows down the characters vertical spring motion.
    ///
    /// The actual dampening is in direct linear relationship to the vertical velocity it tries to
    /// dampen.
    ///
    /// Note that as this approaches 2.0, the character starts to shake violently and eventually
    /// get launched upward at great speed.
    pub spring_dampening: Float,

    /// The acceleration for horizontal movement.
    ///
    /// Note that this is the acceleration for starting the horizontal motion and for reaching the
    /// top speed. When braking or changing direction the acceleration is greater, up to 2 times
    /// `acceleration` when doing a 180 turn.
    pub acceleration: Float,

    /// The acceleration for horizontal movement while in the air.
    ///
    /// Set to 0.0 to completely disable air movement.
    pub air_acceleration: Float,

    /// The time, in seconds, the character can still jump after losing their footing.
    pub coyote_time: Float,

    /// Extra gravity for free fall (fall that's not initiated by a jump or some other action that
    /// provides its own fall gravity)
    ///
    /// **NOTE**: This force will be added to the normal gravity.
    ///
    /// **NOTE**: If the parameter set to this option is too low, the character may be able to run
    /// up a slope and "jump" potentially even higher than a regular jump, even without pressing
    /// the jump button.
    pub free_fall_extra_gravity: Float,

    /// The maximum angular velocity used for keeping the character standing upright.
    ///
    /// NOTE: The character's rotation can also be locked to prevent it from being tilted, in which
    /// case this paramter is redundant and can be set to 0.0.
    pub tilt_offset_angvel: Float,

    /// The maximum angular acceleration used for reaching `tilt_offset_angvel`.
    ///
    /// NOTE: The character's rotation can also be locked to prevent it from being tilted, in which
    /// case this paramter is redundant and can be set to 0.0.
    pub tilt_offset_angacl: Float,

    /// The maximum angular velocity used for turning the character when the direction changes.
    pub turning_angvel: Float,

    /// The maximum slope, in radians, that the character can stand on without slipping.
    pub max_slope: Float,
}

/// Definition for an upward-facing proximity sensor that checks for obstacles above the
/// character's "head".
#[derive(Clone, Serialize, Deserialize)]
pub struct TnuaBuiltinWalkHeadroom {
    /// Disnce from the collider's center to its top.
    pub distance_to_collider_top: Float,

    /// Extra distance, from the top of the collider, for the sensor to cover.
    ///
    /// Set this slightly higher than zero. Actions that rely on the headroom sensor will want
    /// to add their own extra distance anyway by using
    /// [`set_extra_headroom`](TnuaBasisWithHeadroom::set_extra_headroom).
    pub sensor_extra_distance: Float,
}

impl Default for TnuaBuiltinWalkHeadroom {
    fn default() -> Self {
        Self {
            distance_to_collider_top: 0.0,
            sensor_extra_distance: 0.1,
        }
    }
}

impl Default for TnuaBuiltinWalkConfig {
    fn default() -> Self {
        Self {
            speed: 20.0,
            float_height: 0.0,
            headroom: None,
            cling_distance: 1.0,
            spring_strength: 400.0,
            spring_dampening: 1.2,
            acceleration: 60.0,
            air_acceleration: 20.0,
            coyote_time: 30.15,
            free_fall_extra_gravity: 60.0,
            tilt_offset_angvel: 5.0,
            tilt_offset_angacl: 500.0,
            turning_angvel: 10.0,
            max_slope: float_consts::FRAC_PI_2,
        }
    }
}

#[derive(Debug, Clone)]
#[cfg_attr(feature = "serialize", derive(Serialize, Deserialize))]
struct StandingOnState {
    entity: Entity,
    entity_linvel: Vector3,
}

#[derive(Default, Debug)]
#[cfg_attr(feature = "serialize", derive(Serialize, Deserialize))]
pub struct TnuaBuiltinWalkMemory {
    airborne_timer: Option<Timer>,
    /// The current distance of the character from the distance its supposed to float at.
    pub standing_offset: Vector3,
    standing_on: Option<StandingOnState>,
    effective_velocity: Vector3,
    vertical_velocity: Float,
    /// The velocity, perpendicular to the up direction, that the character is supposed to move at.
    ///
    /// If the character is standing on something else
    /// ([`standing_on_entity`](Self::standing_on_entity) returns `Some`) then the
    /// `running_velocity` will be relative to the velocity of that entity.
    pub running_velocity: Vector3,
    extra_headroom: Float,
}

// TODO: move these to a trait?
impl TnuaBuiltinWalkMemory {
    /// Returns the entity that the character currently stands on.
    pub fn standing_on_entity(&self) -> Option<Entity> {
        Some(self.standing_on.as_ref()?.entity)
    }
}

impl TnuaBasis for TnuaBuiltinWalk {
    type Config = TnuaBuiltinWalkConfig;

    type Memory = TnuaBuiltinWalkMemory;

    type Sensors<'a> = TnuaBuiltinWalkSensors<'a>;

    fn apply(
        &self,
        config: &Self::Config,
        memory: &mut Self::Memory,
        sensors: &Self::Sensors<'_>,
        ctx: TnuaBasisContext,
        motor: &mut TnuaMotor,
    ) {
        if let Some(stopwatch) = &mut memory.airborne_timer {
            #[allow(clippy::unnecessary_cast)]
            stopwatch.tick(Duration::from_secs_f64(ctx.frame_duration as f64));
        }

        // Reset this every frame - if there is an action that changes it, it will use
        // `influence_basis` to set it back immediately after.
        memory.extra_headroom = 0.0;

        let climb_vectors: Option<ClimbVectors>;
        let considered_in_air: bool;
        let impulse_to_offset: Vector3;
        let slipping_vector: Option<Vector3>;

        if let Some(sensor_output) = &sensors.ground.output {
            memory.effective_velocity = ctx.tracker.velocity - sensor_output.entity_linvel;
            let sideways_unnormalized = sensor_output
                .normal
                .cross(*ctx.up_direction)
                .adjust_precision();
            if sideways_unnormalized == Vector3::ZERO {
                climb_vectors = None;
            } else {
                climb_vectors = Some(ClimbVectors {
                    direction: sideways_unnormalized
                        .cross(sensor_output.normal.adjust_precision())
                        .normalize_or_zero()
                        .adjust_precision(),
                    sideways: sideways_unnormalized.normalize_or_zero().adjust_precision(),
                });
            }

            slipping_vector = {
                let angle_with_floor = sensor_output
                    .normal
                    .angle_between(*ctx.up_direction)
                    .adjust_precision();
                if angle_with_floor <= config.max_slope {
                    None
                } else {
                    Some(
                        sensor_output
                            .normal
                            .reject_from(*ctx.up_direction)
                            .adjust_precision(),
                    )
                }
            };

            if memory.airborne_timer.is_some() {
                considered_in_air = true;
                impulse_to_offset = Vector3::ZERO;
                memory.standing_on = None;
            } else {
                if let Some(standing_on_state) = &memory.standing_on {
                    if standing_on_state.entity != sensor_output.entity {
                        impulse_to_offset = Vector3::ZERO;
                    } else {
                        impulse_to_offset =
                            sensor_output.entity_linvel - standing_on_state.entity_linvel;
                    }
                } else {
                    impulse_to_offset = Vector3::ZERO;
                }

                if slipping_vector.is_none() {
                    considered_in_air = false;
                    memory.standing_on = Some(StandingOnState {
                        entity: sensor_output.entity,
                        entity_linvel: sensor_output.entity_linvel,
                    });
                } else {
                    considered_in_air = true;
                    memory.standing_on = None;
                }
            }
        } else {
            memory.effective_velocity = ctx.tracker.velocity;
            climb_vectors = None;
            considered_in_air = true;
            impulse_to_offset = Vector3::ZERO;
            slipping_vector = None;
            memory.standing_on = None;
        }
        memory.effective_velocity += impulse_to_offset;

        let velocity_on_plane = memory
            .effective_velocity
            .reject_from(ctx.up_direction.adjust_precision());

        let desired_velocity = self.desired_motion * config.speed;

        let desired_boost = desired_velocity - velocity_on_plane;

        let safe_direction_coefficient = desired_velocity
            .normalize_or_zero()
            .dot(velocity_on_plane.normalize_or_zero());
        let direction_change_factor = 1.5 - 0.5 * safe_direction_coefficient;

        let relevant_acceleration_limit = if considered_in_air {
            config.air_acceleration
        } else {
            config.acceleration
        };
        let max_acceleration = direction_change_factor * relevant_acceleration_limit;

        memory.vertical_velocity = if let Some(climb_vectors) = &climb_vectors {
            memory.effective_velocity.dot(climb_vectors.direction)
                * climb_vectors
                    .direction
                    .dot(ctx.up_direction.adjust_precision())
        } else {
            0.0
        };

        let walk_vel_change = if desired_velocity == Vector3::ZERO && slipping_vector.is_none() {
            // When stopping, prefer a boost to be able to reach a precise stop (see issue #39)
            let walk_boost = desired_boost.clamp_length_max(ctx.frame_duration * max_acceleration);
            let walk_boost = if let Some(climb_vectors) = &climb_vectors {
                climb_vectors.project(walk_boost)
            } else {
                walk_boost
            };
            TnuaVelChange::boost(walk_boost)
        } else {
            // When accelerating, prefer an acceleration because the physics backends treat it
            // better (see issue #34)
            let walk_acceleration =
                (desired_boost / ctx.frame_duration).clamp_length_max(max_acceleration);
            let walk_acceleration =
                if let (Some(climb_vectors), None) = (&climb_vectors, slipping_vector) {
                    climb_vectors.project(walk_acceleration)
                } else {
                    walk_acceleration
                };

            let slipping_boost = 'slipping_boost: {
                let Some(slipping_vector) = slipping_vector else {
                    break 'slipping_boost Vector3::ZERO;
                };
                let vertical_velocity = if 0.0 <= memory.vertical_velocity {
                    ctx.tracker.gravity.dot(ctx.up_direction.adjust_precision())
                        * ctx.frame_duration
                } else {
                    memory.vertical_velocity
                };

                let Ok((slipping_direction, slipping_per_vertical_unit)) =
                    Dir3::new_and_length(slipping_vector.f32())
                else {
                    break 'slipping_boost Vector3::ZERO;
                };

                let required_veloicty_in_slipping_direction =
                    slipping_per_vertical_unit.adjust_precision() * -vertical_velocity;
                let expected_velocity = velocity_on_plane + walk_acceleration * ctx.frame_duration;
                let expected_velocity_in_slipping_direction =
                    expected_velocity.dot(slipping_direction.adjust_precision());

                let diff = required_veloicty_in_slipping_direction
                    - expected_velocity_in_slipping_direction;

                if diff <= 0.0 {
                    break 'slipping_boost Vector3::ZERO;
                }

                slipping_direction.adjust_precision() * diff
            };
            TnuaVelChange {
                acceleration: walk_acceleration,
                boost: slipping_boost,
            }
        };

        let upward_impulse: TnuaVelChange = 'upward_impulse: {
            let should_disable_due_to_slipping =
                slipping_vector.is_some() && memory.vertical_velocity <= 0.0;
            for _ in 0..2 {
                #[allow(clippy::unnecessary_cast)]
                match &mut memory.airborne_timer {
                    None => {
                        if let (false, Some(sensor_output)) =
                            (should_disable_due_to_slipping, &sensors.ground.output)
                        {
                            // not doing the jump calculation here
                            let spring_offset =
                                config.float_height - sensor_output.proximity.adjust_precision();
                            memory.standing_offset =
                                -spring_offset * ctx.up_direction.adjust_precision();
                            break 'upward_impulse Self::spring_force(
                                &TnuaBasisAccess {
                                    input: self,
                                    config,
                                    memory,
                                },
                                &ctx,
                                spring_offset,
                            );
                        } else {
                            memory.airborne_timer = Some(Timer::from_seconds(
                                config.coyote_time as f32,
                                TimerMode::Once,
                            ));
                            continue;
                        }
                    }
                    Some(_) => {
                        if let (false, Some(sensor_output)) =
                            (should_disable_due_to_slipping, &sensors.ground.output)
                            && sensor_output.proximity.adjust_precision() <= config.float_height
                        {
                            memory.airborne_timer = None;
                            continue;
                        }
                        if memory.vertical_velocity <= 0.0 {
                            break 'upward_impulse TnuaVelChange::acceleration(
                                -config.free_fall_extra_gravity
                                    * ctx.up_direction.adjust_precision(),
                            );
                        } else {
                            break 'upward_impulse TnuaVelChange::ZERO;
                        }
                    }
                }
            }
            error!("Tnua could not decide on jump state");
            TnuaVelChange::ZERO
        };

        motor.lin = walk_vel_change + TnuaVelChange::boost(impulse_to_offset) + upward_impulse;
        let new_velocity = memory.effective_velocity
            + motor.lin.boost
            + ctx.frame_duration * motor.lin.acceleration
            - impulse_to_offset;
        memory.running_velocity = new_velocity.reject_from(ctx.up_direction.adjust_precision());

        // Tilt

        let torque_to_fix_tilt = {
            let tilted_up = ctx.tracker.rotation.mul_vec3(Vector3::Y);

            let rotation_required_to_fix_tilt =
                Quaternion::from_rotation_arc(tilted_up, ctx.up_direction.adjust_precision());

            let desired_angvel = (rotation_required_to_fix_tilt.xyz() / ctx.frame_duration)
                .clamp_length_max(config.tilt_offset_angvel);
            let angular_velocity_diff = desired_angvel - ctx.tracker.angvel;
            angular_velocity_diff.clamp_length_max(ctx.frame_duration * config.tilt_offset_angacl)
        };

        // Turning

        let desired_angvel = if let Some(desired_forward) = self.desired_forward {
            let current_forward = ctx.tracker.rotation.mul_vec3(Vector3::NEG_Z);
            let rotation_along_up_axis = rotation_arc_around_axis(
                ctx.up_direction,
                current_forward,
                desired_forward.adjust_precision(),
            )
            .unwrap_or(0.0);
            (rotation_along_up_axis / ctx.frame_duration)
                .clamp(-config.turning_angvel, config.turning_angvel)
        } else {
            0.0
        };

        // NOTE: This is the regular axis system so we used the configured up.
        let existing_angvel = ctx.tracker.angvel.dot(ctx.up_direction.adjust_precision());

        // This is the torque. Should it be clamped by an acceleration? From experimenting with
        // this I think it's meaningless and only causes bugs.
        let torque_to_turn = desired_angvel - existing_angvel;

        let existing_turn_torque = torque_to_fix_tilt.dot(ctx.up_direction.adjust_precision());
        let torque_to_turn = torque_to_turn - existing_turn_torque;

        motor.ang = TnuaVelChange::boost(
            torque_to_fix_tilt + torque_to_turn * ctx.up_direction.adjust_precision(),
        );
    }

    fn get_or_create_sensors<'a: 'b, 'b>(
        up_direction: Dir3,
        config: &'a Self::Config,
        memory: &Self::Memory,
        entities: &'a mut <Self::Sensors<'static> as TnuaSensors<'static>>::Entities,
        proximity_sensors_query: &'b Query<(&TnuaProximitySensor, Has<TnuaGhostSensor>)>,
        controller_entity: Entity,
        commands: &mut Commands,
        has_ghost_overwrites: bool,
    ) -> Option<Self::Sensors<'b>> {
        let ground = ProximitySensorPreparationHelper {
            cast_direction: -up_direction,
            cast_range: config.float_height + config.cling_distance,
            ghost_sensor: has_ghost_overwrites,
            ..Default::default()
        }
        .prepare_for(
            &mut entities.ground,
            proximity_sensors_query,
            controller_entity,
            commands,
        );

        let headroom = if let Some(headroom) = config.headroom.as_ref() {
            ProximitySensorPreparationHelper {
                cast_direction: up_direction,
                cast_range: headroom.distance_to_collider_top
                    + headroom.sensor_extra_distance
                    + memory.extra_headroom,
                ..Default::default()
            }
            .prepare_for(
                &mut entities.headroom,
                proximity_sensors_query,
                controller_entity,
                commands,
            )
        } else {
            ProximitySensorPreparationHelper::ensure_not_existing(
                &mut entities.headroom,
                proximity_sensors_query,
                commands,
            )
        };
        // .prepare_for(

        Some(Self::Sensors {
            ground: ground?,
            headroom,
        })
    }

    fn ghost_sensor_overwrites<'a>(
        ghost_overwrites: &'a mut <Self::Sensors<'static> as TnuaSensors<'static>>::GhostOverwrites,
        entities: &<Self::Sensors<'static> as TnuaSensors<'static>>::Entities,
    ) -> impl Iterator<Item = (&'a mut TnuaGhostOverwrite, Entity)> {
        [(&mut ghost_overwrites.ground, entities.ground)]
            .into_iter()
            .flat_map(|(o, e)| Some((o, e?)))
    }
}

impl TnuaBasisWithFrameOfReferenceSurface for TnuaBuiltinWalk {
    fn effective_velocity(access: &TnuaBasisAccess<Self>) -> Vector3 {
        access.memory.effective_velocity
    }

    fn vertical_velocity(access: &TnuaBasisAccess<Self>) -> Float {
        access.memory.vertical_velocity
    }
}
impl TnuaBasisWithDisplacement for TnuaBuiltinWalk {
    fn displacement(access: &TnuaBasisAccess<Self>) -> Option<Vector3> {
        match access.memory.airborne_timer {
            None => Some(access.memory.standing_offset),
            Some(_) => None,
        }
    }
}
impl TnuaBasisWithGround for TnuaBuiltinWalk {
    fn is_airborne(access: &TnuaBasisAccess<Self>) -> bool {
        access
            .memory
            .airborne_timer
            .as_ref()
            .is_some_and(|timer| timer.is_finished())
    }

    fn violate_coyote_time(memory: &mut Self::Memory) {
        if let Some(timer) = &mut memory.airborne_timer {
            timer.set_duration(Duration::ZERO);
        }
    }

    fn ground_sensor<'a>(sensors: &Self::Sensors<'a>) -> &'a TnuaProximitySensor {
        sensors.ground
    }
}
impl TnuaBasisWithHeadroom for TnuaBuiltinWalk {
    fn headroom_intrusion<'a>(
        access: &TnuaBasisAccess<Self>,
        sensors: &Self::Sensors<'a>,
    ) -> Option<std::ops::Range<Float>> {
        let headroom_config = access.config.headroom.as_ref()?;
        let headroom_sensor_output = sensors.headroom?.output.as_ref()?;
        Some(headroom_config.distance_to_collider_top..headroom_sensor_output.proximity)
    }

    fn set_extra_headroom(memory: &mut Self::Memory, extra_headroom: Float) {
        memory.extra_headroom = extra_headroom.max(0.0);
    }
}
impl TnuaBasisWithFloating for TnuaBuiltinWalk {
    fn float_height(access: &TnuaBasisAccess<Self>) -> Float {
        access.config.float_height
    }
}
impl TnuaBasisWithSpring for TnuaBuiltinWalk {
    fn spring_force(
        access: &TnuaBasisAccess<Self>,
        ctx: &TnuaBasisContext,
        spring_offset: Float,
    ) -> TnuaVelChange {
        let spring_force: Float = spring_offset * access.config.spring_strength;

        let relative_velocity = access
            .memory
            .effective_velocity
            .dot(ctx.up_direction.adjust_precision())
            - access.memory.vertical_velocity;

        let gravity_compensation = -ctx.tracker.gravity;

        let dampening_boost = relative_velocity * access.config.spring_dampening;

        TnuaVelChange {
            acceleration: ctx.up_direction.adjust_precision() * spring_force + gravity_compensation,
            boost: ctx.up_direction.adjust_precision() * -dampening_boost,
        }
    }
}

#[derive(Debug, Clone)]
struct ClimbVectors {
    direction: Vector3,
    sideways: Vector3,
}

impl ClimbVectors {
    fn project(&self, vector: Vector3) -> Vector3 {
        let axis_direction = vector.dot(self.direction) * self.direction;
        let axis_sideways = vector.dot(self.sideways) * self.sideways;
        axis_direction + axis_sideways
    }
}