Skip to main content

wowlab_engine_domain/encounter/
state.rs

1use wowlab_types::sim::{EnemyIdx, SimTime, SpatialTransform};
2
3use super::{EnemyActorDefinition, HealthModel};
4
5/// Current movement state.
6#[derive(Clone, Copy, Debug, PartialEq)]
7// #t(rust_non_exhaustive_on_public) exhaustive state is required for deterministic dense projection
8pub enum EnemyMovementState {
9    Stationary,
10    Moving { destination: SpatialTransform },
11}
12
13/// Mutable state for one immutable [`EnemyIdx`].
14#[derive(Clone, Debug, PartialEq)]
15pub struct EnemyState {
16    id: EnemyIdx,
17    health_model: HealthModel,
18    active: bool,
19    alive: bool,
20    current_health: f64,
21    current_transform: SpatialTransform,
22    movement: EnemyMovementState,
23    spawned_at: Option<SimTime>,
24    despawned_at: Option<SimTime>,
25    died_at: Option<SimTime>,
26    activation_generation: u64,
27}
28
29/// One-shot transition returned when damage-driven health first reaches zero.
30#[derive(Clone, Copy, Debug, Eq, PartialEq)]
31pub struct BecameDead {
32    pub enemy: EnemyIdx,
33    pub at: SimTime,
34}
35
36impl EnemyState {
37    #[must_use]
38    pub fn from_definition(definition: &EnemyActorDefinition) -> Self {
39        let active = definition.starts_active();
40        let health_model = definition.health_model();
41
42        Self {
43            id: definition.id(),
44            health_model,
45            active,
46            alive: true,
47            current_health: health_model.max_health(),
48            current_transform: definition.initial_transform(),
49            movement: EnemyMovementState::Stationary,
50            spawned_at: active.then_some(SimTime::ZERO),
51            despawned_at: None,
52            died_at: None,
53            activation_generation: u64::from(active),
54        }
55    }
56
57    #[must_use]
58    pub const fn id(&self) -> EnemyIdx {
59        self.id
60    }
61    #[must_use]
62    pub const fn is_active(&self) -> bool {
63        self.active
64    }
65    #[must_use]
66    pub const fn is_alive(&self) -> bool {
67        self.alive
68    }
69    #[must_use]
70    pub const fn current_health(&self) -> f64 {
71        self.current_health
72    }
73    #[must_use]
74    pub const fn current_transform(&self) -> SpatialTransform {
75        self.current_transform
76    }
77    #[must_use]
78    pub const fn movement(&self) -> EnemyMovementState {
79        self.movement
80    }
81    #[must_use]
82    pub const fn spawned_at(&self) -> Option<SimTime> {
83        self.spawned_at
84    }
85    #[must_use]
86    pub const fn despawned_at(&self) -> Option<SimTime> {
87        self.despawned_at
88    }
89    #[must_use]
90    pub const fn died_at(&self) -> Option<SimTime> {
91        self.died_at
92    }
93    #[must_use]
94    pub const fn activation_generation(&self) -> u64 {
95        self.activation_generation
96    }
97
98    pub fn activate(&mut self, at: SimTime) -> bool {
99        if self.active || !self.alive {
100            return false;
101        }
102
103        self.active = true;
104        self.current_health = self.health_model.max_health();
105        self.spawned_at = Some(at);
106        self.despawned_at = None;
107        self.activation_generation = self.activation_generation.wrapping_add(1);
108
109        true
110    }
111
112    pub fn despawn(&mut self, at: SimTime) -> bool {
113        if !self.active {
114            return false;
115        }
116
117        self.active = false;
118        self.despawned_at = Some(at);
119
120        true
121    }
122
123    pub fn commit_relocation(&mut self, transform: SpatialTransform) {
124        self.current_transform = transform;
125        self.movement = EnemyMovementState::Stationary;
126    }
127
128    pub fn apply_damage(&mut self, amount: f64, at: SimTime) -> Option<BecameDead> {
129        if !matches!(self.health_model, HealthModel::DamageDriven { .. })
130            || !self.alive
131            || amount <= 0.0
132        {
133            return None;
134        }
135
136        self.current_health = (self.current_health - amount).max(0.0);
137
138        if self.current_health > 0.0 {
139            return None;
140        }
141
142        self.alive = false;
143        self.active = false;
144        self.died_at = Some(at);
145
146        Some(BecameDead { enemy: self.id, at })
147    }
148
149    pub fn refresh_scripted_health(&mut self, now: SimTime) {
150        let Some(spawned_at) = self.spawned_at else {
151            return;
152        };
153        let Some(current_health) = self.health_model.scripted_health_at(spawned_at, now) else {
154            return;
155        };
156
157        self.current_health = current_health;
158
159        if self.current_health.abs() <= f64::EPSILON && self.alive {
160            let HealthModel::ScriptedLinear { death_at_s, .. } = self.health_model else {
161                return;
162            };
163
164            self.alive = false;
165            self.died_at = Some(spawned_at.saturating_add(SimTime::from_secs_f64(death_at_s)));
166        }
167    }
168
169    pub fn finalize_scripted_death(&mut self, at: SimTime) -> bool {
170        if !matches!(self.health_model, HealthModel::ScriptedLinear { .. }) || !self.alive {
171            return false;
172        }
173
174        self.current_health = 0.0;
175        self.alive = false;
176        self.active = false;
177        self.died_at = Some(at);
178
179        true
180    }
181
182    #[must_use]
183    pub fn time_to_die(&self, now: SimTime) -> Option<f64> {
184        let HealthModel::ScriptedLinear { death_at_s, .. } = self.health_model else {
185            return None;
186        };
187        let elapsed_s = now.saturating_sub(self.spawned_at?).as_secs_f64();
188
189        Some((death_at_s - elapsed_s).max(0.0))
190    }
191}