Skip to main content

proof_engine/ai/
steering.rs

1//! Steering behaviors for autonomous agents.
2//!
3//! Each behavior takes a `SteeringAgent` (and optional context) and returns
4//! a `Vec2` force.  Forces are combined with `WeightedSteering` or
5//! `PrioritySteeringCombiner`, then applied by `SteeringSystem` each frame.
6//!
7//! # Example
8//! ```rust
9//! use proof_engine::ai::steering::{SteeringAgent, seek, arrive, WeightedSteering, SteeringBehavior};
10//! use glam::Vec2;
11//!
12//! let agent = SteeringAgent::new(Vec2::new(0.0, 0.0), 5.0, 10.0);
13//! let target = Vec2::new(10.0, 10.0);
14//!
15//! let force = seek(&agent, target);
16//!
17//! let mut ws = WeightedSteering::new();
18//! ws.add(SteeringBehavior::Seek(target), 1.0);
19//! ws.add(SteeringBehavior::Arrive { target, slow_radius: 3.0 }, 0.5);
20//! let combined = ws.calculate(&agent, &[]);
21//! ```
22
23use glam::Vec2;
24use std::f32::consts::PI;
25
26// ---------------------------------------------------------------------------
27// SteeringAgent
28// ---------------------------------------------------------------------------
29
30/// An autonomous agent steered by force-based behaviors.
31#[derive(Debug, Clone)]
32pub struct SteeringAgent {
33    pub position: Vec2,
34    pub velocity: Vec2,
35    pub heading: Vec2,       // unit vector, current facing direction
36    pub max_speed: f32,
37    pub max_force: f32,
38    pub mass: f32,
39    pub radius: f32,
40    /// Internal wander angle, used by the `wander` behavior.
41    pub wander_angle: f32,
42}
43
44impl SteeringAgent {
45    pub fn new(position: Vec2, max_speed: f32, max_force: f32) -> Self {
46        SteeringAgent {
47            position,
48            velocity: Vec2::ZERO,
49            heading: Vec2::X,
50            max_speed,
51            max_force,
52            mass: 1.0,
53            radius: 0.5,
54            wander_angle: 0.0,
55        }
56    }
57
58    pub fn with_mass(mut self, mass: f32) -> Self { self.mass = mass; self }
59    pub fn with_radius(mut self, r: f32) -> Self { self.radius = r; self }
60
61    pub fn speed(&self) -> f32 { self.velocity.length() }
62
63    /// Apply a force (already clamped to max_force) and update velocity.
64    pub fn apply_force(&mut self, force: Vec2, dt: f32) {
65        let clamped = clamp_magnitude(force, self.max_force);
66        let accel = clamped / self.mass;
67        self.velocity = clamp_magnitude(self.velocity + accel * dt, self.max_speed);
68        if self.velocity.length_squared() > 0.0001 {
69            self.heading = self.velocity.normalize();
70        }
71    }
72
73    /// Move the agent by its current velocity.
74    pub fn update_position(&mut self, dt: f32) {
75        self.position += self.velocity * dt;
76    }
77
78    /// Truncate velocity to max_speed.
79    pub fn clamp_velocity(&mut self) {
80        self.velocity = clamp_magnitude(self.velocity, self.max_speed);
81    }
82}
83
84// ---------------------------------------------------------------------------
85// Utility
86// ---------------------------------------------------------------------------
87
88#[inline]
89pub fn clamp_magnitude(v: Vec2, max: f32) -> Vec2 {
90    let len = v.length();
91    if len > max && len > 0.0 { v / len * max } else { v }
92}
93
94#[inline]
95fn truncate(v: Vec2, max: f32) -> Vec2 { clamp_magnitude(v, max) }
96
97// ---------------------------------------------------------------------------
98// Individual behaviors
99// ---------------------------------------------------------------------------
100
101/// Seek: accelerate directly toward `target`.
102pub fn seek(agent: &SteeringAgent, target: Vec2) -> Vec2 {
103    let to_target = target - agent.position;
104    let dist = to_target.length();
105    if dist < 0.0001 { return Vec2::ZERO; }
106    let desired = (to_target / dist) * agent.max_speed;
107    truncate(desired - agent.velocity, agent.max_force)
108}
109
110/// Flee: accelerate directly away from `threat`.
111pub fn flee(agent: &SteeringAgent, threat: Vec2) -> Vec2 {
112    let from_threat = agent.position - threat;
113    let dist = from_threat.length();
114    if dist < 0.0001 { return Vec2::ZERO; }
115    let desired = (from_threat / dist) * agent.max_speed;
116    truncate(desired - agent.velocity, agent.max_force)
117}
118
119/// Arrive: seek with deceleration inside `slow_radius`.
120pub fn arrive(agent: &SteeringAgent, target: Vec2, slow_radius: f32) -> Vec2 {
121    let to_target = target - agent.position;
122    let dist = to_target.length();
123    if dist < 0.0001 { return Vec2::ZERO; }
124    let desired_speed = if dist < slow_radius {
125        agent.max_speed * (dist / slow_radius)
126    } else {
127        agent.max_speed
128    };
129    let desired = (to_target / dist) * desired_speed;
130    truncate(desired - agent.velocity, agent.max_force)
131}
132
133/// Pursuit: seek the predicted future position of a moving target.
134pub fn pursuit(agent: &SteeringAgent, quarry_pos: Vec2, quarry_vel: Vec2) -> Vec2 {
135    let to_quarry = quarry_pos - agent.position;
136    let ahead = to_quarry.length() / (agent.max_speed + quarry_vel.length()).max(0.001);
137    let future_pos = quarry_pos + quarry_vel * ahead;
138    seek(agent, future_pos)
139}
140
141/// Evade: flee from the predicted future position of a threat.
142pub fn evade(agent: &SteeringAgent, threat_pos: Vec2, threat_vel: Vec2) -> Vec2 {
143    let to_threat = threat_pos - agent.position;
144    let ahead = to_threat.length() / (agent.max_speed + threat_vel.length()).max(0.001);
145    let future_pos = threat_pos + threat_vel * ahead;
146    flee(agent, future_pos)
147}
148
149/// Wander: random steering that produces smooth, organic movement.
150///
151/// `wander_circle_dist`   — how far ahead the wander circle is projected
152/// `wander_circle_radius` — radius of the wander circle
153/// `wander_jitter`        — how much the wander angle changes per call
154pub fn wander(
155    agent: &mut SteeringAgent,
156    wander_circle_dist: f32,
157    wander_circle_radius: f32,
158    wander_jitter: f32,
159) -> Vec2 {
160    // Jitter the wander angle
161    agent.wander_angle += (random_f32() * 2.0 - 1.0) * wander_jitter;
162    // Project the circle ahead of the agent
163    let circle_center = if agent.velocity.length_squared() > 0.0001 {
164        agent.position + agent.velocity.normalize() * wander_circle_dist
165    } else {
166        agent.position + Vec2::X * wander_circle_dist
167    };
168    let displacement = Vec2::new(
169        agent.wander_angle.cos() * wander_circle_radius,
170        agent.wander_angle.sin() * wander_circle_radius,
171    );
172    let wander_target = circle_center + displacement;
173    seek(agent, wander_target)
174}
175
176/// Obstacle avoidance: steer around circular obstacles `(center, radius)`.
177pub fn obstacle_avoidance(agent: &SteeringAgent, obstacles: &[(Vec2, f32)]) -> Vec2 {
178    let vel_len = agent.velocity.length();
179    let ahead_dist = agent.radius + vel_len * 2.0;
180    let heading = if vel_len > 0.0001 { agent.velocity / vel_len } else { agent.heading };
181
182    // Find the most threatening obstacle
183    let mut closest_dist = f32::INFINITY;
184    let mut closest: Option<(Vec2, f32)> = None;
185
186    for &(center, radius) in obstacles {
187        let local = center - agent.position;
188        // Project onto heading
189        let proj = local.dot(heading);
190        if proj < 0.0 { continue; } // behind agent
191        if proj > ahead_dist + radius { continue; } // too far
192        // Lateral distance
193        let lateral = (local - heading * proj).length();
194        let min_dist = agent.radius + radius;
195        if lateral < min_dist && proj < closest_dist {
196            closest_dist = proj;
197            closest = Some((center, radius));
198        }
199    }
200
201    if let Some((center, _)) = closest {
202        // Steer away laterally
203        let lateral = center - agent.position - heading * (center - agent.position).dot(heading);
204        if lateral.length_squared() > 0.0001 {
205            let away = -lateral.normalize() * agent.max_force;
206            return away;
207        }
208    }
209    Vec2::ZERO
210}
211
212/// Wall avoidance: steer away from line-segment walls `(a, b)`.
213pub fn wall_avoidance(agent: &SteeringAgent, walls: &[(Vec2, Vec2)]) -> Vec2 {
214    let vel_len = agent.velocity.length();
215    let ahead = if vel_len > 0.0001 {
216        agent.position + agent.velocity.normalize() * (agent.radius * 2.0 + vel_len)
217    } else {
218        agent.position + agent.heading * agent.radius * 2.0
219    };
220
221    let mut strongest = Vec2::ZERO;
222    let mut max_penetration = 0.0f32;
223
224    for &(a, b) in walls {
225        let closest = closest_point_on_segment(ahead, a, b);
226        let diff = ahead - closest;
227        let dist = diff.length();
228        let penetration = agent.radius * 2.0 - dist;
229        if penetration > 0.0 && penetration > max_penetration {
230            max_penetration = penetration;
231            if dist > 0.0001 {
232                strongest = (diff / dist) * agent.max_force;
233            }
234        }
235    }
236    strongest
237}
238
239/// Path following: stay on a path corridor.
240pub fn path_following(agent: &SteeringAgent, path: &[Vec2], path_radius: f32) -> Vec2 {
241    if path.len() < 2 { return Vec2::ZERO; }
242    let vel_len = agent.velocity.length();
243    let future = if vel_len > 0.0001 {
244        agent.position + agent.velocity.normalize() * (vel_len * 0.5 + 1.0)
245    } else {
246        agent.position
247    };
248
249    // Find nearest point on path
250    let mut nearest_pt = path[0];
251    let mut nearest_dist = f32::INFINITY;
252    let mut nearest_seg = 0usize;
253
254    for i in 0..path.len() - 1 {
255        let pt = closest_point_on_segment(future, path[i], path[i + 1]);
256        let d = future.distance(pt);
257        if d < nearest_dist {
258            nearest_dist = d;
259            nearest_pt = pt;
260            nearest_seg = i;
261        }
262    }
263
264    if nearest_dist <= path_radius {
265        return Vec2::ZERO; // within corridor, no correction needed
266    }
267
268    // Seek a point slightly ahead on the path
269    let ahead_dist = vel_len * 0.5 + 1.0;
270    let seg_dir = (path[nearest_seg + 1] - path[nearest_seg]).normalize_or_zero();
271    let target = nearest_pt + seg_dir * ahead_dist;
272    seek(agent, target)
273}
274
275/// Separation: maintain distance from nearby agents.
276pub fn separation(agent: &SteeringAgent, neighbors: &[&SteeringAgent]) -> Vec2 {
277    let mut force = Vec2::ZERO;
278    let mut count = 0;
279    for &nb in neighbors {
280        let diff = agent.position - nb.position;
281        let dist = diff.length();
282        let min_dist = agent.radius + nb.radius + 0.5;
283        if dist < min_dist && dist > 0.0001 {
284            force += diff.normalize() / dist;
285            count += 1;
286        }
287    }
288    if count > 0 {
289        force /= count as f32;
290        truncate(force * agent.max_speed - agent.velocity, agent.max_force)
291    } else {
292        Vec2::ZERO
293    }
294}
295
296/// Alignment: match heading with nearby agents.
297pub fn alignment(agent: &SteeringAgent, neighbors: &[&SteeringAgent]) -> Vec2 {
298    if neighbors.is_empty() { return Vec2::ZERO; }
299    let avg: Vec2 = neighbors.iter().map(|n| n.heading).sum::<Vec2>() / neighbors.len() as f32;
300    if avg.length_squared() < 0.0001 { return Vec2::ZERO; }
301    let desired = avg.normalize() * agent.max_speed;
302    truncate(desired - agent.velocity, agent.max_force)
303}
304
305/// Cohesion: steer toward the center of nearby agents.
306pub fn cohesion(agent: &SteeringAgent, neighbors: &[&SteeringAgent]) -> Vec2 {
307    if neighbors.is_empty() { return Vec2::ZERO; }
308    let center: Vec2 = neighbors.iter().map(|n| n.position).sum::<Vec2>() / neighbors.len() as f32;
309    seek(agent, center)
310}
311
312/// Leader following: follow a leader while maintaining an offset.
313pub fn leader_following(agent: &SteeringAgent, leader: &SteeringAgent, offset: Vec2) -> Vec2 {
314    // Transform offset into world space based on leader heading
315    let heading = leader.heading;
316    let right = Vec2::new(-heading.y, heading.x);
317    let world_offset = leader.position + heading * offset.y + right * offset.x;
318    // Evade if we're going to be run over
319    let too_close = agent.position.distance(leader.position) < leader.radius + agent.radius + 1.0;
320    if too_close {
321        flee(agent, leader.position)
322    } else {
323        arrive(agent, world_offset, 2.0)
324    }
325}
326
327/// Queue: follow leader in a line (steer to stop if blocked by another agent ahead).
328pub fn queue(
329    agent: &SteeringAgent,
330    leader: &SteeringAgent,
331    neighbors: &[&SteeringAgent],
332) -> Vec2 {
333    let ahead = agent.position + agent.heading * (agent.radius * 2.0 + 1.0);
334    let blocked = neighbors.iter().any(|n| {
335        n.position.distance(ahead) < n.radius + agent.radius
336    });
337    if blocked {
338        // Brake
339        -agent.velocity * 0.5
340    } else {
341        arrive(agent, leader.position + leader.heading * -(leader.radius + agent.radius + 0.5), 2.0)
342    }
343}
344
345/// Interpose: steer to a position between two points `a` and `b`.
346pub fn interpose(agent: &SteeringAgent, a: Vec2, b: Vec2) -> Vec2 {
347    let midpoint = (a + b) * 0.5;
348    arrive(agent, midpoint, 2.0)
349}
350
351// ---------------------------------------------------------------------------
352// Helpers
353// ---------------------------------------------------------------------------
354
355fn closest_point_on_segment(pt: Vec2, a: Vec2, b: Vec2) -> Vec2 {
356    let ab = b - a;
357    let len_sq = ab.length_squared();
358    if len_sq < 0.0001 { return a; }
359    let t = ((pt - a).dot(ab) / len_sq).clamp(0.0, 1.0);
360    a + ab * t
361}
362
363/// Pseudo-random float in [0, 1) using a bit-mixing trick on a counter.
364static RAND_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(12345);
365fn random_f32() -> f32 {
366    let v = RAND_COUNTER.fetch_add(2654435761, std::sync::atomic::Ordering::Relaxed);
367    let h = v ^ (v >> 16);
368    let h = h.wrapping_mul(0x45d9f3b37197344d);
369    let h = h ^ (h >> 16);
370    (h as f32) / (u64::MAX as f32)
371}
372
373// ---------------------------------------------------------------------------
374// SteeringBehavior enum
375// ---------------------------------------------------------------------------
376
377/// All available steering behaviors as a tagged enum for use in combiners.
378#[derive(Debug, Clone)]
379pub enum SteeringBehavior {
380    Seek(Vec2),
381    Flee(Vec2),
382    Arrive { target: Vec2, slow_radius: f32 },
383    Pursuit { quarry_pos: Vec2, quarry_vel: Vec2 },
384    Evade   { threat_pos: Vec2, threat_vel: Vec2 },
385    Wander  { circle_dist: f32, circle_radius: f32, jitter: f32 },
386    ObstacleAvoidance(Vec<(Vec2, f32)>),
387    WallAvoidance(Vec<(Vec2, Vec2)>),
388    PathFollowing { path: Vec<Vec2>, radius: f32 },
389    Separation,
390    Alignment,
391    Cohesion,
392    LeaderFollowing { offset: Vec2 },
393    Interpose { a: Vec2, b: Vec2 },
394    None,
395}
396
397impl SteeringBehavior {
398    /// Compute the steering force for this behavior.
399    /// `neighbors` and `leader` are optional context.
400    pub fn compute(
401        &self,
402        agent: &mut SteeringAgent,
403        neighbors: &[&SteeringAgent],
404        leader: Option<&SteeringAgent>,
405    ) -> Vec2 {
406        match self {
407            SteeringBehavior::Seek(t) => seek(agent, *t),
408            SteeringBehavior::Flee(t) => flee(agent, *t),
409            SteeringBehavior::Arrive { target, slow_radius } => arrive(agent, *target, *slow_radius),
410            SteeringBehavior::Pursuit { quarry_pos, quarry_vel } => pursuit(agent, *quarry_pos, *quarry_vel),
411            SteeringBehavior::Evade   { threat_pos, threat_vel } => evade(agent, *threat_pos, *threat_vel),
412            SteeringBehavior::Wander  { circle_dist, circle_radius, jitter } =>
413                wander(agent, *circle_dist, *circle_radius, *jitter),
414            SteeringBehavior::ObstacleAvoidance(obs) => obstacle_avoidance(agent, obs),
415            SteeringBehavior::WallAvoidance(walls)   => wall_avoidance(agent, walls),
416            SteeringBehavior::PathFollowing { path, radius } => path_following(agent, path, *radius),
417            SteeringBehavior::Separation => separation(agent, neighbors),
418            SteeringBehavior::Alignment  => alignment(agent, neighbors),
419            SteeringBehavior::Cohesion   => cohesion(agent, neighbors),
420            SteeringBehavior::LeaderFollowing { offset } =>
421                leader.map(|l| leader_following(agent, l, *offset)).unwrap_or(Vec2::ZERO),
422            SteeringBehavior::Interpose { a, b } => interpose(agent, *a, *b),
423            SteeringBehavior::None => Vec2::ZERO,
424        }
425    }
426}
427
428// ---------------------------------------------------------------------------
429// WeightedSteering
430// ---------------------------------------------------------------------------
431
432/// Combine multiple steering behaviors using a weighted sum.
433#[derive(Debug, Clone, Default)]
434pub struct WeightedSteering {
435    pub behaviors: Vec<(SteeringBehavior, f32)>,
436}
437
438impl WeightedSteering {
439    pub fn new() -> Self { WeightedSteering::default() }
440
441    pub fn add(&mut self, behavior: SteeringBehavior, weight: f32) -> &mut Self {
442        self.behaviors.push((behavior, weight));
443        self
444    }
445
446    /// Compute the weighted sum of all forces, clamped to agent's max_force.
447    pub fn calculate(
448        &self,
449        agent: &mut SteeringAgent,
450        neighbors: &[&SteeringAgent],
451    ) -> Vec2 {
452        self.calculate_with_leader(agent, neighbors, None)
453    }
454
455    pub fn calculate_with_leader(
456        &self,
457        agent: &mut SteeringAgent,
458        neighbors: &[&SteeringAgent],
459        leader: Option<&SteeringAgent>,
460    ) -> Vec2 {
461        let mut total = Vec2::ZERO;
462        for (behavior, weight) in &self.behaviors {
463            let force = behavior.compute(agent, neighbors, leader);
464            total += force * *weight;
465            // Early out if we've already hit max force
466            if total.length() >= agent.max_force { break; }
467        }
468        truncate(total, agent.max_force)
469    }
470}
471
472// ---------------------------------------------------------------------------
473// PrioritySteeringCombiner
474// ---------------------------------------------------------------------------
475
476/// Apply behaviors in priority order; use the first one that produces a
477/// non-negligible force.  Higher-indexed entries have lower priority.
478#[derive(Debug, Clone, Default)]
479pub struct PrioritySteeringCombiner {
480    pub behaviors: Vec<(SteeringBehavior, f32)>, // (behavior, min_magnitude_to_accept)
481}
482
483impl PrioritySteeringCombiner {
484    pub fn new() -> Self { PrioritySteeringCombiner::default() }
485
486    pub fn add(&mut self, behavior: SteeringBehavior, min_magnitude: f32) -> &mut Self {
487        self.behaviors.push((behavior, min_magnitude));
488        self
489    }
490
491    /// Return the force from the highest-priority active behavior.
492    pub fn calculate(
493        &self,
494        agent: &mut SteeringAgent,
495        neighbors: &[&SteeringAgent],
496        leader: Option<&SteeringAgent>,
497    ) -> Vec2 {
498        for (behavior, threshold) in &self.behaviors {
499            let force = behavior.compute(agent, neighbors, leader);
500            if force.length() > *threshold {
501                return truncate(force, agent.max_force);
502            }
503        }
504        Vec2::ZERO
505    }
506}
507
508// ---------------------------------------------------------------------------
509// SteeringSystem
510// ---------------------------------------------------------------------------
511
512/// Manages a list of agents and updates them each frame.
513#[derive(Debug, Clone, Default)]
514pub struct SteeringSystem {
515    pub agents: Vec<SteeringAgent>,
516    pub behaviors: Vec<WeightedSteering>,
517}
518
519impl SteeringSystem {
520    pub fn new() -> Self { SteeringSystem::default() }
521
522    pub fn add_agent(&mut self, agent: SteeringAgent, behavior: WeightedSteering) -> usize {
523        let idx = self.agents.len();
524        self.agents.push(agent);
525        self.behaviors.push(behavior);
526        idx
527    }
528
529    /// Update all agents for one timestep.
530    pub fn update(&mut self, dt: f32) {
531        // Collect neighbor slices per agent
532        let n = self.agents.len();
533        for i in 0..n {
534            // Build neighbor list (all other agents within some arbitrary radius)
535            let neighbors: Vec<&SteeringAgent> = self.agents.iter().enumerate()
536                .filter(|(j, _)| *j != i)
537                .map(|(_, a)| a as &SteeringAgent)
538                .collect();
539
540            // Temporarily take ownership
541            let mut agent = self.agents[i].clone();
542            let force = self.behaviors[i].calculate(&mut agent, &neighbors);
543            self.agents[i].apply_force(force, dt);
544            self.agents[i].update_position(dt);
545            self.agents[i].wander_angle = agent.wander_angle;
546        }
547    }
548
549    /// Returns the index of the agent nearest to `pos`.
550    pub fn nearest_agent(&self, pos: Vec2) -> Option<usize> {
551        self.agents.iter().enumerate()
552            .min_by(|(_, a), (_, b)| {
553                a.position.distance_squared(pos)
554                    .partial_cmp(&b.position.distance_squared(pos))
555                    .unwrap_or(std::cmp::Ordering::Equal)
556            })
557            .map(|(i, _)| i)
558    }
559
560    pub fn agent_count(&self) -> usize { self.agents.len() }
561}
562
563// ---------------------------------------------------------------------------
564// Context steering (slot-based)
565// ---------------------------------------------------------------------------
566
567/// Context steering maps: store interest and danger scores per direction slot.
568/// Useful for blending obstacle avoidance with goal seeking.
569#[derive(Debug, Clone)]
570pub struct ContextMap {
571    pub slots: usize,
572    pub interest: Vec<f32>,
573    pub danger: Vec<f32>,
574}
575
576impl ContextMap {
577    pub fn new(slots: usize) -> Self {
578        ContextMap {
579            slots,
580            interest: vec![0.0; slots],
581            danger: vec![0.0; slots],
582        }
583    }
584
585    pub fn slot_direction(&self, slot: usize) -> Vec2 {
586        let angle = (slot as f32 / self.slots as f32) * 2.0 * PI;
587        Vec2::new(angle.cos(), angle.sin())
588    }
589
590    pub fn add_interest(&mut self, direction: Vec2, weight: f32) {
591        let dir = if direction.length_squared() > 0.0 { direction.normalize() } else { return; };
592        for i in 0..self.slots {
593            let slot_dir = self.slot_direction(i);
594            let dot = dir.dot(slot_dir).max(0.0);
595            self.interest[i] += dot * weight;
596        }
597    }
598
599    pub fn add_danger(&mut self, direction: Vec2, weight: f32) {
600        let dir = if direction.length_squared() > 0.0 { direction.normalize() } else { return; };
601        for i in 0..self.slots {
602            let slot_dir = self.slot_direction(i);
603            let dot = dir.dot(slot_dir).max(0.0);
604            self.danger[i] += dot * weight;
605        }
606    }
607
608    /// Compute the best direction by masking interest with danger.
609    pub fn best_direction(&self) -> Vec2 {
610        let mut best_score = f32::NEG_INFINITY;
611        let mut best_dir = Vec2::ZERO;
612        for i in 0..self.slots {
613            let score = self.interest[i] - self.danger[i];
614            if score > best_score {
615                best_score = score;
616                best_dir = self.slot_direction(i);
617            }
618        }
619        best_dir
620    }
621
622    pub fn reset(&mut self) {
623        self.interest.fill(0.0);
624        self.danger.fill(0.0);
625    }
626}
627
628// ---------------------------------------------------------------------------
629// Kinematic helpers
630// ---------------------------------------------------------------------------
631
632/// Simple kinematic character: instant velocity change (no forces).
633#[derive(Debug, Clone)]
634pub struct KinematicAgent {
635    pub position: Vec2,
636    pub orientation: f32,
637    pub velocity: Vec2,
638    pub rotation: f32,
639    pub max_speed: f32,
640    pub max_rotation: f32,
641}
642
643impl KinematicAgent {
644    pub fn new(position: Vec2, max_speed: f32) -> Self {
645        KinematicAgent {
646            position,
647            orientation: 0.0,
648            velocity: Vec2::ZERO,
649            rotation: 0.0,
650            max_speed,
651            max_rotation: PI,
652        }
653    }
654
655    pub fn update(&mut self, dt: f32) {
656        self.position += self.velocity * dt;
657        self.orientation += self.rotation * dt;
658        // Wrap orientation to [-PI, PI]
659        while self.orientation >  PI { self.orientation -= 2.0 * PI; }
660        while self.orientation < -PI { self.orientation += 2.0 * PI; }
661    }
662
663    /// Kinematic seek: instantly set velocity toward target.
664    pub fn kinematic_seek(&mut self, target: Vec2) {
665        let diff = target - self.position;
666        let dist = diff.length();
667        if dist < 0.0001 { self.velocity = Vec2::ZERO; return; }
668        self.velocity = clamp_magnitude(diff / dist * self.max_speed, self.max_speed);
669        self.orientation = self.velocity.y.atan2(self.velocity.x);
670    }
671
672    /// Kinematic arrive: slow down near target.
673    pub fn kinematic_arrive(&mut self, target: Vec2, slow_radius: f32) {
674        let diff = target - self.position;
675        let dist = diff.length();
676        if dist < 0.0001 { self.velocity = Vec2::ZERO; return; }
677        let speed = if dist < slow_radius {
678            self.max_speed * dist / slow_radius
679        } else {
680            self.max_speed
681        };
682        self.velocity = clamp_magnitude(diff / dist * speed, self.max_speed);
683    }
684}
685
686// ---------------------------------------------------------------------------
687// Unit tests
688// ---------------------------------------------------------------------------
689
690#[cfg(test)]
691mod tests {
692    use super::*;
693    use glam::Vec2;
694
695    fn agent_at(pos: Vec2) -> SteeringAgent {
696        let mut a = SteeringAgent::new(pos, 5.0, 10.0);
697        a.velocity = Vec2::X * 1.0;
698        a.heading  = Vec2::X;
699        a
700    }
701
702    #[test]
703    fn test_seek_toward_target() {
704        let agent = agent_at(Vec2::ZERO);
705        let force = seek(&agent, Vec2::new(10.0, 0.0));
706        assert!(force.x > 0.0, "should seek in +x");
707    }
708
709    #[test]
710    fn test_flee_away_from_threat() {
711        let agent = agent_at(Vec2::ZERO);
712        let force = flee(&agent, Vec2::new(1.0, 0.0));
713        assert!(force.x < 0.0, "should flee in -x");
714    }
715
716    #[test]
717    fn test_arrive_slows_near_target() {
718        let agent = agent_at(Vec2::new(1.0, 0.0));
719        let far  = arrive(&agent, Vec2::new(100.0, 0.0), 5.0);
720        let near = arrive(&agent, Vec2::new(2.0, 0.0), 5.0);
721        // Near target: desired speed is reduced → smaller force magnitude
722        assert!(near.length() <= far.length() + 0.1);
723    }
724
725    #[test]
726    fn test_arrive_zero_at_target() {
727        let agent = agent_at(Vec2::ZERO);
728        let force = arrive(&agent, Vec2::ZERO, 2.0);
729        assert!(force.length() < 0.1);
730    }
731
732    #[test]
733    fn test_pursuit_ahead_of_quarry() {
734        let agent = agent_at(Vec2::ZERO);
735        let quarry_pos = Vec2::new(5.0, 0.0);
736        let quarry_vel = Vec2::new(1.0, 0.0);
737        let force = pursuit(&agent, quarry_pos, quarry_vel);
738        // Should lead the quarry, so force.x > 0
739        assert!(force.x > 0.0);
740    }
741
742    #[test]
743    fn test_evade_from_approaching_threat() {
744        let agent = agent_at(Vec2::ZERO);
745        let threat_pos = Vec2::new(3.0, 0.0);
746        let threat_vel = Vec2::new(-2.0, 0.0); // approaching
747        let force = evade(&agent, threat_pos, threat_vel);
748        assert!(force.x < 0.0, "should evade away");
749    }
750
751    #[test]
752    fn test_wander_produces_force() {
753        let mut agent = agent_at(Vec2::ZERO);
754        let force = wander(&mut agent, 3.0, 1.0, 0.5);
755        // Wander should produce some non-trivial force (or zero in degenerate case)
756        let _ = force.length(); // just check it doesn't panic
757    }
758
759    #[test]
760    fn test_obstacle_avoidance_no_obstacles() {
761        let agent = agent_at(Vec2::ZERO);
762        let force = obstacle_avoidance(&agent, &[]);
763        assert_eq!(force, Vec2::ZERO);
764    }
765
766    #[test]
767    fn test_obstacle_avoidance_with_obstacle_ahead() {
768        let mut agent = agent_at(Vec2::ZERO);
769        agent.velocity = Vec2::new(3.0, 0.0);
770        agent.heading  = Vec2::X;
771        let force = obstacle_avoidance(&agent, &[(Vec2::new(3.0, 0.0), 1.0)]);
772        // Some lateral force should be produced
773        let _ = force;
774    }
775
776    #[test]
777    fn test_separation_pushes_apart() {
778        let a1 = agent_at(Vec2::ZERO);
779        let a2 = agent_at(Vec2::new(0.3, 0.0));
780        let force = separation(&a1, &[&a2]);
781        assert!(force.x < 0.0 || force.length() > 0.0); // pushed away
782    }
783
784    #[test]
785    fn test_cohesion_pulls_together() {
786        let a1 = agent_at(Vec2::ZERO);
787        let a2 = agent_at(Vec2::new(4.0, 0.0));
788        let force = cohesion(&a1, &[&a2]);
789        assert!(force.x > 0.0, "should attract toward neighbor");
790    }
791
792    #[test]
793    fn test_alignment_matching_heading() {
794        let a1 = agent_at(Vec2::ZERO);
795        let mut a2 = agent_at(Vec2::new(1.0, 0.0));
796        a2.heading = Vec2::X;
797        // Both heading +x — alignment force should be minimal
798        let force = alignment(&a1, &[&a2]);
799        assert!(force.length() < 5.0);
800    }
801
802    #[test]
803    fn test_interpose_midpoint() {
804        let agent = agent_at(Vec2::ZERO);
805        let a = Vec2::new(-5.0, 0.0);
806        let b = Vec2::new(5.0, 0.0);
807        let force = interpose(&agent, a, b);
808        // Midpoint is (0,0) where agent already is — force should be small
809        assert!(force.length() < agent.max_force + 0.1);
810    }
811
812    #[test]
813    fn test_weighted_steering() {
814        let mut agent = agent_at(Vec2::ZERO);
815        let mut ws = WeightedSteering::new();
816        ws.add(SteeringBehavior::Seek(Vec2::new(10.0, 0.0)), 1.0);
817        ws.add(SteeringBehavior::Flee(Vec2::new(0.0, 0.0)), 0.2);
818        let force = ws.calculate(&mut agent, &[]);
819        assert!(force.length() > 0.0);
820    }
821
822    #[test]
823    fn test_priority_combiner() {
824        let mut agent = agent_at(Vec2::ZERO);
825        let mut combiner = PrioritySteeringCombiner::new();
826        combiner.add(SteeringBehavior::None, 0.001);
827        combiner.add(SteeringBehavior::Seek(Vec2::new(5.0, 0.0)), 0.001);
828        let force = combiner.calculate(&mut agent, &[], None);
829        // Should skip None and use Seek
830        assert!(force.x > 0.0);
831    }
832
833    #[test]
834    fn test_steering_system_update() {
835        let mut system = SteeringSystem::new();
836        let agent = agent_at(Vec2::ZERO);
837        let mut ws = WeightedSteering::new();
838        ws.add(SteeringBehavior::Seek(Vec2::new(10.0, 10.0)), 1.0);
839        system.add_agent(agent, ws);
840        system.update(0.016);
841        assert!(system.agents[0].position.length() > 0.0 || true);
842    }
843
844    #[test]
845    fn test_apply_force_updates_velocity() {
846        let mut agent = agent_at(Vec2::ZERO);
847        agent.velocity = Vec2::ZERO;
848        agent.apply_force(Vec2::new(5.0, 0.0), 1.0);
849        assert!(agent.velocity.x > 0.0);
850    }
851
852    #[test]
853    fn test_clamp_magnitude() {
854        let v = Vec2::new(3.0, 4.0); // length = 5
855        let clamped = clamp_magnitude(v, 2.5);
856        assert!((clamped.length() - 2.5).abs() < 0.001);
857    }
858
859    #[test]
860    fn test_context_map() {
861        let mut ctx = ContextMap::new(8);
862        ctx.add_interest(Vec2::new(1.0, 0.0), 1.0);
863        ctx.add_danger (Vec2::new(-1.0, 0.0), 0.5);
864        let best = ctx.best_direction();
865        assert!(best.x > 0.0, "best direction should be +x");
866    }
867
868    #[test]
869    fn test_kinematic_seek() {
870        let mut k = KinematicAgent::new(Vec2::ZERO, 5.0);
871        k.kinematic_seek(Vec2::new(10.0, 0.0));
872        assert!(k.velocity.x > 0.0);
873    }
874
875    #[test]
876    fn test_path_following_on_path() {
877        let agent = agent_at(Vec2::new(0.0, 0.5));
878        let path = vec![Vec2::ZERO, Vec2::new(10.0, 0.0)];
879        // Agent is very close to path (y=0.5 < radius=2.0) → should return ~zero
880        let force = path_following(&agent, &path, 2.0);
881        assert!(force.length() < agent.max_force + 0.1);
882    }
883
884    #[test]
885    fn test_leader_following() {
886        let follower = agent_at(Vec2::new(0.0, 0.0));
887        let mut leader = agent_at(Vec2::new(5.0, 0.0));
888        leader.heading = Vec2::X;
889        let force = leader_following(&follower, &leader, Vec2::new(0.0, -2.0));
890        let _ = force; // just no panic
891    }
892}