subtr-actor 0.8.2

Rocket League replay transformer
Documentation
use super::*;

const WAVEDASH_MAX_DODGE_TO_LANDING_SECONDS: f32 = 0.35;
const WAVEDASH_MAX_CANDIDATE_SECONDS: f32 = 0.5;
const WAVEDASH_MIN_DODGE_START_Z: f32 = PLAYER_GROUND_Z_THRESHOLD + 8.0;
const WAVEDASH_MAX_DODGE_START_Z: f32 = 320.0;
const WAVEDASH_MIN_LANDING_UPRIGHTNESS: f32 = 0.15;
const WAVEDASH_MIN_CONFIDENCE: f32 = 0.45;
const WAVEDASH_HIGH_CONFIDENCE: f32 = 0.75;

#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
#[ts(export)]
pub struct WavedashEvent {
    pub time: f32,
    pub frame: usize,
    #[ts(as = "crate::ts_bindings::RemoteIdTs")]
    pub player: PlayerId,
    pub is_team_0: bool,
    pub dodge_time: f32,
    pub dodge_frame: usize,
    pub time_since_dodge: f32,
    pub dodge_position: [f32; 3],
    pub landing_position: [f32; 3],
    pub start_speed: f32,
    pub landing_speed: f32,
    pub horizontal_speed_gain: f32,
    pub landing_uprightness: f32,
    pub confidence: f32,
}

#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ts_rs::TS)]
#[ts(export)]
pub struct WavedashStats {
    pub count: u32,
    pub high_confidence_count: u32,
    pub is_last_wavedash: bool,
    pub last_wavedash_time: Option<f32>,
    pub last_wavedash_frame: Option<usize>,
    pub time_since_last_wavedash: Option<f32>,
    pub frames_since_last_wavedash: Option<usize>,
    pub last_quality: Option<f32>,
    pub best_quality: f32,
    pub cumulative_quality: f32,
}

impl WavedashStats {
    pub fn average_quality(&self) -> f32 {
        if self.count == 0 {
            0.0
        } else {
            self.cumulative_quality / self.count as f32
        }
    }
}

#[derive(Debug, Clone, PartialEq)]
struct ActiveWavedashCandidate {
    is_team_0: bool,
    dodge_time: f32,
    dodge_frame: usize,
    dodge_position: [f32; 3],
    start_horizontal_speed: f32,
    start_height: f32,
}

#[derive(Debug, Clone, Default, PartialEq)]
pub struct WavedashCalculator {
    player_stats: HashMap<PlayerId, WavedashStats>,
    events: Vec<WavedashEvent>,
    active_candidates: HashMap<PlayerId, ActiveWavedashCandidate>,
    previous_dodge_active: HashMap<PlayerId, bool>,
    current_last_wavedash_player: Option<PlayerId>,
}

impl WavedashCalculator {
    pub fn new() -> Self {
        Self::default()
    }

    pub fn player_stats(&self) -> &HashMap<PlayerId, WavedashStats> {
        &self.player_stats
    }

    pub fn events(&self) -> &[WavedashEvent] {
        &self.events
    }

    fn horizontal_speed(player: &PlayerSample) -> f32 {
        player
            .velocity()
            .map(|velocity| velocity.truncate().length())
            .unwrap_or(0.0)
    }

    fn normalize_score(value: f32, min_value: f32, max_value: f32) -> f32 {
        if max_value <= min_value {
            return 0.0;
        }

        ((value - min_value) / (max_value - min_value)).clamp(0.0, 1.0)
    }

    fn landing_uprightness(player: &PlayerSample) -> Option<f32> {
        let rigid_body = player.rigid_body.as_ref()?;
        Some((quat_to_glam(&rigid_body.rotation) * glam::Vec3::Z).dot(glam::Vec3::Z))
    }

    fn maybe_start_candidate(&mut self, frame: &FrameInfo, player: &PlayerSample) {
        let was_dodge_active = self
            .previous_dodge_active
            .insert(player.player_id.clone(), player.dodge_active)
            .unwrap_or(false);
        if !player.dodge_active || was_dodge_active {
            return;
        }

        let Some(position) = player.position() else {
            return;
        };
        if !(WAVEDASH_MIN_DODGE_START_Z..=WAVEDASH_MAX_DODGE_START_Z).contains(&position.z) {
            return;
        }

        self.active_candidates.insert(
            player.player_id.clone(),
            ActiveWavedashCandidate {
                is_team_0: player.is_team_0,
                dodge_time: frame.time,
                dodge_frame: frame.frame_number,
                dodge_position: position.to_array(),
                start_horizontal_speed: Self::horizontal_speed(player),
                start_height: position.z,
            },
        );
    }

