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.
14#[derive(Clone)]
15pub struct GlyphCohesion {
16    /// The spring driving this glyph toward its target slot.
17    pub spring: SpringDamper3,
18    /// Current temperature (0 = cold/calm, 1 = hot/jittery).
19    pub temperature: f32,
20    /// Thermal velocity — random drift added to spring velocity each frame.
21    pub thermal_vel: Vec3,
22    /// How much this glyph has drifted from its slot (0 = at rest, 1 = maximum drift).
23    pub drift: f32,
24    /// Phase offset for independent oscillation (prevents lockstep movement).
25    pub phase: f32,
26    /// Multipliers on the spring constants cohesion would otherwise give.
27    ///
28    /// Cohesion says how strongly matter *wants* to hold its shape, which is a
29    /// property of the thing. How fast it gets there, and whether it overshoots
30    /// on the way, is a property of the shot: a body settling into an idle and
31    /// a title card snapping into place want very different springs at the same
32    /// cohesion. `1.0, 1.0` is the default cohesion curve.
33    pub bind_stiffness: f32,
34    pub bind_damping: f32,
35}
36
37impl GlyphCohesion {
38    /// Create a new cohesion spring at a formation slot position.
39    pub fn new(slot_position: Vec3, cohesion_strength: f32, phase: f32) -> Self {
40        let (stiffness, damping) = cohesion_to_spring(cohesion_strength);
41        Self {
42            spring: SpringDamper3::from_vec3(slot_position, stiffness, damping),
43            temperature: 0.0,
44            thermal_vel: Vec3::ZERO,
45            drift: 0.0,
46            phase,
47            bind_stiffness: 1.0,
48            bind_damping: 1.0,
49        }
50    }
51
52    /// Step the cohesion spring by dt seconds.
53    ///
54    /// Returns the new glyph position including thermal jitter.
55    pub fn tick(&mut self, dt: f32, cohesion: f32) -> Vec3 {
56        // Recompute spring constants from current cohesion
57        let (k, d) = cohesion_to_spring(cohesion);
58        let stiffness = k * self.bind_stiffness.max(0.0);
59        let damping = d * self.bind_damping.max(0.0);
60        self.spring.x.stiffness = stiffness;
61        self.spring.x.damping = damping;
62        self.spring.y.stiffness = stiffness;
63        self.spring.y.damping = damping;
64        self.spring.z.stiffness = stiffness;
65        self.spring.z.damping = damping;
66
67        // Decay thermal jitter
68        self.thermal_vel *= (1.0 - 4.0 * dt).max(0.0);
69
70        // Step spring
71        let base_pos = self.spring.tick(dt);
72
73        // Compute drift from target
74        let target = Vec3::new(
75            self.spring.x.target,
76            self.spring.y.target,
77            self.spring.z.target,
78        );
79        self.drift = (base_pos - target).length();
80
81        // Add thermal jitter to position
82        base_pos + self.thermal_vel * self.temperature
83    }
84
85    /// Apply thermal energy to this glyph (increases jitter).
86    pub fn heat(&mut self, temperature: f32, seed: f32) {
87        self.temperature = temperature.clamp(0.0, 1.0);
88        // Random impulse in a unit sphere direction
89        let jitter_vel = thermal_direction(seed + self.phase) * temperature * 0.8;
90        self.thermal_vel += jitter_vel;
91    }
92
93    /// Cool this glyph down (reduces jitter).
94    pub fn cool(&mut self, rate: f32, dt: f32) {
95        self.temperature = (self.temperature - rate * dt).max(0.0);
96    }
97
98    /// Move the target formation slot (animated spring follow).
99    pub fn set_target(&mut self, new_slot: Vec3) {
100        self.spring.set_target(new_slot);
101    }
102
103    /// Teleport position (no spring animation, instant).
104    pub fn teleport(&mut self, pos: Vec3) {
105        self.spring.x.position = pos.x;
106        self.spring.y.position = pos.y;
107        self.spring.z.position = pos.z;
108        self.spring.x.velocity = 0.0;
109        self.spring.y.velocity = 0.0;
110        self.spring.z.velocity = 0.0;
111    }
112
113    /// Current position (without ticking).
114    pub fn position(&self) -> Vec3 {
115        Vec3::new(self.spring.x.position, self.spring.y.position, self.spring.z.position)
116    }
117
118    /// Current velocity.
119    pub fn velocity(&self) -> Vec3 {
120        Vec3::new(self.spring.x.velocity, self.spring.y.velocity, self.spring.z.velocity)
121    }
122
123    /// Apply an impulse (adds to spring velocity).
124    ///
125    /// An impulse is a change in momentum, so the same blow moves a light
126    /// glyph further than a heavy one: dv = J / m.
127    pub fn apply_impulse(&mut self, impulse: Vec3) {
128        let dv = impulse / self.mass().max(0.01);
129        self.spring.x.velocity += dv.x;
130        self.spring.y.velocity += dv.y;
131        self.spring.z.velocity += dv.z;
132    }
133
134    /// This glyph's inertia.
135    pub fn mass(&self) -> f32 {
136        self.spring.mass()
137    }
138
139    /// Set this glyph's inertia. Heavy glyphs form the core of a figure and
140    /// swing slowly; light ones fly off first when the binding fails.
141    pub fn set_mass(&mut self, mass: f32) {
142        self.spring.set_mass(mass);
143    }
144}
145
146// ── Entity-level cohesion manager ─────────────────────────────────────────────
147
148/// Manages cohesion for all glyphs in an entity.
149#[derive(Clone)]
150pub struct CohesionManager {
151    /// Where the ground is, if this matter is standing on any.
152    ///
153    /// In formation space, y downward: nothing may go below it. See
154    /// [`CohesionManager::set_floor`].
155    floor: Option<f32>,
156    pub glyphs: Vec<GlyphCohesion>,
157    /// Entity-level cohesion [0, 1]. 0 = chaotic, 1 = perfectly bound.
158    pub cohesion: f32,
159    /// Dissolution state: None = intact, Some(t) = dissolving (t = time since start).
160    pub dissolution: Option<f32>,
161    /// Velocity vectors set during dissolution burst.
162    pub burst_velocities: Vec<Vec3>,
163}
164
165impl CohesionManager {
166    /// Create with N glyphs at given positions.
167    pub fn new(positions: &[Vec3], cohesion: f32) -> Self {
168        let glyphs = positions
169            .iter()
170            .enumerate()
171            .map(|(i, &pos)| {
172                let phase = i as f32 * 1.618033988; // golden ratio spacing
173                GlyphCohesion::new(pos, cohesion, phase)
174            })
175            .collect();
176        Self {
177            floor: None,
178            glyphs,
179            cohesion,
180            dissolution: None,
181            burst_velocities: Vec::new(),
182        }
183    }
184
185    /// Tick all glyph springs by dt. Returns Vec of positions.
186    pub fn tick(&mut self, dt: f32) -> Vec<Vec3> {
187        if let Some(ref mut t) = self.dissolution {
188            *t += dt;
189            // Drift outward using stored burst velocities
190            let mut out: Vec<Vec3> = self
191                .glyphs
192                .iter_mut()
193                .zip(self.burst_velocities.iter())
194                .map(|(g, &bv)| {
195                    let pos = g.position() + bv * dt;
196                    // Update spring position to match drift
197                    g.spring.x.position = pos.x;
198                    g.spring.y.position = pos.y;
199                    g.spring.z.position = pos.z;
200                    // Dissolving matter is the furthest thing there is from its
201                    // slot, so `drift` has to keep tracking it. Leaving it at
202                    // its last bound value tells every reader downstream that a
203                    // scattering cloud is still held together.
204                    let target = Vec3::new(
205                        g.spring.x.target,
206                        g.spring.y.target,
207                        g.spring.z.target,
208                    );
209                    g.drift = (pos - target).length();
210                    pos
211                })
212                .collect();
213            self.apply_floor(&mut out);
214            return out;
215        }
216
217        let mut out: Vec<Vec3> = self
218            .glyphs
219            .iter_mut()
220            .map(|g| g.tick(dt, self.cohesion))
221            .collect();
222        self.apply_floor(&mut out);
223        out
224    }
225
226    /// Put a floor under the matter.
227    ///
228    /// `y` is where the ground is, in the same space the formation is in, and
229    /// y grows downward — so nothing may end up with a larger y than this.
230    /// `None` removes it.
231    ///
232    /// This is what stops a figure being a cloud that happens to hover at the
233    /// right height. Matter driven into the ground stops at it, debris from a
234    /// death piles up on it instead of falling through it, and a body that
235    /// crouches has something to crouch *onto*.
236    ///
237    /// The bounce is deliberately small and the friction large: this is a stone
238    /// floor and a body, not a ball.
239    pub fn set_floor(&mut self, y: Option<f32>) {
240        self.floor = y;
241    }
242
243    /// Where the floor is, if there is one.
244    pub fn floor(&self) -> Option<f32> {
245        self.floor
246    }
247
248    /// Stop anything that has gone through the floor, and take its energy.
249    fn apply_floor(&mut self, out: &mut [Vec3]) {
250        let Some(y) = self.floor else { return };
251        for (g, p) in self.glyphs.iter_mut().zip(out.iter_mut()) {
252            if p.y <= y {
253                continue;
254            }
255            p.y = y;
256            g.spring.y.position = y;
257            // Most of the downward speed is absorbed by the ground and a little
258            // is given back. Sideways speed is scrubbed off by friction, which
259            // is what makes debris settle rather than skate.
260            if g.spring.y.velocity > 0.0 {
261                g.spring.y.velocity *= -0.18;
262            }
263            g.spring.x.velocity *= 0.72;
264            g.spring.z.velocity *= 0.72;
265            g.thermal_vel.y = g.thermal_vel.y.min(0.0);
266        }
267    }
268
269    /// Apply damage to cohesion (reduce it by amount).
270    pub fn damage_cohesion(&mut self, amount: f32) {
271        self.cohesion = (self.cohesion - amount).max(0.0);
272        if self.cohesion == 0.0 && self.dissolution.is_none() {
273            self.begin_dissolution();
274        }
275    }
276
277    /// Restore cohesion (healing effect).
278    pub fn restore_cohesion(&mut self, amount: f32) {
279        self.cohesion = (self.cohesion + amount).min(1.0);
280    }
281
282    /// Stop dissolving and let the springs take hold again.
283    ///
284    /// Dissolution is a one-way branch in `tick`: once it starts, glyphs coast
285    /// on their burst velocities and the springs are never consulted again.
286    /// That is right for a death, and wrong for everything else matter can do
287    /// — scattering and re-forming into a different shape, a teleport, a
288    /// transformation. Ending it puts the glyphs back under the springs from
289    /// wherever they happen to have drifted to, so they fly home rather than
290    /// snapping.
291    pub fn end_dissolution(&mut self, cohesion: f32) {
292        self.dissolution = None;
293        self.burst_velocities.clear();
294        self.cohesion = cohesion.clamp(0.0, 1.0);
295        // The burst left the springs with no velocity of their own, because
296        // dissolution moves positions directly. Give them a nudge outward so
297        // the return reads as matter being pulled back rather than teleporting.
298        for g in &mut self.glyphs {
299            g.thermal_vel = Vec3::ZERO;
300        }
301    }
302
303    /// Whether the matter is currently coming apart.
304    pub fn dissolving(&self) -> bool {
305        self.dissolution.is_some()
306    }
307
308    /// Apply thermal energy to all glyphs (makes them jitter).
309    pub fn heat_all(&mut self, temperature: f32, time: f32) {
310        for (i, g) in self.glyphs.iter_mut().enumerate() {
311            g.heat(temperature, time + i as f32 * 0.37);
312        }
313    }
314
315    /// Set how hard the springs pull, on top of what cohesion asks for.
316    ///
317    /// `1.0, 1.0` is the plain cohesion curve, which is underdamped on purpose:
318    /// a body that has just been hit should wobble. Raising both makes matter
319    /// arrive faster and stop when it gets there, which is what a title card or
320    /// any deliberate transformation wants.
321    pub fn set_bind(&mut self, stiffness_scale: f32, damping_scale: f32) {
322        for g in &mut self.glyphs {
323            g.bind_stiffness = stiffness_scale.max(0.0);
324            g.bind_damping = damping_scale.max(0.0);
325        }
326    }
327
328    /// Give each glyph its own spring, by slot.
329    ///
330    /// This is what makes a material a material. Cohesion says how strongly the
331    /// *entity* holds together; these multipliers say how strongly each piece
332    /// of matter in it resists being moved, and they are the difference between
333    /// a steel blade and a wool cloak hanging off the same shoulders. Steel is
334    /// four hundred thousand times stiffer than flesh in reality; nothing here
335    /// needs that range, but the ordering has to be real or a sword bends like
336    /// an arm.
337    ///
338    /// Damping is the other half and is just as material: steel rings because
339    /// it barely damps, cloth does not because it damps almost completely.
340    ///
341    /// Shorter than the glyph list is fine: the rest keep what they have.
342    pub fn set_bind_each(&mut self, springs: &[(f32, f32)]) {
343        for (g, (k, c)) in self.glyphs.iter_mut().zip(springs.iter()) {
344            g.bind_stiffness = k.max(0.0);
345            g.bind_damping = c.max(0.0);
346        }
347    }
348
349    /// Give each glyph its own inertia, by slot.
350    ///
351    /// Shorter than the glyph list is fine: the rest keep the mass they have.
352    pub fn set_masses(&mut self, masses: &[f32]) {
353        for (g, m) in self.glyphs.iter_mut().zip(masses.iter()) {
354            g.set_mass(*m);
355        }
356    }
357
358    /// Total mass of the matter in this entity.
359    pub fn total_mass(&self) -> f32 {
360        self.glyphs.iter().map(|g| g.mass()).sum()
361    }
362
363    /// Cool all glyphs down.
364    pub fn cool_all(&mut self, rate: f32, dt: f32) {
365        for g in &mut self.glyphs {
366            g.cool(rate, dt);
367        }
368    }
369
370    /// Apply an outward impulse from center (e.g. shockwave impact).
371    pub fn apply_shockwave(&mut self, center: Vec3, strength: f32) {
372        for g in &mut self.glyphs {
373            let dir = (g.position() - center).normalize_or_zero();
374            g.apply_impulse(dir * strength);
375        }
376    }
377
378    /// Apply a directional force to all glyphs.
379    pub fn apply_force(&mut self, force: Vec3) {
380        for g in &mut self.glyphs {
381            g.apply_impulse(force);
382        }
383    }
384
385    /// Update formation targets (e.g. entity moved, or formation changed).
386    pub fn update_targets(&mut self, new_positions: &[Vec3]) {
387        for (g, &pos) in self.glyphs.iter_mut().zip(new_positions.iter()) {
388            g.set_target(pos);
389        }
390    }
391
392    /// Teleport all glyphs instantly to formation positions (no animation).
393    pub fn teleport_all(&mut self, positions: &[Vec3]) {
394        for (g, &pos) in self.glyphs.iter_mut().zip(positions.iter()) {
395            g.teleport(pos);
396        }
397    }
398
399    /// Whether the entity is currently dissolving.
400    pub fn is_dissolving(&self) -> bool { self.dissolution.is_some() }
401
402    /// Whether dissolution is complete (> 2 seconds have elapsed).
403    pub fn is_dissolved(&self) -> bool {
404        self.dissolution.map(|t| t > 2.0).unwrap_or(false)
405    }
406
407    /// Begin the dissolution burst.
408    fn begin_dissolution(&mut self) {
409        self.dissolution = Some(0.0);
410        let center = self.centroid();
411        let mut burst = dissolution_burst(
412            &self.glyphs.iter().map(|g| g.position()).collect::<Vec<_>>(),
413            center,
414        );
415        // The blast delivers momentum, not speed: light matter sprays outward
416        // while the heavy core barely shifts.
417        for (v, g) in burst.iter_mut().zip(self.glyphs.iter()) {
418            *v /= g.mass().max(0.01);
419        }
420        self.burst_velocities = burst;
421    }
422
423    /// Average position of all glyphs.
424    pub fn centroid(&self) -> Vec3 {
425        if self.glyphs.is_empty() { return Vec3::ZERO; }
426        let sum: Vec3 = self.glyphs.iter().map(|g| g.position()).sum();
427        sum / self.glyphs.len() as f32
428    }
429
430    /// Max drift across all glyphs (measures how chaotic the formation is).
431    pub fn max_drift(&self) -> f32 {
432        self.glyphs.iter().map(|g| g.drift).fold(0.0f32, f32::max)
433    }
434
435    /// Average temperature.
436    pub fn avg_temperature(&self) -> f32 {
437        if self.glyphs.is_empty() { return 0.0; }
438        self.glyphs.iter().map(|g| g.temperature).sum::<f32>() / self.glyphs.len() as f32
439    }
440}
441
442// ── Free functions ────────────────────────────────────────────────────────────
443
444/// Convert cohesion [0, 1] to spring stiffness and damping.
445///
446/// At cohesion=0: very loose (stiffness=0.5, damping=0.3)
447/// At cohesion=1: snappy (stiffness=40, damping=8)
448pub fn cohesion_to_spring(cohesion: f32) -> (f32, f32) {
449    let c = cohesion.clamp(0.0, 1.0);
450    let stiffness = 0.5 + c * c * 39.5;  // quadratic for more natural feel
451    let damping   = 0.3 + c * 7.7;
452    (stiffness, damping)
453}
454
455/// Calculate how far a glyph at `actual` should move toward `target`
456/// given cohesion strength [0, 1] and elapsed time dt.
457pub fn cohesion_pull(actual: Vec3, target: Vec3, cohesion: f32, dt: f32) -> Vec3 {
458    let delta = target - actual;
459    let stiffness = cohesion_to_spring(cohesion).0;
460    delta * stiffness * dt
461}
462
463/// Emit a formation dissolution burst.
464/// Returns outward velocity vectors for each glyph.
465pub fn dissolution_burst(positions: &[Vec3], center: Vec3) -> Vec<Vec3> {
466    positions.iter().enumerate().map(|(i, pos)| {
467        let dir = (*pos - center).normalize_or_zero();
468        let speed = 2.0 + rand_f32_seeded(i as u64) * 3.0;
469        // Add upward component for visual interest
470        let up_bias = Vec3::new(0.0, rand_f32_seeded(i as u64 + 1000) * 2.0, 0.0);
471        dir * speed + up_bias
472    }).collect()
473}
474
475/// Evaluate a thermal random direction from a seed.
476fn thermal_direction(seed: f32) -> Vec3 {
477    let h1 = (seed * 127.1 + 311.7) as u64;
478    let h1 = h1.wrapping_mul(0x9e3779b97f4a7c15);
479    let h2 = h1.wrapping_mul(0x6c62272e07bb0142);
480    let h3 = h2.wrapping_mul(0x9e3779b97f4a7c15);
481    let x = (h1 >> 32) as f32 / u32::MAX as f32 * 2.0 - 1.0;
482    let y = (h2 >> 32) as f32 / u32::MAX as f32 * 2.0 - 1.0;
483    let z = (h3 >> 32) as f32 / u32::MAX as f32 * 2.0 - 1.0;
484    Vec3::new(x, y, z).normalize_or_zero()
485}
486
487fn rand_f32_seeded(seed: u64) -> f32 {
488    let x = seed.wrapping_mul(0x9e3779b97f4a7c15).wrapping_add(0x6c62272e07bb0142);
489    (x >> 32) as f32 / u32::MAX as f32
490}
491
492// ── Tests ─────────────────────────────────────────────────────────────────────
493
494#[cfg(test)]
495mod tests {
496    use super::*;
497
498    #[test]
499    fn cohesion_spring_bounds() {
500        let (s0, d0) = cohesion_to_spring(0.0);
501        let (s1, d1) = cohesion_to_spring(1.0);
502        assert!(s1 > s0);
503        assert!(d1 > d0);
504    }
505
506    #[test]
507    fn manager_ticks_without_panic() {
508        let positions = vec![Vec3::ZERO, Vec3::X, Vec3::Y];
509        let mut mgr = CohesionManager::new(&positions, 0.8);
510        let result = mgr.tick(0.016);
511        assert_eq!(result.len(), 3);
512    }
513
514    #[test]
515    fn dissolution_triggers_at_zero_cohesion() {
516        let positions = vec![Vec3::X, Vec3::Y, Vec3::Z];
517        let mut mgr = CohesionManager::new(&positions, 0.1);
518        mgr.damage_cohesion(0.1);
519        assert!(mgr.is_dissolving());
520    }
521
522    #[test]
523    fn shockwave_imparts_velocity() {
524        let positions = vec![Vec3::new(1.0, 0.0, 0.0)];
525        let mut mgr = CohesionManager::new(&positions, 0.9);
526        let before = mgr.glyphs[0].velocity();
527        mgr.apply_shockwave(Vec3::ZERO, 5.0);
528        let after = mgr.glyphs[0].velocity();
529        assert!(after.length() > before.length());
530    }
531
532    #[test]
533    fn cohesion_pull_scales_with_cohesion() {
534        let a = Vec3::ZERO;
535        let b = Vec3::new(1.0, 0.0, 0.0);
536        let low  = cohesion_pull(a, b, 0.1, 0.016).length();
537        let high = cohesion_pull(a, b, 0.9, 0.016).length();
538        assert!(high > low);
539    }
540}