Skip to main content

proof_engine/metaball/
damage.rs

1//! Damage response through field manipulation — hits deform the body, crits punch holes,
2//! low HP makes the entity skeletal, death dissolves sources.
3
4use glam::Vec3;
5use super::entity_field::MetaballEntity;
6
7/// A damage event applied to the entity.
8#[derive(Debug, Clone)]
9pub struct DamageEvent {
10    /// World-space impact point.
11    pub impact_point: Vec3,
12    /// Damage amount (raw, before mitigation).
13    pub damage: f32,
14    /// Maximum HP of the entity.
15    pub max_hp: f32,
16    /// Whether this is a critical hit.
17    pub is_crit: bool,
18    /// Knockback direction (if any).
19    pub knockback_dir: Option<Vec3>,
20    /// Time of the hit.
21    pub time: f32,
22}
23
24/// Result of processing a damage event.
25#[derive(Debug, Clone)]
26pub struct DamageResponse {
27    /// Index of the source that was hit.
28    pub hit_source: Option<usize>,
29    /// Whether a source was destroyed (crit).
30    pub source_destroyed: bool,
31    /// The deformation magnitude applied.
32    pub deformation: f32,
33    /// Whether the entity died from this hit.
34    pub entity_died: bool,
35    /// Particle burst position and count (for VFX).
36    pub particle_burst: Option<(Vec3, u32)>,
37}
38
39/// System that processes damage events on metaball entities.
40pub struct DamageSystem;
41
42impl DamageSystem {
43    /// Process a damage event on a metaball entity.
44    pub fn apply_damage(entity: &mut MetaballEntity, event: &DamageEvent) -> DamageResponse {
45        let damage_ratio = event.damage / event.max_hp.max(1.0);
46
47        // Find the nearest active source to the impact point
48        let hit_source = entity.nearest_source(event.impact_point);
49
50        let mut source_destroyed = false;
51        let mut deformation = 0.0;
52
53        if let Some(idx) = hit_source {
54            let source = &mut entity.sources[idx];
55
56            if event.is_crit {
57                // ── Crit: destroy the source entirely ───────────────────────
58                source.destroyed = true;
59                source.strength = 0.0;
60                source_destroyed = true;
61                deformation = source.base_strength;
62            } else {
63                // ── Normal hit: reduce source strength ──────────────────────
64                // Immediate reduction proportional to damage
65                let reduction = damage_ratio * source.base_strength;
66                source.damage_reduction += reduction;
67
68                // Cap reduction so source doesn't go permanently below 10% base
69                let max_reduction = source.base_strength * 0.9;
70                source.damage_reduction = source.damage_reduction.min(max_reduction);
71
72                source.last_hit_time = event.time;
73                deformation = reduction;
74            }
75
76            // Apply knockback to source position
77            if let Some(kb_dir) = event.knockback_dir {
78                let kb_strength = damage_ratio * 0.3;
79                source.position += kb_dir * kb_strength;
80            }
81        }
82
83        // Update entity HP ratio
84        entity.set_hp(entity.hp_ratio - damage_ratio);
85
86        // Check for death
87        let entity_died = entity.is_dead();
88
89        // Determine particle burst
90        let particle_burst = if source_destroyed {
91            Some((event.impact_point, 50))
92        } else if deformation > 0.1 {
93            Some((event.impact_point, (deformation * 20.0) as u32))
94        } else {
95            None
96        };
97
98        entity.dirty = true;
99
100        DamageResponse {
101            hit_source,
102            source_destroyed,
103            deformation,
104            entity_died,
105            particle_burst,
106        }
107    }
108
109    /// Process the death sequence: all sources decay to zero over `duration` seconds.
110    ///
111    /// Call each frame during the death animation. `progress` is 0.0 (just died) to 1.0 (fully gone).
112    pub fn death_sequence(entity: &mut MetaballEntity, progress: f32) {
113        let p = progress.clamp(0.0, 1.0);
114
115        for source in &mut entity.sources {
116            if source.destroyed { continue; }
117
118            // Sources contract toward their center
119            let contract_factor = 1.0 - p;
120            source.position = entity.center + source.rest_offset * contract_factor * entity.scale;
121
122            // Strength decays non-linearly (fast at first, then slow)
123            let decay = (1.0 - p).powi(2);
124            source.strength = source.base_strength * decay;
125
126            // Radius shrinks
127            let original_radius = source.radius;
128            source.radius = original_radius * (1.0 - p * 0.8); // shrink to 20% radius
129
130            // At the very end, destroy remaining sources
131            if p > 0.95 {
132                source.destroyed = true;
133                source.strength = 0.0;
134            }
135        }
136
137        entity.hp_ratio = 0.0;
138        entity.dirty = true;
139    }
140
141    /// Low HP visual update: make the entity look skeletal and fragile.
142    ///
143    /// Called during normal update when HP is below 30%.
144    pub fn low_hp_effects(entity: &mut MetaballEntity) {
145        if entity.hp_ratio > 0.3 { return; }
146
147        let fragility = 1.0 - (entity.hp_ratio / 0.3);
148
149        for source in &mut entity.sources {
150            if source.destroyed { continue; }
151
152            // Increase emission (internal glow visible through thin surface)
153            source.emission = source.emission.max(fragility * 0.8);
154
155            // Increase breathing amplitude (labored breathing)
156            source.breath_amplitude = 0.02 + fragility * 0.05;
157        }
158    }
159
160    /// Apply area-of-effect damage to all sources within radius.
161    pub fn apply_aoe_damage(
162        entity: &mut MetaballEntity,
163        center: Vec3,
164        radius: f32,
165        damage: f32,
166        max_hp: f32,
167        time: f32,
168    ) -> Vec<DamageResponse> {
169        let mut responses = Vec::new();
170        let damage_ratio = damage / max_hp.max(1.0);
171
172        for (idx, source) in entity.sources.iter_mut().enumerate() {
173            if source.destroyed { continue; }
174            let dist = (source.position - center).length();
175            if dist > radius { continue; }
176
177            let falloff = 1.0 - (dist / radius);
178            let local_damage = damage_ratio * falloff;
179            let reduction = local_damage * source.base_strength;
180            source.damage_reduction += reduction;
181            source.last_hit_time = time;
182
183            responses.push(DamageResponse {
184                hit_source: Some(idx),
185                source_destroyed: false,
186                deformation: reduction,
187                entity_died: false,
188                particle_burst: if reduction > 0.05 {
189                    Some((source.position, (reduction * 10.0) as u32))
190                } else { None },
191            });
192        }
193
194        entity.set_hp(entity.hp_ratio - damage_ratio);
195        entity.dirty = true;
196        responses
197    }
198}
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203    use super::super::entity_field::FieldSource;
204
205    fn test_entity() -> MetaballEntity {
206        let mut e = MetaballEntity::new(0.5, 16);
207        e.add_source(FieldSource::new(Vec3::ZERO, 1.0, 1.0).with_tag("center"));
208        e.add_source(FieldSource::new(Vec3::X, 0.8, 0.8).with_tag("right"));
209        e.add_source(FieldSource::new(Vec3::NEG_X, 0.8, 0.8).with_tag("left"));
210        e
211    }
212
213    #[test]
214    fn normal_hit_reduces_strength() {
215        let mut e = test_entity();
216        let before = e.sources[0].base_strength;
217        let event = DamageEvent {
218            impact_point: Vec3::ZERO, damage: 10.0, max_hp: 100.0,
219            is_crit: false, knockback_dir: None, time: 1.0,
220        };
221        let resp = DamageSystem::apply_damage(&mut e, &event);
222        assert!(resp.hit_source == Some(0));
223        assert!(!resp.source_destroyed);
224        assert!(e.sources[0].damage_reduction > 0.0);
225    }
226
227    #[test]
228    fn crit_destroys_source() {
229        let mut e = test_entity();
230        let event = DamageEvent {
231            impact_point: Vec3::X * 0.9, damage: 20.0, max_hp: 100.0,
232            is_crit: true, knockback_dir: None, time: 1.0,
233        };
234        let resp = DamageSystem::apply_damage(&mut e, &event);
235        assert!(resp.source_destroyed);
236        // The nearest source to X * 0.9 should be "right" (index 1)
237        assert_eq!(resp.hit_source, Some(1));
238        assert!(e.sources[1].destroyed);
239    }
240
241    #[test]
242    fn death_sequence_removes_all() {
243        let mut e = test_entity();
244        e.set_hp(0.0);
245        DamageSystem::death_sequence(&mut e, 1.0);
246        assert!(e.active_source_count() == 0);
247    }
248
249    #[test]
250    fn low_hp_increases_emission() {
251        let mut e = test_entity();
252        e.set_hp(0.1);
253        let emission_before = e.sources[0].emission;
254        DamageSystem::low_hp_effects(&mut e);
255        assert!(e.sources[0].emission > emission_before);
256    }
257
258    #[test]
259    fn aoe_damage_affects_multiple() {
260        let mut e = test_entity();
261        let responses = DamageSystem::apply_aoe_damage(
262            &mut e, Vec3::ZERO, 2.0, 10.0, 100.0, 1.0,
263        );
264        assert!(responses.len() >= 2, "AoE should hit multiple sources");
265    }
266
267    #[test]
268    fn lethal_damage_kills() {
269        let mut e = test_entity();
270        let event = DamageEvent {
271            impact_point: Vec3::ZERO, damage: 100.0, max_hp: 100.0,
272            is_crit: false, knockback_dir: None, time: 1.0,
273        };
274        let resp = DamageSystem::apply_damage(&mut e, &event);
275        assert!(resp.entity_died);
276    }
277
278    #[test]
279    fn death_sequence_progressive() {
280        let mut e = test_entity();
281        e.set_hp(0.0);
282        DamageSystem::death_sequence(&mut e, 0.0);
283        assert!(e.active_source_count() > 0, "At progress=0, sources should still exist");
284        DamageSystem::death_sequence(&mut e, 0.5);
285        let mid_strength: f32 = e.sources.iter().map(|s| s.strength).sum();
286        assert!(mid_strength > 0.0 && mid_strength < 3.0);
287    }
288}