mirror of
https://github.com/SK8-ENGINE/skate-3-rust-engine.git
synced 2026-10-26 19:47:44 +00:00
Merge Lua modding SDK, packaged mods and vehicle support
This commit is contained in:
commit
57742e0e54
97 files changed
+9637
-42
No files matched your search
@@ -1,6 +1,7 @@
|
||||
//! Complete TU3 Toolkit_AntiFlipTorque 82D94190, distinct from heading/manual.
|
||||
use super::vector::{cross, dot3, normalize};
|
||||
use crate::{point_graph::PointGraph, trigonometry::acos};
|
||||
#[derive(Clone)]
|
||||
pub struct AntiFlipSettings {
|
||||
pub axis_96_response: PointGraph<8>, // +1832/+1864
|
||||
pub axis_64_response: PointGraph<8>, // +1912/+1944
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
//! The research index has not recovered its original name. Keep its four
|
||||
//! caller-supplied scalar arguments explicit instead of inventing units.
|
||||
use super::vector::{clamp, dot3, normalize};
|
||||
#[derive(Clone)]
|
||||
pub struct GroundForceSettings {
|
||||
pub range_1220: f32,
|
||||
pub speed_scale_1224: f32,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
//! AntiFlipTorque; the two are distinct. The caller owns board+284 history.
|
||||
use crate::{physics::reciprocal_sqrt::estimate, point_graph::PointGraph, trigonometry::acos};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct HeadingSettings {
|
||||
pub manual_wrong_wheel_scalar: f32, // +904
|
||||
pub manual_damping: f32, // +908
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
use super::vector::{clamp, dot3};
|
||||
use crate::{point_graph::PointGraph, trigonometry::acos};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct SlideFrictionSettings {
|
||||
pub angle_response: PointGraph<16>, // +196/+260
|
||||
pub speed_response: PointGraph<8>, // +324/+356
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
use super::vector::{clamp, dot3, normalize};
|
||||
use crate::point_graph::PointGraph;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct SpeedModelSettings {
|
||||
/// physics_speedmodel layout +0,+8,+16,+20,+24.
|
||||
pub negative_gain: f32,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Complete TU3 Toolkit_CalcStraightenOutTorque 82D92B60.
|
||||
use super::vector::{clamp, cross, dot3};
|
||||
use crate::point_graph::PointGraph;
|
||||
#[derive(Clone)]
|
||||
pub struct StraightenSettings {
|
||||
pub time_response: PointGraph<16>, // settings +768/+832
|
||||
pub opposite_turn_limit: f32, // +896
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
//! Export the exact native order for an authored vehicle animation package.
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let args: Vec<_> = std::env::args_os().skip(1).collect();
|
||||
if args.len() != 2 { return Err("Usage: vehicle_bone_names OnBoard.abin bone-names.json".into()); }
|
||||
let bank = skate_data::abin::Bank::load(std::path::Path::new(&args[0]))?;
|
||||
let hierarchy = bank.hierarchy().ok_or("Bank has no skeleton hierarchy")?;
|
||||
std::fs::write(&args[1], serde_json::to_vec_pretty(&hierarchy.bone_names)?)?;
|
||||
println!("Exported {} bone names", hierarchy.bone_names.len());
|
||||
Ok(())
|
||||
}
|
||||
@@ -29,4 +29,7 @@ skate-data.workspace = true
|
||||
half = "=2.7.1"
|
||||
serde_json.workspace = true
|
||||
serde.workspace = true
|
||||
skate-mods = { path = "../skate-mods" }
|
||||
|
||||
skate-vehicles = { path = "../skate-vehicles" }
|
||||
skate-net = { path = "../skate-net" }
|
||||
@@ -231,3 +231,48 @@ 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], steering:Option<(&[Mat4],f32)>) {
|
||||
let board=world.resource::<crate::physics::SkaterRuntime>().animation.evaluator.frames.bone_names.iter().position(|n|n=="SKATEBOARD_ROOT");
|
||||
world.resource_scope(|world,animation:Mut<AnimationStatus>| {
|
||||
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};
|
||||
let mut target=Transform::from_matrix(local);
|
||||
if let Some((turn,weight))=steering {
|
||||
if let Some(global)=turn.get(binding.bone).map(|m|*m*basis) {
|
||||
let local=if let Some(parent)=binding.parent_bone {turn.get(parent).map(|p|(*p*basis).inverse()*global).unwrap_or(global)} else {global};
|
||||
target=crate::presentation::blend(target,Transform::from_matrix(local),weight.clamp(0.,1.));
|
||||
}
|
||||
}
|
||||
if let Some(mut transform)=world.get_mut::<Transform>(binding.entity) {*transform=target;if Some(binding.bone)==board {transform.scale=Vec3::splat(0.001);}}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn capture_vehicle_visual(world: &mut World) -> Vec<(Entity, Transform)> {
|
||||
let mut ids:Vec<_>=world.resource::<AnimationStatus>().bindings.iter().map(|b|b.entity).collect();
|
||||
ids.extend(world.query_filtered::<Entity,With<crate::world::PlayerRoot>>().iter(world));
|
||||
ids.into_iter().filter_map(|id|world.get::<Transform>(id).copied().map(|t|(id,t))).collect()
|
||||
}
|
||||
pub(crate) fn blend_vehicle_visual(world:&mut World, from:&[(Entity,Transform)], alpha:f32) {
|
||||
for &(id,previous) in from {if let Some(mut current)=world.get_mut::<Transform>(id) {*current=crate::presentation::blend(previous,*current,alpha);}}
|
||||
}
|
||||
|
||||
/// Read the final displayed hierarchy, including vehicle steering and stance blends.
|
||||
pub(crate) fn network_visual(world: &mut World) -> skate_net::packed::PoseState {
|
||||
let root=world.query_filtered::<&Transform,With<PlayerRoot>>().iter(world).next().copied().unwrap_or_default();
|
||||
let status=world.resource::<AnimationStatus>();
|
||||
let mut locals=std::collections::BTreeMap::new();
|
||||
for b in &status.bindings {if let Some(t)=world.get::<Transform>(b.entity) {locals.entry(b.bone).or_insert((b.parent_bone,t.to_matrix()));}}
|
||||
fn global(i:usize,locals:&std::collections::BTreeMap<usize,(Option<usize>,Mat4)>,depth:usize)->Mat4 {
|
||||
if depth>128 {return Mat4::IDENTITY;}
|
||||
let Some(&(parent,m))=locals.get(&i) else {return Mat4::IDENTITY;};
|
||||
parent.map_or(m,|p|global(p,locals,depth+1)*m)
|
||||
}
|
||||
let anchors=crate::physics::network::anchors(world.resource::<SkaterRuntime>());
|
||||
let basis=render_basis().inverse();
|
||||
skate_net::packed::PoseState{root:crate::physics::network::pose(root.to_matrix()),bones:anchors.into_iter().map(|i|skate_net::Bone{index:i as u16,pose:crate::physics::network::pose(if locals.contains_key(&i) {global(i,&locals,0)*basis} else {native_matrix(world.resource::<SkaterRuntime>().render_pose[i])})}).collect()}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ mod authored_clips;
|
||||
pub(crate) struct PoseEvaluator {
|
||||
pub frames: AnimationFrames,
|
||||
authored: authored_clips::Replacements,
|
||||
mod_clips: std::sync::RwLock<std::collections::BTreeMap<String, authored_clips::Replacements>>,
|
||||
}
|
||||
|
||||
impl PoseEvaluator {
|
||||
@@ -29,6 +30,7 @@ impl PoseEvaluator {
|
||||
Ok(Self {
|
||||
frames: AnimationFrames::from_banks(banks)?,
|
||||
authored: Default::default(),
|
||||
mod_clips: Default::default(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -37,9 +39,29 @@ impl PoseEvaluator {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Reuses the existing constrained body-clip parser; never replaces board/trajectory data.
|
||||
pub(crate) fn install_mod_clips(&self, owner: &str, text: &str) -> Result<(), String> {
|
||||
let clips = authored_clips::Replacements::parse(text, &self.frames)?;
|
||||
let mut all = self.mod_clips.write().map_err(|_| "Animation override lock poisoned")?;
|
||||
for (id, other) in all.iter() {
|
||||
if id != owner && clips.0.keys().any(|k| other.0.contains_key(k)) {
|
||||
return Err(format!("Animation slots conflict with mod {id}"));
|
||||
}
|
||||
}
|
||||
all.insert(owner.to_owned(), clips);
|
||||
Ok(())
|
||||
}
|
||||
pub(crate) fn remove_mod_clips(&self, owner: &str) {
|
||||
if let Ok(mut all) = self.mod_clips.write() { all.remove(owner); }
|
||||
}
|
||||
pub(crate) fn clear_mod_clips(&self) {
|
||||
if let Ok(mut all) = self.mod_clips.write() { all.clear(); }
|
||||
}
|
||||
|
||||
/// Executes the ordered stock tree. This uses the native immediate ACS
|
||||
/// arithmetic; the host does not recreate packed animation job commands.
|
||||
pub fn evaluate(&self, commands: &[PoseCommand]) -> Result<Vec<Sqt>, String> {
|
||||
let mod_clips = self.mod_clips.read().map_err(|_| "Animation override lock poisoned")?;
|
||||
let mut stack: Vec<Vec<Sqt>> = Vec::new();
|
||||
for command in commands {
|
||||
match command {
|
||||
@@ -91,7 +113,7 @@ impl PoseEvaluator {
|
||||
loops,
|
||||
} => {
|
||||
let stock = self.frames.clip(name)?;
|
||||
let clip = self.authored.clip(&stock.name).unwrap_or(stock);
|
||||
let clip = mod_clips.values().find_map(|c|c.clip(&stock.name)).or_else(||self.authored.clip(&stock.name)).unwrap_or(stock);
|
||||
let mut pose = sample_clip(clip, *time)?;
|
||||
if self.frames.has_trajectory {
|
||||
let previous = sample_bone(clip, *previous_time, 0)?;
|
||||
|
||||
@@ -4,7 +4,7 @@ use bevy::math::{Mat4, Quat, Vec3};
|
||||
use serde::Deserialize;
|
||||
use std::collections::BTreeMap;
|
||||
#[derive(Default)]
|
||||
pub(super) struct Replacements(BTreeMap<String, ClipFrames>);
|
||||
pub(super) struct Replacements(pub(super) BTreeMap<String, ClipFrames>);
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct File {
|
||||
@@ -28,7 +28,7 @@ impl Replacements {
|
||||
};
|
||||
Self::parse(&text, frames).map_err(|e| format!("{}: {e}", path.display()))
|
||||
}
|
||||
fn parse(text: &str, frames: &AnimationFrames) -> Result<Self, String> {
|
||||
pub(super) fn parse(text: &str, frames: &AnimationFrames) -> Result<Self, String> {
|
||||
let file: File = serde_json::from_str(text).map_err(|e| e.to_string())?;
|
||||
if file.version != 1 || file.bone_names != frames.bone_names || file.clips.len() > 32 {
|
||||
return Err("Authored clip format or skeleton mismatch".into());
|
||||
|
||||
@@ -40,6 +40,8 @@ pub(crate) fn build(
|
||||
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();
|
||||
crate::custom_models::register_source(&mut app);
|
||||
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 {
|
||||
@@ -111,6 +113,7 @@ pub(crate) fn build(
|
||||
));
|
||||
app.add_plugins((crate::session_marker::SessionMarkerPlugin, crate::customiser::CustomiserPlugin));
|
||||
app.add_plugins(crate::custom_models::CustomModelsPlugin);
|
||||
app.add_plugins(crate::modding::ModdingPlugin);
|
||||
crate::teleport_menu::install(&mut app);
|
||||
app.add_plugins(crate::updater::UpdaterPlugin);
|
||||
app.add_plugins(crate::multiplayer::MultiplayerPlugin);
|
||||
|
||||
@@ -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,9 +49,13 @@ fn spawn(mut commands: Commands, config: Res<Config>, retail: Res<crate::retail_
|
||||
}
|
||||
}
|
||||
|
||||
fn present(mut runtime: ResMut<CameraRuntime>, windows: Query<&Window>,
|
||||
#[derive(Default)]
|
||||
pub(crate) struct VehicleCameraBlend { active:bool, previous:Option<Transform>, from:Option<Transform>, elapsed:f32 }
|
||||
|
||||
pub(crate) fn present(vehicles: Res<crate::modding::vehicles::Vehicles>, mut runtime: ResMut<CameraRuntime>, windows: Query<&Window>,
|
||||
history: Res<crate::presentation::Presentation>, time: Res<Time<Fixed>>,
|
||||
replay: Res<crate::replay::Replay>,
|
||||
virtual_time: Res<Time<Virtual>>, mut vehicle_blend: Local<VehicleCameraBlend>,
|
||||
customiser: Option<Res<crate::customiser::Customiser>>,
|
||||
mut cameras: Query<(&mut Camera, &mut Transform, &mut Projection), With<GameplayCamera>>) {
|
||||
if let Ok(window) = windows.single() {
|
||||
@@ -75,6 +79,21 @@ fn present(mut runtime: ResMut<CameraRuntime>, 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(); }
|
||||
}
|
||||
let vehicle=vehicles.camera();
|
||||
let active=vehicle.is_some();
|
||||
if active!=vehicle_blend.active {
|
||||
vehicle_blend.from=vehicle_blend.previous;vehicle_blend.elapsed=0.;vehicle_blend.active=active;
|
||||
}
|
||||
if let Some(pose)=vehicle {*transform=pose;}
|
||||
vehicle_blend.elapsed+=virtual_time.delta_secs();
|
||||
if let Some(from)=vehicle_blend.from {
|
||||
if vehicle_blend.elapsed<0.5 && from.translation.distance(transform.translation)<50. {
|
||||
let t=(vehicle_blend.elapsed/0.5).clamp(0.,1.);*transform=crate::presentation::blend(from,*transform,t*t*(3.-2.*t));
|
||||
} else {vehicle_blend.from=None;}
|
||||
}
|
||||
// Vehicle motion is already interpolated with the rider/chassis. A separate
|
||||
// follow filter introduces relative motion and makes fixed ticks visible.
|
||||
vehicle_blend.previous=Some(*transform);
|
||||
camera.is_active = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
#[path = "motion_toggle_execute.rs"]
|
||||
mod toggle_execution;
|
||||
#[path = "motion_fakie_hold.rs"]
|
||||
mod fakie_hold;
|
||||
#[path = "motion_air_execute.rs"]
|
||||
mod air_execution;
|
||||
#[path = "motion_execute.rs"]
|
||||
@@ -103,6 +105,8 @@ pub struct MotionHost {
|
||||
pub applying_body_tilt: bool,
|
||||
pub crouching_physical: Option<skate_core::animation::crouching::Physical>,
|
||||
pub body_tilt_physical: Option<skate_core::animation::body_tilt::Physical>,
|
||||
pub hold_fakie: bool,
|
||||
automatic_fakie_conditions: Vec<bool>,
|
||||
pub fakie_physical: Option<skate_core::animation::riding_fakie::Physical>,
|
||||
/// PhysOutAnimation158/157, used directly by IsRidingGoofy82BA5AA8.
|
||||
pub physical_stance: Option<(bool, bool)>,
|
||||
@@ -252,6 +256,8 @@ impl MotionHost {
|
||||
applying_body_tilt: false,
|
||||
crouching_physical: None,
|
||||
body_tilt_physical: None,
|
||||
hold_fakie: false,
|
||||
automatic_fakie_conditions: fakie_hold::bind(&graph.binding),
|
||||
fakie_physical: None,
|
||||
physical_stance: None,
|
||||
flags: Default::default(),
|
||||
@@ -306,6 +312,12 @@ impl MotionHost {
|
||||
}
|
||||
impl ConditionHost for MotionHost {
|
||||
fn condition_activation(&mut self, condition: usize, frame: &Frame) -> u32 {
|
||||
// Gate only the authored automatic switch transitions. Other fakie
|
||||
// conditions (tricks, pushes, dismounts) still see the real stance.
|
||||
if self.remap.conditions.get(condition).is_some_and(|&id|
|
||||
fakie_hold::blocked(self.hold_fakie, &self.automatic_fakie_conditions, id)) {
|
||||
return 0;
|
||||
}
|
||||
let result = self
|
||||
.remap
|
||||
.conditions
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
//! Trainer gates for the stock automatic fakie-to-switch animation transitions.
|
||||
//! FakieTurn is player steering and must never be suppressed by this option.
|
||||
use skate_data::state_graph::binding::{Binding, Node};
|
||||
|
||||
pub(super) fn bind(binding: &Binding) -> Vec<bool> {
|
||||
let mut gates = vec![false; binding.operations.len()];
|
||||
for transition in &binding.transitions {
|
||||
let Some(target) = transition.target else { continue; };
|
||||
let mut names = Vec::new();
|
||||
let mut state = Some(target);
|
||||
while let Some(id) = state {
|
||||
names.push(binding.states[id].name.as_str());
|
||||
state = binding.states[id].parent;
|
||||
}
|
||||
names.reverse();
|
||||
if names != ["Motion", "OnBoard", "OnGround", "RidingIdle", "Riding", "Turning", "Switch"] {
|
||||
continue;
|
||||
}
|
||||
if let Some(expression) = transition.expression {
|
||||
mark(binding, expression, &mut gates);
|
||||
}
|
||||
}
|
||||
gates
|
||||
}
|
||||
fn mark(binding: &Binding, expression: usize, gates: &mut [bool]) {
|
||||
for child in &binding.expressions[expression].children {
|
||||
match *child {
|
||||
Node::Operation(id) if binding.operations[id].name == "IsRidingFakie" => gates[id] = true,
|
||||
Node::Expression(id) => mark(binding, id, gates),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
pub(super) fn blocked(enabled: bool, gates: &[bool], operation: usize) -> bool {
|
||||
enabled && gates.get(operation).copied().unwrap_or(false)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use skate_data::state_graph::{GraphAttribute, GraphElement, StateGraph};
|
||||
struct Tree(&'static str, Vec<(&'static str, &'static str)>, Vec<Tree>);
|
||||
fn state(name: &'static str, children: Vec<Tree>) -> Tree { Tree("state", vec![("name", name)], children) }
|
||||
fn condition() -> Tree { Tree("condition", vec![("name", "IsRidingFakie")], vec![]) }
|
||||
fn transition(target: &'static str) -> Tree {
|
||||
Tree("transition", vec![("target", target)], vec![Tree("expression", vec![("op", "and")], vec![condition()])])
|
||||
}
|
||||
fn add(tree: Tree, graph: &mut StateGraph) -> usize {
|
||||
let id = graph.elements.len();
|
||||
graph.elements.push(GraphElement { source_offset: id, tag: tree.0.into(),
|
||||
attributes: tree.1.into_iter().map(|(name, text)| GraphAttribute {
|
||||
name: name.into(), text: text.into(), float_bits: 0, boolean_byte: 0,
|
||||
}).collect(), children: vec![] });
|
||||
let children = tree.2.into_iter().map(|child| add(child, graph)).collect();
|
||||
graph.elements[id].children = children;
|
||||
id
|
||||
}
|
||||
#[test]
|
||||
fn gates_idle_and_bump_switch_but_preserves_other_fakie_conditions() {
|
||||
let tree = state("Motion", vec![state("OnBoard", vec![state("OnGround", vec![
|
||||
state("RidingIdle", vec![
|
||||
state("Bumped", vec![transition("Riding.Turning.Switch")]),
|
||||
state("Riding", vec![state("Turning", vec![
|
||||
state("Idle", vec![transition("Switch")]), state("Switch", vec![]),
|
||||
state("Push", vec![Tree("expression", vec![], vec![condition()])]),
|
||||
])]),
|
||||
]),
|
||||
// Same leaf name elsewhere must not be treated as automatic switching.
|
||||
state("Other", vec![state("Idle", vec![transition("Switch")]), state("Switch", vec![])]),
|
||||
])])]);
|
||||
let mut graph = StateGraph { elements: vec![] }; add(tree, &mut graph);
|
||||
let binding = Binding::from_graph(&graph).unwrap();
|
||||
let gates = bind(&binding);
|
||||
assert_eq!(gates.iter().filter(|&&gate| gate).count(), 2);
|
||||
assert_eq!(gates.iter().filter(|&&gate| !gate).count(), 2);
|
||||
for (id, gate) in gates.iter().enumerate() {
|
||||
assert!(!blocked(false, &gates, id), "disabled trainer must preserve stock transitions");
|
||||
assert_eq!(blocked(true, &gates, id), *gate);
|
||||
}
|
||||
assert!(!blocked(true, &gates, usize::MAX));
|
||||
}
|
||||
}
|
||||
@@ -218,7 +218,7 @@ fn setup(
|
||||
BackgroundColor(Color::srgb(0.035,0.055,0.08)))).with_children(|panel| {
|
||||
panel.spawn((Text::new("GAME MENU"),TextFont {font_size:32.,..default()},TextColor(Color::WHITE)));
|
||||
panel.spawn((Text::new("GAMEPLAY & GRAPHICS"),TextFont {font_size:16.,..default()},TextColor(Color::srgb(0.4,0.85,0.85))));
|
||||
for i in 0..15 {
|
||||
for i in 0..16 {
|
||||
panel.spawn((Button, MenuRow(i), Node {width:percent(100),min_height:px(32),padding:UiRect::all(px(6)),align_items:AlignItems::Center,border_radius:BorderRadius::all(px(5)),..default()},
|
||||
BackgroundColor(Color::srgb(0.08,0.11,0.15)))).with_children(|row| {
|
||||
row.spawn((MenuLabel(i),Text::new(""),TextFont {font_size:18.,..default()},TextColor(Color::WHITE)));
|
||||
@@ -263,6 +263,7 @@ pub(crate) fn interact(
|
||||
mut transition: ResMut<crate::map_transition::MapTransition>,
|
||||
mut customiser: ResMut<crate::customiser::Customiser>,
|
||||
mut custom_models: ResMut<crate::custom_models::CustomModels>,
|
||||
(mut mods, panel): (ResMut<crate::modding::ModMenu>, Res<crate::modding::EnabledPanel>),
|
||||
nav: Res<crate::customiser::Navigation>,
|
||||
mut physics: ResMut<crate::physics::GamePhysics>,
|
||||
keys: Res<ButtonInput<KeyCode>>,
|
||||
@@ -280,7 +281,7 @@ pub(crate) fn interact(
|
||||
time.pause();
|
||||
return;
|
||||
}
|
||||
if travel.open || travel.closed_this_frame || customiser.open || custom_models.open { return; }
|
||||
if mods.open || travel.open || travel.closed_this_frame || customiser.open || custom_models.open { return; }
|
||||
if keys.just_pressed(KeyCode::Escape) || nav.pressed & 0x10 != 0 {
|
||||
menu.open = !menu.open;
|
||||
}
|
||||
@@ -306,7 +307,8 @@ pub(crate) fn interact(
|
||||
}
|
||||
}
|
||||
if menu.open {
|
||||
let rows = if menu.multiplayer { 11 } else { 15 };
|
||||
let rows = if menu.multiplayer { 11 } else { 16 };
|
||||
if !panel.focused {
|
||||
if keys.just_pressed(KeyCode::ArrowUp) || nav.pressed & 1 != 0 {
|
||||
menu.selected = (menu.selected + rows - 1) % rows;
|
||||
}
|
||||
@@ -319,7 +321,9 @@ pub(crate) fn interact(
|
||||
if keys.just_pressed(KeyCode::ArrowRight) || keys.just_pressed(KeyCode::Enter) || nav.pressed & (8 | 0x1000) != 0 {
|
||||
action = Some((menu.selected, 1));
|
||||
}
|
||||
}
|
||||
for (interaction, row) in &buttons {
|
||||
if panel.dragging() { continue; }
|
||||
if *interaction == Interaction::Pressed {
|
||||
menu.selected = row.0;
|
||||
action = Some((row.0, 1));
|
||||
@@ -427,6 +431,7 @@ pub(crate) fn interact(
|
||||
12 => custom_models.begin(),
|
||||
13 => menu.status = updater.open(false),
|
||||
14 => travel.open = true,
|
||||
15 => mods.begin(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -512,6 +517,7 @@ fn labels(
|
||||
customiser: Res<crate::customiser::Customiser>,
|
||||
custom_models: Res<crate::custom_models::CustomModels>,
|
||||
travel: Res<crate::teleport_menu::Travel>,
|
||||
mods: Res<crate::modding::ModMenu>,
|
||||
net: Res<crate::multiplayer::Multiplayer>,
|
||||
window: Single<&Window, With<PrimaryWindow>>,
|
||||
mut root: Single<&mut Node, With<MenuRoot>>,
|
||||
@@ -519,7 +525,7 @@ fn labels(
|
||||
mut status: Single<&mut Text, With<StatusLabel>>,
|
||||
mut buttons: Query<(&MenuRow, &Interaction, &mut BackgroundColor, &mut Node), Without<MenuRoot>>,
|
||||
) {
|
||||
root.display = if menu.open && !travel.open && !customiser.open && !custom_models.open {
|
||||
root.display = if menu.open && !mods.open && !travel.open && !customiser.open && !custom_models.open {
|
||||
Display::Flex
|
||||
} else {
|
||||
Display::None
|
||||
@@ -613,6 +619,7 @@ fn labels(
|
||||
10 => "Character customiser".into(),
|
||||
12 => "Custom models".into(),
|
||||
13 => "Updates".into(),
|
||||
15 => "Mods".into(),
|
||||
14 => "Teleport…".into(),
|
||||
_ => "Multiplayer".into(),
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ mod skater_animation;
|
||||
mod verification;
|
||||
mod performance;
|
||||
mod graphics_menu;
|
||||
mod modding;
|
||||
mod customiser;
|
||||
mod customiser_parts;
|
||||
mod customiser_material;
|
||||
|
||||
@@ -0,0 +1,356 @@
|
||||
use super::Mods;
|
||||
use bevy::prelude::*;
|
||||
#[derive(Resource, Default)]
|
||||
pub(crate) struct ModMenu {
|
||||
pub open: bool,
|
||||
just_opened: bool,
|
||||
id: Option<String>,
|
||||
selected: usize,
|
||||
editing: Option<(String, String)>,
|
||||
status: String,
|
||||
}
|
||||
impl ModMenu {
|
||||
pub fn configure(&mut self, id: String) {
|
||||
self.begin();
|
||||
self.id = Some(id);
|
||||
}
|
||||
pub fn begin(&mut self) {
|
||||
self.open = true;
|
||||
self.just_opened = true;
|
||||
self.id = None;
|
||||
self.selected = 0;
|
||||
}
|
||||
}
|
||||
#[derive(Component)]
|
||||
struct Root;
|
||||
#[derive(Component)]
|
||||
struct Row(usize);
|
||||
#[derive(Component)]
|
||||
struct Label(usize);
|
||||
#[derive(Component)]
|
||||
struct Badge(usize);
|
||||
#[derive(Component)]
|
||||
struct Detail;
|
||||
#[derive(Clone)]
|
||||
enum Action {
|
||||
Select(String),
|
||||
Enable(String),
|
||||
Reload(String),
|
||||
Reset(String),
|
||||
Setting(String, String),
|
||||
Scan,
|
||||
OpenFolder,
|
||||
Back,
|
||||
}
|
||||
fn rows(menu: &ModMenu, mods: &Mods) -> Vec<(String, Action)> {
|
||||
let manager = &mods.manager;
|
||||
if let Some(id) = &menu.id {
|
||||
if let Some(p) = manager.packages.get(id) {
|
||||
let mut rows = vec![
|
||||
(
|
||||
if p.running() {
|
||||
"DISABLE MOD".into()
|
||||
} else {
|
||||
"ENABLE MOD".into()
|
||||
},
|
||||
Action::Enable(id.clone()),
|
||||
),
|
||||
("Reload from disk".into(), Action::Reload(id.clone())),
|
||||
(
|
||||
"Reset settings to defaults".into(),
|
||||
Action::Reset(id.clone()),
|
||||
),
|
||||
];
|
||||
for (key, s) in &p.manifest.settings {
|
||||
rows.push((
|
||||
format!("{}: {}", s.label, p.settings[key]),
|
||||
Action::Setting(id.clone(), key.clone()),
|
||||
));
|
||||
}
|
||||
rows.push(("Back".into(), Action::Back));
|
||||
return rows;
|
||||
}
|
||||
}
|
||||
let mut rows: Vec<_> = manager
|
||||
.packages
|
||||
.iter()
|
||||
.map(|(id, p)| (p.manifest.name.clone(), Action::Select(id.clone())))
|
||||
.collect();
|
||||
rows.push(("Open mods folder".into(), Action::OpenFolder));
|
||||
rows.push(("Rescan packages".into(), Action::Scan));
|
||||
rows.push(("Back to pause menu".into(), Action::Back));
|
||||
rows
|
||||
}
|
||||
pub(super) fn install(app: &mut App) {
|
||||
app.add_systems(PostStartup, setup)
|
||||
.add_systems(
|
||||
PreUpdate,
|
||||
input
|
||||
.after(crate::graphics_menu::MenuInput)
|
||||
.before(crate::map_transition::MapTransitionSet),
|
||||
)
|
||||
.add_systems(Update, draw);
|
||||
}
|
||||
fn setup(mut commands: Commands) {
|
||||
commands.spawn((Root,GlobalZIndex(20),Node{display:Display::None,width:percent(100),height:percent(100),position_type:PositionType::Absolute,align_items:AlignItems::Center,justify_content:JustifyContent::Center,..default()},BackgroundColor(Color::srgba(0.015,0.025,0.04,0.98)))).with_children(|root| {
|
||||
root.spawn((Node{width:px(760),max_width:percent(95),padding:UiRect::all(px(18)),flex_direction:FlexDirection::Column,row_gap:px(5),..default()},BackgroundColor(Color::srgb(0.035,0.055,0.08)))).with_children(|panel| {
|
||||
panel.spawn((Text::new("MODS — Lua SDK 1"),TextFont{font_size:28.,..default()},TextColor(Color::WHITE)));
|
||||
for i in 0..8 { panel.spawn((Button,Row(i),Node{min_height:px(32),padding:UiRect::all(px(6)),justify_content:JustifyContent::SpaceBetween,..default()},BackgroundColor(Color::srgb(0.08,0.11,0.15)))).with_children(|row| {row.spawn((Label(i),Text::new(""),TextFont{font_size:17.,..default()},TextColor(Color::WHITE)));row.spawn((Badge(i),Text::new(""),TextFont{font_size:16.,..default()},TextColor(Color::WHITE)));}); }
|
||||
panel.spawn((Detail,Text::new(""),TextFont{font_size:15.,..default()},TextColor(Color::srgb(0.65,0.85,0.85))));
|
||||
panel.spawn((Text::new("Arrows / D-pad: select & adjust | Enter / A: choose | Esc / B: back\nStrings: Enter then type; Enter saves, Esc cancels. More rows scroll automatically."),TextFont{font_size:14.,..default()},TextColor(Color::WHITE)));
|
||||
});
|
||||
});
|
||||
}
|
||||
fn input(
|
||||
mut menu: ResMut<ModMenu>,
|
||||
mut mods: ResMut<Mods>,
|
||||
keys: Res<ButtonInput<KeyCode>>,
|
||||
nav: Res<crate::customiser::Navigation>,
|
||||
buttons: Query<(&Interaction, &Row), Changed<Interaction>>,
|
||||
mut typing: MessageReader<bevy::input::keyboard::KeyboardInput>,
|
||||
) {
|
||||
if !menu.open {
|
||||
typing.clear();
|
||||
return;
|
||||
}
|
||||
if menu.just_opened {
|
||||
menu.just_opened = false;
|
||||
typing.clear();
|
||||
return;
|
||||
}
|
||||
if let Some((key, mut value)) = menu.editing.take() {
|
||||
if keys.just_pressed(KeyCode::Escape) || nav.pressed & 0x2000 != 0 {
|
||||
typing.clear();
|
||||
return;
|
||||
}
|
||||
if keys.just_pressed(KeyCode::Enter) || nav.pressed & 0x1000 != 0 {
|
||||
if let Some(id) = &menu.id {
|
||||
menu.status = mods
|
||||
.manager
|
||||
.setting(id, &key, value.into())
|
||||
.err()
|
||||
.unwrap_or_else(|| "Saved".into());
|
||||
}
|
||||
typing.clear();
|
||||
return;
|
||||
}
|
||||
if keys.just_pressed(KeyCode::Backspace) {
|
||||
value.pop();
|
||||
}
|
||||
for e in typing.read() {
|
||||
if e.state == bevy::input::ButtonState::Pressed {
|
||||
if let Some(t) = &e.text {
|
||||
for c in t.chars().filter(|c| !c.is_control()) {
|
||||
if value.chars().count() < 128 {
|
||||
value.push(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
menu.editing = Some((key, value));
|
||||
return;
|
||||
}
|
||||
typing.clear();
|
||||
if keys.just_pressed(KeyCode::Escape) || nav.pressed & (0x2000 | 0x10) != 0 {
|
||||
if menu.id.take().is_none() {
|
||||
menu.open = false;
|
||||
}
|
||||
menu.selected = 0;
|
||||
return;
|
||||
}
|
||||
let entries = rows(&menu, &mods);
|
||||
let count = entries.len();
|
||||
menu.selected = menu.selected.min(count - 1);
|
||||
if keys.just_pressed(KeyCode::ArrowUp) || nav.pressed & 1 != 0 {
|
||||
menu.selected = (menu.selected + count - 1) % count;
|
||||
}
|
||||
if keys.just_pressed(KeyCode::ArrowDown) || nav.pressed & 2 != 0 {
|
||||
menu.selected = (menu.selected + 1) % count;
|
||||
}
|
||||
let mut direction = 0;
|
||||
if keys.just_pressed(KeyCode::ArrowLeft) || nav.pressed & 4 != 0 {
|
||||
direction = -1;
|
||||
}
|
||||
if keys.just_pressed(KeyCode::ArrowRight)
|
||||
|| keys.just_pressed(KeyCode::Enter)
|
||||
|| nav.pressed & (8 | 0x1000) != 0
|
||||
{
|
||||
direction = 1;
|
||||
}
|
||||
let offset = menu.selected / 8 * 8;
|
||||
for (interaction, row) in &buttons {
|
||||
if *interaction == Interaction::Pressed && row.0 + offset < count {
|
||||
menu.selected = row.0 + offset;
|
||||
direction = 1;
|
||||
}
|
||||
}
|
||||
if direction == 0 {
|
||||
return;
|
||||
}
|
||||
let action = entries[menu.selected].1.clone();
|
||||
let result = match action {
|
||||
Action::Select(id) => {
|
||||
menu.id = Some(id);
|
||||
menu.selected = 0;
|
||||
Ok(())
|
||||
}
|
||||
Action::Back => {
|
||||
if menu.id.take().is_none() {
|
||||
menu.open = false;
|
||||
}
|
||||
menu.selected = 0;
|
||||
Ok(())
|
||||
}
|
||||
Action::OpenFolder => {
|
||||
#[cfg(target_os = "windows")]
|
||||
let program = "explorer.exe";
|
||||
#[cfg(target_os = "macos")]
|
||||
let program = "open";
|
||||
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
|
||||
let program = "xdg-open";
|
||||
std::process::Command::new(program)
|
||||
.arg(mods.manager.root())
|
||||
.spawn()
|
||||
.map(|_| ())
|
||||
.map_err(|e| format!("Could not open mods folder: {e}"))
|
||||
}
|
||||
Action::Scan => {
|
||||
mods.manager.scan(true);
|
||||
Ok(())
|
||||
}
|
||||
Action::Enable(id) => {
|
||||
let enabled = !mods.manager.packages[&id].running();
|
||||
mods.manager.enable(&id, enabled)
|
||||
}
|
||||
Action::Reload(id) => {
|
||||
mods.manager.scan(true);
|
||||
mods.manager.reload(&id);
|
||||
Ok(())
|
||||
}
|
||||
Action::Reset(id) => mods.manager.reset(&id),
|
||||
Action::Setting(id, key) => {
|
||||
let p = &mods.manager.packages[&id];
|
||||
let s = &p.manifest.settings[&key];
|
||||
let value = &p.settings[&key];
|
||||
let next = match s.kind.as_str() {
|
||||
"boolean" => Some(serde_json::json!(!value.as_bool().unwrap())),
|
||||
"number" => Some(serde_json::json!(
|
||||
(value.as_f64().unwrap() + direction as f64 * s.step.unwrap())
|
||||
.clamp(s.min.unwrap(), s.max.unwrap())
|
||||
)),
|
||||
"choice" => {
|
||||
let i = s
|
||||
.choices
|
||||
.iter()
|
||||
.position(|v| Some(v.as_str()) == value.as_str())
|
||||
.unwrap_or(0);
|
||||
Some(serde_json::json!(
|
||||
s.choices
|
||||
[(i as i32 + direction).rem_euclid(s.choices.len() as i32) as usize]
|
||||
))
|
||||
}
|
||||
"string" => {
|
||||
menu.editing = Some((key.clone(), value.as_str().unwrap().into()));
|
||||
None
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if let Some(value) = next {
|
||||
mods.manager.setting(&id, &key, value)
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
};
|
||||
menu.status = result.err().unwrap_or_default();
|
||||
}
|
||||
fn draw(
|
||||
menu: Res<ModMenu>,
|
||||
mods: Res<Mods>,
|
||||
mut root: Single<&mut Node, With<Root>>,
|
||||
mut labels: Query<(&Label, &mut Text), Without<Badge>>,
|
||||
mut badges: Query<(&Badge, &mut Text, &mut TextColor), Without<Label>>,
|
||||
mut buttons: Query<(&Row, &mut Node, &mut BackgroundColor), Without<Root>>,
|
||||
mut detail: Single<&mut Text, (With<Detail>, Without<Label>, Without<Badge>)>,
|
||||
) {
|
||||
root.display = if menu.open {
|
||||
Display::Flex
|
||||
} else {
|
||||
Display::None
|
||||
};
|
||||
if !menu.open {
|
||||
return;
|
||||
}
|
||||
let entries = rows(&menu, &mods);
|
||||
let selected = menu.selected.min(entries.len() - 1);
|
||||
let offset = selected / 8 * 8;
|
||||
for (label, mut text) in &mut labels {
|
||||
**text = entries
|
||||
.get(offset + label.0)
|
||||
.map(|e| e.0.clone())
|
||||
.unwrap_or_default();
|
||||
}
|
||||
for (row, mut node, mut color) in &mut buttons {
|
||||
node.display = if offset + row.0 < entries.len() {
|
||||
Display::Flex
|
||||
} else {
|
||||
Display::None
|
||||
};
|
||||
color.0 = if offset + row.0 == selected {
|
||||
Color::srgb(0.10, 0.30, 0.34)
|
||||
} else {
|
||||
Color::srgb(0.08, 0.11, 0.15)
|
||||
};
|
||||
}
|
||||
for (badge, mut text, mut color) in &mut badges {
|
||||
let package = entries.get(offset + badge.0).and_then(|(_, a)| match a {
|
||||
Action::Select(id) | Action::Enable(id) => mods.manager.packages.get(id),
|
||||
_ => None,
|
||||
});
|
||||
**text = package
|
||||
.map(|p| if p.running() { "ENABLED" } else { "DISABLED" })
|
||||
.unwrap_or("")
|
||||
.into();
|
||||
color.0 = if package.is_some_and(|p| p.running()) {
|
||||
Color::srgb(0.25, 1., 0.4)
|
||||
} else {
|
||||
Color::srgb(1., 0.25, 0.25)
|
||||
};
|
||||
}
|
||||
let mut description = format!("{} / {}\n{}", selected + 1, entries.len(), menu.status);
|
||||
if let Some(p) = menu
|
||||
.id
|
||||
.as_ref()
|
||||
.and_then(|id| mods.manager.packages.get(id))
|
||||
{
|
||||
description += &format!(
|
||||
"\n{} v{} by {}\n{}\n{}",
|
||||
p.manifest.id,
|
||||
p.manifest.version,
|
||||
p.manifest.author,
|
||||
p.manifest.description,
|
||||
p.error.as_deref().unwrap_or("")
|
||||
);
|
||||
if let Action::Setting(_, key) = &entries[selected].1 {
|
||||
description += &format!("\n{}", p.manifest.settings[key].description);
|
||||
}
|
||||
} else {
|
||||
description += &mods
|
||||
.manager
|
||||
.diagnostics
|
||||
.iter()
|
||||
.take(3)
|
||||
.cloned()
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
}
|
||||
if let Some((key, value)) = &menu.editing {
|
||||
description += &format!("\nEditing {key}: {value}_");
|
||||
}
|
||||
if description.chars().count() > 650 {
|
||||
description = description.chars().take(650).collect::<String>()
|
||||
+ "...\nFull errors are in the session log.";
|
||||
}
|
||||
***detail = description;
|
||||
}
|
||||
@@ -0,0 +1,512 @@
|
||||
//! Main-thread SDK adapter. Lua never receives World, entity IDs, or asset handles.
|
||||
mod menu;
|
||||
pub(crate) mod network;
|
||||
mod panel;
|
||||
pub(crate) mod vehicles;
|
||||
use bevy::prelude::*;
|
||||
pub(crate) use menu::ModMenu;
|
||||
pub(crate) use panel::EnabledPanel;
|
||||
use serde_json::json;
|
||||
use skate_mods::{Command, Manager};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
#[derive(Resource)]
|
||||
pub(crate) struct Mods {
|
||||
pub manager: Manager,
|
||||
animation_info: serde_json::Value,
|
||||
trainer: Option<(String, skate_mods::TrainerTuning)>,
|
||||
owned: BTreeMap<(String, String), Owned>,
|
||||
generation: u64,
|
||||
last_bail: bool,
|
||||
last_state: u32,
|
||||
last_grind: bool,
|
||||
}
|
||||
struct Owned {
|
||||
entity: Entity,
|
||||
mesh: Option<AssetId<Mesh>>,
|
||||
material: Option<AssetId<StandardMaterial>>,
|
||||
}
|
||||
pub(crate) struct ModdingPlugin;
|
||||
impl Plugin for ModdingPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
let root = package_root();
|
||||
if let Err(e) = std::fs::create_dir_all(&root) {
|
||||
warn!("Cannot create mods folder {}: {e}", root.display());
|
||||
}
|
||||
info!("Mod packages folder: {}", root.display());
|
||||
let settings = std::env::var_os("SKATE3_MOD_SETTINGS")
|
||||
.map(std::path::PathBuf::from)
|
||||
.unwrap_or_else(|| {
|
||||
app.world()
|
||||
.resource::<crate::config::Config>()
|
||||
.asset_root
|
||||
.parent()
|
||||
.unwrap()
|
||||
.join("settings/mods")
|
||||
});
|
||||
let frames = &app
|
||||
.world()
|
||||
.resource::<crate::physics::SkaterRuntime>()
|
||||
.animation
|
||||
.evaluator
|
||||
.frames;
|
||||
let slots: BTreeMap<_, _> = [
|
||||
"R_ANTIC_OLLIE_N_0_INTO",
|
||||
"R_ANTIC_OLLIE_N_0_CYC",
|
||||
"R_ANTIC_360SHUVIT_N_0_CYC",
|
||||
"360FLIP_D_HIGH_G",
|
||||
"360FLIP_D_HIGH_A",
|
||||
"360FLIP_D_LOW_G",
|
||||
"360FLIP_D_LOW_A",
|
||||
]
|
||||
.into_iter()
|
||||
.filter_map(|name| {
|
||||
frames.clip(name).ok().map(|c| {
|
||||
(
|
||||
name,
|
||||
json!({"fps":f32::from_bits(c.fps_bits),"frame_count":c.frames.len()}),
|
||||
)
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let animation_info = json!({"bone_names":frames.bone_names,"slots":slots});
|
||||
app.insert_resource(Mods {
|
||||
animation_info,
|
||||
trainer: None,
|
||||
manager: Manager::new(root, settings),
|
||||
owned: BTreeMap::new(),
|
||||
generation: u64::MAX,
|
||||
last_bail: false,
|
||||
last_state: 0,
|
||||
last_grind: false,
|
||||
})
|
||||
.init_resource::<ModMenu>()
|
||||
.add_systems(
|
||||
PreUpdate,
|
||||
maintenance.after(crate::map_transition::MapTransitionSet),
|
||||
)
|
||||
.add_systems(
|
||||
FixedUpdate,
|
||||
fixed
|
||||
.after(crate::app::SimulationSet::Physics)
|
||||
.run_if(crate::graphics_menu::gameplay_active),
|
||||
)
|
||||
.add_systems(Update, update.after(crate::app::FrameSet::Animation));
|
||||
vehicles::install(app);
|
||||
network::install(app);
|
||||
menu::install(app);
|
||||
panel::install(app);
|
||||
}
|
||||
}
|
||||
fn snapshot(world: &World) -> serde_json::Value {
|
||||
let s = world.resource::<crate::physics::SkaterRuntime>();
|
||||
let p = &s.player_input.physical;
|
||||
let map = world.resource::<crate::map_transition::CurrentMap>();
|
||||
let physics = world.resource::<crate::physics::GamePhysics>();
|
||||
let keys: BTreeMap<_, _> = world
|
||||
.resource::<ButtonInput<KeyCode>>()
|
||||
.get_pressed()
|
||||
.map(|k| (format!("{k:?}"), true))
|
||||
.collect();
|
||||
let (grinding, grind_name, grind_kind, grind_distance) = s.mod_grind();
|
||||
let actions = *world
|
||||
.resource::<crate::input::PublishedTickInput>()
|
||||
.0
|
||||
.actions()
|
||||
.values();
|
||||
let vehicle_pose=world.resource::<vehicles::Vehicles>().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}},
|
||||
"network":network::snapshot(world),"vehicles":vehicles::snapshot(world),"vehicle_input":vehicles::input(world),
|
||||
"animation":world.resource::<Mods>().animation_info,
|
||||
"map":{"name":map.name,"generation":map.generation},"tick":physics.ticks,"keys":keys,"actions":actions,
|
||||
"paused":world.resource::<crate::graphics_menu::Menu>().open,"replay":world.resource::<crate::replay::Replay>().active})
|
||||
}
|
||||
fn maintenance(world: &mut World) {
|
||||
let snap = snapshot(world);
|
||||
world.resource_scope(|world, mut mods: Mut<Mods>| {
|
||||
mods.manager.snapshot = snap;
|
||||
let generation = world
|
||||
.resource::<crate::map_transition::CurrentMap>()
|
||||
.generation;
|
||||
if mods.generation != generation {
|
||||
vehicles::clear(world);
|
||||
network::clear(world);
|
||||
let ids: Vec<_> = mods.owned.keys().cloned().collect();
|
||||
for key in ids {
|
||||
retire(world, &mut mods, &key);
|
||||
}
|
||||
world
|
||||
.resource::<crate::physics::SkaterRuntime>()
|
||||
.animation
|
||||
.evaluator
|
||||
.clear_mod_clips();
|
||||
mods.generation = generation;
|
||||
mods.manager.commands.clear();
|
||||
let map = mods.manager.snapshot["map"].clone();
|
||||
mods.manager
|
||||
.dispatch("on_event", json!({"name":"world_changed","map":map}));
|
||||
}
|
||||
let events = std::mem::take(&mut world.resource_mut::<vehicles::Vehicles>().events);
|
||||
for event in events { mods.manager.dispatch("on_event", event); }
|
||||
mods.manager.scan(false);
|
||||
apply(world, &mut mods);
|
||||
});
|
||||
}
|
||||
fn fixed(world: &mut World) {
|
||||
if world.resource::<crate::replay::Replay>().active {
|
||||
return;
|
||||
}
|
||||
let snap = snapshot(world);
|
||||
let dt = world.resource::<Time<Fixed>>().delta_secs_f64();
|
||||
world.resource_scope(|world, mut mods: Mut<Mods>| {
|
||||
mods.manager.snapshot = snap;
|
||||
let bail = world
|
||||
.resource::<crate::physics::GamePhysics>()
|
||||
.board_wiping_out;
|
||||
let state = world
|
||||
.resource::<crate::physics::SkaterRuntime>()
|
||||
.player_input
|
||||
.physical
|
||||
.state
|
||||
.state_16;
|
||||
if bail != mods.last_bail {
|
||||
mods.last_bail = bail;
|
||||
mods.manager
|
||||
.dispatch("on_event", json!({"name":"bail_changed","bailing":bail}));
|
||||
}
|
||||
if state != mods.last_state {
|
||||
let previous = mods.last_state;
|
||||
mods.last_state = state;
|
||||
mods.manager.dispatch(
|
||||
"on_event",
|
||||
json!({"name":"player_state_changed","previous":previous,"state":state}),
|
||||
);
|
||||
}
|
||||
let grinding = world
|
||||
.resource::<crate::physics::SkaterRuntime>()
|
||||
.mod_grind()
|
||||
.0;
|
||||
if grinding != mods.last_grind {
|
||||
mods.last_grind = grinding;
|
||||
let grind = mods.manager.snapshot["player"]["grind"].clone();
|
||||
mods.manager
|
||||
.dispatch("on_event", json!({"name":"grind_changed","grind":grind}));
|
||||
}
|
||||
mods.manager.dispatch("on_fixed_update", json!({"dt":dt}));
|
||||
apply(world, &mut mods);
|
||||
});
|
||||
}
|
||||
fn update(world: &mut World) {
|
||||
let snap = snapshot(world);
|
||||
let paused =
|
||||
snap["paused"].as_bool().unwrap_or(true) || snap["replay"].as_bool().unwrap_or(false);
|
||||
let dt = world.resource::<Time<Real>>().delta_secs_f64().min(0.25);
|
||||
world.resource_scope(|world, mut mods: Mut<Mods>| {
|
||||
mods.manager.snapshot = snap;
|
||||
if !paused {
|
||||
mods.manager.dispatch("on_update", json!({"dt":dt}));
|
||||
}
|
||||
apply(world, &mut mods);
|
||||
});
|
||||
}
|
||||
fn retire(world: &mut World, mods: &mut Mods, key: &(String, String)) {
|
||||
if let Some(owned) = mods.owned.remove(key) {
|
||||
world.despawn(owned.entity);
|
||||
if let Some(id) = owned.mesh {
|
||||
world.resource_mut::<Assets<Mesh>>().remove(id);
|
||||
}
|
||||
if let Some(id) = owned.material {
|
||||
world.resource_mut::<Assets<StandardMaterial>>().remove(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
fn apply(world: &mut World, mods: &mut Mods) {
|
||||
let retired = std::mem::take(&mut mods.manager.retired);
|
||||
if mods
|
||||
.trainer
|
||||
.as_ref()
|
||||
.is_some_and(|(id, _)| retired.contains(id))
|
||||
{
|
||||
mods.trainer = None;
|
||||
}
|
||||
world.resource_mut::<crate::physics::GamePhysics>().trainer =
|
||||
mods.trainer.as_ref().map(|(_, t)| *t).unwrap_or_default();
|
||||
for id in &retired {
|
||||
network::retire(world,id);
|
||||
vehicles::retire(world, id);
|
||||
world
|
||||
.resource::<crate::physics::SkaterRuntime>()
|
||||
.animation
|
||||
.evaluator
|
||||
.remove_mod_clips(id);
|
||||
}
|
||||
let keys: Vec<_> = mods
|
||||
.owned
|
||||
.keys()
|
||||
.filter(|(id, _)| retired.contains(id))
|
||||
.cloned()
|
||||
.collect();
|
||||
for key in keys {
|
||||
retire(world, mods, &key);
|
||||
}
|
||||
let mut batches = BTreeMap::<String, Vec<Command>>::new();
|
||||
for (id, command) in std::mem::take(&mut mods.manager.commands) {
|
||||
batches.entry(id).or_default().push(command);
|
||||
}
|
||||
for (id, commands) in batches {
|
||||
if !mods.manager.packages.get(&id).is_some_and(|p| p.running()) {
|
||||
continue;
|
||||
}
|
||||
let result = (|| {
|
||||
let mut keys: std::collections::BTreeSet<_> = mods
|
||||
.owned
|
||||
.keys()
|
||||
.filter(|(owner, _)| owner == &id)
|
||||
.map(|(_, key)| key.clone())
|
||||
.collect();
|
||||
let mut teleport = None;
|
||||
for c in &commands {
|
||||
match c {
|
||||
Command::Remove { key } => {
|
||||
keys.remove(key);
|
||||
}
|
||||
Command::Overlay { key, .. } | Command::Cube { key, .. } => {
|
||||
keys.insert(key.clone());
|
||||
if keys.len() > 64 {
|
||||
return Err("64 owned objects per mod maximum".into());
|
||||
}
|
||||
}
|
||||
Command::Teleport { .. } => {
|
||||
if teleport.is_some() {
|
||||
return Err("Only one teleport per mod command batch".into());
|
||||
}
|
||||
teleport = Some(c.clone());
|
||||
teleport_ready(world)?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
// Teleport is an irreversible native request. Submit only after all
|
||||
// reversible installations succeed, so a later bad asset cannot leave it pending.
|
||||
for command in commands {
|
||||
if !matches!(command, Command::Teleport { .. }) {
|
||||
apply_one(world, mods, &id, command)?;
|
||||
}
|
||||
}
|
||||
if let Some(command) = teleport {
|
||||
apply_one(world, mods, &id, command)?;
|
||||
}
|
||||
Ok::<(), String>(())
|
||||
})();
|
||||
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::<crate::physics::GamePhysics>().trainer = Default::default();
|
||||
}
|
||||
world
|
||||
.resource::<crate::physics::SkaterRuntime>()
|
||||
.animation
|
||||
.evaluator
|
||||
.remove_mod_clips(&id);
|
||||
let keys: Vec<_> = mods
|
||||
.owned
|
||||
.keys()
|
||||
.filter(|(owner, _)| owner == &id)
|
||||
.cloned()
|
||||
.collect();
|
||||
for key in keys {
|
||||
retire(world, mods, &key);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Stable text order independent of Bevy entity allocation or previous reloads.
|
||||
let mut row = 0;
|
||||
for owned in mods.owned.values() {
|
||||
if let Some(mut node) = world.get_mut::<Node>(owned.entity) {
|
||||
node.top = px(16. + row as f32 * 28.);
|
||||
row += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
fn teleport_ready(world: &World) -> Result<(), String> {
|
||||
if world.resource::<vehicles::Vehicles>().occupied() { return Err("Exit the vehicle before teleporting the skater".into()); }
|
||||
if world
|
||||
.resource::<crate::map_transition::MapTransition>()
|
||||
.busy()
|
||||
|| world.resource::<crate::replay::Replay>().active
|
||||
{
|
||||
return Err("Teleport unavailable during map loading or replay".into());
|
||||
}
|
||||
let s = world.resource::<crate::physics::SkaterRuntime>();
|
||||
if s.player_input.physical.state.flag_69 != 0
|
||||
|| s.player_input.physical.state.state_16 == 702
|
||||
|| s.player_input.pending_teleport().is_some()
|
||||
{
|
||||
return Err("Teleport unavailable during reset".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn apply_one(world: &mut World, mods: &mut Mods, id: &str, command: Command) -> Result<(), String> {
|
||||
let key = match &command {
|
||||
Command::Cube { key, .. } | Command::Overlay { key, .. } | Command::Remove { key } => {
|
||||
Some((id.to_owned(), key.clone()))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if let Some(key) = &key {
|
||||
if !matches!(command, Command::Remove { .. })
|
||||
&& !mods.owned.contains_key(key)
|
||||
&& mods.owned.keys().filter(|(owner, _)| owner == id).count() >= 64
|
||||
{
|
||||
return Err("64 owned objects per mod maximum".into());
|
||||
}
|
||||
}
|
||||
if !id.starts_with('@') { network::record(world, id, &command)?; }
|
||||
match command {
|
||||
Command::NetworkState { .. } => {},
|
||||
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());
|
||||
}
|
||||
mods.trainer = Some((id.to_owned(), tuning));
|
||||
world.resource_mut::<crate::physics::GamePhysics>().trainer = tuning;
|
||||
}
|
||||
Command::Animation { path } => {
|
||||
let root = &mods.manager.packages[id].root;
|
||||
let bytes = skate_mods::read_bounded(root, &path, 4 * 1024 * 1024)?;
|
||||
let text = std::str::from_utf8(&bytes).map_err(|e| e.to_string())?;
|
||||
world
|
||||
.resource::<crate::physics::SkaterRuntime>()
|
||||
.animation
|
||||
.evaluator
|
||||
.install_mod_clips(id, text)?;
|
||||
}
|
||||
Command::Log { text } => info!("Lua [{id}]: {text}"),
|
||||
Command::Remove { .. } => retire(world, mods, &key.unwrap()),
|
||||
Command::Overlay { text, .. } => {
|
||||
let key = key.unwrap();
|
||||
if let Some(owned) = mods.owned.get(&key) {
|
||||
if let Some(mut t) = world.get_mut::<Text>(owned.entity) {
|
||||
**t = text;
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
retire(world, mods, &key);
|
||||
let entity = world
|
||||
.spawn((
|
||||
Text::new(text),
|
||||
TextFont {
|
||||
font_size: 19.,
|
||||
..default()
|
||||
},
|
||||
TextColor(Color::WHITE),
|
||||
GlobalZIndex(3),
|
||||
Node {
|
||||
position_type: PositionType::Absolute,
|
||||
left: px(16.),
|
||||
..default()
|
||||
},
|
||||
))
|
||||
.id();
|
||||
mods.owned.insert(
|
||||
key,
|
||||
Owned {
|
||||
entity,
|
||||
mesh: None,
|
||||
material: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
Command::Cube {
|
||||
position,
|
||||
size,
|
||||
color,
|
||||
..
|
||||
} => {
|
||||
let key = key.unwrap();
|
||||
if let Some(owned) = mods.owned.get(&key) {
|
||||
if owned.mesh.is_some() {
|
||||
*world.get_mut::<Transform>(owned.entity).unwrap() =
|
||||
Transform::from_translation(Vec3::from_array(position))
|
||||
.with_scale(Vec3::from_array(size));
|
||||
if let Some(material) = owned.material {
|
||||
if let Some(m) = world
|
||||
.resource_mut::<Assets<StandardMaterial>>()
|
||||
.get_mut(material)
|
||||
{
|
||||
m.base_color = Color::srgb(color[0], color[1], color[2]);
|
||||
}
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
retire(world, mods, &key);
|
||||
let mesh = world
|
||||
.resource_mut::<Assets<Mesh>>()
|
||||
.add(Cuboid::new(1., 1., 1.));
|
||||
let material = world
|
||||
.resource_mut::<Assets<StandardMaterial>>()
|
||||
.add(StandardMaterial {
|
||||
base_color: Color::srgb(color[0], color[1], color[2]),
|
||||
..default()
|
||||
});
|
||||
let owned = Owned {
|
||||
entity: world
|
||||
.spawn((
|
||||
Mesh3d(mesh.clone()),
|
||||
MeshMaterial3d(material.clone()),
|
||||
Transform::from_translation(Vec3::from_array(position))
|
||||
.with_scale(Vec3::from_array(size)),
|
||||
))
|
||||
.id(),
|
||||
mesh: Some(mesh.id()),
|
||||
material: Some(material.id()),
|
||||
};
|
||||
mods.owned.insert(key, owned);
|
||||
}
|
||||
Command::Teleport {
|
||||
position,
|
||||
heading,
|
||||
on_board,
|
||||
} => {
|
||||
if world
|
||||
.resource::<crate::map_transition::MapTransition>()
|
||||
.busy()
|
||||
|| world.resource::<crate::replay::Replay>().active
|
||||
{
|
||||
return Err("Teleport unavailable during map loading or replay".into());
|
||||
}
|
||||
let mut s = world.resource_mut::<crate::physics::SkaterRuntime>();
|
||||
if s.player_input.physical.state.flag_69 != 0
|
||||
|| s.player_input.physical.state.state_16 == 702
|
||||
|| s.player_input.pending_teleport().is_some()
|
||||
{
|
||||
return Err("Teleport unavailable during reset".into());
|
||||
}
|
||||
let (sin, cos) = heading.sin_cos();
|
||||
let transform = [
|
||||
[cos, 0., -sin, 0.],
|
||||
[0., 1., 0., 0.],
|
||||
[sin, 0., cos, 0.],
|
||||
[position[0], position[1], position[2], 0.],
|
||||
];
|
||||
s.player_input
|
||||
.request_teleport(transform)
|
||||
.map_err(|e| format!("Teleport rejected: {e}"))?;
|
||||
s.teleport_state.request_manual(transform, on_board);
|
||||
}
|
||||
}
|
||||
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()))
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
//! Generic SDK replication. Only validated data from matching local packages is applied.
|
||||
use super::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeSet;
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", deny_unknown_fields)]
|
||||
pub(super) enum Payload {
|
||||
Cube {
|
||||
key: String,
|
||||
position: [f32; 3],
|
||||
size: [f32; 3],
|
||||
color: [f32; 3],
|
||||
},
|
||||
State {
|
||||
key: String,
|
||||
value: Value,
|
||||
},
|
||||
Tune {
|
||||
key: String,
|
||||
values: BTreeMap<String, Value>,
|
||||
},
|
||||
Car {
|
||||
key: String,
|
||||
state: vehicles::network::Car,
|
||||
},
|
||||
}
|
||||
use serde_json::Value;
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Record {
|
||||
owner: String,
|
||||
fingerprint: u64,
|
||||
payload: Payload,
|
||||
}
|
||||
#[derive(Resource, Default)]
|
||||
pub(crate) struct ModSync {
|
||||
local: BTreeMap<(String, String), Payload>,
|
||||
published: BTreeSet<String>,
|
||||
remote: BTreeMap<(u64, String), (u32, String, String)>,
|
||||
states: BTreeMap<String, BTreeMap<String, BTreeMap<String, Value>>>,
|
||||
identity: u64,
|
||||
elapsed: f32,
|
||||
pub status: String,
|
||||
}
|
||||
pub(super) fn install(app: &mut App) {
|
||||
app.init_resource::<ModSync>()
|
||||
.add_systems(Update, synchronize.after(vehicles::present));
|
||||
}
|
||||
pub(super) fn snapshot(world: &World) -> Value {
|
||||
let (active, id, host) = world
|
||||
.get_resource::<crate::multiplayer::Multiplayer>()
|
||||
.map_or((false, 0, true), |n| n.mod_identity());
|
||||
let sync = world.resource::<ModSync>();
|
||||
json!({"active":active,"local_id":id.to_string(),"is_host":host,"states":sync.states,"status":sync.status})
|
||||
}
|
||||
fn wire_key(owner: &str, key: &str) -> String {
|
||||
format!(
|
||||
"{:016x}",
|
||||
skate_net::hash(format!("{owner}/{key}").as_bytes())
|
||||
)
|
||||
}
|
||||
pub(super) fn record(world: &mut World, owner: &str, command: &Command) -> Result<(), String> {
|
||||
// Remote descriptors are installed directly and never pass back into this path.
|
||||
let mut sync = world.resource_mut::<ModSync>();
|
||||
let entry = match command {
|
||||
Command::Cube {
|
||||
key,
|
||||
position,
|
||||
size,
|
||||
color,
|
||||
} => Some((
|
||||
format!("cube/{key}"),
|
||||
Payload::Cube {
|
||||
key: key.clone(),
|
||||
position: *position,
|
||||
size: *size,
|
||||
color: *color,
|
||||
},
|
||||
)),
|
||||
Command::Remove { key } | Command::Overlay { key, .. } => {
|
||||
sync.local.remove(&(owner.into(), format!("cube/{key}")));
|
||||
None
|
||||
}
|
||||
Command::VehicleRemove { key } => {
|
||||
sync.local.remove(&(owner.into(), format!("tune/{key}")));
|
||||
None
|
||||
}
|
||||
Command::NetworkState { key, value } => Some((
|
||||
format!("state/{key}"),
|
||||
Payload::State {
|
||||
key: key.clone(),
|
||||
value: value.clone(),
|
||||
},
|
||||
)),
|
||||
Command::VehicleTune { key, tuning } => {
|
||||
let slot = (owner.into(), format!("tune/{key}"));
|
||||
let mut values = match sync.local.get(&slot) {
|
||||
Some(Payload::Tune { values, .. }) => values.clone(),
|
||||
_ => BTreeMap::new(),
|
||||
};
|
||||
if let Value::Object(fields) =
|
||||
serde_json::to_value(tuning).map_err(|e| e.to_string())?
|
||||
{
|
||||
values.extend(fields.into_iter().filter(|(_, v)| !v.is_null()));
|
||||
}
|
||||
Some((
|
||||
slot.1,
|
||||
Payload::Tune {
|
||||
key: key.clone(),
|
||||
values,
|
||||
},
|
||||
))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if let Some((key, value)) = entry {
|
||||
if sync.local.len() >= 128 && !sync.local.contains_key(&(owner.into(), key.clone())) {
|
||||
return Err("128 shared mod records maximum".into());
|
||||
}
|
||||
sync.local.insert((owner.into(), key), value);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn synchronize(world: &mut World) {
|
||||
let dt = world.resource::<Time<Real>>().delta_secs();
|
||||
world.resource_scope(|world, mut sync: Mut<ModSync>| {
|
||||
sync.elapsed += dt;
|
||||
if sync.elapsed < 0.05 {
|
||||
return;
|
||||
}
|
||||
sync.elapsed = 0.;
|
||||
let (_, identity, _) = world
|
||||
.resource::<crate::multiplayer::Multiplayer>()
|
||||
.mod_identity();
|
||||
if identity != sync.identity {
|
||||
sync.published.clear();
|
||||
sync.identity = identity;
|
||||
}
|
||||
let packages: BTreeMap<_, _> = world
|
||||
.resource::<Mods>()
|
||||
.manager
|
||||
.packages
|
||||
.iter()
|
||||
.filter(|(_, p)| p.running())
|
||||
.map(|(id, p)| (id.clone(), (p.content_fingerprint(), p.root.clone())))
|
||||
.collect();
|
||||
sync.local
|
||||
.retain(|(owner, _), _| packages.contains_key(owner));
|
||||
let mut outgoing = sync.local.clone();
|
||||
for (owner, key, state) in vehicles::network::capture(world) {
|
||||
outgoing.insert((owner, format!("car/{key}")), Payload::Car { key, state });
|
||||
}
|
||||
let mut published = BTreeSet::new();
|
||||
let mut failures = 0;
|
||||
sync.states.clear();
|
||||
for ((owner, key), payload) in outgoing {
|
||||
let Some((fingerprint, _)) = packages.get(&owner) else {
|
||||
continue;
|
||||
};
|
||||
if let Payload::State { key, value } = &payload {
|
||||
if !value.is_null() {
|
||||
sync.states
|
||||
.entry(owner.clone())
|
||||
.or_default()
|
||||
.entry(identity.to_string())
|
||||
.or_default()
|
||||
.insert(key.clone(), value.clone());
|
||||
}
|
||||
}
|
||||
let key = wire_key(&owner, &key);
|
||||
let bytes = serde_json::to_vec(&Record {
|
||||
owner,
|
||||
fingerprint: *fingerprint,
|
||||
payload,
|
||||
})
|
||||
.unwrap();
|
||||
if identity != 0
|
||||
&& !world
|
||||
.resource_mut::<crate::multiplayer::Multiplayer>()
|
||||
.publish_mod(&key, bytes)
|
||||
{
|
||||
failures += 1;
|
||||
}
|
||||
published.insert(key);
|
||||
}
|
||||
for key in sync.published.difference(&published) {
|
||||
world
|
||||
.resource_mut::<crate::multiplayer::Multiplayer>()
|
||||
.publish_mod(key, vec![]);
|
||||
}
|
||||
sync.published = published;
|
||||
let records = world
|
||||
.resource::<crate::multiplayer::Multiplayer>()
|
||||
.mod_records();
|
||||
let mut seen = BTreeSet::new();
|
||||
let mut mismatches = BTreeSet::new();
|
||||
for (peer, wire, seq, bytes) in records {
|
||||
let Ok(record) = serde_json::from_slice::<Record>(&bytes) else {
|
||||
continue;
|
||||
};
|
||||
let Some((fingerprint, root)) = packages
|
||||
.get(&record.owner)
|
||||
.filter(|(f, _)| *f == record.fingerprint)
|
||||
else {
|
||||
mismatches.insert(record.owner);
|
||||
continue;
|
||||
};
|
||||
let _ = fingerprint;
|
||||
let remote_owner = format!("@{peer}:{}", record.owner);
|
||||
let record_id = (peer, wire.clone());
|
||||
let changed = sync
|
||||
.remote
|
||||
.get(&record_id)
|
||||
.is_none_or(|(old, _, _)| *old != seq);
|
||||
let mut object_key = String::new();
|
||||
let result = match record.payload {
|
||||
Payload::State { key, value } => {
|
||||
if key.len() > 64 || serde_json::to_vec(&value).unwrap().len() > 512 {
|
||||
continue;
|
||||
}
|
||||
if !value.is_null() {
|
||||
sync.states
|
||||
.entry(record.owner.clone())
|
||||
.or_default()
|
||||
.entry(peer.to_string())
|
||||
.or_default()
|
||||
.insert(key, value);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Payload::Cube {
|
||||
key,
|
||||
position,
|
||||
size,
|
||||
color,
|
||||
} => {
|
||||
object_key = format!("cube/{key}");
|
||||
let c = Command::Cube {
|
||||
key,
|
||||
position,
|
||||
size,
|
||||
color,
|
||||
};
|
||||
if !c.validate() {
|
||||
continue;
|
||||
}
|
||||
if changed {
|
||||
world.resource_scope(|world, mut mods: Mut<Mods>| {
|
||||
super::apply_one(world, &mut mods, &remote_owner, c)
|
||||
})
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
Payload::Car { key, state } => {
|
||||
object_key = format!("car/{key}");
|
||||
if changed {
|
||||
vehicles::network::receive(world, root, &remote_owner, &key, state)
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
Payload::Tune { key, values } => {
|
||||
let Ok(tuning) = serde_json::from_value(serde_json::to_value(values).unwrap())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let c = Command::VehicleTune { key, tuning };
|
||||
if !c.validate() {
|
||||
continue;
|
||||
}
|
||||
if changed {
|
||||
vehicles::command(world, root, &remote_owner, c)
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
};
|
||||
if result.is_ok() {
|
||||
seen.insert(record_id.clone());
|
||||
sync.remote
|
||||
.insert(record_id, (seq, remote_owner, object_key));
|
||||
}
|
||||
}
|
||||
let removed: Vec<_> = sync
|
||||
.remote
|
||||
.keys()
|
||||
.filter(|id| !seen.contains(*id))
|
||||
.cloned()
|
||||
.collect();
|
||||
for id in removed {
|
||||
if let Some((_, owner, key)) = sync.remote.remove(&id) {
|
||||
if let Some(key) = key.strip_prefix("cube/") {
|
||||
world.resource_scope(|world, mut mods: Mut<Mods>| {
|
||||
super::retire(world, &mut mods, &(owner.clone(), key.into()))
|
||||
});
|
||||
}
|
||||
if let Some(key) = key.strip_prefix("car/") {
|
||||
vehicles::network::remove(world, &owner, key);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Remote lifecycle messages are observations, not local script commands.
|
||||
world
|
||||
.resource_mut::<vehicles::Vehicles>()
|
||||
.events
|
||||
.retain(|e| !e["owner"].as_str().is_some_and(|o| o.starts_with('@')));
|
||||
sync.status = if failures > 0 {
|
||||
format!("Mod network limit reached: {failures} records")
|
||||
} else if !mismatches.is_empty() {
|
||||
format!(
|
||||
"Mods missing, disabled or different: {}",
|
||||
mismatches.into_iter().collect::<Vec<_>>().join(", ")
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"Matching mods synchronized | {} remote records",
|
||||
sync.remote.len()
|
||||
)
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
pub(super) fn retire(world: &mut World, owner: &str) {
|
||||
world
|
||||
.resource_mut::<ModSync>()
|
||||
.local
|
||||
.retain(|(id, _), _| id != owner);
|
||||
}
|
||||
pub(super) fn clear(world: &mut World) {
|
||||
let mut s = world.resource_mut::<ModSync>();
|
||||
s.local.clear();
|
||||
s.remote.clear();
|
||||
s.states.clear();
|
||||
}
|
||||
@@ -0,0 +1,754 @@
|
||||
//! Independent, draggable enabled-mod windows. Positions survive closing Escape.
|
||||
use super::{ModMenu, Mods};
|
||||
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
|
||||
use bevy::prelude::*;
|
||||
use std::collections::BTreeMap;
|
||||
mod scrollbar;
|
||||
|
||||
#[derive(Default)]
|
||||
struct Layout {
|
||||
position: Vec2,
|
||||
size: Vec2,
|
||||
collapsed: bool,
|
||||
selected: usize,
|
||||
status: String,
|
||||
reveal_selected: bool,
|
||||
}
|
||||
#[derive(Resource, Default)]
|
||||
pub(crate) struct EnabledPanel {
|
||||
pub focused: bool,
|
||||
active: Option<String>,
|
||||
layouts: BTreeMap<String, Layout>,
|
||||
drag: Option<(String, Vec2)>,
|
||||
resize: Option<(String, Vec2)>,
|
||||
scroll_drag: Option<(String, f32)>,
|
||||
signature: Vec<(String, Vec<String>)>,
|
||||
}
|
||||
impl EnabledPanel {
|
||||
pub fn dragging(&self) -> bool {
|
||||
self.drag.is_some() || self.resize.is_some() || self.scroll_drag.is_some()
|
||||
}
|
||||
}
|
||||
#[derive(Component)]
|
||||
struct Root(String);
|
||||
#[derive(Component)]
|
||||
struct Body(String);
|
||||
#[derive(Component)]
|
||||
struct Viewport(String);
|
||||
#[derive(Component)]
|
||||
struct Header(String);
|
||||
#[derive(Component)]
|
||||
struct ResizeGrip(String);
|
||||
#[derive(Component)]
|
||||
struct Row(String, usize);
|
||||
#[derive(Component)]
|
||||
struct Label(String, usize);
|
||||
#[derive(Component)]
|
||||
struct ValueLabel(String, usize);
|
||||
#[derive(Component)]
|
||||
struct Hint(String);
|
||||
#[derive(Component)]
|
||||
struct CollapseLabel(String);
|
||||
#[derive(Component, Clone)]
|
||||
struct Action(String, Operation);
|
||||
#[derive(Clone)]
|
||||
enum Operation {
|
||||
Adjust(usize, i32),
|
||||
Collapse,
|
||||
Configure,
|
||||
}
|
||||
pub(super) fn install(app: &mut App) {
|
||||
app.init_resource::<EnabledPanel>()
|
||||
.add_systems(
|
||||
PreUpdate,
|
||||
input
|
||||
.after(crate::graphics_menu::MenuInput)
|
||||
.before(crate::map_transition::MapTransitionSet),
|
||||
)
|
||||
.add_systems(Update, (sync, draw, scroll, scrollbar::update).chain());
|
||||
}
|
||||
fn button(parent: &mut ChildSpawnerCommands, text: &str, action: Action) {
|
||||
parent
|
||||
.spawn((
|
||||
Button,
|
||||
action,
|
||||
Node {
|
||||
min_width: px(28.),
|
||||
height: px(28.),
|
||||
padding: UiRect::horizontal(px(5.)),
|
||||
align_items: AlignItems::Center,
|
||||
justify_content: JustifyContent::Center,
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(Color::srgb(0.12, 0.20, 0.26)),
|
||||
))
|
||||
.with_children(|p| {
|
||||
p.spawn((
|
||||
Text::new(text),
|
||||
TextFont {
|
||||
font_size: 16.,
|
||||
..default()
|
||||
},
|
||||
TextColor(Color::WHITE),
|
||||
));
|
||||
});
|
||||
}
|
||||
fn sync(
|
||||
mut commands: Commands,
|
||||
mods: Res<Mods>,
|
||||
mut panel: ResMut<EnabledPanel>,
|
||||
roots: Query<Entity, With<Root>>,
|
||||
) {
|
||||
let signature: Vec<_> = mods
|
||||
.manager
|
||||
.packages
|
||||
.iter()
|
||||
.filter(|(_, p)| p.running())
|
||||
.map(|(id, p)| {
|
||||
(
|
||||
id.clone(),
|
||||
p.manifest.settings.keys().cloned().collect::<Vec<_>>(),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
if signature == panel.signature {
|
||||
return;
|
||||
}
|
||||
for root in &roots {
|
||||
commands.entity(root).despawn();
|
||||
}
|
||||
panel.signature = signature.clone();
|
||||
panel.drag = None;
|
||||
panel.resize = None;
|
||||
panel.scroll_drag = None;
|
||||
if !signature
|
||||
.iter()
|
||||
.any(|(id, _)| panel.active.as_ref() == Some(id))
|
||||
{
|
||||
panel.active = signature.first().map(|(id, _)| id.clone());
|
||||
panel.focused = false;
|
||||
}
|
||||
for (index, (id, settings)) in signature.iter().enumerate() {
|
||||
panel.layouts.entry(id.clone()).or_insert_with(|| Layout {
|
||||
size: Vec2::new(360., 760.),
|
||||
position: Vec2::new(16. + index as f32 * 22., 64. + index as f32 * 34.),
|
||||
..default()
|
||||
});
|
||||
let name = &mods.manager.packages[id].manifest.name;
|
||||
commands
|
||||
.spawn((
|
||||
Root(id.clone()),
|
||||
GlobalZIndex(12),
|
||||
Node {
|
||||
display: Display::None,
|
||||
position_type: PositionType::Absolute,
|
||||
width: px(360.),
|
||||
height: px(760.),
|
||||
max_width: percent(95),
|
||||
padding: UiRect::all(px(10.)),
|
||||
row_gap: px(8.),
|
||||
flex_direction: FlexDirection::Column,
|
||||
border_radius: BorderRadius::all(px(9.)),
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(Color::srgb(0.035, 0.055, 0.08)),
|
||||
))
|
||||
.with_children(|window| {
|
||||
window
|
||||
.spawn(Node {
|
||||
width: percent(100),
|
||||
column_gap: px(5.),
|
||||
..default()
|
||||
})
|
||||
.with_children(|bar| {
|
||||
bar.spawn((
|
||||
Button,
|
||||
Header(id.clone()),
|
||||
Node {
|
||||
flex_grow: 1.,
|
||||
min_width: px(0.),
|
||||
padding: UiRect::all(px(5.)),
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(Color::srgb(0.08, 0.18, 0.20)),
|
||||
))
|
||||
.with_children(|title| {
|
||||
title.spawn((
|
||||
Text::new(name),
|
||||
TextFont {
|
||||
font_size: 17.,
|
||||
..default()
|
||||
},
|
||||
TextColor(Color::srgb(0.25, 1., 0.4)),
|
||||
));
|
||||
});
|
||||
bar.spawn((
|
||||
Button,
|
||||
Action(id.clone(), Operation::Collapse),
|
||||
Node {
|
||||
width: px(30.),
|
||||
justify_content: JustifyContent::Center,
|
||||
align_items: AlignItems::Center,
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(Color::srgb(0.12, 0.20, 0.26)),
|
||||
))
|
||||
.with_children(|b| {
|
||||
b.spawn((
|
||||
CollapseLabel(id.clone()),
|
||||
Text::new("-"),
|
||||
TextFont {
|
||||
font_size: 18.,
|
||||
..default()
|
||||
},
|
||||
TextColor(Color::WHITE),
|
||||
));
|
||||
});
|
||||
});
|
||||
window
|
||||
.spawn((
|
||||
Body(id.clone()),
|
||||
Node {
|
||||
flex_grow: 1.,
|
||||
min_height: px(0.),
|
||||
flex_direction: FlexDirection::Column,
|
||||
row_gap: px(6.),
|
||||
..default()
|
||||
},
|
||||
))
|
||||
.with_children(|body| {
|
||||
body.spawn(Node {
|
||||
flex_grow: 1.,
|
||||
flex_basis: px(0.),
|
||||
min_height: px(0.),
|
||||
column_gap: px(6.),
|
||||
..default()
|
||||
})
|
||||
.with_children(|area| {
|
||||
area.spawn((
|
||||
Viewport(id.clone()),
|
||||
ScrollPosition::default(),
|
||||
Node {
|
||||
flex_grow: 1.,
|
||||
flex_basis: px(0.),
|
||||
min_height: px(0.),
|
||||
min_width: px(0.),
|
||||
height: percent(100),
|
||||
overflow: Overflow::scroll_y(),
|
||||
flex_direction: FlexDirection::Column,
|
||||
row_gap: px(6.),
|
||||
..default()
|
||||
},
|
||||
))
|
||||
.with_children(|list| {
|
||||
for i in 0..settings.len() {
|
||||
list.spawn((
|
||||
Row(id.clone(), i),
|
||||
Node {
|
||||
align_items: AlignItems::Center,
|
||||
column_gap: px(4.),
|
||||
min_height: px(34.),
|
||||
flex_shrink: 0.,
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(Color::srgb(0.08, 0.11, 0.15)),
|
||||
))
|
||||
.with_children(|row| {
|
||||
row.spawn((
|
||||
Label(id.clone(), i),
|
||||
Text::new(""),
|
||||
TextFont {
|
||||
font_size: 14.,
|
||||
..default()
|
||||
},
|
||||
TextColor(Color::WHITE),
|
||||
Node {
|
||||
flex_grow: 1.,
|
||||
width: px(132.),
|
||||
..default()
|
||||
},
|
||||
));
|
||||
button(
|
||||
row,
|
||||
"-",
|
||||
Action(id.clone(), Operation::Adjust(i, -1)),
|
||||
);
|
||||
row.spawn((
|
||||
ValueLabel(id.clone(), i),
|
||||
Text::new(""),
|
||||
TextFont {
|
||||
font_size: 14.,
|
||||
..default()
|
||||
},
|
||||
TextColor(Color::WHITE),
|
||||
Node {
|
||||
width: px(51.),
|
||||
..default()
|
||||
},
|
||||
));
|
||||
button(
|
||||
row,
|
||||
"+",
|
||||
Action(id.clone(), Operation::Adjust(i, 1)),
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
scrollbar::spawn(area, id);
|
||||
});
|
||||
body.spawn(Node {
|
||||
column_gap: px(6.),
|
||||
..default()
|
||||
})
|
||||
.with_children(|p| {
|
||||
button(p, "Manage mod", Action(id.clone(), Operation::Configure));
|
||||
});
|
||||
body.spawn((
|
||||
Hint(id.clone()),
|
||||
Text::new(""),
|
||||
TextFont {
|
||||
font_size: 12.,
|
||||
..default()
|
||||
},
|
||||
TextColor(Color::srgb(0.65, 0.85, 0.85)),
|
||||
));
|
||||
body.spawn((
|
||||
Button,
|
||||
ResizeGrip(id.clone()),
|
||||
Node {
|
||||
align_self: AlignSelf::FlexEnd,
|
||||
width: px(28.),
|
||||
height: px(22.),
|
||||
flex_shrink: 0.,
|
||||
justify_content: JustifyContent::Center,
|
||||
align_items: AlignItems::Center,
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(Color::srgb(0.12, 0.20, 0.26)),
|
||||
))
|
||||
.with_children(|grip| {
|
||||
grip.spawn((
|
||||
Text::new("//"),
|
||||
TextFont {
|
||||
font_size: 18.,
|
||||
..default()
|
||||
},
|
||||
TextColor(Color::WHITE),
|
||||
));
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
fn input(
|
||||
mut panel: ResMut<EnabledPanel>,
|
||||
pause: Res<crate::graphics_menu::Menu>,
|
||||
mut menu: ResMut<ModMenu>,
|
||||
custom: Res<crate::customiser::Customiser>,
|
||||
mut mods: ResMut<Mods>,
|
||||
keys: Res<ButtonInput<KeyCode>>,
|
||||
mouse: Res<ButtonInput<MouseButton>>,
|
||||
nav: Res<crate::customiser::Navigation>,
|
||||
window: Single<&Window, With<bevy::window::PrimaryWindow>>,
|
||||
grips: Query<(&Interaction, &ResizeGrip), Changed<Interaction>>,
|
||||
headers: Query<(&Interaction, &Header), Changed<Interaction>>,
|
||||
buttons: Query<(&Interaction, &Action), Changed<Interaction>>,
|
||||
) {
|
||||
if !pause.open || menu.open || custom.open {
|
||||
panel.focused = false;
|
||||
panel.drag = None;
|
||||
panel.resize = None;
|
||||
panel.scroll_drag = None;
|
||||
return;
|
||||
}
|
||||
let ids: Vec<_> = mods
|
||||
.manager
|
||||
.packages
|
||||
.iter()
|
||||
.filter(|(_, p)| p.running())
|
||||
.map(|(id, _)| id.clone())
|
||||
.collect();
|
||||
if ids.is_empty() {
|
||||
panel.focused = false;
|
||||
panel.drag = None;
|
||||
panel.resize = None;
|
||||
panel.scroll_drag = None;
|
||||
return;
|
||||
}
|
||||
if keys.just_pressed(KeyCode::Tab) || nav.pressed & 0x4000 != 0 {
|
||||
let next = if panel.focused {
|
||||
panel
|
||||
.active
|
||||
.as_ref()
|
||||
.and_then(|id| ids.iter().position(|s| s == id))
|
||||
.map(|i| i + 1)
|
||||
.unwrap_or(0)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
panel.focused = next < ids.len();
|
||||
if panel.focused {
|
||||
panel.active = Some(ids[next].clone());
|
||||
}
|
||||
}
|
||||
if !mouse.pressed(MouseButton::Left) {
|
||||
panel.drag = None;
|
||||
panel.resize = None;
|
||||
panel.scroll_drag = None;
|
||||
}
|
||||
for (interaction, header) in &headers {
|
||||
if *interaction == Interaction::Pressed && mouse.pressed(MouseButton::Left) {
|
||||
if let (Some(cursor), Some(layout)) =
|
||||
(window.cursor_position(), panel.layouts.get(&header.0))
|
||||
{
|
||||
panel.drag = Some((header.0.clone(), cursor - fitted_position(layout, &window)));
|
||||
panel.active = Some(header.0.clone());
|
||||
panel.focused = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (interaction, grip) in &grips {
|
||||
if *interaction == Interaction::Pressed && mouse.pressed(MouseButton::Left) {
|
||||
if let (Some(cursor), Some(layout)) =
|
||||
(window.cursor_position(), panel.layouts.get(&grip.0))
|
||||
{
|
||||
let size = fit_size(layout.size, &window);
|
||||
let position = fitted_position(layout, &window);
|
||||
panel.layouts.get_mut(&grip.0).unwrap().position = position;
|
||||
panel.resize = Some((grip.0.clone(), size - cursor));
|
||||
panel.drag = None;
|
||||
panel.active = Some(grip.0.clone());
|
||||
panel.focused = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let (Some((id, offset)), Some(cursor)) = (panel.resize.clone(), window.cursor_position()) {
|
||||
if let Some(layout) = panel.layouts.get_mut(&id) {
|
||||
layout.size = fit_size(cursor + offset, &window)
|
||||
.min(Vec2::new(window.width(), window.height()) - layout.position);
|
||||
}
|
||||
}
|
||||
if let (Some((id, offset)), Some(cursor)) = (panel.drag.clone(), window.cursor_position()) {
|
||||
if let Some(layout) = panel.layouts.get_mut(&id) {
|
||||
layout.position = (cursor - offset).clamp(
|
||||
Vec2::ZERO,
|
||||
Vec2::new(
|
||||
(window.width() - fit_size(layout.size, &window).x).max(0.),
|
||||
(window.height()
|
||||
- if layout.collapsed {
|
||||
48.
|
||||
} else {
|
||||
fit_size(layout.size, &window).y
|
||||
})
|
||||
.max(0.),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
let mut action = None;
|
||||
if panel.focused {
|
||||
if let Some(id) = panel.active.clone() {
|
||||
if let (Some(p), Some(layout)) =
|
||||
(mods.manager.packages.get(&id), panel.layouts.get_mut(&id))
|
||||
{
|
||||
let count = p.manifest.settings.len();
|
||||
if count > 0 {
|
||||
layout.selected = layout.selected.min(count - 1);
|
||||
if keys.just_pressed(KeyCode::ArrowUp) || nav.pressed & 1 != 0 {
|
||||
layout.selected = (layout.selected + count - 1) % count;
|
||||
layout.reveal_selected = true;
|
||||
}
|
||||
if keys.just_pressed(KeyCode::ArrowDown) || nav.pressed & 2 != 0 {
|
||||
layout.selected = (layout.selected + 1) % count;
|
||||
layout.reveal_selected = true;
|
||||
}
|
||||
if keys.just_pressed(KeyCode::ArrowLeft) || nav.pressed & 4 != 0 {
|
||||
action = Some(Action(id.clone(), Operation::Adjust(layout.selected, -1)));
|
||||
}
|
||||
if keys.just_pressed(KeyCode::ArrowRight)
|
||||
|| keys.just_pressed(KeyCode::Enter)
|
||||
|| nav.pressed & (8 | 0x1000) != 0
|
||||
{
|
||||
action = Some(Action(id.clone(), Operation::Adjust(layout.selected, 1)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (interaction, button) in &buttons {
|
||||
if *interaction == Interaction::Pressed {
|
||||
action = Some(button.clone());
|
||||
panel.active = Some(button.0.clone());
|
||||
panel.focused = true;
|
||||
}
|
||||
}
|
||||
let Some(Action(id, operation)) = action else {
|
||||
return;
|
||||
};
|
||||
let Some(p) = mods.manager.packages.get(&id) else {
|
||||
return;
|
||||
};
|
||||
if !p.running() {
|
||||
return;
|
||||
}
|
||||
let Some(layout) = panel.layouts.get_mut(&id) else {
|
||||
return;
|
||||
};
|
||||
match operation {
|
||||
Operation::Collapse => {
|
||||
layout.collapsed = !layout.collapsed;
|
||||
}
|
||||
Operation::Configure => menu.configure(id),
|
||||
Operation::Adjust(row, direction) => {
|
||||
if layout.collapsed {
|
||||
return;
|
||||
}
|
||||
let selected = row;
|
||||
let Some((key, s)) = p.manifest.settings.iter().nth(selected) else {
|
||||
return;
|
||||
};
|
||||
let key = key.clone();
|
||||
layout.selected = selected;
|
||||
let v = &p.settings[&key];
|
||||
let next = match s.kind.as_str() {
|
||||
"number" => Some(serde_json::json!(
|
||||
(v.as_f64().unwrap() + direction as f64 * s.step.unwrap())
|
||||
.clamp(s.min.unwrap(), s.max.unwrap())
|
||||
)),
|
||||
"boolean" => Some(serde_json::json!(!v.as_bool().unwrap())),
|
||||
"choice" => {
|
||||
let i = s
|
||||
.choices
|
||||
.iter()
|
||||
.position(|c| Some(c.as_str()) == v.as_str())
|
||||
.unwrap_or(0);
|
||||
Some(serde_json::json!(
|
||||
s.choices
|
||||
[(i as i32 + direction).rem_euclid(s.choices.len() as i32) as usize]
|
||||
))
|
||||
}
|
||||
_ => {
|
||||
menu.configure(id.clone());
|
||||
None
|
||||
}
|
||||
};
|
||||
if let Some(v) = next {
|
||||
layout.status = mods.manager.setting(&id, &key, v).err().unwrap_or_default();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
fn draw(
|
||||
panel: Res<EnabledPanel>,
|
||||
pause: Res<crate::graphics_menu::Menu>,
|
||||
menu: Res<ModMenu>,
|
||||
custom: Res<crate::customiser::Customiser>,
|
||||
mods: Res<Mods>,
|
||||
window: Single<&Window, With<bevy::window::PrimaryWindow>>,
|
||||
mut roots: Query<(&Root, &mut Node, &mut GlobalZIndex)>,
|
||||
mut bodies: Query<(&Body, &mut Node), Without<Root>>,
|
||||
mut rows: Query<(&Row, &mut Node, &mut BackgroundColor), (Without<Root>, Without<Body>)>,
|
||||
mut text: Query<(
|
||||
&mut Text,
|
||||
Option<&Label>,
|
||||
Option<&ValueLabel>,
|
||||
Option<&Hint>,
|
||||
Option<&CollapseLabel>,
|
||||
)>,
|
||||
) {
|
||||
let visible = pause.open && !menu.open && !custom.open;
|
||||
for (root, mut node, mut z) in &mut roots {
|
||||
if let Some(layout) = panel.layouts.get(&root.0) {
|
||||
node.display = if visible
|
||||
&& mods
|
||||
.manager
|
||||
.packages
|
||||
.get(&root.0)
|
||||
.is_some_and(|p| p.running())
|
||||
{
|
||||
Display::Flex
|
||||
} else {
|
||||
Display::None
|
||||
};
|
||||
let size = fit_size(layout.size, &window);
|
||||
node.width = px(size.x);
|
||||
node.height = if layout.collapsed {
|
||||
Val::Auto
|
||||
} else {
|
||||
px(size.y)
|
||||
};
|
||||
node.left = px(layout
|
||||
.position
|
||||
.x
|
||||
.clamp(0., (window.width() - size.x).max(0.)));
|
||||
node.top = px(layout.position.y.clamp(
|
||||
0.,
|
||||
(window.height() - if layout.collapsed { 48. } else { size.y }).max(0.),
|
||||
));
|
||||
z.0 = if panel.active.as_ref() == Some(&root.0) {
|
||||
14
|
||||
} else {
|
||||
12
|
||||
};
|
||||
}
|
||||
}
|
||||
for (body, mut node) in &mut bodies {
|
||||
node.display = if panel.layouts.get(&body.0).is_some_and(|l| !l.collapsed) {
|
||||
Display::Flex
|
||||
} else {
|
||||
Display::None
|
||||
};
|
||||
}
|
||||
for (row, mut node, mut color) in &mut rows {
|
||||
let selected = panel.layouts.get(&row.0).map_or(0, |l| l.selected);
|
||||
let index = row.1;
|
||||
node.display = if mods
|
||||
.manager
|
||||
.packages
|
||||
.get(&row.0)
|
||||
.is_some_and(|p| index < p.manifest.settings.len())
|
||||
{
|
||||
Display::Flex
|
||||
} else {
|
||||
Display::None
|
||||
};
|
||||
color.0 = if panel.focused && panel.active.as_ref() == Some(&row.0) && index == selected {
|
||||
Color::srgb(0.10, 0.30, 0.34)
|
||||
} else {
|
||||
Color::srgb(0.08, 0.11, 0.15)
|
||||
};
|
||||
}
|
||||
for (mut t, label, value, hint, collapse) in &mut text {
|
||||
if let Some(c) = collapse {
|
||||
**t = if panel.layouts.get(&c.0).is_some_and(|l| l.collapsed) {
|
||||
"+"
|
||||
} else {
|
||||
"-"
|
||||
}
|
||||
.into();
|
||||
}
|
||||
if let Some(h) = hint {
|
||||
if let Some(l) = panel.layouts.get(&h.0) {
|
||||
**t = format!(
|
||||
"Drag title to move | Drag // to resize\nWheel or drag scrollbar | Tab / X: focus\n{}",
|
||||
l.status
|
||||
);
|
||||
}
|
||||
}
|
||||
let field = label
|
||||
.map(|l| (&l.0, l.1, false))
|
||||
.or_else(|| value.map(|v| (&v.0, v.1, true)));
|
||||
if let Some((id, row, is_value)) = field {
|
||||
let setting = mods
|
||||
.manager
|
||||
.packages
|
||||
.get(id)
|
||||
.and_then(|p| p.manifest.settings.iter().nth(row).map(|(k, s)| (p, k, s)));
|
||||
**t = setting
|
||||
.map(|(p, key, s)| {
|
||||
if !is_value {
|
||||
s.label.clone()
|
||||
} else {
|
||||
let v = &p.settings[key];
|
||||
v.as_f64().map(|n| format!("{n:.2}")).unwrap_or_else(|| {
|
||||
v.as_str()
|
||||
.map(str::to_owned)
|
||||
.unwrap_or_else(|| v.to_string())
|
||||
})
|
||||
}
|
||||
})
|
||||
.unwrap_or_default();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Scroll only the uppermost mod window under the pointer. Use computed UI
|
||||
// geometry so DPI scaling, dragging and wrapped setting labels remain correct.
|
||||
fn scroll(
|
||||
mut wheel: MessageReader<MouseWheel>,
|
||||
mut panel: ResMut<EnabledPanel>,
|
||||
pause: Res<crate::graphics_menu::Menu>,
|
||||
menu: Res<ModMenu>,
|
||||
custom: Res<crate::customiser::Customiser>,
|
||||
window: Single<&Window, With<bevy::window::PrimaryWindow>>,
|
||||
roots: Query<(&Root, &ComputedNode, &UiGlobalTransform)>,
|
||||
mut lists: Query<(
|
||||
&Viewport,
|
||||
&ComputedNode,
|
||||
&UiGlobalTransform,
|
||||
&mut ScrollPosition,
|
||||
)>,
|
||||
rows: Query<(&Row, &ComputedNode, &UiGlobalTransform)>,
|
||||
) {
|
||||
let delta: f32 = wheel
|
||||
.read()
|
||||
.map(|event| match event.unit {
|
||||
MouseScrollUnit::Line => event.y * 40.,
|
||||
MouseScrollUnit::Pixel => event.y,
|
||||
})
|
||||
.sum();
|
||||
if !pause.open || menu.open || custom.open || panel.dragging() {
|
||||
return;
|
||||
}
|
||||
let hovered = window.physical_cursor_position().and_then(|cursor| {
|
||||
roots
|
||||
.iter()
|
||||
.filter(|(_, node, transform)| node.contains_point(**transform, cursor))
|
||||
.max_by_key(|(root, node, _)| {
|
||||
(panel.active.as_ref() == Some(&root.0), node.stack_index)
|
||||
})
|
||||
.map(|(root, _, _)| root.0.clone())
|
||||
});
|
||||
for (viewport, node, transform, mut position) in &mut lists {
|
||||
let Some(layout) = panel.layouts.get_mut(&viewport.0) else {
|
||||
continue;
|
||||
};
|
||||
if layout.collapsed {
|
||||
continue;
|
||||
}
|
||||
if hovered.as_ref() == Some(&viewport.0) && delta != 0. {
|
||||
position.0.y -= delta;
|
||||
layout.reveal_selected = false;
|
||||
}
|
||||
if layout.reveal_selected && node.size().y > 0. {
|
||||
if let Some((_, row_node, row_transform)) = rows
|
||||
.iter()
|
||||
.find(|(row, _, _)| row.0 == viewport.0 && row.1 == layout.selected)
|
||||
{
|
||||
let center = transform
|
||||
.inverse()
|
||||
.transform_point2(row_transform.translation);
|
||||
let top = center.y - row_node.size().y / 2. + node.size().y / 2.;
|
||||
let bottom = top + row_node.size().y;
|
||||
let shift = if top < 0. {
|
||||
top
|
||||
} else {
|
||||
(bottom - node.size().y).max(0.)
|
||||
};
|
||||
position.0.y += shift * node.inverse_scale_factor;
|
||||
layout.reveal_selected = false;
|
||||
}
|
||||
}
|
||||
let max = ((node.content_size().y - node.size().y) * node.inverse_scale_factor).max(0.);
|
||||
position.0.y = position.0.y.clamp(0., max);
|
||||
}
|
||||
}
|
||||
|
||||
fn fit_size(size: Vec2, window: &Window) -> Vec2 {
|
||||
let maximum = Vec2::new(
|
||||
(window.width() - 16.).max(1.),
|
||||
(window.height() - 32.).max(1.),
|
||||
);
|
||||
size.clamp(Vec2::new(320., 260.).min(maximum), maximum)
|
||||
}
|
||||
|
||||
fn fitted_position(layout: &Layout, window: &Window) -> Vec2 {
|
||||
let mut size = fit_size(layout.size, window);
|
||||
if layout.collapsed {
|
||||
size.y = 48.;
|
||||
}
|
||||
layout.position.clamp(
|
||||
Vec2::ZERO,
|
||||
(Vec2::new(window.width(), window.height()) - size).max(Vec2::ZERO),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
//! A scrollbar tied to the actual settings viewport, including resize and wheel changes.
|
||||
use super::*;
|
||||
|
||||
#[derive(Component)]
|
||||
pub(super) struct Track(String);
|
||||
#[derive(Component)]
|
||||
pub(super) struct Thumb(String);
|
||||
|
||||
pub(super) fn spawn(area: &mut ChildSpawnerCommands, id: &str) {
|
||||
area.spawn((
|
||||
Button,
|
||||
Track(id.into()),
|
||||
Node {
|
||||
width: px(14.),
|
||||
height: percent(100),
|
||||
flex_shrink: 0.,
|
||||
border_radius: BorderRadius::all(px(6.)),
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(Color::srgb(0.07, 0.11, 0.16)),
|
||||
))
|
||||
.with_children(|track| {
|
||||
track.spawn((
|
||||
Thumb(id.into()),
|
||||
bevy::ui::FocusPolicy::Pass,
|
||||
Node {
|
||||
position_type: PositionType::Absolute,
|
||||
left: px(2.),
|
||||
right: px(2.),
|
||||
top: px(0.),
|
||||
height: percent(100),
|
||||
border_radius: BorderRadius::all(px(5.)),
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(Color::srgb(0.25, 0.65, 0.68)),
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
pub(super) fn update(
|
||||
mut panel: ResMut<EnabledPanel>,
|
||||
pause: Res<crate::graphics_menu::Menu>,
|
||||
menu: Res<ModMenu>,
|
||||
custom: Res<crate::customiser::Customiser>,
|
||||
mouse: Res<ButtonInput<MouseButton>>,
|
||||
window: Single<&Window, With<bevy::window::PrimaryWindow>>,
|
||||
tracks: Query<(&Track, &Interaction, &ComputedNode, &UiGlobalTransform)>,
|
||||
mut thumbs: Query<(&Thumb, &mut Node, &mut BackgroundColor)>,
|
||||
mut lists: Query<(&Viewport, &ComputedNode, &mut ScrollPosition)>,
|
||||
) {
|
||||
if !mouse.pressed(MouseButton::Left) || !pause.open || menu.open || custom.open {
|
||||
panel.scroll_drag = None;
|
||||
}
|
||||
if !pause.open || menu.open || custom.open || panel.drag.is_some() || panel.resize.is_some() {
|
||||
return;
|
||||
}
|
||||
for (track, interaction, geometry, transform) in &tracks {
|
||||
if !panel
|
||||
.layouts
|
||||
.get(&track.0)
|
||||
.is_some_and(|layout| !layout.collapsed)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let Some((_, viewport, mut scroll)) = lists.iter_mut().find(|(v, _, _)| v.0 == track.0)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let height = geometry.size().y * geometry.inverse_scale_factor;
|
||||
if height <= 0. {
|
||||
continue;
|
||||
}
|
||||
let visible = viewport.size().y;
|
||||
let content = viewport.content_size().y.max(visible);
|
||||
let maximum = ((content - visible) * viewport.inverse_scale_factor).max(0.);
|
||||
let thumb_height = (height * visible / content.max(1.)).max(24.).min(height);
|
||||
let travel = height - thumb_height;
|
||||
scroll.0.y = scroll.0.y.clamp(0., maximum);
|
||||
let mut top = if maximum > 0. {
|
||||
travel * scroll.0.y / maximum
|
||||
} else {
|
||||
0.
|
||||
};
|
||||
if let Some(cursor) = window.physical_cursor_position() {
|
||||
let y = (transform.inverse().transform_point2(cursor).y + geometry.size().y / 2.)
|
||||
* geometry.inverse_scale_factor;
|
||||
if maximum > 0.
|
||||
&& travel > 0.
|
||||
&& *interaction == Interaction::Pressed
|
||||
&& mouse.just_pressed(MouseButton::Left)
|
||||
{
|
||||
let offset = if (top..=top + thumb_height).contains(&y) {
|
||||
(y - top) / thumb_height
|
||||
} else {
|
||||
0.5
|
||||
};
|
||||
panel.scroll_drag = Some((track.0.clone(), offset));
|
||||
panel.active = Some(track.0.clone());
|
||||
panel.focused = true;
|
||||
}
|
||||
if let Some((id, offset)) = &panel.scroll_drag {
|
||||
if id == &track.0 && travel > 0. {
|
||||
top = (y - offset * thumb_height).clamp(0., travel);
|
||||
scroll.0.y = top / travel * maximum;
|
||||
if let Some(layout) = panel.layouts.get_mut(&track.0) {
|
||||
layout.reveal_selected = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some((_, mut node, mut color)) =
|
||||
thumbs.iter_mut().find(|(thumb, _, _)| thumb.0 == track.0)
|
||||
{
|
||||
node.top = px(top);
|
||||
node.height = px(thumb_height);
|
||||
color.0 = if maximum <= 0. {
|
||||
Color::srgb(0.16, 0.24, 0.28)
|
||||
} else if *interaction != Interaction::None
|
||||
|| panel
|
||||
.scroll_drag
|
||||
.as_ref()
|
||||
.is_some_and(|(id, _)| id == &track.0)
|
||||
{
|
||||
Color::srgb(0.35, 0.9, 0.85)
|
||||
} else {
|
||||
Color::srgb(0.25, 0.65, 0.68)
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
use bevy::prelude::*;
|
||||
use serde::Deserialize;
|
||||
use std::collections::BTreeMap;
|
||||
#[derive(Default)]
|
||||
pub(super) struct Clips(pub BTreeMap<String, Clip>);
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct File {
|
||||
version: u32,
|
||||
bone_names: Vec<String>,
|
||||
clips: BTreeMap<String, Clip>,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(super) struct Clip {
|
||||
fps: f32,
|
||||
frames: Vec<Vec<[f32; 16]>>,
|
||||
}
|
||||
impl Clips {
|
||||
pub fn load(
|
||||
root: &std::path::Path,
|
||||
definition: &skate_vehicles::VehicleDefinition,
|
||||
names: &[String],
|
||||
) -> Result<Self, String> {
|
||||
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<Vec<Mat4>> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
//! Shared synthesized engine voices; remote volume attenuates with distance.
|
||||
use bevy::{audio::{AddAudioSource,Volume},prelude::*};
|
||||
use std::collections::{BTreeMap,BTreeSet};
|
||||
use super::engine_sound::Engine;
|
||||
#[derive(Component)] struct EngineVoice(u64);
|
||||
#[derive(Resource,Default)] struct State {source:Option<Handle<Engine>>,mix:BTreeMap<u64,(f32,f32)>}
|
||||
pub(super) fn install(app:&mut App) {app.add_audio_source::<Engine>().init_resource::<State>().add_systems(Update,update.after(super::present));}
|
||||
fn update(mut commands:Commands,vehicles:Res<super::Vehicles>,time:Res<Time<Real>>,menu:Option<Res<crate::graphics_menu::Menu>>,
|
||||
replay:Res<crate::replay::Replay>,skater:Res<crate::physics::SkaterRuntime>,mut state:ResMut<State>,mut sources:ResMut<Assets<Engine>>,
|
||||
mut voices:Query<(Entity,&EngineVoice,&mut AudioSink)>,pending:Query<&EngineVoice>) {
|
||||
let active=crate::graphics_menu::gameplay_active(menu) && !replay.active;
|
||||
let listener=vehicles.player_pose().map(|p|Vec3::from_array(p.0)).unwrap_or_else(||crate::animation::native_matrix(skater.animated_skeleton.roots.animation_to_world).w_axis.truncate());
|
||||
let existing:BTreeSet<_>=pending.iter().map(|v|v.0).collect();
|
||||
let mut targets=BTreeMap::new();
|
||||
for (&id,car) in &vehicles.simulation.vehicles {
|
||||
let a=&car.definition.engine_audio;
|
||||
if !a.enabled || !super::network::occupied(&vehicles,id) {continue;}
|
||||
let Some((p,_))=vehicles.simulation.pose(id) else {continue;};
|
||||
let distance=listener.distance(Vec3::from_array(p));if distance>80. {continue;}
|
||||
let local=vehicles.driver.as_ref().and_then(|d|vehicles.owned.get(&(d.owner.clone(),d.key.clone()))).is_some_and(|i|i.id==id);
|
||||
let attenuation=if local {1.} else {1./(1.+distance*distance/36.)};
|
||||
let speed=(car.controller.current_vehicle_speed.abs()/car.definition.max_speed).clamp(0.,1.);
|
||||
let throttle=car.controls.throttle.abs();let revs=(speed*0.65+throttle*0.35).clamp(0.,1.);
|
||||
let pitch=a.idle_pitch+(a.max_pitch-a.idle_pitch)*revs;
|
||||
let volume=if active {a.volume*(0.22+0.65*throttle+0.13*speed)*attenuation} else {0.};
|
||||
targets.insert(id,(pitch,volume));
|
||||
if !existing.contains(&id) {
|
||||
let source=state.source.get_or_insert_with(||sources.add(Engine)).clone();
|
||||
commands.spawn((EngineVoice(id),AudioPlayer(source),PlaybackSettings::ONCE.with_volume(Volume::Linear(0.)).with_speed(pitch)));
|
||||
}
|
||||
state.mix.entry(id).or_insert((pitch,0.));
|
||||
}
|
||||
let dt=time.delta_secs().min(0.1);
|
||||
for (&id,(pitch,volume)) in &mut state.mix {
|
||||
let (p,v)=targets.get(&id).copied().unwrap_or((*pitch,0.));
|
||||
*pitch+=(p-*pitch)*(1.-(-7.*dt).exp());*volume+=(v-*volume)*(1.-(-12.*dt).exp());
|
||||
}
|
||||
for (entity,voice,mut sink) in &mut voices {
|
||||
let (pitch,volume)=state.mix.get(&voice.0).copied().unwrap_or((1.,0.));
|
||||
sink.set_speed(pitch.max(0.25));sink.set_volume(Volume::Linear(volume));
|
||||
if !targets.contains_key(&voice.0) && volume<0.001 {commands.entity(entity).despawn();state.mix.remove(&voice.0);}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
//! Original continuous exhaust synthesizer.
|
||||
use bevy::{audio::Source, prelude::*};
|
||||
use std::time::Duration;
|
||||
#[derive(Asset, TypePath)]
|
||||
pub struct Engine;
|
||||
pub struct Exhaust { phase: f32, noise: u32, filtered: f32 }
|
||||
impl Iterator for Exhaust {
|
||||
type Item = f32;
|
||||
fn next(&mut self) -> Option<f32> {
|
||||
self.phase = (self.phase + 80. / 48000.) % 1.;
|
||||
self.noise = self.noise.wrapping_mul(1664525).wrapping_add(1013904223);
|
||||
let noise = self.noise as f32 / u32::MAX as f32 * 2. - 1.;
|
||||
self.filtered += 0.12 * (noise - self.filtered);
|
||||
let p = self.phase * std::f32::consts::TAU;
|
||||
let pulse = p.sin() * 0.48 + (p * 2.).sin() * 0.24
|
||||
+ (p * 3.).sin() * 0.12 + (p * 5.).sin() * 0.06;
|
||||
Some((pulse + self.filtered * 0.18) * 0.65)
|
||||
}
|
||||
}
|
||||
impl Source for Exhaust {
|
||||
fn current_frame_len(&self) -> Option<usize> { None }
|
||||
fn channels(&self) -> u16 { 1 }
|
||||
fn sample_rate(&self) -> u32 { 48000 }
|
||||
fn total_duration(&self) -> Option<Duration> { None }
|
||||
}
|
||||
impl Decodable for Engine {
|
||||
type DecoderItem = f32;
|
||||
type Decoder = Exhaust;
|
||||
fn decoder(&self) -> Exhaust { Exhaust { phase: 0., noise: 17, filtered: 0. } }
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn exhaust_is_bounded_continuous_and_not_silent() {
|
||||
let mut source = Engine.decoder();
|
||||
let mut last = 0.;
|
||||
let mut energy = 0.;
|
||||
for _ in 0..48000 {
|
||||
let sample = source.next().unwrap();
|
||||
assert!(sample.is_finite() && sample.abs() < 0.8);
|
||||
assert!((sample - last).abs() < 0.1);
|
||||
energy += sample * sample;
|
||||
last = sample;
|
||||
}
|
||||
assert!(energy > 100.);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
//! One render-time sample for every visible part of a fixed-step vehicle.
|
||||
use bevy::prelude::*;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(super) struct Motion {
|
||||
pub body: Transform,
|
||||
pub wheels: Vec<Transform>,
|
||||
}
|
||||
|
||||
fn blend(a: Transform, b: Transform, alpha: f32) -> Transform {
|
||||
Transform {
|
||||
translation: a.translation.lerp(b.translation, alpha),
|
||||
rotation: a.rotation.normalize().slerp(b.rotation.normalize(), alpha),
|
||||
scale: a.scale.lerp(b.scale, alpha),
|
||||
}
|
||||
}
|
||||
|
||||
impl Motion {
|
||||
pub fn capture(sim: &skate_vehicles::Simulation, id: u64) -> Option<Self> {
|
||||
let (p, q) = sim.pose(id)?;
|
||||
let car = sim.vehicles.get(&id)?;
|
||||
Some(Self {
|
||||
body: Transform::from_translation(Vec3::from_array(p))
|
||||
.with_rotation(Quat::from_array(q)),
|
||||
wheels: car.controller.wheels().iter().map(|wheel| {
|
||||
Transform::from_xyz(0., (car.definition.suspension_length
|
||||
- wheel.raycast_info().suspension_length) / car.definition.model_scale, 0.)
|
||||
.with_rotation(Quat::from_rotation_y(wheel.steering)
|
||||
* Quat::from_rotation_x(-wheel.rotation))
|
||||
}).collect(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn sample(&self, current: &Self, alpha: f32) -> Self {
|
||||
let alpha = alpha.clamp(0., 1.);
|
||||
Self {
|
||||
body: blend(self.body, current.body, alpha),
|
||||
wheels: current.wheels.iter().enumerate().map(|(i, &now)| {
|
||||
self.wheels.get(i).map_or(now, |&before| blend(before, now, alpha))
|
||||
}).collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn subframes_move_continuously_and_keep_seat_attached() {
|
||||
let before = Motion { body: Transform::IDENTITY, wheels: vec![Transform::IDENTITY] };
|
||||
let after = Motion {
|
||||
body: Transform::from_xyz(1., 0., 0.).with_rotation(Quat::from_rotation_y(0.2)),
|
||||
wheels: vec![Transform::from_xyz(0., 0.1, 0.)],
|
||||
};
|
||||
let seat = Vec3::new(0., 0.18, -0.2);
|
||||
for index in 0..=4 {
|
||||
let alpha = index as f32 / 4.;
|
||||
let sample = before.sample(&after, alpha);
|
||||
assert!((sample.body.translation.x - alpha).abs() < 1e-6);
|
||||
assert!((sample.wheels[0].translation.y - alpha * 0.1).abs() < 1e-6);
|
||||
let rider = sample.body.transform_point(seat);
|
||||
assert!((rider.distance(sample.body.translation) - seat.length()).abs() < 1e-6);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,746 @@
|
||||
//! Native vehicle host: isolated Rapier world, mod ownership and driver lifecycle.
|
||||
mod animations;
|
||||
pub(crate) mod network;
|
||||
mod audio;
|
||||
mod engine_sound;
|
||||
mod interpolation;
|
||||
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<Scene>,
|
||||
clips: animations::Clips,
|
||||
last_control: f32,
|
||||
definition_path: String,
|
||||
}
|
||||
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,
|
||||
remote: BTreeMap<u64, network::Target>,
|
||||
skaters: BTreeMap<(u64,usize),skate_vehicles::rapier3d::prelude::RigidBodyHandle>,
|
||||
owned: BTreeMap<(String, String), Instance>,
|
||||
driver: Option<Driver>,
|
||||
pub(super) events: Vec<Value>,
|
||||
clock: f32,
|
||||
hidden: Vec<(Entity, Visibility)>,
|
||||
pub(crate) pose: Option<Vec<Mat4>>,
|
||||
pub(crate) network_pose: Option<skate_net::packed::PoseState>,
|
||||
last_visual: Vec<(Entity, Transform)>,
|
||||
blend_from: Vec<(Entity, Transform)>,
|
||||
visual_phase: String,
|
||||
blend_time: f32,
|
||||
steering_visual: f32,
|
||||
crash_handoff: bool,
|
||||
previous_motion: BTreeMap<u64, interpolation::Motion>,
|
||||
rendered_motion: BTreeMap<u64, interpolation::Motion>,
|
||||
}
|
||||
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<Transform> {
|
||||
let d = self.driver.as_ref()?;
|
||||
let i = self.owned.get(&(d.owner.clone(), d.key.clone()))?;
|
||||
let pose = self.rendered_motion.get(&i.id)?.body;
|
||||
let q = pose.rotation;
|
||||
let def = &self.simulation.vehicles[&i.id].definition;
|
||||
let center = pose.translation + 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) {
|
||||
audio::install(app);
|
||||
app.init_resource::<Vehicles>()
|
||||
.add_systems(
|
||||
FixedUpdate,
|
||||
tick.after(super::fixed)
|
||||
.run_if(network::simulation_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::<Vehicles>();
|
||||
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::<AssetServer>().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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(i)=v.owned.get(&(driver.owner.clone(),driver.key.clone())) {v.simulation.set_occupied(i.id,false);}
|
||||
v.pose = None;
|
||||
for (entity, visibility) in v.hidden.drain(..) {
|
||||
if let Some(mut current) = world.get_mut::<Visibility>(entity) {
|
||||
*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::<crate::physics::SkaterRuntime>();
|
||||
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(())
|
||||
}
|
||||
fn eject_now(world: &mut World, v: &mut Vehicles, ejection: skate_vehicles::Ejection) -> Result<(), String> {
|
||||
let Some(driver) = v.driver.as_ref() else {return Ok(());};
|
||||
let owner=driver.owner.clone(); let key=driver.key.clone();
|
||||
let id=v.owned[&(owner.clone(),key.clone())].id;
|
||||
let (_,rotation)=v.simulation.pose(id).ok_or("Missing crash vehicle")?;
|
||||
let forward=Quat::from_array(rotation)*Vec3::Z;
|
||||
let heading=forward.x.atan2(forward.z);
|
||||
let (sin,cos)=heading.sin_cos();
|
||||
// The native reset initializes a full upright body. Start clear of the seat,
|
||||
// then its ordinary ragdoll/contact solver takes over immediately.
|
||||
let p=Vec3::from_array(ejection.position)+Vec3::Y*0.25;
|
||||
let matrix=[[cos,0.,-sin,0.],[0.,1.,0.,0.],[sin,0.,cos,0.],[p.x,p.y,p.z,0.]];
|
||||
{
|
||||
let mut skater=world.resource_mut::<crate::physics::SkaterRuntime>();
|
||||
skater.player_input.request_teleport(matrix).map_err(|e|e.to_string())?;
|
||||
skater.teleport_state.request_vehicle_ejection(matrix,ejection.velocity,ejection.angular_velocity);
|
||||
}
|
||||
v.simulation.set_occupied(id,false);
|
||||
v.simulation.vehicles.get_mut(&id).unwrap().controls=Controls{brake:0.2,..Default::default()};
|
||||
v.driver=None;v.pose=None;v.crash_handoff=true;
|
||||
for (entity,visibility) in v.hidden.drain(..) {
|
||||
if let Some(mut current)=world.get_mut::<Visibility>(entity) {*current=visibility;}
|
||||
}
|
||||
v.events.push(json!({"name":"vehicle_bailed","owner":owner,"key":key,
|
||||
"reason":ejection.reason,"position":ejection.position,"velocity":ejection.velocity,
|
||||
"angular_velocity":ejection.angular_velocity}));
|
||||
Ok(())
|
||||
}
|
||||
pub(super) fn retire(world: &mut World, owner: &str) {
|
||||
world.resource_scope(|world, mut v: Mut<Vehicles>| {
|
||||
if v.driver.as_ref().is_some_and(|d| d.owner == owner) {
|
||||
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);
|
||||
v.previous_motion.remove(&i.id);
|
||||
v.rendered_motion.remove(&i.id);
|
||||
v.remote.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<Vehicles>| {
|
||||
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::<Visibility>(id) {
|
||||
*current = visibility;
|
||||
}
|
||||
}
|
||||
v.driver = None;
|
||||
v.pose = None;
|
||||
v.last_visual.clear();v.blend_from.clear();v.visual_phase.clear();v.steering_visual=0.;
|
||||
v.crash_handoff=false;
|
||||
v.previous_motion.clear();
|
||||
v.rendered_motion.clear();
|
||||
v.remote.clear();
|
||||
v.skaters.clear();
|
||||
v.network_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::<crate::physics::GamePhysics>()
|
||||
.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<Vehicles>| {
|
||||
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.keys().filter(|(o,_)|o.starts_with('@')==owner.starts_with('@')).count() >= if owner.starts_with('@') {288} else {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::<crate::physics::SkaterRuntime>()
|
||||
.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::<AssetServer>()
|
||||
.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,
|
||||
definition_path: definition,
|
||||
},
|
||||
);
|
||||
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);
|
||||
v.previous_motion.remove(&i.id);
|
||||
v.rendered_motion.remove(&i.id);
|
||||
v.remote.remove(&i.id);
|
||||
world.despawn(i.entity);
|
||||
event(&mut v, owner, &key, "vehicle_removed");
|
||||
}
|
||||
}
|
||||
Command::VehicleEnter { .. } => {
|
||||
if v.driver.is_some()
|
||||
|| world.resource::<crate::replay::Replay>().active
|
||||
|| world
|
||||
.resource::<crate::map_transition::MapTransition>()
|
||||
.busy()
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
let i = v.owned.get(&owned_key).ok_or("Unknown vehicle")?;
|
||||
if !world
|
||||
.resource::<AssetServer>()
|
||||
.is_loaded_with_dependencies(i.scene.id())
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
if !world.resource::<crate::animation::AnimationStatus>().ready {
|
||||
return Ok(());
|
||||
}
|
||||
let skater = world.resource::<crate::physics::SkaterRuntime>();
|
||||
if skater.player_input.pending_teleport().is_some()
|
||||
|| world
|
||||
.resource::<crate::physics::GamePhysics>()
|
||||
.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 d.phase != "driving" { 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)?;
|
||||
v.previous_motion.remove(&id);
|
||||
v.rendered_motion.remove(&id);
|
||||
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::<crate::replay::Replay>().active {
|
||||
return;
|
||||
}
|
||||
let dt = world.resource::<Time<Fixed>>().delta_secs();
|
||||
world.resource_scope(|world, mut v: Mut<Vehicles>| {
|
||||
v.clock += dt;
|
||||
network::skater_proxies(world,&mut v);
|
||||
network::advance(&mut v, 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()
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
let occupied_id=v.driver.as_ref().and_then(|d|v.owned.get(&(d.owner.clone(),d.key.clone()))).map(|i|i.id);
|
||||
let ids:Vec<_>=v.simulation.vehicles.keys().copied().collect();
|
||||
for id in ids { if !v.remote.contains_key(&id) { v.simulation.set_occupied(id,Some(id)==occupied_id); }}
|
||||
if !v.owned.is_empty() {
|
||||
v.previous_motion = v.simulation.vehicles.keys().filter_map(|&id|
|
||||
network::motion(&v, id).map(|m| (id, m))).collect();
|
||||
v.simulation.step(dt);
|
||||
}
|
||||
if let Some(id)=occupied_id {
|
||||
if let Some(ejection)=v.simulation.take_ejection(id) {
|
||||
if let Err(error)=eject_now(world,&mut v,ejection) {warn!("Vehicle ejection: {error}");}
|
||||
return;
|
||||
}
|
||||
}
|
||||
if let Some((owner, key, phase)) = driver_info {
|
||||
if let Some(i) = v.owned.get(&(owner.clone(), key.clone())) {
|
||||
let def = &v.simulation.vehicles[&i.id].definition;
|
||||
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) {
|
||||
let dt=world.resource::<Time<Virtual>>().delta_secs();
|
||||
let alpha=world.resource::<Time<Fixed>>().overstep_fraction();
|
||||
let phase=world.resource::<Vehicles>().driver.as_ref().map_or("vanilla",|d|d.phase).to_owned();
|
||||
let current=crate::animation::capture_vehicle_visual(world);
|
||||
{
|
||||
let mut v=world.resource_mut::<Vehicles>();
|
||||
if phase!=v.visual_phase {
|
||||
v.blend_from=if v.last_visual.is_empty() {current} else {v.last_visual.clone()};
|
||||
v.visual_phase=phase;v.blend_time=0.;
|
||||
}
|
||||
v.blend_time+=dt;
|
||||
}
|
||||
world.resource_scope(|world, mut v: Mut<Vehicles>| {
|
||||
let failures: Vec<_> = v
|
||||
.owned
|
||||
.iter()
|
||||
.filter_map(|((owner, key), i)| {
|
||||
if let Some(bevy::asset::LoadState::Failed(error)) =
|
||||
world.resource::<AssetServer>().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::<super::Mods>()
|
||||
.manager
|
||||
.fail(&owner, error);
|
||||
}
|
||||
// Chassis, wheels, rider and camera share one fixed-step render sample.
|
||||
v.rendered_motion = v.simulation.vehicles.keys().filter_map(|&id| {
|
||||
let current = network::motion(&v, id)?;
|
||||
let sample = v.previous_motion.get(&id).map_or_else(|| current.clone(),
|
||||
|previous| previous.sample(¤t, alpha));
|
||||
Some((id, sample))
|
||||
}).collect();
|
||||
for i in v.owned.values() {
|
||||
if let Some(sample) = v.rendered_motion.get(&i.id) {
|
||||
if let Some(mut t) = world.get_mut::<Transform>(i.entity) {
|
||||
*t = sample.body;
|
||||
}
|
||||
}
|
||||
}
|
||||
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<WheelVisual>>()
|
||||
.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::<ChildOf>(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(sample) = v.rendered_motion.get(&wheel.vehicle) {
|
||||
if let Some(offset) = sample.wheels.get(wheel.index) {
|
||||
*transform = wheel.rest;
|
||||
transform.translation += offset.translation;
|
||||
transform.rotation = wheel.rest.rotation * offset.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.controller.current_vehicle_speed.abs() > 0.5 {
|
||||
a.drive.as_ref()
|
||||
} else {
|
||||
a.idle.as_ref()
|
||||
}
|
||||
}
|
||||
}
|
||||
.or(a.drive.as_ref());
|
||||
let target_steering=car.controls.steering;
|
||||
let steering=v.steering_visual+(target_steering-v.steering_visual)*(1.-(-12.*dt).exp());
|
||||
let pose = i.clips.pose(name, d.time, d.phase == "driving");
|
||||
let turn=if d.phase=="driving" {i.clips.pose(if steering>=0. {a.steer_left.as_ref()} else {a.steer_right.as_ref()},d.time,true)} else {None};
|
||||
let body = v.rendered_motion[&i.id].body;
|
||||
let q = body.rotation;
|
||||
let seat = body.translation + q * Vec3::from_array(car.definition.seat);
|
||||
v.steering_visual=steering;
|
||||
let roots: Vec<_> = world
|
||||
.query_filtered::<Entity, With<crate::world::PlayerRoot>>()
|
||||
.iter(world)
|
||||
.collect();
|
||||
for entity in roots {
|
||||
if !v.hidden.iter().any(|(id, _)| *id == entity) {
|
||||
if let Some(vis) = world.get::<Visibility>(entity) {
|
||||
v.hidden.push((entity, *vis));
|
||||
}
|
||||
}
|
||||
if let Some(mut vis) = world.get_mut::<Visibility>(entity) {
|
||||
*vis = if pose.is_some() {
|
||||
Visibility::Inherited
|
||||
} else {
|
||||
Visibility::Hidden
|
||||
};
|
||||
}
|
||||
if let Some(mut transform) = world.get_mut::<Transform>(entity) {
|
||||
*transform = Transform::from_translation(seat).with_rotation(q);
|
||||
}
|
||||
}
|
||||
if let Some(pose) = &pose {
|
||||
crate::animation::vehicle_pose(world, pose, turn.as_deref().map(|p|(p,steering.abs())));
|
||||
}
|
||||
v.pose = pose;
|
||||
});
|
||||
world.resource_scope(|world,mut v:Mut<Vehicles>| {
|
||||
let duration=if v.visual_phase=="vanilla" {if v.crash_handoff {0.12} else {0.5}} else {0.4};
|
||||
if v.blend_time<duration {
|
||||
let t=(v.blend_time/duration).clamp(0.,1.);
|
||||
crate::animation::blend_vehicle_visual(world,&v.blend_from,t*t*(3.-2.*t));
|
||||
} else {v.blend_from.clear();v.crash_handoff=false;}
|
||||
v.last_visual=crate::animation::capture_vehicle_visual(world);
|
||||
v.network_pose=if v.driver.is_some() || !v.blend_from.is_empty() {Some(crate::animation::network_visual(world))} else {None};
|
||||
});
|
||||
}
|
||||
|
||||
pub(super) fn input(world: &World) -> Value {
|
||||
let keys = world.resource::<ButtonInput<KeyCode>>();
|
||||
let pad = world
|
||||
.resource::<crate::input::ControllerInput>()
|
||||
.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})
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
//! Owner-simulated vehicles, matching the native player's multiplayer ownership.
|
||||
use super::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use skate_vehicles::rapier3d::prelude::{RigidBodyType, Rotation, Vector};
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(crate) struct Car {
|
||||
pub definition: String,
|
||||
pub position: [f32; 3],
|
||||
pub rotation: [f32; 4],
|
||||
pub velocity: [f32; 3],
|
||||
pub angular: [f32; 3],
|
||||
pub controls: Controls,
|
||||
pub occupied: bool,
|
||||
pub rider: Option<([f32; 3], [f32; 4])>,
|
||||
pub wheels: Vec<[f32; 3]>,
|
||||
}
|
||||
pub(super) struct Target {
|
||||
state: Car,
|
||||
age: f32,
|
||||
}
|
||||
pub(super) fn simulation_active(
|
||||
menu: Option<Res<crate::graphics_menu::Menu>>,
|
||||
net: Option<Res<crate::multiplayer::Multiplayer>>,
|
||||
) -> bool {
|
||||
menu.is_none_or(|m| !m.open) || net.is_some_and(|n| n.active())
|
||||
}
|
||||
pub(crate) fn capture(world: &World) -> Vec<(String, String, Car)> {
|
||||
let v = world.resource::<Vehicles>();
|
||||
v.owned
|
||||
.iter()
|
||||
.filter(|(_, i)| !v.remote.contains_key(&i.id))
|
||||
.filter_map(|((owner, key), i)| {
|
||||
let car = v.simulation.vehicles.get(&i.id)?;
|
||||
let body = &v.simulation.world.bodies[car.body];
|
||||
Some((
|
||||
owner.clone(),
|
||||
key.clone(),
|
||||
Car {
|
||||
definition: i.definition_path.clone(),
|
||||
position: body.translation().to_array(),
|
||||
rotation: body.rotation().to_array(),
|
||||
velocity: body.linvel().to_array(),
|
||||
angular: body.angvel().to_array(),
|
||||
controls: car.controls,
|
||||
rider: v
|
||||
.network_pose
|
||||
.as_ref()
|
||||
.filter(|_| {
|
||||
v.driver
|
||||
.as_ref()
|
||||
.is_some_and(|d| d.owner == *owner && d.key == *key)
|
||||
})
|
||||
.map(|pose| {
|
||||
let body = Mat4::from_rotation_translation(
|
||||
Quat::from_array(body.rotation().to_array()),
|
||||
Vec3::from_array(body.translation().to_array()),
|
||||
);
|
||||
let t = Transform::from_matrix(
|
||||
body.inverse() * crate::physics::network::matrix(pose.root),
|
||||
);
|
||||
(t.translation.to_array(), t.rotation.to_array())
|
||||
}),
|
||||
occupied: v
|
||||
.driver
|
||||
.as_ref()
|
||||
.is_some_and(|d| d.owner == *owner && d.key == *key),
|
||||
wheels: car
|
||||
.controller
|
||||
.wheels()
|
||||
.iter()
|
||||
.map(|w| [w.steering, w.rotation, w.raycast_info().suspension_length])
|
||||
.collect(),
|
||||
},
|
||||
))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
pub(crate) fn receive(
|
||||
world: &mut World,
|
||||
root: &std::path::Path,
|
||||
owner: &str,
|
||||
key: &str,
|
||||
state: Car,
|
||||
) -> Result<(), String> {
|
||||
if let Some((p, q)) = state.rider {
|
||||
let q = Quat::from_array(q);
|
||||
if p.iter().any(|x| !x.is_finite() || x.abs() > 20.)
|
||||
|| !q.is_finite()
|
||||
|| (q.length() - 1.).abs() > 0.02
|
||||
{
|
||||
return Err("Invalid rider pose".into());
|
||||
}
|
||||
}
|
||||
let spawn = Command::VehicleSpawn {
|
||||
key: key.into(),
|
||||
definition: state.definition.clone(),
|
||||
position: state.position,
|
||||
heading: 0.,
|
||||
};
|
||||
let q = Quat::from_array(state.rotation);
|
||||
if !spawn.validate()
|
||||
|| !state.controls.valid()
|
||||
|| !q.is_finite()
|
||||
|| (q.length() - 1.).abs() > 0.02
|
||||
|| state
|
||||
.velocity
|
||||
.iter()
|
||||
.chain(&state.angular)
|
||||
.any(|x| !x.is_finite() || x.abs() > 200.)
|
||||
|| state.wheels.len() > 16
|
||||
|| state
|
||||
.wheels
|
||||
.iter()
|
||||
.flatten()
|
||||
.any(|x| !x.is_finite() || x.abs() > 100000.)
|
||||
{
|
||||
return Err("Invalid remote vehicle".into());
|
||||
}
|
||||
let owned = (owner.into(), key.into());
|
||||
let replace = world
|
||||
.resource::<Vehicles>()
|
||||
.owned
|
||||
.get(&owned)
|
||||
.is_some_and(|i| i.definition_path != state.definition);
|
||||
if replace {
|
||||
remove(world, owner, key);
|
||||
}
|
||||
if !world.resource::<Vehicles>().owned.contains_key(&owned) {
|
||||
super::command(world, root, owner, spawn)?;
|
||||
}
|
||||
let mut v = world.resource_mut::<Vehicles>();
|
||||
let id = v.owned[&owned].id;
|
||||
let car = v.simulation.vehicles.get_mut(&id).unwrap();
|
||||
car.remote = true;
|
||||
car.controls = state.controls;
|
||||
let handle = car.body;
|
||||
let body = &mut v.simulation.world.bodies[handle];
|
||||
body.set_body_type(RigidBodyType::KinematicVelocityBased, true);
|
||||
if !v.remote.contains_key(&id) {
|
||||
let body = &mut v.simulation.world.bodies[handle];
|
||||
body.set_translation(Vector::from_array(state.position), true);
|
||||
body.set_rotation(
|
||||
Rotation::from_xyzw(
|
||||
state.rotation[0],
|
||||
state.rotation[1],
|
||||
state.rotation[2],
|
||||
state.rotation[3],
|
||||
),
|
||||
true,
|
||||
);
|
||||
}
|
||||
v.simulation.set_occupied(id, state.occupied);
|
||||
v.remote.insert(id, Target { state, age: 0. });
|
||||
Ok(())
|
||||
}
|
||||
pub(crate) fn remove(world: &mut World, owner: &str, key: &str) {
|
||||
// Removal has no asset access or effects on the locally occupied vehicle.
|
||||
let _ = super::command(
|
||||
world,
|
||||
std::path::Path::new(""),
|
||||
owner,
|
||||
Command::VehicleRemove { key: key.into() },
|
||||
);
|
||||
}
|
||||
pub(super) fn advance(v: &mut Vehicles, dt: f32) {
|
||||
for (&id, target) in &mut v.remote {
|
||||
target.age += dt;
|
||||
let Some(car) = v.simulation.vehicles.get_mut(&id) else {
|
||||
continue;
|
||||
};
|
||||
let body = &mut v.simulation.world.bodies[car.body];
|
||||
let source = Vec3::from_array(target.state.position)
|
||||
+ Vec3::from_array(target.state.velocity) * target.age.min(0.15);
|
||||
let mut error = source - Vec3::from_array(body.translation().to_array());
|
||||
if error.length() > 5. {
|
||||
body.set_translation(Vector::from_array(source.to_array()), true);
|
||||
error = Vec3::ZERO;
|
||||
}
|
||||
let velocity = if target.age < 0.15 {
|
||||
Vec3::from_array(target.state.velocity)
|
||||
} else {
|
||||
Vec3::ZERO
|
||||
};
|
||||
body.set_linvel(
|
||||
Vector::from_array(
|
||||
(velocity + (error * 10.).clamp_length_max(5.))
|
||||
.clamp_length_max(100.)
|
||||
.to_array(),
|
||||
),
|
||||
true,
|
||||
);
|
||||
let current = Quat::from_array(body.rotation().to_array());
|
||||
let desired = Quat::from_array(target.state.rotation);
|
||||
let delta = (desired * current.inverse()).normalize();
|
||||
let delta = if delta.w < 0. { -delta } else { delta };
|
||||
let angular = if target.age < 0.15 {
|
||||
Vec3::from_array(target.state.angular)
|
||||
} else {
|
||||
Vec3::ZERO
|
||||
};
|
||||
body.set_angvel(
|
||||
Vector::from_array(
|
||||
(angular + delta.to_scaled_axis() * 10.)
|
||||
.clamp_length_max(20.)
|
||||
.to_array(),
|
||||
),
|
||||
true,
|
||||
);
|
||||
car.controls = target.state.controls;
|
||||
car.controller.current_vehicle_speed = velocity.dot(current * Vec3::Z);
|
||||
for (wheel, values) in car
|
||||
.controller
|
||||
.wheels_mut()
|
||||
.iter_mut()
|
||||
.zip(&target.state.wheels)
|
||||
{
|
||||
wheel.steering = values[0];
|
||||
wheel.rotation = values[1];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn attached_root(v: &Vehicles, peer: u64) -> Option<Transform> {
|
||||
let prefix = format!("@{peer}:");
|
||||
v.owned
|
||||
.iter()
|
||||
.filter(|((owner, _), _)| owner.starts_with(&prefix))
|
||||
.find_map(|(_, i)| {
|
||||
let target = v.remote.get(&i.id)?;
|
||||
if !target.state.occupied {
|
||||
return None;
|
||||
}
|
||||
let (p, q) = target.state.rider?;
|
||||
let body = v.rendered_motion.get(&i.id)?.body;
|
||||
Some(Transform::from_matrix(
|
||||
body.to_matrix()
|
||||
* Mat4::from_rotation_translation(Quat::from_array(q), Vec3::from_array(p)),
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) struct CollisionShape {
|
||||
pub position: [f32; 3],
|
||||
pub rotation: [f32; 4],
|
||||
pub velocity: [f32; 3],
|
||||
pub angular: [f32; 3],
|
||||
pub half: [f32; 3],
|
||||
pub offset: [f32; 3],
|
||||
pub rounding: f32,
|
||||
pub mass: f32,
|
||||
}
|
||||
pub(crate) fn collision_shapes(v: &Vehicles) -> Vec<CollisionShape> {
|
||||
v.simulation
|
||||
.vehicles
|
||||
.values()
|
||||
.map(|car| {
|
||||
let b = &v.simulation.world.bodies[car.body];
|
||||
let d = &car.definition;
|
||||
CollisionShape {
|
||||
position: b.translation().to_array(),
|
||||
rotation: b.rotation().to_array(),
|
||||
velocity: b.linvel().to_array(),
|
||||
angular: b.angvel().to_array(),
|
||||
half: d.half_extents,
|
||||
offset: d.collider_offset,
|
||||
rounding: d.collider_rounding,
|
||||
mass: d.mass,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
pub(super) fn skater_proxies(world: &World, v: &mut Vehicles) {
|
||||
use skate_vehicles::rapier3d::prelude::{ColliderBuilder, RigidBodyBuilder};
|
||||
let mut frames = world
|
||||
.resource::<crate::multiplayer::Multiplayer>()
|
||||
.collision_players();
|
||||
if !v.occupied() {
|
||||
frames.push((
|
||||
0,
|
||||
crate::physics::network::capture_body(
|
||||
world.resource::<crate::physics::GamePhysics>(),
|
||||
world.resource::<crate::physics::SkaterRuntime>(),
|
||||
),
|
||||
));
|
||||
}
|
||||
let mut live = std::collections::BTreeSet::new();
|
||||
for (peer, frame) in frames {
|
||||
for (index, body) in frame.bodies.iter().enumerate() {
|
||||
if frame.enabled & (1u64 << index) == 0 {
|
||||
continue;
|
||||
}
|
||||
// Only near a car: bounded spheres approximate the native articulated collision parts.
|
||||
if !v.simulation.vehicles.values().any(|c| {
|
||||
Vec3::from_array(v.simulation.world.bodies[c.body].translation().to_array())
|
||||
.distance_squared(Vec3::from_array(body.pose.p))
|
||||
< 100.
|
||||
}) {
|
||||
continue;
|
||||
}
|
||||
let key = (peer, index);
|
||||
live.insert(key);
|
||||
let handle = *v.skaters.entry(key).or_insert_with(|| {
|
||||
v.simulation
|
||||
.world
|
||||
.insert(
|
||||
RigidBodyBuilder::kinematic_velocity_based(),
|
||||
ColliderBuilder::ball(if index < 7 { 0.08 } else { 0.14 }).friction(0.3),
|
||||
)
|
||||
.0
|
||||
});
|
||||
let proxy = &mut v.simulation.world.bodies[handle];
|
||||
proxy.set_translation(Vector::from_array(body.pose.p), true);
|
||||
proxy.set_linvel(Vector::from_array(body.velocity), true);
|
||||
}
|
||||
}
|
||||
let removed: Vec<_> = v
|
||||
.skaters
|
||||
.keys()
|
||||
.filter(|key| !live.contains(key))
|
||||
.copied()
|
||||
.collect();
|
||||
for key in removed {
|
||||
if let Some(body) = v.skaters.remove(&key) {
|
||||
v.simulation.world.remove_body(body);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn occupied(v: &Vehicles, id: u64) -> bool {
|
||||
v.remote.get(&id).map_or_else(
|
||||
|| {
|
||||
v.driver
|
||||
.as_ref()
|
||||
.and_then(|d| v.owned.get(&(d.owner.clone(), d.key.clone())))
|
||||
.is_some_and(|i| i.id == id)
|
||||
},
|
||||
|t| t.state.occupied,
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn motion(v: &Vehicles, id: u64) -> Option<interpolation::Motion> {
|
||||
let mut m = interpolation::Motion::capture(&v.simulation, id)?;
|
||||
if let Some(target) = v.remote.get(&id) {
|
||||
let d = &v.simulation.vehicles[&id].definition;
|
||||
for (wheel, values) in m.wheels.iter_mut().zip(&target.state.wheels) {
|
||||
wheel.translation.y = (d.suspension_length - values[2]) / d.model_scale;
|
||||
}
|
||||
}
|
||||
Some(m)
|
||||
}
|
||||
@@ -93,6 +93,20 @@ impl Multiplayer {
|
||||
let rtt = self.lobby.as_ref().map(|lobby| lobby.stats.rtt_ms);
|
||||
format!("provider:{provider} active:{} remote_count:{} rtt_ms:{rtt:?}", self.active(), self.remotes.len())
|
||||
}
|
||||
pub(crate) fn mod_identity(&self) -> (bool, u64, bool) {
|
||||
self.lobby.as_ref().map_or((false, 0, true), |l| (true, l.local, l.is_host()))
|
||||
}
|
||||
pub(crate) fn collision_players(&self) -> Vec<(u64,skate_net::packed::BodyState)> {
|
||||
self.remotes.iter().filter(|(_,r)|r.body_at.elapsed()<Duration::from_millis(500)).map(|(&id,r)|(id,r.body.clone())).collect()
|
||||
}
|
||||
pub(crate) fn mod_records(&self) -> Vec<(u64, String, u32, Vec<u8>)> {
|
||||
self.lobby.as_ref().map_or_else(Vec::new, |l| l.actors.iter().filter(|(id, _)| **id != l.local)
|
||||
.flat_map(|(&id, actor)| actor.application.iter().map(move |(key,r)| (id,key.clone(),r.seq,r.value.clone()))).collect())
|
||||
}
|
||||
pub(crate) fn publish_mod(&mut self, key: &str, value: Vec<u8>) -> bool {
|
||||
let now = self.started.elapsed().as_millis() as u64;
|
||||
self.lobby.as_mut().is_some_and(|l| l.publish_application(key,value,now))
|
||||
}
|
||||
pub fn active(&self) -> bool {
|
||||
self.lobby.is_some()
|
||||
}
|
||||
@@ -321,7 +335,7 @@ impl Plugin for MultiplayerPlugin {
|
||||
.add_systems(PreUpdate, (world_changed, receive).chain().after(crate::map_transition::MapTransitionSet))
|
||||
.add_systems(Startup, setup_hud)
|
||||
.add_systems(Update, hud)
|
||||
.add_systems(Update, send_pose.after(FrameSet::Animation))
|
||||
.add_systems(Update, send_pose.after(crate::modding::vehicles::present))
|
||||
.add_systems(
|
||||
FixedUpdate,
|
||||
prepare
|
||||
@@ -575,7 +589,7 @@ fn receive(mut net: ResMut<Multiplayer>) {
|
||||
net.last_metrics = Instant::now();
|
||||
}
|
||||
}
|
||||
fn prepare(net: Res<Multiplayer>, mut physics: ResMut<GamePhysics>, skater: Res<SkaterRuntime>) {
|
||||
fn prepare(net: Res<Multiplayer>, mut physics: ResMut<GamePhysics>, skater: Res<SkaterRuntime>, vehicles: Res<crate::modding::vehicles::Vehicles>) {
|
||||
physics.network_active = net.active();
|
||||
physics.network_contacts = 0;
|
||||
let mut proxies = std::mem::take(&mut physics.network_proxies);
|
||||
@@ -592,6 +606,9 @@ fn prepare(net: Res<Multiplayer>, mut physics: ResMut<GamePhysics>, skater: Res<
|
||||
);
|
||||
}
|
||||
}
|
||||
for shape in crate::modding::vehicles::network::collision_shapes(&vehicles) {
|
||||
proxies.append_vehicle(&shape,&physics,&skater);
|
||||
}
|
||||
physics.network_proxies = proxies;
|
||||
}
|
||||
#[derive(Component)]
|
||||
@@ -620,39 +637,45 @@ fn hud(
|
||||
net: Res<Multiplayer>,
|
||||
physics: Res<GamePhysics>,
|
||||
mut label: Single<&mut Text, With<NetworkHud>>,
|
||||
mods: Res<crate::modding::network::ModSync>,
|
||||
) {
|
||||
***label = if net.active() {
|
||||
format!(
|
||||
"{}\n{}\n{}\n{}\nPlayer contacts: {} | Esc > Multiplayer",
|
||||
"{}\n{}\n{}\n{}\n{}\nPlayer contacts: {} | Esc > Multiplayer",
|
||||
net.status,
|
||||
net.rates,
|
||||
net.provider_metrics,
|
||||
net.visual_status,
|
||||
mods.status,
|
||||
physics.network_contacts
|
||||
)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
}
|
||||
fn send(mut net: ResMut<Multiplayer>, physics: Res<GamePhysics>, skater: Res<SkaterRuntime>) {
|
||||
fn send(mut net: ResMut<Multiplayer>, physics: Res<GamePhysics>, skater: Res<SkaterRuntime>, vehicles: Res<crate::modding::vehicles::Vehicles>) {
|
||||
if !net.active() || skater.pose_generation == 0 {
|
||||
return;
|
||||
}
|
||||
let now = net.started.elapsed().as_millis() as u64;
|
||||
if net.last_body.elapsed() >= Duration::from_millis(49) {
|
||||
if let Some(p) = Packed::body(&network::capture_body(&physics, &skater)) {
|
||||
let mut state=network::capture_body(&physics,&skater);
|
||||
if let Some(pose)=&vehicles.network_pose {state.root=pose.root;}
|
||||
if vehicles.occupied() {state.enabled=1u64<<62;}
|
||||
if let Some(p) = Packed::body(&state) {
|
||||
net.lobby.as_mut().unwrap().publish(packed::BODY, p, now);
|
||||
}
|
||||
net.last_body = Instant::now();
|
||||
}
|
||||
}
|
||||
fn send_pose(mut net: ResMut<Multiplayer>, skater: Res<SkaterRuntime>) {
|
||||
fn send_pose(mut net: ResMut<Multiplayer>, skater: Res<SkaterRuntime>, vehicles: Res<crate::modding::vehicles::Vehicles>) {
|
||||
if !net.active() || skater.pose_generation == 0 {
|
||||
return;
|
||||
}
|
||||
let now = net.started.elapsed().as_millis() as u64;
|
||||
if net.last_pose.elapsed() >= Duration::from_millis(if net.loopback { 49 } else { 99 }) {
|
||||
if let Some(p) = Packed::pose(&network::capture_pose(&skater, &net.anchors)) {
|
||||
let pose=vehicles.network_pose.clone().unwrap_or_else(||network::capture_pose(&skater,&net.anchors));
|
||||
if let Some(p) = Packed::pose(&pose) {
|
||||
net.lobby.as_mut().unwrap().publish(packed::POSE, p, now);
|
||||
}
|
||||
net.last_pose = Instant::now();
|
||||
|
||||
@@ -7,6 +7,7 @@ struct RemoteSkins {
|
||||
actors: BTreeMap<u64, RemoteSkin>,
|
||||
rest: Vec<Mat4>,
|
||||
parents: Vec<i32>,
|
||||
board: Option<usize>,
|
||||
}
|
||||
#[derive(Default)]
|
||||
struct RemoteSkin {
|
||||
@@ -58,7 +59,9 @@ impl Plugin for RemoteRenderPlugin {
|
||||
.iter()
|
||||
.map(|&p| p as i32)
|
||||
.collect();
|
||||
let board=skater.animation.evaluator.frames.bone_names.iter().position(|n|n=="SKATEBOARD_ROOT");
|
||||
app.insert_resource(RemoteSkins {
|
||||
board,
|
||||
reported: 0.,
|
||||
actors: BTreeMap::new(),
|
||||
rest,
|
||||
@@ -66,7 +69,7 @@ impl Plugin for RemoteRenderPlugin {
|
||||
})
|
||||
.add_systems(
|
||||
Update,
|
||||
(spawn, bind, present).chain().after(FrameSet::Animation),
|
||||
(spawn, bind, present).chain().after(crate::modding::vehicles::present),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -188,6 +191,7 @@ fn globals(bones: &[skate_net::Bone], skin: &RemoteSkins) -> Vec<Mat4> {
|
||||
}
|
||||
fn present(
|
||||
mut net: ResMut<Multiplayer>,
|
||||
vehicles: Res<crate::modding::vehicles::Vehicles>,
|
||||
mut skins: ResMut<RemoteSkins>,
|
||||
mut nodes: Query<&mut Transform>,
|
||||
) {
|
||||
@@ -272,6 +276,11 @@ fn present(
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(root)=skin.root {
|
||||
if let Some(attached)=crate::modding::vehicles::network::attached_root(&vehicles,id) {if let Ok(mut t)=nodes.get_mut(root) {*t=attached;}}
|
||||
}
|
||||
let seated=remote.body.enabled & (1u64<<62)!=0;
|
||||
for &(entity,i,_) in &skin.bindings {if skins.board==Some(i) {if let Ok(mut t)=nodes.get_mut(entity) {t.scale=Vec3::splat(if seated {0.001} else {1.});}}}
|
||||
skins.actors.insert(id, skin);
|
||||
}
|
||||
if now < skins.reported || now - skins.reported >= 1. {
|
||||
|
||||
@@ -115,6 +115,7 @@ pub(crate) struct GamePhysics {
|
||||
pub processed_flags_2468: u32,
|
||||
/// Toolkit ctor82C0680C clears8384bit7; wipeout entry/exit owns changes.
|
||||
pub board_wiping_out: bool,
|
||||
pub trainer: skate_mods::TrainerTuning,
|
||||
}
|
||||
|
||||
/// Cross-phase records for the current fixed tick. Subsystems retain their
|
||||
@@ -230,7 +231,6 @@ impl GamePhysics {
|
||||
|
||||
pub(crate) fn difficulty_index(&self) -> u32 { self.animation_profile.physics_mode }
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn world_triangles(&self) -> &[skate_core::physics::board_world::WorldTriangle] { self.world.triangles() }
|
||||
|
||||
pub(crate) fn world(&self) -> &BoardWorld {
|
||||
@@ -344,6 +344,7 @@ impl GamePhysics {
|
||||
exchange: SimulationExchange::new(0),
|
||||
processed_flags_2468,
|
||||
board_wiping_out: false,
|
||||
trainer: Default::default(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -408,6 +409,7 @@ impl Plugin for PhysicsPlugin {
|
||||
}
|
||||
|
||||
fn advance(
|
||||
vehicles: Res<crate::modding::vehicles::Vehicles>,
|
||||
mut physics: ResMut<GamePhysics>,
|
||||
mut skater: ResMut<SkaterRuntime>,
|
||||
mut controls: ResMut<PlayerControls>,
|
||||
@@ -418,7 +420,7 @@ fn advance(
|
||||
mut exit: MessageWriter<AppExit>,
|
||||
mut performance: Option<ResMut<crate::performance::Performance>>,
|
||||
) {
|
||||
if physics.failed {
|
||||
if physics.failed || vehicles.occupied() {
|
||||
return;
|
||||
}
|
||||
let timer = performance.as_ref().map(|_| std::time::Instant::now());
|
||||
@@ -596,3 +598,16 @@ mod offboard_jump_playback_tests;
|
||||
#[cfg(test)]
|
||||
#[path = "tests/offboard_root_trace.rs"]
|
||||
mod offboard_root_trace;
|
||||
|
||||
impl SkaterRuntime {
|
||||
/// Observe the current grind publication without adding a second grind owner.
|
||||
pub(crate) fn mod_grind(&self) -> (bool, &str, u32, f32) {
|
||||
let out = &self.player_input.physical.grinds;
|
||||
let active = self.grind.active_family().is_some();
|
||||
let name = if active {
|
||||
grind_chromosome::names::lookup(out.animation_chromosome_268)
|
||||
.map(|name| name.attribute).unwrap_or("")
|
||||
} else { "" };
|
||||
(active, name, out.words_136_140[0], self.grind.last_grind_distance())
|
||||
}
|
||||
}
|
||||
@@ -251,6 +251,7 @@ pub(crate) fn advance(
|
||||
surface_up_y: physics.riding.ground.wheel_normal.y,
|
||||
},
|
||||
);
|
||||
skater.animation.motion.hold_fakie = physics.trainer.hold_fakie;
|
||||
let observations = AnimationPhysical {
|
||||
conditions,
|
||||
feedback,
|
||||
|
||||
@@ -80,6 +80,10 @@ pub(super) fn advance(
|
||||
input_available,
|
||||
)?;
|
||||
skater.ground_settings = skater.ground_profiles.select(skater.player_input.processed.state_variant_index_2528, skater.player_input.processed.surface_mode_2540)?;
|
||||
if physics.trainer != skate_mods::TrainerTuning::default() {
|
||||
skater.ground_settings = std::sync::Arc::new(skater.ground_settings.tuned(physics.trainer));
|
||||
}
|
||||
let mut vehicle_ejected = false;
|
||||
if teleported {
|
||||
skater.respawn.reset_measurements();
|
||||
#[cfg(test)]
|
||||
@@ -98,6 +102,7 @@ pub(super) fn advance(
|
||||
&mut skater.skeleton_collision,
|
||||
&mut skater.collision_feedback,
|
||||
);
|
||||
vehicle_ejected = player_state::apply_vehicle_ejection(physics, skater)?;
|
||||
}
|
||||
//World8275EC0C ends board queries before PostInput/state selection.
|
||||
physics.riding.finish_wheel_queries()?;
|
||||
@@ -110,7 +115,7 @@ pub(super) fn advance(
|
||||
skater.offboard_grab.execute_queries(&scene)?;
|
||||
}
|
||||
let state_before_selection = skater.player_state.current();
|
||||
player_state::post_input_and_select(physics, skater)?;
|
||||
if !vehicle_ejected { player_state::post_input_and_select(physics, skater)?; }
|
||||
let state_after_selection = skater.player_state.current();
|
||||
super::offboard_audit_trace::stage(tick, "selected", physics, skater, controls);
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -43,6 +43,7 @@ pub(super) fn execute(
|
||||
p.state_variant_index_2528,
|
||||
skater.animation_input.extra.jump_strength,
|
||||
)?;
|
||||
let vertical_jump = vertical_jump * physics.trainer.grind_pop;
|
||||
let geometry_side_jump = settings::side(family, p.grind.geometry_kind_1464);
|
||||
let input = LaunchInput {
|
||||
velocity_400: p.vectors_400_416[0].map(f32::from_bits),
|
||||
|
||||
@@ -25,7 +25,7 @@ pub(crate) fn execute(physics: &mut GamePhysics, skater: &mut SkaterRuntime) ->
|
||||
Family::FiftyFifty,
|
||||
p.state_variant_index_2528,
|
||||
skater.animation_input.extra.jump_strength,
|
||||
)?,
|
||||
)? * physics.trainer.grind_pop,
|
||||
};
|
||||
let velocity = super::super::launch::apply(
|
||||
&mut physics.board,
|
||||
|
||||
@@ -33,7 +33,7 @@ pub(super) fn advance(physics: &mut GamePhysics, skater: &mut SkaterRuntime) ->
|
||||
p.flags_2472,
|
||||
);
|
||||
physics.settings.wheel_material = s.wheel_material;
|
||||
let mode = *skater
|
||||
let mut mode = *skater
|
||||
.ground_animation_settings
|
||||
.modes
|
||||
.get(p.state_variant_index_2528 as usize)
|
||||
@@ -43,6 +43,9 @@ pub(super) fn advance(physics: &mut GamePhysics, skater: &mut SkaterRuntime) ->
|
||||
p.state_variant_index_2528
|
||||
)
|
||||
})?;
|
||||
mode.minimum_height_64 *= physics.trainer.pop;
|
||||
mode.minimum_height_68 *= physics.trainer.pop;
|
||||
mode.maximum_height *= physics.trainer.pop;
|
||||
skater.ground_animation.jump = ground_jump::calculate(
|
||||
GroundJumpInput {
|
||||
flags_2468: p.flags_2468,
|
||||
|
||||
@@ -118,7 +118,7 @@ impl GroundSettings {
|
||||
propulsion: GroundPropulsionInput {
|
||||
flags_2468: p.flags_2468,
|
||||
flags_2472: p.flags_2472,
|
||||
target_speed: a.contacts.push_speed,
|
||||
target_speed: a.contacts.push_speed * self.push_target_multiplier,
|
||||
signed_speed: p.scalar_2612,
|
||||
absolute_body_speed: t.absolute_speed,
|
||||
scalar_2660: t.total_mass,
|
||||
|
||||
@@ -40,6 +40,7 @@ impl GroundProfiles {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct GroundSettings {
|
||||
steering: SteeringSettings,
|
||||
wobble: SpeedWobbleSettings,
|
||||
@@ -58,6 +59,7 @@ pub(crate) struct GroundSettings {
|
||||
contact_force_time: f32,
|
||||
pub wheel_material: RetailContactMaterial,
|
||||
pub foot_force_offset: f32,
|
||||
pub push_target_multiplier: f32,
|
||||
pub absorption_front: f32,
|
||||
pub absorption_rear: f32,
|
||||
pub surface_braking_factor: f32,
|
||||
@@ -75,6 +77,7 @@ impl GroundSettings {
|
||||
//broadcasts82165A10 (zero) into830BD380 for speed override direction.
|
||||
let threshold = [f32::from_bits(0x3586_37bd); 4];
|
||||
Ok(Self {
|
||||
push_target_multiplier: 1.,
|
||||
foot_force_offset: f("physics_feet", "FootForceOffset")?,
|
||||
absorption_front: f("physics_feet", "AbsorptionFootForceScalar")?,
|
||||
absorption_rear: f("physics_feet", "AbsorptionFootForceRearScalar")?,
|
||||
@@ -204,6 +207,22 @@ impl GroundSettings {
|
||||
wobble_amplitude: m("Hash_5B57F2CCCCEEF430")?,
|
||||
})
|
||||
}
|
||||
pub fn tuned(&self, tuning: skate_mods::TrainerTuning) -> Self {
|
||||
let mut result=self.clone();
|
||||
result.push_target_multiplier = tuning.push_speed;
|
||||
result.propulsion.maximum_pushable_speed *= tuning.push_speed;
|
||||
for dv in &mut result.propulsion.mode_speed_changes { *dv *= tuning.push_power; }
|
||||
result.propulsion.braking.input_force *= tuning.braking;
|
||||
result.propulsion.braking.override_force *= tuning.braking;
|
||||
result.steering.general_scalar *= tuning.steering;
|
||||
result.wobble_amplitude *= tuning.wobble;
|
||||
result.slide.friction *= tuning.grip;
|
||||
result.wheel_material.static_friction *= tuning.grip;
|
||||
result.wheel_material.dynamic_friction *= tuning.grip;
|
||||
result.heading.turn_strength *= tuning.turn_power;
|
||||
result.drag.balance_drag *= tuning.manual_drag;
|
||||
result
|
||||
}
|
||||
pub fn board(&self) -> GroundBoardSettings<'_> {
|
||||
GroundBoardSettings {
|
||||
steering: &self.steering,
|
||||
|
||||
@@ -336,3 +336,26 @@ impl Proxies {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Proxies {
|
||||
/// Vehicle chassis join the existing native contact solver; reactions remain local.
|
||||
pub fn append_vehicle(&mut self,shape:&crate::modding::vehicles::network::CollisionShape,physics:&GamePhysics,skater:&SkaterRuntime) {
|
||||
let p=Vec3::from_array(shape.position);
|
||||
if !physics.board.bodies().iter().chain(skater.skeleton.bodies()).any(|b|Vec3::from_array(xyz(b.rates.position)).distance_squared(p)<100.) {return;}
|
||||
let base=skater.skeleton.bodies().len()+skater.skeleton_drives.targets.bodies.len()+self.bodies.len();
|
||||
let mut body=physics.board.bodies()[0];
|
||||
let q=Quat::from_array(shape.rotation).normalize();
|
||||
body.rates.position=vector(shape.position);
|
||||
body.rates.orientation=RetailQuaternion{x:q.x,y:q.y,z:q.z,w:q.w};
|
||||
body.rates.basis=skate_core::math::Basis3{columns:Mat3::from_quat(q).to_cols_array_2d()};
|
||||
body.inertia.inverse_mass=1./shape.mass;
|
||||
let [x,y,z]=shape.half; body.inertia.inverse_tensor=vector([3./(shape.mass*(y*y+z*z)),3./(shape.mass*(x*x+z*z)),3./(shape.mass*(x*x+y*y))]);
|
||||
body.rates.world_inverse_inertia=world_inverse_inertia(body.rates.basis,body.inertia.inverse_tensor);
|
||||
body.rates.linear_velocity=vector(shape.velocity);body.rates.angular_velocity=vector(shape.angular);
|
||||
body.rates.force_acceleration=Vector3::ZERO;body.rates.torque_acceleration=Vector3::ZERO;body.state_flags=4;
|
||||
self.bodies.push(body);
|
||||
self.volumes.push(BoardWorldVolume{body:CollisionBody::Attached(base),primitive:ContactPrimitive::RoundedBox{
|
||||
center:vector((p+q*Vec3::from_array(shape.offset)).to_array()),basis:body.rates.basis,
|
||||
half_extents:vector(shape.half.map(|x|x-shape.rounding)),radius:shape.rounding},linear_velocity:body.rates.linear_velocity,material:skate_core::physics::contact::RetailContactMaterial{static_friction:0.4,dynamic_friction:0.3,restitution:0.1}});
|
||||
}
|
||||
}
|
||||
@@ -43,7 +43,7 @@ pub(crate) fn enter(physics: &mut GamePhysics, skater: &mut SkaterRuntime) -> Re
|
||||
.skeleton_controller
|
||||
.request(4, &mut skater.skeleton_collision)?;
|
||||
if skater.offboard.air_prediction.is_none() {
|
||||
let launch = super::air_trajectory::prepare(skater, false)?;
|
||||
let launch = super::air_trajectory::prepare(skater, false, physics.trainer.offboard_jump)?;
|
||||
super::air_trajectory::launch(physics, skater, launch)?;
|
||||
}
|
||||
let p = &skater.player_input.processed;
|
||||
@@ -136,7 +136,7 @@ pub(crate) fn update(physics: &mut GamePhysics, skater: &mut SkaterRuntime) -> R
|
||||
can_requery,
|
||||
&mut skater.wipeout.state,
|
||||
) {
|
||||
let mut launch = super::air_trajectory::prepare(skater, false)?;
|
||||
let mut launch = super::air_trajectory::prepare(skater, false, physics.trainer.offboard_jump)?;
|
||||
launch.velocity = relaunch.velocity;
|
||||
launch.has_target = true;
|
||||
let feet = feet_world(skater);
|
||||
|
||||
@@ -4,7 +4,7 @@ use ground_query::GroundQueryScene;
|
||||
use skate_core::player::offboard::{
|
||||
air_completion, air_launch, air_prediction, air_queries, ground_query,
|
||||
};
|
||||
pub(crate) fn prepare(skater: &SkaterRuntime, current: bool) -> Result<air_launch::Launch, String> {
|
||||
pub(crate) fn prepare(skater: &SkaterRuntime, current: bool, height_multiplier: f32) -> Result<air_launch::Launch, String> {
|
||||
let p = &skater.player_input.processed;
|
||||
let toolkit = skater
|
||||
.player_input
|
||||
@@ -39,7 +39,7 @@ pub(crate) fn prepare(skater: &SkaterRuntime, current: bool) -> Result<air_launc
|
||||
&air_launch::JumpInput {
|
||||
reference_up_144: c.state.frame_output.frame[1],
|
||||
contact_active_708: c.state.contact.active,
|
||||
height_792: skater.offboard.jump_height,
|
||||
height_792: skater.offboard.jump_height * height_multiplier,
|
||||
velocity_scalar_796: skater.offboard.jump_speed_scalar,
|
||||
speed_704: c.state.motion.speed_704,
|
||||
steering_760: c.state.intent.steering,
|
||||
|
||||
@@ -177,7 +177,7 @@ impl ground_sync::Services for Services<'_> {
|
||||
}
|
||||
}
|
||||
fn prepare_biped_launch(&mut self) -> Self::Launch {
|
||||
super::air_trajectory::prepare(self.skater, false)
|
||||
super::air_trajectory::prepare(self.skater, false, self.physics.trainer.offboard_jump)
|
||||
}
|
||||
fn skeleton_point_10960(&self) -> Vector {
|
||||
self.skater.animated_skeleton.record.centre_of_mass
|
||||
|
||||
@@ -164,3 +164,23 @@ pub(crate) fn resume_after_climb(physics: &mut GamePhysics, skater: &mut SkaterR
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Vehicle ownership has ended; reset first, then enter the native ragdoll and seed momentum.
|
||||
pub(crate) fn apply_vehicle_ejection(physics: &mut GamePhysics, skater: &mut SkaterRuntime) -> Result<bool, String> {
|
||||
let Some((velocity, angular)) = skater.teleport_state.take_vehicle_ejection() else { return Ok(false); };
|
||||
transition::set(physics, skater, PhysicalStateId::WipeoutGround)?;
|
||||
let velocity=skate_core::math::Vector3::new(velocity[0],velocity[1],velocity[2]);
|
||||
let angular=skate_core::math::Vector3::new(angular[0],angular[1],angular[2]);
|
||||
for body in skater.skeleton.bodies_mut() {
|
||||
body.rates.linear_velocity=velocity;
|
||||
body.rates.angular_velocity=angular;
|
||||
}
|
||||
for body in physics.board.bodies_mut() {
|
||||
body.rates.linear_velocity=velocity;
|
||||
body.rates.angular_velocity=angular;
|
||||
}
|
||||
skater.animated_skeleton.motion.velocity_world=[velocity.x,velocity.y,velocity.z,0.];
|
||||
skater.player_input.processed.vectors_544_560_592_608[3]=[velocity.x,velocity.y,velocity.z,0.].map(f32::to_bits);
|
||||
skater.wipeout_state.state.velocity=[velocity.x,velocity.y,velocity.z,0.];
|
||||
Ok(true)
|
||||
}
|
||||
@@ -23,14 +23,21 @@ pub(crate) struct Runtime {
|
||||
checkpoint: Checkpoint,
|
||||
pending_reply: Option<Target>,
|
||||
manual_on_board: Option<bool>,
|
||||
vehicle_ejection: Option<([f32; 3], [f32; 3])>,
|
||||
}
|
||||
impl Runtime {
|
||||
/// Explicit manual return uses the actor-reset publication without replacing
|
||||
/// the automatic recovery checkpoint owned by this runtime.
|
||||
pub fn request_manual(&mut self, transform: AnimationPartTransform, on_board: bool) {
|
||||
self.vehicle_ejection = None;
|
||||
self.pending_reply = Some(Target { transform: transform.map(|r| r.map(f32::to_bits)), on_board });
|
||||
self.manual_on_board = Some(on_board);
|
||||
}
|
||||
pub fn request_vehicle_ejection(&mut self, transform: AnimationPartTransform, velocity: [f32;3], angular: [f32;3]) {
|
||||
self.request_manual(transform, false);
|
||||
self.vehicle_ejection = Some((velocity, angular));
|
||||
}
|
||||
pub fn take_vehicle_ejection(&mut self) -> Option<([f32;3],[f32;3])> { self.vehicle_ejection.take() }
|
||||
pub fn take_manual_on_board(&mut self) -> Option<bool> { self.manual_on_board.take() }
|
||||
pub(super) fn set_checkpoint(&mut self, checkpoint: Checkpoint) {
|
||||
self.checkpoint = checkpoint;
|
||||
@@ -42,6 +49,7 @@ impl Runtime {
|
||||
checkpoint,
|
||||
pending_reply: None,
|
||||
manual_on_board: None,
|
||||
vehicle_ejection: None,
|
||||
}
|
||||
}
|
||||
pub fn enter(&mut self) {
|
||||
@@ -84,3 +92,20 @@ impl Runtime {
|
||||
Some(output)
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod vehicle_tests {
|
||||
use super::*;
|
||||
fn matrix() -> AnimationPartTransform { [[1.,0.,0.,0.],[0.,1.,0.,0.],[0.,0.,1.,0.],[3.,4.,5.,0.]] }
|
||||
#[test]
|
||||
fn vehicle_momentum_survives_reset_reply_and_is_consumed_once() {
|
||||
let mut state=Runtime::new(Checkpoint{transform:matrix(),on_board:true});
|
||||
state.request_vehicle_ejection(matrix(),[12.,2.,-3.],[0.,1.,0.]);
|
||||
assert!(state.take_reply().is_some());
|
||||
assert_eq!(state.take_manual_on_board(),Some(false));
|
||||
assert_eq!(state.take_vehicle_ejection(),Some(([12.,2.,-3.],[0.,1.,0.])));
|
||||
assert_eq!(state.take_vehicle_ejection(),None);
|
||||
state.request_vehicle_ejection(matrix(),[12.,2.,-3.],[0.,1.,0.]);
|
||||
state.request_manual(matrix(),true);
|
||||
assert_eq!(state.take_vehicle_ejection(),None);
|
||||
}
|
||||
}
|
||||
@@ -152,6 +152,7 @@ fn axis(value: f32) -> f32 {
|
||||
}
|
||||
|
||||
fn controls(
|
||||
vehicles: Res<crate::modding::vehicles::Vehicles>,
|
||||
mut replay: ResMut<Replay>,
|
||||
mut input: ResMut<crate::input::ControllerInput>,
|
||||
keys: Res<ButtonInput<KeyCode>>,
|
||||
@@ -169,7 +170,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();
|
||||
|
||||
@@ -97,6 +97,7 @@ fn suspend(
|
||||
}
|
||||
|
||||
fn update(
|
||||
vehicles: Res<crate::modding::vehicles::Vehicles>,
|
||||
mut session: ResMut<SessionMarker>,
|
||||
input: Res<ControllerInput>,
|
||||
map: Res<CurrentMap>,
|
||||
@@ -106,6 +107,7 @@ fn update(
|
||||
replay: Res<crate::replay::Replay>,
|
||||
mut audio: MessageWriter<SessionMarkerAudio>,
|
||||
) {
|
||||
if vehicles.occupied() {session.blocked_until_release = true;return;}
|
||||
if replay.active {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "skate-mods"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
blake3 = "1.8.2"
|
||||
mlua = { version = "=0.12.1", features = ["lua54", "vendored", "serde", "send"] }
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
skate-vehicles = { path = "../skate-vehicles" }
|
||||
zip = { version = "6.0.0", default-features = false, features = ["deflate"] }
|
||||
@@ -0,0 +1,6 @@
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let path = std::env::args_os().nth(1).ok_or("Usage: check_mod path/to/mod.zip-or-folder")?;
|
||||
let manifest = skate_mods::validate_package(std::path::Path::new(&path))?;
|
||||
println!("Valid package: {} {} (API {})", manifest.id, manifest.version, manifest.api);
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
local submit = sdk._submit
|
||||
sdk._submit = nil
|
||||
function sdk.log(text) submit{kind="log",text=text} end
|
||||
sdk.ui = {}
|
||||
function sdk.ui.text(key,text) submit{kind="overlay",key=key,text=text} end
|
||||
sdk.scene = {}
|
||||
function sdk.scene.cube(key,position,size,color)
|
||||
submit{kind="cube",key=key,position=position,size=size,color=color}
|
||||
end
|
||||
function sdk.scene.remove(key) submit{kind="remove",key=key} end
|
||||
sdk.player = {}
|
||||
function sdk.player.read() return sdk.snapshot.player end
|
||||
function sdk.player.teleport(position,heading,on_board)
|
||||
submit{kind="teleport",position=position,heading=heading,on_board=on_board}
|
||||
end
|
||||
sdk.input = {}
|
||||
function sdk.input.down(key) return sdk.snapshot.keys[key] == true end
|
||||
-- Timers use active Update time. Reusing a key replaces the old timer.
|
||||
sdk.time = { elapsed = 0 }
|
||||
local timers = {}
|
||||
function sdk.time.after(key,seconds,callback)
|
||||
assert(type(key)=='string' and #key>0 and #key<=64,'invalid timer key')
|
||||
assert(type(seconds)=='number' and seconds>=0 and seconds<=86400,'invalid timer delay')
|
||||
assert(type(callback)=='function','timer callback must be a function')
|
||||
local count=0; for _ in pairs(timers) do count=count+1 end
|
||||
assert(timers[key] or count<64,'64 timers maximum')
|
||||
timers[key]={at=sdk.time.elapsed+seconds,callback=callback}
|
||||
end
|
||||
function sdk.time.cancel(key) timers[key]=nil end
|
||||
function sdk._advance(dt)
|
||||
sdk.time.elapsed=sdk.time.elapsed+dt
|
||||
local due={}
|
||||
for key,timer in pairs(timers) do if timer.at<=sdk.time.elapsed then due[#due+1]=key end end
|
||||
table.sort(due)
|
||||
for _,key in ipairs(due) do
|
||||
local timer=timers[key]
|
||||
if timer and timer.at<=sdk.time.elapsed then timers[key]=nil; timer.callback() end
|
||||
end
|
||||
end
|
||||
function sdk.input.action(id)
|
||||
assert(type(id)=='number' and id%1==0 and id>=64 and id<=81,'action ID must be 64..81')
|
||||
return sdk.snapshot.actions[id-63]
|
||||
end
|
||||
|
||||
sdk.animation = {}
|
||||
function sdk.animation.replace(path) submit{kind="animation",path=path} end
|
||||
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
|
||||
|
||||
-- Owner-scoped latest state: changes replicate, callbacks never execute remotely.
|
||||
sdk.net = {}
|
||||
function sdk.net.info() return sdk.snapshot.network or {active=false,local_id="0",is_host=true} end
|
||||
function sdk.net.publish(key,value) submit{kind="network_state",key=key,value=value} end
|
||||
function sdk.net.read(peer,key)
|
||||
local n = sdk.snapshot.network or {}
|
||||
local state = ((n.states or {})[sdk.mod_id] or {})[tostring(peer)] or {}
|
||||
return state[key]
|
||||
end
|
||||
@@ -0,0 +1,99 @@
|
||||
//! Bounded ZIP extraction. A session cache never overwrites another package.
|
||||
use std::{collections::{BTreeMap, BTreeSet}, io::{Read, Write}, path::{Path, PathBuf}};
|
||||
const LIMIT: u64 = 64 * 1024 * 1024;
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Cache {
|
||||
session: Option<PathBuf>,
|
||||
entries: BTreeMap<PathBuf, (blake3::Hash, PathBuf)>,
|
||||
next: u64,
|
||||
}
|
||||
|
||||
fn safe_name(name: &str) -> Result<PathBuf, String> {
|
||||
let name = name.strip_suffix('/').unwrap_or(name);
|
||||
if name.is_empty() || name.len() > 240 || name.contains(['\\', ':', '<', '>', '"', '|', '?', '*'])
|
||||
|| name.chars().any(char::is_control) {
|
||||
return Err("Invalid ZIP path".into());
|
||||
}
|
||||
for part in name.split('/') {
|
||||
let stem = part.split('.').next().unwrap_or("").to_ascii_lowercase();
|
||||
if part.is_empty() || part == "." || part == ".." || part.ends_with(['.', ' '])
|
||||
|| matches!(stem.as_str(), "con" | "prn" | "aux" | "nul" | "com1" | "com2" | "com3" | "com4" | "com5" | "com6" | "com7" | "com8" | "com9" | "lpt1" | "lpt2" | "lpt3" | "lpt4" | "lpt5" | "lpt6" | "lpt7" | "lpt8" | "lpt9") {
|
||||
return Err("ZIP paths must be ordinary relative paths without traversal or device names".into());
|
||||
}
|
||||
}
|
||||
Ok(PathBuf::from(name))
|
||||
}
|
||||
|
||||
impl Cache {
|
||||
pub fn materialize(&mut self, root: &Path, archive: &Path) -> Result<PathBuf, String> {
|
||||
let mut bytes = Vec::new();
|
||||
std::fs::File::open(archive).map_err(|e| e.to_string())?.take(LIMIT + 1)
|
||||
.read_to_end(&mut bytes).map_err(|e| e.to_string())?;
|
||||
if bytes.len() as u64 > LIMIT { return Err("ZIP exceeds 64 MiB compressed".into()); }
|
||||
let hash = blake3::hash(&bytes);
|
||||
if let Some((old, path)) = self.entries.get(archive) {
|
||||
if old == &hash { return Ok(path.clone()); }
|
||||
}
|
||||
// Validate and decompress into bounded memory before publishing anything.
|
||||
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).map_err(|e| e.to_string())?;
|
||||
if zip.len() > 512 { return Err("ZIP exceeds 512 entries".into()); }
|
||||
let mut names = BTreeSet::new();
|
||||
let mut files = Vec::new();
|
||||
let mut total = 0_u64;
|
||||
for i in 0..zip.len() {
|
||||
let mut file = zip.by_index(i).map_err(|e| e.to_string())?;
|
||||
let path = safe_name(file.name())?;
|
||||
if !names.insert(path.to_string_lossy().to_lowercase()) { return Err("Duplicate/case-colliding ZIP path".into()); }
|
||||
let kind = file.unix_mode().unwrap_or(0) & 0o170000;
|
||||
if kind != 0 && kind != 0o100000 && kind != 0o040000 { return Err("ZIP links and special files are unsupported".into()); }
|
||||
if file.is_dir() { continue; }
|
||||
if file.size() > LIMIT - total { return Err("ZIP exceeds 64 MiB expanded".into()); }
|
||||
let mut contents = Vec::new();
|
||||
file.by_ref().take(LIMIT - total + 1).read_to_end(&mut contents).map_err(|e| e.to_string())?;
|
||||
total += contents.len() as u64;
|
||||
if total > LIMIT { return Err("ZIP exceeds 64 MiB expanded".into()); }
|
||||
files.push((path, contents));
|
||||
}
|
||||
if !files.iter().any(|(p, _)| p == Path::new("mod.json")) {
|
||||
return Err("ZIP must contain mod.json at its root, not inside a wrapper folder".into());
|
||||
}
|
||||
if self.session.is_none() {
|
||||
let root = root.canonicalize().map_err(|e| e.to_string())?;
|
||||
let cache = root.join(".cache");
|
||||
if cache.exists() && std::fs::symlink_metadata(&cache).map_err(|e|e.to_string())?.file_type().is_symlink() {
|
||||
return Err("ZIP cache must not be a link".into());
|
||||
}
|
||||
std::fs::create_dir_all(&cache).map_err(|e| e.to_string())?;
|
||||
if !cache.canonicalize().map_err(|e|e.to_string())?.starts_with(&root) { return Err("ZIP cache escapes mods folder".into()); }
|
||||
let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_err(|e|e.to_string())?.as_nanos();
|
||||
let session = cache.join(format!("{}-{nonce}", std::process::id()));
|
||||
std::fs::create_dir(&session).map_err(|e| e.to_string())?;
|
||||
self.session = Some(session);
|
||||
}
|
||||
self.next += 1;
|
||||
let destination = self.session.as_ref().unwrap().join(self.next.to_string());
|
||||
std::fs::create_dir(&destination).map_err(|e| e.to_string())?;
|
||||
for (path, contents) in files {
|
||||
let target = destination.join(path);
|
||||
std::fs::create_dir_all(target.parent().unwrap()).map_err(|e| e.to_string())?;
|
||||
std::fs::OpenOptions::new().write(true).create_new(true).open(target)
|
||||
.and_then(|mut f| f.write_all(&contents)).map_err(|e| e.to_string())?;
|
||||
}
|
||||
self.entries.insert(archive.to_owned(), (hash, destination.clone()));
|
||||
Ok(destination)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Cache {
|
||||
fn drop(&mut self) {
|
||||
if let Some(path) = &self.session {
|
||||
// Only this process's uniquely created session, and never a redirected path.
|
||||
if let (Ok(actual), Ok(parent)) = (path.canonicalize(), path.parent().unwrap().canonicalize()) {
|
||||
if actual.parent() == Some(parent.as_path()) && actual.file_name() == path.file_name() {
|
||||
let _ = std::fs::remove_dir_all(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,483 @@
|
||||
//! Window-free, versioned Lua package runtime. Host commands commit only after callbacks succeed.
|
||||
mod schema;
|
||||
mod archive;
|
||||
mod vm;
|
||||
pub use schema::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
path::{Path, PathBuf},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
pub use vm::Command;
|
||||
|
||||
/// Inspect a folder or ZIP and compile its Lua entry without executing callbacks.
|
||||
pub fn validate_package(source: &Path) -> Result<Manifest, String> {
|
||||
let source = source.canonicalize().map_err(|e| e.to_string())?;
|
||||
let mut cache = archive::Cache::default();
|
||||
let root = if source.is_dir() { source.clone() } else {
|
||||
cache.materialize(source.parent().ok_or("Missing package parent")?, &source)?
|
||||
};
|
||||
let manifest: Manifest = serde_json::from_slice(&read_bounded(&root, "mod.json", 64 * 1024)?)
|
||||
.map_err(|e| e.to_string())?;
|
||||
manifest.validate()?;
|
||||
fingerprint(&root)?;
|
||||
let source = read_bounded(&root, &manifest.entry, 256 * 1024)?;
|
||||
let source = std::str::from_utf8(&source).map_err(|e| e.to_string())?;
|
||||
if source.starts_with('\u{1b}') { return Err("Lua bytecode is unsupported".into()); }
|
||||
mlua::Lua::new().load(source).set_mode(mlua::chunk::ChunkMode::Text).into_function().map_err(|e|e.to_string())?;
|
||||
Ok(manifest)
|
||||
}
|
||||
|
||||
#[derive(Default, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Preferences {
|
||||
enabled: bool,
|
||||
#[serde(default)]
|
||||
values: BTreeMap<String, Value>,
|
||||
}
|
||||
|
||||
pub struct Package {
|
||||
pub manifest: Manifest,
|
||||
pub root: PathBuf,
|
||||
pub error: Option<String>,
|
||||
pub settings: BTreeMap<String, Value>,
|
||||
pub enabled: bool,
|
||||
vm: Option<vm::Vm>,
|
||||
fingerprint: u64,
|
||||
pending: Option<(u64, Instant)>,
|
||||
}
|
||||
impl Package {
|
||||
pub fn content_fingerprint(&self) -> u64 { self.fingerprint }
|
||||
pub fn running(&self) -> bool {
|
||||
self.vm.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Manager {
|
||||
pub packages: BTreeMap<String, Package>,
|
||||
pub diagnostics: Vec<String>,
|
||||
root: PathBuf,
|
||||
preferences: PathBuf,
|
||||
pub commands: Vec<(String, Command)>,
|
||||
pub retired: Vec<String>,
|
||||
last_scan: Instant,
|
||||
pub snapshot: Value,
|
||||
invalid_since: BTreeMap<String, Instant>,
|
||||
archives: archive::Cache,
|
||||
}
|
||||
impl Manager {
|
||||
pub fn root(&self) -> &Path {
|
||||
&self.root
|
||||
}
|
||||
pub fn new(root: PathBuf, preferences: PathBuf) -> Self {
|
||||
Self {
|
||||
packages: BTreeMap::new(),
|
||||
diagnostics: vec![],
|
||||
root,
|
||||
preferences,
|
||||
commands: vec![],
|
||||
retired: vec![],
|
||||
last_scan: Instant::now() - Duration::from_secs(2),
|
||||
snapshot: Value::Null,
|
||||
invalid_since: BTreeMap::new(),
|
||||
archives: archive::Cache::default(),
|
||||
}
|
||||
}
|
||||
pub fn scan(&mut self, force: bool) {
|
||||
if !force && self.last_scan.elapsed() < Duration::from_millis(500) {
|
||||
return;
|
||||
}
|
||||
self.last_scan = Instant::now();
|
||||
self.diagnostics.clear();
|
||||
let mut found = BTreeMap::<String, (PathBuf, Manifest, u64)>::new();
|
||||
let mut duplicates = std::collections::BTreeSet::new();
|
||||
let dirs = match std::fs::read_dir(&self.root) {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
self.diagnostics
|
||||
.push(format!("Mods directory {}: {e}", self.root.display()));
|
||||
if e.kind() == std::io::ErrorKind::NotFound {
|
||||
let ids: Vec<_> = self.packages.keys().cloned().collect();
|
||||
for id in ids {
|
||||
self.stop(&id);
|
||||
self.packages.remove(&id);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
};
|
||||
let mut paths: Vec<_> = dirs
|
||||
.filter_map(Result::ok)
|
||||
.map(|d| d.path())
|
||||
.filter(|p| !p.file_name().is_some_and(|n| n.to_string_lossy().starts_with('.'))
|
||||
&& (p.is_dir() || p.extension().is_some_and(|e| e.eq_ignore_ascii_case("zip"))))
|
||||
.collect();
|
||||
paths.sort();
|
||||
if paths.len() > 128 {
|
||||
self.diagnostics.push(
|
||||
"Only the first 128 packages are supported; remove excess packages"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
for source in paths.into_iter().take(128) {
|
||||
let path = if source.is_file() {
|
||||
match self.archives.materialize(&self.root, &source) {
|
||||
Ok(path) => path,
|
||||
Err(e) => { self.diagnostics.push(format!("{}: {e}", source.display())); continue; }
|
||||
}
|
||||
} else { source.clone() };
|
||||
// Invalid edits retire the old package: never silently retain outdated gameplay.
|
||||
let result = (|| {
|
||||
let bytes = read_bounded(&path, "mod.json", 64 * 1024)?;
|
||||
let manifest: Manifest =
|
||||
serde_json::from_slice(&bytes).map_err(|e| e.to_string())?;
|
||||
manifest.validate()?;
|
||||
let hash = fingerprint(&path)?;
|
||||
Ok::<_, String>((manifest, hash))
|
||||
})();
|
||||
match result {
|
||||
Ok((manifest, hash)) => {
|
||||
let id = manifest.id.clone();
|
||||
if found.contains_key(&id) {
|
||||
duplicates.insert(id.clone());
|
||||
}
|
||||
found.insert(id, (path, manifest, hash));
|
||||
}
|
||||
Err(e) => self.diagnostics.push(format!("{}: {e}", source.display())),
|
||||
}
|
||||
}
|
||||
for id in duplicates {
|
||||
found.remove(&id);
|
||||
self.diagnostics
|
||||
.push(format!("Duplicate mod ID {id}: all copies rejected"));
|
||||
}
|
||||
let removed: Vec<_> = self
|
||||
.packages
|
||||
.iter()
|
||||
.filter_map(|(id, p)| {
|
||||
if found.contains_key(id) {
|
||||
self.invalid_since.remove(id);
|
||||
return None;
|
||||
}
|
||||
if !p.root.exists() || force {
|
||||
return Some(id.clone());
|
||||
}
|
||||
let since = self
|
||||
.invalid_since
|
||||
.entry(id.clone())
|
||||
.or_insert_with(Instant::now);
|
||||
(since.elapsed() >= Duration::from_millis(750)).then(|| id.clone())
|
||||
})
|
||||
.collect();
|
||||
for id in removed {
|
||||
self.stop(&id);
|
||||
self.packages.remove(&id);
|
||||
}
|
||||
for (id, (root, manifest, hash)) in found {
|
||||
if let Some(p) = self.packages.get_mut(&id) {
|
||||
if p.fingerprint == hash {
|
||||
p.pending = None;
|
||||
continue;
|
||||
}
|
||||
let ready = match p.pending {
|
||||
Some((h, since)) if h == hash => since.elapsed() >= Duration::from_millis(750),
|
||||
_ => {
|
||||
p.pending = Some((hash, Instant::now()));
|
||||
false
|
||||
}
|
||||
};
|
||||
if !force && !ready {
|
||||
continue;
|
||||
}
|
||||
let enabled = p.enabled;
|
||||
self.stop(&id);
|
||||
let p = self.packages.get_mut(&id).unwrap();
|
||||
p.manifest = manifest;
|
||||
p.root = root;
|
||||
p.fingerprint = hash;
|
||||
p.pending = None;
|
||||
p.settings = p
|
||||
.manifest
|
||||
.settings
|
||||
.iter()
|
||||
.map(|(k, s)| {
|
||||
(
|
||||
k.clone(),
|
||||
p.settings
|
||||
.get(k)
|
||||
.filter(|v| s.accepts(v))
|
||||
.cloned()
|
||||
.unwrap_or_else(|| s.default.clone()),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
if enabled {
|
||||
self.start(&id);
|
||||
}
|
||||
} else {
|
||||
let saved = self.read_preferences(&id);
|
||||
let settings = manifest
|
||||
.settings
|
||||
.iter()
|
||||
.map(|(k, s)| {
|
||||
(
|
||||
k.clone(),
|
||||
saved
|
||||
.values
|
||||
.get(k)
|
||||
.filter(|v| s.accepts(v))
|
||||
.cloned()
|
||||
.unwrap_or_else(|| s.default.clone()),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let enabled = saved.enabled;
|
||||
self.packages.insert(
|
||||
id.clone(),
|
||||
Package {
|
||||
root,
|
||||
manifest,
|
||||
fingerprint: hash,
|
||||
pending: None,
|
||||
error: None,
|
||||
settings,
|
||||
enabled,
|
||||
vm: None,
|
||||
},
|
||||
);
|
||||
if enabled {
|
||||
self.start(&id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
fn read_preferences(&mut self, id: &str) -> Preferences {
|
||||
match std::fs::read(self.preferences.join(format!("{id}.json"))) {
|
||||
Ok(b) => serde_json::from_slice(&b).unwrap_or_else(|e| {
|
||||
self.diagnostics
|
||||
.push(format!("Settings {id}: {e}; defaults used"));
|
||||
Preferences::default()
|
||||
}),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Preferences::default(),
|
||||
Err(e) => {
|
||||
self.diagnostics.push(format!("Settings {id}: {e}"));
|
||||
Preferences::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
fn save(&self, id: &str) -> Result<(), String> {
|
||||
let p = self.packages.get(id).ok_or("Unknown mod")?;
|
||||
std::fs::create_dir_all(&self.preferences).map_err(|e| e.to_string())?;
|
||||
let file = self.preferences.join(format!("{id}.json"));
|
||||
let temp = self.preferences.join(format!("{id}.tmp"));
|
||||
std::fs::write(
|
||||
&temp,
|
||||
serde_json::to_vec_pretty(&Preferences {
|
||||
enabled: p.enabled,
|
||||
values: p.settings.clone(),
|
||||
})
|
||||
.unwrap(),
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
std::fs::rename(temp, file).map_err(|e| e.to_string())
|
||||
}
|
||||
pub fn enable(&mut self, id: &str, enabled: bool) -> Result<(), String> {
|
||||
self.packages.get_mut(id).ok_or("Unknown mod")?.enabled = enabled;
|
||||
if enabled {
|
||||
self.reload(id);
|
||||
} else {
|
||||
self.stop(id);
|
||||
}
|
||||
self.save(id)
|
||||
}
|
||||
pub fn reload(&mut self, id: &str) {
|
||||
self.stop(id);
|
||||
if self.packages.get(id).is_some_and(|p| p.enabled) {
|
||||
self.start(id);
|
||||
}
|
||||
}
|
||||
fn start(&mut self, id: &str) {
|
||||
let Some(p) = self.packages.get_mut(id) else {
|
||||
return;
|
||||
};
|
||||
p.error = None;
|
||||
match vm::Vm::new(&p.root, &p.manifest, &p.settings, &self.snapshot) {
|
||||
Ok(vm) => {
|
||||
p.vm = Some(vm);
|
||||
self.call_one(id, "on_load", Value::Null);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Lua [{id}] load: {e}");
|
||||
p.error = Some(e);
|
||||
self.retired.push(id.to_owned());
|
||||
}
|
||||
}
|
||||
}
|
||||
fn stop(&mut self, id: &str) {
|
||||
if let Some(p) = self.packages.get_mut(id) {
|
||||
if let Some(mut vm) = p.vm.take() {
|
||||
if let Err(e) = vm.call("on_unload", Value::Null, &self.snapshot) {
|
||||
p.error = Some(format!("on_unload: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
self.commands.retain(|(owner, _)| owner != id);
|
||||
self.retired.push(id.to_owned());
|
||||
}
|
||||
pub fn fail(&mut self, id: &str, error: String) {
|
||||
eprintln!("Lua [{id}]: {error}");
|
||||
self.stop(id);
|
||||
if let Some(p) = self.packages.get_mut(id) {
|
||||
p.error = Some(error);
|
||||
}
|
||||
}
|
||||
fn call_one(&mut self, id: &str, callback: &str, payload: Value) {
|
||||
let result = self
|
||||
.packages
|
||||
.get_mut(id)
|
||||
.and_then(|p| p.vm.as_mut())
|
||||
.map(|vm| vm.call(callback, payload, &self.snapshot));
|
||||
match result {
|
||||
Some(Ok(cmds)) => self
|
||||
.commands
|
||||
.extend(cmds.into_iter().map(|c| (id.to_owned(), c))),
|
||||
Some(Err(e)) => self.fail(id, format!("{callback}: {e}")),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
pub fn dispatch(&mut self, callback: &str, payload: Value) {
|
||||
let ids: Vec<_> = self.packages.keys().cloned().collect();
|
||||
for id in ids {
|
||||
self.call_one(&id, callback, payload.clone());
|
||||
}
|
||||
}
|
||||
pub fn setting(&mut self, id: &str, key: &str, value: Value) -> Result<(), String> {
|
||||
let p = self.packages.get_mut(id).ok_or("Unknown mod")?;
|
||||
if !p
|
||||
.manifest
|
||||
.settings
|
||||
.get(key)
|
||||
.ok_or("Unknown setting")?
|
||||
.accepts(&value)
|
||||
{
|
||||
return Err("Invalid setting value".into());
|
||||
}
|
||||
p.settings.insert(key.into(), value.clone());
|
||||
if let Some(vm) = p.vm.as_mut() {
|
||||
if let Err(e) = vm.settings(&p.settings) {
|
||||
let e = e.to_string();
|
||||
self.fail(id, e.clone());
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
self.call_one(
|
||||
id,
|
||||
"on_settings",
|
||||
serde_json::json!({"key":key,"value":value}),
|
||||
);
|
||||
self.save(id)
|
||||
}
|
||||
pub fn reset(&mut self, id: &str) -> Result<(), String> {
|
||||
let values: Vec<_> = self
|
||||
.packages
|
||||
.get(id)
|
||||
.ok_or("Unknown mod")?
|
||||
.manifest
|
||||
.settings
|
||||
.iter()
|
||||
.map(|(k, s)| (k.clone(), s.default.clone()))
|
||||
.collect();
|
||||
for (k, v) in values {
|
||||
self.setting(id, &k, v)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
fn fingerprint(root: &Path) -> Result<u64, String> {
|
||||
use std::hash::{Hash, Hasher};
|
||||
fn visit(
|
||||
root: &Path,
|
||||
dir: &Path,
|
||||
h: &mut std::collections::hash_map::DefaultHasher,
|
||||
count: &mut usize,
|
||||
bytes: &mut u64,
|
||||
) -> Result<(), String> {
|
||||
let mut paths: Vec<_> = std::fs::read_dir(dir)
|
||||
.map_err(|e| e.to_string())?
|
||||
.map(|p| p.map(|p| p.path()))
|
||||
.collect::<Result<_, _>>()
|
||||
.map_err(|e| e.to_string())?;
|
||||
paths.sort();
|
||||
for p in paths {
|
||||
*count += 1;
|
||||
if *count > 512 {
|
||||
return Err("Package exceeds 512 files/directories".into());
|
||||
}
|
||||
if std::fs::symlink_metadata(&p)
|
||||
.map_err(|e| e.to_string())?
|
||||
.file_type()
|
||||
.is_symlink()
|
||||
{
|
||||
return Err("Symlinks are not supported".into());
|
||||
}
|
||||
let canonical = p.canonicalize().map_err(|e| e.to_string())?;
|
||||
if !canonical.starts_with(root) {
|
||||
return Err("Asset escapes mod root".into());
|
||||
}
|
||||
if p.is_dir() {
|
||||
visit(root, &p, h, count, bytes)?;
|
||||
} else {
|
||||
let m = p.metadata().map_err(|e| e.to_string())?;
|
||||
*bytes += m.len();
|
||||
if *bytes > 64 * 1024 * 1024 {
|
||||
return Err("Package exceeds 64 MiB".into());
|
||||
}
|
||||
p.strip_prefix(root).map_err(|e| e.to_string())?.to_string_lossy().replace('\\', "/").hash(h);
|
||||
std::fs::read(&p).map_err(|e| e.to_string())?.hash(h);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
let root = root.canonicalize().map_err(|e| e.to_string())?;
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
visit(&root, &root, &mut h, &mut 0, &mut 0)?;
|
||||
Ok(h.finish())
|
||||
}
|
||||
pub fn read_bounded(root: &Path, relative: &str, limit: u64) -> Result<Vec<u8>, String> {
|
||||
let path = Path::new(relative);
|
||||
if path
|
||||
.components()
|
||||
.any(|c| !matches!(c, std::path::Component::Normal(_)))
|
||||
|| relative.contains(':')
|
||||
{
|
||||
return Err("Use a relative path without traversal".into());
|
||||
}
|
||||
let root = root.canonicalize().map_err(|e| e.to_string())?;
|
||||
let path = root.join(path).canonicalize().map_err(|e| e.to_string())?;
|
||||
if !path.starts_with(&root) {
|
||||
return Err("Asset escapes mod root".into());
|
||||
}
|
||||
use std::io::Read;
|
||||
let mut bytes = vec![];
|
||||
std::fs::File::open(path)
|
||||
.map_err(|e| e.to_string())?
|
||||
.take(limit + 1)
|
||||
.read_to_end(&mut bytes)
|
||||
.map_err(|e| e.to_string())?;
|
||||
if bytes.len() as u64 > limit {
|
||||
return Err("Asset too large".into());
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
impl Drop for Manager {
|
||||
fn drop(&mut self) {
|
||||
for package in self.packages.values_mut() {
|
||||
if let Some(mut vm) = package.vm.take() {
|
||||
let _ = vm.call("on_unload", Value::Null, &self.snapshot);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::collections::BTreeMap;
|
||||
#[derive(Clone, Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Manifest {
|
||||
pub id: String,
|
||||
pub api: u32,
|
||||
pub name: String,
|
||||
pub version: String,
|
||||
pub author: String,
|
||||
pub description: String,
|
||||
pub entry: String,
|
||||
#[serde(default)]
|
||||
pub settings: BTreeMap<String, Setting>,
|
||||
}
|
||||
#[derive(Clone, Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Setting {
|
||||
pub label: String,
|
||||
pub description: String,
|
||||
#[serde(rename = "type")]
|
||||
pub kind: String,
|
||||
pub default: Value,
|
||||
pub min: Option<f64>,
|
||||
pub max: Option<f64>,
|
||||
pub step: Option<f64>,
|
||||
#[serde(default)]
|
||||
pub choices: Vec<String>,
|
||||
}
|
||||
pub fn valid_id(s: &str) -> bool {
|
||||
let stem = s.split('.').next().unwrap_or("");
|
||||
let reserved = matches!(
|
||||
stem,
|
||||
"con"
|
||||
| "prn"
|
||||
| "aux"
|
||||
| "nul"
|
||||
| "com1"
|
||||
| "com2"
|
||||
| "com3"
|
||||
| "com4"
|
||||
| "com5"
|
||||
| "com6"
|
||||
| "com7"
|
||||
| "com8"
|
||||
| "com9"
|
||||
| "lpt1"
|
||||
| "lpt2"
|
||||
| "lpt3"
|
||||
| "lpt4"
|
||||
| "lpt5"
|
||||
| "lpt6"
|
||||
| "lpt7"
|
||||
| "lpt8"
|
||||
| "lpt9"
|
||||
);
|
||||
!reserved
|
||||
&& !s.is_empty()
|
||||
&& s.len() <= 64
|
||||
&& s.bytes().all(|b| {
|
||||
b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_' || b == b'-' || b == b'.'
|
||||
})
|
||||
&& s != "."
|
||||
&& s != ".."
|
||||
}
|
||||
impl Manifest {
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
if !valid_id(&self.id) || self.api != 1 {
|
||||
return Err("Invalid ID or unsupported API (expected 1)".into());
|
||||
}
|
||||
if self.name.is_empty()
|
||||
|| self.name.len() > 120
|
||||
|| self.author.len() > 120
|
||||
|| self.description.len() > 2048
|
||||
{
|
||||
return Err("Invalid metadata length".into());
|
||||
}
|
||||
if self.version.split('.').count() != 3
|
||||
|| self.version.split('.').any(|p| p.parse::<u32>().is_err())
|
||||
{
|
||||
return Err("version must be MAJOR.MINOR.PATCH".into());
|
||||
}
|
||||
if self.settings.len() > 24 {
|
||||
return Err("At most 24 settings".into());
|
||||
}
|
||||
for (key, s) in &self.settings {
|
||||
if !valid_id(key) || s.label.len() > 120 || s.description.len() > 512 {
|
||||
return Err(format!("Invalid setting {key}"));
|
||||
}
|
||||
if s.kind == "number"
|
||||
&& !(s
|
||||
.min
|
||||
.zip(s.max)
|
||||
.is_some_and(|(a, b)| a.is_finite() && b.is_finite() && a <= b)
|
||||
&& s.step.is_some_and(|v| v.is_finite() && v > 0.))
|
||||
{
|
||||
return Err(format!(
|
||||
"{key}: numbers need finite min, max and positive step"
|
||||
));
|
||||
}
|
||||
if s.kind == "choice"
|
||||
&& (s.choices.is_empty()
|
||||
|| s.choices.len() > 32
|
||||
|| s.choices.iter().any(|s| s.len() > 128))
|
||||
{
|
||||
return Err(format!("{key}: invalid choices"));
|
||||
}
|
||||
if !s.accepts(&s.default) {
|
||||
return Err(format!("{key}: invalid type/default"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
impl Setting {
|
||||
pub fn accepts(&self, v: &Value) -> bool {
|
||||
match self.kind.as_str() {
|
||||
"boolean" => v.is_boolean(),
|
||||
"number" => v.as_f64().is_some_and(|v| {
|
||||
v.is_finite()
|
||||
&& self.min.is_some_and(|a| v >= a)
|
||||
&& self.max.is_some_and(|b| v <= b)
|
||||
}),
|
||||
"string" => v
|
||||
.as_str()
|
||||
.is_some_and(|s| s.chars().count() <= 128 && !s.chars().any(char::is_control)),
|
||||
"choice" => v
|
||||
.as_str()
|
||||
.is_some_and(|s| self.choices.iter().any(|c| c == s)),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whitelisted native trainer multipliers. 1.0 preserves the installed stock value.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Deserialize, Serialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct TrainerTuning {
|
||||
pub pop: f32,
|
||||
pub grind_pop: f32,
|
||||
pub push_speed: f32,
|
||||
pub push_power: f32,
|
||||
pub braking: f32,
|
||||
pub steering: f32,
|
||||
pub wobble: f32,
|
||||
pub offboard_jump: f32,
|
||||
pub grip: f32,
|
||||
pub turn_power: f32,
|
||||
pub manual_drag: f32,
|
||||
pub hold_fakie: bool,
|
||||
}
|
||||
impl Default for TrainerTuning {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
pop: 1.,
|
||||
grind_pop: 1.,
|
||||
push_speed: 1.,
|
||||
push_power: 1.,
|
||||
braking: 1.,
|
||||
steering: 1.,
|
||||
wobble: 1.,
|
||||
offboard_jump: 1.,
|
||||
grip: 1.,
|
||||
turn_power: 1.,
|
||||
manual_drag: 1.,
|
||||
hold_fakie: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl TrainerTuning {
|
||||
pub fn valid(&self) -> bool {
|
||||
[
|
||||
self.pop,
|
||||
self.grind_pop,
|
||||
self.push_speed,
|
||||
self.push_power,
|
||||
self.braking,
|
||||
self.steering,
|
||||
self.offboard_jump,
|
||||
self.grip,
|
||||
self.turn_power,
|
||||
self.manual_drag,
|
||||
]
|
||||
.into_iter()
|
||||
.all(|v| v.is_finite() && (0.25..=4.).contains(&v))
|
||||
&& self.wobble.is_finite()
|
||||
&& (0. ..=2.).contains(&self.wobble)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
use crate::{Manifest, read_bounded};
|
||||
use mlua::{HookTriggers, Lua, LuaOptions, LuaSerdeExt, StdLib, Table, VmState};
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
path::Path,
|
||||
sync::{
|
||||
Arc, Mutex,
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
},
|
||||
};
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum Command {
|
||||
NetworkState { key: String, #[serde(default)] value: Value },
|
||||
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,
|
||||
},
|
||||
Log {
|
||||
text: String,
|
||||
},
|
||||
Animation {
|
||||
path: String,
|
||||
},
|
||||
Overlay {
|
||||
key: String,
|
||||
text: String,
|
||||
},
|
||||
Cube {
|
||||
key: String,
|
||||
position: [f32; 3],
|
||||
size: [f32; 3],
|
||||
color: [f32; 3],
|
||||
},
|
||||
Remove {
|
||||
key: String,
|
||||
},
|
||||
Teleport {
|
||||
position: [f32; 3],
|
||||
heading: f32,
|
||||
on_board: bool,
|
||||
},
|
||||
}
|
||||
impl Command {
|
||||
pub fn validate(&self) -> bool {
|
||||
let point = |p: &[f32; 3]| p.iter().all(|v| v.is_finite() && v.abs() <= 100_000.);
|
||||
match self {
|
||||
Self::NetworkState { key, value } => crate::schema::valid_id(key) && serde_json::to_vec(value).is_ok_and(|v| v.len() <= 512),
|
||||
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,
|
||||
Self::Overlay { key, text } => crate::schema::valid_id(key) && text.len() <= 1024,
|
||||
Self::Cube {
|
||||
key,
|
||||
position,
|
||||
size,
|
||||
color,
|
||||
} => {
|
||||
crate::schema::valid_id(key)
|
||||
&& point(position)
|
||||
&& size.iter().all(|v| v.is_finite() && *v > 0. && *v <= 100.)
|
||||
&& color
|
||||
.iter()
|
||||
.all(|v| v.is_finite() && (0. ..=1.).contains(v))
|
||||
}
|
||||
Self::Remove { key } => crate::schema::valid_id(key),
|
||||
Self::Teleport {
|
||||
position, heading, ..
|
||||
} => point(position) && heading.is_finite(),
|
||||
}
|
||||
}
|
||||
}
|
||||
pub(crate) struct Vm {
|
||||
lua: Lua,
|
||||
callbacks: Table,
|
||||
advance: mlua::Function,
|
||||
budget: Arc<AtomicUsize>,
|
||||
queue: Arc<Mutex<Vec<Command>>>,
|
||||
}
|
||||
impl Vm {
|
||||
pub fn new(
|
||||
root: &Path,
|
||||
manifest: &Manifest,
|
||||
settings: &BTreeMap<String, Value>,
|
||||
snapshot: &Value,
|
||||
) -> Result<Self, String> {
|
||||
let build = || -> mlua::Result<Self> {
|
||||
let lua = Lua::new_with(
|
||||
StdLib::TABLE | StdLib::STRING | StdLib::MATH | StdLib::UTF8,
|
||||
LuaOptions::default(),
|
||||
)?;
|
||||
lua.set_memory_limit(8 * 1024 * 1024)?;
|
||||
// No catch/retry loops around quota errors, coroutine bypass, bytecode or filesystem loaders.
|
||||
for key in [
|
||||
"pcall",
|
||||
"xpcall",
|
||||
"load",
|
||||
"loadfile",
|
||||
"dofile",
|
||||
"collectgarbage",
|
||||
"print",
|
||||
] {
|
||||
lua.globals().set(key, mlua::Value::Nil)?;
|
||||
}
|
||||
let budget = Arc::new(AtomicUsize::new(100));
|
||||
let counter = budget.clone();
|
||||
lua.set_hook(
|
||||
HookTriggers::new().every_nth_instruction(1000),
|
||||
move |_, _| {
|
||||
if counter
|
||||
.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |n| n.checked_sub(1))
|
||||
.is_err()
|
||||
{
|
||||
return Err(mlua::Error::RuntimeError(
|
||||
"Lua instruction budget exhausted".into(),
|
||||
));
|
||||
}
|
||||
Ok(VmState::Continue)
|
||||
},
|
||||
)?;
|
||||
let queue = Arc::new(Mutex::new(Vec::<Command>::new()));
|
||||
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| {
|
||||
let c: Command = lua.from_value(value)?;
|
||||
if !c.validate() {
|
||||
return Err(mlua::Error::RuntimeError(
|
||||
"Invalid command arguments".into(),
|
||||
));
|
||||
}
|
||||
let mut q = out.lock().unwrap();
|
||||
if q.len() >= 128 {
|
||||
return Err(mlua::Error::RuntimeError(
|
||||
"128 commands per callback maximum".into(),
|
||||
));
|
||||
}
|
||||
q.push(c);
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
let asset_root = root.to_path_buf();
|
||||
sdk.set(
|
||||
"read_text",
|
||||
lua.create_function(move |_, path: String| {
|
||||
let b = read_bounded(&asset_root, &path, 256 * 1024)
|
||||
.map_err(mlua::Error::RuntimeError)?;
|
||||
String::from_utf8(b).map_err(mlua::Error::external)
|
||||
})?,
|
||||
)?;
|
||||
sdk.set("settings", lua.to_value(settings)?)?;
|
||||
sdk.set("snapshot", lua.to_value(snapshot)?)?;
|
||||
lua.globals().set("sdk", sdk)?;
|
||||
lua.load(include_str!("api.lua"))
|
||||
.set_name("@skate-sdk-1")
|
||||
.exec()?;
|
||||
let sdk: Table = lua.globals().get("sdk")?;
|
||||
let advance = sdk.get("_advance")?;
|
||||
sdk.set("_advance", mlua::Value::Nil)?;
|
||||
let code = crate::read_bounded(root, &manifest.entry, 256 * 1024)
|
||||
.map_err(mlua::Error::RuntimeError)?;
|
||||
let source = std::str::from_utf8(&code).map_err(mlua::Error::external)?;
|
||||
let callbacks: Table = lua
|
||||
.load(source)
|
||||
.set_name(format!("@{}/{}", manifest.id, manifest.entry))
|
||||
.eval()?;
|
||||
for pair in callbacks.clone().pairs::<String, mlua::Value>() {
|
||||
let (key, value) = pair?;
|
||||
if ![
|
||||
"on_load",
|
||||
"on_unload",
|
||||
"on_update",
|
||||
"on_fixed_update",
|
||||
"on_event",
|
||||
"on_settings",
|
||||
]
|
||||
.contains(&key.as_str())
|
||||
|| !matches!(value, mlua::Value::Function(_))
|
||||
{
|
||||
return Err(mlua::Error::RuntimeError(format!(
|
||||
"Unknown callback or non-function: {key}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
// Top-level commands are staged together with on_load, not committed yet.
|
||||
Ok(Self {
|
||||
lua,
|
||||
callbacks,
|
||||
advance,
|
||||
budget,
|
||||
queue,
|
||||
})
|
||||
};
|
||||
build().map_err(|e| e.to_string())
|
||||
}
|
||||
pub fn settings(&mut self, settings: &BTreeMap<String, Value>) -> mlua::Result<()> {
|
||||
self.lua
|
||||
.globals()
|
||||
.get::<Table>("sdk")?
|
||||
.set("settings", self.lua.to_value(settings)?)
|
||||
}
|
||||
pub fn call(
|
||||
&mut self,
|
||||
name: &str,
|
||||
payload: Value,
|
||||
snapshot: &Value,
|
||||
) -> Result<Vec<Command>, String> {
|
||||
self.budget.store(100, Ordering::Relaxed);
|
||||
let invoke = || -> mlua::Result<()> {
|
||||
self.lua
|
||||
.globals()
|
||||
.get::<Table>("sdk")?
|
||||
.set("snapshot", self.lua.to_value(snapshot)?)?;
|
||||
if let Some(f) = self.callbacks.get::<Option<mlua::Function>>(name)? {
|
||||
f.call::<()>(self.lua.to_value(&payload)?)?;
|
||||
}
|
||||
if name == "on_update" {
|
||||
self.advance
|
||||
.call::<()>(payload["dt"].as_f64().unwrap_or(0.))?;
|
||||
}
|
||||
Ok(())
|
||||
};
|
||||
let result = invoke();
|
||||
let commands = std::mem::take(&mut *self.queue.lock().unwrap());
|
||||
result.map(|_| commands).map_err(|e| e.to_string())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
use skate_mods::Manager;
|
||||
use std::{io::Write, path::PathBuf, sync::atomic::{AtomicUsize, Ordering}};
|
||||
static NEXT: AtomicUsize = AtomicUsize::new(0);
|
||||
struct Fixture(PathBuf);
|
||||
impl Fixture {
|
||||
fn new() -> Self {
|
||||
let root = std::env::temp_dir().join(format!("skate-zip-{}-{}", std::process::id(), NEXT.fetch_add(1,Ordering::Relaxed)));
|
||||
std::fs::create_dir_all(&root).unwrap(); Self(root)
|
||||
}
|
||||
fn zip(&self, name: &str, files: &[(&str, &[u8])]) {
|
||||
let mut zip = zip::ZipWriter::new(std::fs::File::create(self.0.join(name)).unwrap());
|
||||
for (name, contents) in files {
|
||||
zip.start_file(*name, zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated)).unwrap();
|
||||
zip.write_all(contents).unwrap();
|
||||
}
|
||||
zip.finish().unwrap();
|
||||
}
|
||||
fn manager(&self) -> Manager { Manager::new(self.0.clone(), self.0.join(".settings")) }
|
||||
}
|
||||
impl Drop for Fixture {
|
||||
fn drop(&mut self) {
|
||||
let actual=self.0.canonicalize().unwrap();
|
||||
let temp=std::env::temp_dir().canonicalize().unwrap();
|
||||
assert!(actual.parent()==Some(temp.as_path()));
|
||||
std::fs::remove_dir_all(actual).unwrap();
|
||||
}
|
||||
}
|
||||
const MANIFEST: &[u8] = br#"{"id":"zip.example","api":1,"name":"ZIP Example","version":"1.0.0","author":"Test","description":"Test","entry":"main.lua"}"#;
|
||||
|
||||
#[test]
|
||||
fn zip_assets_reload_and_removal_follow_the_archive() {
|
||||
let f=Fixture::new();
|
||||
let code=b"return {on_load=function() sdk.log(sdk.read_text('assets/message.txt')) end}";
|
||||
f.zip("example.zip", &[("mod.json",MANIFEST),("main.lua",code),("assets/message.txt",b"first")]);
|
||||
let mut m=f.manager(); m.scan(true); assert!(m.diagnostics.is_empty(), "{:?}",m.diagnostics);
|
||||
m.enable("zip.example",true).unwrap(); assert!(m.packages["zip.example"].running());
|
||||
let old=m.packages["zip.example"].root.clone();
|
||||
assert_eq!(std::fs::read(old.join("assets/message.txt")).unwrap(),b"first");
|
||||
m.scan(true); assert_eq!(m.packages["zip.example"].root,old);
|
||||
f.zip("example.zip", &[("mod.json",MANIFEST),("main.lua",code),("assets/message.txt",b"second")]);
|
||||
m.scan(true); assert_ne!(m.packages["zip.example"].root,old);
|
||||
assert!(m.packages["zip.example"].running());
|
||||
std::fs::remove_file(f.0.join("example.zip")).unwrap(); m.scan(true);
|
||||
assert!(m.packages.is_empty()); assert!(m.retired.contains(&"zip.example".to_owned()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsafe_paths_wrapper_folders_and_case_collisions_are_rejected() {
|
||||
for name in ["../escape.txt", "/absolute.txt", "C:/escape.txt", "a\\escape.txt", "nul.txt", "a./x", "a/../x"] {
|
||||
let f=Fixture::new(); f.zip("bad.zip", &[("mod.json",MANIFEST),(name,b"bad")]);
|
||||
let mut m=f.manager(); m.scan(true); assert!(m.packages.is_empty(),"{name}"); assert!(!m.diagnostics.is_empty());
|
||||
}
|
||||
for files in [vec![("wrapper/mod.json",MANIFEST)], vec![("mod.json",MANIFEST),("MOD.JSON",MANIFEST)]] {
|
||||
let f=Fixture::new(); f.zip("bad.zip", &files);
|
||||
let mut m=f.manager(); m.scan(true); assert!(m.packages.is_empty()); assert!(!m.diagnostics.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expansion_limits_and_duplicate_ids_are_rejected() {
|
||||
let f=Fixture::new(); let huge=vec![0_u8;64*1024*1024+1];
|
||||
f.zip("huge.zip", &[("mod.json",MANIFEST),("huge.bin",&huge)]);
|
||||
let mut m=f.manager(); m.scan(true); assert!(m.packages.is_empty());
|
||||
assert!(m.diagnostics.iter().any(|s|s.contains("64 MiB")));
|
||||
std::fs::remove_file(f.0.join("huge.zip")).unwrap();
|
||||
for name in ["one.zip","two.zip"] { f.zip(name,&[("mod.json",MANIFEST),("main.lua",b"return {}")]); }
|
||||
m.scan(true); assert!(m.packages.is_empty()); assert!(m.diagnostics.iter().any(|s|s.contains("Duplicate mod ID")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn links_excess_entries_and_invalid_replacements_do_not_run() {
|
||||
let f=Fixture::new();
|
||||
let mut zip=zip::ZipWriter::new(std::fs::File::create(f.0.join("bad.zip")).unwrap());
|
||||
zip.add_symlink("escape", "../outside", zip::write::SimpleFileOptions::default()).unwrap();
|
||||
zip.finish().unwrap();
|
||||
let mut m=f.manager(); m.scan(true); assert!(m.packages.is_empty());
|
||||
assert!(m.diagnostics.iter().any(|s|s.contains("links")));
|
||||
let mut zip=zip::ZipWriter::new(std::fs::File::create(f.0.join("bad.zip")).unwrap());
|
||||
for i in 0..513 { zip.start_file(format!("file-{i}"),zip::write::SimpleFileOptions::default()).unwrap(); }
|
||||
zip.finish().unwrap(); m.scan(true); assert!(m.diagnostics.iter().any(|s|s.contains("512")));
|
||||
f.zip("bad.zip", &[("mod.json",MANIFEST),("main.lua",b"return {}")]);
|
||||
m.scan(true); m.enable("zip.example",true).unwrap(); assert!(m.packages["zip.example"].running());
|
||||
std::fs::write(f.0.join("bad.zip"),b"not a ZIP").unwrap(); m.scan(true);
|
||||
assert!(m.packages.is_empty()); assert!(!m.diagnostics.is_empty());
|
||||
}
|
||||
@@ -0,0 +1,476 @@
|
||||
use serde_json::json;
|
||||
use skate_mods::*;
|
||||
use std::{
|
||||
path::PathBuf,
|
||||
sync::atomic::{AtomicU64, Ordering},
|
||||
};
|
||||
static NEXT: AtomicU64 = AtomicU64::new(0);
|
||||
struct Fixture(PathBuf);
|
||||
impl Fixture {
|
||||
fn new(code: &str) -> Self {
|
||||
let p = std::env::temp_dir().join(format!(
|
||||
"skate-sdk-{}-{}",
|
||||
std::process::id(),
|
||||
NEXT.fetch_add(1, Ordering::Relaxed)
|
||||
));
|
||||
std::fs::create_dir_all(p.join("mods/example")).unwrap();
|
||||
let f = Self(p);
|
||||
f.code(code);
|
||||
std::fs::write(f.0.join("mods/example/mod.json"),serde_json::to_vec(&json!({"id":"example","api":1,"name":"Example","version":"1.0.0","author":"Test","description":"Test","entry":"main.lua","settings":{"count":{"type":"number","label":"Count","description":"Count","min":1,"max":10,"step":1,"default":3}}})).unwrap()).unwrap();
|
||||
f
|
||||
}
|
||||
fn code(&self, s: &str) {
|
||||
std::fs::write(self.0.join("mods/example/main.lua"), s).unwrap();
|
||||
}
|
||||
fn manager(&self) -> Manager {
|
||||
let mut m = Manager::new(self.0.join("mods"), self.0.join("settings"));
|
||||
m.scan(true);
|
||||
m
|
||||
}
|
||||
}
|
||||
impl Drop for Fixture {
|
||||
fn drop(&mut self) {
|
||||
let temp = std::env::temp_dir().canonicalize().unwrap();
|
||||
let target = self.0.canonicalize().unwrap();
|
||||
assert!(target.starts_with(&temp) && target != temp);
|
||||
assert!(
|
||||
target
|
||||
.file_name()
|
||||
.unwrap()
|
||||
.to_string_lossy()
|
||||
.starts_with("skate-sdk-")
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(target);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn disabled_by_default_reload_resets_and_removes_stale_commands() {
|
||||
let f =
|
||||
Fixture::new("local n=0; return {on_update=function() n=n+1; sdk.log(tostring(n)) end}");
|
||||
let mut m = f.manager();
|
||||
assert!(!m.packages["example"].running());
|
||||
m.enable("example", true).unwrap();
|
||||
m.dispatch("on_update", json!({}));
|
||||
m.dispatch("on_update", json!({}));
|
||||
assert_eq!(m.commands.len(), 2);
|
||||
m.reload("example");
|
||||
assert!(m.commands.is_empty());
|
||||
m.dispatch("on_update", json!({}));
|
||||
assert!(matches!(&m.commands[0].1,Command::Log{text} if text=="1"));
|
||||
m.enable("example", false).unwrap();
|
||||
assert!(m.commands.is_empty());
|
||||
assert!(!m.packages["example"].running());
|
||||
}
|
||||
#[test]
|
||||
fn failed_callback_discards_all_commands_and_stops_mod() {
|
||||
let f =
|
||||
Fixture::new("return {on_load=function() sdk.ui.text('test','before'); error('fail') end}");
|
||||
let mut m = f.manager();
|
||||
m.enable("example", true).unwrap();
|
||||
assert!(m.commands.is_empty());
|
||||
assert!(!m.packages["example"].running());
|
||||
assert!(
|
||||
m.packages["example"]
|
||||
.error
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.contains("fail")
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn instruction_and_memory_limits() {
|
||||
for code in [
|
||||
"return {on_load=function() while true do end end}",
|
||||
"return {on_load=function() local t={} for i=1,100000 do t[i]=string.rep('a',10000) end end}",
|
||||
] {
|
||||
let f = Fixture::new(code);
|
||||
let mut m = f.manager();
|
||||
m.enable("example", true).unwrap();
|
||||
assert!(m.packages["example"].error.is_some());
|
||||
assert!(!m.packages["example"].running());
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn libraries_are_restricted() {
|
||||
let f = Fixture::new(
|
||||
"assert(io==nil and os==nil and debug==nil and package==nil and require==nil and load==nil and pcall==nil and coroutine==nil); return {}",
|
||||
);
|
||||
let mut m = f.manager();
|
||||
m.enable("example", true).unwrap();
|
||||
assert!(
|
||||
m.packages["example"].running(),
|
||||
"{:?}",
|
||||
m.packages["example"].error
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn immediate_settings_persist_and_validate() {
|
||||
let f = Fixture::new(
|
||||
"return {on_settings=function(e) sdk.log(tostring(sdk.settings.count)..':'..e.key) end}",
|
||||
);
|
||||
let mut m = f.manager();
|
||||
m.enable("example", true).unwrap();
|
||||
assert!(m.setting("example", "count", json!(11)).is_err());
|
||||
m.setting("example", "count", json!(7)).unwrap();
|
||||
assert!(matches!(&m.commands.last().unwrap().1,Command::Log{text} if text=="7:count"));
|
||||
let m = f.manager();
|
||||
assert_eq!(m.packages["example"].settings["count"], json!(7));
|
||||
assert!(m.packages["example"].running());
|
||||
}
|
||||
#[test]
|
||||
fn files_changed_removed_and_invalid_manifest() {
|
||||
let f = Fixture::new("return {}");
|
||||
let mut m = f.manager();
|
||||
m.enable("example", true).unwrap();
|
||||
f.code("return {on_load=function() sdk.log('new') end}");
|
||||
m.scan(true);
|
||||
assert!(matches!(&m.commands[0].1,Command::Log{text} if text=="new"));
|
||||
std::fs::write(f.0.join("mods/example/mod.json"), "{}").unwrap();
|
||||
m.scan(true);
|
||||
assert!(m.packages.is_empty());
|
||||
assert!(!m.diagnostics.is_empty());
|
||||
}
|
||||
#[test]
|
||||
fn traversal_and_unknown_dependencies_rejected() {
|
||||
let f = Fixture::new("return {}");
|
||||
assert!(read_bounded(&f.0, "../anything", 100).is_err());
|
||||
assert!(read_bounded(&f.0, "C:/Windows/win.ini", 100).is_err());
|
||||
let mut v: serde_json::Value =
|
||||
serde_json::from_slice(&std::fs::read(f.0.join("mods/example/mod.json")).unwrap()).unwrap();
|
||||
v["dependencies"] = json!(["other"]);
|
||||
assert!(serde_json::from_value::<Manifest>(v).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn examples_load_and_run() {
|
||||
let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../sdk/examples");
|
||||
if !root.exists() {
|
||||
panic!("Examples missing");
|
||||
}
|
||||
let f = Fixture::new("return {}");
|
||||
let mut m = Manager::new(root, f.0.join("examples-settings"));
|
||||
m.snapshot = json!({"player":{"position":[0,0,0],"velocity":[0,0,0],"state":100,"on_board":true},"keys":{},"map":{"name":"test","generation":0},"tick":1,"actions":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]});
|
||||
m.scan(true);
|
||||
assert!(m.diagnostics.is_empty(), "{:?}", m.diagnostics);
|
||||
let ids: Vec<_> = m.packages.keys().cloned().collect();
|
||||
assert_eq!(ids, vec!["community.mario-kart", "community.native-trainer"]);
|
||||
for id in ids {
|
||||
m.enable(&id, true).unwrap();
|
||||
for _ in 0..5 {
|
||||
m.dispatch("on_update", json!({"dt":0.016}));
|
||||
m.dispatch("on_fixed_update", json!({"dt":0.016}));
|
||||
m.commands.clear();
|
||||
}
|
||||
assert!(m.packages[&id].running(), "{:?}", m.packages[&id].error);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timers_are_ordered_replaced_and_reset() {
|
||||
let f = Fixture::new(
|
||||
"return {on_load=function() sdk.time.after('z',0,function() sdk.log('z') end); sdk.time.after('a',0,function() sdk.log('old') end); sdk.time.after('a',0,function() sdk.log('a') end) end}",
|
||||
);
|
||||
let mut m = f.manager();
|
||||
m.enable("example", true).unwrap();
|
||||
m.dispatch("on_update", json!({"dt":0.1}));
|
||||
let logs: Vec<_> = m
|
||||
.commands
|
||||
.iter()
|
||||
.filter_map(|(_, c)| {
|
||||
if let Command::Log { text } = c {
|
||||
Some(text.as_str())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(logs, vec!["a", "z"]);
|
||||
m.commands.clear();
|
||||
m.dispatch("on_update", json!({"dt":0.1}));
|
||||
assert!(m.commands.is_empty());
|
||||
m.reload("example");
|
||||
m.dispatch("on_update", json!({"dt":0.1}));
|
||||
assert_eq!(m.commands.len(), 2);
|
||||
}
|
||||
#[test]
|
||||
fn one_mod_error_does_not_stop_other_mods() {
|
||||
let f = Fixture::new("return {on_update=function() error('oops') end}");
|
||||
let other = f.0.join("mods/other");
|
||||
std::fs::create_dir_all(&other).unwrap();
|
||||
let mut manifest: serde_json::Value =
|
||||
serde_json::from_slice(&std::fs::read(f.0.join("mods/example/mod.json")).unwrap()).unwrap();
|
||||
manifest["id"] = json!("other");
|
||||
std::fs::write(
|
||||
other.join("mod.json"),
|
||||
serde_json::to_vec(&manifest).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
other.join("main.lua"),
|
||||
"return {on_update=function() sdk.log('alive') end}",
|
||||
)
|
||||
.unwrap();
|
||||
let mut m = f.manager();
|
||||
m.enable("example", true).unwrap();
|
||||
m.enable("other", true).unwrap();
|
||||
m.dispatch("on_update", json!({"dt":0.1}));
|
||||
assert!(!m.packages["example"].running());
|
||||
assert!(m.packages["other"].running());
|
||||
assert_eq!(m.commands.len(), 1);
|
||||
}
|
||||
#[test]
|
||||
fn duplicate_ids_and_unload_failure_cleanup() {
|
||||
let f = Fixture::new("return {on_unload=function() sdk.log('discard'); error('unload') end}");
|
||||
let mut m = f.manager();
|
||||
m.enable("example", true).unwrap();
|
||||
m.enable("example", false).unwrap();
|
||||
assert!(m.commands.is_empty());
|
||||
assert!(
|
||||
m.packages["example"]
|
||||
.error
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.contains("unload")
|
||||
);
|
||||
let other = f.0.join("mods/other");
|
||||
std::fs::create_dir_all(&other).unwrap();
|
||||
std::fs::copy(f.0.join("mods/example/mod.json"), other.join("mod.json")).unwrap();
|
||||
std::fs::write(other.join("main.lua"), "return {}").unwrap();
|
||||
m.scan(true);
|
||||
assert!(m.packages.is_empty());
|
||||
assert!(m.diagnostics.iter().any(|s| s.contains("Duplicate")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_upgrade_keeps_only_compatible_keys() {
|
||||
let f = Fixture::new("return {}");
|
||||
let mut m = f.manager();
|
||||
m.setting("example", "count", json!(7)).unwrap();
|
||||
let path = f.0.join("mods/example/mod.json");
|
||||
let mut v: serde_json::Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
|
||||
v["version"] = json!("2.0.0");
|
||||
v["settings"]["count"]["max"] = json!(5);
|
||||
v["settings"]["renamed"] = v["settings"]["count"].clone();
|
||||
std::fs::write(&path, serde_json::to_vec(&v).unwrap()).unwrap();
|
||||
m.scan(true);
|
||||
assert_eq!(m.packages["example"].settings["count"], json!(3));
|
||||
assert_eq!(m.packages["example"].settings["renamed"], json!(3));
|
||||
}
|
||||
#[test]
|
||||
fn changed_source_waits_for_debounce() {
|
||||
let f = Fixture::new("return {}");
|
||||
let mut m = f.manager();
|
||||
m.enable("example", true).unwrap();
|
||||
f.code("return {on_load=function() sdk.log('changed') end}");
|
||||
std::thread::sleep(std::time::Duration::from_millis(510));
|
||||
m.scan(false);
|
||||
assert!(m.commands.is_empty());
|
||||
std::thread::sleep(std::time::Duration::from_millis(510));
|
||||
m.scan(false);
|
||||
assert!(m.commands.is_empty());
|
||||
std::thread::sleep(std::time::Duration::from_millis(510));
|
||||
m.scan(false);
|
||||
assert_eq!(m.commands.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trainer_commands_validate_and_follow_settings() {
|
||||
let f = Fixture::new(
|
||||
"return {on_load=function() sdk.trainer.apply{pop=2,push_speed=3,wobble=0} end, on_settings=function() sdk.trainer.apply{pop=sdk.settings.count} end}",
|
||||
);
|
||||
let mut m = f.manager();
|
||||
m.enable("example", true).unwrap();
|
||||
assert!(
|
||||
matches!(&m.commands[0].1,Command::Trainer{tuning} if tuning.pop==2. && tuning.push_speed==3. && tuning.wobble==0. && tuning.braking==1.)
|
||||
);
|
||||
m.setting("example", "count", json!(4)).unwrap();
|
||||
assert!(matches!(&m.commands.last().unwrap().1,Command::Trainer{tuning} if tuning.pop==4.));
|
||||
m.setting("example", "count", json!(5)).unwrap();
|
||||
assert!(!m.packages["example"].running());
|
||||
assert!(m.commands.is_empty());
|
||||
assert!(
|
||||
!TrainerTuning {
|
||||
pop: f32::NAN,
|
||||
..Default::default()
|
||||
}
|
||||
.valid()
|
||||
);
|
||||
m.enable("example", false).unwrap();
|
||||
assert!(m.retired.contains(&"example".into()));
|
||||
}
|
||||
|
||||
fn showcase_manager(f: &Fixture) -> Manager {
|
||||
let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../sdk/examples");
|
||||
let mut m = Manager::new(root, f.0.join("showcase-settings"));
|
||||
m.snapshot = json!({"player":{"position":[0,0,0],"velocity":[3,0,0],"heading":0.5,"state":100,"on_board":true,"bailing":false,"grind":{"active":false}},"keys":{},"map":{"name":"test","generation":0},"actions":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]});
|
||||
m.scan(true);
|
||||
m.enable("community.native-trainer", true).unwrap();
|
||||
m.commands.clear();
|
||||
m
|
||||
}
|
||||
#[test]
|
||||
fn showcase_checkpoint_stopwatch_and_world_cleanup() {
|
||||
let f = Fixture::new("return {}");
|
||||
let mut m = showcase_manager(&f);
|
||||
m.snapshot["keys"] = json!({"F5":true});
|
||||
m.dispatch("on_update", json!({"dt":0.1}));
|
||||
assert!(
|
||||
m.commands
|
||||
.iter()
|
||||
.any(|(_, c)| matches!(c,Command::Cube{key,..} if key=="checkpoint"))
|
||||
);
|
||||
m.commands.clear();
|
||||
m.snapshot["keys"] = json!({"F6":true,"F7":true});
|
||||
m.dispatch("on_update", json!({"dt":0.1}));
|
||||
assert!(
|
||||
m.commands
|
||||
.iter()
|
||||
.any(|(_, c)| matches!(c,Command::Teleport{heading,..} if *heading==0.5))
|
||||
);
|
||||
assert!(
|
||||
m.commands
|
||||
.iter()
|
||||
.any(|(_, c)| matches!(c,Command::Overlay{key,..} if key=="timer"))
|
||||
);
|
||||
m.commands.clear();
|
||||
m.snapshot["keys"] = json!({});
|
||||
m.dispatch("on_event", json!({"name":"world_changed"}));
|
||||
assert!(
|
||||
m.commands
|
||||
.iter()
|
||||
.any(|(_, c)| matches!(c,Command::Remove{key} if key=="checkpoint"))
|
||||
);
|
||||
m.commands.clear();
|
||||
m.snapshot["keys"] = json!({"F6":true});
|
||||
m.dispatch("on_update", json!({"dt":0.1}));
|
||||
assert!(
|
||||
!m.commands
|
||||
.iter()
|
||||
.any(|(_, c)| matches!(c, Command::Teleport { .. }))
|
||||
);
|
||||
assert!(m.packages["community.native-trainer"].running());
|
||||
}
|
||||
#[test]
|
||||
fn showcase_reuses_trail_keys_and_updates_hud_settings() {
|
||||
let f = Fixture::new("return {}");
|
||||
let mut m = showcase_manager(&f);
|
||||
let id = "community.native-trainer";
|
||||
m.setting(id, "trail", json!(true)).unwrap();
|
||||
m.commands.clear();
|
||||
let mut trail = std::collections::BTreeSet::new();
|
||||
for i in 0..100 {
|
||||
m.snapshot["player"]["position"] = json!([i * 10, 0, 0]);
|
||||
m.dispatch("on_update", json!({"dt":0.1}));
|
||||
for (_, c) in &m.commands {
|
||||
if let Command::Cube { key, .. } = c {
|
||||
trail.insert(key.clone());
|
||||
}
|
||||
}
|
||||
m.commands.clear();
|
||||
}
|
||||
assert_eq!(trail.len(), 24);
|
||||
assert!(m.packages[id].running());
|
||||
m.setting(id, "hud", json!("off")).unwrap();
|
||||
assert!(
|
||||
m.commands
|
||||
.iter()
|
||||
.any(|(_, c)| matches!(c,Command::Remove{key} if key=="hud"))
|
||||
);
|
||||
m.commands.clear();
|
||||
m.setting(id, "grip", json!(2)).unwrap();
|
||||
assert!(
|
||||
m.commands
|
||||
.iter()
|
||||
.any(|(_, c)| matches!(c,Command::Trainer{tuning} if tuning.grip==2.))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn showcase_hold_fakie_is_optional_live_and_reversible() {
|
||||
let f = Fixture::new("return {}");
|
||||
let mut m = showcase_manager(&f);
|
||||
let id = "community.native-trainer";
|
||||
assert!(!TrainerTuning::default().hold_fakie);
|
||||
assert_eq!(m.packages[id].settings["hold_fakie"], json!(false));
|
||||
for enabled in [true, false] {
|
||||
m.commands.clear();
|
||||
m.setting(id, "hold_fakie", json!(enabled)).unwrap();
|
||||
let tuning = m.commands.iter().find_map(|(_, command)| match command {
|
||||
Command::Trainer { tuning } => Some(tuning),
|
||||
_ => None,
|
||||
}).expect("setting update must emit native trainer tuning");
|
||||
assert_eq!(tuning.hold_fakie, enabled);
|
||||
assert_eq!(tuning.pop, 1.);
|
||||
assert!(tuning.valid());
|
||||
}
|
||||
m.setting(id, "hold_fakie", json!(true)).unwrap();
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kart_controller_reset_is_edge_triggered_and_rebindable() {
|
||||
let f=Fixture::new("return {}");let mut m=Manager::new(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../sdk/examples"),f.0.join("kart-settings"));
|
||||
m.snapshot=json!({"player":{"position":[0,0,0],"heading":0},"keys":{},"vehicles":{"community.mario-kart":{"kart":{"position":[2,1,3],"heading":0.5,"occupied":true,"phase":"driving","speed":0,"ready":true}}},"vehicle_input":{"throttle":0,"steering":0,"brake":0,"handbrake":false,"interact":false,"pad_buttons":0}});
|
||||
m.scan(true);m.enable("community.mario-kart",true).unwrap();m.commands.clear();
|
||||
for buttons in [128,128,128,0,128] {m.snapshot["vehicle_input"]["pad_buttons"]=json!(buttons);m.dispatch("on_fixed_update",json!({"dt":0.016}));}
|
||||
assert_eq!(m.commands.iter().filter(|(_,c)|matches!(c,Command::VehicleReset{..})).count(),2);
|
||||
m.setting("community.mario-kart","reset_button",json!("Left stick")).unwrap();m.commands.clear();
|
||||
for buttons in [0,128,0,64,64] {m.snapshot["vehicle_input"]["pad_buttons"]=json!(buttons);m.dispatch("on_fixed_update",json!({"dt":0.016}));}
|
||||
assert_eq!(m.commands.iter().filter(|(_,c)|matches!(c,Command::VehicleReset{..})).count(),1);
|
||||
assert!(m.packages["community.mario-kart"].running());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fingerprints_are_portable_and_include_assets() {
|
||||
let a=Fixture::new("return {}");let b=Fixture::new("return {}");
|
||||
std::fs::write(a.0.join("mods/example/asset.txt"),"same").unwrap();
|
||||
std::fs::write(b.0.join("mods/example/asset.txt"),"same").unwrap();
|
||||
let first=a.manager().packages["example"].content_fingerprint();
|
||||
assert_eq!(first,b.manager().packages["example"].content_fingerprint());
|
||||
std::fs::write(b.0.join("mods/example/asset.txt"),"changed").unwrap();
|
||||
assert_ne!(first,b.manager().packages["example"].content_fingerprint());
|
||||
}
|
||||
#[test]
|
||||
fn shared_state_api_is_owner_scoped_and_transactional() {
|
||||
let f=Fixture::new(r#"return {on_update=function()
|
||||
assert(sdk.net.info().active)
|
||||
assert(sdk.net.read("2","score")==7)
|
||||
sdk.net.publish("score",8)
|
||||
end}"#);
|
||||
let mut m=f.manager();m.snapshot=json!({"network":{"active":true,"states":{"example":{"2":{"score":7}}}}});
|
||||
m.enable("example",true).unwrap();m.dispatch("on_update",json!({"dt":0.1}));
|
||||
assert!(matches!(&m.commands[0].1,Command::NetworkState{key,value} if key=="score" && value==8));
|
||||
let f=Fixture::new(r#"return {on_update=function() sdk.net.publish("a",1); error("abort") end}"#);
|
||||
let mut m=f.manager();m.enable("example",true).unwrap();m.dispatch("on_update",json!({"dt":0.1}));
|
||||
assert!(m.commands.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn network_nil_clears_and_oversized_values_fail() {
|
||||
let f=Fixture::new(r#"return {on_load=function() sdk.net.publish("score",nil) end}"#);
|
||||
let mut m=f.manager();m.enable("example",true).unwrap();
|
||||
assert!(matches!(&m.commands[0].1,Command::NetworkState{value,..} if value.is_null()));
|
||||
let f=Fixture::new(r#"return {on_load=function() sdk.net.publish("score",string.rep("x",513)) end}"#);
|
||||
let mut m=f.manager();m.enable("example",true).unwrap();assert!(m.packages["example"].error.is_some());assert!(m.commands.is_empty());
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Bounded control messages between the game and the optional discovery helper.
|
||||
use serde::{Deserialize, Serialize};
|
||||
pub const NAMESPACE: &str = "skate3rust-free-skate-v4";
|
||||
pub const NAMESPACE: &str = "skate3rust-free-skate-v5";
|
||||
pub const PAGE_SIZE: usize = 5;
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct Request {
|
||||
|
||||
@@ -10,6 +10,13 @@ pub const PING: u8 = 6;
|
||||
pub const PONG: u8 = 7;
|
||||
pub const REJECT: u8 = 8;
|
||||
pub const GOODBYE: u8 = 9;
|
||||
pub const APPLICATION: u8 = 10;
|
||||
pub const APPLICATION_ACK: u8 = 11;
|
||||
pub const MAX_APP_KEYS: usize = 256;
|
||||
pub const MAX_APP_VALUE: usize = 1024;
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Application { pub seq: u32, pub value: Vec<u8>, pub received: u64 }
|
||||
|
||||
pub const DEFAULT_LINK_BUDGET: f64 = 180_000.;
|
||||
pub const DEFAULT_HOST_BUDGET: f64 = 1_000_000.;
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -64,6 +71,7 @@ pub struct Actor {
|
||||
pub info: Info,
|
||||
pub body: Stream,
|
||||
pub pose: Stream,
|
||||
pub application: BTreeMap<String, Application>,
|
||||
}
|
||||
impl Actor {
|
||||
fn new(info: Info) -> Self {
|
||||
@@ -71,6 +79,7 @@ impl Actor {
|
||||
info,
|
||||
body: Stream::default(),
|
||||
pose: Stream::default(),
|
||||
application: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
pub fn stream(&self, kind: u8) -> &Stream {
|
||||
@@ -91,6 +100,9 @@ struct Link {
|
||||
sent: BTreeMap<(u64, u8), (u64, u32)>,
|
||||
keys: BTreeMap<(u64, u8), u64>,
|
||||
credits: f64,
|
||||
app_sent: BTreeMap<(u64, String), (u64, u32)>,
|
||||
app_acks: BTreeMap<(u64,String),u32>,
|
||||
app_round: usize,
|
||||
last_ack: u64,
|
||||
last_ping: u64,
|
||||
rtt: u64,
|
||||
@@ -104,6 +116,7 @@ impl Link {
|
||||
sent: BTreeMap::new(),
|
||||
keys: BTreeMap::new(),
|
||||
credits: 2400.,
|
||||
app_sent: BTreeMap::new(), app_acks: BTreeMap::new(), app_round: 0,
|
||||
last_ack: 0,
|
||||
last_ping: 0,
|
||||
rtt: 0,
|
||||
@@ -230,6 +243,17 @@ impl Session {
|
||||
});
|
||||
stream.push(seq, state, now);
|
||||
}
|
||||
/// Latest owner-authenticated state. Empty values are persistent tombstones.
|
||||
/// Repeated full records recover loss and initialize late joiners without replaying Lua.
|
||||
pub fn publish_application(&mut self, key: &str, value: Vec<u8>, now: u64) -> bool {
|
||||
if key.is_empty() || key.len() > 128 || value.len() > MAX_APP_VALUE { return false; }
|
||||
let records = &mut self.actors.get_mut(&self.local).unwrap().application;
|
||||
if records.len() >= MAX_APP_KEYS && !records.contains_key(key) { return false; }
|
||||
if records.get(key).is_some_and(|r| r.value == value) { return true; }
|
||||
let seq = records.get(key).map_or(1, |r| r.seq.saturating_add(1));
|
||||
records.insert(key.into(), Application {seq, value, received: now});
|
||||
true
|
||||
}
|
||||
pub fn record_send(&mut self, len: usize, success: bool) {
|
||||
if success {
|
||||
self.stats.tx_bytes += len as u64;
|
||||
@@ -355,7 +379,33 @@ impl Session {
|
||||
.into();
|
||||
return;
|
||||
}
|
||||
if self.links[&peer].actor != actor && !matches!(kind, BODY | POSE) {
|
||||
if self.links[&peer].actor != actor && !matches!(kind, BODY | POSE | APPLICATION) {
|
||||
return;
|
||||
}
|
||||
if kind == APPLICATION_ACK {
|
||||
let Some(origin)=r.u64() else {return;};
|
||||
let Ok(key)=std::str::from_utf8(r.0) else {return;};
|
||||
if self.actors.get(&origin).and_then(|a|a.application.get(key)).is_some_and(|record|seq<=record.seq) {
|
||||
let link=self.links.get_mut(&peer).unwrap();
|
||||
let ack=link.app_acks.entry((origin,key.into())).or_default();*ack=(*ack).max(seq);link.seen=now;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if kind == APPLICATION {
|
||||
if actor == self.local || (self.is_host() && self.links[&peer].actor != actor) { return; }
|
||||
let Some(a) = self.actors.get_mut(&actor) else { return; };
|
||||
let Some(length) = r.byte() else { return; };
|
||||
let length = length as usize;
|
||||
if length == 0 || length > 128 || r.0.len() < length || r.0.len() - length > MAX_APP_VALUE { return; }
|
||||
let Ok(key) = std::str::from_utf8(&r.0[..length]) else { return; };
|
||||
if a.application.len() >= MAX_APP_KEYS && !a.application.contains_key(key) { return; }
|
||||
if a.application.get(key).is_none_or(|old| seq > old.seq) {
|
||||
a.application.insert(key.into(), Application { seq, value: r.0[length..].to_vec(), received: now });
|
||||
}
|
||||
let latest=a.application.get(key).map_or(seq,|r|r.seq);
|
||||
let mut ack=packed::header(self.session,self.local,APPLICATION_ACK,latest);
|
||||
ack.extend(actor.to_le_bytes());ack.extend(key.as_bytes());self.queue(peer,ack);
|
||||
self.links.get_mut(&peer).unwrap().seen = now;
|
||||
return;
|
||||
}
|
||||
if kind == GOODBYE {
|
||||
@@ -622,6 +672,31 @@ impl Session {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Rotate every record, not just actors, so a busy mod cannot starve later keys.
|
||||
let mut app_peers:Vec<_>=self.links.keys().copied().collect();
|
||||
if !app_peers.is_empty() {let offset=self.round%app_peers.len();app_peers.rotate_left(offset);}
|
||||
for peer in app_peers {
|
||||
let link=self.links.get_mut(&peer).unwrap();
|
||||
link.app_sent.retain(|(id, _), _| self.actors.contains_key(id));
|
||||
link.app_acks.retain(|(id,_),_|self.actors.contains_key(id));
|
||||
let records: Vec<_> = self.actors.iter().filter(|(id, _)| **id != link.actor && (self.host.is_none() || **id == self.local))
|
||||
.flat_map(|(&id, a)| a.application.iter().map(move |(key, record)| (id, key, record))).collect();
|
||||
let count = records.len();
|
||||
for offset in 0..count {
|
||||
let index = (link.app_round + offset) % count;
|
||||
let (id, key, record) = records[index];
|
||||
if link.app_acks.get(&(id,key.clone())).is_some_and(|&seq|seq==record.seq) {continue;}
|
||||
let previous = link.app_sent.get(&(id, key.clone()));
|
||||
if previous.is_some_and(|&(at, seq)| now.saturating_sub(at) < if seq == record.seq { 110 + ((self.round as u64 + id) * 17) % 130 } else { 50 }) { continue; }
|
||||
let mut data = packed::header(self.session, id, APPLICATION, record.seq);
|
||||
data.push(key.len() as u8); data.extend(key.as_bytes()); data.extend(&record.value);
|
||||
let bytes = data.len() as f64;
|
||||
if bytes > link.credits || bytes > self.credits { link.app_round = index; break; }
|
||||
link.credits -= bytes; self.credits -= bytes;
|
||||
link.app_sent.insert((id, key.clone()), (now, record.seq));
|
||||
output.push(Outgoing {peer, data});
|
||||
}
|
||||
}
|
||||
self.stats.rtt_ms = self.links.values().map(|l| l.rtt).max().unwrap_or(0);
|
||||
output
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
//! Version 4: one datagram per independently decodable stream update.
|
||||
//! Version 5: one datagram per independently decodable stream update.
|
||||
//! Position error <= 0.5 mm/axis within 32 m; larger offsets use full floats.
|
||||
//! Quaternion uses smallest-three 10-bit components. Velocities use binary16.
|
||||
use crate::{Body, Bone, Pose};
|
||||
use half::f16;
|
||||
pub const MTU: usize = 1200;
|
||||
pub const MAGIC: &[u8; 8] = b"SK8NET04";
|
||||
pub const MAGIC: &[u8; 8] = b"SK8NET05";
|
||||
pub const HEADER: usize = 29;
|
||||
pub const BODY: u8 = 1;
|
||||
pub const POSE: u8 = 2;
|
||||
@@ -167,7 +167,7 @@ impl Packed {
|
||||
self.root == other.root && self.enabled == other.enabled && self.rows == other.rows
|
||||
}
|
||||
pub fn body(s: &BodyState) -> Option<Self> {
|
||||
if !s.root.valid() || s.bodies.len() != 33 || (s.enabled & !(1 << 63)) >> 33 != 0 {
|
||||
if !s.root.valid() || s.bodies.len() != 33 || (s.enabled & !((1 << 63) | (1 << 62))) >> 33 != 0 {
|
||||
return None;
|
||||
}
|
||||
let mut rows = Vec::with_capacity(33);
|
||||
@@ -222,7 +222,7 @@ impl Packed {
|
||||
pub fn unpack_body(&self) -> Option<BodyState> {
|
||||
let root = unroot(self.root)?;
|
||||
let mut bodies = vec![];
|
||||
if self.rows.len() != 33 || (self.enabled & !(1 << 63)) >> 33 != 0 {
|
||||
if self.rows.len() != 33 || (self.enabled & !((1 << 63) | (1 << 62))) >> 33 != 0 {
|
||||
return None;
|
||||
}
|
||||
for row in &self.rows {
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
use skate_net::{
|
||||
lobby::{APPLICATION, Info, MAX_APP_KEYS, Session},
|
||||
packed,
|
||||
};
|
||||
fn info(id: u64) -> Info {
|
||||
Info {
|
||||
id,
|
||||
map: 1,
|
||||
rig: 2,
|
||||
physics: 3,
|
||||
appearance: 4,
|
||||
}
|
||||
}
|
||||
fn pump(host: &mut Session, guests: &mut [Session], now: u64, loss: bool) {
|
||||
for (i, g) in guests.iter_mut().enumerate() {
|
||||
for (n, p) in g.service(now).into_iter().enumerate() {
|
||||
if !loss || (now / 50 + n as u64) % 3 != 0 {
|
||||
host.receive(i as u64 + 10, &p.data, now);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (n, p) in host.service(now).into_iter().enumerate() {
|
||||
assert!(p.data.len() <= packed::MTU);
|
||||
if !loss || (now / 50 + n as u64) % 4 != 0 {
|
||||
if let Some(g) = guests.get_mut((p.peer - 10) as usize) {
|
||||
g.receive(1, &p.data, now);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn owner_state_recovers_loss_late_join_and_tombstones() {
|
||||
let mut h = Session::new(7, info(1), None);
|
||||
let mut g = vec![Session::new(7, info(2), Some(1))];
|
||||
assert!(g[0].publish_application("kart", b"spawned".to_vec(), 0));
|
||||
for t in (0..2000).step_by(50) {
|
||||
pump(&mut h, &mut g, t, true);
|
||||
}
|
||||
assert_eq!(h.actors[&2].application["kart"].value, b"spawned");
|
||||
g.push(Session::new(7, info(3), Some(1)));
|
||||
for t in (2000..4000).step_by(50) {
|
||||
pump(&mut h, &mut g, t, true);
|
||||
}
|
||||
assert_eq!(g[1].actors[&2].application["kart"].value, b"spawned");
|
||||
g[0].publish_application("kart", vec![], 4000);
|
||||
for t in (4000..6000).step_by(50) {
|
||||
pump(&mut h, &mut g, t, true);
|
||||
}
|
||||
assert!(g[1].actors[&2].application["kart"].value.is_empty());
|
||||
for p in g[0].goodbye() {
|
||||
h.receive(10, &p.data, 6000);
|
||||
}
|
||||
// Only the second guest remains; service it without reviving the first.
|
||||
for t in (6000..7000).step_by(50) {
|
||||
for p in g[1].service(t) {
|
||||
h.receive(11, &p.data, t);
|
||||
}
|
||||
for p in h.service(t) {
|
||||
if p.peer == 11 {
|
||||
g[1].receive(1, &p.data, t);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(!g[1].actors.contains_key(&2));
|
||||
}
|
||||
fn wire(actor: u64, seq: u32, key: &str, value: &[u8]) -> Vec<u8> {
|
||||
let mut b = packed::header(7, actor, APPLICATION, seq);
|
||||
b.push(key.len() as u8);
|
||||
b.extend(key.as_bytes());
|
||||
b.extend(value);
|
||||
b
|
||||
}
|
||||
#[test]
|
||||
fn spoofed_owners_old_updates_and_oversized_records_are_rejected() {
|
||||
let mut h = Session::new(7, info(1), None);
|
||||
let mut g = vec![
|
||||
Session::new(7, info(2), Some(1)),
|
||||
Session::new(7, info(3), Some(1)),
|
||||
];
|
||||
for t in (0..1000).step_by(50) {
|
||||
pump(&mut h, &mut g, t, false);
|
||||
}
|
||||
h.receive(10, &wire(3, 10, "state", b"forged"), 1000);
|
||||
assert!(h.actors[&3].application.is_empty());
|
||||
h.receive(10, &wire(2, 5, "state", b"new"), 1000);
|
||||
h.receive(10, &wire(2, 4, "state", b"old"), 1001);
|
||||
assert_eq!(h.actors[&2].application["state"].value, b"new");
|
||||
h.receive(10, &wire(2, 6, "state", &vec![0; 1025]), 1002);
|
||||
assert_eq!(h.actors[&2].application["state"].value, b"new");
|
||||
assert!(!h.publish_application("big", vec![0; 1025], 0));
|
||||
for i in 0..MAX_APP_KEYS {
|
||||
assert!(h.publish_application(&format!("k{i}"), vec![1], 0));
|
||||
}
|
||||
assert!(!h.publish_application("overflow", vec![], 0));
|
||||
assert!(h.publish_application("k0", vec![], 0));
|
||||
}
|
||||
#[test]
|
||||
fn many_mod_records_eventually_reach_every_player() {
|
||||
let mut h = Session::new(7, info(1), None);
|
||||
let mut g: Vec<_> = (2..=10)
|
||||
.map(|id| Session::new(7, info(id), Some(1)))
|
||||
.collect();
|
||||
for (i, g) in g.iter_mut().enumerate() {
|
||||
for k in 0..24 {
|
||||
assert!(g.publish_application(&format!("object{k}"), vec![i as u8; 900], 0));
|
||||
}
|
||||
}
|
||||
for t in (0..30000).step_by(50) {
|
||||
pump(&mut h, &mut g, t, true);
|
||||
}
|
||||
for guest in &g {
|
||||
assert_eq!(guest.actors.len(), 10);
|
||||
for (&id, a) in &guest.actors {
|
||||
if id != 1 {
|
||||
assert_eq!(a.application.len(), 24, "peer {} actor {id}", guest.local);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -254,3 +254,13 @@ fn repeated_host_migration_preserves_local_state_and_rebuilds_ten_player_lobby()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn occupied_driver_flag_preserves_root_without_native_collision_parts() {
|
||||
let mut frame=body();frame.enabled=1u64<<62;frame.root=pose([10.,4.,20.],0.4);
|
||||
let packed=Packed::body(&frame).unwrap();
|
||||
let bytes=packed::delta(1,2,packed::BODY,1,&packed,None);
|
||||
let decoded=packed::apply(&bytes,None).unwrap().unpack_body().unwrap();
|
||||
assert_eq!(decoded.enabled,1u64<<62);assert_eq!(decoded.root.p,frame.root.p);
|
||||
frame.enabled=1u64<<61;assert!(Packed::body(&frame).is_none());
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,262 @@
|
||||
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 center_of_mass: [f32; 3],
|
||||
pub inertia_half_extents: Option<[f32; 3]>,
|
||||
pub collider_offset: [f32; 3],
|
||||
pub collider_rounding: f32,
|
||||
pub chassis_friction: f32,
|
||||
pub engine_audio: EngineAudio,
|
||||
pub rider_safety: RiderSafety,
|
||||
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<WheelDefinition>,
|
||||
pub seat: [f32; 3],
|
||||
pub exit: [f32; 3],
|
||||
pub camera_distance: f32,
|
||||
pub camera_height: f32,
|
||||
pub animations: Animations,
|
||||
}
|
||||
/// Occupied-seat collision and automatic ejection. Coordinates are chassis-local.
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct RiderSafety {
|
||||
pub enabled: bool,
|
||||
pub offset: [f32; 3],
|
||||
pub radius: f32,
|
||||
pub half_height: f32,
|
||||
pub crash_delta_v: f32,
|
||||
pub hit_impulse: f32,
|
||||
pub inverted_up_y: f32,
|
||||
pub inverted_seconds: f32,
|
||||
pub eject_up_speed: f32,
|
||||
}
|
||||
impl Default for RiderSafety {
|
||||
fn default() -> Self { Self { enabled: true, offset: [0.,0.5,0.], radius: 0.25,
|
||||
half_height: 0.25, crash_delta_v: 6., hit_impulse: 180., inverted_up_y: -0.2,
|
||||
inverted_seconds: 0.2, eject_up_speed: 2. } }
|
||||
}
|
||||
/// Built-in synthesized engine; no external recording is required.
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct EngineAudio {
|
||||
pub enabled: bool,
|
||||
pub volume: f32,
|
||||
pub idle_pitch: f32,
|
||||
pub max_pitch: f32,
|
||||
}
|
||||
impl Default for EngineAudio {
|
||||
fn default() -> Self { Self { enabled: false, volume: 0.45, idle_pitch: 0.7, max_pitch: 2.8 } }
|
||||
}
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WheelDefinition {
|
||||
pub position: [f32; 3],
|
||||
pub radius: f32,
|
||||
pub steering: bool,
|
||||
pub driven: bool,
|
||||
#[serde(default)]
|
||||
pub node: Option<String>,
|
||||
}
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct Animations {
|
||||
pub file: Option<String>,
|
||||
pub enter: Option<String>,
|
||||
pub exit: Option<String>,
|
||||
pub drive: Option<String>,
|
||||
pub idle: Option<String>,
|
||||
pub reverse: Option<String>,
|
||||
pub brake: Option<String>,
|
||||
pub steer_left: Option<String>,
|
||||
pub steer_right: Option<String>,
|
||||
}
|
||||
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],
|
||||
center_of_mass: [0.; 3],
|
||||
inertia_half_extents: None,
|
||||
collider_offset: [0.; 3],
|
||||
collider_rounding: 0.,
|
||||
chassis_friction: 0.3,
|
||||
engine_audio: EngineAudio::default(),
|
||||
rider_safety: RiderSafety::default(),
|
||||
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.))
|
||||
|| !point(&self.center_of_mass, 10.)
|
||||
|| self.inertia_half_extents.is_some_and(|v| !v.iter().all(|&x| range(x, 0.05, 10.)))
|
||||
|| !point(&self.collider_offset, 10.)
|
||||
|| !range(self.collider_rounding, 0., self.half_extents.iter().copied().fold(f32::INFINITY, f32::min) * 0.95)
|
||||
|| !range(self.chassis_friction, 0., 2.)
|
||||
|| !range(self.engine_audio.volume, 0., 1.)
|
||||
|| !range(self.engine_audio.idle_pitch, 0.25, 2.)
|
||||
|| !range(self.engine_audio.max_pitch, self.engine_audio.idle_pitch, 5.)
|
||||
|| !point(&self.rider_safety.offset, 5.)
|
||||
|| !range(self.rider_safety.radius, 0.1, 1.)
|
||||
|| !range(self.rider_safety.half_height, 0.05, 1.)
|
||||
|| !range(self.rider_safety.crash_delta_v, 1., 50.)
|
||||
|| !range(self.rider_safety.hit_impulse, 10., 10000.)
|
||||
|| !range(self.rider_safety.inverted_up_y, -1., 0.5)
|
||||
|| !range(self.rider_safety.inverted_seconds, 0.05, 3.)
|
||||
|| !range(self.rider_safety.eject_up_speed, 0., 10.)
|
||||
|| !range(self.mass, 10., 10000.)
|
||||
|| !range(self.engine_force, 0., 100000.)
|
||||
|| !range(self.brake_impulse, 0., 10000.)
|
||||
|| !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_volume: Option<f32>,
|
||||
pub engine_force: Option<f32>,
|
||||
pub max_speed: Option<f32>,
|
||||
pub brake_impulse: Option<f32>,
|
||||
pub steering_angle: Option<f32>,
|
||||
pub tire_grip: Option<f32>,
|
||||
}
|
||||
impl VehicleTuning {
|
||||
pub fn apply(&self, definition: &VehicleDefinition) -> Result<VehicleDefinition, String> {
|
||||
let mut d = definition.clone();
|
||||
if let Some(v) = self.engine_volume { d.engine_audio.volume = v; }
|
||||
if let Some(v) = self.engine_force {
|
||||
d.engine_force = v;
|
||||
}
|
||||
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_volume.map(|x| (x, 0., 1.)),
|
||||
self.engine_force.map(|x| (x, 0., 100000.)),
|
||||
self.max_speed.map(|x| (x, 1., 100.)),
|
||||
self.brake_impulse.map(|x| (x, 0., 10000.)),
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
//! Bevy-independent, fixed-step Rapier vehicle simulation and validated mod definitions.
|
||||
mod definition;
|
||||
mod safety;
|
||||
pub use safety::Ejection;
|
||||
pub use definition::*;
|
||||
pub use rapier3d;
|
||||
use rapier3d::{
|
||||
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,
|
||||
rider: ColliderHandle,
|
||||
occupied: bool,
|
||||
pub remote: bool,
|
||||
inverted_time: f32,
|
||||
pub ejection: Option<Ejection>,
|
||||
}
|
||||
pub struct Simulation {
|
||||
pub world: PhysicsWorld,
|
||||
pub vehicles: BTreeMap<u64, Vehicle>,
|
||||
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<Item = [[f32; 3]; 3]>) -> 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<u64, String> {
|
||||
definition.validate()?;
|
||||
if !point(&position, 100000.) || !heading.is_finite() {
|
||||
return Err("Invalid vehicle spawn pose".into());
|
||||
}
|
||||
let d = &definition;
|
||||
let r = d.collider_rounding;
|
||||
let shape = if r > 0. {
|
||||
ColliderBuilder::round_cuboid(d.half_extents[0]-r, d.half_extents[1]-r, d.half_extents[2]-r, r)
|
||||
} else {
|
||||
ColliderBuilder::cuboid(d.half_extents[0], d.half_extents[1], d.half_extents[2])
|
||||
};
|
||||
let [x, y, z] = d.inertia_half_extents.unwrap_or(d.half_extents);
|
||||
let inertia = Vector::new(y*y+z*z, x*x+z*z, x*x+y*y) * (d.mass / 3.);
|
||||
let mass_properties = MassProperties::new(Vector::from_array(d.center_of_mass) - Vector::from_array(d.collider_offset), d.mass, inertia);
|
||||
let (body, _) = self.world.insert(
|
||||
RigidBodyBuilder::dynamic()
|
||||
.translation(Vector::from_array(position))
|
||||
.rotation(Vector::Y * heading)
|
||||
.ccd_enabled(true)
|
||||
.linear_damping(0.08)
|
||||
.angular_damping(0.5),
|
||||
shape.translation(Vector::from_array(d.collider_offset))
|
||||
.mass_properties(mass_properties)
|
||||
.friction(d.chassis_friction),
|
||||
);
|
||||
let mut controller = DynamicRayCastVehicleController::new(body);
|
||||
controller.index_up_axis = 1;
|
||||
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 safety = &d.rider_safety;
|
||||
let rider = self.world.colliders.insert_with_parent(
|
||||
ColliderBuilder::capsule_y(safety.half_height, safety.radius)
|
||||
.translation(Vector::from_array(d.seat) + Vector::from_array(safety.offset))
|
||||
.density(0.).friction(0.2).enabled(false).build(), body, &mut self.world.bodies);
|
||||
let id = self.next;
|
||||
self.next += 1;
|
||||
self.vehicles.insert(
|
||||
id,
|
||||
Vehicle {
|
||||
definition,
|
||||
body,
|
||||
controller,
|
||||
controls: Controls::default(),
|
||||
rider, remote: false, occupied: false, inverted_time: 0., ejection: None,
|
||||
},
|
||||
);
|
||||
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 {
|
||||
let before = self.capture_riders();
|
||||
self.world.integration_parameters.dt = h;
|
||||
for v in self.vehicles.values_mut() {
|
||||
if v.remote {continue;}
|
||||
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 {
|
||||
// With +Z forward and +Y up, the driver's left is +X.
|
||||
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();
|
||||
self.check_riders(&before, h);
|
||||
}
|
||||
}
|
||||
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();
|
||||
v.ejection = None; v.inverted_time = 0.;
|
||||
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())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
use super::*;
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Ejection {
|
||||
pub position: [f32; 3],
|
||||
pub velocity: [f32; 3],
|
||||
pub angular_velocity: [f32; 3],
|
||||
pub reason: &'static str,
|
||||
}
|
||||
|
||||
impl Simulation {
|
||||
pub fn set_occupied(&mut self, id: u64, occupied: bool) {
|
||||
if let Some(v) = self.vehicles.get_mut(&id) {
|
||||
if v.occupied != occupied {
|
||||
v.occupied = occupied; v.inverted_time = 0.; v.ejection = None;
|
||||
self.world.colliders[v.rider].set_enabled(occupied && v.definition.rider_safety.enabled);
|
||||
self.world.bodies[v.body].wake_up(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn take_ejection(&mut self, id: u64) -> Option<Ejection> {
|
||||
self.vehicles.get_mut(&id)?.ejection.take()
|
||||
}
|
||||
pub(super) fn capture_riders(&self) -> BTreeMap<u64, (Vector, Vector)> {
|
||||
self.vehicles.iter().filter(|(_, v)| !v.remote && v.occupied && v.definition.rider_safety.enabled)
|
||||
.map(|(&id, v)| {
|
||||
let body=&self.world.bodies[v.body];
|
||||
let point=body.position().transform_point(Vector::from_array(v.definition.seat));
|
||||
(id, (body.linvel(), body.velocity_at_point(point)))
|
||||
}).collect()
|
||||
}
|
||||
pub(super) fn check_riders(&mut self, before: &BTreeMap<u64,(Vector,Vector)>, dt: f32) {
|
||||
for (&id, &(linear, point_velocity)) in before {
|
||||
let v=self.vehicles.get_mut(&id).unwrap();
|
||||
if v.ejection.is_some() { continue; }
|
||||
let body=&self.world.bodies[v.body];
|
||||
let safety=&v.definition.rider_safety;
|
||||
let up=body.rotation()*Vector::Y;
|
||||
v.inverted_time=if up.y < safety.inverted_up_y {v.inverted_time+dt} else {0.};
|
||||
let hit=self.world.narrow_phase.contact_pairs_with(v.rider)
|
||||
.map(|p|p.total_impulse_magnitude()).fold(0_f32,f32::max);
|
||||
let contact=body.colliders().iter().any(|&c| self.world.narrow_phase.contact_pairs_with(c)
|
||||
.any(|p|p.total_impulse_magnitude()>0.));
|
||||
let reason=if hit >= safety.hit_impulse {Some("rider_impact")}
|
||||
else if contact && (body.linvel()-linear).length() >= safety.crash_delta_v {Some("crash")}
|
||||
else if v.inverted_time >= safety.inverted_seconds {Some("inverted")}
|
||||
else {None};
|
||||
if let Some(reason)=reason {
|
||||
let seat=body.position().transform_point(Vector::from_array(v.definition.seat));
|
||||
let after=body.velocity_at_point(seat);
|
||||
let velocity=if after.length_squared()>point_velocity.length_squared() {after} else {point_velocity};
|
||||
let velocity=(velocity + Vector::Y*safety.eject_up_speed).clamp_length_max(60.);
|
||||
v.ejection=Some(Ejection { position: seat.to_array(), velocity: velocity.to_array(),
|
||||
angular_velocity: body.angvel().clamp_length_max(15.).to_array(), reason });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
use skate_vehicles::*;
|
||||
fn definition() -> VehicleDefinition {
|
||||
serde_json::from_str(include_str!("../../../sdk/examples/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, "brake speed {} initial {speed}, pose {:?}", s.vehicles[&id].controller.current_vehicle_speed, s.pose(id));
|
||||
s.reset(id, [5., 2., 5.], 1.).unwrap();
|
||||
assert_eq!(s.pose(id).unwrap().0, [5., 2., 5.]);
|
||||
s.remove(id);
|
||||
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.file = None;
|
||||
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 steering_turns_toward_the_drivers_requested_side() {
|
||||
for heading in [0., std::f32::consts::FRAC_PI_2] {
|
||||
for steering in [-0.5, 0.5] {
|
||||
let mut s = simulation();
|
||||
let id = s.spawn(definition(), [0., 1., 0.], heading).unwrap();
|
||||
for _ in 0..120 {
|
||||
s.step(1. / 120.);
|
||||
}
|
||||
s.vehicles.get_mut(&id).unwrap().controls = Controls {
|
||||
throttle: 1.,
|
||||
steering,
|
||||
..Default::default()
|
||||
};
|
||||
for _ in 0..180 {
|
||||
s.step(1. / 120.);
|
||||
}
|
||||
// Driver/camera left is up cross forward: +X at heading zero (+Z forward).
|
||||
let p = s.pose(id).unwrap().0;
|
||||
let left_displacement = p[0] * heading.cos() - p[2] * heading.sin();
|
||||
assert!(
|
||||
left_displacement * steering > 0.1,
|
||||
"heading={heading}, steering={steering}, left displacement={left_displacement}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn kart_climbs_ramps_without_nose_catching() {
|
||||
for degrees in [20_f32, 30., 40.] {
|
||||
let slope = degrees.to_radians().tan();
|
||||
let mut s = Simulation::default();
|
||||
let y = 20. * slope;
|
||||
s.ground([
|
||||
[[-20.,0.,-30.],[20.,0.,3.],[20.,0.,-30.]],
|
||||
[[-20.,0.,-30.],[-20.,0.,3.],[20.,0.,3.]],
|
||||
[[-20.,0.,3.],[20.,y,23.],[20.,0.,3.]],
|
||||
[[-20.,0.,3.],[-20.,y,23.],[20.,y,23.]],
|
||||
].into_iter()).unwrap();
|
||||
let id = s.spawn(definition(), [0.,1.,-2.], 0.).unwrap();
|
||||
for _ in 0..240 { s.step(1./120.); }
|
||||
s.vehicles.get_mut(&id).unwrap().controls = Controls { throttle: 1., ..Default::default() };
|
||||
let mut highest = 0_f32;
|
||||
let mut forward = 0_f32;
|
||||
for _ in 0..960 {
|
||||
s.step(1./120.);
|
||||
let p=s.pose(id).unwrap().0;
|
||||
highest=highest.max(p[1]); forward=forward.max(p[2]);
|
||||
}
|
||||
println!("Ramp {degrees}: height={highest}, forward={forward}");
|
||||
assert!(highest > 2. && forward > 7., "failed {degrees} degree ramp: {highest}, {forward}");
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn collision_mass_and_audio_tuning_are_validated() {
|
||||
let base=definition();
|
||||
let mut d=base.clone(); d.collider_rounding=0.5; assert!(d.validate().is_err());
|
||||
let mut d=base.clone(); d.center_of_mass[1]=f32::NAN; assert!(d.validate().is_err());
|
||||
let mut d=base.clone(); d.inertia_half_extents=Some([0.,1.,1.]); assert!(d.validate().is_err());
|
||||
let mut d=base.clone(); d.engine_audio.max_pitch=0.1; assert!(d.validate().is_err());
|
||||
let mute=VehicleTuning { engine_volume:Some(0.), ..Default::default() };
|
||||
assert!(mute.valid()); assert_eq!(mute.apply(&base).unwrap().engine_audio.volume,0.);
|
||||
let invalid=VehicleTuning { engine_volume:Some(f32::NAN), ..Default::default() };
|
||||
assert!(!invalid.valid()); assert!(invalid.apply(&base).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn occupied_crash_ejects_with_preimpact_momentum() {
|
||||
use rapier3d::prelude::*;
|
||||
let mut s=simulation();
|
||||
s.world.insert(RigidBodyBuilder::fixed().translation(Vector::new(0.,1.,3.)),ColliderBuilder::cuboid(5.,2.,0.2));
|
||||
let id=s.spawn(definition(),[0.,0.6,0.],0.).unwrap();
|
||||
s.set_occupied(id,true);
|
||||
let handle=s.vehicles[&id].body;
|
||||
s.world.bodies[handle].set_linvel(Vector::new(0.,0.,20.),true);
|
||||
let mut crash=None;
|
||||
for _ in 0..120 {s.step(1./120.);if let Some(e)=s.take_ejection(id){crash=Some(e);break;}}
|
||||
let crash=crash.expect("wall impact should eject");
|
||||
assert!(matches!(crash.reason,"crash"|"rider_impact"));
|
||||
assert!(crash.velocity[2]>15.,"momentum was lost: {:?}",crash.velocity);
|
||||
s.set_occupied(id,false);
|
||||
for _ in 0..60 {s.step(1./120.);}
|
||||
assert!(s.take_ejection(id).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inverted_rider_bails_but_parked_vehicle_does_not() {
|
||||
use rapier3d::prelude::*;
|
||||
for occupied in [false,true] {
|
||||
let mut s=simulation();let id=s.spawn(definition(),[0.,4.,0.],0.).unwrap();
|
||||
s.set_occupied(id,occupied);
|
||||
let h=s.vehicles[&id].body;
|
||||
s.world.bodies[h].set_rotation(Rotation::from_rotation_z(std::f32::consts::PI),true);
|
||||
s.world.bodies[h].set_linvel(Vector::new(5.,0.,0.),true);
|
||||
for _ in 0..30 {s.step(1./120.);}
|
||||
let e=s.take_ejection(id);
|
||||
if occupied {let e=e.unwrap();assert_eq!(e.reason,"inverted");assert!(e.velocity[0]>4.);}
|
||||
else {assert!(e.is_none());}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rider_hitbox_catches_an_overhang_above_the_chassis() {
|
||||
use rapier3d::prelude::*;
|
||||
let mut s=simulation();
|
||||
s.world.insert(RigidBodyBuilder::fixed().translation(Vector::new(0.,1.55,3.)),ColliderBuilder::cuboid(5.,0.15,0.2));
|
||||
let id=s.spawn(definition(),[0.,0.6,0.],0.).unwrap();s.set_occupied(id,true);
|
||||
let h=s.vehicles[&id].body;s.world.bodies[h].set_linvel(Vector::new(0.,0.,18.),true);
|
||||
let mut crash=None;
|
||||
for _ in 0..120 {s.step(1./120.);if let Some(e)=s.take_ejection(id){crash=Some(e);break;}}
|
||||
assert_eq!(crash.expect("head/torso collision must eject").reason,"rider_impact");
|
||||
}
|
||||
#[test]
|
||||
fn occupied_ramp_driving_does_not_false_bail() {
|
||||
let mut s=Simulation::default();let slope=30_f32.to_radians().tan();
|
||||
s.ground([
|
||||
[[-20.,0.,-20.],[20.,0.,3.],[20.,0.,-20.]],
|
||||
[[-20.,0.,-20.],[-20.,0.,3.],[20.,0.,3.]],
|
||||
[[-20.,0.,3.],[20.,20.*slope,23.],[20.,0.,3.]],
|
||||
[[-20.,0.,3.],[-20.,20.*slope,23.],[20.,20.*slope,23.]],
|
||||
].into_iter()).unwrap();
|
||||
let id=s.spawn(definition(),[0.,0.6,-2.],0.).unwrap();s.set_occupied(id,true);
|
||||
s.vehicles.get_mut(&id).unwrap().controls=Controls{throttle:1.,..Default::default()};
|
||||
let mut climbed=false;
|
||||
for _ in 0..960 {
|
||||
s.step(1./120.);assert!(s.take_ejection(id).is_none(),"normal ramp triggered ejection");
|
||||
if s.pose(id).unwrap().0[2]>10. {climbed=true;break;}
|
||||
}
|
||||
assert!(climbed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_car_collides_with_remote_chassis_and_can_eject() {
|
||||
use skate_vehicles::rapier3d::prelude::*;
|
||||
let mut s=simulation();let local=s.spawn(definition(),[0.,0.7,-4.],0.).unwrap();
|
||||
let remote=s.spawn(definition(),[0.,0.7,0.],0.).unwrap();
|
||||
for _ in 0..120 {s.step(1./120.);}
|
||||
let remote_body=s.vehicles[&remote].body;
|
||||
s.vehicles.get_mut(&remote).unwrap().remote=true;
|
||||
s.world.bodies[remote_body].set_body_type(RigidBodyType::KinematicVelocityBased,true);
|
||||
s.world.bodies[remote_body].set_linvel(Vector::ZERO,true);
|
||||
s.world.bodies[remote_body].set_angvel(Vector::ZERO,true);
|
||||
let before=s.pose(remote).unwrap().0;
|
||||
s.set_occupied(local,true);
|
||||
let body=s.vehicles[&local].body;
|
||||
s.world.bodies[body].set_linvel(Vector::Z*15.,true);
|
||||
let mut contact=false;let mut ejected=false;
|
||||
for _ in 0..120 {
|
||||
s.step(1./120.);
|
||||
contact |= s.world.narrow_phase.contact_pairs().any(|p|p.total_impulse_magnitude()>0.);
|
||||
ejected |= s.take_ejection(local).is_some();
|
||||
}
|
||||
assert!(contact);assert!(ejected,"crash should produce a native bail request");
|
||||
assert!(s.pose(local).unwrap().0[2]<before[2]+1.,"car passed through remote chassis");
|
||||
assert_eq!(s.pose(remote).unwrap().0,before,"remote simulation must not change its owner's pose");
|
||||
}
|
||||
Reference in new issue
Block a user