Skip to main content

alice_physics/
character.rs

1//! Character Controller
2//!
3//! Kinematic character controller with capsule collider.
4//! Handles ground detection, stair stepping, slope limiting,
5//! and SDF terrain collision.
6//!
7//! # Features
8//!
9//! - Capsule-based collision shape
10//! - `move_and_slide()` for smooth movement along surfaces
11//! - Ground detection via downward raycast
12//! - Configurable stair step height
13//! - Slope angle limiting
14//! - SDF terrain integration
15//!
16//! `move_and_slide()` sees each static body as a sphere of the character's
17//! radius; [`crate::solver::PhysicsWorld::move_character`]
18//! ([`crate::world_character`]) moves the same controller against the geometry
19//! the world collides with (body shapes, compound children, static colliders,
20//! SDF colliders).
21//!
22//! Author: Moroya Sakamoto
23
24use crate::collider::Sphere;
25use crate::math::{Fix128, Vec3Fix};
26use crate::raycast::{ray_plane, ray_sphere, sweep_ray_sphere, Ray, RayHit};
27use crate::sdf_collider::SdfCollider;
28use crate::solver::RigidBody;
29
30#[cfg(not(feature = "std"))]
31use alloc::vec::Vec;
32
33// ============================================================================
34// Configuration
35// ============================================================================
36
37/// Character controller configuration
38#[derive(Clone, Copy, Debug, PartialEq, Eq)]
39pub struct CharacterConfig {
40    /// Capsule radius
41    pub radius: Fix128,
42    /// Capsule total height (including hemispheres)
43    pub height: Fix128,
44    /// Maximum slope angle the character can walk on (radians)
45    pub max_slope_angle: Fix128,
46    /// Stair step height
47    pub step_height: Fix128,
48    /// Skin width (small gap to prevent tunneling)
49    pub skin_width: Fix128,
50    /// Ground detection ray distance below feet
51    pub ground_probe_distance: Fix128,
52    /// Maximum number of slide iterations
53    pub max_slides: usize,
54    /// Push force applied to dynamic bodies on collision
55    pub push_force: Fix128,
56}
57
58impl Default for CharacterConfig {
59    fn default() -> Self {
60        Self {
61            radius: Fix128::from_ratio(3, 10),                // 0.3
62            height: Fix128::from_ratio(18, 10),               // 1.8
63            max_slope_angle: Fix128::from_ratio(785, 1000),   // ~0.785 rad (45 degrees)
64            step_height: Fix128::from_ratio(3, 10),           // 0.3
65            skin_width: Fix128::from_ratio(1, 100),           // 0.01
66            ground_probe_distance: Fix128::from_ratio(1, 10), // 0.1
67            max_slides: 4,
68            push_force: Fix128::from_int(5),
69        }
70    }
71}
72
73// ============================================================================
74// Move Result
75// ============================================================================
76
77/// Impulse to apply to a rigid body from character collision
78#[derive(Clone, Copy, Debug, PartialEq, Eq)]
79pub struct PushImpulse {
80    /// Index of the body to push
81    pub body_index: usize,
82    /// Impulse vector to apply
83    pub impulse: Vec3Fix,
84    /// World-space point of application
85    pub point: Vec3Fix,
86}
87
88/// Result of a character move operation
89#[derive(Clone, Copy, Debug, PartialEq, Eq)]
90pub struct MoveResult {
91    /// Final position after movement
92    pub position: Vec3Fix,
93    /// Whether the character is on the ground
94    pub grounded: bool,
95    /// Velocity after sliding (may differ from input if we slid along a wall)
96    pub velocity: Vec3Fix,
97    /// Velocity inherited from the moving platform (zero if not on platform)
98    pub platform_velocity: Vec3Fix,
99}
100
101// ============================================================================
102// Character Controller
103// ============================================================================
104
105/// Kinematic character controller
106pub struct CharacterController {
107    /// Current position (capsule center)
108    pub position: Vec3Fix,
109    /// Current velocity
110    pub velocity: Vec3Fix,
111    /// Whether the character is on the ground
112    pub grounded: bool,
113    /// Configuration
114    pub config: CharacterConfig,
115    /// Index of the ground body (for moving platform support)
116    pub ground_body_index: Option<usize>,
117    /// Velocity inherited from the moving platform
118    pub platform_velocity: Vec3Fix,
119}
120
121impl CharacterController {
122    /// Create a new character controller at the given position
123    #[must_use]
124    pub const fn new(position: Vec3Fix, config: CharacterConfig) -> Self {
125        Self {
126            position,
127            velocity: Vec3Fix::ZERO,
128            grounded: false,
129            config,
130            ground_body_index: None,
131            platform_velocity: Vec3Fix::ZERO,
132        }
133    }
134
135    /// Create with default config
136    #[must_use]
137    pub fn new_default(position: Vec3Fix) -> Self {
138        Self::new(position, CharacterConfig::default())
139    }
140
141    /// Get the bottom of the capsule (feet position)
142    #[inline]
143    #[must_use]
144    pub fn feet_position(&self) -> Vec3Fix {
145        let half_height = self.config.height.half();
146        Vec3Fix::new(
147            self.position.x,
148            self.position.y - half_height + self.config.radius,
149            self.position.z,
150        )
151    }
152
153    /// Move the character by the given displacement, sliding along colliders.
154    ///
155    /// Uses the "collide and slide" algorithm:
156    /// 1. Try to move the full displacement
157    /// 2. On collision, project remaining displacement onto the collision plane
158    /// 3. Repeat up to `max_slides` times
159    ///
160    /// Static bodies are seen as spheres of the character's radius at their
161    /// positions (see `sweep_against_bodies`); for the true shapes and the
162    /// world's static colliders use [`crate::solver::PhysicsWorld::move_character`].
163    pub fn move_and_slide(
164        &mut self,
165        displacement: Vec3Fix,
166        bodies: &[RigidBody],
167        sdf_colliders: &[SdfCollider],
168    ) -> MoveResult {
169        // Add platform velocity from previous frame
170        let mut remaining = displacement + self.platform_velocity;
171        let mut pos = self.position;
172
173        for _ in 0..self.config.max_slides {
174            let move_len = remaining.length();
175            if move_len < self.config.skin_width {
176                break;
177            }
178
179            // Check body collisions (simplified: treat each body as a sphere)
180            if let Some(hit) = self.sweep_against_bodies(pos, remaining, bodies) {
181                // Move to just before the hit
182                let safe_t = if hit.t > self.config.skin_width {
183                    hit.t - self.config.skin_width
184                } else {
185                    Fix128::ZERO
186                };
187                let move_dir = remaining.normalize();
188                pos = pos + move_dir * safe_t;
189
190                // Slide: project remaining displacement onto the collision plane
191                let used = safe_t;
192                let left = move_len - used;
193                if left < self.config.skin_width {
194                    break;
195                }
196                let remainder_dir = remaining.normalize();
197                let remainder_vec = remainder_dir * left;
198                let slide = remainder_vec - hit.normal * remainder_vec.dot(hit.normal);
199                remaining = slide;
200            } else {
201                // No collision, move freely
202                pos = pos + remaining;
203                break;
204            }
205        }
206
207        // SDF collision: push out of any SDF surfaces
208        #[cfg(feature = "std")]
209        {
210            pos = self.resolve_sdf(pos, sdf_colliders);
211        }
212
213        // Stair stepping: if blocked horizontally and grounded, try stepping up
214        let horizontal_moved = Vec3Fix::new(
215            pos.x - self.position.x,
216            Fix128::ZERO,
217            pos.z - self.position.z,
218        );
219        let horizontal_desired = Vec3Fix::new(displacement.x, Fix128::ZERO, displacement.z);
220        let moved_ratio = horizontal_moved.length_squared();
221        let desired_ratio = horizontal_desired.length_squared();
222
223        if self.grounded && !desired_ratio.is_zero() {
224            let threshold = desired_ratio * Fix128::from_ratio(1, 4); // less than 25% moved
225            if moved_ratio < threshold {
226                let step_up = Vec3Fix::new(Fix128::ZERO, self.config.step_height, Fix128::ZERO);
227                let stepped_pos = pos + step_up;
228                // Try moving horizontally from stepped position
229                let horiz_disp = Vec3Fix::new(displacement.x, Fix128::ZERO, displacement.z);
230                let test_pos = stepped_pos + horiz_disp;
231
232                // Verify no collision at stepped position
233                let step_clear = self.check_position_clear(test_pos, bodies);
234
235                #[cfg(feature = "std")]
236                let step_clear_sdf = self.check_sdf_clear(test_pos, sdf_colliders);
237                #[cfg(not(feature = "std"))]
238                let step_clear_sdf = true;
239
240                if step_clear && step_clear_sdf {
241                    // Snap down to find the actual stair surface
242                    let snap_ray = Ray::new(
243                        test_pos,
244                        Vec3Fix::new(Fix128::ZERO, Fix128::NEG_ONE, Fix128::ZERO),
245                    );
246                    let snap_dist = self.config.step_height + self.config.step_height;
247                    if let Some(ground_hit) = ray_plane(
248                        &snap_ray,
249                        Vec3Fix::UNIT_Y,
250                        self.position.y - self.config.height.half() + self.config.radius,
251                        snap_dist,
252                    ) {
253                        let snapped_y = test_pos.y - ground_hit.t;
254                        pos = Vec3Fix::new(test_pos.x, snapped_y, test_pos.z);
255                    } else {
256                        pos = test_pos;
257                    }
258                }
259            }
260        }
261
262        // Ground detection
263        let (grounded, ground_idx) = self.detect_ground(pos, bodies, sdf_colliders);
264
265        // Compute platform velocity from ground body
266        let platform_vel = ground_idx.map_or(Vec3Fix::ZERO, |idx| {
267            if idx < bodies.len() {
268                bodies[idx].velocity
269            } else {
270                Vec3Fix::ZERO
271            }
272        });
273
274        self.position = pos;
275        self.grounded = grounded;
276        self.ground_body_index = ground_idx;
277        self.platform_velocity = platform_vel;
278        self.velocity = displacement; // Caller controls velocity
279
280        MoveResult {
281            position: pos,
282            grounded,
283            velocity: displacement,
284            platform_velocity: platform_vel,
285        }
286    }
287
288    // COV-MBD-104: collision against the true body shapes and the world's static
289    // colliders is `PhysicsWorld::move_character` (src/world_character.rs); this
290    // sweep stays the sphere approximation below and its behaviour is unchanged.
291    /// Sweep the character capsule against rigid bodies (simplified sphere
292    /// approximation): each static body is a sphere of the character's radius at
293    /// the body's position, dynamic bodies are skipped, and the world's static
294    /// colliders (planes, height fields, triangle meshes) and body shapes are not
295    /// seen. [`crate::solver::PhysicsWorld::move_character`] sweeps the capsule
296    /// against the geometry the world collides with.
297    fn sweep_against_bodies(
298        &self,
299        from: Vec3Fix,
300        displacement: Vec3Fix,
301        bodies: &[RigidBody],
302    ) -> Option<RayHit> {
303        let move_len = displacement.length();
304        if move_len.is_zero() {
305            return None;
306        }
307        let direction = displacement / move_len;
308
309        let char_sphere = Sphere::new(from, self.config.radius);
310        let ray = Ray::new(from, direction);
311
312        let mut closest: Option<RayHit> = None;
313        let mut best_t = move_len;
314
315        for (i, body) in bodies.iter().enumerate() {
316            // Skip dynamic bodies for now (character interacts with statics)
317            if !body.is_static() {
318                continue;
319            }
320
321            // Treat body as a sphere with the character's collision radius expanded
322            let body_sphere = Sphere::new(body.position, self.config.radius);
323            let expanded = Sphere::new(body_sphere.center, body_sphere.radius + char_sphere.radius);
324
325            if let Some(mut hit) = sweep_ray_sphere(&ray, &expanded, best_t) {
326                hit.body_index = i;
327                best_t = hit.t;
328                closest = Some(hit);
329            }
330        }
331
332        closest
333    }
334
335    /// Check if a position is clear of body collisions
336    fn check_position_clear(&self, pos: Vec3Fix, bodies: &[RigidBody]) -> bool {
337        let r = self.config.radius + self.config.skin_width;
338        let r_sq = r * r;
339
340        for body in bodies {
341            if !body.is_static() {
342                continue;
343            }
344            let dist_sq = (pos - body.position).length_squared();
345            if dist_sq < r_sq {
346                return false;
347            }
348        }
349        true
350    }
351
352    /// Resolve SDF collisions: push character out of SDF surfaces
353    #[cfg(feature = "std")]
354    fn resolve_sdf(&self, mut pos: Vec3Fix, sdf_colliders: &[SdfCollider]) -> Vec3Fix {
355        let half_h = self.config.height.half();
356        // Check at 3 points along the capsule: bottom, center, top
357        let offsets = [
358            Vec3Fix::new(Fix128::ZERO, -half_h + self.config.radius, Fix128::ZERO),
359            Vec3Fix::ZERO,
360            Vec3Fix::new(Fix128::ZERO, half_h - self.config.radius, Fix128::ZERO),
361        ];
362
363        for sdf in sdf_colliders {
364            for offset in &offsets {
365                let sample_pos = pos + *offset;
366                let (lx, ly, lz) = sdf.world_to_local(sample_pos);
367                let dist = sdf.field.distance(lx, ly, lz) * sdf.scale_f32;
368                let threshold = self.config.radius.to_f32();
369
370                if dist < threshold {
371                    let (nx, ny, nz) = sdf.field.normal(lx, ly, lz);
372                    let normal = sdf.local_normal_to_world(nx, ny, nz);
373                    let push = Fix128::from_f32(threshold - dist);
374                    pos = pos + normal * push;
375                }
376            }
377        }
378        pos
379    }
380
381    /// Check if a position is clear of SDF surfaces
382    #[cfg(feature = "std")]
383    fn check_sdf_clear(&self, pos: Vec3Fix, sdf_colliders: &[SdfCollider]) -> bool {
384        for sdf in sdf_colliders {
385            let (lx, ly, lz) = sdf.world_to_local(pos);
386            let dist = sdf.field.distance(lx, ly, lz) * sdf.scale_f32;
387            if dist < self.config.radius.to_f32() {
388                return false;
389            }
390        }
391        true
392    }
393
394    /// Detect if the character is on the ground.
395    /// Returns (grounded, `ground_body_index`).
396    fn detect_ground(
397        &self,
398        pos: Vec3Fix,
399        bodies: &[RigidBody],
400        #[cfg_attr(not(feature = "std"), allow(unused_variables))] sdf_colliders: &[SdfCollider],
401    ) -> (bool, Option<usize>) {
402        let feet = Vec3Fix::new(
403            pos.x,
404            pos.y - self.config.height.half() + self.config.radius,
405            pos.z,
406        );
407        let down = Vec3Fix::new(Fix128::ZERO, Fix128::NEG_ONE, Fix128::ZERO);
408        let ray = Ray::new(feet, down);
409        let probe = self.config.ground_probe_distance + self.config.skin_width;
410
411        // Check against static/kinematic bodies (sphere approximation)
412        for (i, body) in bodies.iter().enumerate() {
413            if !body.is_static() {
414                continue;
415            }
416            let body_sphere = Sphere::new(body.position, self.config.radius);
417            if ray_sphere(&ray, &body_sphere, probe).is_some() {
418                return (true, Some(i));
419            }
420        }
421
422        // Check SDF ground
423        #[cfg(feature = "std")]
424        for sdf in sdf_colliders {
425            let (lx, ly, lz) = sdf.world_to_local(feet);
426            let dist = sdf.field.distance(lx, ly, lz) * sdf.scale_f32;
427            if dist < (probe.to_f32() + self.config.radius.to_f32()) {
428                // Check slope angle
429                let (nx, ny, nz) = sdf.field.normal(lx, ly, lz);
430                let normal = sdf.local_normal_to_world(nx, ny, nz);
431                let up_dot = normal.dot(Vec3Fix::UNIT_Y);
432                let cos_max = self.config.max_slope_angle.cos();
433                if up_dot >= cos_max {
434                    return (true, None); // SDF ground, no body index
435                }
436            }
437        }
438
439        (false, None)
440    }
441
442    /// Compute push impulses for dynamic bodies overlapping the character.
443    ///
444    /// Call this after `move_and_slide` to get impulses that should be applied
445    /// to nearby dynamic bodies. Apply them via `body.apply_impulse_at()`.
446    #[must_use]
447    pub fn compute_push_impulses(
448        &self,
449        bodies: &[RigidBody],
450        body_radius: Fix128,
451    ) -> Vec<PushImpulse> {
452        let mut pushes = Vec::new();
453        let combined = self.config.radius + body_radius;
454        let combined_sq = combined * combined;
455
456        for (i, body) in bodies.iter().enumerate() {
457            // Only push dynamic bodies
458            if body.is_static() || body.is_sensor {
459                continue;
460            }
461
462            let delta = body.position - self.position;
463            let dist_sq = delta.length_squared();
464            if dist_sq < combined_sq {
465                let (normal, overlap) = if dist_sq.is_zero() {
466                    // Bodies at same position: push upward as default
467                    (Vec3Fix::UNIT_Y, combined)
468                } else {
469                    let dist = dist_sq.sqrt();
470                    (delta / dist, combined - dist)
471                };
472                let impulse = normal * overlap * self.config.push_force;
473                pushes.push(PushImpulse {
474                    body_index: i,
475                    impulse,
476                    point: body.position - normal * body_radius,
477                });
478            }
479        }
480
481        pushes
482    }
483
484    /// Get the velocity of the platform the character is standing on
485    #[inline]
486    #[must_use]
487    pub const fn get_platform_velocity(&self) -> Vec3Fix {
488        self.platform_velocity
489    }
490
491    /// Integrate gravity into the velocity while airborne (`v += g·dt`).
492    ///
493    /// Does **not** move the character; the position update happens in the
494    /// controller's own step / move path (before 1.2.0 the doc claimed
495    /// "and update position", which the code never did).
496    pub fn apply_gravity(&mut self, gravity: Vec3Fix, dt: Fix128) {
497        if !self.grounded {
498            self.velocity = self.velocity + gravity * dt;
499        }
500    }
501}
502
503impl core::fmt::Debug for CharacterController {
504    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
505        f.debug_struct("CharacterController")
506            .field("position", &self.position)
507            .field("velocity", &self.velocity)
508            .field("grounded", &self.grounded)
509            .field("config", &self.config)
510            .field("ground_body_index", &self.ground_body_index)
511            .field("platform_velocity", &self.platform_velocity)
512            .finish()
513    }
514}
515
516// ============================================================================
517// Tests
518// ============================================================================
519
520#[cfg(all(test, feature = "std"))]
521mod tests {
522    use super::*;
523
524    #[test]
525    fn test_character_creation() {
526        let pos = Vec3Fix::from_int(0, 5, 0);
527        let cc = CharacterController::new_default(pos);
528        assert_eq!(cc.position.x.hi, 0);
529        assert_eq!(cc.position.y.hi, 5);
530        assert!(!cc.grounded);
531    }
532
533    #[test]
534    fn test_feet_position() {
535        let cc = CharacterController::new_default(Vec3Fix::from_int(0, 5, 0));
536        let feet = cc.feet_position();
537        // height=1.8, so half_height=0.9, feet = 5 - 0.9 + 0.3 = 4.4
538        let feet_y = feet.y.to_f32();
539        assert!(
540            feet_y > 4.0 && feet_y < 5.0,
541            "Feet should be below center, got {feet_y}"
542        );
543    }
544
545    #[test]
546    fn test_move_and_slide_no_collision() {
547        let mut cc = CharacterController::new_default(Vec3Fix::from_int(0, 5, 0));
548        let displacement = Vec3Fix::from_int(1, 0, 0);
549        let result = cc.move_and_slide(displacement, &[], &[]);
550        // Should move freely with no collisions
551        let dx = (result.position.x - Fix128::ONE).abs();
552        assert!(dx < Fix128::from_ratio(1, 10), "Should have moved to x=1");
553    }
554
555    #[test]
556    fn test_platform_velocity_tracking() {
557        let mut cc = CharacterController::new_default(Vec3Fix::from_int(0, 2, 0));
558
559        // Create a kinematic platform
560        let mut platform = RigidBody::new_kinematic(Vec3Fix::from_int(0, 0, 0));
561        platform.velocity = Vec3Fix::from_int(5, 0, 0); // moving right
562        let bodies = vec![platform];
563
564        cc.move_and_slide(Vec3Fix::ZERO, &bodies, &[]);
565
566        // If grounded on the platform, platform_velocity should be tracked
567        if cc.grounded {
568            assert_eq!(
569                cc.platform_velocity.x.hi, 5,
570                "Should track platform velocity"
571            );
572        }
573    }
574
575    #[test]
576    fn test_push_impulses() {
577        let cc = CharacterController::new_default(Vec3Fix::from_int(0, 0, 0));
578
579        // Dynamic body overlapping the character
580        let dynamic = RigidBody::new(Vec3Fix::from_int(0, 0, 0), Fix128::ONE);
581        let bodies = vec![dynamic];
582
583        let pushes = cc.compute_push_impulses(&bodies, Fix128::from_ratio(1, 2));
584        assert!(
585            !pushes.is_empty(),
586            "Should generate push impulse for overlapping dynamic body"
587        );
588        assert_eq!(pushes[0].body_index, 0);
589    }
590
591    #[test]
592    fn test_no_push_static() {
593        let cc = CharacterController::new_default(Vec3Fix::from_int(0, 0, 0));
594
595        // Static body — should not be pushed
596        let static_body = RigidBody::new_static(Vec3Fix::from_int(0, 0, 0));
597        let bodies = vec![static_body];
598
599        let pushes = cc.compute_push_impulses(&bodies, Fix128::from_ratio(1, 2));
600        assert!(pushes.is_empty(), "Should not push static bodies");
601    }
602
603    #[test]
604    fn test_move_and_slide_config() {
605        let config = CharacterConfig {
606            radius: Fix128::from_ratio(5, 10),
607            height: Fix128::from_int(2),
608            ..Default::default()
609        };
610        let cc = CharacterController::new(Vec3Fix::ZERO, config);
611        assert_eq!(cc.config.height.hi, 2);
612    }
613
614    #[test]
615    fn apply_gravity_accumulates_only_while_airborne() {
616        let mut cc = CharacterController::new_default(Vec3Fix::from_int(0, 5, 0));
617        let g = Vec3Fix::from_int(0, -10, 0);
618        let dt = Fix128::from_ratio(1, 4);
619        assert!(!cc.grounded);
620        cc.apply_gravity(g, dt);
621        // v = g * dt = (0, -5/2, 0)
622        assert_eq!(
623            cc.velocity,
624            Vec3Fix::new(Fix128::ZERO, -Fix128::from_ratio(5, 2), Fix128::ZERO)
625        );
626        cc.apply_gravity(g, dt);
627        assert_eq!(cc.velocity, Vec3Fix::from_int(0, -5, 0));
628        // 既存の水平速度は保持され、y だけ積算
629        cc.velocity = Vec3Fix::from_int(3, 0, 0);
630        cc.apply_gravity(g, dt);
631        assert_eq!(
632            cc.velocity,
633            Vec3Fix::new(Fix128::from_int(3), -Fix128::from_ratio(5, 2), Fix128::ZERO)
634        );
635        // grounded 中は重力を積まない
636        cc.grounded = true;
637        cc.apply_gravity(g, dt);
638        assert_eq!(
639            cc.velocity,
640            Vec3Fix::new(Fix128::from_int(3), -Fix128::from_ratio(5, 2), Fix128::ZERO)
641        );
642    }
643
644    #[test]
645    fn get_platform_velocity_reports_ground_body_velocity_and_clears_after_leaving() {
646        // default config: height 1.8 / radius 0.3 → feet = y - 0.6、probe = 0.1 + 0.01
647        // platform (kinematic、inv_mass 0) at origin は半径 0.3 の球扱い → top 0.3
648        // character y = 1 → feet 0.4、gap 0.1 < 0.11 → grounded
649        let mut cc = CharacterController::new_default(Vec3Fix::from_int(0, 1, 0));
650        assert_eq!(cc.get_platform_velocity(), Vec3Fix::ZERO);
651        let mut platform = RigidBody::new_kinematic(Vec3Fix::ZERO);
652        platform.velocity = Vec3Fix::from_int(5, 0, 0);
653        // dynamic body (接地判定対象外) が同じ位置にあっても platform 判定に影響しない
654        let mut dynamic = RigidBody::new_dynamic(Vec3Fix::ZERO, Fix128::ONE);
655        dynamic.velocity = Vec3Fix::from_int(-9, 0, 0);
656        let bodies = vec![dynamic, platform];
657
658        let res = cc.move_and_slide(Vec3Fix::ZERO, &bodies, &[]);
659        assert!(cc.grounded);
660        assert_eq!(cc.ground_body_index, Some(1));
661        assert_eq!(cc.get_platform_velocity(), Vec3Fix::from_int(5, 0, 0));
662        assert_eq!(res.platform_velocity, cc.get_platform_velocity());
663        assert_eq!(cc.position, Vec3Fix::from_int(0, 1, 0));
664
665        // 次 frame: platform 速度が変位に加算されて character が (5, 1, 0) へ運ばれ、
666        // platform 上から外れるので platform 速度は ZERO に戻る
667        let res2 = cc.move_and_slide(Vec3Fix::ZERO, &bodies, &[]);
668        assert_eq!(cc.position, Vec3Fix::from_int(5, 1, 0));
669        assert!(!cc.grounded);
670        assert_eq!(cc.ground_body_index, None);
671        assert_eq!(cc.get_platform_velocity(), Vec3Fix::ZERO);
672        assert_eq!(res2.platform_velocity, Vec3Fix::ZERO);
673    }
674}