ferravex_physics/character/
motion.rs

1use super::{
2    geometry::{Hit, Queries},
3    *,
4};
5#[derive(Clone, Copy, Debug, Default, PartialEq)]
6pub struct JumpState {
7    /// Seconds remaining. Include both timers and held in prediction snapshots/replays.
8    pub coyote: f32,
9    pub buffer: f32,
10    pub held: bool,
11}
12impl JumpState {
13    fn valid(self) -> bool {
14        self.coyote.is_finite()
15            && (0. ..=0.5).contains(&self.coyote)
16            && self.buffer.is_finite()
17            && (0. ..=0.5).contains(&self.buffer)
18    }
19}
20#[derive(Clone, Copy, Debug, PartialEq)]
21pub struct Support {
22    pub id: u64,
23    pub local_anchor: Vec3,
24    pub previous_anchor: Vec3,
25}
26impl Support {
27    fn new(o: Obstacle, feet: Vec3) -> Self {
28        Self {
29            id: o.id,
30            local_anchor: o.rotation.conjugate() * (feet - o.position),
31            previous_anchor: feet,
32        }
33    }
34}
35#[derive(Clone, Copy, Debug)]
36pub struct Motion {
37    pub position: Vec3,
38    pub translation: Vec3,
39    pub grounded: bool,
40    pub ground_normal: Vec3,
41    pub support: Option<Support>,
42    pub stepped: bool,
43    pub blocked: bool,
44    pub candidates: usize,
45    normals: [Vec3; 16],
46    normal_count: usize,
47}
48fn slide(mut value: Vec3, normal: Vec3, min_ground: f32) -> Vec3 {
49    let into = value.dot(normal);
50    if into < 0. {
51        value -= normal * into;
52    }
53    if normal.y > 0. && normal.y < min_ground && value.y > 0. {
54        value.y = 0.;
55        let horizontal = Vec3::new(normal.x, 0., normal.z).normalize_or_zero();
56        let into = value.dot(horizontal);
57        if into < 0. {
58            value -= horizontal * into;
59        }
60    }
61    value
62}
63fn ground<T: Terrain + ?Sized>(
64    queries: &mut Queries<'_, T>,
65    p: Vec3,
66    distance: f32,
67) -> Result<Option<Hit>, CharacterError> {
68    Ok(queries
69        .cast(p, -Vec3::Y * distance)?
70        .filter(|h| h.normal.y >= queries.settings.min_ground_normal))
71}
72fn try_step<T: Terrain + ?Sized>(
73    q: &mut Queries<'_, T>,
74    p: Vec3,
75    remaining: Vec3,
76) -> Result<Option<(Vec3, Hit)>, CharacterError> {
77    let up = Vec3::Y * q.settings.step_height;
78    let across = Vec3::new(remaining.x, 0., remaining.z);
79    if across.length_squared() < 1e-12 || q.cast(p, up)?.is_some() {
80        return Ok(None);
81    }
82    let raised = p + up;
83    if q.cast(raised, across)?.is_some() {
84        return Ok(None);
85    }
86    let destination = raised + across;
87    let distance = q.settings.step_height + q.settings.ground_snap;
88    let Some(mut hit) = q.cast(destination, -Vec3::Y * distance)? else {
89        return Ok(None);
90    };
91    if hit.normal.y < q.settings.min_ground_normal {
92        // A capsule meets a short stair's rounded Minkowski edge before its center
93        // reaches the tread. Classify that authored tread, not the round edge, while
94        // still sweeping the full capsule and advancing only the requested XY/XZ move.
95        let ObstacleShape::Box { half } = hit.obstacle.shape else {
96            return Ok(None);
97        };
98        let top = hit.obstacle.rotation * Vec3::Y;
99        if top.y < q.settings.min_ground_normal || hit.normal.y <= 0. {
100            return Ok(None);
101        }
102        let plane = hit.obstacle.position + top * half.y;
103        let height = (plane.dot(top) - destination.x * top.x - destination.z * top.z) / top.y;
104        if height - p.y > q.settings.step_height + q.settings.skin || height < p.y - q.settings.skin
105        {
106            return Ok(None);
107        }
108        hit.normal = top;
109    }
110    Ok(Some((destination - Vec3::Y * distance * hit.fraction, hit)))
111}
112fn move_inner<T: Terrain + ?Sized>(
113    q: &mut Queries<'_, T>,
114    origin: Vec3,
115    delta: Vec3,
116    was_grounded: bool,
117) -> Result<Motion, CharacterError> {
118    let mut p = q.recover(origin)?;
119    let mut remaining = delta;
120    let mut result = Motion {
121        position: p,
122        translation: Vec3::ZERO,
123        grounded: false,
124        ground_normal: Vec3::Y,
125        support: None,
126        stepped: false,
127        blocked: false,
128        candidates: 0,
129        normals: [Vec3::ZERO; 16],
130        normal_count: 0,
131    };
132    let mut stair_support = None;
133    for _ in 0..q.settings.max_iterations {
134        if remaining.length_squared() < 1e-12 {
135            break;
136        }
137        let Some(hit) = q.cast(p, remaining)? else {
138            p += remaining;
139            break;
140        };
141        p += remaining * hit.fraction;
142        remaining *= 1. - hit.fraction;
143        result.blocked = true;
144        if was_grounded
145            && delta.y <= 0.
146            && hit.normal.y < q.settings.min_ground_normal
147            && q.settings.step_height > 0.
148            && let Some((stepped, landing)) = try_step(q, p, remaining)?
149        {
150            p = stepped;
151            stair_support = Some(landing);
152            result.stepped = true;
153            break;
154        }
155        if result.normal_count < result.normals.len() {
156            result.normals[result.normal_count] = hit.normal;
157            result.normal_count += 1;
158        }
159        remaining = slide(remaining, hit.normal, q.settings.min_ground_normal);
160    }
161    let support = if delta.y <= 0. {
162        ground(q, p, q.settings.ground_snap + q.settings.skin)?
163    } else {
164        None
165    };
166    if let Some(hit) = support {
167        p -= Vec3::Y * (q.settings.ground_snap + q.settings.skin) * hit.fraction;
168    }
169    if let Some(hit) = support.or(stair_support) {
170        result.grounded = true;
171        result.ground_normal = hit.normal;
172        result.support = Some(Support::new(hit.obstacle, p));
173    }
174    if !bounded(p) {
175        return Err(CharacterError::InvalidInput);
176    }
177    result.position = p;
178    result.translation = p - origin;
179    result.candidates = q.candidates;
180    Ok(result)
181}
182/// Sweep/slide without applying acceleration, gravity or jump state. Displacement
183/// may be long; no destination-only collision tests or discrete movement substeps.
184/// A bounded starting-overlap recovery is attempted; errors leave caller state intact.
185pub fn move_and_slide<T: Terrain + ?Sized>(
186    world: &T,
187    shape: CharacterShape,
188    settings: CharacterSettings,
189    position: Vec3,
190    displacement: Vec3,
191    was_grounded: bool,
192) -> Result<Motion, CharacterError> {
193    if !shape.valid()
194        || !settings.valid()
195        || !bounded(position)
196        || !bounded(displacement)
197        || (shape.planar() && (position.z != 0. || displacement.z != 0.))
198    {
199        return Err(CharacterError::InvalidInput);
200    }
201    let mut queries = Queries {
202        world,
203        shape,
204        settings,
205        candidates: 0,
206        ignored: None,
207    };
208    move_inner(&mut queries, position, displacement, was_grounded)
209}
210#[derive(Clone, Copy, Debug, Default)]
211pub struct CharacterInput {
212    /// Desired horizontal direction, clamped to unit length. Y is ignored.
213    pub direction: Vec3,
214    /// Current held state; the motor buffers only a new press, avoiding automatic bunny hops.
215    pub jump: bool,
216}
217#[derive(Clone, Copy, Debug, PartialEq)]
218pub struct Character {
219    pub position: Vec3,
220    /// Velocity relative to a supporting platform; airborne velocity is in world space.
221    pub velocity: Vec3,
222    pub grounded: bool,
223    pub ground_normal: Vec3,
224    pub jump: JumpState,
225    pub support: Option<Support>,
226}
227impl Character {
228    pub fn new(feet: Vec3) -> Self {
229        Self {
230            position: feet,
231            velocity: Vec3::ZERO,
232            grounded: false,
233            ground_normal: Vec3::Y,
234            jump: Default::default(),
235            support: None,
236        }
237    }
238    /// Advance at a fixed dt in (0, 0.1]. Terrain must already have this tick's pose.
239    /// All state updates commit together; invalid input or exhausted queries leave self unchanged.
240    pub fn step<T: Terrain + ?Sized>(
241        &mut self,
242        world: &T,
243        shape: CharacterShape,
244        settings: CharacterSettings,
245        input: CharacterInput,
246        dt: f32,
247    ) -> Result<CharacterStep, CharacterError> {
248        if !dt.is_finite()
249            || !(0. ..=0.1).contains(&dt)
250            || dt == 0.
251            || !settings.valid()
252            || !shape.valid()
253            || !bounded(self.position)
254            || !bounded(self.velocity)
255            || !self.ground_normal.is_finite()
256            || !input.direction.is_finite()
257            || !self.jump.valid()
258            || (shape.planar() && (self.position.z != 0. || self.velocity.z != 0.))
259            || self
260                .support
261                .is_some_and(|s| !bounded(s.local_anchor) || !bounded(s.previous_anchor))
262        {
263            return Err(CharacterError::InvalidInput);
264        }
265        let mut next = *self;
266        let mut q = Queries {
267            world,
268            shape,
269            settings,
270            candidates: 0,
271            ignored: None,
272        };
273        let mut carry = Vec3::ZERO;
274        if let Some(support) = next.support
275            && let Some(o) = world.obstacle(support.id)
276        {
277            if !o.valid() {
278                return Err(CharacterError::InvalidObstacle);
279            }
280            if !o.sensor && o.membership & settings.membership_mask != 0 {
281                let delta =
282                    o.position + o.rotation * support.local_anchor - support.previous_anchor;
283                if !bounded(delta) {
284                    return Err(CharacterError::InvalidInput);
285                }
286                q.ignored = Some(o.id);
287                let motion = move_inner(&mut q, next.position, delta, false)?;
288                q.ignored = None;
289                next.position = motion.position;
290                carry = motion.translation / dt;
291            }
292        }
293        next.position = q.recover(next.position)?;
294        let on_ground = next.velocity.y <= 0.
295            && ground(&mut q, next.position, settings.ground_snap + settings.skin)?.is_some();
296        let was_grounded = next.grounded || on_ground;
297        next.jump.coyote = if was_grounded {
298            settings.coyote_time
299        } else {
300            (next.jump.coyote - dt).max(0.)
301        };
302        next.jump.buffer = if input.jump && !next.jump.held {
303            settings.jump_buffer.max(dt)
304        } else {
305            (next.jump.buffer - dt).max(0.)
306        };
307        next.jump.held = input.jump;
308        let jumped = next.jump.buffer > 0. && (was_grounded || next.jump.coyote > 0.);
309        let wish = Vec3::new(
310            input.direction.x,
311            0.,
312            if shape.planar() {
313                0.
314            } else {
315                input.direction.z
316            },
317        )
318        .clamp_length_max(1.)
319            * settings.speed;
320        let horizontal = Vec3::new(next.velocity.x, 0., next.velocity.z);
321        let acceleration = if was_grounded {
322            settings.ground_acceleration
323        } else {
324            settings.air_acceleration
325        };
326        next.velocity += (wish - horizontal).clamp_length_max(acceleration * dt);
327        if jumped {
328            next.velocity.y = settings.jump_speed;
329            next.velocity += carry;
330            next.jump.buffer = 0.;
331            next.jump.coyote = 0.;
332            next.grounded = false;
333        }
334        next.velocity.y = (next.velocity.y - settings.gravity * dt).max(-settings.terminal_speed);
335        let motion = move_inner(
336            &mut q,
337            next.position,
338            next.velocity * dt,
339            was_grounded && !jumped,
340        )?;
341        next.position = motion.position;
342        next.grounded = motion.grounded;
343        next.ground_normal = motion.ground_normal;
344        next.support = motion.support;
345        for normal in &motion.normals[..motion.normal_count] {
346            next.velocity = slide(next.velocity, *normal, settings.min_ground_normal);
347        }
348        if next.grounded {
349            next.velocity.y = 0.;
350        }
351        if !bounded(next.velocity) {
352            return Err(CharacterError::InvalidInput);
353        }
354        *self = next;
355        Ok(CharacterStep {
356            motion,
357            jumped,
358            carried: carry * dt,
359        })
360    }
361}
362#[derive(Clone, Copy, Debug)]
363pub struct CharacterStep {
364    pub motion: Motion,
365    pub jumped: bool,
366    pub carried: Vec3,
367}