From 332e6342752ea00c482d2d225480cdd8d7e8671a Mon Sep 17 00:00:00 2001 From: Ethanw05 <46205865+Ethanw05@users.noreply.github.com> Date: Tue, 8 Sep 2026 19:36:18 -0600 Subject: [PATCH] Improve vehicle handling, collision response and airborne control --- crates/skate-game/src/modding/vehicles/mod.rs | 8 +- crates/skate-game/src/physics/frame.rs | 11 +- crates/skate-game/src/physics/player_state.rs | 12 +- crates/skate-mods/tests/runtime.rs | 4 + crates/skate-vehicles/src/assists.rs | 43 ++++++ crates/skate-vehicles/src/definition.rs | 8 +- crates/skate-vehicles/src/handling.rs | 131 +++++++++++++++++ crates/skate-vehicles/src/lib.rs | 58 ++++---- crates/skate-vehicles/src/safety.rs | 4 +- crates/skate-vehicles/tests/assists.rs | 81 +++++++++++ crates/skate-vehicles/tests/collision.rs | 69 +++++++++ crates/skate-vehicles/tests/handling.rs | 137 ++++++++++++++++++ crates/skate-vehicles/tests/simulation.rs | 5 +- docs/vehicle-sdk.md | 53 +++++++ mods/mario-kart.zip | Bin 5278905 -> 5279490 bytes sdk/examples/mario-kart/README.md | 13 ++ sdk/examples/mario-kart/main.lua | 3 +- sdk/examples/mario-kart/mod.json | 16 +- sdk/examples/mario-kart/vehicle.json | 16 +- sdk/skate.lua | 1 + 20 files changed, 619 insertions(+), 54 deletions(-) create mode 100644 crates/skate-vehicles/src/assists.rs create mode 100644 crates/skate-vehicles/src/handling.rs create mode 100644 crates/skate-vehicles/tests/assists.rs create mode 100644 crates/skate-vehicles/tests/collision.rs create mode 100644 crates/skate-vehicles/tests/handling.rs diff --git a/crates/skate-game/src/modding/vehicles/mod.rs b/crates/skate-game/src/modding/vehicles/mod.rs index 381e0ec5..020f894e 100644 --- a/crates/skate-game/src/modding/vehicles/mod.rs +++ b/crates/skate-game/src/modding/vehicles/mod.rs @@ -273,10 +273,6 @@ pub(super) fn command( let id = v.owned.get(&owned_key).ok_or("Unknown vehicle")?.id; let car = v.simulation.vehicles.get_mut(&id).unwrap(); car.definition = tuning.apply(&car.definition)?; - let grip = car.definition.tire_grip; - for wheel in car.controller.wheels_mut() { - wheel.friction_slip = grip; - } } Command::VehicleSpawn { definition, @@ -742,5 +738,7 @@ pub(super) fn input(world: &World) -> Value { .resource::() .raw_input(); let pressed = |key| if keys.pressed(key) { 1. } else { 0. }; - json!({"throttle":(pressed(KeyCode::KeyW)-pressed(KeyCode::KeyS)+pad.triggers[1]-pad.triggers[0]).clamp(-1.,1.),"steering":(pressed(KeyCode::KeyA)-pressed(KeyCode::KeyD)-if pad.left[0].abs()>0.15 {pad.left[0]} else {0.}).clamp(-1.,1.),"brake":if pad.buttons & 0x1000 != 0 {1.} else {pressed(KeyCode::Space)},"handbrake":keys.pressed(KeyCode::ShiftLeft) || pad.buttons & 0x2000 != 0,"interact":keys.pressed(KeyCode::KeyE) || pad.buttons & 0x8000 != 0,"pad_buttons":pad.buttons}) + let pitch = (pressed(KeyCode::ArrowUp) - pressed(KeyCode::ArrowDown) + + if pad.left[1].abs() > 0.15 { pad.left[1] } else { 0. }).clamp(-1., 1.); + json!({"pitch":pitch,"throttle":(pressed(KeyCode::KeyW)-pressed(KeyCode::KeyS)+pad.triggers[1]-pad.triggers[0]).clamp(-1.,1.),"steering":(pressed(KeyCode::KeyA)-pressed(KeyCode::KeyD)-if pad.left[0].abs()>0.15 {pad.left[0]} else {0.}).clamp(-1.,1.),"brake":if pad.buttons & 0x1000 != 0 {1.} else {pressed(KeyCode::Space)},"handbrake":keys.pressed(KeyCode::ShiftLeft) || pad.buttons & 0x2000 != 0,"interact":keys.pressed(KeyCode::KeyE) || pad.buttons & 0x8000 != 0,"pad_buttons":pad.buttons}) } diff --git a/crates/skate-game/src/physics/frame.rs b/crates/skate-game/src/physics/frame.rs index c155dca7..353a3efb 100644 --- a/crates/skate-game/src/physics/frame.rs +++ b/crates/skate-game/src/physics/frame.rs @@ -115,7 +115,16 @@ pub(super) fn advance( skater.offboard_grab.execute_queries(&scene)?; } let state_before_selection = skater.player_state.current(); - if !vehicle_ejected { player_state::post_input_and_select(physics, skater)?; } + if vehicle_ejected { + // Vehicle ejection is a host transition that must retain WipeoutGround, + // so it bypasses the native state selector. ProcessInput has already + // prepared this tick's grind work, however, and native PostInput is its + // mandatory consumer. Skipping both phases leaves a stale request that + // aborts the following tick. + player_state::complete_post_input(physics, skater)?; + } else { + player_state::post_input_and_select(physics, skater)?; + } let state_after_selection = skater.player_state.current(); super::offboard_audit_trace::stage(tick, "selected", physics, skater, controls); #[cfg(test)] diff --git a/crates/skate-game/src/physics/player_state.rs b/crates/skate-game/src/physics/player_state.rs index fb51fa8d..47c30149 100644 --- a/crates/skate-game/src/physics/player_state.rs +++ b/crates/skate-game/src/physics/player_state.rs @@ -111,10 +111,20 @@ pub(crate) fn post_input_and_select( physics: &mut GamePhysics, skater: &mut SkaterRuntime, ) -> Result<(), String> { - post_input::advance(physics, skater)?; + complete_post_input(physics, skater)?; let processed = skater.player_input.processed_snapshot(physics.ticks); selection::advance(physics, skater, processed) } + +/// Completes the mandatory PostInput half of the current ProcessInput pass. +/// Host-owned transitions may need to retain their selected state, but they +/// must still drain the query work produced by ProcessInput this tick. +pub(crate) fn complete_post_input( + physics: &mut GamePhysics, + skater: &mut SkaterRuntime, +) -> Result<(), String> { + post_input::advance(physics, skater) +} ///The reset/board/skeleton publications precede this selected-state FillPhysOut. pub(crate) fn publish(physics: &mut GamePhysics, skater: &mut SkaterRuntime) -> Result<(), String> { publication::publish(physics, skater) diff --git a/crates/skate-mods/tests/runtime.rs b/crates/skate-mods/tests/runtime.rs index dd6899a4..e97576af 100644 --- a/crates/skate-mods/tests/runtime.rs +++ b/crates/skate-mods/tests/runtime.rs @@ -439,6 +439,10 @@ fn kart_controller_reset_is_edge_triggered_and_rebindable() { for buttons in [0,128,0,64,64] {m.snapshot["vehicle_input"]["pad_buttons"]=json!(buttons);m.dispatch("on_fixed_update",json!({"dt":0.016}));} assert_eq!(m.commands.iter().filter(|(_,c)|matches!(c,Command::VehicleReset{..})).count(),1); assert!(m.packages["community.mario-kart"].running()); + m.commands.clear(); + m.snapshot["vehicle_input"]["pitch"]=json!(0.75); + m.dispatch("on_fixed_update",json!({"dt":0.016})); + assert!(m.commands.iter().any(|(_,c)|matches!(c,Command::VehicleControl{controls,..} if controls.pitch==0.75))); } #[test] diff --git a/crates/skate-vehicles/src/assists.rs b/crates/skate-vehicles/src/assists.rs new file mode 100644 index 00000000..0a998725 --- /dev/null +++ b/crates/skate-vehicles/src/assists.rs @@ -0,0 +1,43 @@ +//! Optional arcade assists, not recovered Burnout constants. Apply angular +//! impulses through the rigid body; never overwrite its transform or add lift. +use crate::{Vehicle, rapier3d::prelude::*}; +use crate::rapier3d::utils::AngularInertiaOps; + +pub(crate) fn apply(v: &Vehicle, bodies: &mut RigidBodySet, dt: f32) { + let mut contacts = 0; + let mut normal = Vector::ZERO; + for wheel in v.controller.wheels() { + if wheel.raycast_info().is_in_contact { + contacts += 1; + normal += wheel.raycast_info().contact_normal_ws; + } + } + let body = &mut bodies[v.body]; + let rotation = *body.rotation(); + let local_omega = rotation.inverse() * body.angvel(); + let mut acceleration = Vector::ZERO; + if contacts >= 2 && v.definition.ground_stability > 0. { + let normal = normal.normalize_or_zero(); + let up = rotation * Vector::Y; + if up.dot(normal) > 0.25 { + // Follow banked support, not global up. Stabilize roll without steering + // the car or fighting the pitch needed to climb a ramp. + let error = rotation.inverse() * up.cross(normal); + acceleration.z = (error.z * 24. - local_omega.z * 8.) + * v.definition.ground_stability; + } + } else if contacts == 0 && v.occupied && v.definition.air_control > 0. { + // Rate target keeps repeated input controllable. Neutral stick damps only + // pitch/roll; yaw and linear momentum remain with the physical simulation. + // Driver-left is +X: a negative local-Z rotation leans the roof left. + let target = Vector::new(v.controls.pitch * 2.5, 0., -v.controls.steering * 2.5); + let strength = v.definition.air_control; + acceleration.x = ((target.x - local_omega.x) * 3.).clamp(-strength, strength); + acceleration.z = ((target.z - local_omega.z) * 3.).clamp(-strength, strength); + } + if acceleration.length_squared() > 0. { + let angular_impulse = body.mass_properties().effective_world_inv_inertia.inverse() + * (rotation * acceleration * dt); + body.apply_torque_impulse(angular_impulse, true); + } +} diff --git a/crates/skate-vehicles/src/definition.rs b/crates/skate-vehicles/src/definition.rs index c9467efc..86e51553 100644 --- a/crates/skate-vehicles/src/definition.rs +++ b/crates/skate-vehicles/src/definition.rs @@ -24,6 +24,8 @@ pub struct VehicleDefinition { pub suspension_stiffness: f32, pub suspension_damping: f32, pub tire_grip: f32, + pub ground_stability: f32, + pub air_control: f32, pub wheels: Vec, pub seat: [f32; 3], pub exit: [f32; 3], @@ -109,7 +111,9 @@ impl Default for VehicleDefinition { suspension_length: 0.25, suspension_stiffness: 30., suspension_damping: 4., - tire_grip: 3., + tire_grip: 1.3, + ground_stability: 0., + air_control: 0., wheels: vec![], seat: [0., 0.25, 0.], exit: [1.8, 0., 0.], @@ -166,6 +170,8 @@ impl VehicleDefinition { || !range(self.suspension_stiffness, 1., 200.) || !range(self.suspension_damping, 0.1, 30.) || !range(self.tire_grip, 0.1, 20.) + || !range(self.ground_stability, 0., 1.) + || !range(self.air_control, 0., 10.) || !point(&self.seat, 10.) || !point(&self.exit, 10.) || !range(self.camera_distance, 2., 30.) diff --git a/crates/skate-vehicles/src/handling.rs b/crates/skate-vehicles/src/handling.rs new file mode 100644 index 00000000..c0131b02 --- /dev/null +++ b/crates/skate-vehicles/src/handling.rs @@ -0,0 +1,131 @@ +//! Force-based kart handling. Rapier owns suspension queries and rigid-body collision; +//! this module owns steering, wheel angular momentum and combined tire traction. +use crate::{Vehicle, rapier3d::prelude::*}; + +#[derive(Default)] +pub(crate) struct Handling { + steering: f32, + omega: [f32; 8], + spin: [f32; 8], +} + +pub(crate) fn prepare(v: &mut Vehicle, bodies: &mut RigidBodySet, dt: f32) { + let body = &mut bodies[v.body]; + let speed = body.linvel().dot(body.rotation() * Vector::Z); + let d = &v.definition; + let c = v.controls; + if c.throttle != 0. || c.brake != 0. || c.handbrake { + body.wake_up(true); + } + // Full low-speed lock, progressively reduced at speed. Rate limiting models + // steering travel, including keyboard input; it never rotates the chassis. + let target = c.steering * d.steering_angle / (1. + speed.abs().powi(2) * 0.008); + v.handling.steering += (target - v.handling.steering).clamp(-2.5 * dt, 2.5 * dt); + let front = d.wheels.iter().filter(|w| w.steering).map(|w| w.position[2]).fold(f32::NEG_INFINITY, f32::max); + let rear = d.wheels.iter().filter(|w| !w.steering).map(|w| w.position[2]).fold(f32::INFINITY, f32::min); + let wheelbase = front - rear; + for (wheel, def) in v.controller.wheels_mut().iter_mut().zip(&d.wheels) { + let angle = v.handling.steering; + // Ackermann: inside front wheel turns more sharply around the rear axle. + wheel.steering = if !def.steering { 0. } else if wheelbase > 0.1 && angle.abs() > 0.001 { + let radius = wheelbase / angle.tan(); + (wheelbase / (radius - def.position[0])).atan() + } else { angle }; + // Disable Rapier's arcade tire impulses: there must be exactly one tire owner. + wheel.engine_force = 0.; + wheel.brake = 0.; + wheel.friction_slip = 0.; + wheel.side_friction_stiffness = 0.; + } + // Quadratic aerodynamic drag, applied at COM; no artificial speed cap. + let velocity = body.linvel(); + let drag = -velocity * velocity.length() * (0.5 * 1.225 * 0.65) * dt; + body.apply_impulse(drag, false); +} + +pub(crate) fn tires(v: &mut Vehicle, bodies: &mut RigidBodySet, colliders: &ColliderSet, dt: f32) { + let d = &v.definition; + let c = v.controls; + let speed = bodies[v.body].linvel().dot(bodies[v.body].rotation() * Vector::Z); + // Opposing pedal first brakes, then engages reverse close to rest. + let opposing = c.throttle * speed < -0.5; + let brake = c.brake.max(if opposing { c.throttle.abs() } else { 0. }); + let throttle = if opposing || brake > 0.01 { 0. } else { c.throttle }; + let driven = d.wheels.iter().filter(|w| w.driven).count() as f32; + let wheel_count = d.wheels.len() as f32; + for (i, (wheel, def)) in v.controller.wheels_mut().iter_mut().zip(&d.wheels).enumerate() { + let r = def.radius; + // Solid-cylinder rotational inertia, 2% of chassis mass per wheel. + let inertia = 0.5 * (d.mass * 0.02) * r * r; + let omega = &mut v.handling.omega[i]; + let limit = if throttle < 0. { d.max_speed.min(8.) } else { d.max_speed }; + let taper = (1. - (omega.abs() * r / limit).powi(2)).max(0.); + let engine = if def.driven { throttle * d.engine_force / driven * taper } else { 0. }; + *omega += engine * r * dt / inertia; + // Preserve legacy brake setting scale, but interpret at the reference 120 Hz. + // Brake torque is time-scaled and can lock a wheel without reversing its spin. + let braking = if c.handbrake && !def.steering { 1. } else { brake }; + let brake_step = braking * d.brake_impulse * 120. * r * dt / inertia; + *omega -= omega.clamp(-brake_step, brake_step); + let contact = *wheel.raycast_info(); + if contact.is_in_contact { + let p = contact.contact_point_ws; + let normal = contact.contact_normal_ws; + let side = (wheel.axle() - normal * wheel.axle().dot(normal)).normalize_or_zero(); + let forward = normal.cross(side).normalize_or_zero(); + let ground = contact.ground_object.and_then(|h| colliders[h].parent()); + let ground_velocity = ground.map(|h| bodies[h].velocity_at_point(p)).unwrap_or(Vector::ZERO); + let relative = bodies[v.body].velocity_at_point(p) - ground_velocity; + let longitudinal = relative.dot(forward); + let lateral = relative.dot(side); + let load = wheel.wheel_suspension_force.min(wheel.max_suspension_force); + // Tire and surface friction coefficients multiply; force scales with load. + let surface = contact.ground_object.map(|h| colliders[h].friction()).unwrap_or(1.); + let capacity = load * d.tire_grip * surface * dt; + let inv_long = effective_inverse_mass(&bodies[v.body], p, forward) + + ground.map(|h| effective_inverse_mass(&bodies[h], p, forward)).unwrap_or(0.); + let inv_side = effective_inverse_mass(&bodies[v.body], p, side) + + ground.map(|h| effective_inverse_mass(&bodies[h], p, side)).unwrap_or(0.); + // Implicit longitudinal slip solve includes wheel inertia. Locked wheels + // instead use the brake's available holding torque. + let locked = braking > 0. && omega.abs() < 0.001; + let mut jx = if locked { + (-longitudinal / inv_long.max(1e-6)).clamp(-braking*d.brake_impulse*120.*dt, braking*d.brake_impulse*120.*dt) + } else { (*omega * r - longitudinal) / (inv_long + r*r/inertia) }; + let slip_angle = lateral.atan2(longitudinal.abs().max(1.)); + let cornering = -load * 8. * slip_angle * dt; + let stopping = -lateral / inv_side.max(1e-6) / wheel_count; + let mut jy = cornering.signum() * cornering.abs().min(stopping.abs()); + // Braking, acceleration and turning share one friction circle. + let magnitude = jx.hypot(jy); + if magnitude > capacity && magnitude > 0. { + jx *= capacity / magnitude; + jy *= capacity / magnitude; + } + if !locked { *omega -= jx * r / inertia; } + // Rolling resistance acts as an axle torque, so it cannot accelerate a wheel. + let rolling = 0.015 * load * r * dt / inertia; + *omega -= omega.clamp(-rolling, rolling); + let impulse = forward * jx + side * jy; + bodies[v.body].apply_impulse_at_point(impulse, p, false); + if let Some(h) = ground { + // Suspension was applied to the chassis by Rapier. Return both the + // tire and suspension reactions to movable support bodies. + if bodies[h].is_dynamic() { + bodies[h].apply_impulse_at_point(-impulse - normal * load * dt, p, true); + } + } + wheel.forward_impulse = jx; + wheel.side_impulse = jy; + } + v.handling.spin[i] += *omega * dt; + wheel.rotation = v.handling.spin[i]; + } +} + +fn effective_inverse_mass(body: &RigidBody, point: Vector, direction: Vector) -> f32 { + if !body.is_dynamic() { return 0.; } + let lever = (point - body.center_of_mass()).cross(direction); + body.mass_properties().local_mprops.inv_mass + + lever.dot(body.mass_properties().effective_world_inv_inertia * lever) +} diff --git a/crates/skate-vehicles/src/lib.rs b/crates/skate-vehicles/src/lib.rs index 15ab1e13..3f5848de 100644 --- a/crates/skate-vehicles/src/lib.rs +++ b/crates/skate-vehicles/src/lib.rs @@ -1,6 +1,8 @@ //! Bevy-independent, fixed-step Rapier vehicle simulation and validated mod definitions. mod definition; mod safety; +mod handling; +mod assists; pub use safety::Ejection; pub use definition::*; pub use rapier3d; @@ -14,6 +16,8 @@ use std::collections::BTreeMap; pub struct Controls { pub throttle: f32, pub steering: f32, + /// Positive pitches the nose down in flight; ignored on the ground. + pub pitch: f32, pub brake: f32, pub handbrake: bool, } @@ -22,6 +26,8 @@ impl Controls { self.throttle.is_finite() && self.steering.is_finite() && self.brake.is_finite() + && self.pitch.is_finite() + && (-1. ..=1.).contains(&self.pitch) && (-1. ..=1.).contains(&self.throttle) && (-1. ..=1.).contains(&self.steering) && (0. ..=1.).contains(&self.brake) @@ -32,6 +38,7 @@ pub struct Vehicle { pub body: RigidBodyHandle, pub controller: DynamicRayCastVehicleController, pub controls: Controls, + handling: handling::Handling, rider: ColliderHandle, occupied: bool, pub remote: bool, @@ -64,7 +71,10 @@ impl Simulation { if vertices.is_empty() { return Err("Vehicle collision world has no triangles".into()); } - let collider = ColliderBuilder::trimesh(vertices, indices) + // Weld shared vertices and use neighboring face normals at internal edges. + // Keep authored triangles: this does not simplify or delete map geometry. + let collider = ColliderBuilder::trimesh_with_flags(vertices, indices, + rapier3d::parry::shape::TriMeshFlags::FIX_INTERNAL_EDGES) .map_err(|e| e.to_string())? .friction(1.); self.world.insert(RigidBodyBuilder::fixed(), collider); @@ -100,7 +110,8 @@ impl Simulation { .angular_damping(0.5), shape.translation(Vector::from_array(d.collider_offset)) .mass_properties(mass_properties) - .friction(d.chassis_friction), + .friction(d.chassis_friction) + .friction_combine_rule(CoefficientCombineRule::Min), ); let mut controller = DynamicRayCastVehicleController::new(body); controller.index_up_axis = 1; @@ -137,6 +148,7 @@ impl Simulation { body, controller, controls: Controls::default(), + handling: handling::Handling::default(), rider, remote: false, occupied: false, inverted_time: 0., ejection: None, }, ); @@ -155,33 +167,10 @@ impl Simulation { let steps = (dt / 0.008334).ceil().clamp(1., 16.) as u32; let h = dt.min(0.1) / steps as f32; for _ in 0..steps { - let before = self.capture_riders(); self.world.integration_parameters.dt = h; for v in self.vehicles.values_mut() { if v.remote {continue;} - let c = v.controls; - let speed = v.controller.current_vehicle_speed; - let d = &v.definition; - let driven = d.wheels.iter().filter(|w| w.driven).count() as f32; - for (wheel, def) in v.controller.wheels_mut().iter_mut().zip(&d.wheels) { - wheel.steering = if def.steering { - // With +Z forward and +Y up, the driver's left is +X. - c.steering * d.steering_angle / (1. + speed.abs() * 0.025) - } else { - 0. - }; - wheel.engine_force = - if def.driven && (speed.abs() < d.max_speed || c.throttle * speed < 0.) { - c.throttle * d.engine_force / driven - } else { - 0. - }; - wheel.brake = if c.handbrake && !def.steering { - d.brake_impulse - } else { - c.brake * d.brake_impulse - }; - } + handling::prepare(v, &mut self.world.bodies, h); let queries = self.world.broad_phase.as_query_pipeline_mut( self.world.narrow_phase.query_dispatcher(), &mut self.world.bodies, @@ -189,8 +178,17 @@ impl Simulation { QueryFilter::default().exclude_rigid_body(v.body), ); v.controller.update_vehicle(h, queries); + handling::tires(v, &mut self.world.bodies, &self.world.colliders, h); + assists::apply(v, &mut self.world.bodies, h); } + // Capture after suspension/tire impulses so crash delta-v measures the + // collision solve, not the normal driving forces preceding it. + let before = self.capture_riders(); self.world.step(); + for v in self.vehicles.values_mut().filter(|v| !v.remote) { + let body = &self.world.bodies[v.body]; + v.controller.current_vehicle_speed = body.linvel().dot(body.rotation() * Vector::Z); + } self.check_riders(&before, h); } } @@ -209,6 +207,14 @@ impl Simulation { b.set_linvel(Vector::ZERO, true); b.set_angvel(Vector::ZERO, true); v.controls = Controls::default(); + v.handling = handling::Handling::default(); + v.controller.current_vehicle_speed = 0.; + for wheel in v.controller.wheels_mut() { + wheel.rotation = 0.; + wheel.steering = 0.; + wheel.forward_impulse = 0.; + wheel.side_impulse = 0.; + } v.ejection = None; v.inverted_time = 0.; Ok(()) } diff --git a/crates/skate-vehicles/src/safety.rs b/crates/skate-vehicles/src/safety.rs index c947c5c4..bcdcf973 100644 --- a/crates/skate-vehicles/src/safety.rs +++ b/crates/skate-vehicles/src/safety.rs @@ -36,11 +36,13 @@ impl Simulation { let body=&self.world.bodies[v.body]; let safety=&v.definition.rider_safety; let up=body.rotation()*Vector::Y; - v.inverted_time=if up.y < safety.inverted_up_y {v.inverted_time+dt} else {0.}; let hit=self.world.narrow_phase.contact_pairs_with(v.rider) .map(|p|p.total_impulse_magnitude()).fold(0_f32,f32::max); let contact=body.colliders().iter().any(|&c| self.world.narrow_phase.contact_pairs_with(c) .any(|p|p.total_impulse_magnitude()>0.)); + // An aerial barrel roll is not a wreck. Require sustained physical + // contact while inverted; airborne time never advances this timer. + v.inverted_time=if contact && up.y < safety.inverted_up_y {v.inverted_time+dt} else {0.}; let reason=if hit >= safety.hit_impulse {Some("rider_impact")} else if contact && (body.linvel()-linear).length() >= safety.crash_delta_v {Some("crash")} else if v.inverted_time >= safety.inverted_seconds {Some("inverted")} diff --git a/crates/skate-vehicles/tests/assists.rs b/crates/skate-vehicles/tests/assists.rs new file mode 100644 index 00000000..d39a8efa --- /dev/null +++ b/crates/skate-vehicles/tests/assists.rs @@ -0,0 +1,81 @@ +use skate_vehicles::{*, rapier3d::prelude::*}; +fn setup(height: f32) -> (Simulation,u64) { + let mut s=Simulation::default(); + s.ground([ + [[-100.,0.,-100.],[100.,0.,100.],[100.,0.,-100.]], + [[-100.,0.,-100.],[-100.,0.,100.],[100.,0.,100.]], + ].into_iter()).unwrap(); + let d=serde_json::from_str(include_str!("../../../sdk/examples/mario-kart/vehicle.json")).unwrap(); + let id=s.spawn(d,[0.,height,0.],0.).unwrap(); + s.set_occupied(id,true); + (s,id) +} +#[test] +fn stick_controls_pitch_and_roll_only_in_air_without_adding_lift() { + for (pitch,steering) in [(1.,0.),(-1.,0.),(0.,1.),(0.,-1.)] { + let (mut s,id)=setup(100.); + s.vehicles.get_mut(&id).unwrap().controls=Controls{pitch,steering,..Default::default()}; + for _ in 0..60 {s.step(1./120.);assert!(s.take_ejection(id).is_none());} + let body=&s.world.bodies[s.vehicles[&id].body]; + let angular=body.rotation().inverse()*body.angvel(); + if pitch!=0. {assert!(angular.x*pitch>1.);} + if steering!=0. {assert!(angular.z*steering< -1.);} + assert!(body.linvel().y< -4.); + assert!(body.linvel().x.abs()<0.001 && body.linvel().z.abs()<0.001); + s.vehicles.get_mut(&id).unwrap().controls=Controls::default(); + for _ in 0..60 {s.step(1./120.);} + assert!(s.world.bodies[s.vehicles[&id].body].angvel().length() (Simulation,u64) { + let mut s=Simulation::default(); + s.ground([ + [[-100.,0.,-100.],[100.,0.,100.],[100.,0.,-100.]], + [[-100.,0.,-100.],[-100.,0.,100.],[100.,0.,100.]], + ].into_iter()).unwrap(); + let d=serde_json::from_str(include_str!("../../../sdk/examples/mario-kart/vehicle.json")).unwrap(); + let id=s.spawn(d,[0.,1.,0.],0.).unwrap(); + for _ in 0..240 {s.step(1./120.);} + (s,id) +} + +#[test] +fn map_faces_share_vertices_and_internal_edge_normals() { + let (s,_)=setup(); + let mesh=s.world.colliders.iter().find_map(|(_,c)|c.shape().as_trimesh()).unwrap(); + assert_eq!(mesh.vertices().len(),4); + assert_eq!(mesh.indices().len(),2); + assert!(mesh.flags().contains(rapier3d::parry::shape::TriMeshFlags::FIX_INTERNAL_EDGES)); +} + +#[test] +fn glancing_chassis_contact_preserves_tangential_motion() { + let fixed=scrape(false); + let previous=scrape(true); + println!("Tangential speed after scrape: corrected={fixed}, previous average={previous}"); + assert!(fixed>9.5,"wall scrape consumed tangential motion: {fixed}"); + assert!(fixed>previous+0.5,"friction fix did not improve scrape: {fixed}, {previous}"); +} + +fn scrape(previous_average: bool) -> f32 { + let (mut s,id)=setup(); + s.world.insert(RigidBodyBuilder::fixed().translation(Vector::new(1.,1.,0.)), + ColliderBuilder::cuboid(0.1,2.,100.).friction(1.)); + s.world.step(); + let h=s.vehicles[&id].body; + if previous_average { + let colliders=s.world.bodies[h].colliders().to_vec(); + for c in colliders {s.world.colliders[c].set_friction_combine_rule(CoefficientCombineRule::Average);} + } + s.world.bodies[h].set_linvel(Vector::new(6.,0.,12.),true); + let mut touched=false; + for _ in 0..30 { + s.step(1./120.); + touched |= s.world.narrow_phase.contact_pairs().any(|p|p.total_impulse_magnitude()>1.); + } + assert!(touched); + let velocity=s.world.bodies[h].linvel(); + assert!(s.pose(id).unwrap().0[0]<1.,"passed through wall"); + velocity.z +} + +#[test] +fn head_on_collision_still_stops_and_ejects_at_speed() { + let (mut s,id)=setup(); + s.world.insert(RigidBodyBuilder::fixed().translation(Vector::new(0.,1.,3.)), + ColliderBuilder::cuboid(10.,2.,0.05)); + s.world.step(); + s.set_occupied(id,true); + let h=s.vehicles[&id].body; + s.world.bodies[h].set_linvel(Vector::Z*25.,true); + let mut ejection=false; + for _ in 0..60 {s.step(1./120.);ejection |= s.take_ejection(id).is_some();} + assert!(ejection); + assert!(s.pose(id).unwrap().0[2]<3.); + assert!(s.world.bodies[h].linvel().z<1.); +} diff --git a/crates/skate-vehicles/tests/handling.rs b/crates/skate-vehicles/tests/handling.rs new file mode 100644 index 00000000..9dc23e11 --- /dev/null +++ b/crates/skate-vehicles/tests/handling.rs @@ -0,0 +1,137 @@ +use skate_vehicles::{*, rapier3d::prelude::*}; + +fn car() -> (Simulation, u64) { + let mut s = Simulation::default(); + s.ground([ + [[-1000.,0.,-1000.],[1000.,0.,1000.],[1000.,0.,-1000.]], + [[-1000.,0.,-1000.],[-1000.,0.,1000.],[1000.,0.,1000.]], + ].into_iter()).unwrap(); + let d = serde_json::from_str(include_str!("../../../sdk/examples/mario-kart/vehicle.json")).unwrap(); + let id = s.spawn(d, [0.,1.,0.], 0.).unwrap(); + run(&mut s, 2., 120); + (s,id) +} +fn run(s: &mut Simulation, seconds: f32, hz: u32) { + for _ in 0..(seconds*hz as f32) as u32 { s.step(1./hz as f32); } +} +fn speed(s: &Simulation, id: u64) -> f32 { s.vehicles[&id].controller.current_vehicle_speed } + +#[test] +fn full_brakes_override_full_throttle_and_stop_without_reversing() { + let (mut s,id)=car(); + s.vehicles.get_mut(&id).unwrap().controls.throttle=1.; + run(&mut s,3.,120); + let initial=speed(&s,id); + assert!(initial>5.,"acceleration {initial}"); + s.vehicles.get_mut(&id).unwrap().controls.brake=1.; + run(&mut s,3.,120); + assert!(speed(&s,id).abs()<0.2,"brake+throttle {}",speed(&s,id)); +} + +#[test] +fn reverse_wakes_a_sleeping_car_and_opposing_pedal_stops_before_reversing() { + let (mut s,id)=car(); + let h=s.vehicles[&id].body; + s.world.bodies[h].sleep(); + s.vehicles.get_mut(&id).unwrap().controls.throttle=-1.; + run(&mut s,2.,120); + assert!(speed(&s,id)< -1.); + s.vehicles.get_mut(&id).unwrap().controls.throttle=1.; + run(&mut s,4.,120); + assert!(speed(&s,id)>2.); +} + +#[test] +fn tire_impulses_share_the_available_contact_load() { + let (mut s,id)=car(); + s.vehicles.get_mut(&id).unwrap().controls=Controls{throttle:1.,steering:0.8,..Default::default()}; + for _ in 0..1200 { + s.step(1./120.); + let v=&s.vehicles[&id]; + for w in v.controller.wheels() { + let capacity=w.wheel_suspension_force.min(w.max_suspension_force)*v.definition.tire_grip/120.; + assert!(w.forward_impulse.hypot(w.side_impulse)<=capacity+0.001); + } + let body=&s.world.bodies[v.body]; + assert!((body.rotation()*Vector::Y).y>0.5,"flat corner rolled car"); + } +} + +#[test] +fn steering_is_progressive_and_inner_wheel_has_more_lock() { + let (mut s,id)=car(); + s.vehicles.get_mut(&id).unwrap().controls.steering=1.; + s.step(1./120.); + let first=s.vehicles[&id].controller.wheels()[2].steering; + assert!(first>0. && first<0.05); + run(&mut s,1.,120); + let w=s.vehicles[&id].controller.wheels(); + assert!(w[2].steering>w[3].steering); + s.reset(id,[0.,1.,0.],0.).unwrap(); + assert!(s.vehicles[&id].controller.wheels().iter().all(|w|w.rotation==0. && w.steering==0.)); + assert_eq!(speed(&s,id),0.); +} + +#[test] +fn driving_is_consistent_at_60_and_120_hz() { + let mut results=Vec::new(); + for hz in [60,120] { + let (mut s,id)=car(); + s.vehicles.get_mut(&id).unwrap().controls.throttle=1.; + run(&mut s,4.,hz); + let accelerated=speed(&s,id); + s.vehicles.get_mut(&id).unwrap().controls=Controls{brake:0.4,..Default::default()}; + run(&mut s,1.,hz); + results.push((accelerated,speed(&s,id),s.pose(id).unwrap().0[2])); + } + assert!((results[0].0-results[1].0).abs()<0.05,"{results:?}"); + assert!((results[0].1-results[1].1).abs()<0.05,"{results:?}"); + assert!((results[0].2-results[1].2).abs()<0.1,"{results:?}"); +} + +#[test] +fn air_motion_does_not_count_as_forward_speed_or_create_tire_forces() { + let (mut s,id)=car(); + s.reset(id,[0.,100.,0.],0.).unwrap(); + let h=s.vehicles[&id].body; + s.world.bodies[h].set_linvel(Vector::new(10.,-10.,0.),true); + s.vehicles.get_mut(&id).unwrap().controls.throttle=1.; + s.step(1./120.); + assert!(speed(&s,id).abs()<0.01); + assert!(s.vehicles[&id].controller.wheels().iter().all(|w|w.forward_impulse==0. && w.side_impulse==0.)); +} + +#[test] +fn mixed_driving_remains_finite_for_7200_ticks() { + let (mut s,id)=car(); + for tick in 0..7200 { + let phase=(tick/240)%6; + s.vehicles.get_mut(&id).unwrap().controls=match phase { + 0 => Controls{throttle:1.,..Default::default()}, + 1 => Controls{throttle:0.5,steering:0.6,..Default::default()}, + 2 => Controls{brake:0.5,steering:-0.3,..Default::default()}, + 3 => Controls{throttle:-0.5,..Default::default()}, + 4 => Controls{handbrake:true,steering:0.3,..Default::default()}, + _ => Controls::default(), + }; + s.step(1./120.); + let (p,q)=s.pose(id).unwrap(); + assert!(p.iter().chain(q.iter()).all(|x|x.is_finite()),"tick {tick}"); + let body=&s.world.bodies[s.vehicles[&id].body]; + assert!(body.linvel().is_finite() && body.angvel().is_finite()); + assert!(s.vehicles[&id].controller.wheels().iter().all(|w|w.rotation.is_finite())); + } +} + +#[test] +fn low_friction_surface_reduces_acceleration() { + let mut results=Vec::new(); + for friction in [0.1,1.] { + let (mut s,id)=car(); + for (_,collider) in s.world.colliders.iter_mut() { collider.set_friction(friction); } + s.vehicles.get_mut(&id).unwrap().controls.throttle=1.; + run(&mut s,3.,120); + results.push(speed(&s,id)); + } + assert!(results[1]>results[0]*1.5,"surface traction {results:?}"); +} diff --git a/crates/skate-vehicles/tests/simulation.rs b/crates/skate-vehicles/tests/simulation.rs index f32dc297..55621f00 100644 --- a/crates/skate-vehicles/tests/simulation.rs +++ b/crates/skate-vehicles/tests/simulation.rs @@ -202,7 +202,7 @@ fn occupied_crash_ejects_with_preimpact_momentum() { } #[test] -fn inverted_rider_bails_but_parked_vehicle_does_not() { +fn airborne_inversion_does_not_eject_a_stunt_driver() { use rapier3d::prelude::*; for occupied in [false,true] { let mut s=simulation();let id=s.spawn(definition(),[0.,4.,0.],0.).unwrap(); @@ -212,8 +212,7 @@ fn inverted_rider_bails_but_parked_vehicle_does_not() { s.world.bodies[h].set_linvel(Vector::new(5.,0.,0.),true); for _ in 0..30 {s.step(1./120.);} let e=s.take_ejection(id); - if occupied {let e=e.unwrap();assert_eq!(e.reason,"inverted");assert!(e.velocity[0]>4.);} - else {assert!(e.is_none());} + assert!(e.is_none(), "airborne inversion must remain controllable"); } } diff --git a/docs/vehicle-sdk.md b/docs/vehicle-sdk.md index 8f9ec811..22fded98 100644 --- a/docs/vehicle-sdk.md +++ b/docs/vehicle-sdk.md @@ -1,4 +1,17 @@ # Vehicle SDK v1 +Arcade assist additions (kart package 1.5): optional `ground_stability` (0..1, +default 0) adds road-normal roll stabilization while at least two wheels touch; +optional `air_control` (0..10 rad/s², default 0) enables occupied-aircraft-style +pitch/roll torque control when no wheels touch. These are authored gameplay +assists inspired by the requested Burnout Paradise feel, not recovered constants. +`VehicleControls.pitch` is optional, -1..1 (positive nose down), mapped from left +stick vertical or Up/Down arrows. Existing steering maps to airborne roll. +Neutral air input damps pitch/roll without adding linear acceleration. +The kart uses ground_stability=0.8, air_control=6, broader inertia dimensions, +crash_delta_v=12 m/s, rider hit_impulse=1200 N·s and a 1-second grounded inversion +timer. Inversion without chassis/rider contact no longer causes an ejection. +Default safety settings on other vehicle definitions are unchanged. + `skate-vehicles` pins Rapier 3D 0.35.3 and owns an independent, fixed-step rigid-body world. The existing skating solver remains authoritative outside vehicles. Rapier @@ -6,6 +19,46 @@ uses the installed map's collision triangles and resolves chassis collisions wit the map and other vehicles. Wheels use Rapier raycast suspension. Each host tick is split into substeps of approximately 1/120 second or smaller. +### Force-based handling (September 2026) + +The shared `skate-vehicles/src/handling.rs` now owns tire forces. Rapier's built-in +arcade tire impulses are disabled; its suspension queries and chassis collision +solver remain active. Steering has a 2.5 rad/s travel rate, speed-sensitive lock +and Ackermann front-wheel angles. Wheel torque integrates angular momentum; +longitudinal slip and lateral slip angle produce contact impulses constrained by +one load-dependent friction circle. Contact-point application produces chassis +pitch/roll, and dynamic supports receive opposing tire and suspension impulses. + +`tire_grip` is now the tire friction coefficient, multiplied by the contacted +collider's friction. The kart uses 1.3 for a dry setup. Its new `tire_friction` +setting intentionally replaces the old arcade `grip` setting so a saved value of +3 is not reused as a physical coefficient. Other saved settings remain compatible. +Older third-party definitions should review their grip values with this build. + +Service brake overrides throttle; an opposing throttle request brakes before +engaging the other direction near rest. Handbrake locks non-steering wheels. +Brakes scale with simulation time (the legacy `brake_impulse` setting retains its +120 Hz reference scale). Torque tapers with wheel speed rather than switching off +at a chassis-speed threshold. Reverse is limited by torque taper at up to 8 m/s. +Aerodynamic drag and axle rolling resistance slow coasting. Published speed is +the post-solve longitudinal velocity; falling or sliding sideways does not count +as forward speed. Reset clears steering, wheel angular momentum and displayed spin. + +This is a simplified physical kart model, not a measured tire/engine dataset: +wheel inertia is estimated from chassis mass, lateral stiffness is a fixed model +coefficient, and suspension still uses raycasts. There is no gearbox, tire heat, +deformation, ABS, or validated moving-platform suspension damping. Engine sound +still follows speed/throttle. Automated physics tests do not establish rendered +handling quality; steering feel and unusual map surfaces require playtesting. + +Chassis contacts use the minimum friction combine rule: the kart's 0.05 body +friction against a 1.0 map stays 0.05 instead of averaging to 0.525. Tire friction +still reads the map material independently. The imported collision mesh welds +identical vertices and enables internal-edge normal correction while retaining +all authored triangles. Crash delta-v is sampled immediately before collision +integration, after tire/suspension impulses, so ordinary driving forces are not +included in that impact measurement. Rider contact and inversion tests remain active. + ## First mod: Mario Kart Run PLAY-MARIO-KART.bat, enable **Mario Kart** in Mods, resume and press **F10**. diff --git a/mods/mario-kart.zip b/mods/mario-kart.zip index a67b4ca7245672dc594798b4fa8efb751f33138e..b4c875fd622a592b01bbfe567bcc2be61cf3d2e2 100644 GIT binary patch delta 5068 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 diff --git a/sdk/examples/mario-kart/README.md b/sdk/examples/mario-kart/README.md index a1017ddd..2502c784 100644 --- a/sdk/examples/mario-kart/README.md +++ b/sdk/examples/mario-kart/README.md @@ -1,9 +1,22 @@ # Mario Kart vehicle mod +Version 1.5 adds arcade rollover resistance and airborne left-stick control. +In air, stick left/right rolls, forward pitches the nose down, and back pitches +the nose up. Keyboard: A/D roll, Up/Down arrows pitch. Neutral input damps rotation. +No lift is added. Aerial inversion is allowed; sustained grounded inversion still +ejects. Minor bumps are more forgiving; severe collisions still eject the rider. +The matching updated host executable is required. Enable this mod in Mods, resume, then press F10 to spawn the kart in front of you. E (controller Y) enters/exits within 4 metres (slow below 3 m/s to exit). WASD drives; Space brakes; left Shift is the handbrake; R or right-stick click rights/resets the kart. The controller bind can be changed to left-stick click in the mod settings. F10 replaces the kart. Controller: RT/LT accelerate/reverse, left stick steers, A brakes, B handbrakes. +Version 1.4 uses the updated host's physical tire model: progressive steering, +Ackermann wheel angles, torque-driven wheel spin, shared braking/cornering grip, +and contact-point weight transfer. Brake overrides throttle; the opposing pedal +brakes before engaging reverse. Tire friction defaults to 1.3. The old arcade +grip preference is replaced by this new coefficient; other settings are retained. +This package requires the matching updated game executable. It remains a +simplified raycast-wheel kart, without a gearbox or ABS. The enabled mod shows keyboard and controller controls on screen, including the current reset bind. Toggle Show driving HUD in its settings to hide/show them, even before spawning. The GLB is prepared from your supplied ZIP with tools/prepare_mario_kart.py and is diff --git a/sdk/examples/mario-kart/main.lua b/sdk/examples/mario-kart/main.lua index e7f590f0..71115a6e 100644 --- a/sdk/examples/mario-kart/main.lua +++ b/sdk/examples/mario-kart/main.lua @@ -21,7 +21,7 @@ local function pressed(key) end local function tune() local s=sdk.settings - sdk.vehicle.tune('kart',{engine_volume=s.engine_volume,engine_force=s.engine,max_speed=s.speed,brake_impulse=s.brake,steering_angle=s.steering,tire_grip=s.grip}) + sdk.vehicle.tune('kart',{engine_volume=s.engine_volume,engine_force=s.engine,max_speed=s.speed,brake_impulse=s.brake,steering_angle=s.steering,tire_grip=s.tire_friction}) end local function nearby() local p=sdk.player.read();local h=p.heading or 0 @@ -52,6 +52,7 @@ return { end if car.phase=='driving' then sdk.vehicle.control('kart',sdk.vehicle.input() and { throttle=sdk.vehicle.input().throttle,steering=sdk.vehicle.input().steering, + pitch=sdk.vehicle.input().pitch or 0, brake=sdk.vehicle.input().brake,handbrake=sdk.vehicle.input().handbrake}) end hud(car) end, diff --git a/sdk/examples/mario-kart/mod.json b/sdk/examples/mario-kart/mod.json index 4c3da229..5da2ddcb 100644 --- a/sdk/examples/mario-kart/mod.json +++ b/sdk/examples/mario-kart/mod.json @@ -2,7 +2,7 @@ "id": "community.mario-kart", "api": 1, "name": "Mario Kart", - "version": "1.3.0", + "version": "1.5.0", "author": "Local vehicle SDK example", "description": "Rapier kart using your supplied model. F10 spawns nearby, E enters/exits, WASD drives, Space brakes, Shift handbrake, R resets. Slow down before exiting. Fitted entry/exit and steering animations included.", "entry": "main.lua", @@ -43,14 +43,14 @@ "max": 1, "step": 0.05 }, - "grip": { + "tire_friction": { "type": "number", - "label": "Tire grip", - "description": "Applied live to the kart.", - "default": 3, - "min": 0.5, - "max": 8, - "step": 0.25 + "label": "Tire friction coefficient", + "description": "Combined braking and cornering grip. 1.3 is the dry kart setup; lower values allow more sliding.", + "default": 1.3, + "min": 0.2, + "max": 2, + "step": 0.05 }, "hud": { "type": "boolean", diff --git a/sdk/examples/mario-kart/vehicle.json b/sdk/examples/mario-kart/vehicle.json index 801a164e..aa6132ba 100644 --- a/sdk/examples/mario-kart/vehicle.json +++ b/sdk/examples/mario-kart/vehicle.json @@ -14,7 +14,9 @@ "suspension_length": 0.25, "suspension_stiffness": 30, "suspension_damping": 4, - "tire_grip": 3, + "tire_grip": 1.3, + "ground_stability": 0.8, + "air_control": 6, "wheels": [ { "node": "wheel_rl", @@ -103,9 +105,9 @@ 0 ], "inertia_half_extents": [ - 0.7, - 0.2, - 1.05 + 1.1, + 0.35, + 1.4 ], "rider_safety": { "enabled": true, @@ -116,10 +118,10 @@ ], "radius": 0.25, "half_height": 0.25, - "crash_delta_v": 6, - "hit_impulse": 180, + "crash_delta_v": 12, + "hit_impulse": 1200, "inverted_up_y": -0.2, - "inverted_seconds": 0.2, + "inverted_seconds": 1.0, "eject_up_speed": 2 } } diff --git a/sdk/skate.lua b/sdk/skate.lua index 59819342..b8c238bc 100644 --- a/sdk/skate.lua +++ b/sdk/skate.lua @@ -112,6 +112,7 @@ sdk.trainer = {} function sdk.trainer.apply(tuning) end ---@class VehicleControls +---@field pitch? number -1..1; airborne nose up to nose down (left stick vertical) ---@field throttle? number -1..1, reverse to forward ---@field steering? number -1..1, right to left ---@field brake? number 0..1