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}