wowlab_engine_domain/encounter/
state.rs1use wowlab_types::sim::{EnemyIdx, SimTime, SpatialTransform};
2
3use super::{EnemyActorDefinition, HealthModel};
4
5#[derive(Clone, Copy, Debug, PartialEq)]
7pub enum EnemyMovementState {
9 Stationary,
10 Moving { destination: SpatialTransform },
11}
12
13#[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#[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}