Skip to main content

mittens_engine/engine/ecs/system/
kinetic_response_system.rs

1use crate::engine::ecs::component::{
2    CollisionComponent, CollisionMode, CollisionShape, CollisionShapeComponent,
3    KineticResponseComponent, KineticResponseMode, RenderableComponent, TransformComponent,
4};
5use crate::engine::ecs::system::{CollisionSystem, TransformSystem};
6use crate::engine::ecs::{ComponentId, IntentValue, SignalEmitter, World};
7use crate::engine::graphics::VisualWorld;
8use crate::engine::user_input::InputState;
9use crate::utils::math;
10use std::collections::HashMap;
11
12#[derive(Debug, Default)]
13pub struct KineticResponseSystem {
14    responders: Vec<ComponentId>,
15}
16
17impl KineticResponseSystem {
18    pub fn new() -> Self {
19        Self::default()
20    }
21
22    pub fn register_kinetic_response(&mut self, world: &mut World, component: ComponentId) {
23        if !self.responders.iter().any(|c| *c == component) {
24            self.responders.push(component);
25        }
26
27        // Cache gravity coefficient from the nearest enabled GravityComponent ancestor.
28        let mut cur = component;
29        let mut gravity_coef = 0.0f32;
30        while let Some(parent) = world.parent_of(cur) {
31            if let Some(g) = world
32                .get_component_by_id_as::<crate::engine::ecs::component::GravityComponent>(parent)
33            {
34                if g.enabled {
35                    gravity_coef = g.coefficient;
36                    break;
37                }
38            }
39            cur = parent;
40        }
41
42        if let Some(r) = world.get_component_by_id_as_mut::<KineticResponseComponent>(component) {
43            r.gravity_coefficient = gravity_coef;
44        }
45    }
46
47    pub fn remove_kinetic_response(&mut self, component: ComponentId) {
48        self.responders.retain(|c| *c != component);
49    }
50
51    pub fn tick_with_queue(
52        &mut self,
53        world: &mut World,
54        _visuals: &mut VisualWorld,
55        _input: &InputState,
56        dt_sec: f32,
57        emit: &mut dyn SignalEmitter,
58        collision: &CollisionSystem,
59    ) {
60        if self.responders.is_empty() {
61            return;
62        }
63
64        let pairs = collision.active_pairs_with_delta_snapshot();
65
66        // Build an adjacency index once per tick so we don't scan all pairs per responder.
67        // Key: collider cid, Value: other colliders overlapping it.
68        // Note: pairs can be empty; Push mode still needs to integrate velocity.
69        let mut overlaps_by_collider: HashMap<ComponentId, Vec<(ComponentId, [f32; 3])>> =
70            HashMap::new();
71        if !pairs.is_empty() {
72            for (a, b, delta_ab) in pairs.iter().copied() {
73                // Store the vector from other -> self (so it matches `self_pos - other_pos`).
74                overlaps_by_collider
75                    .entry(a)
76                    .or_default()
77                    .push((b, [-delta_ab[0], -delta_ab[1], -delta_ab[2]]));
78                overlaps_by_collider
79                    .entry(b)
80                    .or_default()
81                    .push((a, delta_ab));
82            }
83        }
84
85        let responder_ids: Vec<ComponentId> = self.responders.iter().copied().collect();
86        let mut pending_updates: Vec<(ComponentId, ComponentId, [f32; 3], [f32; 3])> = Vec::new();
87
88        for response_cid in responder_ids {
89            let Some(response) =
90                world.get_component_by_id_as::<KineticResponseComponent>(response_cid)
91            else {
92                continue;
93            };
94            if !response.enabled {
95                continue;
96            }
97
98            // Required topology:
99            //   TransformComponent { CollisionComponent { KineticResponseComponent { ... } } }
100            let Some(collider_cid) = world.parent_of(response_cid) else {
101                continue;
102            };
103            if world
104                .get_component_by_id_as::<CollisionComponent>(collider_cid)
105                .is_none()
106            {
107                continue;
108            }
109
110            let Some(transform_cid) = world.parent_of(collider_cid) else {
111                continue;
112            };
113            if world
114                .get_component_by_id_as::<TransformComponent>(transform_cid)
115                .is_none()
116            {
117                continue;
118            }
119
120            // Only treat kinematic/rigged colliders as responders.
121            let Some(collider) = world.get_component_by_id_as::<CollisionComponent>(collider_cid)
122            else {
123                continue;
124            };
125            if collider.mode == CollisionMode::Static {
126                continue;
127            }
128
129            let gravity_coef = response.gravity_coefficient;
130
131            let overlaps: Vec<(ComponentId, [f32; 3])> = overlaps_by_collider
132                .get(&collider_cid)
133                .cloned()
134                .unwrap_or_default();
135
136            // Split overlaps into static and non-static colliders.
137            let mut statics: Vec<ComponentId> = Vec::new();
138            let mut non_statics: Vec<(ComponentId, [f32; 3])> = Vec::new();
139            for (other, delta_other_to_self) in overlaps {
140                let Some(c) = world.get_component_by_id_as::<CollisionComponent>(other) else {
141                    continue;
142                };
143                if c.mode == CollisionMode::Static {
144                    statics.push(other);
145                } else {
146                    non_statics.push((other, delta_other_to_self));
147                }
148            }
149
150            // Slide mode only does static separation; no overlaps means no work.
151            if response.mode == KineticResponseMode::Slide && statics.is_empty() {
152                continue;
153            }
154
155            let mut moved = false;
156            let mut desired_world_pos = match TransformSystem::world_position(world, transform_cid)
157            {
158                Some(p) => p,
159                None => continue,
160            };
161            let base_world_pos = desired_world_pos;
162
163            // Mode-dependent motion intent.
164            let mut velocity = response.velocity;
165
166            // Gravity is applied for any mode when GravityComponent is present.
167            if dt_sec > 0.0 && gravity_coef != 0.0 {
168                const GRAVITY_MPS2: f32 = -9.81;
169                velocity[1] += GRAVITY_MPS2 * gravity_coef * dt_sec;
170            }
171
172            if response.mode == KineticResponseMode::Push {
173                // Apply friction-like damping.
174                if dt_sec > 0.0 && response.friction > 0.0 {
175                    let k = (1.0 - response.friction * dt_sec).clamp(0.0, 1.0);
176                    velocity[0] *= k;
177                    velocity[1] *= k;
178                    velocity[2] *= k;
179                }
180
181                // Overlaps with non-static colliders produce acceleration away from them.
182                if !non_statics.is_empty() && dt_sec > 0.0 && response.push_strength != 0.0 {
183                    let mut sum = [0.0f32; 3];
184                    let mut n = 0.0f32;
185                    let self_motion = [
186                        desired_world_pos[0] - base_world_pos[0],
187                        desired_world_pos[1] - base_world_pos[1],
188                        desired_world_pos[2] - base_world_pos[2],
189                    ];
190
191                    for &(_other_cid, delta_other_to_self_at_base) in non_statics.iter() {
192                        // Adjust delta to reflect our integrated desired_world_pos.
193                        let adjusted = [
194                            delta_other_to_self_at_base[0] + self_motion[0],
195                            delta_other_to_self_at_base[1] + self_motion[1],
196                            delta_other_to_self_at_base[2] + self_motion[2],
197                        ];
198
199                        // Delta from other -> self (desired).
200                        sum[0] += adjusted[0];
201                        sum[1] += adjusted[1];
202                        sum[2] += adjusted[2];
203                        n += 1.0;
204                    }
205
206                    if n > 0.0 {
207                        let avg = [sum[0] / n, sum[1] / n, sum[2] / n];
208                        velocity[0] += avg[0] * response.push_strength * dt_sec;
209                        velocity[1] += avg[1] * response.push_strength * dt_sec;
210                        velocity[2] += avg[2] * response.push_strength * dt_sec;
211                    }
212                }
213
214                // Clamp speed.
215                if response.max_speed > 0.0 {
216                    let speed = (velocity[0] * velocity[0]
217                        + velocity[1] * velocity[1]
218                        + velocity[2] * velocity[2])
219                        .sqrt();
220                    if speed > response.max_speed {
221                        let s = response.max_speed / speed;
222                        velocity[0] *= s;
223                        velocity[1] *= s;
224                        velocity[2] *= s;
225                    }
226                }
227
228                // Integrate.
229                desired_world_pos[0] += velocity[0] * dt_sec;
230                desired_world_pos[1] += velocity[1] * dt_sec;
231                desired_world_pos[2] += velocity[2] * dt_sec;
232                moved = moved || velocity != [0.0, 0.0, 0.0];
233            }
234
235            // Slide mode typically does no integration; gravity-enabled colliders still need
236            // vertical integration so they can fall onto static geometry.
237            if response.mode == KineticResponseMode::Slide && dt_sec > 0.0 {
238                if velocity[1] != 0.0 {
239                    desired_world_pos[1] += velocity[1] * dt_sec;
240                    moved = true;
241                }
242            }
243
244            let a_shape = resolve_shape(world, collider_cid)
245                .unwrap_or_else(|| crate::engine::ecs::component::CollisionShape::CUBE());
246
247            // Static separation (applies to both Slide and Push modes).
248            for _ in 0..response.max_iterations {
249                let mut any_overlap = false;
250
251                for &static_cid in statics.iter() {
252                    let Some(static_parent) = world.parent_of(static_cid) else {
253                        continue;
254                    };
255                    if world
256                        .get_component_by_id_as::<TransformComponent>(static_parent)
257                        .is_none()
258                    {
259                        continue;
260                    }
261
262                    let b_world_pos = match TransformSystem::world_position(world, static_parent) {
263                        Some(p) => p,
264                        None => continue,
265                    };
266                    let b_shape = resolve_shape(world, static_cid)
267                        .unwrap_or_else(|| crate::engine::ecs::component::CollisionShape::CUBE());
268
269                    let Some(push) = compute_push_out_aabb(
270                        desired_world_pos,
271                        a_shape,
272                        b_world_pos,
273                        b_shape,
274                        response.push_out_epsilon,
275                    ) else {
276                        continue;
277                    };
278
279                    desired_world_pos[0] += push[0];
280                    desired_world_pos[1] += push[1];
281                    desired_world_pos[2] += push[2];
282
283                    let axis = if push[0] != 0.0 {
284                        0
285                    } else if push[1] != 0.0 {
286                        1
287                    } else {
288                        2
289                    };
290
291                    if axis == 1 {
292                        // Floor/ceiling contact: dampen vertical velocity only.
293                        if dt_sec > 0.0 && response.friction_y > 0.0 {
294                            let k = (1.0 - response.friction_y * dt_sec).clamp(0.0, 1.0);
295                            velocity[1] *= k;
296                        }
297                    } else if response.mode == KineticResponseMode::Push {
298                        // Push-mode "bounce" on horizontal static contacts.
299                        // Without this, a body with outward velocity will just keep trying to
300                        // move into the wall and get corrected every tick (looks like sticking).
301                        if velocity[axis] * push[axis] < 0.0 {
302                            const RESTITUTION: f32 = 0.85;
303                            velocity[axis] = -velocity[axis] * RESTITUTION;
304                        }
305                    }
306                    any_overlap = true;
307                }
308
309                if !any_overlap {
310                    break;
311                }
312                moved = true;
313            }
314
315            if !moved {
316                continue;
317            }
318
319            let new_local_translation =
320                world_to_local_translation(world, transform_cid, desired_world_pos);
321
322            pending_updates.push((response_cid, transform_cid, new_local_translation, velocity));
323        }
324
325        for (response_cid, transform_cid, new_local_translation, new_velocity) in pending_updates {
326            if let Some(r) =
327                world.get_component_by_id_as_mut::<KineticResponseComponent>(response_cid)
328            {
329                r.velocity = new_velocity;
330            }
331            if let Some(t) = world.get_component_by_id_as_mut::<TransformComponent>(transform_cid) {
332                t.transform.translation = new_local_translation;
333                t.transform.recompute_model();
334                let transform = t.transform;
335                emit.push_intent_now(
336                    transform_cid,
337                    IntentValue::UpdateTransform {
338                        component_ids: vec![transform_cid],
339                        translation: transform.translation,
340                        rotation_quat_xyzw: transform.rotation,
341                        scale: transform.scale,
342                    },
343                );
344            }
345        }
346    }
347}
348
349fn resolve_shape(world: &World, collision_cid: ComponentId) -> Option<CollisionShape> {
350    for child in world.children_of(collision_cid) {
351        if let Some(s) = world.get_component_by_id_as::<CollisionShapeComponent>(*child) {
352            return Some(s.shape);
353        }
354    }
355
356    let parent = world.parent_of(collision_cid)?;
357    for sib in world.children_of(parent) {
358        if *sib == collision_cid {
359            continue;
360        }
361        let Some(r) = world.get_component_by_id_as::<RenderableComponent>(*sib) else {
362            continue;
363        };
364
365        if r.renderable.base_mesh == crate::engine::graphics::primitives::CpuMeshHandle::CUBE {
366            return Some(CollisionShape::CUBE());
367        }
368        if r.renderable.base_mesh == crate::engine::graphics::primitives::CpuMeshHandle::SPHERE {
369            return Some(CollisionShape::SPHERE());
370        }
371    }
372
373    None
374}
375
376fn compute_push_out_aabb(
377    a_center: [f32; 3],
378    a_shape: CollisionShape,
379    b_center: [f32; 3],
380    b_shape: CollisionShape,
381    eps: f32,
382) -> Option<[f32; 3]> {
383    let (a_min, a_max) = aabb_world(a_center, a_shape);
384    let (b_min, b_max) = aabb_world(b_center, b_shape);
385
386    let overlap_x = f32::min(a_max[0], b_max[0]) - f32::max(a_min[0], b_min[0]);
387    let overlap_y = f32::min(a_max[1], b_max[1]) - f32::max(a_min[1], b_min[1]);
388    let overlap_z = f32::min(a_max[2], b_max[2]) - f32::max(a_min[2], b_min[2]);
389
390    if overlap_x <= 0.0 || overlap_y <= 0.0 || overlap_z <= 0.0 {
391        return None;
392    }
393
394    let mut axis = 0;
395    let mut min_overlap = overlap_x;
396    if overlap_y < min_overlap {
397        min_overlap = overlap_y;
398        axis = 1;
399    }
400    if overlap_z < min_overlap {
401        min_overlap = overlap_z;
402        axis = 2;
403    }
404
405    let mut out = [0.0f32; 3];
406    let dir = if a_center[axis] < b_center[axis] {
407        -1.0
408    } else {
409        1.0
410    };
411    out[axis] = dir * (min_overlap + eps);
412    Some(out)
413}
414
415fn aabb_world(center: [f32; 3], shape: CollisionShape) -> ([f32; 3], [f32; 3]) {
416    match shape {
417        CollisionShape::Cube { half_extents } => (
418            [
419                center[0] - half_extents[0],
420                center[1] - half_extents[1],
421                center[2] - half_extents[2],
422            ],
423            [
424                center[0] + half_extents[0],
425                center[1] + half_extents[1],
426                center[2] + half_extents[2],
427            ],
428        ),
429        CollisionShape::Sphere { radius } => (
430            [center[0] - radius, center[1] - radius, center[2] - radius],
431            [center[0] + radius, center[1] + radius, center[2] + radius],
432        ),
433    }
434}
435
436fn mat4_mul_vec4(m: [[f32; 4]; 4], v: [f32; 4]) -> [f32; 4] {
437    [
438        m[0][0] * v[0] + m[1][0] * v[1] + m[2][0] * v[2] + m[3][0] * v[3],
439        m[0][1] * v[0] + m[1][1] * v[1] + m[2][1] * v[2] + m[3][1] * v[3],
440        m[0][2] * v[0] + m[1][2] * v[1] + m[2][2] * v[2] + m[3][2] * v[3],
441        m[0][3] * v[0] + m[1][3] * v[1] + m[2][3] * v[2] + m[3][3] * v[3],
442    ]
443}
444
445fn world_to_local_translation(
446    world: &World,
447    transform_cid: ComponentId,
448    desired_world: [f32; 3],
449) -> [f32; 3] {
450    let mut cur = transform_cid;
451    while let Some(parent) = world.parent_of(cur) {
452        if let Some(t) = world.get_component_by_id_as::<TransformComponent>(parent) {
453            if let Some(inv) = math::mat4_inverse(t.transform.matrix_world) {
454                let p_local = mat4_mul_vec4(
455                    inv,
456                    [desired_world[0], desired_world[1], desired_world[2], 1.0],
457                );
458                return [p_local[0], p_local[1], p_local[2]];
459            }
460            break;
461        }
462        cur = parent;
463    }
464
465    desired_world
466}