osu_map_analyzer/analyze/
jump.rs

1use crate::utils::{bpm, calculate_distance};
2use rosu_map::{section::hit_objects::HitObject, Beatmap};
3use std::collections::VecDeque;
4
5pub struct Jump {
6    map: Beatmap,
7}
8
9#[derive(Debug, Clone, Copy, serde::Deserialize)]
10#[cfg_attr(feature = "serialize", derive(serde::Serialize))]
11pub struct JumpAnalysis {
12    pub overall_confidence: f64,
13    pub total_jump_count: usize,
14
15    pub max_jump_length: usize,
16    pub long_jumps: usize,
17    pub medium_jumps: usize,
18    pub short_jumps: usize,
19
20    pub jump_density: f64,
21    pub bpm_consistency: f64,
22}
23
24impl Jump {
25    /// Creates a new jump analyzer for the given beatmap.
26    ///
27    /// # Arguments
28    ///
29    /// * `map` - The beatmap to analyze, type: rosu_map::Beatmap.
30    pub fn new(map: Beatmap) -> Self {
31        Self { map }
32    }
33
34    /// Analyzes the beatmap for jumps and returns a `JumpAnalysis`.
35    ///
36    /// # Example
37    ///
38    /// ```rs
39    /// let path = Path::new("example-maps/jump-caffeinefighter.osu");
40    /// let map = rosu_map::from_path::<rosu_map::Beatmap>(path).unwrap();
41
42    /// let mut jump_analyzer = Jump::new(map);
43    /// let analasis = jump_analyzer.analyze();
44    /// println!("{:#?}", analasis);
45    /// ```
46    pub fn analyze(&mut self) -> JumpAnalysis {
47        let bpm = bpm(
48            self.map.hit_objects.last_mut(),
49            &self.map.control_points.timing_points,
50        );
51        let beat_length = 60.0 / bpm * 1000.0;
52        let expected_jump_interval = beat_length / 2.0; // 1/2ths
53        let hit_objects = &self.map.hit_objects;
54
55        let (consecutive_notes, bpm_variations) =
56            self.calculate_consecutive_notes(hit_objects, expected_jump_interval);
57
58        // Calculate jumps' lengths
59        let short_jumps_amount = consecutive_notes
60            .iter()
61            .filter(|&&len| len >= 4 && len < 7)
62            .count();
63        let medium_jumps_amount = consecutive_notes
64            .iter()
65            .filter(|&&len| len >= 7 && len < 12)
66            .count();
67        let long_jumps_amount = consecutive_notes.iter().filter(|&&len| len >= 12).count();
68
69        // Filter `consecutive_notes` to only have note amount higher than `5`,
70        // because `consecutive_notes` returns the consecutive notes, `not` jumps.
71        // Extremely short jumps could still be counted as jumps if I don't do this.
72        let jumps_lengths: Vec<usize> = consecutive_notes
73            .iter()
74            .filter(|&&len| len >= 4)
75            .map(|&len| len)
76            .collect();
77
78        let total_jump_notes: usize = jumps_lengths.iter().sum();
79        let jump_density = total_jump_notes as f64 / hit_objects.len() as f64;
80
81        let max_jump_length = *consecutive_notes.iter().max().unwrap_or(&0);
82        let total_jumps_amount = short_jumps_amount + medium_jumps_amount + long_jumps_amount;
83
84        let bpm_consistency = if !bpm_variations.is_empty() {
85            1.0 - (bpm_variations.iter().sum::<f64>() / bpm_variations.len() as f64)
86                / expected_jump_interval
87        } else {
88            0.0
89        };
90
91        let average_jump_length = if total_jumps_amount > 0 {
92            total_jump_notes as f64 / total_jumps_amount as f64
93        } else {
94            0.0
95        };
96
97        let jump_variety = (medium_jumps_amount * 2 + long_jumps_amount * 3) as f64
98            / (short_jumps_amount + medium_jumps_amount + long_jumps_amount).max(1) as f64;
99
100        let long_jump_ratio = long_jumps_amount as f64 / total_jumps_amount as f64;
101
102        let overall_confidence = (jump_density * 0.4
103            + bpm_consistency * 0.2
104            + jump_variety * 0.35
105            + long_jump_ratio * 0.45
106            + (average_jump_length / 3.0).min(1.0) * 0.3)
107            .min(1.0);
108
109        JumpAnalysis {
110            long_jumps: long_jumps_amount,
111            medium_jumps: medium_jumps_amount,
112            short_jumps: short_jumps_amount,
113            max_jump_length,
114            total_jump_count: total_jumps_amount,
115            overall_confidence,
116            jump_density,
117            bpm_consistency,
118        }
119    }
120
121    fn calculate_consecutive_notes(
122        &self,
123        hit_objects: &[HitObject],
124        expected_interval: f64,
125    ) -> (Vec<usize>, Vec<f64>) {
126        let mut jumps_lengths = Vec::new();
127        let mut curr_jump = VecDeque::new();
128        let mut bpm_variations = Vec::new();
129        let tolerance = 0.10; // 10% tolerance
130        let distance_threshold = 120.0_f32;
131
132        for pair in hit_objects.windows(2) {
133            let obj1 = &pair[0];
134            let obj2 = &pair[1];
135
136            let time_diff = obj2.start_time - obj1.start_time;
137            let distance = calculate_distance(obj1, obj2);
138
139            // Check if the pair is between expected interval.
140            if (time_diff - expected_interval).abs() / expected_interval <= tolerance
141                && distance >= distance_threshold
142            {
143                curr_jump.push_back(time_diff);
144                if curr_jump.len() > 1 {
145                    let prev_diff = curr_jump[curr_jump.len() - 2];
146                    bpm_variations.push((time_diff - prev_diff).abs());
147                }
148            } else if !curr_jump.is_empty() {
149                jumps_lengths.push(curr_jump.len());
150                curr_jump.clear();
151            }
152        }
153
154        if !curr_jump.is_empty() {
155            jumps_lengths.push(curr_jump.len());
156        }
157
158        (jumps_lengths, bpm_variations)
159    }
160}