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