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}