From 3e22fba2d5c032b9c39e3982f8c60b3f322f6bef Mon Sep 17 00:00:00 2001 From: Jamie Wells Date: Mon, 2 Nov 2026 03:30:10 +0000 Subject: [PATCH] Add friction, oriented slope physics and collision scripting APIs --- Engine/apps/editor/src/agent_tools.rs | 2 +- Engine/apps/editor/src/main.rs | 10 +- Engine/apps/luau-host/src/main.rs | 3 + Engine/crates/engine-play/src/physics.rs | 449 ++++++++++++++++-- .../crates/engine-play/src/script_runtime.rs | 212 ++++++++- .../engine-scripting/src/agent_authoring.md | 6 +- Engine/crates/engine-scripting/src/cpp_sdk.rs | 6 + .../engine-scripting/src/external_runtime.rs | 1 + .../crates/engine-scripting/src/javascript.rs | 1 + Engine/crates/engine-scripting/src/lua.rs | 6 +- .../crates/engine-scripting/src/sdk/Rustic.cs | 4 + .../engine-scripting/src/sdk/Rustic.java | 4 + .../engine-scripting/src/sdk/gameplay.js | 4 + .../engine-scripting/src/sdk/gameplay.lua | 4 + .../crates/engine-scripting/src/sdk/rustic.h | 11 +- .../engine-scripting/src/sdk/rustic.php | 4 + .../crates/engine-scripting/src/sdk/rustic.py | 8 + Engine/crates/engine-world/src/components.rs | 50 ++ Engine/crates/engine-world/src/scene.rs | 5 + Engine/crates/engine-world/src/world.rs | 8 + Engine/docs/GAMEPLAY_PROGRAMMING.md | 10 +- Engine/docs/PHYSICS.md | 57 ++- Engine/docs/ROADMAP.md | 3 +- Engine/docs/Scripting/API/physics-actions.md | 4 +- .../Scripting/API/physics-actions/contacts.md | 100 ++++ Engine/docs/Scripting/apiLanguages.md | 4 +- Engine/docs/Scripting/gameplayActions.md | 2 +- Engine/docs/Scripting/sceneObjects.md | 6 +- Engine/docs/Scripting/scriptingC.md | 2 +- Engine/docs/Scripting/scriptingCSharp.md | 2 +- Engine/docs/Scripting/scriptingCpp.md | 2 +- Engine/docs/Scripting/scriptingJava.md | 2 +- Engine/docs/Scripting/scriptingJavaScript.md | 2 +- Engine/docs/Scripting/scriptingLua.md | 2 +- Engine/docs/Scripting/scriptingLuau.md | 2 +- Engine/docs/Scripting/scriptingPHP.md | 2 +- Engine/docs/Scripting/scriptingPython.md | 2 +- 37 files changed, 920 insertions(+), 82 deletions(-) create mode 100644 Engine/docs/Scripting/API/physics-actions/contacts.md diff --git a/Engine/apps/editor/src/agent_tools.rs b/Engine/apps/editor/src/agent_tools.rs index 7e47a92c..435585bf 100644 --- a/Engine/apps/editor/src/agent_tools.rs +++ b/Engine/apps/editor/src/agent_tools.rs @@ -188,7 +188,7 @@ impl EditorApp { { if !matches!( k.as_str(), - "color" | "anchored" | "can_touch" | "can_collide" + "color" | "anchored" | "can_touch" | "can_collide" | "friction" ) { return Err(format!("unknown part attribute: {k}")); } diff --git a/Engine/apps/editor/src/main.rs b/Engine/apps/editor/src/main.rs index dccfe8ee..e261eae4 100644 --- a/Engine/apps/editor/src/main.rs +++ b/Engine/apps/editor/src/main.rs @@ -2839,7 +2839,7 @@ impl EditorViewer<'_> { attributes.color = color; attributes_changed |= ui .checkbox(&mut attributes.can_touch, "Can touch") - .on_hover_text("Reserved for touch events. Does not affect solid collisions.") + .on_hover_text("Enables clicks, taps and collision event subscriptions. Does not affect solid response.") .changed(); attributes_changed |= ui .checkbox(&mut attributes.can_collide, "Can collide") @@ -2851,6 +2851,14 @@ impl EditorViewer<'_> { "Prevents gravity and collision response from moving this primitive in Play.", ) .changed(); + attributes_changed |= ui + .add( + egui::DragValue::new(&mut attributes.friction) + .range(0.0..=10.0) + .speed(0.01) + .prefix("Friction: "), + ) + .changed(); ui.label(format!( "Parent: {}", snapshot diff --git a/Engine/apps/luau-host/src/main.rs b/Engine/apps/luau-host/src/main.rs index 0a7793cf..23695b6e 100644 --- a/Engine/apps/luau-host/src/main.rs +++ b/Engine/apps/luau-host/src/main.rs @@ -72,6 +72,9 @@ fn run() -> Result<(), Box> { if let Some(error) = response.get("error").and_then(serde_json::Value::as_str) { return Err(mlua::Error::external(error.to_owned())); } + if response["result"].is_null() { + return Ok(Value::Nil); + } lua.to_value_with( &response["result"], mlua::SerializeOptions::new().serialize_none_to_null(false), diff --git a/Engine/crates/engine-play/src/physics.rs b/Engine/crates/engine-play/src/physics.rs index 83fac098..ec7d933a 100644 --- a/Engine/crates/engine-play/src/physics.rs +++ b/Engine/crates/engine-play/src/physics.rs @@ -9,6 +9,16 @@ const SKIN: f32 = 0.025; #[derive(Default)] pub(crate) struct PhysicsWorld { velocities: BTreeMap, + contacts: BTreeMap<(EntityId, EntityId), Vec3>, + pub events: Vec, + notified: BTreeMap<(EntityId, EntityId), Vec3>, +} + +pub(crate) struct ContactEvent { + pub entity: EntityId, + pub other: EntityId, + pub name: &'static str, + pub normal: Vec3, } struct Body { @@ -18,6 +28,8 @@ struct Body { center: Vec3, half: Vec3, velocity: Vec3, + axes: [Vec3; 3], + friction: f32, dynamic: bool, solid: bool, } @@ -54,6 +66,86 @@ impl PhysicsWorld { } Ok(result) } + pub fn cast( + world: &mut SceneWorld, + origin: [f64; 3], + direction: [f64; 3], + distance: f64, + radius: f64, + ignore: &[EntityId], + ) -> Result, String> { + use engine_core::gameplay::physics::{Collider, cast}; + // Validate even an empty scene. + cast(&[], origin, direction, distance, radius, ignore)?; + world.propagate_transforms(); + let mut best: Option = None; + for id in world.entity_ids() { + if ignore.contains(&id) + || !world + .part_attributes(id) + .map_err(|e| e.to_string())? + .can_collide + { + continue; + } + let Some(primitive) = world.primitive(id).map_err(|e| e.to_string())? else { + continue; + }; + let (min, max) = bounds(primitive); + let (center, half, axes) = shape( + world.world_transform(id).map_err(|e| e.to_string())?.0, + min, + max, + ); + let basis = + glam::DMat3::from_cols(axes[0].as_dvec3(), axes[1].as_dvec3(), axes[2].as_dvec3()); + let local_origin = + basis.transpose() * (glam::DVec3::from_array(origin) - center.as_dvec3()); + let local_direction = basis.transpose() * glam::DVec3::from_array(direction); + let collider = Collider { + entity: id, + center: [0.0; 3], + half: half.as_dvec3().to_array(), + }; + if let Some(mut hit) = cast( + &[collider], + local_origin.to_array(), + local_direction.to_array(), + distance, + radius, + &[], + )? && best.as_ref().is_none_or(|old| hit.distance < old.distance) + { + hit.point = + (basis * glam::DVec3::from_array(hit.point) + center.as_dvec3()).to_array(); + hit.normal = (basis * glam::DVec3::from_array(hit.normal)).to_array(); + best = Some(hit); + } + } + Ok(best) + } + pub fn ground(&self, id: EntityId, max_angle: f64) -> Result { + if !max_angle.is_finite() || !(0.0..=90.0).contains(&max_angle) { + return Err("maximum slope must be between 0 and 90 degrees".into()); + } + let mut support: Option<(EntityId, Vec3)> = None; + for (&(a, b), &normal) in &self.contacts { + let (other, n) = if a == id { + (b, normal) + } else if b == id { + (a, -normal) + } else { + continue; + }; + if n.y > 0.0 && support.is_none_or(|(_, old)| n.y > old.y) { + support = Some((other, n)); + } + } + Ok(support.map_or(serde_json::Value::Null, |(entity, normal)| { + let angle = f64::from(normal.y.clamp(-1.0, 1.0).acos().to_degrees()); + serde_json::json!({"entity":entity,"normal":normal.to_array(),"angle":angle,"walkable":angle <= max_angle}) + })) + } pub fn impulse( &mut self, world: &SceneWorld, @@ -88,7 +180,10 @@ impl PhysicsWorld { id: EntityId, velocity: [f64; 3], ) -> Result<(), String> { - self.impulse(world, id, velocity)?; + if !glam::DVec3::from_array(velocity).as_vec3().is_finite() { + return Err("velocity must be finite and representable".into()); + } + self.impulse(world, id, [0.0; 3])?; self.velocities .insert(id, glam::DVec3::from_array(velocity).as_vec3()); Ok(()) @@ -109,18 +204,16 @@ impl PhysicsWorld { }; let (min, max) = bounds(primitive); let matrix = world.world_transform(id)?.0; - let center = matrix.transform_point3((min + max) * 0.5); - let half = (max - min) * 0.5; - let half = matrix.x_axis.truncate().abs() * half.x - + matrix.y_axis.truncate().abs() * half.y - + matrix.z_axis.truncate().abs() * half.z; + let (center, half, axes) = shape(matrix, min, max); let attributes = world.part_attributes(id)?; bodies.push(Body { id, start: center, original: center, center, - half: half.max(Vec3::splat(SKIN)), + half, + axes, + friction: attributes.friction, velocity: if attributes.anchored { Vec3::ZERO } else { @@ -136,6 +229,7 @@ impl PhysicsWorld { reason = "finite positive delta is clamped to 0.1 before converting to simulation precision" )] let dt = delta.min(0.1) as f32 / 4.0; + let mut contacts = BTreeMap::new(); for _ in 0..4 { for body in &mut bodies { body.start = body.center; @@ -155,6 +249,7 @@ impl PhysicsWorld { if let Some((time, normal)) = sweep(a, b) { let relative_move = (a.center - a.start) - (b.center - b.start); let correction = normal * (-relative_move.dot(normal) * (1.0 - time)); + record_contact(&mut contacts, a, b, normal); separate(a, b, correction, normal); } } @@ -168,25 +263,60 @@ impl PhysicsWorld { if !a.solid || !b.solid || (!a.dynamic && !b.dynamic) { continue; } - let distance = a.center - b.center; - let overlap = a.half + b.half - distance.abs(); - if overlap.min_element() <= 0.0 { - continue; + if let Some((depth, normal)) = penetration(a, b) { + record_contact(&mut contacts, a, b, normal); + separate(a, b, normal * depth.max(0.0), normal); } - let axis = if overlap.y <= overlap.x && overlap.y <= overlap.z { - 1 - } else if overlap.x <= overlap.z { - 0 - } else { - 2 - }; - let mut normal = Vec3::ZERO; - normal[axis] = if distance[axis] >= 0.0 { 1.0 } else { -1.0 }; - separate(a, b, normal * overlap[axis], normal); } } } } + self.events.clear(); + let notified: BTreeMap<_, _> = contacts + .iter() + .filter(|((a, b), _)| { + world.part_attributes(*a).is_ok_and(|p| p.can_touch) + && world.part_attributes(*b).is_ok_and(|p| p.can_touch) + }) + .map(|(pair, normal)| (*pair, *normal)) + .collect(); + for (&(a, b), &normal) in ¬ified { + let name = if self.notified.contains_key(&(a, b)) { + "CollisionStay" + } else { + "CollisionEnter" + }; + self.events.push(ContactEvent { + entity: a, + other: b, + name, + normal, + }); + self.events.push(ContactEvent { + entity: b, + other: a, + name, + normal: -normal, + }); + } + for (&(a, b), &normal) in &self.notified { + if !notified.contains_key(&(a, b)) { + self.events.push(ContactEvent { + entity: a, + other: b, + name: "CollisionExit", + normal, + }); + self.events.push(ContactEvent { + entity: b, + other: a, + name: "CollisionExit", + normal: -normal, + }); + } + } + self.contacts = contacts; + self.notified = notified; self.velocities.clear(); // Parents must be written first, then convert each absolute target through // the updated parent matrix. Otherwise moving parents double-move children. @@ -238,39 +368,126 @@ fn separate(a: &mut Body, b: &mut Body, correction: Vec3, normal: Vec3) { if closing < 0.0 { a.velocity -= normal * (closing * wa / total); b.velocity += normal * (closing * wb / total); + let relative = a.velocity - b.velocity; + let tangent = relative - normal * relative.dot(normal); + let speed = tangent.length(); + if speed > 1e-8 { + // Coulomb friction cannot reverse the tangential relative velocity. + let coefficient = (a.friction * b.friction).sqrt(); + let impulse = tangent / speed * speed.min(-closing * coefficient); + a.velocity -= impulse * (wa / total); + b.velocity += impulse * (wb / total); + } } } +// SAT handles oriented boxes without introducing angular dynamics. Continuous +// SAT uses the same axes, so fast bodies cannot tunnel through rotated ramps. +fn axes(a: &Body, b: &Body) -> impl Iterator { + let cross: [Vec3; 9] = std::array::from_fn(|i| a.axes[i / 3].cross(b.axes[i % 3])); + a.axes + .into_iter() + .chain(b.axes) + .chain(cross.into_iter().filter_map(Vec3::try_normalize)) +} +fn radius(body: &Body, axis: Vec3) -> f32 { + body.axes + .iter() + .enumerate() + .map(|(i, a)| a.dot(axis).abs() * body.half[i]) + .sum() +} +fn penetration(a: &Body, b: &Body) -> Option<(f32, Vec3)> { + let distance = a.center - b.center; + let mut best = (f32::INFINITY, Vec3::Y); + for axis in axes(a, b) { + let depth = radius(a, axis) + radius(b, axis) - distance.dot(axis).abs(); + if depth < -1e-5 { + return None; + } + if depth < best.0 { + best = ( + depth, + axis * if distance.dot(axis) >= 0.0 { 1.0 } else { -1.0 }, + ); + } + } + Some(best) +} +fn record_contact( + contacts: &mut BTreeMap<(EntityId, EntityId), Vec3>, + a: &Body, + b: &Body, + normal: Vec3, +) { + contacts.insert((a.id, b.id), normal); +} fn sweep(a: &Body, b: &Body) -> Option<(f32, Vec3)> { let origin = a.start - b.start; - let half = a.half + b.half; - if (half - origin.abs()).min_element() > 0.0 { - return None; - } let movement = (a.center - a.start) - (b.center - b.start); let mut enter = f32::NEG_INFINITY; let mut exit = f32::INFINITY; let mut normal = Vec3::ZERO; - for axis in 0..3 { - if movement[axis].abs() < 1e-8 { - if origin[axis].abs() >= half[axis] { + for axis in axes(a, b) { + let half = radius(a, axis) + radius(b, axis); + let position = origin.dot(axis); + let speed = movement.dot(axis); + if speed.abs() < 1e-8 { + if position.abs() > half { return None; } continue; } - let t1 = (-half[axis] - origin[axis]) / movement[axis]; - let t2 = (half[axis] - origin[axis]) / movement[axis]; - let near = t1.min(t2); - if near > enter { - enter = near; - normal = Vec3::ZERO; - normal[axis] = -movement[axis].signum(); + let t1 = (-half - position) / speed; + let t2 = (half - position) / speed; + if t1.min(t2) > enter { + enter = t1.min(t2); + normal = -axis * speed.signum(); } exit = exit.min(t1.max(t2)); } ((0.0..=1.0).contains(&enter) && enter <= exit).then_some((enter, normal)) } +// A nonuniformly scaled, rotated parent can shear its children. Conservatively +// enclose sheared transforms; orthogonal transforms retain their slope surfaces. +fn shape(matrix: glam::Mat4, min: Vec3, max: Vec3) -> (Vec3, Vec3, [Vec3; 3]) { + let center = matrix.transform_point3((min + max) * 0.5); + let local_half = (max - min) * 0.5; + let columns = [ + matrix.x_axis.truncate(), + matrix.y_axis.truncate(), + matrix.z_axis.truncate(), + ]; + let axes = columns.map(|v| v.try_normalize().unwrap_or(Vec3::ZERO)); + if axes.iter().all(|v| v.length_squared() > 0.5) + && axes[0].dot(axes[1]).abs() < 1e-4 + && axes[0].dot(axes[2]).abs() < 1e-4 + && axes[1].dot(axes[2]).abs() < 1e-4 + { + ( + center, + (local_half + * Vec3::new( + columns[0].length(), + columns[1].length(), + columns[2].length(), + )) + .max(Vec3::splat(SKIN)), + axes, + ) + } else { + ( + center, + (columns[0].abs() * local_half.x + + columns[1].abs() * local_half.y + + columns[2].abs() * local_half.z) + .max(Vec3::splat(SKIN)), + [Vec3::X, Vec3::Y, Vec3::Z], + ) + } +} + pub(crate) fn bounds(primitive: &Primitive) -> (Vec3, Vec3) { let half = match primitive { Primitive::Cube { size } => Vec3::splat(*size * 0.5), @@ -338,6 +555,168 @@ mod tests { } } + fn floor(world: &mut SceneWorld, rotation: f32, friction: f32) -> EntityId { + let mut e = EntitySnapshot::default(); + e.primitive = Some(Primitive::RectangularPrism { + size: [40.0, 1.0, 20.0], + }); + e.local_transform.rotation = Quat::from_rotation_z(rotation); + e.part_attributes.anchored = true; + e.part_attributes.friction = friction; + let id = e.id; + world + .apply_commands(&[WorldCommand::Spawn(Box::new(e))]) + .unwrap(); + id + } + fn friction(world: &mut SceneWorld, id: EntityId, value: f32) { + let mut attributes = world.part_attributes(id).unwrap(); + attributes.friction = value; + world + .apply_commands(&[WorldCommand::SetPartAttributes { + entity: id, + value: attributes, + }]) + .unwrap(); + } + #[test] + fn friction_stops_sliding_and_zero_friction_preserves_speed() { + for coefficient in [0.0, 1.0] { + let mut world = SceneWorld::new(); + floor(&mut world, 0.0, coefficient); + let id = cube(&mut world, Vec3::Y, false); + friction(&mut world, id, coefficient); + let mut physics = PhysicsWorld::default(); + physics.launch(&world, id, [3.0, 0.0, 0.0]).unwrap(); + tick(&mut physics, &mut world, 60); + let speed = physics.velocities[&id].x; + if coefficient == 0.0 { + assert!((speed - 3.0).abs() < 0.001); + } else { + assert!(speed.abs() < 0.001); + } + } + } + #[test] + fn slopes_slide_or_hold_using_the_surface_normal() { + for coefficient in [0.0, 1.0] { + let mut world = SceneWorld::new(); + let angle = 0.4_f32; + let ramp = floor(&mut world, angle, coefficient); + let id = cube(&mut world, Vec3::Y * 3.0, false); + friction(&mut world, id, coefficient); + let mut physics = PhysicsWorld::default(); + tick(&mut physics, &mut world, 100); + let ground = physics.ground(id, 30.0).unwrap(); + assert_eq!(ground["entity"], serde_json::json!(ramp)); + assert!( + (ground["angle"].as_f64().unwrap() - f64::from(angle.to_degrees())).abs() < 0.01 + ); + assert_eq!(ground["walkable"], true); + assert_eq!(physics.ground(id, 10.0).unwrap()["walkable"], false); + if coefficient == 0.0 { + assert!(physics.velocities[&id].x < -1.0); + } else { + assert!(physics.velocities[&id].length() < 0.01); + } + } + } + #[test] + fn casts_hit_rotated_surfaces_and_respect_ignore() { + let mut world = SceneWorld::new(); + let angle = 0.4_f32; + let id = floor(&mut world, angle, 0.5); + let hit = PhysicsWorld::cast( + &mut world, + [2.0, 10.0, 0.0], + [0.0, -2.0, 0.0], + 20.0, + 0.0, + &[], + ) + .unwrap() + .unwrap(); + assert_eq!(hit.entity, id); + let expected_y = 2.0 * angle.tan() + 0.5 / angle.cos(); + assert!((hit.point[1] - f64::from(expected_y)).abs() < 0.001); + assert!((hit.normal[0] + f64::from(angle.sin())).abs() < 0.001); + assert!( + PhysicsWorld::cast( + &mut world, + [2.0, 10.0, 0.0], + [0.0, -1.0, 0.0], + 20.0, + 0.0, + &[id] + ) + .unwrap() + .is_none() + ); + let sphere = PhysicsWorld::cast( + &mut world, + [2.0, 10.0, 0.0], + [0.0, -1.0, 0.0], + 20.0, + 0.25, + &[], + ) + .unwrap() + .unwrap(); + assert!((hit.distance - sphere.distance - 0.25 / f64::from(angle.cos())).abs() < 0.001); + } + #[test] + fn contact_events_are_paired_and_despawn_produces_exit() { + let mut world = SceneWorld::new(); + let floor = floor(&mut world, 0.0, 0.5); + let id = cube(&mut world, Vec3::Y, false); + let mut physics = PhysicsWorld::default(); + tick(&mut physics, &mut world, 1); + assert_eq!(physics.events.len(), 2); + assert!(physics.events.iter().all(|e| e.name == "CollisionEnter")); + assert!( + physics + .events + .iter() + .find(|e| e.entity == id) + .unwrap() + .normal + .y + > 0.99 + ); + tick(&mut physics, &mut world, 1); + assert!(physics.events.iter().all(|e| e.name == "CollisionStay")); + world + .apply_commands(&[WorldCommand::Despawn(floor)]) + .unwrap(); + tick(&mut physics, &mut world, 1); + assert!( + physics + .events + .iter() + .any(|e| e.entity == id && e.other == floor && e.name == "CollisionExit") + ); + tick(&mut physics, &mut world, 1); + assert!(physics.events.is_empty()); + } + #[test] + fn can_touch_controls_events_without_losing_ground_support() { + let mut world = SceneWorld::new(); + floor(&mut world, 0.0, 0.5); + let id = cube(&mut world, Vec3::Y, false); + let mut attributes = world.part_attributes(id).unwrap(); + attributes.can_touch = false; + world + .apply_commands(&[WorldCommand::SetPartAttributes { + entity: id, + value: attributes, + }]) + .unwrap(); + let mut physics = PhysicsWorld::default(); + tick(&mut physics, &mut world, 2); + assert!(physics.events.is_empty()); + assert_eq!(physics.ground(id, 45.0).unwrap()["walkable"], true); + } + #[test] fn gravity_and_anchoring() { let mut world = SceneWorld::new(); diff --git a/Engine/crates/engine-play/src/script_runtime.rs b/Engine/crates/engine-play/src/script_runtime.rs index 15d617a0..8dc1bcea 100644 --- a/Engine/crates/engine-play/src/script_runtime.rs +++ b/Engine/crates/engine-play/src/script_runtime.rs @@ -511,7 +511,29 @@ impl SceneRuntime { context .physics .step(&mut world, scaled) - .map_err(|e| e.to_string()) + .map_err(|e| e.to_string())?; + let events = std::mem::take(&mut context.physics.events); + for event in events { + if !world.contains(event.entity) { + continue; + } + context.runtime.signals.emit( + &engine_core::gameplay::signals::Signal { + name: event.name.into(), + source: Some(event.entity), + }, + vec![ + engine_core::gameplay::signals::Payload::String( + event.other.to_string(), + ), + engine_core::gameplay::Value::Vector( + event.normal.as_dvec3().to_array(), + ) + .into(), + ], + )?; + } + Ok(()) }) { push_log( @@ -1608,9 +1630,8 @@ impl GameplayHost for RuntimeHost { let mut world = self.world.lock().map_err(|_| "world lock poisoned")?; match op { "physics_raycast" | "physics_sphere_cast" => { - let colliders = crate::physics::PhysicsWorld::colliders(&mut world)?; - serde_json::to_value(engine_core::gameplay::physics::cast( - &colliders, + serde_json::to_value(crate::physics::PhysicsWorld::cast( + &mut world, args.origin, args.direction, args.distance, @@ -1623,6 +1644,18 @@ impl GameplayHost for RuntimeHost { )?) .map_err(|e| e.to_string()) } + "physics_ground" => { + let id = args.entity.unwrap_or(self.entity); + if !world.contains(id) { + return Err("ground entity does not exist".into()); + } + context.physics.ground( + id, + query.get("max_angle").map_or(Ok(45.0), |v| { + v.as_f64().ok_or("maximum slope must be numeric") + })?, + ) + } "physics_overlap" => { let c = crate::physics::PhysicsWorld::colliders(&mut world)?; serde_json::to_value(engine_core::gameplay::physics::overlap( @@ -1706,6 +1739,14 @@ impl GameplayHost for RuntimeHost { { return Err("input event names are reserved for the engine".into()); } + if let Request::Emit { signal, .. } = &request + && matches!( + signal.name.as_str(), + "CollisionEnter" | "CollisionStay" | "CollisionExit" + ) + { + return Err("collision event names are reserved for the engine".into()); + } let mut context = self.gameplay.lock().map_err(|_| "gameplay lock poisoned")?; let owner = Owner { entity_bound: self.entity_bound, @@ -2034,6 +2075,7 @@ impl GameplayHost for RuntimeHost { "cantouch" => EngineValue::Boolean(snapshot.part_attributes.can_touch), "cancollide" => EngineValue::Boolean(snapshot.part_attributes.can_collide), "anchored" => EngineValue::Boolean(snapshot.part_attributes.anchored), + "friction" => EngineValue::Number(f64::from(snapshot.part_attributes.friction)), "parent" => EngineValue::Entity(snapshot.parent), _ => return Ok(None), }; @@ -2076,7 +2118,7 @@ impl GameplayHost for RuntimeHost { parent, }]) } - (property @ ("color" | "cantouch" | "cancollide" | "anchored"), value) => { + (property @ ("color" | "cantouch" | "cancollide" | "anchored" | "friction"), value) => { let mut attributes = world .part_attributes(self.entity) .map_err(|error| error.to_string())?; @@ -2088,6 +2130,26 @@ impl GameplayHost for RuntimeHost { ("cantouch", EngineValue::Boolean(value)) => attributes.can_touch = value, ("cancollide", EngineValue::Boolean(value)) => attributes.can_collide = value, ("anchored", EngineValue::Boolean(value)) => attributes.anchored = value, + ("friction", EngineValue::Number(value)) + if value.is_finite() && (0.0..=10.0).contains(&value) => + { + #[allow( + clippy::cast_possible_truncation, + reason = "coefficient is bounded to 0..10" + )] + { + attributes.friction = value as f32; + } + } + ("friction", EngineValue::Integer(value)) if (0..=10).contains(&value) => { + #[allow( + clippy::cast_precision_loss, + reason = "coefficient is bounded to 0..10" + )] + { + attributes.friction = value as f32; + } + } _ => return Err(format!("attribute `{name}` type mismatch")), } world.apply_commands(&[WorldCommand::SetPartAttributes { @@ -3397,6 +3459,146 @@ rustic_run(["on_start"=>"on_start"]);"#), ); } + #[test] + #[allow( + clippy::too_many_lines, + reason = "exercise the shared physics API through every available SDK" + )] + fn physics_contacts_and_ground_work_through_available_sdks() { + let lua = br"return {Start=function() + rustic.EditAttribute('Friction',0.75) + assert(Physics.ground(rustic.entity_id(),45)==nil) + Physics.onCollisionEnter(rustic.entity_id(),function(other,n) assert(n[2]>0.99);rustic.log('info','enter') end) + Physics.onCollisionStay(rustic.entity_id(),function() rustic.log('info','stay') end) + Physics.onCollisionExit(rustic.entity_id(),function() rustic.log('info','exit') end) + end,Update=function() local g=Physics.ground(rustic.entity_id(),45);if g then assert(g.walkable);rustic.log('info','ground') end end}"; + let js = br"globalThis.behavior={Start(){ + rustic.EditAttribute('Friction',0.75); + if(Physics.ground(rustic.entity_id(),45)!==null)throw Error('startup ground'); + Physics.onCollisionEnter(rustic.entity_id(),(other,n)=>{if(n[1]<0.99)throw Error('normal');rustic.log('info','enter');}); + Physics.onCollisionStay(rustic.entity_id(),()=>rustic.log('info','stay')); + Physics.onCollisionExit(rustic.entity_id(),()=>rustic.log('info','exit')); + },Update(){const g=Physics.ground(rustic.entity_id(),45);if(g){if(!g.walkable)throw Error('slope');rustic.log('info','ground');}}};"; + let fixtures: &[(ScriptLanguage, &str, &[u8])] = &[ + (ScriptLanguage::Lua54,"contacts.lua",lua), + (ScriptLanguage::Luau,"contacts.luau",lua), + (ScriptLanguage::JavaScript,"contacts.js",js), + (ScriptLanguage::Web,"contacts.html",br""), + (ScriptLanguage::Python,"contacts.py",br"from rustic import * +def start(): + rustic.EditAttribute('Friction',0.75) + assert Physics.ground(rustic.entity_id(),45) is None + Physics.onCollisionEnter(rustic.entity_id(),lambda other,n: rustic.log('info','enter')) + Physics.onCollisionStay(rustic.entity_id(),lambda other,n: rustic.log('info','stay')) + Physics.onCollisionExit(rustic.entity_id(),lambda other,n: rustic.log('info','exit')) +def update(dt): + if Physics.ground(rustic.entity_id(),45): rustic.log('info','ground') +run({'on_start':start,'update':update})"), + (ScriptLanguage::C,"contacts.c",br#"#include "rustic.h" +void enter(const RusticValue*a,size_t n){if(n!=2||a[1].vector[1]<0.99)r_fail("normal");rustic.log("info","enter");} +void stay(const RusticValue*a,size_t n){(void)a;(void)n;rustic.log("info","stay");} +void leave(const RusticValue*a,size_t n){(void)a;(void)n;rustic.log("info","exit");} +void start(void){rustic.edit_attribute("Friction",(RusticValue){.type=RUSTIC_NUMBER,.number=0.75});Physics.onCollisionEnter(rustic.entity_id(),enter);Physics.onCollisionStay(rustic.entity_id(),stay);Physics.onCollisionExit(rustic.entity_id(),leave);} +void update(double dt){(void)dt;RusticGround g=Physics.ground(rustic.entity_id(),45);if(g.found){if(!g.walkable)r_fail("slope");rustic.log("info","ground");}} +int main(void){return rustic_run((RusticBehavior){.on_start=start,.update=update});}"#), + (ScriptLanguage::Cpp,"contacts.cpp",br#"#include "rustic.hpp" +int main(){RusticBehavior b;b.on_start=[](){rustic.EditAttribute("Friction",0.75);Physics::onCollisionEnter(rustic.entity_id(),[](const RusticValue::Array&){rustic.log("info","enter");});Physics::onCollisionStay(rustic.entity_id(),[](const RusticValue::Array&){rustic.log("info","stay");});Physics::onCollisionExit(rustic.entity_id(),[](const RusticValue::Array&){rustic.log("info","exit");});};b.update=[](double){if(!Physics::ground(rustic.entity_id(),45).is_null())rustic.log("info","ground");};return rustic_run(b);}"#), + (ScriptLanguage::CSharp,"contacts.cs",br#"using static Rustic; +Run((callback,dt)=>{if(callback=="on_start"){rustic.EditAttribute("Friction",0.75);Physics.onCollisionEnter(rustic.entity_id(),a=>rustic.log("info","enter"));Physics.onCollisionStay(rustic.entity_id(),a=>rustic.log("info","stay"));Physics.onCollisionExit(rustic.entity_id(),a=>rustic.log("info","exit"));}if(callback=="update"&&Physics.ground(rustic.entity_id(),45).ValueKind!=System.Text.Json.JsonValueKind.Null)rustic.log("info","ground");});"#), + (ScriptLanguage::Java,"RusticBehavior.java",br#"class RusticBehavior extends Rustic {public static void main(String[]a)throws Exception{run((callback,dt)->{if(callback.equals("on_start")){rustic.EditAttribute("Friction",0.75);Physics.onCollisionEnter(rustic.entity_id(),v->rustic.log("info","enter"));Physics.onCollisionStay(rustic.entity_id(),v->rustic.log("info","stay"));Physics.onCollisionExit(rustic.entity_id(),v->rustic.log("info","exit"));}if(callback.equals("update")&&Physics.ground(rustic.entity_id(),45)!=null)rustic.log("info","ground");});}}"#), + (ScriptLanguage::Php,"contacts.php",br#"function(){global $rustic;$rustic->EditAttribute("Friction",0.75);Physics::onCollisionEnter($rustic->entity_id(),fn($o,$n)=>$rustic->log("info","enter"));Physics::onCollisionStay($rustic->entity_id(),fn($o,$n)=>$rustic->log("info","stay"));Physics::onCollisionExit($rustic->entity_id(),fn($o,$n)=>$rustic->log("info","exit"));},'update'=>function($dt){global $rustic;if(Physics::ground($rustic->entity_id(),45)!==null)$rustic->log("info","ground");}]);"#), + ]; + for &(language, name, source) in fixtures { + if !matches!(language, ScriptLanguage::Lua54 | ScriptLanguage::JavaScript) + && !engine_scripting::probe_language_toolchain(language).available + { + continue; + } + eprintln!("Physics SDK: {}", language.display_name()); + let name = if matches!(language, ScriptLanguage::Web | ScriptLanguage::Php) { + format!("ui/{name}") + } else { + format!("scripts/{name}") + }; + let temp = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(temp.path().join("config")).unwrap(); + let script = ScriptId::new(); + std::fs::create_dir_all(temp.path().join(&name).parent().unwrap()).unwrap(); + std::fs::write(temp.path().join(&name), source).unwrap(); + save_manifest_atomic( + &temp.path().join("config/scripts.ron"), + &ScriptManifest { + format_version: 2, + scripts: vec![ScriptManifestEntry { + id: script, + language, + relative_path: name.into(), + api_version: ScriptApiVersion::CURRENT, + public_properties: Vec::new(), + }], + }, + ) + .unwrap(); + let mut floor = EntitySnapshot::default(); + floor.primitive = Some(engine_world::Primitive::RectangularPrism { + size: [20.0, 1.0, 20.0], + }); + floor.part_attributes.anchored = true; + let mut body = EntitySnapshot::default(); + body.primitive = Some(engine_world::Primitive::Cube { size: 1.0 }); + body.local_transform.translation.y = 1.0; + body.scripts.push(ScriptComponent::new(script)); + let id = body.id; + let mut scene = SceneDocument::new("Physics"); + scene.entities = vec![floor, body]; + let mut runtime = + RuntimeScripts::load(temp.path(), &scene.to_bytes().unwrap()).unwrap(); + for _ in 0..2 { + runtime.fixed_update(1.0 / 60.0); + runtime.frame_update(1.0 / 60.0); + } + assert_eq!( + runtime + .live_entity(id) + .unwrap() + .unwrap() + .part_attributes + .friction, + 0.75, + "{}", + language.display_name() + ); + { + let mut world = runtime.active.world.lock().unwrap(); + let mut transform = world.local_transform(id).unwrap(); + transform.translation.y = 20.0; + world + .apply_commands(&[WorldCommand::SetLocalTransform { + entity: id, + value: transform, + }]) + .unwrap(); + } + runtime.fixed_update(1.0 / 60.0); + runtime.frame_update(1.0 / 60.0); + let logs = runtime.drain_logs(); + assert!( + !logs.iter().any(|(level, _)| level == "error"), + "{}: {logs:?}", + language.display_name() + ); + for expected in ["enter", "stay", "exit", "ground"] { + assert!( + logs.iter().any(|(_, s)| s == expected), + "{} missing {expected}: {logs:?}", + language.display_name() + ); + } + } + } + #[test] fn physics_runs_without_scripts_and_resets_with_new_runtime() { let temp = tempfile::tempdir().unwrap(); diff --git a/Engine/crates/engine-scripting/src/agent_authoring.md b/Engine/crates/engine-scripting/src/agent_authoring.md index 11c1e037..233dbd21 100644 --- a/Engine/crates/engine-scripting/src/agent_authoring.md +++ b/Engine/crates/engine-scripting/src/agent_authoring.md @@ -30,7 +30,7 @@ Before placing a model, request actual preview images. `model_preview` takes eit Build and save the full initial scene in the editor: models, objects, camera, lighting, colliders, UI and script attachments must be inspectable before Play. Do not hide scene construction in startup scripts or make the scene appear only after playtesting begins. Runtime spawning is appropriate for actual gameplay (projectiles, enemies, changing procedural content) or an explicit procedural-generation request; persist a representative authored layout and make the runtime behavior clear. -Call `editor_status` for live object IDs and properties, including unsaved changes; `scene_summary` reads saved files only. Use `object_create` for folders, empty nodes, cameras, lights and deliberate blockouts; `model_create` places finished mesh assets. `object_update` changes names, parent, local xyz translation/scale, xyzw quaternion rotation, part attributes (color, anchored, can_touch, can_collide), cameras, lights and object script components. These tools use the editor's document validation and undo history; do not click inspector controls through computer use. They affect the active scene only. Object edits are unsaved until `scene_save`; inspect the result before saving. `editor_undo` and `editor_redo` support scene object edits. +Call `editor_status` for live object IDs and properties, including unsaved changes; `scene_summary` reads saved files only. Use `object_create` for folders, empty nodes, cameras, lights and deliberate blockouts; `model_create` places finished mesh assets. `object_update` changes names, parent, local xyz translation/scale, xyzw quaternion rotation, part attributes (color, anchored, can_touch, can_collide, friction), cameras, lights and object script components. These tools use the editor's document validation and undo history; do not click inspector controls through computer use. They affect the active scene only. Object edits are unsaved until `scene_save`; inspect the result before saving. `editor_undo` and `editor_redo` support scene object edits. Use `scene_list` to discover active/start scenes and nested paths. Scene-management tool paths are relative to the configured scenes directory, e.g. `levels/forest.scene`; file/model/script paths are relative to the project root. `scene_create` creates an empty scene without overwriting or switching. `scene_switch` saves current edits before opening an existing scene. `scene_set_start` controls the launch scene independently of the editor's active scene. Stop play and resolve any pending runtime-change review before switching/editing. Re-read live IDs after switching; do not reuse assumptions from the previous scene. Save each scene, and test both its individual flow and the game's launch/transition flow. Use the engine scene-lifecycle documentation for runtime load/switch/unload and additive scene behavior; do not invent APIs from another engine. @@ -64,3 +64,7 @@ These are optional references, not installed dependencies or engine API specific - Game UI workflow and checklist: https://github.com/jeremylongworth-source/AgentSkills/blob/main/skills/game-ui-design/SKILL.md - Broader design/accessibility reference catalog: https://github.com/jeremylongworth-source/AgentSkills/tree/main/skills - Blender workflow reference: https://github.com/Chara-Xh/blender-modeling-skill/tree/main/skills/blender-modeling + +## Physics authoring + +Built-in primitives use oriented box collision proxies and unit mass in Play. Anchor floors and ramps, enable can_collide on both bodies, and author friction (0..10, default 0.5) through object_update.part_attributes. Rotated planes/boxes provide slope normals; curved primitives remain boxes and sheared transforms use enclosing AABBs. Use Physics.force once per FixedUpdate with dt, impulse to add velocity, and launch to replace it. Raycasts/sphere casts return rotated surface normals. Physics.ground(entity, maxAngle) reads the last completed fixed step and returns null or {entity, normal, angle, walkable}; it does not implement a character controller. Register Physics.onCollisionEnter/Stay/Exit explicitly from Start; callbacks arrive during Update with (other ID, world normal), using native array callbacks in C/C++/C#/Java. Both bodies need can_touch and can_collide; at least one must be unanchored. Defining OnCollisionEnter lifecycle functions alone does not register them. Inspect logs and test sliding, landing and leaving contact. diff --git a/Engine/crates/engine-scripting/src/cpp_sdk.rs b/Engine/crates/engine-scripting/src/cpp_sdk.rs index 6011d66c..19562102 100644 --- a/Engine/crates/engine-scripting/src/cpp_sdk.rs +++ b/Engine/crates/engine-scripting/src/cpp_sdk.rs @@ -276,6 +276,7 @@ inline std::pair smoothDamp(double c,double t,double v,double tim } namespace Interpolation=Smooth; namespace Physics{ +inline RusticValue ground(const std::string&e,double maxAngle=45){return rustic.query(rustic_detail::object({{"op","physics_ground"},{"entity",Gameplay::entity(e)},{"max_angle",maxAngle}}));} struct Hit{std::string entity;RusticVector3 point,normal;double distance;}; inline RusticValue vec(RusticVector3 p){return RusticValue::Array{p.x,p.y,p.z};} inline RusticVector3 vector(const RusticValue& v){auto&a=v.array();return {a[0].number(),a[1].number(),a[2].number()};} @@ -351,6 +352,11 @@ inline auto once(const std::string&name,std::function fn){return Events::connect(e,"CollisionEnter",std::move(fn));} +inline auto onCollisionStay(const std::string&e,std::function fn){return Events::connect(e,"CollisionStay",std::move(fn));} +inline auto onCollisionExit(const std::string&e,std::function fn){return Events::connect(e,"CollisionExit",std::move(fn));} +} namespace Input{ inline RusticValue touches(){return rustic.query(rustic_detail::object({{"op","input_touches"}}));} inline RusticValue touchEvents(){return rustic.query(rustic_detail::object({{"op","input_touch_events"}}));} diff --git a/Engine/crates/engine-scripting/src/external_runtime.rs b/Engine/crates/engine-scripting/src/external_runtime.rs index d7ffc8df..0c211470 100644 --- a/Engine/crates/engine-scripting/src/external_runtime.rs +++ b/Engine/crates/engine-scripting/src/external_runtime.rs @@ -288,6 +288,7 @@ impl ExternalBehavior { "CanTouch", "CanCollide", "Anchored", + "Friction", "Parent", ] .into_iter() diff --git a/Engine/crates/engine-scripting/src/javascript.rs b/Engine/crates/engine-scripting/src/javascript.rs index a6d75b42..ec74cbbd 100644 --- a/Engine/crates/engine-scripting/src/javascript.rs +++ b/Engine/crates/engine-scripting/src/javascript.rs @@ -371,6 +371,7 @@ impl JavaScriptBehavior { "CanTouch", "CanCollide", "Anchored", + "Friction", "Parent", ] .into_iter() diff --git a/Engine/crates/engine-scripting/src/lua.rs b/Engine/crates/engine-scripting/src/lua.rs index faee6460..2bc57e21 100644 --- a/Engine/crates/engine-scripting/src/lua.rs +++ b/Engine/crates/engine-scripting/src/lua.rs @@ -500,7 +500,11 @@ fn install_api(lua: &Lua, host: Arc>>) -> mlua::Resu let result = lock_host(&h)? .gameplay_query(query) .map_err(MluaError::RuntimeError)?; - lua.to_value(&result) + if result.is_null() { + Ok(Value::Nil) + } else { + lua.to_value(&result) + } })?, )?; let h = Arc::clone(&host); diff --git a/Engine/crates/engine-scripting/src/sdk/Rustic.cs b/Engine/crates/engine-scripting/src/sdk/Rustic.cs index 10a69f65..d9595aa3 100644 --- a/Engine/crates/engine-scripting/src/sdk/Rustic.cs +++ b/Engine/crates/engine-scripting/src/sdk/Rustic.cs @@ -71,6 +71,10 @@ public static class Input{ public static Events.Connection onTouchCancel(Action fn)=>Events.on("TouchCancel",fn); } public static class Physics{ + public static JsonElement ground(string? e,double maxAngle=45)=>Query(new{op="physics_ground",entity=Gameplay.Entity(e),max_angle=maxAngle}); + public static Events.Connection onCollisionEnter(string e,Action fn)=>Events.connect(e,"CollisionEnter",fn); + public static Events.Connection onCollisionStay(string e,Action fn)=>Events.connect(e,"CollisionStay",fn); + public static Events.Connection onCollisionExit(string e,Action fn)=>Events.connect(e,"CollisionExit",fn); public sealed record Hit(string entity,double[] point,double[] normal,double distance); public static Hit? raycast(double[] origin,double[] direction,double distance)=>JsonSerializer.Deserialize(Query(new{op="physics_raycast",origin,direction,distance}).GetRawText()); public static Hit? sphereCast(double[] origin,double[] direction,double distance,double radius)=>JsonSerializer.Deserialize(Query(new{op="physics_sphere_cast",origin,direction,distance,radius}).GetRawText()); diff --git a/Engine/crates/engine-scripting/src/sdk/Rustic.java b/Engine/crates/engine-scripting/src/sdk/Rustic.java index 7a64de82..868b1ba9 100644 --- a/Engine/crates/engine-scripting/src/sdk/Rustic.java +++ b/Engine/crates/engine-scripting/src/sdk/Rustic.java @@ -86,6 +86,10 @@ static class Input{ static Events.Connection onTouchCancel(java.util.function.Consumer fn){return Events.on("TouchCancel",fn);} } static class Physics{ + static Object ground(String e,double maxAngle){return query(args("physics_ground","entity",Gameplay.entity(e),"max_angle",maxAngle));} + static Events.Connection onCollisionEnter(String e,java.util.function.Consumer fn){return Events.connect(e,"CollisionEnter",fn);} + static Events.Connection onCollisionStay(String e,java.util.function.Consumer fn){return Events.connect(e,"CollisionStay",fn);} + static Events.Connection onCollisionExit(String e,java.util.function.Consumer fn){return Events.connect(e,"CollisionExit",fn);} static Object raycast(double[] origin,double[] direction,double distance){return query(args("physics_raycast","origin",origin,"direction",direction,"distance",distance));} static Object sphereCast(double[] origin,double[] direction,double distance,double radius){return query(args("physics_sphere_cast","origin",origin,"direction",direction,"distance",distance,"radius",radius));} static Object overlap(double[] center,double radius){return query(args("physics_overlap","center",center,"radius",radius));} diff --git a/Engine/crates/engine-scripting/src/sdk/gameplay.js b/Engine/crates/engine-scripting/src/sdk/gameplay.js index 5a288bc9..49d4e1d0 100644 --- a/Engine/crates/engine-scripting/src/sdk/gameplay.js +++ b/Engine/crates/engine-scripting/src/sdk/gameplay.js @@ -93,6 +93,10 @@ onTouchCancel:fn=>Events.on('TouchCancel',fn) }; const Physics={ + ground:(o,maxAngle=45)=>rustic.query({op:'physics_ground',entity:entity(o),max_angle:maxAngle}), + onCollisionEnter:(o,fn)=>Events.connect(o,'CollisionEnter',fn), + onCollisionStay:(o,fn)=>Events.connect(o,'CollisionStay',fn), + onCollisionExit:(o,fn)=>Events.connect(o,'CollisionExit',fn), raycast:(origin,direction,distance,ignore=[])=>rustic.query({op:'physics_raycast',origin,direction,distance,ignore}), sphereCast:(origin,direction,distance,radius,ignore=[])=>rustic.query({op:'physics_sphere_cast',origin,direction,distance,radius,ignore}), overlap:(center,radius,ignore=[])=>rustic.query({op:'physics_overlap',center,radius,ignore}), diff --git a/Engine/crates/engine-scripting/src/sdk/gameplay.lua b/Engine/crates/engine-scripting/src/sdk/gameplay.lua index 33797531..22d2fc7b 100644 --- a/Engine/crates/engine-scripting/src/sdk/gameplay.lua +++ b/Engine/crates/engine-scripting/src/sdk/gameplay.lua @@ -217,6 +217,10 @@ function Input.onTouchEnd(fn) return Events.on('TouchEnd',fn) end function Input.onTouchCancel(fn) return Events.on('TouchCancel',fn) end Physics={} +function Physics.ground(o,maxAngle) return rustic.query({op='physics_ground',entity=entity(o),max_angle=maxAngle or 45}) end +function Physics.onCollisionEnter(o,fn) return Events.connect(o,'CollisionEnter',fn) end +function Physics.onCollisionStay(o,fn) return Events.connect(o,'CollisionStay',fn) end +function Physics.onCollisionExit(o,fn) return Events.connect(o,'CollisionExit',fn) end function Physics.raycast(origin,direction,distance,ignore) return rustic.query({op='physics_raycast',origin=origin,direction=direction,distance=distance,ignore=ignore or {}}) end function Physics.sphereCast(origin,direction,distance,radius,ignore) return rustic.query({op='physics_sphere_cast',origin=origin,direction=direction,distance=distance,radius=radius,ignore=ignore or {}}) end function Physics.overlap(center,radius,ignore) return rustic.query({op='physics_overlap',center=center,radius=radius,ignore=ignore or {}}) end diff --git a/Engine/crates/engine-scripting/src/sdk/rustic.h b/Engine/crates/engine-scripting/src/sdk/rustic.h index c9826fb9..832cec65 100644 --- a/Engine/crates/engine-scripting/src/sdk/rustic.h +++ b/Engine/crates/engine-scripting/src/sdk/rustic.h @@ -198,10 +198,11 @@ static RusticPointerEvent r_pointer_event(size_t i){ } typedef struct {char *source,*pending;RToken *tokens;size_t count,at;bool audio_devices;} RQuerySave; typedef struct {bool hit;char entity[64];RusticVector3 point,normal;double distance;} RusticHit; +typedef struct {bool found;char entity[64];RusticVector3 normal;double angle;bool walkable;} RusticGround; typedef struct {double value,velocity;} RusticDamping; typedef struct {bool found;char status[16],error[256];} RusticOperationState; typedef struct {double scale;bool paused;} RusticClockState; -typedef struct {RusticClockState clock;RusticOperationState operation;RusticValue value;RusticHit hit;RusticDamping damping;char *json;RusticList list;RusticAudioDevices devices;RusticCaptureData capture;RusticPointerEvents pointers;RusticObjectClicks clicks;} RQueryResult; +typedef struct {RusticClockState clock;RusticOperationState operation;RusticValue value;RusticHit hit;RusticGround ground;RusticDamping damping;char *json;RusticList list;RusticAudioDevices devices;RusticCaptureData capture;RusticPointerEvents pointers;RusticObjectClicks clicks;} RQueryResult; static char *r_query_owned[8192];static size_t r_query_owned_count; static char *r_owned(const char *s,size_t length){if(r_query_owned_count>=8192)r_fail("Rustic C: query allocation budget");char *copy=(char*)malloc(length+1);if(!copy)r_fail("Rustic C: allocation failed");if(s)memcpy(copy,s,length);else memset(copy,0,length);copy[length]=0;r_query_owned[r_query_owned_count++]=copy;return copy;} static RQuerySave r_query_begin(const char *op){ @@ -235,6 +236,7 @@ static RQueryResult r_query_end(RQuerySave saved){ } size_t status=r_field(result,"status");if(statusquery(["op"=>"physics_ground","entity"=>Gameplay::entity($e),"max_angle"=>$maxAngle]);} + static function onCollisionEnter(string $e,callable $fn){return Events::connect($e,"CollisionEnter",$fn);} + static function onCollisionStay(string $e,callable $fn){return Events::connect($e,"CollisionStay",$fn);} + static function onCollisionExit(string $e,callable $fn){return Events::connect($e,"CollisionExit",$fn);} static function raycast(array $origin,array $direction,float $distance):?array{global $rustic;return $rustic->query(["op"=>"physics_raycast","origin"=>$origin,"direction"=>$direction,"distance"=>$distance]);} static function sphereCast(array $origin,array $direction,float $distance,float $radius):?array{global $rustic;return $rustic->query(["op"=>"physics_sphere_cast","origin"=>$origin,"direction"=>$direction,"distance"=>$distance,"radius"=>$radius]);} static function overlap(array $center,float $radius):array{global $rustic;return $rustic->query(["op"=>"physics_overlap","center"=>$center,"radius"=>$radius]);} diff --git a/Engine/crates/engine-scripting/src/sdk/rustic.py b/Engine/crates/engine-scripting/src/sdk/rustic.py index 7e98282f..6347f2fa 100644 --- a/Engine/crates/engine-scripting/src/sdk/rustic.py +++ b/Engine/crates/engine-scripting/src/sdk/rustic.py @@ -289,6 +289,14 @@ def onTouchEnd(fn): return Events.on("TouchEnd",fn) def onTouchCancel(fn): return Events.on("TouchCancel",fn) class Physics: + @staticmethod + def ground(o,maxAngle=45): return rustic.query(dict(op="physics_ground",entity=_entity(o),max_angle=maxAngle)) + @staticmethod + def onCollisionEnter(o,fn): return Events.connect(o,"CollisionEnter",fn) + @staticmethod + def onCollisionStay(o,fn): return Events.connect(o,"CollisionStay",fn) + @staticmethod + def onCollisionExit(o,fn): return Events.connect(o,"CollisionExit",fn) @staticmethod def raycast(origin,direction,distance,ignore=None): return rustic.query(dict(op="physics_raycast",origin=origin,direction=direction,distance=distance,ignore=ignore or [])) @staticmethod diff --git a/Engine/crates/engine-world/src/components.rs b/Engine/crates/engine-world/src/components.rs index 38fa92dd..fc74e026 100644 --- a/Engine/crates/engine-world/src/components.rs +++ b/Engine/crates/engine-world/src/components.rs @@ -78,6 +78,13 @@ pub struct PartAttributes { pub can_touch: bool, pub can_collide: bool, pub anchored: bool, + /// Coulomb friction coefficient, combined geometrically at contacts. + #[serde(default = "default_friction")] + pub friction: f32, +} + +fn default_friction() -> f32 { + 0.5 } impl Default for PartAttributes { @@ -87,6 +94,7 @@ impl Default for PartAttributes { can_touch: true, can_collide: true, anchored: false, + friction: default_friction(), } } } @@ -268,3 +276,45 @@ impl Light { && self.spot_outer_angle_radians < std::f32::consts::PI } } + +#[cfg(test)] +mod physics_attribute_tests { + use super::*; + #[test] + fn legacy_attributes_default_to_friction_and_new_values_round_trip() { + let legacy = ron::ser::to_string(&PartAttributes::default()) + .unwrap() + .replace(",friction:0.5", ""); + assert!(!legacy.contains("friction")); + let mut attributes: PartAttributes = ron::from_str(&legacy).unwrap(); + assert!((attributes.friction - 0.5).abs() < f32::EPSILON); + attributes.friction = 0.8; + let encoded = ron::ser::to_string(&attributes).unwrap(); + assert_eq!( + ron::from_str::(&encoded).unwrap(), + attributes + ); + } + #[test] + fn invalid_friction_is_rejected_before_world_mutation() { + let mut world = crate::SceneWorld::new(); + let entity = crate::EntitySnapshot::default(); + let id = entity.id; + world + .apply_commands(&[crate::WorldCommand::Spawn(Box::new(entity))]) + .unwrap(); + for value in [f32::NAN, f32::INFINITY, -1.0, 10.1] { + let mut attributes = world.part_attributes(id).unwrap(); + attributes.friction = value; + assert!( + world + .apply_commands(&[crate::WorldCommand::SetPartAttributes { + entity: id, + value: attributes + }]) + .is_err() + ); + assert!((world.part_attributes(id).unwrap().friction - 0.5).abs() < f32::EPSILON); + } + } +} diff --git a/Engine/crates/engine-world/src/scene.rs b/Engine/crates/engine-world/src/scene.rs index cc7e4d1a..972953f3 100644 --- a/Engine/crates/engine-world/src/scene.rs +++ b/Engine/crates/engine-world/src/scene.rs @@ -78,6 +78,11 @@ impl Default for EntitySnapshot { impl EntitySnapshot { pub(crate) fn validate(&self) -> Result<(), WorldError> { + if !self.part_attributes.friction.is_finite() + || !(0.0..=10.0).contains(&self.part_attributes.friction) + { + return Err(WorldError::InvalidFriction(self.id)); + } if !self.local_transform.is_finite() { return Err(WorldError::InvalidTransform(self.id)); } diff --git a/Engine/crates/engine-world/src/world.rs b/Engine/crates/engine-world/src/world.rs index 470e8716..a319f0d1 100644 --- a/Engine/crates/engine-world/src/world.rs +++ b/Engine/crates/engine-world/src/world.rs @@ -14,6 +14,8 @@ use thiserror::Error; /// Errors from world mutation, reference validation, or schedule execution. #[derive(Clone, Debug, Error, PartialEq)] pub enum WorldError { + #[error("invalid friction for {0}: expected a finite coefficient between 0 and 10")] + InvalidFriction(EntityId), #[error("invalid argument: {0}")] InvalidArgument(String), #[error("invalid scene environment settings")] @@ -416,6 +418,11 @@ impl SceneWorld { WorldCommand::SetParent { child, parent } => { parents.insert(*child, *parent); } + WorldCommand::SetPartAttributes { entity, value } => { + if !value.friction.is_finite() || !(0.0..=10.0).contains(&value.friction) { + return Err(WorldError::InvalidFriction(*entity)); + } + } WorldCommand::SetLocalTransform { entity, value } => { if !value.is_finite() { return Err(WorldError::InvalidTransform(*entity)); @@ -1156,6 +1163,7 @@ mod tests { can_touch: false, can_collide: true, anchored: true, + friction: 0.5, }; world .apply_commands(&[WorldCommand::SetPartAttributes { diff --git a/Engine/docs/GAMEPLAY_PROGRAMMING.md b/Engine/docs/GAMEPLAY_PROGRAMMING.md index 0063141a..44dd1b86 100644 --- a/Engine/docs/GAMEPLAY_PROGRAMMING.md +++ b/Engine/docs/GAMEPLAY_PROGRAMMING.md @@ -40,14 +40,12 @@ Global scripts are instantiated first when the game starts. Scene scripts are ne when their scene loads. Component scripts are instantiated with their object and are disabled/unregistered with it. Only callbacks actually supplied by a script are invoked. The canonical callbacks are `Start`, `Update`, `FixedUpdate`, -`OnCollisionEnter`, `OnCollisionStay`, `OnCollisionExit`, `OnEnable`, `OnDisable`, and -`OnDestroy`. Original snake-case callback names continue to work. +`OnEnable`, `OnDisable`, and `OnDestroy`. Original snake-case callback names continue to work. Collision notifications use explicit Physics event subscriptions. The basic Play simulator runs gravity and primitive box collision response after `FixedUpdate`, including in scenes with no scripts. `Anchored` prevents physics movement; `CanCollide` must be enabled on both objects for solid response. Collision -callback names are reserved: the simulator does not dispatch collision or touch -events. See `PHYSICS.md` for floor setup, falling objects, and current limitations. +subscriptions use `Physics.onCollisionEnter/Stay/Exit`, delivered during Update after resolution. Merely declaring an OnCollisionEnter lifecycle function does not subscribe. See [Contacts and slopes](/docs/api/physics-actions/contacts). Execution is deterministic: scope (global, scene, component), explicit execution order, entity Asset ID, script Asset ID, then attachment order. Scripts communicate @@ -74,7 +72,7 @@ The global `rustic` table exposes `entity_id`, `get_translation`, `set_translati `find_entity`, `get_attribute`/`GetAttribute`, `edit_attribute`/`EditAttribute`, `input`, `log`, `delta_time`, `fixed_delta_time`, `get_property`, `set_property`, and `set_enabled`. The built-in attributes are `Name`, `Position`, `Size`, `Color`, -`CanTouch`, `CanCollide`, `Anchored`, and `Parent`. +`CanTouch`, `CanCollide`, `Anchored`, `Friction`, and `Parent`. Lua also provides `print(...)` for an info Console entry and `warn(...)` for a warning Console entry. Use `rustic.log(level, message)` to choose a different level. @@ -188,7 +186,7 @@ CSS alone is styling; HTML inline JavaScript has the JavaScript API. Each SDK handles omitted callbacks automatically. Supported names are `on_create`, `on_start`, `on_enable`, `fixed_update`, `update`, `on_disable`, `on_destroy`, and `on_stop`. Only frame callbacks take dt. Luau also accepts the capitalized Lua -aliases. External collision callbacks remain unavailable. API 1.1 adds shared +aliases. Collision subscriptions are available through `Physics.onCollisionEnter/Stay/Exit`; see [Contacts and slopes](/docs/api/physics-actions/contacts). API 1.1 adds shared `Events` to every supported script type, backed by the same scene services. See [Shared gameplay actions](Scripting/gameplayActions.md) for coverage and setup. Use `rustic.log` for game diagnostics and run scripts through Play. diff --git a/Engine/docs/PHYSICS.md b/Engine/docs/PHYSICS.md index d033991d..02bf3cc6 100644 --- a/Engine/docs/PHYSICS.md +++ b/Engine/docs/PHYSICS.md @@ -1,4 +1,4 @@ -# Basic physics +# Physics: forces, friction, contacts and slopes Built-in primitives simulate automatically in Play, New Window, and Standalone. No script or physics component is required. Edit mode never moves objects. Gravity @@ -29,7 +29,8 @@ Rectangle2D or Circle2D above it. Gravity still acts along world Y. | `Anchored = false` | The primitive falls, including when collision is disabled. Unanchoring starts with zero velocity after an anchored tick. | | `CanCollide = true` | Solid response occurs only when both primitives have this flag enabled. Anchored primitives can be floors or walls. | | `CanCollide = false` | The object passes through other objects; it still falls if unanchored. | -| `CanTouch` | Required for object clicks and touchscreen taps; it does not change solid response or enable collision callbacks. See [Touchscreen input and object clicks](/docs/api/touch-input). | +| `CanTouch` | Enables collision subscriptions when both bodies allow touch, plus object clicks and taps. Does not change solid response. | +| `Friction` | Finite coefficient from 0 to 10; default 0.5. Zero is frictionless. Set it in the Inspector or through `EditAttribute`. The contact coefficient is the geometric mean of both values. | | `Size` | Local transform scale, multiplied by primitive dimensions for collision bounds. | These flags are read every fixed tick, after all `FixedUpdate` script callbacks. @@ -69,17 +70,52 @@ globalThis.behavior = { Attach this version as a JavaScript Object Component instead of the Lua script. Scene paths can also target other objects; see the **Edit scene objects** guide. +## Forces, friction and slopes + +Use the falling-box scene above. Create a JavaScript **Object Component** in +Programming, attach it to `Box` with **+ Add Component**, and replace its source: + +```javascript +globalThis.behavior = { + Start() { + rustic.EditAttribute("Friction", 0.6); + Physics.onCollisionEnter(rustic.entity_id(), (other, normal) => { + print("Contact", other, "normal", normal); + }); + Physics.onCollisionExit(rustic.entity_id(), other => print("Left", other)); + Physics.impulse(rustic.entity_id(), [2, 0, 0]); + }, + FixedUpdate(dt) { + // Unit mass: an upward force of 9.81 cancels gravity over this interval. + // Physics.force(rustic.entity_id(), [0, 9.81, 0], dt); + const support = Physics.ground(rustic.entity_id(), 45); + if (support && support.walkable) { + // normal is world-space; angle is degrees from world up. + // Use support.normal to project controller movement along the surface. + } + } +}; +``` + +Press Play. The box falls, emits one enter callback, and slows on the floor. +Rotate the anchored floor about Z to make a ramp. With zero friction on either +body the box slides down the slope; sufficiently high friction holds it. There +is no automatic character stair stepping or slope-angle movement clamp. +`Physics.ground` reads contacts from the most recent completed fixed step, so it +is null before the first physics tick and may lag a scripted teleport by a tick. + +`Physics.force` adds `force * scaledDelta` to velocity. Call it once per +`FixedUpdate`, passing `dt` in languages requiring an explicit interval. Impulse +adds velocity immediately; launch replaces it. Applying force from every rendered +frame with a fixed interval produces frame-rate-dependent acceleration. + ## Current limits - Only built-in primitives participate. Imported mesh assets, cameras, lights, folders, and empty nodes have no physics bodies. Runtime-created primitives join the next fixed tick; removed primitives lose their velocity state. -- Collision uses world-aligned boxes enclosing primitive dimensions, transformed - by rotation, scale, and the hierarchy. Spheres and circles behave as boxes, torus - holes are solid, and rotated slopes do not produce true slope sliding. Thin planes - and 2D shapes have a minimum collision half-thickness of 0.025 units. -- Bodies have equal mass and translate without rotating, bouncing, friction, - joints, forces, configurable gravity, raycasts, or a velocity Script API. +- Collision uses oriented boxes enclosing primitive dimensions, transformed by rotation, scale, and the hierarchy. Rotated boxes and planes provide slope normals. Spheres and circles still behave as boxes and torus holes are solid. Sheared parent transforms fall back to world-aligned boxes. Planes and 2D shapes have a minimum collision half-thickness of 0.025 units. +- Bodies have equal unit mass and translate without rotating, bouncing, joints or configurable gravity. Coulomb friction reduces relative tangential motion and can hold bodies on shallow slopes. Live primitive raycasts, sphere casts, overlaps, forces, impulses and velocity changes are available through the Physics scripting API. - Swept box checks reduce tunnelling during simulated falls. Script teleports and script-moved anchored platforms are not swept. Fast multi-body impacts and large stacks are approximate; use separated starting positions and modest scene sizes. @@ -90,8 +126,7 @@ Scene paths can also target other objects; see the **Edit scene objects** guide. - Child bodies receive world-space gravity and are converted back to local positions. Anchored children still follow their parent's transform. For predictable independent rigid bodies, place them at scene root or under an organizational folder. -- No `OnCollisionEnter`, `OnCollisionStay`, or `OnCollisionExit` events are dispatched - by this simulator, even if a language adapter accepts those callback names. +- Collision callbacks use explicit `Physics.onCollisionEnter`, `Physics.onCollisionStay`, and `Physics.onCollisionExit` subscriptions. Merely declaring lifecycle functions named `OnCollisionEnter` does not register a subscription. See [Contacts and slopes](/docs/api/physics-actions/contacts). ## Diagnose problems @@ -102,7 +137,7 @@ Scene paths can also target other objects; see the **Edit scene objects** guide. - **The box falls through:** enable Can collide on both the box and floor. Imported models need a separate built-in primitive as a collision proxy for now. - **Objects hover or collide too early:** check primitive dimensions and transform - scale. Rotated or curved shapes use enclosing boxes rather than their surface. + scale. Curved primitives use enclosing boxes; sheared transforms use world-aligned enclosing boxes. - **The object vanishes:** check the camera view and floor placement. Without a collidable floor, a body continues falling beyond the camera. - **A script release fails:** verify attachment and language, and read Console diff --git a/Engine/docs/ROADMAP.md b/Engine/docs/ROADMAP.md index edf1e441..f4cfd794 100644 --- a/Engine/docs/ROADMAP.md +++ b/Engine/docs/ROADMAP.md @@ -178,8 +178,7 @@ loop is timed out/restarted without editor failure; no project-language setting - Implement initial Rapier rigid/static/kinematic bodies, colliders, triggers, layers, raycasts, fixed-step synchronization, and debug draw behind `physics-api`. Basic Play gravity and enclosing-box primitive collision are already implemented - in `engine-play`; see `PHYSICS.md`. Full rigid-body rotation, triggers, queries, - collision callbacks, and imported-mesh physics remain future work. + in `engine-play`, along with forces, friction, raycasts, contact subscriptions and slope support queries; see `PHYSICS.md`. Full rigid-body rotation, triggers and imported-mesh physics remain future work. - Implement WAV/MP3/AAC/FLAC/OGG 2D/3D playback, source/listener, attenuation, loop, bus, volume, pitch, and streaming music behind `audio-api`. - Implement native runtime labels, buttons, images, panels, text input, basic flex/grid diff --git a/Engine/docs/Scripting/API/physics-actions.md b/Engine/docs/Scripting/API/physics-actions.md index be32cc0f..2af203cb 100644 --- a/Engine/docs/Scripting/API/physics-actions.md +++ b/Engine/docs/Scripting/API/physics-actions.md @@ -41,9 +41,11 @@ globalThis.behavior = { ## Behavior and limitations - Attach to an unanchored collidable Part above an anchored collidable floor. The query reports the nearest hit; launch replaces the Part velocity with an upward velocity. -- Queries use primitive world AABBs including parent transforms. SphereCast uses rounded sphere/box contacts; model meshes do not automatically acquire primitive colliders. +- Raycasts and sphere casts use oriented primitive bounds including parent transforms; sphere casts retain rounded sphere/box contacts. Overlap and explosion candidate searches use conservative world AABBs. Sheared transforms use enclosing AABBs. Imported models require primitive collision proxies. - The current simulator uses unit mass. Impulse/knockback adds velocity; launch sets it; force integrates over its interval. Clock scaling affects physics. - Direct force, impulse or launch on an anchored or nonphysical object fails. Explosion uses linear radial falloff and skips anchored bodies. - See [Basic physics](/docs/guides/physics) for collider setup, gravity and collision limits. - Check script attachment, enabled state and the Console when an operation fails. Asynchronous errors also appear in operation state. Callback exceptions disable the owner and clean up its actions. - Owner destruction, disable, successful reload or Stop cleans up owned actions and subscriptions. Failed reload retains the previous behavior's operations. See [Operation handles](/docs/api/operation-handles) for controls and lifetime details. + +See [Collision callbacks and slope queries](/docs/api/physics-actions/contacts) for contact subscriptions, friction and ground support. diff --git a/Engine/docs/Scripting/API/physics-actions/contacts.md b/Engine/docs/Scripting/API/physics-actions/contacts.md new file mode 100644 index 00000000..a93109e0 --- /dev/null +++ b/Engine/docs/Scripting/API/physics-actions/contacts.md @@ -0,0 +1,100 @@ +# Collision callbacks and slope queries + +[Back to Physics APIs](/docs/api/physics-actions). + +## Setup + +Create an anchored, collidable Rectangular Prism floor and an unanchored Cube at +`[0, 5, 0]`. Enable **Can touch** on both. In Programming create an Object Component, +then select the cube and attach it using **+ Add Component**. Physics runs in Play, +New Window and Standalone; edit mode does not simulate. + +## Calls + +- `Physics.onCollisionEnter(entity, callback)` +- `Physics.onCollisionStay(entity, callback)` +- `Physics.onCollisionExit(entity, callback)` +- `Physics.ground(entity, maxAngle = 45)` +- `rustic.EditAttribute("Friction", coefficient)` and `rustic.GetAttribute("Friction")` + +Subscriptions return an Events connection; call `disconnect()` or +`Events.disconnect(connection)` to remove it. Owner disable, destruction, scene +unload and successful reload clean up subscriptions using the existing event rules. +Callbacks run during the next `Update` event dispatch, after fixed-step collision +resolution. Multiple fixed ticks can queue multiple stay notifications before a +rendered frame. Exceptions disable the behavior using the normal script error path. + +Each callback receives `(otherEntityId, normal)`. The ID is a stable string. The +normal is a three-component world vector pointing from the other body toward the +subscribed body. Exit reports the last contact normal; the other ID may refer to +an object that was removed. There is one enter/stay notification per touching pair +per fixed tick, mirrored to both bodies; substeps and solver iterations do not +duplicate notifications. Leaving contact, removing a collider, anchoring both bodies, +or disabling collision/touch produces exit on the next fixed tick. At least one +body must be dynamic, both must enable CanCollide, and both must enable CanTouch. +These are solid-contact callbacks; noncollidable triggers are not implemented. +Collision event names are reserved and cannot be emitted by scripts. + +`ground` returns null without an upward support contact, otherwise +`{entity, normal, angle, walkable}`. `angle` is degrees from world up; `walkable` +compares it with `maxAngle`, a finite number from 0 through 90. It chooses the +most upward-facing contact from the latest completed fixed step, independently +of CanTouch. It does not cast a ray, predict the next step, stop a body on a steep +slope, or implement stairs. Query after a fixed step has completed; startup has no +contacts yet. Friction and the solver produce sliding along rotated surfaces. + +## JavaScript example + +```javascript +globalThis.behavior = { + Start() { + rustic.EditAttribute("Friction", 0.5); + Physics.onCollisionEnter(rustic.entity_id(), (other, normal) => { + print("Landed on", other, normal); + }); + }, + Update() { + const support = Physics.ground(rustic.entity_id(), 45); + if (support && support.walkable) { + // Surface projection: tangent = movement - normal * dot(movement, normal). + } + } +}; +``` + +Press Play: one landing message appears, followed by stay events if subscribed. +Rotate the floor about Z to test ramps. Set friction to zero to observe sliding. +If no callback arrives, check attachment, Console errors, both flags on both +bodies, and that Update is running. If ground is null in Start, wait for a physics +tick. Merely defining `OnCollisionEnter` lifecycle functions does not subscribe. + +## Language bindings + +Lua 5.4 and Luau use `Physics.onCollisionEnter(id, function(other, normal) ... end)` +and `Physics.ground(id, 45)`; vectors are one-indexed arrays. JavaScript and HTML +inline scripts use the example above. Python uses the same method names and +`lambda other, normal: ...`. PHP uses `Physics::onCollisionEnter($id, $fn)` and +`Physics::ground($id, 45)`; a callback receives the two positional arguments. + +C++ uses `Physics::onCollisionEnter(id, [](const RusticValue::Array& args){ ... })` +and `Physics::ground(id, 45)`, which returns a RusticValue object or null. C# uses +`Physics.onCollisionEnter(id, args => { ... })` and `Physics.ground(id, 45)`, which +returns a JsonElement (test ValueKind for Null). Java uses the same calls with a +Consumer callback and returns an Object (null or Map). Native callback +arrays contain the other ID at index 0 and the normal at index 1. + +C uses `Physics.onCollisionEnter(id, fn)` where +`void fn(const RusticValue *args, size_t count)` receives those same two values. +`Physics.ground(id, 45)` returns `RusticGround` with `found`, `entity`, `normal`, +`angle` and `walkable`; check `found` before reading the other fields. C and Java +require an explicit maximum angle. The matching Stay/Exit methods use the same +callback signature in every language. + +The shared query protocol is +`{"op":"physics_ground","entity":"","max_angle":45}`. +The event subscription protocol is the existing `connect` gameplay request with +signal `{name:"CollisionEnter",source:""}` (or CollisionStay/Exit). +Friction accepts numeric values from 0 to 10 and rejects NaN/infinity. Old scenes +without a coefficient load with 0.5. These features use oriented box proxies; +curved primitives and meshes do not gain exact shape collision. Sheared transforms +fall back to enclosing AABBs. See [Basic physics](/docs/guides/physics). diff --git a/Engine/docs/Scripting/apiLanguages.md b/Engine/docs/Scripting/apiLanguages.md index 907323a0..b620b9ac 100644 --- a/Engine/docs/Scripting/apiLanguages.md +++ b/Engine/docs/Scripting/apiLanguages.md @@ -222,6 +222,4 @@ Find/List retain their existing snapshot semantics in external adapters. List re Keyboard input remains limited to held WASD, arrows and Shift in embedded Play. [Touchscreen input and object clicks](/docs/api/touch-input) provide multi-touch -input and CanTouch-gated click subscriptions in the embedded game view. Physics -collision contact callbacks are not dispatched. These are shared runtime limits -across languages. +input and CanTouch-gated click subscriptions in the embedded game view. Physics contact subscriptions and ground queries are available across languages; see [Contacts and slopes](/docs/api/physics-actions/contacts). diff --git a/Engine/docs/Scripting/gameplayActions.md b/Engine/docs/Scripting/gameplayActions.md index 5e0afa65..8c6863ee 100644 --- a/Engine/docs/Scripting/gameplayActions.md +++ b/Engine/docs/Scripting/gameplayActions.md @@ -394,7 +394,7 @@ end } `sphereCast` adds a radius and uses rounded sphere/box contacts; `overlap(center, radius, ignore?)` returns entity IDs. Hits contain entity, point, normal and distance. Direction is normalized by the engine. Queries use the simulation's authored -primitive colliders represented by world AABBs, including parent transforms. +primitive colliders represented by oriented boxes, including parent transforms. Overlaps and explosion candidate searches use conservative world AABBs. `force(object, vector, delta?)` applies force for the interval, `impulse` / `knockback` add velocity, and `launch` sets velocity. The current simulation uses unit mass. diff --git a/Engine/docs/Scripting/sceneObjects.md b/Engine/docs/Scripting/sceneObjects.md index 667bbb5e..8315795e 100644 --- a/Engine/docs/Scripting/sceneObjects.md +++ b/Engine/docs/Scripting/sceneObjects.md @@ -116,8 +116,9 @@ See [APIs in every language](/docs/guides/api-languages) for native signatures. | `Position` | three finite numbers | Local translation relative to parent | | `Size` | three finite numbers | Local transform scale | | `Color` | three finite numbers | RGB; preserves existing alpha | -| `CanTouch` | boolean | Enables object clicks/taps; does not affect solid response | +| `CanTouch` | boolean | Enables clicks/taps and collision subscriptions when both bodies allow touch; does not affect solid response | | `CanCollide` | boolean | Enables solid box collisions with other collidable primitives in Play | +| `Friction` | number, 0..10 | Contact friction coefficient; defaults to 0.5 | | `Anchored` | boolean | Prevents physics movement; unchecked primitives fall under gravity | | `Parent` | stable entity ID or null | Reparents the target; changes future path | @@ -127,8 +128,7 @@ path string. Material and Enabled are not supported attributes; `set_enabled` still affects the script owner. Physics runs after fixed-update callbacks. Imported meshes do not yet simulate; -curved and rotated primitives use enclosing boxes. Physics does not dispatch -collision callbacks. See the **Basic physics** guide for setup and limitations. +curved primitives use oriented enclosing boxes. Rotated planes and boxes support slopes. Register collision callbacks through `Physics.onCollisionEnter/Stay/Exit`; see [Contacts and slopes](/docs/api/physics-actions/contacts). See the **Basic physics** guide for setup and limitations. ## Diagnose failures diff --git a/Engine/docs/Scripting/scriptingC.md b/Engine/docs/Scripting/scriptingC.md index a0845a27..81a2908c 100644 --- a/Engine/docs/Scripting/scriptingC.md +++ b/Engine/docs/Scripting/scriptingC.md @@ -69,7 +69,7 @@ instance creation, and camera selection. Use native member syntax for your langu Mutations apply after the callback, in call order. Getters read the callback's snapshot, so a getter after a setter still reads the original state. Properties and attributes must retain their engine types; numbers must be finite. Attribute names -are `Name`, `Position`, `Size`, `Color`, `CanTouch`, `CanCollide`, `Anchored`, and +are `Name`, `Position`, `Size`, `Color`, `CanTouch`, `CanCollide`, `Anchored`, `Friction`, and `Parent`. Color is three RGB numbers. Instance parents must be stable entity IDs; resolve a path with `Game.scene.Find` first. Scene List returns IDs, not path strings. diff --git a/Engine/docs/Scripting/scriptingCSharp.md b/Engine/docs/Scripting/scriptingCSharp.md index 1121b220..d22e15dd 100644 --- a/Engine/docs/Scripting/scriptingCSharp.md +++ b/Engine/docs/Scripting/scriptingCSharp.md @@ -65,7 +65,7 @@ instance creation, and camera selection. Use native member syntax for your langu Mutations apply after the callback, in call order. Getters read the callback's snapshot, so a getter after a setter still reads the original state. Properties and attributes must retain their engine types; numbers must be finite. Attribute names -are `Name`, `Position`, `Size`, `Color`, `CanTouch`, `CanCollide`, `Anchored`, and +are `Name`, `Position`, `Size`, `Color`, `CanTouch`, `CanCollide`, `Anchored`, `Friction`, and `Parent`. Color is three RGB numbers. Instance parents must be stable entity IDs; resolve a path with `Game.scene.Find` first. Scene List returns IDs, not path strings. diff --git a/Engine/docs/Scripting/scriptingCpp.md b/Engine/docs/Scripting/scriptingCpp.md index c475892a..6bf40835 100644 --- a/Engine/docs/Scripting/scriptingCpp.md +++ b/Engine/docs/Scripting/scriptingCpp.md @@ -100,7 +100,7 @@ object names. The optional parent is an ID. These mutations are queued and appli after the callback, in call order, so creation does not return a new ID. Built-in attributes are `Name`, `Position`, `Size`, `Color`, `CanTouch`, -`CanCollide`, `Anchored`, and `Parent`. Property/attribute writes must retain the +`CanCollide`, `Anchored`, `Friction`, and `Parent`. Property/attribute writes must retain the existing engine type. A rejected command fails and disables only this behavior. ## Lifetime, safety, and compatibility diff --git a/Engine/docs/Scripting/scriptingJava.md b/Engine/docs/Scripting/scriptingJava.md index da8b1435..c6a65b1d 100644 --- a/Engine/docs/Scripting/scriptingJava.md +++ b/Engine/docs/Scripting/scriptingJava.md @@ -67,7 +67,7 @@ instance creation, and camera selection. Use native member syntax for your langu Mutations apply after the callback, in call order. Getters read the callback's snapshot, so a getter after a setter still reads the original state. Properties and attributes must retain their engine types; numbers must be finite. Attribute names -are `Name`, `Position`, `Size`, `Color`, `CanTouch`, `CanCollide`, `Anchored`, and +are `Name`, `Position`, `Size`, `Color`, `CanTouch`, `CanCollide`, `Anchored`, `Friction`, and `Parent`. Color is three RGB numbers. Instance parents must be stable entity IDs; resolve a path with `Game.scene.Find` first. Scene List returns IDs, not path strings. diff --git a/Engine/docs/Scripting/scriptingJavaScript.md b/Engine/docs/Scripting/scriptingJavaScript.md index c82af145..e4140c1f 100644 --- a/Engine/docs/Scripting/scriptingJavaScript.md +++ b/Engine/docs/Scripting/scriptingJavaScript.md @@ -66,7 +66,7 @@ to select a level dynamically. Lower-case `get_attribute`/`edit_attribute` are equivalent. Set `Color`, `Position`, and `Size` with three-number arrays, and `Parent` with an entity ID string or `null`. Supported built-ins are -`Name`, `Position`, `Size`, `Color`, `CanTouch`, `CanCollide`, `Anchored`, and +`Name`, `Position`, `Size`, `Color`, `CanTouch`, `CanCollide`, `Anchored`, `Friction`, and `Parent`. Properties must already be declared and writes must preserve their type. Mutations are queued and applied in issue order after the callback. diff --git a/Engine/docs/Scripting/scriptingLua.md b/Engine/docs/Scripting/scriptingLua.md index e69ecbfc..d4dce71e 100644 --- a/Engine/docs/Scripting/scriptingLua.md +++ b/Engine/docs/Scripting/scriptingLua.md @@ -87,7 +87,7 @@ needed. `get_attribute`/`GetAttribute` and `edit_attribute`/`EditAttribute` are aliases. Built-in attributes are `Name`, `Position`, `Size`, `Color`, `CanTouch`, -`CanCollide`, `Anchored`, and `Parent`. Attribute and property writes must match the +`CanCollide`, `Anchored`, `Friction`, and `Parent`. Attribute and property writes must match the existing value's type. Attribute setters accept scalar values, exactly three finite numbers in a table for `Position`, `Size`, and RGB `Color`, and a stable entity ID string or `nil` for `Parent`. Public-property setters retain their existing scalar diff --git a/Engine/docs/Scripting/scriptingLuau.md b/Engine/docs/Scripting/scriptingLuau.md index 0551f829..919fba5b 100644 --- a/Engine/docs/Scripting/scriptingLuau.md +++ b/Engine/docs/Scripting/scriptingLuau.md @@ -74,7 +74,7 @@ instance creation, and camera selection. Use native member syntax for your langu Mutations apply after the callback, in call order. Getters read the callback's snapshot, so a getter after a setter still reads the original state. Properties and attributes must retain their engine types; numbers must be finite. Attribute names -are `Name`, `Position`, `Size`, `Color`, `CanTouch`, `CanCollide`, `Anchored`, and +are `Name`, `Position`, `Size`, `Color`, `CanTouch`, `CanCollide`, `Anchored`, `Friction`, and `Parent`. Color is three RGB numbers. Instance parents must be stable entity IDs; resolve a path with `Game.scene.Find` first. Scene List returns IDs, not path strings. diff --git a/Engine/docs/Scripting/scriptingPHP.md b/Engine/docs/Scripting/scriptingPHP.md index 3faa3c2c..47b20b70 100644 --- a/Engine/docs/Scripting/scriptingPHP.md +++ b/Engine/docs/Scripting/scriptingPHP.md @@ -69,7 +69,7 @@ instance creation, and camera selection. Use native member syntax for your langu Mutations apply after the callback, in call order. Getters read the callback's snapshot, so a getter after a setter still reads the original state. Properties and attributes must retain their engine types; numbers must be finite. Attribute names -are `Name`, `Position`, `Size`, `Color`, `CanTouch`, `CanCollide`, `Anchored`, and +are `Name`, `Position`, `Size`, `Color`, `CanTouch`, `CanCollide`, `Anchored`, `Friction`, and `Parent`. Color is three RGB numbers. Instance parents must be stable entity IDs; resolve a path with `Game.scene.Find` first. Scene List returns IDs, not path strings. diff --git a/Engine/docs/Scripting/scriptingPython.md b/Engine/docs/Scripting/scriptingPython.md index 547fea4f..e30d1bc9 100644 --- a/Engine/docs/Scripting/scriptingPython.md +++ b/Engine/docs/Scripting/scriptingPython.md @@ -67,7 +67,7 @@ instance creation, and camera selection. Use native member syntax for your langu Mutations apply after the callback, in call order. Getters read the callback's snapshot, so a getter after a setter still reads the original state. Properties and attributes must retain their engine types; numbers must be finite. Attribute names -are `Name`, `Position`, `Size`, `Color`, `CanTouch`, `CanCollide`, `Anchored`, and +are `Name`, `Position`, `Size`, `Color`, `CanTouch`, `CanCollide`, `Anchored`, `Friction`, and `Parent`. Color is three RGB numbers. Instance parents must be stable entity IDs; resolve a path with `Game.scene.Find` first. Scene List returns IDs, not path strings.