Skip to main content

proof_engine/entity/
cohesion.rs

1//! Cohesion dynamics — spring-based physics keeping glyphs bound to their formation.
2//!
3//! Cohesion drives how tightly glyphs cling to their target positions. At cohesion=1.0
4//! they snap instantly; at 0.0 they drift freely under chaos forces. Temperature adds
5//! thermal jitter that makes formations feel alive and organic. When cohesion reaches
6//! zero, the entity dissolves in an outward burst.
7
8use glam::Vec3;
9use crate::math::springs::SpringDamper3;
10
11// ── Cohesion state per glyph ──────────────────────────────────────────────────
12
13/// Per-glyph cohesion spring connecting the glyph to its formation slot.
14pub struct GlyphCohesion {
15    /// The spring driving this glyph toward its target slot.
16    pub spring: SpringDamper3,
17    /// Current temperature (0 = cold/calm, 1 = hot/jittery).
18    pub temperature: f32,
19    /// Thermal velocity — random drift added to spring velocity each frame.
20    pub thermal_vel: Vec3,
21    /// How much this glyph has drifted from its slot (0 = at rest, 1 = maximum drift).
22    pub drift: f32,
23    /// Phase offset for independent oscillation (prevents lockstep movement).
24    pub phase: f32,
25}
26
27impl GlyphCohesion {
28    /// Create a new cohesion spring at a formation slot position.
29    pub fn new(slot_position: Vec3, cohesion_strength: f32, phase: f32) -> Self {
30        let (stiffness, damping) = cohesion_to_spring(cohesion_strength);
31        Self {
32            spring: SpringDamper3::from_vec3(slot_position, stiffness, damping),
33            temperature: 0.0,
34            thermal_vel: Vec3::ZERO,
35            drift: 0.0,
36            phase,
37        }
38    }
39
40    /// Step the cohesion spring by dt seconds.
41    ///
42    /// Returns the new glyph position including thermal jitter.
43    pub fn tick(&mut self, dt: f32, cohesion: f32) -> Vec3 {
44        // Recompute spring constants from current cohesion
45        let (stiffness, damping) = cohesion_to_spring(cohesion);
46        self.spring.x.stiffness = stiffness;
47        self.spring.x.damping = damping;
48        self.spring.y.stiffness = stiffness;
49        self.spring.y.damping = damping;
50        self.spring.z.stiffness = stiffness;
51        self.spring.z.damping = damping;
52
53        // Decay thermal jitter
54        self.thermal_vel *= (1.0 - 4.0 * dt).max(0.0);
55
56        // Step spring
57        let base_pos = self.spring.tick(dt);
58
59        // Compute drift from target
60        let target = Vec3::new(
61            self.spring.x.target,
62            self.spring.y.target,
63            self.spring.z.target,
64        );
65        self.drift = (base_pos - target).length();
66
67        // Add thermal jitter to position
68        base_pos + self.thermal_vel * self.temperature
69    }
70
71    /// Apply thermal energy to this glyph (increases jitter).
72    pub fn heat(&mut self, temperature: f32, seed: f32) {
73        self.temperature = temperature.clamp(0.0, 1.0);
74        // Random impulse in a unit sphere direction
75        let jitter_vel = thermal_direction(seed + self.phase) * temperature * 0.8;
76        self.thermal_vel += jitter_vel;
77    }
78
79    /// Cool this glyph down (reduces jitter).
80    pub fn cool(&mut self, rate: f32, dt: f32) {
81        self.temperature = (self.temperature - rate * dt).max(0.0);
82    }
83
84    /// Move the target formation slot (animated spring follow).
85    pub fn set_target(&mut self, new_slot: Vec3) {
86        self.spring.set_target(new_slot);
87    }
88
89    /// Teleport position (no spring animation, instant).
90    pub fn teleport(&mut self, pos: Vec3) {
91        self.spring.x.position = pos.x;
92        self.spring.y.position = pos.y;
93        self.spring.z.position = pos.z;
94        self.spring.x.velocity = 0.0;
95        self.spring.y.velocity = 0.0;
96        self.spring.z.velocity = 0.0;
97    }
98
99    /// Current position (without ticking).
100    pub fn position(&self) -> Vec3 {
101        Vec3::new(self.spring.x.position, self.spring.y.position, self.spring.z.position)
102    }
103
104    /// Current velocity.
105    pub fn velocity(&self) -> Vec3 {
106        Vec3::new(self.spring.x.velocity, self.spring.y.velocity, self.spring.z.velocity)
107    }
108
109    /// Apply an impulse force (adds to spring velocity).
110    pub fn apply_impulse(&mut self, impulse: Vec3) {
111        self.spring.x.velocity += impulse.x;
112        self.spring.y.velocity += impulse.y;
113        self.spring.z.velocity += impulse.z;
114    }
115}
116
117// ── Entity-level cohesion manager ─────────────────────────────────────────────
118
119/// Manages cohesion for all glyphs in an entity.
120pub struct CohesionManager {
121    pub glyphs: Vec<GlyphCohesion>,
122    /// Entity-level cohesion [0, 1]. 0 = chaotic, 1 = perfectly bound.
123    pub cohesion: f32,
124    /// Dissolution state: None = intact, Some(t) = dissolving (t = time since start).
125    pub dissolution: Option<f32>,
126    /// Velocity vectors set during dissolution burst.
127    pub burst_velocities: Vec<Vec3>,
128}
129
130impl CohesionManager {
131    /// Create with N glyphs at given positions.
132    pub fn new(positions: &[Vec3], cohesion: f32) -> Self {
133        let glyphs = positions
134            .iter()
135            .enumerate()
136            .map(|(i, &pos)| {
137                let phase = i as f32 * 1.618033988; // golden ratio spacing
138                GlyphCohesion::new(pos, cohesion, phase)
139            })
140            .collect();
141        Self {
142            glyphs,
143            cohesion,
144            dissolution: None,
145            burst_velocities: Vec::new(),
146        }
147    }
148
149    /// Tick all glyph springs by dt. Returns Vec of positions.
150    pub fn tick(&mut self, dt: f32) -> Vec<Vec3> {
151        if let Some(ref mut t) = self.dissolution {
152            *t += dt;
153            // Drift outward using stored burst velocities
154            return self.glyphs
155                .iter_mut()
156                .zip(self.burst_velocities.iter())
157                .map(|(g, &bv)| {
158                    let pos = g.position() + bv * dt;
159                    // Update spring position to match drift
160                    g.spring.x.position = pos.x;
161                    g.spring.y.position = pos.y;
162                    g.spring.z.position = pos.z;
163                    pos
164                })
165                .collect();
166        }
167
168        self.glyphs
169            .iter_mut()
170            .map(|g| g.tick(dt, self.cohesion))
171            .collect()
172    }
173
174    /// Apply damage to cohesion (reduce it by amount).
175    pub fn damage_cohesion(&mut self, amount: f32) {
176        self.cohesion = (self.cohesion - amount).max(0.0);
177        if self.cohesion == 0.0 && self.dissolution.is_none() {
178            self.begin_dissolution();
179        }
180    }
181
182    /// Restore cohesion (healing effect).
183    pub fn restore_cohesion(&mut self, amount: f32) {
184        self.cohesion = (self.cohesion + amount).min(1.0);
185    }
186
187    /// Apply thermal energy to all glyphs (makes them jitter).
188    pub fn heat_all(&mut self, temperature: f32, time: f32) {
189        for (i, g) in self.glyphs.iter_mut().enumerate() {
190            g.heat(temperature, time + i as f32 * 0.37);
191        }
192    }
193
194    /// Cool all glyphs down.
195    pub fn cool_all(&mut self, rate: f32, dt: f32) {
196        for g in &mut self.glyphs {
197            g.cool(rate, dt);
198        }
199    }
200
201    /// Apply an outward impulse from center (e.g. shockwave impact).
202    pub fn apply_shockwave(&mut self, center: Vec3, strength: f32) {
203        for g in &mut self.glyphs {
204            let dir = (g.position() - center).normalize_or_zero();
205            g.apply_impulse(dir * strength);
206        }
207    }
208
209    /// Apply a directional force to all glyphs.
210    pub fn apply_force(&mut self, force: Vec3) {
211        for g in &mut self.glyphs {
212            g.apply_impulse(force);
213        }
214    }
215
216    /// Update formation targets (e.g. entity moved, or formation changed).
217    pub fn update_targets(&mut self, new_positions: &[Vec3]) {
218        for (g, &pos) in self.glyphs.iter_mut().zip(new_positions.iter()) {
219            g.set_target(pos);
220        }
221    }
222
223    /// Teleport all glyphs instantly to formation positions (no animation).
224    pub fn teleport_all(&mut self, positions: &[Vec3]) {
225        for (g, &pos) in self.glyphs.iter_mut().zip(positions.iter()) {
226            g.teleport(pos);
227        }
228    }
229
230    /// Whether the entity is currently dissolving.
231    pub fn is_dissolving(&self) -> bool { self.dissolution.is_some() }
232
233    /// Whether dissolution is complete (> 2 seconds have elapsed).
234    pub fn is_dissolved(&self) -> bool {
235        self.dissolution.map(|t| t > 2.0).unwrap_or(false)
236    }
237
238    /// Begin the dissolution burst.
239    fn begin_dissolution(&mut self) {
240        self.dissolution = Some(0.0);
241        let center = self.centroid();
242        self.burst_velocities = dissolution_burst(
243            &self.glyphs.iter().map(|g| g.position()).collect::<Vec<_>>(),
244            center,
245        );
246    }
247
248    /// Average position of all glyphs.
249    pub fn centroid(&self) -> Vec3 {
250        if self.glyphs.is_empty() { return Vec3::ZERO; }
251        let sum: Vec3 = self.glyphs.iter().map(|g| g.position()).sum();
252        sum / self.glyphs.len() as f32
253    }
254
255    /// Max drift across all glyphs (measures how chaotic the formation is).
256    pub fn max_drift(&self) -> f32 {
257        self.glyphs.iter().map(|g| g.drift).fold(0.0f32, f32::max)
258    }
259
260    /// Average temperature.
261    pub fn avg_temperature(&self) -> f32 {
262        if self.glyphs.is_empty() { return 0.0; }
263        self.glyphs.iter().map(|g| g.temperature).sum::<f32>() / self.glyphs.len() as f32
264    }
265}
266
267// ── Free functions ────────────────────────────────────────────────────────────
268
269/// Convert cohesion [0, 1] to spring stiffness and damping.
270///
271/// At cohesion=0: very loose (stiffness=0.5, damping=0.3)
272/// At cohesion=1: snappy (stiffness=40, damping=8)
273pub fn cohesion_to_spring(cohesion: f32) -> (f32, f32) {
274    let c = cohesion.clamp(0.0, 1.0);
275    let stiffness = 0.5 + c * c * 39.5;  // quadratic for more natural feel
276    let damping   = 0.3 + c * 7.7;
277    (stiffness, damping)
278}
279
280/// Calculate how far a glyph at `actual` should move toward `target`
281/// given cohesion strength [0, 1] and elapsed time dt.
282pub fn cohesion_pull(actual: Vec3, target: Vec3, cohesion: f32, dt: f32) -> Vec3 {
283    let delta = target - actual;
284    let stiffness = cohesion_to_spring(cohesion).0;
285    delta * stiffness * dt
286}
287
288/// Emit a formation dissolution burst.
289/// Returns outward velocity vectors for each glyph.
290pub fn dissolution_burst(positions: &[Vec3], center: Vec3) -> Vec<Vec3> {
291    positions.iter().enumerate().map(|(i, pos)| {
292        let dir = (*pos - center).normalize_or_zero();
293        let speed = 2.0 + rand_f32_seeded(i as u64) * 3.0;
294        // Add upward component for visual interest
295        let up_bias = Vec3::new(0.0, rand_f32_seeded(i as u64 + 1000) * 2.0, 0.0);
296        dir * speed + up_bias
297    }).collect()
298}
299
300/// Evaluate a thermal random direction from a seed.
301fn thermal_direction(seed: f32) -> Vec3 {
302    let h1 = (seed * 127.1 + 311.7) as u64;
303    let h1 = h1.wrapping_mul(0x9e3779b97f4a7c15);
304    let h2 = h1.wrapping_mul(0x6c62272e07bb0142);
305    let h3 = h2.wrapping_mul(0x9e3779b97f4a7c15);
306    let x = (h1 >> 32) as f32 / u32::MAX as f32 * 2.0 - 1.0;
307    let y = (h2 >> 32) as f32 / u32::MAX as f32 * 2.0 - 1.0;
308    let z = (h3 >> 32) as f32 / u32::MAX as f32 * 2.0 - 1.0;
309    Vec3::new(x, y, z).normalize_or_zero()
310}
311
312fn rand_f32_seeded(seed: u64) -> f32 {
313    let x = seed.wrapping_mul(0x9e3779b97f4a7c15).wrapping_add(0x6c62272e07bb0142);
314    (x >> 32) as f32 / u32::MAX as f32
315}
316
317// ── Tests ─────────────────────────────────────────────────────────────────────
318
319#[cfg(test)]
320mod tests {
321    use super::*;
322
323    #[test]
324    fn cohesion_spring_bounds() {
325        let (s0, d0) = cohesion_to_spring(0.0);
326        let (s1, d1) = cohesion_to_spring(1.0);
327        assert!(s1 > s0);
328        assert!(d1 > d0);
329    }
330
331    #[test]
332    fn manager_ticks_without_panic() {
333        let positions = vec![Vec3::ZERO, Vec3::X, Vec3::Y];
334        let mut mgr = CohesionManager::new(&positions, 0.8);
335        let result = mgr.tick(0.016);
336        assert_eq!(result.len(), 3);
337    }
338
339    #[test]
340    fn dissolution_triggers_at_zero_cohesion() {
341        let positions = vec![Vec3::X, Vec3::Y, Vec3::Z];
342        let mut mgr = CohesionManager::new(&positions, 0.1);
343        mgr.damage_cohesion(0.1);
344        assert!(mgr.is_dissolving());
345    }
346
347    #[test]
348    fn shockwave_imparts_velocity() {
349        let positions = vec![Vec3::new(1.0, 0.0, 0.0)];
350        let mut mgr = CohesionManager::new(&positions, 0.9);
351        let before = mgr.glyphs[0].velocity();
352        mgr.apply_shockwave(Vec3::ZERO, 5.0);
353        let after = mgr.glyphs[0].velocity();
354        assert!(after.length() > before.length());
355    }
356
357    #[test]
358    fn cohesion_pull_scales_with_cohesion() {
359        let a = Vec3::ZERO;
360        let b = Vec3::new(1.0, 0.0, 0.0);
361        let low  = cohesion_pull(a, b, 0.1, 0.016).length();
362        let high = cohesion_pull(a, b, 0.9, 0.016).length();
363        assert!(high > low);
364    }
365}