    fn candidate_event(
        player_id: &PlayerId,
        candidate: ActiveWavedashCandidate,
        frame: &FrameInfo,
        player: &PlayerSample,
    ) -> Option<WavedashEvent> {
        let landing_position = player.position()?;
        if landing_position.z > PLAYER_GROUND_Z_THRESHOLD {
            return None;
        }

        let time_since_dodge = frame.time - candidate.dodge_time;
        if !(0.0..=WAVEDASH_MAX_DODGE_TO_LANDING_SECONDS).contains(&time_since_dodge) {
            return None;
        }

        let landing_uprightness = Self::landing_uprightness(player)?;
        if landing_uprightness < WAVEDASH_MIN_LANDING_UPRIGHTNESS {
            return None;
        }

        let landing_speed = Self::horizontal_speed(player);
        let horizontal_speed_gain = landing_speed - candidate.start_horizontal_speed;
        let timing_score = 1.0
            - Self::normalize_score(
                time_since_dodge,
                0.08,
                WAVEDASH_MAX_DODGE_TO_LANDING_SECONDS,
            );
        let height_score =
            1.0 - Self::normalize_score(candidate.start_height, WAVEDASH_MIN_DODGE_START_Z, 220.0);
        let speed_score = Self::normalize_score(horizontal_speed_gain, 80.0, 550.0)
            .max(Self::normalize_score(landing_speed, 900.0, 1800.0) * 0.8);
        let upright_score = Self::normalize_score(landing_uprightness, 0.3, 0.95);
        let confidence =
            0.35 * timing_score + 0.25 * height_score + 0.25 * speed_score + 0.15 * upright_score;

        if confidence < WAVEDASH_MIN_CONFIDENCE {
            return None;
        }

        Some(WavedashEvent {
            time: frame.time,
            frame: frame.frame_number,
            player: player_id.clone(),
            is_team_0: candidate.is_team_0,
            dodge_time: candidate.dodge_time,
            dodge_frame: candidate.dodge_frame,
            time_since_dodge,
            dodge_position: candidate.dodge_position,
            landing_position: landing_position.to_array(),
            start_speed: candidate.start_horizontal_speed,
            landing_speed,
            horizontal_speed_gain,
            landing_uprightness,
            confidence,
        })
    }

    fn apply_event(&mut self, event: WavedashEvent) {
        for stats in self.player_stats.values_mut() {
            stats.is_last_wavedash = false;
        }

        let stats = self.player_stats.entry(event.player.clone()).or_default();
        stats.count += 1;
        if event.confidence >= WAVEDASH_HIGH_CONFIDENCE {
            stats.high_confidence_count += 1;
        }
        stats.is_last_wavedash = true;
        stats.last_wavedash_time = Some(event.time);
        stats.last_wavedash_frame = Some(event.frame);
        stats.time_since_last_wavedash = Some(0.0);
        stats.frames_since_last_wavedash = Some(0);
        stats.last_quality = Some(event.confidence);
        stats.best_quality = stats.best_quality.max(event.confidence);
        stats.cumulative_quality += event.confidence;

        self.current_last_wavedash_player = Some(event.player.clone());
        self.events.push(event);
    }

    fn begin_sample(&mut self, frame: &FrameInfo) {
        for stats in self.player_stats.values_mut() {
            stats.is_last_wavedash = false;
            stats.time_since_last_wavedash = stats
                .last_wavedash_time
                .map(|time| (frame.time - time).max(0.0));
            stats.frames_since_last_wavedash = stats
                .last_wavedash_frame
                .map(|last_frame| frame.frame_number.saturating_sub(last_frame));
        }

        if let Some(player_id) = self.current_last_wavedash_player.as_ref() {
            if let Some(stats) = self.player_stats.get_mut(player_id) {
                stats.is_last_wavedash = true;
            }
        }
    }

    fn update_active_candidates(&mut self, frame: &FrameInfo, players: &PlayerFrameState) {
        let mut finished = Vec::new();
        let mut visible_players = HashSet::new();

        for player in &players.players {
            visible_players.insert(player.player_id.clone());
            self.maybe_start_candidate(frame, player);

            let Some(candidate) = self.active_candidates.get(&player.player_id).cloned() else {
                continue;
            };
            if frame.time - candidate.dodge_time > WAVEDASH_MAX_CANDIDATE_SECONDS {
                finished.push((player.player_id.clone(), None));
                continue;
            }
            if let Some(event) = Self::candidate_event(&player.player_id, candidate, frame, player)
            {
                finished.push((player.player_id.clone(), Some(event)));
            }
        }

        for (player_id, event) in finished {
            self.active_candidates.remove(&player_id);
            if let Some(event) = event {
                self.apply_event(event);
            }
        }

        self.active_candidates
            .retain(|player_id, _| visible_players.contains(player_id));
    }

    pub fn update(
        &mut self,
        frame: &FrameInfo,
        players: &PlayerFrameState,
        live_play: bool,
    ) -> SubtrActorResult<()> {
        if !live_play {
            self.active_candidates.clear();
            self.current_last_wavedash_player = None;
            return Ok(());
        }

        self.begin_sample(frame);
        self.update_active_candidates(frame, players);

        Ok(())
    }
}

#[cfg(test)]
#[path = "wavedash_tests.rs"]
mod tests;