subtr-actor 0.7.0

Rocket League replay transformer
Documentation
use super::*;

const WHIFF_ENTER_DISTANCE: f32 = 185.0;
const WHIFF_EXIT_DISTANCE: f32 = 340.0;
const WHIFF_MAX_CANDIDATE_SECONDS: f32 = 0.65;
const WHIFF_MIN_APPROACH_SPEED: f32 = 300.0;
const WHIFF_MIN_FORWARD_ALIGNMENT: f32 = 0.15;
const WHIFF_MIN_DODGE_APPROACH_SPEED: f32 = 100.0;

#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
#[ts(export)]
pub struct WhiffEvent {
    pub time: f32,
    pub frame: usize,
    #[ts(as = "crate::ts_bindings::RemoteIdTs")]
    pub player: PlayerId,
    pub is_team_0: bool,
    pub closest_approach_distance: f32,
    pub forward_alignment: f32,
    pub approach_speed: f32,
    pub dodge_active: bool,
    pub aerial: bool,
}

#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, ts_rs::TS)]
#[ts(export)]
pub struct WhiffStats {
    pub whiff_count: u32,
    pub grounded_whiff_count: u32,
    pub aerial_whiff_count: u32,
    pub dodge_whiff_count: u32,
    pub is_last_whiff: bool,
    pub last_whiff_time: Option<f32>,
    pub last_whiff_frame: Option<usize>,
    pub time_since_last_whiff: Option<f32>,
    pub frames_since_last_whiff: Option<usize>,
    pub last_closest_approach_distance: Option<f32>,
    pub best_closest_approach_distance: Option<f32>,
    pub cumulative_closest_approach_distance: f32,
}

impl WhiffStats {
    pub fn average_closest_approach_distance(&self) -> f32 {
        if self.whiff_count == 0 {
            0.0
        } else {
            self.cumulative_closest_approach_distance / self.whiff_count as f32
        }
    }
}

#[derive(Debug, Clone, PartialEq)]
struct ActiveWhiffCandidate {
    player: PlayerId,
    is_team_0: bool,
    start_time: f32,
    closest_time: f32,
    closest_frame: usize,
    closest_approach_distance: f32,
    forward_alignment: f32,
    approach_speed: f32,
    dodge_active: bool,
    aerial: bool,
}

