From 61fc9f4418ea9e1f466329e1df69c33b532c434d Mon Sep 17 00:00:00 2001 From: chazmm Date: Tue, 8 Sep 2026 20:14:54 +0100 Subject: [PATCH] Add Rapier vehicle SDK and configurable Mario Kart mod --- Build-VehicleSDK.ps1 | 67 ++ Cargo.lock | 300 +++++++- Cargo.toml | 2 +- PLAY-MARIO-KART.bat | 24 + crates/skate-game/Cargo.toml | 2 + crates/skate-game/src/animation.rs | 12 + crates/skate-game/src/app.rs | 2 + crates/skate-game/src/camera.rs | 5 +- crates/skate-game/src/modding/mod.rs | 30 +- .../src/modding/vehicles/animations.rs | 156 +++++ crates/skate-game/src/modding/vehicles/mod.rs | 646 ++++++++++++++++++ crates/skate-game/src/physics.rs | 3 +- crates/skate-game/src/replay.rs | 3 +- crates/skate-game/src/session_marker/mod.rs | 2 + crates/skate-mods/Cargo.toml | 2 + crates/skate-mods/src/api.lua | 15 + crates/skate-mods/src/lib.rs | 4 +- crates/skate-mods/src/vm.rs | 11 + crates/skate-mods/tests/runtime.rs | 23 +- crates/skate-vehicles/Cargo.toml | 9 + crates/skate-vehicles/src/definition.rs | 198 ++++++ crates/skate-vehicles/src/lib.rs | 212 ++++++ crates/skate-vehicles/tests/simulation.rs | 136 ++++ docs/lua-modding.md | 2 + docs/vehicle-sdk.md | 162 +++++ mods/mario-kart/README.md | 16 + mods/mario-kart/main.lua | 42 ++ mods/mario-kart/mod.json | 62 ++ mods/mario-kart/vehicle.json | 87 +++ sdk/skate.lua | 47 ++ tools/prepare_mario_kart.py | 42 ++ 31 files changed, 2302 insertions(+), 22 deletions(-) create mode 100644 Build-VehicleSDK.ps1 create mode 100644 PLAY-MARIO-KART.bat create mode 100644 crates/skate-game/src/modding/vehicles/animations.rs create mode 100644 crates/skate-game/src/modding/vehicles/mod.rs create mode 100644 crates/skate-vehicles/Cargo.toml create mode 100644 crates/skate-vehicles/src/definition.rs create mode 100644 crates/skate-vehicles/src/lib.rs create mode 100644 crates/skate-vehicles/tests/simulation.rs create mode 100644 docs/vehicle-sdk.md create mode 100644 mods/mario-kart/README.md create mode 100644 mods/mario-kart/main.lua create mode 100644 mods/mario-kart/mod.json create mode 100644 mods/mario-kart/vehicle.json create mode 100644 tools/prepare_mario_kart.py diff --git a/Build-VehicleSDK.ps1 b/Build-VehicleSDK.ps1 new file mode 100644 index 00000000..253fe433 --- /dev/null +++ b/Build-VehicleSDK.ps1 @@ -0,0 +1,67 @@ +param([switch]$StageOnly, [string]$DependencyTargetDirectory = (Join-Path $PSScriptRoot 'target')) +$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-sdk.exe' +Push-Location $PSScriptRoot +$previousFlags = $env:CARGO_TARGET_X86_64_PC_WINDOWS_MSVC_RUSTFLAGS +try { + New-Item -ItemType Directory -Force -Path $buildDirectory | Out-Null + if (-not $StageOnly) { + $env:CARGO_TARGET_X86_64_PC_WINDOWS_MSVC_RUSTFLAGS = '-C target-feature=+crt-static' + # Dependencies may use a shared cache. The linker writes this task's + # artifact directly, never staging the cache's generic skate3rust.exe. + # Shared target fingerprints can refer to another worktree's local crates. + # Refresh only our local source timestamps; never clean the shared cache. + foreach ($crate in @('skate-core', 'skate-data', 'skate-mods', 'skate-vehicles')) { + (Get-Item -LiteralPath (Join-Path $PSScriptRoot "crates/$crate/src/lib.rs")).LastWriteTime = Get-Date + } + & cargo rustc --release --locked --target x86_64-pc-windows-msvc ` + --target-dir $DependencyTargetDirectory -p skate-game --no-default-features ` + --bin skate3rust -- -C extra-filename=-vehicle-sdk ` + -o (Join-Path $buildDirectory 'skate3rust.exe') + if ($LASTEXITCODE -ne 0) { throw 'Lua SDK build failed.' } + } + $bytes = [System.IO.File]::ReadAllBytes($artifact) + $text = [System.Text.Encoding]::ASCII.GetString($bytes) + foreach ($required in @('--start-paused', 'Wheel or drag scrollbar', 'Drag // to resize', 'hold_fakie', 'Vehicle key already spawned', 'Native trainer controls are already owned by another mod')) { + if (-not $text.Contains($required)) { throw "Wrong vehicle-sdk artifact: missing $required" } + } + Copy-Item -LiteralPath $artifact -Destination $destination -Force + # Only original SDK example sources are staged. Never copy extracted assets. + $modDestination = Join-Path $privateDirectory 'mods/native-trainer' + New-Item -ItemType Directory -Force -Path $modDestination | Out-Null + foreach ($name in @('mod.json','main.lua','help.txt','README.md')) { + $source = Join-Path $PSScriptRoot "mods/native-trainer/$name" + $target = Join-Path $modDestination $name + if (-not (Test-Path -LiteralPath $target)) { Copy-Item -LiteralPath $source -Destination $target } + elseif ((Get-FileHash -LiteralPath $source).Hash -ne (Get-FileHash -LiteralPath $target).Hash) { + Write-Warning "Preserving existing mod file: $target. Copy the updated source there when ready." + } + } + Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'mods/README.md') -Destination (Join-Path $privateDirectory 'mods/README.md') -Force + $kartDestination = Join-Path $privateDirectory 'mods/mario-kart' + New-Item -ItemType Directory -Force -Path $kartDestination | Out-Null + foreach ($name in @('mod.json','main.lua','vehicle.json','README.md','kart.glb')) { + $source = Join-Path $PSScriptRoot "mods/mario-kart/$name" + $target = Join-Path $kartDestination $name + if (-not (Test-Path -LiteralPath $target)) { Copy-Item -LiteralPath $source -Destination $target } + elseif ((Get-FileHash -LiteralPath $source).Hash -ne (Get-FileHash -LiteralPath $target).Hash) { Write-Warning "Preserving $target" } + } + 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 '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) { + throw 'Staged executable hash mismatch.' + } + @{ executable = $destination; sha256 = $hash; source = $PSScriptRoot; + revision = (& git rev-parse HEAD); staticChecks = 'CLI, Mods UI, Lua quota diagnostics' } | + ConvertTo-Json | Set-Content -LiteralPath (Join-Path $privateDirectory 'build-manifest.json') -Encoding UTF8 + Write-Host "Ready for manual launch: $destination" + Write-Host "SHA256: $hash" +} finally { + $env:CARGO_TARGET_X86_64_PC_WINDOWS_MSVC_RUSTFLAGS = $previousFlags + Pop-Location +} diff --git a/Cargo.lock b/Cargo.lock index 58fa0cf8..d9910310 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -103,6 +103,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "alsa" version = "0.9.1" @@ -1029,7 +1035,7 @@ dependencies = [ "arrayvec", "bevy_reflect", "derive_more", - "glam", + "glam 0.30.10", "itertools 0.14.0", "libm", "rand", @@ -1201,7 +1207,7 @@ dependencies = [ "downcast-rs 2.0.2", "erased-serde", "foldhash 0.2.0", - "glam", + "glam 0.30.10", "indexmap", "inventory", "petgraph", @@ -1261,7 +1267,7 @@ dependencies = [ "downcast-rs 2.0.2", "encase", "fixedbitset", - "glam", + "glam 0.30.10", "image", "indexmap", "js-sys", @@ -1428,7 +1434,7 @@ dependencies = [ "crossbeam-queue", "derive_more", "futures-lite", - "heapless", + "heapless 0.9.3", "pin-project", ] @@ -2264,6 +2270,15 @@ version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" +[[package]] +name = "ena" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabffdaee24bd1bf95c5ef7cec31260444317e72ea56c4c91750e8b7ee58d5f1" +dependencies = [ + "log", +] + [[package]] name = "encase" version = "0.12.1" @@ -2630,6 +2645,41 @@ dependencies = [ "serde_core", ] +[[package]] +name = "glam" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556f6b2ea90b8d15a74e0e7bb41671c9bdf38cd9f78c284d750b9ce58a2b5be7" + +[[package]] +name = "glam" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f70749695b063ecbf6b62949ccccde2e733ec3ecbbd71d467dca4e5c6c97cca0" + +[[package]] +name = "glam" +version = "0.33.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e762b9b188634fc508d4ec54b62ea3d352c43fd431d18d05d46f559f217f4725" +dependencies = [ + "approx", + "libm", +] + +[[package]] +name = "glamx" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9847f980afb0628849bcd6a406cdffd29eb9d53fca83e1646005901a9acd4c5c" +dependencies = [ + "approx", + "glam 0.33.7", + "nalgebra", + "num-traits", + "simba", +] + [[package]] name = "glob" version = "0.3.4" @@ -2809,6 +2859,7 @@ version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ + "allocator-api2", "equivalent", "foldhash 0.2.0", "serde", @@ -2820,6 +2871,19 @@ name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash 0.2.0", +] + +[[package]] +name = "heapless" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" +dependencies = [ + "hash32", + "stable_deref_trait", +] [[package]] name = "heapless" @@ -2845,7 +2909,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29a164ceff4500f2a72b1d21beaa8aa8ad83aec2b641844c659b190cb3ea2e0b" dependencies = [ "constgebra", - "glam", + "glam 0.30.10", "tinyvec", ] @@ -3206,6 +3270,16 @@ dependencies = [ "regex-automata", ] +[[package]] +name = "matrixmultiply" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f607c237553f086e7043417a51df26b2eb899d3caff94e6a67592ff992fedc7" +dependencies = [ + "autocfg", + "rawpointer", +] + [[package]] name = "memchr" version = "2.8.3" @@ -3348,6 +3422,37 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "nalgebra" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adc43a60c217b0c6ff46e47f26911015ad8d2e5a8be1af668c67e370d99a4346" +dependencies = [ + "approx", + "glam 0.30.10", + "glam 0.31.1", + "glam 0.32.1", + "glam 0.33.7", + "matrixmultiply", + "nalgebra-macros", + "num-complex", + "num-rational", + "num-traits", + "simba", + "typenum", +] + +[[package]] +name = "nalgebra-macros" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "973e7178a678cfd059ccec50887658d482ce16b0aa9da3888ddeab5cd5eb4889" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "ndk" version = "0.8.0" @@ -3447,6 +3552,25 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + [[package]] name = "num-derive" version = "0.4.2" @@ -3458,6 +3582,26 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -3845,6 +3989,34 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "parry3d" +version = "0.30.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01b00bf3ea4e0961a3f44aeb666b7dc3b0e87027b629561bd8283a7f6b2e2ed1" +dependencies = [ + "approx", + "arrayvec", + "bitflags 2.13.1", + "downcast-rs 2.0.2", + "either", + "ena", + "foldhash 0.2.0", + "glamx", + "hashbrown 0.17.1", + "log", + "num-derive", + "num-traits", + "ordered-float 5.5.0", + "rstar", + "simba", + "slab", + "smallvec", + "spade", + "static_assertions", + "thiserror 2.0.20", +] + [[package]] name = "paste" version = "1.0.15" @@ -4008,6 +4180,19 @@ name = "profiling" version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" +dependencies = [ + "profiling-procmacros", +] + +[[package]] +name = "profiling-procmacros" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4488a4a36b9a4ba6b9334a32a39971f77c1436ec82c38707bce707699cc3bbcb" +dependencies = [ + "quote", + "syn 2.0.119", +] [[package]] name = "pxfm" @@ -4102,12 +4287,40 @@ version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a611d15b50743feb4c76b7d03edcb0e64f399c26961e4efe6975bc398be6aa3d" +[[package]] +name = "rapier3d" +version = "0.35.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b14829af0960a134e2c39605bd6617a5c70e9837ce1b7c3c738efedc6fd8610" +dependencies = [ + "approx", + "arrayvec", + "bitflags 2.13.1", + "glamx", + "log", + "nalgebra", + "num-traits", + "parry3d", + "profiling", + "serde", + "simba", + "static_assertions", + "thiserror 2.0.20", + "wide", +] + [[package]] name = "raw-window-handle" version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" +[[package]] +name = "rawpointer" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" + [[package]] name = "read-fonts" version = "0.36.0" @@ -4198,6 +4411,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" +[[package]] +name = "robust" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e27ee8bb91ca0adcf0ecb116293afa12d393f9c2b9b9cd54d33e8078fe19839" + [[package]] name = "rodio" version = "0.20.1" @@ -4228,6 +4447,17 @@ version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" +[[package]] +name = "rstar" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5912b862fa5ffb462607bfd1e35036c458c537921f508c8235a83d5f3987edfe" +dependencies = [ + "heapless 0.8.0", + "num-traits", + "smallvec", +] + [[package]] name = "rustc-hash" version = "1.1.0" @@ -4290,6 +4520,15 @@ dependencies = [ "twox-hash", ] +[[package]] +name = "safe_arch" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42c6efa15875e6ecb39ca61fb0b0c1a40b84fac5a5ffe71eef7d1000c8eb3f5f" +dependencies = [ + "bytemuck", +] + [[package]] name = "same-file" version = "1.0.6" @@ -4416,6 +4655,18 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "simba" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a7200d82ff1c7b4235efd12f11e2537a402c91cf83a5cb97ce80eef787fde7" +dependencies = [ + "approx", + "num-complex", + "num-traits", + "wide", +] + [[package]] name = "simd-adler32" version = "0.3.10" @@ -4464,6 +4715,7 @@ dependencies = [ "skate-core", "skate-data", "skate-mods", + "skate-vehicles", ] [[package]] @@ -4473,6 +4725,16 @@ dependencies = [ "mlua", "serde", "serde_json", + "skate-vehicles", +] + +[[package]] +name = "skate-vehicles" +version = "0.1.0" +dependencies = [ + "rapier3d", + "serde", + "serde_json", ] [[package]] @@ -4550,6 +4812,18 @@ dependencies = [ "serde", ] +[[package]] +name = "spade" +version = "2.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9699399fd9349b00b184f5635b074f9ec93afffef30c853f8c875b32c0f8c7fa" +dependencies = [ + "hashbrown 0.16.1", + "num-traits", + "robust", + "smallvec", +] + [[package]] name = "spin" version = "0.10.1" @@ -4923,6 +5197,12 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + [[package]] name = "typewit" version = "1.15.2" @@ -5373,6 +5653,16 @@ dependencies = [ "libc", ] +[[package]] +name = "wide" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cf05ca94c9fba0c51316899caa1d1e74a064fc310a5efa7375e614343d415be" +dependencies = [ + "bytemuck", + "safe_arch", +] + [[package]] name = "winapi" version = "0.3.9" diff --git a/Cargo.toml b/Cargo.toml index 0b474ee6..ae47e069 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["crates/skate-core", "crates/skate-data", "crates/skate-game", "crates/skate-mods"] +members = ["crates/skate-core", "crates/skate-data", "crates/skate-game", "crates/skate-mods", "crates/skate-vehicles"] resolver = "3" [workspace.package] diff --git a/PLAY-MARIO-KART.bat b/PLAY-MARIO-KART.bat new file mode 100644 index 00000000..79b17a3d --- /dev/null +++ b/PLAY-MARIO-KART.bat @@ -0,0 +1,24 @@ +@echo off +setlocal +cd /d "%~dp0" +if not defined SKATE3_ASSETS ( + if exist "%~dp0.local\vehicle-sdk\assets.path" set /p "SKATE3_ASSETS="<"%~dp0.local\vehicle-sdk\assets.path" +) +if not defined SKATE3_ASSETS ( + echo Set SKATE3_ASSETS to your prepared assets directory. + pause + exit /b 1 +) +if not exist "%~dp0.local\vehicle-sdk\skate3-vehicle-sdk.exe" ( + echo Build missing. Run Build-VehicleSDK.ps1 first. + pause + exit /b 1 +) +set "SKATE3_MOD_SETTINGS=%~dp0.local\vehicle-sdk\settings" +if exist "%~dp0.local\vehicle-sdk\overlay.path" set /p "SKATE3_SESSION_MARKER_OVERLAY="<"%~dp0.local\vehicle-sdk\overlay.path" +echo Open Mods and ENABLE Mario Kart. F10 spawns the kart; E enters/exits. Escape shows draggable settings windows. Drag the title to move, drag // to resize, or scroll for more settings. +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-sdk.exe" --assets "%SKATE3_ASSETS%" --start-paused %* > "%~dp0.local\vehicle-sdk\session.log" 2>&1 +if errorlevel 1 pause diff --git a/crates/skate-game/Cargo.toml b/crates/skate-game/Cargo.toml index 6358b763..9349ed10 100644 --- a/crates/skate-game/Cargo.toml +++ b/crates/skate-game/Cargo.toml @@ -20,3 +20,5 @@ half = "=2.7.1" serde_json.workspace = true serde.workspace = true skate-mods = { path = "../skate-mods" } + +skate-vehicles = { path = "../skate-vehicles" } diff --git a/crates/skate-game/src/animation.rs b/crates/skate-game/src/animation.rs index 40e3b4d7..62a2fbb5 100644 --- a/crates/skate-game/src/animation.rs +++ b/crates/skate-game/src/animation.rs @@ -146,3 +146,15 @@ pub(crate) fn native_matrix(matrix: NativeMatrix) -> Mat4 { Vec3::from_array(matrix[3][..3].try_into().unwrap()).extend(1.0), ) } + +/// Vehicle clips use the same native model-space skeleton matrices as the SDK. +pub(crate) fn vehicle_pose(world:&mut World, pose:&[Mat4]) { + world.resource_scope(|world,animation:Mut| { + let basis=render_basis(); + for binding in &animation.bindings { + let Some(global)=pose.get(binding.bone).map(|m|*m*basis) else {continue;}; + let local=if let Some(parent)=binding.parent_bone {let Some(parent)=pose.get(parent) else {continue;};(*parent*basis).inverse()*global} else {global}; + if let Some(mut transform)=world.get_mut::(binding.entity) {*transform=Transform::from_matrix(local);} + } + }); +} diff --git a/crates/skate-game/src/app.rs b/crates/skate-game/src/app.rs index ccd12db7..78ef17f7 100644 --- a/crates/skate-game/src/app.rs +++ b/crates/skate-game/src/app.rs @@ -39,6 +39,8 @@ pub(crate) fn build( ) -> App { let retail_scene = config.map.as_ref().is_some_and(|map| map.materials.iter().any(|m| m.retail_definition.is_some())); let mut app = App::new(); + app.register_asset_source("mods", bevy::asset::io::AssetSourceBuilder::platform_default( + &crate::modding::package_root().to_string_lossy(), None)); app.add_plugins( DefaultPlugins .set(AssetPlugin { diff --git a/crates/skate-game/src/camera.rs b/crates/skate-game/src/camera.rs index 9664d5f7..9c981bcf 100644 --- a/crates/skate-game/src/camera.rs +++ b/crates/skate-game/src/camera.rs @@ -24,7 +24,7 @@ use bevy::prelude::*; use crate::{app::FrameSet, config::Config}; #[derive(Component)] -struct GameplayCamera; +pub(crate) struct GameplayCamera; pub(crate) struct CameraPlugin; impl Plugin for CameraPlugin { @@ -49,7 +49,7 @@ fn spawn(mut commands: Commands, config: Res, retail: Res, windows: Query<&Window>, +pub(crate) fn present(vehicles: Res, mut runtime: ResMut, windows: Query<&Window>, history: Res, time: Res>, replay: Res, customiser: Option>, @@ -75,6 +75,7 @@ fn present(mut runtime: ResMut, windows: Query<&Window>, *transform = Transform::from_translation(eye).looking_at(center + right * (distance * 0.265625), Vec3::Y); if let Projection::Perspective(p) = &mut *projection { p.fov = 50_f32.to_radians(); } } + if let Some(pose) = vehicles.camera() { *transform = pose; } camera.is_active = true; } } diff --git a/crates/skate-game/src/modding/mod.rs b/crates/skate-game/src/modding/mod.rs index 662ee4be..9b4e0efb 100644 --- a/crates/skate-game/src/modding/mod.rs +++ b/crates/skate-game/src/modding/mod.rs @@ -1,6 +1,7 @@ //! Main-thread SDK adapter. Lua never receives World, entity IDs, or asset handles. mod menu; mod panel; +pub(crate) mod vehicles; use bevy::prelude::*; pub(crate) use menu::ModMenu; pub(crate) use panel::EnabledPanel; @@ -27,14 +28,7 @@ struct Owned { pub(crate) struct ModdingPlugin; impl Plugin for ModdingPlugin { fn build(&self, app: &mut App) { - let root = std::env::var_os("SKATE3_MODS") - .map(std::path::PathBuf::from) - .unwrap_or_else(|| { - std::env::current_exe() - .ok() - .and_then(|exe| exe.parent().map(|p| p.join("mods"))) - .unwrap_or_else(|| std::path::PathBuf::from("mods")) - }); + let root = package_root(); if let Err(e) = std::fs::create_dir_all(&root) { warn!("Cannot create mods folder {}: {e}", root.display()); } @@ -97,6 +91,7 @@ impl Plugin for ModdingPlugin { .run_if(crate::graphics_menu::gameplay_active), ) .add_systems(Update, update.after(crate::app::FrameSet::Animation)); + vehicles::install(app); menu::install(app); panel::install(app); } @@ -117,7 +112,10 @@ fn snapshot(world: &World) -> serde_json::Value { .0 .actions() .values(); - json!({"player":{"position":&s.animated_skeleton.roots.animation_to_world[3][..3],"velocity": &p.skateboard.vector_80.map(f32::from_bits)[..3],"heading":s.animated_skeleton.roots.animation_to_world[2][0].atan2(s.animated_skeleton.roots.animation_to_world[2][2]),"on_board":p.state.category_12!=500,"state":p.state.state_16,"category":p.state.category_12,"bailing":physics.board_wiping_out,"grind":{"active":grinding,"name":grind_name,"kind":grind_kind,"distance":grind_distance}}, + let vehicle_pose=world.resource::().player_pose(); + let player_position=vehicle_pose.map(|p|p.0).unwrap_or_else(||s.animated_skeleton.roots.animation_to_world[3][..3].try_into().unwrap()); + json!({"player":{"position":player_position,"velocity": &p.skateboard.vector_80.map(f32::from_bits)[..3],"heading":vehicle_pose.map(|p|p.1).unwrap_or_else(||s.animated_skeleton.roots.animation_to_world[2][0].atan2(s.animated_skeleton.roots.animation_to_world[2][2])),"on_board":p.state.category_12!=500,"state":p.state.state_16,"category":p.state.category_12,"bailing":physics.board_wiping_out,"grind":{"active":grinding,"name":grind_name,"kind":grind_kind,"distance":grind_distance}}, + "vehicles":vehicles::snapshot(world),"vehicle_input":vehicles::input(world), "animation":world.resource::().animation_info, "map":{"name":map.name,"generation":map.generation},"tick":physics.ticks,"keys":keys,"actions":actions, "paused":world.resource::().open,"replay":world.resource::().active}) @@ -130,6 +128,7 @@ fn maintenance(world: &mut World) { .resource::() .generation; if mods.generation != generation { + vehicles::clear(world); let ids: Vec<_> = mods.owned.keys().cloned().collect(); for key in ids { retire(world, &mut mods, &key); @@ -145,6 +144,8 @@ fn maintenance(world: &mut World) { mods.manager .dispatch("on_event", json!({"name":"world_changed","map":map})); } + let events = std::mem::take(&mut world.resource_mut::().events); + for event in events { mods.manager.dispatch("on_event", event); } mods.manager.scan(false); apply(world, &mut mods); }); @@ -229,6 +230,7 @@ fn apply(world: &mut World, mods: &mut Mods) { world.resource_mut::().trainer = mods.trainer.as_ref().map(|(_, t)| *t).unwrap_or_default(); for id in &retired { + vehicles::retire(world, id); world .resource::() .animation @@ -296,6 +298,7 @@ fn apply(world: &mut World, mods: &mut Mods) { if let Err(e) = result { warn!("Lua mod {id}: {e}"); mods.manager.fail(&id, e); + vehicles::retire(world, &id); if mods.trainer.as_ref().is_some_and(|(owner, _)| owner == &id) { mods.trainer = None; world.resource_mut::().trainer = Default::default(); @@ -326,6 +329,7 @@ fn apply(world: &mut World, mods: &mut Mods) { } } fn teleport_ready(world: &World) -> Result<(), String> { + if world.resource::().occupied() { return Err("Exit the vehicle before teleporting the skater".into()); } if world .resource::() .busy() @@ -358,6 +362,9 @@ fn apply_one(world: &mut World, mods: &mut Mods, id: &str, command: Command) -> } } match command { + command @ (Command::VehicleTune{..}|Command::VehicleSpawn{..}|Command::VehicleRemove{..}|Command::VehicleEnter{..}|Command::VehicleExit{..}|Command::VehicleReset{..}|Command::VehicleControl{..}) => { + vehicles::command(world, &mods.manager.packages[id].root, id, command)?; + } Command::Trainer { tuning } => { if mods.trainer.as_ref().is_some_and(|(owner, _)| owner != id) { return Err("Native trainer controls are already owned by another mod".into()); @@ -492,3 +499,8 @@ fn apply_one(world: &mut World, mods: &mut Mods, id: &str, command: Command) -> } Ok(()) } + +pub(crate) fn package_root()->std::path::PathBuf { + std::env::var_os("SKATE3_MODS").map(std::path::PathBuf::from).unwrap_or_else(|| + std::env::current_exe().ok().and_then(|p|p.parent().map(|p|p.join("mods"))).unwrap_or_else(||"mods".into())) +} diff --git a/crates/skate-game/src/modding/vehicles/animations.rs b/crates/skate-game/src/modding/vehicles/animations.rs new file mode 100644 index 00000000..7e7d1ff1 --- /dev/null +++ b/crates/skate-game/src/modding/vehicles/animations.rs @@ -0,0 +1,156 @@ +use bevy::prelude::*; +use serde::Deserialize; +use std::collections::BTreeMap; +#[derive(Default)] +pub(super) struct Clips(pub BTreeMap); +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct File { + version: u32, + bone_names: Vec, + clips: BTreeMap, +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct Clip { + fps: f32, + frames: Vec>, +} +impl Clips { + pub fn load( + root: &std::path::Path, + definition: &skate_vehicles::VehicleDefinition, + names: &[String], + ) -> Result { + let Some(path) = &definition.animations.file else { + return Ok(Self::default()); + }; + let bytes = skate_mods::read_bounded(root, path, 16 * 1024 * 1024)?; + let file: File = serde_json::from_slice(&bytes).map_err(|e| e.to_string())?; + if file.version != 1 || file.bone_names != names || file.clips.len() > 32 { + return Err("Vehicle animation version/skeleton mismatch".into()); + } + for clip in file.clips.values() { + if !clip.fps.is_finite() + || !(1. ..=120.).contains(&clip.fps) + || clip.frames.is_empty() + || clip.frames.len() > 3600 + { + return Err("Invalid vehicle clip timing".into()); + } + for frame in &clip.frames { + if frame.len() != names.len() { + return Err("Vehicle clip missing bones".into()); + } + for values in frame { + let m = Mat4::from_cols_array(values); + let (scale, rotation, translation) = m.to_scale_rotation_translation(); + if !m.is_finite() + || m.determinant() <= 0. + || !rotation.is_finite() + || scale.min_element() < 0.9 + || scale.max_element() > 1.1 + || translation.abs().max_element() > 20. + || (m.w_axis.w - 1.).abs() > 0.001 + || m.x_axis.w.abs() + m.y_axis.w.abs() + m.z_axis.w.abs() > 0.001 + { + return Err("Invalid vehicle bone transform".into()); + } + } + } + } + let a = &definition.animations; + for name in [ + &a.enter, + &a.exit, + &a.drive, + &a.idle, + &a.reverse, + &a.brake, + &a.steer_left, + &a.steer_right, + ] + .into_iter() + .flatten() + { + if !file.clips.contains_key(name) { + return Err(format!("Missing vehicle animation {name}")); + } + } + Ok(Self(file.clips)) + } + pub fn duration(&self, name: Option<&String>) -> f32 { + name.and_then(|n| self.0.get(n)) + .map_or(0., |c| c.frames.len() as f32 / c.fps) + } + pub fn pose(&self, name: Option<&String>, time: f32, looping: bool) -> Option> { + let c = self.0.get(name?)?; + let at = time * c.fps; + let at = if looping { + at % c.frames.len() as f32 + } else { + at.min((c.frames.len() - 1) as f32) + }; + let i = at.floor() as usize; + let j = if looping { + (i + 1) % c.frames.len() + } else { + (i + 1).min(c.frames.len() - 1) + }; + Some( + c.frames[i] + .iter() + .zip(&c.frames[j]) + .map(|(a, b)| { + let (sa, ra, ta) = Mat4::from_cols_array(a).to_scale_rotation_translation(); + let (sb, rb, tb) = Mat4::from_cols_array(b).to_scale_rotation_translation(); + Mat4::from_scale_rotation_translation( + sa.lerp(sb, at.fract()), + ra.slerp(rb, at.fract()), + ta.lerp(tb, at.fract()), + ) + }) + .collect(), + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn vehicle_clips_validate_and_play_once_or_loop() { + let root = std::env::temp_dir().join(format!("skate-vehicle-clips-{}", std::process::id())); + std::fs::create_dir_all(&root).unwrap(); + let a = Mat4::IDENTITY.to_cols_array(); + let b = Mat4::from_translation(Vec3::X).to_cols_array(); + let file = serde_json::json!({"version":1,"bone_names":["HIPS"],"clips":{"enter":{"fps":1,"frames":[[a],[b]]}}}); + std::fs::write(root.join("rider.json"), file.to_string()).unwrap(); + let mut definition = skate_vehicles::VehicleDefinition::default(); + definition.animations.file = Some("rider.json".into()); + definition.animations.enter = Some("enter".into()); + let clips = Clips::load(&root, &definition, &["HIPS".into()]).unwrap(); + let name = definition.animations.enter.as_ref(); + assert_eq!(clips.duration(name), 2.); + assert!((clips.pose(name, 0.5, false).unwrap()[0].w_axis.x - 0.5).abs() < 0.001); + assert_eq!(clips.pose(name, 3., false).unwrap()[0].w_axis.x, 1.); + assert_eq!(clips.pose(name, 2., true).unwrap()[0].w_axis.x, 0.); + assert!(Clips::load(&root, &definition, &["WRONG".into()]).is_err()); + definition.animations.drive = Some("missing".into()); + assert!(Clips::load(&root, &definition, &["HIPS".into()]).is_err()); + definition.animations.drive = None; + let mut invalid = file; + invalid["clips"]["enter"]["frames"][0][0][0] = serde_json::json!(0); + std::fs::write(root.join("rider.json"), invalid.to_string()).unwrap(); + assert!(Clips::load(&root, &definition, &["HIPS".into()]).is_err()); + // Remove only this fixture's known file; do not recursively delete a computed directory. + std::fs::remove_file(root.join("rider.json")).unwrap(); + let _ = std::fs::remove_dir(root); + } + #[test] + fn missing_animation_uses_immediate_hidden_rider_fallback() { + let clips = Clips::default(); + assert_eq!(clips.duration(None), 0.); + assert!(clips.pose(None, 0., false).is_none()); + } +} diff --git a/crates/skate-game/src/modding/vehicles/mod.rs b/crates/skate-game/src/modding/vehicles/mod.rs new file mode 100644 index 00000000..7741f3ac --- /dev/null +++ b/crates/skate-game/src/modding/vehicles/mod.rs @@ -0,0 +1,646 @@ +//! Native vehicle host: isolated Rapier world, mod ownership and driver lifecycle. +mod animations; +use bevy::prelude::*; +use serde_json::{Value, json}; +use skate_mods::Command; +use skate_vehicles::{Controls, Simulation, VehicleDefinition}; +use std::collections::BTreeMap; +#[derive(Component, Clone)] +struct WheelVisual { + vehicle: u64, + index: usize, + rest: Transform, +} +struct Instance { + id: u64, + entity: Entity, + scene: Handle, + clips: animations::Clips, + last_control: f32, +} +struct Driver { + owner: String, + key: String, + phase: &'static str, + time: f32, + return_position: [f32; 3], + return_heading: f32, +} +#[derive(Resource, Default)] +pub(crate) struct Vehicles { + simulation: Simulation, + owned: BTreeMap<(String, String), Instance>, + driver: Option, + pub(super) events: Vec, + clock: f32, + hidden: Vec<(Entity, Visibility)>, + pub(crate) pose: Option>, +} +impl Vehicles { + pub(crate) fn occupied(&self) -> bool { + self.driver.is_some() + } + pub(super) fn player_pose(&self) -> Option<([f32; 3], f32)> { + let d = self.driver.as_ref()?; + let i = self.owned.get(&(d.owner.clone(), d.key.clone()))?; + let (p, q) = self.simulation.pose(i.id)?; + let q = Quat::from_array(q); + let forward = q * Vec3::Z; + let seat = Vec3::from_array(self.simulation.vehicles[&i.id].definition.seat); + Some(( + (Vec3::from_array(p) + q * seat).to_array(), + forward.x.atan2(forward.z), + )) + } + pub(crate) fn camera(&self) -> Option { + let d = self.driver.as_ref()?; + let i = self.owned.get(&(d.owner.clone(), d.key.clone()))?; + let (p, q) = self.simulation.pose(i.id)?; + let q = Quat::from_array(q); + let def = &self.simulation.vehicles[&i.id].definition; + let center = Vec3::from_array(p) + Vec3::Y * 0.5; + let forward = (q * Vec3::Z).with_y(0.).normalize_or_zero(); + Some( + Transform::from_translation( + center - forward * def.camera_distance + Vec3::Y * def.camera_height, + ) + .looking_at(center + forward, Vec3::Y), + ) + } +} +pub(super) fn install(app: &mut App) { + app.init_resource::() + .add_systems( + FixedUpdate, + tick.after(super::fixed) + .run_if(crate::graphics_menu::gameplay_active), + ) + .add_systems( + Update, + present + .after(crate::app::FrameSet::Animation) + .before(crate::camera::present), + ); +} +pub(super) fn snapshot(world: &World) -> Value { + let v = world.resource::(); + let mut out = serde_json::Map::new(); + for ((owner, key), instance) in &v.owned { + let Some((p, q)) = v.simulation.pose(instance.id) else { + continue; + }; + let car = &v.simulation.vehicles[&instance.id]; + let phase = v + .driver + .as_ref() + .filter(|d| d.owner == *owner && d.key == *key) + .map_or("parked", |d| d.phase); + out.entry(owner.clone()).or_insert(json!({})).as_object_mut().unwrap().insert(key.clone(),json!({"position":p,"rotation":q,"heading":({let f=Quat::from_array(q)*Vec3::Z;f.x.atan2(f.z)}),"speed":car.controller.current_vehicle_speed,"phase":phase,"occupied":phase!="parked","ready":world.resource::().is_loaded_with_dependencies(instance.scene.id())})); + } + Value::Object(out) +} +fn event(v: &mut Vehicles, owner: &str, key: &str, name: &str) { + v.events.push(json!({"name":name,"owner":owner,"key":key})); +} +fn exit_now(world: &mut World, v: &mut Vehicles, forced: bool) -> Result<(), String> { + let Some(driver) = v.driver.take() else { + return Ok(()); + }; + let mut position = driver.return_position; + let mut heading = driver.return_heading; + if !forced { + if let Some(i) = v.owned.get(&(driver.owner.clone(), driver.key.clone())) { + if let Some((p, q)) = v.simulation.pose(i.id) { + let q = Quat::from_array(q); + let offset = Vec3::from_array(v.simulation.vehicles[&i.id].definition.exit); + let candidate = (Vec3::from_array(p) + q * offset).to_array(); + if let Some(floor) = v.simulation.floor(candidate) { + position = [floor[0], floor[1] + 0.15, floor[2]]; + let forward = q * Vec3::Z; + heading = forward.x.atan2(forward.z); + } + } + } + } + v.pose = None; + for (entity, visibility) in v.hidden.drain(..) { + if let Some(mut current) = world.get_mut::(entity) { + *current = visibility; + } + } + let (sin, cos) = heading.sin_cos(); + let matrix = [ + [cos, 0., -sin, 0.], + [0., 1., 0., 0.], + [sin, 0., cos, 0.], + [position[0], position[1], position[2], 0.], + ]; + let mut skater = world.resource_mut::(); + if skater.player_input.pending_teleport().is_none() { + skater + .player_input + .request_teleport(matrix) + .map_err(|e| e.to_string())?; + skater.teleport_state.request_manual(matrix, false); + } + event(v, &driver.owner, &driver.key, "vehicle_exited"); + 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) { + let _ = exit_now(world, &mut v, true); + } + let keys: Vec<_> = v + .owned + .keys() + .filter(|(o, _)| o == owner) + .cloned() + .collect(); + for key in keys { + if let Some(i) = v.owned.remove(&key) { + v.simulation.remove(i.id); + world.despawn(i.entity); + } + } + }); +} +pub(super) fn clear(world: &mut World) { + // A map transition has already installed a new skater: never teleport it back to the old map. + world.resource_scope(|world, mut v: Mut| { + for (_, i) in std::mem::take(&mut v.owned) { + world.despawn(i.entity); + } + for (id, visibility) in v.hidden.drain(..) { + if let Some(mut current) = world.get_mut::(id) { + *current = visibility; + } + } + v.driver = None; + v.pose = None; + v.simulation = Simulation::default(); + v.events.clear(); + }); +} +fn ensure_ground(world: &World, v: &mut Vehicles) -> Result<(), String> { + if v.simulation.world.colliders.is_empty() { + v.simulation.ground( + world + .resource::() + .world_triangles() + .iter() + .map(|t| t.triangle.vertices.map(|p| [p.x, p.y, p.z])), + )?; + } + Ok(()) +} +pub(super) fn command( + world: &mut World, + root: &std::path::Path, + owner: &str, + command: Command, +) -> Result<(), String> { + world.resource_scope(|world, mut v: Mut| { + let key = match &command { + Command::VehicleTune { key, .. } + | Command::VehicleSpawn { key, .. } + | Command::VehicleRemove { key } + | Command::VehicleEnter { key } + | Command::VehicleExit { key } + | Command::VehicleReset { key, .. } + | Command::VehicleControl { key, .. } => key.clone(), + _ => unreachable!(), + }; + let owned_key = (owner.to_owned(), key.clone()); + match command { + Command::VehicleTune { tuning, .. } => { + let id = v.owned.get(&owned_key).ok_or("Unknown vehicle")?.id; + let car = v.simulation.vehicles.get_mut(&id).unwrap(); + car.definition = tuning.apply(&car.definition)?; + let grip = car.definition.tire_grip; + for wheel in car.controller.wheels_mut() { + wheel.friction_slip = grip; + } + } + Command::VehicleSpawn { + definition, + position, + heading, + .. + } => { + if v.owned.contains_key(&owned_key) { + return Err("Vehicle key already spawned; remove it before respawning".into()); + } + if v.owned.keys().filter(|(o, _)| o == owner).count() >= 8 || v.owned.len() >= 32 { + return Err("Vehicle limit: 8 per mod, 32 total".into()); + } + let bytes = skate_mods::read_bounded(root, &definition, 128 * 1024)?; + let def: VehicleDefinition = + serde_json::from_slice(&bytes).map_err(|e| e.to_string())?; + def.validate()?; + let bytes = skate_mods::read_bounded(root, &def.model, 32 * 1024 * 1024)?; + validate_glb(&bytes)?; + let clips = animations::Clips::load( + root, + &def, + &world + .resource::() + .animation + .evaluator + .frames + .bone_names, + )?; + let model = root + .join(&def.model) + .canonicalize() + .map_err(|e| e.to_string())?; + let package_root = super::package_root() + .canonicalize() + .map_err(|e| e.to_string())?; + let relative = model + .strip_prefix(package_root) + .map_err(|_| "Model outside mod packages")? + .to_string_lossy() + .replace('\\', "/"); + let scene = world + .resource::() + .load(GltfAssetLabel::Scene(0).from_asset(format!("mods://{relative}"))); + ensure_ground(world, &mut v)?; + let id = v.simulation.spawn(def.clone(), position, heading)?; + let entity = world + .spawn(( + Transform::from_translation(Vec3::from_array(position)) + .with_rotation(Quat::from_rotation_y(heading)), + Visibility::default(), + )) + .id(); + world.spawn(( + SceneRoot(scene.clone()), + Transform::from_translation(Vec3::from_array(def.model_offset)) + .with_rotation(Quat::from_rotation_y(def.model_yaw)) + .with_scale(Vec3::splat(def.model_scale)), + ChildOf(entity), + )); + let clock = v.clock; + v.owned.insert( + owned_key, + Instance { + id, + entity, + scene, + clips, + last_control: clock, + }, + ); + event(&mut v, owner, &key, "vehicle_spawned"); + } + Command::VehicleRemove { .. } => { + if v.driver + .as_ref() + .is_some_and(|d| d.owner == owner && d.key == key) + { + exit_now(world, &mut v, true)?; + } + if let Some(i) = v.owned.remove(&owned_key) { + v.simulation.remove(i.id); + world.despawn(i.entity); + event(&mut v, owner, &key, "vehicle_removed"); + } + } + Command::VehicleEnter { .. } => { + if v.driver.is_some() + || world.resource::().active + || world + .resource::() + .busy() + { + return Ok(()); + } + let i = v.owned.get(&owned_key).ok_or("Unknown vehicle")?; + if !world + .resource::() + .is_loaded_with_dependencies(i.scene.id()) + { + return Ok(()); + } + if !world.resource::().ready { + return Ok(()); + } + let skater = world.resource::(); + if skater.player_input.pending_teleport().is_some() + || world + .resource::() + .board_wiping_out + { + return Ok(()); + } + let m = skater.animated_skeleton.roots.animation_to_world; + let p = [m[3][0], m[3][1], m[3][2]]; + let car = v.simulation.pose(i.id).ok_or("Missing vehicle body")?.0; + if Vec3::from_array(p).distance(Vec3::from_array(car)) > 4. + || v.simulation.vehicles[&i.id] + .controller + .current_vehicle_speed + .abs() + > 3. + { + return Ok(()); + } + v.driver = Some(Driver { + owner: owner.into(), + key: key.clone(), + phase: "entering", + time: 0., + return_position: p, + return_heading: m[2][0].atan2(m[2][2]), + }); + event(&mut v, owner, &key, "vehicle_entering"); + } + Command::VehicleExit { .. } => { + if let Some(d) = &v.driver { + if d.owner != owner || d.key != key { + return Ok(()); + } + } + if let Some(i) = v.owned.get(&owned_key) { + if v.simulation.vehicles[&i.id] + .controller + .current_vehicle_speed + .abs() + > 3. + { + event(&mut v, owner, &key, "vehicle_exit_blocked"); + return Ok(()); + } + } + if let Some(d) = &mut v.driver { + d.phase = "exiting"; + d.time = 0.; + } + } + Command::VehicleControl { controls, .. } => { + let clock = v.clock; + let i = v.owned.get_mut(&owned_key).ok_or("Unknown vehicle")?; + i.last_control = clock; + let id = i.id; + v.simulation.vehicles.get_mut(&id).unwrap().controls = controls; + } + Command::VehicleReset { + position, heading, .. + } => { + let i = v.owned.get(&owned_key).ok_or("Unknown vehicle")?; + let id = i.id; + v.simulation.reset(id, position, heading)?; + event(&mut v, owner, &key, "vehicle_reset"); + } + _ => unreachable!(), + } + Ok(()) + }) +} +fn validate_glb(bytes: &[u8]) -> Result<(), String> { + if bytes.len() < 20 + || &bytes[..4] != b"glTF" + || u32::from_le_bytes(bytes[4..8].try_into().unwrap()) != 2 + { + return Err("Vehicle model must be GLB 2".into()); + } + let n = u32::from_le_bytes(bytes[12..16].try_into().unwrap()) as usize; + let json: Value = serde_json::from_slice(bytes.get(20..20 + n).ok_or("Truncated GLB")?) + .map_err(|e| e.to_string())?; + for field in ["buffers", "images"] { + for entry in json[field].as_array().into_iter().flatten() { + if entry.get("uri").is_some() { + return Err("Vehicle GLB must embed all buffers and textures".into()); + } + } + } + Ok(()) +} +fn tick(world: &mut World) { + if world.resource::().active { + return; + } + let dt = world.resource::>().delta_secs(); + world.resource_scope(|world, mut v: Mut| { + v.clock += dt; + let clock = v.clock; + let parked: Vec<_> = v + .owned + .values() + .filter(|i| clock - i.last_control > 0.25) + .map(|i| i.id) + .collect(); + for id in parked { + v.simulation.vehicles.get_mut(&id).unwrap().controls = Controls { + brake: 0.2, + ..Default::default() + }; + } + let driver_info = v + .driver + .as_ref() + .map(|d| (d.owner.clone(), d.key.clone(), d.phase)); + if let Some((owner, key, phase)) = &driver_info { + if *phase != "driving" { + if let Some(i) = v.owned.get(&(owner.clone(), key.clone())) { + let id = i.id; + v.simulation.vehicles.get_mut(&id).unwrap().controls = Controls { + brake: 1., + ..Default::default() + }; + } + } + } + if !v.owned.is_empty() { + v.simulation.step(dt); + } + 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; + let duration = i.clips.duration(if phase == "entering" { + def.animations.enter.as_ref() + } else { + def.animations.exit.as_ref() + }); + if let Some(d) = &mut v.driver { + d.time += dt; + } + if phase != "driving" && v.driver.as_ref().unwrap().time >= duration { + if phase == "exiting" { + if let Err(e) = exit_now(world, &mut v, false) { + warn!("Vehicle exit: {e}"); + } + } else { + let d = v.driver.as_mut().unwrap(); + d.phase = "driving"; + d.time = 0.; + event(&mut v, &owner, &key, "vehicle_entered"); + } + } + } + } + }); +} +pub(crate) fn present(world: &mut World) { + world.resource_scope(|world, mut v: Mut| { + let failures: Vec<_> = v + .owned + .iter() + .filter_map(|((owner, key), i)| { + if let Some(bevy::asset::LoadState::Failed(error)) = + world.resource::().get_load_state(i.scene.id()) + { + Some(( + owner.clone(), + format!("Vehicle {key} model failed: {error}"), + )) + } else { + None + } + }) + .collect(); + for (owner, error) in failures { + world + .resource_mut::() + .manager + .fail(&owner, error); + } + for i in v.owned.values() { + if let Some((p, q)) = v.simulation.pose(i.id) { + if let Some(mut t) = world.get_mut::(i.entity) { + *t = Transform::from_translation(Vec3::from_array(p)) + .with_rotation(Quat::from_array(q)); + } + } + } + let mut wheels = Vec::new(); + for instance in v.owned.values() { + for (index, wheel) in v.simulation.vehicles[&instance.id] + .definition + .wheels + .iter() + .enumerate() + { + let Some(name) = &wheel.node else { + continue; + }; + let matches: Vec<_> = world + .query_filtered::<(Entity, &Name, &Transform), Without>() + .iter(world) + .filter(|(_, n, _)| n.as_str() == name) + .map(|(e, _, t)| (e, *t)) + .collect(); + for (entity, rest) in matches { + let mut ancestor = entity; + let mut owned = false; + for _ in 0..128 { + let Some(parent) = world.get::(ancestor) else { + break; + }; + ancestor = parent.parent(); + if ancestor == instance.entity { + owned = true; + break; + } + } + if owned { + wheels.push(( + entity, + WheelVisual { + vehicle: instance.id, + index, + rest, + }, + )); + } + } + } + } + for (entity, wheel) in wheels { + world.entity_mut(entity).insert(wheel); + } + for (wheel, mut transform) in world + .query::<(&WheelVisual, &mut Transform)>() + .iter_mut(world) + { + if let Some(car) = v.simulation.vehicles.get(&wheel.vehicle) { + let physical = &car.controller.wheels()[wheel.index]; + *transform = wheel.rest; + transform.translation.y += (car.definition.suspension_length + - physical.raycast_info().suspension_length) + / car.definition.model_scale; + transform.rotation = wheel.rest.rotation + * Quat::from_rotation_y(physical.steering) + * Quat::from_rotation_x(-physical.rotation); + } + } + v.pose = None; + let Some(d) = &v.driver else { + return; + }; + let Some(i) = v.owned.get(&(d.owner.clone(), d.key.clone())) else { + return; + }; + let car = &v.simulation.vehicles[&i.id]; + let a = &car.definition.animations; + let name = match d.phase { + "entering" => a.enter.as_ref(), + "exiting" => a.exit.as_ref(), + _ => { + if car.controls.brake > 0.2 { + a.brake.as_ref() + } else if car.controller.current_vehicle_speed < -0.5 { + a.reverse.as_ref() + } else if car.controls.steering > 0.2 { + a.steer_left.as_ref() + } else if car.controls.steering < -0.2 { + a.steer_right.as_ref() + } else if car.controller.current_vehicle_speed.abs() > 0.5 { + a.drive.as_ref() + } else { + a.idle.as_ref() + } + } + } + .or(a.drive.as_ref()); + let pose = i.clips.pose(name, d.time, d.phase == "driving"); + let (p, q) = v.simulation.pose(i.id).unwrap(); + let q = Quat::from_array(q); + let seat = Vec3::from_array(p) + q * Vec3::from_array(car.definition.seat); + let roots: Vec<_> = world + .query_filtered::>() + .iter(world) + .collect(); + for entity in roots { + if !v.hidden.iter().any(|(id, _)| *id == entity) { + if let Some(vis) = world.get::(entity) { + v.hidden.push((entity, *vis)); + } + } + if let Some(mut vis) = world.get_mut::(entity) { + *vis = if pose.is_some() { + Visibility::Inherited + } else { + Visibility::Hidden + }; + } + if let Some(mut transform) = world.get_mut::(entity) { + *transform = Transform::from_translation(seat).with_rotation(q); + } + } + if let Some(pose) = &pose { + crate::animation::vehicle_pose(world, pose); + } + v.pose = pose; + }); +} + +pub(super) fn input(world: &World) -> Value { + let keys = world.resource::>(); + let pad = world + .resource::() + .raw_input(); + let pressed = |key| if keys.pressed(key) { 1. } else { 0. }; + json!({"throttle":(pressed(KeyCode::KeyW)-pressed(KeyCode::KeyS)+pad.triggers[1]-pad.triggers[0]).clamp(-1.,1.),"steering":(pressed(KeyCode::KeyA)-pressed(KeyCode::KeyD)-if pad.left[0].abs()>0.15 {pad.left[0]} else {0.}).clamp(-1.,1.),"brake":if pad.buttons & 0x1000 != 0 {1.} else {pressed(KeyCode::Space)},"handbrake":keys.pressed(KeyCode::ShiftLeft) || pad.buttons & 0x2000 != 0,"interact":keys.pressed(KeyCode::KeyE) || pad.buttons & 0x8000 != 0,"pad_buttons":pad.buttons}) +} diff --git a/crates/skate-game/src/physics.rs b/crates/skate-game/src/physics.rs index 57b72a1d..c78b1b62 100644 --- a/crates/skate-game/src/physics.rs +++ b/crates/skate-game/src/physics.rs @@ -313,6 +313,7 @@ impl Plugin for PhysicsPlugin { } fn advance( + vehicles: Res, mut physics: ResMut, mut skater: ResMut, mut controls: ResMut, @@ -323,7 +324,7 @@ fn advance( mut exit: MessageWriter, mut performance: Option>, ) { - if physics.failed { + if physics.failed || vehicles.occupied() { return; } let timer = performance.as_ref().map(|_| std::time::Instant::now()); diff --git a/crates/skate-game/src/replay.rs b/crates/skate-game/src/replay.rs index 942b2102..755248fc 100644 --- a/crates/skate-game/src/replay.rs +++ b/crates/skate-game/src/replay.rs @@ -152,6 +152,7 @@ fn axis(value: f32) -> f32 { } fn controls( + vehicles: Res, mut replay: ResMut, mut input: ResMut, keys: Res>, @@ -164,7 +165,7 @@ fn controls( let pressed = pad.buttons & !replay.previous_buttons; replay.previous_buttons = pad.buttons; let menu_open = !crate::graphics_menu::gameplay_active(menu); - if !menu_open { + if !menu_open && !vehicles.occupied() { if pressed & SELECT != 0 || keys.just_pressed(KeyCode::F6) { if replay.active { replay.exit(); diff --git a/crates/skate-game/src/session_marker/mod.rs b/crates/skate-game/src/session_marker/mod.rs index b18fe78f..ba5a71c5 100644 --- a/crates/skate-game/src/session_marker/mod.rs +++ b/crates/skate-game/src/session_marker/mod.rs @@ -97,6 +97,7 @@ fn suspend( } fn update( + vehicles: Res, mut session: ResMut, input: Res, map: Res, @@ -106,6 +107,7 @@ fn update( replay: Res, mut audio: MessageWriter, ) { + if vehicles.occupied() {session.blocked_until_release = true;return;} if replay.active { return; } diff --git a/crates/skate-mods/Cargo.toml b/crates/skate-mods/Cargo.toml index 2012b09a..19a4788c 100644 --- a/crates/skate-mods/Cargo.toml +++ b/crates/skate-mods/Cargo.toml @@ -8,3 +8,5 @@ publish.workspace = true mlua = { version = "=0.12.1", features = ["lua54", "vendored", "serde", "send"] } serde.workspace = true serde_json.workspace = true + +skate-vehicles = { path = "../skate-vehicles" } diff --git a/crates/skate-mods/src/api.lua b/crates/skate-mods/src/api.lua index 1efb280b..011f9c10 100644 --- a/crates/skate-mods/src/api.lua +++ b/crates/skate-mods/src/api.lua @@ -48,3 +48,18 @@ function sdk.animation.info() return sdk.snapshot.animation end sdk.trainer = {} function sdk.trainer.apply(tuning) submit{kind="trainer",tuning=tuning} end + +sdk.vehicle = {} +function sdk.vehicle.spawn(key,definition,position,heading) submit{kind="vehicle_spawn",key=key,definition=definition,position=position,heading=heading or 0} end +function sdk.vehicle.remove(key) submit{kind="vehicle_remove",key=key} end +function sdk.vehicle.enter(key) submit{kind="vehicle_enter",key=key} end +function sdk.vehicle.exit(key) submit{kind="vehicle_exit",key=key} end +function sdk.vehicle.reset(key,position,heading) submit{kind="vehicle_reset",key=key,position=position,heading=heading or 0} end +function sdk.vehicle.control(key,controls) submit{kind="vehicle_control",key=key,controls=controls} end +function sdk.vehicle.read(key) + local owned = (sdk.snapshot.vehicles or {})[sdk.mod_id] + return owned and owned[key] or nil +end + +function sdk.vehicle.tune(key,tuning) submit{kind="vehicle_tune",key=key,tuning=tuning} end +function sdk.vehicle.input() return sdk.snapshot.vehicle_input end diff --git a/crates/skate-mods/src/lib.rs b/crates/skate-mods/src/lib.rs index f6200b34..07d90f35 100644 --- a/crates/skate-mods/src/lib.rs +++ b/crates/skate-mods/src/lib.rs @@ -402,8 +402,8 @@ fn fingerprint(root: &Path) -> Result { } else { let m = p.metadata().map_err(|e| e.to_string())?; *bytes += m.len(); - if *bytes > 8 * 1024 * 1024 { - return Err("Package exceeds 8 MiB".into()); + if *bytes > 64 * 1024 * 1024 { + return Err("Package exceeds 64 MiB".into()); } p.hash(h); std::fs::read(&p).map_err(|e| e.to_string())?.hash(h); diff --git a/crates/skate-mods/src/vm.rs b/crates/skate-mods/src/vm.rs index c63e4740..72e6b77d 100644 --- a/crates/skate-mods/src/vm.rs +++ b/crates/skate-mods/src/vm.rs @@ -13,6 +13,11 @@ use std::{ #[derive(Clone, Debug, Deserialize)] #[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] pub enum Command { + VehicleTune { key:String, tuning:skate_vehicles::VehicleTuning }, + VehicleSpawn { key:String, definition:String, position:[f32;3], heading:f32 }, + VehicleRemove { key:String }, VehicleEnter { key:String }, VehicleExit { key:String }, + VehicleReset { key:String, position:[f32;3], heading:f32 }, + VehicleControl { key:String, controls:skate_vehicles::Controls }, Trainer { tuning: crate::TrainerTuning, }, @@ -45,6 +50,11 @@ impl Command { fn validate(&self) -> bool { let point = |p: &[f32; 3]| p.iter().all(|v| v.is_finite() && v.abs() <= 100_000.); match self { + Self::VehicleTune{key,tuning} => crate::schema::valid_id(key) && tuning.valid(), + Self::VehicleSpawn{key,definition,position,heading} => crate::schema::valid_id(key) && skate_vehicles::package_path(definition) && point(position) && heading.is_finite(), + Self::VehicleReset{key,position,heading} => crate::schema::valid_id(key) && point(position) && heading.is_finite(), + Self::VehicleControl{key,controls} => crate::schema::valid_id(key) && controls.valid(), + Self::VehicleRemove{key}|Self::VehicleEnter{key}|Self::VehicleExit{key} => crate::schema::valid_id(key), Self::Trainer { tuning } => tuning.valid(), Self::Animation { path } => !path.is_empty() && path.len() <= 256, Self::Log { text } => text.len() <= 2048, @@ -121,6 +131,7 @@ impl Vm { let out = queue.clone(); let sdk = lua.create_table()?; sdk.set("api_version", 1)?; + sdk.set("mod_id", manifest.id.clone())?; sdk.set( "_submit", lua.create_function(move |lua, value: mlua::Value| { diff --git a/crates/skate-mods/tests/runtime.rs b/crates/skate-mods/tests/runtime.rs index ad7ec43a..79b31239 100644 --- a/crates/skate-mods/tests/runtime.rs +++ b/crates/skate-mods/tests/runtime.rs @@ -152,7 +152,7 @@ fn examples_load_and_run() { m.scan(true); assert!(m.diagnostics.is_empty(), "{:?}", m.diagnostics); let ids: Vec<_> = m.packages.keys().cloned().collect(); - assert_eq!(ids, vec!["community.native-trainer"]); + assert_eq!(ids, vec!["community.mario-kart", "community.native-trainer"]); for id in ids { m.enable(&id, true).unwrap(); for _ in 0..5 { @@ -406,3 +406,24 @@ fn showcase_hold_fakie_is_optional_live_and_reversible() { m.enable(id, false).unwrap(); assert!(m.retired.iter().any(|owner| owner == id)); } + +#[test] +fn vehicle_api_validates_and_queues_owner_scoped_commands() { + let f=Fixture::new(r#"return {on_load=function() + sdk.vehicle.spawn('kart','vehicle.json',{0,2,0},0) + sdk.vehicle.tune('kart',{max_speed=20}) + sdk.vehicle.control('kart',{throttle=1,steering=0.2}) + sdk.vehicle.enter('kart');sdk.vehicle.exit('kart');sdk.vehicle.reset('kart',{0,2,0},0);sdk.vehicle.remove('kart') + end}"#); + let mut m=f.manager();m.enable("example",true).unwrap();assert_eq!(m.commands.len(),7); + assert!(m.commands.iter().all(|(owner,_)|owner=="example")); + assert!(matches!(&m.commands[0].1,Command::VehicleSpawn{definition,..} if definition=="vehicle.json")); + assert!(matches!(&m.commands[2].1,Command::VehicleControl{controls,..} if controls.throttle==1.)); + m.enable("example",false).unwrap();assert!(m.commands.is_empty()); +} +#[test] +fn vehicle_api_rejects_traversal_and_invalid_controls() { + for script in ["sdk.vehicle.spawn('kart','../vehicle.json',{0,0,0},0)","sdk.vehicle.control('kart',{throttle=2})","sdk.vehicle.tune('kart',{max_speed=-1})"] { + let f=Fixture::new(&format!("return {{on_load=function() {script} end}}"));let mut m=f.manager();m.enable("example",true).unwrap();assert!(!m.packages["example"].running());assert!(m.commands.is_empty()); + } +} diff --git a/crates/skate-vehicles/Cargo.toml b/crates/skate-vehicles/Cargo.toml new file mode 100644 index 00000000..5dd5fdec --- /dev/null +++ b/crates/skate-vehicles/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name="skate-vehicles" +version.workspace=true +edition.workspace=true +publish.workspace=true +[dependencies] +rapier3d="=0.35.3" +serde.workspace=true +serde_json.workspace=true diff --git a/crates/skate-vehicles/src/definition.rs b/crates/skate-vehicles/src/definition.rs new file mode 100644 index 00000000..c53c5ef4 --- /dev/null +++ b/crates/skate-vehicles/src/definition.rs @@ -0,0 +1,198 @@ +use serde::{Deserialize, Serialize}; +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(default, deny_unknown_fields)] +pub struct VehicleDefinition { + pub version: u32, + pub model: String, + pub model_scale: f32, + pub model_offset: [f32; 3], + pub model_yaw: f32, + pub half_extents: [f32; 3], + pub mass: f32, + pub engine_force: f32, + pub brake_impulse: f32, + pub max_speed: f32, + pub steering_angle: f32, + pub suspension_length: f32, + pub suspension_stiffness: f32, + pub suspension_damping: f32, + pub tire_grip: f32, + pub wheels: Vec, + pub seat: [f32; 3], + pub exit: [f32; 3], + pub camera_distance: f32, + pub camera_height: f32, + pub animations: Animations, +} +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct WheelDefinition { + pub position: [f32; 3], + pub radius: f32, + pub steering: bool, + pub driven: bool, + #[serde(default)] + pub node: Option, +} +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +#[serde(default, deny_unknown_fields)] +pub struct Animations { + pub file: Option, + pub enter: Option, + pub exit: Option, + pub drive: Option, + pub idle: Option, + pub reverse: Option, + pub brake: Option, + pub steer_left: Option, + pub steer_right: Option, +} +impl Default for VehicleDefinition { + fn default() -> Self { + Self { + version: 1, + model: String::new(), + model_scale: 1., + model_offset: [0.; 3], + model_yaw: 0., + half_extents: [0.7, 0.2, 1.1], + mass: 220., + engine_force: 1800., + brake_impulse: 100., + max_speed: 25., + steering_angle: 0.5, + suspension_length: 0.25, + suspension_stiffness: 30., + suspension_damping: 4., + tire_grip: 3., + wheels: vec![], + seat: [0., 0.25, 0.], + exit: [1.8, 0., 0.], + camera_distance: 5., + camera_height: 2., + animations: Animations::default(), + } + } +} +pub fn package_path(path: &str) -> bool { + !path.is_empty() + && path.len() <= 256 + && !path.contains([':', '\\', '#']) + && path + .split('/') + .all(|p| !p.is_empty() && p != "." && p != "..") + && !path.chars().any(char::is_control) +} +pub fn point(p: &[f32; 3], max: f32) -> bool { + p.iter().all(|x| x.is_finite() && x.abs() <= max) +} +impl VehicleDefinition { + pub fn validate(&self) -> Result<(), String> { + let range = |v: f32, a: f32, b: f32| v.is_finite() && (a..=b).contains(&v); + if self.version != 1 || !package_path(&self.model) || !self.model.ends_with(".glb") { + return Err("Vehicle version must be 1 and model a package-relative GLB".into()); + } + if !range(self.model_scale, 0.001, 100.) + || !point(&self.model_offset, 100.) + || !self.model_yaw.is_finite() + || !self.half_extents.iter().all(|x| range(*x, 0.05, 10.)) + || !range(self.mass, 10., 10000.) + || !range(self.engine_force, 0., 100000.) + || !range(self.brake_impulse, 0., 10000.) + || !range(self.max_speed, 1., 100.) + || !range(self.steering_angle, 0.01, 1.2) + || !range(self.suspension_length, 0.01, 2.) + || !range(self.suspension_stiffness, 1., 200.) + || !range(self.suspension_damping, 0.1, 30.) + || !range(self.tire_grip, 0.1, 20.) + || !point(&self.seat, 10.) + || !point(&self.exit, 10.) + || !range(self.camera_distance, 2., 30.) + || !range(self.camera_height, 0.5, 15.) + { + return Err("Vehicle dimensions/tuning outside supported finite ranges".into()); + } + if !(2..=8).contains(&self.wheels.len()) + || !self.wheels.iter().any(|w| w.driven) + || !self.wheels.iter().all(|w| { + point(&w.position, 10.) + && range(w.radius, 0.05, 2.) + && w.node + .as_ref() + .is_none_or(|n| !n.is_empty() && n.len() <= 128) + }) + { + return Err("Vehicle needs 2..8 valid wheels and at least one driven wheel".into()); + } + if self + .animations + .file + .as_ref() + .is_some_and(|p| !package_path(p)) + { + return Err("Invalid vehicle animation path".into()); + } + for name in [ + &self.animations.enter, + &self.animations.exit, + &self.animations.drive, + &self.animations.idle, + &self.animations.reverse, + &self.animations.brake, + &self.animations.steer_left, + &self.animations.steer_right, + ] + .into_iter() + .flatten() + { + if self.animations.file.is_none() || name.is_empty() || name.len() > 128 { + return Err("Animation slots require a file and valid clip names".into()); + } + } + Ok(()) + } +} + +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +#[serde(default, deny_unknown_fields)] +pub struct VehicleTuning { + pub engine_force: Option, + pub max_speed: Option, + pub brake_impulse: Option, + pub steering_angle: Option, + pub tire_grip: Option, +} +impl VehicleTuning { + pub fn apply(&self, definition: &VehicleDefinition) -> Result { + let mut d = definition.clone(); + if let Some(v) = self.engine_force { + d.engine_force = v; + } + if let Some(v) = self.max_speed { + d.max_speed = v; + } + if let Some(v) = self.brake_impulse { + d.brake_impulse = v; + } + if let Some(v) = self.steering_angle { + d.steering_angle = v; + } + if let Some(v) = self.tire_grip { + d.tire_grip = v; + } + d.validate()?; + Ok(d) + } + pub fn valid(&self) -> bool { + [ + self.engine_force.map(|x| (x, 0., 100000.)), + self.max_speed.map(|x| (x, 1., 100.)), + self.brake_impulse.map(|x| (x, 0., 10000.)), + self.steering_angle.map(|x| (x, 0.01, 1.2)), + self.tire_grip.map(|x| (x, 0.1, 20.)), + ] + .into_iter() + .flatten() + .all(|(x, a, b)| x.is_finite() && (a..=b).contains(&x)) + } +} diff --git a/crates/skate-vehicles/src/lib.rs b/crates/skate-vehicles/src/lib.rs new file mode 100644 index 00000000..a8d9bf2b --- /dev/null +++ b/crates/skate-vehicles/src/lib.rs @@ -0,0 +1,212 @@ +//! Bevy-independent, fixed-step Rapier vehicle simulation and validated mod definitions. +mod definition; +pub use definition::*; +pub use rapier3d; +use rapier3d::{ + control::{DynamicRayCastVehicleController, WheelTuning}, + prelude::*, +}; +use std::collections::BTreeMap; +#[derive(Clone, Copy, Debug, Default, serde::Deserialize, serde::Serialize)] +#[serde(default, deny_unknown_fields)] +pub struct Controls { + pub throttle: f32, + pub steering: f32, + pub brake: f32, + pub handbrake: bool, +} +impl Controls { + pub fn valid(&self) -> bool { + self.throttle.is_finite() + && self.steering.is_finite() + && self.brake.is_finite() + && (-1. ..=1.).contains(&self.throttle) + && (-1. ..=1.).contains(&self.steering) + && (0. ..=1.).contains(&self.brake) + } +} +pub struct Vehicle { + pub definition: VehicleDefinition, + pub body: RigidBodyHandle, + pub controller: DynamicRayCastVehicleController, + pub controls: Controls, +} +pub struct Simulation { + pub world: PhysicsWorld, + pub vehicles: BTreeMap, + next: u64, +} +impl Default for Simulation { + fn default() -> Self { + Self { + world: PhysicsWorld::default(), + vehicles: BTreeMap::new(), + next: 1, + } + } +} +impl Simulation { + pub fn ground(&mut self, triangles: impl Iterator) -> Result<(), String> { + let mut vertices = Vec::new(); + let mut indices = Vec::new(); + for triangle in triangles { + let n = vertices.len() as u32; + vertices.extend(triangle.map(Vector::from_array)); + indices.push([n, n + 1, n + 2]); + } + if vertices.is_empty() { + return Err("Vehicle collision world has no triangles".into()); + } + let collider = ColliderBuilder::trimesh(vertices, indices) + .map_err(|e| e.to_string())? + .friction(1.); + self.world.insert(RigidBodyBuilder::fixed(), collider); + self.world.step(); + Ok(()) + } + pub fn spawn( + &mut self, + definition: VehicleDefinition, + position: [f32; 3], + heading: f32, + ) -> Result { + definition.validate()?; + if !point(&position, 100000.) || !heading.is_finite() { + return Err("Invalid vehicle spawn pose".into()); + } + let d = &definition; + let (body, _) = self.world.insert( + RigidBodyBuilder::dynamic() + .translation(Vector::from_array(position)) + .rotation(Vector::Y * heading) + .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), + ); + let mut controller = DynamicRayCastVehicleController::new(body); + controller.index_up_axis = 1; + controller.index_forward_axis = 2; + let tuning = WheelTuning { + suspension_stiffness: d.suspension_stiffness, + suspension_compression: d.suspension_damping, + suspension_damping: d.suspension_damping, + max_suspension_travel: d.suspension_length, + friction_slip: d.tire_grip, + ..Default::default() + }; + for w in &d.wheels { + controller.add_wheel( + Vector::from_array(w.position), + -Vector::Y, + -Vector::X, + d.suspension_length, + w.radius, + &tuning, + ); + } + let id = self.next; + self.next += 1; + self.vehicles.insert( + id, + Vehicle { + definition, + body, + controller, + controls: Controls::default(), + }, + ); + Ok(id) + } + pub fn remove(&mut self, id: u64) { + if let Some(v) = self.vehicles.remove(&id) { + self.world.remove_body(v.body); + } + } + pub fn step(&mut self, dt: f32) { + if !dt.is_finite() || dt <= 0. { + return; + } + // Bound substeps even when a host uses a coarse fixed timestep. + let steps = (dt / 0.008334).ceil().clamp(1., 16.) as u32; + let h = dt.min(0.1) / steps as f32; + for _ in 0..steps { + self.world.integration_parameters.dt = h; + for v in self.vehicles.values_mut() { + let c = v.controls; + let speed = v.controller.current_vehicle_speed; + let d = &v.definition; + let driven = d.wheels.iter().filter(|w| w.driven).count() as f32; + for (wheel, def) in v.controller.wheels_mut().iter_mut().zip(&d.wheels) { + wheel.steering = if def.steering { + -c.steering * d.steering_angle / (1. + speed.abs() * 0.025) + } else { + 0. + }; + wheel.engine_force = + if def.driven && (speed.abs() < d.max_speed || c.throttle * speed < 0.) { + c.throttle * d.engine_force / driven + } else { + 0. + }; + wheel.brake = if c.handbrake && !def.steering { + d.brake_impulse + } else { + c.brake * d.brake_impulse + }; + } + let queries = self.world.broad_phase.as_query_pipeline_mut( + self.world.narrow_phase.query_dispatcher(), + &mut self.world.bodies, + &mut self.world.colliders, + QueryFilter::default().exclude_rigid_body(v.body), + ); + v.controller.update_vehicle(h, queries); + } + self.world.step(); + } + } + pub fn pose(&self, id: u64) -> Option<([f32; 3], [f32; 4])> { + let body = &self.world.bodies[self.vehicles.get(&id)?.body]; + Some((body.translation().to_array(), body.rotation().to_array())) + } + pub fn reset(&mut self, id: u64, position: [f32; 3], heading: f32) -> Result<(), String> { + if !point(&position, 100000.) || !heading.is_finite() { + return Err("Invalid reset pose".into()); + } + let v = self.vehicles.get_mut(&id).ok_or("Unknown vehicle")?; + let b = &mut self.world.bodies[v.body]; + b.set_translation(Vector::from_array(position), true); + b.set_rotation(Rotation::from_rotation_y(heading), true); + b.set_linvel(Vector::ZERO, true); + b.set_angvel(Vector::ZERO, true); + v.controls = Controls::default(); + Ok(()) + } + pub fn floor(&self, position: [f32; 3]) -> Option<[f32; 3]> { + let origin = Vector::from_array(position) + Vector::Y * 2.; + let (_, hit) = self.world.cast_ray( + &Ray::new(origin, -Vector::Y), + 12., + true, + QueryFilter::only_fixed(), + )?; + let floor = origin - Vector::Y * hit; + let shape = rapier3d::parry::shape::Capsule::new_y(0.55, 0.3); + if self + .world + .intersect_shape( + Pose::from_translation(floor + Vector::Y * 1.), + &shape, + QueryFilter::default(), + ) + .next() + .is_some() + { + return None; + } + Some(floor.to_array()) + } +} diff --git a/crates/skate-vehicles/tests/simulation.rs b/crates/skate-vehicles/tests/simulation.rs new file mode 100644 index 00000000..9f1c5d15 --- /dev/null +++ b/crates/skate-vehicles/tests/simulation.rs @@ -0,0 +1,136 @@ +use skate_vehicles::*; +fn definition() -> VehicleDefinition { + serde_json::from_str(include_str!("../../../mods/mario-kart/vehicle.json")).unwrap() +} +fn simulation() -> Simulation { + let mut s = Simulation::default(); + s.ground( + [ + [[-100., 0., -100.], [100., 0., 100.], [100., 0., -100.]], + [[-100., 0., -100.], [-100., 0., 100.], [100., 0., 100.]], + ] + .into_iter(), + ) + .unwrap(); + s +} +#[test] +fn kart_suspension_acceleration_braking_reset_and_cleanup() { + let mut s = simulation(); + let id = s.spawn(definition(), [0., 2., 0.], 0.).unwrap(); + for _ in 0..240 { + s.step(1. / 120.); + } + let height = s.pose(id).unwrap().0[1]; + assert!((0.2..1.).contains(&height), "settled chassis {height}"); + assert!( + s.vehicles[&id] + .controller + .wheels() + .iter() + .all(|w| w.raycast_info().is_in_contact) + ); + s.vehicles.get_mut(&id).unwrap().controls = Controls { + throttle: 1., + ..Default::default() + }; + for _ in 0..360 { + s.step(1. / 120.); + } + let speed = s.vehicles[&id].controller.current_vehicle_speed; + assert!(speed > 3., "forward speed {speed}"); + assert!(s.pose(id).unwrap().0[2] > 3.); + s.vehicles.get_mut(&id).unwrap().controls = Controls { + brake: 1., + ..Default::default() + }; + for _ in 0..240 { + s.step(1. / 120.); + } + assert!(s.vehicles[&id].controller.current_vehicle_speed.abs() < speed * 0.25); + s.reset(id, [5., 2., 5.], 1.).unwrap(); + assert_eq!(s.pose(id).unwrap().0, [5., 2., 5.]); + s.remove(id); + assert!(s.vehicles.is_empty()); + assert_eq!(s.world.bodies.len(), 1); +} +#[test] +fn definitions_and_controls_reject_invalid_inputs() { + let mut d = definition(); + d.mass = f32::NAN; + assert!(d.validate().is_err()); + let mut d = definition(); + d.model = "../outside.glb".into(); + assert!(d.validate().is_err()); + let mut d = definition(); + d.animations.drive = Some("drive".into()); + assert!(d.validate().is_err()); + let mut d = definition(); + d.wheels.clear(); + assert!(d.validate().is_err()); + assert!( + !Controls { + throttle: 2., + ..Default::default() + } + .valid() + ); + assert!( + !Controls { + brake: f32::NAN, + ..Default::default() + } + .valid() + ); + assert!(!package_path("C:/model.glb")); + assert!(!package_path("../model.glb")); + assert!(!package_path("model.glb#Scene1")); + assert!( + VehicleTuning { + max_speed: Some(-1.), + ..Default::default() + } + .apply(&definition()) + .is_err() + ); +} +#[test] +fn independent_vehicles_collide_and_remain_finite() { + let mut s = simulation(); + let a = s.spawn(definition(), [0., 1., -4.], 0.).unwrap(); + let b = s + .spawn(definition(), [0., 1., 4.], std::f32::consts::PI) + .unwrap(); + for id in [a, b] { + s.vehicles.get_mut(&id).unwrap().controls = Controls { + throttle: 1., + ..Default::default() + }; + } + for _ in 0..400 { + s.step(1. / 120.); + } + for id in [a, b] { + let (p, q) = s.pose(id).unwrap(); + assert!(p.iter().chain(q.iter()).all(|x| x.is_finite())); + } +} + +#[test] +fn positive_steering_turns_left() { + let mut s = simulation(); + let id = s.spawn(definition(), [0., 1., 0.], 0.).unwrap(); + for _ in 0..120 { + s.step(1. / 120.); + } + s.vehicles.get_mut(&id).unwrap().controls = Controls { + throttle: 1., + steering: 0.5, + ..Default::default() + }; + for _ in 0..180 { + s.step(1. / 120.); + } + let x = s.pose(id).unwrap().0[0]; + assert!(x < -0.1, "left steering x={x}"); +} diff --git a/docs/lua-modding.md b/docs/lua-modding.md index 5ff8cf55..f9f25263 100644 --- a/docs/lua-modding.md +++ b/docs/lua-modding.md @@ -278,3 +278,5 @@ viewport grows with the window and keeps mouse-wheel scrolling. Window sizes are retained in memory while the game runs, alongside position/collapse state. Each enabled-mod settings viewport also has a right-side scrollbar. Drag its thumb or click the track to jump; wheel and keyboard scrolling update the same thumb. It resizes with the window and dims when all settings fit. + +Vehicle SDK v1 adds Rapier vehicles and the Mario Kart example. See [vehicle-sdk.md](vehicle-sdk.md) for commands, animation hooks, limits and controls. Packages now allow 64 MiB for embedded models/animations. diff --git a/docs/vehicle-sdk.md b/docs/vehicle-sdk.md new file mode 100644 index 00000000..78c6a620 --- /dev/null +++ b/docs/vehicle-sdk.md @@ -0,0 +1,162 @@ +# Vehicle SDK v1 + +`skate-vehicles` pins Rapier 3D 0.35.3 and owns an independent, fixed-step rigid-body +world. The existing skating solver remains authoritative outside vehicles. Rapier +uses the installed map's collision triangles and resolves chassis collisions with +the map and other vehicles. Wheels use Rapier raycast suspension. Each host tick +is split into substeps of approximately 1/120 second or smaller. + +## First mod: Mario Kart + +Run PLAY-MARIO-KART.bat, enable **Mario Kart** in Mods, resume and press **F10**. +Walk/skate within four metres and press **E** to enter. **W/S** accelerate/reverse, +**A/D** steer, **Space** brakes, **left Shift** handbrakes, **R** rights/resets. +**E** exits once speed is below 3 m/s. F10 replaces a parked kart or resets an +occupied kart. Live engine, speed, brake, steering and grip controls appear in its +own draggable, resizable, scrollable mod window. + +Controller: **Y** enter/exit, **RT/LT** accelerate/reverse, **left stick** steer, +**A** brake, **B** handbrake. F10 and R remain keyboard shortcuts in this example. +Release controls before switching between driving and skating. + +`mods/mario-kart/kart.glb` is generated from the user-supplied ZIP. It is excluded +from Git. The importer preserves meshes/materials/embedded textures, normalizes +the model to metres and creates named wheel pivots. To regenerate: + +```powershell +python tools/prepare_mario_kart.py C:/Users/Daddy/Downloads/kart-de-mario.zip mods/mario-kart +``` + +NumPy is required by the importer. Run Build-VehicleSDK.ps1 to build/stage the +executable and original mod sources. The helper preserves existing modified mod +files; copy updates manually when it reports a preserved file. The mods directory +sits beside the executable. No model or game assets are committed. + +## Lua API + +All keys belong to the calling mod. Commands cannot control another mod's vehicle. +The host allows eight vehicles per mod and 32 total. Definitions are validated +before allocation. Commands from failed Lua callbacks are discarded. Disabling, +removing or reloading a mod removes its vehicles and releases its driver. + +```lua +sdk.vehicle.spawn('kart', 'vehicle.json', {x, y, z}, heading_radians) +sdk.vehicle.enter('kart') +sdk.vehicle.control('kart', { + throttle = 1, -- -1 reverse through +1 forward + steering = 0.25, -- -1 right through +1 left + brake = 0, -- 0..1 + handbrake = false, +}) +sdk.vehicle.tune('kart', {engine_force=1800, max_speed=25, tire_grip=3}) +local car = sdk.vehicle.read('kart') +sdk.vehicle.exit('kart') +sdk.vehicle.reset('kart', {x, y, z}, heading_radians) +sdk.vehicle.remove('kart') +``` + +`read` returns nil for an absent vehicle, otherwise position, quaternion rotation +(x/y/z/w), heading, signed speed in m/s, occupied, ready and phase. Phases are +`parked`, `entering`, `driving`, `exiting`. Snapshots update at host callback boundaries; +a spawn command is visible on a later callback. `input()` returns normalized +keyboard/controller throttle, steering, brake, handbrake, interact and pad_buttons. +Do not pass the full input table to `control`; copy only its four control fields. +`control` must be refreshed; after 0.25 seconds without a command, throttle clears +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). +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`. +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. + +## 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`, +`model_offset` and `model_yaw` affect the model only, not its collider. + +Each wheel defines a chassis-local suspension mounting `position`, radius, +steering/driven flags and optional unique model node name. Support is 2..8 wheels +with at least one driven wheel. Wheel nodes should have local +Y steering and +X +axle axes. Pivots must be correctly positioned; the host adds suspension travel, +steering and spin to their rest transforms. The supplied kart importer does this. + +Seat and exit are chassis-local offsets. Exit checks the nearby fixed ground and +standing clearance; if unavailable, it returns the rider to the saved entry spot. +The camera follows using camera_distance and camera_height. Model GLBs must embed +all textures and buffers; filesystem/network references inside GLBs are rejected. +A package may total 64 MiB; a model is limited to 32 MiB and an animation file to +16 MiB. Paths stay within the owning mod folder. + +## Rider animation support + +No animations are supplied. All animation slots default 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. + +Set `animations.file` to a package-relative JSON file and set slot names: + +```json +"animations": { + "file": "rider.json", + "enter": "get_in", + "exit": "get_out", + "drive": "drive_loop", + "idle": "seated_idle", + "reverse": "look_back", + "brake": "braking", + "steer_left": "turn_left", + "steer_right": "turn_right" +} +``` + +The file is: + +```json +{ + "version": 1, + "bone_names": ["EXACT_NAMES_FROM_sdk.animation.info().bone_names"], + "clips": { + "drive_loop": { + "fps": 30, + "frames": [["ONE_16_NUMBER_MATRIX_PER_BONE"]] + } + } +} +``` + +The strings in the example frames/bone list are explanatory placeholders. +Actual frames must contain one 16-number, column-major, native model-space matrix +per bone, in the exact native skeleton order. Matrices use metres and +Y up, +matching the existing authored animation convention. They are full poses, not +additive deltas. Obtain names from `sdk.animation.info()`; do not invent bone names. +The renderer performs the existing native-to-GLB skin-basis conversion. Author the +pose relative to the seat offset; enter/exit root motion is visual and does not +move the Rapier chassis. The native skater is restored through its teleport path +when exiting. Files with a mismatched skeleton, invalid matrices or missing named +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. +Edit vehicle.json or rider.json and reload the mod to install new clips. + +## Current boundaries + +The native skating simulation is suspended while driving, and normal skating +resumes on exit. Vehicle time pauses with Escape and during replay. Replay entry +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. + +Verification uses headless Rapier tests and window-free Lua tests. The game is +not launched automatically; rendering, entry/exit and handling need manual playtesting. diff --git a/mods/mario-kart/README.md b/mods/mario-kart/README.md new file mode 100644 index 00000000..1fd07ef9 --- /dev/null +++ b/mods/mario-kart/README.md @@ -0,0 +1,16 @@ +# Mario Kart vehicle mod +Enable this mod in Mods, resume, then press F10 to spawn the kart in front of you. +E (controller Y) enters/exits within 4 metres (slow below 3 m/s to exit). WASD drives; Space brakes; +left Shift is the handbrake; R rights/resets the kart. F10 replaces the kart. + +Controller: RT/LT accelerate/reverse, left stick steers, A brakes, B handbrakes. + +The GLB is prepared from your supplied ZIP with tools/prepare_mario_kart.py and is +kept out of Git. To rebuild it: python tools/prepare_mario_kart.py PATH_TO_ZIP mods/mario-kart +The script requires NumPy. No third-party model is downloaded or bundled in source commits. + +Edit vehicle.json for dimensions, wheels, suspension, seat, exit and camera offsets. +Animation slots are intentionally null. With no animation the native skater is hidden +while driving. Supply a compatible vehicle animation file and name its clips in the +enter/exit/drive/idle/reverse/brake/steer_left/steer_right slots to display the rider. +See docs/vehicle-sdk.md for the animation format and lifecycle details. diff --git a/mods/mario-kart/main.lua b/mods/mario-kart/main.lua new file mode 100644 index 00000000..16fddc7c --- /dev/null +++ b/mods/mario-kart/main.lua @@ -0,0 +1,42 @@ +-- The original Lua/JSON example uses the user's separately supplied kart model. +local previous={} +local interact_was_down=false +local function pressed(key) + local down=sdk.input.down(key);local edge=down and not previous[key];previous[key]=down;return edge +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}) +end +local function nearby() + local p=sdk.player.read();local h=p.heading or 0 + return {p.position[1]+math.sin(h)*3,p.position[2]+1,p.position[3]+math.cos(h)*3},h +end +local function spawn() + local car=sdk.vehicle.read('kart') + if car and car.occupied then sdk.vehicle.reset('kart',{car.position[1],car.position[2]+1,car.position[3]},car.heading);return end + if car then sdk.vehicle.remove('kart') end + local p,h=nearby();sdk.vehicle.spawn('kart','vehicle.json',p,h);tune() +end +return { + on_load=function() sdk.ui.text('kart-hud','MARIO KART | F10: spawn kart | E: enter/exit') end, + on_fixed_update=function() + if pressed('F10') then spawn() end + local car=sdk.vehicle.read('kart') + if not car then return end + local interact=sdk.vehicle.input().interact + local interact_pressed=interact and not interact_was_down;interact_was_down=interact + if interact_pressed then if car.occupied then sdk.vehicle.exit('kart') else sdk.vehicle.enter('kart') end end + if pressed('KeyR') then + local p,h=nearby() + if car.occupied then p={car.position[1],car.position[2]+1,car.position[3]};h=car.heading end + sdk.vehicle.reset('kart',p,h) + end + if car.phase=='driving' then sdk.vehicle.control('kart',sdk.vehicle.input() and { + throttle=sdk.vehicle.input().throttle,steering=sdk.vehicle.input().steering, + brake=sdk.vehicle.input().brake,handbrake=sdk.vehicle.input().handbrake}) end + if sdk.settings.hud then sdk.ui.text('kart-hud',string.format('MARIO KART | %.0f km/h | %s | E enter/exit | WASD drive | Space brake | Shift handbrake | R reset',math.abs(car.speed)*3.6,car.ready and car.phase or 'loading')) else sdk.scene.remove('kart-hud') end + end, + on_settings=function() if sdk.vehicle.read('kart') then tune() end end, + on_event=function(e) if e.name=='world_changed' then previous={};sdk.ui.text('kart-hud','MARIO KART | F10: spawn kart in this map') end end, +} diff --git a/mods/mario-kart/mod.json b/mods/mario-kart/mod.json new file mode 100644 index 00000000..d3071905 --- /dev/null +++ b/mods/mario-kart/mod.json @@ -0,0 +1,62 @@ +{ + "id": "community.mario-kart", + "api": 1, + "name": "Mario Kart", + "version": "1.0.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. Animation hooks are in vehicle.json.", + "entry": "main.lua", + "settings": { + "engine": { + "type": "number", + "label": "Engine force", + "description": "Applied live to the kart.", + "default": 1800, + "min": 100, + "max": 8000, + "step": 100 + }, + "speed": { + "type": "number", + "label": "Maximum speed (m/s)", + "description": "Applied live to the kart.", + "default": 25, + "min": 5, + "max": 60, + "step": 1 + }, + "brake": { + "type": "number", + "label": "Brake impulse", + "description": "Applied live to the kart.", + "default": 90, + "min": 10, + "max": 300, + "step": 10 + }, + "steering": { + "type": "number", + "label": "Steering angle", + "description": "Applied live to the kart.", + "default": 0.5, + "min": 0.1, + "max": 1, + "step": 0.05 + }, + "grip": { + "type": "number", + "label": "Tire grip", + "description": "Applied live to the kart.", + "default": 3, + "min": 0.5, + "max": 8, + "step": 0.25 + }, + "hud": { + "type": "boolean", + "label": "Show driving HUD", + "description": "Show controls, speed and vehicle state.", + "default": true + } + } +} diff --git a/mods/mario-kart/vehicle.json b/mods/mario-kart/vehicle.json new file mode 100644 index 00000000..870e9300 --- /dev/null +++ b/mods/mario-kart/vehicle.json @@ -0,0 +1,87 @@ +{ + "version": 1, + "model": "kart.glb", + "half_extents": [ + 0.67, + 0.18, + 1.05 + ], + "mass": 220, + "engine_force": 1800, + "max_speed": 25, + "brake_impulse": 90, + "steering_angle": 0.5, + "suspension_length": 0.25, + "suspension_stiffness": 30, + "suspension_damping": 4, + "tire_grip": 3, + "wheels": [ + { + "node": "wheel_rl", + "position": [ + 0.5815213608992547, + 0.057323790779298445, + -0.673016238225116 + ], + "radius": 0.3338663063949073, + "steering": false, + "driven": true + }, + { + "node": "wheel_rr", + "position": [ + -0.5302057197312625, + 0.06093891045413358, + -0.6730162402958683 + ], + "radius": 0.3338663063949072, + "steering": false, + "driven": true + }, + { + "node": "wheel_fl", + "position": [ + 0.5573244172948093, + 0.016883687342375997, + 0.4261990809959044 + ], + "radius": 0.31535000391066886, + "steering": true, + "driven": false + }, + { + "node": "wheel_fr", + "position": [ + -0.5201046643741327, + 0.01739195285686146, + 0.4231450809914261 + ], + "radius": 0.31535000391066886, + "steering": true, + "driven": false + } + ], + "seat": [ + 0, + 0.15, + -0.15 + ], + "exit": [ + 1.8, + 0, + 0 + ], + "camera_distance": 5, + "camera_height": 2, + "animations": { + "file": null, + "enter": null, + "exit": null, + "drive": null, + "idle": null, + "reverse": null, + "brake": null, + "steer_left": null, + "steer_right": null + } +} diff --git a/sdk/skate.lua b/sdk/skate.lua index 7556ed52..6bb2632b 100644 --- a/sdk/skate.lua +++ b/sdk/skate.lua @@ -109,3 +109,50 @@ function sdk.animation.replace(path) end sdk.trainer = {} ---@param tuning TrainerTuning Owned, reversible native tuning. Conflicting owner is rejected. function sdk.trainer.apply(tuning) end + +---@class VehicleControls +---@field throttle? number -1..1, reverse to forward +---@field steering? number -1..1, right to left +---@field brake? number 0..1 +---@field handbrake? boolean +---@class VehicleTuning +---@field engine_force? number 0..100000 N +---@field max_speed? number 1..100 m/s, engine limit +---@field brake_impulse? number 0..10000 +---@field steering_angle? number 0.01..1.2 radians +---@field tire_grip? number 0.1..20 +---@class VehicleSnapshot +---@field position number[] metres +---@field rotation number[] quaternion xyzw +---@field heading number radians +---@field speed number signed m/s +---@field phase 'parked'|'entering'|'driving'|'exiting' +---@field occupied boolean +---@field ready boolean +sdk.vehicle = {} +---@param key string Owner-local key +---@param definition string Package-relative vehicle JSON +---@param position number[] World position +---@param heading? number Radians +function sdk.vehicle.spawn(key,definition,position,heading) end +---@param key string +---@return VehicleSnapshot? +function sdk.vehicle.read(key) end +---@param key string +---@param controls VehicleControls Refresh every fixed tick +function sdk.vehicle.control(key,controls) end +---@param key string +---@param tuning VehicleTuning +function sdk.vehicle.tune(key,tuning) end +---@param key string +function sdk.vehicle.enter(key) end +---@param key string +function sdk.vehicle.exit(key) end +---@param key string +---@param position number[] +---@param heading? number +function sdk.vehicle.reset(key,position,heading) end +---@param key string +function sdk.vehicle.remove(key) end +---@return table Normalized keyboard/controller axes plus interact and pad_buttons +function sdk.vehicle.input() end diff --git a/tools/prepare_mario_kart.py b/tools/prepare_mario_kart.py new file mode 100644 index 00000000..8be71f32 --- /dev/null +++ b/tools/prepare_mario_kart.py @@ -0,0 +1,42 @@ +"""Prepare the user-supplied kart GLB; never downloads or commits third-party assets.""" +import argparse, itertools, json, struct, zipfile +from pathlib import Path +import numpy as np +parser=argparse.ArgumentParser(); parser.add_argument("archive",type=Path);parser.add_argument("output",type=Path);args=parser.parse_args() +with zipfile.ZipFile(args.archive) as archive: data=archive.read("source/Kart de mario.glb") +n=struct.unpack_from("