use super::*;
const GOAL_LINE_Y: f32 = 5120.0;
const GOAL_MOUTH_HEIGHT_Z: f32 = 642.775;
const GOAL_MOUTH_TRAJECTORY_MARGIN: f32 = BALL_RADIUS_Z * 1.5;
const SHOT_MAX_TIME_TO_GOAL_SECONDS: f32 = 2.5;
const SHOT_MIN_BALL_SPEED: f32 = 1000.0;
const SAVE_MAX_TIME_TO_GOAL_SECONDS: f32 = 2.0;
const SAVE_MIN_INBOUND_BALL_SPEED: f32 = 250.0;
const STAT_EVENT_MATCH_WINDOW_SECONDS: f32 = 0.75;
const CLEAR_MAX_ATTACKING_Y: f32 = -GOAL_LINE_Y / 3.0;
const CLEAR_MIN_BALL_SPEED: f32 = 1300.0;
const CLEAR_MIN_AWAY_FROM_OWN_GOAL_ALIGNMENT: f32 = 0.2;
const BOOM_MIN_BALL_SPEED: f32 = 1500.0;
const BOOM_MIN_DOWNFIELD_ALIGNMENT: f32 = 0.3;
const PASS_MIN_BALL_SPEED: f32 = 500.0;
const PASS_MIN_LEAD_SECONDS: f32 = 0.15;
const PASS_MAX_LEAD_SECONDS: f32 = 2.5;
const PASS_RECEIVER_MAX_DISTANCE: f32 = 800.0;
const PASS_MIN_TRAVEL_DISTANCE: f32 = 500.0;
const FIRST_TOUCH_RESET_SECONDS: f32 = 2.5;
const CONTROL_FOLLOW_WINDOW_SECONDS: f32 = 1.25;
const CONTROL_FOLLOW_MAX_DISTANCE: f32 = 600.0;
const CONTROL_FOLLOW_MAX_RELATIVE_SPEED: f32 = 800.0;
const CONTROL_FOLLOW_MIN_TRACKED_SECONDS: f32 = 0.4;
const CONTROL_FOLLOW_MIN_CONTROLLED_FRACTION: f32 = 0.7;
const ADVANCE_MIN_PEAK_DISTANCE: f32 = 900.0;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TouchAction {
Shot,
Save,
Clear,
Boom,
Pass,
}
impl TouchAction {
pub fn as_label_value(self) -> &'static str {
match self {
Self::Shot => "shot",
Self::Save => "save",
Self::Clear => "clear",
Self::Boom => "boom",
Self::Pass => "pass",
}
}
pub(crate) fn watches_possession(self) -> bool {
matches!(self, Self::Pass | Self::Clear | Self::Boom)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Possession {
Control,
Advance,
}
impl Possession {
pub fn as_label_value(self) -> &'static str {
match self {
Self::Control => "control",
Self::Advance => "advance",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TouchActionResolution {
pub action: Option<TouchAction>,
pub first_touch: bool,
pub contested: bool,
}
pub struct TouchIntentionFrameContext<'a> {
pub ball_position: Option<glam::Vec3>,
pub ball_velocity: Option<glam::Vec3>,
pub previous_ball_position: Option<glam::Vec3>,
pub previous_ball_velocity: Option<glam::Vec3>,
pub teammate_positions: &'a [glam::Vec3],
pub contested: bool,
}
#[derive(Debug, Clone, PartialEq)]
struct Reception {
player: PlayerId,
last_touch_time: f32,
}
#[derive(Debug, Clone, PartialEq)]
struct RecentStatEvent {
kind: PlayerStatEventKind,
player: PlayerId,
time: f32,
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct TouchIntentionClassifier {
reception: Option<Reception>,
recent_stat_events: VecDeque<RecentStatEvent>,
}
impl TouchIntentionClassifier {
pub fn reset(&mut self) {
self.reception = None;
self.recent_stat_events.clear();
}
pub fn begin_frame(&mut self, frame: &FrameInfo, player_stat_events: &[PlayerStatEvent]) {
for event in player_stat_events {
match event.kind {
PlayerStatEventKind::Shot | PlayerStatEventKind::Save => {
self.recent_stat_events.push_back(RecentStatEvent {
kind: event.kind,
player: event.player.clone(),
time: event.time,
});
}
PlayerStatEventKind::Assist => {}
}
}
while self
.recent_stat_events
.front()
.is_some_and(|event| frame.time - event.time > STAT_EVENT_MATCH_WINDOW_SECONDS)
{
self.recent_stat_events.pop_front();
}
}
pub fn classify(
&mut self,
touch: &TouchEvent,
player_id: &PlayerId,
ctx: &TouchIntentionFrameContext,
) -> TouchActionResolution {
let first_touch = self.is_first_touch(player_id, touch.time);
let is_team_0 = touch.team_is_team_0;
let action =
if self.has_matching_stat_event(PlayerStatEventKind::Save, player_id, touch.time) {
Some(TouchAction::Save)
} else if self.has_matching_stat_event(PlayerStatEventKind::Shot, player_id, touch.time)
{
Some(TouchAction::Shot)
} else if Self::is_geometric_save(ctx, is_team_0) {
Some(TouchAction::Save)
} else if Self::is_geometric_shot(ctx, is_team_0) {
Some(TouchAction::Shot)
} else if Self::is_clear(ctx, is_team_0) {
Some(TouchAction::Clear)
} else if Self::is_pass(ctx) {
Some(TouchAction::Pass)
} else if Self::is_boom(ctx, is_team_0) {
Some(TouchAction::Boom)
} else {
None
};
self.note_touch(player_id, touch.time, ctx.contested);
TouchActionResolution {
action,
first_touch,
contested: ctx.contested,
}
}
fn is_first_touch(&self, player_id: &PlayerId, time: f32) -> bool {
match self.reception.as_ref() {
None => true,
Some(reception) => {
reception.player != *player_id
|| (time - reception.last_touch_time) > FIRST_TOUCH_RESET_SECONDS
}
}
}
fn note_touch(&mut self, player_id: &PlayerId, time: f32, contested: bool) {
let fresh = self.reception.as_ref().is_some_and(|reception| {
(time - reception.last_touch_time) <= FIRST_TOUCH_RESET_SECONDS
});
match self.reception.as_mut() {
Some(reception) if contested && fresh => {
reception.last_touch_time = time;
}
_ => {
self.reception = Some(Reception {
player: player_id.clone(),
last_touch_time: time,
});
}
}
}
fn has_matching_stat_event(
&self,
kind: PlayerStatEventKind,
player_id: &PlayerId,
touch_time: f32,
) -> bool {
self.recent_stat_events.iter().any(|event| {
event.kind == kind
&& event.player == *player_id
&& (touch_time - event.time).abs() <= STAT_EVENT_MATCH_WINDOW_SECONDS
})
}
fn is_geometric_save(ctx: &TouchIntentionFrameContext, is_team_0: bool) -> bool {
let (Some(position), Some(velocity)) =
(ctx.previous_ball_position, ctx.previous_ball_velocity)
else {
return false;
};
velocity.length() >= SAVE_MIN_INBOUND_BALL_SPEED
&& trajectory_crosses_goal_mouth(
position,
velocity,
own_goal_line_y(is_team_0),
SAVE_MAX_TIME_TO_GOAL_SECONDS,
)
}
fn is_geometric_shot(ctx: &TouchIntentionFrameContext, is_team_0: bool) -> bool {
let (Some(position), Some(velocity)) = (ctx.ball_position, ctx.ball_velocity) else {
return false;
};
velocity.length() >= SHOT_MIN_BALL_SPEED
&& trajectory_crosses_goal_mouth(
position,
velocity,
opponent_goal_line_y(is_team_0),
SHOT_MAX_TIME_TO_GOAL_SECONDS,
)
}
fn is_clear(ctx: &TouchIntentionFrameContext, is_team_0: bool) -> bool {
let (Some(position), Some(velocity)) = (ctx.ball_position, ctx.ball_velocity) else {
return false;
};
let team_forward_sign = if is_team_0 { 1.0 } else { -1.0 };
if position.y * team_forward_sign > CLEAR_MAX_ATTACKING_Y {
return false;
}
if velocity.length() < CLEAR_MIN_BALL_SPEED {
return false;
}
let own_goal_center = glam::Vec3::new(0.0, own_goal_line_y(is_team_0), 0.0);
let away_from_own_goal = (position - own_goal_center).normalize_or_zero();
velocity.normalize_or_zero().dot(away_from_own_goal)
>= CLEAR_MIN_AWAY_FROM_OWN_GOAL_ALIGNMENT
}
fn is_pass(ctx: &TouchIntentionFrameContext) -> bool {
let (Some(position), Some(velocity)) = (ctx.ball_position, ctx.ball_velocity) else {
return false;
};
let speed_squared = velocity.length_squared();
if speed_squared < PASS_MIN_BALL_SPEED * PASS_MIN_BALL_SPEED {
return false;
}
ctx.teammate_positions.iter().any(|teammate| {
let lead_seconds = (*teammate - position).dot(velocity) / speed_squared;
if !(PASS_MIN_LEAD_SECONDS..=PASS_MAX_LEAD_SECONDS).contains(&lead_seconds) {
return false;
}
let lead_travel = velocity * lead_seconds;
lead_travel.length() >= PASS_MIN_TRAVEL_DISTANCE
&& (position + lead_travel - *teammate).length() <= PASS_RECEIVER_MAX_DISTANCE
})
}
fn is_boom(ctx: &TouchIntentionFrameContext, is_team_0: bool) -> bool {
let (Some(position), Some(velocity)) = (ctx.ball_position, ctx.ball_velocity) else {
return false;
};
if velocity.length() < BOOM_MIN_BALL_SPEED {
return false;
}
let opponent_goal_center = glam::Vec3::new(0.0, opponent_goal_line_y(is_team_0), 0.0);
let toward_opponent_goal = (opponent_goal_center - position).normalize_or_zero();
velocity.normalize_or_zero().dot(toward_opponent_goal) >= BOOM_MIN_DOWNFIELD_ALIGNMENT
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PossessionResolution {
pub touch_index: usize,
pub possession: Option<Possession>,
}
#[derive(Debug, Clone, PartialEq)]
struct ControlFollowWindow {
touch_index: usize,
player: PlayerId,
touch_time: f32,
tracked_seconds: f32,
controlled_seconds: f32,
max_ball_distance: f32,
}
impl ControlFollowWindow {
fn stay_close_resolution(&self) -> PossessionResolution {
let confirmed = self.tracked_seconds >= CONTROL_FOLLOW_MIN_TRACKED_SECONDS
&& self.controlled_seconds
>= CONTROL_FOLLOW_MIN_CONTROLLED_FRACTION * self.tracked_seconds;
PossessionResolution {
touch_index: self.touch_index,
possession: confirmed.then_some(Possession::Control),
}
}
fn follow_up_resolution(&self) -> PossessionResolution {
let possession = if self.max_ball_distance >= ADVANCE_MIN_PEAK_DISTANCE {
Possession::Advance
} else {
Possession::Control
};
PossessionResolution {
touch_index: self.touch_index,
possession: Some(possession),
}
}
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct ControlFollowTracker {
window: Option<ControlFollowWindow>,
}
impl ControlFollowTracker {
pub fn window_player(&self) -> Option<&PlayerId> {
self.window.as_ref().map(|window| &window.player)
}
pub fn open(&mut self, touch_index: usize, player_id: &PlayerId, time: f32) {
self.window = Some(ControlFollowWindow {
touch_index,
player: player_id.clone(),
touch_time: time,
tracked_seconds: 0.0,
controlled_seconds: 0.0,
max_ball_distance: 0.0,
});
}
pub fn observe_touch(
&mut self,
player_id: &PlayerId,
time: f32,
) -> Option<PossessionResolution> {
let window = self.window.take()?;
if window.player == *player_id && time - window.touch_time <= CONTROL_FOLLOW_WINDOW_SECONDS
{
return Some(window.follow_up_resolution());
}
Some(window.stay_close_resolution())
}
pub fn advance(
&mut self,
frame: &FrameInfo,
ball_position: Option<glam::Vec3>,
ball_velocity: Option<glam::Vec3>,
player_position: Option<glam::Vec3>,
player_velocity: Option<glam::Vec3>,
) -> Option<PossessionResolution> {
if self
.window
.as_ref()
.is_some_and(|window| frame.time - window.touch_time > CONTROL_FOLLOW_WINDOW_SECONDS)
{
return self.flush();
}
let window = self.window.as_mut()?;
let dt = frame.dt.max(0.0);
window.tracked_seconds += dt;
let distance = match (ball_position, player_position) {
(Some(ball_position), Some(player_position)) => {
Some((ball_position - player_position).length())
}
_ => None,
};
if let Some(distance) = distance {
window.max_ball_distance = window.max_ball_distance.max(distance);
}
let close = distance.is_some_and(|distance| distance <= CONTROL_FOLLOW_MAX_DISTANCE);
let speed_matched = match (ball_velocity, player_velocity) {
(Some(ball_velocity), Some(player_velocity)) => {
(ball_velocity - player_velocity).length() <= CONTROL_FOLLOW_MAX_RELATIVE_SPEED
}
_ => false,
};
if close && speed_matched {
window.controlled_seconds += dt;
}
None
}
pub fn flush(&mut self) -> Option<PossessionResolution> {
self.window
.take()
.map(|window| window.stay_close_resolution())
}
}
const SHOT_PROJECTION_MIN_FREE_FLIGHT_SECONDS: f32 = 0.06;
const SHOT_PROJECTION_NEXT_TOUCH_GUARD_SECONDS: f32 = 0.12;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ShotProjectionResolution {
pub touch_index: usize,
pub is_shot: bool,
}
#[derive(Debug, Clone, Copy, PartialEq)]
struct ShotProjectionSample {
position: glam::Vec3,
velocity: glam::Vec3,
time: f32,
}
#[derive(Debug, Clone, PartialEq)]
struct ShotProjectionWindow {
touch_index: usize,
is_team_0: bool,
touch_time: f32,
samples: VecDeque<ShotProjectionSample>,
}
impl ShotProjectionWindow {
fn record(&mut self, sample: ShotProjectionSample) {
let cutoff = sample.time - (SHOT_PROJECTION_NEXT_TOUCH_GUARD_SECONDS + 0.1);
self.samples.push_back(sample);
while self
.samples
.front()
.is_some_and(|oldest| oldest.time < cutoff)
{
self.samples.pop_front();
}
}
fn is_shot_from(&self, sample: &ShotProjectionSample) -> bool {
sample.time - self.touch_time >= SHOT_PROJECTION_MIN_FREE_FLIGHT_SECONDS
&& sample.velocity.length() >= SHOT_MIN_BALL_SPEED
&& trajectory_crosses_goal_mouth(
sample.position,
sample.velocity,
opponent_goal_line_y(self.is_team_0),
SHOT_MAX_TIME_TO_GOAL_SECONDS,
)
}
fn resolution_latest(&self) -> ShotProjectionResolution {
let is_shot = self
.samples
.back()
.is_some_and(|sample| self.is_shot_from(sample));
ShotProjectionResolution {
touch_index: self.touch_index,
is_shot,
}
}
fn resolution_before(&self, close_time: f32) -> ShotProjectionResolution {
let limit = close_time - SHOT_PROJECTION_NEXT_TOUCH_GUARD_SECONDS;
let is_shot = self
.samples
.iter()
.rev()
.find(|sample| sample.time <= limit)
.is_some_and(|sample| self.is_shot_from(sample));
ShotProjectionResolution {
touch_index: self.touch_index,
is_shot,
}
}
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct ShotProjectionTracker {
window: Option<ShotProjectionWindow>,
}
impl ShotProjectionTracker {
pub fn open(&mut self, touch_index: usize, is_team_0: bool, time: f32) {
self.window = Some(ShotProjectionWindow {
touch_index,
is_team_0,
touch_time: time,
samples: VecDeque::new(),
});
}
pub fn observe_touch(&mut self, next_touch_time: f32) -> Option<ShotProjectionResolution> {
self.window
.take()
.map(|window| window.resolution_before(next_touch_time))
}
pub fn advance(
&mut self,
frame: &FrameInfo,
ball_position: Option<glam::Vec3>,
ball_velocity: Option<glam::Vec3>,
) -> Option<ShotProjectionResolution> {
if self
.window
.as_ref()
.is_some_and(|window| frame.time - window.touch_time > SHOT_MAX_TIME_TO_GOAL_SECONDS)
{
return self.window.take().map(|window| window.resolution_latest());
}
let window = self.window.as_mut()?;
if let (Some(position), Some(velocity)) = (ball_position, ball_velocity) {
window.record(ShotProjectionSample {
position,
velocity,
time: frame.time,
});
}
None
}
pub fn flush(&mut self) -> Option<ShotProjectionResolution> {
self.window.take().map(|window| window.resolution_latest())
}
}
pub(crate) fn fifty_fifty_involves_player(
active: &ActiveFiftyFifty,
player_id: &PlayerId,
is_team_0: bool,
) -> bool {
let contestant = if is_team_0 {
active.team_zero_player.as_ref()
} else {
active.team_one_player.as_ref()
};
contestant == Some(player_id)
}
fn own_goal_line_y(is_team_0: bool) -> f32 {
if is_team_0 { -GOAL_LINE_Y } else { GOAL_LINE_Y }
}
fn opponent_goal_line_y(is_team_0: bool) -> f32 {
-own_goal_line_y(is_team_0)
}
fn trajectory_crosses_goal_mouth(
position: glam::Vec3,
velocity: glam::Vec3,
target_goal_y: f32,
max_seconds: f32,
) -> bool {
if velocity.length_squared() <= f32::EPSILON {
return false;
}
let time_to_goal_line = (target_goal_y - position.y) / velocity.y;
if !time_to_goal_line.is_finite() || !(0.0..=max_seconds).contains(&time_to_goal_line) {
return false;
}
let projected_x = position.x + velocity.x * time_to_goal_line;
let projected_z = position.z
+ velocity.z * time_to_goal_line
+ 0.5
* crate::util::ballistics::STANDARD_BALL_GRAVITY_Z
* time_to_goal_line
* time_to_goal_line;
projected_x.abs() <= BACK_WALL_GOAL_MOUTH_HALF_WIDTH_X + GOAL_MOUTH_TRAJECTORY_MARGIN
&& (BALL_RADIUS_Z - GOAL_MOUTH_TRAJECTORY_MARGIN
..=GOAL_MOUTH_HEIGHT_Z + GOAL_MOUTH_TRAJECTORY_MARGIN)
.contains(&projected_z)
}
#[cfg(test)]
#[path = "touch_intention_tests.rs"]
mod tests;