diff --git a/Build-VehicleSDK.ps1 b/Build-VehicleSDK.ps1 index f71943be..c694235d 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-vehicle-smooth.exe' +$destination = Join-Path $privateDirectory 'skate3-vehicle-ramp-audio.exe' Push-Location $PSScriptRoot $previousFlags = $env:CARGO_TARGET_X86_64_PC_WINDOWS_MSVC_RUSTFLAGS try { @@ -51,6 +51,7 @@ try { } New-Item -ItemType Directory -Force -Path (Join-Path $privateDirectory 'docs'), (Join-Path $privateDirectory 'sdk') | Out-Null Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'docs/vehicle-sdk.md') -Destination (Join-Path $privateDirectory 'docs/vehicle-sdk.md') -Force + Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'docs/mixamo-vehicle-workflow.md') -Destination (Join-Path $privateDirectory 'docs/mixamo-vehicle-workflow.md') -Force Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'sdk/skate.lua') -Destination (Join-Path $privateDirectory 'sdk/skate.lua') -Force $hash = (Get-FileHash -LiteralPath $destination -Algorithm SHA256).Hash if ($hash -ne (Get-FileHash -LiteralPath $artifact -Algorithm SHA256).Hash) { diff --git a/PLAY-MARIO-KART.bat b/PLAY-MARIO-KART.bat index 7b70b6fa..6aae6484 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-vehicle-smooth.exe" ( +if not exist "%~dp0.local\vehicle-sdk\skate3-vehicle-ramp-audio.exe" ( echo Build missing. Run Build-VehicleSDK.ps1 first. pause exit /b 1 @@ -20,5 +20,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: %~dp0.local\vehicle-sdk\mods echo Trainer log: %~dp0.local\vehicle-sdk\session.log -"%~dp0.local\vehicle-sdk\skate3-vehicle-smooth.exe" --assets "%SKATE3_ASSETS%" --start-paused %* > "%~dp0.local\vehicle-sdk\session.log" 2>&1 +"%~dp0.local\vehicle-sdk\skate3-vehicle-ramp-audio.exe" --assets "%SKATE3_ASSETS%" --start-paused %* > "%~dp0.local\vehicle-sdk\session.log" 2>&1 if errorlevel 1 pause diff --git a/crates/skate-data/examples/vehicle_bone_names.rs b/crates/skate-data/examples/vehicle_bone_names.rs new file mode 100644 index 00000000..bd9e5da6 --- /dev/null +++ b/crates/skate-data/examples/vehicle_bone_names.rs @@ -0,0 +1,10 @@ +//! Export the exact native order for an authored vehicle animation package. +fn main() -> Result<(), Box> { + 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(()) +} diff --git a/crates/skate-game/src/modding/vehicles/audio.rs b/crates/skate-game/src/modding/vehicles/audio.rs new file mode 100644 index 00000000..a9f461a2 --- /dev/null +++ b/crates/skate-game/src/modding/vehicles/audio.rs @@ -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>, pitch: f32, volume: f32 } + +pub(super) fn install(app: &mut App) { + app.add_audio_source::().init_resource::() + .add_systems(Update, update.after(super::present)); +} + +fn update( + mut commands: Commands, vehicles: Res, + time: Res>, menu: Option>, + replay: Res, mut state: ResMut, + mut sources: ResMut>, mut voices: Query<(Entity, &mut AudioSink), With>, + pending: Query>, +) { + 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(); } + } +} + diff --git a/crates/skate-game/src/modding/vehicles/engine_sound.rs b/crates/skate-game/src/modding/vehicles/engine_sound.rs new file mode 100644 index 00000000..57e24c50 --- /dev/null +++ b/crates/skate-game/src/modding/vehicles/engine_sound.rs @@ -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 { + 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 { None } + fn channels(&self) -> u16 { 1 } + fn sample_rate(&self) -> u32 { 48000 } + fn total_duration(&self) -> Option { 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.); + } +} diff --git a/crates/skate-game/src/modding/vehicles/mod.rs b/crates/skate-game/src/modding/vehicles/mod.rs index b560c4b8..33210470 100644 --- a/crates/skate-game/src/modding/vehicles/mod.rs +++ b/crates/skate-game/src/modding/vehicles/mod.rs @@ -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::() .add_systems( FixedUpdate, diff --git a/crates/skate-vehicles/src/definition.rs b/crates/skate-vehicles/src/definition.rs index c53c5ef4..8e8bede8 100644 --- a/crates/skate-vehicles/src/definition.rs +++ b/crates/skate-vehicles/src/definition.rs @@ -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, pub engine_force: Option, pub max_speed: Option, pub brake_impulse: Option, @@ -165,6 +198,7 @@ pub struct VehicleTuning { impl VehicleTuning { pub fn apply(&self, definition: &VehicleDefinition) -> Result { 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.)), diff --git a/crates/skate-vehicles/src/lib.rs b/crates/skate-vehicles/src/lib.rs index 0e0f6711..6912c3b7 100644 --- a/crates/skate-vehicles/src/lib.rs +++ b/crates/skate-vehicles/src/lib.rs @@ -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; diff --git a/crates/skate-vehicles/tests/simulation.rs b/crates/skate-vehicles/tests/simulation.rs index fe3e75e8..94619abf 100644 --- a/crates/skate-vehicles/tests/simulation.rs +++ b/crates/skate-vehicles/tests/simulation.rs @@ -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()); +} diff --git a/docs/mixamo-vehicle-workflow.md b/docs/mixamo-vehicle-workflow.md new file mode 100644 index 00000000..703b8be0 --- /dev/null +++ b/docs/mixamo-vehicle-workflow.md @@ -0,0 +1,122 @@ +# Mixamo to native vehicle animations + +This is the workflow used for the working Mario Kart rider. The game plays a native +Skate skeleton pose on the currently selected compatible character. It does not swap +the rider to the mesh bundled in the Mixamo download. Other characters must first be +converted to the supported native render rig; arbitrary skeletons are not interchangeable. +Different proportions may still need contact adjustments to reach a wheel or pedals. + +## 1. Keep a matched calibration pair + +Use your owned stock `assets/private/skater.glb` as the native reference. Upload the +same stock character mesh to Mixamo, rig it there, and retain its returned rest/T-pose +with skin and skeleton. Convert that returned reference FBX to GLB without altering +its bind pose (Blender import FBX, export glTF/GLB with skin). This paired stock return +is the calibration source, not a Mario mesh or a posed entry/exit frame. + +The converter's `--calibrate` path accepts a GLB. It verifies mesh bounds and records +the reference hash, source hash, scale and Mixamo bind transforms. Example: + +```powershell +python tools/mixamo_to_skate/main.py returned-stock.glb --reference assets/private/skater.glb --profile calibration.json --calibrate --noninteractive +``` + +Keep calibration.json with that exact stock reference. For this local kart, the matched +profile is `C:/Users/Daddy/Documents/Mixamo-to-Skate/calibration.json`. Do not replace it +with a profile built from another character. Calibration transfers the change from a +Mixamo bind pose into the original native bind basis; copying raw rotations ignores +bone axes and is what produced the forward shoulders in the earlier attempt. + +## 2. Download the animations from the same Mixamo character + +Select Entering Car and Exiting Car on that same uploaded stock character. Download FBX +**with skin** for this pipeline, retaining its skeleton. The skin is useful for verifying +the reference and is not used as the in-game rider. Use consistent frame rate and no +keyframe reduction. Preserve root motion; these clips need to move between standing +and seated positions. Keep the original downloads untouched. + +The accepted local inputs were `Entering Car (2).fbx` and `Exiting Car (2).fbx` in Downloads. +The earlier `(1)` files used another character and are not the calibrated source. + +## 3. Verify the raw transfer before fitting a vehicle + +Run Blender in background mode with the stock GLB and matched profile. Use fresh output +directories: the tools refuse to overwrite existing preview folders. + +```powershell +& $blender -b --python tools/preview_mixamo_animation.py -- --source 'Entering Car (2).fbx' --reference assets/private/skater.glb --profile calibration.json --output .local/raw-enter +& $blender -b --python tools/preview_mixamo_animation.py -- --source 'Exiting Car (2).fbx' --reference assets/private/skater.glb --profile calibration.json --output .local/raw-exit +``` + +`$blender` is the path to your Blender executable. Open each `Raw-Skate-Animation.blend`. +Check the first, middle and final frames, especially shoulders, wrists, hips and feet. +The tool checks the calibration hash and source bind compatibility. Do not bypass a +mismatch by forcing rotation offsets into the native rest skeleton. Native rest bones +and skin inverse-bind matrices must remain unchanged. Fix the calibration/source first. + +## 4. Fit the approved poses to the kart + +```powershell +& $blender -b --python tools/fit_kart_animation_preview.py -- --raw .local/raw-enter/Raw-Skate-Animation.blend --kart mods/mario-kart/kart.glb --mode enter --output .local/fit-enter +& $blender -b --python tools/fit_kart_animation_preview.py -- --raw .local/raw-exit/Raw-Skate-Animation.blend --kart mods/mario-kart/kart.glb --mode exit --output .local/fit-exit +``` + +This example script aligns the seated hips, eases torso lean and solves arms/legs to +wheel and footwell targets. It bakes a separate fitted action, retains the raw action, +checks unchanged rest bones and writes a fit report. Review both .blend files before +export. Feet need clearance over the side pod, hands need a reachable wheel position, +and the last entry pose should match the first exit pose. The seated pose is also +used for the driving loop. Fingers are limited by the native character's hand rig. + +Targets in this script use Blender Z-up: seat `(0, .20, .73)`, wheel grips +`(+/-.19, -.36, .90)`, feet `(+/-.17, -.55, .33)`; the preview kart is raised `.55`. +These are **kart example values**. For another vehicle adjust alignment, contact targets, +clearance and torso lean in the authoring script or bake equivalent edits in Blender. +Do not change rest bones, and keep the fitted armature object transform identity. + +## 5. Export in the game's exact skeleton order + +Obtain the order from the installed native bank (or `sdk.animation.info().bone_names`). +Do not use the larger reference list in the vendor importer: this build's bank has 36 +bones, including reparented helpers. Generate the JSON list with the provided utility: + +```powershell +cargo run --locked -p skate-data --example vehicle_bone_names -- assets/private/stock/data/anim/OnBoard.abin .local/bone-names.json +& $blender -b --python tools/export_kart_rider.py -- --enter .local/fit-enter/Kart-enter.blend --exit .local/fit-exit/Kart-exit.blend --reference assets/private/skater.glb --bone-names .local/bone-names.json --output mods/mario-kart/rider.json +``` + +The exporter preserves the glTF skin bind basis while converting Blender poses into +column-major native matrices. It subtracts the preview seat, exports enter/exit clips, +uses the final entry pose as drive, and generates left/right steering endpoints with +length-preserving arm reach. It renders both endpoints for inspection. The host blends +between them continuously with steering input. For another cockpit, change the exporter +seat, steering-wheel centre/axis and steering angle alongside the fitted scene targets. + +The example's game seat is `[0, .18, -.20]`: convert the Blender seat to Y-up and subtract +the preview kart's .55 m lift. Model fitting offsets are not an excuse to alter native +bone axes. Export fps comes from each scene; retain its timing instead of relabelling +frames with a different rate. The output is a full native pose, not additive rotations. + +## 6. Package, validate and playtest + +Put `rider.json`, the embedded `kart.glb`, `vehicle.json`, `mod.json` and `main.lua` in the +executable-adjacent mod folder. Set animations.file to rider.json, enter/exit to their +names, idle/drive/reverse/brake to drive for this example, and steer_left/steer_right to +the corresponding endpoints. See vehicle-sdk.md for limits, schema and lifecycle. + +The host validates names, frame counts, finite rigid transforms and clip references at +load. The source loader tests cover malformed inputs; local validation also checked the +export against the actual stock bank and matched the final entry pose to drive. The +runtime applies the same native pose to whichever compatible character is selected. + +`Build-VehicleSDK.ps1` stages a build and documentation. It preserves differing local +mod files; copy your approved changed files into the playable mod directory explicitly +when it reports them. Reload/disable-enable the mod to load changed animation files. +Use PLAY-MARIO-KART.bat for a manual session. Check entry/exit from multiple vanilla +stances, steering in both directions, ramps, braking, reset, Escape, and mod disable. +Also try characters with different proportions; native-rig compatibility does not +promise exact contact for every body size. Gameplay is never launched by these tools. + +Source code and authoring scripts are reusable. Proprietary FBX, GLB, .blend and exported +rider data remain local and ignored by Git. Distribute only assets you have permission +to redistribute; the SDK source itself does not include the stock mesh or these clips. diff --git a/docs/vehicle-sdk.md b/docs/vehicle-sdk.md index 868e0914..016f6406 100644 --- a/docs/vehicle-sdk.md +++ b/docs/vehicle-sdk.md @@ -65,7 +65,7 @@ Do not pass the full input table to `control`; copy only its four control fields and a parking brake is applied. Controls are ignored during enter/exit animations. `tune` supports optional engine_force (0..100000 N), max_speed (1..100 m/s), -brake_impulse (0..10000), steering_angle (0.01..1.2 radians), tire_grip (0.1..20). +engine_volume (0..1), brake_impulse (0..10000), steering_angle (0.01..1.2 radians), tire_grip (0.1..20). Maximum speed limits engine application; it is not an absolute downhill speed cap. Events passed to `on_event`: `vehicle_spawned`, `vehicle_entering`, `vehicle_entered`, @@ -77,8 +77,8 @@ mod errors; they retire the mod's vehicles instead of crashing the game. ## Vehicle definition See `mods/mario-kart/vehicle.json` for a complete working definition. Coordinates -are metres, +Y up, +Z forward, +X right, with heading in radians around +Y. -`half_extents` describes the chassis cuboid. `mass` is kilograms. `model_scale`, +are metres, +Y up, +Z forward, +X driver-left, with heading in radians around +Y. +`half_extents` describes the outer chassis collision bounds. `mass` is kilograms. `model_scale`, `model_offset` and `model_yaw` affect the model only, not its collider. Each wheel defines a chassis-local suspension mounting `position`, radius, @@ -96,7 +96,7 @@ A package may total 64 MiB; a model is limited to 32 MiB and an animation file t ## Rider animation support -No animations are supplied. All animation slots default to null. Without a matching +The local kart package supplies fitted clips. Other definitions default their animation slots to null. Without a matching clip, the skater is hidden and entering/exiting completes immediately. This avoids showing an unrelated standing/skating pose on the kart. @@ -143,9 +143,10 @@ when exiting. Files with a mismatched skeleton, invalid matrices or missing name clips are rejected. Maximum 32 clips, 3600 frames per clip, 1..120 fps. Enter/exit clips play once and their frame count/fps determines transition duration. -Driving slots loop; brake, reverse and steering states select their respective clips, -with drive as fallback. Missing optional driving slots use the drive clip when -available. Slot switching currently cuts between clips; cross-fades are not included. +Driving slots loop; brake and reverse select their respective base clips, with drive +as fallback. Steering continuously blends that base towards the left/right pose. +Vehicle phase changes blend with native bone-local interpolation; brake/reverse +base-clip changes currently cut, so author compatible seated poses. Edit vehicle.json or rider.json and reload the mod to install new clips. ## Current boundaries @@ -156,7 +157,7 @@ and session-marker controls are blocked while driving. Vehicle motion is not recorded in skating replays. Native skater-versus-vehicle impact forces are not bridged yet: vehicles collide with the map and one another, but parked vehicles are not native skating obstacles. This API does not include weapons, damage, -network synchronization, engine audio or a racing ruleset. +network synchronization or a racing ruleset. Verification uses headless Rapier tests and window-free Lua tests. The game is not launched automatically; rendering, entry/exit and handling need manual playtesting. @@ -172,3 +173,49 @@ jitter from separate camera damping. Resets discard the old motion sample. The board root is scaled away during vehicle playback and restored by the vanilla blend. The local Mario Kart package supplies fitted entry/exit, seated and steering clips; its proprietary rider.json is generated locally and is not included in source control. + +## Ramp clearance and mass distribution +These are shared definition fields, available to any mod, not Mario-specific code: + +| Field | Meaning | Default | +| --- | --- | --- | +| `collider_offset` | Chassis-local collision centre, metres | `[0,0,0]` | +| `collider_rounding` | Rounded edge radius; below 95% of smallest half extent | `0` | +| `chassis_friction` | Body contact friction, 0..2 | `0.3` | +| `center_of_mass` | Chassis-local mass centre, metres | `[0,0,0]` | +| `inertia_half_extents` | Box dimensions used for mass distribution, independent of contact shape | null: use half_extents | + +Rounding stays inside half_extents. Shorten low front/rear overhangs so wheel contact +can lift the chassis before the body catches the ramp. Keep the collider large enough +to protect the cockpit; the visual model does not define collision. Inertia dimensions +let a shorter collision shape retain stable pitching/rolling behaviour. A lower centre +of mass helps resist nose-diving under braking. These fields require respawning. +The example passes headless 20/30/40-degree incline tests plus braking and steering tests; +this does not guarantee every map seam or vertical ledge is traversable. Wheels remain +raycasts, and vertical walls remain obstacles. No teleporting or artificial ramp boost is used. + +## Engine sound +Opt in per definition: + +```json +"engine_audio": { "enabled": true, "volume": 0.45, "idle_pitch": 0.7, "max_pitch": 2.8 } +``` + +The host synthesizes an original layered exhaust pulse with filtered noise. No downloaded +sound asset or extra file is required. It plays for the occupied vehicle; parked engines +are silent. Pitch follows speed and absolute throttle, including reverse and free revving +while stopped. Full throttle increases volume; releasing it smoothly drops the engine back +towards idle. Pitch/volume changes are smoothed, Escape/replay fades it silent, and exiting +or disabling the mod fades/removes the voice. This is a driver-focused mono sound, not a +spatial multi-car mixer or a simulated gearbox. There is currently no custom sample slot. + +Volume is 0..1, idle_pitch 0.25..2, max_pitch idle_pitch..5 (multipliers of the 80 Hz +synth fundamental). All are validated. `sdk.vehicle.tune(key,{engine_volume=0.5})` +changes volume live; use zero to mute. Mario Kart exposes this in its mod settings. + +## Authoring and reuse +See [Mixamo vehicle animation workflow](mixamo-vehicle-workflow.md) for the full sequence, +calibration, Blender fitting, native export and packaging commands. The host's animation +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. diff --git a/mods/mario-kart/README.md b/mods/mario-kart/README.md index 13e5f6e9..4baecb39 100644 --- a/mods/mario-kart/README.md +++ b/mods/mario-kart/README.md @@ -21,3 +21,8 @@ Rebuild rider.json with Blender and tools/export_kart_rider.py, passing --enter --reference the stock skater.glb and --output the local rider.json path. Character/model assets and exported animation data stay out of Git. See docs/vehicle-sdk.md for the animation format and lifecycle details. + +Ramp tuning uses a shorter rounded chassis and a lower centre of mass, with mass +distribution independent of the collider. The shared engine_audio definition enables +throttle/speed-responsive synthesized engine sound; Engine volume in the mod window +controls it live (zero mutes). Full authoring guide: docs/mixamo-vehicle-workflow.md. diff --git a/mods/mario-kart/main.lua b/mods/mario-kart/main.lua index 3c15b243..d273392b 100644 --- a/mods/mario-kart/main.lua +++ b/mods/mario-kart/main.lua @@ -21,7 +21,7 @@ local function pressed(key) end local function tune() local s=sdk.settings - sdk.vehicle.tune('kart',{engine_force=s.engine,max_speed=s.speed,brake_impulse=s.brake,steering_angle=s.steering,tire_grip=s.grip}) + sdk.vehicle.tune('kart',{engine_volume=s.engine_volume,engine_force=s.engine,max_speed=s.speed,brake_impulse=s.brake,steering_angle=s.steering,tire_grip=s.grip}) end local function nearby() local p=sdk.player.read();local h=p.heading or 0 diff --git a/mods/mario-kart/mod.json b/mods/mario-kart/mod.json index 47e96406..ba2118a0 100644 --- a/mods/mario-kart/mod.json +++ b/mods/mario-kart/mod.json @@ -2,7 +2,7 @@ "id": "community.mario-kart", "api": 1, "name": "Mario Kart", - "version": "1.1.0", + "version": "1.2.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", @@ -67,6 +67,15 @@ "Right stick", "Left stick" ] + }, + "engine_volume": { + "type": "number", + "label": "Engine volume", + "default": 0.45, + "min": 0, + "max": 1, + "step": 0.05, + "description": "Engine loudness; zero mutes the motor." } } } diff --git a/mods/mario-kart/vehicle.json b/mods/mario-kart/vehicle.json index c9275b41..b4123bfd 100644 --- a/mods/mario-kart/vehicle.json +++ b/mods/mario-kart/vehicle.json @@ -2,9 +2,9 @@ "version": 1, "model": "kart.glb", "half_extents": [ - 0.67, - 0.18, - 1.05 + 0.5, + 0.14, + 0.65 ], "mass": 220, "engine_force": 1800, @@ -83,5 +83,28 @@ "brake": "drive", "steer_left": "steer_left", "steer_right": "steer_right" - } + }, + "collider_offset": [ + 0, + 0, + 0 + ], + "collider_rounding": 0.1, + "chassis_friction": 0.05, + "engine_audio": { + "enabled": true, + "volume": 0.45, + "idle_pitch": 0.7, + "max_pitch": 2.8 + }, + "center_of_mass": [ + 0, + -0.2, + 0 + ], + "inertia_half_extents": [ + 0.7, + 0.2, + 1.05 + ] } diff --git a/sdk/skate.lua b/sdk/skate.lua index 6bb2632b..66e44041 100644 --- a/sdk/skate.lua +++ b/sdk/skate.lua @@ -116,6 +116,7 @@ function sdk.trainer.apply(tuning) end ---@field brake? number 0..1 ---@field handbrake? boolean ---@class VehicleTuning +---@field engine_volume? number 0..1; requires engine_audio.enabled in definition ---@field engine_force? number 0..100000 N ---@field max_speed? number 1..100 m/s, engine limit ---@field brake_impulse? number 0..10000