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 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;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TouchIntention {
Control,
Shot,
Save,
Challenge,
Clear,
Pass,
Neutral,
}
impl TouchIntention {
pub fn as_label_value(self) -> &'static str {
match self {
Self::Control => "control",
Self::Shot => "shot",
Self::Save => "save",
Self::Challenge => "challenge",
Self::Clear => "clear",
Self::Pass => "pass",
Self::Neutral => "neutral",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TouchIntentionResolution {
pub intention: TouchIntention,
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,
) -> TouchIntentionResolution {
let first_touch = self.is_first_touch(player_id, touch.time);
let is_team_0 = touch.team_is_team_0;
let intention =
if self.has_matching_stat_event(PlayerStatEventKind::Save, player_id, touch.time) {
TouchIntention::Save
} else if self.has_matching_stat_event(PlayerStatEventKind::Shot, player_id, touch.time)
{
TouchIntention::Shot
} else if ctx.contested {
TouchIntention::Challenge
} else if Self::is_geometric_save(ctx, is_team_0) {
TouchIntention::Save
} else if Self::is_geometric_shot(ctx, is_team_0) {
TouchIntention::Shot
} else if Self::is_clear(ctx, is_team_0) {
TouchIntention::Clear
} else if Self::is_pass(ctx) {
TouchIntention::Pass
} else {
TouchIntention::Neutral
};
self.note_touch(player_id, touch.time, ctx.contested);
TouchIntentionResolution {
intention,
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
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ControlResolution {
pub touch_index: usize,
pub control: bool,
}
#[derive(Debug, Clone, PartialEq)]
struct ControlFollowWindow {
touch_index: usize,
player: PlayerId,
touch_time: f32,
tracked_seconds: f32,
controlled_seconds: f32,
}
impl ControlFollowWindow {
fn stay_close_resolution(&self) -> ControlResolution {
ControlResolution {
touch_index: self.touch_index,
control: self.tracked_seconds >= CONTROL_FOLLOW_MIN_TRACKED_SECONDS
&& self.controlled_seconds
>= CONTROL_FOLLOW_MIN_CONTROLLED_FRACTION * self.tracked_seconds,
}
}
}
#[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,
});
}
pub fn observe_touch(&mut self, player_id: &PlayerId, time: f32) -> Option<ControlResolution> {
let window = self.window.take()?;
if window.player == *player_id && time - window.touch_time <= CONTROL_FOLLOW_WINDOW_SECONDS
{
return Some(ControlResolution {
touch_index: window.touch_index,
control: true,
});
}
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<ControlResolution> {
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 close = match (ball_position, player_position) {
(Some(ball_position), Some(player_position)) => {
(ball_position - player_position).length() <= CONTROL_FOLLOW_MAX_DISTANCE
}
_ => false,
};
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<ControlResolution> {
self.window
.take()
.map(|window| window.stay_close_resolution())
}
}
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 = position + velocity * time_to_goal_line;
projected.x.abs() <= BACK_WALL_GOAL_MOUTH_HALF_WIDTH_X + GOAL_MOUTH_TRAJECTORY_MARGIN
&& projected.z >= BALL_RADIUS_Z - GOAL_MOUTH_TRAJECTORY_MARGIN
&& projected.z <= GOAL_MOUTH_HEIGHT_Z + GOAL_MOUTH_TRAJECTORY_MARGIN
}
#[cfg(test)]
#[path = "touch_intention_tests.rs"]
mod tests;