From 48a7fb59b5dcd1b3c982a653bfaa63a587cd7425 Mon Sep 17 00:00:00 2001 From: chazmm Date: Tue, 8 Sep 2026 22:54:52 +0100 Subject: [PATCH] Add occupied rider collision and momentum-preserving native crash bails --- Build-VehicleSDK.ps1 | 2 +- PLAY-MARIO-KART.bat | 4 +- crates/skate-game/src/modding/vehicles/mod.rs | 44 +++++++++- crates/skate-game/src/physics/frame.rs | 4 +- crates/skate-game/src/physics/player_state.rs | 20 +++++ .../skate-game/src/physics/teleport_state.rs | 25 ++++++ crates/skate-vehicles/src/definition.rs | 29 +++++++ crates/skate-vehicles/src/lib.rs | 15 ++++ crates/skate-vehicles/src/safety.rs | 58 ++++++++++++++ crates/skate-vehicles/tests/simulation.rs | 64 +++++++++++++++ docs/vehicle-sdk.md | 80 ++++++++++++++++++- sdk/AGENTS.md | 2 + sdk/examples/mario-kart/README.md | 5 ++ sdk/examples/mario-kart/main.lua | 2 +- sdk/examples/mario-kart/mod.json | 2 +- sdk/examples/mario-kart/vehicle.json | 17 +++- sdk/skate.lua | 10 +++ 17 files changed, 373 insertions(+), 10 deletions(-) create mode 100644 crates/skate-vehicles/src/safety.rs diff --git a/Build-VehicleSDK.ps1 b/Build-VehicleSDK.ps1 index 070457fe..7361af31 100644 --- a/Build-VehicleSDK.ps1 +++ b/Build-VehicleSDK.ps1 @@ -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 { diff --git a/PLAY-MARIO-KART.bat b/PLAY-MARIO-KART.bat index 6e881cde..5f6695f2 100644 --- a/PLAY-MARIO-KART.bat +++ b/PLAY-MARIO-KART.bat @@ -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 diff --git a/crates/skate-game/src/modding/vehicles/mod.rs b/crates/skate-game/src/modding/vehicles/mod.rs index 33210470..60eabf4e 100644 --- a/crates/skate-game/src/modding/vehicles/mod.rs +++ b/crates/skate-game/src/modding/vehicles/mod.rs @@ -43,6 +43,7 @@ pub(crate) struct Vehicles { visual_phase: String, blend_time: f32, steering_visual: f32, + crash_handoff: bool, previous_motion: BTreeMap, rendered_motion: BTreeMap, } @@ -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::(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::(); + 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::(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| { 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| { - 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 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 { + 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) +} diff --git a/crates/skate-game/src/physics/teleport_state.rs b/crates/skate-game/src/physics/teleport_state.rs index da0f2ef0..ede5550d 100644 --- a/crates/skate-game/src/physics/teleport_state.rs +++ b/crates/skate-game/src/physics/teleport_state.rs @@ -23,14 +23,21 @@ pub(crate) struct Runtime { checkpoint: Checkpoint, pending_reply: Option, manual_on_board: Option, + 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 { 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); + } +} diff --git a/crates/skate-vehicles/src/definition.rs b/crates/skate-vehicles/src/definition.rs index 8e8bede8..c9467efc 100644 --- a/crates/skate-vehicles/src/definition.rs +++ b/crates/skate-vehicles/src/definition.rs @@ -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.) diff --git a/crates/skate-vehicles/src/lib.rs b/crates/skate-vehicles/src/lib.rs index 6912c3b7..49954209 100644 --- a/crates/skate-vehicles/src/lib.rs +++ b/crates/skate-vehicles/src/lib.rs @@ -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, } 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]> { diff --git a/crates/skate-vehicles/src/safety.rs b/crates/skate-vehicles/src/safety.rs new file mode 100644 index 00000000..10314807 --- /dev/null +++ b/crates/skate-vehicles/src/safety.rs @@ -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 { + self.vehicles.get_mut(&id)?.ejection.take() + } + pub(super) fn capture_riders(&self) -> BTreeMap { + 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, 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 }); + } + } + } +} diff --git a/crates/skate-vehicles/tests/simulation.rs b/crates/skate-vehicles/tests/simulation.rs index f882ba52..a3d57fe9 100644 --- a/crates/skate-vehicles/tests/simulation.rs +++ b/crates/skate-vehicles/tests/simulation.rs @@ -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); +} diff --git a/docs/vehicle-sdk.md b/docs/vehicle-sdk.md index 91a62746..e14297f3 100644 --- a/docs/vehicle-sdk.md +++ b/docs/vehicle-sdk.md @@ -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. diff --git a/sdk/AGENTS.md b/sdk/AGENTS.md index 64f3d8dd..6bcb272a 100644 --- a/sdk/AGENTS.md +++ b/sdk/AGENTS.md @@ -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. diff --git a/sdk/examples/mario-kart/README.md b/sdk/examples/mario-kart/README.md index c39664b0..ac27f010 100644 --- a/sdk/examples/mario-kart/README.md +++ b/sdk/examples/mario-kart/README.md @@ -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. diff --git a/sdk/examples/mario-kart/main.lua b/sdk/examples/mario-kart/main.lua index d273392b..e7f590f0 100644 --- a/sdk/examples/mario-kart/main.lua +++ b/sdk/examples/mario-kart/main.lua @@ -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 diff --git a/sdk/examples/mario-kart/mod.json b/sdk/examples/mario-kart/mod.json index ba2118a0..f144f07e 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.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", diff --git a/sdk/examples/mario-kart/vehicle.json b/sdk/examples/mario-kart/vehicle.json index b4123bfd..801a164e 100644 --- a/sdk/examples/mario-kart/vehicle.json +++ b/sdk/examples/mario-kart/vehicle.json @@ -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 + } } diff --git a/sdk/skate.lua b/sdk/skate.lua index 66e44041..820d65ab 100644 --- a/sdk/skate.lua +++ b/sdk/skate.lua @@ -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.