Skip to main content

subtr_actor/stats/calculators/
wavedash.rs

1use 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;