Skip to main content

gizmo_scripting/
api_physics.rs

1//! Physics API — Lua'ya sunulan fizik sistemi fonksiyonları
2//!
3//! Kuvvet uygulama, raycast ve yerçekimi ayarı gibi işlemler için kullanılır.
4
5use crate::commands::{CommandQueue, ScriptCommand};
6use gizmo_math::Vec3;
7use mlua::prelude::*;
8use std::sync::Arc;
9use tracing::trace;
10
11/// Bir collider boyutunun alt sınırı. Script'ten gelen negatif/NaN/sonsuz/sıfır değerler
12/// (yazım hatası) bu değere kelepçelenir — dejenere AABB veya GJK'da NaN üretmesinler.
13const MIN_COLLIDER_DIM: f32 = 1e-4;
14
15/// Collider boyutunu güvene al: sonlu ve pozitif değilse küçük pozitif bir extent'e çek.
16/// `NaN`/`-inf`/`inf`/negatif/sıfır hepsi tek dalda `MIN_COLLIDER_DIM`'e düşer.
17fn sanitize_dim(v: f32) -> f32 {
18    if v.is_finite() && v > MIN_COLLIDER_DIM {
19        v
20    } else {
21        MIN_COLLIDER_DIM
22    }
23}
24
25/// Physics API fonksiyonlarını Lua'ya kaydeder
26pub fn register_physics_api(lua: &Lua, command_queue: Arc<CommandQueue>) -> Result<(), LuaError> {
27    crate::api_table::register_protected(lua, "physics", |physics_table| {
28
29    // === KUVVET UYGULA ===
30    {
31        let cq = command_queue.clone();
32        physics_table.raw_set(
33            "apply_force",
34            lua.create_function(move |_, (id, fx, fy, fz): (u32, f32, f32, f32)| {
35                cq.push(ScriptCommand::ApplyForce(id, Vec3::new(fx, fy, fz)));
36                Ok(())
37            })?,
38        )?;
39    }
40
41    // === İMPULS UYGULA ===
42    {
43        let cq = command_queue.clone();
44        physics_table.raw_set(
45            "apply_impulse",
46            lua.create_function(move |_, (id, ix, iy, iz): (u32, f32, f32, f32)| {
47                cq.push(ScriptCommand::ApplyImpulse(id, Vec3::new(ix, iy, iz)));
48                Ok(())
49            })?,
50        )?;
51    }
52
53    // === RIGIDBODY EKLE ===
54    {
55        let cq = command_queue.clone();
56        physics_table.raw_set(
57            "add_rigidbody",
58            lua.create_function(
59                // Contact friction/restitution live on the collider material, not
60                // the body, so `add_rigidbody` no longer takes them.
61                move |_, (id, mass, use_gravity): (u32, f32, bool)| {
62                    cq.push(ScriptCommand::AddRigidBody {
63                        id,
64                        mass,
65                        use_gravity,
66                    });
67                    Ok(())
68                },
69            )?,
70        )?;
71    }
72
73    // === COLLIDER EKLE ===
74    {
75        let cq = command_queue.clone();
76        physics_table.raw_set(
77            "add_box_collider",
78            lua.create_function(move |_, (id, hx, hy, hz): (u32, f32, f32, f32)| {
79                cq.push(ScriptCommand::AddBoxCollider {
80                    id,
81                    hx: sanitize_dim(hx),
82                    hy: sanitize_dim(hy),
83                    hz: sanitize_dim(hz),
84                });
85                Ok(())
86            })?,
87        )?;
88    }
89
90    {
91        let cq = command_queue.clone();
92        physics_table.raw_set(
93            "add_sphere_collider",
94            lua.create_function(move |_, (id, radius): (u32, f32)| {
95                cq.push(ScriptCommand::AddSphereCollider {
96                    id,
97                    radius: sanitize_dim(radius),
98                });
99                Ok(())
100            })?,
101        )?;
102    }
103
104        Ok(())
105    })
106}
107
108/// Install the **call-time** physics queries for the duration of one frame's script run.
109///
110/// # Why a scope
111///
112/// Everything else in this API is a per-frame *snapshot*: Rust copies what it has into a table and
113/// Lua reads it. That works for "what collided this frame" and cannot work for a parameterised
114/// question — "what is the ground height at (x, z)" has no answer to precompute, because the
115/// engine does not know which (x, z) the script will ask about. Answering it means running a
116/// raycast **while the script is calling**, from a closure that holds the world.
117///
118/// The record said that was blocked: with mlua's `send` feature, `Lua::create_function` demands
119/// `Fn(..) + Send + 'static`, and `&World` is neither. That is true of `Lua::create_function` and
120/// **not** of `Scope::create_function`, whose bound is `F: Fn(..) + 'scope` — no `Send`, no
121/// `'static`. A scoped closure may borrow the world, and mlua invalidates it when the scope ends,
122/// which is exactly the lifetime the borrow has.
123///
124/// So the frame looks like this: enter a scope, hand Lua a function that borrows the world, run
125/// the scripts, leave. Outside that window the function is gone rather than dangling — removed
126/// below, so a script that squirrelled the name away and called it later gets a plain "nil value"
127/// instead of an mlua error about a destructed callback.
128pub fn with_call_time_queries<R>(
129    lua: &Lua,
130    world: &gizmo_core::World,
131    run: impl FnOnce() -> Result<R, LuaError>,
132) -> Result<R, LuaError> {
133    let physics_table = crate::api_table::raw(lua, "physics")?;
134
135    let out = lua.scope(|scope| {
136        let ground_at = scope.create_function(|_, (x, z): (f32, f32)| {
137            // Straight down from well above the world; the height is where it lands.
138            let Ok(pw) = world.try_get_resource::<gizmo_physics_rigid::world::PhysicsWorld>()
139            else {
140                return Ok(LuaValue::Nil);
141            };
142            let ray = gizmo_physics_core::raycast::Ray::new(
143                gizmo_math::Vec3::new(x, GROUND_PROBE_HEIGHT, z),
144                gizmo_math::Vec3::new(0.0, -1.0, 0.0),
145            );
146            Ok(match pw.raycast(&ray, GROUND_PROBE_HEIGHT * 2.0) {
147                Some(hit) => LuaValue::Number(f64::from(hit.point.y)),
148                // Nothing under that point. `nil` rather than 0.0: a floor at height zero and no
149                // floor at all are different answers, and a script that cannot tell them apart
150                // will happily place something on a floor that is not there.
151                None => LuaValue::Nil,
152            })
153        })?;
154        physics_table.raw_set("ground_at", ground_at)?;
155        run()
156    });
157
158    // Whatever happened, the borrow is over — take the name with it.
159    physics_table.raw_set("ground_at", LuaValue::Nil)?;
160    out
161}
162
163/// How far above the query point the ground probe starts. High enough to clear any level geometry
164/// the engine is used for, and finite so the raycast has a bound.
165const GROUND_PROBE_HEIGHT: f32 = 10_000.0;
166
167/// Her frame güncel fizik olaylarını (Tetikleyiciler, Çarpışmalar) Lua'ya aktarır
168#[tracing::instrument(skip_all, name = "script_physics_read")]
169pub fn update_physics_api(
170    lua: &Lua,
171    world: &gizmo_core::World,
172) -> Result<(), LuaError> {
173    // The real table, not the global: the global is a read-only proxy so a script cannot
174    // rewrite the API (see `api_table`), and the engine's per-frame writes go behind it.
175    let physics_table = crate::api_table::raw(lua, "physics")?;
176    
177    let triggers = lua.create_table()?;
178    let collisions = lua.create_table()?;
179    
180    if let Ok(physics_world) = world.try_get_resource::<gizmo_physics_rigid::world::PhysicsWorld>() {
181        // Trigger (Tetikleyici) Olayları
182        for (i, t_event) in physics_world.trigger_events().iter().enumerate() {
183            let ev = lua.create_table()?;
184            ev.set("trigger_id", t_event.trigger_entity.id())?;
185            ev.set("other_id", t_event.other_entity.id())?;
186            let status = match t_event.event_type {
187                gizmo_physics_core::collision::CollisionEventType::Started => "enter",
188                gizmo_physics_core::collision::CollisionEventType::Persisting => "stay",
189                gizmo_physics_core::collision::CollisionEventType::Ended => "exit",
190            };
191            ev.set("status", status)?;
192            triggers.set(i + 1, ev)?;
193        }
194        
195        // Fiziksel Çarpışma Olayları
196        for (i, c_event) in physics_world.collision_events().iter().enumerate() {
197            let ev = lua.create_table()?;
198            ev.set("entity_a", c_event.entity_a.id())?;
199            ev.set("entity_b", c_event.entity_b.id())?;
200            let status = match c_event.event_type {
201                gizmo_physics_core::collision::CollisionEventType::Started => "enter",
202                gizmo_physics_core::collision::CollisionEventType::Persisting => "stay",
203                gizmo_physics_core::collision::CollisionEventType::Ended => "exit",
204            };
205            ev.set("status", status)?;
206            collisions.set(i + 1, ev)?;
207        }
208
209        trace!(
210            trigger_count = physics_world.trigger_events().len(),
211            collision_count = physics_world.collision_events().len(),
212            "[Scripting] fizik olayları Lua'ya aktarıldı"
213        );
214    } else {
215        // PhysicsWorld kaynağı yoksa listeler sessizce boş kalır; olay bekleyen bir
216        // script'in neden hiçbir şey almadığını anlamak için görünür kıl.
217        trace!("[Scripting] PhysicsWorld kaynağı yok — trigger/collision listeleri boş");
218    }
219
220    // Her frame listeleri güncelle
221    physics_table.raw_set("triggers", triggers)?;
222    physics_table.raw_set("collisions", collisions)?;
223    
224    Ok(())
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230    use gizmo_core::World;
231    use gizmo_physics_rigid::world::PhysicsWorld;
232    use gizmo_physics_core::collision::{TriggerEvent, CollisionEvent, CollisionEventType};
233    use mlua::Lua;
234
235    #[test]
236    fn test_update_physics_api() {
237        let lua = Lua::new();
238        // Register the API the way the engine does: the update path reads the real table from the
239        // registry, and a hand-built global is a different table that nothing writes to.
240        register_physics_api(&lua, Arc::new(CommandQueue::new())).unwrap();
241
242        let mut world = World::new();
243        let ent1 = world.spawn();
244        let ent2 = world.spawn();
245
246        let mut physics_world = PhysicsWorld::new();
247        physics_world.trigger_events.push(TriggerEvent {
248            trigger_entity: gizmo_physics_core::BodyHandle::from_id(ent1.id()),
249            other_entity: gizmo_physics_core::BodyHandle::from_id(ent2.id()),
250            event_type: CollisionEventType::Started,
251        });
252
253        physics_world.collision_events.push(CollisionEvent {
254            entity_a: gizmo_physics_core::BodyHandle::from_id(ent1.id()),
255            entity_b: gizmo_physics_core::BodyHandle::from_id(ent2.id()),
256            event_type: CollisionEventType::Started,
257            contact_points: Default::default(),
258        });
259
260        world.insert_resource(physics_world);
261
262        update_physics_api(&lua, &world).unwrap();
263
264        let script = r#"
265            local triggers = physics.triggers
266            local collisions = physics.collisions
267            assert(#triggers == 1)
268            assert(triggers[1].status == "enter")
269            assert(#collisions == 1)
270            assert(collisions[1].status == "enter")
271        "#;
272
273        lua.load(script).exec().unwrap();
274    }
275
276    #[test]
277    fn sanitize_dim_rejects_bad_values() {
278        assert_eq!(sanitize_dim(f32::NAN), MIN_COLLIDER_DIM);
279        assert_eq!(sanitize_dim(f32::INFINITY), MIN_COLLIDER_DIM);
280        assert_eq!(sanitize_dim(f32::NEG_INFINITY), MIN_COLLIDER_DIM);
281        assert_eq!(sanitize_dim(-5.0), MIN_COLLIDER_DIM);
282        assert_eq!(sanitize_dim(0.0), MIN_COLLIDER_DIM);
283        assert_eq!(sanitize_dim(2.5), 2.5); // valid values pass through
284    }
285
286    #[test]
287    fn box_collider_dims_are_sanitized_from_lua() {
288        let lua = Lua::new();
289        let cq = Arc::new(CommandQueue::new());
290        register_physics_api(&lua, cq.clone()).unwrap();
291        // hx negative, hy NaN (0/0), hz valid — script typo hardening.
292        lua.load("physics.add_box_collider(1, -5.0, 0/0, 2.0)").exec().unwrap();
293
294        let cmds = cq.drain();
295        assert_eq!(cmds.len(), 1);
296        match &cmds[0] {
297            ScriptCommand::AddBoxCollider { hx, hy, hz, .. } => {
298                assert!(hx.is_finite() && *hx > 0.0, "negative hx must be clamped, got {hx}");
299                assert!(hy.is_finite() && *hy > 0.0, "NaN hy must be clamped, got {hy}");
300                assert_eq!(*hz, 2.0, "valid hz must pass through untouched");
301            }
302            other => panic!("expected AddBoxCollider, got {other:?}"),
303        }
304    }
305
306    #[test]
307    fn sphere_collider_radius_is_sanitized_from_lua() {
308        let lua = Lua::new();
309        let cq = Arc::new(CommandQueue::new());
310        register_physics_api(&lua, cq.clone()).unwrap();
311        lua.load("physics.add_sphere_collider(7, -3.0)").exec().unwrap();
312
313        let cmds = cq.drain();
314        assert_eq!(cmds.len(), 1);
315        match &cmds[0] {
316            ScriptCommand::AddSphereCollider { radius, .. } => {
317                assert!(radius.is_finite() && *radius > 0.0, "radius clamped, got {radius}");
318            }
319            other => panic!("expected AddSphereCollider, got {other:?}"),
320        }
321    }
322
323    /// apply_force / apply_impulse / add_rigidbody Lua argümanlarını doğru komutlara çevirmeli.
324    #[test]
325    fn force_impulse_rigidbody_calls_push_expected_commands() {
326        let lua = Lua::new();
327        let cq = Arc::new(CommandQueue::new());
328        register_physics_api(&lua, cq.clone()).unwrap();
329
330        lua.load(
331            r#"
332            physics.apply_force(1, 0.0, -9.8, 0.0)
333            physics.apply_impulse(1, 10.0, 0.0, 0.0)
334            physics.add_rigidbody(1, 2.5, true)
335            "#,
336        )
337        .exec()
338        .unwrap();
339
340        let cmds = cq.drain();
341        assert_eq!(cmds.len(), 3);
342        assert!(matches!(cmds[0], ScriptCommand::ApplyForce(1, f) if f == Vec3::new(0.0, -9.8, 0.0)));
343        assert!(matches!(cmds[1], ScriptCommand::ApplyImpulse(1, i) if i == Vec3::new(10.0, 0.0, 0.0)));
344        match cmds[2] {
345            ScriptCommand::AddRigidBody { id, mass, use_gravity } => {
346                assert_eq!(id, 1);
347                assert!((mass - 2.5).abs() < 1e-6);
348                assert!(use_gravity);
349            }
350            ref other => panic!("beklenen AddRigidBody, gelen {other:?}"),
351        }
352    }
353
354    /// Geçerli (sonlu, pozitif) box boyutları dokunulmadan geçmeli.
355    #[test]
356    fn valid_box_dims_pass_through() {
357        let lua = Lua::new();
358        let cq = Arc::new(CommandQueue::new());
359        register_physics_api(&lua, cq.clone()).unwrap();
360        lua.load("physics.add_box_collider(1, 0.5, 1.0, 2.0)").exec().unwrap();
361
362        let cmds = cq.drain();
363        match &cmds[0] {
364            ScriptCommand::AddBoxCollider { hx, hy, hz, .. } => {
365                assert_eq!((*hx, *hy, *hz), (0.5, 1.0, 2.0));
366            }
367            other => panic!("beklenen AddBoxCollider, gelen {other:?}"),
368        }
369    }
370
371    /// PhysicsWorld kaynağı hiç yoksa update_physics_api panik etmemeli ve
372    /// triggers/collisions BOŞ tablolar olarak ayarlanmalı (belirsiz değil).
373    #[test]
374    fn update_without_physics_world_yields_empty_lists() {
375        let lua = Lua::new();
376        register_physics_api(&lua, Arc::new(CommandQueue::new())).unwrap();
377
378        let world = World::new(); // PhysicsWorld kaynağı eklenmedi
379        update_physics_api(&lua, &world).unwrap();
380
381        lua.load(
382            r#"
383            assert(type(physics.triggers) == "table", "triggers tablo olmalı")
384            assert(#physics.triggers == 0, "triggers boş olmalı")
385            assert(#physics.collisions == 0, "collisions boş olmalı")
386            "#,
387        )
388        .exec()
389        .unwrap();
390    }
391
392    /// Çarpışma olay tipi → durum string eşlemesi: Persisting="stay", Ended="exit".
393    #[test]
394    fn collision_event_status_maps_stay_and_exit() {
395        let lua = Lua::new();
396        register_physics_api(&lua, Arc::new(CommandQueue::new())).unwrap();
397
398        let mut world = World::new();
399        let a = world.spawn();
400        let b = world.spawn();
401        let mut pw = PhysicsWorld::new();
402        pw.collision_events.push(CollisionEvent {
403            entity_a: gizmo_physics_core::BodyHandle::from_id(a.id()),
404            entity_b: gizmo_physics_core::BodyHandle::from_id(b.id()),
405            event_type: CollisionEventType::Persisting,
406            contact_points: Default::default(),
407        });
408        pw.trigger_events.push(TriggerEvent {
409            trigger_entity: gizmo_physics_core::BodyHandle::from_id(a.id()),
410            other_entity: gizmo_physics_core::BodyHandle::from_id(b.id()),
411            event_type: CollisionEventType::Ended,
412        });
413        world.insert_resource(pw);
414
415        update_physics_api(&lua, &world).unwrap();
416
417        lua.load(
418            r#"
419            assert(physics.collisions[1].status == "stay", "Persisting -> stay")
420            assert(physics.triggers[1].status == "exit", "Ended -> exit")
421            "#,
422        )
423        .exec()
424        .unwrap();
425    }
426}