subtr_actor/stats/calculators/
wavedash.rs1use super::*;
2
3const WAVEDASH_MAX_DODGE_TO_LANDING_SECONDS: f32 = 0.35;
4const WAVEDASH_MAX_CANDIDATE_SECONDS: f32 = 0.5;
5const WAVEDASH_MIN_DODGE_START_Z: f32 = PLAYER_GROUND_Z_THRESHOLD + 8.0;
6const WAVEDASH_MAX_DODGE_START_Z: f32 = 320.0;
7const WAVEDASH_MIN_LANDING_UPRIGHTNESS: f32 = 0.15;
8const WAVEDASH_MIN_CONFIDENCE: f32 = 0.45;
9const WAVEDASH_HIGH_CONFIDENCE: f32 = 0.75;
10
11#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
12#[ts(export)]
13pub struct WavedashEvent {
14 pub time: f32,
15 pub frame: usize,
16 #[ts(as = "crate::ts_bindings::RemoteIdTs")]
17 pub player: PlayerId,
18 pub is_team_0: bool,
19 pub dodge_time: f32,
20 pub dodge_frame: usize,
21 pub time_since_dodge: f32,
22 pub dodge_position: [f32; 3],
23 pub landing_position: [f32; 3],
24 pub start_speed: f32,
25 pub landing_speed: f32,
26 pub horizontal_speed_gain: f32,
27 pub landing_uprightness: f32,
28 pub confidence: f32,
29}
30
31#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ts_rs::TS)]
32#[ts(export)]
33pub struct WavedashStats {
34 pub count: u32,
35 pub high_confidence_count: u32,
36 pub is_last_wavedash: bool,
37 pub last_wavedash_time: Option<f32>,
38 pub last_wavedash_frame: Option<usize>,
39 pub time_since_last_wavedash: Option<f32>,
40 pub frames_since_last_wavedash: Option<usize>,
41 pub last_quality: Option<f32>,
42 pub best_quality: f32,
43 pub cumulative_quality: f32,
44 #[serde(default, skip_serializing_if = "LabeledCounts::is_empty")]
45 pub labeled_event_counts: LabeledCounts,
46}
47
48impl WavedashStats {
49 pub fn average_quality(&self) -> f32 {
50 if self.count == 0 {
51 0.0
52 } else {
53 self.cumulative_quality / self.count as f32
54 }
55 }
56
57 fn record_event(&mut self, event: &WavedashEvent) {
58 self.labeled_event_counts.increment([confidence_band_label(
59 event.confidence >= WAVEDASH_HIGH_CONFIDENCE,
60 )]);
61 self.sync_legacy_counts();
62 self.last_wavedash_time = Some(event.time);
63 self.last_wavedash_frame = Some(event.frame);
64 self.last_quality = Some(event.confidence);
65 self.best_quality = self.best_quality.max(event.confidence);
66 self.cumulative_quality += event.confidence;
67 }
68
69 pub fn event_count_with_labels(&self, labels: &[StatLabel]) -> u32 {
70 self.labeled_event_counts.count_matching(labels)
71 }
72
73 pub fn complete_labeled_event_counts(&self) -> LabeledCounts {
74 LabeledCounts::complete_from_label_sets(
75 &[&CONFIDENCE_BAND_LABELS],
76 &self.labeled_event_counts,
77 )
78 }
79
80 fn sync_legacy_counts(&mut self) {
81 self.count = self.labeled_event_counts.total();
82 self.high_confidence_count = self.event_count_with_labels(&[confidence_band_label(true)]);
83 }
84}
85
86#[derive(Debug, Clone, PartialEq)]
87struct ActiveWavedashCandidate {
88 is_team_0: bool,
89 dodge_time: f32,
90 dodge_frame: usize,
91 dodge_position: [f32; 3],
92 start_horizontal_speed: f32,
93 start_height: f32,
94}
95
96#[derive(Debug, Clone, Default, PartialEq)]
97pub struct WavedashCalculator {
98 player_stats: HashMap<PlayerId, WavedashStats>,
99 events: Vec<WavedashEvent>,
100 active_candidates: HashMap<PlayerId, ActiveWavedashCandidate>,
101 previous_dodge_active: HashMap<PlayerId, bool>,
102 current_last_wavedash_player: Option<PlayerId>,
103}
104
105impl WavedashCalculator {
106 pub fn new() -> Self {
107 Self::default()
108 }
109
110 pub fn player_stats(&self) -> &HashMap<PlayerId, WavedashStats> {
111 &self.player_stats
112 }
113
114 pub fn events(&self) -> &[WavedashEvent] {
115 &self.events
116 }
117
118 fn horizontal_speed(player: &PlayerSample) -> f32 {
119 player
120 .velocity()
121 .map(|velocity| velocity.truncate().length())
122 .unwrap_or(0.0)
123 }
124
125 fn normalize_score(value: f32, min_value: f32, max_value: f32) -> f32 {
126 if max_value <= min_value {
127 return 0.0;
128 }
129
130 ((value - min_value) / (max_value - min_value)).clamp(0.0, 1.0)
131 }
132
133 fn landing_uprightness(player: &PlayerSample) -> Option<f32> {
134 let rigid_body = player.rigid_body.as_ref()?;
135 Some((quat_to_glam(&rigid_body.rotation) * glam::Vec3::Z).dot(glam::Vec3::Z))
136 }
137
138 fn maybe_start_candidate(&mut self, frame: &FrameInfo, player: &PlayerSample) {
139 let was_dodge_active = self
140 .previous_dodge_active
141 .insert(player.player_id.clone(), player.dodge_active)
142 .unwrap_or(false);
143 if !player.dodge_active || was_dodge_active {
144 return;
145 }
146
147 let Some(position) = player.position() else {
148 return;
149 };
150 if !(WAVEDASH_MIN_DODGE_START_Z..=WAVEDASH_MAX_DODGE_START_Z).contains(&position.z) {
151 return;
152 }
153
154 self.active_candidates.insert(
155 player.player_id.clone(),
156 ActiveWavedashCandidate {
157 is_team_0: player.is_team_0,
158 dodge_time: frame.time,
159 dodge_frame: frame.frame_number,
160 dodge_position: position.to_array(),
161 start_horizontal_speed: Self::horizontal_speed(player),
162 start_height: position.z,
163 },
164 );
165 }
166
167 fn candidate_event(
168 player_id: &PlayerId,
169 candidate: ActiveWavedashCandidate,
170 frame: &FrameInfo,
171 player: &PlayerSample,
172 ) -> Option<WavedashEvent> {
173 let landing_position = player.position()?;
174 if landing_position.z > PLAYER_GROUND_Z_THRESHOLD {
175 return None;
176 }
177
178 let time_since_dodge = frame.time - candidate.dodge_time;
179 if !(0.0..=WAVEDASH_MAX_DODGE_TO_LANDING_SECONDS).contains(&time_since_dodge) {
180 return None;
181 }
182
183 let landing_uprightness = Self::landing_uprightness(player)?;
184 if landing_uprightness < WAVEDASH_MIN_LANDING_UPRIGHTNESS {
185 return None;
186 }
187
188 let landing_speed = Self::horizontal_speed(player);
189 let horizontal_speed_gain = landing_speed - candidate.start_horizontal_speed;
190 let timing_score = 1.0
191 - Self::normalize_score(
192 time_since_dodge,
193 0.08,
194 WAVEDASH_MAX_DODGE_TO_LANDING_SECONDS,
195 );
196 let height_score =
197 1.0 - Self::normalize_score(candidate.start_height, WAVEDASH_MIN_DODGE_START_Z, 220.0);
198 let speed_score = Self::normalize_score(horizontal_speed_gain, 80.0, 550.0)
199 .max(Self::normalize_score(landing_speed, 900.0, 1800.0) * 0.8);
200 let upright_score = Self::normalize_score(landing_uprightness, 0.3, 0.95);
201 let confidence =
202 0.35 * timing_score + 0.25 * height_score + 0.25 * speed_score + 0.15 * upright_score;
203
204 if confidence < WAVEDASH_MIN_CONFIDENCE {
205 return None;
206 }
207
208 Some(WavedashEvent {
209 time: frame.time,
210 frame: frame.frame_number,
211 player: player_id.clone(),
212 is_team_0: candidate.is_team_0,
213 dodge_time: candidate.dodge_time,
214 dodge_frame: candidate.dodge_frame,
215 time_since_dodge,
216 dodge_position: candidate.dodge_position,
217 landing_position: landing_position.to_array(),
218 start_speed: candidate.start_horizontal_speed,
219 landing_speed,
220 horizontal_speed_gain,
221 landing_uprightness,
222 confidence,
223 })
224 }
225
226 fn apply_event(&mut self, event: WavedashEvent) {
227 for stats in self.player_stats.values_mut() {
228 stats.is_last_wavedash = false;
229 }
230
231 let stats = self.player_stats.entry(event.player.clone()).or_default();
232 stats.record_event(&event);
233 stats.is_last_wavedash = true;
234 stats.time_since_last_wavedash = Some(0.0);
235 stats.frames_since_last_wavedash = Some(0);
236
237 self.current_last_wavedash_player = Some(event.player.clone());
238 self.events.push(event);
239 }
240
241 fn begin_sample(&mut self, frame: &FrameInfo) {
242 for stats in self.player_stats.values_mut() {
243 stats.is_last_wavedash = false;
244 stats.time_since_last_wavedash = stats
245 .last_wavedash_time
246 .map(|time| (frame.time - time).max(0.0));
247 stats.frames_since_last_wavedash = stats
248 .last_wavedash_frame
249 .map(|last_frame| frame.frame_number.saturating_sub(last_frame));
250 }
251
252 if let Some(player_id) = self.current_last_wavedash_player.as_ref() {
253 if let Some(stats) = self.player_stats.get_mut(player_id) {
254 stats.is_last_wavedash = true;
255 }
256 }
257 }
258
259 fn update_active_candidates(&mut self, frame: &FrameInfo, players: &PlayerFrameState) {
260 let mut finished = Vec::new();
261 let mut visible_players = HashSet::new();
262
263 for player in &players.players {
264 visible_players.insert(player.player_id.clone());
265 self.maybe_start_candidate(frame, player);
266
267 let Some(candidate) = self.active_candidates.get(&player.player_id).cloned() else {
268 continue;
269 };
270 if frame.time - candidate.dodge_time > WAVEDASH_MAX_CANDIDATE_SECONDS {
271 finished.push((player.player_id.clone(), None));
272 continue;
273 }
274 if let Some(event) = Self::candidate_event(&player.player_id, candidate, frame, player)
275 {
276 finished.push((player.player_id.clone(), Some(event)));
277 }
278 }
279
280 for (player_id, event) in finished {
281 self.active_candidates.remove(&player_id);
282 if let Some(event) = event {
283 self.apply_event(event);
284 }
285 }
286
287 self.active_candidates
288 .retain(|player_id, _| visible_players.contains(player_id));
289 }
290
291 pub fn update(
292 &mut self,
293 frame: &FrameInfo,
294 players: &PlayerFrameState,
295 live_play: bool,
296 ) -> SubtrActorResult<()> {
297 if !live_play {
298 self.active_candidates.clear();
299 self.current_last_wavedash_player = None;
300 return Ok(());
301 }
302
303 self.begin_sample(frame);
304 self.update_active_candidates(frame, players);
305
306 Ok(())
307 }
308}
309
310#[cfg(test)]
311#[path = "wavedash_tests.rs"]
312mod tests;