gradual calc for osu
This commit is contained in:
@@ -5,12 +5,12 @@ pub fn strain_decay(ms: f64, strain_decay_base: f64) -> f64 {
|
|||||||
/// Wrapper around a difficulty skill that carries a list of all difficulty
|
/// Wrapper around a difficulty skill that carries a list of all difficulty
|
||||||
/// objects.
|
/// objects.
|
||||||
pub struct Skill<'a, S: ISkill> {
|
pub struct Skill<'a, S: ISkill> {
|
||||||
pub inner: S,
|
pub inner: &'a mut S,
|
||||||
pub diff_objects: &'a S::DifficultyObjects<'a>,
|
pub diff_objects: &'a S::DifficultyObjects<'a>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a, S: ISkill> Skill<'a, S> {
|
impl<'a, S: ISkill> Skill<'a, S> {
|
||||||
pub const fn new(skill: S, diff_objects: &'a S::DifficultyObjects<'a>) -> Self {
|
pub fn new(skill: &'a mut S, diff_objects: &'a S::DifficultyObjects<'a>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
inner: skill,
|
inner: skill,
|
||||||
diff_objects,
|
diff_objects,
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ impl DifficultyValues {
|
|||||||
let mut attrs = CatchDifficultyAttributesBuilder::new(attrs, take);
|
let mut attrs = CatchDifficultyAttributesBuilder::new(attrs, take);
|
||||||
|
|
||||||
let hr = difficulty.get_mods().hr();
|
let hr = difficulty.get_mods().hr();
|
||||||
let movement = Movement::new(clock_rate);
|
let mut movement = Movement::new(clock_rate);
|
||||||
|
|
||||||
let palpable_objects = convert_objects(converted, &mut attrs, hr, map_attrs.cs as f32);
|
let palpable_objects = convert_objects(converted, &mut attrs, hr, map_attrs.cs as f32);
|
||||||
let mut palpable_objects_iter = palpable_objects.iter().take(take);
|
let mut palpable_objects_iter = palpable_objects.iter().take(take);
|
||||||
@@ -90,14 +90,16 @@ impl DifficultyValues {
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let mut movement = Skill::new(movement, &diff_objects);
|
{
|
||||||
|
let mut movement = Skill::new(&mut movement, &diff_objects);
|
||||||
|
|
||||||
for curr in diff_objects.iter() {
|
for curr in diff_objects.iter() {
|
||||||
movement.process(curr);
|
movement.process(curr);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
movement: movement.inner,
|
movement,
|
||||||
attrs: attrs.into_inner(),
|
attrs: attrs.into_inner(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,14 +75,18 @@ impl DifficultyValues {
|
|||||||
let mut diff_objects = Vec::with_capacity(n_diff_objects);
|
let mut diff_objects = Vec::with_capacity(n_diff_objects);
|
||||||
diff_objects.extend(diff_objects_iter);
|
diff_objects.extend(diff_objects_iter);
|
||||||
|
|
||||||
let mut strain = Skill::new(Strain::new(total_columns as usize), &diff_objects);
|
let mut strain = Strain::new(total_columns as usize);
|
||||||
|
|
||||||
for curr in diff_objects.iter() {
|
{
|
||||||
strain.process(curr);
|
let mut strain = Skill::new(&mut strain, &diff_objects);
|
||||||
|
|
||||||
|
for curr in diff_objects.iter() {
|
||||||
|
strain.process(curr);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
strain: strain.inner,
|
strain,
|
||||||
max_combo: params.into_max_combo(),
|
max_combo: params.into_max_combo(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -32,12 +32,12 @@ pub fn convert_objects(
|
|||||||
time_preempt: f64,
|
time_preempt: f64,
|
||||||
mut take: usize,
|
mut take: usize,
|
||||||
attrs: &mut OsuDifficultyAttributes,
|
attrs: &mut OsuDifficultyAttributes,
|
||||||
) -> Vec<OsuObject> {
|
) -> Box<[OsuObject]> {
|
||||||
let mut curve_bufs = CurveBuffers::default();
|
let mut curve_bufs = CurveBuffers::default();
|
||||||
// mean=5.16 | median=4
|
// mean=5.16 | median=4
|
||||||
let mut ticks_buf = Vec::new();
|
let mut ticks_buf = Vec::new();
|
||||||
|
|
||||||
let mut osu_objects: Vec<_> = converted
|
let mut osu_objects: Box<[_]> = converted
|
||||||
.map
|
.map
|
||||||
.hit_objects
|
.hit_objects
|
||||||
.iter()
|
.iter()
|
||||||
|
|||||||
@@ -0,0 +1,331 @@
|
|||||||
|
use std::{
|
||||||
|
fmt::{Debug, Formatter, Result as FmtResult},
|
||||||
|
mem,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
any::difficulty::skills::Skill,
|
||||||
|
osu::{
|
||||||
|
convert::convert_objects,
|
||||||
|
object::{OsuObject, OsuObjectKind},
|
||||||
|
OsuBeatmap,
|
||||||
|
},
|
||||||
|
util::mods::Mods,
|
||||||
|
ModeDifficulty,
|
||||||
|
};
|
||||||
|
|
||||||
|
use self::osu_objects::OsuObjects;
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
object::OsuDifficultyObject, skills::OsuSkills, DifficultyValues, OsuDifficultyAttributes,
|
||||||
|
OsuDifficultySetup,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Gradually calculate the difficulty attributes of an osu!standard map.
|
||||||
|
///
|
||||||
|
/// Note that this struct implements [`Iterator`].
|
||||||
|
/// On every call of [`Iterator::next`], the map's next hit object will
|
||||||
|
/// be processed and the [`OsuDifficultyAttributes`] will be updated and
|
||||||
|
/// returned.
|
||||||
|
///
|
||||||
|
/// If you want to calculate performance attributes, use
|
||||||
|
/// [`OsuGradualPerformance`] instead.
|
||||||
|
///
|
||||||
|
/// # Example
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// use rosu_pp::{Beatmap, ModeDifficulty};
|
||||||
|
/// use rosu_pp::osu::{Osu, OsuGradualDifficulty};
|
||||||
|
///
|
||||||
|
/// let converted = Beatmap::from_path("./resources/2785319.osu")
|
||||||
|
/// .unwrap()
|
||||||
|
/// .unchecked_into_converted::<Osu>()
|
||||||
|
/// .unwrap();
|
||||||
|
///
|
||||||
|
/// let difficulty = ModeDifficulty::new().mods(64); // DT
|
||||||
|
/// let mut iter = OsuGradualDifficulty::new(&difficulty, &converted);
|
||||||
|
///
|
||||||
|
/// // the difficulty of the map after the first hit object
|
||||||
|
/// let attrs1 = iter.next();
|
||||||
|
/// // ... after the second hit object
|
||||||
|
/// let attrs2 = iter.next();
|
||||||
|
///
|
||||||
|
/// // Remaining hit objects
|
||||||
|
/// for difficulty in iter {
|
||||||
|
/// // ...
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// [`OsuGradualPerformance`]: crate::osu::OsuGradualPerformance
|
||||||
|
pub struct OsuGradualDifficulty {
|
||||||
|
pub(crate) idx: usize,
|
||||||
|
mods: u32,
|
||||||
|
attrs: OsuDifficultyAttributes,
|
||||||
|
skills: OsuSkills,
|
||||||
|
// 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: Box<[OsuDifficultyObject<'static>]>,
|
||||||
|
osu_objects: OsuObjects,
|
||||||
|
// Additional safety measure that this type can't be cloned as it would
|
||||||
|
// invalidate `diff_objects`.
|
||||||
|
_not_clonable: NotClonable,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct NotClonable;
|
||||||
|
|
||||||
|
impl Debug for OsuGradualDifficulty {
|
||||||
|
fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
|
||||||
|
f.debug_struct("OsuGradualDifficulty")
|
||||||
|
.field("idx", &self.idx)
|
||||||
|
.field("mods", &self.mods)
|
||||||
|
.field("attrs", &self.attrs)
|
||||||
|
.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OsuGradualDifficulty {
|
||||||
|
/// Create a new difficulty attributes iterator for osu!standard maps.
|
||||||
|
pub fn new(difficulty: &ModeDifficulty, converted: &OsuBeatmap<'_>) -> Self {
|
||||||
|
let mods = difficulty.get_mods();
|
||||||
|
|
||||||
|
let OsuDifficultySetup {
|
||||||
|
scaling_factor,
|
||||||
|
map_attrs,
|
||||||
|
mut attrs,
|
||||||
|
time_preempt,
|
||||||
|
} = OsuDifficultySetup::new(&difficulty, converted);
|
||||||
|
|
||||||
|
let osu_objects = convert_objects(
|
||||||
|
converted,
|
||||||
|
&scaling_factor,
|
||||||
|
mods.hr(),
|
||||||
|
time_preempt,
|
||||||
|
converted.map.hit_objects.len(),
|
||||||
|
&mut attrs,
|
||||||
|
);
|
||||||
|
|
||||||
|
attrs.n_circles = 0;
|
||||||
|
attrs.n_sliders = 0;
|
||||||
|
attrs.n_spinners = 0;
|
||||||
|
attrs.max_combo = 0;
|
||||||
|
|
||||||
|
if let Some(h) = osu_objects.first() {
|
||||||
|
Self::increment_combo(h, &mut attrs);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut osu_objects = OsuObjects::new(osu_objects);
|
||||||
|
|
||||||
|
let diff_objects = DifficultyValues::create_difficulty_objects(
|
||||||
|
difficulty,
|
||||||
|
&scaling_factor,
|
||||||
|
osu_objects.iter_mut(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let skills = OsuSkills::new(mods, &scaling_factor, &map_attrs, time_preempt);
|
||||||
|
let diff_objects = extend_lifetime(diff_objects.into_boxed_slice());
|
||||||
|
|
||||||
|
Self {
|
||||||
|
idx: 0,
|
||||||
|
mods,
|
||||||
|
attrs,
|
||||||
|
skills,
|
||||||
|
diff_objects,
|
||||||
|
osu_objects,
|
||||||
|
_not_clonable: NotClonable,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn increment_combo(h: &OsuObject, attrs: &mut OsuDifficultyAttributes) {
|
||||||
|
attrs.max_combo += 1;
|
||||||
|
|
||||||
|
match &h.kind {
|
||||||
|
OsuObjectKind::Circle => attrs.n_circles += 1,
|
||||||
|
OsuObjectKind::Slider(slider) => {
|
||||||
|
attrs.n_sliders += 1;
|
||||||
|
attrs.max_combo += slider.nested_objects.len() as u32;
|
||||||
|
}
|
||||||
|
OsuObjectKind::Spinner { .. } => attrs.n_spinners += 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extend_lifetime(
|
||||||
|
diff_objects: Box<[OsuDifficultyObject<'_>]>,
|
||||||
|
) -> Box<[OsuDifficultyObject<'static>]> {
|
||||||
|
// 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) }
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Iterator for OsuGradualDifficulty {
|
||||||
|
type Item = OsuDifficultyAttributes;
|
||||||
|
|
||||||
|
fn next(&mut self) -> Option<Self::Item> {
|
||||||
|
// 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)?;
|
||||||
|
|
||||||
|
Skill::new(&mut self.skills.aim, &self.diff_objects).process(curr);
|
||||||
|
Skill::new(&mut self.skills.aim_no_sliders, &self.diff_objects).process(curr);
|
||||||
|
Skill::new(&mut self.skills.speed, &self.diff_objects).process(curr);
|
||||||
|
Skill::new(&mut self.skills.flashlight, &self.diff_objects).process(curr);
|
||||||
|
|
||||||
|
Self::increment_combo(curr.base, &mut self.attrs);
|
||||||
|
} else if self.osu_objects.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.idx += 1;
|
||||||
|
|
||||||
|
let mut attrs = self.attrs.clone();
|
||||||
|
|
||||||
|
let aim_difficulty_value = self.skills.aim.as_difficulty_value();
|
||||||
|
let aim_no_sliders_difficulty_value = self.skills.aim_no_sliders.as_difficulty_value();
|
||||||
|
let speed_relevant_note_count = self.skills.speed.relevant_note_count();
|
||||||
|
let speed_difficulty_value = self.skills.speed.as_difficulty_value();
|
||||||
|
let flashlight_difficulty_value = self.skills.flashlight.as_difficulty_value();
|
||||||
|
|
||||||
|
DifficultyValues::eval(
|
||||||
|
&mut attrs,
|
||||||
|
self.mods,
|
||||||
|
aim_difficulty_value,
|
||||||
|
aim_no_sliders_difficulty_value,
|
||||||
|
speed_difficulty_value,
|
||||||
|
speed_relevant_note_count,
|
||||||
|
flashlight_difficulty_value,
|
||||||
|
);
|
||||||
|
|
||||||
|
Some(attrs)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn size_hint(&self) -> (usize, Option<usize>) {
|
||||||
|
let len = self.len();
|
||||||
|
|
||||||
|
(len, Some(len))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn nth(&mut self, n: usize) -> Option<Self::Item> {
|
||||||
|
let skip_iter = self.diff_objects.iter().skip(self.idx.saturating_sub(1));
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut aim = Skill::new(&mut self.skills.aim, &self.diff_objects);
|
||||||
|
let mut aim_no_sliders = Skill::new(&mut self.skills.aim_no_sliders, &self.diff_objects);
|
||||||
|
let mut speed = Skill::new(&mut self.skills.speed, &self.diff_objects);
|
||||||
|
let mut flashlight = Skill::new(&mut self.skills.flashlight, &self.diff_objects);
|
||||||
|
|
||||||
|
for curr in skip_iter.take(take) {
|
||||||
|
aim.process(curr);
|
||||||
|
aim_no_sliders.process(curr);
|
||||||
|
speed.process(curr);
|
||||||
|
flashlight.process(curr);
|
||||||
|
|
||||||
|
Self::increment_combo(curr.base, &mut self.attrs);
|
||||||
|
self.idx += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ExactSizeIterator for OsuGradualDifficulty {
|
||||||
|
fn len(&self) -> usize {
|
||||||
|
self.diff_objects.len() + 1 - self.idx
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mod osu_objects {
|
||||||
|
use std::pin::Pin;
|
||||||
|
|
||||||
|
use crate::osu::object::OsuObject;
|
||||||
|
|
||||||
|
/// Wrapper to ensure that the data will not be moved
|
||||||
|
pub(super) struct OsuObjects {
|
||||||
|
objects: Box<[OsuObject]>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OsuObjects {
|
||||||
|
pub(super) fn new(objects: Box<[OsuObject]>) -> Self {
|
||||||
|
Self { objects }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) const fn is_empty(&self) -> bool {
|
||||||
|
self.objects.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn iter_mut(&mut self) -> impl ExactSizeIterator<Item = Pin<&mut OsuObject>> {
|
||||||
|
self.objects.iter_mut().map(Pin::new)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
|
||||||
|
use crate::{osu::Osu, Beatmap};
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty() {
|
||||||
|
let converted = Beatmap::from_bytes(&[])
|
||||||
|
.unwrap()
|
||||||
|
.unchecked_into_converted::<Osu>();
|
||||||
|
|
||||||
|
let difficulty = ModeDifficulty::new();
|
||||||
|
let mut gradual = OsuGradualDifficulty::new(&difficulty, &converted);
|
||||||
|
|
||||||
|
assert!(gradual.next().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn next_and_nth() {
|
||||||
|
let converted = Beatmap::from_path("./resources/2785319.osu")
|
||||||
|
.unwrap()
|
||||||
|
.unchecked_into_converted::<Osu>();
|
||||||
|
|
||||||
|
let difficulty = ModeDifficulty::new();
|
||||||
|
|
||||||
|
let mut gradual = OsuGradualDifficulty::new(&difficulty, &converted);
|
||||||
|
let mut gradual_2nd = OsuGradualDifficulty::new(&difficulty, &converted);
|
||||||
|
let mut gradual_3rd = OsuGradualDifficulty::new(&difficulty, &converted);
|
||||||
|
|
||||||
|
let hit_objects_len = converted.map.hit_objects.len();
|
||||||
|
|
||||||
|
for i in 1.. {
|
||||||
|
let Some(next_gradual) = gradual.next() else {
|
||||||
|
assert_eq!(i, hit_objects_len + 1);
|
||||||
|
assert!(gradual_2nd.last().is_some() || hit_objects_len % 2 == 0);
|
||||||
|
assert!(gradual_3rd.last().is_some() || hit_objects_len % 3 == 0);
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
|
||||||
|
if i % 2 == 0 {
|
||||||
|
let next_gradual_2nd = gradual_2nd.nth(1).unwrap();
|
||||||
|
assert_eq!(next_gradual, next_gradual_2nd);
|
||||||
|
}
|
||||||
|
|
||||||
|
if i % 3 == 0 {
|
||||||
|
let next_gradual_3rd = gradual_3rd.nth(2).unwrap();
|
||||||
|
assert_eq!(next_gradual, next_gradual_3rd);
|
||||||
|
}
|
||||||
|
|
||||||
|
let expected = ModeDifficulty::new()
|
||||||
|
.passed_objects(i as u32)
|
||||||
|
.calculate(&converted);
|
||||||
|
|
||||||
|
assert_eq!(next_gradual, expected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+175
-128
@@ -1,7 +1,8 @@
|
|||||||
use std::cmp;
|
use std::{cmp, pin::Pin};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
any::difficulty::{skills::Skill, ModeDifficulty},
|
any::difficulty::{skills::Skill, ModeDifficulty},
|
||||||
|
model::beatmap::BeatmapAttributes,
|
||||||
osu::{
|
osu::{
|
||||||
convert::convert_objects,
|
convert::convert_objects,
|
||||||
difficulty::{object::OsuDifficultyObject, scaling_factor::ScalingFactor},
|
difficulty::{object::OsuDifficultyObject, scaling_factor::ScalingFactor},
|
||||||
@@ -11,13 +12,14 @@ use crate::{
|
|||||||
util::mods::Mods,
|
util::mods::Mods,
|
||||||
};
|
};
|
||||||
|
|
||||||
use self::skills::{aim::Aim, flashlight::Flashlight, speed::Speed};
|
use self::skills::OsuSkills;
|
||||||
|
|
||||||
use super::{attributes::OsuDifficultyAttributes, convert::OsuBeatmap};
|
use super::{attributes::OsuDifficultyAttributes, convert::OsuBeatmap};
|
||||||
|
|
||||||
|
pub mod gradual;
|
||||||
mod object;
|
mod object;
|
||||||
pub mod scaling_factor;
|
pub mod scaling_factor;
|
||||||
mod skills;
|
pub mod skills;
|
||||||
|
|
||||||
const DIFFICULTY_MULTIPLIER: f64 = 0.0675;
|
const DIFFICULTY_MULTIPLIER: f64 = 0.0675;
|
||||||
|
|
||||||
@@ -29,84 +31,47 @@ pub fn difficulty(
|
|||||||
converted: &OsuBeatmap<'_>,
|
converted: &OsuBeatmap<'_>,
|
||||||
) -> OsuDifficultyAttributes {
|
) -> OsuDifficultyAttributes {
|
||||||
let DifficultyValues {
|
let DifficultyValues {
|
||||||
aim,
|
skills:
|
||||||
aim_no_sliders,
|
OsuSkills {
|
||||||
speed,
|
aim,
|
||||||
flashlight,
|
aim_no_sliders,
|
||||||
|
speed,
|
||||||
|
flashlight,
|
||||||
|
},
|
||||||
mut attrs,
|
mut attrs,
|
||||||
} = DifficultyValues::calculate(difficulty, converted);
|
} = DifficultyValues::calculate(difficulty, converted);
|
||||||
|
|
||||||
let mut aim_rating = aim.difficulty_value().sqrt() * DIFFICULTY_MULTIPLIER;
|
let aim_difficulty_value = aim.difficulty_value();
|
||||||
let aim_rating_no_sliders = aim_no_sliders.difficulty_value().sqrt() * DIFFICULTY_MULTIPLIER;
|
let aim_no_sliders_difficulty_value = aim_no_sliders.difficulty_value();
|
||||||
|
let speed_relevant_note_count = speed.relevant_note_count();
|
||||||
let speed_notes = speed.relevant_note_count();
|
let speed_difficulty_value = speed.difficulty_value();
|
||||||
let mut speed_rating = speed.difficulty_value().sqrt() * DIFFICULTY_MULTIPLIER;
|
let flashlight_difficulty_value = flashlight.difficulty_value();
|
||||||
|
|
||||||
let mut flashlight_rating = flashlight.difficulty_value().sqrt() * DIFFICULTY_MULTIPLIER;
|
|
||||||
|
|
||||||
let slider_factor = if aim_rating > 0.0 {
|
|
||||||
aim_rating_no_sliders / aim_rating
|
|
||||||
} else {
|
|
||||||
1.0
|
|
||||||
};
|
|
||||||
|
|
||||||
let mods = difficulty.get_mods();
|
let mods = difficulty.get_mods();
|
||||||
|
|
||||||
if mods.td() {
|
DifficultyValues::eval(
|
||||||
aim_rating = aim_rating.powf(0.8);
|
&mut attrs,
|
||||||
flashlight_rating = flashlight_rating.powf(0.8);
|
mods,
|
||||||
}
|
aim_difficulty_value,
|
||||||
|
aim_no_sliders_difficulty_value,
|
||||||
if mods.rx() {
|
speed_difficulty_value,
|
||||||
aim_rating *= 0.9;
|
speed_relevant_note_count,
|
||||||
speed_rating = 0.0;
|
flashlight_difficulty_value,
|
||||||
flashlight_rating *= 0.7;
|
);
|
||||||
}
|
|
||||||
|
|
||||||
let base_aim_performance = (5.0 * (aim_rating / 0.0675).max(1.0) - 4.0).powi(3) / 100_000.0;
|
|
||||||
let base_speed_performance = (5.0 * (speed_rating / 0.0675).max(1.0) - 4.0).powi(3) / 100_000.0;
|
|
||||||
|
|
||||||
let base_flashlight_performance = if mods.fl() {
|
|
||||||
flashlight_rating.powi(2) * 25.0
|
|
||||||
} else {
|
|
||||||
0.0
|
|
||||||
};
|
|
||||||
|
|
||||||
let base_performance = ((base_aim_performance).powf(1.1)
|
|
||||||
+ (base_speed_performance).powf(1.1)
|
|
||||||
+ (base_flashlight_performance).powf(1.1))
|
|
||||||
.powf(1.0 / 1.1);
|
|
||||||
|
|
||||||
let star_rating = if base_performance > 0.00001 {
|
|
||||||
PERFORMANCE_BASE_MULTIPLIER.cbrt()
|
|
||||||
* 0.027
|
|
||||||
* ((100_000.0 / 2.0_f64.powf(1.0 / 1.1) * base_performance).cbrt() + 4.0)
|
|
||||||
} else {
|
|
||||||
0.0
|
|
||||||
};
|
|
||||||
|
|
||||||
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;
|
|
||||||
|
|
||||||
attrs
|
attrs
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct DifficultyValues {
|
pub struct OsuDifficultySetup {
|
||||||
pub aim: Aim,
|
scaling_factor: ScalingFactor,
|
||||||
pub aim_no_sliders: Aim,
|
map_attrs: BeatmapAttributes,
|
||||||
pub speed: Speed,
|
attrs: OsuDifficultyAttributes,
|
||||||
pub flashlight: Flashlight,
|
time_preempt: f64,
|
||||||
pub attrs: OsuDifficultyAttributes,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DifficultyValues {
|
impl OsuDifficultySetup {
|
||||||
pub fn calculate(difficulty: &ModeDifficulty, converted: &OsuBeatmap<'_>) -> Self {
|
pub fn new(difficulty: &ModeDifficulty, converted: &OsuBeatmap) -> Self {
|
||||||
let mods = difficulty.get_mods();
|
let mods = difficulty.get_mods();
|
||||||
let take = difficulty.get_passed_objects();
|
|
||||||
let clock_rate = difficulty.get_clock_rate();
|
let clock_rate = difficulty.get_clock_rate();
|
||||||
|
|
||||||
let map_attrs = converted
|
let map_attrs = converted
|
||||||
@@ -116,95 +81,177 @@ impl DifficultyValues {
|
|||||||
.build();
|
.build();
|
||||||
|
|
||||||
let scaling_factor = ScalingFactor::new(map_attrs.cs);
|
let scaling_factor = ScalingFactor::new(map_attrs.cs);
|
||||||
let hr = mods.hr();
|
|
||||||
let hit_window = 2.0 * map_attrs.hit_windows.od;
|
|
||||||
let time_preempt = f64::from((map_attrs.hit_windows.ar * clock_rate) as f32);
|
|
||||||
|
|
||||||
// * Preempt time can go below 450ms. Normally, this is achieved via the DT mod
|
let attrs = OsuDifficultyAttributes {
|
||||||
// * which uniformly speeds up all animations game wide regardless of AR.
|
|
||||||
// * This uniform speedup is hard to match 1:1, however we can at least make
|
|
||||||
// * AR>10 (via mods) feel good by extending the upper linear function above.
|
|
||||||
// * Note that this doesn't exactly match the AR>10 visuals as they're
|
|
||||||
// * classically known, but it feels good.
|
|
||||||
// * This adjustment is necessary for AR>10, otherwise TimePreempt can
|
|
||||||
// * become smaller leading to hitcircles not fully fading in.
|
|
||||||
let time_fade_in = if mods.hd() {
|
|
||||||
time_preempt * HD_FADE_IN_DURATION_MULTIPLIER
|
|
||||||
} else {
|
|
||||||
400.0 * (time_preempt / OsuObject::PREEMPT_MIN).min(1.0)
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut attrs = OsuDifficultyAttributes {
|
|
||||||
ar: map_attrs.ar,
|
ar: map_attrs.ar,
|
||||||
hp: map_attrs.hp,
|
hp: map_attrs.hp,
|
||||||
od: map_attrs.od,
|
od: map_attrs.od,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let time_preempt = f64::from((map_attrs.hit_windows.ar * clock_rate) as f32);
|
||||||
|
|
||||||
|
Self {
|
||||||
|
scaling_factor,
|
||||||
|
map_attrs,
|
||||||
|
attrs,
|
||||||
|
time_preempt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct DifficultyValues {
|
||||||
|
pub skills: OsuSkills,
|
||||||
|
pub attrs: OsuDifficultyAttributes,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DifficultyValues {
|
||||||
|
pub fn calculate(difficulty: &ModeDifficulty, converted: &OsuBeatmap<'_>) -> Self {
|
||||||
|
let mods = difficulty.get_mods();
|
||||||
|
let take = difficulty.get_passed_objects();
|
||||||
|
|
||||||
|
let OsuDifficultySetup {
|
||||||
|
scaling_factor,
|
||||||
|
map_attrs,
|
||||||
|
mut attrs,
|
||||||
|
time_preempt,
|
||||||
|
} = OsuDifficultySetup::new(difficulty, converted);
|
||||||
|
|
||||||
let mut osu_objects = convert_objects(
|
let mut osu_objects = convert_objects(
|
||||||
converted,
|
converted,
|
||||||
&scaling_factor,
|
&scaling_factor,
|
||||||
hr,
|
mods.hr(),
|
||||||
time_preempt,
|
time_preempt,
|
||||||
take,
|
take,
|
||||||
&mut attrs,
|
&mut attrs,
|
||||||
);
|
);
|
||||||
|
|
||||||
let mut osu_objects_iter = osu_objects
|
let osu_object_iter = osu_objects.iter_mut().map(Pin::new);
|
||||||
.iter_mut()
|
|
||||||
.map(|h| OsuDifficultyObject::compute_slider_cursor_pos(h, scaling_factor.radius));
|
|
||||||
|
|
||||||
let aim = Aim::new(true);
|
let diff_objects =
|
||||||
let aim_no_sliders = Aim::new(false);
|
Self::create_difficulty_objects(difficulty, &scaling_factor, osu_object_iter);
|
||||||
let speed = Speed::new(hit_window);
|
|
||||||
let flashlight = Flashlight::new(mods, scaling_factor.radius, time_preempt, time_fade_in);
|
let mut skills = OsuSkills::new(mods, &scaling_factor, &map_attrs, time_preempt);
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut aim = Skill::new(&mut skills.aim, &diff_objects);
|
||||||
|
let mut aim_no_sliders = Skill::new(&mut skills.aim_no_sliders, &diff_objects);
|
||||||
|
let mut speed = Skill::new(&mut skills.speed, &diff_objects);
|
||||||
|
let mut flashlight = Skill::new(&mut skills.flashlight, &diff_objects);
|
||||||
|
|
||||||
|
// The first hit object has no difficulty object
|
||||||
|
let take_diff_objects =
|
||||||
|
cmp::min(converted.map.hit_objects.len(), take).saturating_sub(1);
|
||||||
|
|
||||||
|
for hit_object in diff_objects.iter().take(take_diff_objects) {
|
||||||
|
aim.process(hit_object);
|
||||||
|
aim_no_sliders.process(hit_object);
|
||||||
|
speed.process(hit_object);
|
||||||
|
flashlight.process(hit_object);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Self { skills, attrs }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Process the difficulty values and store the results in `attrs`.
|
||||||
|
pub fn eval(
|
||||||
|
attrs: &mut OsuDifficultyAttributes,
|
||||||
|
mods: u32,
|
||||||
|
aim_difficulty_value: f64,
|
||||||
|
aim_no_sliders_difficulty_value: f64,
|
||||||
|
speed_difficulty_value: f64,
|
||||||
|
speed_relevant_note_count: f64,
|
||||||
|
flashlight_difficulty_value: f64,
|
||||||
|
) {
|
||||||
|
let mut aim_rating = aim_difficulty_value.sqrt() * DIFFICULTY_MULTIPLIER;
|
||||||
|
let aim_rating_no_sliders = aim_no_sliders_difficulty_value.sqrt() * DIFFICULTY_MULTIPLIER;
|
||||||
|
let mut speed_rating = speed_difficulty_value.sqrt() * DIFFICULTY_MULTIPLIER;
|
||||||
|
let mut flashlight_rating = flashlight_difficulty_value.sqrt() * DIFFICULTY_MULTIPLIER;
|
||||||
|
|
||||||
|
let slider_factor = if aim_rating > 0.0 {
|
||||||
|
aim_rating_no_sliders / aim_rating
|
||||||
|
} else {
|
||||||
|
1.0
|
||||||
|
};
|
||||||
|
|
||||||
|
if mods.td() {
|
||||||
|
aim_rating = aim_rating.powf(0.8);
|
||||||
|
flashlight_rating = flashlight_rating.powf(0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
if mods.rx() {
|
||||||
|
aim_rating *= 0.9;
|
||||||
|
speed_rating = 0.0;
|
||||||
|
flashlight_rating *= 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
let base_aim_performance = (5.0 * (aim_rating / 0.0675).max(1.0) - 4.0).powi(3) / 100_000.0;
|
||||||
|
let base_speed_performance =
|
||||||
|
(5.0 * (speed_rating / 0.0675).max(1.0) - 4.0).powi(3) / 100_000.0;
|
||||||
|
|
||||||
|
let base_flashlight_performance = if mods.fl() {
|
||||||
|
flashlight_rating.powi(2) * 25.0
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
|
||||||
|
let base_performance = ((base_aim_performance).powf(1.1)
|
||||||
|
+ (base_speed_performance).powf(1.1)
|
||||||
|
+ (base_flashlight_performance).powf(1.1))
|
||||||
|
.powf(1.0 / 1.1);
|
||||||
|
|
||||||
|
let star_rating = if base_performance > 0.00001 {
|
||||||
|
PERFORMANCE_BASE_MULTIPLIER.cbrt()
|
||||||
|
* 0.027
|
||||||
|
* ((100_000.0 / 2.0_f64.powf(1.0 / 1.1) * base_performance).cbrt() + 4.0)
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
|
||||||
|
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_relevant_note_count;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn create_difficulty_objects<'a>(
|
||||||
|
difficulty: &ModeDifficulty,
|
||||||
|
scaling_factor: &ScalingFactor,
|
||||||
|
osu_objects: impl Iterator<Item = Pin<&'a mut OsuObject>>,
|
||||||
|
) -> Vec<OsuDifficultyObject<'a>> {
|
||||||
|
let take = difficulty.get_passed_objects();
|
||||||
|
let clock_rate = difficulty.get_clock_rate();
|
||||||
|
|
||||||
|
let mut osu_objects_iter = osu_objects
|
||||||
|
.map(|h| OsuDifficultyObject::compute_slider_cursor_pos(h, scaling_factor.radius))
|
||||||
|
.map(Pin::into_ref);
|
||||||
|
|
||||||
let Some(mut last) = osu_objects_iter.next().filter(|_| take > 0) else {
|
let Some(mut last) = osu_objects_iter.next().filter(|_| take > 0) else {
|
||||||
return Self {
|
return Vec::new();
|
||||||
aim,
|
|
||||||
aim_no_sliders,
|
|
||||||
speed,
|
|
||||||
flashlight,
|
|
||||||
attrs,
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut last_last = None;
|
let mut last_last = None;
|
||||||
|
|
||||||
let diff_objects: Vec<_> = osu_objects_iter
|
osu_objects_iter
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.map(|(idx, h)| {
|
.map(|(idx, h)| {
|
||||||
let diff_object =
|
let diff_object = OsuDifficultyObject::new(
|
||||||
OsuDifficultyObject::new(h, last, last_last, clock_rate, idx, &scaling_factor);
|
h.get_ref(),
|
||||||
|
last.get_ref(),
|
||||||
|
last_last.as_deref(),
|
||||||
|
clock_rate,
|
||||||
|
idx,
|
||||||
|
&scaling_factor,
|
||||||
|
);
|
||||||
|
|
||||||
last_last = Some(last);
|
last_last = Some(last);
|
||||||
last = h;
|
last = h;
|
||||||
|
|
||||||
diff_object
|
diff_object
|
||||||
})
|
})
|
||||||
.collect();
|
.collect()
|
||||||
|
|
||||||
let mut aim = Skill::new(aim, &diff_objects);
|
|
||||||
let mut aim_no_sliders = Skill::new(aim_no_sliders, &diff_objects);
|
|
||||||
let mut speed = Skill::new(speed, &diff_objects);
|
|
||||||
let mut flashlight = Skill::new(flashlight, &diff_objects);
|
|
||||||
|
|
||||||
// The first hit object has no difficulty object
|
|
||||||
let take_diff_objects = cmp::min(converted.map.hit_objects.len(), take) - 1;
|
|
||||||
|
|
||||||
for hit_object in diff_objects.iter().take(take_diff_objects) {
|
|
||||||
aim.process(hit_object);
|
|
||||||
aim_no_sliders.process(hit_object);
|
|
||||||
speed.process(hit_object);
|
|
||||||
flashlight.process(hit_object);
|
|
||||||
}
|
|
||||||
|
|
||||||
Self {
|
|
||||||
aim: aim.inner,
|
|
||||||
aim_no_sliders: aim_no_sliders.inner,
|
|
||||||
speed: speed.inner,
|
|
||||||
flashlight: flashlight.inner,
|
|
||||||
attrs,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
use std::pin::Pin;
|
||||||
|
|
||||||
use rosu_map::util::Pos;
|
use rosu_map::util::Pos;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
@@ -148,14 +150,20 @@ impl<'a> OsuDifficultyObject<'a> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn compute_slider_cursor_pos(h: &mut OsuObject, radius: f64) -> &OsuObject {
|
/// The [`Pin<&mut OsuObject>`](std::pin::Pin) denotes that the object will
|
||||||
|
/// be mutated but not moved.
|
||||||
|
pub fn compute_slider_cursor_pos(
|
||||||
|
mut h: Pin<&mut OsuObject>,
|
||||||
|
radius: f64,
|
||||||
|
) -> Pin<&mut OsuObject> {
|
||||||
|
let pos = h.pos;
|
||||||
let stack_offset = h.stack_offset;
|
let stack_offset = h.stack_offset;
|
||||||
|
|
||||||
let OsuObjectKind::Slider(ref mut slider) = h.kind else {
|
let OsuObjectKind::Slider(ref mut slider) = h.kind else {
|
||||||
return h;
|
return h;
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut curr_cursor_pos = h.pos + stack_offset;
|
let mut curr_cursor_pos = pos + stack_offset;
|
||||||
let scaling_factor = f64::from(OsuDifficultyObject::NORMALIZED_RADIUS) / radius;
|
let scaling_factor = f64::from(OsuDifficultyObject::NORMALIZED_RADIUS) / radius;
|
||||||
|
|
||||||
for (curr_movement_obj, i) in slider.nested_objects.iter().zip(1..) {
|
for (curr_movement_obj, i) in slider.nested_objects.iter().zip(1..) {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ use super::strain::OsuStrainSkill;
|
|||||||
const SKILL_MULTIPLIER: f64 = 23.55;
|
const SKILL_MULTIPLIER: f64 = 23.55;
|
||||||
const STRAIN_DECAY_BASE: f64 = 0.15;
|
const STRAIN_DECAY_BASE: f64 = 0.15;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
pub struct Aim {
|
pub struct Aim {
|
||||||
with_sliders: bool,
|
with_sliders: bool,
|
||||||
curr_strain: f64,
|
curr_strain: f64,
|
||||||
@@ -34,7 +35,17 @@ impl Aim {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn difficulty_value(self) -> f64 {
|
pub fn difficulty_value(self) -> f64 {
|
||||||
self.inner.difficulty_value(
|
Self::static_difficulty_value(self.inner)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Use [`difficulty_value`] instead whenever possible because
|
||||||
|
/// [`as_difficulty_value`] clones internally.
|
||||||
|
pub fn as_difficulty_value(&self) -> f64 {
|
||||||
|
Self::static_difficulty_value(self.inner.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn static_difficulty_value(skill: OsuStrainSkill) -> f64 {
|
||||||
|
skill.difficulty_value(
|
||||||
OsuStrainSkill::REDUCED_SECTION_COUNT,
|
OsuStrainSkill::REDUCED_SECTION_COUNT,
|
||||||
OsuStrainSkill::REDUCED_STRAIN_BASELINE,
|
OsuStrainSkill::REDUCED_STRAIN_BASELINE,
|
||||||
OsuStrainSkill::DECAY_WEIGHT,
|
OsuStrainSkill::DECAY_WEIGHT,
|
||||||
@@ -56,7 +67,7 @@ impl<'a> Skill<'a, Aim> {
|
|||||||
self.inner.curr_strain * strain_decay(time - prev_start_time, STRAIN_DECAY_BASE)
|
self.inner.curr_strain * strain_decay(time - prev_start_time, STRAIN_DECAY_BASE)
|
||||||
}
|
}
|
||||||
|
|
||||||
const fn curr_section_peak(&self) -> f64 {
|
fn curr_section_peak(&self) -> f64 {
|
||||||
self.inner.inner.inner.curr_section_peak
|
self.inner.inner.inner.curr_section_peak
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,7 +75,7 @@ impl<'a> Skill<'a, Aim> {
|
|||||||
&mut self.inner.inner.inner.curr_section_peak
|
&mut self.inner.inner.inner.curr_section_peak
|
||||||
}
|
}
|
||||||
|
|
||||||
const fn curr_section_end(&self) -> f64 {
|
fn curr_section_end(&self) -> f64 {
|
||||||
self.inner.inner.inner.curr_section_end
|
self.inner.inner.inner.curr_section_end
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,18 @@ impl Flashlight {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn difficulty_value(self) -> f64 {
|
pub fn difficulty_value(self) -> f64 {
|
||||||
self.get_curr_strain_peaks().into_iter().sum::<f64>() * OsuStrainSkill::DIFFICULTY_MULTIPLER
|
Self::static_difficulty_value(self.inner)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Use [`difficulty_value`] instead whenever possible because
|
||||||
|
/// [`as_difficulty_value`] clones internally.
|
||||||
|
pub fn as_difficulty_value(&self) -> f64 {
|
||||||
|
Self::static_difficulty_value(self.inner.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn static_difficulty_value(skill: StrainSkill) -> f64 {
|
||||||
|
skill.get_curr_strain_peaks().into_iter().sum::<f64>()
|
||||||
|
* OsuStrainSkill::DIFFICULTY_MULTIPLER
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,7 +64,7 @@ impl<'a> Skill<'a, Flashlight> {
|
|||||||
self.inner.curr_strain * strain_decay(time - prev_start_time, STRAIN_DECAY_BASE)
|
self.inner.curr_strain * strain_decay(time - prev_start_time, STRAIN_DECAY_BASE)
|
||||||
}
|
}
|
||||||
|
|
||||||
const fn curr_section_peak(&self) -> f64 {
|
fn curr_section_peak(&self) -> f64 {
|
||||||
self.inner.inner.curr_section_peak
|
self.inner.inner.curr_section_peak
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,7 +72,7 @@ impl<'a> Skill<'a, Flashlight> {
|
|||||||
&mut self.inner.inner.curr_section_peak
|
&mut self.inner.inner.curr_section_peak
|
||||||
}
|
}
|
||||||
|
|
||||||
const fn curr_section_end(&self) -> f64 {
|
fn curr_section_end(&self) -> f64 {
|
||||||
self.inner.inner.curr_section_end
|
self.inner.inner.curr_section_end
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,54 @@
|
|||||||
|
use crate::{model::beatmap::BeatmapAttributes, osu::object::OsuObject, util::mods::Mods};
|
||||||
|
|
||||||
|
use self::{aim::Aim, flashlight::Flashlight, speed::Speed};
|
||||||
|
|
||||||
|
use super::{scaling_factor::ScalingFactor, HD_FADE_IN_DURATION_MULTIPLIER};
|
||||||
|
|
||||||
pub mod aim;
|
pub mod aim;
|
||||||
pub mod flashlight;
|
pub mod flashlight;
|
||||||
pub mod speed;
|
pub mod speed;
|
||||||
pub mod strain;
|
pub mod strain;
|
||||||
|
|
||||||
|
pub struct OsuSkills {
|
||||||
|
pub aim: Aim,
|
||||||
|
pub aim_no_sliders: Aim,
|
||||||
|
pub speed: Speed,
|
||||||
|
pub flashlight: Flashlight,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OsuSkills {
|
||||||
|
pub fn new(
|
||||||
|
mods: u32,
|
||||||
|
scaling_factor: &ScalingFactor,
|
||||||
|
map_attrs: &BeatmapAttributes,
|
||||||
|
time_preempt: f64,
|
||||||
|
) -> Self {
|
||||||
|
let hit_window = 2.0 * map_attrs.hit_windows.od;
|
||||||
|
|
||||||
|
// * Preempt time can go below 450ms. Normally, this is achieved via the DT mod
|
||||||
|
// * which uniformly speeds up all animations game wide regardless of AR.
|
||||||
|
// * This uniform speedup is hard to match 1:1, however we can at least make
|
||||||
|
// * AR>10 (via mods) feel good by extending the upper linear function above.
|
||||||
|
// * Note that this doesn't exactly match the AR>10 visuals as they're
|
||||||
|
// * classically known, but it feels good.
|
||||||
|
// * This adjustment is necessary for AR>10, otherwise TimePreempt can
|
||||||
|
// * become smaller leading to hitcircles not fully fading in.
|
||||||
|
let time_fade_in = if mods.hd() {
|
||||||
|
time_preempt * HD_FADE_IN_DURATION_MULTIPLIER
|
||||||
|
} else {
|
||||||
|
400.0 * (time_preempt / OsuObject::PREEMPT_MIN).min(1.0)
|
||||||
|
};
|
||||||
|
|
||||||
|
let aim = Aim::new(true);
|
||||||
|
let aim_no_sliders = Aim::new(false);
|
||||||
|
let speed = Speed::new(hit_window);
|
||||||
|
let flashlight = Flashlight::new(mods, scaling_factor.radius, time_preempt, time_fade_in);
|
||||||
|
|
||||||
|
Self {
|
||||||
|
aim,
|
||||||
|
aim_no_sliders,
|
||||||
|
speed,
|
||||||
|
flashlight,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ const STRAIN_DECAY_BASE: f64 = 0.3;
|
|||||||
const DIFFICULTY_MULTIPLER: f64 = 1.04;
|
const DIFFICULTY_MULTIPLER: f64 = 1.04;
|
||||||
const REDUCED_SECTION_COUNT: usize = 5;
|
const REDUCED_SECTION_COUNT: usize = 5;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
pub struct Speed {
|
pub struct Speed {
|
||||||
curr_strain: f64,
|
curr_strain: f64,
|
||||||
curr_rhythm: f64,
|
curr_rhythm: f64,
|
||||||
@@ -41,7 +42,17 @@ impl Speed {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn difficulty_value(self) -> f64 {
|
pub fn difficulty_value(self) -> f64 {
|
||||||
self.inner.difficulty_value(
|
Self::static_difficulty_value(self.inner)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Use [`difficulty_value`] instead whenever possible because
|
||||||
|
/// [`as_difficulty_value`] clones internally.
|
||||||
|
pub fn as_difficulty_value(&self) -> f64 {
|
||||||
|
Self::static_difficulty_value(self.inner.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn static_difficulty_value(skill: OsuStrainSkill) -> f64 {
|
||||||
|
skill.difficulty_value(
|
||||||
REDUCED_SECTION_COUNT,
|
REDUCED_SECTION_COUNT,
|
||||||
OsuStrainSkill::REDUCED_STRAIN_BASELINE,
|
OsuStrainSkill::REDUCED_STRAIN_BASELINE,
|
||||||
OsuStrainSkill::DECAY_WEIGHT,
|
OsuStrainSkill::DECAY_WEIGHT,
|
||||||
@@ -77,7 +88,7 @@ impl<'a> Skill<'a, Speed> {
|
|||||||
* strain_decay(time - prev_start_time, STRAIN_DECAY_BASE)
|
* strain_decay(time - prev_start_time, STRAIN_DECAY_BASE)
|
||||||
}
|
}
|
||||||
|
|
||||||
const fn curr_section_peak(&self) -> f64 {
|
fn curr_section_peak(&self) -> f64 {
|
||||||
self.inner.inner.inner.curr_section_peak
|
self.inner.inner.inner.curr_section_peak
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,7 +96,7 @@ impl<'a> Skill<'a, Speed> {
|
|||||||
&mut self.inner.inner.inner.curr_section_peak
|
&mut self.inner.inner.inner.curr_section_peak
|
||||||
}
|
}
|
||||||
|
|
||||||
const fn curr_section_end(&self) -> f64 {
|
fn curr_section_end(&self) -> f64 {
|
||||||
self.inner.inner.inner.curr_section_end
|
self.inner.inner.inner.curr_section_end
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use crate::any::difficulty::skills::StrainSkill;
|
use crate::any::difficulty::skills::StrainSkill;
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Clone, Default)]
|
||||||
pub struct OsuStrainSkill {
|
pub struct OsuStrainSkill {
|
||||||
pub inner: StrainSkill,
|
pub inner: StrainSkill,
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -13,7 +13,8 @@ use crate::{
|
|||||||
pub use self::{
|
pub use self::{
|
||||||
attributes::{OsuDifficultyAttributes, OsuPerformanceAttributes},
|
attributes::{OsuDifficultyAttributes, OsuPerformanceAttributes},
|
||||||
convert::OsuBeatmap,
|
convert::OsuBeatmap,
|
||||||
performance::OsuPerformance,
|
difficulty::gradual::OsuGradualDifficulty,
|
||||||
|
performance::{gradual::OsuGradualPerformance, OsuPerformance},
|
||||||
score_state::OsuScoreState,
|
score_state::OsuScoreState,
|
||||||
strains::OsuStrains,
|
strains::OsuStrains,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,833 +0,0 @@
|
|||||||
use std::cmp;
|
|
||||||
|
|
||||||
use rosu_map::section::general::GameMode;
|
|
||||||
|
|
||||||
use crate::{
|
|
||||||
any::ModeDifficulty,
|
|
||||||
any::{HitResultPriority, ModeAttributeProvider, Performance},
|
|
||||||
catch::CatchPerformance,
|
|
||||||
mania::ManiaPerformance,
|
|
||||||
taiko::TaikoPerformance,
|
|
||||||
util::{float_ext::FloatExt, map_or_attrs::MapOrAttrs, mods::Mods},
|
|
||||||
};
|
|
||||||
|
|
||||||
use super::{
|
|
||||||
attributes::{OsuDifficultyAttributes, OsuPerformanceAttributes},
|
|
||||||
convert::OsuBeatmap,
|
|
||||||
score_state::OsuScoreState,
|
|
||||||
Osu,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// Performance calculator on osu!standard maps.
|
|
||||||
#[derive(Clone, Debug, PartialEq)]
|
|
||||||
#[must_use]
|
|
||||||
pub struct OsuPerformance<'map> {
|
|
||||||
pub(crate) map_or_attrs: MapOrAttrs<'map, Osu>,
|
|
||||||
pub(crate) mods: u32,
|
|
||||||
pub(crate) acc: Option<f64>,
|
|
||||||
pub(crate) combo: Option<u32>,
|
|
||||||
|
|
||||||
pub(crate) n300: Option<u32>,
|
|
||||||
pub(crate) n100: Option<u32>,
|
|
||||||
pub(crate) n50: Option<u32>,
|
|
||||||
pub(crate) n_misses: Option<u32>,
|
|
||||||
pub(crate) passed_objects: Option<u32>,
|
|
||||||
pub(crate) clock_rate: Option<f64>,
|
|
||||||
pub(crate) hitresult_priority: HitResultPriority,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'map> OsuPerformance<'map> {
|
|
||||||
/// Create a new performance calculator for osu!standard maps.
|
|
||||||
pub fn new(map: OsuBeatmap<'map>) -> Self {
|
|
||||||
map.into()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Attempt to convert the map to the specified mode.
|
|
||||||
///
|
|
||||||
/// Returns `None` if the internal beatmap was already replaced with
|
|
||||||
/// [`OsuDifficultyAttributes`], i.e. if [`OsuPerformance::attributes`] or
|
|
||||||
/// [`OsuPerformance::generate_state`] was called.
|
|
||||||
///
|
|
||||||
/// If the given mode should be ignored in case the internal beatmap was
|
|
||||||
/// replaced, use [`mode_or_ignore`] instead.
|
|
||||||
///
|
|
||||||
/// [`mode_or_ignore`]: Self::mode_or_ignore
|
|
||||||
pub fn try_mode(self, mode: GameMode) -> Option<Performance<'map>> {
|
|
||||||
match mode {
|
|
||||||
GameMode::Osu => Some(Performance::Osu(self)),
|
|
||||||
GameMode::Taiko => TaikoPerformance::try_from(self)
|
|
||||||
.map(Performance::Taiko)
|
|
||||||
.ok(),
|
|
||||||
GameMode::Catch => CatchPerformance::try_from(self)
|
|
||||||
.map(Performance::Catch)
|
|
||||||
.ok(),
|
|
||||||
GameMode::Mania => ManiaPerformance::try_from(self)
|
|
||||||
.map(Performance::Mania)
|
|
||||||
.ok(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Attempt to convert the map to the specified mode.
|
|
||||||
///
|
|
||||||
/// If the internal beatmap was already replaced with difficulty
|
|
||||||
/// attributes, the map won't be modified.
|
|
||||||
///
|
|
||||||
/// To see whether the internal beatmap was replaced, use [`try_mode`]
|
|
||||||
/// instead.
|
|
||||||
///
|
|
||||||
/// [`try_mode`]: Self::try_mode
|
|
||||||
pub fn mode_or_ignore(self, mode: GameMode) -> Performance<'map> {
|
|
||||||
match mode {
|
|
||||||
GameMode::Osu => Performance::Osu(self),
|
|
||||||
GameMode::Taiko => {
|
|
||||||
TaikoPerformance::try_from(self).map_or_else(Performance::Osu, Performance::Taiko)
|
|
||||||
}
|
|
||||||
GameMode::Catch => {
|
|
||||||
CatchPerformance::try_from(self).map_or_else(Performance::Osu, Performance::Catch)
|
|
||||||
}
|
|
||||||
GameMode::Mania => {
|
|
||||||
ManiaPerformance::try_from(self).map_or_else(Performance::Osu, Performance::Mania)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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.
|
|
||||||
pub fn attributes(mut self, attributes: impl ModeAttributeProvider<Osu>) -> Self {
|
|
||||||
if let Some(attrs) = attributes.attributes() {
|
|
||||||
self.map_or_attrs = MapOrAttrs::Attrs(attrs);
|
|
||||||
}
|
|
||||||
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Specify mods through their bit values.
|
|
||||||
///
|
|
||||||
/// See [https://github.com/ppy/osu-api/wiki#mods](https://github.com/ppy/osu-api/wiki#mods)
|
|
||||||
pub const fn mods(mut self, mods: u32) -> Self {
|
|
||||||
self.mods = mods;
|
|
||||||
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Specify the max combo of the play.
|
|
||||||
pub const fn combo(mut self, combo: u32) -> Self {
|
|
||||||
self.combo = Some(combo);
|
|
||||||
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Specify how hitresults should be generated.
|
|
||||||
///
|
|
||||||
/// Defauls to [`HitResultPriority::BestCase`].
|
|
||||||
pub const fn hitresult_priority(mut self, priority: HitResultPriority) -> Self {
|
|
||||||
self.hitresult_priority = priority;
|
|
||||||
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Specify the amount of 300s of a play.
|
|
||||||
pub const fn n300(mut self, n300: u32) -> Self {
|
|
||||||
self.n300 = Some(n300);
|
|
||||||
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Specify the amount of 100s of a play.
|
|
||||||
pub const fn n100(mut self, n100: u32) -> Self {
|
|
||||||
self.n100 = Some(n100);
|
|
||||||
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Specify the amount of 50s of a play.
|
|
||||||
pub const fn n50(mut self, n50: u32) -> Self {
|
|
||||||
self.n50 = Some(n50);
|
|
||||||
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Specify the amount of misses of a play.
|
|
||||||
pub const fn n_misses(mut self, n_misses: u32) -> Self {
|
|
||||||
self.n_misses = Some(n_misses);
|
|
||||||
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Amount of passed objects for partial plays, e.g. a fail.
|
|
||||||
///
|
|
||||||
#[cfg_attr(
|
|
||||||
feature = "gradual",
|
|
||||||
doc = "If you want to calculate the performance after every few objects, instead of
|
|
||||||
using [`OsuPP`] multiple times with different `passed_objects`, you should use
|
|
||||||
[`OsuGradualPerformanceAttributes`](crate::osu::OsuGradualPerformance)."
|
|
||||||
)]
|
|
||||||
pub const fn passed_objects(mut self, passed_objects: u32) -> 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.
|
|
||||||
pub const fn clock_rate(mut self, clock_rate: f64) -> Self {
|
|
||||||
self.clock_rate = Some(clock_rate);
|
|
||||||
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Provide parameters through an [`OsuScoreState`].
|
|
||||||
#[allow(clippy::needless_pass_by_value)]
|
|
||||||
pub const fn state(mut self, state: OsuScoreState) -> Self {
|
|
||||||
let OsuScoreState {
|
|
||||||
max_combo,
|
|
||||||
n300,
|
|
||||||
n100,
|
|
||||||
n50,
|
|
||||||
n_misses,
|
|
||||||
} = state;
|
|
||||||
|
|
||||||
self.combo = Some(max_combo);
|
|
||||||
self.n300 = Some(n300);
|
|
||||||
self.n100 = Some(n100);
|
|
||||||
self.n50 = Some(n50);
|
|
||||||
self.n_misses = Some(n_misses);
|
|
||||||
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Specify the accuracy of a play between `0.0` and `100.0`.
|
|
||||||
/// This will be used to generate matching hitresults.
|
|
||||||
pub fn accuracy(mut self, acc: f64) -> Self {
|
|
||||||
self.acc = Some(acc / 100.0);
|
|
||||||
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create the [`OsuScoreState`] that will be used for performance calculation.
|
|
||||||
#[allow(clippy::too_many_lines)]
|
|
||||||
pub fn generate_state(&mut self) -> OsuScoreState {
|
|
||||||
let attrs = match self.map_or_attrs {
|
|
||||||
MapOrAttrs::Map(ref map) => {
|
|
||||||
let attrs = self.generate_attributes(map);
|
|
||||||
|
|
||||||
self.map_or_attrs.attrs_or_insert(attrs)
|
|
||||||
}
|
|
||||||
MapOrAttrs::Attrs(ref attrs) => attrs,
|
|
||||||
};
|
|
||||||
|
|
||||||
let max_combo = attrs.max_combo;
|
|
||||||
let n_objects = self.passed_objects.unwrap_or(attrs.n_objects());
|
|
||||||
let priority = self.hitresult_priority;
|
|
||||||
|
|
||||||
let n_misses = self.n_misses.map_or(0, |n| n.min(n_objects));
|
|
||||||
let n_remaining = n_objects - n_misses;
|
|
||||||
|
|
||||||
let mut n300 = self.n300.map_or(0, |n| n.min(n_remaining));
|
|
||||||
let mut n100 = self.n100.map_or(0, |n| n.min(n_remaining));
|
|
||||||
let mut n50 = self.n50.map_or(0, |n| n.min(n_remaining));
|
|
||||||
|
|
||||||
if let Some(acc) = self.acc {
|
|
||||||
let target_total = acc * f64::from(6 * n_objects);
|
|
||||||
|
|
||||||
match (self.n300, self.n100, self.n50) {
|
|
||||||
(Some(_), Some(_), Some(_)) => {
|
|
||||||
let remaining = n_objects.saturating_sub(n300 + n100 + n50 + n_misses);
|
|
||||||
|
|
||||||
match priority {
|
|
||||||
HitResultPriority::BestCase => n300 += remaining,
|
|
||||||
HitResultPriority::WorstCase => n50 += remaining,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
(Some(_), Some(_), None) => n50 = n_objects.saturating_sub(n300 + n100 + n_misses),
|
|
||||||
(Some(_), None, Some(_)) => n100 = n_objects.saturating_sub(n300 + n50 + n_misses),
|
|
||||||
(None, Some(_), Some(_)) => n300 = n_objects.saturating_sub(n100 + n50 + n_misses),
|
|
||||||
(Some(_), None, None) => {
|
|
||||||
let mut best_dist = f64::MAX;
|
|
||||||
|
|
||||||
n300 = n300.min(n_remaining);
|
|
||||||
let n_remaining = n_remaining - n300;
|
|
||||||
|
|
||||||
let raw_n100 = target_total - f64::from(n_remaining + 6 * n300);
|
|
||||||
let min_n100 = n_remaining.min(raw_n100.floor() as u32);
|
|
||||||
let max_n100 = n_remaining.min(raw_n100.ceil() as u32);
|
|
||||||
|
|
||||||
for new100 in min_n100..=max_n100 {
|
|
||||||
let new50 = n_remaining - new100;
|
|
||||||
let dist = (acc - accuracy(n300, new100, new50, n_misses)).abs();
|
|
||||||
|
|
||||||
if dist < best_dist {
|
|
||||||
best_dist = dist;
|
|
||||||
n100 = new100;
|
|
||||||
n50 = new50;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
(None, Some(_), None) => {
|
|
||||||
let mut best_dist = f64::MAX;
|
|
||||||
|
|
||||||
n100 = n100.min(n_remaining);
|
|
||||||
let n_remaining = n_remaining - n100;
|
|
||||||
|
|
||||||
let raw_n300 = (target_total - f64::from(n_remaining + 2 * n100)) / 5.0;
|
|
||||||
let min_n300 = n_remaining.min(raw_n300.floor() as u32);
|
|
||||||
let max_n300 = n_remaining.min(raw_n300.ceil() as u32);
|
|
||||||
|
|
||||||
for new300 in min_n300..=max_n300 {
|
|
||||||
let new50 = n_remaining - new300;
|
|
||||||
let curr_dist = (acc - accuracy(new300, n100, new50, n_misses)).abs();
|
|
||||||
|
|
||||||
if curr_dist < best_dist {
|
|
||||||
best_dist = curr_dist;
|
|
||||||
n300 = new300;
|
|
||||||
n50 = new50;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
(None, None, Some(_)) => {
|
|
||||||
let mut best_dist = f64::MAX;
|
|
||||||
|
|
||||||
n50 = n50.min(n_remaining);
|
|
||||||
let n_remaining = n_remaining - n50;
|
|
||||||
|
|
||||||
let raw_n300 = (target_total + f64::from(2 * n_misses + n50)
|
|
||||||
- f64::from(2 * n_objects))
|
|
||||||
/ 4.0;
|
|
||||||
|
|
||||||
let min_n300 = n_remaining.min(raw_n300.floor() as u32);
|
|
||||||
let max_n300 = n_remaining.min(raw_n300.ceil() as u32);
|
|
||||||
|
|
||||||
for new300 in min_n300..=max_n300 {
|
|
||||||
let new100 = n_remaining - new300;
|
|
||||||
let curr_dist = (acc - accuracy(new300, new100, n50, n_misses)).abs();
|
|
||||||
|
|
||||||
if curr_dist < best_dist {
|
|
||||||
best_dist = curr_dist;
|
|
||||||
n300 = new300;
|
|
||||||
n100 = new100;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
(None, None, None) => {
|
|
||||||
let mut best_dist = f64::MAX;
|
|
||||||
|
|
||||||
let raw_n300 = (target_total - f64::from(n_remaining)) / 5.0;
|
|
||||||
let min_n300 = cmp::min(n_remaining, raw_n300.floor() as u32);
|
|
||||||
let max_n300 = cmp::min(n_remaining, raw_n300.ceil() as u32);
|
|
||||||
|
|
||||||
for new300 in min_n300..=max_n300 {
|
|
||||||
let raw_n100 = target_total - f64::from(n_remaining + 5 * new300);
|
|
||||||
let min_n100 = cmp::min(raw_n100.floor() as u32, n_remaining - new300);
|
|
||||||
let max_n100 = cmp::min(raw_n100.ceil() as u32, n_remaining - new300);
|
|
||||||
|
|
||||||
for new100 in min_n100..=max_n100 {
|
|
||||||
let new50 = n_remaining - new300 - new100;
|
|
||||||
let curr_dist = (acc - accuracy(new300, new100, new50, n_misses)).abs();
|
|
||||||
|
|
||||||
if curr_dist < best_dist {
|
|
||||||
best_dist = curr_dist;
|
|
||||||
n300 = new300;
|
|
||||||
n100 = new100;
|
|
||||||
n50 = new50;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
match priority {
|
|
||||||
HitResultPriority::BestCase => {
|
|
||||||
// Shift n50 to n100 by sacrificing n300
|
|
||||||
let n = n300.min(n50 / 4);
|
|
||||||
n300 -= n;
|
|
||||||
n100 += 5 * n;
|
|
||||||
n50 -= 4 * n;
|
|
||||||
}
|
|
||||||
HitResultPriority::WorstCase => {
|
|
||||||
// Shift n100 to n50 by gaining n300
|
|
||||||
let n = n100 / 5;
|
|
||||||
n300 += n;
|
|
||||||
n100 -= 5 * n;
|
|
||||||
n50 += 4 * n;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
let remaining = n_objects.saturating_sub(n300 + n100 + n50 + n_misses);
|
|
||||||
|
|
||||||
match priority {
|
|
||||||
HitResultPriority::BestCase => match (self.n300, self.n100, self.n50) {
|
|
||||||
(None, ..) => n300 = remaining,
|
|
||||||
(_, None, _) => n100 = remaining,
|
|
||||||
(.., None) => n50 = remaining,
|
|
||||||
_ => n300 += remaining,
|
|
||||||
},
|
|
||||||
HitResultPriority::WorstCase => match (self.n50, self.n100, self.n300) {
|
|
||||||
(None, ..) => n50 = remaining,
|
|
||||||
(_, None, _) => n100 = remaining,
|
|
||||||
(.., None) => n300 = remaining,
|
|
||||||
_ => n50 += remaining,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let max_possible_combo = max_combo.saturating_sub(n_misses);
|
|
||||||
|
|
||||||
let max_combo = self
|
|
||||||
.combo
|
|
||||||
.map_or(max_possible_combo, |combo| combo.min(max_possible_combo));
|
|
||||||
|
|
||||||
OsuScoreState {
|
|
||||||
max_combo,
|
|
||||||
n300,
|
|
||||||
n100,
|
|
||||||
n50,
|
|
||||||
n_misses,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Calculate all performance related values, including pp and stars.
|
|
||||||
pub fn calculate(mut self) -> OsuPerformanceAttributes {
|
|
||||||
let state = self.generate_state();
|
|
||||||
|
|
||||||
let attrs = match self.map_or_attrs {
|
|
||||||
MapOrAttrs::Map(ref map) => self.generate_attributes(map),
|
|
||||||
MapOrAttrs::Attrs(attrs) => attrs,
|
|
||||||
};
|
|
||||||
|
|
||||||
let effective_miss_count = calculate_effective_misses(&attrs, &state);
|
|
||||||
|
|
||||||
let inner = OsuPerformanceInner {
|
|
||||||
attrs,
|
|
||||||
mods: self.mods,
|
|
||||||
acc: state.accuracy(),
|
|
||||||
state,
|
|
||||||
effective_miss_count,
|
|
||||||
};
|
|
||||||
|
|
||||||
inner.calculate()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn generate_attributes(&self, map: &OsuBeatmap<'_>) -> OsuDifficultyAttributes {
|
|
||||||
let mut calculator = ModeDifficulty::new();
|
|
||||||
|
|
||||||
if let Some(passed_objects) = self.passed_objects {
|
|
||||||
calculator.passed_objects(passed_objects);
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(clock_rate) = self.clock_rate {
|
|
||||||
calculator.clock_rate(clock_rate);
|
|
||||||
}
|
|
||||||
|
|
||||||
calculator.mods(self.mods).calculate(map)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Try to create [`OsuPerformance`] through a [`ModeAttributeProvider`].
|
|
||||||
///
|
|
||||||
/// If you already calculated the attributes for the current map-mod
|
|
||||||
/// combination, the [`OsuBeatmap`] is no longer necessary to calculate
|
|
||||||
/// performance attributes so this method can be used instead of
|
|
||||||
/// [`OsuPerformance::new`].
|
|
||||||
///
|
|
||||||
/// Returns `None` only if the [`ModeAttributeProvider`] did not contain
|
|
||||||
/// attributes for osu e.g. if it's [`DifficultyAttributes::Taiko`].
|
|
||||||
///
|
|
||||||
/// [`DifficultyAttributes::Taiko`]: crate::any::DifficultyAttributes::Taiko
|
|
||||||
pub fn try_from_attributes(attributes: impl ModeAttributeProvider<Osu>) -> Option<Self> {
|
|
||||||
attributes.attributes().map(Self::from)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create [`OsuPerformance`] through a [`ModeAttributeProvider`].
|
|
||||||
///
|
|
||||||
/// If you already calculated the attributes for the current map-mod
|
|
||||||
/// combination, the [`OsuBeatmap`] is no longer necessary to calculate
|
|
||||||
/// performance attributes so this method can be used instead of
|
|
||||||
/// [`OsuPerformance::new`].
|
|
||||||
///
|
|
||||||
/// # Panics
|
|
||||||
///
|
|
||||||
/// Panics if the [`ModeAttributeProvider`] did not contain attributes for
|
|
||||||
/// osu e.g. if it's [`DifficultyAttributes::Taiko`].
|
|
||||||
///
|
|
||||||
/// [`DifficultyAttributes::Taiko`]: crate::any::DifficultyAttributes::Taiko
|
|
||||||
pub fn unchecked_from_attributes(attributes: impl ModeAttributeProvider<Osu>) -> Self {
|
|
||||||
Self::try_from_attributes(attributes).expect("invalid osu attributes")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'map> From<OsuBeatmap<'map>> for OsuPerformance<'map> {
|
|
||||||
fn from(map: OsuBeatmap<'map>) -> Self {
|
|
||||||
Self {
|
|
||||||
map_or_attrs: MapOrAttrs::Map(map),
|
|
||||||
mods: 0,
|
|
||||||
acc: None,
|
|
||||||
combo: None,
|
|
||||||
|
|
||||||
n300: None,
|
|
||||||
n100: None,
|
|
||||||
n50: None,
|
|
||||||
n_misses: None,
|
|
||||||
passed_objects: None,
|
|
||||||
clock_rate: None,
|
|
||||||
hitresult_priority: HitResultPriority::default(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<OsuDifficultyAttributes> for OsuPerformance<'_> {
|
|
||||||
fn from(attrs: OsuDifficultyAttributes) -> Self {
|
|
||||||
Self {
|
|
||||||
map_or_attrs: MapOrAttrs::Attrs(attrs),
|
|
||||||
mods: 0,
|
|
||||||
acc: None,
|
|
||||||
combo: None,
|
|
||||||
|
|
||||||
n300: None,
|
|
||||||
n100: None,
|
|
||||||
n50: None,
|
|
||||||
n_misses: None,
|
|
||||||
passed_objects: None,
|
|
||||||
clock_rate: None,
|
|
||||||
hitresult_priority: HitResultPriority::default(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<OsuPerformanceAttributes> for OsuPerformance<'_> {
|
|
||||||
fn from(attrs: OsuPerformanceAttributes) -> Self {
|
|
||||||
attrs.difficulty.into()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub const PERFORMANCE_BASE_MULTIPLIER: f64 = 1.14;
|
|
||||||
|
|
||||||
struct OsuPerformanceInner {
|
|
||||||
attrs: OsuDifficultyAttributes,
|
|
||||||
mods: u32,
|
|
||||||
acc: f64,
|
|
||||||
state: OsuScoreState,
|
|
||||||
effective_miss_count: f64,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl OsuPerformanceInner {
|
|
||||||
fn calculate(mut self) -> OsuPerformanceAttributes {
|
|
||||||
let total_hits = self.state.total_hits();
|
|
||||||
|
|
||||||
if total_hits == 0 {
|
|
||||||
return OsuPerformanceAttributes {
|
|
||||||
difficulty: self.attrs,
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
let total_hits = f64::from(total_hits);
|
|
||||||
|
|
||||||
let mut multiplier = PERFORMANCE_BASE_MULTIPLIER;
|
|
||||||
|
|
||||||
if self.mods.nf() {
|
|
||||||
multiplier *= (1.0 - 0.02 * self.effective_miss_count).max(0.9);
|
|
||||||
}
|
|
||||||
|
|
||||||
if self.mods.so() && total_hits > 0.0 {
|
|
||||||
multiplier *= 1.0 - (f64::from(self.attrs.n_spinners) / total_hits).powf(0.85);
|
|
||||||
}
|
|
||||||
|
|
||||||
if self.mods.rx() {
|
|
||||||
// * https://www.desmos.com/calculator/bc9eybdthb
|
|
||||||
// * we use OD13.3 as maximum since it's the value at which great hitwidow becomes 0
|
|
||||||
// * this is well beyond currently maximum achievable OD which is 12.17 (DTx2 + DA with OD11)
|
|
||||||
let (n100_mult, n50_mult) = if self.attrs.od > 0.0 {
|
|
||||||
(
|
|
||||||
1.0 - (self.attrs.od / 13.33).powf(1.8),
|
|
||||||
1.0 - (self.attrs.od / 13.33).powi(5),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
(1.0, 1.0)
|
|
||||||
};
|
|
||||||
|
|
||||||
// * As we're adding Oks and Mehs to an approximated number of combo breaks the result can be
|
|
||||||
// * higher than total hits in specific scenarios (which breaks some calculations) so we need to clamp it.
|
|
||||||
self.effective_miss_count = (self.effective_miss_count
|
|
||||||
+ f64::from(self.state.n100)
|
|
||||||
+ n100_mult
|
|
||||||
+ f64::from(self.state.n50) * n50_mult)
|
|
||||||
.min(total_hits);
|
|
||||||
}
|
|
||||||
|
|
||||||
let aim_value = self.compute_aim_value();
|
|
||||||
let speed_value = self.compute_speed_value();
|
|
||||||
let acc_value = self.compute_accuracy_value();
|
|
||||||
let flashlight_value = self.compute_flashlight_value();
|
|
||||||
|
|
||||||
let pp = (aim_value.powf(1.1)
|
|
||||||
+ speed_value.powf(1.1)
|
|
||||||
+ acc_value.powf(1.1)
|
|
||||||
+ flashlight_value.powf(1.1))
|
|
||||||
.powf(1.0 / 1.1)
|
|
||||||
* multiplier;
|
|
||||||
|
|
||||||
OsuPerformanceAttributes {
|
|
||||||
difficulty: self.attrs,
|
|
||||||
pp_acc: acc_value,
|
|
||||||
pp_aim: aim_value,
|
|
||||||
pp_flashlight: flashlight_value,
|
|
||||||
pp_speed: speed_value,
|
|
||||||
pp,
|
|
||||||
effective_miss_count: self.effective_miss_count,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn compute_aim_value(&self) -> f64 {
|
|
||||||
let mut aim_value = (5.0 * (self.attrs.aim / 0.0675).max(1.0) - 4.0).powi(3) / 100_000.0;
|
|
||||||
|
|
||||||
let total_hits = self.total_hits();
|
|
||||||
|
|
||||||
let len_bonus = 0.95
|
|
||||||
+ 0.4 * (total_hits / 2000.0).min(1.0)
|
|
||||||
+ f64::from(u8::from(total_hits > 2000.0)) * (total_hits / 2000.0).log10() * 0.5;
|
|
||||||
|
|
||||||
aim_value *= len_bonus;
|
|
||||||
|
|
||||||
// * Penalize misses by assessing # of misses relative to the total # of objects.
|
|
||||||
// * Default a 3% reduction for any # of misses.
|
|
||||||
if self.effective_miss_count > 0.0 {
|
|
||||||
aim_value *= 0.97
|
|
||||||
* (1.0 - (self.effective_miss_count / total_hits).powf(0.775))
|
|
||||||
.powf(self.effective_miss_count);
|
|
||||||
}
|
|
||||||
|
|
||||||
aim_value *= self.get_combo_scaling_factor();
|
|
||||||
|
|
||||||
let ar_factor = if self.mods.rx() {
|
|
||||||
0.0
|
|
||||||
} else if self.attrs.ar > 10.33 {
|
|
||||||
0.3 * (self.attrs.ar - 10.33)
|
|
||||||
} else if self.attrs.ar < 8.0 {
|
|
||||||
0.05 * (8.0 - self.attrs.ar)
|
|
||||||
} else {
|
|
||||||
0.0
|
|
||||||
};
|
|
||||||
|
|
||||||
// * Buff for longer maps with high AR.
|
|
||||||
aim_value *= 1.0 + ar_factor * len_bonus;
|
|
||||||
|
|
||||||
if self.mods.hd() {
|
|
||||||
// * We want to give more reward for lower AR when it comes to aim and HD. This nerfs high AR and buffs lower AR.
|
|
||||||
aim_value *= 1.0 + 0.04 * (12.0 - self.attrs.ar);
|
|
||||||
}
|
|
||||||
|
|
||||||
// * We assume 15% of sliders in a map are difficult since there's no way to tell from the performance calculator.
|
|
||||||
let estimate_diff_sliders = f64::from(self.attrs.n_sliders) * 0.15;
|
|
||||||
|
|
||||||
if self.attrs.n_sliders > 0 {
|
|
||||||
let estimate_slider_ends_dropped = f64::from(
|
|
||||||
(self.state.n100 + self.state.n50 + self.state.n_misses)
|
|
||||||
.min(self.attrs.max_combo.saturating_sub(self.state.max_combo)),
|
|
||||||
)
|
|
||||||
.clamp(0.0, estimate_diff_sliders);
|
|
||||||
let slider_nerf_factor = (1.0 - self.attrs.slider_factor)
|
|
||||||
* (1.0 - estimate_slider_ends_dropped / estimate_diff_sliders).powi(3)
|
|
||||||
+ self.attrs.slider_factor;
|
|
||||||
|
|
||||||
aim_value *= slider_nerf_factor;
|
|
||||||
}
|
|
||||||
|
|
||||||
aim_value *= self.acc;
|
|
||||||
// * It is important to consider accuracy difficulty when scaling with accuracy.
|
|
||||||
aim_value *= 0.98 + self.attrs.od.powi(2) / 2500.0;
|
|
||||||
|
|
||||||
aim_value
|
|
||||||
}
|
|
||||||
|
|
||||||
fn compute_speed_value(&self) -> f64 {
|
|
||||||
if self.mods.rx() {
|
|
||||||
return 0.0;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut speed_value =
|
|
||||||
(5.0 * (self.attrs.speed / 0.0675).max(1.0) - 4.0).powi(3) / 100_000.0;
|
|
||||||
|
|
||||||
let total_hits = self.total_hits();
|
|
||||||
|
|
||||||
let len_bonus = 0.95
|
|
||||||
+ 0.4 * (total_hits / 2000.0).min(1.0)
|
|
||||||
+ f64::from(u8::from(total_hits > 2000.0)) * (total_hits / 2000.0).log10() * 0.5;
|
|
||||||
|
|
||||||
speed_value *= len_bonus;
|
|
||||||
|
|
||||||
// * Penalize misses by assessing # of misses relative to the total # of objects.
|
|
||||||
// * Default a 3% reduction for any # of misses.
|
|
||||||
if self.effective_miss_count > 0.0 {
|
|
||||||
speed_value *= 0.97
|
|
||||||
* (1.0 - (self.effective_miss_count / total_hits).powf(0.775))
|
|
||||||
.powf(self.effective_miss_count.powf(0.875));
|
|
||||||
}
|
|
||||||
|
|
||||||
speed_value *= self.get_combo_scaling_factor();
|
|
||||||
|
|
||||||
let ar_factor = if self.attrs.ar > 10.33 {
|
|
||||||
0.3 * (self.attrs.ar - 10.33)
|
|
||||||
} else {
|
|
||||||
0.0
|
|
||||||
};
|
|
||||||
|
|
||||||
// * Buff for longer maps with high AR.
|
|
||||||
speed_value *= 1.0 + ar_factor * len_bonus;
|
|
||||||
|
|
||||||
if self.mods.hd() {
|
|
||||||
// * We want to give more reward for lower AR when it comes to aim and HD.
|
|
||||||
// * This nerfs high AR and buffs lower AR.
|
|
||||||
speed_value *= 1.0 + 0.04 * (12.0 - self.attrs.ar);
|
|
||||||
}
|
|
||||||
|
|
||||||
// * Calculate accuracy assuming the worst case scenario
|
|
||||||
let relevant_total_diff = total_hits - self.attrs.speed_note_count;
|
|
||||||
let relevant_n300 = (f64::from(self.state.n300) - relevant_total_diff).max(0.0);
|
|
||||||
let relevant_n100 = (f64::from(self.state.n100)
|
|
||||||
- (relevant_total_diff - f64::from(self.state.n300)).max(0.0))
|
|
||||||
.max(0.0);
|
|
||||||
let relevant_n50 = (f64::from(self.state.n50)
|
|
||||||
- (relevant_total_diff - f64::from(self.state.n300 + self.state.n100)).max(0.0))
|
|
||||||
.max(0.0);
|
|
||||||
|
|
||||||
let relevant_acc = if self.attrs.speed_note_count.eq(0.0) {
|
|
||||||
0.0
|
|
||||||
} else {
|
|
||||||
(relevant_n300 * 6.0 + relevant_n100 * 2.0 + relevant_n50)
|
|
||||||
/ (self.attrs.speed_note_count * 6.0)
|
|
||||||
};
|
|
||||||
|
|
||||||
// * Scale the speed value with accuracy and OD.
|
|
||||||
speed_value *= (0.95 + self.attrs.od * self.attrs.od / 750.0)
|
|
||||||
* ((self.acc + relevant_acc) / 2.0).powf((14.5 - (self.attrs.od).max(8.0)) / 2.0);
|
|
||||||
|
|
||||||
// * Scale the speed value with # of 50s to punish doubletapping.
|
|
||||||
speed_value *= 0.99_f64.powf(
|
|
||||||
f64::from(u8::from(f64::from(self.state.n50) >= total_hits / 500.0))
|
|
||||||
* (f64::from(self.state.n50) - total_hits / 500.0),
|
|
||||||
);
|
|
||||||
|
|
||||||
speed_value
|
|
||||||
}
|
|
||||||
|
|
||||||
fn compute_accuracy_value(&self) -> f64 {
|
|
||||||
if self.mods.rx() {
|
|
||||||
return 0.0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// * This percentage only considers HitCircles of any value - in this part
|
|
||||||
// * of the calculation we focus on hitting the timing hit window.
|
|
||||||
let amount_hit_objects_with_acc = self.attrs.n_circles;
|
|
||||||
|
|
||||||
let better_acc_percentage = if amount_hit_objects_with_acc > 0 {
|
|
||||||
let sub = self.state.total_hits() - amount_hit_objects_with_acc;
|
|
||||||
|
|
||||||
// * It is possible to reach a negative accuracy with this formula. Cap it at zero - zero points.
|
|
||||||
if self.state.n300 < sub {
|
|
||||||
0.0
|
|
||||||
} else {
|
|
||||||
f64::from((self.state.n300 - sub) * 6 + self.state.n100 * 2 + self.state.n50)
|
|
||||||
/ f64::from(amount_hit_objects_with_acc * 6)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
0.0
|
|
||||||
};
|
|
||||||
|
|
||||||
// * Lots of arbitrary values from testing.
|
|
||||||
// * Considering to use derivation from perfect accuracy in a probabilistic manner - assume normal distribution.
|
|
||||||
let mut acc_value = 1.52163_f64.powf(self.attrs.od) * better_acc_percentage.powi(24) * 2.83;
|
|
||||||
|
|
||||||
// * Bonus for many hitcircles - it's harder to keep good accuracy up for longer.
|
|
||||||
acc_value *= (f64::from(amount_hit_objects_with_acc) / 1000.0)
|
|
||||||
.powf(0.3)
|
|
||||||
.min(1.15);
|
|
||||||
|
|
||||||
// * Increasing the accuracy value by object count for Blinds isn't ideal, so the minimum buff is given.
|
|
||||||
if self.mods.hd() {
|
|
||||||
acc_value *= 1.08;
|
|
||||||
}
|
|
||||||
|
|
||||||
if self.mods.fl() {
|
|
||||||
acc_value *= 1.02;
|
|
||||||
}
|
|
||||||
|
|
||||||
acc_value
|
|
||||||
}
|
|
||||||
|
|
||||||
fn compute_flashlight_value(&self) -> f64 {
|
|
||||||
if !self.mods.fl() {
|
|
||||||
return 0.0;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut flashlight_value = self.attrs.flashlight.powi(2) * 25.0;
|
|
||||||
|
|
||||||
let total_hits = self.total_hits();
|
|
||||||
|
|
||||||
// * Penalize misses by assessing # of misses relative to the total # of objects. Default a 3% reduction for any # of misses.
|
|
||||||
if self.effective_miss_count > 0.0 {
|
|
||||||
flashlight_value *= 0.97
|
|
||||||
* (1.0 - (self.effective_miss_count / total_hits).powf(0.775))
|
|
||||||
.powf(self.effective_miss_count.powf(0.875));
|
|
||||||
}
|
|
||||||
|
|
||||||
flashlight_value *= self.get_combo_scaling_factor();
|
|
||||||
|
|
||||||
// * Account for shorter maps having a higher ratio of 0 combo/100 combo flashlight radius.
|
|
||||||
flashlight_value *= 0.7
|
|
||||||
+ 0.1 * (total_hits / 200.0).min(1.0)
|
|
||||||
+ f64::from(u8::from(total_hits > 200.0))
|
|
||||||
* 0.2
|
|
||||||
* ((total_hits - 200.0) / 200.0).min(1.0);
|
|
||||||
|
|
||||||
// * Scale the flashlight value with accuracy _slightly_.
|
|
||||||
flashlight_value *= 0.5 + self.acc / 2.0;
|
|
||||||
// * It is important to also consider accuracy difficulty when doing that.
|
|
||||||
flashlight_value *= 0.98 + self.attrs.od.powi(2) / 2500.0;
|
|
||||||
|
|
||||||
flashlight_value
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_combo_scaling_factor(&self) -> f64 {
|
|
||||||
if self.attrs.max_combo == 0 {
|
|
||||||
1.0
|
|
||||||
} else {
|
|
||||||
(f64::from(self.state.max_combo).powf(0.8) / f64::from(self.attrs.max_combo).powf(0.8))
|
|
||||||
.min(1.0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const fn total_hits(&self) -> f64 {
|
|
||||||
self.state.total_hits() as f64
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn calculate_effective_misses(attrs: &OsuDifficultyAttributes, state: &OsuScoreState) -> f64 {
|
|
||||||
// * Guess the number of misses + slider breaks from combo
|
|
||||||
let mut combo_based_miss_count = 0.0;
|
|
||||||
|
|
||||||
if attrs.n_sliders > 0 {
|
|
||||||
let full_combo_threshold = f64::from(attrs.max_combo) - 0.1 * f64::from(attrs.n_sliders);
|
|
||||||
|
|
||||||
if f64::from(state.max_combo) < full_combo_threshold {
|
|
||||||
combo_based_miss_count = full_combo_threshold / f64::from(state.max_combo).max(1.0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// * Clamp miss count to maximum amount of possible breaks
|
|
||||||
combo_based_miss_count =
|
|
||||||
combo_based_miss_count.min(f64::from(state.n100 + state.n50 + state.n_misses));
|
|
||||||
|
|
||||||
combo_based_miss_count.max(f64::from(state.n_misses))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn accuracy(n300: u32, n100: u32, n50: u32, n_misses: u32) -> f64 {
|
|
||||||
if n300 + n100 + n50 + n_misses == 0 {
|
|
||||||
return 0.0;
|
|
||||||
}
|
|
||||||
|
|
||||||
let numerator = 6 * n300 + 2 * n100 + n50;
|
|
||||||
let denominator = 6 * (n300 + n100 + n50 + n_misses);
|
|
||||||
|
|
||||||
f64::from(numerator) / f64::from(denominator)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
use crate::{
|
||||||
|
osu::{OsuBeatmap, OsuGradualDifficulty},
|
||||||
|
ModeDifficulty,
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::{OsuPerformance, OsuPerformanceAttributes, OsuScoreState};
|
||||||
|
|
||||||
|
/// Gradually calculate the performance attributes of an osu!standard map.
|
||||||
|
///
|
||||||
|
/// After each hit object you can call [`next`]
|
||||||
|
/// and it will return the resulting current [`OsuPerformanceAttributes`].
|
||||||
|
/// To process multiple objects at once, use [`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
|
||||||
|
/// [`OsuGradualDifficulty`] instead.
|
||||||
|
///
|
||||||
|
/// # Example
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// use rosu_pp::{Beatmap, ModeDifficulty};
|
||||||
|
/// use rosu_pp::osu::{Osu, OsuGradualPerformance, OsuScoreState};
|
||||||
|
///
|
||||||
|
/// let converted = Beatmap::from_path("./resources/2785319.osu")
|
||||||
|
/// .unwrap()
|
||||||
|
/// .unchecked_into_converted::<Osu>()
|
||||||
|
/// .unwrap();
|
||||||
|
///
|
||||||
|
/// let difficulty = ModeDifficulty::new().mods(64); // DT
|
||||||
|
/// let mut gradual_perf = OsuGradualPerformance::new(&difficulty, converted);
|
||||||
|
/// let mut state = OsuScoreState::new(); // empty state, everything is on 0.
|
||||||
|
///
|
||||||
|
/// // The first 10 hits are 300s and there are no sliders for additional combo
|
||||||
|
/// for _ in 0..10 {
|
||||||
|
/// state.n300 += 1;
|
||||||
|
/// state.max_combo += 1;
|
||||||
|
///
|
||||||
|
/// let performance = gradual_perf.next(state.clone()).unwrap();
|
||||||
|
/// println!("PP: {}", performance.pp);
|
||||||
|
/// }
|
||||||
|
///
|
||||||
|
/// // 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.n_misses += 1;
|
||||||
|
/// let performance = gradual_perf.next(state.clone()).unwrap();
|
||||||
|
/// println!("PP: {}", performance.pp);
|
||||||
|
///
|
||||||
|
/// // 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.nth(state.clone(), 9).unwrap();
|
||||||
|
/// println!("PP: {}", performance.pp);
|
||||||
|
///
|
||||||
|
/// // Now comes another 300. Note that the max combo gets incremented again.
|
||||||
|
/// state.n300 += 1;
|
||||||
|
/// state.max_combo += 1;
|
||||||
|
/// let performance = gradual_perf.next(state.clone()).unwrap();
|
||||||
|
/// println!("PP: {}", performance.pp);
|
||||||
|
///
|
||||||
|
/// // Skip to the end
|
||||||
|
/// # /*
|
||||||
|
/// state.max_combo = ...
|
||||||
|
/// state.n300 = ...
|
||||||
|
/// state.n100 = ...
|
||||||
|
/// state.n50 = ...
|
||||||
|
/// state.n_misses = ...
|
||||||
|
/// # */
|
||||||
|
/// let final_performance = gradual_perf.nth(state.clone(), usize::MAX).unwrap();
|
||||||
|
/// println!("PP: {}", performance.pp);
|
||||||
|
///
|
||||||
|
/// // Once the final performance has been calculated, attempting to process
|
||||||
|
/// // further objects will return `None`.
|
||||||
|
/// assert!(gradual_perf.next(state).is_none());
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// [`next`]: OsuGradualPerformance::next
|
||||||
|
/// [`nth`]: OsuGradualPerformance::nth
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct OsuGradualPerformance<'map> {
|
||||||
|
difficulty: OsuGradualDifficulty,
|
||||||
|
performance: OsuPerformance<'map>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'map> OsuGradualPerformance<'map> {
|
||||||
|
/// Create a new gradual performance calculator for osu!standard maps.
|
||||||
|
pub fn new(difficulty: &ModeDifficulty, converted: OsuBeatmap<'map>) -> Self {
|
||||||
|
let mods = difficulty.get_mods();
|
||||||
|
let clock_rate = difficulty.get_clock_rate();
|
||||||
|
let difficulty = OsuGradualDifficulty::new(difficulty, &converted);
|
||||||
|
|
||||||
|
let performance = OsuPerformance::new(converted)
|
||||||
|
.mods(mods)
|
||||||
|
.clock_rate(clock_rate)
|
||||||
|
.passed_objects(0);
|
||||||
|
|
||||||
|
Self {
|
||||||
|
difficulty,
|
||||||
|
performance,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Process the next hit object and calculate the performance attributes
|
||||||
|
/// for the resulting score state.
|
||||||
|
pub fn next(&mut self, state: OsuScoreState) -> Option<OsuPerformanceAttributes> {
|
||||||
|
self.nth(state, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Process all remaining hit objects and calculate the final performance
|
||||||
|
/// attributes.
|
||||||
|
pub fn last(&mut self, state: OsuScoreState) -> Option<OsuPerformanceAttributes> {
|
||||||
|
self.nth(state, usize::MAX)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Process everything up to the next `n`th hitobject and calculate the
|
||||||
|
/// performance attributes for the resulting score state.
|
||||||
|
///
|
||||||
|
/// 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 as u32)
|
||||||
|
.calculate();
|
||||||
|
|
||||||
|
Some(performance)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use crate::{osu::Osu, Beatmap};
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn next_and_nth() {
|
||||||
|
let converted = Beatmap::from_path("./resources/2785319.osu")
|
||||||
|
.unwrap()
|
||||||
|
.unchecked_into_converted::<Osu>();
|
||||||
|
|
||||||
|
let mods = 88; // HDHRDT
|
||||||
|
|
||||||
|
let difficulty = ModeDifficulty::new().mods(mods);
|
||||||
|
|
||||||
|
let mut gradual = OsuGradualPerformance::new(&difficulty, converted.as_owned());
|
||||||
|
let mut gradual_2nd = OsuGradualPerformance::new(&difficulty, converted.as_owned());
|
||||||
|
let mut gradual_3rd = OsuGradualPerformance::new(&difficulty, converted.as_owned());
|
||||||
|
|
||||||
|
let mut state = OsuScoreState::default();
|
||||||
|
|
||||||
|
let hit_objects_len = converted.map.hit_objects.len();
|
||||||
|
|
||||||
|
for i in 1.. {
|
||||||
|
state.n_misses += 1;
|
||||||
|
|
||||||
|
let Some(next_gradual) = gradual.next(state.clone()) else {
|
||||||
|
assert_eq!(i, hit_objects_len + 1);
|
||||||
|
assert!(gradual_2nd.last(state.clone()).is_some() || hit_objects_len % 2 == 0);
|
||||||
|
assert!(gradual_3rd.last(state.clone()).is_some() || hit_objects_len % 3 == 0);
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
|
||||||
|
if i % 2 == 0 {
|
||||||
|
let next_gradual_2nd = gradual_2nd.nth(state.clone(), 1).unwrap();
|
||||||
|
assert_eq!(next_gradual, next_gradual_2nd);
|
||||||
|
}
|
||||||
|
|
||||||
|
if i % 3 == 0 {
|
||||||
|
let next_gradual_3rd = gradual_3rd.nth(state.clone(), 2).unwrap();
|
||||||
|
assert_eq!(next_gradual, next_gradual_3rd);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut regular_calc = OsuPerformance::new(converted.as_owned())
|
||||||
|
.mods(mods)
|
||||||
|
.passed_objects(i as u32)
|
||||||
|
.state(state.clone());
|
||||||
|
|
||||||
|
let regular_state = regular_calc.generate_state();
|
||||||
|
assert_eq!(state, regular_state);
|
||||||
|
|
||||||
|
let expected = regular_calc.calculate();
|
||||||
|
|
||||||
|
assert_eq!(next_gradual, expected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+11
-5
@@ -1,6 +1,9 @@
|
|||||||
use crate::any::ModeDifficulty;
|
use crate::any::ModeDifficulty;
|
||||||
|
|
||||||
use super::{convert::OsuBeatmap, difficulty::DifficultyValues};
|
use super::{
|
||||||
|
convert::OsuBeatmap,
|
||||||
|
difficulty::{skills::OsuSkills, DifficultyValues},
|
||||||
|
};
|
||||||
|
|
||||||
/// The result of calculating the strains on a osu! map.
|
/// The result of calculating the strains on a osu! map.
|
||||||
///
|
///
|
||||||
@@ -24,10 +27,13 @@ impl OsuStrains {
|
|||||||
|
|
||||||
pub fn strains(difficulty: &ModeDifficulty, converted: &OsuBeatmap<'_>) -> OsuStrains {
|
pub fn strains(difficulty: &ModeDifficulty, converted: &OsuBeatmap<'_>) -> OsuStrains {
|
||||||
let DifficultyValues {
|
let DifficultyValues {
|
||||||
aim,
|
skills:
|
||||||
aim_no_sliders,
|
OsuSkills {
|
||||||
speed,
|
aim,
|
||||||
flashlight,
|
aim_no_sliders,
|
||||||
|
speed,
|
||||||
|
flashlight,
|
||||||
|
},
|
||||||
attrs: _,
|
attrs: _,
|
||||||
} = DifficultyValues::calculate(difficulty, converted);
|
} = DifficultyValues::calculate(difficulty, converted);
|
||||||
|
|
||||||
|
|||||||
@@ -130,17 +130,21 @@ impl DifficultyValues {
|
|||||||
|
|
||||||
ColorDifficultyPreprocessor::process_and_assign(&diff_objects);
|
ColorDifficultyPreprocessor::process_and_assign(&diff_objects);
|
||||||
|
|
||||||
let mut peaks = PeaksSkill::new(&diff_objects);
|
|
||||||
|
|
||||||
// The first two hit objects have no difficulty object
|
// The first two hit objects have no difficulty object
|
||||||
n_diff_objects -= 2;
|
n_diff_objects -= 2;
|
||||||
|
|
||||||
for hit_object in diff_objects.iter().take(n_diff_objects) {
|
let mut peaks = Peaks::new();
|
||||||
peaks.process(&hit_object.borrow());
|
|
||||||
|
{
|
||||||
|
let mut peaks = PeaksSkill::new(&mut peaks, &diff_objects);
|
||||||
|
|
||||||
|
for hit_object in diff_objects.iter().take(n_diff_objects) {
|
||||||
|
peaks.process(&hit_object.borrow());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
peaks: peaks.into_inner(),
|
peaks,
|
||||||
max_combo: max_combo as u32,
|
max_combo: max_combo as u32,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,12 +89,6 @@ impl Peaks {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Peaks {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct PeaksSkill<'a> {
|
pub struct PeaksSkill<'a> {
|
||||||
pub color: Skill<'a, Color>,
|
pub color: Skill<'a, Color>,
|
||||||
pub rhythm: Skill<'a, Rhythm>,
|
pub rhythm: Skill<'a, Rhythm>,
|
||||||
@@ -102,11 +96,11 @@ pub struct PeaksSkill<'a> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> PeaksSkill<'a> {
|
impl<'a> PeaksSkill<'a> {
|
||||||
pub fn new(diff_objects: &'a TaikoDifficultyObjects) -> Self {
|
pub fn new(peaks: &'a mut Peaks, diff_objects: &'a TaikoDifficultyObjects) -> Self {
|
||||||
Self {
|
Self {
|
||||||
color: Skill::new(Color::default(), diff_objects),
|
color: Skill::new(&mut peaks.color, diff_objects),
|
||||||
rhythm: Skill::new(Rhythm::default(), diff_objects),
|
rhythm: Skill::new(&mut peaks.rhythm, diff_objects),
|
||||||
stamina: Skill::new(Stamina::default(), diff_objects),
|
stamina: Skill::new(&mut peaks.stamina, diff_objects),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,12 +109,4 @@ impl<'a> PeaksSkill<'a> {
|
|||||||
self.color.process(curr);
|
self.color.process(curr);
|
||||||
self.stamina.process(curr);
|
self.stamina.process(curr);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn into_inner(self) -> Peaks {
|
|
||||||
Peaks {
|
|
||||||
color: self.color.inner,
|
|
||||||
rhythm: self.rhythm.inner,
|
|
||||||
stamina: self.stamina.inner,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user