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