subtr_actor/stats/calculators/
ball_half.rs1use super::*;
2
3pub(crate) const DEFAULT_BALL_HALF_NEUTRAL_ZONE_HALF_WIDTH_Y: f32 = 200.0;
4
5#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
9pub(crate) enum BallHalfLabel {
10 TeamZeroSide,
11 TeamOneSide,
12 #[default]
13 Neutral,
14}
15
16impl BallHalfLabel {
17 pub(crate) fn from_y(ball_y: f32, neutral_zone_half_width_y: f32) -> Self {
18 if ball_y.abs() <= neutral_zone_half_width_y {
19 Self::Neutral
20 } else if ball_y < 0.0 {
21 Self::TeamZeroSide
22 } else {
23 Self::TeamOneSide
24 }
25 }
26
27 pub(crate) fn as_label_value(self) -> &'static str {
28 match self {
29 Self::TeamZeroSide => "team_zero_side",
30 Self::TeamOneSide => "team_one_side",
31 Self::Neutral => "neutral",
32 }
33 }
34}
35
36#[derive(Debug, Clone, PartialEq, Serialize, ts_rs::TS)]
38#[ts(export)]
39pub struct BallHalfEvent {
40 pub time: f32,
41 pub frame: usize,
42 pub end_time: f32,
43 pub end_frame: usize,
44 pub active: bool,
45 pub duration: f32,
46 pub field_half: String,
47}
48
49impl BallHalfEvent {
50 fn absorb_duration(&mut self, frame: &FrameInfo, duration: f32) {
51 self.end_time = frame.time;
52 self.end_frame = frame.frame_number;
53 self.duration += duration;
54 }
55}
56
57#[derive(Debug, Clone, PartialEq)]
59pub struct BallHalfCalculatorConfig {
60 pub neutral_zone_half_width_y: f32,
61}
62
63impl Default for BallHalfCalculatorConfig {
64 fn default() -> Self {
65 Self {
66 neutral_zone_half_width_y: DEFAULT_BALL_HALF_NEUTRAL_ZONE_HALF_WIDTH_Y,
67 }
68 }
69}
70
71#[derive(Debug, Clone, Default, PartialEq)]
73pub struct BallHalfCalculator {
74 config: BallHalfCalculatorConfig,
75 events: EventStream<BallHalfEvent>,
76 last_emitted_event_state: Option<BallHalfEventState>,
77 pending_event: Option<PendingBallHalfEvent>,
78}
79
80#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
81struct BallHalfEventState {
82 active: bool,
83 field_half: BallHalfLabel,
84}
85
86#[derive(Debug, Clone, PartialEq)]
87struct PendingBallHalfEvent {
88 state: BallHalfEventState,
89 event: BallHalfEvent,
90}
91
92impl BallHalfCalculator {
93 pub fn new() -> Self {
94 Self::with_config(BallHalfCalculatorConfig::default())
95 }
96
97 pub fn with_config(config: BallHalfCalculatorConfig) -> Self {
98 Self {
99 config,
100 ..Self::default()
101 }
102 }
103
104 pub fn events(&self) -> &[BallHalfEvent] {
105 self.events.all()
106 }
107
108 pub fn new_events(&self) -> &[BallHalfEvent] {
109 self.events.new_events()
110 }
111
112 pub fn projected_events(&self) -> Vec<BallHalfEvent> {
113 let mut events = self.events.all().to_vec();
114 if let Some(pending) = &self.pending_event {
115 events.push(pending.event.clone());
116 }
117 events
118 }
119
120 pub fn flush_pending_event(&mut self) {
121 let Some(pending) = self.pending_event.take() else {
122 return;
123 };
124 self.events.push(pending.event);
125 }
126
127 pub fn current_event(&self) -> Option<&BallHalfEvent> {
130 self.pending_event
131 .as_ref()
132 .map(|pending| &pending.event)
133 .or_else(|| self.events.all().last())
134 }
135
136 pub fn config(&self) -> &BallHalfCalculatorConfig {
137 &self.config
138 }
139
140 fn emit_event_if_changed(
141 &mut self,
142 frame: &FrameInfo,
143 active: bool,
144 duration: f32,
145 field_half: BallHalfLabel,
146 ) {
147 let event_state = BallHalfEventState { active, field_half };
148 if self.last_emitted_event_state == Some(event_state) && duration == 0.0 {
149 return;
150 }
151 let event = BallHalfEvent {
152 time: frame.time,
153 frame: frame.frame_number,
154 end_time: frame.time,
155 end_frame: frame.frame_number,
156 active,
157 duration,
158 field_half: field_half.as_label_value().to_owned(),
159 };
160 self.record_event(event_state, frame, event);
161 self.last_emitted_event_state = Some(event_state);
162 }
163
164 fn record_event(&mut self, state: BallHalfEventState, frame: &FrameInfo, event: BallHalfEvent) {
165 let Some(pending) = self.pending_event.as_mut() else {
166 self.pending_event = Some(PendingBallHalfEvent { state, event });
167 return;
168 };
169
170 if pending.state == state {
171 pending.event.absorb_duration(frame, event.duration);
172 } else {
173 let previous = self
174 .pending_event
175 .replace(PendingBallHalfEvent { state, event });
176 let Some(previous) = previous else {
177 return;
178 };
179 self.events.push(previous.event);
180 }
181 }
182
183 pub fn update(
184 &mut self,
185 frame: &FrameInfo,
186 ball: &BallFrameState,
187 live_play_state: &LivePlayState,
188 ) -> SubtrActorResult<()> {
189 self.events.begin_update();
190 if !live_play_state.is_live_play {
191 self.emit_event_if_changed(frame, false, 0.0, BallHalfLabel::Neutral);
192 return Ok(());
193 }
194 if let Some(ball) = ball.sample() {
195 let half =
196 BallHalfLabel::from_y(ball.position().y, self.config.neutral_zone_half_width_y);
197 self.emit_event_if_changed(frame, true, frame.dt, half);
198 } else {
199 self.emit_event_if_changed(frame, false, 0.0, BallHalfLabel::Neutral);
200 }
201 Ok(())
202 }
203}