Skip to main content

proof_engine/game/
arena_physics.rs

1//! Arena physics objects and environmental interactions for Chaos RPG.
2//!
3//! Provides a self-contained 2D physics world with broad-phase (sweep-and-prune),
4//! narrow-phase collision detection (circle-circle, circle-AABB, AABB-AABB),
5//! impulse-based collision response, raycasting, overlap tests, and a rich set
6//! of arena room types with interactive traps, treasure, and chaos rift mechanics.
7
8use glam::{Vec2, Vec3};
9use std::collections::{HashMap, BTreeMap};
10
11// ── Constants ────────────────────────────────────────────────────────────────
12
13const DEFAULT_GRAVITY: Vec2 = Vec2::new(0.0, -9.81);
14const DEFAULT_DAMPING: f32 = 0.99;
15const ANGULAR_DAMPING: f32 = 0.98;
16const POSITION_SLOP: f32 = 0.005;
17const POSITION_CORRECTION: f32 = 0.4;
18const SOLVER_ITERATIONS: usize = 8;
19
20// ── ObjectId ─────────────────────────────────────────────────────────────────
21
22/// Unique identifier for a physics object in the arena world.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
24pub struct ObjectId(pub u32);
25
26// ── CollisionShape ───────────────────────────────────────────────────────────
27
28/// Collision shape for arena physics objects.
29#[derive(Debug, Clone)]
30pub enum CollisionShape {
31    /// Circle with radius.
32    Circle { radius: f32 },
33    /// Axis-aligned bounding box with half-extents.
34    AABB { half_extents: Vec2 },
35    /// Capsule: cylinder capped with hemispheres.
36    Capsule { radius: f32, height: f32 },
37}
38
39impl CollisionShape {
40    /// Compute the local-space axis-aligned bounding box.
41    pub fn local_aabb(&self) -> AABB {
42        match self {
43            CollisionShape::Circle { radius } => AABB {
44                min: Vec2::new(-*radius, -*radius),
45                max: Vec2::new(*radius, *radius),
46            },
47            CollisionShape::AABB { half_extents } => AABB {
48                min: -*half_extents,
49                max: *half_extents,
50            },
51            CollisionShape::Capsule { radius, height } => {
52                let half_h = height * 0.5;
53                AABB {
54                    min: Vec2::new(-*radius, -half_h - *radius),
55                    max: Vec2::new(*radius, half_h + *radius),
56                }
57            }
58        }
59    }
60
61    /// Compute area for mass calculations.
62    pub fn area(&self) -> f32 {
63        match self {
64            CollisionShape::Circle { radius } => std::f32::consts::PI * radius * radius,
65            CollisionShape::AABB { half_extents } => 4.0 * half_extents.x * half_extents.y,
66            CollisionShape::Capsule { radius, height } => {
67                std::f32::consts::PI * radius * radius + 2.0 * radius * height
68            }
69        }
70    }
71}
72
73// ── AABB helper ──────────────────────────────────────────────────────────────
74
75/// Axis-aligned bounding box for broad-phase.
76#[derive(Debug, Clone, Copy)]
77pub struct AABB {
78    pub min: Vec2,
79    pub max: Vec2,
80}
81
82impl AABB {
83    pub fn new(min: Vec2, max: Vec2) -> Self {
84        Self { min, max }
85    }
86
87    pub fn from_center_half(center: Vec2, half: Vec2) -> Self {
88        Self {
89            min: center - half,
90            max: center + half,
91        }
92    }
93
94    pub fn overlaps(&self, other: &AABB) -> bool {
95        self.min.x <= other.max.x
96            && self.max.x >= other.min.x
97            && self.min.y <= other.max.y
98            && self.max.y >= other.min.y
99    }
100
101    pub fn contains_point(&self, p: Vec2) -> bool {
102        p.x >= self.min.x && p.x <= self.max.x && p.y >= self.min.y && p.y <= self.max.y
103    }
104
105    pub fn center(&self) -> Vec2 {
106        (self.min + self.max) * 0.5
107    }
108
109    pub fn half_extents(&self) -> Vec2 {
110        (self.max - self.min) * 0.5
111    }
112
113    /// Merge two AABBs.
114    pub fn union(&self, other: &AABB) -> Self {
115        Self {
116            min: self.min.min(other.min),
117            max: self.max.max(other.max),
118        }
119    }
120
121    /// Ray intersection. Returns t value or None.
122    pub fn ray_intersect(&self, origin: Vec2, dir: Vec2) -> Option<f32> {
123        let inv_d = Vec2::new(
124            if dir.x.abs() > 1e-12 { 1.0 / dir.x } else { f32::MAX },
125            if dir.y.abs() > 1e-12 { 1.0 / dir.y } else { f32::MAX },
126        );
127        let t1 = (self.min.x - origin.x) * inv_d.x;
128        let t2 = (self.max.x - origin.x) * inv_d.x;
129        let t3 = (self.min.y - origin.y) * inv_d.y;
130        let t4 = (self.max.y - origin.y) * inv_d.y;
131        let tmin = t1.min(t2).max(t3.min(t4));
132        let tmax = t1.max(t2).min(t3.max(t4));
133        if tmax < 0.0 || tmin > tmax {
134            None
135        } else {
136            Some(if tmin >= 0.0 { tmin } else { tmax })
137        }
138    }
139
140    /// Expand by a margin.
141    pub fn expand(&self, margin: f32) -> Self {
142        Self {
143            min: self.min - Vec2::splat(margin),
144            max: self.max + Vec2::splat(margin),
145        }
146    }
147}
148
149// ── PhysicsObject ────────────────────────────────────────────────────────────
150
151/// A physics object within the arena world.
152#[derive(Debug, Clone)]
153pub struct PhysicsObject {
154    pub position: Vec3,
155    pub velocity: Vec3,
156    pub angular_velocity: f32,
157    pub mass: f32,
158    pub restitution: f32,
159    pub friction: f32,
160    pub shape: CollisionShape,
161    pub is_static: bool,
162    pub is_trigger: bool,
163    pub collision_layer: u32,
164    // Internal fields
165    inv_mass: f32,
166    force_accum: Vec2,
167    torque_accum: f32,
168    angle: f32,
169}
170
171impl PhysicsObject {
172    /// Create a new dynamic physics object.
173    pub fn new(position: Vec3, mass: f32, shape: CollisionShape) -> Self {
174        let inv = if mass > 0.0 && !mass.is_infinite() {
175            1.0 / mass
176        } else {
177            0.0
178        };
179        Self {
180            position,
181            velocity: Vec3::ZERO,
182            angular_velocity: 0.0,
183            mass,
184            restitution: 0.3,
185            friction: 0.4,
186            shape,
187            is_static: false,
188            is_trigger: false,
189            collision_layer: 1,
190            inv_mass: inv,
191            force_accum: Vec2::ZERO,
192            torque_accum: 0.0,
193            angle: 0.0,
194        }
195    }
196
197    /// Create a static physics object (infinite mass, no movement).
198    pub fn new_static(position: Vec3, shape: CollisionShape) -> Self {
199        let mut obj = Self::new(position, 0.0, shape);
200        obj.is_static = true;
201        obj.inv_mass = 0.0;
202        obj
203    }
204
205    /// Create a trigger volume (no collision response, fires events).
206    pub fn new_trigger(position: Vec3, shape: CollisionShape) -> Self {
207        let mut obj = Self::new_static(position, shape);
208        obj.is_trigger = true;
209        obj
210    }
211
212    /// Position projected to 2D (XY plane).
213    pub fn pos2d(&self) -> Vec2 {
214        Vec2::new(self.position.x, self.position.y)
215    }
216
217    /// Velocity projected to 2D.
218    pub fn vel2d(&self) -> Vec2 {
219        Vec2::new(self.velocity.x, self.velocity.y)
220    }
221
222    /// Inverse mass (0 for static).
223    pub fn inv_mass(&self) -> f32 {
224        self.inv_mass
225    }
226
227    /// Compute world-space AABB.
228    pub fn world_aabb(&self) -> AABB {
229        let local = self.shape.local_aabb();
230        let pos = self.pos2d();
231        AABB {
232            min: local.min + pos,
233            max: local.max + pos,
234        }
235    }
236
237    /// Apply a force (accumulated, applied during integration).
238    pub fn apply_force(&mut self, force: Vec2) {
239        self.force_accum += force;
240    }
241
242    /// Apply a torque.
243    pub fn apply_torque(&mut self, torque: f32) {
244        self.torque_accum += torque;
245    }
246
247    /// Apply an impulse at the center of mass.
248    pub fn apply_impulse(&mut self, impulse: Vec2) {
249        if self.is_static {
250            return;
251        }
252        self.velocity.x += impulse.x * self.inv_mass;
253        self.velocity.y += impulse.y * self.inv_mass;
254    }
255
256    /// Apply an impulse at a contact point.
257    pub fn apply_impulse_at(&mut self, impulse: Vec2, contact_offset: Vec2) {
258        if self.is_static {
259            return;
260        }
261        self.velocity.x += impulse.x * self.inv_mass;
262        self.velocity.y += impulse.y * self.inv_mass;
263        self.angular_velocity += cross2d(contact_offset, impulse) * self.inv_mass;
264    }
265
266    /// Integrate forces and velocity (semi-implicit Euler).
267    fn integrate(&mut self, dt: f32, gravity: Vec2, damping: f32) {
268        if self.is_static {
269            return;
270        }
271        // Apply gravity
272        self.velocity.x += (gravity.x + self.force_accum.x * self.inv_mass) * dt;
273        self.velocity.y += (gravity.y + self.force_accum.y * self.inv_mass) * dt;
274        // Apply angular torque
275        self.angular_velocity += self.torque_accum * self.inv_mass * dt;
276        // Damping
277        self.velocity.x *= damping;
278        self.velocity.y *= damping;
279        self.angular_velocity *= ANGULAR_DAMPING;
280        // Integrate position
281        self.position.x += self.velocity.x * dt;
282        self.position.y += self.velocity.y * dt;
283        self.angle += self.angular_velocity * dt;
284        // Clear accumulators
285        self.force_accum = Vec2::ZERO;
286        self.torque_accum = 0.0;
287    }
288}
289
290// ── 2D math helpers ──────────────────────────────────────────────────────────
291
292/// 2D cross product (scalar).
293#[inline]
294fn cross2d(a: Vec2, b: Vec2) -> f32 {
295    a.x * b.y - a.y * b.x
296}
297
298/// Clamp a value between min and max.
299#[inline]
300fn clampf(val: f32, lo: f32, hi: f32) -> f32 {
301    val.max(lo).min(hi)
302}
303
304/// Closest point on line segment AB to point P.
305fn closest_point_on_segment(a: Vec2, b: Vec2, p: Vec2) -> Vec2 {
306    let ab = b - a;
307    let len_sq = ab.length_squared();
308    if len_sq < 1e-12 {
309        return a;
310    }
311    let t = clampf((p - a).dot(ab) / len_sq, 0.0, 1.0);
312    a + ab * t
313}
314
315// ── Collision contact ────────────────────────────────────────────────────────
316
317/// Contact information from narrow-phase collision.
318#[derive(Debug, Clone)]
319pub struct Contact {
320    /// Contact point in world space.
321    pub point: Vec2,
322    /// Contact normal (from A to B).
323    pub normal: Vec2,
324    /// Penetration depth (positive means overlapping).
325    pub penetration: f32,
326}
327
328// ── RayHit ───────────────────────────────────────────────────────────────────
329
330/// Result of a ray cast against the physics world.
331#[derive(Debug, Clone)]
332pub struct RayHit {
333    pub object_id: ObjectId,
334    pub point: Vec2,
335    pub normal: Vec2,
336    pub t: f32,
337}
338
339// ── Collision detection (narrow phase) ───────────────────────────────────────
340
341/// Test circle vs circle collision.
342fn collide_circle_circle(
343    pos_a: Vec2,
344    radius_a: f32,
345    pos_b: Vec2,
346    radius_b: f32,
347) -> Option<Contact> {
348    let delta = pos_b - pos_a;
349    let dist_sq = delta.length_squared();
350    let sum_r = radius_a + radius_b;
351    if dist_sq >= sum_r * sum_r {
352        return None;
353    }
354    let dist = dist_sq.sqrt();
355    let normal = if dist > 1e-6 {
356        delta / dist
357    } else {
358        Vec2::new(1.0, 0.0)
359    };
360    let penetration = sum_r - dist;
361    let point = pos_a + normal * (radius_a - penetration * 0.5);
362    Some(Contact {
363        point,
364        normal,
365        penetration,
366    })
367}
368
369/// Test circle vs AABB collision.
370fn collide_circle_aabb(
371    circle_pos: Vec2,
372    radius: f32,
373    aabb_pos: Vec2,
374    half_ext: Vec2,
375) -> Option<Contact> {
376    let center = aabb_pos + half_ext;
377    let local = circle_pos - center;
378    let clamped = Vec2::new(
379        clampf(local.x, -half_ext.x, half_ext.x),
380        clampf(local.y, -half_ext.y, half_ext.y),
381    );
382    let closest = center + clamped;
383    let delta = circle_pos - closest;
384    let dist_sq = delta.length_squared();
385    if dist_sq >= radius * radius {
386        return None;
387    }
388    let dist = dist_sq.sqrt();
389    let normal = if dist > 1e-6 {
390        delta / dist
391    } else {
392        // Circle center inside box — push out on shortest axis
393        let dx = half_ext.x - local.x.abs();
394        let dy = half_ext.y - local.y.abs();
395        if dx < dy {
396            Vec2::new(if local.x >= 0.0 { 1.0 } else { -1.0 }, 0.0)
397        } else {
398            Vec2::new(0.0, if local.y >= 0.0 { 1.0 } else { -1.0 })
399        }
400    };
401    let penetration = radius - dist;
402    let point = closest;
403    Some(Contact {
404        point,
405        normal,
406        penetration,
407    })
408}
409
410/// Test AABB vs AABB collision.
411fn collide_aabb_aabb(
412    pos_a: Vec2,
413    half_a: Vec2,
414    pos_b: Vec2,
415    half_b: Vec2,
416) -> Option<Contact> {
417    let delta = pos_b - pos_a;
418    let overlap_x = half_a.x + half_b.x - delta.x.abs();
419    if overlap_x <= 0.0 {
420        return None;
421    }
422    let overlap_y = half_a.y + half_b.y - delta.y.abs();
423    if overlap_y <= 0.0 {
424        return None;
425    }
426    let (normal, penetration) = if overlap_x < overlap_y {
427        (
428            Vec2::new(if delta.x >= 0.0 { 1.0 } else { -1.0 }, 0.0),
429            overlap_x,
430        )
431    } else {
432        (
433            Vec2::new(0.0, if delta.y >= 0.0 { 1.0 } else { -1.0 }),
434            overlap_y,
435        )
436    };
437    let point = pos_a + delta * 0.5;
438    Some(Contact {
439        point,
440        normal,
441        penetration,
442    })
443}
444
445/// General narrow-phase dispatch between two shapes.
446fn collide_shapes(
447    pos_a: Vec2,
448    shape_a: &CollisionShape,
449    pos_b: Vec2,
450    shape_b: &CollisionShape,
451) -> Option<Contact> {
452    match (shape_a, shape_b) {
453        (CollisionShape::Circle { radius: ra }, CollisionShape::Circle { radius: rb }) => {
454            collide_circle_circle(pos_a, *ra, pos_b, *rb)
455        }
456        (CollisionShape::Circle { radius }, CollisionShape::AABB { half_extents }) => {
457            collide_circle_aabb(pos_a, *radius, pos_b, *half_extents)
458        }
459        (CollisionShape::AABB { half_extents }, CollisionShape::Circle { radius }) => {
460            collide_circle_aabb(pos_b, *radius, pos_a, *half_extents).map(|mut c| {
461                c.normal = -c.normal;
462                c
463            })
464        }
465        (CollisionShape::AABB { half_extents: ha }, CollisionShape::AABB { half_extents: hb }) => {
466            collide_aabb_aabb(pos_a, *ha, pos_b, *hb)
467        }
468        // Capsule collisions — approximate as circle sweep (top and bottom circles + rect)
469        (CollisionShape::Capsule { radius, height }, other) => {
470            let half_h = height * 0.5;
471            let top = pos_a + Vec2::new(0.0, half_h);
472            let bot = pos_a - Vec2::new(0.0, half_h);
473            let circ = CollisionShape::Circle { radius: *radius };
474            let c1 = collide_shapes(top, &circ, pos_b, other);
475            let c2 = collide_shapes(bot, &circ, pos_b, other);
476            // Also test the middle rectangle
477            let rect = CollisionShape::AABB {
478                half_extents: Vec2::new(*radius, half_h),
479            };
480            let c3 = collide_shapes(pos_a, &rect, pos_b, other);
481            // Return deepest
482            [c1, c2, c3]
483                .into_iter()
484                .flatten()
485                .max_by(|a, b| a.penetration.partial_cmp(&b.penetration).unwrap())
486        }
487        (other, CollisionShape::Capsule { radius, height }) => {
488            let half_h = height * 0.5;
489            let top = pos_b + Vec2::new(0.0, half_h);
490            let bot = pos_b - Vec2::new(0.0, half_h);
491            let circ = CollisionShape::Circle { radius: *radius };
492            let c1 = collide_shapes(pos_a, other, top, &circ);
493            let c2 = collide_shapes(pos_a, other, bot, &circ);
494            let rect = CollisionShape::AABB {
495                half_extents: Vec2::new(*radius, half_h),
496            };
497            let c3 = collide_shapes(pos_a, other, pos_b, &rect);
498            [c1, c2, c3]
499                .into_iter()
500                .flatten()
501                .max_by(|a, b| a.penetration.partial_cmp(&b.penetration).unwrap())
502        }
503    }
504}
505
506/// Raycast against a single shape. Returns (t, normal).
507fn raycast_shape(
508    origin: Vec2,
509    dir: Vec2,
510    max_dist: f32,
511    pos: Vec2,
512    shape: &CollisionShape,
513) -> Option<(f32, Vec2)> {
514    match shape {
515        CollisionShape::Circle { radius } => {
516            let oc = origin - pos;
517            let a = dir.dot(dir);
518            let b = 2.0 * oc.dot(dir);
519            let c = oc.dot(oc) - radius * radius;
520            let disc = b * b - 4.0 * a * c;
521            if disc < 0.0 {
522                return None;
523            }
524            let sqrt_disc = disc.sqrt();
525            let t = (-b - sqrt_disc) / (2.0 * a);
526            if t < 0.0 || t > max_dist {
527                return None;
528            }
529            let hit = origin + dir * t;
530            let normal = (hit - pos).normalize_or_zero();
531            Some((t, normal))
532        }
533        CollisionShape::AABB { half_extents } => {
534            let aabb = AABB::from_center_half(pos, *half_extents);
535            aabb.ray_intersect(origin, dir).and_then(|t| {
536                if t > max_dist {
537                    return None;
538                }
539                let hit = origin + dir * t;
540                let local = hit - pos;
541                // Determine face normal
542                let nx = if (local.x.abs() - half_extents.x).abs() < 0.01 {
543                    if local.x > 0.0 { 1.0 } else { -1.0 }
544                } else {
545                    0.0
546                };
547                let ny = if (nx as f32).abs() < 0.5 {
548                    if local.y > 0.0 { 1.0 } else { -1.0 }
549                } else {
550                    0.0
551                };
552                Some((t, Vec2::new(nx, ny)))
553            })
554        }
555        CollisionShape::Capsule { radius, height } => {
556            let half_h = height * 0.5;
557            // Test top circle, bottom circle, and center rect
558            let top = pos + Vec2::new(0.0, half_h);
559            let bot = pos - Vec2::new(0.0, half_h);
560            let circ = CollisionShape::Circle { radius: *radius };
561            let rect = CollisionShape::AABB {
562                half_extents: Vec2::new(*radius, half_h),
563            };
564            let r1 = raycast_shape(origin, dir, max_dist, top, &circ);
565            let r2 = raycast_shape(origin, dir, max_dist, bot, &circ);
566            let r3 = raycast_shape(origin, dir, max_dist, pos, &rect);
567            [r1, r2, r3]
568                .into_iter()
569                .flatten()
570                .min_by(|a, b| a.0.partial_cmp(&b.0).unwrap())
571        }
572    }
573}
574
575// ── Collision pair ───────────────────────────────────────────────────────────
576
577/// A pair of objects that are colliding.
578#[derive(Debug, Clone)]
579pub struct CollisionPair {
580    pub id_a: ObjectId,
581    pub id_b: ObjectId,
582    pub contact: Contact,
583}
584
585// ── Trigger event ────────────────────────────────────────────────────────────
586
587/// Event fired when an object enters/exits a trigger volume.
588#[derive(Debug, Clone)]
589pub struct TriggerEvent {
590    pub trigger_id: ObjectId,
591    pub other_id: ObjectId,
592    pub is_enter: bool,
593}
594
595// ── Damage event ─────────────────────────────────────────────────────────────
596
597/// Damage event produced by traps hitting entities.
598#[derive(Debug, Clone)]
599pub struct DamageEvent {
600    pub source_description: String,
601    pub target_object_id: ObjectId,
602    pub damage: f32,
603    pub knockback: Vec2,
604}
605
606// ── PhysicsWorld ─────────────────────────────────────────────────────────────
607
608/// Core 2D physics simulation for the arena.
609pub struct PhysicsWorld {
610    objects: HashMap<ObjectId, PhysicsObject>,
611    next_id: u32,
612    pub gravity: Vec2,
613    pub damping: f32,
614    // Broad-phase sorted axis endpoints (object_id -> min_x for sweep-and-prune)
615    broad_cache: Vec<(ObjectId, f32, f32)>, // id, min_x, max_x
616    // Previous frame triggers for enter/exit detection
617    active_triggers: HashMap<(ObjectId, ObjectId), bool>,
618}
619
620impl PhysicsWorld {
621    pub fn new() -> Self {
622        Self {
623            objects: HashMap::new(),
624            next_id: 0,
625            gravity: DEFAULT_GRAVITY,
626            damping: DEFAULT_DAMPING,
627            broad_cache: Vec::new(),
628            active_triggers: HashMap::new(),
629        }
630    }
631
632    /// Add a physics object, returning its unique ID.
633    pub fn add_object(&mut self, obj: PhysicsObject) -> ObjectId {
634        let id = ObjectId(self.next_id);
635        self.next_id += 1;
636        self.objects.insert(id, obj);
637        id
638    }
639
640    /// Remove a physics object.
641    pub fn remove_object(&mut self, id: ObjectId) -> Option<PhysicsObject> {
642        // Clean up trigger state
643        self.active_triggers.retain(|k, _| k.0 != id && k.1 != id);
644        self.objects.remove(&id)
645    }
646
647    /// Get a reference to an object.
648    pub fn get_object(&self, id: ObjectId) -> Option<&PhysicsObject> {
649        self.objects.get(&id)
650    }
651
652    /// Get a mutable reference to an object.
653    pub fn get_object_mut(&mut self, id: ObjectId) -> Option<&mut PhysicsObject> {
654        self.objects.get_mut(&id)
655    }
656
657    /// Number of active objects.
658    pub fn object_count(&self) -> usize {
659        self.objects.len()
660    }
661
662    /// Iterate over all object IDs.
663    pub fn object_ids(&self) -> Vec<ObjectId> {
664        self.objects.keys().copied().collect()
665    }
666
667    /// Step the physics world forward by dt seconds.
668    ///
669    /// Returns (collision_pairs, trigger_events) from this step.
670    pub fn step(&mut self, dt: f32) -> (Vec<CollisionPair>, Vec<TriggerEvent>) {
671        // 1. Integrate velocities and positions
672        let gravity = self.gravity;
673        let damping = self.damping;
674        for obj in self.objects.values_mut() {
675            obj.integrate(dt, gravity, damping);
676        }
677
678        // 2. Broad phase: sweep-and-prune on X axis
679        self.broad_cache.clear();
680        for (&id, obj) in &self.objects {
681            let aabb = obj.world_aabb();
682            self.broad_cache.push((id, aabb.min.x, aabb.max.x));
683        }
684        self.broad_cache.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap());
685
686        let mut broad_pairs: Vec<(ObjectId, ObjectId)> = Vec::new();
687        for i in 0..self.broad_cache.len() {
688            let (id_a, _min_a, max_a) = self.broad_cache[i];
689            for j in (i + 1)..self.broad_cache.len() {
690                let (id_b, min_b, _max_b) = self.broad_cache[j];
691                if min_b > max_a {
692                    break; // No more overlaps on X for this object
693                }
694                // Also check Y overlap
695                let aabb_a = self.objects[&id_a].world_aabb();
696                let aabb_b = self.objects[&id_b].world_aabb();
697                if aabb_a.overlaps(&aabb_b) {
698                    broad_pairs.push((id_a, id_b));
699                }
700            }
701        }
702
703        // 3. Narrow phase + response
704        let mut collision_pairs = Vec::new();
705        let mut trigger_events = Vec::new();
706        let mut new_triggers: HashMap<(ObjectId, ObjectId), bool> = HashMap::new();
707
708        for (id_a, id_b) in &broad_pairs {
709            let obj_a = &self.objects[id_a];
710            let obj_b = &self.objects[id_b];
711
712            // Layer check
713            if obj_a.collision_layer & obj_b.collision_layer == 0 {
714                continue;
715            }
716
717            let pos_a = obj_a.pos2d();
718            let pos_b = obj_b.pos2d();
719
720            if let Some(contact) = collide_shapes(pos_a, &obj_a.shape, pos_b, &obj_b.shape) {
721                // Trigger handling
722                if obj_a.is_trigger || obj_b.is_trigger {
723                    let key = if *id_a < *id_b {
724                        (*id_a, *id_b)
725                    } else {
726                        (*id_b, *id_a)
727                    };
728                    new_triggers.insert(key, true);
729                    if !self.active_triggers.contains_key(&key) {
730                        trigger_events.push(TriggerEvent {
731                            trigger_id: if obj_a.is_trigger { *id_a } else { *id_b },
732                            other_id: if obj_a.is_trigger { *id_b } else { *id_a },
733                            is_enter: true,
734                        });
735                    }
736                    continue;
737                }
738
739                collision_pairs.push(CollisionPair {
740                    id_a: *id_a,
741                    id_b: *id_b,
742                    contact: contact.clone(),
743                });
744            }
745        }
746
747        // Trigger exit events
748        for (key, _) in &self.active_triggers {
749            if !new_triggers.contains_key(key) {
750                let obj_a = self.objects.get(&key.0);
751                let obj_b = self.objects.get(&key.1);
752                if let (Some(a), Some(b)) = (obj_a, obj_b) {
753                    trigger_events.push(TriggerEvent {
754                        trigger_id: if a.is_trigger { key.0 } else { key.1 },
755                        other_id: if a.is_trigger { key.1 } else { key.0 },
756                        is_enter: false,
757                    });
758                }
759            }
760        }
761        self.active_triggers = new_triggers;
762
763        // 4. Impulse resolution (sequential impulse solver)
764        for _iter in 0..SOLVER_ITERATIONS {
765            for pair in &collision_pairs {
766                self.resolve_collision(pair);
767            }
768        }
769
770        // 5. Position correction (Baumgarte stabilization)
771        for pair in &collision_pairs {
772            self.correct_positions(pair);
773        }
774
775        (collision_pairs, trigger_events)
776    }
777
778    /// Resolve collision impulse for a pair.
779    fn resolve_collision(&mut self, pair: &CollisionPair) {
780        let inv_a;
781        let inv_b;
782        let vel_a;
783        let vel_b;
784        let rest;
785        let fric;
786        {
787            let a = &self.objects[&pair.id_a];
788            let b = &self.objects[&pair.id_b];
789            inv_a = a.inv_mass;
790            inv_b = b.inv_mass;
791            vel_a = a.vel2d();
792            vel_b = b.vel2d();
793            rest = (a.restitution + b.restitution) * 0.5;
794            fric = (a.friction + b.friction) * 0.5;
795        }
796
797        if inv_a + inv_b < 1e-12 {
798            return; // Both static
799        }
800
801        let n = pair.contact.normal;
802        let rel_vel = vel_b - vel_a;
803        let vel_along_normal = rel_vel.dot(n);
804
805        // Don't resolve if separating
806        if vel_along_normal > 0.0 {
807            return;
808        }
809
810        // Impulse magnitude
811        let j = -(1.0 + rest) * vel_along_normal / (inv_a + inv_b);
812        let impulse = n * j;
813
814        // Apply impulses
815        if let Some(a) = self.objects.get_mut(&pair.id_a) {
816            a.apply_impulse(-impulse);
817        }
818        if let Some(b) = self.objects.get_mut(&pair.id_b) {
819            b.apply_impulse(impulse);
820        }
821
822        // Friction impulse
823        let vel_a2;
824        let vel_b2;
825        {
826            let a = &self.objects[&pair.id_a];
827            let b = &self.objects[&pair.id_b];
828            vel_a2 = a.vel2d();
829            vel_b2 = b.vel2d();
830        }
831        let rel_vel2 = vel_b2 - vel_a2;
832        let tangent = rel_vel2 - n * rel_vel2.dot(n);
833        let tangent_len = tangent.length();
834        if tangent_len > 1e-6 {
835            let tangent_norm = tangent / tangent_len;
836            let jt = -rel_vel2.dot(tangent_norm) / (inv_a + inv_b);
837            let friction_impulse = if jt.abs() < j * fric {
838                tangent_norm * jt
839            } else {
840                tangent_norm * (-j * fric)
841            };
842            if let Some(a) = self.objects.get_mut(&pair.id_a) {
843                a.apply_impulse(-friction_impulse);
844            }
845            if let Some(b) = self.objects.get_mut(&pair.id_b) {
846                b.apply_impulse(friction_impulse);
847            }
848        }
849    }
850
851    /// Apply position correction to prevent sinking.
852    fn correct_positions(&mut self, pair: &CollisionPair) {
853        let inv_a;
854        let inv_b;
855        {
856            let a = &self.objects[&pair.id_a];
857            let b = &self.objects[&pair.id_b];
858            inv_a = a.inv_mass;
859            inv_b = b.inv_mass;
860        }
861        let total_inv = inv_a + inv_b;
862        if total_inv < 1e-12 {
863            return;
864        }
865        let pen = pair.contact.penetration;
866        if pen <= POSITION_SLOP {
867            return;
868        }
869        let correction = pair.contact.normal * (POSITION_CORRECTION * (pen - POSITION_SLOP) / total_inv);
870        if let Some(a) = self.objects.get_mut(&pair.id_a) {
871            a.position.x -= correction.x * inv_a;
872            a.position.y -= correction.y * inv_a;
873        }
874        if let Some(b) = self.objects.get_mut(&pair.id_b) {
875            b.position.x += correction.x * inv_b;
876            b.position.y += correction.y * inv_b;
877        }
878    }
879
880    /// Cast a ray into the world.
881    pub fn raycast(&self, origin: Vec2, dir: Vec2, max_dist: f32) -> Option<RayHit> {
882        let dir_norm = if dir.length_squared() > 1e-12 {
883            dir.normalize()
884        } else {
885            return None;
886        };
887
888        let mut best: Option<RayHit> = None;
889        for (&id, obj) in &self.objects {
890            if obj.is_trigger {
891                continue;
892            }
893            if let Some((t, normal)) = raycast_shape(origin, dir_norm, max_dist, obj.pos2d(), &obj.shape) {
894                if best.as_ref().map_or(true, |b| t < b.t) {
895                    best = Some(RayHit {
896                        object_id: id,
897                        point: origin + dir_norm * t,
898                        normal,
899                        t,
900                    });
901                }
902            }
903        }
904        best
905    }
906
907    /// Test for all objects overlapping a given shape at a position.
908    pub fn overlap_test(&self, shape: &CollisionShape, pos: Vec2) -> Vec<ObjectId> {
909        let mut results = Vec::new();
910        for (&id, obj) in &self.objects {
911            let obj_pos = obj.pos2d();
912            if collide_shapes(pos, shape, obj_pos, &obj.shape).is_some() {
913                results.push(id);
914            }
915        }
916        results
917    }
918
919    /// Apply a radial force from a point (explosion, vortex).
920    pub fn apply_radial_force(&mut self, center: Vec2, radius: f32, strength: f32) {
921        for obj in self.objects.values_mut() {
922            if obj.is_static {
923                continue;
924            }
925            let delta = obj.pos2d() - center;
926            let dist = delta.length();
927            if dist < radius && dist > 1e-6 {
928                let falloff = 1.0 - dist / radius;
929                let force = delta.normalize() * strength * falloff;
930                obj.apply_force(force);
931            }
932        }
933    }
934
935    /// Apply a vortex (pull toward center with tangential spin).
936    pub fn apply_vortex_force(&mut self, center: Vec2, radius: f32, pull_strength: f32, spin_strength: f32) {
937        for obj in self.objects.values_mut() {
938            if obj.is_static {
939                continue;
940            }
941            let delta = obj.pos2d() - center;
942            let dist = delta.length();
943            if dist < radius && dist > 1e-6 {
944                let falloff = 1.0 - dist / radius;
945                let dir = delta.normalize();
946                let tangent = Vec2::new(-dir.y, dir.x);
947                let force = -dir * pull_strength * falloff + tangent * spin_strength * falloff;
948                obj.apply_force(force);
949            }
950        }
951    }
952}
953
954// ── Room types ───────────────────────────────────────────────────────────────
955
956/// Type of arena room.
957#[derive(Debug, Clone, Copy, PartialEq, Eq)]
958pub enum RoomType {
959    Normal,
960    Trap,
961    Treasure,
962    Boss,
963    ChaosRift,
964    Shop,
965}
966
967/// Exit descriptor.
968#[derive(Debug, Clone)]
969pub struct RoomExit {
970    pub position: Vec2,
971    pub direction: Vec2,
972    pub target_room: Option<u32>,
973}
974
975/// A room within the arena dungeon.
976#[derive(Debug, Clone)]
977pub struct ArenaRoom {
978    pub room_type: RoomType,
979    pub bounds: AABB,
980    pub physics_objects: Vec<ObjectId>,
981    pub spawn_points: Vec<Vec2>,
982    pub exits: Vec<RoomExit>,
983    pub room_id: u32,
984}
985
986impl ArenaRoom {
987    pub fn new(room_id: u32, room_type: RoomType, bounds: AABB) -> Self {
988        Self {
989            room_type,
990            bounds,
991            physics_objects: Vec::new(),
992            spawn_points: Vec::new(),
993            exits: Vec::new(),
994            room_id,
995        }
996    }
997
998    /// Add a spawn point.
999    pub fn add_spawn_point(&mut self, point: Vec2) {
1000        self.spawn_points.push(point);
1001    }
1002
1003    /// Add an exit.
1004    pub fn add_exit(&mut self, exit: RoomExit) {
1005        self.exits.push(exit);
1006    }
1007
1008    /// Register a physics object as belonging to this room.
1009    pub fn register_object(&mut self, id: ObjectId) {
1010        self.physics_objects.push(id);
1011    }
1012
1013    /// Check if a point is within room bounds.
1014    pub fn contains_point(&self, p: Vec2) -> bool {
1015        self.bounds.contains_point(p)
1016    }
1017
1018    /// Get center of the room.
1019    pub fn center(&self) -> Vec2 {
1020        self.bounds.center()
1021    }
1022
1023    /// Get room dimensions.
1024    pub fn dimensions(&self) -> Vec2 {
1025        self.bounds.max - self.bounds.min
1026    }
1027}
1028
1029// ── Trap System ──────────────────────────────────────────────────────────────
1030
1031/// State of a swinging pendulum trap.
1032#[derive(Debug, Clone)]
1033pub struct SwingingPendulum {
1034    pub pivot: Vec2,
1035    pub rope_length: f32,
1036    pub bob_mass: f32,
1037    pub angular_position: f32,
1038    pub angular_velocity: f32,
1039    pub damage: f32,
1040    pub bob_radius: f32,
1041    pub physics_object_id: Option<ObjectId>,
1042}
1043
1044impl SwingingPendulum {
1045    pub fn new(pivot: Vec2, rope_length: f32, bob_mass: f32, initial_angle: f32, damage: f32) -> Self {
1046        Self {
1047            pivot,
1048            rope_length,
1049            bob_mass,
1050            angular_position: initial_angle,
1051            angular_velocity: 0.0,
1052            damage,
1053            bob_radius: 0.5,
1054            physics_object_id: None,
1055        }
1056    }
1057
1058    /// Bob position in world space.
1059    pub fn bob_position(&self) -> Vec2 {
1060        self.pivot
1061            + Vec2::new(
1062                self.angular_position.sin() * self.rope_length,
1063                -self.angular_position.cos() * self.rope_length,
1064            )
1065    }
1066
1067    /// Update using gravity-driven oscillation.
1068    pub fn update(&mut self, dt: f32, gravity: f32) {
1069        // Angular acceleration: alpha = -(g / L) * sin(theta)
1070        let alpha = -(gravity / self.rope_length) * self.angular_position.sin();
1071        self.angular_velocity += alpha * dt;
1072        // Light damping to simulate air resistance
1073        self.angular_velocity *= 0.999;
1074        self.angular_position += self.angular_velocity * dt;
1075    }
1076
1077    /// Sync the physics object position in the world.
1078    pub fn sync_physics(&self, world: &mut PhysicsWorld) {
1079        if let Some(id) = self.physics_object_id {
1080            if let Some(obj) = world.get_object_mut(id) {
1081                let pos = self.bob_position();
1082                obj.position.x = pos.x;
1083                obj.position.y = pos.y;
1084            }
1085        }
1086    }
1087
1088    /// Spawn the physics object into the world.
1089    pub fn spawn(&mut self, world: &mut PhysicsWorld) -> ObjectId {
1090        let pos = self.bob_position();
1091        let mut obj = PhysicsObject::new(
1092            Vec3::new(pos.x, pos.y, 0.0),
1093            self.bob_mass,
1094            CollisionShape::Circle {
1095                radius: self.bob_radius,
1096            },
1097        );
1098        obj.is_static = true; // Kinematically driven
1099        let id = world.add_object(obj);
1100        self.physics_object_id = Some(id);
1101        id
1102    }
1103}
1104
1105/// State of a falling rock.
1106#[derive(Debug, Clone)]
1107pub struct FallingRock {
1108    pub object_id: ObjectId,
1109    pub has_shattered: bool,
1110    pub debris_ids: Vec<ObjectId>,
1111}
1112
1113/// Falling rocks trap system.
1114#[derive(Debug, Clone)]
1115pub struct FallingRocks {
1116    pub spawn_positions: Vec<Vec2>,
1117    pub trigger_zone: AABB,
1118    pub rock_mass: f32,
1119    pub rock_radius: f32,
1120    pub damage: f32,
1121    pub is_triggered: bool,
1122    pub active_rocks: Vec<FallingRock>,
1123    pub debris_lifetime: f32,
1124    pub debris_timer: f32,
1125    pub shatter_threshold_velocity: f32,
1126}
1127
1128impl FallingRocks {
1129    pub fn new(spawn_positions: Vec<Vec2>, trigger_zone: AABB, damage: f32) -> Self {
1130        Self {
1131            spawn_positions,
1132            trigger_zone,
1133            rock_mass: 5.0,
1134            rock_radius: 0.4,
1135            damage,
1136            is_triggered: false,
1137            active_rocks: Vec::new(),
1138            debris_lifetime: 3.0,
1139            debris_timer: 0.0,
1140            shatter_threshold_velocity: 5.0,
1141        }
1142    }
1143
1144    /// Trigger the rock fall.
1145    pub fn trigger(&mut self, world: &mut PhysicsWorld) {
1146        if self.is_triggered {
1147            return;
1148        }
1149        self.is_triggered = true;
1150        for &pos in &self.spawn_positions {
1151            let obj = PhysicsObject::new(
1152                Vec3::new(pos.x, pos.y, 0.0),
1153                self.rock_mass,
1154                CollisionShape::Circle {
1155                    radius: self.rock_radius,
1156                },
1157            );
1158            let id = world.add_object(obj);
1159            self.active_rocks.push(FallingRock {
1160                object_id: id,
1161                has_shattered: false,
1162                debris_ids: Vec::new(),
1163            });
1164        }
1165    }
1166
1167    /// Update: check for floor impacts and spawn debris.
1168    pub fn update(&mut self, dt: f32, world: &mut PhysicsWorld, floor_y: f32) {
1169        if !self.is_triggered {
1170            return;
1171        }
1172
1173        // Check for shattering
1174        for rock in &mut self.active_rocks {
1175            if rock.has_shattered {
1176                continue;
1177            }
1178            if let Some(obj) = world.get_object(rock.object_id) {
1179                let vel = obj.vel2d().length();
1180                let at_floor = obj.position.y <= floor_y + self.rock_radius * 2.0;
1181                if at_floor && vel < self.shatter_threshold_velocity {
1182                    rock.has_shattered = true;
1183                    // Spawn debris
1184                    let pos = obj.pos2d();
1185                    let debris_count = 4;
1186                    for i in 0..debris_count {
1187                        let angle =
1188                            (i as f32 / debris_count as f32) * std::f32::consts::TAU;
1189                        let dir = Vec2::new(angle.cos(), angle.sin());
1190                        let mut debris = PhysicsObject::new(
1191                            Vec3::new(pos.x, pos.y, 0.0),
1192                            self.rock_mass * 0.15,
1193                            CollisionShape::Circle {
1194                                radius: self.rock_radius * 0.3,
1195                            },
1196                        );
1197                        debris.velocity.x = dir.x * 3.0;
1198                        debris.velocity.y = dir.y * 3.0 + 2.0;
1199                        debris.restitution = 0.5;
1200                        let did = world.add_object(debris);
1201                        rock.debris_ids.push(did);
1202                    }
1203                }
1204            }
1205        }
1206
1207        // Debris lifetime
1208        if self.active_rocks.iter().any(|r| !r.debris_ids.is_empty()) {
1209            self.debris_timer += dt;
1210            if self.debris_timer >= self.debris_lifetime {
1211                for rock in &mut self.active_rocks {
1212                    for &did in &rock.debris_ids {
1213                        world.remove_object(did);
1214                    }
1215                    rock.debris_ids.clear();
1216                }
1217                self.debris_timer = 0.0;
1218            }
1219        }
1220    }
1221
1222    /// Check if a point is in the trigger zone.
1223    pub fn check_trigger(&self, point: Vec2) -> bool {
1224        !self.is_triggered && self.trigger_zone.contains_point(point)
1225    }
1226}
1227
1228/// Spike pit trap.
1229#[derive(Debug, Clone)]
1230pub struct SpikePit {
1231    pub trigger_zone: AABB,
1232    pub spike_positions: Vec<Vec2>,
1233    pub spike_heights: Vec<f32>,
1234    pub spike_velocities: Vec<f32>,
1235    pub target_height: f32,
1236    pub spring_constant: f32,
1237    pub spring_damping: f32,
1238    pub damage_per_second: f32,
1239    pub is_active: bool,
1240    pub object_ids: Vec<ObjectId>,
1241}
1242
1243impl SpikePit {
1244    pub fn new(trigger_zone: AABB, spike_count: usize, target_height: f32, damage: f32) -> Self {
1245        let width = trigger_zone.max.x - trigger_zone.min.x;
1246        let spacing = width / (spike_count as f32 + 1.0);
1247        let base_y = trigger_zone.min.y;
1248        let positions: Vec<Vec2> = (0..spike_count)
1249            .map(|i| {
1250                Vec2::new(
1251                    trigger_zone.min.x + spacing * (i as f32 + 1.0),
1252                    base_y,
1253                )
1254            })
1255            .collect();
1256        Self {
1257            trigger_zone,
1258            spike_positions: positions,
1259            spike_heights: vec![0.0; spike_count],
1260            spike_velocities: vec![0.0; spike_count],
1261            target_height,
1262            spring_constant: 200.0,
1263            spring_damping: 8.0,
1264            damage_per_second: damage,
1265            is_active: false,
1266            object_ids: Vec::new(),
1267        }
1268    }
1269
1270    /// Activate the spikes.
1271    pub fn activate(&mut self) {
1272        self.is_active = true;
1273    }
1274
1275    /// Update spike physics (spring-damper system with overshoot).
1276    pub fn update(&mut self, dt: f32, world: &mut PhysicsWorld) {
1277        if !self.is_active {
1278            return;
1279        }
1280        for i in 0..self.spike_heights.len() {
1281            let displacement = self.target_height - self.spike_heights[i];
1282            let spring_force = self.spring_constant * displacement;
1283            let damping_force = -self.spring_damping * self.spike_velocities[i];
1284            let accel = spring_force + damping_force;
1285            self.spike_velocities[i] += accel * dt;
1286            self.spike_heights[i] += self.spike_velocities[i] * dt;
1287            // Clamp to not go below ground
1288            if self.spike_heights[i] < 0.0 {
1289                self.spike_heights[i] = 0.0;
1290                self.spike_velocities[i] = 0.0;
1291            }
1292
1293            // Sync physics objects
1294            if i < self.object_ids.len() {
1295                if let Some(obj) = world.get_object_mut(self.object_ids[i]) {
1296                    obj.position.y = self.spike_positions[i].y + self.spike_heights[i] * 0.5;
1297                }
1298            }
1299        }
1300    }
1301
1302    /// Spawn spike physics objects.
1303    pub fn spawn(&mut self, world: &mut PhysicsWorld) {
1304        self.object_ids.clear();
1305        for pos in &self.spike_positions {
1306            let obj = PhysicsObject::new_static(
1307                Vec3::new(pos.x, pos.y, 0.0),
1308                CollisionShape::AABB {
1309                    half_extents: Vec2::new(0.1, 0.01),
1310                },
1311            );
1312            let id = world.add_object(obj);
1313            self.object_ids.push(id);
1314        }
1315    }
1316
1317    /// Check if a point triggers the spikes.
1318    pub fn check_trigger(&self, point: Vec2) -> bool {
1319        !self.is_active && self.trigger_zone.contains_point(point)
1320    }
1321
1322    /// Check if a point is touching any risen spike.
1323    pub fn is_touching_spike(&self, point: Vec2, spike_radius: f32) -> bool {
1324        for (i, pos) in self.spike_positions.iter().enumerate() {
1325            if i >= self.spike_heights.len() {
1326                continue;
1327            }
1328            let h = self.spike_heights[i];
1329            if h < 0.1 {
1330                continue;
1331            }
1332            let spike_top = pos.y + h;
1333            let spike_aabb = AABB {
1334                min: Vec2::new(pos.x - spike_radius, pos.y),
1335                max: Vec2::new(pos.x + spike_radius, spike_top),
1336            };
1337            if spike_aabb.contains_point(point) {
1338                return true;
1339            }
1340        }
1341        false
1342    }
1343}
1344
1345/// Flame jet trap.
1346#[derive(Debug, Clone)]
1347pub struct FlameJet {
1348    pub position: Vec2,
1349    pub direction: Vec2,
1350    pub warmup_time: f32,
1351    pub active_time: f32,
1352    pub cooldown_time: f32,
1353    pub cycle_timer: f32,
1354    pub damage_per_second: f32,
1355    pub jet_length: f32,
1356    pub jet_width: f32,
1357    pub particle_spawn_rate: f32,
1358    pub particles: Vec<FlameParticle>,
1359    state: FlameJetState,
1360}
1361
1362#[derive(Debug, Clone, Copy, PartialEq)]
1363enum FlameJetState {
1364    Warmup,
1365    Active,
1366    Cooldown,
1367}
1368
1369/// A single flame particle.
1370#[derive(Debug, Clone)]
1371pub struct FlameParticle {
1372    pub position: Vec2,
1373    pub velocity: Vec2,
1374    pub lifetime: f32,
1375    pub max_lifetime: f32,
1376    pub size: f32,
1377}
1378
1379impl FlameJet {
1380    pub fn new(position: Vec2, direction: Vec2, damage: f32) -> Self {
1381        Self {
1382            position,
1383            direction: direction.normalize_or_zero(),
1384            warmup_time: 0.5,
1385            active_time: 2.0,
1386            cooldown_time: 1.5,
1387            cycle_timer: 0.0,
1388            damage_per_second: damage,
1389            jet_length: 4.0,
1390            jet_width: 1.0,
1391            particle_spawn_rate: 30.0,
1392            particles: Vec::new(),
1393            state: FlameJetState::Cooldown,
1394        }
1395    }
1396
1397    /// Current total cycle duration.
1398    fn cycle_duration(&self) -> f32 {
1399        self.warmup_time + self.active_time + self.cooldown_time
1400    }
1401
1402    /// Is the jet currently firing?
1403    pub fn is_firing(&self) -> bool {
1404        self.state == FlameJetState::Active
1405    }
1406
1407    /// Is the jet warming up?
1408    pub fn is_warming_up(&self) -> bool {
1409        self.state == FlameJetState::Warmup
1410    }
1411
1412    /// Update the flame jet cycle and particles.
1413    pub fn update(&mut self, dt: f32) {
1414        self.cycle_timer += dt;
1415        let cycle = self.cycle_duration();
1416        if self.cycle_timer >= cycle {
1417            self.cycle_timer -= cycle;
1418        }
1419
1420        self.state = if self.cycle_timer < self.warmup_time {
1421            FlameJetState::Warmup
1422        } else if self.cycle_timer < self.warmup_time + self.active_time {
1423            FlameJetState::Active
1424        } else {
1425            FlameJetState::Cooldown
1426        };
1427
1428        // Spawn particles when active
1429        if self.is_firing() {
1430            let count = (self.particle_spawn_rate * dt).ceil() as usize;
1431            for _ in 0..count {
1432                let spread = (pseudo_random(self.cycle_timer) - 0.5) * self.jet_width;
1433                let perp = Vec2::new(-self.direction.y, self.direction.x);
1434                let vel = self.direction * 8.0 + perp * spread * 2.0;
1435                self.particles.push(FlameParticle {
1436                    position: self.position,
1437                    velocity: vel,
1438                    lifetime: 0.0,
1439                    max_lifetime: 0.5,
1440                    size: 0.2,
1441                });
1442            }
1443        }
1444
1445        // Update particles
1446        for p in &mut self.particles {
1447            p.position += p.velocity * dt;
1448            p.velocity.y += 1.5 * dt; // Slight upward drift for fire
1449            p.lifetime += dt;
1450            p.size *= 0.98;
1451        }
1452
1453        // Remove dead particles
1454        self.particles.retain(|p| p.lifetime < p.max_lifetime);
1455    }
1456
1457    /// Check if a point is within the flame jet's damage zone.
1458    pub fn is_in_flame_zone(&self, point: Vec2) -> bool {
1459        if !self.is_firing() {
1460            return false;
1461        }
1462        let to_point = point - self.position;
1463        let along = to_point.dot(self.direction);
1464        if along < 0.0 || along > self.jet_length {
1465            return false;
1466        }
1467        let perp = Vec2::new(-self.direction.y, self.direction.x);
1468        let lateral = to_point.dot(perp).abs();
1469        // Flame cone widens with distance
1470        let width_at_dist = self.jet_width * 0.5 * (1.0 + along / self.jet_length);
1471        lateral < width_at_dist
1472    }
1473}
1474
1475/// Simple pseudo-random from a seed (deterministic, fast).
1476fn pseudo_random(seed: f32) -> f32 {
1477    let x = (seed * 12.9898).sin() * 43758.5453;
1478    x - x.floor()
1479}
1480
1481/// Crushing walls trap.
1482#[derive(Debug, Clone)]
1483pub struct CrushingWalls {
1484    pub left_wall_pos: f32,
1485    pub right_wall_pos: f32,
1486    pub left_start: f32,
1487    pub right_start: f32,
1488    pub close_speed: f32,
1489    pub wall_height: f32,
1490    pub wall_y: f32,
1491    pub min_gap: f32,
1492    pub damage_per_second: f32,
1493    pub is_active: bool,
1494    pub is_stopped: bool,
1495    pub push_force: f32,
1496    pub left_wall_id: Option<ObjectId>,
1497    pub right_wall_id: Option<ObjectId>,
1498    pub power_source_id: Option<ObjectId>,
1499    pub power_source_health: f32,
1500}
1501
1502impl CrushingWalls {
1503    pub fn new(left_x: f32, right_x: f32, wall_y: f32, wall_height: f32, damage: f32) -> Self {
1504        Self {
1505            left_wall_pos: left_x,
1506            right_wall_pos: right_x,
1507            left_start: left_x,
1508            right_start: right_x,
1509            close_speed: 0.8,
1510            wall_height,
1511            wall_y,
1512            min_gap: 0.5,
1513            damage_per_second: damage,
1514            is_active: false,
1515            is_stopped: false,
1516            push_force: 50.0,
1517            left_wall_id: None,
1518            right_wall_id: None,
1519            power_source_id: None,
1520            power_source_health: 100.0,
1521        }
1522    }
1523
1524    /// Activate the crushing walls.
1525    pub fn activate(&mut self) {
1526        self.is_active = true;
1527    }
1528
1529    /// Damage the power source. Returns true if destroyed.
1530    pub fn damage_power_source(&mut self, amount: f32) -> bool {
1531        self.power_source_health -= amount;
1532        if self.power_source_health <= 0.0 {
1533            self.is_stopped = true;
1534            self.power_source_health = 0.0;
1535            true
1536        } else {
1537            false
1538        }
1539    }
1540
1541    /// Spawn wall physics objects.
1542    pub fn spawn(&mut self, world: &mut PhysicsWorld) {
1543        let wall_half = Vec2::new(0.5, self.wall_height * 0.5);
1544        let left_obj = PhysicsObject::new_static(
1545            Vec3::new(self.left_wall_pos, self.wall_y, 0.0),
1546            CollisionShape::AABB { half_extents: wall_half },
1547        );
1548        self.left_wall_id = Some(world.add_object(left_obj));
1549
1550        let right_obj = PhysicsObject::new_static(
1551            Vec3::new(self.right_wall_pos, self.wall_y, 0.0),
1552            CollisionShape::AABB { half_extents: wall_half },
1553        );
1554        self.right_wall_id = Some(world.add_object(right_obj));
1555
1556        // Power source (small destructible box between walls)
1557        let mid_x = (self.left_wall_pos + self.right_wall_pos) * 0.5;
1558        let ps_obj = PhysicsObject::new_static(
1559            Vec3::new(mid_x, self.wall_y + self.wall_height * 0.5 + 1.0, 0.0),
1560            CollisionShape::AABB {
1561                half_extents: Vec2::new(0.3, 0.3),
1562            },
1563        );
1564        self.power_source_id = Some(world.add_object(ps_obj));
1565    }
1566
1567    /// Update the crushing walls.
1568    pub fn update(&mut self, dt: f32, world: &mut PhysicsWorld) {
1569        if !self.is_active || self.is_stopped {
1570            return;
1571        }
1572        let gap = self.right_wall_pos - self.left_wall_pos;
1573        if gap > self.min_gap {
1574            self.left_wall_pos += self.close_speed * dt;
1575            self.right_wall_pos -= self.close_speed * dt;
1576        }
1577        // Sync physics objects
1578        if let Some(id) = self.left_wall_id {
1579            if let Some(obj) = world.get_object_mut(id) {
1580                obj.position.x = self.left_wall_pos;
1581            }
1582        }
1583        if let Some(id) = self.right_wall_id {
1584            if let Some(obj) = world.get_object_mut(id) {
1585                obj.position.x = self.right_wall_pos;
1586            }
1587        }
1588    }
1589
1590    /// Check if a point is being crushed between the walls.
1591    pub fn is_being_crushed(&self, point: Vec2) -> bool {
1592        if !self.is_active || self.is_stopped {
1593            return false;
1594        }
1595        let in_y = point.y >= self.wall_y - self.wall_height * 0.5
1596            && point.y <= self.wall_y + self.wall_height * 0.5;
1597        let in_x = point.x >= self.left_wall_pos && point.x <= self.right_wall_pos;
1598        let gap = self.right_wall_pos - self.left_wall_pos;
1599        in_x && in_y && gap < 2.0 // Only crush when walls are close
1600    }
1601
1602    /// Get push direction for an entity between the walls.
1603    pub fn push_direction(&self, point: Vec2) -> Vec2 {
1604        let mid = (self.left_wall_pos + self.right_wall_pos) * 0.5;
1605        if point.x < mid {
1606            Vec2::new(self.push_force, 0.0)
1607        } else {
1608            Vec2::new(-self.push_force, 0.0)
1609        }
1610    }
1611
1612    /// Reset walls to starting positions.
1613    pub fn reset(&mut self) {
1614        self.left_wall_pos = self.left_start;
1615        self.right_wall_pos = self.right_start;
1616        self.is_active = false;
1617        self.is_stopped = false;
1618        self.power_source_health = 100.0;
1619    }
1620}
1621
1622/// Arrow trap: fires projectiles at regular intervals.
1623#[derive(Debug, Clone)]
1624pub struct ArrowTrap {
1625    pub position: Vec2,
1626    pub direction: Vec2,
1627    pub fire_interval: f32,
1628    pub arrow_speed: f32,
1629    pub arrow_mass: f32,
1630    pub damage: f32,
1631    pub timer: f32,
1632    pub active_arrows: Vec<ObjectId>,
1633    pub max_arrows: usize,
1634    pub arrow_lifetime: f32,
1635    pub arrow_timers: Vec<f32>,
1636}
1637
1638impl ArrowTrap {
1639    pub fn new(position: Vec2, direction: Vec2, fire_interval: f32, damage: f32) -> Self {
1640        Self {
1641            position,
1642            direction: direction.normalize_or_zero(),
1643            fire_interval,
1644            arrow_speed: 12.0,
1645            arrow_mass: 0.2,
1646            damage,
1647            timer: 0.0,
1648            active_arrows: Vec::new(),
1649            max_arrows: 10,
1650            arrow_lifetime: 5.0,
1651            arrow_timers: Vec::new(),
1652        }
1653    }
1654
1655    /// Update the arrow trap, firing if interval elapsed.
1656    pub fn update(&mut self, dt: f32, world: &mut PhysicsWorld) {
1657        self.timer += dt;
1658
1659        // Fire arrow if interval elapsed
1660        if self.timer >= self.fire_interval {
1661            self.timer -= self.fire_interval;
1662            if self.active_arrows.len() < self.max_arrows {
1663                let mut arrow = PhysicsObject::new(
1664                    Vec3::new(self.position.x, self.position.y, 0.0),
1665                    self.arrow_mass,
1666                    CollisionShape::Capsule {
1667                        radius: 0.05,
1668                        height: 0.4,
1669                    },
1670                );
1671                arrow.velocity.x = self.direction.x * self.arrow_speed;
1672                arrow.velocity.y = self.direction.y * self.arrow_speed;
1673                arrow.restitution = 0.1;
1674                arrow.collision_layer = 0xFFFF_FFFF;
1675                let id = world.add_object(arrow);
1676                self.active_arrows.push(id);
1677                self.arrow_timers.push(0.0);
1678            }
1679        }
1680
1681        // Update arrow lifetimes
1682        let mut to_remove = Vec::new();
1683        for i in 0..self.arrow_timers.len() {
1684            self.arrow_timers[i] += dt;
1685            if self.arrow_timers[i] >= self.arrow_lifetime {
1686                to_remove.push(i);
1687            }
1688        }
1689
1690        // Remove expired arrows (reverse order to maintain indices)
1691        for &i in to_remove.iter().rev() {
1692            if i < self.active_arrows.len() {
1693                world.remove_object(self.active_arrows[i]);
1694                self.active_arrows.remove(i);
1695                self.arrow_timers.remove(i);
1696            }
1697        }
1698    }
1699}
1700
1701/// Aggregated trap system for a room.
1702#[derive(Debug, Clone)]
1703pub struct TrapSystem {
1704    pub pendulums: Vec<SwingingPendulum>,
1705    pub falling_rocks: Vec<FallingRocks>,
1706    pub spike_pits: Vec<SpikePit>,
1707    pub flame_jets: Vec<FlameJet>,
1708    pub crushing_walls: Vec<CrushingWalls>,
1709    pub arrow_traps: Vec<ArrowTrap>,
1710}
1711
1712impl TrapSystem {
1713    pub fn new() -> Self {
1714        Self {
1715            pendulums: Vec::new(),
1716            falling_rocks: Vec::new(),
1717            spike_pits: Vec::new(),
1718            flame_jets: Vec::new(),
1719            crushing_walls: Vec::new(),
1720            arrow_traps: Vec::new(),
1721        }
1722    }
1723
1724    /// Update all traps in the system.
1725    pub fn update(&mut self, dt: f32, world: &mut PhysicsWorld, floor_y: f32) {
1726        let gravity = world.gravity.y.abs();
1727
1728        for pendulum in &mut self.pendulums {
1729            pendulum.update(dt, gravity);
1730            pendulum.sync_physics(world);
1731        }
1732
1733        for rocks in &mut self.falling_rocks {
1734            rocks.update(dt, world, floor_y);
1735        }
1736
1737        for spikes in &mut self.spike_pits {
1738            spikes.update(dt, world);
1739        }
1740
1741        for jet in &mut self.flame_jets {
1742            jet.update(dt);
1743        }
1744
1745        for walls in &mut self.crushing_walls {
1746            walls.update(dt, world);
1747        }
1748
1749        for arrows in &mut self.arrow_traps {
1750            arrows.update(dt, world);
1751        }
1752    }
1753
1754    /// Collect damage events for an entity at a given position.
1755    pub fn check_damage(&self, entity_pos: Vec2, entity_id: ObjectId) -> Vec<DamageEvent> {
1756        let mut events = Vec::new();
1757
1758        for pendulum in &self.pendulums {
1759            let bob_pos = pendulum.bob_position();
1760            let dist = (entity_pos - bob_pos).length();
1761            if dist < pendulum.bob_radius + 0.5 {
1762                let knockback = (entity_pos - bob_pos).normalize_or_zero() * 5.0;
1763                events.push(DamageEvent {
1764                    source_description: "Swinging Pendulum".to_string(),
1765                    target_object_id: entity_id,
1766                    damage: pendulum.damage,
1767                    knockback,
1768                });
1769            }
1770        }
1771
1772        for jet in &self.flame_jets {
1773            if jet.is_in_flame_zone(entity_pos) {
1774                events.push(DamageEvent {
1775                    source_description: "Flame Jet".to_string(),
1776                    target_object_id: entity_id,
1777                    damage: jet.damage_per_second,
1778                    knockback: jet.direction * 2.0,
1779                });
1780            }
1781        }
1782
1783        for spikes in &self.spike_pits {
1784            if spikes.is_touching_spike(entity_pos, 0.15) {
1785                events.push(DamageEvent {
1786                    source_description: "Spike Pit".to_string(),
1787                    target_object_id: entity_id,
1788                    damage: spikes.damage_per_second,
1789                    knockback: Vec2::new(0.0, 3.0),
1790                });
1791            }
1792        }
1793
1794        for walls in &self.crushing_walls {
1795            if walls.is_being_crushed(entity_pos) {
1796                let push = walls.push_direction(entity_pos);
1797                events.push(DamageEvent {
1798                    source_description: "Crushing Walls".to_string(),
1799                    target_object_id: entity_id,
1800                    damage: walls.damage_per_second,
1801                    knockback: push,
1802                });
1803            }
1804        }
1805
1806        events
1807    }
1808
1809    /// Check trigger zones for an entity position (activates traps).
1810    pub fn check_triggers(&mut self, entity_pos: Vec2, world: &mut PhysicsWorld) {
1811        for rocks in &mut self.falling_rocks {
1812            if rocks.check_trigger(entity_pos) {
1813                rocks.trigger(world);
1814            }
1815        }
1816
1817        for spikes in &mut self.spike_pits {
1818            if spikes.check_trigger(entity_pos) {
1819                spikes.activate();
1820            }
1821        }
1822    }
1823}
1824
1825// ── Treasure Room ────────────────────────────────────────────────────────────
1826
1827/// Treasure chest with hinge-lid physics.
1828#[derive(Debug, Clone)]
1829pub struct TreasureChest {
1830    pub position: Vec2,
1831    pub lid_angle: f32,
1832    pub lid_angular_velocity: f32,
1833    pub lid_spring_constant: f32,
1834    pub lid_damping: f32,
1835    pub is_open: bool,
1836    pub target_angle: f32,
1837    pub loot_items: Vec<ObjectId>,
1838    pub body_object_id: Option<ObjectId>,
1839    pub loot_scatter_speed: f32,
1840}
1841
1842impl TreasureChest {
1843    pub fn new(position: Vec2) -> Self {
1844        Self {
1845            position,
1846            lid_angle: 0.0,
1847            lid_angular_velocity: 0.0,
1848            lid_spring_constant: 80.0,
1849            lid_damping: 6.0,
1850            is_open: false,
1851            target_angle: std::f32::consts::FRAC_PI_2 * 1.5, // ~135 degrees open
1852            loot_items: Vec::new(),
1853            body_object_id: None,
1854            loot_scatter_speed: 4.0,
1855        }
1856    }
1857
1858    /// Spawn the chest body physics object.
1859    pub fn spawn(&mut self, world: &mut PhysicsWorld) -> ObjectId {
1860        let obj = PhysicsObject::new_static(
1861            Vec3::new(self.position.x, self.position.y, 0.0),
1862            CollisionShape::AABB {
1863                half_extents: Vec2::new(0.5, 0.3),
1864            },
1865        );
1866        let id = world.add_object(obj);
1867        self.body_object_id = Some(id);
1868        id
1869    }
1870
1871    /// Open the chest and spawn loot items.
1872    pub fn open(&mut self, world: &mut PhysicsWorld, loot_count: usize) {
1873        if self.is_open {
1874            return;
1875        }
1876        self.is_open = true;
1877
1878        // Spawn loot items
1879        for i in 0..loot_count {
1880            let angle = (i as f32 / loot_count as f32) * std::f32::consts::PI + 0.1;
1881            let dir = Vec2::new(angle.cos(), angle.sin());
1882            let mut item = PhysicsObject::new(
1883                Vec3::new(self.position.x, self.position.y + 0.5, 0.0),
1884                0.3,
1885                CollisionShape::Circle { radius: 0.15 },
1886            );
1887            item.velocity.x = dir.x * self.loot_scatter_speed;
1888            item.velocity.y = dir.y * self.loot_scatter_speed + 2.0;
1889            item.friction = 0.8; // High friction so items settle
1890            item.restitution = 0.3;
1891            let id = world.add_object(item);
1892            self.loot_items.push(id);
1893        }
1894    }
1895
1896    /// Update lid angular spring physics.
1897    pub fn update(&mut self, dt: f32) {
1898        if !self.is_open {
1899            return;
1900        }
1901        let displacement = self.target_angle - self.lid_angle;
1902        let spring_torque = self.lid_spring_constant * displacement;
1903        let damping_torque = -self.lid_damping * self.lid_angular_velocity;
1904        let accel = spring_torque + damping_torque;
1905        self.lid_angular_velocity += accel * dt;
1906        self.lid_angle += self.lid_angular_velocity * dt;
1907        // Clamp
1908        self.lid_angle = clampf(self.lid_angle, 0.0, self.target_angle + 0.1);
1909    }
1910}
1911
1912/// Pedestal item that bobs up and down.
1913#[derive(Debug, Clone)]
1914pub struct PedestalItem {
1915    pub base_position: Vec2,
1916    pub bob_amplitude: f32,
1917    pub bob_frequency: f32,
1918    pub bob_timer: f32,
1919    pub halo_radius: f32,
1920    pub halo_particle_count: usize,
1921    pub collision_object_id: Option<ObjectId>,
1922    pub item_name: String,
1923}
1924
1925impl PedestalItem {
1926    pub fn new(position: Vec2, item_name: String) -> Self {
1927        Self {
1928            base_position: position,
1929            bob_amplitude: 0.3,
1930            bob_frequency: 2.0,
1931            bob_timer: 0.0,
1932            halo_radius: 0.6,
1933            halo_particle_count: 8,
1934            collision_object_id: None,
1935            item_name,
1936        }
1937    }
1938
1939    /// Spawn the collision object.
1940    pub fn spawn(&mut self, world: &mut PhysicsWorld) -> ObjectId {
1941        let obj = PhysicsObject::new_static(
1942            Vec3::new(self.base_position.x, self.base_position.y, 0.0),
1943            CollisionShape::Circle { radius: 0.3 },
1944        );
1945        let id = world.add_object(obj);
1946        self.collision_object_id = Some(id);
1947        id
1948    }
1949
1950    /// Current display position (with bob).
1951    pub fn display_position(&self) -> Vec2 {
1952        let offset = (self.bob_timer * self.bob_frequency * std::f32::consts::TAU).sin()
1953            * self.bob_amplitude;
1954        Vec2::new(self.base_position.x, self.base_position.y + offset)
1955    }
1956
1957    /// Get halo particle positions (golden ring around item).
1958    pub fn halo_positions(&self) -> Vec<Vec2> {
1959        let center = self.display_position();
1960        (0..self.halo_particle_count)
1961            .map(|i| {
1962                let angle = (i as f32 / self.halo_particle_count as f32) * std::f32::consts::TAU
1963                    + self.bob_timer * 1.5;
1964                center + Vec2::new(angle.cos(), angle.sin()) * self.halo_radius
1965            })
1966            .collect()
1967    }
1968
1969    /// Update bob timer.
1970    pub fn update(&mut self, dt: f32) {
1971        self.bob_timer += dt;
1972    }
1973}
1974
1975/// Treasure room aggregator.
1976#[derive(Debug, Clone)]
1977pub struct TreasureRoom {
1978    pub chests: Vec<TreasureChest>,
1979    pub pedestals: Vec<PedestalItem>,
1980}
1981
1982impl TreasureRoom {
1983    pub fn new() -> Self {
1984        Self {
1985            chests: Vec::new(),
1986            pedestals: Vec::new(),
1987        }
1988    }
1989
1990    pub fn update(&mut self, dt: f32) {
1991        for chest in &mut self.chests {
1992            chest.update(dt);
1993        }
1994        for pedestal in &mut self.pedestals {
1995            pedestal.update(dt);
1996        }
1997    }
1998}
1999
2000// ── Chaos Rift Room ──────────────────────────────────────────────────────────
2001
2002/// A rift object spawned from the chaos portal.
2003#[derive(Debug, Clone)]
2004pub struct RiftObject {
2005    pub object_id: ObjectId,
2006    pub lifetime: f32,
2007    pub max_lifetime: f32,
2008}
2009
2010/// Chaos rift room: portal that spawns random physics objects.
2011#[derive(Debug, Clone)]
2012pub struct ChaosRiftRoom {
2013    pub rift_center: Vec2,
2014    pub rift_radius: f32,
2015    pub vortex_pull_strength: f32,
2016    pub vortex_spin_strength: f32,
2017    pub spawn_rate: f32,
2018    pub spawn_rate_escalation: f32,
2019    pub max_spawn_rate: f32,
2020    pub spawn_timer: f32,
2021    pub elapsed: f32,
2022    pub active_objects: Vec<RiftObject>,
2023    pub max_objects: usize,
2024    pub despawn_burst_count: usize,
2025    pub seed: f32,
2026}
2027
2028impl ChaosRiftRoom {
2029    pub fn new(center: Vec2, radius: f32) -> Self {
2030        Self {
2031            rift_center: center,
2032            rift_radius: radius,
2033            vortex_pull_strength: 15.0,
2034            vortex_spin_strength: 8.0,
2035            spawn_rate: 1.0,
2036            spawn_rate_escalation: 0.1,
2037            max_spawn_rate: 10.0,
2038            spawn_timer: 0.0,
2039            elapsed: 0.0,
2040            active_objects: Vec::new(),
2041            max_objects: 50,
2042            despawn_burst_count: 6,
2043            seed: 42.0,
2044        }
2045    }
2046
2047    /// Update the chaos rift, spawning objects and applying vortex.
2048    pub fn update(&mut self, dt: f32, world: &mut PhysicsWorld) {
2049        self.elapsed += dt;
2050
2051        // Escalate spawn rate
2052        let current_rate = (self.spawn_rate + self.spawn_rate_escalation * self.elapsed)
2053            .min(self.max_spawn_rate);
2054        let spawn_interval = 1.0 / current_rate;
2055
2056        self.spawn_timer += dt;
2057
2058        // Spawn objects
2059        while self.spawn_timer >= spawn_interval && self.active_objects.len() < self.max_objects {
2060            self.spawn_timer -= spawn_interval;
2061            self.seed += 1.0;
2062
2063            let angle = pseudo_random(self.seed * 0.7) * std::f32::consts::TAU;
2064            let speed = 2.0 + pseudo_random(self.seed * 1.3) * 5.0;
2065            let dir = Vec2::new(angle.cos(), angle.sin());
2066
2067            // Random shape
2068            let shape_rand = pseudo_random(self.seed * 2.1);
2069            let shape = if shape_rand < 0.33 {
2070                let r = 0.1 + pseudo_random(self.seed * 3.7) * 0.5;
2071                CollisionShape::Circle { radius: r }
2072            } else if shape_rand < 0.66 {
2073                let w = 0.1 + pseudo_random(self.seed * 4.3) * 0.4;
2074                let h = 0.1 + pseudo_random(self.seed * 5.1) * 0.4;
2075                CollisionShape::AABB {
2076                    half_extents: Vec2::new(w, h),
2077                }
2078            } else {
2079                let r = 0.08 + pseudo_random(self.seed * 6.2) * 0.2;
2080                let h = 0.2 + pseudo_random(self.seed * 7.4) * 0.5;
2081                CollisionShape::Capsule { radius: r, height: h }
2082            };
2083
2084            let mass = 0.5 + pseudo_random(self.seed * 8.9) * 3.0;
2085            let mut obj = PhysicsObject::new(
2086                Vec3::new(self.rift_center.x + dir.x * self.rift_radius, self.rift_center.y + dir.y * self.rift_radius, 0.0),
2087                mass,
2088                shape,
2089            );
2090            obj.velocity.x = dir.x * speed;
2091            obj.velocity.y = dir.y * speed;
2092            obj.restitution = 0.6;
2093
2094            let id = world.add_object(obj);
2095            self.active_objects.push(RiftObject {
2096                object_id: id,
2097                lifetime: 0.0,
2098                max_lifetime: 8.0 + pseudo_random(self.seed * 9.1) * 4.0,
2099            });
2100        }
2101
2102        // Apply vortex force
2103        world.apply_vortex_force(
2104            self.rift_center,
2105            self.rift_radius * 3.0,
2106            self.vortex_pull_strength,
2107            self.vortex_spin_strength,
2108        );
2109
2110        // Check for objects touching the rift center (despawn with burst)
2111        let mut to_despawn = Vec::new();
2112        for (i, rift_obj) in self.active_objects.iter_mut().enumerate() {
2113            rift_obj.lifetime += dt;
2114            if let Some(obj) = world.get_object(rift_obj.object_id) {
2115                let dist = (obj.pos2d() - self.rift_center).length();
2116                if dist < self.rift_radius * 0.3 || rift_obj.lifetime >= rift_obj.max_lifetime {
2117                    to_despawn.push(i);
2118                }
2119            } else {
2120                to_despawn.push(i);
2121            }
2122        }
2123
2124        // Despawn (reverse order)
2125        for &i in to_despawn.iter().rev() {
2126            if i < self.active_objects.len() {
2127                let rift_obj = self.active_objects.remove(i);
2128                world.remove_object(rift_obj.object_id);
2129            }
2130        }
2131    }
2132
2133    /// Current effective spawn rate.
2134    pub fn current_spawn_rate(&self) -> f32 {
2135        (self.spawn_rate + self.spawn_rate_escalation * self.elapsed).min(self.max_spawn_rate)
2136    }
2137
2138    /// Number of active rift objects.
2139    pub fn active_count(&self) -> usize {
2140        self.active_objects.len()
2141    }
2142}
2143
2144// ── ArenaPhysicsManager ──────────────────────────────────────────────────────
2145
2146/// Callback type for collision events.
2147pub type CollisionCallback = fn(ObjectId, ObjectId, &Contact);
2148
2149/// Top-level manager that owns the PhysicsWorld, trap systems, and room state.
2150pub struct ArenaPhysicsManager {
2151    pub world: PhysicsWorld,
2152    pub rooms: Vec<ArenaRoom>,
2153    pub trap_systems: HashMap<u32, TrapSystem>,
2154    pub treasure_rooms: HashMap<u32, TreasureRoom>,
2155    pub chaos_rifts: HashMap<u32, ChaosRiftRoom>,
2156    pub damage_events: Vec<DamageEvent>,
2157    pub collision_callbacks: Vec<CollisionCallback>,
2158    pub floor_y: f32,
2159}
2160
2161impl ArenaPhysicsManager {
2162    pub fn new() -> Self {
2163        Self {
2164            world: PhysicsWorld::new(),
2165            rooms: Vec::new(),
2166            trap_systems: HashMap::new(),
2167            treasure_rooms: HashMap::new(),
2168            chaos_rifts: HashMap::new(),
2169            damage_events: Vec::new(),
2170            collision_callbacks: Vec::new(),
2171            floor_y: 0.0,
2172        }
2173    }
2174
2175    /// Set the global floor Y coordinate.
2176    pub fn set_floor(&mut self, y: f32) {
2177        self.floor_y = y;
2178    }
2179
2180    /// Add a room to the arena.
2181    pub fn add_room(&mut self, room: ArenaRoom) -> u32 {
2182        let id = room.room_id;
2183        self.rooms.push(room);
2184        id
2185    }
2186
2187    /// Register a trap system for a room.
2188    pub fn register_trap_system(&mut self, room_id: u32, system: TrapSystem) {
2189        self.trap_systems.insert(room_id, system);
2190    }
2191
2192    /// Register a treasure room.
2193    pub fn register_treasure_room(&mut self, room_id: u32, treasure: TreasureRoom) {
2194        self.treasure_rooms.insert(room_id, treasure);
2195    }
2196
2197    /// Register a chaos rift room.
2198    pub fn register_chaos_rift(&mut self, room_id: u32, rift: ChaosRiftRoom) {
2199        self.chaos_rifts.insert(room_id, rift);
2200    }
2201
2202    /// Register a collision callback.
2203    pub fn on_collision(&mut self, callback: CollisionCallback) {
2204        self.collision_callbacks.push(callback);
2205    }
2206
2207    /// Step the entire arena physics forward.
2208    pub fn step(&mut self, dt: f32) -> Vec<DamageEvent> {
2209        self.damage_events.clear();
2210
2211        // Step physics world
2212        let (collisions, _triggers) = self.world.step(dt);
2213
2214        // Fire collision callbacks
2215        for pair in &collisions {
2216            for cb in &self.collision_callbacks {
2217                cb(pair.id_a, pair.id_b, &pair.contact);
2218            }
2219        }
2220
2221        // Update trap systems
2222        let floor_y = self.floor_y;
2223        for (_, system) in &mut self.trap_systems {
2224            system.update(dt, &mut self.world, floor_y);
2225        }
2226
2227        // Update treasure rooms
2228        for (_, treasure) in &mut self.treasure_rooms {
2229            treasure.update(dt);
2230        }
2231
2232        // Update chaos rifts
2233        for (_, rift) in &mut self.chaos_rifts {
2234            rift.update(dt, &mut self.world);
2235        }
2236
2237        self.damage_events.clone()
2238    }
2239
2240    /// Check trap damage for an entity.
2241    pub fn check_entity_damage(&self, entity_pos: Vec2, entity_id: ObjectId) -> Vec<DamageEvent> {
2242        let mut all_damage = Vec::new();
2243        for (_, system) in &self.trap_systems {
2244            let events = system.check_damage(entity_pos, entity_id);
2245            all_damage.extend(events);
2246        }
2247        all_damage
2248    }
2249
2250    /// Notify traps that an entity is at a position (for trigger zones).
2251    pub fn notify_entity_position(&mut self, entity_pos: Vec2) {
2252        for (_, system) in &mut self.trap_systems {
2253            system.check_triggers(entity_pos, &mut self.world);
2254        }
2255    }
2256
2257    /// Raycast through the arena.
2258    pub fn raycast(&self, origin: Vec2, dir: Vec2, max_dist: f32) -> Option<RayHit> {
2259        self.world.raycast(origin, dir, max_dist)
2260    }
2261
2262    /// Overlap test in the arena.
2263    pub fn overlap_test(&self, shape: &CollisionShape, pos: Vec2) -> Vec<ObjectId> {
2264        self.world.overlap_test(shape, pos)
2265    }
2266
2267    /// Find which room contains a point.
2268    pub fn room_at(&self, pos: Vec2) -> Option<&ArenaRoom> {
2269        self.rooms.iter().find(|r| r.contains_point(pos))
2270    }
2271
2272    /// Get total active physics objects.
2273    pub fn total_objects(&self) -> usize {
2274        self.world.object_count()
2275    }
2276}
2277
2278// ── Unit Tests ───────────────────────────────────────────────────────────────
2279
2280#[cfg(test)]
2281mod tests {
2282    use super::*;
2283
2284    // -- Collision detection tests --
2285
2286    #[test]
2287    fn test_circle_circle_collision() {
2288        let c = collide_circle_circle(Vec2::ZERO, 1.0, Vec2::new(1.5, 0.0), 1.0);
2289        assert!(c.is_some());
2290        let c = c.unwrap();
2291        assert!((c.penetration - 0.5).abs() < 0.01);
2292        assert!(c.normal.x > 0.9);
2293    }
2294
2295    #[test]
2296    fn test_circle_circle_no_collision() {
2297        let c = collide_circle_circle(Vec2::ZERO, 1.0, Vec2::new(3.0, 0.0), 1.0);
2298        assert!(c.is_none());
2299    }
2300
2301    #[test]
2302    fn test_aabb_aabb_collision() {
2303        let c = collide_aabb_aabb(
2304            Vec2::ZERO,
2305            Vec2::new(1.0, 1.0),
2306            Vec2::new(1.5, 0.0),
2307            Vec2::new(1.0, 1.0),
2308        );
2309        assert!(c.is_some());
2310        let c = c.unwrap();
2311        assert!(c.penetration > 0.0);
2312    }
2313
2314    #[test]
2315    fn test_aabb_aabb_no_collision() {
2316        let c = collide_aabb_aabb(
2317            Vec2::ZERO,
2318            Vec2::new(1.0, 1.0),
2319            Vec2::new(5.0, 0.0),
2320            Vec2::new(1.0, 1.0),
2321        );
2322        assert!(c.is_none());
2323    }
2324
2325    #[test]
2326    fn test_circle_aabb_collision() {
2327        let c = collide_circle_aabb(Vec2::new(1.8, 0.0), 0.5, Vec2::ZERO, Vec2::new(1.0, 1.0));
2328        assert!(c.is_some());
2329    }
2330
2331    #[test]
2332    fn test_circle_aabb_no_collision() {
2333        let c = collide_circle_aabb(Vec2::new(3.0, 0.0), 0.5, Vec2::ZERO, Vec2::new(1.0, 1.0));
2334        assert!(c.is_none());
2335    }
2336
2337    // -- Physics world tests --
2338
2339    #[test]
2340    fn test_add_remove_objects() {
2341        let mut world = PhysicsWorld::new();
2342        let id = world.add_object(PhysicsObject::new(
2343            Vec3::ZERO,
2344            1.0,
2345            CollisionShape::Circle { radius: 1.0 },
2346        ));
2347        assert_eq!(world.object_count(), 1);
2348        world.remove_object(id);
2349        assert_eq!(world.object_count(), 0);
2350    }
2351
2352    #[test]
2353    fn test_gravity_integration() {
2354        let mut world = PhysicsWorld::new();
2355        let id = world.add_object(PhysicsObject::new(
2356            Vec3::new(0.0, 10.0, 0.0),
2357            1.0,
2358            CollisionShape::Circle { radius: 0.5 },
2359        ));
2360        // Step for 1 second in 10 steps
2361        for _ in 0..10 {
2362            world.step(0.1);
2363        }
2364        let obj = world.get_object(id).unwrap();
2365        // Object should have fallen
2366        assert!(obj.position.y < 10.0);
2367    }
2368
2369    #[test]
2370    fn test_impulse_resolution() {
2371        let mut world = PhysicsWorld::new();
2372        world.gravity = Vec2::ZERO; // Disable gravity for this test
2373
2374        // Two circles moving toward each other
2375        let id_a = world.add_object({
2376            let mut obj = PhysicsObject::new(
2377                Vec3::new(-1.0, 0.0, 0.0),
2378                1.0,
2379                CollisionShape::Circle { radius: 1.0 },
2380            );
2381            obj.velocity.x = 5.0;
2382            obj
2383        });
2384        let id_b = world.add_object({
2385            let mut obj = PhysicsObject::new(
2386                Vec3::new(1.0, 0.0, 0.0),
2387                1.0,
2388                CollisionShape::Circle { radius: 1.0 },
2389            );
2390            obj.velocity.x = -5.0;
2391            obj
2392        });
2393
2394        world.step(0.016);
2395
2396        let a = world.get_object(id_a).unwrap();
2397        let b = world.get_object(id_b).unwrap();
2398        // After collision, they should be separating
2399        assert!(a.velocity.x <= 0.0);
2400        assert!(b.velocity.x >= 0.0);
2401    }
2402
2403    #[test]
2404    fn test_static_object_immovable() {
2405        let mut world = PhysicsWorld::new();
2406        let id = world.add_object(PhysicsObject::new_static(
2407            Vec3::new(0.0, 0.0, 0.0),
2408            CollisionShape::AABB {
2409                half_extents: Vec2::new(5.0, 0.5),
2410            },
2411        ));
2412        world.step(1.0);
2413        let obj = world.get_object(id).unwrap();
2414        assert!((obj.position.y - 0.0).abs() < 0.001);
2415    }
2416
2417    #[test]
2418    fn test_raycast() {
2419        let mut world = PhysicsWorld::new();
2420        world.gravity = Vec2::ZERO;
2421        world.add_object(PhysicsObject::new_static(
2422            Vec3::new(5.0, 0.0, 0.0),
2423            CollisionShape::Circle { radius: 1.0 },
2424        ));
2425        let hit = world.raycast(Vec2::ZERO, Vec2::new(1.0, 0.0), 100.0);
2426        assert!(hit.is_some());
2427        let hit = hit.unwrap();
2428        assert!((hit.point.x - 4.0).abs() < 0.1);
2429    }
2430
2431    #[test]
2432    fn test_overlap_test() {
2433        let mut world = PhysicsWorld::new();
2434        world.gravity = Vec2::ZERO;
2435        let id = world.add_object(PhysicsObject::new_static(
2436            Vec3::new(0.0, 0.0, 0.0),
2437            CollisionShape::Circle { radius: 2.0 },
2438        ));
2439        let results = world.overlap_test(&CollisionShape::Circle { radius: 1.0 }, Vec2::new(1.0, 0.0));
2440        assert!(results.contains(&id));
2441    }
2442
2443    #[test]
2444    fn test_trigger_events() {
2445        let mut world = PhysicsWorld::new();
2446        world.gravity = Vec2::ZERO;
2447
2448        // Trigger volume
2449        world.add_object(PhysicsObject::new_trigger(
2450            Vec3::new(0.0, 0.0, 0.0),
2451            CollisionShape::Circle { radius: 2.0 },
2452        ));
2453
2454        // Dynamic object entering trigger
2455        let mut entering = PhysicsObject::new(
2456            Vec3::new(0.5, 0.0, 0.0),
2457            1.0,
2458            CollisionShape::Circle { radius: 0.5 },
2459        );
2460        entering.collision_layer = 1;
2461        world.add_object(entering);
2462
2463        let (_, triggers) = world.step(0.016);
2464        assert!(!triggers.is_empty());
2465        assert!(triggers[0].is_enter);
2466    }
2467
2468    // -- Trap timing tests --
2469
2470    #[test]
2471    fn test_pendulum_oscillation() {
2472        let mut pendulum = SwingingPendulum::new(Vec2::new(0.0, 5.0), 3.0, 2.0, 0.5, 10.0);
2473        let initial_angle = pendulum.angular_position;
2474        // Run for a bit
2475        for _ in 0..100 {
2476            pendulum.update(0.016, 9.81);
2477        }
2478        // Should have oscillated (angle changed)
2479        assert!((pendulum.angular_position - initial_angle).abs() > 0.01);
2480    }
2481
2482    #[test]
2483    fn test_spike_pit_spring_physics() {
2484        let trigger_zone = AABB::new(Vec2::new(-2.0, -1.0), Vec2::new(2.0, 0.0));
2485        let mut spikes = SpikePit::new(trigger_zone, 4, 1.5, 20.0);
2486        let mut world = PhysicsWorld::new();
2487        spikes.spawn(&mut world);
2488        spikes.activate();
2489
2490        // Run spring simulation
2491        for _ in 0..200 {
2492            spikes.update(0.016, &mut world);
2493        }
2494
2495        // Spikes should have risen close to target
2496        for h in &spikes.spike_heights {
2497            assert!((*h - 1.5).abs() < 0.3, "Spike height {} not near target 1.5", h);
2498        }
2499    }
2500
2501    #[test]
2502    fn test_flame_jet_cycle() {
2503        let mut jet = FlameJet::new(Vec2::ZERO, Vec2::new(1.0, 0.0), 15.0);
2504        // Run through a full cycle
2505        let mut was_firing = false;
2506        let mut was_warming = false;
2507        for _ in 0..300 {
2508            jet.update(0.016);
2509            if jet.is_firing() {
2510                was_firing = true;
2511            }
2512            if jet.is_warming_up() {
2513                was_warming = true;
2514            }
2515        }
2516        assert!(was_firing, "Flame jet should have fired");
2517        assert!(was_warming, "Flame jet should have warmed up");
2518    }
2519
2520    #[test]
2521    fn test_crushing_walls_close() {
2522        let mut walls = CrushingWalls::new(-5.0, 5.0, 0.0, 4.0, 30.0);
2523        let mut world = PhysicsWorld::new();
2524        walls.spawn(&mut world);
2525        walls.activate();
2526
2527        let initial_gap = walls.right_wall_pos - walls.left_wall_pos;
2528        for _ in 0..100 {
2529            walls.update(0.016, &mut world);
2530        }
2531        let final_gap = walls.right_wall_pos - walls.left_wall_pos;
2532        assert!(final_gap < initial_gap, "Walls should have closed in");
2533    }
2534
2535    #[test]
2536    fn test_crushing_walls_stop_on_power_source_destroy() {
2537        let mut walls = CrushingWalls::new(-5.0, 5.0, 0.0, 4.0, 30.0);
2538        let mut world = PhysicsWorld::new();
2539        walls.spawn(&mut world);
2540        walls.activate();
2541
2542        // Run a bit
2543        for _ in 0..50 {
2544            walls.update(0.016, &mut world);
2545        }
2546        let gap_before = walls.right_wall_pos - walls.left_wall_pos;
2547
2548        // Destroy power source
2549        walls.damage_power_source(200.0);
2550        assert!(walls.is_stopped);
2551
2552        // Run more — gap should not change
2553        for _ in 0..50 {
2554            walls.update(0.016, &mut world);
2555        }
2556        let gap_after = walls.right_wall_pos - walls.left_wall_pos;
2557        assert!((gap_before - gap_after).abs() < 0.001);
2558    }
2559
2560    #[test]
2561    fn test_arrow_trap_fires() {
2562        let mut trap = ArrowTrap::new(Vec2::ZERO, Vec2::new(1.0, 0.0), 0.5, 10.0);
2563        let mut world = PhysicsWorld::new();
2564        world.gravity = Vec2::ZERO;
2565
2566        // Run for 2 seconds
2567        for _ in 0..125 {
2568            trap.update(0.016, &mut world);
2569        }
2570        assert!(
2571            !trap.active_arrows.is_empty(),
2572            "Arrow trap should have fired at least one arrow"
2573        );
2574    }
2575
2576    #[test]
2577    fn test_treasure_chest_open() {
2578        let mut chest = TreasureChest::new(Vec2::ZERO);
2579        let mut world = PhysicsWorld::new();
2580        world.gravity = Vec2::ZERO;
2581        chest.spawn(&mut world);
2582        chest.open(&mut world, 5);
2583        assert!(chest.is_open);
2584        assert_eq!(chest.loot_items.len(), 5);
2585        // Run spring
2586        for _ in 0..100 {
2587            chest.update(0.016);
2588        }
2589        assert!(chest.lid_angle > 0.5, "Lid should have opened");
2590    }
2591
2592    #[test]
2593    fn test_chaos_rift_spawns_objects() {
2594        let mut rift = ChaosRiftRoom::new(Vec2::ZERO, 3.0);
2595        let mut world = PhysicsWorld::new();
2596        world.gravity = Vec2::ZERO;
2597
2598        // Run for several seconds
2599        for _ in 0..200 {
2600            rift.update(0.016, &mut world);
2601        }
2602        assert!(
2603            rift.active_count() > 0,
2604            "Chaos rift should have spawned objects"
2605        );
2606    }
2607
2608    #[test]
2609    fn test_chaos_rift_escalation() {
2610        let mut rift = ChaosRiftRoom::new(Vec2::ZERO, 3.0);
2611        let rate_initial = rift.current_spawn_rate();
2612        rift.elapsed = 30.0;
2613        let rate_later = rift.current_spawn_rate();
2614        assert!(rate_later > rate_initial, "Spawn rate should escalate");
2615    }
2616
2617    #[test]
2618    fn test_arena_manager_step() {
2619        let mut mgr = ArenaPhysicsManager::new();
2620        mgr.world.gravity = Vec2::ZERO;
2621
2622        let room = ArenaRoom::new(0, RoomType::Trap, AABB::new(Vec2::new(-10.0, -10.0), Vec2::new(10.0, 10.0)));
2623        mgr.add_room(room);
2624
2625        let mut traps = TrapSystem::new();
2626        traps.pendulums.push(SwingingPendulum::new(
2627            Vec2::new(0.0, 5.0),
2628            3.0,
2629            2.0,
2630            0.5,
2631            10.0,
2632        ));
2633        mgr.register_trap_system(0, traps);
2634
2635        // Step should not panic
2636        for _ in 0..60 {
2637            mgr.step(0.016);
2638        }
2639    }
2640
2641    #[test]
2642    fn test_aabb_helpers() {
2643        let a = AABB::new(Vec2::ZERO, Vec2::new(2.0, 2.0));
2644        assert!(a.contains_point(Vec2::new(1.0, 1.0)));
2645        assert!(!a.contains_point(Vec2::new(3.0, 1.0)));
2646        assert_eq!(a.center(), Vec2::new(1.0, 1.0));
2647        assert_eq!(a.half_extents(), Vec2::new(1.0, 1.0));
2648    }
2649
2650    #[test]
2651    fn test_pedestal_item_bob() {
2652        let mut pedestal = PedestalItem::new(Vec2::new(0.0, 3.0), "Magic Sword".to_string());
2653        pedestal.update(0.5);
2654        let pos = pedestal.display_position();
2655        // Should have bobbed from base
2656        assert!((pos.y - 3.0).abs() <= pedestal.bob_amplitude + 0.01);
2657    }
2658
2659    #[test]
2660    fn test_radial_force() {
2661        let mut world = PhysicsWorld::new();
2662        world.gravity = Vec2::ZERO;
2663        let id = world.add_object(PhysicsObject::new(
2664            Vec3::new(2.0, 0.0, 0.0),
2665            1.0,
2666            CollisionShape::Circle { radius: 0.5 },
2667        ));
2668        world.apply_radial_force(Vec2::ZERO, 5.0, 100.0);
2669        world.step(0.016);
2670        let obj = world.get_object(id).unwrap();
2671        // Should have been pushed away from origin
2672        assert!(obj.velocity.x > 0.0);
2673    }
2674}