refactor!: overhauled gradual calc for osu

This commit is contained in:
MaxOhn
2023-11-07 16:26:50 +01:00
parent 5fd29c2ab4
commit 79e19d5a9b
15 changed files with 402 additions and 375 deletions
+2 -1
View File
@@ -11,9 +11,10 @@ description = "osu! difficulty and pp calculation for all modes"
keywords = ["osu", "pp", "stars", "async"]
[features]
default = []
default = ["gradual"]
async_std = ["async-std"]
async_tokio = ["tokio"]
gradual = []
[dependencies]
async-std = { version = "1.9", optional = true }
+2 -24
View File
@@ -8,9 +8,8 @@ use crate::{
osu::{OsuDifficultyAttributes, OsuObject, ScalingFactor},
taiko::{IntoTaikoObjectIter, TaikoObject},
util::FloatExt,
AnyPP, AnyStars, Beatmap, CatchPP, CatchStars, GameMode, GradualDifficultyAttributes,
GradualPerformanceAttributes, ManiaPP, ManiaStars, Mods, OsuPP, OsuStars,
PerformanceAttributes, Strains, TaikoPP, TaikoStars,
AnyPP, AnyStars, Beatmap, CatchPP, CatchStars, GameMode, ManiaPP, ManiaStars, Mods, OsuPP,
OsuStars, PerformanceAttributes, Strains, TaikoPP, TaikoStars,
};
/// Provides some additional methods on [`Beatmap`].
@@ -35,17 +34,6 @@ pub trait BeatmapExt {
/// Suitable to plot the difficulty of a map over time.
fn strains(&self, mods: u32) -> Strains;
/// Return an iterator that gives you the [`DifficultyAttributes`](crate::DifficultyAttributes) after each hit object.
///
/// Suitable to efficiently get the map's star rating after multiple different locations.
fn gradual_difficulty(&self, mods: u32) -> GradualDifficultyAttributes<'_>;
/// Return a struct that gives you the [`PerformanceAttributes`] after every (few) hit object(s).
///
/// Suitable to efficiently get a score's performance after multiple different locations,
/// i.e. live update a score's pp.
fn gradual_performance(&self, mods: u32) -> GradualPerformanceAttributes<'_>;
/// Process each [`HitObject`](crate::parse::HitObject) into a an osu!-specific [`OsuObject`],
/// just like the difficulty calculation does.
fn osu_hitobjects(&self, mods: u32) -> Vec<OsuObject>;
@@ -112,16 +100,6 @@ impl BeatmapExt for Beatmap {
}
}
#[inline]
fn gradual_difficulty(&self, mods: u32) -> GradualDifficultyAttributes<'_> {
GradualDifficultyAttributes::new(self, mods)
}
#[inline]
fn gradual_performance(&self, mods: u32) -> GradualPerformanceAttributes<'_> {
GradualPerformanceAttributes::new(self, mods)
}
fn osu_hitobjects(&self, mods: u32) -> Vec<OsuObject> {
let attrs = self.attributes().mods(mods).build();
let scaling_factor = ScalingFactor::new(attrs.cs);
+8 -115
View File
@@ -1,9 +1,9 @@
use crate::{
catch::{CatchGradualDifficultyAttributes, CatchGradualPerformanceAttributes, CatchScoreState},
mania::{ManiaGradualDifficultyAttributes, ManiaGradualPerformanceAttributes, ManiaScoreState},
osu::{OsuGradualDifficultyAttributes, OsuGradualPerformanceAttributes, OsuScoreState},
taiko::{TaikoGradualDifficultyAttributes, TaikoGradualPerformanceAttributes, TaikoScoreState},
Beatmap, DifficultyAttributes, GameMode, PerformanceAttributes,
catch::{CatchGradualDifficultyAttributes, CatchGradualPerformanceAttributes},
mania::{ManiaGradualDifficultyAttributes, ManiaGradualPerformanceAttributes},
osu::{OsuGradualDifficultyAttributes, OsuGradualPerformanceAttributes},
taiko::{TaikoGradualDifficultyAttributes, TaikoGradualPerformanceAttributes},
Beatmap, DifficultyAttributes, GameMode, PerformanceAttributes, ScoreState,
};
/// Gradually calculate the difficulty attributes on maps of any mode.
@@ -37,7 +37,6 @@ use crate::{
/// }
/// ```
#[derive(Debug)]
#[allow(clippy::large_enum_variant)]
pub enum GradualDifficultyAttributes<'map> {
/// Gradual osu!standard difficulty attributes.
Osu(OsuGradualDifficultyAttributes),
@@ -86,112 +85,6 @@ impl Iterator for GradualDifficultyAttributes<'_> {
}
}
/// Aggregation for a score's current state i.e. what is
/// the maximum combo so far, what are the current
/// hitresults and what is the current score.
///
/// This struct is used for [`GradualPerformanceAttributes`].
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct ScoreState {
/// Maximum combo that the score has had so far.
/// **Not** the maximum possible combo of the map so far.
///
/// Note that for osu!catch only fruits and droplets are considered for combo.
///
/// Irrelevant for osu!mania.
pub max_combo: usize,
/// Amount of current gekis (n320 for osu!mania).
pub n_geki: usize,
/// Amount of current katus (tiny droplet misses for osu!catch / n200 for osu!mania).
pub n_katu: usize,
/// Amount of current 300s (fruits for osu!catch).
pub n300: usize,
/// Amount of current 100s (droplets for osu!catch).
pub n100: usize,
/// Amount of current 50s (tiny droplets for osu!catch).
pub n50: usize,
/// Amount of current misses (fruits + droplets for osu!catch).
pub n_misses: usize,
}
impl ScoreState {
/// Create a new empty score state.
#[inline]
pub fn new() -> Self {
Self::default()
}
/// Return the total amount of hits by adding everything up based on the mode.
#[inline]
pub fn total_hits(&self, mode: GameMode) -> usize {
let mut amount = self.n300 + self.n100 + self.n_misses;
if mode != GameMode::Taiko {
amount += self.n50;
if mode != GameMode::Osu {
amount += self.n_katu;
amount += (mode != GameMode::Catch) as usize * self.n_geki;
}
}
amount
}
}
impl From<ScoreState> for OsuScoreState {
#[inline]
fn from(state: ScoreState) -> Self {
Self {
max_combo: state.max_combo,
n300: state.n300,
n100: state.n100,
n50: state.n50,
n_misses: state.n_misses,
}
}
}
impl From<ScoreState> for TaikoScoreState {
#[inline]
fn from(state: ScoreState) -> Self {
Self {
max_combo: state.max_combo,
n300: state.n300,
n100: state.n100,
n_misses: state.n_misses,
}
}
}
impl From<ScoreState> for CatchScoreState {
#[inline]
fn from(state: ScoreState) -> Self {
Self {
max_combo: state.max_combo,
n_fruits: state.n300,
n_droplets: state.n100,
n_tiny_droplets: state.n50,
n_tiny_droplet_misses: state.n_katu,
n_misses: state.n_misses,
}
}
}
impl From<ScoreState> for ManiaScoreState {
#[inline]
fn from(state: ScoreState) -> Self {
Self {
n320: state.n_geki,
n300: state.n300,
n200: state.n_katu,
n100: state.n100,
n50: state.n50,
n_misses: state.n_misses,
}
}
}
/// Gradually calculate the performance attributes on maps of any mode.
///
/// After each hit object you can call
@@ -331,9 +224,9 @@ impl<'map> GradualPerformanceAttributes<'map> {
n: usize,
) -> Option<PerformanceAttributes> {
match self {
GradualPerformanceAttributes::Osu(o) => o
.process_next_n_objects(state.into(), n)
.map(PerformanceAttributes::Osu),
GradualPerformanceAttributes::Osu(o) => {
o.nth(state.into(), n).map(PerformanceAttributes::Osu)
}
GradualPerformanceAttributes::Taiko(t) => t
.process_next_n_objects(state.into(), n)
.map(PerformanceAttributes::Taiko),
+6 -1
View File
@@ -198,8 +198,10 @@ pub mod parse;
pub mod beatmap;
pub use beatmap::{Beatmap, BeatmapExt, GameMode};
#[cfg(feature = "gradual")]
mod gradual;
pub use gradual::{GradualDifficultyAttributes, GradualPerformanceAttributes, ScoreState};
#[cfg(feature = "gradual")]
pub use gradual::{GradualDifficultyAttributes, GradualPerformanceAttributes};
mod pp;
pub use pp::{AnyPP, AttributeProvider, HitResultPriority};
@@ -207,6 +209,9 @@ pub use pp::{AnyPP, AttributeProvider, HitResultPriority};
mod stars;
pub use stars::AnyStars;
mod score_state;
pub use score_state::*;
mod curve;
mod mods;
mod util;
+36 -46
View File
@@ -2,6 +2,7 @@ use crate::{
osu::osu_object::{NestedObjectKind, OsuObjectKind},
parse::Pos2,
};
use std::pin::Pin;
use super::{osu_object::OsuSlider, OsuObject, ScalingFactor};
@@ -9,7 +10,7 @@ use super::{osu_object::OsuSlider, OsuObject, ScalingFactor};
pub(crate) struct OsuDifficultyObject<'h> {
pub(crate) start_time: f64,
pub(crate) delta_time: f64,
pub(crate) base: &'h OsuObject,
pub(crate) base: Pin<&'h OsuObject>,
pub(crate) strain_time: f64,
pub(crate) dists: Distances,
pub(crate) idx: usize,
@@ -19,7 +20,7 @@ impl<'h> OsuDifficultyObject<'h> {
pub(crate) const MIN_DELTA_TIME: u32 = 25;
pub(crate) fn new(
base: &'h OsuObject,
base: Pin<&'h OsuObject>,
last: &'h OsuObject,
clock_rate: f64,
idx: usize,
@@ -89,36 +90,40 @@ impl Distances {
const MAXIMUM_SLIDER_RADIUS: f32 = Self::NORMALISED_RADIUS * 2.4;
const ASSUMED_SLIDER_RADIUS: f32 = Self::NORMALISED_RADIUS * 1.8;
/// Create a new instance of [`Distances`].
///
/// By taking in [`Pin<&mut OsuObject>`](Pin), we imply that the argument will be
/// modified but it won't be moved.
pub(crate) fn new(
base: &mut OsuObject,
base: &mut Pin<&mut OsuObject>,
last: &OsuObject,
last_last: Option<&OsuObject>,
clock_rate: f64,
strain_time: f64,
scaling_factor_: &ScalingFactor,
) -> Self {
let mut this =
if let Some(slider_values) = Self::compute_slider_cursor_pos(base, scaling_factor_) {
let SliderValues {
lazy_travel_dist,
slider,
} = slider_values;
let pos = base.pos();
let stack_offset = base.stack_offset;
let repeat_count = slider.repeat_count();
let mut this = if let OsuObjectKind::Slider(ref mut slider) = base.kind {
let lazy_travel_dist =
Self::compute_slider_travel_dist(pos, stack_offset, slider, scaling_factor_);
Self {
// * Bonus for repeat sliders until a better per nested object strain system can be achieved.
travel_dist: (lazy_travel_dist
* (1.0 + repeat_count as f64 / 2.5).powf(1.0 / 2.5) as f32)
as f64,
travel_time: (base.lazy_travel_time() / clock_rate)
.max(OsuDifficultyObject::MIN_DELTA_TIME as f64),
lazy_travel_dist,
..Default::default()
}
} else {
Self::default()
};
let repeat_count = slider.repeat_count();
Self {
// * Bonus for repeat sliders until a better per nested object strain system can be achieved.
travel_dist: (lazy_travel_dist
* (1.0 + repeat_count as f64 / 2.5).powf(1.0 / 2.5) as f32)
as f64,
travel_time: (base.lazy_travel_time() / clock_rate)
.max(OsuDifficultyObject::MIN_DELTA_TIME as f64),
lazy_travel_dist,
..Default::default()
}
} else {
Self::default()
};
// * We don't need to calculate either angle or distance when
// * one of the last->curr objects is a spinner
@@ -196,26 +201,19 @@ impl Distances {
this
}
pub(crate) fn compute_slider_cursor_pos<'h>(
hit_object: &'h mut OsuObject,
pub(crate) fn compute_slider_travel_dist(
pos: Pos2,
stack_offset: Pos2,
slider: &mut OsuSlider,
scaling_factor_: &ScalingFactor,
) -> Option<SliderValues<'h>> {
let pos = hit_object.pos();
let slider = if let OsuObjectKind::Slider(slider) = &mut hit_object.kind {
slider
} else {
return None;
};
let mut curr_cursor_pos = pos + hit_object.stack_offset;
) -> f32 {
let mut curr_cursor_pos = pos + stack_offset;
let scaling_factor = Self::NORMALISED_RADIUS as f64 / scaling_factor_.radius as f64;
let mut lazy_travel_dist: f32 = 0.0;
for (curr_movement_obj, i) in slider.nested_objects.iter().zip(1..) {
let mut curr_movement =
(curr_movement_obj.pos + hit_object.stack_offset) - curr_cursor_pos;
let mut curr_movement = (curr_movement_obj.pos + stack_offset) - curr_cursor_pos;
let mut curr_movement_len = scaling_factor * curr_movement.length() as f64;
// * Amount of movement required so that the cursor position needs to be updated.
@@ -253,18 +251,10 @@ impl Distances {
slider.lazy_end_pos = curr_cursor_pos;
Some(SliderValues {
lazy_travel_dist,
slider,
})
lazy_travel_dist
}
fn get_end_cursor_pos(hit_object: &OsuObject) -> Pos2 {
hit_object.lazy_end_pos()
}
}
pub(crate) struct SliderValues<'s> {
lazy_travel_dist: f32,
slider: &'s OsuSlider,
}
+112 -74
View File
@@ -1,17 +1,21 @@
#![cfg(feature = "gradual")]
use std::{
fmt::{Debug, Formatter, Result as FmtResult},
mem,
pin::Pin,
};
use crate::{curve::CurveBuffers, Beatmap, Mods};
use crate::{Beatmap, Mods};
use self::osu_objects::OsuObjects;
use super::{
difficulty_object::{Distances, OsuDifficultyObject},
old_stacking,
osu_object::{ObjectParameters, OsuObject, OsuObjectKind},
osu_object::{OsuObject, OsuObjectKind},
scaling_factor::ScalingFactor,
skills::{Skill, Skills},
stacking, OsuDifficultyAttributes, DIFFICULTY_MULTIPLIER, FADE_IN_DURATION_MULTIPLIER,
OsuDifficultyAttributes, DIFFICULTY_MULTIPLIER, FADE_IN_DURATION_MULTIPLIER,
PERFORMANCE_BASE_MULTIPLIER, PREEMPT_MIN,
};
@@ -45,15 +49,15 @@ use super::{
/// // ...
/// }
/// ```
#[derive(Clone)]
pub struct OsuGradualDifficultyAttributes {
pub(crate) idx: usize,
mods: u32,
attrs: OsuDifficultyAttributes,
// Unused but `diff_objects`' lifetimes secretly depend on it
#[allow(unused)]
hit_objects: Vec<OsuObject>,
// Lifetimes actually depend on `_osu_objects` so this type is self-referential.
// This field must be treated with great caution, moving `_osu_objects` will immediately
// invalidate `diff_objects`.
diff_objects: Vec<OsuDifficultyObject<'static>>,
osu_objects: OsuObjects,
skills: Skills,
}
@@ -99,38 +103,22 @@ impl OsuGradualDifficultyAttributes {
..Default::default()
};
let mut params = ObjectParameters {
let hit_objects = crate::osu::create_osu_objects(
map,
attrs: &mut attrs,
ticks: Vec::new(),
curve_bufs: CurveBuffers::default(),
};
&mut attrs,
&scaling_factor,
map.hit_objects.len(),
hr,
time_preempt,
);
let mut hit_objects: Vec<_> = map
.hit_objects
.iter()
.map(|h| OsuObject::new(h, &mut params))
.collect();
let mut osu_objects = OsuObjects::new(hit_objects);
attrs.n_circles = 0;
attrs.n_sliders = 0;
attrs.n_spinners = 0;
attrs.max_combo = 0;
let stack_threshold = time_preempt * map.stack_leniency as f64;
if map.version >= 6 {
stacking(&mut hit_objects, stack_threshold);
} else {
old_stacking(&mut hit_objects, stack_threshold);
}
let mut hit_objects_iter = hit_objects.iter_mut().map(|h| {
h.post_process(hr, &scaling_factor);
h
});
let skills = Skills::new(
mods,
scaling_factor.radius,
@@ -139,50 +127,61 @@ impl OsuGradualDifficultyAttributes {
hit_window,
);
let last = match hit_objects_iter.next() {
Some(prev) => prev,
None => {
return Self {
idx: 0,
mods,
attrs,
hit_objects: Vec::new(),
diff_objects: Vec::new(),
skills,
}
}
let mut osu_objects_iter = osu_objects.iter_mut();
let Some(mut last) = osu_objects_iter.next() else {
return Self {
idx: 0,
mods,
attrs,
diff_objects: Vec::new(),
osu_objects: OsuObjects::new(Vec::new()),
skills,
};
};
Self::increment_combo(last, &mut attrs);
Self::increment_combo(last.as_ref().get_ref(), &mut attrs);
let mut last_last = None;
// Prepare `lazy_travel_dist` and `lazy_end_pos` for `last` manually
Distances::compute_slider_cursor_pos(last, &scaling_factor);
let last_pos = last.pos();
let last_stack_offset = last.stack_offset;
let mut last = &*last;
if let OsuObjectKind::Slider(ref mut slider) = last.kind {
Distances::compute_slider_travel_dist(
last_pos,
last_stack_offset,
slider,
&scaling_factor,
);
}
let mut last = last.into_ref();
let mut diff_objects = Vec::with_capacity(map.hit_objects.len().saturating_sub(2));
for (i, curr) in hit_objects_iter.enumerate() {
for (i, mut curr) in osu_objects_iter.enumerate() {
let delta_time = (curr.start_time - last.start_time) / clock_rate;
// * Capped to 25ms to prevent difficulty calculation breaking from simultaneous objects.
let strain_time = delta_time.max(OsuDifficultyObject::MIN_DELTA_TIME as f64);
let dists = Distances::new(
curr,
last,
last_last,
&mut curr,
last.get_ref(),
last_last.map(Pin::get_ref),
clock_rate,
strain_time,
&scaling_factor,
);
let diff_obj = OsuDifficultyObject::new(curr, last, clock_rate, i, dists);
let curr = curr.into_ref();
let diff_obj = OsuDifficultyObject::new(curr, last.get_ref(), clock_rate, i, dists);
diff_objects.push(diff_obj);
last_last = Some(last);
last = &*curr;
last = curr;
}
Self {
@@ -190,7 +189,7 @@ impl OsuGradualDifficultyAttributes {
mods,
attrs,
diff_objects: extend_lifetime(diff_objects),
hit_objects,
osu_objects,
skills,
}
}
@@ -212,8 +211,8 @@ impl OsuGradualDifficultyAttributes {
fn extend_lifetime(
diff_objects: Vec<OsuDifficultyObject<'_>>,
) -> Vec<OsuDifficultyObject<'static>> {
// SAFETY: Owned values of the references will be contained
// in the same struct and hence live just as long as this vec.
// SAFETY: Owned values of the references will be contained in the same struct (same lifetime).
// Also, the only mutable access wraps them in `Pin` to ensure that they won't move.
unsafe { mem::transmute(diff_objects) }
}
@@ -221,13 +220,19 @@ impl Iterator for OsuGradualDifficultyAttributes {
type Item = OsuDifficultyAttributes;
fn next(&mut self) -> Option<Self::Item> {
let curr = self.diff_objects.get(self.idx)?;
// The first difficulty object belongs to the second note since each difficulty
// object requires the current and the last note. Hence, if we're still on the first
// object, we don't have a difficulty object yet and just skip processing.
if self.idx > 0 {
let curr = self.diff_objects.get(self.idx - 1)?;
self.skills.process(curr, &self.diff_objects);
Self::increment_combo(curr.base.get_ref(), &mut self.attrs);
} else if self.osu_objects.is_empty() {
return None;
}
self.idx += 1;
self.skills.process(curr, &self.diff_objects);
Self::increment_combo(curr.base, &mut self.attrs);
let Skills {
mut aim,
mut aim_no_sliders,
@@ -284,13 +289,15 @@ impl Iterator for OsuGradualDifficultyAttributes {
0.0
};
let mut attrs = self.attrs.clone();
attrs.aim = aim_rating;
attrs.speed = speed_rating;
attrs.flashlight = flashlight_rating;
attrs.slider_factor = slider_factor;
attrs.stars = star_rating;
attrs.speed_note_count = speed_notes;
let attrs = OsuDifficultyAttributes {
aim: aim_rating,
speed: speed_rating,
flashlight: flashlight_rating,
slider_factor,
stars: star_rating,
speed_note_count: speed_notes,
..self.attrs.clone()
};
Some(attrs)
}
@@ -303,15 +310,20 @@ impl Iterator for OsuGradualDifficultyAttributes {
}
fn nth(&mut self, n: usize) -> Option<Self::Item> {
let skip = n.min(self.len()).saturating_sub(1);
let skip_iter = self.diff_objects.iter().skip(self.idx.saturating_sub(1));
for _ in 0..skip {
let curr = self.diff_objects.get(self.idx)?;
let mut take = n.min(self.len().saturating_sub(1));
// The first note has no difficulty object
if self.idx == 0 && take > 0 {
take -= 1;
self.idx += 1;
}
for curr in skip_iter.take(take) {
self.skills.process(curr, &self.diff_objects);
Self::increment_combo(curr.base, &mut self.attrs);
Self::increment_combo(curr.base.get_ref(), &mut self.attrs);
self.idx += 1;
}
self.next()
@@ -321,6 +333,32 @@ impl Iterator for OsuGradualDifficultyAttributes {
impl ExactSizeIterator for OsuGradualDifficultyAttributes {
#[inline]
fn len(&self) -> usize {
self.diff_objects.len() - self.idx
self.diff_objects.len() + 1 - self.idx
}
}
mod osu_objects {
use crate::osu::OsuObject;
use std::pin::Pin;
// Wrapper to ensure that the data will not be moved
pub(super) struct OsuObjects {
objects: Box<[OsuObject]>,
}
impl OsuObjects {
pub(super) fn new(objects: Vec<OsuObject>) -> Self {
Self {
objects: objects.into_boxed_slice(),
}
}
pub(super) fn is_empty(&self) -> bool {
self.objects.is_empty()
}
pub(super) fn iter_mut(&mut self) -> impl Iterator<Item = Pin<&mut OsuObject>> {
self.objects.iter_mut().map(Pin::new)
}
}
}
+27 -82
View File
@@ -1,68 +1,21 @@
#![cfg(feature = "gradual")]
use crate::{Beatmap, OsuPP};
use super::{OsuGradualDifficultyAttributes, OsuPerformanceAttributes};
/// 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 [`OsuGradualPerformanceAttributes`].
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct OsuScoreState {
/// Maximum combo that the score has had so far.
/// **Not** the maximum possible combo of the map so far.
pub max_combo: usize,
/// Amount of current 300s.
pub n300: usize,
/// Amount of current 100s.
pub n100: usize,
/// Amount of current 50s.
pub n50: usize,
/// Amount of current misses.
pub n_misses: usize,
}
impl OsuScoreState {
/// Create a new empty score state.
#[inline]
pub fn new() -> Self {
Self::default()
}
/// Return the total amount of hits by adding everything up.
#[inline]
pub fn total_hits(&self) -> usize {
self.n300 + self.n100 + self.n50 + self.n_misses
}
/// Calculate the accuracy between `0.0` and `1.0` for this state.
#[inline]
pub fn accuracy(&self) -> f64 {
let total_hits = self.total_hits();
if total_hits == 0 {
return 0.0;
}
let numerator = 6 * self.n300 + 2 * self.n100 + self.n50;
let denominator = 6 * total_hits;
numerator as f64 / denominator as f64
}
}
use super::{OsuGradualDifficultyAttributes, OsuPerformanceAttributes, OsuScoreState};
/// Gradually calculate the performance attributes of an osu!standard map.
///
/// After each hit object you can call
/// [`process_next_object`](`OsuGradualPerformanceAttributes::process_next_object`)
/// After each hit object you can call [`next`](`OsuGradualPerformanceAttributes::next`)
/// and it will return the resulting current [`OsuPerformanceAttributes`].
/// To process multiple objects at once, use
/// [`process_next_n_objects`](`OsuGradualPerformanceAttributes::process_next_n_objects`) instead.
/// [`nth`](`OsuGradualPerformanceAttributes::nth`) instead.
///
/// Both methods require an [`OsuScoreState`] that contains the current
/// hitresults as well as the maximum combo so far.
///
/// If you only want to calculate difficulty attributes use
/// [`OsuGradualDifficultyAttributes`](crate::osu::OsuGradualDifficultyAttributes) instead.
/// [`OsuGradualDifficultyAttributes`] instead.
///
/// # Example
///
@@ -84,10 +37,10 @@ impl OsuScoreState {
/// state.max_combo += 1;
///
/// # /*
/// let performance = gradual_perf.process_next_object(state.clone()).unwrap();
/// let performance = gradual_perf.next(state.clone()).unwrap();
/// println!("PP: {}", performance.pp);
/// # */
/// # let _ = gradual_perf.process_next_object(state.clone());
/// # let _ = gradual_perf.next(state.clone());
/// }
///
/// // Then comes a miss.
@@ -95,30 +48,31 @@ impl OsuScoreState {
/// // the next few objects because the combo is reset.
/// state.n_misses += 1;
/// # /*
/// let performance = gradual_perf.process_next_object(state.clone()).unwrap();
/// let performance = gradual_perf.next(state.clone()).unwrap();
/// println!("PP: {}", performance.pp);
/// # */
/// # let _ = gradual_perf.process_next_object(state.clone());
/// # let _ = gradual_perf.next(state.clone());
///
/// // The next 10 objects will be a mixture of 300s, 100s, and 50s.
/// // Notice how all 10 objects will be processed in one go.
/// state.n300 += 2;
/// state.n100 += 7;
/// state.n50 += 1;
/// // The `nth` method takes a zero-based value.
/// # /*
/// let performance = gradual_perf.process_next_n_objects(state.clone(), 10).unwrap();
/// let performance = gradual_perf.nth(state.clone(), 9).unwrap();
/// println!("PP: {}", performance.pp);
/// # */
/// # let _ = gradual_perf.process_next_n_objects(state.clone(), 10);
/// # let _ = gradual_perf.nth(state.clone(), 9);
///
/// // Now comes another 300. Note that the max combo gets incremented again.
/// state.n300 += 1;
/// state.max_combo += 1;
/// # /*
/// let performance = gradual_perf.process_next_object(state.clone()).unwrap();
/// let performance = gradual_perf.next(state.clone()).unwrap();
/// println!("PP: {}", performance.pp);
/// # */
/// # let _ = gradual_perf.process_next_object(state.clone());
/// # let _ = gradual_perf.next(state.clone());
///
/// // Skip to the end
/// # /*
@@ -127,14 +81,14 @@ impl OsuScoreState {
/// state.n100 = ...
/// state.n50 = ...
/// state.n_misses = ...
/// let final_performance = gradual_perf.process_next_n_objects(state.clone(), usize::MAX).unwrap();
/// let final_performance = gradual_perf.nth(state.clone(), usize::MAX).unwrap();
/// println!("PP: {}", performance.pp);
/// # */
/// # let _ = gradual_perf.process_next_n_objects(state.clone(), usize::MAX);
/// # let _ = gradual_perf.nth(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());
/// assert!(gradual_perf.next(state).is_none());
/// ```
#[derive(Debug)]
pub struct OsuGradualPerformanceAttributes<'map> {
@@ -156,33 +110,24 @@ impl<'map> OsuGradualPerformanceAttributes<'map> {
/// Process the next hit object and calculate the
/// performance attributes for the resulting score state.
pub fn process_next_object(
&mut self,
state: OsuScoreState,
) -> Option<OsuPerformanceAttributes> {
self.process_next_n_objects(state, 1)
pub fn next(&mut self, state: OsuScoreState) -> Option<OsuPerformanceAttributes> {
self.nth(state, 0)
}
/// Same as [`process_next_object`](`OsuGradualPerformanceAttributes::process_next_object`)
/// but instead of processing only one object it process `n` many.
/// Process everything up the the next `n`th hit object and calculate the performance
/// attributes for the resulting score state.
///
/// 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: OsuScoreState,
n: usize,
) -> Option<OsuPerformanceAttributes> {
let sub = (self.difficulty.idx == 0) as usize;
let difficulty = self.difficulty.nth(n.saturating_sub(sub))?;
/// Note that the count is zero-indexed, so `n=0` will process 1 object, `n=1` will process 2,
/// and so on.
pub fn nth(&mut self, state: OsuScoreState, n: usize) -> Option<OsuPerformanceAttributes> {
let difficulty = self.difficulty.nth(n)?;
let performance = self
.performance
.clone()
.attributes(difficulty)
.state(state)
.passed_objects(self.difficulty.idx + 1)
.passed_objects(self.difficulty.idx)
.calculate();
Some(performance)
+36 -19
View File
@@ -1,19 +1,30 @@
mod difficulty_object;
mod gradual_difficulty;
mod gradual_performance;
mod osu_object;
mod pp;
mod scaling_factor;
mod score_state;
mod skills;
#[cfg(feature = "gradual")]
mod gradual_difficulty;
#[cfg(feature = "gradual")]
mod gradual_performance;
use crate::{curve::CurveBuffers, parse::Pos2, AnyStars, Beatmap, GameMode, Mods};
use std::pin::Pin;
use self::{
difficulty_object::{Distances, OsuDifficultyObject},
skills::{Skill, Skills},
};
pub use self::{gradual_difficulty::*, gradual_performance::*, osu_object::*, pp::*};
pub use self::{osu_object::*, pp::*, score_state::OsuScoreState};
#[cfg(feature = "gradual")]
pub use self::{
gradual_difficulty::OsuGradualDifficultyAttributes,
gradual_performance::OsuGradualPerformanceAttributes,
};
pub(crate) use self::scaling_factor::ScalingFactor;
@@ -91,7 +102,7 @@ impl<'map> OsuStars<'map> {
///
/// If you want to calculate the difficulty after every few objects, instead of
/// using [`OsuStars`] multiple times with different `passed_objects`, you should use
/// [`OsuGradualDifficultyAttributes`](crate::osu::OsuGradualDifficultyAttributes).
/// [`OsuGradualDifficultyAttributes`].
#[inline]
pub fn passed_objects(mut self, passed_objects: usize) -> Self {
self.passed_objects = Some(passed_objects);
@@ -271,7 +282,7 @@ fn calculate_skills(params: OsuStars<'_>) -> (Skills, OsuDifficultyAttributes) {
let mut hit_objects =
create_osu_objects(map, &mut attrs, &scaling_factor, take, hr, time_preempt);
let mut hit_objects_iter = hit_objects.iter_mut();
let mut hit_objects_iter = hit_objects.iter_mut().map(Pin::new);
let mut skills = Skills::new(
mods,
@@ -281,39 +292,46 @@ fn calculate_skills(params: OsuStars<'_>) -> (Skills, OsuDifficultyAttributes) {
hit_window,
);
let last = match hit_objects_iter.next() {
Some(prev) => prev,
None => return (skills, attrs),
let Some(mut last) = hit_objects_iter.next() else {
return (skills, attrs);
};
let mut last_last = None;
// Prepare `lazy_travel_dist` and `lazy_end_pos` for `last` manually
Distances::compute_slider_cursor_pos(last, &scaling_factor);
let last_pos = last.pos();
let last_stack_offset = last.stack_offset;
let mut last = &*last;
if let OsuObjectKind::Slider(ref mut slider) = last.kind {
Distances::compute_slider_travel_dist(last_pos, last_stack_offset, slider, &scaling_factor);
}
let mut last = last.into_ref();
let mut diff_objects = Vec::with_capacity(hit_objects_iter.len());
for (i, curr) in hit_objects_iter.enumerate() {
for (i, mut curr) in hit_objects_iter.enumerate() {
let delta_time = (curr.start_time - last.start_time) / clock_rate;
// * Capped to 25ms to prevent difficulty calculation breaking from simultaneous objects.
let strain_time = delta_time.max(OsuDifficultyObject::MIN_DELTA_TIME as f64);
let dists = Distances::new(
curr,
last,
last_last,
&mut curr,
last.get_ref(),
last_last.map(Pin::get_ref),
clock_rate,
strain_time,
&scaling_factor,
);
let diff_obj = OsuDifficultyObject::new(curr, last, clock_rate, i, dists);
let curr = curr.into_ref();
let diff_obj =
OsuDifficultyObject::new(curr, last.get_ref(), clock_rate, i, dists);
diff_objects.push(diff_obj);
last_last = Some(last);
last = &*curr;
last = curr;
}
for curr in diff_objects.iter() {
@@ -363,9 +381,8 @@ pub(crate) fn create_osu_objects(
fn stacking(hit_objects: &mut [OsuObject], stack_threshold: f64) {
let mut extended_start_idx = 0;
let extended_end_idx = match hit_objects.len().checked_sub(1) {
Some(idx) => idx,
None => return,
let Some(extended_end_idx) = hit_objects.len().checked_sub(1) else {
return;
};
// First big `if` in osu!lazer's function can be skipped
+47
View File
@@ -0,0 +1,47 @@
/// 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 [`OsuGradualPerformanceAttributes`](crate::osu::OsuGradualPerformanceAttributes).
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct OsuScoreState {
/// Maximum combo that the score has had so far.
/// **Not** the maximum possible combo of the map so far.
pub max_combo: usize,
/// Amount of current 300s.
pub n300: usize,
/// Amount of current 100s.
pub n100: usize,
/// Amount of current 50s.
pub n50: usize,
/// Amount of current misses.
pub n_misses: usize,
}
impl OsuScoreState {
/// Create a new empty score state.
#[inline]
pub fn new() -> Self {
Self::default()
}
/// Return the total amount of hits by adding everything up.
#[inline]
pub fn total_hits(&self) -> usize {
self.n300 + self.n100 + self.n50 + self.n_misses
}
/// Calculate the accuracy between `0.0` and `1.0` for this state.
#[inline]
pub fn accuracy(&self) -> f64 {
let total_hits = self.total_hits();
if total_hits == 0 {
return 0.0;
}
let numerator = 6 * self.n300 + 2 * self.n100 + self.n50;
let denominator = 6 * total_hits;
numerator as f64 / denominator as f64
}
}
+110
View File
@@ -0,0 +1,110 @@
use crate::{
catch::CatchScoreState, mania::ManiaScoreState, osu::OsuScoreState, taiko::TaikoScoreState,
GameMode,
};
/// Aggregation for a score's current state i.e. what is
/// the maximum combo so far, what are the current
/// hitresults and what is the current score.
///
/// This struct is used for [`GradualPerformanceAttributes`].
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct ScoreState {
/// Maximum combo that the score has had so far.
/// **Not** the maximum possible combo of the map so far.
///
/// Note that for osu!catch only fruits and droplets are considered for combo.
///
/// Irrelevant for osu!mania.
pub max_combo: usize,
/// Amount of current gekis (n320 for osu!mania).
pub n_geki: usize,
/// Amount of current katus (tiny droplet misses for osu!catch / n200 for osu!mania).
pub n_katu: usize,
/// Amount of current 300s (fruits for osu!catch).
pub n300: usize,
/// Amount of current 100s (droplets for osu!catch).
pub n100: usize,
/// Amount of current 50s (tiny droplets for osu!catch).
pub n50: usize,
/// Amount of current misses (fruits + droplets for osu!catch).
pub n_misses: usize,
}
impl ScoreState {
/// Create a new empty score state.
#[inline]
pub fn new() -> Self {
Self::default()
}
/// Return the total amount of hits by adding everything up based on the mode.
#[inline]
pub fn total_hits(&self, mode: GameMode) -> usize {
let mut amount = self.n300 + self.n100 + self.n_misses;
if mode != GameMode::Taiko {
amount += self.n50;
if mode != GameMode::Osu {
amount += self.n_katu;
amount += (mode != GameMode::Catch) as usize * self.n_geki;
}
}
amount
}
}
impl From<ScoreState> for OsuScoreState {
#[inline]
fn from(state: ScoreState) -> Self {
Self {
max_combo: state.max_combo,
n300: state.n300,
n100: state.n100,
n50: state.n50,
n_misses: state.n_misses,
}
}
}
impl From<ScoreState> for TaikoScoreState {
#[inline]
fn from(state: ScoreState) -> Self {
Self {
max_combo: state.max_combo,
n300: state.n300,
n100: state.n100,
n_misses: state.n_misses,
}
}
}
impl From<ScoreState> for CatchScoreState {
#[inline]
fn from(state: ScoreState) -> Self {
Self {
max_combo: state.max_combo,
n_fruits: state.n300,
n_droplets: state.n100,
n_tiny_droplets: state.n50,
n_tiny_droplet_misses: state.n_katu,
n_misses: state.n_misses,
}
}
}
impl From<ScoreState> for ManiaScoreState {
#[inline]
fn from(state: ScoreState) -> Self {
Self {
n320: state.n_geki,
n300: state.n300,
n200: state.n_katu,
n100: state.n100,
n50: state.n50,
n_misses: state.n_misses,
}
}
}
View File
+1 -1
View File
@@ -1,4 +1,4 @@
#![cfg(not(any(feature = "async_tokio", feature = "async_std")))]
#![cfg(all(not(any(feature = "async_tokio", feature = "async_std")), feature = "gradual"))]
use rosu_pp::{
catch::{CatchGradualDifficultyAttributes, CatchGradualPerformanceAttributes, CatchScoreState},
+1 -1
View File
@@ -1,4 +1,4 @@
#![cfg(not(any(feature = "async_tokio", feature = "async_std")))]
#![cfg(all(not(any(feature = "async_tokio", feature = "async_std")), feature = "gradual"))]
use rosu_pp::{
mania::{ManiaGradualDifficultyAttributes, ManiaGradualPerformanceAttributes, ManiaScoreState},
+13 -10
View File
@@ -1,4 +1,7 @@
#![cfg(not(any(feature = "async_tokio", feature = "async_std")))]
#![cfg(all(
not(any(feature = "async_tokio", feature = "async_std")),
feature = "gradual"
))]
use rosu_pp::{
osu::{OsuGradualDifficultyAttributes, OsuGradualPerformanceAttributes, OsuScoreState},
@@ -35,10 +38,10 @@ fn correct_empty() {
let mut gradual = OsuGradualPerformanceAttributes::new(&map, 0);
let state = OsuScoreState::default();
let first_attrs = gradual.process_next_n_objects(state.clone(), usize::MAX);
let first_attrs = gradual.nth(state.clone(), usize::MAX);
assert!(first_attrs.is_some());
assert!(gradual.process_next_object(state).is_none());
assert!(gradual.next(state).is_none());
}
#[test]
@@ -50,14 +53,14 @@ fn next_and_next_n() {
let mut gradual2 = OsuGradualPerformanceAttributes::new(&map, 0);
for _ in 0..20 {
let _ = gradual1.process_next_object(state.clone());
let _ = gradual2.process_next_object(state.clone());
let _ = gradual1.next(state.clone());
let _ = gradual2.next(state.clone());
}
let n = 80;
for _ in 1..n {
let _ = gradual1.process_next_object(state.clone());
let _ = gradual1.next(state.clone());
}
let state = OsuScoreState {
@@ -68,8 +71,8 @@ fn next_and_next_n() {
n_misses: 2,
};
let next = gradual1.process_next_object(state.clone());
let next_n = gradual2.process_next_n_objects(state, n);
let next = gradual1.next(state.clone());
let next_n = gradual2.nth(state, n - 1);
assert_eq!(next_n, next);
}
@@ -88,7 +91,7 @@ fn gradual_end_eq_regular() {
n_misses: 0,
};
let gradual_end = gradual.process_next_n_objects(state, usize::MAX).unwrap();
let gradual_end = gradual.nth(state, usize::MAX).unwrap();
assert_eq!(regular, gradual_end);
}
@@ -109,7 +112,7 @@ fn gradual_eq_regular_passed() {
n_misses: 0,
};
let gradual = gradual.process_next_n_objects(state, n).unwrap();
let gradual = gradual.nth(state, n - 1).unwrap();
assert_eq!(regular, gradual);
}
+1 -1
View File
@@ -1,4 +1,4 @@
#![cfg(not(any(feature = "async_tokio", feature = "async_std")))]
#![cfg(all(not(any(feature = "async_tokio", feature = "async_std")), feature = "gradual"))]
use rosu_pp::{
taiko::{TaikoGradualDifficultyAttributes, TaikoGradualPerformanceAttributes, TaikoScoreState},