mirror of
https://github.com/SK8-ENGINE/skate-3-rust-engine.git
synced 2026-10-26 19:47:44 +00:00
Add occupied rider collision and momentum-preserving native crash bails
This commit is contained in:
1 parent
b262dfdcfe
commit
48a7fb59b5
17 files changed
+373
-10
No files matched your search
@@ -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
@@ -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
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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.)
|
||||
|
||||
@@ -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]> {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user