1use glam::{Vec3, Vec4};
8use crate::math::AttractorType;
9
10#[derive(Debug, Clone)]
14pub enum FalloffType {
15 InverseSquare,
17 Gaussian,
19 Wyvill,
21 Attractor(AttractorType),
23 Linear,
25 SmoothPoly,
27}
28
29impl FalloffType {
30 pub fn evaluate(&self, r: f32, radius: f32) -> f32 {
32 match self {
33 Self::InverseSquare => {
34 1.0 / (1.0 + (r * r) / (radius * radius))
35 }
36 Self::Gaussian => {
37 let sigma = radius * 0.4;
38 (-r * r / (2.0 * sigma * sigma)).exp()
39 }
40 Self::Wyvill => {
41 if r >= radius { return 0.0; }
42 let t = r * r / (radius * radius);
43 let v = 1.0 - t;
44 v * v * v
45 }
46 Self::Linear => {
47 if r >= radius { 0.0 } else { 1.0 - r / radius }
48 }
49 Self::SmoothPoly => {
50 if r >= radius { return 0.0; }
51 let t = r / radius;
52 1.0 - 3.0 * t * t + 2.0 * t * t * t
53 }
54 Self::Attractor(_) => {
55 if r >= radius { return 0.0; }
57 let t = r * r / (radius * radius);
58 let v = 1.0 - t;
59 v * v * v
60 }
61 }
62 }
63}
64
65impl Default for FalloffType {
66 fn default() -> Self { Self::Wyvill }
67}
68
69#[derive(Debug, Clone)]
73pub struct FieldSource {
74 pub position: Vec3,
76 pub rest_offset: Vec3,
78 pub strength: f32,
80 pub radius: f32,
82 pub falloff: FalloffType,
84 pub color: Vec4,
86 pub emission: f32,
88 pub base_strength: f32,
90 pub breath_amplitude: f32,
92 pub breath_phase_offset: f32,
94 pub destroyed: bool,
96 pub damage_reduction: f32,
98 pub last_hit_time: f32,
100 pub tag: String,
102}
103
104impl FieldSource {
105 pub fn new(offset: Vec3, strength: f32, radius: f32) -> Self {
106 Self {
107 position: offset,
108 rest_offset: offset,
109 strength,
110 radius,
111 falloff: FalloffType::Wyvill,
112 color: Vec4::ONE,
113 emission: 0.0,
114 base_strength: strength,
115 breath_amplitude: 0.02,
116 breath_phase_offset: 0.0,
117 destroyed: false,
118 damage_reduction: 0.0,
119 last_hit_time: -10.0,
120 tag: String::new(),
121 }
122 }
123
124 pub fn with_color(mut self, color: Vec4) -> Self { self.color = color; self }
125 pub fn with_emission(mut self, e: f32) -> Self { self.emission = e; self }
126 pub fn with_falloff(mut self, f: FalloffType) -> Self { self.falloff = f; self }
127 pub fn with_tag(mut self, tag: impl Into<String>) -> Self { self.tag = tag.into(); self }
128 pub fn with_breath(mut self, amplitude: f32, phase: f32) -> Self {
129 self.breath_amplitude = amplitude;
130 self.breath_phase_offset = phase;
131 self
132 }
133
134 pub fn effective_strength(&self, hp_ratio: f32) -> f32 {
136 if self.destroyed { return 0.0; }
137 (self.base_strength * hp_ratio - self.damage_reduction).max(0.0)
138 }
139
140 pub fn evaluate(&self, point: Vec3, hp_ratio: f32) -> f32 {
142 let strength = self.effective_strength(hp_ratio);
143 if strength <= 0.0 { return 0.0; }
144 let dist = (point - self.position).length();
145 strength * self.falloff.evaluate(dist, self.radius)
146 }
147
148 pub fn is_active(&self) -> bool {
150 !self.destroyed && self.base_strength > 0.0
151 }
152}
153
154impl Default for FieldSource {
155 fn default() -> Self { Self::new(Vec3::ZERO, 1.0, 1.0) }
156}
157
158#[derive(Debug, Clone)]
166pub struct MetaballEntity {
167 pub sources: Vec<FieldSource>,
169 pub threshold: f32,
171 pub hp_ratio: f32,
173 pub grid_resolution: u32,
176 pub breath_phase: f32,
178 pub breath_frequency: f32,
180 pub center: Vec3,
182 pub rotation: Vec3,
184 pub scale: f32,
186 pub bounds_half: Vec3,
188 pub dirty: bool,
190 pub name: String,
192}
193
194impl MetaballEntity {
195 pub fn new(threshold: f32, resolution: u32) -> Self {
196 Self {
197 sources: Vec::new(),
198 threshold,
199 hp_ratio: 1.0,
200 grid_resolution: resolution,
201 breath_phase: 0.0,
202 breath_frequency: 0.3,
203 center: Vec3::ZERO,
204 rotation: Vec3::ZERO,
205 scale: 1.0,
206 bounds_half: Vec3::splat(2.0),
207 dirty: true,
208 name: String::new(),
209 }
210 }
211
212 pub fn with_name(mut self, name: impl Into<String>) -> Self {
213 self.name = name.into();
214 self
215 }
216
217 pub fn add_source(&mut self, source: FieldSource) -> usize {
219 let idx = self.sources.len();
220 self.sources.push(source);
221 self.recompute_bounds();
222 self.dirty = true;
223 idx
224 }
225
226 pub fn nearest_source(&self, point: Vec3) -> Option<usize> {
228 self.sources.iter().enumerate()
229 .filter(|(_, s)| s.is_active())
230 .min_by(|(_, a), (_, b)| {
231 let da = (a.position - point).length_squared();
232 let db = (b.position - point).length_squared();
233 da.partial_cmp(&db).unwrap()
234 })
235 .map(|(i, _)| i)
236 }
237
238 pub fn find_source(&self, tag: &str) -> Option<usize> {
240 self.sources.iter().position(|s| s.tag == tag)
241 }
242
243 pub fn update(&mut self, dt: f32, time: f32) {
250 self.breath_phase = time * self.breath_frequency * std::f32::consts::TAU;
251
252 let mut any_changed = false;
253 for source in &mut self.sources {
254 if source.destroyed { continue; }
255
256 let breath = (self.breath_phase + source.breath_phase_offset).sin()
258 * source.breath_amplitude;
259 let new_pos = self.center + (source.rest_offset + source.rest_offset.normalize_or_zero() * breath) * self.scale;
260
261 if (new_pos - source.position).length_squared() > 1e-6 {
262 source.position = new_pos;
263 any_changed = true;
264 }
265
266 if source.damage_reduction > 0.001 {
268 let recovery_rate = 0.5_f32.powf(dt / 0.5);
269 let permanent_reduction = source.base_strength * (1.0 - self.hp_ratio);
270 let excess = (source.damage_reduction - permanent_reduction).max(0.0);
271 source.damage_reduction = permanent_reduction + excess * recovery_rate;
272 any_changed = true;
273 }
274
275 source.strength = source.effective_strength(self.hp_ratio);
277 }
278
279 if any_changed { self.dirty = true; }
280 }
281
282 pub fn evaluate(&self, point: Vec3) -> f32 {
284 let mut total = 0.0;
285 for source in &self.sources {
286 total += source.evaluate(point, self.hp_ratio);
287 }
288 total
289 }
290
291 pub fn evaluate_full(&self, point: Vec3) -> (f32, Vec4, f32) {
293 let mut total_strength = 0.0;
294 let mut total_color = Vec4::ZERO;
295 let mut total_emission = 0.0;
296
297 for source in &self.sources {
298 let contrib = source.evaluate(point, self.hp_ratio);
299 if contrib > 0.0 {
300 total_strength += contrib;
301 total_color += source.color * contrib;
302 total_emission += source.emission * contrib;
303 }
304 }
305
306 if total_strength > 0.0 {
307 total_color /= total_strength;
308 total_emission /= total_strength;
309 }
310
311 (total_strength, total_color, total_emission)
312 }
313
314 pub fn gradient(&self, point: Vec3) -> Vec3 {
316 let eps = 0.01 * self.scale;
317 Vec3::new(
318 self.evaluate(point + Vec3::X * eps) - self.evaluate(point - Vec3::X * eps),
319 self.evaluate(point + Vec3::Y * eps) - self.evaluate(point - Vec3::Y * eps),
320 self.evaluate(point + Vec3::Z * eps) - self.evaluate(point - Vec3::Z * eps),
321 ) / (2.0 * eps)
322 }
323
324 pub fn normal_at(&self, point: Vec3) -> Vec3 {
326 self.gradient(point).normalize_or_zero()
327 }
328
329 pub fn set_hp(&mut self, ratio: f32) {
331 let new_ratio = ratio.clamp(0.0, 1.0);
332 if (self.hp_ratio - new_ratio).abs() > 1e-6 {
333 self.hp_ratio = new_ratio;
334 self.dirty = true;
335 }
336 }
337
338 pub fn set_center(&mut self, center: Vec3) {
340 if (self.center - center).length_squared() > 1e-6 {
341 self.center = center;
342 for source in &mut self.sources {
343 source.position = center + source.rest_offset * self.scale;
344 }
345 self.dirty = true;
346 }
347 }
348
349 pub fn recompute_bounds(&mut self) {
351 let mut max_extent = Vec3::ZERO;
352 for source in &self.sources {
353 let extent = source.rest_offset.abs() + Vec3::splat(source.radius);
354 max_extent = max_extent.max(extent);
355 }
356 self.bounds_half = max_extent * self.scale * 1.1; }
358
359 pub fn bounds(&self) -> (Vec3, Vec3) {
361 (self.center - self.bounds_half, self.center + self.bounds_half)
362 }
363
364 pub fn active_source_count(&self) -> usize {
366 self.sources.iter().filter(|s| s.is_active()).count()
367 }
368
369 pub fn source_count(&self) -> usize { self.sources.len() }
371
372 pub fn is_dead(&self) -> bool {
374 self.hp_ratio <= 0.0 || self.active_source_count() == 0
375 }
376
377 pub fn take_dirty(&mut self) -> bool {
379 let d = self.dirty;
380 self.dirty = false;
381 d
382 }
383}
384
385impl Default for MetaballEntity {
386 fn default() -> Self { Self::new(0.5, 32) }
387}
388
389#[cfg(test)]
392mod tests {
393 use super::*;
394
395 fn basic_entity() -> MetaballEntity {
396 let mut e = MetaballEntity::new(0.5, 16);
397 e.add_source(FieldSource::new(Vec3::ZERO, 1.0, 1.0).with_tag("center"));
398 e.add_source(FieldSource::new(Vec3::new(0.8, 0.0, 0.0), 0.7, 0.8).with_tag("right"));
399 e.add_source(FieldSource::new(Vec3::new(-0.8, 0.0, 0.0), 0.7, 0.8).with_tag("left"));
400 e
401 }
402
403 #[test]
404 fn field_at_source_center_is_strong() {
405 let e = basic_entity();
406 let val = e.evaluate(Vec3::ZERO);
407 assert!(val > e.threshold, "Field at center should exceed threshold: {val}");
408 }
409
410 #[test]
411 fn field_far_away_is_zero() {
412 let e = basic_entity();
413 let val = e.evaluate(Vec3::new(100.0, 0.0, 0.0));
414 assert!(val < 0.01, "Field far away should be ~0: {val}");
415 }
416
417 #[test]
418 fn hp_modulates_field() {
419 let mut e = basic_entity();
420 let full_hp = e.evaluate(Vec3::ZERO);
421 e.set_hp(0.5);
422 let half_hp = e.evaluate(Vec3::ZERO);
423 assert!(half_hp < full_hp, "Half HP should weaken field: full={full_hp}, half={half_hp}");
424 }
425
426 #[test]
427 fn destroyed_source_contributes_nothing() {
428 let mut e = basic_entity();
429 e.sources[0].destroyed = true;
430 let val = e.sources[0].evaluate(Vec3::ZERO, 1.0);
431 assert_eq!(val, 0.0);
432 }
433
434 #[test]
435 fn find_source_by_tag() {
436 let e = basic_entity();
437 assert_eq!(e.find_source("right"), Some(1));
438 assert_eq!(e.find_source("nonexistent"), None);
439 }
440
441 #[test]
442 fn nearest_source_correct() {
443 let e = basic_entity();
444 let idx = e.nearest_source(Vec3::new(0.7, 0.0, 0.0)).unwrap();
445 assert_eq!(idx, 1); }
447
448 #[test]
449 fn gradient_points_away_from_sources() {
450 let e = basic_entity();
451 let grad = e.gradient(Vec3::new(1.5, 0.0, 0.0));
452 assert!(grad.x > 0.0 || grad.length() < 0.01, "grad.x={}", grad.x);
454 }
455
456 #[test]
457 fn evaluate_full_returns_weighted_color() {
458 let mut e = MetaballEntity::new(0.5, 16);
459 e.add_source(FieldSource::new(Vec3::ZERO, 1.0, 1.0).with_color(Vec4::new(1.0, 0.0, 0.0, 1.0)));
460 let (strength, color, _emission) = e.evaluate_full(Vec3::ZERO);
461 assert!(strength > 0.0);
462 assert!(color.x > 0.5); }
464
465 #[test]
466 fn wyvill_falloff_c2_continuous() {
467 let f = FalloffType::Wyvill;
468 let r = 1.0;
469 assert_eq!(f.evaluate(r, r), 0.0);
471 assert_eq!(f.evaluate(0.0, r), 1.0);
473 assert!(f.evaluate(r * 0.99, r) > 0.0);
475 }
476
477 #[test]
478 fn entity_bounds_encompass_sources() {
479 let e = basic_entity();
480 let (min, max) = e.bounds();
481 for source in &e.sources {
482 assert!(source.rest_offset.x >= min.x && source.rest_offset.x <= max.x);
483 }
484 }
485
486 #[test]
487 fn dead_entity_detection() {
488 let mut e = basic_entity();
489 assert!(!e.is_dead());
490 e.set_hp(0.0);
491 assert!(e.is_dead());
492 }
493
494 #[test]
495 fn breathing_changes_positions() {
496 let mut e = basic_entity();
497 let pos_before = e.sources[1].position;
498 e.update(0.016, 1.0);
499 let pos_after = e.sources[1].position;
500 assert!(e.sources[1].breath_amplitude > 0.0);
503 }
504}