subtr-actor 1.2.0

Rocket League replay transformer
Documentation
use super::*;

const AIR_DRIBBLE_MIN_BALL_Z: f32 = 300.0;
pub(crate) const AIR_DRIBBLE_MIN_PLAYER_Z: f32 = 100.0;
pub(crate) const AIR_DRIBBLE_MIN_DURATION: f32 = 0.65;
const AIR_DRIBBLE_MIN_TOUCHES: u32 = 3;
const AIR_DRIBBLE_MIN_AIR_TOUCHES: u32 = 2;
const AIR_DRIBBLE_TOUCH_MAX_GAP_SECONDS: f32 = 3.0;
/// A resting ball's center sits at ~93uu (ball radius ≈ 92.75uu), so a ball
/// whose center descends to this height between touches has effectively
/// returned to the ground and any active dribble must not bridge across it;
/// the small margin above resting height absorbs physics/sampling jitter.
/// The gate deliberately keys on genuine ground-level height rather than wall
/// proximity: a ball hugging a wall at ~100uu is sitting in the ground corner,
/// where breaking the dribble is correct, while a true wall dribble keeps the
/// ball well above resting height.
const AIR_DRIBBLE_BALL_GROUND_RETURN_MAX_Z: f32 = 110.0;
const WALL_TAKEOFF_MIN_Z: f32 = 120.0;
const SIDE_WALL_START_ABS_X: f32 = 3200.0;
const BACK_WALL_START_ABS_Y: f32 = 4600.0;

/// Where an air dribble originated (ground vs wall).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)]
#[ts(export)]
#[serde(rename_all = "snake_case")]
pub enum AirDribbleOrigin {
    GroundToAir,
    WallToAir,
}

impl AirDribbleOrigin {
    pub fn as_label_value(self) -> &'static str {
        match self {
            Self::GroundToAir => "ground_to_air",
            Self::WallToAir => "wall_to_air",
        }
    }
}

pub(crate) struct AirDribblePolicy;

impl AirDribblePolicy {
    pub(crate) fn is_air_touch_position(player_position: glam::Vec3) -> bool {
        player_position.z > PLAYER_GROUND_Z_THRESHOLD && !player_is_on_wall(player_position)
    }

    pub(crate) fn origin(start_position: glam::Vec3) -> AirDribbleOrigin {
        if start_position.z >= WALL_TAKEOFF_MIN_Z
            && (start_position.x.abs() >= SIDE_WALL_START_ABS_X
                || start_position.y.abs() >= BACK_WALL_START_ABS_Y)
        {
            AirDribbleOrigin::WallToAir
        } else {
            AirDribbleOrigin::GroundToAir
        }
    }
}

#[derive(Debug, Clone)]
struct ActiveAirDribble {
    player_id: PlayerId,
    is_team_0: bool,
    start_frame: usize,
    end_frame: usize,
    start_time: f32,
    end_time: f32,
    start_position: glam::Vec3,
    end_position: glam::Vec3,
    path_distance: f32,
    horizontal_gap_sum: f32,
    vertical_gap_sum: f32,
    touch_count: u32,
    air_touch_count: u32,
    /// Lowest ball height observed on frames since this dribble's most recent
    /// touch. Touch events sample with lag (a touch can register with the
    /// ball already back above ground level), so the between-touch per-frame
    /// minimum — not the touch's own ball height — is what detects the ball
    /// returning to the ground between touches.
    min_ball_z_since_last_touch: f32,
}

impl ActiveAirDribble {
    fn from_touch(touch: &TouchClassificationEvent) -> Option<Self> {
        let player_position = touch.player_position.map(glam::Vec3::from_array)?;
        let ball_position = touch.ball_position.map(glam::Vec3::from_array)?;
        let (horizontal_gap, vertical_gap) = touch_gaps(player_position, ball_position);
        Some(Self {
            player_id: touch.player.clone(),
            is_team_0: touch.is_team_0,
            start_frame: touch.frame,
            end_frame: touch.frame,
            start_time: touch.time,
            end_time: touch.time,
            start_position: player_position,
            end_position: player_position,
            path_distance: 0.0,
            horizontal_gap_sum: horizontal_gap,
            vertical_gap_sum: vertical_gap,
            touch_count: 1,
            air_touch_count: u32::from(is_qualifying_air_dribble_touch(touch)),
            min_ball_z_since_last_touch: f32::INFINITY,
        })
    }

    fn extend(&mut self, touch: &TouchClassificationEvent) -> Option<()> {
        let player_position = touch.player_position.map(glam::Vec3::from_array)?;
        let ball_position = touch.ball_position.map(glam::Vec3::from_array)?;
        let (horizontal_gap, vertical_gap) = touch_gaps(player_position, ball_position);
        self.path_distance += player_position.distance(self.end_position);
        self.end_position = player_position;
        self.end_time = touch.time;
        self.end_frame = touch.frame;
        self.horizontal_gap_sum += horizontal_gap;
        self.vertical_gap_sum += vertical_gap;
        self.touch_count += 1;
        self.air_touch_count += u32::from(is_qualifying_air_dribble_touch(touch));
        self.min_ball_z_since_last_touch = f32::INFINITY;
        Some(())
    }

