Skip to main content

proof_engine/entity/
mod.rs

1//! Amorphous entity rendering.
2//!
3//! Entities are not rigid sprites. They are clusters of Glyphs bound together
4//! by internal force fields. Their visual form is emergent from the binding forces.
5//! As HP decreases, cohesion drops and the entity visibly falls apart.
6
7pub mod formation;
8pub mod cohesion;
9pub mod ai;
10pub mod layered_entity;
11
12use crate::glyph::GlyphId;
13use crate::math::ForceField;
14use glam::{Vec3, Vec4};
15
16/// Opaque handle to an entity in the scene.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
18pub struct EntityId(pub u32);
19
20/// An amorphous visual entity held together by binding forces.
21#[derive(Clone)]
22pub struct AmorphousEntity {
23    // ── Identity ─────────────────────────────────────────────────────────────
24    pub name: String,
25    pub position: Vec3,
26
27    // ── Binding ──────────────────────────────────────────────────────────────
28    /// The core force holding the entity together. Usually Gravity.
29    pub binding_field: ForceField,
30    /// IDs of glyphs that compose this entity.
31    pub glyph_ids: Vec<GlyphId>,
32    /// Target positions (relative to entity center) for each glyph.
33    pub formation: Vec<Vec3>,
34    /// Character assigned to each formation slot.
35    pub formation_chars: Vec<char>,
36    /// Color assigned to each formation slot.
37    pub formation_colors: Vec<Vec4>,
38
39    // ── Stats that drive visuals ──────────────────────────────────────────────
40    pub hp: f32,
41    pub max_hp: f32,
42    pub entity_mass: f32,
43    pub entity_temperature: f32,
44    pub entity_entropy: f32,
45
46    // ── Visual state ─────────────────────────────────────────────────────────
47    /// 0.0 = fully dispersed, 1.0 = tight formation.
48    /// Driven by hp/max_hp: cohesion = (hp / max_hp).sqrt()
49    pub cohesion: f32,
50
51    /// The entity's pulse function (breathing/heartbeat rhythm).
52    pub pulse_rate: f32,   // Hz
53    pub pulse_depth: f32,  // amplitude of pulse oscillation
54
55    // ── Internal animation time ───────────────────────────────────────────────
56    pub age: f32,
57
58    // ── Lifecycle ─────────────────────────────────────────────────────────────
59    /// Arbitrary string tags for filtering/grouping.
60    pub tags: Vec<String>,
61    /// Set to true to remove the entity on the next GC pass.
62    pub despawn_requested: bool,
63
64    /// Per-glyph springs binding the matter to the formation.
65    ///
66    /// Built lazily on the first tick, so an entity constructed field-by-field
67    /// still gets physics without the caller knowing this exists.
68    #[doc(hidden)]
69    pub cohesion_system: Option<cohesion::CohesionManager>,
70}
71
72impl AmorphousEntity {
73    pub fn new(name: impl Into<String>, position: Vec3) -> Self {
74        Self {
75            name: name.into(),
76            position,
77            binding_field: ForceField::Gravity {
78                center: position,
79                strength: 5.0,
80                falloff: crate::math::Falloff::InverseSquare,
81            },
82            glyph_ids: Vec::new(),
83            formation: Vec::new(),
84            formation_chars: Vec::new(),
85            formation_colors: Vec::new(),
86            hp: 100.0,
87            max_hp: 100.0,
88            entity_mass: 10.0,
89            entity_temperature: 0.5,
90            entity_entropy: 0.1,
91            cohesion: 1.0,
92            pulse_rate: 1.0,
93            pulse_depth: 0.05,
94            age: 0.0,
95            tags: Vec::new(),
96            despawn_requested: false,
97            cohesion_system: None,
98        }
99    }
100
101    /// HP fraction [0, 1].
102    pub fn hp_frac(&self) -> f32 {
103        (self.hp / self.max_hp.max(0.001)).clamp(0.0, 1.0)
104    }
105
106    /// Update cohesion based on current HP.
107    pub fn update_cohesion(&mut self) {
108        // Low HP = lower cohesion (entity falls apart)
109        self.cohesion = self.hp_frac().sqrt();
110    }
111
112    /// Advance entity time. Returns true if the entity should be removed (hp <= 0).
113    ///
114    /// This only advances the entity's own state. Moving its glyphs is
115    /// [`crate::scene::SceneGraph::tick`]'s job, because that is where the
116    /// glyph pool lives.
117    pub fn tick(&mut self, dt: f32, _time: f32) -> bool {
118        self.age += dt;
119        self.update_cohesion();
120        if self.cohesion_system.is_none() {
121            self.rebuild_cohesion();
122        }
123        if let Some(cs) = self.cohesion_system.as_mut() {
124            cs.cohesion = self.cohesion;
125        }
126        self.hp <= 0.0
127    }
128
129    /// Build the per-glyph spring system from the current formation.
130    ///
131    /// Called lazily so an entity assembled field-by-field still gets physics.
132    pub fn rebuild_cohesion(&mut self) {
133        let world: Vec<Vec3> = self.formation.iter().map(|o| self.position + *o).collect();
134        self.cohesion_system = Some(cohesion::CohesionManager::new(&world, self.cohesion));
135    }
136
137    /// Step the glyph springs and return where each glyph should now be.
138    ///
139    /// The formation is the target; cohesion decides how hard the springs pull
140    /// toward it. A damaged entity does not play a "hurt" animation — its
141    /// binding weakens and the matter drifts, which is the whole premise of
142    /// rendering things out of particles.
143    pub fn step_glyph_physics(&mut self, dt: f32) -> Vec<Vec3> {
144        if self.cohesion_system.is_none() {
145            self.rebuild_cohesion();
146        }
147        // Breathing: the formation expands and contracts around its centre.
148        let pulse = 1.0 + (self.age * self.pulse_rate * std::f32::consts::TAU).sin()
149            * self.pulse_depth;
150
151        let (position, formation, temperature, entropy, age) = (
152            self.position,
153            &self.formation,
154            self.entity_temperature,
155            self.entity_entropy,
156            self.age,
157        );
158
159        let Some(cs) = self.cohesion_system.as_mut() else {
160            return Vec::new();
161        };
162        cs.cohesion = self.cohesion;
163
164        for (i, slot) in formation.iter().enumerate() {
165            let Some(g) = cs.glyphs.get_mut(i) else { break };
166            // Entropy makes the target itself wander, so a chaotic entity is
167            // never quite the same shape twice.
168            let wander = if entropy > 0.0 {
169                let a = age * 0.7 + i as f32 * 1.618;
170                Vec3::new(a.sin(), (a * 1.3).cos(), (a * 0.6).sin()) * entropy * 0.35
171            } else {
172                Vec3::ZERO
173            };
174            let target = position + *slot * pulse + wander;
175            g.spring.x.target = target.x;
176            g.spring.y.target = target.y;
177            g.spring.z.target = target.z;
178            g.temperature = temperature;
179        }
180        cs.tick(dt)
181    }
182
183    /// Knock the whole formation in a direction — a hit landing.
184    pub fn apply_impulse(&mut self, impulse: Vec3) {
185        if self.cohesion_system.is_none() {
186            self.rebuild_cohesion();
187        }
188        if let Some(cs) = self.cohesion_system.as_mut() {
189            for g in &mut cs.glyphs {
190                g.apply_impulse(impulse);
191            }
192        }
193    }
194
195    /// Cut the binding entirely: the entity comes apart and drifts.
196    pub fn dissolve(&mut self) {
197        if self.cohesion_system.is_none() {
198            self.rebuild_cohesion();
199        }
200        if let Some(cs) = self.cohesion_system.as_mut() {
201            cs.damage_cohesion(1.0);
202        }
203        self.cohesion = 0.0;
204    }
205
206    /// Pull a dissolved entity back together.
207    ///
208    /// The counterpart to [`AmorphousEntity::dissolve`]. Matter that has been
209    /// scattered comes back under the springs and flies to whatever formation
210    /// is currently set, which is what lets an entity be taken apart and
211    /// rebuilt as something else rather than only ever dying.
212    pub fn reform(&mut self, cohesion: f32) {
213        self.cohesion = cohesion.clamp(0.0, 1.0);
214        self.hp = self.max_hp * self.cohesion * self.cohesion;
215        if let Some(cs) = self.cohesion_system.as_mut() {
216            cs.end_dissolution(self.cohesion);
217        }
218    }
219
220    /// Replace the formation, keeping the matter where it currently is.
221    ///
222    /// The springs retarget, so the glyphs travel to the new shape instead of
223    /// appearing in it. Slots are reused cyclically when the new shape has
224    /// fewer of them than the entity has glyphs, so no matter is stranded.
225    pub fn reshape(&mut self, formation: &[Vec3]) {
226        if formation.is_empty() {
227            return;
228        }
229        let n = self.formation.len().max(formation.len());
230        self.formation = (0..n).map(|i| formation[i % formation.len()]).collect();
231    }
232
233    /// Set how hard this entity's springs pull, on top of cohesion.
234    ///
235    /// See [`cohesion::CohesionManager::set_bind`].
236    pub fn set_bind(&mut self, stiffness_scale: f32, damping_scale: f32) {
237        if self.cohesion_system.is_none() {
238            self.rebuild_cohesion();
239        }
240        if let Some(cs) = self.cohesion_system.as_mut() {
241            cs.set_bind(stiffness_scale, damping_scale);
242        }
243    }
244
245    /// Give each particle its own spring, by slot.
246    ///
247    /// See [`cohesion::CohesionManager::set_bind_each`]. This is how a body
248    /// made of several materials behaves like several materials.
249    pub fn set_bind_each(&mut self, springs: &[(f32, f32)]) {
250        if self.cohesion_system.is_none() {
251            self.rebuild_cohesion();
252        }
253        if let Some(cs) = self.cohesion_system.as_mut() {
254            cs.set_bind_each(springs);
255        }
256    }
257
258    /// Put a floor under this entity's matter.
259    ///
260    /// See [`cohesion::CohesionManager::set_floor`]. This is what makes a
261    /// figure stand on the ground rather than hover at whatever height
262    /// something else decided it should be.
263    pub fn set_floor(&mut self, y: Option<f32>) {
264        if self.cohesion_system.is_none() {
265            self.rebuild_cohesion();
266        }
267        if let Some(cs) = self.cohesion_system.as_mut() {
268            cs.set_floor(y);
269        }
270    }
271
272    /// Apply damage to this entity.
273    pub fn take_damage(&mut self, amount: f32) {
274        self.hp = (self.hp - amount).max(0.0);
275    }
276
277    /// Return true if the entity is dead.
278    pub fn is_dead(&self) -> bool { self.hp <= 0.0 }
279}
280
281impl Default for AmorphousEntity {
282    fn default() -> Self { Self::new("", Vec3::ZERO) }
283}