Add occupied rider collision and momentum-preserving native crash bails

This commit is contained in:
chazmm committed 2026-09-08 22:54:52 +01:00
1 parent b262dfdcfe
commit 48a7fb59b5
17 files changed
+373 -10

No files matched your search

+1 -1
View File
@@ -3,7 +3,7 @@ $ErrorActionPreference = 'Stop'
$privateDirectory = Join-Path $PSScriptRoot '.local/vehicle-sdk'
$buildDirectory = Join-Path $privateDirectory 'build'
$artifact = Join-Path $buildDirectory 'skate3rust-vehicle-sdk.exe'
$destination = Join-Path $privateDirectory 'skate3-zip-mods.exe'
$destination = Join-Path $privateDirectory 'skate3-vehicle-bails.exe'
Push-Location $PSScriptRoot
$previousFlags = $env:CARGO_TARGET_X86_64_PC_WINDOWS_MSVC_RUSTFLAGS
try {
+2 -2
View File
@@ -9,7 +9,7 @@ if not defined SKATE3_ASSETS (
pause
exit /b 1
)
if not exist "%~dp0.local\vehicle-sdk\skate3-zip-mods.exe" (
if not exist "%~dp0.local\vehicle-sdk\skate3-vehicle-bails.exe" (
echo Build missing. Run Build-VehicleSDK.ps1 first.
pause
exit /b 1
@@ -21,5 +21,5 @@ echo Open Mods and ENABLE Mario Kart. F10 spawns the kart; E enters/exits. Escap
echo WASD drive, Space brake, Shift handbrake, R reset. Slow down before exiting.
echo Mods folder: %SKATE3_MODS%
echo Trainer log: %~dp0.local\vehicle-sdk\session.log
"%~dp0.local\vehicle-sdk\skate3-zip-mods.exe" --assets "%SKATE3_ASSETS%" --start-paused %* > "%~dp0.local\vehicle-sdk\session.log" 2>&1
"%~dp0.local\vehicle-sdk\skate3-vehicle-bails.exe" --assets "%SKATE3_ASSETS%" --start-paused %* > "%~dp0.local\vehicle-sdk\session.log" 2>&1
if errorlevel 1 pause
+42 -2
View File
@@ -43,6 +43,7 @@ pub(crate) struct Vehicles {
visual_phase: String,
blend_time: f32,
steering_visual: f32,
crash_handoff: bool,
previous_motion: BTreeMap<u64, interpolation::Motion>,
rendered_motion: BTreeMap<u64, interpolation::Motion>,
}
@@ -133,6 +134,7 @@ fn exit_now(world: &mut World, v: &mut Vehicles, forced: bool) -> Result<(), Str
}
}
}
if let Some(i)=v.owned.get(&(driver.owner.clone(),driver.key.clone())) {v.simulation.set_occupied(i.id,false);}
v.pose = None;
for (entity, visibility) in v.hidden.drain(..) {
if let Some(mut current) = world.get_mut::<Visibility>(entity) {
@@ -157,6 +159,34 @@ fn exit_now(world: &mut World, v: &mut Vehicles, forced: bool) -> Result<(), Str
event(v, &driver.owner, &driver.key, "vehicle_exited");
Ok(())
}
fn eject_now(world: &mut World, v: &mut Vehicles, ejection: skate_vehicles::Ejection) -> Result<(), String> {
let Some(driver) = v.driver.as_ref() else {return Ok(());};
let owner=driver.owner.clone(); let key=driver.key.clone();
let id=v.owned[&(owner.clone(),key.clone())].id;
let (_,rotation)=v.simulation.pose(id).ok_or("Missing crash vehicle")?;
let forward=Quat::from_array(rotation)*Vec3::Z;
let heading=forward.x.atan2(forward.z);
let (sin,cos)=heading.sin_cos();
// The native reset initializes a full upright body. Start clear of the seat,
// then its ordinary ragdoll/contact solver takes over immediately.
let p=Vec3::from_array(ejection.position)+Vec3::Y*0.25;
let matrix=[[cos,0.,-sin,0.],[0.,1.,0.,0.],[sin,0.,cos,0.],[p.x,p.y,p.z,0.]];
{
let mut skater=world.resource_mut::<crate::physics::SkaterRuntime>();
skater.player_input.request_teleport(matrix).map_err(|e|e.to_string())?;
skater.teleport_state.request_vehicle_ejection(matrix,ejection.velocity,ejection.angular_velocity);
}
v.simulation.set_occupied(id,false);
v.simulation.vehicles.get_mut(&id).unwrap().controls=Controls{brake:0.2,..Default::default()};
v.driver=None;v.pose=None;v.crash_handoff=true;
for (entity,visibility) in v.hidden.drain(..) {
if let Some(mut current)=world.get_mut::<Visibility>(entity) {*current=visibility;}
}
v.events.push(json!({"name":"vehicle_bailed","owner":owner,"key":key,
"reason":ejection.reason,"position":ejection.position,"velocity":ejection.velocity,
"angular_velocity":ejection.angular_velocity}));
Ok(())
}
pub(super) fn retire(world: &mut World, owner: &str) {
world.resource_scope(|world, mut v: Mut<Vehicles>| {
if v.driver.as_ref().is_some_and(|d| d.owner == owner) {
@@ -192,6 +222,7 @@ pub(super) fn clear(world: &mut World) {
v.driver = None;
v.pose = None;
v.last_visual.clear();v.blend_from.clear();v.visual_phase.clear();v.steering_visual=0.;
v.crash_handoff=false;
v.previous_motion.clear();
v.rendered_motion.clear();
v.simulation = Simulation::default();
@@ -473,11 +504,20 @@ fn tick(world: &mut World) {
}
}
}
let occupied_id=v.driver.as_ref().and_then(|d|v.owned.get(&(d.owner.clone(),d.key.clone()))).map(|i|i.id);
let ids:Vec<_>=v.simulation.vehicles.keys().copied().collect();
for id in ids {v.simulation.set_occupied(id,Some(id)==occupied_id);}
if !v.owned.is_empty() {
v.previous_motion = v.simulation.vehicles.keys().filter_map(|&id|
interpolation::Motion::capture(&v.simulation, id).map(|m| (id, m))).collect();
v.simulation.step(dt);
}
if let Some(id)=occupied_id {
if let Some(ejection)=v.simulation.take_ejection(id) {
if let Err(error)=eject_now(world,&mut v,ejection) {warn!("Vehicle ejection: {error}");}
return;
}
}
if let Some((owner, key, phase)) = driver_info {
if let Some(i) = v.owned.get(&(owner.clone(), key.clone())) {
let def = &v.simulation.vehicles[&i.id].definition;
@@ -673,11 +713,11 @@ pub(crate) fn present(world: &mut World) {
v.pose = pose;
});
world.resource_scope(|world,mut v:Mut<Vehicles>| {
let duration=if v.visual_phase=="vanilla" {0.5} else {0.4};
let duration=if v.visual_phase=="vanilla" {if v.crash_handoff {0.12} else {0.5}} else {0.4};
if v.blend_time<duration {
let t=(v.blend_time/duration).clamp(0.,1.);
crate::animation::blend_vehicle_visual(world,&v.blend_from,t*t*(3.-2.*t));
} else {v.blend_from.clear();}
} else {v.blend_from.clear();v.crash_handoff=false;}
v.last_visual=crate::animation::capture_vehicle_visual(world);
});
}
+3 -1
View File
@@ -78,6 +78,7 @@ pub(super) fn advance(
if physics.trainer != skate_mods::TrainerTuning::default() {
skater.ground_settings = std::sync::Arc::new(skater.ground_settings.tuned(physics.trainer));
}
let mut vehicle_ejected = false;
if teleported {
skater.respawn.reset_measurements();
skater.player_state.reset_for_teleport();
@@ -91,11 +92,12 @@ pub(super) fn advance(
&mut skater.skeleton_collision,
&mut skater.collision_feedback,
);
vehicle_ejected = player_state::apply_vehicle_ejection(physics, skater)?;
}
//World8275EC0C ends board queries before PostInput/state selection.
physics.riding.finish_wheel_queries()?;
let state_before_selection = skater.player_state.current();
player_state::post_input_and_select(physics, skater)?;
if !vehicle_ejected { player_state::post_input_and_select(physics, skater)?; }
let state_after_selection = skater.player_state.current();
if state_before_selection != state_after_selection {
physics.exchange.emit_event(
@@ -163,3 +163,23 @@ pub(crate) fn pre_state(
) -> Result<(), String> {
pre_state::advance(physics, skater)
}
/// Vehicle ownership has ended; reset first, then enter the native ragdoll and seed momentum.
pub(crate) fn apply_vehicle_ejection(physics: &mut GamePhysics, skater: &mut SkaterRuntime) -> Result<bool, String> {
let Some((velocity, angular)) = skater.teleport_state.take_vehicle_ejection() else { return Ok(false); };
transition::set(physics, skater, PhysicalStateId::WipeoutGround)?;
let velocity=skate_core::math::Vector3::new(velocity[0],velocity[1],velocity[2]);
let angular=skate_core::math::Vector3::new(angular[0],angular[1],angular[2]);
for body in skater.skeleton.bodies_mut() {
body.rates.linear_velocity=velocity;
body.rates.angular_velocity=angular;
}
for body in physics.board.bodies_mut() {
body.rates.linear_velocity=velocity;
body.rates.angular_velocity=angular;
}
skater.animated_skeleton.motion.velocity_world=[velocity.x,velocity.y,velocity.z,0.];
skater.player_input.processed.vectors_544_560_592_608[3]=[velocity.x,velocity.y,velocity.z,0.].map(f32::to_bits);
skater.wipeout_state.state.velocity=[velocity.x,velocity.y,velocity.z,0.];
Ok(true)
}
@@ -23,14 +23,21 @@ pub(crate) struct Runtime {
checkpoint: Checkpoint,
pending_reply: Option<Target>,
manual_on_board: Option<bool>,
vehicle_ejection: Option<([f32; 3], [f32; 3])>,
}
impl Runtime {
/// Explicit manual return uses the actor-reset publication without replacing
/// the automatic recovery checkpoint owned by this runtime.
pub fn request_manual(&mut self, transform: AnimationPartTransform, on_board: bool) {
self.vehicle_ejection = None;
self.pending_reply = Some(Target { transform: transform.map(|r| r.map(f32::to_bits)), on_board });
self.manual_on_board = Some(on_board);
}
pub fn request_vehicle_ejection(&mut self, transform: AnimationPartTransform, velocity: [f32;3], angular: [f32;3]) {
self.request_manual(transform, false);
self.vehicle_ejection = Some((velocity, angular));
}
pub fn take_vehicle_ejection(&mut self) -> Option<([f32;3],[f32;3])> { self.vehicle_ejection.take() }
pub fn take_manual_on_board(&mut self) -> Option<bool> { self.manual_on_board.take() }
pub fn new(checkpoint: Checkpoint) -> Self {
Self {
@@ -38,6 +45,7 @@ impl Runtime {
checkpoint,
pending_reply: None,
manual_on_board: None,
vehicle_ejection: None,
}
}
pub fn enter(&mut self) {
@@ -80,3 +88,20 @@ impl Runtime {
Some(output)
}
}
#[cfg(test)]
mod vehicle_tests {
use super::*;
fn matrix() -> AnimationPartTransform { [[1.,0.,0.,0.],[0.,1.,0.,0.],[0.,0.,1.,0.],[3.,4.,5.,0.]] }
#[test]
fn vehicle_momentum_survives_reset_reply_and_is_consumed_once() {
let mut state=Runtime::new(Checkpoint{transform:matrix(),on_board:true});
state.request_vehicle_ejection(matrix(),[12.,2.,-3.],[0.,1.,0.]);
assert!(state.take_reply().is_some());
assert_eq!(state.take_manual_on_board(),Some(false));
assert_eq!(state.take_vehicle_ejection(),Some(([12.,2.,-3.],[0.,1.,0.])));
assert_eq!(state.take_vehicle_ejection(),None);
state.request_vehicle_ejection(matrix(),[12.,2.,-3.],[0.,1.,0.]);
state.request_manual(matrix(),true);
assert_eq!(state.take_vehicle_ejection(),None);
}
}
+29
View File
@@ -14,6 +14,7 @@ pub struct VehicleDefinition {
pub collider_rounding: f32,
pub chassis_friction: f32,
pub engine_audio: EngineAudio,
pub rider_safety: RiderSafety,
pub mass: f32,
pub engine_force: f32,
pub brake_impulse: f32,
@@ -30,6 +31,25 @@ pub struct VehicleDefinition {
pub camera_height: f32,
pub animations: Animations,
}
/// Occupied-seat collision and automatic ejection. Coordinates are chassis-local.
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct RiderSafety {
pub enabled: bool,
pub offset: [f32; 3],
pub radius: f32,
pub half_height: f32,
pub crash_delta_v: f32,
pub hit_impulse: f32,
pub inverted_up_y: f32,
pub inverted_seconds: f32,
pub eject_up_speed: f32,
}
impl Default for RiderSafety {
fn default() -> Self { Self { enabled: true, offset: [0.,0.5,0.], radius: 0.25,
half_height: 0.25, crash_delta_v: 6., hit_impulse: 180., inverted_up_y: -0.2,
inverted_seconds: 0.2, eject_up_speed: 2. } }
}
/// Built-in synthesized engine; no external recording is required.
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
@@ -80,6 +100,7 @@ impl Default for VehicleDefinition {
collider_rounding: 0.,
chassis_friction: 0.3,
engine_audio: EngineAudio::default(),
rider_safety: RiderSafety::default(),
mass: 220.,
engine_force: 1800.,
brake_impulse: 100.,
@@ -128,6 +149,14 @@ impl VehicleDefinition {
|| !range(self.engine_audio.volume, 0., 1.)
|| !range(self.engine_audio.idle_pitch, 0.25, 2.)
|| !range(self.engine_audio.max_pitch, self.engine_audio.idle_pitch, 5.)
|| !point(&self.rider_safety.offset, 5.)
|| !range(self.rider_safety.radius, 0.1, 1.)
|| !range(self.rider_safety.half_height, 0.05, 1.)
|| !range(self.rider_safety.crash_delta_v, 1., 50.)
|| !range(self.rider_safety.hit_impulse, 10., 10000.)
|| !range(self.rider_safety.inverted_up_y, -1., 0.5)
|| !range(self.rider_safety.inverted_seconds, 0.05, 3.)
|| !range(self.rider_safety.eject_up_speed, 0., 10.)
|| !range(self.mass, 10., 10000.)
|| !range(self.engine_force, 0., 100000.)
|| !range(self.brake_impulse, 0., 10000.)
+15
View File
@@ -1,5 +1,7 @@
//! Bevy-independent, fixed-step Rapier vehicle simulation and validated mod definitions.
mod definition;
mod safety;
pub use safety::Ejection;
pub use definition::*;
pub use rapier3d;
use rapier3d::{
@@ -30,6 +32,10 @@ pub struct Vehicle {
pub body: RigidBodyHandle,
pub controller: DynamicRayCastVehicleController,
pub controls: Controls,
rider: ColliderHandle,
occupied: bool,
inverted_time: f32,
pub ejection: Option<Ejection>,
}
pub struct Simulation {
pub world: PhysicsWorld,
@@ -116,6 +122,11 @@ impl Simulation {
&tuning,
);
}
let safety = &d.rider_safety;
let rider = self.world.colliders.insert_with_parent(
ColliderBuilder::capsule_y(safety.half_height, safety.radius)
.translation(Vector::from_array(d.seat) + Vector::from_array(safety.offset))
.density(0.).friction(0.2).enabled(false).build(), body, &mut self.world.bodies);
let id = self.next;
self.next += 1;
self.vehicles.insert(
@@ -125,6 +136,7 @@ impl Simulation {
body,
controller,
controls: Controls::default(),
rider, occupied: false, inverted_time: 0., ejection: None,
},
);
Ok(id)
@@ -142,6 +154,7 @@ 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() {
let c = v.controls;
@@ -176,6 +189,7 @@ impl Simulation {
v.controller.update_vehicle(h, queries);
}
self.world.step();
self.check_riders(&before, h);
}
}
pub fn pose(&self, id: u64) -> Option<([f32; 3], [f32; 4])> {
@@ -193,6 +207,7 @@ impl Simulation {
b.set_linvel(Vector::ZERO, true);
b.set_angvel(Vector::ZERO, true);
v.controls = Controls::default();
v.ejection = None; v.inverted_time = 0.;
Ok(())
}
pub fn floor(&self, position: [f32; 3]) -> Option<[f32; 3]> {
+58
View File
@@ -0,0 +1,58 @@
use super::*;
#[derive(Clone, Copy, Debug)]
pub struct Ejection {
pub position: [f32; 3],
pub velocity: [f32; 3],
pub angular_velocity: [f32; 3],
pub reason: &'static str,
}
impl Simulation {
pub fn set_occupied(&mut self, id: u64, occupied: bool) {
if let Some(v) = self.vehicles.get_mut(&id) {
if v.occupied != occupied {
v.occupied = occupied; v.inverted_time = 0.; v.ejection = None;
self.world.colliders[v.rider].set_enabled(occupied && v.definition.rider_safety.enabled);
self.world.bodies[v.body].wake_up(true);
}
}
}
pub fn take_ejection(&mut self, id: u64) -> Option<Ejection> {
self.vehicles.get_mut(&id)?.ejection.take()
}
pub(super) fn capture_riders(&self) -> BTreeMap<u64, (Vector, Vector)> {
self.vehicles.iter().filter(|(_, v)| v.occupied && v.definition.rider_safety.enabled)
.map(|(&id, v)| {
let body=&self.world.bodies[v.body];
let point=body.position().transform_point(Vector::from_array(v.definition.seat));
(id, (body.linvel(), body.velocity_at_point(point)))
}).collect()
}
pub(super) fn check_riders(&mut self, before: &BTreeMap<u64,(Vector,Vector)>, dt: f32) {
for (&id, &(linear, point_velocity)) in before {
let v=self.vehicles.get_mut(&id).unwrap();
if v.ejection.is_some() { continue; }
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.));
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")}
else {None};
if let Some(reason)=reason {
let seat=body.position().transform_point(Vector::from_array(v.definition.seat));
let after=body.velocity_at_point(seat);
let velocity=if after.length_squared()>point_velocity.length_squared() {after} else {point_velocity};
let velocity=(velocity + Vector::Y*safety.eject_up_speed).clamp_length_max(60.);
v.ejection=Some(Ejection { position: seat.to_array(), velocity: velocity.to_array(),
angular_velocity: body.angvel().clamp_length_max(15.).to_array(), reason });
}
}
}
}
+64
View File
@@ -182,3 +182,67 @@ fn collision_mass_and_audio_tuning_are_validated() {
let invalid=VehicleTuning { engine_volume:Some(f32::NAN), ..Default::default() };
assert!(!invalid.valid()); assert!(invalid.apply(&base).is_err());
}
#[test]
fn occupied_crash_ejects_with_preimpact_momentum() {
use rapier3d::prelude::*;
let mut s=simulation();
s.world.insert(RigidBodyBuilder::fixed().translation(Vector::new(0.,1.,3.)),ColliderBuilder::cuboid(5.,2.,0.2));
let id=s.spawn(definition(),[0.,0.6,0.],0.).unwrap();
s.set_occupied(id,true);
let handle=s.vehicles[&id].body;
s.world.bodies[handle].set_linvel(Vector::new(0.,0.,20.),true);
let mut crash=None;
for _ in 0..120 {s.step(1./120.);if let Some(e)=s.take_ejection(id){crash=Some(e);break;}}
let crash=crash.expect("wall impact should eject");
assert!(matches!(crash.reason,"crash"|"rider_impact"));
assert!(crash.velocity[2]>15.,"momentum was lost: {:?}",crash.velocity);
s.set_occupied(id,false);
for _ in 0..60 {s.step(1./120.);}
assert!(s.take_ejection(id).is_none());
}
#[test]
fn inverted_rider_bails_but_parked_vehicle_does_not() {
use rapier3d::prelude::*;
for occupied in [false,true] {
let mut s=simulation();let id=s.spawn(definition(),[0.,4.,0.],0.).unwrap();
s.set_occupied(id,occupied);
let h=s.vehicles[&id].body;
s.world.bodies[h].set_rotation(Rotation::from_rotation_z(std::f32::consts::PI),true);
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());}
}
}
#[test]
fn rider_hitbox_catches_an_overhang_above_the_chassis() {
use rapier3d::prelude::*;
let mut s=simulation();
s.world.insert(RigidBodyBuilder::fixed().translation(Vector::new(0.,1.55,3.)),ColliderBuilder::cuboid(5.,0.15,0.2));
let id=s.spawn(definition(),[0.,0.6,0.],0.).unwrap();s.set_occupied(id,true);
let h=s.vehicles[&id].body;s.world.bodies[h].set_linvel(Vector::new(0.,0.,18.),true);
let mut crash=None;
for _ in 0..120 {s.step(1./120.);if let Some(e)=s.take_ejection(id){crash=Some(e);break;}}
assert_eq!(crash.expect("head/torso collision must eject").reason,"rider_impact");
}
#[test]
fn occupied_ramp_driving_does_not_false_bail() {
let mut s=Simulation::default();let slope=30_f32.to_radians().tan();
s.ground([
[[-20.,0.,-20.],[20.,0.,3.],[20.,0.,-20.]],
[[-20.,0.,-20.],[-20.,0.,3.],[20.,0.,3.]],
[[-20.,0.,3.],[20.,20.*slope,23.],[20.,0.,3.]],
[[-20.,0.,3.],[-20.,20.*slope,23.],[20.,20.*slope,23.]],
].into_iter()).unwrap();
let id=s.spawn(definition(),[0.,0.6,-2.],0.).unwrap();s.set_occupied(id,true);
s.vehicles.get_mut(&id).unwrap().controls=Controls{throttle:1.,..Default::default()};
let mut climbed=false;
for _ in 0..960 {
s.step(1./120.);assert!(s.take_ejection(id).is_none(),"normal ramp triggered ejection");
if s.pose(id).unwrap().0[2]>10. {climbed=true;break;}
}
assert!(climbed);
}
+79 -1
View File
@@ -70,7 +70,8 @@ engine_volume (0..1), brake_impulse (0..10000), steering_angle (0.01..1.2 radian
Maximum speed limits engine application; it is not an absolute downhill speed cap.
Events passed to `on_event`: `vehicle_spawned`, `vehicle_entering`, `vehicle_entered`,
`vehicle_exited`, `vehicle_exit_blocked`, `vehicle_reset`, `vehicle_removed`.
`vehicle_exited`, `vehicle_exit_blocked`, `vehicle_reset`, `vehicle_removed`,
`vehicle_bailed`.
Each includes owner and key. Map changes use the existing `world_changed` event;
spawn new vehicles in the new world as needed. Invalid assets/commands appear as
mod errors; they retire the mod's vehicles instead of crashing the game.
@@ -221,3 +222,80 @@ calibration, Blender fitting, native export and packaging commands. The host's a
slots, steering blend, stance hand-offs, board hiding and shared motion interpolation
apply automatically to every vehicle definition. The example fitting scripts contain
kart geometry targets; adjust those targets for another vehicle without changing the host.
## Rider hitbox and crash ejection
Every vehicle can use the shared `rider_safety` definition. It is enabled by default;
explicit example values are shown below. Changes require respawning the vehicle.
```json
"rider_safety": {
"enabled": true,
"offset": [0, 0.5, 0],
"radius": 0.25,
"half_height": 0.25,
"crash_delta_v": 6,
"hit_impulse": 180,
"inverted_up_y": -0.2,
"inverted_seconds": 0.2,
"eject_up_speed": 2
}
```
A solid capsule is attached to the occupied chassis, centred at `seat + offset`.
It covers the seated torso/head, including roof/ground/overhang contact when rolled.
It is not a sensor: contacts affect the vehicle. It adds no mass, so existing mass and
inertia tuning remains authoritative. Empty vehicles have no active rider collider.
The capsule is an approximation, not separate animated hand/foot hitboxes; adjust it
for your cockpit and expected character proportions. It is active through entry/exit
as well as driving, and is removed from collision when ownership ends. Disabling
rider_safety disables both this collider and automatic ejection.
Ejection triggers on any of:
- A chassis collision changing linear velocity by at least crash_delta_v in a physics
substep. This is delta speed in m/s, not total driving speed; braking/ordinary ramps
should not meet the default threshold.
- A rider-capsule contact impulse at least hit_impulse, in N·s. This is an impulse
threshold, not force in newtons, and is evaluated on actual Rapier contacts.
- Chassis-local up having world Y below inverted_up_y continuously for inverted_seconds.
The timer clears when upright again. Reset clears pending impacts/inversion history.
The host stops driver controls, releases occupancy and fades engine audio. It queues
an actor reset just above the seat, enters the native Wipeout ragdoll after normal reset
initialization, and seeds the native body and board velocities. Seat point velocity
includes the vehicle's angular motion. Pre-impact velocity is retained when the car
stops abruptly; if a hit accelerates the car from rest, the stronger post-impact point
velocity is used instead. A small world-up launch speed helps clear the seat. Output
linear/angular speeds are bounded to 60 m/s and 15 rad/s. This is a momentum hand-off,
not a fully coupled passenger rigid-body simulation or an exact seated ragdoll pose.
The crash visual hand-off lasts 0.12 seconds; normal entry/exit blends remain unchanged.
Native map collision and ordinary bail recovery take over. After release, native rider
collision against the Rapier car itself is still not bridged, as described above.
Lua receives **vehicle_bailed** instead of the normal vehicle_exited event:
```lua
on_event = function(event)
if event.name == 'vehicle_bailed' then
-- event.key is this mod's vehicle key; reason is crash/rider_impact/inverted.
sdk.log('Ejected: ' .. event.reason)
-- event.position: world seat position at detection, metres
-- event.velocity: carried world velocity including launch lift, m/s
-- event.angular_velocity: world angular velocity, rad/s
end
end
```
The vehicle remains spawned and can be reset or entered again after bail recovery.
Use vehicle.read().occupied/phase for ownership; do not keep sending driving input
while unoccupied. Physics events are delivered through the existing mod callback queue.
The shared Rust simulation also exposes set_occupied and take_ejection for host tests;
Lua cannot directly write native ragdoll bodies or bypass the hand-off lifecycle.
Bounds: offset components ±5 m, radius 0.1..1 m, half_height 0.05..1 m (capsule cylinder
half-length; total height is 2*(half_height+radius)), crash_delta_v 1..50 m/s,
hit_impulse 10..10000 N·s, inverted_up_y -1..0.5, inverted_seconds 0.05..3 s,
eject_up_speed 0..10 m/s. Nonfinite values are rejected. Headless tests cover wall
momentum retention, overhang rider hits and sustained inversion versus empty vehicles.
Native rendering/recovery and unusual map geometry still need manual playtesting.
+2
View File
@@ -57,6 +57,8 @@ python tools/package_mod.py sdk/examples/your-mod mods/your-mod.zip
- Vehicles: spawn/read/control/tune/enter/exit/reset/remove, Rapier map and vehicle
collision, raycast wheels/suspension, collider offsets/rounding, independent inertia
and centre of mass, throttle-responsive engine synthesis and live engine volume.
Rider safety adds an occupied capsule, crash/inversion ejection, carried momentum
into the native bail state and vehicle_bailed events; configure rider_safety.
- Native-rig rider enter/exit/drive/idle/reverse/brake/steering slots. The host handles
steering blending, vanilla hand-offs, board hiding and shared motion interpolation.
- Automatic per-mod draggable/resizable/collapsible settings windows and enable badges.
+5
View File
@@ -29,3 +29,8 @@ controls it live (zero mutes). Full authoring guide: docs/mixamo-vehicle-workflo
Author here, then run `python tools/package_mod.py sdk/examples/mario-kart mods/mario-kart.zip`.
The live ZIP loads automatically; do not edit mods/.cache. See sdk/AGENTS.md.
Hard crashes, strong head/torso hits and sustained inversion now eject the rider into
native bail physics with carried momentum. An occupied-seat capsule protects the torso
against map geometry. Tune rider_safety in vehicle.json; see docs/vehicle-sdk.md for
threshold units, vehicle_bailed events and the native/Rapier collision boundary.
+1 -1
View File
@@ -9,7 +9,7 @@ local function hud(car)
'KEYBOARD | Space brake | Left Shift handbrake | R reset',
'CONTROLLER | Y enter / exit | RT / LT accelerate / reverse | Left stick steer',
'CONTROLLER | A brake | B handbrake | '..(sdk.settings.reset_button=='Left stick' and 'L3' or 'R3')..' (stick click) reset',
'Slow below 3 m/s to exit | Escape: mod settings'
'Slow below 3 m/s to exit | Hard crashes / rollovers eject the rider | Escape: settings'
}
for i,line in ipairs(lines) do
local key=i==1 and 'kart-hud' or 'kart-hud-'..i
+1 -1
View File
@@ -2,7 +2,7 @@
"id": "community.mario-kart",
"api": 1,
"name": "Mario Kart",
"version": "1.2.0",
"version": "1.3.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 locally.",
"entry": "main.lua",
+16 -1
View File
@@ -106,5 +106,20 @@
0.7,
0.2,
1.05
]
],
"rider_safety": {
"enabled": true,
"offset": [
0,
0.5,
0
],
"radius": 0.25,
"half_height": 0.25,
"crash_delta_v": 6,
"hit_impulse": 180,
"inverted_up_y": -0.2,
"inverted_seconds": 0.2,
"eject_up_speed": 2
}
}
+10
View File
@@ -157,3 +157,13 @@ function sdk.vehicle.reset(key,position,heading) end
function sdk.vehicle.remove(key) end
---@return table Normalized keyboard/controller axes plus interact and pad_buttons
function sdk.vehicle.input() end
---@class VehicleBailEvent
---@field name 'vehicle_bailed'
---@field owner string
---@field key string
---@field reason 'crash'|'rider_impact'|'inverted'
---@field position number[] World seat position at detection, metres
---@field velocity number[] Carried world velocity plus launch lift, m/s
---@field angular_velocity number[] World angular velocity, rad/s
-- Configure rider_safety in the vehicle definition; see docs/vehicle-sdk.md.