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 a67b4ca7..b4c875fd 100644 Binary files a/mods/mario-kart.zip and b/mods/mario-kart.zip differ 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