#[derive(Debug, Clone, Default, PartialEq)]
pub struct WhiffCalculator {
    player_stats: HashMap<PlayerId, WhiffStats>,
    active_candidates: HashMap<PlayerId, ActiveWhiffCandidate>,
    events: Vec<WhiffEvent>,
    current_last_whiff_player: Option<PlayerId>,
}

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

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

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

    fn hitbox_distance(ball_position: glam::Vec3, player: &PlayerSample) -> Option<f32> {
        const OCTANE_HITBOX_LENGTH: f32 = 118.01;
        const OCTANE_HITBOX_WIDTH: f32 = 84.2;
        const OCTANE_HITBOX_HEIGHT: f32 = 36.16;
        const OCTANE_HITBOX_OFFSET: f32 = 13.88;
        const OCTANE_HITBOX_ELEVATION: f32 = 17.05;

        let rigid_body = player.rigid_body.as_ref()?;
        let player_position = player.position()?;
        let local_ball_position =
            quat_to_glam(&rigid_body.rotation).inverse() * (ball_position - player_position);

        let x_min = -OCTANE_HITBOX_LENGTH / 2.0 + OCTANE_HITBOX_OFFSET;
        let x_max = OCTANE_HITBOX_LENGTH / 2.0 + OCTANE_HITBOX_OFFSET;
        let y_min = -OCTANE_HITBOX_WIDTH / 2.0;
        let y_max = OCTANE_HITBOX_WIDTH / 2.0;
        let z_min = -OCTANE_HITBOX_HEIGHT / 2.0 + OCTANE_HITBOX_ELEVATION;
        let z_max = OCTANE_HITBOX_HEIGHT / 2.0 + OCTANE_HITBOX_ELEVATION;

        let x_distance = if local_ball_position.x < x_min {
            x_min - local_ball_position.x
        } else if local_ball_position.x > x_max {
            local_ball_position.x - x_max
        } else {
            0.0
        };
        let y_distance = if local_ball_position.y < y_min {
            y_min - local_ball_position.y
        } else if local_ball_position.y > y_max {
            local_ball_position.y - y_max
        } else {
            0.0
        };
        let z_distance = if local_ball_position.z < z_min {
            z_min - local_ball_position.z
        } else if local_ball_position.z > z_max {
            local_ball_position.z - z_max
        } else {
            0.0
        };

        Some(glam::Vec3::new(x_distance, y_distance, z_distance).length())
    }

    fn whiff_candidate(
        frame: &FrameInfo,
        ball_position: glam::Vec3,
        player: &PlayerSample,
    ) -> Option<ActiveWhiffCandidate> {
        let distance = Self::hitbox_distance(ball_position, player)?;
        if distance > WHIFF_ENTER_DISTANCE {
            return None;
        }

        let rigid_body = player.rigid_body.as_ref()?;
        let player_position = player.position()?;
        let to_ball = (ball_position - player_position).normalize_or_zero();
        if to_ball.length_squared() <= f32::EPSILON {
            return None;
        }

        let rotation = quat_to_glam(&rigid_body.rotation);
        let forward_alignment = (rotation * glam::Vec3::X).dot(to_ball);
        let approach_speed = player.velocity().unwrap_or(glam::Vec3::ZERO).dot(to_ball);
        let committed_approach = approach_speed >= WHIFF_MIN_APPROACH_SPEED
            && forward_alignment >= WHIFF_MIN_FORWARD_ALIGNMENT;
        let committed_dodge =
            player.dodge_active && approach_speed >= WHIFF_MIN_DODGE_APPROACH_SPEED;
        if !committed_approach && !committed_dodge {
            return None;
        }

        Some(ActiveWhiffCandidate {
            player: player.player_id.clone(),
            is_team_0: player.is_team_0,
            start_time: frame.time,
            closest_time: frame.time,
            closest_frame: frame.frame_number,
            closest_approach_distance: distance,
            forward_alignment,
            approach_speed,
            dodge_active: player.dodge_active,
            aerial: player_position.z > POWERSLIDE_MAX_Z_THRESHOLD,
        })
    }

    fn begin_sample(&mut self, frame: &FrameInfo) {
        for stats in self.player_stats.values_mut() {
            stats.is_last_whiff = false;
            stats.time_since_last_whiff = stats
                .last_whiff_time
                .map(|time| (frame.time - time).max(0.0));
            stats.frames_since_last_whiff = stats
                .last_whiff_frame
                .map(|last_frame| frame.frame_number.saturating_sub(last_frame));
        }
    }

    fn cancel_touched_candidates(&mut self, touch_state: &TouchState) {
        for touch in &touch_state.touch_events {
            if let Some(player_id) = touch.player.as_ref() {
                self.active_candidates.remove(player_id);
            }
        }
    }

    fn emit_whiff(&mut self, candidate: ActiveWhiffCandidate, frame: &FrameInfo) {
        let event = WhiffEvent {
            time: candidate.closest_time,
            frame: candidate.closest_frame,
            player: candidate.player.clone(),
            is_team_0: candidate.is_team_0,
            closest_approach_distance: candidate.closest_approach_distance,
            forward_alignment: candidate.forward_alignment,
            approach_speed: candidate.approach_speed,
            dodge_active: candidate.dodge_active,
            aerial: candidate.aerial,
        };

        let stats = self
            .player_stats
            .entry(candidate.player.clone())
            .or_default();
        stats.whiff_count += 1;
        if event.aerial {
            stats.aerial_whiff_count += 1;
        } else {
            stats.grounded_whiff_count += 1;
        }
        if event.dodge_active {
            stats.dodge_whiff_count += 1;
        }
        stats.is_last_whiff = true;
        stats.last_whiff_time = Some(event.time);
        stats.last_whiff_frame = Some(event.frame);
        stats.time_since_last_whiff = Some((frame.time - event.time).max(0.0));
        stats.frames_since_last_whiff = Some(frame.frame_number.saturating_sub(event.frame));
        stats.last_closest_approach_distance = Some(event.closest_approach_distance);
        stats.best_closest_approach_distance = Some(
            stats
                .best_closest_approach_distance
                .map(|distance| distance.min(event.closest_approach_distance))
                .unwrap_or(event.closest_approach_distance),
        );
        stats.cumulative_closest_approach_distance += event.closest_approach_distance;

        self.current_last_whiff_player = Some(candidate.player);
        self.events.push(event);
    }

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

        for player in &players.players {
            let player_id = player.player_id.clone();
            visible_players.insert(player_id.clone());
            let distance = Self::hitbox_distance(ball_position, player);

            if let (Some(candidate), Some(distance)) =
                (self.active_candidates.get_mut(&player_id), distance)
            {
                if distance < candidate.closest_approach_distance {
                    candidate.closest_approach_distance = distance;
                    candidate.closest_time = frame.time;
                    candidate.closest_frame = frame.frame_number;
                    if let Some(updated) = Self::whiff_candidate(frame, ball_position, player) {
                        candidate.forward_alignment = updated.forward_alignment;
                        candidate.approach_speed = updated.approach_speed;
                        candidate.dodge_active |= updated.dodge_active;
                        candidate.aerial |= updated.aerial;
                    }
                }

                if distance > WHIFF_EXIT_DISTANCE
                    || frame.time - candidate.start_time > WHIFF_MAX_CANDIDATE_SECONDS
                {
                    if let Some(candidate) = self.active_candidates.remove(&player_id) {
                        self.emit_whiff(candidate, frame);
                    }
                }
                continue;
            }

            if let Some(candidate) = Self::whiff_candidate(frame, ball_position, player) {
                self.active_candidates.insert(player_id, candidate);
            }
        }

        let missing_players = self
            .active_candidates
            .keys()
            .filter(|player_id| !visible_players.contains(*player_id))
            .cloned()
            .collect::<Vec<_>>();
        for player_id in missing_players {
            self.active_candidates.remove(&player_id);
        }
    }

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

        self.begin_sample(frame);
        self.cancel_touched_candidates(touch_state);
        if let Some(ball_position) = ball.position() {
            self.update_active_candidates(frame, ball_position, players);
        }

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

        Ok(())
    }
}

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