refactor osu_2019 into modern format
This commit is contained in:
+11
-4
@@ -1,8 +1,5 @@
|
||||
use crate::{
|
||||
catch::{CatchDifficultyAttributes, CatchPerformanceAttributes},
|
||||
mania::{ManiaDifficultyAttributes, ManiaPerformanceAttributes},
|
||||
osu::{OsuDifficultyAttributes, OsuPerformanceAttributes},
|
||||
taiko::{TaikoDifficultyAttributes, TaikoPerformanceAttributes},
|
||||
catch::{CatchDifficultyAttributes, CatchPerformanceAttributes}, mania::{ManiaDifficultyAttributes, ManiaPerformanceAttributes}, osu::{OsuDifficultyAttributes, OsuPerformanceAttributes}, osu_2019, taiko::{TaikoDifficultyAttributes, TaikoPerformanceAttributes}
|
||||
};
|
||||
|
||||
use super::performance::{into::IntoPerformance, Performance};
|
||||
@@ -18,6 +15,8 @@ pub enum DifficultyAttributes {
|
||||
Catch(CatchDifficultyAttributes),
|
||||
/// osu!mania difficulty calculation result.
|
||||
Mania(ManiaDifficultyAttributes),
|
||||
/// osu!standard (relax) difficulty calculation result.
|
||||
OsuRelax(osu_2019::OsuDifficultyAttributes),
|
||||
}
|
||||
|
||||
impl DifficultyAttributes {
|
||||
@@ -28,6 +27,7 @@ impl DifficultyAttributes {
|
||||
Self::Taiko(attrs) => attrs.stars,
|
||||
Self::Catch(attrs) => attrs.stars,
|
||||
Self::Mania(attrs) => attrs.stars,
|
||||
Self::OsuRelax(attrs) => attrs.stars,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ impl DifficultyAttributes {
|
||||
Self::Taiko(attrs) => attrs.max_combo,
|
||||
Self::Catch(attrs) => attrs.max_combo(),
|
||||
Self::Mania(attrs) => attrs.max_combo,
|
||||
Self::OsuRelax(attrs) => attrs.max_combo,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,6 +59,8 @@ pub enum PerformanceAttributes {
|
||||
Catch(CatchPerformanceAttributes),
|
||||
/// osu!mania performance calculation result.
|
||||
Mania(ManiaPerformanceAttributes),
|
||||
/// osu!standard (relax) performance calculation result.
|
||||
OsuRelax(osu_2019::OsuPerformanceAttributes),
|
||||
}
|
||||
|
||||
impl PerformanceAttributes {
|
||||
@@ -68,6 +71,7 @@ impl PerformanceAttributes {
|
||||
Self::Taiko(attrs) => attrs.pp,
|
||||
Self::Catch(attrs) => attrs.pp,
|
||||
Self::Mania(attrs) => attrs.pp,
|
||||
Self::OsuRelax(attrs) => attrs.pp,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,6 +82,7 @@ impl PerformanceAttributes {
|
||||
Self::Taiko(attrs) => attrs.stars(),
|
||||
Self::Catch(attrs) => attrs.stars(),
|
||||
Self::Mania(attrs) => attrs.stars(),
|
||||
Self::OsuRelax(attrs) => attrs.difficulty.stars,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,6 +93,7 @@ impl PerformanceAttributes {
|
||||
Self::Taiko(attrs) => DifficultyAttributes::Taiko(attrs.difficulty.clone()),
|
||||
Self::Catch(attrs) => DifficultyAttributes::Catch(attrs.difficulty.clone()),
|
||||
Self::Mania(attrs) => DifficultyAttributes::Mania(attrs.difficulty.clone()),
|
||||
Self::OsuRelax(attrs) => DifficultyAttributes::OsuRelax(attrs.difficulty.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,6 +104,7 @@ impl PerformanceAttributes {
|
||||
Self::Taiko(attrs) => attrs.difficulty.max_combo,
|
||||
Self::Catch(attrs) => attrs.difficulty.max_combo(),
|
||||
Self::Mania(attrs) => attrs.difficulty.max_combo,
|
||||
Self::OsuRelax(attrs) => attrs.difficulty.max_combo,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -111,6 +111,11 @@ impl_from_mode!(
|
||||
ManiaDifficultyAttributes,
|
||||
ManiaPerformanceAttributes
|
||||
},
|
||||
osu_2019 {
|
||||
OsuRelax,
|
||||
OsuDifficultyAttributes,
|
||||
OsuPerformanceAttributes
|
||||
},
|
||||
);
|
||||
|
||||
impl<'a> IntoPerformance<'a> for Beatmap {
|
||||
@@ -141,6 +146,7 @@ impl<'a> IntoPerformance<'a> for DifficultyAttributes {
|
||||
Self::Taiko(attrs) => Performance::Taiko(attrs.into()),
|
||||
Self::Catch(attrs) => Performance::Catch(attrs.into()),
|
||||
Self::Mania(attrs) => Performance::Mania(attrs.into()),
|
||||
Self::OsuRelax(attrs) => Performance::OsuRelax(attrs.into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -152,6 +158,7 @@ impl<'a> IntoPerformance<'a> for PerformanceAttributes {
|
||||
Self::Taiko(attrs) => Performance::Taiko(attrs.difficulty.into()),
|
||||
Self::Catch(attrs) => Performance::Catch(attrs.difficulty.into()),
|
||||
Self::Mania(attrs) => Performance::Mania(attrs.difficulty.into()),
|
||||
Self::OsuRelax(attrs) => Performance::OsuRelax(attrs.difficulty.into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ pub enum Performance<'map> {
|
||||
Taiko(TaikoPerformance<'map>),
|
||||
Catch(CatchPerformance<'map>),
|
||||
Mania(ManiaPerformance<'map>),
|
||||
OsuRelax(crate::osu_2019::OsuPerformance<'map>),
|
||||
}
|
||||
|
||||
impl<'map> Performance<'map> {
|
||||
@@ -56,6 +57,7 @@ impl<'map> Performance<'map> {
|
||||
Self::Taiko(t) => PerformanceAttributes::Taiko(t.calculate()),
|
||||
Self::Catch(f) => PerformanceAttributes::Catch(f.calculate()),
|
||||
Self::Mania(m) => PerformanceAttributes::Mania(m.calculate()),
|
||||
Self::OsuRelax(or) => PerformanceAttributes::OsuRelax(or.calculate()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,6 +116,7 @@ impl<'map> Performance<'map> {
|
||||
Self::Taiko(t) => Self::Taiko(t.mods(mods)),
|
||||
Self::Catch(f) => Self::Catch(f.mods(mods)),
|
||||
Self::Mania(m) => Self::Mania(m.mods(mods)),
|
||||
Self::OsuRelax(or) => Self::OsuRelax(or.mods(mods)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,6 +127,7 @@ impl<'map> Performance<'map> {
|
||||
Self::Taiko(t) => Self::Taiko(t.difficulty(difficulty)),
|
||||
Self::Catch(f) => Self::Catch(f.difficulty(difficulty)),
|
||||
Self::Mania(m) => Self::Mania(m.difficulty(difficulty)),
|
||||
Self::OsuRelax(or) => Self::OsuRelax(or.difficulty(difficulty)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,6 +144,7 @@ impl<'map> Performance<'map> {
|
||||
Self::Taiko(t) => Self::Taiko(t.passed_objects(passed_objects)),
|
||||
Self::Catch(f) => Self::Catch(f.passed_objects(passed_objects)),
|
||||
Self::Mania(m) => Self::Mania(m.passed_objects(passed_objects)),
|
||||
Self::OsuRelax(or) => Self::OsuRelax(or.passed_objects(passed_objects)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,6 +162,7 @@ impl<'map> Performance<'map> {
|
||||
Self::Taiko(t) => Self::Taiko(t.clock_rate(clock_rate)),
|
||||
Self::Catch(f) => Self::Catch(f.clock_rate(clock_rate)),
|
||||
Self::Mania(m) => Self::Mania(m.clock_rate(clock_rate)),
|
||||
Self::OsuRelax(or) => Self::OsuRelax(or.clock_rate(clock_rate)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,6 +181,7 @@ impl<'map> Performance<'map> {
|
||||
match self {
|
||||
Self::Osu(o) => Self::Osu(o.ar(ar, with_mods)),
|
||||
Self::Catch(c) => Self::Catch(c.ar(ar, with_mods)),
|
||||
Self::OsuRelax(or) => Self::OsuRelax(or.ar(ar, with_mods)),
|
||||
Self::Taiko(_) | Self::Mania(_) => self,
|
||||
}
|
||||
}
|
||||
@@ -194,6 +201,7 @@ impl<'map> Performance<'map> {
|
||||
match self {
|
||||
Self::Osu(o) => Self::Osu(o.cs(cs, with_mods)),
|
||||
Self::Catch(c) => Self::Catch(c.cs(cs, with_mods)),
|
||||
Self::OsuRelax(or) => Self::OsuRelax(or.cs(cs, with_mods)),
|
||||
Self::Taiko(_) | Self::Mania(_) => self,
|
||||
}
|
||||
}
|
||||
@@ -213,6 +221,7 @@ impl<'map> Performance<'map> {
|
||||
Self::Taiko(t) => Self::Taiko(t.hp(hp, with_mods)),
|
||||
Self::Catch(c) => Self::Catch(c.hp(hp, with_mods)),
|
||||
Self::Mania(m) => Self::Mania(m.hp(hp, with_mods)),
|
||||
Self::OsuRelax(or) => Self::OsuRelax(or.hp(hp, with_mods)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -231,6 +240,7 @@ impl<'map> Performance<'map> {
|
||||
Self::Taiko(t) => Self::Taiko(t.od(od, with_mods)),
|
||||
Self::Catch(c) => Self::Catch(c.od(od, with_mods)),
|
||||
Self::Mania(m) => Self::Mania(m.od(od, with_mods)),
|
||||
Self::OsuRelax(or) => Self::OsuRelax(or.od(od, with_mods)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -252,6 +262,7 @@ impl<'map> Performance<'map> {
|
||||
Self::Taiko(t) => Self::Taiko(t.state(state.into())),
|
||||
Self::Catch(f) => Self::Catch(f.state(state.into())),
|
||||
Self::Mania(m) => Self::Mania(m.state(state.into())),
|
||||
Self::OsuRelax(or) => Self::OsuRelax(or.state(state.into())),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -262,6 +273,7 @@ impl<'map> Performance<'map> {
|
||||
Self::Taiko(t) => Self::Taiko(t.accuracy(acc)),
|
||||
Self::Catch(f) => Self::Catch(f.accuracy(acc)),
|
||||
Self::Mania(m) => Self::Mania(m.accuracy(acc)),
|
||||
Self::OsuRelax(or) => Self::OsuRelax(or.accuracy(acc)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -272,6 +284,7 @@ impl<'map> Performance<'map> {
|
||||
Self::Taiko(t) => Self::Taiko(t.misses(n_misses)),
|
||||
Self::Catch(f) => Self::Catch(f.misses(n_misses)),
|
||||
Self::Mania(m) => Self::Mania(m.misses(n_misses)),
|
||||
Self::OsuRelax(or) => Self::OsuRelax(or.misses(n_misses)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -283,6 +296,7 @@ impl<'map> Performance<'map> {
|
||||
Self::Osu(o) => Self::Osu(o.combo(combo)),
|
||||
Self::Taiko(t) => Self::Taiko(t.combo(combo)),
|
||||
Self::Catch(f) => Self::Catch(f.combo(combo)),
|
||||
Self::OsuRelax(or) => Self::OsuRelax(or.combo(combo)),
|
||||
Self::Mania(_) => self,
|
||||
}
|
||||
}
|
||||
@@ -296,6 +310,7 @@ impl<'map> Performance<'map> {
|
||||
Self::Taiko(t) => Self::Taiko(t.hitresult_priority(priority)),
|
||||
Self::Catch(_) => self,
|
||||
Self::Mania(m) => Self::Mania(m.hitresult_priority(priority)),
|
||||
Self::OsuRelax(or) => Self::OsuRelax(or.hitresult_priority(priority)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -311,6 +326,8 @@ impl<'map> Performance<'map> {
|
||||
pub fn lazer(self, lazer: bool) -> Self {
|
||||
if let Self::Osu(osu) = self {
|
||||
Self::Osu(osu.lazer(lazer))
|
||||
} else if let Self::OsuRelax(osu_relax) = self {
|
||||
Self::OsuRelax(osu_relax.lazer(lazer))
|
||||
} else {
|
||||
self
|
||||
}
|
||||
@@ -323,6 +340,7 @@ impl<'map> Performance<'map> {
|
||||
Self::Taiko(t) => Self::Taiko(t.n300(n300)),
|
||||
Self::Catch(f) => Self::Catch(f.fruits(n300)),
|
||||
Self::Mania(m) => Self::Mania(m.n300(n300)),
|
||||
Self::OsuRelax(or) => Self::OsuRelax(or.n300(n300)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -333,6 +351,7 @@ impl<'map> Performance<'map> {
|
||||
Self::Taiko(t) => Self::Taiko(t.n100(n100)),
|
||||
Self::Catch(f) => Self::Catch(f.droplets(n100)),
|
||||
Self::Mania(m) => Self::Mania(m.n100(n100)),
|
||||
Self::OsuRelax(or) => Self::OsuRelax(or.n100(n100)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -345,6 +364,7 @@ impl<'map> Performance<'map> {
|
||||
Self::Taiko(_) => self,
|
||||
Self::Catch(f) => Self::Catch(f.tiny_droplets(n50)),
|
||||
Self::Mania(m) => Self::Mania(m.n50(n50)),
|
||||
Self::OsuRelax(or) => Self::OsuRelax(or.n50(n50)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -354,7 +374,7 @@ impl<'map> Performance<'map> {
|
||||
/// droplet misses and osu!mania for which it repesents the amount of n200.
|
||||
pub fn n_katu(self, n_katu: u32) -> Self {
|
||||
match self {
|
||||
Self::Osu(_) | Self::Taiko(_) => self,
|
||||
Self::Osu(_) | Self::Taiko(_) | Self::OsuRelax(_) => self,
|
||||
Self::Catch(f) => Self::Catch(f.tiny_droplet_misses(n_katu)),
|
||||
Self::Mania(m) => Self::Mania(m.n200(n_katu)),
|
||||
}
|
||||
@@ -366,7 +386,7 @@ impl<'map> Performance<'map> {
|
||||
/// amount of n320.
|
||||
pub fn n_geki(self, n_geki: u32) -> Self {
|
||||
match self {
|
||||
Self::Osu(_) | Self::Taiko(_) | Self::Catch(_) => self,
|
||||
Self::Osu(_) | Self::Taiko(_) | Self::Catch(_) | Self::OsuRelax(_) => self,
|
||||
Self::Mania(m) => Self::Mania(m.n320(n_geki)),
|
||||
}
|
||||
}
|
||||
@@ -378,6 +398,7 @@ impl<'map> Performance<'map> {
|
||||
Self::Taiko(t) => t.generate_state().into(),
|
||||
Self::Catch(f) => f.generate_state().into(),
|
||||
Self::Mania(m) => m.generate_state().into(),
|
||||
Self::OsuRelax(or) => or.generate_state().into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,14 +21,14 @@ use self::skills::OsuSkills;
|
||||
use super::{attributes::OsuDifficultyAttributes, convert::OsuBeatmap};
|
||||
|
||||
pub mod gradual;
|
||||
mod object;
|
||||
pub mod object;
|
||||
pub mod scaling_factor;
|
||||
pub mod skills;
|
||||
|
||||
const DIFFICULTY_MULTIPLIER: f64 = 0.0675;
|
||||
|
||||
const HD_FADE_IN_DURATION_MULTIPLIER: f64 = 0.4;
|
||||
const HD_FADE_OUT_DURATION_MULTIPLIER: f64 = 0.3;
|
||||
pub(crate) const HD_FADE_IN_DURATION_MULTIPLIER: f64 = 0.4;
|
||||
pub(crate) const HD_FADE_OUT_DURATION_MULTIPLIER: f64 = 0.3;
|
||||
|
||||
pub fn difficulty(difficulty: &Difficulty, converted: &OsuBeatmap<'_>) -> OsuDifficultyAttributes {
|
||||
let DifficultyValues {
|
||||
@@ -64,10 +64,10 @@ pub fn difficulty(difficulty: &Difficulty, converted: &OsuBeatmap<'_>) -> OsuDif
|
||||
}
|
||||
|
||||
pub struct OsuDifficultySetup {
|
||||
scaling_factor: ScalingFactor,
|
||||
map_attrs: BeatmapAttributes,
|
||||
attrs: OsuDifficultyAttributes,
|
||||
time_preempt: f64,
|
||||
pub(crate) scaling_factor: ScalingFactor,
|
||||
pub(crate) map_attrs: BeatmapAttributes,
|
||||
pub(crate) attrs: OsuDifficultyAttributes,
|
||||
pub(crate) time_preempt: f64,
|
||||
}
|
||||
|
||||
impl OsuDifficultySetup {
|
||||
|
||||
+3
-3
@@ -18,9 +18,9 @@ pub use self::{
|
||||
};
|
||||
|
||||
mod attributes;
|
||||
mod convert;
|
||||
mod difficulty;
|
||||
mod object;
|
||||
pub(crate) mod convert;
|
||||
pub(crate) mod difficulty;
|
||||
pub(crate) mod object;
|
||||
mod performance;
|
||||
mod score_state;
|
||||
mod strains;
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
use super::OsuPerformance;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Default)]
|
||||
pub struct OsuDifficultyAttributes {
|
||||
pub aim_strain: f64,
|
||||
pub speed_strain: f64,
|
||||
pub ar: f64,
|
||||
pub od: f64,
|
||||
pub hp: f64,
|
||||
pub cs: f64,
|
||||
pub n_circles: u32,
|
||||
pub n_sliders: u32,
|
||||
pub n_spinners: u32,
|
||||
pub stars: f64,
|
||||
pub max_combo: u32,
|
||||
pub aim_difficult_strain_count: f64,
|
||||
pub speed_difficult_strain_count: f64,
|
||||
pub beatmap_id: i32,
|
||||
pub beatmap_creator: String,
|
||||
pub n_slider_ticks: u32,
|
||||
}
|
||||
|
||||
impl OsuDifficultyAttributes {
|
||||
/// Return the maximum combo.
|
||||
pub const fn max_combo(&self) -> u32 {
|
||||
self.max_combo
|
||||
}
|
||||
|
||||
/// Return the amount of hitobjects.
|
||||
pub const fn n_objects(&self) -> u32 {
|
||||
self.n_circles + self.n_sliders + self.n_spinners
|
||||
}
|
||||
|
||||
/// Returns a builder for performance calculation.
|
||||
pub fn performance<'a>(self) -> OsuPerformance<'a> {
|
||||
self.into()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Debug, Default)]
|
||||
pub struct OsuPerformanceAttributes {
|
||||
pub difficulty: OsuDifficultyAttributes,
|
||||
pub pp: f64,
|
||||
pub pp_acc: f64,
|
||||
pub pp_aim: f64,
|
||||
pub pp_speed: f64,
|
||||
pub effective_miss_count: f64,
|
||||
}
|
||||
|
||||
impl OsuPerformanceAttributes {
|
||||
/// Return the star value.
|
||||
pub const fn stars(&self) -> f64 {
|
||||
self.difficulty.stars
|
||||
}
|
||||
|
||||
/// Return the performance point value.
|
||||
pub const fn pp(&self) -> f64 {
|
||||
self.pp
|
||||
}
|
||||
|
||||
/// Return the maximum combo of the map.
|
||||
pub const fn max_combo(&self) -> u32 {
|
||||
self.difficulty.max_combo
|
||||
}
|
||||
/// Return the amount of hitobjects.
|
||||
pub const fn n_objects(&self) -> u32 {
|
||||
self.difficulty.n_objects()
|
||||
}
|
||||
|
||||
/// Returns a builder for performance calculation.
|
||||
pub fn performance<'a>(self) -> OsuPerformance<'a> {
|
||||
self.difficulty.into()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<OsuPerformanceAttributes> for OsuDifficultyAttributes {
|
||||
fn from(attributes: OsuPerformanceAttributes) -> Self {
|
||||
attributes.difficulty
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
use rosu_map::section::{general::GameMode, hit_objects::CurveBuffers};
|
||||
|
||||
use crate::{model::{
|
||||
beatmap::{Beatmap, Converted},
|
||||
mode::ConvertStatus,
|
||||
}, osu::difficulty::scaling_factor::ScalingFactor};
|
||||
|
||||
use super::{
|
||||
attributes::OsuDifficultyAttributes,
|
||||
object::{NestedSliderObjectKind, OsuObject, OsuObjectKind},
|
||||
OsuRelax,
|
||||
};
|
||||
|
||||
/// A [`Beatmap`] for [`Osu`] calculations.
|
||||
pub type OsuRelaxBeatmap<'a> = Converted<'a, OsuRelax>;
|
||||
|
||||
pub fn check_convert(map: &Beatmap) -> ConvertStatus {
|
||||
if map.mode == GameMode::Osu {
|
||||
ConvertStatus::Noop
|
||||
} else {
|
||||
ConvertStatus::Incompatible
|
||||
}
|
||||
}
|
||||
|
||||
pub fn try_convert(map: &mut Beatmap) -> ConvertStatus {
|
||||
check_convert(map)
|
||||
}
|
||||
|
||||
pub fn convert_objects(
|
||||
converted: &OsuRelaxBeatmap<'_>,
|
||||
scaling_factor: &ScalingFactor,
|
||||
hr: bool,
|
||||
time_preempt: f64,
|
||||
mut take: usize,
|
||||
attrs: &mut OsuDifficultyAttributes,
|
||||
) -> Box<[OsuObject]> {
|
||||
let mut curve_bufs = CurveBuffers::default();
|
||||
// mean=5.16 | median=4
|
||||
let mut ticks_buf = Vec::new();
|
||||
|
||||
let mut osu_objects: Box<[_]> = converted
|
||||
.hit_objects
|
||||
.iter()
|
||||
.map(|h| OsuObject::new(h, converted, &mut curve_bufs, &mut ticks_buf))
|
||||
.inspect(|h| {
|
||||
if take == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
take -= 1;
|
||||
attrs.max_combo += 1;
|
||||
|
||||
match h.kind {
|
||||
OsuObjectKind::Circle => attrs.n_circles += 1,
|
||||
OsuObjectKind::Slider(ref slider) => {
|
||||
attrs.n_sliders += 1;
|
||||
attrs.max_combo += slider.nested_objects.len() as u32;
|
||||
|
||||
attrs.n_slider_ticks += slider
|
||||
.nested_objects
|
||||
.iter()
|
||||
.filter(|nested| {
|
||||
matches!(
|
||||
nested.kind,
|
||||
NestedSliderObjectKind::Tick | NestedSliderObjectKind::Repeat
|
||||
)
|
||||
})
|
||||
.count() as u32;
|
||||
}
|
||||
OsuObjectKind::Spinner(_) => attrs.n_spinners += 1,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
if hr {
|
||||
osu_objects
|
||||
.iter_mut()
|
||||
.for_each(OsuObject::reflect_vertically);
|
||||
} else {
|
||||
osu_objects.iter_mut().for_each(OsuObject::finalize_nested);
|
||||
}
|
||||
|
||||
let stack_threshold = time_preempt * f64::from(converted.stack_leniency);
|
||||
|
||||
if converted.version >= 6 {
|
||||
stacking(&mut osu_objects, stack_threshold);
|
||||
} else {
|
||||
old_stacking(&mut osu_objects, stack_threshold);
|
||||
}
|
||||
|
||||
for h in osu_objects.iter_mut() {
|
||||
h.stack_offset = scaling_factor.stack_offset(h.stack_height);
|
||||
|
||||
if let OsuObjectKind::Slider(ref mut slider) = h.kind {
|
||||
slider.lazy_end_pos += h.pos + h.stack_offset;
|
||||
}
|
||||
}
|
||||
|
||||
osu_objects
|
||||
}
|
||||
|
||||
const STACK_DISTANCE: f32 = 3.0;
|
||||
|
||||
fn stacking(hit_objects: &mut [OsuObject], stack_threshold: f64) {
|
||||
let mut extended_start_idx = 0;
|
||||
|
||||
let Some(extended_end_idx) = hit_objects.len().checked_sub(1) else {
|
||||
return;
|
||||
};
|
||||
|
||||
// First big `if` in osu!lazer's function can be skipped
|
||||
|
||||
for i in (1..=extended_end_idx).rev() {
|
||||
let mut n = i;
|
||||
let mut obj_i_idx = i;
|
||||
// * We should check every note which has not yet got a stack.
|
||||
// * Consider the case we have two interwound stacks and this will make sense.
|
||||
// * o <-1 o <-2
|
||||
// * o <-3 o <-4
|
||||
// * We first process starting from 4 and handle 2,
|
||||
// * then we come backwards on the i loop iteration until we reach 3 and handle 1.
|
||||
// * 2 and 1 will be ignored in the i loop because they already have a stack value.
|
||||
|
||||
if hit_objects[obj_i_idx].stack_height != 0 || hit_objects[obj_i_idx].is_spinner() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// * If this object is a hitcircle, then we enter this "special" case.
|
||||
// * It either ends with a stack of hitcircles only,
|
||||
// * or a stack of hitcircles that are underneath a slider.
|
||||
// * Any other case is handled by the "is_slider" code below this.
|
||||
if hit_objects[obj_i_idx].is_circle() {
|
||||
loop {
|
||||
n = match n.checked_sub(1) {
|
||||
Some(n) => n,
|
||||
None => break,
|
||||
};
|
||||
|
||||
if hit_objects[n].is_spinner() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if hit_objects[obj_i_idx].start_time - hit_objects[n].end_time() > stack_threshold {
|
||||
break; // * We are no longer within stacking range of the previous object.
|
||||
}
|
||||
|
||||
// * HitObjects before the specified update range haven't been reset yet
|
||||
if n < extended_start_idx {
|
||||
hit_objects[n].stack_height = 0;
|
||||
extended_start_idx = n;
|
||||
}
|
||||
|
||||
// * This is a special case where hticircles are moved DOWN and RIGHT (negative stacking)
|
||||
// * if they are under the *last* slider in a stacked pattern.
|
||||
// * o==o <- slider is at original location
|
||||
// * o <- hitCircle has stack of -1
|
||||
// * o <- hitCircle has stack of -2
|
||||
if hit_objects[n].is_slider()
|
||||
&& hit_objects[n]
|
||||
.end_pos()
|
||||
.distance(hit_objects[obj_i_idx].pos)
|
||||
< STACK_DISTANCE
|
||||
{
|
||||
let offset =
|
||||
hit_objects[obj_i_idx].stack_height - hit_objects[n].stack_height + 1;
|
||||
|
||||
for j in n + 1..=i {
|
||||
// * For each object which was declared under this slider, we will offset
|
||||
// * it to appear *below* the slider end (rather than above).
|
||||
if hit_objects[n].end_pos().distance(hit_objects[j].pos) < STACK_DISTANCE {
|
||||
hit_objects[j].stack_height -= offset;
|
||||
}
|
||||
}
|
||||
|
||||
// * We have hit a slider. We should restart calculation using this as the new base.
|
||||
// * Breaking here will mean that the slider still has StackCount of 0,
|
||||
// * so will be handled in the i-outer-loop.
|
||||
break;
|
||||
}
|
||||
|
||||
if hit_objects[n].pos.distance(hit_objects[obj_i_idx].pos) < STACK_DISTANCE {
|
||||
// * Keep processing as if there are no sliders.
|
||||
// * If we come across a slider, this gets cancelled out.
|
||||
// * NOTE: Sliders with start positions stacking
|
||||
// * are a special case that is also handled here.
|
||||
|
||||
hit_objects[n].stack_height = hit_objects[obj_i_idx].stack_height + 1;
|
||||
obj_i_idx = n;
|
||||
}
|
||||
}
|
||||
} else if hit_objects[obj_i_idx].is_slider() {
|
||||
// * We have hit the first slider in a possible stack.
|
||||
// * From this point on, we ALWAYS stack positive regardless.
|
||||
loop {
|
||||
n = match n.checked_sub(1) {
|
||||
Some(n) => n,
|
||||
None => break,
|
||||
};
|
||||
|
||||
if hit_objects[n].is_spinner() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if hit_objects[obj_i_idx].start_time - hit_objects[n].start_time > stack_threshold {
|
||||
break; // * We are no longer within stacking range of the previous object.
|
||||
}
|
||||
|
||||
if hit_objects[n]
|
||||
.end_pos()
|
||||
.distance(hit_objects[obj_i_idx].pos)
|
||||
< STACK_DISTANCE
|
||||
{
|
||||
hit_objects[n].stack_height = hit_objects[obj_i_idx].stack_height + 1;
|
||||
obj_i_idx = n;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn old_stacking(hit_objects: &mut [OsuObject], stack_threshold: f64) {
|
||||
for i in 0..hit_objects.len() {
|
||||
if hit_objects[i].stack_height != 0 && !hit_objects[i].is_slider() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut start_time = hit_objects[i].end_time();
|
||||
|
||||
let pos2 = {
|
||||
let h = &hit_objects[i];
|
||||
|
||||
match h.kind {
|
||||
OsuObjectKind::Circle | OsuObjectKind::Spinner(_) => h.pos,
|
||||
OsuObjectKind::Slider(ref slider) => {
|
||||
// We need the path endpos instead of the slider endpos
|
||||
let repeat_count = slider.repeat_count();
|
||||
|
||||
let nested = if repeat_count % 2 == 0 {
|
||||
slider.tail()
|
||||
} else {
|
||||
slider
|
||||
.nested_objects
|
||||
.iter()
|
||||
.find(|nested| matches!(nested.kind, NestedSliderObjectKind::Repeat))
|
||||
};
|
||||
|
||||
nested.map_or(h.pos, |nested| nested.pos)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let mut slider_stack = 0;
|
||||
|
||||
for j in i + 1..hit_objects.len() {
|
||||
if hit_objects[j].start_time - stack_threshold > start_time {
|
||||
break;
|
||||
}
|
||||
|
||||
// * Note the use of `StartTime` in the code below doesn't match stable's use of `EndTime`.
|
||||
// * This is because in the stable implementation, `UpdateCalculations` is not called on the inner-loop hitobject (j)
|
||||
// * and therefore it does not have a correct `EndTime`, but instead the default of `EndTime = StartTime`.
|
||||
// *
|
||||
// * Effects of this can be seen on https://osu.ppy.sh/beatmapsets/243#osu/1146 at sliders around 86647 ms, where
|
||||
// * if we use `EndTime` here it would result in unexpected stacking.
|
||||
|
||||
if hit_objects[j].pos.distance(hit_objects[i].pos) < STACK_DISTANCE {
|
||||
hit_objects[i].stack_height += 1;
|
||||
start_time = hit_objects[j].start_time;
|
||||
} else if hit_objects[j].pos.distance(pos2) < STACK_DISTANCE {
|
||||
slider_stack += 1;
|
||||
hit_objects[j].stack_height -= slider_stack;
|
||||
start_time = hit_objects[j].start_time;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
use std::{cmp, mem};
|
||||
|
||||
use osu_objects::OsuObjects;
|
||||
|
||||
use crate::{any::difficulty::skills::Skill, osu_2019::{convert::{convert_objects, OsuRelaxBeatmap}, object::{OsuObject, OsuObjectKind}, OsuDifficultyAttributes}, Difficulty};
|
||||
|
||||
use super::{object::OsuDifficultyObject, skills::OsuSkills, DifficultyValues, OsuDifficultySetup};
|
||||
|
||||
/// Gradually calculate the difficulty attributes of an osu!standard (relax) 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, Difficulty};
|
||||
/// use rosu_pp::osu_2019::{OsuRelax, OsuGradualDifficulty};
|
||||
///
|
||||
/// let converted = Beatmap::from_path("./resources/2785319.osu")
|
||||
/// .unwrap()
|
||||
/// .unchecked_into_converted::<OsuRelax>();
|
||||
///
|
||||
/// let difficulty = Difficulty::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,
|
||||
pub(crate) difficulty: Difficulty,
|
||||
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 OsuGradualDifficulty {
|
||||
/// Create a new difficulty attributes iterator for osu!standard (relax) maps.
|
||||
pub fn new(difficulty: Difficulty, converted: &OsuRelaxBeatmap<'_>) -> 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.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();
|
||||
let diff_objects = extend_lifetime(diff_objects.into_boxed_slice());
|
||||
|
||||
Self {
|
||||
idx: 0,
|
||||
difficulty,
|
||||
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.speed, &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 speed_difficulty_value = self.skills.speed.as_difficulty_value();
|
||||
|
||||
DifficultyValues::eval(
|
||||
&mut attrs,
|
||||
&aim_difficulty_value,
|
||||
&speed_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 = cmp::min(n, 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 speed = Skill::new(&mut self.skills.speed, &self.diff_objects);
|
||||
|
||||
for curr in skip_iter.take(take) {
|
||||
aim.process(curr);
|
||||
speed.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_2019::object::OsuObject;
|
||||
|
||||
/// Wrapper to ensure that the data will not be moved
|
||||
pub(super) struct OsuObjects {
|
||||
objects: Box<[OsuObject]>,
|
||||
}
|
||||
|
||||
impl OsuObjects {
|
||||
pub(super) const 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
use std::{cmp, pin::Pin};
|
||||
|
||||
use object::OsuDifficultyObject;
|
||||
use skills::{strain::{DifficultyValue, UsedOsuStrainSkills}, OsuSkills};
|
||||
|
||||
use crate::{any::difficulty::skills::Skill, model::beatmap::BeatmapAttributes, osu::difficulty::scaling_factor::ScalingFactor, Difficulty};
|
||||
|
||||
use super::{attributes::OsuDifficultyAttributes, convert::{convert_objects, OsuRelaxBeatmap}, object::OsuObject};
|
||||
|
||||
pub mod skills;
|
||||
pub mod gradual;
|
||||
pub mod object;
|
||||
|
||||
const DIFFICULTY_MULTIPLIER: f64 = 0.0675;
|
||||
|
||||
pub fn difficulty(difficulty: &Difficulty, converted: &OsuRelaxBeatmap) -> OsuDifficultyAttributes {
|
||||
let DifficultyValues {
|
||||
skills:
|
||||
OsuSkills {
|
||||
aim,
|
||||
speed,
|
||||
},
|
||||
mut attrs,
|
||||
} = DifficultyValues::calculate(difficulty, converted);
|
||||
|
||||
let aim_difficulty_value = aim.difficulty_value();
|
||||
let speed_difficulty_value = speed.difficulty_value();
|
||||
|
||||
DifficultyValues::eval(
|
||||
&mut attrs,
|
||||
&aim_difficulty_value,
|
||||
&speed_difficulty_value,
|
||||
);
|
||||
|
||||
attrs
|
||||
}
|
||||
|
||||
pub struct OsuDifficultySetup {
|
||||
scaling_factor: ScalingFactor,
|
||||
map_attrs: BeatmapAttributes,
|
||||
attrs: OsuDifficultyAttributes,
|
||||
time_preempt: f64,
|
||||
}
|
||||
|
||||
impl OsuDifficultySetup {
|
||||
pub fn new(difficulty: &Difficulty, beatmap: &OsuRelaxBeatmap) -> Self {
|
||||
let clock_rate = difficulty.get_clock_rate();
|
||||
let map_attrs = beatmap.attributes().difficulty(difficulty).build();
|
||||
let scaling_factor = ScalingFactor::new(map_attrs.cs);
|
||||
|
||||
let attrs = OsuDifficultyAttributes {
|
||||
ar: map_attrs.ar,
|
||||
hp: map_attrs.hp,
|
||||
od: map_attrs.od,
|
||||
..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: &Difficulty, converted: &OsuRelaxBeatmap) -> 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(
|
||||
converted,
|
||||
&scaling_factor,
|
||||
mods.hr(),
|
||||
time_preempt,
|
||||
take,
|
||||
&mut attrs,
|
||||
);
|
||||
|
||||
let osu_object_iter = osu_objects.iter_mut().map(Pin::new);
|
||||
|
||||
let diff_objects =
|
||||
Self::create_difficulty_objects(difficulty, &scaling_factor, osu_object_iter);
|
||||
|
||||
let mut skills = OsuSkills::new();
|
||||
|
||||
{
|
||||
let mut aim = Skill::new(&mut skills.aim, &diff_objects);
|
||||
let mut speed = Skill::new(&mut skills.speed, &diff_objects);
|
||||
|
||||
// The first hit object has no difficulty object
|
||||
let take_diff_objects = cmp::min(converted.hit_objects.len(), take).saturating_sub(1);
|
||||
|
||||
for hit_object in diff_objects.iter().take(take_diff_objects) {
|
||||
aim.process(hit_object);
|
||||
speed.process(hit_object);
|
||||
}
|
||||
}
|
||||
|
||||
Self { skills, attrs }
|
||||
}
|
||||
|
||||
/// Process the difficulty values and store the results in `attrs`.
|
||||
pub fn eval(
|
||||
attrs: &mut OsuDifficultyAttributes,
|
||||
aim: &UsedOsuStrainSkills<DifficultyValue>,
|
||||
speed: &UsedOsuStrainSkills<DifficultyValue>,
|
||||
) {
|
||||
let aim_rating = aim.difficulty_value().sqrt() * DIFFICULTY_MULTIPLIER;
|
||||
let speed_rating = speed.difficulty_value().sqrt() * DIFFICULTY_MULTIPLIER;
|
||||
|
||||
let aim_difficult_strain_count = aim.count_difficult_strains();
|
||||
let speed_difficult_strain_count = speed.count_difficult_strains();
|
||||
|
||||
let stars = aim_rating + speed_rating + (aim_rating - speed_rating).abs() / 2.0;
|
||||
|
||||
attrs.aim_strain = aim_rating;
|
||||
attrs.speed_strain = speed_rating;
|
||||
attrs.aim_difficult_strain_count = aim_difficult_strain_count;
|
||||
attrs.speed_difficult_strain_count = speed_difficult_strain_count;
|
||||
attrs.stars = stars;
|
||||
}
|
||||
|
||||
pub fn create_difficulty_objects<'a>(
|
||||
difficulty: &Difficulty,
|
||||
scaling_factor: &ScalingFactor,
|
||||
osu_objects: impl ExactSizeIterator<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 {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
let mut last_last = None;
|
||||
|
||||
osu_objects_iter
|
||||
.enumerate()
|
||||
.map(|(idx, h)| {
|
||||
let diff_object = OsuDifficultyObject::new(
|
||||
h.get_ref(),
|
||||
last.get_ref(),
|
||||
last_last.as_deref(),
|
||||
clock_rate,
|
||||
idx,
|
||||
scaling_factor,
|
||||
);
|
||||
|
||||
last_last = Some(last);
|
||||
last = h;
|
||||
|
||||
diff_object
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
use std::{borrow::Cow, pin::Pin};
|
||||
|
||||
use rosu_map::util::Pos;
|
||||
|
||||
use crate::{
|
||||
any::difficulty::object::IDifficultyObject,
|
||||
osu_2019::object::{OsuObject, OsuObjectKind, OsuSlider},
|
||||
};
|
||||
|
||||
use crate::osu::difficulty::{scaling_factor::ScalingFactor, HD_FADE_OUT_DURATION_MULTIPLIER};
|
||||
|
||||
pub struct OsuDifficultyObject<'a> {
|
||||
pub idx: usize,
|
||||
pub base: &'a OsuObject,
|
||||
pub start_time: f64,
|
||||
pub delta_time: f64,
|
||||
|
||||
pub strain_time: f64,
|
||||
pub lazy_jump_dist: f64,
|
||||
pub min_jump_dist: f64,
|
||||
pub min_jump_time: f64,
|
||||
pub travel_dist: f64,
|
||||
pub travel_time: f64,
|
||||
pub angle: Option<f64>,
|
||||
}
|
||||
|
||||
impl<'a> OsuDifficultyObject<'a> {
|
||||
pub const NORMALIZED_RADIUS: f32 = 50.0;
|
||||
|
||||
pub const MIN_DELTA_TIME: f64 = 25.0;
|
||||
const MAX_SLIDER_RADIUS: f32 = Self::NORMALIZED_RADIUS * 2.4;
|
||||
const ASSUMED_SLIDER_RADIUS: f32 = Self::NORMALIZED_RADIUS * 1.8;
|
||||
|
||||
pub fn new(
|
||||
hit_object: &'a OsuObject,
|
||||
last_object: &'a OsuObject,
|
||||
last_last_object: Option<&OsuObject>,
|
||||
clock_rate: f64,
|
||||
idx: usize,
|
||||
scaling_factor: &ScalingFactor,
|
||||
) -> Self {
|
||||
let delta_time = (hit_object.start_time - last_object.start_time) / clock_rate;
|
||||
let start_time = hit_object.start_time / clock_rate;
|
||||
|
||||
let strain_time = delta_time.max(Self::MIN_DELTA_TIME);
|
||||
|
||||
let mut this = Self {
|
||||
idx,
|
||||
base: hit_object,
|
||||
start_time,
|
||||
delta_time,
|
||||
strain_time,
|
||||
lazy_jump_dist: 0.0,
|
||||
min_jump_dist: 0.0,
|
||||
min_jump_time: 0.0,
|
||||
travel_dist: 0.0,
|
||||
travel_time: 0.0,
|
||||
angle: None,
|
||||
};
|
||||
|
||||
this.set_distances(last_object, last_last_object, clock_rate, scaling_factor);
|
||||
|
||||
this
|
||||
}
|
||||
|
||||
pub fn opacity_at(&self, time: f64, hidden: bool, time_preempt: f64, time_fade_in: f64) -> f64 {
|
||||
if time > self.base.start_time {
|
||||
// * Consider a hitobject as being invisible when its start time is passed.
|
||||
// * In reality the hitobject will be visible beyond its start time up until its hittable window has passed,
|
||||
// * but this is an approximation and such a case is unlikely to be hit where this function is used.
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
let fade_in_start_time = self.base.start_time - time_preempt;
|
||||
let fade_in_duration = time_fade_in;
|
||||
|
||||
if hidden {
|
||||
// * Taken from OsuModHidden.
|
||||
let fade_out_start_time = self.base.start_time - time_preempt + time_fade_in;
|
||||
let fade_out_duration = time_preempt * HD_FADE_OUT_DURATION_MULTIPLIER;
|
||||
|
||||
(((time - fade_in_start_time) / fade_in_duration).clamp(0.0, 1.0))
|
||||
.min(1.0 - ((time - fade_out_start_time) / fade_out_duration).clamp(0.0, 1.0))
|
||||
} else {
|
||||
((time - fade_in_start_time) / fade_in_duration).clamp(0.0, 1.0)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_doubletapness(&self, next: Option<&Self>, hit_window: f64) -> f64 {
|
||||
let Some(next) = next else { return 0.0 };
|
||||
|
||||
let hit_window = if self.base.is_spinner() {
|
||||
0.0
|
||||
} else {
|
||||
hit_window
|
||||
};
|
||||
|
||||
let curr_delta_time = self.delta_time.max(1.0);
|
||||
let next_delta_time = next.delta_time.max(1.0);
|
||||
let delta_diff = (next_delta_time - curr_delta_time).abs();
|
||||
let speed_ratio = curr_delta_time / curr_delta_time.max(delta_diff);
|
||||
let window_ratio = (curr_delta_time / hit_window).min(1.0).powf(2.0);
|
||||
|
||||
1.0 - (speed_ratio).powf(1.0 - window_ratio)
|
||||
}
|
||||
|
||||
fn set_distances(
|
||||
&mut self,
|
||||
last_object: &OsuObject,
|
||||
last_last_object: Option<&OsuObject>,
|
||||
clock_rate: f64,
|
||||
scaling_factor: &ScalingFactor,
|
||||
) {
|
||||
if let OsuObjectKind::Slider(ref slider) = self.base.kind {
|
||||
self.travel_dist = f64::from(
|
||||
slider.lazy_travel_dist
|
||||
* ((1.0 + slider.repeat_count() as f64 / 2.5).powf(1.0 / 2.5)) as f32,
|
||||
);
|
||||
|
||||
self.travel_time = (self.base.lazy_travel_time() / clock_rate)
|
||||
.max(OsuDifficultyObject::MIN_DELTA_TIME);
|
||||
}
|
||||
|
||||
if self.base.is_spinner() || last_object.is_spinner() {
|
||||
return;
|
||||
}
|
||||
|
||||
let scaling_factor = scaling_factor.factor;
|
||||
|
||||
let last_cursor_pos = Self::get_end_cursor_pos(last_object);
|
||||
|
||||
self.lazy_jump_dist = f64::from(
|
||||
(self.base.stacked_pos() * scaling_factor - last_cursor_pos * scaling_factor).length(),
|
||||
);
|
||||
self.min_jump_time = self.strain_time;
|
||||
self.min_jump_dist = self.lazy_jump_dist;
|
||||
|
||||
if let OsuObjectKind::Slider(ref last_slider) = last_object.kind {
|
||||
let last_travel_time = (last_object.lazy_travel_time() / clock_rate)
|
||||
.max(OsuDifficultyObject::MIN_DELTA_TIME);
|
||||
self.min_jump_time =
|
||||
(self.strain_time - last_travel_time).max(OsuDifficultyObject::MIN_DELTA_TIME);
|
||||
|
||||
let tail_pos = last_slider.tail().map_or(last_object.pos, |tail| tail.pos);
|
||||
let stacked_tail_pos = tail_pos + last_object.stack_offset;
|
||||
|
||||
let tail_jump_dist =
|
||||
(stacked_tail_pos - self.base.stacked_pos()).length() * scaling_factor;
|
||||
|
||||
let diff = f64::from(
|
||||
OsuDifficultyObject::MAX_SLIDER_RADIUS - OsuDifficultyObject::ASSUMED_SLIDER_RADIUS,
|
||||
);
|
||||
|
||||
let min = f64::from(tail_jump_dist - OsuDifficultyObject::MAX_SLIDER_RADIUS);
|
||||
self.min_jump_dist = ((self.lazy_jump_dist - diff).min(min)).max(0.0);
|
||||
}
|
||||
|
||||
if let Some(last_last_object) = last_last_object.filter(|h| !h.is_spinner()) {
|
||||
let last_last_cursor_pos = Self::get_end_cursor_pos(last_last_object);
|
||||
|
||||
let v1 = last_last_cursor_pos - last_object.stacked_pos();
|
||||
let v2 = self.base.stacked_pos() - last_cursor_pos;
|
||||
|
||||
let dot = v1.dot(v2);
|
||||
let det = v1.x * v2.y - v1.y * v2.x;
|
||||
|
||||
self.angle = Some((f64::from(det).atan2(f64::from(dot))).abs());
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 start_time = h.start_time;
|
||||
|
||||
let OsuObjectKind::Slider(ref mut slider) = h.kind else {
|
||||
return h;
|
||||
};
|
||||
|
||||
let mut nested = Cow::Borrowed(slider.nested_objects.as_slice());
|
||||
let duration = slider.end_time - start_time;
|
||||
OsuSlider::lazy_travel_time(start_time, duration, &mut nested);
|
||||
let nested = nested.as_ref();
|
||||
|
||||
let mut curr_cursor_pos = pos + stack_offset;
|
||||
let scaling_factor = f64::from(OsuDifficultyObject::NORMALIZED_RADIUS) / radius;
|
||||
|
||||
for (curr_movement_obj, i) in nested.iter().zip(1..) {
|
||||
let mut curr_movement = curr_movement_obj.pos + stack_offset - curr_cursor_pos;
|
||||
let mut curr_movement_len = scaling_factor * f64::from(curr_movement.length());
|
||||
let mut required_movement = f64::from(OsuDifficultyObject::ASSUMED_SLIDER_RADIUS);
|
||||
|
||||
if i == nested.len() {
|
||||
let lazy_movement = slider.lazy_end_pos - curr_cursor_pos;
|
||||
|
||||
if lazy_movement.length() < curr_movement.length() {
|
||||
curr_movement = lazy_movement;
|
||||
}
|
||||
|
||||
curr_movement_len = scaling_factor * f64::from(curr_movement.length());
|
||||
} else if curr_movement_obj.is_repeat() {
|
||||
required_movement = f64::from(OsuDifficultyObject::NORMALIZED_RADIUS);
|
||||
}
|
||||
|
||||
if curr_movement_len > required_movement {
|
||||
curr_cursor_pos += curr_movement
|
||||
* ((curr_movement_len - required_movement) / curr_movement_len) as f32;
|
||||
curr_movement_len *= (curr_movement_len - required_movement) / curr_movement_len;
|
||||
slider.lazy_travel_dist += curr_movement_len as f32;
|
||||
}
|
||||
|
||||
if i == nested.len() {
|
||||
slider.lazy_end_pos = curr_cursor_pos;
|
||||
}
|
||||
}
|
||||
|
||||
h
|
||||
}
|
||||
|
||||
fn get_end_cursor_pos(hit_object: &OsuObject) -> Pos {
|
||||
if let OsuObjectKind::Slider(ref slider) = hit_object.kind {
|
||||
// We don't have access to the slider's curve at this point so we
|
||||
// take the pre-computed value.
|
||||
slider.lazy_end_pos
|
||||
} else {
|
||||
hit_object.stacked_pos()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IDifficultyObject for OsuDifficultyObject<'_> {
|
||||
fn idx(&self) -> usize {
|
||||
self.idx
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
use crate::{any::difficulty::{object::IDifficultyObject, skills::{strain_decay, ISkill, Skill}}, osu_2019::difficulty::object::OsuDifficultyObject, util::strains_vec::StrainsVec};
|
||||
|
||||
use super::strain::{DifficultyValue, OsuStrainSkill, UsedOsuStrainSkills};
|
||||
|
||||
const SKILL_MULTIPLIER: f64 = 26.25;
|
||||
const STRAIN_DECAY_BASE: f64 = 0.15;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Aim {
|
||||
curr_strain: f64,
|
||||
inner: OsuStrainSkill,
|
||||
}
|
||||
|
||||
impl Aim {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
curr_strain: 0.0,
|
||||
inner: OsuStrainSkill::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_curr_strain_peaks(self) -> StrainsVec {
|
||||
self.inner.get_curr_strain_peaks().strains()
|
||||
}
|
||||
|
||||
pub fn difficulty_value(self) -> UsedOsuStrainSkills<DifficultyValue> {
|
||||
Self::static_difficulty_value(self.inner)
|
||||
}
|
||||
|
||||
/// Use [`difficulty_value`] instead whenever possible because
|
||||
/// [`as_difficulty_value`] clones internally.
|
||||
pub fn as_difficulty_value(&self) -> UsedOsuStrainSkills<DifficultyValue> {
|
||||
Self::static_difficulty_value(self.inner.clone())
|
||||
}
|
||||
|
||||
fn static_difficulty_value(skill: OsuStrainSkill) -> UsedOsuStrainSkills<DifficultyValue> {
|
||||
skill.difficulty_value(
|
||||
OsuStrainSkill::DECAY_WEIGHT,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl ISkill for Aim {
|
||||
type DifficultyObjects<'a> = [OsuDifficultyObject<'a>];
|
||||
}
|
||||
|
||||
impl<'a> Skill<'a, Aim> {
|
||||
fn calculate_initial_strain(&mut self, time: f64, curr: &'a OsuDifficultyObject<'a>) -> f64 {
|
||||
let prev_start_time = curr
|
||||
.previous(0, self.diff_objects)
|
||||
.map_or(0.0, |prev| prev.start_time);
|
||||
|
||||
self.inner.curr_strain * strain_decay(time - prev_start_time, STRAIN_DECAY_BASE)
|
||||
}
|
||||
|
||||
fn curr_section_peak(&self) -> f64 {
|
||||
self.inner.inner.inner.curr_section_peak
|
||||
}
|
||||
|
||||
fn curr_section_peak_mut(&mut self) -> &mut f64 {
|
||||
&mut self.inner.inner.inner.curr_section_peak
|
||||
}
|
||||
|
||||
fn curr_section_end(&self) -> f64 {
|
||||
self.inner.inner.inner.curr_section_end
|
||||
}
|
||||
|
||||
fn curr_section_end_mut(&mut self) -> &mut f64 {
|
||||
&mut self.inner.inner.inner.curr_section_end
|
||||
}
|
||||
|
||||
pub fn process(&mut self, curr: &'a OsuDifficultyObject<'a>) {
|
||||
if curr.idx == 0 {
|
||||
*self.curr_section_end_mut() = (curr.start_time / OsuStrainSkill::SECTION_LEN).ceil()
|
||||
* OsuStrainSkill::SECTION_LEN;
|
||||
}
|
||||
|
||||
while curr.start_time > self.curr_section_end() {
|
||||
self.inner.inner.save_curr_peak();
|
||||
let initial_strain = self.calculate_initial_strain(self.curr_section_end(), curr);
|
||||
self.inner.inner.start_new_section_from(initial_strain);
|
||||
*self.curr_section_end_mut() += OsuStrainSkill::SECTION_LEN;
|
||||
}
|
||||
|
||||
let strain_value_at = self.strain_value_at(curr);
|
||||
*self.curr_section_peak_mut() = strain_value_at.max(self.curr_section_peak());
|
||||
}
|
||||
|
||||
fn strain_value_at(&mut self, curr: &'a OsuDifficultyObject<'a>) -> f64 {
|
||||
self.inner.curr_strain *= strain_decay(curr.delta_time, STRAIN_DECAY_BASE);
|
||||
self.inner.curr_strain +=
|
||||
AimEvaluator::evaluate_diff_of(curr, self.diff_objects)
|
||||
* SKILL_MULTIPLIER;
|
||||
self.inner.inner.object_strains.push(self.inner.curr_strain);
|
||||
|
||||
self.inner.curr_strain
|
||||
}
|
||||
}
|
||||
|
||||
struct AimEvaluator;
|
||||
|
||||
impl AimEvaluator {
|
||||
const ANGLE_BONUS_BEGIN: f64 = std::f64::consts::FRAC_PI_3;
|
||||
const TIMING_THRESHOLD: f64 = 107.0;
|
||||
|
||||
fn evaluate_diff_of<'a>(
|
||||
curr: &'a OsuDifficultyObject<'a>,
|
||||
diff_objects: &'a [OsuDifficultyObject<'a>],
|
||||
) -> f64 {
|
||||
if curr.base.is_spinner() {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
let mut result = 0.0;
|
||||
|
||||
if let Some(prev) = curr.previous(0, diff_objects) {
|
||||
if let Some(angle) = curr.angle.filter(|a| *a > Self::ANGLE_BONUS_BEGIN) {
|
||||
let scale = 90.0;
|
||||
|
||||
let angle_bonus = (((angle - Self::ANGLE_BONUS_BEGIN).sin()).powi(2)
|
||||
* (prev.lazy_jump_dist - scale).max(0.0)
|
||||
* (curr.lazy_jump_dist - scale).max(0.0))
|
||||
.sqrt();
|
||||
|
||||
result = 1.5 * apply_diminishing_exp(angle_bonus.max(0.0))
|
||||
/ (Self::TIMING_THRESHOLD).max(prev.strain_time)
|
||||
}
|
||||
}
|
||||
|
||||
let jump_dist_exp = apply_diminishing_exp(curr.lazy_jump_dist);
|
||||
let travel_dist_exp = apply_diminishing_exp(curr.travel_dist);
|
||||
|
||||
let dist_exp =
|
||||
jump_dist_exp + travel_dist_exp + (travel_dist_exp * jump_dist_exp).sqrt();
|
||||
|
||||
(result + dist_exp / (curr.strain_time).max(Self::TIMING_THRESHOLD))
|
||||
.max(dist_exp / curr.strain_time)
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn apply_diminishing_exp(val: f64) -> f64 {
|
||||
val.powf(0.99)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
use aim::Aim;
|
||||
use speed::Speed;
|
||||
|
||||
pub mod aim;
|
||||
pub mod speed;
|
||||
pub mod strain;
|
||||
|
||||
pub struct OsuSkills {
|
||||
pub aim: Aim,
|
||||
pub speed: Speed,
|
||||
}
|
||||
|
||||
impl OsuSkills {
|
||||
pub fn new() -> Self {
|
||||
let aim = Aim::new();
|
||||
let speed = Speed::new();
|
||||
|
||||
Self {
|
||||
aim,
|
||||
speed,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
use crate::{any::difficulty::{object::IDifficultyObject, skills::{strain_decay, ISkill, Skill}}, osu_2019::difficulty::object::OsuDifficultyObject, util::strains_vec::StrainsVec};
|
||||
|
||||
use super::strain::{DifficultyValue, OsuStrainSkill, UsedOsuStrainSkills};
|
||||
|
||||
const SKILL_MULTIPLIER: f64 = 1400.0;
|
||||
const STRAIN_DECAY_BASE: f64 = 0.3;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Speed {
|
||||
curr_strain: f64,
|
||||
inner: OsuStrainSkill,
|
||||
}
|
||||
|
||||
impl Speed {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
curr_strain: 0.0,
|
||||
inner: OsuStrainSkill::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_curr_strain_peaks(self) -> StrainsVec {
|
||||
self.inner.get_curr_strain_peaks().strains()
|
||||
}
|
||||
|
||||
pub fn difficulty_value(self) -> UsedOsuStrainSkills<DifficultyValue> {
|
||||
Self::static_difficulty_value(self.inner)
|
||||
}
|
||||
|
||||
/// Use [`difficulty_value`] instead whenever possible because
|
||||
/// [`as_difficulty_value`] clones internally.
|
||||
pub fn as_difficulty_value(&self) -> UsedOsuStrainSkills<DifficultyValue> {
|
||||
Self::static_difficulty_value(self.inner.clone())
|
||||
}
|
||||
|
||||
fn static_difficulty_value(skill: OsuStrainSkill) -> UsedOsuStrainSkills<DifficultyValue> {
|
||||
skill.difficulty_value(
|
||||
OsuStrainSkill::DECAY_WEIGHT,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn relevant_note_count(&self) -> f64 {
|
||||
self.inner
|
||||
.object_strains
|
||||
.iter()
|
||||
.copied()
|
||||
.max_by(f64::total_cmp)
|
||||
.filter(|&n| n > 0.0)
|
||||
.map_or(0.0, |max_strain| {
|
||||
self.inner.object_strains.iter().fold(0.0, |sum, strain| {
|
||||
sum + (1.0 + (-(strain / max_strain * 12.0 - 6.0)).exp()).recip()
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl ISkill for Speed {
|
||||
type DifficultyObjects<'a> = [OsuDifficultyObject<'a>];
|
||||
}
|
||||
|
||||
impl<'a> Skill<'a, Speed> {
|
||||
fn calculate_initial_strain(&mut self, time: f64, curr: &'a OsuDifficultyObject<'a>) -> f64 {
|
||||
let prev_start_time = curr
|
||||
.previous(0, self.diff_objects)
|
||||
.map_or(0.0, |prev| prev.start_time);
|
||||
|
||||
self.inner.curr_strain * strain_decay(time - prev_start_time, STRAIN_DECAY_BASE)
|
||||
}
|
||||
|
||||
fn curr_section_peak(&self) -> f64 {
|
||||
self.inner.inner.inner.curr_section_peak
|
||||
}
|
||||
|
||||
fn curr_section_peak_mut(&mut self) -> &mut f64 {
|
||||
&mut self.inner.inner.inner.curr_section_peak
|
||||
}
|
||||
|
||||
fn curr_section_end(&self) -> f64 {
|
||||
self.inner.inner.inner.curr_section_end
|
||||
}
|
||||
|
||||
fn curr_section_end_mut(&mut self) -> &mut f64 {
|
||||
&mut self.inner.inner.inner.curr_section_end
|
||||
}
|
||||
|
||||
pub fn process(&mut self, curr: &'a OsuDifficultyObject<'a>) {
|
||||
if curr.idx == 0 {
|
||||
*self.curr_section_end_mut() = (curr.start_time / OsuStrainSkill::SECTION_LEN).ceil()
|
||||
* OsuStrainSkill::SECTION_LEN;
|
||||
}
|
||||
|
||||
while curr.start_time > self.curr_section_end() {
|
||||
self.inner.inner.save_curr_peak();
|
||||
let initial_strain = self.calculate_initial_strain(self.curr_section_end(), curr);
|
||||
self.inner.inner.start_new_section_from(initial_strain);
|
||||
*self.curr_section_end_mut() += OsuStrainSkill::SECTION_LEN;
|
||||
}
|
||||
|
||||
let strain_value_at = self.strain_value_at(curr);
|
||||
*self.curr_section_peak_mut() = strain_value_at.max(self.curr_section_peak());
|
||||
}
|
||||
|
||||
fn strain_value_at(&mut self, curr: &'a OsuDifficultyObject<'a>) -> f64 {
|
||||
self.inner.curr_strain *= strain_decay(curr.strain_time, STRAIN_DECAY_BASE);
|
||||
self.inner.curr_strain += SpeedEvaluator::evaluate_diff_of(
|
||||
curr
|
||||
) * SKILL_MULTIPLIER;
|
||||
|
||||
self.inner.inner.object_strains.push(self.inner.curr_strain);
|
||||
|
||||
self.inner.curr_strain
|
||||
}
|
||||
}
|
||||
|
||||
struct SpeedEvaluator;
|
||||
|
||||
impl SpeedEvaluator {
|
||||
const SINGLE_SPACING_TRESHOLD: f64 = 125.0;
|
||||
const ANGLE_BONUS_BEGIN: f64 = 5.0 * std::f64::consts::FRAC_PI_6;
|
||||
const PI_OVER_4: f64 = std::f64::consts::FRAC_PI_4;
|
||||
const PI_OVER_2: f64 = std::f64::consts::FRAC_PI_2;
|
||||
const MIN_SPEED_BONUS: f64 = 75.0;
|
||||
const MAX_SPEED_BONUS: f64 = 45.0;
|
||||
const SPEED_BALANCING_FACTOR: f64 = 40.0;
|
||||
|
||||
fn evaluate_diff_of<'a>(curr: &'a OsuDifficultyObject<'a>) -> f64 {
|
||||
if curr.base.is_spinner() {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
let dist = Self::SINGLE_SPACING_TRESHOLD.min(curr.travel_dist + curr.lazy_jump_dist);
|
||||
let delta_time = Self::MAX_SPEED_BONUS.max(curr.delta_time);
|
||||
|
||||
let mut speed_bonus = 1.0;
|
||||
|
||||
if delta_time < Self::MIN_SPEED_BONUS {
|
||||
let exp_base = (Self::MIN_SPEED_BONUS - delta_time) / Self::SPEED_BALANCING_FACTOR;
|
||||
speed_bonus += exp_base * exp_base;
|
||||
}
|
||||
|
||||
let mut angle_bonus = 1.0;
|
||||
|
||||
if let Some(angle) = curr.angle.filter(|a| *a < Self::ANGLE_BONUS_BEGIN) {
|
||||
let exp_base = (1.5 * (Self::ANGLE_BONUS_BEGIN - angle)).sin();
|
||||
angle_bonus = 1.0 + exp_base * exp_base / 3.57;
|
||||
|
||||
if angle < Self::PI_OVER_2 {
|
||||
angle_bonus = 1.28;
|
||||
|
||||
if dist < 90.0 && angle < Self::PI_OVER_4 {
|
||||
angle_bonus += (1.0 - angle_bonus) * ((90.0 - dist) / 10.0).min(1.0);
|
||||
} else if dist < 90.0 {
|
||||
angle_bonus += (1.0 - angle_bonus)
|
||||
* ((90.0 - dist) / 10.0).min(1.0)
|
||||
* ((Self::PI_OVER_2 - angle) / Self::PI_OVER_4).sin();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(1.0 + (speed_bonus - 1.0) * 0.75)
|
||||
* angle_bonus
|
||||
* (0.95 + speed_bonus * (dist / Self::SINGLE_SPACING_TRESHOLD).powf(3.5))
|
||||
/ curr.strain_time
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
use crate::{any::difficulty::skills::StrainSkill, util::strains_vec::StrainsVec};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct OsuStrainSkill {
|
||||
pub object_strains: Vec<f64>,
|
||||
pub inner: StrainSkill,
|
||||
}
|
||||
|
||||
impl Default for OsuStrainSkill {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
// mean=406.72 | median=307
|
||||
object_strains: Vec::with_capacity(256),
|
||||
inner: StrainSkill::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl OsuStrainSkill {
|
||||
pub const REDUCED_SECTION_COUNT: usize = 10;
|
||||
pub const REDUCED_STRAIN_BASELINE: f64 = 0.75;
|
||||
|
||||
pub const DECAY_WEIGHT: f64 = 0.9;
|
||||
pub const SECTION_LEN: f64 = 400.0;
|
||||
|
||||
pub fn save_curr_peak(&mut self) {
|
||||
self.inner.save_curr_peak();
|
||||
}
|
||||
|
||||
pub fn start_new_section_from(&mut self, initial_strain: f64) {
|
||||
self.inner.start_new_section_from(initial_strain);
|
||||
}
|
||||
|
||||
pub fn get_curr_strain_peaks(self) -> UsedOsuStrainSkills<StrainsVec> {
|
||||
UsedOsuStrainSkills {
|
||||
value: self.inner.get_curr_strain_peaks(),
|
||||
object_strains: self.object_strains,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn difficulty_value(
|
||||
self,
|
||||
decay_weight: f64,
|
||||
) -> UsedOsuStrainSkills<DifficultyValue> {
|
||||
let mut difficulty = 0.0;
|
||||
let mut weight = 1.0;
|
||||
|
||||
let UsedOsuStrainSkills {
|
||||
value: mut peaks,
|
||||
object_strains,
|
||||
} = self.get_curr_strain_peaks();
|
||||
|
||||
peaks.sort_desc();
|
||||
|
||||
for strain in peaks.iter() {
|
||||
difficulty += strain * weight;
|
||||
weight *= decay_weight;
|
||||
}
|
||||
|
||||
UsedOsuStrainSkills {
|
||||
value: DifficultyValue(difficulty),
|
||||
object_strains,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn difficulty_to_performance(difficulty: f64) -> f64 {
|
||||
(5.0 * (difficulty / 0.0675).max(1.0) - 4.0).powf(3.0) / 100_000.0
|
||||
}
|
||||
}
|
||||
|
||||
pub struct DifficultyValue(f64);
|
||||
|
||||
pub struct UsedOsuStrainSkills<T> {
|
||||
value: T,
|
||||
object_strains: Vec<f64>,
|
||||
}
|
||||
|
||||
impl UsedOsuStrainSkills<DifficultyValue> {
|
||||
pub const fn difficulty_value(&self) -> f64 {
|
||||
self.value.0
|
||||
}
|
||||
|
||||
pub fn count_difficult_strains(&self) -> f64 {
|
||||
let DifficultyValue(diff) = self.value;
|
||||
|
||||
if diff.abs() < f64::EPSILON {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
// * What would the top strain be if all strain values were identical
|
||||
let consistent_top_strain = diff / 10.0;
|
||||
|
||||
// * Use a weighted sum of all strains. Constants are arbitrary and give nice values
|
||||
self.object_strains
|
||||
.iter()
|
||||
.map(|s| 1.1 / (1.0 + (-10.0 * (s / consistent_top_strain - 0.88)).exp()))
|
||||
.sum()
|
||||
}
|
||||
}
|
||||
|
||||
impl UsedOsuStrainSkills<StrainsVec> {
|
||||
pub fn strains(self) -> StrainsVec {
|
||||
self.value
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
use super::OsuObject;
|
||||
|
||||
pub(crate) struct DifficultyObject<'h> {
|
||||
pub(crate) base: &'h OsuObject,
|
||||
pub(crate) prev: Option<(f32, f32)>, // (jump_dist, strain_time)
|
||||
|
||||
pub(crate) jump_dist: f32,
|
||||
pub(crate) travel_dist: f32,
|
||||
pub(crate) angle: Option<f32>,
|
||||
|
||||
pub(crate) delta: f32,
|
||||
pub(crate) strain_time: f32,
|
||||
}
|
||||
|
||||
impl<'h> DifficultyObject<'h> {
|
||||
pub(crate) fn new(
|
||||
base: &'h OsuObject,
|
||||
prev: &OsuObject,
|
||||
prev_vals: Option<(f32, f32)>, // (jump_dist, strain_time)
|
||||
prev_prev: Option<OsuObject>,
|
||||
clock_rate: f32,
|
||||
scaling_factor: f32,
|
||||
) -> Self {
|
||||
let delta = (base.time - prev.time) / clock_rate;
|
||||
let strain_time = delta.max(50.0);
|
||||
|
||||
let pos = base.pos;
|
||||
let travel_dist = prev.travel_dist.unwrap_or(0.0);
|
||||
let prev_cursor_pos = prev.end_pos;
|
||||
|
||||
let jump_dist = if base.is_spinner() {
|
||||
0.0
|
||||
} else {
|
||||
((pos - prev_cursor_pos) * scaling_factor).length()
|
||||
};
|
||||
|
||||
let angle = prev_prev.map(|prev_prev| {
|
||||
let prev_prev_cursor_pos = prev_prev.end_pos;
|
||||
|
||||
let v1 = prev_prev_cursor_pos - prev.pos;
|
||||
let v2 = pos - prev_cursor_pos;
|
||||
|
||||
let dot = v1.dot(v2);
|
||||
let det = v1.x * v2.y - v1.y * v2.x;
|
||||
|
||||
det.atan2(dot).abs()
|
||||
});
|
||||
|
||||
Self {
|
||||
base,
|
||||
prev: prev_vals,
|
||||
|
||||
jump_dist,
|
||||
travel_dist,
|
||||
angle,
|
||||
|
||||
delta,
|
||||
strain_time,
|
||||
}
|
||||
}
|
||||
}
|
||||
+62
-11
@@ -1,16 +1,67 @@
|
||||
mod difficulty_object;
|
||||
use difficulty_object::DifficultyObject;
|
||||
use convert::OsuRelaxBeatmap;
|
||||
use rosu_map::util::Pos;
|
||||
use strains::OsuStrains;
|
||||
|
||||
mod osu_object;
|
||||
use osu_object::OsuObject;
|
||||
use crate::{model::mode::{ConvertStatus, IGameMode}, Beatmap, Difficulty};
|
||||
|
||||
mod pp;
|
||||
pub use pp::{OsuAttributeProvider, OsuPP};
|
||||
mod difficulty;
|
||||
mod attributes;
|
||||
mod performance;
|
||||
mod strains;
|
||||
mod object;
|
||||
mod convert;
|
||||
|
||||
mod skill;
|
||||
use skill::Skill;
|
||||
pub use performance::OsuPerformance;
|
||||
pub use attributes::OsuPerformanceAttributes;
|
||||
pub use attributes::OsuDifficultyAttributes;
|
||||
pub use performance::gradual::OsuGradualPerformance;
|
||||
pub use difficulty::gradual::OsuGradualDifficulty;
|
||||
|
||||
mod skill_kind;
|
||||
use skill_kind::SkillKind;
|
||||
const PLAYFIELD_BASE_SIZE: Pos = Pos::new(512.0, 384.0);
|
||||
|
||||
pub mod stars;
|
||||
/// Marker type for [`GameMode::Osu`] with the Relax mod.
|
||||
///
|
||||
/// [`GameMode::Osu`]: rosu_map::section::general::GameMode::Osu
|
||||
pub struct OsuRelax;
|
||||
|
||||
impl IGameMode for OsuRelax {
|
||||
type DifficultyAttributes = OsuDifficultyAttributes;
|
||||
type Strains = OsuStrains;
|
||||
type Performance<'map> = OsuPerformance<'map>;
|
||||
type GradualDifficulty = OsuGradualDifficulty;
|
||||
type GradualPerformance = OsuGradualPerformance;
|
||||
|
||||
fn check_convert(map: &Beatmap) -> ConvertStatus {
|
||||
convert::check_convert(map)
|
||||
}
|
||||
|
||||
fn try_convert(map: &mut Beatmap) -> ConvertStatus {
|
||||
convert::try_convert(map)
|
||||
}
|
||||
|
||||
fn difficulty(
|
||||
difficulty: &Difficulty,
|
||||
converted: &OsuRelaxBeatmap<'_>,
|
||||
) -> Self::DifficultyAttributes {
|
||||
difficulty::difficulty(difficulty, converted)
|
||||
}
|
||||
|
||||
fn strains(difficulty: &Difficulty, converted: &OsuRelaxBeatmap<'_>) -> Self::Strains {
|
||||
strains::strains(difficulty, converted)
|
||||
}
|
||||
|
||||
fn performance(map: OsuRelaxBeatmap<'_>) -> Self::Performance<'_> {
|
||||
OsuPerformance::new(map)
|
||||
}
|
||||
|
||||
fn gradual_difficulty(difficulty: Difficulty, map: &OsuRelaxBeatmap<'_>) -> Self::GradualDifficulty {
|
||||
OsuGradualDifficulty::new(difficulty, map)
|
||||
}
|
||||
|
||||
fn gradual_performance(
|
||||
difficulty: Difficulty,
|
||||
map: &OsuRelaxBeatmap<'_>,
|
||||
) -> Self::GradualPerformance {
|
||||
OsuGradualPerformance::new(difficulty, map)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
use std::borrow::Cow;
|
||||
|
||||
use rosu_map::{
|
||||
section::{
|
||||
general::GameMode,
|
||||
hit_objects::{CurveBuffers, SliderEvent, SliderEventType, SliderEventsIter},
|
||||
},
|
||||
util::Pos,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
model::{
|
||||
control_point::{DifficultyPoint, TimingPoint},
|
||||
hit_object::{HitObject, HitObjectKind, HoldNote, Slider, Spinner},
|
||||
},
|
||||
util::{get_precision_adjusted_beat_len, sort},
|
||||
};
|
||||
|
||||
use super::{convert::OsuRelaxBeatmap, PLAYFIELD_BASE_SIZE};
|
||||
|
||||
pub struct OsuObject {
|
||||
pub pos: Pos,
|
||||
pub start_time: f64,
|
||||
pub stack_height: i32,
|
||||
pub stack_offset: Pos,
|
||||
pub kind: OsuObjectKind,
|
||||
}
|
||||
|
||||
impl OsuObject {
|
||||
pub const OBJECT_RADIUS: f32 = 64.0;
|
||||
pub const PREEMPT_MIN: f64 = 450.0;
|
||||
|
||||
const BASE_SCORING_DIST: f32 = 100.0;
|
||||
|
||||
pub fn new(
|
||||
h: &HitObject,
|
||||
converted: &OsuRelaxBeatmap<'_>,
|
||||
curve_bufs: &mut CurveBuffers,
|
||||
ticks_buf: &mut Vec<SliderEvent>,
|
||||
) -> Self {
|
||||
let kind = match h.kind {
|
||||
HitObjectKind::Circle => OsuObjectKind::Circle,
|
||||
HitObjectKind::Slider(ref slider) => {
|
||||
OsuObjectKind::Slider(OsuSlider::new(h, slider, converted, curve_bufs, ticks_buf))
|
||||
}
|
||||
HitObjectKind::Spinner(spinner) => OsuObjectKind::Spinner(spinner),
|
||||
HitObjectKind::Hold(HoldNote { duration }) => {
|
||||
OsuObjectKind::Spinner(Spinner { duration })
|
||||
}
|
||||
};
|
||||
|
||||
Self {
|
||||
pos: h.pos,
|
||||
start_time: h.start_time,
|
||||
stack_height: 0,
|
||||
stack_offset: Pos::default(),
|
||||
kind,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn reflect_vertically(&mut self) {
|
||||
fn reflect_y(y: &mut f32) {
|
||||
*y = PLAYFIELD_BASE_SIZE.y - *y;
|
||||
}
|
||||
|
||||
reflect_y(&mut self.pos.y);
|
||||
|
||||
if let OsuObjectKind::Slider(ref mut slider) = self.kind {
|
||||
// Requires `stack_offset` so we can't add `h.pos` just yet
|
||||
slider.lazy_end_pos.y = -slider.lazy_end_pos.y;
|
||||
|
||||
for nested in slider.nested_objects.iter_mut() {
|
||||
let mut nested_pos = self.pos; // already reflected at this point
|
||||
nested_pos += Pos::new(nested.pos.x, -nested.pos.y);
|
||||
nested.pos = nested_pos;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn finalize_nested(&mut self) {
|
||||
if let OsuObjectKind::Slider(ref mut slider) = self.kind {
|
||||
for nested in slider.nested_objects.iter_mut() {
|
||||
nested.pos += self.pos;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn end_time(&self) -> f64 {
|
||||
match self.kind {
|
||||
OsuObjectKind::Circle => self.start_time,
|
||||
OsuObjectKind::Slider(ref slider) => slider.end_time,
|
||||
OsuObjectKind::Spinner(ref spinner) => self.start_time + spinner.duration,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn stacked_pos(&self) -> Pos {
|
||||
self.pos + self.stack_offset
|
||||
}
|
||||
|
||||
pub fn end_pos(&self) -> Pos {
|
||||
match self.kind {
|
||||
OsuObjectKind::Circle | OsuObjectKind::Spinner(_) => self.pos,
|
||||
OsuObjectKind::Slider(ref slider) => {
|
||||
slider.tail().map_or(Pos::default(), |nested| nested.pos)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn stacked_end_pos(&self) -> Pos {
|
||||
self.end_pos() + self.stack_offset
|
||||
}
|
||||
|
||||
pub const fn lazy_travel_time(&self) -> f64 {
|
||||
match self.kind {
|
||||
OsuObjectKind::Circle | OsuObjectKind::Spinner(_) => 0.0,
|
||||
OsuObjectKind::Slider(ref slider) => slider.lazy_travel_time,
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn is_circle(&self) -> bool {
|
||||
matches!(self.kind, OsuObjectKind::Circle)
|
||||
}
|
||||
|
||||
pub const fn is_slider(&self) -> bool {
|
||||
matches!(self.kind, OsuObjectKind::Slider { .. })
|
||||
}
|
||||
|
||||
pub const fn is_spinner(&self) -> bool {
|
||||
matches!(self.kind, OsuObjectKind::Spinner(_))
|
||||
}
|
||||
}
|
||||
|
||||
pub enum OsuObjectKind {
|
||||
Circle,
|
||||
Slider(OsuSlider),
|
||||
Spinner(Spinner),
|
||||
}
|
||||
|
||||
pub struct OsuSlider {
|
||||
pub end_time: f64,
|
||||
pub lazy_end_pos: Pos,
|
||||
pub lazy_travel_dist: f32,
|
||||
pub lazy_travel_time: f64,
|
||||
pub nested_objects: Vec<NestedSliderObject>,
|
||||
}
|
||||
|
||||
impl OsuSlider {
|
||||
fn new(
|
||||
h: &HitObject,
|
||||
slider: &Slider,
|
||||
converted: &OsuRelaxBeatmap<'_>,
|
||||
curve_bufs: &mut CurveBuffers,
|
||||
ticks_buf: &mut Vec<SliderEvent>,
|
||||
) -> Self {
|
||||
let start_time = h.start_time;
|
||||
let slider_multiplier = converted.slider_multiplier;
|
||||
let slider_tick_rate = converted.slider_tick_rate;
|
||||
|
||||
let beat_len = converted
|
||||
.timing_point_at(start_time)
|
||||
.map_or(TimingPoint::DEFAULT_BEAT_LEN, |point| point.beat_len);
|
||||
|
||||
let (slider_velocity, generate_ticks) = converted.difficulty_point_at(start_time).map_or(
|
||||
(
|
||||
DifficultyPoint::DEFAULT_SLIDER_VELOCITY,
|
||||
DifficultyPoint::DEFAULT_GENERATE_TICKS,
|
||||
),
|
||||
|point| (point.slider_velocity, point.generate_ticks),
|
||||
);
|
||||
|
||||
let path = slider.curve(GameMode::Osu, curve_bufs);
|
||||
|
||||
let span_count = slider.span_count() as f64;
|
||||
|
||||
let velocity = f64::from(OsuObject::BASE_SCORING_DIST) * slider_multiplier
|
||||
/ get_precision_adjusted_beat_len(slider_velocity, beat_len);
|
||||
let scoring_dist = velocity * beat_len;
|
||||
|
||||
let end_time = start_time + span_count * path.dist() / velocity;
|
||||
|
||||
let duration = end_time - start_time;
|
||||
let span_duration = duration / span_count;
|
||||
|
||||
let tick_dist_multiplier = if converted.version < 8 {
|
||||
slider_velocity.recip()
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
|
||||
let tick_dist = if generate_ticks {
|
||||
scoring_dist / slider_tick_rate * tick_dist_multiplier
|
||||
} else {
|
||||
f64::INFINITY
|
||||
};
|
||||
|
||||
let events = SliderEventsIter::new(
|
||||
start_time,
|
||||
span_duration,
|
||||
velocity,
|
||||
tick_dist,
|
||||
path.dist(),
|
||||
slider.span_count() as i32,
|
||||
ticks_buf,
|
||||
);
|
||||
|
||||
let span_at = |progress: f64| (progress * span_count) as i32;
|
||||
|
||||
let obj_progress_at = |progress: f64| {
|
||||
let p = progress * span_count % 1.0;
|
||||
|
||||
if span_at(progress) % 2 == 1 {
|
||||
1.0 - p
|
||||
} else {
|
||||
p
|
||||
}
|
||||
};
|
||||
|
||||
let end_path_pos = path.position_at(obj_progress_at(1.0));
|
||||
|
||||
let mut nested_objects: Vec<_> = events
|
||||
.filter_map(|e| {
|
||||
let obj = match e.kind {
|
||||
SliderEventType::Tick => NestedSliderObject {
|
||||
pos: path.position_at(e.path_progress),
|
||||
start_time: e.time,
|
||||
kind: NestedSliderObjectKind::Tick,
|
||||
},
|
||||
SliderEventType::Repeat => NestedSliderObject {
|
||||
pos: path.position_at(e.path_progress),
|
||||
start_time: start_time + f64::from(e.span_idx + 1) * span_duration,
|
||||
kind: NestedSliderObjectKind::Repeat,
|
||||
},
|
||||
SliderEventType::Tail => NestedSliderObject {
|
||||
pos: end_path_pos, // no `h.pos` yet to keep order of float operations
|
||||
start_time: e.time,
|
||||
kind: NestedSliderObjectKind::Tail,
|
||||
},
|
||||
SliderEventType::Head | SliderEventType::LastTick => return None,
|
||||
};
|
||||
|
||||
Some(obj)
|
||||
})
|
||||
.collect();
|
||||
|
||||
sort::csharp(&mut nested_objects, |a, b| {
|
||||
a.start_time.total_cmp(&b.start_time)
|
||||
});
|
||||
|
||||
let mut nested = Cow::Borrowed(nested_objects.as_slice());
|
||||
let lazy_travel_time = OsuSlider::lazy_travel_time(start_time, duration, &mut nested);
|
||||
|
||||
let mut end_time_min = lazy_travel_time / span_duration;
|
||||
|
||||
if end_time_min % 2.0 >= 1.0 {
|
||||
end_time_min = 1.0 - end_time_min % 1.0;
|
||||
} else {
|
||||
end_time_min %= 1.0;
|
||||
}
|
||||
|
||||
let lazy_end_pos = path.position_at(end_time_min);
|
||||
|
||||
Self {
|
||||
end_time,
|
||||
lazy_end_pos,
|
||||
lazy_travel_dist: 0.0,
|
||||
lazy_travel_time,
|
||||
nested_objects,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn lazy_travel_time(
|
||||
start_time: f64,
|
||||
duration: f64,
|
||||
nested_objects: &mut Cow<'_, [NestedSliderObject]>,
|
||||
) -> f64 {
|
||||
const TAIL_LENIENCY: f64 = -36.0;
|
||||
|
||||
let mut tracking_end_time =
|
||||
(start_time + duration + TAIL_LENIENCY).max(start_time + duration / 2.0);
|
||||
|
||||
let last_real_tick = nested_objects
|
||||
.iter()
|
||||
.enumerate()
|
||||
.rfind(|(_, nested)| nested.is_tick());
|
||||
|
||||
if let Some((idx, last_real_tick)) =
|
||||
last_real_tick.filter(|(_, tick)| tick.start_time > tracking_end_time)
|
||||
{
|
||||
tracking_end_time = last_real_tick.start_time;
|
||||
|
||||
// * When the last tick falls after the tracking end time, we need to re-sort the nested objects
|
||||
// * based on time. This creates a somewhat weird ordering which is counter to how a user would
|
||||
// * understand the slider, but allows a zero-diff with known diffcalc output.
|
||||
// *
|
||||
// * To reiterate, this is definitely not correct from a difficulty calculation perspective
|
||||
// * and should be revisited at a later date (likely by replacing this whole code with the commented
|
||||
// * version above).
|
||||
nested_objects.to_mut()[idx..].rotate_left(1);
|
||||
}
|
||||
|
||||
tracking_end_time - start_time
|
||||
}
|
||||
|
||||
pub fn repeat_count(&self) -> usize {
|
||||
self.nested_objects
|
||||
.iter()
|
||||
.filter(|nested| matches!(nested.kind, NestedSliderObjectKind::Repeat))
|
||||
.count()
|
||||
}
|
||||
|
||||
pub fn tail(&self) -> Option<&NestedSliderObject> {
|
||||
self.nested_objects
|
||||
.iter()
|
||||
// The tail is not necessarily the last nested object, e.g. on very
|
||||
// short and fast buzz sliders (/b/1001757)
|
||||
.rfind(|nested| matches!(nested.kind, NestedSliderObjectKind::Tail))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct NestedSliderObject {
|
||||
pub pos: Pos,
|
||||
pub start_time: f64,
|
||||
pub kind: NestedSliderObjectKind,
|
||||
}
|
||||
|
||||
impl NestedSliderObject {
|
||||
pub const fn is_repeat(&self) -> bool {
|
||||
matches!(self.kind, NestedSliderObjectKind::Repeat)
|
||||
}
|
||||
|
||||
pub const fn is_tick(&self) -> bool {
|
||||
matches!(self.kind, NestedSliderObjectKind::Tick)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug)]
|
||||
pub enum NestedSliderObjectKind {
|
||||
Repeat,
|
||||
Tail,
|
||||
Tick,
|
||||
}
|
||||
@@ -1,226 +0,0 @@
|
||||
use rosu_map::{
|
||||
section::{
|
||||
general::GameMode,
|
||||
hit_objects::{BorrowedCurve, CurveBuffers},
|
||||
},
|
||||
util::Pos,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
model::{
|
||||
control_point::{DifficultyPoint, TimingPoint},
|
||||
hit_object::{HitObject, HitObjectKind, Slider},
|
||||
},
|
||||
Beatmap,
|
||||
};
|
||||
|
||||
use super::stars::OsuDifficultyAttributes;
|
||||
|
||||
const LEGACY_LAST_TICK_OFFSET: f64 = 36.0;
|
||||
const BASE_SCORING_DISTANCE: f64 = 100.0;
|
||||
|
||||
pub(crate) struct OsuObject {
|
||||
pub(crate) time: f32,
|
||||
pub(crate) pos: Pos,
|
||||
pub(crate) end_pos: Pos,
|
||||
// circle: Some(0.0) | slider: Some(_) | spinner: None
|
||||
pub(crate) travel_dist: Option<f32>,
|
||||
}
|
||||
|
||||
impl OsuObject {
|
||||
pub(crate) fn new(
|
||||
h: &HitObject,
|
||||
map: &Beatmap,
|
||||
radius: f32,
|
||||
scaling_factor: f32,
|
||||
ticks: &mut Vec<f64>,
|
||||
attrs: &mut OsuDifficultyAttributes,
|
||||
curve_bufs: &mut CurveBuffers,
|
||||
) -> Self {
|
||||
attrs.max_combo += 1; // hitcircle, slider head, or spinner
|
||||
|
||||
match &h.kind {
|
||||
HitObjectKind::Circle => {
|
||||
attrs.n_circles += 1;
|
||||
|
||||
Self {
|
||||
time: h.start_time as f32,
|
||||
pos: h.pos,
|
||||
end_pos: h.pos,
|
||||
travel_dist: Some(0.0),
|
||||
}
|
||||
}
|
||||
HitObjectKind::Slider(Slider {
|
||||
expected_dist,
|
||||
repeats,
|
||||
control_points,
|
||||
..
|
||||
}) => {
|
||||
attrs.n_sliders += 1;
|
||||
|
||||
let beat_len = timing_point_at(&map.timing_points, h.start_time)
|
||||
.map_or(TimingPoint::DEFAULT_BEAT_LEN, |point| point.beat_len);
|
||||
|
||||
let (slider_vel, generate_ticks) =
|
||||
difficulty_point_at(&map.difficulty_points, h.start_time).map_or(
|
||||
(
|
||||
DifficultyPoint::DEFAULT_SLIDER_VELOCITY,
|
||||
DifficultyPoint::DEFAULT_GENERATE_TICKS,
|
||||
),
|
||||
|point| (point.slider_velocity, point.generate_ticks),
|
||||
);
|
||||
|
||||
let scoring_dist = BASE_SCORING_DISTANCE * map.slider_multiplier * slider_vel;
|
||||
let vel = scoring_dist / beat_len;
|
||||
|
||||
// Key values which are computed here
|
||||
let mut end_pos = h.pos;
|
||||
let mut travel_dist = 0.0;
|
||||
|
||||
let approx_follow_circle_radius = radius * 3.0;
|
||||
|
||||
let tick_dist_mult = if map.version < 8 {
|
||||
slider_vel.recip()
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
|
||||
let mut tick_dist = if generate_ticks {
|
||||
scoring_dist / map.slider_tick_rate * tick_dist_mult
|
||||
} else {
|
||||
f64::INFINITY
|
||||
};
|
||||
|
||||
let span_count = (*repeats + 1) as f64;
|
||||
|
||||
// Build the curve w.r.t. the curve points
|
||||
let curve =
|
||||
BorrowedCurve::new(GameMode::Osu, control_points, *expected_dist, curve_bufs);
|
||||
|
||||
let end_time = h.start_time + span_count * curve.dist() / vel;
|
||||
let total_duration = end_time - h.start_time;
|
||||
let span_duration = total_duration / span_count;
|
||||
|
||||
// Called on each slider object except for the head.
|
||||
// Increases combo and adjusts `end_pos` and `travel_dist`
|
||||
// w.r.t. the object position at the given time on the slider curve.
|
||||
let mut compute_vertex = |time: f64| {
|
||||
attrs.max_combo += 1;
|
||||
|
||||
let mut progress = (time - h.start_time) / span_duration;
|
||||
|
||||
if progress % 2.0 >= 1.0 {
|
||||
progress = 1.0 - progress % 1.0;
|
||||
} else {
|
||||
progress %= 1.0;
|
||||
}
|
||||
|
||||
let curr_pos = h.pos + curve.position_at(progress);
|
||||
|
||||
let diff = curr_pos - end_pos;
|
||||
let mut dist = diff.length();
|
||||
|
||||
if dist > approx_follow_circle_radius {
|
||||
dist -= approx_follow_circle_radius;
|
||||
end_pos += diff.normalize() * dist;
|
||||
travel_dist += dist;
|
||||
}
|
||||
};
|
||||
|
||||
let max_len = 100_000.0;
|
||||
|
||||
let len = curve.dist().min(max_len);
|
||||
tick_dist = tick_dist.clamp(0.0, len);
|
||||
let min_dist_from_end = vel * 10.0;
|
||||
|
||||
let mut curr_dist = tick_dist;
|
||||
|
||||
if tick_dist != 0.0 {
|
||||
ticks.reserve((len / tick_dist) as usize);
|
||||
|
||||
// Tick of the first span
|
||||
while curr_dist < len - min_dist_from_end {
|
||||
let progress = curr_dist / len;
|
||||
|
||||
let curr_time = h.start_time + progress * span_duration;
|
||||
compute_vertex(curr_time);
|
||||
ticks.push(curr_time);
|
||||
|
||||
curr_dist += tick_dist;
|
||||
}
|
||||
|
||||
// Other spans
|
||||
for span_idx in 1..=*repeats {
|
||||
let span_idx_f64 = span_idx as f64;
|
||||
|
||||
// Repeat point
|
||||
let curr_time = h.start_time + span_duration * span_idx_f64;
|
||||
compute_vertex(curr_time);
|
||||
|
||||
let span_offset = span_idx_f64 * span_duration;
|
||||
|
||||
// Ticks
|
||||
if span_idx & 1 == 1 {
|
||||
let base = h.start_time + h.start_time + span_duration;
|
||||
|
||||
for time in ticks.iter().rev() {
|
||||
compute_vertex(span_offset + base - time);
|
||||
}
|
||||
} else {
|
||||
for time in ticks.iter() {
|
||||
compute_vertex(span_offset + time);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ticks.clear();
|
||||
}
|
||||
|
||||
// Slider tail
|
||||
let final_span_start_time = h.start_time + *repeats as f64 * span_duration;
|
||||
let final_span_end_time = (h.start_time + total_duration / 2.0)
|
||||
.max(final_span_start_time + span_duration - LEGACY_LAST_TICK_OFFSET);
|
||||
compute_vertex(final_span_end_time);
|
||||
|
||||
travel_dist *= scaling_factor;
|
||||
|
||||
Self {
|
||||
time: h.start_time as f32,
|
||||
pos: h.pos,
|
||||
end_pos,
|
||||
travel_dist: Some(travel_dist),
|
||||
}
|
||||
}
|
||||
HitObjectKind::Spinner { .. } | HitObjectKind::Hold { .. } => {
|
||||
attrs.n_spinners += 1;
|
||||
|
||||
Self {
|
||||
time: h.start_time as f32,
|
||||
pos: h.pos,
|
||||
end_pos: h.pos,
|
||||
travel_dist: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn is_spinner(&self) -> bool {
|
||||
self.travel_dist.is_none()
|
||||
}
|
||||
}
|
||||
|
||||
fn timing_point_at(points: &[TimingPoint], time: f64) -> Option<&TimingPoint> {
|
||||
let i = points
|
||||
.binary_search_by(|probe| probe.time.total_cmp(&time))
|
||||
.unwrap_or_else(|i| i.saturating_sub(1));
|
||||
|
||||
points.get(i)
|
||||
}
|
||||
|
||||
fn difficulty_point_at(points: &[DifficultyPoint], time: f64) -> Option<&DifficultyPoint> {
|
||||
points
|
||||
.binary_search_by(|probe| probe.time.total_cmp(&time))
|
||||
.map_or_else(|i| i.checked_sub(1), Some)
|
||||
.map(|i| &points[i])
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
use crate::{
|
||||
osu_2019::{OsuRelaxBeatmap, OsuGradualDifficulty},
|
||||
Difficulty,
|
||||
};
|
||||
|
||||
use super::{OsuPerformanceAttributes, OsuScoreState};
|
||||
|
||||
/// Gradually calculate the performance attributes of an osu!standard (relax) 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, Difficulty};
|
||||
/// use rosu_pp::osu_2019::{OsuRelax, OsuGradualPerformance, OsuScoreState};
|
||||
///
|
||||
/// let converted = Beatmap::from_path("./resources/2785319.osu")
|
||||
/// .unwrap()
|
||||
/// .unchecked_into_converted::<OsuRelax>();
|
||||
///
|
||||
/// let difficulty = Difficulty::new().mods(64); // DT
|
||||
/// let mut gradual = 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 attrs = gradual.next(state.clone()).unwrap();
|
||||
/// println!("PP: {}", attrs.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.misses += 1;
|
||||
/// let attrs = gradual.next(state.clone()).unwrap();
|
||||
/// println!("PP: {}", attrs.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 attrs = gradual.nth(state.clone(), 9).unwrap();
|
||||
/// println!("PP: {}", attrs.pp);
|
||||
///
|
||||
/// // Now comes another 300. Note that the max combo gets incremented again.
|
||||
/// state.n300 += 1;
|
||||
/// state.max_combo += 1;
|
||||
/// let attrs = gradual.next(state.clone()).unwrap();
|
||||
/// println!("PP: {}", attrs.pp);
|
||||
///
|
||||
/// // Skip to the end
|
||||
/// # /*
|
||||
/// state.max_combo = ...
|
||||
/// state.n300 = ...
|
||||
/// state.n100 = ...
|
||||
/// state.n50 = ...
|
||||
/// state.misses = ...
|
||||
/// # */
|
||||
/// let attrs = gradual.last(state.clone()).unwrap();
|
||||
/// println!("PP: {}", attrs.pp);
|
||||
///
|
||||
/// // Once the final performance has been calculated, attempting to process
|
||||
/// // further objects will return `None`.
|
||||
/// assert!(gradual.next(state).is_none());
|
||||
/// ```
|
||||
///
|
||||
/// [`next`]: OsuGradualPerformance::next
|
||||
/// [`nth`]: OsuGradualPerformance::nth
|
||||
pub struct OsuGradualPerformance {
|
||||
difficulty: OsuGradualDifficulty,
|
||||
}
|
||||
|
||||
impl OsuGradualPerformance {
|
||||
/// Create a new gradual performance calculator for osu!standard (relax) maps.
|
||||
pub fn new(difficulty: Difficulty, converted: &OsuRelaxBeatmap<'_>) -> Self {
|
||||
let difficulty = OsuGradualDifficulty::new(difficulty, converted);
|
||||
|
||||
Self { difficulty }
|
||||
}
|
||||
|
||||
/// 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 performance = self
|
||||
.difficulty
|
||||
.nth(n)?
|
||||
.performance()
|
||||
.state(state)
|
||||
.difficulty(self.difficulty.difficulty.clone())
|
||||
.passed_objects(self.difficulty.idx as u32)
|
||||
.calculate();
|
||||
|
||||
Some(performance)
|
||||
}
|
||||
|
||||
/// Returns the amount of remaining objects.
|
||||
#[allow(clippy::len_without_is_empty)]
|
||||
pub fn len(&self) -> usize {
|
||||
self.difficulty.len()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,923 @@
|
||||
use std::cmp;
|
||||
|
||||
use crate::{any::{HitResultPriority, IntoModePerformance, IntoPerformance}, osu::OsuScoreState, util::map_or_attrs::MapOrAttrs, Difficulty, GameMods, Performance};
|
||||
use super::{OsuDifficultyAttributes, OsuPerformanceAttributes, OsuRelax};
|
||||
|
||||
pub mod gradual;
|
||||
|
||||
/// Performance calculator on osu!standard (relax) maps.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
#[must_use]
|
||||
pub struct OsuPerformance<'map> {
|
||||
pub(crate) map_or_attrs: MapOrAttrs<'map, OsuRelax>,
|
||||
pub(crate) difficulty: Difficulty,
|
||||
pub(crate) acc: Option<f64>,
|
||||
pub(crate) combo: Option<u32>,
|
||||
pub(crate) slider_tick_hits: Option<u32>,
|
||||
pub(crate) slider_end_hits: Option<u32>,
|
||||
pub(crate) n300: Option<u32>,
|
||||
pub(crate) n100: Option<u32>,
|
||||
pub(crate) n50: Option<u32>,
|
||||
pub(crate) misses: Option<u32>,
|
||||
pub(crate) hitresult_priority: HitResultPriority,
|
||||
pub(crate) lazer: Option<bool>,
|
||||
}
|
||||
|
||||
impl<'map> OsuPerformance<'map> {
|
||||
/// Create a new performance calculator for osu! maps.
|
||||
///
|
||||
/// The argument `map_or_attrs` must be either
|
||||
/// - previously calculated attributes ([`OsuDifficultyAttributes`]
|
||||
/// or [`OsuPerformanceAttributes`])
|
||||
/// - a beatmap ([`OsuRelaxBeatmap<'map>`])
|
||||
///
|
||||
/// If a map is given, difficulty attributes will need to be calculated
|
||||
/// internally which is a costly operation. Hence, passing attributes
|
||||
/// should be prefered.
|
||||
///
|
||||
/// However, when passing previously calculated attributes, make sure they
|
||||
/// have been calculated for the same map and [`Difficulty`] settings.
|
||||
/// Otherwise, the final attributes will be incorrect.
|
||||
///
|
||||
/// [`OsuBeatmap<'map>`]: crate::osu::OsuBeatmap
|
||||
pub fn new(map_or_attrs: impl IntoModePerformance<'map, OsuRelax>) -> Self {
|
||||
map_or_attrs.into_performance()
|
||||
}
|
||||
|
||||
/// Try to create a new performance calculator for osu! maps.
|
||||
///
|
||||
/// Returns `None` if `map_or_attrs` does not belong to osu! e.g.
|
||||
/// a [`Converted`], [`DifficultyAttributes`], or [`PerformanceAttributes`]
|
||||
/// of a different mode.
|
||||
///
|
||||
/// See [`OsuPerformance::new`] for more information.
|
||||
///
|
||||
/// [`Converted`]: crate::model::beatmap::Converted
|
||||
/// [`DifficultyAttributes`]: crate::any::DifficultyAttributes
|
||||
/// [`PerformanceAttributes`]: crate::any::PerformanceAttributes
|
||||
pub fn try_new(map_or_attrs: impl IntoPerformance<'map>) -> Option<Self> {
|
||||
if let Performance::OsuRelax(calc) = map_or_attrs.into_performance() {
|
||||
Some(calc)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Specify mods.
|
||||
///
|
||||
/// Accepted types are
|
||||
/// - `u32`
|
||||
/// - [`rosu_mods::GameModsLegacy`]
|
||||
/// - [`rosu_mods::GameMods`]
|
||||
/// - [`rosu_mods::GameModsIntermode`]
|
||||
/// - [`&rosu_mods::GameModsIntermode`](rosu_mods::GameModsIntermode)
|
||||
///
|
||||
/// See <https://github.com/ppy/osu-api/wiki#mods>
|
||||
pub fn mods(mut self, mods: impl Into<GameMods>) -> Self {
|
||||
self.difficulty = self.difficulty.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
|
||||
}
|
||||
|
||||
/// Whether the calculated attributes belong to an osu!lazer or osu!stable
|
||||
/// score.
|
||||
///
|
||||
/// Defaults to lazer.
|
||||
///
|
||||
/// This affects internal accuracy calculation because lazer considers
|
||||
/// slider heads for accuracy whereas stable does not.
|
||||
pub const fn lazer(mut self, lazer: bool) -> Self {
|
||||
self.lazer = Some(lazer);
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
/// Specify the amount of hit slider ticks.
|
||||
///
|
||||
/// Only relevant for osu!lazer.
|
||||
pub const fn n_slider_ticks(mut self, n_slider_ticks: u32) -> Self {
|
||||
self.slider_tick_hits = Some(n_slider_ticks);
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
/// Specify the amount of hit slider ends.
|
||||
///
|
||||
/// Only relevant for osu!lazer.
|
||||
pub const fn n_slider_ends(mut self, n_slider_ends: u32) -> Self {
|
||||
self.slider_end_hits = Some(n_slider_ends);
|
||||
|
||||
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 misses(mut self, n_misses: u32) -> Self {
|
||||
self.misses = Some(n_misses);
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
/// Use the specified settings of the given [`Difficulty`].
|
||||
pub fn difficulty(mut self, difficulty: Difficulty) -> Self {
|
||||
self.difficulty = difficulty;
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
/// Amount of passed objects for partial plays, e.g. a fail.
|
||||
///
|
||||
/// If you want to calculate the performance after every few objects,
|
||||
/// instead of using [`OsuPerformance`] multiple times with different
|
||||
/// `passed_objects`, you should use [`OsuGradualPerformance`].
|
||||
///
|
||||
/// [`OsuGradualPerformance`]: crate::osu::OsuGradualPerformance
|
||||
pub fn passed_objects(mut self, passed_objects: u32) -> Self {
|
||||
self.difficulty = self.difficulty.passed_objects(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.
|
||||
///
|
||||
/// | Minimum | Maximum |
|
||||
/// | :-----: | :-----: |
|
||||
/// | 0.01 | 100 |
|
||||
pub fn clock_rate(mut self, clock_rate: f64) -> Self {
|
||||
self.difficulty = self.difficulty.clock_rate(clock_rate);
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
/// Override a beatmap's set AR.
|
||||
///
|
||||
/// `with_mods` determines if the given value should be used before
|
||||
/// or after accounting for mods, e.g. on `true` the value will be
|
||||
/// used as is and on `false` it will be modified based on the mods.
|
||||
///
|
||||
/// | Minimum | Maximum |
|
||||
/// | :-----: | :-----: |
|
||||
/// | -20 | 20 |
|
||||
pub fn ar(mut self, ar: f32, with_mods: bool) -> Self {
|
||||
self.difficulty = self.difficulty.ar(ar, with_mods);
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
/// Override a beatmap's set CS.
|
||||
///
|
||||
/// `with_mods` determines if the given value should be used before
|
||||
/// or after accounting for mods, e.g. on `true` the value will be
|
||||
/// used as is and on `false` it will be modified based on the mods.
|
||||
///
|
||||
/// | Minimum | Maximum |
|
||||
/// | :-----: | :-----: |
|
||||
/// | -20 | 20 |
|
||||
pub fn cs(mut self, cs: f32, with_mods: bool) -> Self {
|
||||
self.difficulty = self.difficulty.cs(cs, with_mods);
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
/// Override a beatmap's set HP.
|
||||
///
|
||||
/// `with_mods` determines if the given value should be used before
|
||||
/// or after accounting for mods, e.g. on `true` the value will be
|
||||
/// used as is and on `false` it will be modified based on the mods.
|
||||
///
|
||||
/// | Minimum | Maximum |
|
||||
/// | :-----: | :-----: |
|
||||
/// | -20 | 20 |
|
||||
pub fn hp(mut self, hp: f32, with_mods: bool) -> Self {
|
||||
self.difficulty = self.difficulty.hp(hp, with_mods);
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
/// Override a beatmap's set OD.
|
||||
///
|
||||
/// `with_mods` determines if the given value should be used before
|
||||
/// or after accounting for mods, e.g. on `true` the value will be
|
||||
/// used as is and on `false` it will be modified based on the mods.
|
||||
///
|
||||
/// | Minimum | Maximum |
|
||||
/// | :-----: | :-----: |
|
||||
/// | -20 | 20 |
|
||||
pub fn od(mut self, od: f32, with_mods: bool) -> Self {
|
||||
self.difficulty = self.difficulty.od(od, with_mods);
|
||||
|
||||
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,
|
||||
slider_tick_hits,
|
||||
slider_end_hits,
|
||||
n300,
|
||||
n100,
|
||||
n50,
|
||||
misses,
|
||||
} = state;
|
||||
|
||||
self.combo = Some(max_combo);
|
||||
self.slider_tick_hits = Some(slider_tick_hits);
|
||||
self.slider_end_hits = Some(slider_end_hits);
|
||||
self.n300 = Some(n300);
|
||||
self.n100 = Some(n100);
|
||||
self.n50 = Some(n50);
|
||||
self.misses = Some(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.clamp(0.0, 100.0) / 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.difficulty.with_mode().calculate(map);
|
||||
|
||||
self.map_or_attrs.insert_attrs(attrs)
|
||||
}
|
||||
MapOrAttrs::Attrs(ref attrs) => attrs,
|
||||
};
|
||||
|
||||
let max_combo = attrs.max_combo;
|
||||
let n_objects = cmp::min(
|
||||
self.difficulty.get_passed_objects() as u32,
|
||||
attrs.n_objects(),
|
||||
);
|
||||
let priority = self.hitresult_priority;
|
||||
|
||||
let misses = self.misses.map_or(0, |n| cmp::min(n, n_objects));
|
||||
let n_remaining = n_objects - misses;
|
||||
|
||||
let mut n300 = self.n300.map_or(0, |n| cmp::min(n, n_remaining));
|
||||
let mut n100 = self.n100.map_or(0, |n| cmp::min(n, n_remaining));
|
||||
let mut n50 = self.n50.map_or(0, |n| cmp::min(n, n_remaining));
|
||||
|
||||
let lazer = self.lazer.unwrap_or(true);
|
||||
|
||||
let (n_slider_ends, n_slider_ticks, max_slider_ends, max_slider_ticks) = if lazer {
|
||||
let n_slider_ends = self
|
||||
.slider_end_hits
|
||||
.map_or(attrs.n_sliders, |n| cmp::min(n, attrs.n_sliders));
|
||||
let n_slider_ticks = self
|
||||
.slider_tick_hits
|
||||
.map_or(attrs.n_slider_ticks, |n| cmp::min(n, attrs.n_slider_ticks));
|
||||
|
||||
(
|
||||
n_slider_ends,
|
||||
n_slider_ticks,
|
||||
attrs.n_sliders,
|
||||
attrs.n_slider_ticks,
|
||||
)
|
||||
} else {
|
||||
(0, 0, 0, 0)
|
||||
};
|
||||
|
||||
if let Some(acc) = self.acc {
|
||||
let target_total =
|
||||
acc * f64::from(30 * n_objects + 15 * max_slider_ends + 3 * max_slider_ticks);
|
||||
|
||||
match (self.n300, self.n100, self.n50) {
|
||||
(Some(_), Some(_), Some(_)) => {
|
||||
let remaining = n_objects.saturating_sub(n300 + n100 + n50 + misses);
|
||||
|
||||
match priority {
|
||||
HitResultPriority::BestCase => n300 += remaining,
|
||||
HitResultPriority::WorstCase => n50 += remaining,
|
||||
}
|
||||
}
|
||||
(Some(_), Some(_), None) => n50 = n_objects.saturating_sub(n300 + n100 + misses),
|
||||
(Some(_), None, Some(_)) => n100 = n_objects.saturating_sub(n300 + n50 + misses),
|
||||
(None, Some(_), Some(_)) => n300 = n_objects.saturating_sub(n100 + n50 + misses),
|
||||
(Some(_), None, None) => {
|
||||
let mut best_dist = f64::MAX;
|
||||
|
||||
n300 = cmp::min(n300, n_remaining);
|
||||
let n_remaining = n_remaining - n300;
|
||||
|
||||
let raw_n100 = (target_total
|
||||
- f64::from(
|
||||
5 * n_remaining + 30 * n300 + 15 * n_slider_ends + 3 * n_slider_ticks,
|
||||
))
|
||||
/ 5.0;
|
||||
let min_n100 = cmp::min(n_remaining, raw_n100.floor() as u32);
|
||||
let max_n100 = cmp::min(n_remaining, raw_n100.ceil() as u32);
|
||||
|
||||
for new100 in min_n100..=max_n100 {
|
||||
let new50 = n_remaining - new100;
|
||||
let dist = (acc
|
||||
- accuracy(
|
||||
n_slider_ticks,
|
||||
n_slider_ends,
|
||||
n300,
|
||||
new100,
|
||||
new50,
|
||||
misses,
|
||||
max_slider_ticks,
|
||||
max_slider_ends,
|
||||
))
|
||||
.abs();
|
||||
|
||||
if dist < best_dist {
|
||||
best_dist = dist;
|
||||
n100 = new100;
|
||||
n50 = new50;
|
||||
}
|
||||
}
|
||||
}
|
||||
(None, Some(_), None) => {
|
||||
let mut best_dist = f64::MAX;
|
||||
|
||||
n100 = cmp::min(n100, n_remaining);
|
||||
let n_remaining = n_remaining - n100;
|
||||
|
||||
let raw_n300 = (target_total
|
||||
- f64::from(
|
||||
5 * n_remaining + 10 * n100 + 15 * n_slider_ends + 3 * n_slider_ticks,
|
||||
))
|
||||
/ 25.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 new50 = n_remaining - new300;
|
||||
let curr_dist = (acc
|
||||
- accuracy(
|
||||
n_slider_ticks,
|
||||
n_slider_ends,
|
||||
new300,
|
||||
n100,
|
||||
new50,
|
||||
misses,
|
||||
max_slider_ticks,
|
||||
max_slider_ends,
|
||||
))
|
||||
.abs();
|
||||
|
||||
if curr_dist < best_dist {
|
||||
best_dist = curr_dist;
|
||||
n300 = new300;
|
||||
n50 = new50;
|
||||
}
|
||||
}
|
||||
}
|
||||
(None, None, Some(_)) => {
|
||||
let mut best_dist = f64::MAX;
|
||||
|
||||
n50 = cmp::min(n50, n_remaining);
|
||||
let n_remaining = n_remaining - n50;
|
||||
|
||||
let raw_n300 = (target_total + f64::from(10 * misses + 5 * n50)
|
||||
- f64::from(10 * n_objects + 15 * n_slider_ends + 3 * n_slider_ticks))
|
||||
/ 20.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 new100 = n_remaining - new300;
|
||||
let curr_dist = (acc
|
||||
- accuracy(
|
||||
n_slider_ticks,
|
||||
n_slider_ends,
|
||||
new300,
|
||||
new100,
|
||||
n50,
|
||||
misses,
|
||||
max_slider_ticks,
|
||||
max_slider_ends,
|
||||
))
|
||||
.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(5 * n_remaining + 15 * n_slider_ends + 3 * n_slider_ticks))
|
||||
/ 25.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(
|
||||
5 * n_remaining
|
||||
+ 25 * new300
|
||||
+ 15 * n_slider_ends
|
||||
+ 3 * n_slider_ticks,
|
||||
))
|
||||
/ 5.0;
|
||||
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(
|
||||
n_slider_ticks,
|
||||
n_slider_ends,
|
||||
new300,
|
||||
new100,
|
||||
new50,
|
||||
misses,
|
||||
max_slider_ticks,
|
||||
max_slider_ends,
|
||||
))
|
||||
.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 = cmp::min(n300, 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 + 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(misses);
|
||||
|
||||
let max_combo = self.combo.map_or(max_possible_combo, |combo| {
|
||||
cmp::min(combo, max_possible_combo)
|
||||
});
|
||||
|
||||
self.combo = Some(max_combo);
|
||||
self.slider_end_hits = Some(n_slider_ends);
|
||||
self.slider_tick_hits = Some(n_slider_ticks);
|
||||
self.n300 = Some(n300);
|
||||
self.n100 = Some(n100);
|
||||
self.n50 = Some(n50);
|
||||
self.misses = Some(misses);
|
||||
|
||||
OsuScoreState {
|
||||
max_combo,
|
||||
slider_tick_hits: n_slider_ticks,
|
||||
slider_end_hits: n_slider_ends,
|
||||
n300,
|
||||
n100,
|
||||
n50,
|
||||
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.difficulty.with_mode().calculate(map),
|
||||
MapOrAttrs::Attrs(attrs) => attrs,
|
||||
};
|
||||
|
||||
let effective_miss_count = calculate_effective_misses(&attrs, &state);
|
||||
|
||||
let lazer = self.lazer.unwrap_or(true);
|
||||
|
||||
let (n_slider_ends, n_slider_ticks) = if lazer {
|
||||
(attrs.n_sliders, attrs.n_slider_ticks)
|
||||
} else {
|
||||
(0, 0)
|
||||
};
|
||||
|
||||
let inner = OsuPerformanceInner {
|
||||
attrs,
|
||||
mods: self.difficulty.get_mods(),
|
||||
acc: state.accuracy(n_slider_ticks, n_slider_ends),
|
||||
state,
|
||||
effective_miss_count,
|
||||
lazer,
|
||||
};
|
||||
|
||||
inner.calculate()
|
||||
}
|
||||
|
||||
pub(crate) const fn from_map_or_attrs(map_or_attrs: MapOrAttrs<'map, OsuRelax>) -> Self {
|
||||
Self {
|
||||
map_or_attrs,
|
||||
difficulty: Difficulty::new(),
|
||||
acc: None,
|
||||
combo: None,
|
||||
slider_tick_hits: None,
|
||||
slider_end_hits: None,
|
||||
n300: None,
|
||||
n100: None,
|
||||
n50: None,
|
||||
misses: None,
|
||||
hitresult_priority: HitResultPriority::DEFAULT,
|
||||
lazer: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'map, T: IntoModePerformance<'map, OsuRelax>> From<T> for OsuPerformance<'map> {
|
||||
fn from(into: T) -> Self {
|
||||
into.into_performance()
|
||||
}
|
||||
}
|
||||
|
||||
// * This is being adjusted to keep the final pp value scaled around what it used to be when changing things.
|
||||
pub const PERFORMANCE_BASE_MULTIPLIER: f64 = 1.09;
|
||||
|
||||
struct OsuPerformanceInner<'mods> {
|
||||
attrs: OsuDifficultyAttributes,
|
||||
mods: &'mods GameMods,
|
||||
acc: f64,
|
||||
state: OsuScoreState,
|
||||
effective_miss_count: f64,
|
||||
lazer: bool,
|
||||
}
|
||||
|
||||
impl OsuPerformanceInner<'_> {
|
||||
fn calculate(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;
|
||||
|
||||
// SO penalty
|
||||
if self.mods.so() {
|
||||
multiplier *= 1.0 - (self.attrs.n_spinners as f64 / total_hits).powf(0.85);
|
||||
}
|
||||
|
||||
let mut aim_value = self.compute_aim_value(total_hits);
|
||||
let speed_value = self.compute_speed_value(total_hits);
|
||||
let acc_value = self.compute_accuracy_value(total_hits);
|
||||
|
||||
let mut acc_depression = 1.0;
|
||||
|
||||
let streams_nerf =
|
||||
((self.attrs.aim_strain / self.attrs.speed_strain) * 100.0).round() / 100.0;
|
||||
|
||||
if streams_nerf < 1.09 {
|
||||
let acc_factor = (1.0 - self.acc).abs();
|
||||
acc_depression = (0.86 - acc_factor).max(0.5);
|
||||
|
||||
if acc_depression > 0.0 {
|
||||
aim_value *= acc_depression;
|
||||
}
|
||||
}
|
||||
|
||||
let nodt_bonus = match !(self.mods.dt() || self.mods.nc() || self.mods.ht()) {
|
||||
true => 1.02,
|
||||
false => 1.0,
|
||||
};
|
||||
|
||||
let mut pp = (aim_value.powf(1.185 * nodt_bonus)
|
||||
+ speed_value.powf(0.83 * acc_depression)
|
||||
+ acc_value.powf(1.14 * nodt_bonus))
|
||||
.powf(1.0 / 1.1)
|
||||
* multiplier;
|
||||
|
||||
if self.mods.dt() && self.mods.hr() {
|
||||
pp *= 1.025;
|
||||
}
|
||||
|
||||
if self.attrs.beatmap_creator == "gwb" || self.attrs.beatmap_creator == "Plasma" {
|
||||
pp *= 0.9;
|
||||
}
|
||||
|
||||
pp *= match self.attrs.beatmap_id {
|
||||
// Louder than steel [ok this is epic]
|
||||
1808605 => 0.85,
|
||||
|
||||
// over the top [Above the stars]
|
||||
1821147 => 0.70,
|
||||
|
||||
// Just press F [Parkour's ok this is epic]
|
||||
1844776 => 0.64,
|
||||
|
||||
// Hardware Store [skyapple mode]
|
||||
1777768 => 0.90,
|
||||
|
||||
// Akatsuki compilation [ok this is akatsuki]
|
||||
1962833 => {
|
||||
pp *= 0.885;
|
||||
|
||||
if self.mods.dt() {
|
||||
0.83
|
||||
} else {
|
||||
1.0
|
||||
}
|
||||
}
|
||||
|
||||
// Songs Compilation [Marathon]
|
||||
2403677 => 0.85,
|
||||
|
||||
// Songs Compilation [Remembrance]
|
||||
2174272 => 0.85,
|
||||
|
||||
// Apocalypse 1992 [Universal Annihilation]
|
||||
2382377 => 0.85,
|
||||
|
||||
_ => 1.0,
|
||||
};
|
||||
|
||||
OsuPerformanceAttributes {
|
||||
difficulty: self.attrs,
|
||||
pp_aim: aim_value,
|
||||
pp_speed: speed_value,
|
||||
pp_acc: acc_value,
|
||||
pp: pp,
|
||||
effective_miss_count: self.effective_miss_count,
|
||||
}
|
||||
}
|
||||
|
||||
fn compute_aim_value(&self, total_hits: f64) -> f64 {
|
||||
// TD penalty
|
||||
let raw_aim = if self.mods.td() {
|
||||
self.attrs.aim_strain.powf(0.8)
|
||||
} else {
|
||||
self.attrs.aim_strain
|
||||
};
|
||||
|
||||
let mut aim_value = (5.0 * (raw_aim / 0.0675).max(1.0) - 4.0).powi(3) / 100_000.0;
|
||||
|
||||
// Longer maps are worth more
|
||||
let len_bonus = 0.88
|
||||
+ 0.4 * (total_hits / 2000.0).min(1.0)
|
||||
+ (total_hits > 2000.0) as u8 as f64 * 0.5 * (total_hits / 2000.0).log10();
|
||||
aim_value *= len_bonus;
|
||||
|
||||
// Penalize misses
|
||||
if self.effective_miss_count > 0.0 {
|
||||
let miss_penalty = self.calculate_miss_penalty(self.effective_miss_count, total_hits);
|
||||
aim_value *= miss_penalty;
|
||||
}
|
||||
|
||||
// AR bonus
|
||||
let mut ar_factor = if self.attrs.ar > 10.33 {
|
||||
0.3 * (self.attrs.ar - 10.33)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
if self.attrs.ar < 8.0 {
|
||||
ar_factor = 0.025 * (8.0 - self.attrs.ar);
|
||||
}
|
||||
|
||||
aim_value *= 1.0 + ar_factor * len_bonus;
|
||||
|
||||
// HD bonus
|
||||
if self.mods.hd() {
|
||||
aim_value *= 1.0 + 0.05 * (11.0 - self.attrs.ar) as f64;
|
||||
}
|
||||
|
||||
// FL bonus
|
||||
if self.mods.fl() {
|
||||
aim_value *= 1.0
|
||||
+ 0.3 * (total_hits / 200.0).min(1.0)
|
||||
+ (total_hits > 200.0) as u8 as f64
|
||||
* 0.25
|
||||
* ((total_hits - 200.0) / 300.0).min(1.0)
|
||||
+ (total_hits > 500.0) as u8 as f64 * (total_hits - 500.0) / 1600.0;
|
||||
}
|
||||
|
||||
// EZ bonus
|
||||
if self.mods.ez() {
|
||||
let mut base_buff = 1.08_f64;
|
||||
|
||||
if self.attrs.ar <= 8.0 {
|
||||
base_buff += (7.0 - self.attrs.ar as f64) / 100.0;
|
||||
}
|
||||
|
||||
aim_value *= base_buff;
|
||||
}
|
||||
|
||||
// Scale with accuracy
|
||||
aim_value *= 0.3 + self.acc / 2.0;
|
||||
aim_value *= 0.98 + self.attrs.od * self.attrs.od / 2500.0;
|
||||
|
||||
aim_value
|
||||
}
|
||||
|
||||
fn compute_speed_value(&self, total_hits: f64) -> f64 {
|
||||
let mut speed_value =
|
||||
(5.0 * (self.attrs.speed_strain / 0.0675).max(1.0) - 4.0).powi(3) / 100_000.0;
|
||||
|
||||
// Longer maps are worth more
|
||||
let len_bonus = 0.88
|
||||
+ 0.4 * (total_hits / 2000.0).min(1.0)
|
||||
+ (total_hits > 2000.0) as u8 as f64 * 0.5 * (total_hits / 2000.0).log10();
|
||||
speed_value *= len_bonus;
|
||||
|
||||
// Penalize misses
|
||||
if self.effective_miss_count > 0.0 {
|
||||
let miss_penalty = self.calculate_miss_penalty(self.effective_miss_count, total_hits);
|
||||
speed_value *= miss_penalty;
|
||||
}
|
||||
|
||||
// AR bonus
|
||||
let mut ar_factor = if self.attrs.ar > 10.33 {
|
||||
0.3 * (self.attrs.ar - 10.33)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
if self.attrs.ar < 8.0 {
|
||||
ar_factor = 0.025 * (8.0 - self.attrs.ar);
|
||||
}
|
||||
|
||||
speed_value *= 1.0 + ar_factor * len_bonus;
|
||||
|
||||
// HD bonus
|
||||
if self.mods.hd() {
|
||||
speed_value *= 1.0 + 0.05 * (11.0 - self.attrs.ar) as f64;
|
||||
}
|
||||
|
||||
// Scaling the speed value with accuracy and OD
|
||||
speed_value *= (0.93 + self.attrs.od * self.attrs.od / 750.0)
|
||||
* self
|
||||
.acc
|
||||
.powf((14.5 - self.attrs.od.max(8.0)) / 2.0);
|
||||
|
||||
speed_value *= 0.98_f64.powf(match (self.state.n50 as f64) < total_hits / 500.0 {
|
||||
true => 0.0,
|
||||
false => self.state.n50 as f64 - total_hits / 500.0,
|
||||
});
|
||||
|
||||
speed_value
|
||||
}
|
||||
|
||||
fn compute_accuracy_value(&self, total_hits: f64) -> f64 {
|
||||
let n_circles = self.attrs.n_circles as f64;
|
||||
let n300 = self.state.n300 as f64;
|
||||
let n100 = self.state.n100 as f64;
|
||||
let n50 = self.state.n50 as f64;
|
||||
|
||||
let better_acc_percentage = (n_circles > 0.0) as u8 as f64
|
||||
* (((n300 - (total_hits - n_circles)) * 6.0 + n100 * 2.0 + n50) / (n_circles * 6.0))
|
||||
.max(0.0);
|
||||
|
||||
let mut acc_value =
|
||||
1.52163_f64.powf(self.attrs.od) * better_acc_percentage.powi(24) * 2.83;
|
||||
|
||||
// Bonus for many hitcircles
|
||||
acc_value *= ((n_circles / 1000.0).powf(0.3)).min(1.15);
|
||||
|
||||
// HD bonus
|
||||
if self.mods.hd() {
|
||||
acc_value *= 1.08;
|
||||
}
|
||||
|
||||
// FL bonus
|
||||
if self.mods.fl() {
|
||||
acc_value *= 1.02;
|
||||
}
|
||||
|
||||
acc_value
|
||||
}
|
||||
|
||||
fn calculate_miss_penalty(&self, effective_miss_count: f64, total_hits: f64) -> f64 {
|
||||
0.97 * (1.0 - (effective_miss_count / total_hits).powf(0.5))
|
||||
.powf(1.0 + (effective_miss_count / 1.5))
|
||||
}
|
||||
|
||||
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.misses));
|
||||
|
||||
combo_based_miss_count.max(f64::from(state.misses))
|
||||
}
|
||||
|
||||
fn accuracy(
|
||||
n_slider_ticks: u32,
|
||||
n_slider_ends: u32,
|
||||
n300: u32,
|
||||
n100: u32,
|
||||
n50: u32,
|
||||
misses: u32,
|
||||
max_slider_ticks: u32,
|
||||
max_slider_ends: u32,
|
||||
) -> f64 {
|
||||
if n_slider_ticks + n_slider_ends + n300 + n100 + n50 + misses == 0 {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
let numerator = 300 * n300 + 100 * n100 + 50 * n50 + 150 * n_slider_ends + 30 * n_slider_ticks;
|
||||
|
||||
let denominator =
|
||||
300 * (n300 + n100 + n50 + misses) + 150 * max_slider_ends + 30 * max_slider_ticks;
|
||||
|
||||
f64::from(numerator) / f64::from(denominator)
|
||||
}
|
||||
@@ -1,547 +0,0 @@
|
||||
use super::stars::{stars, OsuDifficultyAttributes, OsuPerformanceAttributes};
|
||||
use crate::{Beatmap, GameMods};
|
||||
|
||||
/// Calculator for pp on osu!standard maps.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// # use rosu_pp::{OsuPP, Beatmap};
|
||||
/// # /*
|
||||
/// let map: Beatmap = ...
|
||||
/// # */
|
||||
/// # let map = Beatmap::default();
|
||||
/// let attrs = OsuPP::new(&map)
|
||||
/// .mods(8 + 64) // HDDT
|
||||
/// .combo(1234)
|
||||
/// .misses(1)
|
||||
/// .accuracy(98.5) // should be set last
|
||||
/// .calculate();
|
||||
///
|
||||
/// println!("PP: {} | Stars: {}", attrs.pp(), attrs.stars());
|
||||
///
|
||||
/// let next_result = OsuPP::new(&map)
|
||||
/// .attributes(attrs) // reusing previous results for performance
|
||||
/// .mods(8 + 64) // has to be the same to reuse attributes
|
||||
/// .accuracy(99.5)
|
||||
/// .calculate();
|
||||
///
|
||||
/// println!("PP: {} | Stars: {}", next_result.pp(), next_result.stars());
|
||||
/// ```
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct OsuPP<'m> {
|
||||
map: Option<&'m Beatmap>,
|
||||
attributes: Option<OsuDifficultyAttributes>,
|
||||
mods: GameMods,
|
||||
combo: Option<u32>,
|
||||
acc: Option<f32>,
|
||||
|
||||
n300: Option<u32>,
|
||||
n100: Option<u32>,
|
||||
n50: Option<u32>,
|
||||
n_misses: u32,
|
||||
}
|
||||
|
||||
impl<'m> OsuPP<'m> {
|
||||
/// Creates a new calculator for the given map.
|
||||
#[inline]
|
||||
pub fn from_map(map: &'m Beatmap) -> Self {
|
||||
Self {
|
||||
map: Some(map),
|
||||
attributes: None,
|
||||
mods: GameMods::default(),
|
||||
combo: None,
|
||||
acc: None,
|
||||
n300: None,
|
||||
n100: None,
|
||||
n50: None,
|
||||
n_misses: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a new calculator for the given attributes.
|
||||
#[inline]
|
||||
pub fn from_attributes(attributes: OsuDifficultyAttributes) -> Self {
|
||||
Self {
|
||||
map: None,
|
||||
attributes: Some(attributes),
|
||||
mods: GameMods::default(),
|
||||
combo: None,
|
||||
acc: None,
|
||||
n300: None,
|
||||
n100: None,
|
||||
n50: None,
|
||||
n_misses: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Specify mods through their bit values.
|
||||
///
|
||||
/// See [https://github.com/ppy/osu-api/wiki#mods](https://github.com/ppy/osu-api/wiki#mods)
|
||||
#[inline]
|
||||
pub fn mods(mut self, mods: impl Into<GameMods>) -> Self {
|
||||
self.mods = mods.into();
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
/// Specify the max combo of the play.
|
||||
#[inline]
|
||||
pub fn combo(mut self, combo: u32) -> Self {
|
||||
self.combo = Some(combo);
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
/// Specify the amount of 300s of a play.
|
||||
#[inline]
|
||||
pub fn n300(mut self, n300: u32) -> Self {
|
||||
self.n300 = Some(n300);
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
/// Specify the amount of 100s of a play.
|
||||
#[inline]
|
||||
pub fn n100(mut self, n100: u32) -> Self {
|
||||
self.n100 = Some(n100);
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
/// Specify the amount of 50s of a play.
|
||||
#[inline]
|
||||
pub fn n50(mut self, n50: u32) -> Self {
|
||||
self.n50 = Some(n50);
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
/// Specify the amount of misses of a play.
|
||||
#[inline]
|
||||
pub fn misses(mut self, n_misses: u32) -> Self {
|
||||
self.n_misses = n_misses;
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
/// Generate the hit results with respect to the given accuracy between `0` and `100`.
|
||||
///
|
||||
/// Be sure to set `misses` beforehand!
|
||||
pub fn accuracy(mut self, acc: f32) -> Self {
|
||||
let n_objects = self.n_objects();
|
||||
|
||||
let acc = acc / 100.0;
|
||||
|
||||
if self.n100.or(self.n50).is_some() {
|
||||
let mut n100 = self.n100.unwrap_or(0);
|
||||
let mut n50 = self.n50.unwrap_or(0);
|
||||
|
||||
let placed_points = 2 * n100 + n50 + self.n_misses;
|
||||
let missing_objects = n_objects - n100 - n50 - self.n_misses;
|
||||
let missing_points =
|
||||
((6.0 * acc * n_objects as f32).round() as u32).saturating_sub(placed_points);
|
||||
|
||||
let mut n300 = missing_objects.min(missing_points / 6);
|
||||
n50 += missing_objects - n300;
|
||||
|
||||
if let Some(orig_n50) = self.n50.filter(|_| self.n100.is_none()) {
|
||||
// Only n50s were changed, try to load some off again onto n100s
|
||||
let difference = n50 - orig_n50;
|
||||
let n = n300.min(difference / 4);
|
||||
|
||||
n300 -= n;
|
||||
n100 += 5 * n;
|
||||
n50 -= 4 * n;
|
||||
}
|
||||
|
||||
self.n300.replace(n300);
|
||||
self.n100.replace(n100);
|
||||
self.n50.replace(n50);
|
||||
} else {
|
||||
let misses = self.n_misses.min(n_objects);
|
||||
let target_total = (acc * n_objects as f32 * 6.0).round() as u32;
|
||||
let delta = target_total - (n_objects - misses);
|
||||
|
||||
let mut n300 = delta / 5;
|
||||
let mut n100 = delta % 5;
|
||||
let mut n50 = n_objects - n300 - n100 - misses;
|
||||
|
||||
// Sacrifice n300s to transform n50s into n100s
|
||||
let n = n300.min(n50 / 4);
|
||||
n300 -= n;
|
||||
n100 += 5 * n;
|
||||
n50 -= 4 * n;
|
||||
|
||||
self.n300.replace(n300);
|
||||
self.n100.replace(n100);
|
||||
self.n50.replace(n50);
|
||||
}
|
||||
|
||||
let acc = (6 * self.n300.unwrap() + 2 * self.n100.unwrap() + self.n50.unwrap()) as f32
|
||||
/ (6 * n_objects) as f32;
|
||||
|
||||
self.acc.replace(acc);
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
fn assert_hitresults(&mut self) {
|
||||
if self.acc.is_none() {
|
||||
let n_objects = self.n_objects();
|
||||
|
||||
let remaining = n_objects
|
||||
.saturating_sub(self.n300.unwrap_or(0))
|
||||
.saturating_sub(self.n100.unwrap_or(0))
|
||||
.saturating_sub(self.n50.unwrap_or(0))
|
||||
.saturating_sub(self.n_misses);
|
||||
|
||||
if remaining > 0 {
|
||||
if self.n300.is_none() {
|
||||
self.n300.replace(remaining);
|
||||
self.n100.get_or_insert(0);
|
||||
self.n50.get_or_insert(0);
|
||||
} else if self.n100.is_none() {
|
||||
self.n100.replace(remaining);
|
||||
self.n50.get_or_insert(0);
|
||||
} else if self.n50.is_none() {
|
||||
self.n50.replace(remaining);
|
||||
} else {
|
||||
*self.n300.as_mut().unwrap() += remaining;
|
||||
}
|
||||
} else {
|
||||
self.n300.get_or_insert(0);
|
||||
self.n100.get_or_insert(0);
|
||||
self.n50.get_or_insert(0);
|
||||
}
|
||||
|
||||
let numerator = self.n50.unwrap() + self.n100.unwrap() * 2 + self.n300.unwrap() * 6;
|
||||
self.acc.replace(numerator as f32 / n_objects as f32 / 6.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns an object which contains the pp and [`DifficultyAttributes`](crate::osu::DifficultyAttributes)
|
||||
/// containing stars and other attributes.
|
||||
pub fn calculate(mut self) -> OsuPerformanceAttributes {
|
||||
if self.attributes.is_none() {
|
||||
let attributes = stars(self.map.unwrap(), self.mods.clone());
|
||||
self.attributes.replace(attributes);
|
||||
}
|
||||
|
||||
// Make sure the hitresults and accuracy are set
|
||||
self.assert_hitresults();
|
||||
|
||||
let total_hits = self.total_hits() as f32;
|
||||
let mut multiplier = 1.09;
|
||||
|
||||
let effective_miss_count = self.calculate_effective_miss_count();
|
||||
|
||||
// SO penalty
|
||||
if self.mods.so() {
|
||||
multiplier *=
|
||||
1.0 - (self.attributes.as_ref().unwrap().n_spinners as f32 / total_hits).powf(0.85);
|
||||
}
|
||||
|
||||
let mut aim_value = self.compute_aim_value(total_hits, effective_miss_count);
|
||||
let speed_value = self.compute_speed_value(total_hits, effective_miss_count);
|
||||
let acc_value = self.compute_accuracy_value(total_hits);
|
||||
|
||||
let mut acc_depression = 1.0;
|
||||
|
||||
let difficulty = self.attributes.as_ref().unwrap();
|
||||
let streams_nerf =
|
||||
((difficulty.aim_strain / difficulty.speed_strain) * 100.0).round() / 100.0;
|
||||
|
||||
if streams_nerf < 1.09 {
|
||||
let acc_factor = (1.0 - self.acc.unwrap()).abs();
|
||||
acc_depression = (0.86 - acc_factor).max(0.5);
|
||||
|
||||
if acc_depression > 0.0 {
|
||||
aim_value *= acc_depression;
|
||||
}
|
||||
}
|
||||
|
||||
let nodt_bonus = match !(self.mods.dt() || self.mods.nc() || self.mods.ht()) {
|
||||
true => 1.02,
|
||||
false => 1.0,
|
||||
};
|
||||
|
||||
let mut pp = (aim_value.powf(1.185 * nodt_bonus)
|
||||
+ speed_value.powf(0.83 * acc_depression)
|
||||
+ acc_value.powf(1.14 * nodt_bonus))
|
||||
.powf(1.0 / 1.1)
|
||||
* multiplier;
|
||||
|
||||
if self.mods.dt() && self.mods.hr() {
|
||||
pp *= 1.025;
|
||||
}
|
||||
|
||||
if difficulty.beatmap_creator == "gwb" || difficulty.beatmap_creator == "Plasma" {
|
||||
pp *= 0.9;
|
||||
}
|
||||
|
||||
pp *= match difficulty.beatmap_id {
|
||||
// Louder than steel [ok this is epic]
|
||||
1808605 => 0.85,
|
||||
|
||||
// over the top [Above the stars]
|
||||
1821147 => 0.70,
|
||||
|
||||
// Just press F [Parkour's ok this is epic]
|
||||
1844776 => 0.64,
|
||||
|
||||
// Hardware Store [skyapple mode]
|
||||
1777768 => 0.90,
|
||||
|
||||
// Akatsuki compilation [ok this is akatsuki]
|
||||
1962833 => {
|
||||
pp *= 0.885;
|
||||
|
||||
if self.mods.dt() {
|
||||
0.83
|
||||
} else {
|
||||
1.0
|
||||
}
|
||||
}
|
||||
|
||||
// Songs Compilation [Marathon]
|
||||
2403677 => 0.85,
|
||||
|
||||
// Songs Compilation [Remembrance]
|
||||
2174272 => 0.85,
|
||||
|
||||
// Apocalypse 1992 [Universal Annihilation]
|
||||
2382377 => 0.85,
|
||||
|
||||
_ => 1.0,
|
||||
};
|
||||
|
||||
OsuPerformanceAttributes {
|
||||
difficulty: self.attributes.unwrap(),
|
||||
pp_aim: aim_value as f64,
|
||||
pp_speed: speed_value as f64,
|
||||
pp_acc: acc_value as f64,
|
||||
pp: pp as f64,
|
||||
effective_miss_count: effective_miss_count as f64,
|
||||
}
|
||||
}
|
||||
|
||||
fn compute_aim_value(&self, total_hits: f32, effective_miss_count: f32) -> f32 {
|
||||
let attributes = self.attributes.as_ref().unwrap();
|
||||
|
||||
// TD penalty
|
||||
let raw_aim = if self.mods.td() {
|
||||
attributes.aim_strain.powf(0.8) as f32
|
||||
} else {
|
||||
attributes.aim_strain as f32
|
||||
};
|
||||
|
||||
let mut aim_value = (5.0 * (raw_aim / 0.0675).max(1.0) - 4.0).powi(3) / 100_000.0;
|
||||
|
||||
// Longer maps are worth more
|
||||
let len_bonus = 0.88
|
||||
+ 0.4 * (total_hits / 2000.0).min(1.0)
|
||||
+ (total_hits > 2000.0) as u8 as f32 * 0.5 * (total_hits / 2000.0).log10();
|
||||
aim_value *= len_bonus;
|
||||
|
||||
// Penalize misses
|
||||
if effective_miss_count > 0.0 {
|
||||
let miss_penalty = self.calculate_miss_penalty(effective_miss_count);
|
||||
aim_value *= miss_penalty;
|
||||
}
|
||||
|
||||
// AR bonus
|
||||
let mut ar_factor = if attributes.ar > 10.33 {
|
||||
0.3 * (attributes.ar - 10.33)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
if attributes.ar < 8.0 {
|
||||
ar_factor = 0.025 * (8.0 - attributes.ar);
|
||||
}
|
||||
|
||||
aim_value *= 1.0 + ar_factor as f32 * len_bonus;
|
||||
|
||||
// HD bonus
|
||||
if self.mods.hd() {
|
||||
aim_value *= 1.0 + 0.05 * (11.0 - attributes.ar) as f32;
|
||||
}
|
||||
|
||||
// FL bonus
|
||||
if self.mods.fl() {
|
||||
aim_value *= 1.0
|
||||
+ 0.3 * (total_hits / 200.0).min(1.0)
|
||||
+ (total_hits > 200.0) as u8 as f32
|
||||
* 0.25
|
||||
* ((total_hits - 200.0) / 300.0).min(1.0)
|
||||
+ (total_hits > 500.0) as u8 as f32 * (total_hits - 500.0) / 1600.0;
|
||||
}
|
||||
|
||||
// EZ bonus
|
||||
if self.mods.ez() {
|
||||
let mut base_buff = 1.08_f32;
|
||||
|
||||
if attributes.ar <= 8.0 {
|
||||
base_buff += (7.0 - attributes.ar as f32) / 100.0;
|
||||
}
|
||||
|
||||
aim_value *= base_buff;
|
||||
}
|
||||
|
||||
// Scale with accuracy
|
||||
aim_value *= 0.3 + self.acc.unwrap() / 2.0;
|
||||
aim_value *= 0.98 + attributes.od as f32 * attributes.od as f32 / 2500.0;
|
||||
|
||||
aim_value
|
||||
}
|
||||
|
||||
fn compute_speed_value(&self, total_hits: f32, effective_miss_count: f32) -> f32 {
|
||||
let attributes = self.attributes.as_ref().unwrap();
|
||||
|
||||
let mut speed_value =
|
||||
(5.0 * (attributes.speed_strain as f32 / 0.0675).max(1.0) - 4.0).powi(3) / 100_000.0;
|
||||
|
||||
// Longer maps are worth more
|
||||
let len_bonus = 0.88
|
||||
+ 0.4 * (total_hits / 2000.0).min(1.0)
|
||||
+ (total_hits > 2000.0) as u8 as f32 * 0.5 * (total_hits / 2000.0).log10();
|
||||
speed_value *= len_bonus;
|
||||
|
||||
// Penalize misses
|
||||
if effective_miss_count > 0.0 {
|
||||
let miss_penalty = self.calculate_miss_penalty(effective_miss_count);
|
||||
speed_value *= miss_penalty;
|
||||
}
|
||||
|
||||
// AR bonus
|
||||
if attributes.ar > 10.33 {
|
||||
let mut ar_factor = if attributes.ar > 10.33 {
|
||||
0.3 * (attributes.ar - 10.33)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
if attributes.ar < 8.0 {
|
||||
ar_factor = 0.025 * (8.0 - attributes.ar);
|
||||
}
|
||||
|
||||
speed_value *= 1.0 + ar_factor as f32 * len_bonus;
|
||||
}
|
||||
|
||||
// HD bonus
|
||||
if self.mods.hd() {
|
||||
speed_value *= 1.0 + 0.05 * (11.0 - attributes.ar) as f32;
|
||||
}
|
||||
|
||||
// Scaling the speed value with accuracy and OD
|
||||
speed_value *= (0.93 + attributes.od as f32 * attributes.od as f32 / 750.0)
|
||||
* self
|
||||
.acc
|
||||
.unwrap()
|
||||
.powf((14.5 - attributes.od.max(8.0) as f32) / 2.0);
|
||||
|
||||
speed_value *= 0.98_f32.powf(match (self.n50.unwrap() as f32) < total_hits / 500.0 {
|
||||
true => 0.0,
|
||||
false => self.n50.unwrap() as f32 - total_hits / 500.0,
|
||||
});
|
||||
|
||||
speed_value
|
||||
}
|
||||
|
||||
fn compute_accuracy_value(&self, total_hits: f32) -> f32 {
|
||||
let attributes = self.attributes.as_ref().unwrap();
|
||||
let n_circles = attributes.n_circles as f32;
|
||||
let n300 = self.n300.unwrap_or(0) as f32;
|
||||
let n100 = self.n100.unwrap_or(0) as f32;
|
||||
let n50 = self.n50.unwrap_or(0) as f32;
|
||||
|
||||
let better_acc_percentage = (n_circles > 0.0) as u8 as f32
|
||||
* (((n300 - (total_hits - n_circles)) * 6.0 + n100 * 2.0 + n50) / (n_circles * 6.0))
|
||||
.max(0.0);
|
||||
|
||||
let mut acc_value =
|
||||
1.52163_f32.powf(attributes.od as f32) * better_acc_percentage.powi(24) * 2.83;
|
||||
|
||||
// Bonus for many hitcircles
|
||||
acc_value *= ((n_circles as f32 / 1000.0).powf(0.3)).min(1.15);
|
||||
|
||||
// HD bonus
|
||||
if self.mods.hd() {
|
||||
acc_value *= 1.08;
|
||||
}
|
||||
|
||||
// FL bonus
|
||||
if self.mods.fl() {
|
||||
acc_value *= 1.02;
|
||||
}
|
||||
|
||||
acc_value
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn total_hits(&self) -> u32 {
|
||||
let n_objects = self.n_objects();
|
||||
|
||||
(self.n300.unwrap_or(0) + self.n100.unwrap_or(0) + self.n50.unwrap_or(0) + self.n_misses)
|
||||
.min(n_objects)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn calculate_miss_penalty(&self, effective_miss_count: f32) -> f32 {
|
||||
let total_hits = self.total_hits() as f32;
|
||||
|
||||
0.97 * (1.0 - (effective_miss_count / total_hits).powf(0.5))
|
||||
.powf(1.0 + (effective_miss_count / 1.5))
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn calculate_effective_miss_count(&self) -> f32 {
|
||||
let mut combo_based_miss_count = 0.0;
|
||||
|
||||
let attributes = self.attributes.as_ref().unwrap();
|
||||
let combo = self.combo.unwrap_or(attributes.max_combo as u32) as f32;
|
||||
let n100 = self.n100.unwrap_or(0) as f32;
|
||||
let n50 = self.n50.unwrap_or(0) as f32;
|
||||
|
||||
if attributes.n_sliders > 0 {
|
||||
let fc_threshold = attributes.max_combo as f32 - (0.1 * attributes.n_sliders as f32);
|
||||
if combo < fc_threshold {
|
||||
combo_based_miss_count = fc_threshold / combo.max(1.0);
|
||||
}
|
||||
}
|
||||
|
||||
combo_based_miss_count = combo_based_miss_count.min(n100 + n50 + self.n_misses as f32);
|
||||
combo_based_miss_count.max(self.n_misses as f32)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn n_objects(&self) -> u32 {
|
||||
match self.attributes.as_ref() {
|
||||
Some(attributes) => {
|
||||
(attributes.n_circles + attributes.n_sliders + attributes.n_spinners) as u32
|
||||
}
|
||||
None => self.map.unwrap().hit_objects.len() as u32,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Provides attributes for an osu! beatmap.
|
||||
pub trait OsuAttributeProvider {
|
||||
/// Returns the attributes of the map.
|
||||
fn attributes(self) -> Option<OsuDifficultyAttributes>;
|
||||
}
|
||||
|
||||
impl OsuAttributeProvider for OsuDifficultyAttributes {
|
||||
#[inline]
|
||||
fn attributes(self) -> Option<OsuDifficultyAttributes> {
|
||||
Some(self)
|
||||
}
|
||||
}
|
||||
|
||||
impl OsuAttributeProvider for OsuPerformanceAttributes {
|
||||
#[inline]
|
||||
fn attributes(self) -> Option<OsuDifficultyAttributes> {
|
||||
Some(self.difficulty)
|
||||
}
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
use super::{DifficultyObject, SkillKind};
|
||||
|
||||
use std::cmp::Ordering;
|
||||
|
||||
const SPEED_SKILL_MULTIPLIER: f32 = 1400.0;
|
||||
const SPEED_STRAIN_DECAY_BASE: f32 = 0.3;
|
||||
|
||||
const AIM_SKILL_MULTIPLIER: f32 = 26.25;
|
||||
const AIM_STRAIN_DECAY_BASE: f32 = 0.15;
|
||||
|
||||
const DECAY_WEIGHT: f32 = 0.9;
|
||||
|
||||
pub(crate) struct Skill {
|
||||
current_strain: f32,
|
||||
current_section_peak: f32,
|
||||
|
||||
kind: SkillKind,
|
||||
pub(crate) strain_peaks: Vec<f32>,
|
||||
|
||||
prev_time: Option<f32>,
|
||||
pub(crate) object_strains: Vec<f32>,
|
||||
}
|
||||
|
||||
impl Skill {
|
||||
#[inline]
|
||||
pub(crate) fn new(kind: SkillKind) -> Self {
|
||||
Self {
|
||||
current_strain: 1.0,
|
||||
current_section_peak: 1.0,
|
||||
|
||||
kind,
|
||||
strain_peaks: Vec::with_capacity(128),
|
||||
|
||||
prev_time: None,
|
||||
object_strains: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn save_current_peak(&mut self) {
|
||||
self.strain_peaks.push(self.current_section_peak);
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn start_new_section_from(&mut self, time: f32) {
|
||||
self.current_section_peak = self.peak_strain(time - self.prev_time.unwrap());
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn process(&mut self, current: &DifficultyObject<'_>) {
|
||||
self.current_strain *= self.strain_decay(current.delta);
|
||||
self.current_strain += self.kind.strain_value_of(current) * self.skill_multiplier();
|
||||
|
||||
self.object_strains.push(self.current_strain);
|
||||
|
||||
self.current_section_peak = self.current_section_peak.max(self.current_strain);
|
||||
self.prev_time.replace(current.base.time);
|
||||
}
|
||||
|
||||
pub(crate) fn difficulty_value(&mut self) -> f32 {
|
||||
let mut difficulty = 0.0;
|
||||
let mut weight = 1.0;
|
||||
|
||||
self.strain_peaks
|
||||
.sort_unstable_by(|a, b| b.partial_cmp(a).unwrap_or(Ordering::Equal));
|
||||
|
||||
for &strain in self.strain_peaks.iter() {
|
||||
difficulty += strain * weight;
|
||||
weight *= DECAY_WEIGHT;
|
||||
}
|
||||
|
||||
difficulty
|
||||
}
|
||||
|
||||
pub(crate) fn count_difficult_strains(&mut self) -> f64 {
|
||||
let top_strain = self
|
||||
.object_strains
|
||||
.iter()
|
||||
.fold(f64::NEG_INFINITY, |prev, curr| prev.max(*curr as f64));
|
||||
|
||||
self.object_strains
|
||||
.iter()
|
||||
.map(|strain| (strain / top_strain as f32).powi(4))
|
||||
.sum::<f32>() as f64
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn skill_multiplier(&self) -> f32 {
|
||||
match self.kind {
|
||||
SkillKind::Aim => AIM_SKILL_MULTIPLIER,
|
||||
SkillKind::Speed => SPEED_SKILL_MULTIPLIER,
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn strain_decay_base(&self) -> f32 {
|
||||
match self.kind {
|
||||
SkillKind::Aim => AIM_STRAIN_DECAY_BASE,
|
||||
SkillKind::Speed => SPEED_STRAIN_DECAY_BASE,
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn peak_strain(&self, delta_time: f32) -> f32 {
|
||||
self.current_strain * self.strain_decay(delta_time)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn strain_decay(&self, ms: f32) -> f32 {
|
||||
self.strain_decay_base().powf(ms / 1000.0)
|
||||
}
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
use super::DifficultyObject;
|
||||
|
||||
const SINGLE_SPACING_TRESHOLD: f32 = 125.0;
|
||||
const SPEED_ANGLE_BONUS_BEGIN: f32 = 5.0 * std::f32::consts::FRAC_PI_6;
|
||||
const PI_OVER_4: f32 = std::f32::consts::FRAC_PI_4;
|
||||
const PI_OVER_2: f32 = std::f32::consts::FRAC_PI_2;
|
||||
|
||||
const MIN_SPEED_BONUS: f32 = 75.0;
|
||||
const MAX_SPEED_BONUS: f32 = 45.0;
|
||||
const SPEED_BALANCING_FACTOR: f32 = 40.0;
|
||||
|
||||
const AIM_ANGLE_BONUS_BEGIN: f32 = std::f32::consts::FRAC_PI_3;
|
||||
const TIMING_THRESHOLD: f32 = 107.0;
|
||||
|
||||
#[derive(Copy, Clone)]
|
||||
pub(crate) enum SkillKind {
|
||||
Aim,
|
||||
Speed,
|
||||
}
|
||||
|
||||
impl SkillKind {
|
||||
pub(crate) fn strain_value_of(self, current: &DifficultyObject<'_>) -> f32 {
|
||||
match self {
|
||||
Self::Aim => {
|
||||
if current.base.is_spinner() {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
let mut result = 0.0;
|
||||
|
||||
if let Some((prev_jump_dist, prev_strain_time)) = current.prev {
|
||||
if let Some(angle) = current.angle.filter(|a| *a > AIM_ANGLE_BONUS_BEGIN) {
|
||||
let scale = 90.0;
|
||||
|
||||
let angle_bonus = (((angle - AIM_ANGLE_BONUS_BEGIN).sin()).powi(2)
|
||||
* (prev_jump_dist - scale).max(0.0)
|
||||
* (current.jump_dist - scale).max(0.0))
|
||||
.sqrt();
|
||||
|
||||
result = 1.5 * apply_diminishing_exp(angle_bonus.max(0.0))
|
||||
/ (TIMING_THRESHOLD).max(prev_strain_time)
|
||||
}
|
||||
}
|
||||
|
||||
let jump_dist_exp = apply_diminishing_exp(current.jump_dist);
|
||||
let travel_dist_exp = apply_diminishing_exp(current.travel_dist);
|
||||
|
||||
let dist_exp =
|
||||
jump_dist_exp + travel_dist_exp + (travel_dist_exp * jump_dist_exp).sqrt();
|
||||
|
||||
(result + dist_exp / (current.strain_time).max(TIMING_THRESHOLD))
|
||||
.max(dist_exp / current.strain_time)
|
||||
}
|
||||
Self::Speed => {
|
||||
if current.base.is_spinner() {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
let dist = SINGLE_SPACING_TRESHOLD.min(current.travel_dist + current.jump_dist);
|
||||
let delta_time = MAX_SPEED_BONUS.max(current.delta);
|
||||
|
||||
let mut speed_bonus = 1.0;
|
||||
|
||||
if delta_time < MIN_SPEED_BONUS {
|
||||
let exp_base = (MIN_SPEED_BONUS - delta_time) / SPEED_BALANCING_FACTOR;
|
||||
speed_bonus += exp_base * exp_base;
|
||||
}
|
||||
|
||||
let mut angle_bonus = 1.0;
|
||||
|
||||
if let Some(angle) = current.angle.filter(|a| *a < SPEED_ANGLE_BONUS_BEGIN) {
|
||||
let exp_base = (1.5 * (SPEED_ANGLE_BONUS_BEGIN - angle)).sin();
|
||||
angle_bonus = 1.0 + exp_base * exp_base / 3.57;
|
||||
|
||||
if angle < PI_OVER_2 {
|
||||
angle_bonus = 1.28;
|
||||
|
||||
if dist < 90.0 && angle < PI_OVER_4 {
|
||||
angle_bonus += (1.0 - angle_bonus) * ((90.0 - dist) / 10.0).min(1.0);
|
||||
} else if dist < 90.0 {
|
||||
angle_bonus += (1.0 - angle_bonus)
|
||||
* ((90.0 - dist) / 10.0).min(1.0)
|
||||
* ((PI_OVER_2 - angle) / PI_OVER_4).sin();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(1.0 + (speed_bonus - 1.0) * 0.75)
|
||||
* angle_bonus
|
||||
* (0.95 + speed_bonus * (dist / SINGLE_SPACING_TRESHOLD).powf(3.5))
|
||||
/ current.strain_time
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn apply_diminishing_exp(val: f32) -> f32 {
|
||||
val.powf(0.99)
|
||||
}
|
||||
@@ -1,173 +0,0 @@
|
||||
//! The positional offset of notes created by stack leniency is not considered.
|
||||
//! This means the jump distance inbetween notes might be slightly off, resulting in small inaccuracies.
|
||||
//! Since calculating these offsets is relatively expensive though, this version is faster than `all_included`.
|
||||
|
||||
use super::{DifficultyObject, OsuObject, Skill, SkillKind};
|
||||
|
||||
use crate::{Beatmap, GameMods};
|
||||
|
||||
use rosu_map::section::hit_objects::CurveBuffers;
|
||||
|
||||
const OBJECT_RADIUS: f32 = 64.0;
|
||||
const SECTION_LEN: f32 = 400.0;
|
||||
const DIFFICULTY_MULTIPLIER: f32 = 0.0675;
|
||||
const NORMALIZED_RADIUS: f32 = 52.0;
|
||||
|
||||
/// Star calculation for osu!standard maps.
|
||||
///
|
||||
/// Slider paths are considered but stack leniency is ignored.
|
||||
/// As most maps don't even make use of leniency and even if,
|
||||
/// it has generally little effect on stars, the results are close to perfect.
|
||||
/// This version is considerably more efficient than `all_included` since
|
||||
/// processing stack leniency is relatively expensive.
|
||||
///
|
||||
/// In case of a partial play, e.g. a fail, one can specify the amount of passed objects.
|
||||
pub fn stars(map: &Beatmap, mods: GameMods) -> OsuDifficultyAttributes {
|
||||
let map_attributes = map.attributes().mods(mods).build();
|
||||
|
||||
let mut diff_attributes = OsuDifficultyAttributes {
|
||||
ar: map_attributes.ar,
|
||||
od: map_attributes.od,
|
||||
cs: map_attributes.cs,
|
||||
beatmap_id: map.beatmap_id,
|
||||
beatmap_creator: map.creator.clone(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
if map.hit_objects.len() < 2 {
|
||||
return diff_attributes;
|
||||
}
|
||||
|
||||
let section_len = SECTION_LEN * map_attributes.clock_rate as f32;
|
||||
let radius = OBJECT_RADIUS * (1.0 - 0.7 * (map_attributes.cs as f32 - 5.0) / 5.0) / 2.0;
|
||||
let mut scaling_factor = NORMALIZED_RADIUS / radius;
|
||||
|
||||
if radius < 30.0 {
|
||||
let small_circle_bonus = (30.0 - radius).min(5.0) / 50.0;
|
||||
scaling_factor *= 1.0 + small_circle_bonus;
|
||||
}
|
||||
|
||||
let mut ticks_buf = Vec::new();
|
||||
let mut curve_bufs = CurveBuffers::default();
|
||||
|
||||
let mut hit_objects = map.hit_objects.iter().filter_map(|h| {
|
||||
Some(OsuObject::new(
|
||||
h,
|
||||
map,
|
||||
radius,
|
||||
scaling_factor,
|
||||
&mut ticks_buf,
|
||||
&mut diff_attributes,
|
||||
&mut curve_bufs,
|
||||
))
|
||||
});
|
||||
|
||||
let mut aim = Skill::new(SkillKind::Aim);
|
||||
let mut speed = Skill::new(SkillKind::Speed);
|
||||
|
||||
// First object has no predecessor and thus no strain, handle distinctly
|
||||
let mut current_section_end =
|
||||
(map.hit_objects[0].start_time as f32 / section_len).ceil() * section_len;
|
||||
|
||||
let mut prev_prev = None;
|
||||
let mut prev = hit_objects.next().unwrap();
|
||||
let mut prev_vals = None;
|
||||
|
||||
// Handle second object separately to remove later if-branching
|
||||
let curr = hit_objects.next().unwrap();
|
||||
let h = DifficultyObject::new(
|
||||
&curr,
|
||||
&prev,
|
||||
prev_vals,
|
||||
prev_prev,
|
||||
map_attributes.clock_rate as f32,
|
||||
scaling_factor,
|
||||
);
|
||||
|
||||
while h.base.time as f32 > current_section_end {
|
||||
current_section_end += section_len;
|
||||
}
|
||||
|
||||
aim.process(&h);
|
||||
speed.process(&h);
|
||||
|
||||
prev_prev = Some(prev);
|
||||
prev_vals = Some((h.jump_dist, h.strain_time));
|
||||
prev = curr;
|
||||
|
||||
// Handle all other objects
|
||||
for curr in hit_objects {
|
||||
let h = DifficultyObject::new(
|
||||
&curr,
|
||||
&prev,
|
||||
prev_vals,
|
||||
prev_prev,
|
||||
map_attributes.clock_rate as f32,
|
||||
scaling_factor,
|
||||
);
|
||||
|
||||
while h.base.time as f32 > current_section_end {
|
||||
aim.save_current_peak();
|
||||
aim.start_new_section_from(current_section_end);
|
||||
speed.save_current_peak();
|
||||
speed.start_new_section_from(current_section_end);
|
||||
|
||||
current_section_end += section_len;
|
||||
}
|
||||
|
||||
aim.process(&h);
|
||||
speed.process(&h);
|
||||
|
||||
prev_prev = Some(prev);
|
||||
prev_vals = Some((h.jump_dist, h.strain_time));
|
||||
prev = curr;
|
||||
}
|
||||
|
||||
aim.save_current_peak();
|
||||
speed.save_current_peak();
|
||||
|
||||
let aim_strain = aim.difficulty_value().sqrt() * DIFFICULTY_MULTIPLIER;
|
||||
let speed_strain = speed.difficulty_value().sqrt() * DIFFICULTY_MULTIPLIER;
|
||||
|
||||
let aim_difficult_strain_count = aim.count_difficult_strains();
|
||||
let speed_difficult_strain_count = speed.count_difficult_strains();
|
||||
|
||||
let stars = aim_strain + speed_strain + (aim_strain - speed_strain).abs() / 2.0;
|
||||
|
||||
diff_attributes.stars = stars as f64;
|
||||
diff_attributes.speed_strain = speed_strain as f64;
|
||||
diff_attributes.aim_strain = aim_strain as f64;
|
||||
diff_attributes.aim_difficult_strain_count = aim_difficult_strain_count;
|
||||
diff_attributes.speed_difficult_strain_count = speed_difficult_strain_count;
|
||||
|
||||
diff_attributes
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct OsuDifficultyAttributes {
|
||||
pub aim_strain: f64,
|
||||
pub speed_strain: f64,
|
||||
pub ar: f64,
|
||||
pub od: f64,
|
||||
pub hp: f64,
|
||||
pub cs: f64,
|
||||
pub n_circles: usize,
|
||||
pub n_sliders: usize,
|
||||
pub n_spinners: usize,
|
||||
pub stars: f64,
|
||||
pub max_combo: usize,
|
||||
pub aim_difficult_strain_count: f64,
|
||||
pub speed_difficult_strain_count: f64,
|
||||
pub beatmap_id: i32,
|
||||
pub beatmap_creator: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct OsuPerformanceAttributes {
|
||||
pub difficulty: OsuDifficultyAttributes,
|
||||
pub pp: f64,
|
||||
pub pp_acc: f64,
|
||||
pub pp_aim: f64,
|
||||
pub pp_speed: f64,
|
||||
pub effective_miss_count: f64,
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
use crate::Difficulty;
|
||||
|
||||
use super::{
|
||||
difficulty::{skills::OsuSkills, DifficultyValues}, OsuRelaxBeatmap,
|
||||
};
|
||||
|
||||
/// The result of calculating the strains on a osu! map.
|
||||
///
|
||||
/// Suitable to plot the difficulty of a map over time.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct OsuStrains {
|
||||
/// Strain peaks of the aim skill.
|
||||
pub aim: Vec<f64>,
|
||||
/// Strain peaks of the speed skill.
|
||||
pub speed: Vec<f64>,
|
||||
}
|
||||
|
||||
impl OsuStrains {
|
||||
/// Time between two strains in ms.
|
||||
pub const SECTION_LEN: f64 = 400.0;
|
||||
}
|
||||
|
||||
pub fn strains(difficulty: &Difficulty, converted: &OsuRelaxBeatmap<'_>) -> OsuStrains {
|
||||
let DifficultyValues {
|
||||
skills:
|
||||
OsuSkills {
|
||||
aim,
|
||||
speed,
|
||||
},
|
||||
attrs: _,
|
||||
} = DifficultyValues::calculate(difficulty, converted);
|
||||
|
||||
OsuStrains {
|
||||
aim: aim.get_curr_strain_peaks().into_vec(),
|
||||
speed: speed.get_curr_strain_peaks().into_vec(),
|
||||
}
|
||||
}
|
||||
@@ -120,4 +120,9 @@ from_attrs!(
|
||||
ManiaDifficultyAttributes,
|
||||
ManiaPerformanceAttributes
|
||||
},
|
||||
osu_2019 {
|
||||
OsuRelax,
|
||||
OsuDifficultyAttributes,
|
||||
OsuPerformanceAttributes
|
||||
},
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user