mirror of
https://github.com/SK8-ENGINE/skate-3-rust-engine.git
synced 2026-10-26 19:47:44 +00:00
Add ramp-friendly vehicle collision, throttle engine audio and Mixamo SDK guide
This commit is contained in:
1 parent
fb0dd98e83
commit
a97c9157e6
16 files changed
+421
-21
No files matched your search
@@ -0,0 +1,10 @@
|
||||
//! Export the exact native order for an authored vehicle animation package.
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let args: Vec<_> = std::env::args_os().skip(1).collect();
|
||||
if args.len() != 2 { return Err("Usage: vehicle_bone_names OnBoard.abin bone-names.json".into()); }
|
||||
let bank = skate_data::abin::Bank::load(std::path::Path::new(&args[0]))?;
|
||||
let hierarchy = bank.hierarchy().ok_or("Bank has no skeleton hierarchy")?;
|
||||
std::fs::write(&args[1], serde_json::to_vec_pretty(&hierarchy.bone_names)?)?;
|
||||
println!("Exported {} bone names", hierarchy.bone_names.len());
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
//! Original synthesized exhaust sound, shared by all vehicle mods that opt in.
|
||||
use bevy::{audio::{AddAudioSource, Volume}, prelude::*};
|
||||
use super::engine_sound::Engine;
|
||||
|
||||
#[derive(Component)]
|
||||
struct EngineVoice;
|
||||
#[derive(Resource, Default)]
|
||||
struct State { source: Option<Handle<Engine>>, pitch: f32, volume: f32 }
|
||||
|
||||
pub(super) fn install(app: &mut App) {
|
||||
app.add_audio_source::<Engine>().init_resource::<State>()
|
||||
.add_systems(Update, update.after(super::present));
|
||||
}
|
||||
|
||||
fn update(
|
||||
mut commands: Commands, vehicles: Res<super::Vehicles>,
|
||||
time: Res<Time<Real>>, menu: Option<Res<crate::graphics_menu::Menu>>,
|
||||
replay: Res<crate::replay::Replay>, mut state: ResMut<State>,
|
||||
mut sources: ResMut<Assets<Engine>>, mut voices: Query<(Entity, &mut AudioSink), With<EngineVoice>>,
|
||||
pending: Query<Entity, With<EngineVoice>>,
|
||||
) {
|
||||
let car = vehicles.driver.as_ref().and_then(|d| vehicles.owned.get(&(d.owner.clone(), d.key.clone())))
|
||||
.and_then(|i| vehicles.simulation.vehicles.get(&i.id))
|
||||
.filter(|c| c.definition.engine_audio.enabled);
|
||||
let active = crate::graphics_menu::gameplay_active(menu) && !replay.active;
|
||||
let (pitch, volume) = car.map_or((0.7, 0.), |car| {
|
||||
let a = &car.definition.engine_audio;
|
||||
let speed = (car.controller.current_vehicle_speed.abs() / car.definition.max_speed).clamp(0., 1.);
|
||||
let throttle = car.controls.throttle.abs();
|
||||
let revs = (speed * 0.65 + throttle * 0.35).clamp(0., 1.);
|
||||
(a.idle_pitch + (a.max_pitch - a.idle_pitch) * revs,
|
||||
if active { a.volume * (0.22 + 0.65 * throttle + 0.13 * speed) } else { 0. })
|
||||
});
|
||||
let dt = time.delta_secs().min(0.1);
|
||||
state.pitch += (pitch - state.pitch) * (1. - (-7. * dt).exp());
|
||||
state.volume += (volume - state.volume) * (1. - (-12. * dt).exp());
|
||||
if car.is_some() && pending.is_empty() {
|
||||
let source = state.source.get_or_insert_with(|| sources.add(Engine)).clone();
|
||||
commands.spawn((EngineVoice, AudioPlayer(source), PlaybackSettings::ONCE
|
||||
.with_volume(Volume::Linear(0.)).with_speed(pitch)));
|
||||
state.pitch = pitch;
|
||||
}
|
||||
for (entity, mut sink) in &mut voices {
|
||||
sink.set_speed(state.pitch.max(0.25));
|
||||
sink.set_volume(Volume::Linear(state.volume));
|
||||
if car.is_none() && state.volume < 0.001 { commands.entity(entity).despawn(); }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
//! Original continuous exhaust synthesizer.
|
||||
use bevy::{audio::Source, prelude::*};
|
||||
use std::time::Duration;
|
||||
#[derive(Asset, TypePath)]
|
||||
pub struct Engine;
|
||||
pub struct Exhaust { phase: f32, noise: u32, filtered: f32 }
|
||||
impl Iterator for Exhaust {
|
||||
type Item = f32;
|
||||
fn next(&mut self) -> Option<f32> {
|
||||
self.phase = (self.phase + 80. / 48000.) % 1.;
|
||||
self.noise = self.noise.wrapping_mul(1664525).wrapping_add(1013904223);
|
||||
let noise = self.noise as f32 / u32::MAX as f32 * 2. - 1.;
|
||||
self.filtered += 0.12 * (noise - self.filtered);
|
||||
let p = self.phase * std::f32::consts::TAU;
|
||||
let pulse = p.sin() * 0.48 + (p * 2.).sin() * 0.24
|
||||
+ (p * 3.).sin() * 0.12 + (p * 5.).sin() * 0.06;
|
||||
Some((pulse + self.filtered * 0.18) * 0.65)
|
||||
}
|
||||
}
|
||||
impl Source for Exhaust {
|
||||
fn current_frame_len(&self) -> Option<usize> { None }
|
||||
fn channels(&self) -> u16 { 1 }
|
||||
fn sample_rate(&self) -> u32 { 48000 }
|
||||
fn total_duration(&self) -> Option<Duration> { None }
|
||||
}
|
||||
impl Decodable for Engine {
|
||||
type DecoderItem = f32;
|
||||
type Decoder = Exhaust;
|
||||
fn decoder(&self) -> Exhaust { Exhaust { phase: 0., noise: 17, filtered: 0. } }
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn exhaust_is_bounded_continuous_and_not_silent() {
|
||||
let mut source = Engine.decoder();
|
||||
let mut last = 0.;
|
||||
let mut energy = 0.;
|
||||
for _ in 0..48000 {
|
||||
let sample = source.next().unwrap();
|
||||
assert!(sample.is_finite() && sample.abs() < 0.8);
|
||||
assert!((sample - last).abs() < 0.1);
|
||||
energy += sample * sample;
|
||||
last = sample;
|
||||
}
|
||||
assert!(energy > 100.);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
//! Native vehicle host: isolated Rapier world, mod ownership and driver lifecycle.
|
||||
mod animations;
|
||||
mod audio;
|
||||
mod engine_sound;
|
||||
mod interpolation;
|
||||
use bevy::prelude::*;
|
||||
use serde_json::{Value, json};
|
||||
@@ -77,6 +79,7 @@ impl Vehicles {
|
||||
}
|
||||
}
|
||||
pub(super) fn install(app: &mut App) {
|
||||
audio::install(app);
|
||||
app.init_resource::<Vehicles>()
|
||||
.add_systems(
|
||||
FixedUpdate,
|
||||
|
||||
@@ -8,6 +8,12 @@ pub struct VehicleDefinition {
|
||||
pub model_offset: [f32; 3],
|
||||
pub model_yaw: f32,
|
||||
pub half_extents: [f32; 3],
|
||||
pub center_of_mass: [f32; 3],
|
||||
pub inertia_half_extents: Option<[f32; 3]>,
|
||||
pub collider_offset: [f32; 3],
|
||||
pub collider_rounding: f32,
|
||||
pub chassis_friction: f32,
|
||||
pub engine_audio: EngineAudio,
|
||||
pub mass: f32,
|
||||
pub engine_force: f32,
|
||||
pub brake_impulse: f32,
|
||||
@@ -24,6 +30,18 @@ pub struct VehicleDefinition {
|
||||
pub camera_height: f32,
|
||||
pub animations: Animations,
|
||||
}
|
||||
/// Built-in synthesized engine; no external recording is required.
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct EngineAudio {
|
||||
pub enabled: bool,
|
||||
pub volume: f32,
|
||||
pub idle_pitch: f32,
|
||||
pub max_pitch: f32,
|
||||
}
|
||||
impl Default for EngineAudio {
|
||||
fn default() -> Self { Self { enabled: false, volume: 0.45, idle_pitch: 0.7, max_pitch: 2.8 } }
|
||||
}
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WheelDefinition {
|
||||
@@ -56,6 +74,12 @@ impl Default for VehicleDefinition {
|
||||
model_offset: [0.; 3],
|
||||
model_yaw: 0.,
|
||||
half_extents: [0.7, 0.2, 1.1],
|
||||
center_of_mass: [0.; 3],
|
||||
inertia_half_extents: None,
|
||||
collider_offset: [0.; 3],
|
||||
collider_rounding: 0.,
|
||||
chassis_friction: 0.3,
|
||||
engine_audio: EngineAudio::default(),
|
||||
mass: 220.,
|
||||
engine_force: 1800.,
|
||||
brake_impulse: 100.,
|
||||
@@ -96,6 +120,14 @@ impl VehicleDefinition {
|
||||
|| !point(&self.model_offset, 100.)
|
||||
|| !self.model_yaw.is_finite()
|
||||
|| !self.half_extents.iter().all(|x| range(*x, 0.05, 10.))
|
||||
|| !point(&self.center_of_mass, 10.)
|
||||
|| self.inertia_half_extents.is_some_and(|v| !v.iter().all(|&x| range(x, 0.05, 10.)))
|
||||
|| !point(&self.collider_offset, 10.)
|
||||
|| !range(self.collider_rounding, 0., self.half_extents.iter().copied().fold(f32::INFINITY, f32::min) * 0.95)
|
||||
|| !range(self.chassis_friction, 0., 2.)
|
||||
|| !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.)
|
||||
|| !range(self.mass, 10., 10000.)
|
||||
|| !range(self.engine_force, 0., 100000.)
|
||||
|| !range(self.brake_impulse, 0., 10000.)
|
||||
@@ -156,6 +188,7 @@ impl VehicleDefinition {
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct VehicleTuning {
|
||||
pub engine_volume: Option<f32>,
|
||||
pub engine_force: Option<f32>,
|
||||
pub max_speed: Option<f32>,
|
||||
pub brake_impulse: Option<f32>,
|
||||
@@ -165,6 +198,7 @@ pub struct VehicleTuning {
|
||||
impl VehicleTuning {
|
||||
pub fn apply(&self, definition: &VehicleDefinition) -> Result<VehicleDefinition, String> {
|
||||
let mut d = definition.clone();
|
||||
if let Some(v) = self.engine_volume { d.engine_audio.volume = v; }
|
||||
if let Some(v) = self.engine_force {
|
||||
d.engine_force = v;
|
||||
}
|
||||
@@ -185,6 +219,7 @@ impl VehicleTuning {
|
||||
}
|
||||
pub fn valid(&self) -> bool {
|
||||
[
|
||||
self.engine_volume.map(|x| (x, 0., 1.)),
|
||||
self.engine_force.map(|x| (x, 0., 100000.)),
|
||||
self.max_speed.map(|x| (x, 1., 100.)),
|
||||
self.brake_impulse.map(|x| (x, 0., 10000.)),
|
||||
|
||||
@@ -75,6 +75,15 @@ impl Simulation {
|
||||
return Err("Invalid vehicle spawn pose".into());
|
||||
}
|
||||
let d = &definition;
|
||||
let r = d.collider_rounding;
|
||||
let shape = if r > 0. {
|
||||
ColliderBuilder::round_cuboid(d.half_extents[0]-r, d.half_extents[1]-r, d.half_extents[2]-r, r)
|
||||
} else {
|
||||
ColliderBuilder::cuboid(d.half_extents[0], d.half_extents[1], d.half_extents[2])
|
||||
};
|
||||
let [x, y, z] = d.inertia_half_extents.unwrap_or(d.half_extents);
|
||||
let inertia = Vector::new(y*y+z*z, x*x+z*z, x*x+y*y) * (d.mass / 3.);
|
||||
let mass_properties = MassProperties::new(Vector::from_array(d.center_of_mass) - Vector::from_array(d.collider_offset), d.mass, inertia);
|
||||
let (body, _) = self.world.insert(
|
||||
RigidBodyBuilder::dynamic()
|
||||
.translation(Vector::from_array(position))
|
||||
@@ -82,9 +91,9 @@ impl Simulation {
|
||||
.ccd_enabled(true)
|
||||
.linear_damping(0.08)
|
||||
.angular_damping(0.5),
|
||||
ColliderBuilder::cuboid(d.half_extents[0], d.half_extents[1], d.half_extents[2])
|
||||
.mass(d.mass)
|
||||
.friction(0.3),
|
||||
shape.translation(Vector::from_array(d.collider_offset))
|
||||
.mass_properties(mass_properties)
|
||||
.friction(d.chassis_friction),
|
||||
);
|
||||
let mut controller = DynamicRayCastVehicleController::new(body);
|
||||
controller.index_up_axis = 1;
|
||||
|
||||
@@ -47,7 +47,7 @@ fn kart_suspension_acceleration_braking_reset_and_cleanup() {
|
||||
for _ in 0..240 {
|
||||
s.step(1. / 120.);
|
||||
}
|
||||
assert!(s.vehicles[&id].controller.current_vehicle_speed.abs() < speed * 0.25);
|
||||
assert!(s.vehicles[&id].controller.current_vehicle_speed.abs() < speed * 0.25, "brake speed {} initial {speed}, pose {:?}", s.vehicles[&id].controller.current_vehicle_speed, s.pose(id));
|
||||
s.reset(id, [5., 2., 5.], 1.).unwrap();
|
||||
assert_eq!(s.pose(id).unwrap().0, [5., 2., 5.]);
|
||||
s.remove(id);
|
||||
@@ -144,3 +144,41 @@ fn steering_turns_toward_the_drivers_requested_side() {
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn kart_climbs_ramps_without_nose_catching() {
|
||||
for degrees in [20_f32, 30., 40.] {
|
||||
let slope = degrees.to_radians().tan();
|
||||
let mut s = Simulation::default();
|
||||
let y = 20. * slope;
|
||||
s.ground([
|
||||
[[-20.,0.,-30.],[20.,0.,3.],[20.,0.,-30.]],
|
||||
[[-20.,0.,-30.],[-20.,0.,3.],[20.,0.,3.]],
|
||||
[[-20.,0.,3.],[20.,y,23.],[20.,0.,3.]],
|
||||
[[-20.,0.,3.],[-20.,y,23.],[20.,y,23.]],
|
||||
].into_iter()).unwrap();
|
||||
let id = s.spawn(definition(), [0.,1.,-2.], 0.).unwrap();
|
||||
for _ in 0..240 { s.step(1./120.); }
|
||||
s.vehicles.get_mut(&id).unwrap().controls = Controls { throttle: 1., ..Default::default() };
|
||||
let mut highest = 0_f32;
|
||||
let mut forward = 0_f32;
|
||||
for _ in 0..960 {
|
||||
s.step(1./120.);
|
||||
let p=s.pose(id).unwrap().0;
|
||||
highest=highest.max(p[1]); forward=forward.max(p[2]);
|
||||
}
|
||||
println!("Ramp {degrees}: height={highest}, forward={forward}");
|
||||
assert!(highest > 2. && forward > 7., "failed {degrees} degree ramp: {highest}, {forward}");
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn collision_mass_and_audio_tuning_are_validated() {
|
||||
let base=definition();
|
||||
let mut d=base.clone(); d.collider_rounding=0.5; assert!(d.validate().is_err());
|
||||
let mut d=base.clone(); d.center_of_mass[1]=f32::NAN; assert!(d.validate().is_err());
|
||||
let mut d=base.clone(); d.inertia_half_extents=Some([0.,1.,1.]); assert!(d.validate().is_err());
|
||||
let mut d=base.clone(); d.engine_audio.max_pitch=0.1; assert!(d.validate().is_err());
|
||||
let mute=VehicleTuning { engine_volume:Some(0.), ..Default::default() };
|
||||
assert!(mute.valid()); assert_eq!(mute.apply(&base).unwrap().engine_audio.volume,0.);
|
||||
let invalid=VehicleTuning { engine_volume:Some(f32::NAN), ..Default::default() };
|
||||
assert!(!invalid.valid()); assert!(invalid.apply(&base).is_err());
|
||||
}
|
||||
Reference in new issue
Block a user