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;
9
10/// A low airborne dodge that lands quickly and converts the dodge into ground speed.
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::interop::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, PartialEq)]
32struct ActiveWavedashCandidate {
33    is_team_0: bool,
34    dodge_time: f32,
35    dodge_frame: usize,
36    dodge_position: [f32; 3],
37    start_horizontal_speed: f32,
38    start_height: f32,
39}
40
41/// Detects wavedashes from player frame state.
42#[derive(Debug, Clone, Default, PartialEq)]
43pub struct WavedashCalculator {
44    events: EventStream<WavedashEvent>,
45    active_candidates: HashMap<PlayerId, ActiveWavedashCandidate>,
46    previous_dodge_active: HashMap<PlayerId, bool>,
47}
48
49impl WavedashCalculator {
50    pub fn new() -> Self {
51        Self::default()
52    }
53
54    pub fn events(&self) -> &[WavedashEvent] {
55        self.events.all()
56    }
57
58    pub fn new_events(&self) -> &[WavedashEvent] {
59        self.events.new_events()
60    }
61
62    fn horizontal_speed(player: &PlayerSample) -> f32 {
63        player
64            .velocity()
65            .map(|velocity| velocity.truncate().length())
66            .unwrap_or(0.0)
67    }
68
69    fn normalize_score(value: f32, min_value: f32, max_value: f32) -> f32 {
70        if max_value <= min_value {
71            return 0.0;
72        }
73
74        ((value - min_value) / (max_value - min_value)).clamp(0.0, 1.0)
75    }
76
77    fn landing_uprightness(player: &PlayerSample) -> Option<f32> {
78        let rigid_body = player.rigid_body.as_ref()?;
79        Some((quat_to_glam(&rigid_body.rotation) * glam::Vec3::Z).dot(glam::Vec3::Z))
80    }
81
82    fn maybe_start_candidate(&mut self, frame: &FrameInfo, player: &PlayerSample) {
83        let was_dodge_active = self
84            .previous_dodge_active
85            .insert(player.player_id.clone(), player.dodge_active)
86            .unwrap_or(false);
87        if !player.dodge_active || was_dodge_active {
88            return;
89        }
90
91        let Some(position) = player.position() else {
92            return;
93        };
94        if !(WAVEDASH_MIN_DODGE_START_Z..=WAVEDASH_MAX_DODGE_START_Z).contains(&position.z) {
95            return;
96        }
97
98        self.active_candidates.insert(
99            player.player_id.clone(),
100            ActiveWavedashCandidate {
101                is_team_0: player.is_team_0,
102                dodge_time: frame.time,
103                dodge_frame: frame.frame_number,
104                dodge_position: position.to_array(),
105                start_horizontal_speed: Self::horizontal_speed(player),
106                start_height: position.z,
107            },
108        );
109    }
110
111    fn candidate_event(
112        player_id: &PlayerId,
113        candidate: ActiveWavedashCandidate,
114        frame: &FrameInfo,
115        player: &PlayerSample,
116    ) -> Option<WavedashEvent> {
117        let landing_position = player.position()?;
118        if landing_position.z > PLAYER_GROUND_Z_THRESHOLD {
119            return None;
120        }
121
122        let time_since_dodge = frame.time - candidate.dodge_time;
123        if !(0.0..=WAVEDASH_MAX_DODGE_TO_LANDING_SECONDS).contains(&time_since_dodge) {
124            return None;
125        }
126
127        let landing_uprightness = Self::landing_uprightness(player)?;
128        if landing_uprightness < WAVEDASH_MIN_LANDING_UPRIGHTNESS {
129            return None;
130        }
131
132        let landing_speed = Self::horizontal_speed(player);
133        let horizontal_speed_gain = landing_speed - candidate.start_horizontal_speed;
134        let timing_score = 1.0
135            - Self::normalize_score(
136                time_since_dodge,
137                0.08,
138                WAVEDASH_MAX_DODGE_TO_LANDING_SECONDS,
139            );
140        let height_score =
141            1.0 - Self::normalize_score(candidate.start_height, WAVEDASH_MIN_DODGE_START_Z, 220.0);
142        let speed_score = Self::normalize_score(horizontal_speed_gain, 80.0, 550.0)
143            .max(Self::normalize_score(landing_speed, 900.0, 1800.0) * 0.8);
144        let upright_score = Self::normalize_score(landing_uprightness, 0.3, 0.95);
145        let confidence =
146            0.35 * timing_score + 0.25 * height_score + 0.25 * speed_score + 0.15 * upright_score;
147
148        if confidence < WAVEDASH_MIN_CONFIDENCE {
149            return None;
150        }
151
152        Some(WavedashEvent {
153            time: frame.time,
154            frame: frame.frame_number,
155            player: player_id.clone(),
156            is_team_0: candidate.is_team_0,
157            dodge_time: candidate.dodge_time,
158            dodge_frame: candidate.dodge_frame,
159            time_since_dodge,
160            dodge_position: candidate.dodge_position,
161            landing_position: landing_position.to_array(),
162            start_speed: candidate.start_horizontal_speed,
163            landing_speed,
164            horizontal_speed_gain,
165            landing_uprightness,
166            confidence,
167        })
168    }
169
170    fn apply_event(&mut self, event: WavedashEvent) {
171        self.events.push(event);
172    }
173
174    fn update_active_candidates(&mut self, frame: &FrameInfo, players: &PlayerFrameState) {
175        let mut finished = Vec::new();
176        let mut visible_players = HashSet::new();
177
178        for player in &players.players {
179            visible_players.insert(player.player_id.clone());
180            self.maybe_start_candidate(frame, player);
181
182            let Some(candidate) = self.active_candidates.get(&player.player_id).cloned() else {
183                continue;
184            };
185            if frame.time - candidate.dodge_time > WAVEDASH_MAX_CANDIDATE_SECONDS {
186                finished.push((player.player_id.clone(), None));
187                continue;
188            }
189            if let Some(event) = Self::candidate_event(&player.player_id, candidate, frame, player)
190            {
191                finished.push((player.player_id.clone(), Some(event)));
192            }
193        }
194
195        for (player_id, event) in finished {
196            self.active_candidates.remove(&player_id);
197            if let Some(event) = event {
198                self.apply_event(event);
199            }
200        }
201
202        self.active_candidates
203            .retain(|player_id, _| visible_players.contains(player_id));
204    }
205
206    pub fn update(
207        &mut self,
208        frame: &FrameInfo,
209        players: &PlayerFrameState,
210        live_play_state: &LivePlayState,
211    ) -> SubtrActorResult<()> {
212        self.events.begin_update();
213        if !live_play_state.is_live_play {
214            self.active_candidates.clear();
215            return Ok(());
216        }
217        self.update_active_candidates(frame, players);
218
219        Ok(())
220    }
221}
222
223#[cfg(test)]
224#[path = "wavedash_tests.rs"]
225mod tests;