renamed fruits to catch

This commit is contained in:
MaxOhn
2022-03-20 17:05:37 +01:00
parent 71eac0ffa3
commit 58b2ffba08
18 changed files with 186 additions and 201 deletions
+91
View File
@@ -0,0 +1,91 @@
use crate::parse::Pos2;
use super::fruit_or_juice::FruitParams;
const PLAYFIELD_WIDTH: f32 = 512.0;
const BASE_SPEED: f64 = 1.0;
#[derive(Clone, Debug)]
pub struct CatchObject {
pub(crate) pos: f32,
pub(crate) time: f64,
pub(crate) hyper_dash: bool,
pub(crate) hyper_dist: f32,
}
impl CatchObject {
#[inline]
pub(crate) fn new((pos, time): (Pos2, f64)) -> Self {
Self {
pos: pos.x,
time,
hyper_dash: false,
hyper_dist: 0.0,
}
}
pub(crate) fn with_hr(mut self, params: &mut FruitParams<'_>) -> Self {
let mut offset_pos = self.pos;
let time_diff = self.time - params.last_time;
if let Some(last_pos_ref) = params.last_pos.filter(|_| time_diff <= 1000.0) {
let pos_diff = offset_pos - last_pos_ref;
if pos_diff.abs() > f32::EPSILON {
if pos_diff.abs() < (time_diff as f32 / 3.0).floor() {
if pos_diff > 0.0 {
if offset_pos + pos_diff < PLAYFIELD_WIDTH {
offset_pos += pos_diff;
}
} else if offset_pos + pos_diff > 0.0 {
offset_pos += pos_diff;
}
}
params.last_pos.replace(offset_pos);
params.last_time = self.time;
}
self.pos = offset_pos;
} else {
params.last_pos.replace(offset_pos);
params.last_time = self.time;
}
self
}
pub(crate) fn init_hyper_dash(
&mut self,
half_catcher_width: f64,
next: &CatchObject,
last_direction: &mut i8,
last_excess: &mut f64,
) {
let next_x = next.pos;
let curr_x = self.pos;
let this_direction = (next_x > curr_x) as i8 * 2 - 1;
let time_to_next = next.time - self.time - 1000.0 / 60.0 / 4.0;
let sub = if *last_direction == this_direction {
*last_excess
} else {
half_catcher_width
};
let dist_to_next = (next_x - curr_x).abs() as f64 - sub;
let hyper_dist = (time_to_next * BASE_SPEED - dist_to_next) as f32;
if hyper_dist < 0.0 {
self.hyper_dash = true;
*last_excess = half_catcher_width;
} else {
self.hyper_dist = hyper_dist;
*last_excess = (hyper_dist as f64).max(0.0).min(half_catcher_width);
}
*last_direction = this_direction;
}
}
+46
View File
@@ -0,0 +1,46 @@
use super::CatchObject;
const NORMALIZED_HITOBJECT_RADIUS: f32 = 41.0;
pub(crate) struct DifficultyObject<'o> {
pub(crate) base: &'o CatchObject,
pub(crate) last: &'o CatchObject,
pub(crate) delta: f64,
pub(crate) start_time: f64,
pub(crate) normalized_pos: f32,
pub(crate) last_normalized_pos: f32,
pub(crate) strain_time: f64,
pub(crate) clock_rate: f64,
}
impl<'o> DifficultyObject<'o> {
#[inline]
pub(crate) fn new(
base: &'o CatchObject,
last: &'o CatchObject,
half_catcher_width: f32,
clock_rate: f64,
) -> Self {
let delta = (base.time - last.time) / clock_rate;
let start_time = base.time / clock_rate;
let strain_time = delta.max(40.0);
let scaling_factor = NORMALIZED_HITOBJECT_RADIUS / half_catcher_width;
let normalized_pos = base.pos * scaling_factor;
let last_normalized_pos = last.pos * scaling_factor;
Self {
base,
last,
delta,
start_time,
normalized_pos,
last_normalized_pos,
strain_time,
clock_rate,
}
}
}
+280
View File
@@ -0,0 +1,280 @@
use std::{iter::Map, vec::IntoIter};
use crate::{
curve::{Curve, CurveBuffers},
parse::{HitObject, HitObjectKind, Pos2},
Beatmap,
};
use super::{catch_object::CatchObject, slider_state::SliderState, CatchDifficultyAttributes};
const LEGACY_LAST_TICK_OFFSET: f64 = 36.0;
const BASE_SCORING_DISTANCE: f64 = 100.0;
#[derive(Clone, Debug)]
pub(crate) struct FruitParams<'a> {
pub(crate) attributes: CatchDifficultyAttributes,
pub(crate) curve_bufs: CurveBuffers,
pub(crate) last_pos: Option<f32>,
pub(crate) last_time: f64,
pub(crate) map: &'a Beatmap,
pub(crate) slider_state: SliderState<'a>,
pub(crate) ticks: Vec<(Pos2, f64)>,
pub(crate) with_hr: bool,
}
type JuiceStream = Map<IntoIter<(Pos2, f64)>, fn((Pos2, f64)) -> CatchObject>;
#[derive(Clone, Debug)]
pub(crate) enum FruitOrJuice {
Fruit(Option<CatchObject>),
Juice(JuiceStream),
}
impl FruitOrJuice {
pub(crate) fn new(h: &HitObject, params: &mut FruitParams<'_>) -> Option<Self> {
match &h.kind {
HitObjectKind::Circle => {
let mut h = CatchObject::new((h.pos, h.start_time));
if params.with_hr {
h = h.with_hr(params);
}
params.attributes.n_fruits += 1;
Some(FruitOrJuice::Fruit(Some(h)))
}
HitObjectKind::Slider {
pixel_len,
repeats,
control_points,
} => {
// HR business
params.last_pos = Some(h.pos.x + control_points[control_points.len() - 1].pos.x);
params.last_time = h.start_time;
// Responsible for timing point values
params.slider_state.update(h.start_time);
let span_count = (*repeats + 1) as f64;
let mut tick_dist = 100.0 * params.map.slider_mult / params.map.tick_rate;
if params.map.version >= 8 {
tick_dist /= (100.0 / params.slider_state.slider_velocity)
.max(10.0)
.min(1000.0)
/ 100.0;
}
// Build the curve w.r.t. the control points
let curve = Curve::new(control_points, *pixel_len, &mut params.curve_bufs);
let velocity = (BASE_SCORING_DISTANCE
* params.map.slider_mult
* params.slider_state.slider_velocity)
/ params.slider_state.beat_len;
let end_time = h.start_time + span_count * curve.dist() / velocity;
let duration = end_time - h.start_time;
let span_duration = duration / span_count;
// * A very lenient maximum length of a slider for ticks to be generated.
// * This exists for edge cases such as /b/1573664 where the beatmap has
// * been edited by the user, and should never be reached in normal usage.
let max_len = 100_000.0;
let len = curve.dist().min(max_len);
tick_dist = tick_dist.clamp(0.0, len);
let min_dist_from_end = velocity * 10.0;
let mut curr_dist = tick_dist;
let time_add = duration * tick_dist / (*pixel_len * span_count);
let target = *pixel_len - tick_dist / 8.0;
params.ticks.reserve((target / tick_dist) as usize);
// Tick of the first span
while curr_dist < len - min_dist_from_end {
let progress = curr_dist / len;
let pos = h.pos + curve.position_at(progress);
let time = h.start_time + progress * span_duration;
params.ticks.push((pos, time));
curr_dist += tick_dist;
}
params.attributes.n_tiny_droplets += tiny_droplet_count(
h.start_time,
time_add,
duration,
span_count as usize,
&params.ticks,
);
let mut slider_objects =
Vec::with_capacity(span_count as usize * (params.ticks.len() + 1));
slider_objects.push((h.pos, h.start_time));
// Other spans
if *repeats == 0 {
slider_objects.append(&mut params.ticks); // automatically empties buffer for next slider
} else {
slider_objects.extend(&params.ticks);
for span_idx in 1..=*repeats {
let progress = (span_idx % 2 == 1) as u8 as f64;
let pos = h.pos + curve.position_at(progress);
let time_offset = span_duration * span_idx as f64;
// Reverse tick
slider_objects.push((pos, h.start_time + time_offset));
// Actual ticks
if span_idx & 1 == 1 {
let tick_iter = params
.ticks
.iter()
.rev()
.zip(params.ticks.iter())
.map(|((pos, _), (_, time))| (*pos, *time + time_offset));
slider_objects.extend(tick_iter);
} else {
let tick_iter = params
.ticks
.iter()
.map(|(pos, time)| (*pos, *time + time_offset));
slider_objects.extend(tick_iter);
}
}
params.ticks.clear();
}
// Slider tail
let progress = (*repeats % 2 == 0) as u8 as f64;
let pos = h.pos + curve.position_at(progress);
slider_objects.push((pos, h.start_time + duration));
let new_fruits = *repeats + 2;
params.attributes.n_fruits += new_fruits;
params.attributes.n_droplets += slider_objects.len() - new_fruits;
let iter = slider_objects
.into_iter()
.map(CatchObject::new as fn(_) -> _);
Some(FruitOrJuice::Juice(iter))
}
HitObjectKind::Spinner { .. } | HitObjectKind::Hold { .. } => None,
}
}
}
impl Iterator for FruitOrJuice {
type Item = CatchObject;
#[inline]
fn next(&mut self) -> Option<Self::Item> {
match self {
Self::Fruit(fruit) => fruit.take(),
Self::Juice(slider) => slider.next(),
}
}
#[inline]
fn size_hint(&self) -> (usize, Option<usize>) {
let len = self.len();
(len, Some(len))
}
}
impl ExactSizeIterator for FruitOrJuice {
#[inline]
fn len(&self) -> usize {
match self {
FruitOrJuice::Fruit(Some(_)) => 1,
FruitOrJuice::Fruit(None) => 0,
FruitOrJuice::Juice(slider) => slider.len(),
}
}
}
// BUG: Sometimes there are off-by-one errors,
// presumably caused by floating point inaccuracies
fn tiny_droplet_count(
start_time: f64,
time_between_ticks: f64,
duration: f64,
span_count: usize,
ticks: &[(Pos2, f64)],
) -> usize {
// tiny droplets preceeding a _tick_
let per_tick = if !ticks.is_empty() && time_between_ticks > 80.0 {
let time_between_tiny = shrink_down(time_between_ticks);
// add a little for floating point inaccuracies
let start = time_between_tiny + 0.001;
count_iterations(start, time_between_tiny, time_between_ticks)
} else {
0
};
// tiny droplets preceeding a _reverse_
let last = ticks.last().map_or(start_time, |(_, last)| *last);
let repeat_time = start_time + duration / span_count as f64;
let since_last_tick = repeat_time - last;
let span_last_section = if since_last_tick > 80.0 {
let time_between_tiny = shrink_down(since_last_tick);
count_iterations(time_between_tiny, time_between_tiny, since_last_tick)
} else {
0
};
// tiny droplets preceeding the slider tail
// necessary to handle distinctly because of the legacy last tick
let last = ticks.last().map_or(start_time, |(_, last)| *last);
let end_time = start_time + duration / span_count as f64 - LEGACY_LAST_TICK_OFFSET;
let since_last_tick = end_time - last;
let last_section = if since_last_tick > 80.0 {
let time_between_tiny = shrink_down(since_last_tick);
count_iterations(time_between_tiny, time_between_tiny, since_last_tick)
} else {
0
};
// Combine tiny droplets counts
per_tick * ticks.len() * span_count
+ span_last_section * (span_count.saturating_sub(1))
+ last_section
}
#[inline]
fn shrink_down(mut val: f64) -> f64 {
while val > 100.0 {
val /= 2.0;
}
val
}
#[inline]
fn count_iterations(mut start: f64, step: f64, end: f64) -> usize {
let mut count = 0;
while start < end {
count += 1;
start += step;
}
count
}
+241
View File
@@ -0,0 +1,241 @@
use std::{iter, slice::Iter};
use crate::{
catch::{
difficulty_object::DifficultyObject, slider_state::SliderState, SECTION_LENGTH,
STAR_SCALING_FACTOR,
},
curve::CurveBuffers,
parse::{HitObject, Pos2},
Beatmap, Mods,
};
use super::{
calculate_catch_width,
catch_object::CatchObject,
fruit_or_juice::{FruitOrJuice, FruitParams},
movement::Movement,
CatchDifficultyAttributes, ALLOWED_CATCH_RANGE,
};
/// Gradually calculate the difficulty attributes of an osu!catch map.
///
/// Note that this struct implements [`Iterator`](std::iter::Iterator).
/// On every call of [`Iterator::next`](std::iter::Iterator::next), the map's next fruit or droplet
/// will be processed and the [`CatchDifficultyAttributes`] will be updated and returned.
///
/// Note that it does not return attributes after a tiny droplet. Only for fruits and droplets.
///
/// If you want to calculate performance attributes, use
/// [`CatchGradualPerformanceAttributes`](crate::catch::CatchGradualPerformanceAttributes) instead.
///
/// # Example
///
/// ```
/// use rosu_pp::{Beatmap, catch::CatchGradualDifficultyAttributes};
///
/// # /*
/// let map: Beatmap = ...
/// # */
/// # let map = Beatmap::default();
///
/// let mods = 64; // DT
/// let mut iter = CatchGradualDifficultyAttributes::new(&map, mods);
///
/// let attrs1 = iter.next(); // the difficulty of the map after the first hit object
/// let attrs2 = iter.next(); // after the second hit object
///
/// // Remaining hit objects
/// for difficulty in iter {
/// // ...
/// }
/// ```
#[derive(Clone, Debug)]
pub struct CatchGradualDifficultyAttributes<'map> {
pub(crate) idx: usize,
clock_rate: f64,
hit_objects: CatchObjectIter<'map>,
movement: Movement,
prev: CatchObject,
half_catcher_width: f64,
last_direction: i8,
last_excess: f64,
curr_section_end: f64,
strain_peak_buf: Vec<f64>,
}
impl<'map> CatchGradualDifficultyAttributes<'map> {
/// Create a new difficulty attributes iterator for osu!catch maps.
pub fn new(map: &'map Beatmap, mods: impl Mods) -> Self {
let map_attributes = map.attributes().mods(mods);
let attributes = CatchDifficultyAttributes {
ar: map_attributes.ar,
..Default::default()
};
let hit_objects = CatchObjectIter::new(map, mods, attributes);
let half_catcher_width =
(calculate_catch_width(map_attributes.cs as f32) / 2.0 / ALLOWED_CATCH_RANGE) as f64;
let last_direction = 0;
let last_excess = half_catcher_width;
let movement = Movement::new(map_attributes.cs as f32);
let prev = CatchObject::new((Pos2::zero(), 0.0));
Self {
idx: 0,
clock_rate: mods.speed(),
hit_objects,
movement,
prev,
half_catcher_width,
last_direction,
last_excess,
curr_section_end: 0.0,
strain_peak_buf: Vec::new(),
}
}
fn init_hyper_dash(&mut self, next: &CatchObject) {
self.prev.init_hyper_dash(
self.half_catcher_width,
next,
&mut self.last_direction,
&mut self.last_excess,
);
}
}
impl Iterator for CatchGradualDifficultyAttributes<'_> {
type Item = CatchDifficultyAttributes;
fn next(&mut self) -> Option<Self::Item> {
let curr = self.hit_objects.next()?;
self.idx += 1;
if self.idx == 1 {
self.prev = curr;
return Some(self.hit_objects.attributes());
}
self.init_hyper_dash(&curr);
let h = DifficultyObject::new(
&curr,
&self.prev,
self.movement.half_catcher_width,
self.clock_rate,
);
if self.idx == 2 {
self.curr_section_end =
(h.base.time / self.clock_rate / SECTION_LENGTH).ceil() * SECTION_LENGTH;
} else {
let base_time = h.base.time / self.clock_rate;
while base_time > self.curr_section_end {
self.movement.save_current_peak();
self.movement.start_new_section_from(self.curr_section_end);
self.curr_section_end += SECTION_LENGTH;
}
}
self.movement.process(&h);
self.prev = curr;
let len = self.movement.strain_peaks.len();
let missing = len + 1 - self.strain_peak_buf.len();
self.strain_peak_buf.extend(iter::repeat(0.0).take(missing));
self.strain_peak_buf[..len].copy_from_slice(&self.movement.strain_peaks);
if let Some(last) = self.strain_peak_buf.last_mut() {
*last = self.movement.curr_section_peak;
}
let mut attributes = self.hit_objects.attributes();
attributes.stars =
Movement::difficulty_value(&mut self.strain_peak_buf).sqrt() * STAR_SCALING_FACTOR;
Some(attributes)
}
}
#[derive(Clone, Debug)]
struct CatchObjectIter<'map> {
last_object: Option<FruitOrJuice>,
hit_objects: Iter<'map, HitObject>,
params: FruitParams<'map>,
}
impl<'map> CatchObjectIter<'map> {
fn new(map: &'map Beatmap, mods: impl Mods, attributes: CatchDifficultyAttributes) -> Self {
let params = FruitParams {
attributes,
curve_bufs: CurveBuffers::default(),
last_pos: None,
last_time: 0.0,
map,
slider_state: SliderState::new(map),
ticks: Vec::new(),
with_hr: mods.hr(),
};
Self {
last_object: None,
hit_objects: map.hit_objects.iter(),
params,
}
}
fn attributes(&self) -> CatchDifficultyAttributes {
self.params.attributes.clone()
}
}
impl Iterator for CatchObjectIter<'_> {
type Item = CatchObject;
fn next(&mut self) -> Option<Self::Item> {
if let Some(h) = self.last_object.as_mut().and_then(Iterator::next) {
return Some(h);
}
for h in &mut self.hit_objects {
if let Some(h) = FruitOrJuice::new(h, &mut self.params) {
return self.last_object.insert(h).next();
}
}
None
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_map() {
let map = Beatmap::default();
let mut attributes = CatchGradualDifficultyAttributes::new(&map, 0);
assert!(attributes.next().is_none());
}
#[cfg(not(any(feature = "async_tokio", feature = "async_std")))]
#[test]
fn iter_end_eq_regular() {
let map = Beatmap::from_path("./maps/2118524.osu").expect("failed to parse map");
let mods = 64;
let regular = crate::CatchStars::new(&map).mods(mods).calculate();
let iter_end = CatchGradualDifficultyAttributes::new(&map, mods)
.last()
.expect("empty iter");
assert_eq!(regular, iter_end);
}
}
+292
View File
@@ -0,0 +1,292 @@
use crate::{Beatmap, CatchPP};
use super::{CatchGradualDifficultyAttributes, CatchPerformanceAttributes};
/// Aggregation for a score's current state i.e. what was the
/// maximum combo so far and what are the current hitresults.
///
/// This struct is used for [`CatchGradualPerformanceAttributes`].
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct CatchScoreState {
/// Maximum combo that the score has had so far.
/// **Not** the maximum possible combo of the map so far.
///
/// Note that only fruits and droplets are considered for osu!catch combo.
pub max_combo: usize,
/// Amount of current fruits (300s).
pub n_fruits: usize,
/// Amount of current droplets (100s).
pub n_droplets: usize,
/// Amount of current tiny droplets (50s).
pub n_tiny_droplets: usize,
/// Amount of current tiny droplet misses (katus).
pub n_tiny_droplet_misses: usize,
/// Amount of current misses (fruits and droplets).
pub misses: usize,
}
impl CatchScoreState {
/// Create a new empty score state.
pub fn new() -> Self {
Self::default()
}
}
/// Gradually calculate the performance attributes of an osu!catch map.
///
/// After each hit object you can call
/// [`process_next_object`](`CatchGradualPerformanceAttributes::process_next_object`)
/// and it will return the resulting current [`CatchPerformanceAttributes`].
/// To process multiple objects at once, use
/// [`process_next_n_objects`](`CatchGradualPerformanceAttributes::process_next_n_objects`) instead.
///
/// Both methods require a [`CatchScoreState`] that contains the current
/// hitresults as well as the maximum combo so far.
///
/// Note that neither hits nor misses of tiny droplets require
/// to be processed. Only fruits and droplets do.
///
/// If you only want to calculate difficulty attributes use
/// [`CatchGradualDifficultyAttributes`](crate::catch::CatchGradualDifficultyAttributes) instead.
///
/// # Example
///
/// ```
/// use rosu_pp::{Beatmap, catch::{CatchGradualPerformanceAttributes, CatchScoreState}};
///
/// # /*
/// let map: Beatmap = ...
/// # */
/// # let map = Beatmap::default();
///
/// let mods = 64; // DT
/// let mut gradual_perf = CatchGradualPerformanceAttributes::new(&map, mods);
/// let mut state = CatchScoreState::new(); // empty state, everything is on 0.
///
/// // The first 10 hitresults are only fruits
/// for _ in 0..10 {
/// state.n_fruits += 1;
/// state.max_combo += 1;
///
/// # /*
/// let performance = gradual_perf.process_next_object(state.clone()).unwrap();
/// println!("PP: {}", performance.pp);
/// # */
/// # let _ = gradual_perf.process_next_object(state.clone());
/// }
///
/// // Then comes a miss.
/// // Note that state's max combo won't be incremented for
/// // the next few objects because the combo is reset.
/// state.misses += 1;
/// # /*
/// let performance = gradual_perf.process_next_object(state.clone()).unwrap();
/// println!("PP: {}", performance.pp);
/// # */
/// # let _ = gradual_perf.process_next_object(state.clone());
///
/// // The next 10 objects will be a mixture of fruits and droplets.
/// // Notice how tiny droplets from sliders do not count as hit objects
/// // that require processing. Only fruits and droplets do.
/// // Also notice how all 10 objects will be processed in one go.
/// state.n_fruits += 4;
/// state.n_droplets += 6;
/// state.n_tiny_droplets += 12;
/// # /*
/// let performance = gradual_perf.process_next_n_objects(state.clone(), 10).unwrap();
/// println!("PP: {}", performance.pp);
/// # */
/// # let _ = gradual_perf.process_next_n_objects(state.clone(), 10);
///
/// // Now comes another fruit. Note that the max combo gets incremented again.
/// state.n_fruits += 1;
/// state.max_combo += 1;
/// # /*
/// let performance = gradual_perf.process_next_object(state.clone()).unwrap();
/// println!("PP: {}", performance.pp);
/// # */
/// # let _ = gradual_perf.process_next_object(state.clone());
///
/// // Skip to the end
/// # /*
/// state.max_combo = ...
/// state.n_fruits = ...
/// state.n_droplets = ...
/// state.n_tiny_droplets = ...
/// state.n_tiny_droplet_misses = ...
/// state.misses = ...
/// let final_performance = gradual_perf.process_next_n_objects(state.clone(), usize::MAX).unwrap();
/// println!("PP: {}", performance.pp);
/// # */
/// # let _ = gradual_perf.process_next_n_objects(state.clone(), usize::MAX);
///
/// // Once the final performance was calculated,
/// // attempting to process further objects will return `None`.
/// assert!(gradual_perf.process_next_object(state).is_none());
/// ```
#[derive(Clone, Debug)]
pub struct CatchGradualPerformanceAttributes<'map> {
difficulty: CatchGradualDifficultyAttributes<'map>,
performance: CatchPP<'map>,
}
impl<'map> CatchGradualPerformanceAttributes<'map> {
/// Create a new gradual performance calculator for osu!standard maps.
pub fn new(map: &'map Beatmap, mods: u32) -> Self {
let difficulty = CatchGradualDifficultyAttributes::new(map, mods);
let performance = CatchPP::new(map).mods(mods).passed_objects(0);
Self {
difficulty,
performance,
}
}
/// Process the next hit object and calculate the
/// performance attributes for the resulting score state.
///
/// Note that neither hits nor misses of tiny droplets require
/// to be processed. Only fruits and droplets do.
pub fn process_next_object(
&mut self,
state: CatchScoreState,
) -> Option<CatchPerformanceAttributes> {
self.process_next_n_objects(state, 1)
}
/// Same as [`process_next_object`](`CatchGradualPerformanceAttributes::process_next_object`)
/// but instead of processing only one object it process `n` many.
///
/// If `n` is 0 it will be considered as 1.
/// If there are still objects to be processed but `n` is larger than the amount
/// of remaining objects, `n` will be considered as the amount of remaining objects.
pub fn process_next_n_objects(
&mut self,
state: CatchScoreState,
n: usize,
) -> Option<CatchPerformanceAttributes> {
let mut difficulty = None;
for _ in 0..n.max(1) {
match self.difficulty.next() {
Some(attrs) => difficulty = Some(attrs),
None => break,
}
}
let difficulty = difficulty?;
let performance = self
.performance
.clone()
.attributes(difficulty)
.state(state)
.passed_objects(self.difficulty.idx)
.calculate();
Some(performance)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(not(any(feature = "async_tokio", feature = "async_std")))]
#[test]
fn correct_empty() {
let map = Beatmap::from_path("./maps/2118524.osu").expect("failed to parse map");
let mods = 64;
let mut gradual = CatchGradualPerformanceAttributes::new(&map, mods);
let state = CatchScoreState::default();
assert!(gradual
.process_next_n_objects(state.clone(), usize::MAX)
.is_some());
assert!(gradual.process_next_object(state).is_none());
}
#[cfg(not(any(feature = "async_tokio", feature = "async_std")))]
#[test]
fn next_and_next_n() {
let map = Beatmap::from_path("./maps/2118524.osu").expect("failed to parse map");
let mods = 64;
let state = CatchScoreState::default();
let mut gradual1 = CatchGradualPerformanceAttributes::new(&map, mods);
let mut gradual2 = CatchGradualPerformanceAttributes::new(&map, mods);
for _ in 0..20 {
let _ = gradual1.process_next_object(state.clone());
let _ = gradual2.process_next_object(state.clone());
}
let n = 80;
for _ in 1..n {
let _ = gradual1.process_next_object(state.clone());
}
let state = CatchScoreState {
max_combo: 101,
n_fruits: 99,
n_droplets: 2,
n_tiny_droplets: 68,
n_tiny_droplet_misses: 0,
misses: 0,
};
let next = gradual1.process_next_object(state.clone());
let next_n = gradual2.process_next_n_objects(state, n);
assert_eq!(next_n, next);
}
#[cfg(not(any(feature = "async_tokio", feature = "async_std")))]
#[test]
fn gradual_end_eq_regular() {
let map = Beatmap::from_path("./maps/2118524.osu").expect("failed to parse map");
let mods = 64;
let regular = CatchPP::new(&map).mods(mods).calculate();
let mut gradual = CatchGradualPerformanceAttributes::new(&map, mods);
let state = CatchScoreState {
max_combo: 730,
n_fruits: 728,
n_droplets: 2,
n_tiny_droplets: 291,
n_tiny_droplet_misses: 0,
misses: 0,
};
let gradual_end = gradual.process_next_n_objects(state, usize::MAX).unwrap();
assert_eq!(regular, gradual_end);
}
#[cfg(not(any(feature = "async_tokio", feature = "async_std")))]
#[test]
fn gradual_eq_regular_passed() {
let map = Beatmap::from_path("./maps/2118524.osu").expect("failed to parse map");
let mods = 64;
let n = 100;
let regular = CatchPP::new(&map).mods(mods).passed_objects(n).calculate();
let mut gradual = CatchGradualPerformanceAttributes::new(&map, mods);
let state = CatchScoreState {
max_combo: 101,
n_fruits: 99,
n_droplets: 2,
n_tiny_droplets: 68,
n_tiny_droplet_misses: 0,
misses: 0,
};
let gradual = gradual.process_next_n_objects(state, n).unwrap();
assert_eq!(regular, gradual);
}
}
+280
View File
@@ -0,0 +1,280 @@
mod catch_object;
mod difficulty_object;
mod fruit_or_juice;
mod gradual_difficulty;
mod gradual_performance;
mod movement;
mod pp;
mod slider_state;
use catch_object::CatchObject;
use difficulty_object::DifficultyObject;
use fruit_or_juice::FruitOrJuice;
pub use gradual_difficulty::*;
pub use gradual_performance::*;
use movement::Movement;
pub use pp::*;
use slider_state::SliderState;
use crate::{catch::fruit_or_juice::FruitParams, curve::CurveBuffers, Beatmap, Mods, Strains};
const SECTION_LENGTH: f64 = 750.0;
const STAR_SCALING_FACTOR: f64 = 0.153;
const ALLOWED_CATCH_RANGE: f32 = 0.8;
const CATCHER_SIZE: f32 = 106.75;
/// Difficulty calculator on osu!catch maps.
///
/// # Example
///
/// ```
/// use rosu_pp::{CatchStars, Beatmap};
///
/// # /*
/// let map: Beatmap = ...
/// # */
/// # let map = Beatmap::default();
///
/// let difficulty_attrs = CatchStars::new(&map)
/// .mods(8 + 64) // HDDT
/// .calculate();
///
/// println!("Stars: {}", difficulty_attrs.stars);
/// ```
#[derive(Clone, Debug)]
pub struct CatchStars<'map> {
map: &'map Beatmap,
mods: u32,
passed_objects: Option<usize>,
clock_rate: Option<f64>,
}
impl<'map> CatchStars<'map> {
/// Create a new difficulty calculator for osu!catch maps.
#[inline]
pub fn new(map: &'map Beatmap) -> Self {
Self {
map,
mods: 0,
passed_objects: None,
clock_rate: None,
}
}
/// Specify mods through their bit values.
///
/// See [https://github.com/ppy/osu-api/wiki#mods](https://github.com/ppy/osu-api/wiki#mods)
#[inline]
pub fn mods(mut self, mods: u32) -> Self {
self.mods = mods;
self
}
/// Amount of passed objects for partial plays, e.g. a fail.
///
/// If you want to calculate the difficulty after every few objects, instead of
/// using [`CatchStars`] multiple times with different `passed_objects`, you should use
/// [`CatchGradualDifficultyAttributes`](crate::catch::CatchGradualDifficultyAttributes).
#[inline]
pub fn passed_objects(mut self, passed_objects: usize) -> Self {
self.passed_objects = Some(passed_objects);
self
}
/// Adjust the clock rate used in the calculation.
/// If none is specified, it will take the clock rate based on the mods
/// i.e. 1.5 for DT, 0.75 for HT and 1.0 otherwise.
#[inline]
pub fn clock_rate(mut self, clock_rate: f64) -> Self {
self.clock_rate = Some(clock_rate);
self
}
/// Calculate all difficulty related values, including stars.
#[inline]
pub fn calculate(self) -> CatchDifficultyAttributes {
let (mut movement, mut attributes) = calculate_movement(self);
attributes.stars =
Movement::difficulty_value(&mut movement.strain_peaks).sqrt() * STAR_SCALING_FACTOR;
attributes
}
/// Calculate the skill strains.
///
/// Suitable to plot the difficulty of a map over time.
#[inline]
pub fn strains(self) -> Strains {
let clock_rate = self.clock_rate.unwrap_or_else(|| self.mods.speed());
let (movement, _) = calculate_movement(self);
Strains {
section_length: SECTION_LENGTH * clock_rate,
strains: movement.strain_peaks,
}
}
}
fn calculate_movement(params: CatchStars<'_>) -> (Movement, CatchDifficultyAttributes) {
let CatchStars {
map,
mods,
passed_objects,
clock_rate,
} = params;
let take = passed_objects.unwrap_or(usize::MAX);
let map_attributes = map.attributes().mods(mods);
let clock_rate = clock_rate.unwrap_or(map_attributes.clock_rate);
let attributes = CatchDifficultyAttributes {
ar: map_attributes.ar,
..Default::default()
};
let mut params = FruitParams {
attributes,
curve_bufs: CurveBuffers::default(),
last_pos: None,
last_time: 0.0,
map,
slider_state: SliderState::new(map),
ticks: Vec::new(), // using the same buffer for all sliders
with_hr: mods.hr(),
};
// BUG: Incorrect object order on 2B maps that have fruits within sliders
let mut hit_objects = map
.hit_objects
.iter()
.filter_map(|h| FruitOrJuice::new(h, &mut params))
.flatten()
.take(take);
// Hyper dash business
let half_catcher_width =
(calculate_catch_width(map_attributes.cs as f32) / 2.0 / ALLOWED_CATCH_RANGE) as f64;
let mut last_direction = 0;
let mut last_excess = half_catcher_width;
// Strain business
let mut movement = Movement::new(map_attributes.cs as f32);
let (mut prev, curr) = match (hit_objects.next(), hit_objects.next()) {
(Some(prev), Some(curr)) => (prev, curr),
(Some(_), None) | (None, None) => return (movement, params.attributes),
(None, Some(_)) => unreachable!(),
};
let mut curr_section_end = (curr.time / clock_rate / SECTION_LENGTH).ceil() * SECTION_LENGTH;
prev.init_hyper_dash(
half_catcher_width,
&curr,
&mut last_direction,
&mut last_excess,
);
// Handle first object distinctly
let h = DifficultyObject::new(&curr, &prev, movement.half_catcher_width, clock_rate);
movement.process(&h);
prev = curr;
// Handle all other objects
for curr in hit_objects {
prev.init_hyper_dash(
half_catcher_width,
&curr,
&mut last_direction,
&mut last_excess,
);
let h = DifficultyObject::new(&curr, &prev, movement.half_catcher_width, clock_rate);
let base_time = h.base.time / clock_rate;
while base_time > curr_section_end {
movement.save_current_peak();
movement.start_new_section_from(curr_section_end);
curr_section_end += SECTION_LENGTH;
}
movement.process(&h);
prev = curr;
}
movement.save_current_peak();
(movement, params.attributes)
}
#[inline]
pub(crate) fn calculate_catch_width(cs: f32) -> f32 {
let scale = 1.0 - 0.7 * (cs - 5.0) / 5.0;
CATCHER_SIZE * scale.abs() * ALLOWED_CATCH_RANGE
}
/// The result of a difficulty calculation on an osu!catch map.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct CatchDifficultyAttributes {
/// The final star rating
pub stars: f64,
/// The approach rate.
pub ar: f64,
/// The amount of fruits.
pub n_fruits: usize,
/// The amount of droplets.
pub n_droplets: usize,
/// The amount of tiny droplets.
pub n_tiny_droplets: usize,
}
impl CatchDifficultyAttributes {
/// Return the maximum combo.
#[inline]
pub fn max_combo(&self) -> usize {
self.n_fruits + self.n_droplets
}
}
/// The result of a performance calculation on an osu!catch map.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct CatchPerformanceAttributes {
/// The difficulty attributes that were used for the performance calculation
pub difficulty: CatchDifficultyAttributes,
/// The final performance points.
pub pp: f64,
}
impl CatchPerformanceAttributes {
/// Return the star value.
#[inline]
pub fn stars(&self) -> f64 {
self.difficulty.stars
}
/// Return the performance point value.
#[inline]
pub fn pp(&self) -> f64 {
self.pp
}
/// Return the maximum combo of the map.
#[inline]
pub fn max_combo(&self) -> usize {
self.difficulty.max_combo()
}
}
impl From<CatchPerformanceAttributes> for CatchDifficultyAttributes {
fn from(attributes: CatchPerformanceAttributes) -> Self {
attributes.difficulty
}
}
+144
View File
@@ -0,0 +1,144 @@
use super::DifficultyObject;
use std::cmp::Ordering;
const ABSOLUTE_PLAYER_POSITIONING_ERROR: f32 = 16.0;
const NORMALIZED_HITOBJECT_RADIUS: f32 = 41.0;
const POSITION_EPSILON: f32 = NORMALIZED_HITOBJECT_RADIUS - ABSOLUTE_PLAYER_POSITIONING_ERROR;
const DIRECTION_CHANGE_BONUS: f64 = 21.0;
const SKILL_MULTIPLIER: f64 = 900.0;
const STRAIN_DECAY_BASE: f64 = 0.2;
const DECAY_WEIGHT: f64 = 0.94;
#[derive(Clone, Debug)]
pub(crate) struct Movement {
pub(crate) half_catcher_width: f32,
last_player_position: Option<f32>,
last_distance_moved: f32,
last_strain_time: f64,
current_strain: f64,
pub(crate) curr_section_peak: f64,
pub(crate) strain_peaks: Vec<f64>,
prev_time: Option<f64>,
}
impl Movement {
#[inline]
pub(crate) fn new(cs: f32) -> Self {
let mut half_catcher_width = super::calculate_catch_width(cs) * 0.5;
half_catcher_width *= 1.0 - ((cs - 5.5).max(0.0) * 0.0625);
Self {
half_catcher_width,
last_player_position: None,
last_distance_moved: 0.0,
last_strain_time: 0.0,
current_strain: 1.0,
curr_section_peak: 1.0,
strain_peaks: Vec::with_capacity(128),
prev_time: None,
}
}
#[inline]
pub(crate) fn save_current_peak(&mut self) {
self.strain_peaks.push(self.curr_section_peak);
}
#[inline]
pub(crate) fn start_new_section_from(&mut self, time: f64) {
self.curr_section_peak = self.peak_strain(time - self.prev_time.unwrap());
}
pub(crate) fn process(&mut self, current: &DifficultyObject<'_>) {
self.current_strain *= strain_decay(current.delta);
self.current_strain += self.strain_value_of(current) * SKILL_MULTIPLIER;
self.curr_section_peak = self.current_strain.max(self.curr_section_peak);
self.prev_time.replace(current.start_time);
}
pub(crate) fn difficulty_value(strain_peaks: &mut [f64]) -> f64 {
let mut difficulty = 0.0;
let mut weight = 1.0;
strain_peaks.sort_unstable_by(|a, b| b.partial_cmp(a).unwrap_or(Ordering::Equal));
for &strain in strain_peaks.iter() {
difficulty += strain * weight;
weight *= DECAY_WEIGHT;
}
difficulty
}
fn strain_value_of(&mut self, current: &DifficultyObject<'_>) -> f64 {
let last_player_pos = self
.last_player_position
.unwrap_or(current.last_normalized_pos);
let mut pos = last_player_pos
.max(current.normalized_pos - POSITION_EPSILON)
.min(current.normalized_pos + POSITION_EPSILON);
let dist_moved = pos - last_player_pos;
let weighted_strain_time = current.strain_time + 13.0 + (3.0 / current.clock_rate);
let mut dist_addition = (dist_moved.abs().powf(1.3) / 510.0) as f64;
if dist_moved.abs() > 0.1 {
if self.last_distance_moved.abs() > 0.1
&& dist_moved.signum() != self.last_distance_moved.signum()
{
let bonus_factor = (dist_moved.abs().min(50.0) / 50.0) as f64;
let anti_flow_factor =
(self.last_distance_moved.abs().min(70.0) / 70.0).max(0.38) as f64;
dist_addition += DIRECTION_CHANGE_BONUS / (self.last_strain_time + 16.0).sqrt()
* bonus_factor
* anti_flow_factor
* (1.0 - (weighted_strain_time / 1000.0).powi(3)).max(0.0);
}
dist_addition += (12.5 * dist_moved.abs().min(NORMALIZED_HITOBJECT_RADIUS * 2.0)
/ (NORMALIZED_HITOBJECT_RADIUS * 6.0)) as f64
/ weighted_strain_time.sqrt();
}
let mut edge_dash_bonus = 0.0;
if current.last.hyper_dist <= 20.0 {
if !current.last.hyper_dash {
edge_dash_bonus += 5.7;
} else {
pos = current.normalized_pos;
}
dist_addition *= 1.0
+ edge_dash_bonus
* ((20.0 - current.last.hyper_dist) / 20.0) as f64
* ((current.strain_time * current.clock_rate).min(265.0) / 265.0).powf(1.5);
}
self.last_player_position.replace(pos);
self.last_distance_moved = dist_moved;
self.last_strain_time = current.strain_time;
dist_addition / weighted_strain_time
}
#[inline]
fn peak_strain(&self, delta_time: f64) -> f64 {
self.current_strain * strain_decay(delta_time)
}
}
#[inline]
fn strain_decay(ms: f64) -> f64 {
STRAIN_DECAY_BASE.powf(ms / 1000.0)
}
+606
View File
@@ -0,0 +1,606 @@
use super::{CatchDifficultyAttributes, CatchPerformanceAttributes, CatchScoreState, CatchStars};
use crate::{Beatmap, DifficultyAttributes, Mods, PerformanceAttributes};
/// Performance calculator on osu!catch maps.
///
/// # Example
///
/// ```
/// use rosu_pp::{CatchPP, Beatmap};
///
/// # /*
/// let map: Beatmap = ...
/// # */
/// # let map = Beatmap::default();
///
/// let pp_result = CatchPP::new(&map)
/// .mods(8 + 64) // HDDT
/// .combo(1234)
/// .misses(1)
/// .accuracy(98.5)
/// .calculate();
///
/// println!("PP: {} | Stars: {}", pp_result.pp(), pp_result.stars());
///
/// let next_result = CatchPP::new(&map)
/// .attributes(pp_result) // reusing previous results for performance
/// .mods(8 + 64) // has to be the same to reuse attributes
/// .accuracy(99.5)
/// .calculate();
///
/// println!("PP: {} | Stars: {}", next_result.pp(), next_result.stars());
/// ```
#[derive(Clone, Debug)]
#[allow(clippy::upper_case_acronyms)]
pub struct CatchPP<'map> {
map: &'map Beatmap,
attributes: Option<CatchDifficultyAttributes>,
mods: u32,
combo: Option<usize>,
pub(crate) n_fruits: Option<usize>,
pub(crate) n_droplets: Option<usize>,
pub(crate) n_tiny_droplets: Option<usize>,
pub(crate) n_tiny_droplet_misses: Option<usize>,
pub(crate) n_misses: usize,
passed_objects: Option<usize>,
clock_rate: Option<f64>,
}
impl<'map> CatchPP<'map> {
/// Create a new performance calculator for osu!catch maps.
#[inline]
pub fn new(map: &'map Beatmap) -> Self {
Self {
map,
attributes: None,
mods: 0,
combo: None,
n_fruits: None,
n_droplets: None,
n_tiny_droplets: None,
n_tiny_droplet_misses: None,
n_misses: 0,
passed_objects: None,
clock_rate: None,
}
}
/// Provide the result of a previous difficulty or performance calculation.
/// If you already calculated the attributes for the current map-mod combination,
/// be sure to put them in here so that they don't have to be recalculated.
#[inline]
pub fn attributes(mut self, attributes: impl CatchAttributeProvider) -> Self {
if let Some(attributes) = attributes.attributes() {
self.attributes.replace(attributes);
}
self
}
/// Specify mods through their bit values.
///
/// See [https://github.com/ppy/osu-api/wiki#mods](https://github.com/ppy/osu-api/wiki#mods)
#[inline]
pub fn mods(mut self, mods: u32) -> Self {
self.mods = mods;
self
}
/// Specify the max combo of the play.
#[inline]
pub fn combo(mut self, combo: usize) -> Self {
self.combo.replace(combo);
self
}
/// Specify the amount of fruits of a play i.e. n300.
#[inline]
pub fn fruits(mut self, n_fruits: usize) -> Self {
self.n_fruits.replace(n_fruits);
self
}
/// Specify the amount of droplets of a play i.e. n100.
#[inline]
pub fn droplets(mut self, n_droplets: usize) -> Self {
self.n_droplets.replace(n_droplets);
self
}
/// Specify the amount of tiny droplets of a play i.e. n50.
#[inline]
pub fn tiny_droplets(mut self, n_tiny_droplets: usize) -> Self {
self.n_tiny_droplets.replace(n_tiny_droplets);
self
}
/// Specify the amount of tiny droplet misses of a play i.e. n_katu.
#[inline]
pub fn tiny_droplet_misses(mut self, n_tiny_droplet_misses: usize) -> Self {
self.n_tiny_droplet_misses.replace(n_tiny_droplet_misses);
self
}
/// Specify the amount of fruit / droplet misses of the play.
#[inline]
pub fn misses(mut self, n_misses: usize) -> Self {
self.n_misses = n_misses;
self
}
/// Amount of passed objects for partial plays, e.g. a fail.
///
/// If you want to calculate the performance after every few objects, instead of
/// using [`CatchPP`] multiple times with different `passed_objects`, you should use
/// [`CatchGradualPerformanceAttributes`](crate::catch::CatchGradualPerformanceAttributes).
#[inline]
pub fn passed_objects(mut self, passed_objects: usize) -> Self {
self.passed_objects.replace(passed_objects);
self
}
/// Adjust the clock rate used in the calculation.
/// If none is specified, it will take the clock rate based on the mods
/// i.e. 1.5 for DT, 0.75 for HT and 1.0 otherwise.
#[inline]
pub fn clock_rate(mut self, clock_rate: f64) -> Self {
self.clock_rate = Some(clock_rate);
self
}
/// Provide parameters through an [`CatchScoreState`].
#[inline]
pub fn state(mut self, state: CatchScoreState) -> Self {
let CatchScoreState {
max_combo,
n_fruits,
n_droplets,
n_tiny_droplets,
n_tiny_droplet_misses,
misses,
} = state;
self.combo = Some(max_combo);
self.n_fruits = Some(n_fruits);
self.n_droplets = Some(n_droplets);
self.n_tiny_droplets = Some(n_tiny_droplets);
self.n_tiny_droplet_misses = Some(n_tiny_droplet_misses);
self.n_misses = misses;
self
}
/// Generate the hit results with respect to the given accuracy between `0` and `100`.
///
/// Be sure to set `misses` beforehand! Also, if available, set `attributes` beforehand.
pub fn accuracy(mut self, mut acc: f64) -> Self {
if self.attributes.is_none() {
let mut calculator = CatchStars::new(self.map).mods(self.mods);
if let Some(passed_objects) = self.passed_objects {
calculator = calculator.passed_objects(passed_objects);
}
if let Some(clock_rate) = self.clock_rate {
calculator = calculator.clock_rate(clock_rate);
}
self.attributes = Some(calculator.calculate());
}
let attributes = self.attributes.as_ref().unwrap();
let n_droplets = self
.n_droplets
.unwrap_or_else(|| attributes.n_droplets.saturating_sub(self.n_misses));
let max_combo = attributes.max_combo();
let n_fruits = self.n_fruits.unwrap_or_else(|| {
max_combo
.saturating_sub(self.n_misses)
.saturating_sub(n_droplets)
});
let max_tiny_droplets = attributes.n_tiny_droplets;
acc /= 100.0;
let n_tiny_droplets = self.n_tiny_droplets.unwrap_or_else(|| {
((acc * (max_combo + max_tiny_droplets) as f64).round() as usize)
.saturating_sub(n_fruits)
.saturating_sub(n_droplets)
});
let n_tiny_droplet_misses = max_tiny_droplets.saturating_sub(n_tiny_droplets);
self.n_fruits.replace(n_fruits);
self.n_droplets.replace(n_droplets);
self.n_tiny_droplets.replace(n_tiny_droplets);
self.n_tiny_droplet_misses.replace(n_tiny_droplet_misses);
self
}
fn assert_hitresults(self, attributes: CatchDifficultyAttributes) -> CatchPPInner {
let max_combo = attributes.max_combo();
let correct_combo_hits = self
.n_fruits
.and_then(|f| self.n_droplets.map(|d| f + d + self.n_misses))
.filter(|h| *h == max_combo);
let correct_fruits = self
.n_fruits
.filter(|f| *f >= attributes.n_fruits.saturating_sub(self.n_misses));
let correct_droplets = self
.n_droplets
.filter(|d| *d >= attributes.n_droplets.saturating_sub(self.n_misses));
let correct_tinies = self
.n_tiny_droplets
.and_then(|t| self.n_tiny_droplet_misses.map(|m| t + m))
.filter(|h| *h == attributes.n_tiny_droplets);
if correct_combo_hits
.and(correct_fruits)
.and(correct_droplets)
.and(correct_tinies)
.is_none()
{
let mut n_fruits = self.n_fruits.unwrap_or(0);
let mut n_droplets = self.n_droplets.unwrap_or(0);
let mut n_tiny_droplets = self.n_tiny_droplets.unwrap_or(0);
let n_tiny_droplet_misses = self.n_tiny_droplet_misses.unwrap_or(0);
let missing = max_combo
.saturating_sub(n_fruits)
.saturating_sub(n_droplets)
.saturating_sub(self.n_misses);
let missing_fruits =
missing.saturating_sub(attributes.n_droplets.saturating_sub(n_droplets));
n_fruits += missing_fruits;
n_droplets += missing.saturating_sub(missing_fruits);
n_tiny_droplets += attributes
.n_tiny_droplets
.saturating_sub(n_tiny_droplets)
.saturating_sub(n_tiny_droplet_misses);
return CatchPPInner {
attributes,
mods: self.mods,
combo: self.combo,
n_fruits,
n_droplets,
n_tiny_droplets,
n_tiny_droplet_misses,
n_misses: self.n_misses,
};
}
CatchPPInner {
attributes,
mods: self.mods,
combo: self.combo,
n_fruits: self.n_fruits.unwrap_or(0),
n_droplets: self.n_droplets.unwrap_or(0),
n_tiny_droplets: self.n_tiny_droplets.unwrap_or(0),
n_tiny_droplet_misses: self.n_tiny_droplet_misses.unwrap_or(0),
n_misses: self.n_misses,
}
}
/// Calculate all performance related values, including pp and stars.
pub fn calculate(mut self) -> CatchPerformanceAttributes {
let attributes = self.attributes.take().unwrap_or_else(|| {
let mut calculator = CatchStars::new(self.map).mods(self.mods);
if let Some(passed_objects) = self.passed_objects {
calculator = calculator.passed_objects(passed_objects);
}
if let Some(clock_rate) = self.clock_rate {
calculator = calculator.clock_rate(clock_rate);
}
calculator.calculate()
});
self.assert_hitresults(attributes).calculate()
}
}
struct CatchPPInner {
attributes: CatchDifficultyAttributes,
mods: u32,
combo: Option<usize>,
n_fruits: usize,
n_droplets: usize,
n_tiny_droplets: usize,
n_tiny_droplet_misses: usize,
n_misses: usize,
}
impl CatchPPInner {
fn calculate(self) -> CatchPerformanceAttributes {
let attributes = &self.attributes;
let stars = attributes.stars;
let max_combo = attributes.max_combo();
// Relying heavily on aim
let mut pp = (5.0 * (stars / 0.0049).max(1.0) - 4.0).powi(2) / 100_000.0;
let mut combo_hits = self.combo_hits();
if combo_hits == 0 {
combo_hits = max_combo;
}
// Longer maps are worth more
let len_bonus = 0.95
+ 0.3 * (combo_hits as f64 / 2500.0).min(1.0)
+ (combo_hits > 2500) as u8 as f64 * (combo_hits as f64 / 2500.0).log10() * 0.475;
pp *= len_bonus;
// Penalize misses exponentially
pp *= 0.97_f64.powi(self.n_misses as i32);
// Combo scaling
if let Some(combo) = self.combo.filter(|_| max_combo > 0) {
pp *= (combo as f64 / max_combo as f64).powf(0.8).min(1.0);
}
// AR scaling
let ar = attributes.ar;
let mut ar_factor = 1.0;
if ar > 9.0 {
ar_factor += 0.1 * (ar - 9.0) + (ar > 10.0) as u8 as f64 * 0.1 * (ar - 10.0);
} else if ar < 8.0 {
ar_factor += 0.025 * (8.0 - ar);
}
pp *= ar_factor;
// HD bonus
if self.mods.hd() {
if ar <= 10.0 {
pp *= 1.05 + 0.075 * (10.0 - ar);
} else if ar > 10.0 {
pp *= 1.01 + 0.04 * (11.0 - ar.min(11.0));
}
}
// FL bonus
if self.mods.fl() {
pp *= 1.35 * len_bonus;
}
// Accuracy scaling
pp *= self.acc().powf(5.5);
// NF penalty
if self.mods.nf() {
pp *= 0.9;
}
CatchPerformanceAttributes {
difficulty: self.attributes,
pp,
}
}
#[inline]
fn combo_hits(&self) -> usize {
self.n_fruits + self.n_droplets + self.n_misses
}
#[inline]
fn successful_hits(&self) -> usize {
self.n_fruits + self.n_droplets + self.n_tiny_droplets
}
#[inline]
fn total_hits(&self) -> usize {
self.successful_hits() + self.n_tiny_droplet_misses + self.n_misses
}
#[inline]
fn acc(&self) -> f64 {
let total_hits = self.total_hits();
if total_hits == 0 {
1.0
} else {
(self.successful_hits() as f64 / total_hits as f64)
.max(0.0)
.min(1.0)
}
}
}
/// Abstract type to provide flexibility when passing difficulty attributes to a performance calculation.
pub trait CatchAttributeProvider {
/// Provide the actual difficulty attributes.
fn attributes(self) -> Option<CatchDifficultyAttributes>;
}
impl CatchAttributeProvider for CatchDifficultyAttributes {
#[inline]
fn attributes(self) -> Option<CatchDifficultyAttributes> {
Some(self)
}
}
impl CatchAttributeProvider for CatchPerformanceAttributes {
#[inline]
fn attributes(self) -> Option<CatchDifficultyAttributes> {
Some(self.difficulty)
}
}
impl CatchAttributeProvider for DifficultyAttributes {
#[inline]
fn attributes(self) -> Option<CatchDifficultyAttributes> {
#[allow(irrefutable_let_patterns)]
if let Self::Catch(attributes) = self {
Some(attributes)
} else {
None
}
}
}
impl CatchAttributeProvider for PerformanceAttributes {
#[inline]
fn attributes(self) -> Option<CatchDifficultyAttributes> {
#[allow(irrefutable_let_patterns)]
if let Self::Catch(attributes) = self {
Some(attributes.difficulty)
} else {
None
}
}
}
#[cfg(test)]
mod test {
use super::*;
use crate::Beatmap;
fn attributes() -> CatchDifficultyAttributes {
CatchDifficultyAttributes {
n_fruits: 1234,
n_droplets: 567,
n_tiny_droplets: 2345,
..Default::default()
}
}
#[test]
fn fruits_only_accuracy() {
let map = Beatmap::default();
let attributes = attributes();
let total_objects = attributes.n_fruits + attributes.n_droplets;
let target_acc = 97.5;
let calculator = CatchPP::new(&map)
.attributes(attributes)
.passed_objects(total_objects)
.accuracy(target_acc);
let numerator = calculator.n_fruits.unwrap_or(0)
+ calculator.n_droplets.unwrap_or(0)
+ calculator.n_tiny_droplets.unwrap_or(0);
let denominator =
numerator + calculator.n_tiny_droplet_misses.unwrap_or(0) + calculator.n_misses;
let acc = 100.0 * numerator as f64 / denominator as f64;
assert!(
(target_acc - acc).abs() < 1.0,
"Expected: {} | Actual: {}",
target_acc,
acc
);
}
#[test]
fn fruits_accuracy_droplets_and_tiny_droplets() {
let map = Beatmap::default();
let attributes = attributes();
let total_objects = attributes.n_fruits + attributes.n_droplets;
let target_acc = 97.5;
let n_droplets = 550;
let n_tiny_droplets = 2222;
let calculator = CatchPP::new(&map)
.attributes(attributes)
.passed_objects(total_objects)
.droplets(n_droplets)
.tiny_droplets(n_tiny_droplets)
.accuracy(target_acc);
assert_eq!(
n_droplets,
calculator.n_droplets.unwrap(),
"Expected: {} | Actual: {}",
n_droplets,
calculator.n_droplets.unwrap()
);
let numerator = calculator.n_fruits.unwrap_or(0)
+ calculator.n_droplets.unwrap_or(0)
+ calculator.n_tiny_droplets.unwrap_or(0);
let denominator =
numerator + calculator.n_tiny_droplet_misses.unwrap_or(0) + calculator.n_misses;
let acc = 100.0 * numerator as f64 / denominator as f64;
assert!(
(target_acc - acc).abs() < 1.0,
"Expected: {} | Actual: {}",
target_acc,
acc
);
}
#[test]
fn fruits_missing_objects() {
let map = Beatmap::default();
let attributes = attributes();
let total_objects = attributes.n_fruits + attributes.n_droplets;
let n_fruits = attributes.n_fruits - 10;
let n_droplets = attributes.n_droplets - 5;
let n_tiny_droplets = attributes.n_tiny_droplets - 50;
let n_tiny_droplet_misses = 20;
let n_misses = 2;
let calculator = CatchPP::new(&map)
.attributes(attributes.clone())
.passed_objects(total_objects)
.fruits(n_fruits)
.droplets(n_droplets)
.tiny_droplets(n_tiny_droplets)
.tiny_droplet_misses(n_tiny_droplet_misses)
.misses(n_misses)
.assert_hitresults(attributes.clone());
assert!(
(attributes.n_fruits as i32 - calculator.n_fruits as i32).abs() <= n_misses as i32,
"Expected: {} | Actual: {} [+/- {} misses]",
attributes.n_fruits,
calculator.n_fruits,
n_misses
);
assert_eq!(
attributes.n_droplets,
calculator.n_droplets - (n_misses - (attributes.n_fruits - calculator.n_fruits)),
"Expected: {} | Actual: {}",
attributes.n_droplets,
calculator.n_droplets - (n_misses - (attributes.n_fruits - calculator.n_fruits)),
);
assert_eq!(
attributes.n_tiny_droplets,
calculator.n_tiny_droplets + calculator.n_tiny_droplet_misses,
"Expected: {} | Actual: {}",
attributes.n_tiny_droplets,
calculator.n_tiny_droplets + calculator.n_tiny_droplet_misses,
);
}
}
+102
View File
@@ -0,0 +1,102 @@
use crate::{Beatmap, ControlPoint, ControlPointIter};
#[derive(Clone, Debug)]
pub(crate) struct SliderState<'p> {
control_points: ControlPointIter<'p>,
next: Option<ControlPoint>,
pub(crate) beat_len: f64,
pub(crate) slider_velocity: f64,
}
impl<'p> SliderState<'p> {
#[inline]
pub(crate) fn new(map: &'p Beatmap) -> Self {
let mut control_points = ControlPointIter::new(map);
let (beat_len, slider_velocity) = match control_points.next() {
Some(ControlPoint::Timing { beat_len, .. }) => (beat_len, 1.0),
Some(ControlPoint::Difficulty {
slider_velocity, ..
}) => (1000.0, slider_velocity),
None => (1000.0, 1.0),
};
Self {
next: control_points.next(),
control_points,
beat_len,
slider_velocity,
}
}
#[inline]
pub(crate) fn update(&mut self, time: f64) {
while let Some(next) = self.next.as_ref().filter(|n| time >= n.time()) {
match next {
ControlPoint::Timing { beat_len, .. } => {
self.beat_len = *beat_len;
self.slider_velocity = 1.0;
}
ControlPoint::Difficulty {
slider_velocity, ..
} => self.slider_velocity = *slider_velocity,
}
self.next = self.control_points.next();
}
}
}
#[cfg(test)]
mod test {
use crate::{
parse::{DifficultyPoint, TimingPoint},
Beatmap,
};
use super::SliderState;
#[test]
fn catch_slider_state() {
let map = Beatmap {
timing_points: vec![
TimingPoint {
time: 1.0,
beat_len: 10.0,
},
TimingPoint {
time: 3.0,
beat_len: 20.0,
},
TimingPoint {
time: 4.0,
beat_len: 30.0,
},
],
difficulty_points: vec![
DifficultyPoint {
time: 2.0,
speed_multiplier: 15.0,
},
DifficultyPoint {
time: 5.0,
speed_multiplier: 45.0,
},
],
..Default::default()
};
let mut state = SliderState::new(&map);
state.update(2.0);
assert!((state.beat_len - 10.0).abs() <= f64::EPSILON);
state.update(3.0);
assert!((state.beat_len - 20.0).abs() <= f64::EPSILON);
assert!((state.slider_velocity - 1.0).abs() <= f64::EPSILON);
state.update(5.0);
assert!((state.beat_len - 30.0).abs() <= f64::EPSILON);
assert!((state.slider_velocity - 45.0).abs() <= f64::EPSILON);
}
}