1use super::*;
2
3const DEFAULT_LEVEL_BALL_DEPTH_MARGIN: f32 = 150.0;
4const DEFAULT_CLOSEST_TO_BALL_SWITCH_MARGIN: f32 = 100.0;
5const DEFAULT_CLOSEST_TO_BALL_SWITCH_MIN_SECONDS: f32 = 0.2;
6const DEFAULT_SHADOW_DEFENSE_MAX_BALL_Y: f32 = 1024.0;
7const DEFAULT_SHADOW_DEFENSE_MIN_GOAL_SIDE_Y: f32 = 250.0;
8const DEFAULT_SHADOW_DEFENSE_MIN_GAP: f32 = 700.0;
9const DEFAULT_SHADOW_DEFENSE_MAX_GAP: f32 = 3200.0;
10const DEFAULT_SHADOW_DEFENSE_MAX_LATERAL_GAP: f32 = 1300.0;
11const DEFAULT_SHADOW_DEFENSE_MIN_RETREAT_SPEED: f32 = 100.0;
12const DEFAULT_SHADOW_DEFENSE_MAX_SPEED_DELTA: f32 = 900.0;
13const SHADOW_DEFENSE_OWN_GOAL_Y: f32 = 4240.0;
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)]
18#[serde(rename_all = "snake_case")]
19#[ts(export)]
20pub enum ActivityState {
21 Tracked,
22 Demolished,
23}
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)]
28#[serde(rename_all = "snake_case")]
29#[ts(export)]
30pub enum FieldThirdState {
31 Defensive,
32 Neutral,
33 Offensive,
34}
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)]
39#[serde(rename_all = "snake_case")]
40#[ts(export)]
41pub enum FieldHalfState {
42 Defensive,
43 Offensive,
44}
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)]
48#[serde(rename_all = "snake_case")]
49#[ts(export)]
50pub enum BallDepthState {
51 BehindBall,
52 LevelWithBall,
53 AheadOfBall,
54}
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)]
59#[serde(rename_all = "snake_case")]
60#[ts(export)]
61pub enum DepthRoleState {
62 NoTeammates,
63 MostBack,
64 MostForward,
65 Mid,
66 Other,
67}
68
69#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)]
74#[ts(export)]
75pub struct BallProximityState {
76 pub closest_to_ball_team: bool,
77 pub closest_to_ball_absolute: bool,
78 pub farthest_from_ball: bool,
79}
80
81impl BallProximityState {
82 fn any(self) -> bool {
83 self.closest_to_ball_team || self.closest_to_ball_absolute || self.farthest_from_ball
84 }
85}
86
87pub type PlayerActivityEvent = PlayerStateSpan<ActivityState>;
88pub type FieldThirdEvent = PlayerStateSpan<FieldThirdState>;
89pub type FieldHalfEvent = PlayerStateSpan<FieldHalfState>;
90pub type BallDepthEvent = PlayerStateSpan<BallDepthState>;
91pub type DepthRoleEvent = PlayerStateSpan<DepthRoleState>;
92pub type BallProximityEvent = PlayerStateSpan<BallProximityState>;
93
94#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)]
98#[serde(rename_all = "snake_case")]
99#[ts(export)]
100pub enum ShadowDefenseState {
101 Shadowing,
102}
103
104pub type ShadowDefenseEvent = PlayerStateSpan<ShadowDefenseState>;
105
106#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)]
108#[serde(rename_all = "snake_case")]
109#[ts(export)]
110pub enum PositioningPossessionState {
111 HasPossession,
112 NoPossession,
113 #[default]
114 Neutral,
115}
116
117#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize, ts_rs::TS)]
126#[ts(export)]
127pub struct PositioningSignalSnapshot {
128 pub sum_distance_to_teammates: f32,
129 pub sum_distance_to_ball: f32,
130 pub sum_distance_to_ball_has_possession: f32,
131 pub time_has_possession: f32,
132 pub sum_distance_to_ball_no_possession: f32,
133 pub time_no_possession: f32,
134}
135
136impl PositioningSignalSnapshot {
137 fn accumulate(&mut self, facets: &PlayerFrameFacets, dt: f32) {
138 if let Some(distance) = facets.distance_to_teammates {
139 self.sum_distance_to_teammates += distance * dt;
140 }
141 let distance = facets.distance_to_ball;
142 if let Some(distance) = distance {
143 self.sum_distance_to_ball += distance * dt;
144 }
145 match facets.possession_state {
146 PositioningPossessionState::HasPossession => {
147 self.time_has_possession += dt;
148 if let Some(distance) = distance {
149 self.sum_distance_to_ball_has_possession += distance * dt;
150 }
151 }
152 PositioningPossessionState::NoPossession => {
153 self.time_no_possession += dt;
154 if let Some(distance) = distance {
155 self.sum_distance_to_ball_no_possession += distance * dt;
156 }
157 }
158 PositioningPossessionState::Neutral => {}
159 }
160 }
161}
162
163#[derive(Debug, Clone)]
165pub struct PositioningCalculatorConfig {
166 pub most_back_forward_threshold_y: f32,
167 pub level_ball_depth_margin: f32,
168 pub closest_to_ball_switch_margin: f32,
169 pub closest_to_ball_switch_min_seconds: f32,
170 pub shadow_defense_max_ball_y: f32,
171 pub shadow_defense_min_goal_side_y: f32,
172 pub shadow_defense_min_gap: f32,
173 pub shadow_defense_max_gap: f32,
174 pub shadow_defense_max_lateral_gap: f32,
175 pub shadow_defense_min_retreat_speed: f32,
176 pub shadow_defense_max_speed_delta: f32,
177}
178
179impl Default for PositioningCalculatorConfig {
180 fn default() -> Self {
181 Self {
182 most_back_forward_threshold_y: DEFAULT_MOST_BACK_FORWARD_THRESHOLD_Y,
183 level_ball_depth_margin: DEFAULT_LEVEL_BALL_DEPTH_MARGIN,
184 closest_to_ball_switch_margin: DEFAULT_CLOSEST_TO_BALL_SWITCH_MARGIN,
185 closest_to_ball_switch_min_seconds: DEFAULT_CLOSEST_TO_BALL_SWITCH_MIN_SECONDS,
186 shadow_defense_max_ball_y: DEFAULT_SHADOW_DEFENSE_MAX_BALL_Y,
187 shadow_defense_min_goal_side_y: DEFAULT_SHADOW_DEFENSE_MIN_GOAL_SIDE_Y,
188 shadow_defense_min_gap: DEFAULT_SHADOW_DEFENSE_MIN_GAP,
189 shadow_defense_max_gap: DEFAULT_SHADOW_DEFENSE_MAX_GAP,
190 shadow_defense_max_lateral_gap: DEFAULT_SHADOW_DEFENSE_MAX_LATERAL_GAP,
191 shadow_defense_min_retreat_speed: DEFAULT_SHADOW_DEFENSE_MIN_RETREAT_SPEED,
192 shadow_defense_max_speed_delta: DEFAULT_SHADOW_DEFENSE_MAX_SPEED_DELTA,
193 }
194 }
195}
196
197#[derive(Debug, Clone)]
198struct ClosestToBallCandidate {
199 player_id: PlayerId,
200 distance: f32,
201}
202
203impl ClosestToBallCandidate {
204 fn from_player(player: &PlayerSample, position: glam::Vec3, ball_position: glam::Vec3) -> Self {
205 Self {
206 player_id: player.player_id.clone(),
207 distance: position.distance(ball_position),
208 }
209 }
210}
211
212#[derive(Debug, Clone, Default)]
213struct ClosestToBallDebouncer {
214 current_player: Option<PlayerId>,
215 pending_player: Option<PlayerId>,
216 pending_seconds: f32,
217}
218
219impl ClosestToBallDebouncer {
220 fn select(
221 &mut self,
222 candidates: &[ClosestToBallCandidate],
223 dt: f32,
224 switch_margin: f32,
225 switch_min_seconds: f32,
226 ) -> Option<ClosestToBallCandidate> {
227 let raw_closest = candidates
228 .iter()
229 .min_by(|left, right| left.distance.partial_cmp(&right.distance).unwrap())?;
230 let Some(current_player) = self.current_player.as_ref() else {
231 self.current_player = Some(raw_closest.player_id.clone());
232 self.pending_player = None;
233 self.pending_seconds = 0.0;
234 return Some(raw_closest.clone());
235 };
236 let Some(current) = candidates
237 .iter()
238 .find(|candidate| candidate.player_id == *current_player)
239 else {
240 self.current_player = Some(raw_closest.player_id.clone());
241 self.pending_player = None;
242 self.pending_seconds = 0.0;
243 return Some(raw_closest.clone());
244 };
245 if raw_closest.player_id == current.player_id {
246 self.pending_player = None;
247 self.pending_seconds = 0.0;
248 return Some(current.clone());
249 }
250 if raw_closest.distance + switch_margin >= current.distance {
251 self.pending_player = None;
252 self.pending_seconds = 0.0;
253 return Some(current.clone());
254 }
255 if self.pending_player.as_ref() == Some(&raw_closest.player_id) {
256 self.pending_seconds += dt;
257 } else {
258 self.pending_player = Some(raw_closest.player_id.clone());
259 self.pending_seconds = dt;
260 }
261 if self.pending_seconds >= switch_min_seconds.max(0.0) {
262 self.current_player = Some(raw_closest.player_id.clone());
263 self.pending_player = None;
264 self.pending_seconds = 0.0;
265 Some(raw_closest.clone())
266 } else {
267 Some(current.clone())
268 }
269 }
270
271 fn clear(&mut self) {
272 self.current_player = None;
273 self.pending_player = None;
274 self.pending_seconds = 0.0;
275 }
276}
277
278#[derive(Debug, Clone, Default)]
281struct PlayerFrameFacets {
282 player_position: Option<[f32; 3]>,
283 is_team_0: bool,
284 activity: Option<ActivityState>,
285 field_third_segments: Vec<(FieldThirdState, f32)>,
286 field_half_segments: Vec<(FieldHalfState, f32)>,
287 ball_depth_segments: Vec<(BallDepthState, f32)>,
288 depth_role: Option<DepthRoleState>,
289 proximity: BallProximityState,
290 shadow_defense: Option<ShadowDefenseState>,
291 distance_to_ball: Option<f32>,
292 distance_to_teammates: Option<f32>,
293 possession_state: PositioningPossessionState,
294}
295
296#[derive(Debug, Clone, Default)]
298pub struct PositioningCalculator {
299 config: PositioningCalculatorConfig,
300 previous_ball_position: Option<glam::Vec3>,
301 previous_player_positions: HashMap<PlayerId, glam::Vec3>,
302 absolute_closest_to_ball: ClosestToBallDebouncer,
303 team_zero_closest_to_ball: ClosestToBallDebouncer,
304 team_one_closest_to_ball: ClosestToBallDebouncer,
305 activity: PlayerSpanTracker<ActivityState>,
306 field_third: PlayerSpanTracker<FieldThirdState>,
307 field_half: PlayerSpanTracker<FieldHalfState>,
308 ball_depth: PlayerSpanTracker<BallDepthState>,
309 depth_role: PlayerSpanTracker<DepthRoleState>,
310 ball_proximity: PlayerSpanTracker<BallProximityState>,
311 shadow_defense: PlayerSpanTracker<ShadowDefenseState>,
312 signal: HashMap<PlayerId, PositioningSignalSnapshot>,
313}
314
315impl PositioningCalculator {
316 pub fn new() -> Self {
317 Self::default()
318 }
319
320 pub fn with_config(config: PositioningCalculatorConfig) -> Self {
321 Self {
322 config,
323 ..Self::default()
324 }
325 }
326
327 pub fn config(&self) -> &PositioningCalculatorConfig {
328 &self.config
329 }
330
331 pub fn activity_events(&self) -> Vec<PlayerActivityEvent> {
332 self.activity.projected_events()
333 }
334
335 pub fn field_third_events(&self) -> Vec<FieldThirdEvent> {
336 self.field_third.projected_events()
337 }
338
339 pub fn field_half_events(&self) -> Vec<FieldHalfEvent> {
340 self.field_half.projected_events()
341 }
342
343 pub fn ball_depth_events(&self) -> Vec<BallDepthEvent> {
344 self.ball_depth.projected_events()
345 }
346
347 pub fn depth_role_events(&self) -> Vec<DepthRoleEvent> {
348 self.depth_role.projected_events()
349 }
350
351 pub fn ball_proximity_events(&self) -> Vec<BallProximityEvent> {
352 self.ball_proximity.projected_events()
353 }
354
355 pub fn shadow_defense_events(&self) -> Vec<ShadowDefenseEvent> {
356 self.shadow_defense.projected_events()
357 }
358
359 pub fn new_event_players(&self) -> Vec<PlayerId> {
361 let mut players: Vec<PlayerId> = self
362 .activity
363 .new_events()
364 .iter()
365 .map(|span| span.player.clone())
366 .chain(
367 self.field_third
368 .new_events()
369 .iter()
370 .map(|span| span.player.clone()),
371 )
372 .chain(
373 self.field_half
374 .new_events()
375 .iter()
376 .map(|span| span.player.clone()),
377 )
378 .chain(
379 self.ball_depth
380 .new_events()
381 .iter()
382 .map(|span| span.player.clone()),
383 )
384 .chain(
385 self.depth_role
386 .new_events()
387 .iter()
388 .map(|span| span.player.clone()),
389 )
390 .chain(
391 self.ball_proximity
392 .new_events()
393 .iter()
394 .map(|span| span.player.clone()),
395 )
396 .chain(
397 self.shadow_defense
398 .new_events()
399 .iter()
400 .map(|span| span.player.clone()),
401 )
402 .collect();
403 players.dedup();
404 players
405 }
406
407 pub fn flush_pending_events(&mut self) {
409 self.activity.close_all();
410 self.field_third.close_all();
411 self.field_half.close_all();
412 self.ball_depth.close_all();
413 self.depth_role.close_all();
414 self.ball_proximity.close_all();
415 self.shadow_defense.close_all();
416 }
417
418 fn close_all_spans(&mut self) {
419 self.flush_pending_events();
420 }
421
422 fn store_previous_positions(
423 &mut self,
424 ball_position: Option<glam::Vec3>,
425 players: &PlayerFrameState,
426 ) {
427 if let Some(ball_position) = ball_position {
428 self.previous_ball_position = Some(ball_position);
429 }
430 for player in &players.players {
431 if let Some(position) = player.position() {
432 self.previous_player_positions
433 .insert(player.player_id.clone(), position);
434 }
435 }
436 }
437
438 #[allow(clippy::too_many_arguments)]
439 fn shadow_defense_state(
440 &self,
441 frame: &FrameInfo,
442 defender_position: glam::Vec3,
443 defender_previous_position: glam::Vec3,
444 ball_position: glam::Vec3,
445 previous_ball_position: glam::Vec3,
446 attacker_position: glam::Vec3,
447 attacker_previous_position: glam::Vec3,
448 defender_is_team_0: bool,
449 ) -> Option<ShadowDefenseState> {
450 if frame.dt <= 0.0 {
451 return None;
452 }
453
454 let defender = normalized_xy(defender_is_team_0, defender_position);
455 let previous_defender = normalized_xy(defender_is_team_0, defender_previous_position);
456 let ball = normalized_xy(defender_is_team_0, ball_position);
457 let previous_ball = normalized_xy(defender_is_team_0, previous_ball_position);
458 let attacker = normalized_xy(defender_is_team_0, attacker_position);
459 let previous_attacker = normalized_xy(defender_is_team_0, attacker_previous_position);
460
461 if ball.y > self.config.shadow_defense_max_ball_y {
462 return None;
463 }
464
465 let deepest_threat_y = ball.y.min(attacker.y);
466 if defender.y + self.config.shadow_defense_min_goal_side_y >= deepest_threat_y {
467 return None;
468 }
469
470 let threat = if defender.distance(ball) <= defender.distance(attacker) {
471 ball
472 } else {
473 attacker
474 };
475 let gap = defender.distance(threat);
476 if !(self.config.shadow_defense_min_gap..=self.config.shadow_defense_max_gap).contains(&gap)
477 {
478 return None;
479 }
480
481 let own_goal = glam::vec2(0.0, -SHADOW_DEFENSE_OWN_GOAL_Y);
482 if distance_to_segment_2d(defender, ball, own_goal)
483 > self.config.shadow_defense_max_lateral_gap
484 {
485 return None;
486 }
487
488 let defender_velocity_y = (defender.y - previous_defender.y) / frame.dt;
489 let ball_velocity_y = (ball.y - previous_ball.y) / frame.dt;
490 let attacker_velocity_y = (attacker.y - previous_attacker.y) / frame.dt;
491 let threat_velocity_y = ball_velocity_y.min(attacker_velocity_y);
492 if defender_velocity_y > -self.config.shadow_defense_min_retreat_speed {
493 return None;
494 }
495 if threat_velocity_y > -self.config.shadow_defense_min_retreat_speed {
496 return None;
497 }
498 if (defender_velocity_y - threat_velocity_y).abs()
499 > self.config.shadow_defense_max_speed_delta
500 {
501 return None;
502 }
503
504 Some(ShadowDefenseState::Shadowing)
505 }
506
507 #[allow(clippy::too_many_arguments)]
508 fn process_sample(
509 &mut self,
510 frame: &FrameInfo,
511 gameplay: &GameplayState,
512 ball: &BallFrameState,
513 players: &PlayerFrameState,
514 events: &FrameEventsState,
515 live_play_state: &LivePlayState,
516 possession_player_before_sample: Option<&PlayerId>,
517 ) -> SubtrActorResult<()> {
518 let live_play = live_play_state.is_live_play;
519 if frame.dt == 0.0 {
520 self.store_previous_positions(ball.sample().map(|ball| ball.position()), players);
521 return Ok(());
522 }
523
524 let Some(ball) = ball.sample() else {
525 self.close_all_spans();
526 return Ok(());
527 };
528 let ball_position = ball.position();
529
530 if !live_play {
531 self.absolute_closest_to_ball.clear();
532 self.team_zero_closest_to_ball.clear();
533 self.team_one_closest_to_ball.clear();
534 self.close_all_spans();
535 self.store_previous_positions(Some(ball_position), players);
536 return Ok(());
537 }
538
539 let mut facets: HashMap<PlayerId, PlayerFrameFacets> = HashMap::new();
540 let demoed_players: HashSet<_> = events
541 .active_demos
542 .iter()
543 .map(|demo| demo.victim.clone())
544 .collect();
545
546 for player in &players.players {
547 let is_demoed = demoed_players.contains(&player.player_id);
548 let entry = facets.entry(player.player_id.clone()).or_default();
549 entry.is_team_0 = player.is_team_0;
550 entry.player_position = player.position().map(|position| position.to_array());
551
552 if is_demoed {
553 entry.activity = Some(ActivityState::Demolished);
554 continue;
555 }
556
557 let Some(position) = player.position() else {
558 continue;
559 };
560 let previous_position = self
561 .previous_player_positions
562 .get(&player.player_id)
563 .copied()
564 .unwrap_or(position);
565 let previous_ball_position = self.previous_ball_position.unwrap_or(ball_position);
566 let normalized_position_y = normalized_y(player.is_team_0, position);
567 let normalized_previous_position_y = normalized_y(player.is_team_0, previous_position);
568 let normalized_ball_y = normalized_y(player.is_team_0, ball_position);
569 let normalized_previous_ball_y = normalized_y(player.is_team_0, previous_ball_position);
570
571 entry.activity = Some(ActivityState::Tracked);
572 entry.distance_to_ball = Some(position.distance(ball_position));
573
574 if possession_player_before_sample == Some(&player.player_id) {
575 entry.possession_state = PositioningPossessionState::HasPossession;
576 } else if possession_player_before_sample.is_some() {
577 entry.possession_state = PositioningPossessionState::NoPossession;
578 }
579
580 entry.field_third_segments = scalar_state_segments(
581 normalized_previous_position_y,
582 normalized_position_y,
583 &[-FIELD_ZONE_BOUNDARY_Y, FIELD_ZONE_BOUNDARY_Y],
584 &[
585 FieldThirdState::Defensive,
586 FieldThirdState::Neutral,
587 FieldThirdState::Offensive,
588 ],
589 );
590 entry.field_half_segments = scalar_state_segments(
591 normalized_previous_position_y,
592 normalized_position_y,
593 &[0.0],
594 &[FieldHalfState::Defensive, FieldHalfState::Offensive],
595 );
596 entry.ball_depth_segments = scalar_state_segments(
597 normalized_previous_position_y - normalized_previous_ball_y,
598 normalized_position_y - normalized_ball_y,
599 &[
600 -self.config.level_ball_depth_margin,
601 self.config.level_ball_depth_margin,
602 ],
603 &[
604 BallDepthState::BehindBall,
605 BallDepthState::LevelWithBall,
606 BallDepthState::AheadOfBall,
607 ],
608 );
609
610 if let Some(attacker_id) = possession_player_before_sample
611 && let Some(attacker) = players.player(attacker_id)
612 && attacker.is_team_0 != player.is_team_0
613 && let Some(attacker_position) = attacker.position()
614 {
615 let attacker_previous_position = self
616 .previous_player_positions
617 .get(&attacker.player_id)
618 .copied()
619 .unwrap_or(attacker_position);
620 entry.shadow_defense = self.shadow_defense_state(
621 frame,
622 position,
623 previous_position,
624 ball_position,
625 previous_ball_position,
626 attacker_position,
627 attacker_previous_position,
628 player.is_team_0,
629 );
630 }
631 }
632
633 let positioned_players: Vec<_> = players
634 .players
635 .iter()
636 .filter(|player| !demoed_players.contains(&player.player_id))
637 .filter_map(|player| {
638 player.position().map(|position| {
639 ClosestToBallCandidate::from_player(player, position, ball_position)
640 })
641 })
642 .collect();
643 if let Some(closest_player) = self.absolute_closest_to_ball.select(
644 &positioned_players,
645 frame.dt,
646 self.config.closest_to_ball_switch_margin,
647 self.config.closest_to_ball_switch_min_seconds,
648 ) {
649 if let Some(entry) = facets.get_mut(&closest_player.player_id) {
650 entry.proximity.closest_to_ball_absolute = true;
651 }
652 } else {
653 self.absolute_closest_to_ball.clear();
654 }
655
656 for is_team_0 in [true, false] {
657 let team_present_player_count = players
658 .players
659 .iter()
660 .filter(|player| player.is_team_0 == is_team_0)
661 .count();
662 let team_roster_count = gameplay
663 .current_in_game_team_player_count(is_team_0)
664 .max(team_present_player_count);
665 let team_players: Vec<_> = players
666 .players
667 .iter()
668 .filter(|player| player.is_team_0 == is_team_0)
669 .filter(|player| !demoed_players.contains(&player.player_id))
670 .filter_map(|player| player.position().map(|position| (player, position)))
671 .collect();
672
673 if team_players.is_empty() {
674 if is_team_0 {
675 self.team_zero_closest_to_ball.clear();
676 } else {
677 self.team_one_closest_to_ball.clear();
678 }
679 continue;
680 }
681
682 for (player, position) in &team_players {
683 let teammate_distance_sum: f32 = team_players
684 .iter()
685 .filter(|(other_player, _)| other_player.player_id != player.player_id)
686 .map(|(_, other_position)| position.distance(*other_position))
687 .sum();
688 let teammate_count = team_players.len().saturating_sub(1);
689 if teammate_count > 0 {
690 if let Some(entry) = facets.get_mut(&player.player_id) {
691 entry.distance_to_teammates =
692 Some(teammate_distance_sum / teammate_count as f32);
693 }
694 }
695 }
696
697 if team_roster_count < 2
698 || team_present_player_count < team_roster_count
699 || team_players.len() < 2
700 {
701 for (player, _) in &team_players {
702 if let Some(entry) = facets.get_mut(&player.player_id) {
703 entry.depth_role = Some(DepthRoleState::NoTeammates);
704 }
705 }
706 } else {
707 let mut sorted_team: Vec<_> = team_players
708 .iter()
709 .map(|(info, pos)| (info.player_id.clone(), normalized_y(is_team_0, *pos)))
710 .collect();
711 sorted_team.sort_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap());
712
713 let team_spread = sorted_team.last().map(|(_, y)| *y).unwrap_or(0.0)
714 - sorted_team.first().map(|(_, y)| *y).unwrap_or(0.0);
715
716 if team_spread <= self.config.most_back_forward_threshold_y {
717 for (player_id, _) in &sorted_team {
718 if let Some(entry) = facets.get_mut(player_id) {
719 entry.depth_role = Some(DepthRoleState::Other);
720 }
721 }
722 } else {
723 let min_y = sorted_team.first().map(|(_, y)| *y).unwrap_or(0.0);
724 let max_y = sorted_team.last().map(|(_, y)| *y).unwrap_or(0.0);
725 let can_assign_mid_role = sorted_team.len() == 3;
726
727 for (player_id, y) in &sorted_team {
728 let near_back = (*y - min_y) <= self.config.most_back_forward_threshold_y;
729 let near_front = (max_y - *y) <= self.config.most_back_forward_threshold_y;
730 let role = if near_back && !near_front {
731 DepthRoleState::MostBack
732 } else if near_front && !near_back {
733 DepthRoleState::MostForward
734 } else if can_assign_mid_role {
735 DepthRoleState::Mid
736 } else {
737 DepthRoleState::Other
738 };
739 if let Some(entry) = facets.get_mut(player_id) {
740 entry.depth_role = Some(role);
741 }
742 }
743 }
744 }
745
746 let team_candidates: Vec<_> = team_players
747 .iter()
748 .map(|(player, position)| {
749 ClosestToBallCandidate::from_player(player, *position, ball_position)
750 })
751 .collect();
752 let team_debouncer = if is_team_0 {
753 &mut self.team_zero_closest_to_ball
754 } else {
755 &mut self.team_one_closest_to_ball
756 };
757 if let Some(closest_player) = team_debouncer.select(
758 &team_candidates,
759 frame.dt,
760 self.config.closest_to_ball_switch_margin,
761 self.config.closest_to_ball_switch_min_seconds,
762 ) {
763 if let Some(entry) = facets.get_mut(&closest_player.player_id) {
764 entry.proximity.closest_to_ball_team = true;
765 }
766 }
767
768 if let Some((farthest_player, _)) = team_players.iter().max_by(|(_, a), (_, b)| {
769 a.distance(ball_position)
770 .partial_cmp(&b.distance(ball_position))
771 .unwrap()
772 }) {
773 if let Some(entry) = facets.get_mut(&farthest_player.player_id) {
774 entry.proximity.farthest_from_ball = true;
775 }
776 }
777 }
778
779 self.record_frame_facets(frame, &facets);
780 self.store_previous_positions(Some(ball_position), players);
781
782 Ok(())
783 }
784
785 fn record_frame_facets(
788 &mut self,
789 frame: &FrameInfo,
790 facets: &HashMap<PlayerId, PlayerFrameFacets>,
791 ) {
792 let mut players: Vec<_> = facets.iter().collect();
793 players.sort_by_key(|(player, _)| format!("{player:?}"));
794
795 let frame_start = frame.time - frame.dt;
796 for (player, entry) in players {
797 let tracked = entry.activity == Some(ActivityState::Tracked);
798 if tracked {
799 self.signal
800 .entry(player.clone())
801 .or_default()
802 .accumulate(entry, frame.dt);
803 }
804
805 match entry.activity {
806 Some(state) => self.activity.record(
807 frame.frame_number,
808 frame_start,
809 frame.time,
810 frame.dt,
811 player,
812 entry.player_position,
813 entry.is_team_0,
814 state,
815 ),
816 None => self.activity.close(player),
817 }
818
819 record_segments(
820 &mut self.field_third,
821 frame,
822 player,
823 entry,
824 &entry.field_third_segments,
825 );
826 record_segments(
827 &mut self.field_half,
828 frame,
829 player,
830 entry,
831 &entry.field_half_segments,
832 );
833 record_segments(
834 &mut self.ball_depth,
835 frame,
836 player,
837 entry,
838 &entry.ball_depth_segments,
839 );
840
841 match entry.depth_role {
842 Some(role) if tracked => self.depth_role.record(
843 frame.frame_number,
844 frame_start,
845 frame.time,
846 frame.dt,
847 player,
848 entry.player_position,
849 entry.is_team_0,
850 role,
851 ),
852 _ => self.depth_role.close(player),
853 }
854
855 if tracked && entry.proximity.any() {
856 self.ball_proximity.record(
857 frame.frame_number,
858 frame_start,
859 frame.time,
860 frame.dt,
861 player,
862 entry.player_position,
863 entry.is_team_0,
864 entry.proximity,
865 );
866 } else {
867 self.ball_proximity.close(player);
868 }
869
870 match entry.shadow_defense {
871 Some(state) if tracked => self.shadow_defense.record(
872 frame.frame_number,
873 frame_start,
874 frame.time,
875 frame.dt,
876 player,
877 entry.player_position,
878 entry.is_team_0,
879 state,
880 ),
881 _ => self.shadow_defense.close(player),
882 }
883 }
884 }
885
886 #[allow(clippy::too_many_arguments)]
887 pub fn update(
888 &mut self,
889 frame: &FrameInfo,
890 gameplay: &GameplayState,
891 ball: &BallFrameState,
892 players: &PlayerFrameState,
893 events: &FrameEventsState,
894 live_play_state: &LivePlayState,
895 possession_player_before_sample: Option<&PlayerId>,
896 ) -> SubtrActorResult<()> {
897 self.activity.begin_update();
898 self.field_third.begin_update();
899 self.field_half.begin_update();
900 self.ball_depth.begin_update();
901 self.depth_role.begin_update();
902 self.ball_proximity.begin_update();
903 self.shadow_defense.begin_update();
904 self.process_sample(
905 frame,
906 gameplay,
907 ball,
908 players,
909 events,
910 live_play_state,
911 possession_player_before_sample,
912 )
913 }
914
915 pub fn player_signal(&self, player: &PlayerId) -> PositioningSignalSnapshot {
918 self.signal.get(player).copied().unwrap_or_default()
919 }
920
921 pub fn signals(&self) -> &HashMap<PlayerId, PositioningSignalSnapshot> {
922 &self.signal
923 }
924}
925
926fn normalized_xy(is_team_0: bool, position: glam::Vec3) -> glam::Vec2 {
927 glam::vec2(position.x, normalized_y(is_team_0, position))
928}
929
930fn distance_to_segment_2d(point: glam::Vec2, start: glam::Vec2, end: glam::Vec2) -> f32 {
931 let segment = end - start;
932 let length_squared = segment.length_squared();
933 if length_squared <= f32::EPSILON {
934 return point.distance(start);
935 }
936 let t = ((point - start).dot(segment) / length_squared).clamp(0.0, 1.0);
937 point.distance(start + segment * t)
938}
939
940fn record_segments<S: Copy + PartialEq>(
943 tracker: &mut PlayerSpanTracker<S>,
944 frame: &FrameInfo,
945 player: &PlayerId,
946 entry: &PlayerFrameFacets,
947 segments: &[(S, f32)],
948) {
949 if segments.is_empty() {
950 tracker.close(player);
951 return;
952 }
953 let frame_start = frame.time - frame.dt;
954 let mut cumulative = 0.0f32;
955 for (state, fraction) in segments {
956 let start_time = frame_start + cumulative * frame.dt;
957 cumulative += fraction;
958 let end_time = if cumulative >= 1.0 {
959 frame.time
960 } else {
961 frame_start + cumulative * frame.dt
962 };
963 tracker.record(
964 frame.frame_number,
965 start_time,
966 end_time,
967 fraction * frame.dt,
968 player,
969 entry.player_position,
970 entry.is_team_0,
971 *state,
972 );
973 }
974}
975
976#[cfg(test)]
977#[path = "positioning_tests.rs"]
978mod tests;