    fn observe_ball_height(&mut self, ball_z: f32) {
        self.min_ball_z_since_last_touch = self.min_ball_z_since_last_touch.min(ball_z);
    }

    fn ball_returned_to_ground_since_last_touch(&self) -> bool {
        self.min_ball_z_since_last_touch <= AIR_DRIBBLE_BALL_GROUND_RETURN_MAX_Z
    }

    fn event(self) -> Option<BallCarryEvent> {
        if self.touch_count < AIR_DRIBBLE_MIN_TOUCHES {
            return None;
        }
        if self.air_touch_count < AIR_DRIBBLE_MIN_AIR_TOUCHES {
            return None;
        }
        let duration = self.end_time - self.start_time;
        if duration < AIR_DRIBBLE_MIN_DURATION {
            return None;
        }
        let average_speed = if duration > 0.0 {
            self.path_distance / duration
        } else {
            0.0
        };
        Some(BallCarryEvent {
            player_id: self.player_id,
            is_team_0: self.is_team_0,
            kind: BallCarryKind::AirDribble,
            start_position: self.start_position.to_array(),
            end_position: self.end_position.to_array(),
            start_frame: self.start_frame,
            end_frame: self.end_frame,
            start_time: self.start_time,
            end_time: self.end_time,
            duration,
            straight_line_distance: self
                .start_position
                .truncate()
                .distance(self.end_position.truncate()),
            path_distance: self.path_distance,
            average_horizontal_gap: self.horizontal_gap_sum / self.touch_count as f32,
            average_vertical_gap: self.vertical_gap_sum / self.touch_count as f32,
            average_speed,
            touch_count: self.touch_count,
            air_touch_count: self.air_touch_count,
            air_dribble_origin: Some(AirDribblePolicy::origin(self.start_position)),
        })
    }

    fn has_qualifying_air_touch(&self) -> bool {
        self.air_touch_count > 0
    }
}

fn touch_gaps(player_position: glam::Vec3, ball_position: glam::Vec3) -> (f32, f32) {
    (
        player_position
            .truncate()
            .distance(ball_position.truncate()),
        ball_position.z - player_position.z,
    )
}

fn is_qualifying_air_dribble_touch(touch: &TouchClassificationEvent) -> bool {
    matches!(touch.tag("surface"), Some("air"))
        && touch
            .ball_position
            .is_some_and(|position| position[2] >= AIR_DRIBBLE_MIN_BALL_Z)
}

/// Detects air dribbles from continuous same-player touches.
#[derive(Debug, Clone, Default)]
pub struct AirDribbleCalculator {
    events: EventStream<BallCarryEvent>,
    active: Option<ActiveAirDribble>,
    processed_touch_count: usize,
}

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

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

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

    fn finish_active(&mut self) {
        if let Some(event) = self.active.take().and_then(ActiveAirDribble::event) {
            self.events.push(event);
        }
    }

    fn observe_touch(&mut self, touch: &TouchClassificationEvent) {
        let same_sequence = self.active.as_ref().is_some_and(|active| {
            active.player_id == touch.player
                && active.is_team_0 == touch.is_team_0
                && touch.time - active.end_time <= AIR_DRIBBLE_TOUCH_MAX_GAP_SECONDS
                && !active.ball_returned_to_ground_since_last_touch()
        });
        if same_sequence {
            if self
                .active
                .as_mut()
                .and_then(|active| active.extend(touch))
                .is_none()
            {
                self.finish_active();
            }
            return;
        }

        self.finish_active();
        self.active = ActiveAirDribble::from_touch(touch);
    }

    pub fn update(
        &mut self,
        frame: &FrameInfo,
        ball: &BallFrameState,
        players: &PlayerFrameState,
        live_play: &LivePlayState,
        touch: &TouchCalculator,
    ) -> SubtrActorResult<()> {
        self.update_with_touch_classification_events(
            frame,
            ball,
            players,
            live_play,
            touch.events(),
        )
    }

    pub(crate) fn update_with_touch_classification_events(
        &mut self,
        frame: &FrameInfo,
        ball: &BallFrameState,
        _players: &PlayerFrameState,
        live_play: &LivePlayState,
        touch_events: &[TouchClassificationEvent],
    ) -> SubtrActorResult<()> {
        self.events.begin_update();
        if !live_play.is_live_play {
            self.finish_active();
            self.processed_touch_count = touch_events.len();
            return Ok(());
        }
        if let (Some(active), Some(position)) = (self.active.as_mut(), ball.position()) {
            active.observe_ball_height(position.z);
        }
        if self
            .active
            .as_ref()
            .is_some_and(ActiveAirDribble::has_qualifying_air_touch)
            && ball
                .position()
                .is_some_and(|position| position.z < AIR_DRIBBLE_MIN_BALL_Z)
        {
            self.finish_active();
        }
        for touch in &touch_events[self.processed_touch_count..] {
            if touch.frame > frame.frame_number {
                break;
            }
            self.observe_touch(touch);
            self.processed_touch_count += 1;
        }
        Ok(())
    }

    pub fn finish(&mut self) -> SubtrActorResult<()> {
        self.events.begin_update();
        self.finish_active();
        Ok(())
    }
}

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