use super::*;
const DEFAULT_LEVEL_BALL_DEPTH_MARGIN: f32 = 150.0;
const DEFAULT_CLOSEST_TO_BALL_SWITCH_MARGIN: f32 = 100.0;
const DEFAULT_CLOSEST_TO_BALL_SWITCH_MIN_SECONDS: f32 = 0.2;
const DEFAULT_SHADOW_DEFENSE_MAX_BALL_Y: f32 = 1024.0;
const DEFAULT_SHADOW_DEFENSE_MIN_GOAL_SIDE_Y: f32 = 250.0;
const DEFAULT_SHADOW_DEFENSE_MIN_GAP: f32 = 700.0;
const DEFAULT_SHADOW_DEFENSE_MAX_GAP: f32 = 3200.0;
const DEFAULT_SHADOW_DEFENSE_MAX_LATERAL_GAP: f32 = 1300.0;
const DEFAULT_SHADOW_DEFENSE_MIN_RETREAT_SPEED: f32 = 100.0;
const DEFAULT_SHADOW_DEFENSE_MAX_SPEED_DELTA: f32 = 900.0;
const SHADOW_DEFENSE_OWN_GOAL_Y: f32 = 4240.0;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)]
#[serde(rename_all = "snake_case")]
#[ts(export)]
pub enum ActivityState {
Tracked,
Demolished,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)]
#[serde(rename_all = "snake_case")]
#[ts(export)]
pub enum FieldThirdState {
Defensive,
Neutral,
Offensive,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)]
#[serde(rename_all = "snake_case")]
#[ts(export)]
pub enum FieldHalfState {
Defensive,
Offensive,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)]
#[serde(rename_all = "snake_case")]
#[ts(export)]
pub enum BallDepthState {
BehindBall,
LevelWithBall,
AheadOfBall,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)]
#[serde(rename_all = "snake_case")]
#[ts(export)]
pub enum DepthRoleState {
NoTeammates,
MostBack,
MostForward,
Mid,
Other,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)]
#[ts(export)]
pub struct BallProximityState {
pub closest_to_ball_team: bool,
pub closest_to_ball_absolute: bool,
pub farthest_from_ball: bool,
}
impl BallProximityState {
fn any(self) -> bool {
self.closest_to_ball_team || self.closest_to_ball_absolute || self.farthest_from_ball
}
}
pub type PlayerActivityEvent = PlayerStateSpan<ActivityState>;
pub type FieldThirdEvent = PlayerStateSpan<FieldThirdState>;
pub type FieldHalfEvent = PlayerStateSpan<FieldHalfState>;
pub type BallDepthEvent = PlayerStateSpan<BallDepthState>;
pub type DepthRoleEvent = PlayerStateSpan<DepthRoleState>;
pub type BallProximityEvent = PlayerStateSpan<BallProximityState>;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)]
#[serde(rename_all = "snake_case")]
#[ts(export)]
pub enum ShadowDefenseState {
Shadowing,
}
pub type ShadowDefenseEvent = PlayerStateSpan<ShadowDefenseState>;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)]
#[serde(rename_all = "snake_case")]
#[ts(export)]
pub enum PositioningPossessionState {
HasPossession,
NoPossession,
#[default]
Neutral,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize, ts_rs::TS)]
#[ts(export)]
pub struct PositioningSignalSnapshot {
pub sum_distance_to_teammates: f32,
pub sum_distance_to_ball: f32,
pub sum_distance_to_ball_has_possession: f32,
pub time_has_possession: f32,
pub sum_distance_to_ball_no_possession: f32,
pub time_no_possession: f32,
}
impl PositioningSignalSnapshot {
fn accumulate(&mut self, facets: &PlayerFrameFacets, dt: f32) {
if let Some(distance) = facets.distance_to_teammates {
self.sum_distance_to_teammates += distance * dt;
}
let distance = facets.distance_to_ball;
if let Some(distance) = distance {
self.sum_distance_to_ball += distance * dt;
}
match facets.possession_state {
PositioningPossessionState::HasPossession => {
self.time_has_possession += dt;
if let Some(distance) = distance {
self.sum_distance_to_ball_has_possession += distance * dt;
}
}
PositioningPossessionState::NoPossession => {
self.time_no_possession += dt;
if let Some(distance) = distance {
self.sum_distance_to_ball_no_possession += distance * dt;
}
}
PositioningPossessionState::Neutral => {}
}
}
}
#[derive(Debug, Clone)]
pub struct PositioningCalculatorConfig {
pub most_back_forward_threshold_y: f32,
pub level_ball_depth_margin: f32,
pub closest_to_ball_switch_margin: f32,
pub closest_to_ball_switch_min_seconds: f32,
pub shadow_defense_max_ball_y: f32,
pub shadow_defense_min_goal_side_y: f32,
pub shadow_defense_min_gap: f32,
pub shadow_defense_max_gap: f32,
pub shadow_defense_max_lateral_gap: f32,
pub shadow_defense_min_retreat_speed: f32,
pub shadow_defense_max_speed_delta: f32,
}
impl Default for PositioningCalculatorConfig {
fn default() -> Self {
Self {
most_back_forward_threshold_y: DEFAULT_MOST_BACK_FORWARD_THRESHOLD_Y,
level_ball_depth_margin: DEFAULT_LEVEL_BALL_DEPTH_MARGIN,
closest_to_ball_switch_margin: DEFAULT_CLOSEST_TO_BALL_SWITCH_MARGIN,
closest_to_ball_switch_min_seconds: DEFAULT_CLOSEST_TO_BALL_SWITCH_MIN_SECONDS,
shadow_defense_max_ball_y: DEFAULT_SHADOW_DEFENSE_MAX_BALL_Y,
shadow_defense_min_goal_side_y: DEFAULT_SHADOW_DEFENSE_MIN_GOAL_SIDE_Y,
shadow_defense_min_gap: DEFAULT_SHADOW_DEFENSE_MIN_GAP,
shadow_defense_max_gap: DEFAULT_SHADOW_DEFENSE_MAX_GAP,
shadow_defense_max_lateral_gap: DEFAULT_SHADOW_DEFENSE_MAX_LATERAL_GAP,
shadow_defense_min_retreat_speed: DEFAULT_SHADOW_DEFENSE_MIN_RETREAT_SPEED,
shadow_defense_max_speed_delta: DEFAULT_SHADOW_DEFENSE_MAX_SPEED_DELTA,
}
}
}
#[derive(Debug, Clone)]
struct ClosestToBallCandidate {
player_id: PlayerId,
distance: f32,
}
impl ClosestToBallCandidate {
fn from_player(player: &PlayerSample, position: glam::Vec3, ball_position: glam::Vec3) -> Self {
Self {
player_id: player.player_id.clone(),
distance: position.distance(ball_position),
}
}
}
#[derive(Debug, Clone, Default)]
struct ClosestToBallDebouncer {
current_player: Option<PlayerId>,
pending_player: Option<PlayerId>,
pending_seconds: f32,
}
impl ClosestToBallDebouncer {
fn select(
&mut self,
candidates: &[ClosestToBallCandidate],
dt: f32,
switch_margin: f32,
switch_min_seconds: f32,
) -> Option<ClosestToBallCandidate> {
let raw_closest = candidates
.iter()
.min_by(|left, right| left.distance.partial_cmp(&right.distance).unwrap())?;
let Some(current_player) = self.current_player.as_ref() else {
self.current_player = Some(raw_closest.player_id.clone());
self.pending_player = None;
self.pending_seconds = 0.0;
return Some(raw_closest.clone());
};
let Some(current) = candidates
.iter()
.find(|candidate| candidate.player_id == *current_player)
else {
self.current_player = Some(raw_closest.player_id.clone());
self.pending_player = None;
self.pending_seconds = 0.0;
return Some(raw_closest.clone());
};
if raw_closest.player_id == current.player_id {
self.pending_player = None;
self.pending_seconds = 0.0;
return Some(current.clone());
}
if raw_closest.distance + switch_margin >= current.distance {
self.pending_player = None;
self.pending_seconds = 0.0;
return Some(current.clone());
}
if self.pending_player.as_ref() == Some(&raw_closest.player_id) {
self.pending_seconds += dt;
} else {
self.pending_player = Some(raw_closest.player_id.clone());
self.pending_seconds = dt;
}
if self.pending_seconds >= switch_min_seconds.max(0.0) {
self.current_player = Some(raw_closest.player_id.clone());
self.pending_player = None;
self.pending_seconds = 0.0;
Some(raw_closest.clone())
} else {
Some(current.clone())
}
}
fn clear(&mut self) {
self.current_player = None;
self.pending_player = None;
self.pending_seconds = 0.0;
}
}
#[derive(Debug, Clone, Default)]
struct PlayerFrameFacets {
player_position: Option<[f32; 3]>,
is_team_0: bool,
activity: Option<ActivityState>,
field_third_segments: Vec<(FieldThirdState, f32)>,
field_half_segments: Vec<(FieldHalfState, f32)>,
ball_depth_segments: Vec<(BallDepthState, f32)>,
depth_role: Option<DepthRoleState>,
proximity: BallProximityState,
shadow_defense: Option<ShadowDefenseState>,
distance_to_ball: Option<f32>,
distance_to_teammates: Option<f32>,
possession_state: PositioningPossessionState,
}
#[derive(Debug, Clone, Default)]
pub struct PositioningCalculator {
config: PositioningCalculatorConfig,
previous_ball_position: Option<glam::Vec3>,
previous_player_positions: HashMap<PlayerId, glam::Vec3>,
absolute_closest_to_ball: ClosestToBallDebouncer,
team_zero_closest_to_ball: ClosestToBallDebouncer,
team_one_closest_to_ball: ClosestToBallDebouncer,
activity: PlayerSpanTracker<ActivityState>,
field_third: PlayerSpanTracker<FieldThirdState>,
field_half: PlayerSpanTracker<FieldHalfState>,
ball_depth: PlayerSpanTracker<BallDepthState>,
depth_role: PlayerSpanTracker<DepthRoleState>,
ball_proximity: PlayerSpanTracker<BallProximityState>,
shadow_defense: PlayerSpanTracker<ShadowDefenseState>,
signal: HashMap<PlayerId, PositioningSignalSnapshot>,
}
impl PositioningCalculator {
pub fn new() -> Self {
Self::default()
}
pub fn with_config(config: PositioningCalculatorConfig) -> Self {
Self {
config,
..Self::default()
}
}
pub fn config(&self) -> &PositioningCalculatorConfig {
&self.config
}
pub fn activity_events(&self) -> Vec<PlayerActivityEvent> {
self.activity.projected_events()
}
pub fn field_third_events(&self) -> Vec<FieldThirdEvent> {
self.field_third.projected_events()
}
pub fn field_half_events(&self) -> Vec<FieldHalfEvent> {
self.field_half.projected_events()
}
pub fn ball_depth_events(&self) -> Vec<BallDepthEvent> {
self.ball_depth.projected_events()
}
pub fn depth_role_events(&self) -> Vec<DepthRoleEvent> {
self.depth_role.projected_events()
}
pub fn ball_proximity_events(&self) -> Vec<BallProximityEvent> {
self.ball_proximity.projected_events()
}
pub fn shadow_defense_events(&self) -> Vec<ShadowDefenseEvent> {
self.shadow_defense.projected_events()
}
pub fn new_event_players(&self) -> Vec<PlayerId> {
let mut players: Vec<PlayerId> = self
.activity
.new_events()
.iter()
.map(|span| span.player.clone())
.chain(
self.field_third
.new_events()
.iter()
.map(|span| span.player.clone()),
)
.chain(
self.field_half
.new_events()
.iter()
.map(|span| span.player.clone()),
)
.chain(
self.ball_depth
.new_events()
.iter()
.map(|span| span.player.clone()),
)
.chain(
self.depth_role
.new_events()
.iter()
.map(|span| span.player.clone()),
)
.chain(
self.ball_proximity
.new_events()
.iter()
.map(|span| span.player.clone()),
)
.chain(
self.shadow_defense
.new_events()
.iter()
.map(|span| span.player.clone()),
)
.collect();
players.dedup();
players
}
pub fn flush_pending_events(&mut self) {
self.activity.close_all();
self.field_third.close_all();
self.field_half.close_all();
self.ball_depth.close_all();
self.depth_role.close_all();
self.ball_proximity.close_all();
self.shadow_defense.close_all();
}
fn close_all_spans(&mut self) {
self.flush_pending_events();
}
fn store_previous_positions(
&mut self,
ball_position: Option<glam::Vec3>,
players: &PlayerFrameState,
) {
if let Some(ball_position) = ball_position {
self.previous_ball_position = Some(ball_position);
}
for player in &players.players {
if let Some(position) = player.position() {
self.previous_player_positions
.insert(player.player_id.clone(), position);
}
}
}
#[allow(clippy::too_many_arguments)]
fn shadow_defense_state(
&self,
frame: &FrameInfo,
defender_position: glam::Vec3,
defender_previous_position: glam::Vec3,
ball_position: glam::Vec3,
previous_ball_position: glam::Vec3,
attacker_position: glam::Vec3,
attacker_previous_position: glam::Vec3,
defender_is_team_0: bool,
) -> Option<ShadowDefenseState> {
if frame.dt <= 0.0 {
return None;
}
let defender = normalized_xy(defender_is_team_0, defender_position);
let previous_defender = normalized_xy(defender_is_team_0, defender_previous_position);
let ball = normalized_xy(defender_is_team_0, ball_position);
let previous_ball = normalized_xy(defender_is_team_0, previous_ball_position);
let attacker = normalized_xy(defender_is_team_0, attacker_position);
let previous_attacker = normalized_xy(defender_is_team_0, attacker_previous_position);
if ball.y > self.config.shadow_defense_max_ball_y {
return None;
}
let deepest_threat_y = ball.y.min(attacker.y);
if defender.y + self.config.shadow_defense_min_goal_side_y >= deepest_threat_y {
return None;
}
let threat = if defender.distance(ball) <= defender.distance(attacker) {
ball
} else {
attacker
};
let gap = defender.distance(threat);
if !(self.config.shadow_defense_min_gap..=self.config.shadow_defense_max_gap).contains(&gap)
{
return None;
}
let own_goal = glam::vec2(0.0, -SHADOW_DEFENSE_OWN_GOAL_Y);
if distance_to_segment_2d(defender, ball, own_goal)
> self.config.shadow_defense_max_lateral_gap
{
return None;
}
let defender_velocity_y = (defender.y - previous_defender.y) / frame.dt;
let ball_velocity_y = (ball.y - previous_ball.y) / frame.dt;
let attacker_velocity_y = (attacker.y - previous_attacker.y) / frame.dt;
let threat_velocity_y = ball_velocity_y.min(attacker_velocity_y);
if defender_velocity_y > -self.config.shadow_defense_min_retreat_speed {
return None;
}
if threat_velocity_y > -self.config.shadow_defense_min_retreat_speed {
return None;
}
if (defender_velocity_y - threat_velocity_y).abs()
> self.config.shadow_defense_max_speed_delta
{
return None;
}
Some(ShadowDefenseState::Shadowing)
}
#[allow(clippy::too_many_arguments)]
fn process_sample(
&mut self,
frame: &FrameInfo,
gameplay: &GameplayState,
ball: &BallFrameState,
players: &PlayerFrameState,
events: &FrameEventsState,
live_play_state: &LivePlayState,
possession_player_before_sample: Option<&PlayerId>,
) -> SubtrActorResult<()> {
let live_play = live_play_state.is_live_play;
if frame.dt == 0.0 {
self.store_previous_positions(ball.sample().map(|ball| ball.position()), players);
return Ok(());
}
let Some(ball) = ball.sample() else {
self.close_all_spans();
return Ok(());
};
let ball_position = ball.position();
if !live_play {
self.absolute_closest_to_ball.clear();
self.team_zero_closest_to_ball.clear();
self.team_one_closest_to_ball.clear();
self.close_all_spans();
self.store_previous_positions(Some(ball_position), players);
return Ok(());
}
let mut facets: HashMap<PlayerId, PlayerFrameFacets> = HashMap::new();
let demoed_players: HashSet<_> = events
.active_demos
.iter()
.map(|demo| demo.victim.clone())
.collect();
for player in &players.players {
let is_demoed = demoed_players.contains(&player.player_id);
let entry = facets.entry(player.player_id.clone()).or_default();
entry.is_team_0 = player.is_team_0;
entry.player_position = player.position().map(|position| position.to_array());
if is_demoed {
entry.activity = Some(ActivityState::Demolished);
continue;
}
let Some(position) = player.position() else {
continue;
};
let previous_position = self
.previous_player_positions
.get(&player.player_id)
.copied()
.unwrap_or(position);
let previous_ball_position = self.previous_ball_position.unwrap_or(ball_position);
let normalized_position_y = normalized_y(player.is_team_0, position);
let normalized_previous_position_y = normalized_y(player.is_team_0, previous_position);
let normalized_ball_y = normalized_y(player.is_team_0, ball_position);
let normalized_previous_ball_y = normalized_y(player.is_team_0, previous_ball_position);
entry.activity = Some(ActivityState::Tracked);
entry.distance_to_ball = Some(position.distance(ball_position));
if possession_player_before_sample == Some(&player.player_id) {
entry.possession_state = PositioningPossessionState::HasPossession;
} else if possession_player_before_sample.is_some() {
entry.possession_state = PositioningPossessionState::NoPossession;
}
entry.field_third_segments = scalar_state_segments(
normalized_previous_position_y,
normalized_position_y,
&[-FIELD_ZONE_BOUNDARY_Y, FIELD_ZONE_BOUNDARY_Y],
&[
FieldThirdState::Defensive,
FieldThirdState::Neutral,
FieldThirdState::Offensive,
],
);
entry.field_half_segments = scalar_state_segments(
normalized_previous_position_y,
normalized_position_y,
&[0.0],
&[FieldHalfState::Defensive, FieldHalfState::Offensive],
);
entry.ball_depth_segments = scalar_state_segments(
normalized_previous_position_y - normalized_previous_ball_y,
normalized_position_y - normalized_ball_y,
&[
-self.config.level_ball_depth_margin,
self.config.level_ball_depth_margin,
],
&[
BallDepthState::BehindBall,
BallDepthState::LevelWithBall,
BallDepthState::AheadOfBall,
],
);
if let Some(attacker_id) = possession_player_before_sample
&& let Some(attacker) = players.player(attacker_id)
&& attacker.is_team_0 != player.is_team_0
&& let Some(attacker_position) = attacker.position()
{
let attacker_previous_position = self
.previous_player_positions
.get(&attacker.player_id)
.copied()
.unwrap_or(attacker_position);
entry.shadow_defense = self.shadow_defense_state(
frame,
position,
previous_position,
ball_position,
previous_ball_position,
attacker_position,
attacker_previous_position,
player.is_team_0,
);
}
}
let positioned_players: Vec<_> = players
.players
.iter()
.filter(|player| !demoed_players.contains(&player.player_id))
.filter_map(|player| {
player.position().map(|position| {
ClosestToBallCandidate::from_player(player, position, ball_position)
})
})
.collect();
if let Some(closest_player) = self.absolute_closest_to_ball.select(
&positioned_players,
frame.dt,
self.config.closest_to_ball_switch_margin,
self.config.closest_to_ball_switch_min_seconds,
) {
if let Some(entry) = facets.get_mut(&closest_player.player_id) {
entry.proximity.closest_to_ball_absolute = true;
}
} else {
self.absolute_closest_to_ball.clear();
}
for is_team_0 in [true, false] {
let team_present_player_count = players
.players
.iter()
.filter(|player| player.is_team_0 == is_team_0)
.count();
let team_roster_count = gameplay
.current_in_game_team_player_count(is_team_0)
.max(team_present_player_count);
let team_players: Vec<_> = players
.players
.iter()
.filter(|player| player.is_team_0 == is_team_0)
.filter(|player| !demoed_players.contains(&player.player_id))
.filter_map(|player| player.position().map(|position| (player, position)))
.collect();
if team_players.is_empty() {
if is_team_0 {
self.team_zero_closest_to_ball.clear();
} else {
self.team_one_closest_to_ball.clear();
}
continue;
}
for (player, position) in &team_players {
let teammate_distance_sum: f32 = team_players
.iter()
.filter(|(other_player, _)| other_player.player_id != player.player_id)
.map(|(_, other_position)| position.distance(*other_position))
.sum();
let teammate_count = team_players.len().saturating_sub(1);
if teammate_count > 0 {
if let Some(entry) = facets.get_mut(&player.player_id) {
entry.distance_to_teammates =
Some(teammate_distance_sum / teammate_count as f32);
}
}
}
if team_roster_count < 2
|| team_present_player_count < team_roster_count
|| team_players.len() < 2
{
for (player, _) in &team_players {
if let Some(entry) = facets.get_mut(&player.player_id) {
entry.depth_role = Some(DepthRoleState::NoTeammates);
}
}
} else {
let mut sorted_team: Vec<_> = team_players
.iter()
.map(|(info, pos)| (info.player_id.clone(), normalized_y(is_team_0, *pos)))
.collect();
sorted_team.sort_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap());
let team_spread = sorted_team.last().map(|(_, y)| *y).unwrap_or(0.0)
- sorted_team.first().map(|(_, y)| *y).unwrap_or(0.0);
if team_spread <= self.config.most_back_forward_threshold_y {
for (player_id, _) in &sorted_team {
if let Some(entry) = facets.get_mut(player_id) {
entry.depth_role = Some(DepthRoleState::Other);
}
}
} else {
let min_y = sorted_team.first().map(|(_, y)| *y).unwrap_or(0.0);
let max_y = sorted_team.last().map(|(_, y)| *y).unwrap_or(0.0);
let can_assign_mid_role = sorted_team.len() == 3;
for (player_id, y) in &sorted_team {
let near_back = (*y - min_y) <= self.config.most_back_forward_threshold_y;
let near_front = (max_y - *y) <= self.config.most_back_forward_threshold_y;
let role = if near_back && !near_front {
DepthRoleState::MostBack
} else if near_front && !near_back {
DepthRoleState::MostForward
} else if can_assign_mid_role {
DepthRoleState::Mid
} else {
DepthRoleState::Other
};
if let Some(entry) = facets.get_mut(player_id) {
entry.depth_role = Some(role);
}
}
}
}
let team_candidates: Vec<_> = team_players
.iter()
.map(|(player, position)| {
ClosestToBallCandidate::from_player(player, *position, ball_position)
})
.collect();
let team_debouncer = if is_team_0 {
&mut self.team_zero_closest_to_ball
} else {
&mut self.team_one_closest_to_ball
};
if let Some(closest_player) = team_debouncer.select(
&team_candidates,
frame.dt,
self.config.closest_to_ball_switch_margin,
self.config.closest_to_ball_switch_min_seconds,
) {
if let Some(entry) = facets.get_mut(&closest_player.player_id) {
entry.proximity.closest_to_ball_team = true;
}
}
if let Some((farthest_player, _)) = team_players.iter().max_by(|(_, a), (_, b)| {
a.distance(ball_position)
.partial_cmp(&b.distance(ball_position))
.unwrap()
}) {
if let Some(entry) = facets.get_mut(&farthest_player.player_id) {
entry.proximity.farthest_from_ball = true;
}
}
}
self.record_frame_facets(frame, &facets);
self.store_previous_positions(Some(ball_position), players);
Ok(())
}
fn record_frame_facets(
&mut self,
frame: &FrameInfo,
facets: &HashMap<PlayerId, PlayerFrameFacets>,
) {
let mut players: Vec<_> = facets.iter().collect();
players.sort_by_key(|(player, _)| format!("{player:?}"));
let frame_start = frame.time - frame.dt;
for (player, entry) in players {
let tracked = entry.activity == Some(ActivityState::Tracked);
if tracked {
self.signal
.entry(player.clone())
.or_default()
.accumulate(entry, frame.dt);
}
match entry.activity {
Some(state) => self.activity.record(
frame.frame_number,
frame_start,
frame.time,
frame.dt,
player,
entry.player_position,
entry.is_team_0,
state,
),
None => self.activity.close(player),
}
record_segments(
&mut self.field_third,
frame,
player,
entry,
&entry.field_third_segments,
);
record_segments(
&mut self.field_half,
frame,
player,
entry,
&entry.field_half_segments,
);
record_segments(
&mut self.ball_depth,
frame,
player,
entry,
&entry.ball_depth_segments,
);
match entry.depth_role {
Some(role) if tracked => self.depth_role.record(
frame.frame_number,
frame_start,
frame.time,
frame.dt,
player,
entry.player_position,
entry.is_team_0,
role,
),
_ => self.depth_role.close(player),
}
if tracked && entry.proximity.any() {
self.ball_proximity.record(
frame.frame_number,
frame_start,
frame.time,
frame.dt,
player,
entry.player_position,
entry.is_team_0,
entry.proximity,
);
} else {
self.ball_proximity.close(player);
}
match entry.shadow_defense {
Some(state) if tracked => self.shadow_defense.record(
frame.frame_number,
frame_start,
frame.time,
frame.dt,
player,
entry.player_position,
entry.is_team_0,
state,
),
_ => self.shadow_defense.close(player),
}
}
}
#[allow(clippy::too_many_arguments)]
pub fn update(
&mut self,
frame: &FrameInfo,
gameplay: &GameplayState,
ball: &BallFrameState,
players: &PlayerFrameState,
events: &FrameEventsState,
live_play_state: &LivePlayState,
possession_player_before_sample: Option<&PlayerId>,
) -> SubtrActorResult<()> {
self.activity.begin_update();
self.field_third.begin_update();
self.field_half.begin_update();
self.ball_depth.begin_update();
self.depth_role.begin_update();
self.ball_proximity.begin_update();
self.shadow_defense.begin_update();
self.process_sample(
frame,
gameplay,
ball,
players,
events,
live_play_state,
possession_player_before_sample,
)
}
pub fn player_signal(&self, player: &PlayerId) -> PositioningSignalSnapshot {
self.signal.get(player).copied().unwrap_or_default()
}
pub fn signals(&self) -> &HashMap<PlayerId, PositioningSignalSnapshot> {
&self.signal
}
}
fn normalized_xy(is_team_0: bool, position: glam::Vec3) -> glam::Vec2 {
glam::vec2(position.x, normalized_y(is_team_0, position))
}
fn distance_to_segment_2d(point: glam::Vec2, start: glam::Vec2, end: glam::Vec2) -> f32 {
let segment = end - start;
let length_squared = segment.length_squared();
if length_squared <= f32::EPSILON {
return point.distance(start);
}
let t = ((point - start).dot(segment) / length_squared).clamp(0.0, 1.0);
point.distance(start + segment * t)
}
fn record_segments<S: Copy + PartialEq>(
tracker: &mut PlayerSpanTracker<S>,
frame: &FrameInfo,
player: &PlayerId,
entry: &PlayerFrameFacets,
segments: &[(S, f32)],
) {
if segments.is_empty() {
tracker.close(player);
return;
}
let frame_start = frame.time - frame.dt;
let mut cumulative = 0.0f32;
for (state, fraction) in segments {
let start_time = frame_start + cumulative * frame.dt;
cumulative += fraction;
let end_time = if cumulative >= 1.0 {
frame.time
} else {
frame_start + cumulative * frame.dt
};
tracker.record(
frame.frame_number,
start_time,
end_time,
fraction * frame.dt,
player,
entry.player_position,
entry.is_team_0,
*state,
);
}
}
#[cfg(test)]
#[path = "positioning_tests.rs"]
mod tests;