initial rewrite
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
use crate::osu::performance::OsuPerformance;
|
||||
|
||||
/// The result of a difficulty calculation on an osu!standard map.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct OsuDifficultyAttributes {
|
||||
/// The difficulty of the aim skill.
|
||||
pub aim: f64,
|
||||
/// The difficulty of the speed skill.
|
||||
pub speed: f64,
|
||||
/// The difficulty of the flashlight skill.
|
||||
pub flashlight: f64,
|
||||
/// The ratio of the aim strain with and without considering sliders
|
||||
pub slider_factor: f64,
|
||||
/// The number of clickable objects weighted by difficulty.
|
||||
pub speed_note_count: f64,
|
||||
/// The approach rate.
|
||||
pub ar: f64,
|
||||
/// The overall difficulty
|
||||
pub od: f64,
|
||||
/// The health drain rate.
|
||||
pub hp: f64,
|
||||
/// The amount of circles.
|
||||
pub n_circles: u32,
|
||||
/// The amount of sliders.
|
||||
pub n_sliders: u32,
|
||||
/// The amount of spinners.
|
||||
pub n_spinners: u32,
|
||||
/// The final star rating
|
||||
pub stars: f64,
|
||||
/// The maximum combo.
|
||||
pub max_combo: 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 pp<'a>(self) -> OsuPerformance<'a> {
|
||||
self.into()
|
||||
}
|
||||
}
|
||||
|
||||
/// The result of a performance calculation on an osu!standard map.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct OsuPerformanceAttributes {
|
||||
/// The difficulty attributes that were used for the performance calculation
|
||||
pub difficulty: OsuDifficultyAttributes,
|
||||
/// The final performance points.
|
||||
pub pp: f64,
|
||||
/// The accuracy portion of the final pp.
|
||||
pub pp_acc: f64,
|
||||
/// The aim portion of the final pp.
|
||||
pub pp_aim: f64,
|
||||
/// The flashlight portion of the final pp.
|
||||
pub pp_flashlight: f64,
|
||||
/// The speed portion of the final pp.
|
||||
pub pp_speed: f64,
|
||||
/// Misses including an approximated amount of slider breaks
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
use std::borrow::Cow;
|
||||
|
||||
use rosu_map::section::{general::GameMode, hit_objects::CurveBuffers};
|
||||
|
||||
use crate::model::{
|
||||
beatmap::{Beatmap, Converted},
|
||||
mode::ConvertStatus,
|
||||
};
|
||||
|
||||
use super::{
|
||||
attributes::OsuDifficultyAttributes,
|
||||
difficulty::scaling_factor::ScalingFactor,
|
||||
object::{OsuObject, OsuObjectKind},
|
||||
Osu,
|
||||
};
|
||||
|
||||
/// A [`Beatmap`] for [`Osu`] calculations.
|
||||
pub type OsuBeatmap<'a> = Converted<'a, Osu>;
|
||||
|
||||
pub fn try_convert(map: &mut Cow<'_, Beatmap>) -> ConvertStatus {
|
||||
if map.mode == GameMode::Osu {
|
||||
ConvertStatus::Noop
|
||||
} else {
|
||||
ConvertStatus::Incompatible
|
||||
}
|
||||
}
|
||||
|
||||
pub fn convert_objects(
|
||||
converted: &OsuBeatmap<'_>,
|
||||
scaling_factor: &ScalingFactor,
|
||||
hr: bool,
|
||||
time_preempt: f64,
|
||||
mut take: usize,
|
||||
attrs: &mut OsuDifficultyAttributes,
|
||||
) -> Vec<OsuObject> {
|
||||
let mut curve_bufs = CurveBuffers::default();
|
||||
let mut ticks_buf = Vec::new(); // TODO: default capacity
|
||||
|
||||
let mut osu_objects: Vec<_> = converted
|
||||
.map
|
||||
.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;
|
||||
}
|
||||
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_tail);
|
||||
}
|
||||
|
||||
let stack_threshold = time_preempt * f64::from(converted.map.stack_leniency);
|
||||
|
||||
if converted.map.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 = hit_objects[i].end_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;
|
||||
}
|
||||
|
||||
if hit_objects[j].pos.distance(hit_objects[i].pos) < STACK_DISTANCE {
|
||||
hit_objects[i].stack_height += 1;
|
||||
start_time = hit_objects[j].end_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].end_time();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
use std::cmp;
|
||||
|
||||
use crate::{
|
||||
any::difficulty::{skills::Skill, ModeDifficulty},
|
||||
osu::{
|
||||
convert::convert_objects,
|
||||
difficulty::{object::OsuDifficultyObject, scaling_factor::ScalingFactor},
|
||||
object::OsuObject,
|
||||
performance::PERFORMANCE_BASE_MULTIPLIER,
|
||||
},
|
||||
util::mods::Mods,
|
||||
};
|
||||
|
||||
use self::skills::{aim::Aim, flashlight::Flashlight, speed::Speed};
|
||||
|
||||
use super::{attributes::OsuDifficultyAttributes, convert::OsuBeatmap};
|
||||
|
||||
mod object;
|
||||
pub mod scaling_factor;
|
||||
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 fn difficulty(
|
||||
difficulty: &ModeDifficulty,
|
||||
converted: &OsuBeatmap<'_>,
|
||||
) -> OsuDifficultyAttributes {
|
||||
let DifficultyValues {
|
||||
aim,
|
||||
aim_no_sliders,
|
||||
speed,
|
||||
flashlight,
|
||||
mut attrs,
|
||||
} = DifficultyValues::calculate(difficulty, converted);
|
||||
|
||||
let mut aim_rating = aim.difficulty_value().sqrt() * DIFFICULTY_MULTIPLIER;
|
||||
let aim_rating_no_sliders = aim_no_sliders.difficulty_value().sqrt() * DIFFICULTY_MULTIPLIER;
|
||||
|
||||
let speed_notes = speed.relevant_note_count();
|
||||
let mut speed_rating = speed.difficulty_value().sqrt() * DIFFICULTY_MULTIPLIER;
|
||||
|
||||
let mut flashlight_rating = flashlight.difficulty_value().sqrt() * DIFFICULTY_MULTIPLIER;
|
||||
|
||||
let slider_factor = if aim_rating > 0.0 {
|
||||
aim_rating_no_sliders / aim_rating
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
|
||||
let mods = difficulty.get_mods();
|
||||
|
||||
if mods.td() {
|
||||
aim_rating = aim_rating.powf(0.8);
|
||||
flashlight_rating = flashlight_rating.powf(0.8);
|
||||
}
|
||||
|
||||
if mods.rx() {
|
||||
aim_rating *= 0.9;
|
||||
speed_rating = 0.0;
|
||||
flashlight_rating *= 0.7;
|
||||
}
|
||||
|
||||
let base_aim_performance = (5.0 * (aim_rating / 0.0675).max(1.0) - 4.0).powi(3) / 100_000.0;
|
||||
let base_speed_performance = (5.0 * (speed_rating / 0.0675).max(1.0) - 4.0).powi(3) / 100_000.0;
|
||||
|
||||
let base_flashlight_performance = if mods.fl() {
|
||||
flashlight_rating * flashlight_rating * 25.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
let base_performance = ((base_aim_performance).powf(1.1)
|
||||
+ (base_speed_performance).powf(1.1)
|
||||
+ (base_flashlight_performance).powf(1.1))
|
||||
.powf(1.0 / 1.1);
|
||||
|
||||
let star_rating = if base_performance > 0.00001 {
|
||||
PERFORMANCE_BASE_MULTIPLIER.cbrt()
|
||||
* 0.027
|
||||
* ((100_000.0 / 2.0_f64.powf(1.0 / 1.1) * base_performance).cbrt() + 4.0)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
attrs.aim = aim_rating;
|
||||
attrs.speed = speed_rating;
|
||||
attrs.flashlight = flashlight_rating;
|
||||
attrs.slider_factor = slider_factor;
|
||||
attrs.stars = star_rating;
|
||||
attrs.speed_note_count = speed_notes;
|
||||
|
||||
attrs
|
||||
}
|
||||
|
||||
pub struct DifficultyValues {
|
||||
pub aim: Aim,
|
||||
pub aim_no_sliders: Aim,
|
||||
pub speed: Speed,
|
||||
pub flashlight: Flashlight,
|
||||
pub attrs: OsuDifficultyAttributes,
|
||||
}
|
||||
|
||||
impl DifficultyValues {
|
||||
pub fn calculate(difficulty: &ModeDifficulty, converted: &OsuBeatmap<'_>) -> Self {
|
||||
let mods = difficulty.get_mods();
|
||||
let take = difficulty.get_passed_objects();
|
||||
let clock_rate = difficulty.get_clock_rate();
|
||||
|
||||
let map_attrs = converted
|
||||
.attributes()
|
||||
.mods(mods)
|
||||
.clock_rate(clock_rate)
|
||||
.build();
|
||||
|
||||
let scaling_factor = ScalingFactor::new(map_attrs.cs);
|
||||
let hr = mods.hr();
|
||||
let hit_window = 2.0 * map_attrs.hit_windows.od;
|
||||
let time_preempt = f64::from((map_attrs.hit_windows.ar * clock_rate) as f32);
|
||||
|
||||
// * Preempt time can go below 450ms. Normally, this is achieved via the DT mod
|
||||
// * which uniformly speeds up all animations game wide regardless of AR.
|
||||
// * This uniform speedup is hard to match 1:1, however we can at least make
|
||||
// * AR>10 (via mods) feel good by extending the upper linear function above.
|
||||
// * Note that this doesn't exactly match the AR>10 visuals as they're
|
||||
// * classically known, but it feels good.
|
||||
// * This adjustment is necessary for AR>10, otherwise TimePreempt can
|
||||
// * become smaller leading to hitcircles not fully fading in.
|
||||
let time_fade_in = if mods.hd() {
|
||||
time_preempt * HD_FADE_IN_DURATION_MULTIPLIER
|
||||
} else {
|
||||
400.0 * (time_preempt / OsuObject::PREEMPT_MIN).min(1.0)
|
||||
};
|
||||
|
||||
let mut attrs = OsuDifficultyAttributes {
|
||||
ar: map_attrs.ar,
|
||||
hp: map_attrs.hp,
|
||||
od: map_attrs.od,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let mut osu_objects = convert_objects(
|
||||
converted,
|
||||
&scaling_factor,
|
||||
hr,
|
||||
time_preempt,
|
||||
take,
|
||||
&mut attrs,
|
||||
);
|
||||
|
||||
let mut osu_objects_iter = osu_objects
|
||||
.iter_mut()
|
||||
.map(|h| OsuDifficultyObject::compute_slider_cursor_pos(h, scaling_factor.radius));
|
||||
|
||||
let aim = Aim::new(true);
|
||||
let aim_no_sliders = Aim::new(false);
|
||||
let speed = Speed::new(hit_window);
|
||||
let flashlight = Flashlight::new(mods, scaling_factor.radius, time_preempt, time_fade_in);
|
||||
|
||||
let Some(mut last) = osu_objects_iter.next().filter(|_| take > 0) else {
|
||||
return Self {
|
||||
aim,
|
||||
aim_no_sliders,
|
||||
speed,
|
||||
flashlight,
|
||||
attrs,
|
||||
};
|
||||
};
|
||||
|
||||
let mut last_last = None;
|
||||
|
||||
let diff_objects: Vec<_> = osu_objects_iter
|
||||
.enumerate()
|
||||
.map(|(idx, h)| {
|
||||
let diff_object =
|
||||
OsuDifficultyObject::new(h, last, last_last, clock_rate, idx, &scaling_factor);
|
||||
|
||||
last_last = Some(last);
|
||||
last = h;
|
||||
|
||||
diff_object
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut aim = Skill::new(aim, &diff_objects);
|
||||
let mut aim_no_sliders = Skill::new(aim_no_sliders, &diff_objects);
|
||||
let mut speed = Skill::new(speed, &diff_objects);
|
||||
let mut flashlight = Skill::new(flashlight, &diff_objects);
|
||||
|
||||
// The first hit object has no difficulty object
|
||||
let take_diff_objects = cmp::min(converted.map.hit_objects.len(), take) - 1;
|
||||
|
||||
for hit_object in diff_objects.iter().take(take_diff_objects) {
|
||||
aim.process(hit_object);
|
||||
aim_no_sliders.process(hit_object);
|
||||
speed.process(hit_object);
|
||||
flashlight.process(hit_object);
|
||||
}
|
||||
|
||||
Self {
|
||||
aim: aim.inner,
|
||||
aim_no_sliders: aim_no_sliders.inner,
|
||||
speed: speed.inner,
|
||||
flashlight: flashlight.inner,
|
||||
attrs,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
use rosu_map::util::Pos;
|
||||
|
||||
use crate::{
|
||||
any::difficulty::object::IDifficultyObject,
|
||||
osu::object::{OsuObject, OsuObjectKind},
|
||||
};
|
||||
|
||||
use super::{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;
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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_with_small_circle_bonus;
|
||||
|
||||
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
|
||||
.nested_objects
|
||||
.last()
|
||||
.map_or(last_object.pos, |nested| nested.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());
|
||||
}
|
||||
}
|
||||
|
||||
pub fn compute_slider_cursor_pos(h: &mut OsuObject, radius: f64) -> &OsuObject {
|
||||
let stack_offset = h.stack_offset;
|
||||
|
||||
let OsuObjectKind::Slider(ref mut slider) = h.kind else {
|
||||
return h;
|
||||
};
|
||||
|
||||
let mut curr_cursor_pos = h.pos + stack_offset;
|
||||
let scaling_factor = f64::from(OsuDifficultyObject::NORMALIZED_RADIUS) / radius;
|
||||
|
||||
for (curr_movement_obj, i) in slider.nested_objects.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 == slider.nested_objects.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 == slider.nested_objects.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,46 @@
|
||||
use rosu_map::util::Pos;
|
||||
|
||||
use crate::osu::object::OsuObject;
|
||||
|
||||
use super::object::OsuDifficultyObject;
|
||||
|
||||
/// Fields around the scaling of hit objects.
|
||||
///
|
||||
/// osu!lazer stores these in each hit object but since all objects share the
|
||||
/// same scaling (w.r.t. difficulty & performance), we store them only once.
|
||||
pub struct ScalingFactor {
|
||||
/// `NORMALIZED_RADIUS / Radius`
|
||||
pub factor: f32,
|
||||
/// `NORMALIZED_RADIUS / Radius` and then adjusted if `Radius < 30`
|
||||
pub factor_with_small_circle_bonus: f32,
|
||||
pub radius: f64,
|
||||
pub scale: f32,
|
||||
}
|
||||
|
||||
impl ScalingFactor {
|
||||
pub fn new(cs: f64) -> Self {
|
||||
let scale = (1.0 - 0.7 * (cs as f32 - 5.0) / 5.0) / 2.0;
|
||||
|
||||
let radius = f64::from(OsuObject::OBJECT_RADIUS * scale);
|
||||
let factor = OsuDifficultyObject::NORMALIZED_RADIUS / radius as f32;
|
||||
|
||||
let factor_with_small_circle_bonus = if radius < 30.0 {
|
||||
factor * (1.0 + (30.0 - radius as f32).min(5.0) / 50.0)
|
||||
} else {
|
||||
factor
|
||||
};
|
||||
|
||||
Self {
|
||||
factor,
|
||||
factor_with_small_circle_bonus,
|
||||
radius,
|
||||
scale,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn stack_offset(&self, stack_height: i32) -> Pos {
|
||||
let stack_offset = stack_height as f32 * self.scale * -6.4;
|
||||
|
||||
Pos::new(stack_offset, stack_offset)
|
||||
}
|
||||
}
|
||||
@@ -1,100 +1,104 @@
|
||||
use std::f64::consts::{FRAC_PI_2, PI};
|
||||
|
||||
use crate::{osu::difficulty_object::OsuDifficultyObject, util::CompactVec};
|
||||
use crate::{
|
||||
any::difficulty::{
|
||||
object::IDifficultyObject,
|
||||
skills::{strain_decay, ISkill, Skill},
|
||||
},
|
||||
osu::difficulty::object::OsuDifficultyObject,
|
||||
util::float_ext::FloatExt,
|
||||
};
|
||||
|
||||
use super::{previous, previous_start_time, OsuStrainSkill, Skill, StrainSkill};
|
||||
use super::strain::OsuStrainSkill;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct Aim {
|
||||
curr_strain: f64,
|
||||
curr_section_peak: f64,
|
||||
curr_section_end: f64,
|
||||
pub(crate) strain_peaks: CompactVec,
|
||||
const SKILL_MULTIPLIER: f64 = 23.55;
|
||||
const STRAIN_DECAY_BASE: f64 = 0.15;
|
||||
|
||||
pub struct Aim {
|
||||
with_sliders: bool,
|
||||
curr_strain: f64,
|
||||
inner: OsuStrainSkill,
|
||||
}
|
||||
|
||||
impl Aim {
|
||||
const SKILL_MULTIPLIER: f64 = 23.55;
|
||||
const STRAIN_DECAY_BASE: f64 = 0.15;
|
||||
|
||||
pub(crate) fn new(with_sliders: bool) -> Self {
|
||||
pub fn new(with_sliders: bool) -> Self {
|
||||
Self {
|
||||
curr_strain: 0.0,
|
||||
curr_section_peak: 0.0,
|
||||
curr_section_end: 0.0,
|
||||
strain_peaks: CompactVec::new(),
|
||||
with_sliders,
|
||||
curr_strain: 0.0,
|
||||
inner: OsuStrainSkill::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn strain_decay(ms: f64) -> f64 {
|
||||
Self::STRAIN_DECAY_BASE.powf(ms / 1000.0)
|
||||
pub fn get_curr_strain_peaks(self) -> Vec<f64> {
|
||||
self.inner.get_curr_strain_peaks()
|
||||
}
|
||||
|
||||
pub fn difficulty_value(self) -> f64 {
|
||||
self.inner.difficulty_value(
|
||||
OsuStrainSkill::REDUCED_SECTION_COUNT,
|
||||
OsuStrainSkill::REDUCED_STRAIN_BASELINE,
|
||||
OsuStrainSkill::DECAY_WEIGHT,
|
||||
OsuStrainSkill::DIFFICULTY_MULTIPLER,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Skill for Aim {
|
||||
#[inline]
|
||||
fn process(
|
||||
&mut self,
|
||||
curr: &OsuDifficultyObject<'_>,
|
||||
diff_objects: &[OsuDifficultyObject<'_>],
|
||||
) {
|
||||
<Self as StrainSkill>::process(self, curr, diff_objects);
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn difficulty_value(&mut self) -> f64 {
|
||||
<Self as OsuStrainSkill>::difficulty_value(self)
|
||||
}
|
||||
impl ISkill for Aim {
|
||||
type DifficultyObjects<'a> = [OsuDifficultyObject<'a>];
|
||||
}
|
||||
|
||||
impl StrainSkill for Aim {
|
||||
#[inline]
|
||||
fn strain_peaks_mut(&mut self) -> &mut CompactVec {
|
||||
&mut self.strain_peaks
|
||||
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)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn curr_section_peak(&mut self) -> &mut f64 {
|
||||
&mut self.curr_section_peak
|
||||
const fn curr_section_peak(&self) -> f64 {
|
||||
self.inner.inner.inner.curr_section_peak
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn curr_section_end(&mut self) -> &mut f64 {
|
||||
&mut self.curr_section_end
|
||||
fn curr_section_peak_mut(&mut self) -> &mut f64 {
|
||||
&mut self.inner.inner.inner.curr_section_peak
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn strain_value_at(
|
||||
&mut self,
|
||||
curr: &OsuDifficultyObject<'_>,
|
||||
diff_objects: &[OsuDifficultyObject<'_>],
|
||||
) -> f64 {
|
||||
self.curr_strain *= Self::strain_decay(curr.delta_time);
|
||||
self.curr_strain += AimEvaluator::evaluate_diff_of(curr, diff_objects, self.with_sliders)
|
||||
* Self::SKILL_MULTIPLIER;
|
||||
|
||||
self.curr_strain
|
||||
const fn curr_section_end(&self) -> f64 {
|
||||
self.inner.inner.inner.curr_section_end
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn calculate_initial_strain(
|
||||
&self,
|
||||
time: f64,
|
||||
curr: &OsuDifficultyObject<'_>,
|
||||
diff_objects: &[OsuDifficultyObject<'_>],
|
||||
) -> f64 {
|
||||
self.curr_strain * Self::strain_decay(time - previous_start_time(diff_objects, curr.idx, 0))
|
||||
fn curr_section_end_mut(&mut self) -> &mut f64 {
|
||||
&mut self.inner.inner.inner.curr_section_end
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn difficulty_value(&mut self) -> f64 {
|
||||
<Self as OsuStrainSkill>::difficulty_value(self)
|
||||
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, self.inner.with_sliders)
|
||||
* SKILL_MULTIPLIER;
|
||||
|
||||
self.inner.curr_strain
|
||||
}
|
||||
}
|
||||
|
||||
impl OsuStrainSkill for Aim {}
|
||||
|
||||
struct AimEvaluator;
|
||||
|
||||
impl AimEvaluator {
|
||||
@@ -103,15 +107,16 @@ impl AimEvaluator {
|
||||
const SLIDER_MULTIPLIER: f64 = 1.35;
|
||||
const VELOCITY_CHANGE_MULTIPLIER: f64 = 0.75;
|
||||
|
||||
fn evaluate_diff_of(
|
||||
curr: &OsuDifficultyObject<'_>,
|
||||
diff_objects: &[OsuDifficultyObject<'_>],
|
||||
fn evaluate_diff_of<'a>(
|
||||
curr: &'a OsuDifficultyObject<'a>,
|
||||
diff_objects: &'a [OsuDifficultyObject<'a>],
|
||||
with_sliders: bool,
|
||||
) -> f64 {
|
||||
let osu_curr_obj = curr;
|
||||
|
||||
let Some((osu_last_last_obj, osu_last_obj)) = previous(diff_objects, curr.idx, 1)
|
||||
.zip(previous(diff_objects, curr.idx, 0))
|
||||
let Some((osu_last_last_obj, osu_last_obj)) = curr
|
||||
.previous(1, diff_objects)
|
||||
.zip(curr.previous(0, diff_objects))
|
||||
.filter(|(_, last)| !(curr.base.is_spinner() || last.base.is_spinner()))
|
||||
else {
|
||||
return 0.0;
|
||||
@@ -119,27 +124,26 @@ impl AimEvaluator {
|
||||
|
||||
// * Calculate the velocity to the current hitobject, which starts
|
||||
// * with a base distance / time assuming the last object is a hitcircle.
|
||||
let mut curr_vel = osu_curr_obj.dists.lazy_jump_dist / osu_curr_obj.strain_time;
|
||||
let mut curr_vel = osu_curr_obj.lazy_jump_dist / osu_curr_obj.strain_time;
|
||||
|
||||
// * But if the last object is a slider, then we extend the travel
|
||||
// * velocity through the slider into the current object.
|
||||
if osu_last_obj.base.is_slider() && with_sliders {
|
||||
// * calculate the slider velocity from slider head to slider end.
|
||||
let travel_vel = osu_last_obj.dists.travel_dist / osu_last_obj.dists.travel_time;
|
||||
let travel_vel = osu_last_obj.travel_dist / osu_last_obj.travel_time;
|
||||
// * calculate the movement velocity from slider end to current object
|
||||
let movement_vel = osu_curr_obj.dists.min_jump_dist / osu_curr_obj.dists.min_jump_time;
|
||||
let movement_vel = osu_curr_obj.min_jump_dist / osu_curr_obj.min_jump_time;
|
||||
|
||||
// * take the larger total combined velocity.
|
||||
curr_vel = curr_vel.max(movement_vel + travel_vel);
|
||||
}
|
||||
|
||||
// * As above, do the same for the previous hitobject.
|
||||
let mut prev_vel = osu_last_obj.dists.lazy_jump_dist / osu_last_obj.strain_time;
|
||||
let mut prev_vel = osu_last_obj.lazy_jump_dist / osu_last_obj.strain_time;
|
||||
|
||||
if osu_last_last_obj.base.is_slider() && with_sliders {
|
||||
let travel_vel =
|
||||
osu_last_last_obj.dists.travel_dist / osu_last_last_obj.dists.travel_time;
|
||||
let movement_vel = osu_last_obj.dists.min_jump_dist / osu_last_obj.dists.min_jump_time;
|
||||
let travel_vel = osu_last_last_obj.travel_dist / osu_last_last_obj.travel_time;
|
||||
let movement_vel = osu_last_obj.min_jump_dist / osu_last_obj.min_jump_time;
|
||||
|
||||
prev_vel = prev_vel.max(movement_vel + travel_vel);
|
||||
}
|
||||
@@ -157,10 +161,9 @@ impl AimEvaluator {
|
||||
< 1.25 * osu_curr_obj.strain_time.min(osu_last_obj.strain_time)
|
||||
{
|
||||
if let Some(((curr_angle, last_angle), last_last_angle)) = osu_curr_obj
|
||||
.dists
|
||||
.angle
|
||||
.zip(osu_last_obj.dists.angle)
|
||||
.zip(osu_last_last_obj.dists.angle)
|
||||
.zip(osu_last_obj.angle)
|
||||
.zip(osu_last_last_obj.angle)
|
||||
{
|
||||
// * Rewarding angles, take the smaller velocity as base.
|
||||
let angle_bonus = curr_vel.min(prev_vel);
|
||||
@@ -176,7 +179,7 @@ impl AimEvaluator {
|
||||
(FRAC_PI_2 * ((100.0 - osu_curr_obj.strain_time) / 25.0).min(1.0)).sin();
|
||||
|
||||
let base2 = (FRAC_PI_2
|
||||
* ((osu_curr_obj.dists.lazy_jump_dist).clamp(50.0, 100.0) - 50.0)
|
||||
* ((osu_curr_obj.lazy_jump_dist).clamp(50.0, 100.0) - 50.0)
|
||||
/ 50.0)
|
||||
.sin();
|
||||
|
||||
@@ -204,13 +207,13 @@ impl AimEvaluator {
|
||||
}
|
||||
}
|
||||
|
||||
if prev_vel.max(curr_vel).abs() > f64::EPSILON {
|
||||
if prev_vel.max(curr_vel).not_eq(0.0) {
|
||||
// * We want to use the average velocity over the whole object when awarding
|
||||
// * differences, not the individual jump and slider path velocities.
|
||||
prev_vel = (osu_last_obj.dists.lazy_jump_dist + osu_last_last_obj.dists.travel_dist)
|
||||
prev_vel = (osu_last_obj.lazy_jump_dist + osu_last_last_obj.travel_dist)
|
||||
/ osu_last_obj.strain_time;
|
||||
curr_vel = (osu_curr_obj.dists.lazy_jump_dist + osu_last_obj.dists.travel_dist)
|
||||
/ osu_curr_obj.strain_time;
|
||||
curr_vel =
|
||||
(osu_curr_obj.lazy_jump_dist + osu_last_obj.travel_dist) / osu_curr_obj.strain_time;
|
||||
|
||||
// * Scale with ratio of difference compared to 0.5 * max dist.
|
||||
let dist_ratio_base =
|
||||
@@ -231,7 +234,7 @@ impl AimEvaluator {
|
||||
|
||||
if osu_last_obj.base.is_slider() {
|
||||
// * Reward sliders based on velocity.
|
||||
slider_bonus = osu_last_obj.dists.travel_dist / osu_last_obj.dists.travel_time;
|
||||
slider_bonus = osu_last_obj.travel_dist / osu_last_obj.travel_time;
|
||||
}
|
||||
|
||||
// * Add in acute angle bonus or wide angle bonus + velocity change bonus, whichever is larger.
|
||||
@@ -0,0 +1,230 @@
|
||||
use crate::{
|
||||
any::difficulty::{
|
||||
object::IDifficultyObject,
|
||||
skills::{strain_decay, ISkill, Skill, StrainSkill},
|
||||
},
|
||||
osu::{difficulty::object::OsuDifficultyObject, object::OsuObjectKind},
|
||||
util::mods::Mods,
|
||||
};
|
||||
|
||||
use super::strain::OsuStrainSkill;
|
||||
|
||||
const SKILL_MULTIPLIER: f64 = 0.052;
|
||||
const STRAIN_DECAY_BASE: f64 = 0.15;
|
||||
|
||||
pub struct Flashlight {
|
||||
curr_strain: f64,
|
||||
has_hidden_mod: bool,
|
||||
inner: StrainSkill,
|
||||
evaluator: FlashlightEvaluator,
|
||||
}
|
||||
|
||||
impl Flashlight {
|
||||
pub fn new(mods: u32, radius: f64, time_preempt: f64, time_fade_in: f64) -> Self {
|
||||
let scaling_factor = 52.0 / radius;
|
||||
|
||||
Self {
|
||||
curr_strain: 0.0,
|
||||
has_hidden_mod: mods.hd(),
|
||||
inner: StrainSkill::default(),
|
||||
evaluator: FlashlightEvaluator::new(scaling_factor, time_preempt, time_fade_in),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_curr_strain_peaks(self) -> Vec<f64> {
|
||||
self.inner.get_curr_strain_peaks()
|
||||
}
|
||||
|
||||
pub fn difficulty_value(self) -> f64 {
|
||||
self.get_curr_strain_peaks().into_iter().sum::<f64>() * OsuStrainSkill::DIFFICULTY_MULTIPLER
|
||||
}
|
||||
}
|
||||
|
||||
impl ISkill for Flashlight {
|
||||
type DifficultyObjects<'a> = [OsuDifficultyObject<'a>];
|
||||
}
|
||||
|
||||
impl<'a> Skill<'a, Flashlight> {
|
||||
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)
|
||||
}
|
||||
|
||||
const fn curr_section_peak(&self) -> f64 {
|
||||
self.inner.inner.curr_section_peak
|
||||
}
|
||||
|
||||
fn curr_section_peak_mut(&mut self) -> &mut f64 {
|
||||
&mut self.inner.inner.curr_section_peak
|
||||
}
|
||||
|
||||
const fn curr_section_end(&self) -> f64 {
|
||||
self.inner.inner.curr_section_end
|
||||
}
|
||||
|
||||
fn curr_section_end_mut(&mut self) -> &mut f64 {
|
||||
&mut self.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 / StrainSkill::SECTION_LEN).ceil() * StrainSkill::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() += StrainSkill::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 += self.inner.evaluator.evaluate_diff_of(
|
||||
curr,
|
||||
self.diff_objects,
|
||||
self.inner.has_hidden_mod,
|
||||
) * SKILL_MULTIPLIER;
|
||||
|
||||
self.inner.curr_strain
|
||||
}
|
||||
}
|
||||
|
||||
struct FlashlightEvaluator {
|
||||
scaling_factor: f64,
|
||||
time_preempt: f64,
|
||||
time_fade_in: f64,
|
||||
}
|
||||
|
||||
impl FlashlightEvaluator {
|
||||
const MAX_OPACITY_BONUS: f64 = 0.4;
|
||||
const HIDDEN_BONUS: f64 = 0.2;
|
||||
|
||||
const MIN_VELOCITY: f64 = 0.5;
|
||||
const SLIDER_MULTIPLIER: f64 = 1.3;
|
||||
|
||||
const MIN_ANGLE_MULTIPLIER: f64 = 0.2;
|
||||
|
||||
const fn new(scaling_factor: f64, time_preempt: f64, time_fade_in: f64) -> Self {
|
||||
Self {
|
||||
scaling_factor,
|
||||
time_preempt,
|
||||
time_fade_in,
|
||||
}
|
||||
}
|
||||
|
||||
fn evaluate_diff_of<'a>(
|
||||
&self,
|
||||
curr: &'a OsuDifficultyObject<'a>,
|
||||
diff_objects: &'a [OsuDifficultyObject<'a>],
|
||||
hidden: bool,
|
||||
) -> f64 {
|
||||
if curr.base.is_spinner() {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
let osu_curr = curr;
|
||||
let osu_hit_obj = curr.base;
|
||||
|
||||
let mut small_dist_nerf = 1.0;
|
||||
let mut cumulative_strain_time = 0.0;
|
||||
|
||||
let mut result = 0.0;
|
||||
|
||||
let mut last_obj = osu_curr;
|
||||
|
||||
let mut angle_repeat_count = 0.0;
|
||||
|
||||
// * This is iterating backwards in time from the current object.
|
||||
for i in 0..curr.idx.min(10) {
|
||||
let Some(curr_obj) = curr.previous(i, diff_objects) else {
|
||||
break;
|
||||
};
|
||||
|
||||
let curr_hit_obj = curr_obj.base;
|
||||
|
||||
if !curr_obj.base.is_spinner() {
|
||||
let jump_dist = f64::from(
|
||||
(osu_hit_obj.stacked_pos() - curr_hit_obj.stacked_end_pos()).length(),
|
||||
);
|
||||
cumulative_strain_time += last_obj.strain_time;
|
||||
|
||||
// * We want to nerf objects that can be easily seen within the Flashlight circle radius.
|
||||
if i == 0 {
|
||||
small_dist_nerf = (jump_dist / 75.0).min(1.0);
|
||||
}
|
||||
|
||||
// * We also want to nerf stacks so that only the first object of the stack is accounted for.
|
||||
let stack_nerf = ((curr_obj.lazy_jump_dist / self.scaling_factor) / 25.0).min(1.0);
|
||||
|
||||
// * Bonus based on how visible the object is.
|
||||
let opacity_bonus = 1.0
|
||||
+ Self::MAX_OPACITY_BONUS
|
||||
* (1.0
|
||||
- osu_curr.opacity_at(
|
||||
curr_hit_obj.start_time,
|
||||
hidden,
|
||||
self.time_preempt,
|
||||
self.time_fade_in,
|
||||
));
|
||||
|
||||
result += stack_nerf * opacity_bonus * self.scaling_factor * jump_dist
|
||||
/ cumulative_strain_time;
|
||||
|
||||
if let Some((curr_obj_angle, osu_curr_angle)) = curr_obj.angle.zip(osu_curr.angle) {
|
||||
// * Objects further back in time should count less for the nerf.
|
||||
if (curr_obj_angle - osu_curr_angle).abs() < 0.02 {
|
||||
angle_repeat_count += (1.0 - 0.1 * i as f64).max(0.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
last_obj = curr_obj;
|
||||
}
|
||||
|
||||
let base = small_dist_nerf * result;
|
||||
result = base * base;
|
||||
|
||||
// * Additional bonus for Hidden due to there being no approach circles.
|
||||
if hidden {
|
||||
result *= 1.0 + Self::HIDDEN_BONUS;
|
||||
}
|
||||
|
||||
// * Nerf patterns with repeated angles.
|
||||
result *= Self::MIN_ANGLE_MULTIPLIER
|
||||
+ (1.0 - Self::MIN_ANGLE_MULTIPLIER) / (angle_repeat_count + 1.0);
|
||||
|
||||
let mut slider_bonus = 0.0;
|
||||
|
||||
if let OsuObjectKind::Slider(slider) = &osu_curr.base.kind {
|
||||
// * Invert the scaling factor to determine the true travel distance independent of circle size.
|
||||
let pixel_travel_dist = f64::from(slider.lazy_travel_dist) / self.scaling_factor;
|
||||
|
||||
// * Reward sliders based on velocity.
|
||||
slider_bonus =
|
||||
((pixel_travel_dist / osu_curr.travel_time - Self::MIN_VELOCITY).max(0.0)).sqrt();
|
||||
|
||||
// * Longer sliders require more memorisation.
|
||||
slider_bonus *= pixel_travel_dist;
|
||||
|
||||
// * Nerf sliders with repeats, as less memorisation is required.
|
||||
let repeat_count = slider.repeat_count();
|
||||
|
||||
if repeat_count > 0 {
|
||||
slider_bonus /= (repeat_count + 1) as f64;
|
||||
}
|
||||
}
|
||||
|
||||
result += slider_bonus * Self::SLIDER_MULTIPLIER;
|
||||
|
||||
result
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
pub mod aim;
|
||||
pub mod flashlight;
|
||||
pub mod speed;
|
||||
pub mod strain;
|
||||
@@ -1,45 +1,58 @@
|
||||
use std::{cmp::Ordering, f64::consts::PI};
|
||||
use std::f64::consts::PI;
|
||||
|
||||
use crate::{osu::difficulty_object::OsuDifficultyObject, util::CompactVec};
|
||||
use crate::{
|
||||
any::difficulty::{
|
||||
object::IDifficultyObject,
|
||||
skills::{strain_decay, ISkill, Skill},
|
||||
},
|
||||
osu::difficulty::object::OsuDifficultyObject,
|
||||
};
|
||||
|
||||
use super::{next, previous, previous_start_time, OsuStrainSkill, Skill, StrainSkill};
|
||||
use super::strain::OsuStrainSkill;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct Speed {
|
||||
const SKILL_MULTIPLIER: f64 = 1375.0;
|
||||
const STRAIN_DECAY_BASE: f64 = 0.3;
|
||||
|
||||
const DIFFICULTY_MULTIPLER: f64 = 1.04;
|
||||
const REDUCED_SECTION_COUNT: usize = 5;
|
||||
|
||||
pub struct Speed {
|
||||
curr_strain: f64,
|
||||
curr_section_peak: f64,
|
||||
curr_section_end: f64,
|
||||
curr_rhythm: f64,
|
||||
pub(crate) strain_peaks: CompactVec,
|
||||
object_strains: Vec<f64>,
|
||||
hit_window: f64,
|
||||
inner: OsuStrainSkill,
|
||||
}
|
||||
|
||||
impl Speed {
|
||||
const SKILL_MULTIPLIER: f64 = 1375.0;
|
||||
const STRAIN_DECAY_BASE: f64 = 0.3;
|
||||
|
||||
pub(crate) fn new(hit_window: f64) -> Self {
|
||||
pub fn new(hit_window: f64) -> Self {
|
||||
Self {
|
||||
curr_strain: 0.0,
|
||||
curr_section_peak: 0.0,
|
||||
curr_section_end: 0.0,
|
||||
curr_rhythm: 0.0,
|
||||
strain_peaks: CompactVec::new(),
|
||||
object_strains: Vec::new(),
|
||||
object_strains: Vec::new(), // TODO: default capacity
|
||||
hit_window,
|
||||
inner: OsuStrainSkill::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn strain_decay(ms: f64) -> f64 {
|
||||
Self::STRAIN_DECAY_BASE.powf(ms / 1000.0)
|
||||
pub fn get_curr_strain_peaks(self) -> Vec<f64> {
|
||||
self.inner.get_curr_strain_peaks()
|
||||
}
|
||||
|
||||
pub(crate) fn relevant_note_count(&self) -> f64 {
|
||||
pub fn difficulty_value(self) -> f64 {
|
||||
self.inner.difficulty_value(
|
||||
REDUCED_SECTION_COUNT,
|
||||
OsuStrainSkill::REDUCED_STRAIN_BASELINE,
|
||||
OsuStrainSkill::DECAY_WEIGHT,
|
||||
DIFFICULTY_MULTIPLER,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn relevant_note_count(&self) -> f64 {
|
||||
self.object_strains
|
||||
.iter()
|
||||
.max_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal))
|
||||
.copied()
|
||||
.max_by(f64::total_cmp)
|
||||
.filter(|&n| n > 0.0)
|
||||
.map_or(0.0, |max_strain| {
|
||||
self.object_strains.iter().fold(0.0, |sum, strain| {
|
||||
@@ -49,75 +62,66 @@ impl Speed {
|
||||
}
|
||||
}
|
||||
|
||||
impl Skill for Speed {
|
||||
#[inline]
|
||||
fn process(
|
||||
&mut self,
|
||||
curr: &OsuDifficultyObject<'_>,
|
||||
diff_objects: &[OsuDifficultyObject<'_>],
|
||||
) {
|
||||
<Self as StrainSkill>::process(self, curr, diff_objects);
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn difficulty_value(&mut self) -> f64 {
|
||||
<Self as OsuStrainSkill>::difficulty_value(self)
|
||||
}
|
||||
impl ISkill for Speed {
|
||||
type DifficultyObjects<'a> = [OsuDifficultyObject<'a>];
|
||||
}
|
||||
|
||||
impl StrainSkill for Speed {
|
||||
#[inline]
|
||||
fn strain_peaks_mut(&mut self) -> &mut CompactVec {
|
||||
&mut self.strain_peaks
|
||||
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 * self.inner.curr_rhythm)
|
||||
* strain_decay(time - prev_start_time, STRAIN_DECAY_BASE)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn curr_section_peak(&mut self) -> &mut f64 {
|
||||
&mut self.curr_section_peak
|
||||
const fn curr_section_peak(&self) -> f64 {
|
||||
self.inner.inner.inner.curr_section_peak
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn curr_section_end(&mut self) -> &mut f64 {
|
||||
&mut self.curr_section_end
|
||||
fn curr_section_peak_mut(&mut self) -> &mut f64 {
|
||||
&mut self.inner.inner.inner.curr_section_peak
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn strain_value_at(
|
||||
&mut self,
|
||||
curr: &OsuDifficultyObject<'_>,
|
||||
diff_objects: &[OsuDifficultyObject<'_>],
|
||||
) -> f64 {
|
||||
self.curr_strain *= Self::strain_decay(curr.strain_time);
|
||||
self.curr_strain += SpeedEvaluator::evaluate_diff_of(curr, diff_objects, self.hit_window)
|
||||
* Self::SKILL_MULTIPLIER;
|
||||
self.curr_rhythm = RhythmEvaluator::evaluate_diff_of(curr, diff_objects, self.hit_window);
|
||||
const fn curr_section_end(&self) -> f64 {
|
||||
self.inner.inner.inner.curr_section_end
|
||||
}
|
||||
|
||||
let total_strain = self.curr_strain * self.curr_rhythm;
|
||||
self.object_strains.push(total_strain);
|
||||
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, self.diff_objects, self.inner.hit_window)
|
||||
* SKILL_MULTIPLIER;
|
||||
self.inner.curr_rhythm =
|
||||
RhythmEvaluator::evaluate_diff_of(curr, self.diff_objects, self.inner.hit_window);
|
||||
|
||||
let total_strain = self.inner.curr_strain * self.inner.curr_rhythm;
|
||||
self.inner.object_strains.push(total_strain);
|
||||
|
||||
total_strain
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn calculate_initial_strain(
|
||||
&self,
|
||||
time: f64,
|
||||
curr: &OsuDifficultyObject<'_>,
|
||||
diff_objects: &[OsuDifficultyObject<'_>],
|
||||
) -> f64 {
|
||||
(self.curr_strain * self.curr_rhythm)
|
||||
* Self::strain_decay(time - previous_start_time(diff_objects, curr.idx, 0))
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn difficulty_value(&mut self) -> f64 {
|
||||
<Self as OsuStrainSkill>::difficulty_value(self)
|
||||
}
|
||||
}
|
||||
|
||||
impl OsuStrainSkill for Speed {
|
||||
const REDUCED_SECTION_COUNT: usize = 5;
|
||||
const DIFFICULTY_MULTIPLER: f64 = 1.04;
|
||||
}
|
||||
|
||||
struct SpeedEvaluator;
|
||||
@@ -127,9 +131,9 @@ impl SpeedEvaluator {
|
||||
const MIN_SPEED_BONUS: f64 = 75.0; // ~200BPM
|
||||
const SPEED_BALANCING_FACTOR: f64 = 40.;
|
||||
|
||||
fn evaluate_diff_of(
|
||||
curr: &OsuDifficultyObject<'_>,
|
||||
diff_objects: &[OsuDifficultyObject<'_>],
|
||||
fn evaluate_diff_of<'a>(
|
||||
curr: &'a OsuDifficultyObject<'a>,
|
||||
diff_objects: &'a [OsuDifficultyObject<'a>],
|
||||
hit_window: f64,
|
||||
) -> f64 {
|
||||
if curr.base.is_spinner() {
|
||||
@@ -138,8 +142,8 @@ impl SpeedEvaluator {
|
||||
|
||||
// * derive strainTime for calculation
|
||||
let osu_curr_obj = curr;
|
||||
let osu_prev_obj = previous(diff_objects, curr.idx, 0);
|
||||
let osu_next_obj = next(diff_objects, curr.idx, 0);
|
||||
let osu_prev_obj = curr.previous(0, diff_objects);
|
||||
let osu_next_obj = curr.next(0, diff_objects);
|
||||
|
||||
let mut strain_time = curr.strain_time;
|
||||
let mut doubletapness = 1.0;
|
||||
@@ -168,9 +172,8 @@ impl SpeedEvaluator {
|
||||
1.0
|
||||
};
|
||||
|
||||
let travel_dist = osu_prev_obj.map_or(0.0, |obj| obj.dists.travel_dist);
|
||||
let dist =
|
||||
Self::SINGLE_SPACING_THRESHOLD.min(travel_dist + osu_curr_obj.dists.min_jump_dist);
|
||||
let travel_dist = osu_prev_obj.map_or(0.0, |obj| obj.travel_dist);
|
||||
let dist = Self::SINGLE_SPACING_THRESHOLD.min(travel_dist + osu_curr_obj.min_jump_dist);
|
||||
|
||||
(speed_bonus + speed_bonus * (dist / Self::SINGLE_SPACING_THRESHOLD).powf(3.5))
|
||||
* doubletapness
|
||||
@@ -185,9 +188,9 @@ impl RhythmEvaluator {
|
||||
const HISTORY_TIME_MAX: u32 = 5000;
|
||||
const RHYTHM_MULTIPLIER: f64 = 0.75;
|
||||
|
||||
fn evaluate_diff_of(
|
||||
curr: &OsuDifficultyObject<'_>,
|
||||
diff_objects: &[OsuDifficultyObject<'_>],
|
||||
fn evaluate_diff_of<'a>(
|
||||
curr: &'a OsuDifficultyObject<'a>,
|
||||
diff_objects: &'a [OsuDifficultyObject<'a>],
|
||||
hit_window: f64,
|
||||
) -> f64 {
|
||||
if curr.base.is_spinner() {
|
||||
@@ -207,7 +210,8 @@ impl RhythmEvaluator {
|
||||
|
||||
let mut rhythm_start = 0;
|
||||
|
||||
while previous(diff_objects, curr.idx, rhythm_start)
|
||||
while curr
|
||||
.previous(rhythm_start, diff_objects)
|
||||
.filter(|prev| {
|
||||
rhythm_start + 2 < historical_note_count
|
||||
&& curr.start_time - prev.start_time < f64::from(Self::HISTORY_TIME_MAX)
|
||||
@@ -218,9 +222,10 @@ impl RhythmEvaluator {
|
||||
}
|
||||
|
||||
for i in (1..=rhythm_start).rev() {
|
||||
let Some(((curr_obj, prev_obj), last_obj)) = previous(diff_objects, curr.idx, i - 1)
|
||||
.zip(previous(diff_objects, curr.idx, i))
|
||||
.zip(previous(diff_objects, curr.idx, i + 1))
|
||||
let Some(((curr_obj, prev_obj), last_obj)) = curr
|
||||
.previous(i - 1, diff_objects)
|
||||
.zip(curr.previous(i, diff_objects))
|
||||
.zip(curr.previous(i + 1, diff_objects))
|
||||
else {
|
||||
break;
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
use crate::any::difficulty::skills::StrainSkill;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct OsuStrainSkill {
|
||||
pub inner: StrainSkill,
|
||||
}
|
||||
|
||||
impl OsuStrainSkill {
|
||||
pub const REDUCED_SECTION_COUNT: usize = 10;
|
||||
pub const REDUCED_STRAIN_BASELINE: f64 = 0.75;
|
||||
pub const DIFFICULTY_MULTIPLER: f64 = 1.06;
|
||||
|
||||
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) -> Vec<f64> {
|
||||
self.inner.get_curr_strain_peaks()
|
||||
}
|
||||
|
||||
pub fn difficulty_value(
|
||||
self,
|
||||
reduced_section_count: usize,
|
||||
reduced_strain_baseline: f64,
|
||||
decay_weight: f64,
|
||||
difficulty_multiplier: f64,
|
||||
) -> f64 {
|
||||
let mut difficulty = 0.0;
|
||||
let mut weight = 1.0;
|
||||
|
||||
let mut peaks = self.get_curr_strain_peaks();
|
||||
peaks.retain(|&strain| strain > 0.0);
|
||||
peaks.sort_by(|a, b| b.total_cmp(a));
|
||||
|
||||
let peaks_iter = peaks.iter_mut().take(reduced_section_count);
|
||||
|
||||
for (i, strain) in peaks_iter.enumerate() {
|
||||
let clamped = f64::from((i as f32 / reduced_section_count as f32).clamp(0.0, 1.0));
|
||||
let scale = (lerp(1.0, 10.0, clamped)).log10();
|
||||
*strain *= lerp(reduced_strain_baseline, 1.0, scale);
|
||||
}
|
||||
|
||||
peaks.sort_by(|a, b| b.total_cmp(a));
|
||||
|
||||
for strain in peaks {
|
||||
difficulty += strain * weight;
|
||||
weight *= decay_weight;
|
||||
}
|
||||
|
||||
difficulty * difficulty_multiplier
|
||||
}
|
||||
}
|
||||
|
||||
fn lerp(start: f64, end: f64, amount: f64) -> f64 {
|
||||
start + (end - start) * amount
|
||||
}
|
||||
@@ -1,261 +0,0 @@
|
||||
use crate::{
|
||||
osu::osu_object::{NestedObjectKind, OsuObjectKind},
|
||||
parse::Pos2,
|
||||
};
|
||||
use std::pin::Pin;
|
||||
|
||||
use super::{osu_object::OsuSlider, OsuObject, ScalingFactor};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct OsuDifficultyObject<'h> {
|
||||
pub(crate) start_time: f64,
|
||||
pub(crate) delta_time: f64,
|
||||
pub(crate) base: Pin<&'h OsuObject>,
|
||||
pub(crate) strain_time: f64,
|
||||
pub(crate) dists: Distances,
|
||||
pub(crate) idx: usize,
|
||||
}
|
||||
|
||||
impl<'h> OsuDifficultyObject<'h> {
|
||||
pub(crate) const MIN_DELTA_TIME: u32 = 25;
|
||||
|
||||
pub(crate) fn new(
|
||||
base: Pin<&'h OsuObject>,
|
||||
last: &'h OsuObject,
|
||||
clock_rate: f64,
|
||||
idx: usize,
|
||||
dists: Distances,
|
||||
) -> Self {
|
||||
let start_time = base.start_time / clock_rate;
|
||||
let delta_time = (base.start_time - last.start_time) / clock_rate;
|
||||
|
||||
// * Capped to 25ms to prevent difficulty calculation breaking from simultaneous objects.
|
||||
let strain_time = delta_time.max(f64::from(Self::MIN_DELTA_TIME));
|
||||
|
||||
Self {
|
||||
start_time,
|
||||
delta_time,
|
||||
base,
|
||||
strain_time,
|
||||
dists,
|
||||
idx,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) 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;
|
||||
const FADE_OUT_DURATION_MULTIPLIER: f64 = 0.3;
|
||||
let fade_out_duration = time_preempt * 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(crate) struct Distances {
|
||||
pub(crate) lazy_jump_dist: f64,
|
||||
pub(crate) lazy_travel_dist: f32,
|
||||
pub(crate) min_jump_dist: f64,
|
||||
pub(crate) min_jump_time: f64,
|
||||
pub(crate) travel_dist: f64,
|
||||
pub(crate) travel_time: f64,
|
||||
pub(crate) angle: Option<f64>,
|
||||
}
|
||||
|
||||
impl Distances {
|
||||
pub(crate) const NORMALISED_RADIUS: f32 = 50.0;
|
||||
|
||||
const MAXIMUM_SLIDER_RADIUS: f32 = Self::NORMALISED_RADIUS * 2.4;
|
||||
const ASSUMED_SLIDER_RADIUS: f32 = Self::NORMALISED_RADIUS * 1.8;
|
||||
|
||||
/// Create a new instance of [`Distances`].
|
||||
///
|
||||
/// By taking in [`Pin<&mut OsuObject>`](Pin), we imply that the argument will be
|
||||
/// modified but it won't be moved.
|
||||
#[allow(clippy::similar_names)]
|
||||
pub(crate) fn new(
|
||||
base: &mut Pin<&mut OsuObject>,
|
||||
last: &OsuObject,
|
||||
last_last: Option<&OsuObject>,
|
||||
clock_rate: f64,
|
||||
strain_time: f64,
|
||||
scaling_factor_: &ScalingFactor,
|
||||
) -> Self {
|
||||
let pos = base.pos();
|
||||
let stack_offset = base.stack_offset;
|
||||
|
||||
let mut this = if let OsuObjectKind::Slider(ref mut slider) = base.kind {
|
||||
let lazy_travel_dist =
|
||||
Self::compute_slider_travel_dist(pos, stack_offset, slider, scaling_factor_);
|
||||
|
||||
let repeat_count = slider.repeat_count();
|
||||
|
||||
Self {
|
||||
// * Bonus for repeat sliders until a better per nested object strain system can be achieved.
|
||||
travel_dist: f64::from(
|
||||
lazy_travel_dist * (1.0 + repeat_count as f64 / 2.5).powf(1.0 / 2.5) as f32,
|
||||
),
|
||||
travel_time: (base.lazy_travel_time() / clock_rate)
|
||||
.max(f64::from(OsuDifficultyObject::MIN_DELTA_TIME)),
|
||||
lazy_travel_dist,
|
||||
..Default::default()
|
||||
}
|
||||
} else {
|
||||
Self::default()
|
||||
};
|
||||
|
||||
// * We don't need to calculate either angle or distance when
|
||||
// * one of the last->curr objects is a spinner
|
||||
if base.is_spinner() || last.is_spinner() {
|
||||
return this;
|
||||
}
|
||||
|
||||
// * We will scale distances by this factor, so we can assume a uniform CircleSize among beatmaps.
|
||||
let scaling_factor = scaling_factor_.factor;
|
||||
|
||||
let last_cursor_pos = Self::get_end_cursor_pos(last);
|
||||
|
||||
this.lazy_jump_dist = f64::from(
|
||||
(base.stacked_pos() * scaling_factor - last_cursor_pos * scaling_factor).length(),
|
||||
);
|
||||
this.min_jump_time = strain_time;
|
||||
this.min_jump_dist = this.lazy_jump_dist;
|
||||
|
||||
if let OsuObjectKind::Slider(slider) = &last.kind {
|
||||
let last_travel_time = (last.lazy_travel_time() / clock_rate)
|
||||
.max(f64::from(OsuDifficultyObject::MIN_DELTA_TIME));
|
||||
this.min_jump_time = (strain_time - last_travel_time)
|
||||
.max(f64::from(OsuDifficultyObject::MIN_DELTA_TIME));
|
||||
|
||||
// * There are two types of slider-to-object patterns to consider in order
|
||||
// * to better approximate the real movement a player will take to jump between the hitobjects.
|
||||
// *
|
||||
// * 1. The anti-flow pattern, where players cut the slider short in order to move to the next hitobject.
|
||||
// *
|
||||
// * <======o==> ← slider
|
||||
// * | ← most natural jump path
|
||||
// * o ← a follow-up hitcircle
|
||||
// *
|
||||
// * In this case the most natural jump path is approximated by LazyJumpDistance.
|
||||
// *
|
||||
// * 2. The flow pattern, where players follow through the slider to its
|
||||
// * visual extent into the next hitobject.
|
||||
// *
|
||||
// * <======o==>---o
|
||||
// * ↑
|
||||
// * most natural jump path
|
||||
// *
|
||||
// * In this case the most natural jump path is better approximated by a new distance
|
||||
// * called "tailJumpDistance" - the distance between the slider's tail and the next hitobject.
|
||||
// *
|
||||
// * Thus, the player is assumed to jump the minimum of these two distances in all cases.
|
||||
|
||||
let stacked_tail_pos =
|
||||
slider.tail().map_or_else(|| last.pos(), |tail| tail.pos) + last.stack_offset;
|
||||
|
||||
let tail_jump_dist = (stacked_tail_pos - base.stacked_pos()).length() * scaling_factor;
|
||||
|
||||
let diff = f64::from(Self::MAXIMUM_SLIDER_RADIUS - Self::ASSUMED_SLIDER_RADIUS);
|
||||
let min = f64::from(tail_jump_dist - Self::MAXIMUM_SLIDER_RADIUS);
|
||||
|
||||
// "attributes on expressions are experimental see issue #15701 https://github.com/rust-lang/rust/issues/15701"
|
||||
// rust pls...
|
||||
#[allow(clippy::manual_clamp)]
|
||||
let tmp = (this.lazy_jump_dist - diff).min(min).max(0.0);
|
||||
this.min_jump_dist = tmp;
|
||||
}
|
||||
|
||||
if let Some(last_last) = last_last.filter(|obj| !obj.is_spinner()) {
|
||||
let last_last_cursor_pos = Self::get_end_cursor_pos(last_last);
|
||||
|
||||
let v1 = last_last_cursor_pos - last.stacked_pos();
|
||||
let v2 = base.stacked_pos() - last_cursor_pos;
|
||||
|
||||
let dot = f64::from(v1.dot(v2));
|
||||
let det = f64::from(v1.x * v2.y - v1.y * v2.x);
|
||||
|
||||
this.angle = Some(det.atan2(dot).abs());
|
||||
}
|
||||
|
||||
this
|
||||
}
|
||||
|
||||
pub(crate) fn compute_slider_travel_dist(
|
||||
pos: Pos2,
|
||||
stack_offset: Pos2,
|
||||
slider: &mut OsuSlider,
|
||||
scaling_factor_: &ScalingFactor,
|
||||
) -> f32 {
|
||||
let mut curr_cursor_pos = pos + stack_offset;
|
||||
let scaling_factor = f64::from(Self::NORMALISED_RADIUS) / f64::from(scaling_factor_.radius);
|
||||
|
||||
let mut lazy_travel_dist: f32 = 0.0;
|
||||
|
||||
for (curr_movement_obj, i) in slider.nested_objects.iter().zip(1..) {
|
||||
let mut curr_movement = (curr_movement_obj.pos + stack_offset) - curr_cursor_pos;
|
||||
let mut curr_movement_len = scaling_factor * f64::from(curr_movement.length());
|
||||
|
||||
// * Amount of movement required so that the cursor position needs to be updated.
|
||||
let mut required_movement = f64::from(Self::ASSUMED_SLIDER_RADIUS);
|
||||
|
||||
if i == slider.nested_objects.len() {
|
||||
// * The end of a slider has special aim rules due
|
||||
// * to the relaxed time constraint on position.
|
||||
// * There is both a lazy end position as well as the actual end slider position.
|
||||
// * We assume the player takes the simpler movement.
|
||||
// * For sliders that are circular, the lazy end position
|
||||
// * may actually be farther away than the sliders true end.
|
||||
// * This code is designed to prevent buffing situations
|
||||
// * where lazy end is actually a less efficient movement.
|
||||
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 let NestedObjectKind::Repeat = curr_movement_obj.kind {
|
||||
// * For a slider repeat, assume a tighter movement threshold to better assess repeat sliders.
|
||||
required_movement = f64::from(Self::NORMALISED_RADIUS);
|
||||
}
|
||||
|
||||
if curr_movement_len > required_movement {
|
||||
// * this finds the positional delta from the required radius and the current position, and updates the currCursorPosition accordingly, as well as rewarding distance.
|
||||
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;
|
||||
lazy_travel_dist += curr_movement_len as f32;
|
||||
}
|
||||
}
|
||||
|
||||
slider.lazy_end_pos = curr_cursor_pos;
|
||||
|
||||
lazy_travel_dist
|
||||
}
|
||||
|
||||
fn get_end_cursor_pos(hit_object: &OsuObject) -> Pos2 {
|
||||
hit_object.lazy_end_pos()
|
||||
}
|
||||
}
|
||||
@@ -1,375 +0,0 @@
|
||||
#![cfg(feature = "gradual")]
|
||||
|
||||
use std::{
|
||||
fmt::{Debug, Formatter, Result as FmtResult},
|
||||
mem,
|
||||
pin::Pin,
|
||||
};
|
||||
|
||||
use crate::{Beatmap, Mods};
|
||||
|
||||
use self::osu_objects::OsuObjects;
|
||||
|
||||
use super::{
|
||||
difficulty_object::{Distances, OsuDifficultyObject},
|
||||
osu_object::{OsuObject, OsuObjectKind},
|
||||
scaling_factor::ScalingFactor,
|
||||
skills::{Skill, Skills},
|
||||
OsuDifficultyAttributes, DIFFICULTY_MULTIPLIER, FADE_IN_DURATION_MULTIPLIER,
|
||||
PERFORMANCE_BASE_MULTIPLIER, PREEMPT_MIN,
|
||||
};
|
||||
|
||||
/// Gradually calculate the difficulty attributes of an osu!standard map.
|
||||
///
|
||||
/// Note that this struct implements [`Iterator`].
|
||||
/// On every call of [`Iterator::next`], the map's next hit object will
|
||||
/// be processed and the [`OsuDifficultyAttributes`] will be updated and returned.
|
||||
///
|
||||
/// If you want to calculate performance attributes, use
|
||||
/// [`OsuGradualPerformance`](crate::osu::OsuGradualPerformance) instead.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use rosu_pp::{Beatmap, osu::OsuGradualDifficulty};
|
||||
///
|
||||
/// # /*
|
||||
/// let map: Beatmap = ...
|
||||
/// # */
|
||||
/// # let map = Beatmap::default();
|
||||
///
|
||||
/// let mods = 64; // DT
|
||||
/// let mut iter = OsuGradualDifficulty::new(&map, mods);
|
||||
///
|
||||
/// let attrs1 = iter.next(); // the difficulty of the map after the first hit object
|
||||
/// let attrs2 = iter.next(); // after the second hit object
|
||||
///
|
||||
/// // Remaining hit objects
|
||||
/// for difficulty in iter {
|
||||
/// // ...
|
||||
/// }
|
||||
/// ```
|
||||
#[cfg_attr(docsrs, doc(cfg(feature = "gradual")))]
|
||||
pub struct OsuGradualDifficulty {
|
||||
pub(crate) idx: usize,
|
||||
mods: u32,
|
||||
attrs: OsuDifficultyAttributes,
|
||||
skills: Skills,
|
||||
// Lifetimes actually depend on `osu_objects` so this type is self-referential.
|
||||
// This field must be treated with great caution, moving `osu_objects` will immediately
|
||||
// invalidate `diff_objects`.
|
||||
diff_objects: Vec<OsuDifficultyObject<'static>>,
|
||||
osu_objects: OsuObjects,
|
||||
// Additional safety measure that this type can't be cloned which would invalidate
|
||||
// `diff_objects`.
|
||||
_not_clonable: NotClonable,
|
||||
}
|
||||
|
||||
struct NotClonable;
|
||||
|
||||
impl Debug for OsuGradualDifficulty {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
|
||||
f.debug_struct("OsuGradualDifficulty")
|
||||
.field("mods", &self.mods)
|
||||
.field("idx", &self.idx)
|
||||
.field("attrs", &self.attrs)
|
||||
.field("diff_objects", &self.diff_objects)
|
||||
.field("osu_objects", &"...")
|
||||
.field("skills", &self.skills)
|
||||
.field("_not_clonable", &"...")
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl OsuGradualDifficulty {
|
||||
/// Create a new difficulty attributes iterator for osu!standard maps.
|
||||
pub fn new(map: &Beatmap, mods: u32) -> Self {
|
||||
let clock_rate = mods.clock_rate();
|
||||
let map_attrs = map.attributes().mods(mods).build();
|
||||
let scaling_factor = ScalingFactor::new(map_attrs.cs);
|
||||
let hr = mods.hr();
|
||||
let hit_window = 2.0 * map_attrs.hit_windows.od;
|
||||
let time_preempt = f64::from((map_attrs.hit_windows.ar * clock_rate) as f32);
|
||||
|
||||
// * Preempt time can go below 450ms. Normally, this is achieved via the DT mod
|
||||
// * which uniformly speeds up all animations game wide regardless of AR.
|
||||
// * This uniform speedup is hard to match 1:1, however we can at least make
|
||||
// * AR>10 (via mods) feel good by extending the upper linear function above.
|
||||
// * Note that this doesn't exactly match the AR>10 visuals as they're
|
||||
// * classically known, but it feels good.
|
||||
// * This adjustment is necessary for AR>10, otherwise TimePreempt can
|
||||
// * become smaller leading to hitcircles not fully fading in.
|
||||
let time_fade_in = if mods.hd() {
|
||||
time_preempt * FADE_IN_DURATION_MULTIPLIER
|
||||
} else {
|
||||
400.0 * (time_preempt / PREEMPT_MIN).min(1.0)
|
||||
};
|
||||
|
||||
let mut attrs = OsuDifficultyAttributes {
|
||||
ar: map_attrs.ar,
|
||||
hp: map_attrs.hp,
|
||||
od: map_attrs.od,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let hit_objects = crate::osu::create_osu_objects(
|
||||
map,
|
||||
&mut attrs,
|
||||
&scaling_factor,
|
||||
map.hit_objects.len(),
|
||||
hr,
|
||||
time_preempt,
|
||||
);
|
||||
|
||||
let mut osu_objects = OsuObjects::new(hit_objects);
|
||||
|
||||
attrs.n_circles = 0;
|
||||
attrs.n_sliders = 0;
|
||||
attrs.n_spinners = 0;
|
||||
attrs.max_combo = 0;
|
||||
|
||||
let skills = Skills::new(
|
||||
mods,
|
||||
scaling_factor.radius,
|
||||
time_preempt,
|
||||
time_fade_in,
|
||||
hit_window,
|
||||
);
|
||||
|
||||
let mut osu_objects_iter = osu_objects.iter_mut();
|
||||
|
||||
let Some(mut last) = osu_objects_iter.next() else {
|
||||
return Self {
|
||||
idx: 0,
|
||||
mods,
|
||||
attrs,
|
||||
skills,
|
||||
diff_objects: Vec::new(),
|
||||
osu_objects: OsuObjects::new(Vec::new()),
|
||||
_not_clonable: NotClonable,
|
||||
};
|
||||
};
|
||||
|
||||
Self::increment_combo(last.as_ref().get_ref(), &mut attrs);
|
||||
|
||||
let mut last_last = None;
|
||||
|
||||
// Prepare `lazy_travel_dist` and `lazy_end_pos` for `last` manually
|
||||
let last_pos = last.pos();
|
||||
let last_stack_offset = last.stack_offset;
|
||||
|
||||
if let OsuObjectKind::Slider(ref mut slider) = last.kind {
|
||||
Distances::compute_slider_travel_dist(
|
||||
last_pos,
|
||||
last_stack_offset,
|
||||
slider,
|
||||
&scaling_factor,
|
||||
);
|
||||
}
|
||||
|
||||
let mut last = last.into_ref();
|
||||
let mut diff_objects = Vec::with_capacity(map.hit_objects.len().saturating_sub(2));
|
||||
|
||||
for (i, mut curr) in osu_objects_iter.enumerate() {
|
||||
let delta_time = (curr.start_time - last.start_time) / clock_rate;
|
||||
|
||||
// * Capped to 25ms to prevent difficulty calculation breaking from simultaneous objects.
|
||||
let strain_time = delta_time.max(f64::from(OsuDifficultyObject::MIN_DELTA_TIME));
|
||||
|
||||
let dists = Distances::new(
|
||||
&mut curr,
|
||||
last.get_ref(),
|
||||
last_last.map(Pin::get_ref),
|
||||
clock_rate,
|
||||
strain_time,
|
||||
&scaling_factor,
|
||||
);
|
||||
|
||||
let curr = curr.into_ref();
|
||||
|
||||
let diff_obj = OsuDifficultyObject::new(curr, last.get_ref(), clock_rate, i, dists);
|
||||
diff_objects.push(diff_obj);
|
||||
|
||||
last_last = Some(last);
|
||||
last = curr;
|
||||
}
|
||||
|
||||
Self {
|
||||
idx: 0,
|
||||
mods,
|
||||
attrs,
|
||||
skills,
|
||||
diff_objects: extend_lifetime(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();
|
||||
}
|
||||
OsuObjectKind::Spinner { .. } => attrs.n_spinners += 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn extend_lifetime(
|
||||
diff_objects: Vec<OsuDifficultyObject<'_>>,
|
||||
) -> Vec<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)?;
|
||||
self.skills.process(curr, &self.diff_objects);
|
||||
Self::increment_combo(curr.base.get_ref(), &mut self.attrs);
|
||||
} else if self.osu_objects.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
self.idx += 1;
|
||||
|
||||
let Skills {
|
||||
mut aim,
|
||||
mut aim_no_sliders,
|
||||
mut speed,
|
||||
mut flashlight,
|
||||
} = self.skills.clone();
|
||||
|
||||
let mut aim_rating = aim.difficulty_value().sqrt() * DIFFICULTY_MULTIPLIER;
|
||||
let aim_rating_no_sliders =
|
||||
aim_no_sliders.difficulty_value().sqrt() * DIFFICULTY_MULTIPLIER;
|
||||
|
||||
let speed_notes = speed.relevant_note_count();
|
||||
let mut speed_rating = speed.difficulty_value().sqrt() * DIFFICULTY_MULTIPLIER;
|
||||
|
||||
let mut flashlight_rating = flashlight.difficulty_value().sqrt() * DIFFICULTY_MULTIPLIER;
|
||||
|
||||
let slider_factor = if aim_rating > 0.0 {
|
||||
aim_rating_no_sliders / aim_rating
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
|
||||
if self.mods.td() {
|
||||
aim_rating = aim_rating.powf(0.8);
|
||||
flashlight_rating = flashlight_rating.powf(0.8);
|
||||
}
|
||||
|
||||
if self.mods.rx() {
|
||||
aim_rating *= 0.9;
|
||||
speed_rating = 0.0;
|
||||
flashlight_rating *= 0.7;
|
||||
}
|
||||
|
||||
let base_aim_performance = (5.0 * (aim_rating / 0.0675).max(1.0) - 4.0).powi(3) / 100_000.0;
|
||||
let base_speed_performance =
|
||||
(5.0 * (speed_rating / 0.0675).max(1.0) - 4.0).powi(3) / 100_000.0;
|
||||
|
||||
let base_flashlight_performance = if self.mods.fl() {
|
||||
flashlight_rating * flashlight_rating * 25.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
let base_performance = ((base_aim_performance).powf(1.1)
|
||||
+ (base_speed_performance).powf(1.1)
|
||||
+ (base_flashlight_performance).powf(1.1))
|
||||
.powf(1.0 / 1.1);
|
||||
|
||||
let star_rating = if base_performance > 0.00001 {
|
||||
PERFORMANCE_BASE_MULTIPLIER.cbrt()
|
||||
* 0.027
|
||||
* ((100_000.0 / 2.0_f64.powf(1.0 / 1.1) * base_performance).cbrt() + 4.0)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
let attrs = OsuDifficultyAttributes {
|
||||
aim: aim_rating,
|
||||
speed: speed_rating,
|
||||
flashlight: flashlight_rating,
|
||||
slider_factor,
|
||||
stars: star_rating,
|
||||
speed_note_count: speed_notes,
|
||||
..self.attrs.clone()
|
||||
};
|
||||
|
||||
Some(attrs)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn size_hint(&self) -> (usize, Option<usize>) {
|
||||
let len = self.len();
|
||||
|
||||
(len, Some(len))
|
||||
}
|
||||
|
||||
fn nth(&mut self, n: usize) -> Option<Self::Item> {
|
||||
let skip_iter = self.diff_objects.iter().skip(self.idx.saturating_sub(1));
|
||||
|
||||
let mut take = n.min(self.len().saturating_sub(1));
|
||||
|
||||
// The first note has no difficulty object
|
||||
if self.idx == 0 && take > 0 {
|
||||
take -= 1;
|
||||
self.idx += 1;
|
||||
}
|
||||
|
||||
for curr in skip_iter.take(take) {
|
||||
self.skills.process(curr, &self.diff_objects);
|
||||
Self::increment_combo(curr.base.get_ref(), &mut self.attrs);
|
||||
self.idx += 1;
|
||||
}
|
||||
|
||||
self.next()
|
||||
}
|
||||
}
|
||||
|
||||
impl ExactSizeIterator for OsuGradualDifficulty {
|
||||
#[inline]
|
||||
fn len(&self) -> usize {
|
||||
self.diff_objects.len() + 1 - self.idx
|
||||
}
|
||||
}
|
||||
|
||||
mod osu_objects {
|
||||
use crate::osu::OsuObject;
|
||||
use std::pin::Pin;
|
||||
|
||||
// Wrapper to ensure that the data will not be moved
|
||||
pub(super) struct OsuObjects {
|
||||
objects: Box<[OsuObject]>,
|
||||
}
|
||||
|
||||
impl OsuObjects {
|
||||
pub(super) fn new(objects: Vec<OsuObject>) -> Self {
|
||||
Self {
|
||||
objects: objects.into_boxed_slice(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) const fn is_empty(&self) -> bool {
|
||||
self.objects.is_empty()
|
||||
}
|
||||
|
||||
pub(super) fn iter_mut(&mut self) -> impl Iterator<Item = Pin<&mut OsuObject>> {
|
||||
self.objects.iter_mut().map(Pin::new)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,194 +0,0 @@
|
||||
#![cfg(feature = "gradual")]
|
||||
|
||||
use crate::{Beatmap, OsuPP};
|
||||
|
||||
use super::{OsuGradualDifficulty, OsuPerformanceAttributes, OsuScoreState};
|
||||
|
||||
/// Gradually calculate the performance attributes of an osu!standard map.
|
||||
///
|
||||
/// After each hit object you can call [`next`](`OsuGradualPerformance::next`)
|
||||
/// and it will return the resulting current [`OsuPerformanceAttributes`].
|
||||
/// To process multiple objects at once, use [`nth`](`OsuGradualPerformance::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, osu::{OsuGradualPerformance, OsuScoreState}};
|
||||
///
|
||||
/// # /*
|
||||
/// let map: Beatmap = ...
|
||||
/// # */
|
||||
/// # let map = Beatmap::default();
|
||||
///
|
||||
/// let mods = 64; // DT
|
||||
/// let mut gradual_perf = OsuGradualPerformance::new(&map, mods);
|
||||
/// let mut state = OsuScoreState::new(); // empty state, everything is on 0.
|
||||
///
|
||||
/// // The first 10 hitresults are 300s and there are no sliders for additional combo
|
||||
/// for _ in 0..10 {
|
||||
/// state.n300 += 1;
|
||||
/// state.max_combo += 1;
|
||||
///
|
||||
/// # /*
|
||||
/// let performance = gradual_perf.next(state.clone()).unwrap();
|
||||
/// println!("PP: {}", performance.pp);
|
||||
/// # */
|
||||
/// # let _ = gradual_perf.next(state.clone());
|
||||
/// }
|
||||
///
|
||||
/// // Then comes a miss.
|
||||
/// // Note that state's max combo won't be incremented for
|
||||
/// // the next few objects because the combo is reset.
|
||||
/// state.n_misses += 1;
|
||||
/// # /*
|
||||
/// let performance = gradual_perf.next(state.clone()).unwrap();
|
||||
/// println!("PP: {}", performance.pp);
|
||||
/// # */
|
||||
/// # let _ = gradual_perf.next(state.clone());
|
||||
///
|
||||
/// // The next 10 objects will be a mixture of 300s, 100s, and 50s.
|
||||
/// // Notice how all 10 objects will be processed in one go.
|
||||
/// state.n300 += 2;
|
||||
/// state.n100 += 7;
|
||||
/// state.n50 += 1;
|
||||
/// // The `nth` method takes a zero-based value.
|
||||
/// # /*
|
||||
/// let performance = gradual_perf.nth(state.clone(), 9).unwrap();
|
||||
/// println!("PP: {}", performance.pp);
|
||||
/// # */
|
||||
/// # let _ = gradual_perf.nth(state.clone(), 9);
|
||||
///
|
||||
/// // Now comes another 300. Note that the max combo gets incremented again.
|
||||
/// state.n300 += 1;
|
||||
/// state.max_combo += 1;
|
||||
/// # /*
|
||||
/// let performance = gradual_perf.next(state.clone()).unwrap();
|
||||
/// println!("PP: {}", performance.pp);
|
||||
/// # */
|
||||
/// # let _ = gradual_perf.next(state.clone());
|
||||
///
|
||||
/// // Skip to the end
|
||||
/// # /*
|
||||
/// state.max_combo = ...
|
||||
/// state.n300 = ...
|
||||
/// state.n100 = ...
|
||||
/// state.n50 = ...
|
||||
/// state.n_misses = ...
|
||||
/// let final_performance = gradual_perf.nth(state.clone(), usize::MAX).unwrap();
|
||||
/// println!("PP: {}", performance.pp);
|
||||
/// # */
|
||||
/// # let _ = gradual_perf.nth(state.clone(), usize::MAX);
|
||||
///
|
||||
/// // Once the final performance was calculated,
|
||||
/// // attempting to process further objects will return `None`.
|
||||
/// assert!(gradual_perf.next(state).is_none());
|
||||
/// ```
|
||||
#[cfg_attr(docsrs, doc(cfg(feature = "gradual")))]
|
||||
#[derive(Debug)]
|
||||
pub struct OsuGradualPerformance<'map> {
|
||||
difficulty: OsuGradualDifficulty,
|
||||
performance: OsuPP<'map>,
|
||||
}
|
||||
|
||||
impl<'map> OsuGradualPerformance<'map> {
|
||||
/// Create a new gradual performance calculator for osu!standard maps.
|
||||
pub fn new(map: &'map Beatmap, mods: u32) -> Self {
|
||||
let difficulty = OsuGradualDifficulty::new(map, mods);
|
||||
let performance = OsuPP::new(map).mods(mods).passed_objects(0);
|
||||
|
||||
Self {
|
||||
difficulty,
|
||||
performance,
|
||||
}
|
||||
}
|
||||
|
||||
/// Process the next hit object and calculate the
|
||||
/// performance attributes for the resulting score state.
|
||||
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 the the next `n`th hit object and calculate the performance
|
||||
/// attributes for the resulting score state.
|
||||
///
|
||||
/// Note that the count is zero-indexed, so `n=0` will process 1 object, `n=1` will process 2,
|
||||
/// and so on.
|
||||
pub fn nth(&mut self, state: OsuScoreState, n: usize) -> Option<OsuPerformanceAttributes> {
|
||||
let difficulty = self.difficulty.nth(n)?;
|
||||
|
||||
let performance = self
|
||||
.performance
|
||||
.clone()
|
||||
.attributes(difficulty)
|
||||
.state(state)
|
||||
.passed_objects(self.difficulty.idx)
|
||||
.calculate();
|
||||
|
||||
Some(performance)
|
||||
}
|
||||
}
|
||||
|
||||
/// Gradually calculate the performance attributes of an osu!standard map.
|
||||
///
|
||||
/// Check [`OsuGradualPerformance`] for more information. This struct does the same
|
||||
/// but takes ownership of [`Beatmap`] to avoid being bound to a lifetime.
|
||||
#[cfg_attr(docsrs, doc(cfg(feature = "gradual")))]
|
||||
#[derive(Debug)]
|
||||
pub struct OsuOwnedGradualPerformance {
|
||||
difficulty: OsuGradualDifficulty,
|
||||
map: Beatmap,
|
||||
mods: u32,
|
||||
}
|
||||
|
||||
impl OsuOwnedGradualPerformance {
|
||||
/// Create a new gradual performance calculator for osu!standard maps.
|
||||
pub fn new(map: Beatmap, mods: u32) -> Self {
|
||||
let difficulty = OsuGradualDifficulty::new(&map, mods);
|
||||
|
||||
Self {
|
||||
difficulty,
|
||||
map,
|
||||
mods,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 the the next `n`th hit object and calculate the performance
|
||||
/// attributes for the resulting score state.
|
||||
///
|
||||
/// Note that the count is zero-indexed, so `n=0` will process 1 object, `n=1` will process 2,
|
||||
/// and so on.
|
||||
pub fn nth(&mut self, state: OsuScoreState, n: usize) -> Option<OsuPerformanceAttributes> {
|
||||
let difficulty = self.difficulty.nth(n)?;
|
||||
|
||||
let performance = OsuPP::new(&self.map)
|
||||
.mods(self.mods)
|
||||
.attributes(difficulty)
|
||||
.state(state)
|
||||
.passed_objects(self.difficulty.idx)
|
||||
.calculate();
|
||||
|
||||
Some(performance)
|
||||
}
|
||||
}
|
||||
+37
-643
@@ -1,660 +1,54 @@
|
||||
mod difficulty_object;
|
||||
mod osu_object;
|
||||
mod pp;
|
||||
mod scaling_factor;
|
||||
mod score_state;
|
||||
mod skills;
|
||||
use std::borrow::Cow;
|
||||
|
||||
#[cfg(feature = "gradual")]
|
||||
mod gradual_difficulty;
|
||||
#[cfg(feature = "gradual")]
|
||||
mod gradual_performance;
|
||||
use rosu_map::util::Pos;
|
||||
|
||||
use crate::{curve::CurveBuffers, parse::Pos2, AnyStars, Beatmap, GameMode, Mods};
|
||||
use std::pin::Pin;
|
||||
|
||||
use self::{
|
||||
difficulty_object::{Distances, OsuDifficultyObject},
|
||||
skills::{Skill, Skills},
|
||||
use crate::{
|
||||
any::ModeDifficulty,
|
||||
model::{
|
||||
beatmap::Beatmap,
|
||||
mode::{ConvertStatus, IGameMode},
|
||||
},
|
||||
};
|
||||
|
||||
pub use self::{osu_object::*, pp::*, score_state::OsuScoreState};
|
||||
|
||||
#[cfg(feature = "gradual")]
|
||||
pub use self::{
|
||||
gradual_difficulty::OsuGradualDifficulty,
|
||||
gradual_performance::{OsuGradualPerformance, OsuOwnedGradualPerformance},
|
||||
attributes::{OsuDifficultyAttributes, OsuPerformanceAttributes},
|
||||
convert::OsuBeatmap,
|
||||
performance::OsuPerformance,
|
||||
score_state::OsuScoreState,
|
||||
strains::OsuStrains,
|
||||
};
|
||||
|
||||
pub(crate) use self::scaling_factor::ScalingFactor;
|
||||
mod attributes;
|
||||
mod convert;
|
||||
mod difficulty;
|
||||
mod object;
|
||||
mod performance;
|
||||
mod score_state;
|
||||
mod strains;
|
||||
|
||||
const SECTION_LEN: f64 = 400.0;
|
||||
const DIFFICULTY_MULTIPLIER: f64 = 0.0675;
|
||||
// * Change radius to 50 to make 100 the diameter. Easier for mental maths.
|
||||
const NORMALIZED_RADIUS: f32 = 50.0;
|
||||
const STACK_DISTANCE: f32 = 3.0;
|
||||
// * This is being adjusted to keep the final pp value scaled around what it used to be when changing things.
|
||||
const PERFORMANCE_BASE_MULTIPLIER: f64 = 1.14;
|
||||
const PREEMPT_MIN: f64 = 450.0;
|
||||
const FADE_IN_DURATION_MULTIPLIER: f64 = 0.4;
|
||||
const PLAYFIELD_BASE_SIZE: Pos2 = Pos2 { x: 512.0, y: 384.0 };
|
||||
const PLAYFIELD_BASE_SIZE: Pos = Pos::new(512.0, 384.0);
|
||||
|
||||
/// Difficulty calculator on osu!standard maps.
|
||||
/// Marker type for [`GameMode::Osu`].
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use rosu_pp::{OsuStars, Beatmap};
|
||||
///
|
||||
/// # /*
|
||||
/// let map: Beatmap = ...
|
||||
/// # */
|
||||
/// # let map = Beatmap::default();
|
||||
///
|
||||
/// let difficulty_attrs = OsuStars::new(&map)
|
||||
/// .mods(8 + 64) // HDDT
|
||||
/// .calculate();
|
||||
///
|
||||
/// println!("Stars: {}", difficulty_attrs.stars);
|
||||
/// ```
|
||||
#[derive(Clone, Debug)]
|
||||
#[must_use]
|
||||
pub struct OsuStars<'map> {
|
||||
pub(crate) map: &'map Beatmap,
|
||||
pub(crate) mods: u32,
|
||||
pub(crate) passed_objects: Option<usize>,
|
||||
pub(crate) clock_rate: Option<f64>,
|
||||
}
|
||||
/// [`GameMode::Osu`]: rosu_map::section::general::GameMode::Osu
|
||||
pub struct Osu;
|
||||
|
||||
impl<'map> OsuStars<'map> {
|
||||
/// Create a new difficulty calculator for osu!standard maps.
|
||||
#[inline]
|
||||
pub const fn new(map: &'map Beatmap) -> Self {
|
||||
Self {
|
||||
map,
|
||||
mods: 0,
|
||||
passed_objects: None,
|
||||
clock_rate: None,
|
||||
}
|
||||
impl IGameMode for Osu {
|
||||
type DifficultyAttributes = OsuDifficultyAttributes;
|
||||
type Strains = OsuStrains;
|
||||
|
||||
fn try_convert(map: &mut Cow<'_, Beatmap>) -> ConvertStatus {
|
||||
convert::try_convert(map)
|
||||
}
|
||||
|
||||
/// Convert the map into another mode.
|
||||
#[inline]
|
||||
pub fn mode(self, mode: GameMode) -> AnyStars<'map> {
|
||||
match mode {
|
||||
GameMode::Osu => AnyStars::Osu(self),
|
||||
GameMode::Taiko => AnyStars::Taiko(self.into()),
|
||||
GameMode::Catch => AnyStars::Catch(self.into()),
|
||||
GameMode::Mania => AnyStars::Mania(self.into()),
|
||||
}
|
||||
fn difficulty(
|
||||
difficulty: &ModeDifficulty,
|
||||
converted: &OsuBeatmap<'_>,
|
||||
) -> Self::DifficultyAttributes {
|
||||
difficulty::difficulty(difficulty, converted)
|
||||
}
|
||||
|
||||
/// 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 const fn mods(mut self, mods: u32) -> Self {
|
||||
self.mods = mods;
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
/// Amount of passed objects for partial plays, e.g. a fail.
|
||||
///
|
||||
#[cfg_attr(
|
||||
feature = "gradual",
|
||||
doc = "If you want to calculate the difficulty after every few objects, instead of
|
||||
using [`OsuStars`] multiple times with different `passed_objects`, you should use
|
||||
[`OsuGradualDifficulty`]."
|
||||
)]
|
||||
#[inline]
|
||||
pub const fn passed_objects(mut self, passed_objects: usize) -> Self {
|
||||
self.passed_objects = Some(passed_objects);
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
/// Adjust the clock rate used in the calculation.
|
||||
/// If none is specified, it will take the clock rate based on the mods
|
||||
/// i.e. 1.5 for DT, 0.75 for HT and 1.0 otherwise.
|
||||
#[inline]
|
||||
pub const fn clock_rate(mut self, clock_rate: f64) -> Self {
|
||||
self.clock_rate = Some(clock_rate);
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
/// Calculate all difficulty related values, including stars.
|
||||
#[inline]
|
||||
pub fn calculate(self) -> OsuDifficultyAttributes {
|
||||
let mods = self.mods;
|
||||
|
||||
let (skills, mut attrs) = calculate_skills(self);
|
||||
|
||||
let Skills {
|
||||
mut aim,
|
||||
mut aim_no_sliders,
|
||||
mut speed,
|
||||
mut flashlight,
|
||||
} = skills;
|
||||
|
||||
let mut aim_rating = aim.difficulty_value().sqrt() * DIFFICULTY_MULTIPLIER;
|
||||
let aim_rating_no_sliders =
|
||||
aim_no_sliders.difficulty_value().sqrt() * DIFFICULTY_MULTIPLIER;
|
||||
|
||||
let speed_notes = speed.relevant_note_count();
|
||||
let mut speed_rating = speed.difficulty_value().sqrt() * DIFFICULTY_MULTIPLIER;
|
||||
|
||||
let mut flashlight_rating = flashlight.difficulty_value().sqrt() * DIFFICULTY_MULTIPLIER;
|
||||
|
||||
let slider_factor = if aim_rating > 0.0 {
|
||||
aim_rating_no_sliders / aim_rating
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
|
||||
if mods.td() {
|
||||
aim_rating = aim_rating.powf(0.8);
|
||||
flashlight_rating = flashlight_rating.powf(0.8);
|
||||
}
|
||||
|
||||
if mods.rx() {
|
||||
aim_rating *= 0.9;
|
||||
speed_rating = 0.0;
|
||||
flashlight_rating *= 0.7;
|
||||
}
|
||||
|
||||
let base_aim_performance = (5.0 * (aim_rating / 0.0675).max(1.0) - 4.0).powi(3) / 100_000.0;
|
||||
let base_speed_performance =
|
||||
(5.0 * (speed_rating / 0.0675).max(1.0) - 4.0).powi(3) / 100_000.0;
|
||||
|
||||
let base_flashlight_performance = if mods.fl() {
|
||||
flashlight_rating * flashlight_rating * 25.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
let base_performance = ((base_aim_performance).powf(1.1)
|
||||
+ (base_speed_performance).powf(1.1)
|
||||
+ (base_flashlight_performance).powf(1.1))
|
||||
.powf(1.0 / 1.1);
|
||||
|
||||
let star_rating = if base_performance > 0.00001 {
|
||||
PERFORMANCE_BASE_MULTIPLIER.cbrt()
|
||||
* 0.027
|
||||
* ((100_000.0 / 2.0_f64.powf(1.0 / 1.1) * base_performance).cbrt() + 4.0)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
attrs.aim = aim_rating;
|
||||
attrs.speed = speed_rating;
|
||||
attrs.flashlight = flashlight_rating;
|
||||
attrs.slider_factor = slider_factor;
|
||||
attrs.stars = star_rating;
|
||||
attrs.speed_note_count = speed_notes;
|
||||
|
||||
attrs
|
||||
}
|
||||
|
||||
/// Calculate the skill strains.
|
||||
///
|
||||
/// Suitable to plot the difficulty of a map over time.
|
||||
#[inline]
|
||||
pub fn strains(self) -> OsuStrains {
|
||||
let (skills, _) = calculate_skills(self);
|
||||
|
||||
let Skills {
|
||||
aim,
|
||||
aim_no_sliders,
|
||||
speed,
|
||||
flashlight,
|
||||
} = skills;
|
||||
|
||||
OsuStrains {
|
||||
section_len: SECTION_LEN,
|
||||
aim: aim.strain_peaks.to_vec(),
|
||||
aim_no_sliders: aim_no_sliders.strain_peaks.to_vec(),
|
||||
speed: speed.strain_peaks.to_vec(),
|
||||
flashlight: flashlight.strain_peaks.to_vec(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The result of calculating the strains on a osu! map.
|
||||
/// Suitable to plot the difficulty of a map over time.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct OsuStrains {
|
||||
/// Time in ms inbetween two strains.
|
||||
pub section_len: f64, // TODO: remove field, make it a method
|
||||
/// Strain peaks of the aim skill.
|
||||
pub aim: Vec<f64>,
|
||||
/// Strain peaks of the aim skill without sliders.
|
||||
pub aim_no_sliders: Vec<f64>,
|
||||
/// Strain peaks of the speed skill.
|
||||
pub speed: Vec<f64>,
|
||||
/// Strain peaks of the flashlight skill.
|
||||
pub flashlight: Vec<f64>,
|
||||
}
|
||||
|
||||
impl OsuStrains {
|
||||
/// Returns the number of strain peaks per skill.
|
||||
#[inline]
|
||||
#[allow(clippy::len_without_is_empty)]
|
||||
pub fn len(&self) -> usize {
|
||||
self.aim.len()
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::needless_pass_by_value)]
|
||||
fn calculate_skills(params: OsuStars<'_>) -> (Skills, OsuDifficultyAttributes) {
|
||||
let OsuStars {
|
||||
map,
|
||||
mods,
|
||||
passed_objects,
|
||||
clock_rate,
|
||||
} = params;
|
||||
|
||||
let take = passed_objects.unwrap_or(map.hit_objects.len());
|
||||
let clock_rate = clock_rate.unwrap_or_else(|| mods.clock_rate());
|
||||
|
||||
let map_attrs = map.attributes().mods(mods).clock_rate(clock_rate).build();
|
||||
let scaling_factor = ScalingFactor::new(map_attrs.cs);
|
||||
let hr = mods.hr();
|
||||
let hit_window = 2.0 * map_attrs.hit_windows.od;
|
||||
let time_preempt = f64::from((map_attrs.hit_windows.ar * clock_rate) as f32);
|
||||
|
||||
// * Preempt time can go below 450ms. Normally, this is achieved via the DT mod
|
||||
// * which uniformly speeds up all animations game wide regardless of AR.
|
||||
// * This uniform speedup is hard to match 1:1, however we can at least make
|
||||
// * AR>10 (via mods) feel good by extending the upper linear function above.
|
||||
// * Note that this doesn't exactly match the AR>10 visuals as they're
|
||||
// * classically known, but it feels good.
|
||||
// * This adjustment is necessary for AR>10, otherwise TimePreempt can
|
||||
// * become smaller leading to hitcircles not fully fading in.
|
||||
let time_fade_in = if mods.hd() {
|
||||
time_preempt * FADE_IN_DURATION_MULTIPLIER
|
||||
} else {
|
||||
400.0 * (time_preempt / PREEMPT_MIN).min(1.0)
|
||||
};
|
||||
|
||||
let mut attrs = OsuDifficultyAttributes {
|
||||
ar: map_attrs.ar,
|
||||
hp: map_attrs.hp,
|
||||
od: map_attrs.od,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let mut hit_objects =
|
||||
create_osu_objects(map, &mut attrs, &scaling_factor, take, hr, time_preempt);
|
||||
let mut hit_objects_iter = hit_objects.iter_mut().map(Pin::new);
|
||||
|
||||
let mut skills = Skills::new(
|
||||
mods,
|
||||
scaling_factor.radius,
|
||||
time_preempt,
|
||||
time_fade_in,
|
||||
hit_window,
|
||||
);
|
||||
|
||||
let Some(mut last) = hit_objects_iter.next() else {
|
||||
return (skills, attrs);
|
||||
};
|
||||
|
||||
let mut last_last = None;
|
||||
|
||||
// Prepare `lazy_travel_dist` and `lazy_end_pos` for `last` manually
|
||||
let last_pos = last.pos();
|
||||
let last_stack_offset = last.stack_offset;
|
||||
|
||||
if let OsuObjectKind::Slider(ref mut slider) = last.kind {
|
||||
Distances::compute_slider_travel_dist(last_pos, last_stack_offset, slider, &scaling_factor);
|
||||
}
|
||||
|
||||
let mut last = last.into_ref();
|
||||
let mut diff_objects = Vec::with_capacity(hit_objects_iter.len());
|
||||
|
||||
for (i, mut curr) in hit_objects_iter.enumerate() {
|
||||
let delta_time = (curr.start_time - last.start_time) / clock_rate;
|
||||
|
||||
// * Capped to 25ms to prevent difficulty calculation breaking from simultaneous objects.
|
||||
let strain_time = delta_time.max(f64::from(OsuDifficultyObject::MIN_DELTA_TIME));
|
||||
|
||||
let dists = Distances::new(
|
||||
&mut curr,
|
||||
last.get_ref(),
|
||||
last_last.map(Pin::get_ref),
|
||||
clock_rate,
|
||||
strain_time,
|
||||
&scaling_factor,
|
||||
);
|
||||
|
||||
let curr = curr.into_ref();
|
||||
|
||||
let diff_obj = OsuDifficultyObject::new(curr, last.get_ref(), clock_rate, i, dists);
|
||||
diff_objects.push(diff_obj);
|
||||
|
||||
last_last = Some(last);
|
||||
last = curr;
|
||||
}
|
||||
|
||||
for curr in diff_objects.iter() {
|
||||
skills.process(curr, &diff_objects);
|
||||
}
|
||||
|
||||
(skills, attrs)
|
||||
}
|
||||
|
||||
pub(crate) fn create_osu_objects(
|
||||
map: &Beatmap,
|
||||
attrs: &mut OsuDifficultyAttributes,
|
||||
scaling_factor: &ScalingFactor,
|
||||
take: usize,
|
||||
hr: bool,
|
||||
time_preempt: f64,
|
||||
) -> Vec<OsuObject> {
|
||||
let mut params = ObjectParameters {
|
||||
map,
|
||||
attrs,
|
||||
ticks: Vec::new(),
|
||||
curve_bufs: CurveBuffers::default(),
|
||||
};
|
||||
|
||||
let mut hit_objects = Vec::with_capacity(map.hit_objects.len());
|
||||
let mut iter = map.hit_objects.iter();
|
||||
|
||||
let hit_object_iter = iter
|
||||
.by_ref()
|
||||
.take(take)
|
||||
.map(|h| OsuObject::new(h, &mut params));
|
||||
hit_objects.extend(hit_object_iter);
|
||||
|
||||
// remaining objects unfortunately still need to be processed since they might influence stacking
|
||||
if take < map.hit_objects.len() {
|
||||
let ObjectParameters {
|
||||
map,
|
||||
attrs,
|
||||
ticks,
|
||||
curve_bufs,
|
||||
} = params;
|
||||
|
||||
// use a new instance for attributes so that max combo & co remain correct in the original
|
||||
let mut throw_away_attrs = attrs.clone();
|
||||
|
||||
let mut params = ObjectParameters {
|
||||
map,
|
||||
attrs: &mut throw_away_attrs,
|
||||
ticks,
|
||||
curve_bufs,
|
||||
};
|
||||
|
||||
hit_objects.extend(iter.map(|h| OsuObject::new(h, &mut params)));
|
||||
}
|
||||
|
||||
let stack_threshold = time_preempt * f64::from(map.stack_leniency);
|
||||
|
||||
if map.version >= 6 {
|
||||
stacking(&mut hit_objects, stack_threshold);
|
||||
} else {
|
||||
old_stacking(&mut hit_objects, stack_threshold);
|
||||
}
|
||||
|
||||
hit_objects.truncate(take);
|
||||
|
||||
hit_objects
|
||||
.iter_mut()
|
||||
.for_each(|h| h.post_process(hr, scaling_factor));
|
||||
|
||||
hit_objects
|
||||
}
|
||||
|
||||
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.abs() > 0.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.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]
|
||||
.pre_stacked_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.0;
|
||||
|
||||
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]
|
||||
.pre_stacked_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.0;
|
||||
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]
|
||||
.pre_stacked_end_pos()
|
||||
.distance(hit_objects[obj_i_idx].pos())
|
||||
< STACK_DISTANCE
|
||||
{
|
||||
hit_objects[n].stack_height = hit_objects[obj_i_idx].stack_height + 1.0;
|
||||
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.0 && !hit_objects[i].is_slider() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut start_time = hit_objects[i].end_time();
|
||||
let pos2 = hit_objects[i].old_stacking_pos2();
|
||||
|
||||
let mut slider_stack = 0.0;
|
||||
|
||||
for j in i + 1..hit_objects.len() {
|
||||
if hit_objects[j].start_time - stack_threshold > start_time {
|
||||
break;
|
||||
}
|
||||
|
||||
if hit_objects[j].pos().distance(hit_objects[i].pos()) < STACK_DISTANCE {
|
||||
hit_objects[i].stack_height += 1.0;
|
||||
start_time = hit_objects[j].end_time();
|
||||
} else if hit_objects[j].pos().distance(pos2) < STACK_DISTANCE {
|
||||
slider_stack += 1.0;
|
||||
hit_objects[j].stack_height -= slider_stack;
|
||||
start_time = hit_objects[j].end_time();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The result of a difficulty calculation on an osu!standard map.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct OsuDifficultyAttributes {
|
||||
/// The aim portion of the total strain.
|
||||
pub aim: f64,
|
||||
/// The speed portion of the total strain.
|
||||
pub speed: f64,
|
||||
/// The flashlight portion of the total strain.
|
||||
pub flashlight: f64,
|
||||
/// The ratio of the aim strain with and without considering sliders
|
||||
pub slider_factor: f64,
|
||||
/// The number of clickable objects weighted by difficulty.
|
||||
pub speed_note_count: f64,
|
||||
/// The approach rate.
|
||||
pub ar: f64,
|
||||
/// The overall difficulty
|
||||
pub od: f64,
|
||||
/// The health drain rate.
|
||||
pub hp: f64,
|
||||
/// The amount of circles.
|
||||
pub n_circles: usize,
|
||||
/// The amount of sliders.
|
||||
pub n_sliders: usize,
|
||||
/// The amount of spinners.
|
||||
pub n_spinners: usize,
|
||||
/// The final star rating
|
||||
pub stars: f64,
|
||||
/// The maximum combo.
|
||||
pub max_combo: usize,
|
||||
}
|
||||
|
||||
impl OsuDifficultyAttributes {
|
||||
/// Return the maximum combo.
|
||||
#[inline]
|
||||
pub const fn max_combo(&self) -> usize {
|
||||
self.max_combo
|
||||
}
|
||||
|
||||
/// Return the amount of hitobjects.
|
||||
#[inline]
|
||||
pub const fn n_objects(&self) -> usize {
|
||||
self.n_circles + self.n_sliders + self.n_spinners
|
||||
}
|
||||
|
||||
/// Returns a builder for performance calculation.
|
||||
#[inline]
|
||||
pub fn pp(self) -> OsuPP<'static> {
|
||||
OsuPP::from(self)
|
||||
}
|
||||
}
|
||||
|
||||
/// The result of a performance calculation on an osu!standard map.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct OsuPerformanceAttributes {
|
||||
/// The difficulty attributes that were used for the performance calculation
|
||||
pub difficulty: OsuDifficultyAttributes,
|
||||
/// The final performance points.
|
||||
pub pp: f64,
|
||||
/// The accuracy portion of the final pp.
|
||||
pub pp_acc: f64,
|
||||
/// The aim portion of the final pp.
|
||||
pub pp_aim: f64,
|
||||
/// The flashlight portion of the final pp.
|
||||
pub pp_flashlight: f64,
|
||||
/// The speed portion of the final pp.
|
||||
pub pp_speed: f64,
|
||||
/// Misses including an approximated amount of slider breaks
|
||||
pub effective_miss_count: f64,
|
||||
}
|
||||
|
||||
impl OsuPerformanceAttributes {
|
||||
/// Return the star value.
|
||||
#[inline]
|
||||
pub const fn stars(&self) -> f64 {
|
||||
self.difficulty.stars
|
||||
}
|
||||
|
||||
/// Return the performance point value.
|
||||
#[inline]
|
||||
pub const fn pp(&self) -> f64 {
|
||||
self.pp
|
||||
}
|
||||
|
||||
/// Return the maximum combo of the map.
|
||||
#[inline]
|
||||
pub const fn max_combo(&self) -> usize {
|
||||
self.difficulty.max_combo
|
||||
}
|
||||
/// Return the amount of hitobjects.
|
||||
#[inline]
|
||||
pub const fn n_objects(&self) -> usize {
|
||||
self.difficulty.n_objects()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<OsuPerformanceAttributes> for OsuDifficultyAttributes {
|
||||
#[inline]
|
||||
fn from(attributes: OsuPerformanceAttributes) -> Self {
|
||||
attributes.difficulty
|
||||
fn strains(difficulty: &ModeDifficulty, converted: &OsuBeatmap<'_>) -> Self::Strains {
|
||||
strains::strains(difficulty, converted)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
use rosu_map::{
|
||||
section::hit_objects::{
|
||||
BorrowedCurve, CurveBuffers, SliderEvent, SliderEventType, SliderEventsIter,
|
||||
},
|
||||
util::Pos,
|
||||
};
|
||||
|
||||
use crate::model::{
|
||||
control_point::{DifficultyPoint, TimingPoint},
|
||||
hit_object::{HitObject, HitObjectKind, HoldNote, Slider, Spinner},
|
||||
};
|
||||
|
||||
use super::{convert::OsuBeatmap, 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: &OsuBeatmap<'_>,
|
||||
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 { end_time }) => {
|
||||
OsuObjectKind::Spinner(Spinner { end_time })
|
||||
}
|
||||
};
|
||||
|
||||
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;
|
||||
|
||||
let mut nested_iter = slider.nested_objects.iter_mut();
|
||||
|
||||
// The tail is adjusted differently than ticks and repeats
|
||||
if let Some(tail) = nested_iter.next_back() {
|
||||
let mut tail_pos = self.pos; // already reflected at this point
|
||||
tail_pos += Pos::new(tail.pos.x, -tail.pos.y);
|
||||
tail.pos = tail_pos;
|
||||
|
||||
for nested in nested_iter {
|
||||
reflect_y(&mut nested.pos.y);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn finalize_tail(&mut self) {
|
||||
if let OsuObjectKind::Slider(ref mut slider) = self.kind {
|
||||
if let Some(tail) = slider.nested_objects.last_mut() {
|
||||
tail.pos += self.pos;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn end_time(&self) -> f64 {
|
||||
match self.kind {
|
||||
OsuObjectKind::Circle => self.start_time,
|
||||
OsuObjectKind::Slider(ref slider) => slider.end_time,
|
||||
OsuObjectKind::Spinner(ref spinner) => spinner.end_time,
|
||||
}
|
||||
}
|
||||
|
||||
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.nested_objects[slider.nested_objects.len() - 1].pos
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn stacked_end_pos(&self) -> Pos {
|
||||
self.end_pos() + self.stack_offset
|
||||
}
|
||||
|
||||
pub fn lazy_travel_time(&self) -> f64 {
|
||||
match self.kind {
|
||||
OsuObjectKind::Circle | OsuObjectKind::Spinner(_) => 0.0,
|
||||
OsuObjectKind::Slider(ref slider) => {
|
||||
slider
|
||||
.nested_objects
|
||||
.last()
|
||||
.map_or(0.0, |nested| nested.start_time)
|
||||
- self.start_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 nested_objects: Vec<NestedSliderObject>,
|
||||
}
|
||||
|
||||
impl OsuSlider {
|
||||
fn new(
|
||||
h: &HitObject,
|
||||
slider: &Slider,
|
||||
converted: &OsuBeatmap<'_>,
|
||||
curve_bufs: &mut CurveBuffers,
|
||||
ticks_buf: &mut Vec<SliderEvent>,
|
||||
) -> Self {
|
||||
let start_time = h.start_time;
|
||||
let slider_multiplier = converted.map.slider_multiplier;
|
||||
let slider_tick_rate = converted.map.slider_tick_rate;
|
||||
|
||||
let beat_len = converted
|
||||
.map
|
||||
.timing_point_at(start_time)
|
||||
.map_or(TimingPoint::DEFAULT_BEAT_LEN, |point| point.beat_len);
|
||||
|
||||
let (slider_velocity, generate_ticks) =
|
||||
converted.map.difficulty_point_at(start_time).map_or(
|
||||
(
|
||||
DifficultyPoint::DEFAULT_SLIDER_VELOCITY,
|
||||
DifficultyPoint::DEFAULT_GENERATE_TICKS,
|
||||
),
|
||||
|point| (point.slider_velocity, point.generate_ticks),
|
||||
);
|
||||
|
||||
let path = BorrowedCurve::new(&slider.control_points, slider.expected_dist, curve_bufs);
|
||||
|
||||
let span_count = slider.span_count() as f64;
|
||||
|
||||
let scoring_dist =
|
||||
f64::from(OsuObject::BASE_SCORING_DIST) * slider_multiplier * slider_velocity;
|
||||
let velocity = scoring_dist / 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.map.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 nested_objects: Vec<_> = events
|
||||
.filter_map(|e| {
|
||||
let obj = match e.kind {
|
||||
SliderEventType::Tick => NestedSliderObject {
|
||||
pos: h.pos + path.position_at(e.path_progress),
|
||||
start_time: e.time,
|
||||
kind: NestedSliderObjectKind::Tick,
|
||||
},
|
||||
SliderEventType::Repeat => NestedSliderObject {
|
||||
pos: h.pos + path.position_at(e.path_progress),
|
||||
start_time: start_time + f64::from(e.span_idx + 1) * span_duration,
|
||||
kind: NestedSliderObjectKind::Repeat,
|
||||
},
|
||||
SliderEventType::LastTick => 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::Tail => return None,
|
||||
};
|
||||
|
||||
Some(obj)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let lazy_travel_time = nested_objects
|
||||
.last()
|
||||
.map_or(0.0, |nested| nested.start_time - h.start_time);
|
||||
|
||||
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,
|
||||
nested_objects,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn repeat_count(&self) -> usize {
|
||||
self.nested_objects
|
||||
.iter()
|
||||
.filter(|nested| matches!(nested.kind, NestedSliderObjectKind::Repeat))
|
||||
.count()
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum NestedSliderObjectKind {
|
||||
Repeat,
|
||||
Tail,
|
||||
Tick,
|
||||
}
|
||||
@@ -1,509 +0,0 @@
|
||||
use super::{scaling_factor::ScalingFactor, OsuDifficultyAttributes, PLAYFIELD_BASE_SIZE};
|
||||
|
||||
use crate::{
|
||||
curve::{Curve, CurveBuffers},
|
||||
parse::{HitObject, HitObjectKind, Pos2},
|
||||
Beatmap,
|
||||
};
|
||||
|
||||
const LEGACY_LAST_TICK_OFFSET: f64 = 36.0;
|
||||
const BASE_SCORING_DISTANCE: f64 = 100.0;
|
||||
|
||||
/// A [`HitObject`] that was processed for the osu! gamemode.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct OsuObject {
|
||||
/// Position of the object.
|
||||
///
|
||||
/// Note that stacking is **not** applied, use the [`stacked_pos`](OsuObject::stacked_pos) method instead.
|
||||
pub pos: Pos2,
|
||||
/// Start time of the object.
|
||||
pub start_time: f64,
|
||||
/// The positional offset due to stacking.
|
||||
pub stack_offset: Pos2,
|
||||
/// The height of the stacking offset.
|
||||
pub stack_height: f32,
|
||||
/// Type of the object.
|
||||
pub kind: OsuObjectKind,
|
||||
}
|
||||
|
||||
/// The type of an [`OsuObject`].
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum OsuObjectKind {
|
||||
/// A hitcircle object.
|
||||
Circle,
|
||||
/// A slider object.
|
||||
Slider(OsuSlider),
|
||||
/// A spinner object.
|
||||
Spinner {
|
||||
/// The endtime of the spinner.
|
||||
end_time: f64,
|
||||
},
|
||||
}
|
||||
|
||||
/// A [`HitObject`] that was processed as a slider for the osu! gamemode.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct OsuSlider {
|
||||
/// The endtime of the slider.
|
||||
pub end_time: f64,
|
||||
/// The lazy end position of the cursor.
|
||||
pub lazy_end_pos: Pos2,
|
||||
/// All nested objects of the slider except for the slider head.
|
||||
pub nested_objects: Vec<NestedObject>,
|
||||
}
|
||||
|
||||
impl OsuSlider {
|
||||
/// The amount of repeat points.
|
||||
pub fn repeat_count(&self) -> usize {
|
||||
self.nested_objects.iter().fold(0, |count, nested| {
|
||||
count + usize::from(matches!(nested.kind, NestedObjectKind::Repeat))
|
||||
})
|
||||
}
|
||||
|
||||
/// The position of the slider tail.
|
||||
///
|
||||
/// This is usually but not necessarily the position of the last nested object.
|
||||
pub fn end_pos(&self) -> Option<Pos2> {
|
||||
self.tail().map(|tail| tail.pos)
|
||||
}
|
||||
|
||||
/// A shared reference to the slider tail.
|
||||
///
|
||||
/// This is usually but not necessarily the last nested object.
|
||||
pub fn tail(&self) -> Option<&NestedObject> {
|
||||
self.nested_objects
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|nested| matches!(nested.kind, NestedObjectKind::Tail))
|
||||
}
|
||||
|
||||
/// An exclusive reference to the slider tail and its index in the nested object list.
|
||||
///
|
||||
/// This is usually but not necessarily the last nested object.
|
||||
pub fn tail_mut(&mut self) -> Option<(usize, &mut NestedObject)> {
|
||||
self.nested_objects
|
||||
.iter_mut()
|
||||
.enumerate()
|
||||
.rev()
|
||||
.find(|(_, nested)| matches!(nested.kind, NestedObjectKind::Tail))
|
||||
}
|
||||
}
|
||||
|
||||
/// A nested object within a slider.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct NestedObject {
|
||||
/// Position of the object.
|
||||
///
|
||||
/// Note: `pos` does not include stacking.
|
||||
pub pos: Pos2,
|
||||
/// Start time of the object.
|
||||
pub start_time: f64,
|
||||
/// Type of the object.
|
||||
pub kind: NestedObjectKind,
|
||||
}
|
||||
|
||||
/// Type of a [`NestedObject`].
|
||||
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
|
||||
pub enum NestedObjectKind {
|
||||
/// A repeat point.
|
||||
Repeat,
|
||||
/// The logical slider tail i.e. the legacy last tick.
|
||||
Tail,
|
||||
/// A regular slider tick.
|
||||
Tick,
|
||||
}
|
||||
|
||||
pub(crate) struct ObjectParameters<'a> {
|
||||
pub(crate) map: &'a Beatmap,
|
||||
pub(crate) attrs: &'a mut OsuDifficultyAttributes,
|
||||
pub(crate) ticks: Vec<(Pos2, f64)>,
|
||||
pub(crate) curve_bufs: CurveBuffers,
|
||||
}
|
||||
|
||||
impl OsuObject {
|
||||
#[allow(clippy::too_many_lines)]
|
||||
pub(crate) fn new(h: &HitObject, params: &mut ObjectParameters<'_>) -> Self {
|
||||
let ObjectParameters {
|
||||
map,
|
||||
attrs,
|
||||
ticks,
|
||||
curve_bufs,
|
||||
} = params;
|
||||
|
||||
attrs.max_combo += 1; // hitcircle, slider head, or spinner
|
||||
let pos = h.pos;
|
||||
|
||||
match &h.kind {
|
||||
HitObjectKind::Circle => {
|
||||
attrs.n_circles += 1;
|
||||
|
||||
Self {
|
||||
start_time: h.start_time,
|
||||
pos,
|
||||
stack_offset: Pos2::default(),
|
||||
stack_height: 0.0,
|
||||
kind: OsuObjectKind::Circle,
|
||||
}
|
||||
}
|
||||
HitObjectKind::Slider {
|
||||
pixel_len,
|
||||
repeats,
|
||||
control_points,
|
||||
..
|
||||
} => {
|
||||
attrs.n_sliders += 1;
|
||||
|
||||
let timing_point = map.timing_point_at(h.start_time);
|
||||
let difficulty_point = map.difficulty_point_at(h.start_time).unwrap_or_default();
|
||||
|
||||
let scoring_dist =
|
||||
BASE_SCORING_DISTANCE * map.slider_mult * difficulty_point.slider_vel;
|
||||
|
||||
let vel = scoring_dist / timing_point.beat_len;
|
||||
|
||||
// * prior to v8, speed multipliers don't adjust for how many ticks are generated over the same distance.
|
||||
// * this results in more (or less) ticks being generated in <v8 maps for the same time duration.
|
||||
let tick_dist_mult = if map.version < 8 {
|
||||
difficulty_point.slider_vel.recip()
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
|
||||
let mut tick_dist = if difficulty_point.generate_ticks {
|
||||
scoring_dist / map.tick_rate * tick_dist_mult
|
||||
} else {
|
||||
f64::INFINITY
|
||||
};
|
||||
|
||||
let span_count = (*repeats + 1) as f64;
|
||||
|
||||
// Build the curve w.r.t. the control points
|
||||
let curve = Curve::new(control_points, *pixel_len, 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;
|
||||
|
||||
// * A very lenient maximum length of a slider for ticks to be generated.
|
||||
// * This exists for edge cases such as /b/1573664 where the beatmap has
|
||||
// * been edited by the user, and should never be reached in normal usage.
|
||||
let max_len = 100_000.0;
|
||||
|
||||
let len = curve.dist().min(max_len);
|
||||
tick_dist = tick_dist.clamp(0.0, len);
|
||||
let min_dist_from_end = vel * 10.0;
|
||||
|
||||
let mut curr_dist = tick_dist;
|
||||
|
||||
ticks.clear();
|
||||
|
||||
#[allow(clippy::if_not_else)]
|
||||
let mut nested_objects = if tick_dist != 0.0 {
|
||||
ticks.reserve((len / tick_dist) as usize);
|
||||
let mut nested_objects =
|
||||
Vec::with_capacity((len * span_count / tick_dist) as usize);
|
||||
|
||||
// Ticks 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;
|
||||
let curr_pos = h.pos + curve.position_at(progress);
|
||||
|
||||
let tick = NestedObject {
|
||||
pos: curr_pos,
|
||||
start_time: curr_time,
|
||||
kind: NestedObjectKind::Tick,
|
||||
};
|
||||
|
||||
nested_objects.push(tick);
|
||||
ticks.push((curr_pos, curr_time));
|
||||
|
||||
curr_dist += tick_dist;
|
||||
}
|
||||
|
||||
// Other spans
|
||||
for span_idx in 1..=*repeats {
|
||||
let progress = f64::from(u8::from(span_idx % 2 == 1));
|
||||
let span_idx_f64 = span_idx as f64;
|
||||
|
||||
// Repeat point
|
||||
let curr_time = h.start_time + span_duration * span_idx_f64;
|
||||
let curr_pos = h.pos + curve.position_at(progress);
|
||||
|
||||
let repeat = NestedObject {
|
||||
pos: curr_pos,
|
||||
start_time: curr_time,
|
||||
kind: NestedObjectKind::Repeat,
|
||||
};
|
||||
|
||||
nested_objects.push(repeat);
|
||||
let span_offset = span_idx_f64 * span_duration;
|
||||
|
||||
// Ticks
|
||||
if span_idx & 1 == 1 {
|
||||
let base = h.start_time + h.start_time + span_duration;
|
||||
|
||||
let tick_iter = ticks.iter().rev().map(|(pos, time)| NestedObject {
|
||||
pos: *pos,
|
||||
start_time: span_offset + base - time,
|
||||
kind: NestedObjectKind::Tick,
|
||||
});
|
||||
|
||||
nested_objects.extend(tick_iter);
|
||||
} else {
|
||||
let tick_iter = ticks.iter().map(|(pos, time)| NestedObject {
|
||||
pos: *pos,
|
||||
start_time: time + span_offset,
|
||||
kind: NestedObjectKind::Tick,
|
||||
});
|
||||
|
||||
nested_objects.extend(tick_iter);
|
||||
}
|
||||
}
|
||||
|
||||
nested_objects
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
// 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);
|
||||
|
||||
let progress = f64::from(u8::from(*repeats % 2 == 0));
|
||||
let end_pos = curve.position_at(progress);
|
||||
|
||||
// * we need to use the LegacyLastTick here for compatibility reasons (difficulty).
|
||||
// * it is *okay* to use this because the TailCircle is not used for any meaningful purpose in gameplay.
|
||||
// * if this is to change, we should revisit this.
|
||||
let legacy_last_tick = NestedObject {
|
||||
pos: end_pos,
|
||||
start_time: final_span_end_time,
|
||||
kind: NestedObjectKind::Tail,
|
||||
};
|
||||
|
||||
// On very short buzz sliders it can happen that the
|
||||
// legacy last tick is not the last object time-wise
|
||||
match nested_objects.last() {
|
||||
Some(last) if last.start_time > final_span_end_time => {
|
||||
let idx = nested_objects
|
||||
.iter()
|
||||
.rev()
|
||||
.position(|nested| nested.start_time <= final_span_end_time)
|
||||
.map_or(0, |i| nested_objects.len() - i);
|
||||
|
||||
nested_objects.insert(idx, legacy_last_tick);
|
||||
}
|
||||
_ => nested_objects.push(legacy_last_tick),
|
||||
};
|
||||
|
||||
attrs.max_combo += nested_objects.len();
|
||||
|
||||
let last_time = nested_objects
|
||||
.last()
|
||||
.map_or(final_span_end_time, |nested| nested.start_time);
|
||||
|
||||
let lazy_travel_time = last_time - h.start_time;
|
||||
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;
|
||||
}
|
||||
|
||||
// * temporary lazy end position until a real result can be derived.
|
||||
// The position is added after the stacking for the correct order of
|
||||
// floating point operations.
|
||||
let lazy_end_pos = curve.position_at(end_time_min);
|
||||
|
||||
let slider = OsuSlider {
|
||||
end_time,
|
||||
lazy_end_pos,
|
||||
nested_objects,
|
||||
};
|
||||
|
||||
Self {
|
||||
start_time: h.start_time,
|
||||
pos,
|
||||
stack_offset: Pos2::default(),
|
||||
stack_height: 0.0,
|
||||
kind: OsuObjectKind::Slider(slider),
|
||||
}
|
||||
}
|
||||
HitObjectKind::Spinner { end_time } | HitObjectKind::Hold { end_time } => {
|
||||
attrs.n_spinners += 1;
|
||||
|
||||
Self {
|
||||
start_time: h.start_time,
|
||||
pos,
|
||||
stack_offset: Pos2::default(),
|
||||
stack_height: 0.0,
|
||||
kind: OsuObjectKind::Spinner {
|
||||
end_time: *end_time,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Endtime of the object.
|
||||
pub const fn end_time(&self) -> f64 {
|
||||
match &self.kind {
|
||||
OsuObjectKind::Circle => self.start_time,
|
||||
OsuObjectKind::Slider(slider) => slider.end_time,
|
||||
OsuObjectKind::Spinner { end_time } => *end_time,
|
||||
}
|
||||
}
|
||||
|
||||
/// End position of the object.
|
||||
pub fn end_pos(&self) -> Pos2 {
|
||||
match &self.kind {
|
||||
OsuObjectKind::Circle | OsuObjectKind::Spinner { .. } => self.pos,
|
||||
OsuObjectKind::Slider(slider) => slider.end_pos().unwrap_or(self.pos),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn pre_stacked_end_pos(&self) -> Pos2 {
|
||||
match &self.kind {
|
||||
OsuObjectKind::Circle | OsuObjectKind::Spinner { .. } => self.pos,
|
||||
OsuObjectKind::Slider(slider) => slider
|
||||
.end_pos()
|
||||
.map_or(self.pos, |end_pos| self.pos + end_pos),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn old_stacking_pos2(&self) -> Pos2 {
|
||||
match &self.kind {
|
||||
OsuObjectKind::Circle | OsuObjectKind::Spinner { .. } => self.pos,
|
||||
OsuObjectKind::Slider(slider) => {
|
||||
// Old stacking requires the path end position
|
||||
// instead of slider end position
|
||||
let repeat_count = slider.repeat_count();
|
||||
|
||||
if repeat_count % 2 == 0 {
|
||||
slider
|
||||
.end_pos()
|
||||
.map_or(self.pos, |end_pos| self.pos + end_pos)
|
||||
} else {
|
||||
slider
|
||||
.nested_objects
|
||||
.iter()
|
||||
.find(|nested| matches!(nested.kind, NestedObjectKind::Repeat))
|
||||
.map_or(self.pos, |repeat| repeat.pos)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Position of the object without stacking.
|
||||
pub const fn pos(&self) -> Pos2 {
|
||||
self.pos
|
||||
}
|
||||
|
||||
/// Stacked position of the object.
|
||||
pub fn stacked_pos(&self) -> Pos2 {
|
||||
self.pos + self.stack_offset
|
||||
}
|
||||
|
||||
/// Stacked end position of the object.
|
||||
pub fn stacked_end_pos(&self) -> Pos2 {
|
||||
self.end_pos() + self.stack_offset
|
||||
}
|
||||
|
||||
/// Lazy end position of the object. Stacking is included.
|
||||
pub fn lazy_end_pos(&self) -> Pos2 {
|
||||
match &self.kind {
|
||||
OsuObjectKind::Circle | OsuObjectKind::Spinner { .. } => self.stacked_pos(),
|
||||
OsuObjectKind::Slider(slider) => slider.lazy_end_pos,
|
||||
}
|
||||
}
|
||||
|
||||
/// Lazy travel time of the object.
|
||||
pub fn lazy_travel_time(&self) -> f64 {
|
||||
match &self.kind {
|
||||
OsuObjectKind::Circle | OsuObjectKind::Spinner { .. } => 0.0,
|
||||
OsuObjectKind::Slider(slider) => slider
|
||||
.nested_objects
|
||||
.last()
|
||||
.map_or(0.0, |nested| nested.start_time - self.start_time),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the object is a hitcircle.
|
||||
pub const fn is_circle(&self) -> bool {
|
||||
matches!(self.kind, OsuObjectKind::Circle)
|
||||
}
|
||||
|
||||
/// Whether the object is a slider.
|
||||
pub const fn is_slider(&self) -> bool {
|
||||
matches!(self.kind, OsuObjectKind::Slider { .. })
|
||||
}
|
||||
|
||||
/// Whether the object is a spinner.
|
||||
pub const fn is_spinner(&self) -> bool {
|
||||
matches!(self.kind, OsuObjectKind::Spinner { .. })
|
||||
}
|
||||
|
||||
/// Applies stack offset, flips playfield for HR,
|
||||
/// and adjusts slider tails and lazy end positions.
|
||||
pub(crate) fn post_process(&mut self, hr: bool, scaling_factor: &ScalingFactor) {
|
||||
self.stack_offset = scaling_factor.stack_offset(self.stack_height);
|
||||
let pos = self.pos();
|
||||
|
||||
if let OsuObjectKind::Slider(slider) = &mut self.kind {
|
||||
if hr {
|
||||
let mut lazy_end_pos = pos;
|
||||
lazy_end_pos.y = PLAYFIELD_BASE_SIZE.y - lazy_end_pos.y;
|
||||
lazy_end_pos += self.stack_offset;
|
||||
|
||||
lazy_end_pos += Pos2 {
|
||||
x: slider.lazy_end_pos.x,
|
||||
y: -slider.lazy_end_pos.y,
|
||||
};
|
||||
|
||||
slider.lazy_end_pos = lazy_end_pos;
|
||||
|
||||
let tail_idx = slider.tail_mut().map(|(tail_idx, tail)| {
|
||||
let mut tail_pos = pos;
|
||||
tail_pos.y = PLAYFIELD_BASE_SIZE.y - tail_pos.y;
|
||||
|
||||
tail_pos += Pos2 {
|
||||
x: tail.pos.x,
|
||||
y: -tail.pos.y,
|
||||
};
|
||||
|
||||
tail.pos = tail_pos;
|
||||
|
||||
tail_idx
|
||||
});
|
||||
|
||||
if let Some(tail_idx) = tail_idx {
|
||||
for nested in slider.nested_objects[..tail_idx].iter_mut() {
|
||||
nested.pos.y = PLAYFIELD_BASE_SIZE.y - nested.pos.y;
|
||||
}
|
||||
|
||||
for nested in slider.nested_objects[tail_idx + 1..].iter_mut() {
|
||||
nested.pos.y = PLAYFIELD_BASE_SIZE.y - nested.pos.y;
|
||||
}
|
||||
} else {
|
||||
// Should never happen since sliders are bound to have a tail
|
||||
for nested in slider.nested_objects.iter_mut() {
|
||||
nested.pos.y = PLAYFIELD_BASE_SIZE.y - nested.pos.y;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
slider.lazy_end_pos += pos + self.stack_offset;
|
||||
|
||||
if let Some((_, tail)) = slider.tail_mut() {
|
||||
tail.pos += pos;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if hr {
|
||||
self.pos.y = PLAYFIELD_BASE_SIZE.y - pos.y;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,101 +1,102 @@
|
||||
use super::{
|
||||
OsuDifficultyAttributes, OsuPerformanceAttributes, OsuScoreState, PERFORMANCE_BASE_MULTIPLIER,
|
||||
};
|
||||
use std::cmp;
|
||||
|
||||
use rosu_map::section::general::GameMode;
|
||||
|
||||
use crate::{
|
||||
util::{MapOrElse, MapRef},
|
||||
AnyPP, Beatmap, CatchPP, DifficultyAttributes, GameMode, HitResultPriority, ManiaPP, Mods,
|
||||
OsuStars, PerformanceAttributes, TaikoPP,
|
||||
any::ModeDifficulty,
|
||||
any::{HitResultPriority, ModeAttributeProvider, Performance},
|
||||
catch::CatchPerformance,
|
||||
mania::ManiaPerformance,
|
||||
taiko::TaikoPerformance,
|
||||
util::{float_ext::FloatExt, map_or_attrs::MapOrAttrs, mods::Mods},
|
||||
};
|
||||
|
||||
use super::{
|
||||
attributes::{OsuDifficultyAttributes, OsuPerformanceAttributes},
|
||||
convert::OsuBeatmap,
|
||||
score_state::OsuScoreState,
|
||||
Osu,
|
||||
};
|
||||
|
||||
/// Performance calculator on osu!standard maps.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use rosu_pp::{OsuPP, Beatmap};
|
||||
///
|
||||
/// # /*
|
||||
/// let map: Beatmap = ...
|
||||
/// # */
|
||||
/// # let map = Beatmap::default();
|
||||
///
|
||||
/// let pp_result = OsuPP::new(&map)
|
||||
/// .mods(8 + 64) // HDDT
|
||||
/// .combo(1234)
|
||||
/// .accuracy(98.5)
|
||||
/// .n_misses(1)
|
||||
/// .calculate();
|
||||
///
|
||||
/// println!("PP: {} | Stars: {}", pp_result.pp(), pp_result.stars());
|
||||
///
|
||||
/// let next_result = OsuPP::new(&map)
|
||||
/// .attributes(pp_result) // reusing previous results for performance
|
||||
/// .mods(8 + 64) // has to be the same to reuse attributes
|
||||
/// .accuracy(99.5)
|
||||
/// .calculate();
|
||||
///
|
||||
/// println!("PP: {} | Stars: {}", next_result.pp(), next_result.stars());
|
||||
/// ```
|
||||
#[derive(Clone, Debug)]
|
||||
#[allow(clippy::upper_case_acronyms)]
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
#[must_use]
|
||||
pub struct OsuPP<'map> {
|
||||
pub(crate) map_or_attrs: MapOrElse<MapRef<'map>, OsuDifficultyAttributes>,
|
||||
pub struct OsuPerformance<'map> {
|
||||
pub(crate) map_or_attrs: MapOrAttrs<'map, Osu>,
|
||||
pub(crate) mods: u32,
|
||||
pub(crate) acc: Option<f64>,
|
||||
pub(crate) combo: Option<usize>,
|
||||
pub(crate) combo: Option<u32>,
|
||||
|
||||
pub(crate) n300: Option<usize>,
|
||||
pub(crate) n100: Option<usize>,
|
||||
pub(crate) n50: Option<usize>,
|
||||
pub(crate) n_misses: Option<usize>,
|
||||
pub(crate) passed_objects: Option<usize>,
|
||||
pub(crate) n300: Option<u32>,
|
||||
pub(crate) n100: Option<u32>,
|
||||
pub(crate) n50: Option<u32>,
|
||||
pub(crate) n_misses: Option<u32>,
|
||||
pub(crate) passed_objects: Option<u32>,
|
||||
pub(crate) clock_rate: Option<f64>,
|
||||
pub(crate) hitresult_priority: Option<HitResultPriority>,
|
||||
pub(crate) hitresult_priority: HitResultPriority,
|
||||
}
|
||||
|
||||
impl<'map> OsuPP<'map> {
|
||||
impl<'map> OsuPerformance<'map> {
|
||||
/// Create a new performance calculator for osu!standard maps.
|
||||
#[inline]
|
||||
pub fn new(map: &'map Beatmap) -> Self {
|
||||
Self {
|
||||
map_or_attrs: MapOrElse::from(map),
|
||||
mods: 0,
|
||||
acc: None,
|
||||
combo: None,
|
||||
pub fn new(map: OsuBeatmap<'map>) -> Self {
|
||||
map.into()
|
||||
}
|
||||
|
||||
n300: None,
|
||||
n100: None,
|
||||
n50: None,
|
||||
n_misses: None,
|
||||
passed_objects: None,
|
||||
clock_rate: None,
|
||||
hitresult_priority: None,
|
||||
/// Attempt to convert the map to the specified mode.
|
||||
///
|
||||
/// Returns `None` if the internal beatmap was already replaced with
|
||||
/// [`OsuDifficultyAttributes`], i.e. if [`OsuPerformance::attributes`] or
|
||||
/// [`OsuPerformance::generate_state`] was called.
|
||||
///
|
||||
/// If the given mode should be ignored in case the internal beatmap was
|
||||
/// replaced, use [`mode_or_ignore`] instead.
|
||||
///
|
||||
/// [`mode_or_ignore`]: Self::mode_or_ignore
|
||||
pub fn try_mode(self, mode: GameMode) -> Option<Performance<'map>> {
|
||||
match mode {
|
||||
GameMode::Osu => Some(Performance::Osu(self)),
|
||||
GameMode::Taiko => TaikoPerformance::try_from(self)
|
||||
.map(Performance::Taiko)
|
||||
.ok(),
|
||||
GameMode::Catch => CatchPerformance::try_from(self)
|
||||
.map(Performance::Catch)
|
||||
.ok(),
|
||||
GameMode::Mania => ManiaPerformance::try_from(self)
|
||||
.map(Performance::Mania)
|
||||
.ok(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert the map into another mode.
|
||||
/// Attempt to convert the map to the specified mode.
|
||||
///
|
||||
/// Returns `None` if `self` already replaced it's internal [`Beatmap`]
|
||||
/// with [`OsuDifficultyAttributes`], i.e. if [`OsuPP::attributes`]
|
||||
/// or [`OsuPP::generate_state`] was called.
|
||||
#[inline]
|
||||
pub fn try_mode(self, mode: GameMode) -> Option<AnyPP<'map>> {
|
||||
/// If the internal beatmap was already replaced with difficulty
|
||||
/// attributes, the map won't be modified.
|
||||
///
|
||||
/// To see whether the internal beatmap was replaced, use [`try_mode`]
|
||||
/// instead.
|
||||
///
|
||||
/// [`try_mode`]: Self::try_mode
|
||||
pub fn mode_or_ignore(self, mode: GameMode) -> Performance<'map> {
|
||||
match mode {
|
||||
GameMode::Osu => Some(AnyPP::Osu(self)),
|
||||
GameMode::Taiko => TaikoPP::try_from_osu(self).map(AnyPP::Taiko),
|
||||
GameMode::Catch => CatchPP::try_from_osu(self).map(AnyPP::Catch),
|
||||
GameMode::Mania => ManiaPP::try_from_osu(self).map(AnyPP::Mania),
|
||||
GameMode::Osu => Performance::Osu(self),
|
||||
GameMode::Taiko => {
|
||||
TaikoPerformance::try_from(self).map_or_else(Performance::Osu, Performance::Taiko)
|
||||
}
|
||||
GameMode::Catch => {
|
||||
CatchPerformance::try_from(self).map_or_else(Performance::Osu, Performance::Catch)
|
||||
}
|
||||
GameMode::Mania => {
|
||||
ManiaPerformance::try_from(self).map_or_else(Performance::Osu, Performance::Mania)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Provide the result of a previous difficulty or performance calculation.
|
||||
/// If you already calculated the attributes for the current map-mod combination,
|
||||
/// be sure to put them in here so that they don't have to be recalculated.
|
||||
#[inline]
|
||||
pub fn attributes(mut self, attributes: impl OsuAttributeProvider) -> Self {
|
||||
pub fn attributes(mut self, attributes: impl ModeAttributeProvider<Osu>) -> Self {
|
||||
if let Some(attrs) = attributes.attributes() {
|
||||
self.map_or_attrs = MapOrElse::Else(attrs);
|
||||
self.map_or_attrs = MapOrAttrs::Attrs(attrs);
|
||||
}
|
||||
|
||||
self
|
||||
@@ -104,7 +105,6 @@ impl<'map> OsuPP<'map> {
|
||||
/// 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 const fn mods(mut self, mods: u32) -> Self {
|
||||
self.mods = mods;
|
||||
|
||||
@@ -112,8 +112,7 @@ impl<'map> OsuPP<'map> {
|
||||
}
|
||||
|
||||
/// Specify the max combo of the play.
|
||||
#[inline]
|
||||
pub const fn combo(mut self, combo: usize) -> Self {
|
||||
pub const fn combo(mut self, combo: u32) -> Self {
|
||||
self.combo = Some(combo);
|
||||
|
||||
self
|
||||
@@ -122,40 +121,35 @@ impl<'map> OsuPP<'map> {
|
||||
/// Specify how hitresults should be generated.
|
||||
///
|
||||
/// Defauls to [`HitResultPriority::BestCase`].
|
||||
#[inline]
|
||||
pub const fn hitresult_priority(mut self, priority: HitResultPriority) -> Self {
|
||||
self.hitresult_priority = Some(priority);
|
||||
self.hitresult_priority = priority;
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
/// Specify the amount of 300s of a play.
|
||||
#[inline]
|
||||
pub const fn n300(mut self, n300: usize) -> Self {
|
||||
pub const fn n300(mut self, n300: u32) -> Self {
|
||||
self.n300 = Some(n300);
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
/// Specify the amount of 100s of a play.
|
||||
#[inline]
|
||||
pub const fn n100(mut self, n100: usize) -> Self {
|
||||
pub const fn n100(mut self, n100: u32) -> Self {
|
||||
self.n100 = Some(n100);
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
/// Specify the amount of 50s of a play.
|
||||
#[inline]
|
||||
pub const fn n50(mut self, n50: usize) -> Self {
|
||||
pub const fn n50(mut self, n50: u32) -> Self {
|
||||
self.n50 = Some(n50);
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
/// Specify the amount of misses of a play.
|
||||
#[inline]
|
||||
pub const fn n_misses(mut self, n_misses: usize) -> Self {
|
||||
pub const fn n_misses(mut self, n_misses: u32) -> Self {
|
||||
self.n_misses = Some(n_misses);
|
||||
|
||||
self
|
||||
@@ -169,8 +163,7 @@ impl<'map> OsuPP<'map> {
|
||||
using [`OsuPP`] multiple times with different `passed_objects`, you should use
|
||||
[`OsuGradualPerformanceAttributes`](crate::osu::OsuGradualPerformance)."
|
||||
)]
|
||||
#[inline]
|
||||
pub const fn passed_objects(mut self, passed_objects: usize) -> Self {
|
||||
pub const fn passed_objects(mut self, passed_objects: u32) -> Self {
|
||||
self.passed_objects = Some(passed_objects);
|
||||
|
||||
self
|
||||
@@ -179,7 +172,6 @@ impl<'map> OsuPP<'map> {
|
||||
/// Adjust the clock rate used in the calculation.
|
||||
/// If none is specified, it will take the clock rate based on the mods
|
||||
/// i.e. 1.5 for DT, 0.75 for HT and 1.0 otherwise.
|
||||
#[inline]
|
||||
pub const fn clock_rate(mut self, clock_rate: f64) -> Self {
|
||||
self.clock_rate = Some(clock_rate);
|
||||
|
||||
@@ -187,7 +179,6 @@ impl<'map> OsuPP<'map> {
|
||||
}
|
||||
|
||||
/// Provide parameters through an [`OsuScoreState`].
|
||||
#[inline]
|
||||
#[allow(clippy::needless_pass_by_value)]
|
||||
pub const fn state(mut self, state: OsuScoreState) -> Self {
|
||||
let OsuScoreState {
|
||||
@@ -209,7 +200,6 @@ impl<'map> OsuPP<'map> {
|
||||
|
||||
/// Specify the accuracy of a play between `0.0` and `100.0`.
|
||||
/// This will be used to generate matching hitresults.
|
||||
#[inline]
|
||||
pub fn accuracy(mut self, acc: f64) -> Self {
|
||||
self.acc = Some(acc / 100.0);
|
||||
|
||||
@@ -220,17 +210,17 @@ impl<'map> OsuPP<'map> {
|
||||
#[allow(clippy::too_many_lines)]
|
||||
pub fn generate_state(&mut self) -> OsuScoreState {
|
||||
let attrs = match self.map_or_attrs {
|
||||
MapOrElse::Map(ref map) => {
|
||||
let attrs = self.generate_attributes(map.as_ref());
|
||||
MapOrAttrs::Map(ref map) => {
|
||||
let attrs = self.generate_attributes(map);
|
||||
|
||||
self.map_or_attrs.else_or_insert(attrs)
|
||||
self.map_or_attrs.attrs_or_insert(attrs)
|
||||
}
|
||||
MapOrElse::Else(ref attrs) => attrs,
|
||||
MapOrAttrs::Attrs(ref attrs) => attrs,
|
||||
};
|
||||
|
||||
let max_combo = attrs.max_combo;
|
||||
let n_objects = self.passed_objects.unwrap_or(attrs.n_objects());
|
||||
let priority = self.hitresult_priority.unwrap_or_default();
|
||||
let priority = self.hitresult_priority;
|
||||
|
||||
let n_misses = self.n_misses.map_or(0, |n| n.min(n_objects));
|
||||
let n_remaining = n_objects - n_misses;
|
||||
@@ -240,7 +230,7 @@ impl<'map> OsuPP<'map> {
|
||||
let mut n50 = self.n50.map_or(0, |n| n.min(n_remaining));
|
||||
|
||||
if let Some(acc) = self.acc {
|
||||
let target_total = acc * (6 * n_objects) as f64;
|
||||
let target_total = acc * f64::from(6 * n_objects);
|
||||
|
||||
match (self.n300, self.n100, self.n50) {
|
||||
(Some(_), Some(_), Some(_)) => {
|
||||
@@ -260,9 +250,9 @@ impl<'map> OsuPP<'map> {
|
||||
n300 = n300.min(n_remaining);
|
||||
let n_remaining = n_remaining - n300;
|
||||
|
||||
let raw_n100 = target_total - (n_remaining + 6 * n300) as f64;
|
||||
let min_n100 = n_remaining.min(raw_n100.floor() as usize);
|
||||
let max_n100 = n_remaining.min(raw_n100.ceil() as usize);
|
||||
let raw_n100 = target_total - f64::from(n_remaining + 6 * n300);
|
||||
let min_n100 = n_remaining.min(raw_n100.floor() as u32);
|
||||
let max_n100 = n_remaining.min(raw_n100.ceil() as u32);
|
||||
|
||||
for new100 in min_n100..=max_n100 {
|
||||
let new50 = n_remaining - new100;
|
||||
@@ -281,9 +271,9 @@ impl<'map> OsuPP<'map> {
|
||||
n100 = n100.min(n_remaining);
|
||||
let n_remaining = n_remaining - n100;
|
||||
|
||||
let raw_n300 = (target_total - (n_remaining + 2 * n100) as f64) / 5.0;
|
||||
let min_n300 = n_remaining.min(raw_n300.floor() as usize);
|
||||
let max_n300 = n_remaining.min(raw_n300.ceil() as usize);
|
||||
let raw_n300 = (target_total - f64::from(n_remaining + 2 * n100)) / 5.0;
|
||||
let min_n300 = n_remaining.min(raw_n300.floor() as u32);
|
||||
let max_n300 = n_remaining.min(raw_n300.ceil() as u32);
|
||||
|
||||
for new300 in min_n300..=max_n300 {
|
||||
let new50 = n_remaining - new300;
|
||||
@@ -302,11 +292,12 @@ impl<'map> OsuPP<'map> {
|
||||
n50 = n50.min(n_remaining);
|
||||
let n_remaining = n_remaining - n50;
|
||||
|
||||
let raw_n300 =
|
||||
(target_total + (2 * n_misses + n50) as f64 - (2 * n_objects) as f64) / 4.0;
|
||||
let raw_n300 = (target_total + f64::from(2 * n_misses + n50)
|
||||
- f64::from(2 * n_objects))
|
||||
/ 4.0;
|
||||
|
||||
let min_n300 = n_remaining.min(raw_n300.floor() as usize);
|
||||
let max_n300 = n_remaining.min(raw_n300.ceil() as usize);
|
||||
let min_n300 = n_remaining.min(raw_n300.floor() as u32);
|
||||
let max_n300 = n_remaining.min(raw_n300.ceil() as u32);
|
||||
|
||||
for new300 in min_n300..=max_n300 {
|
||||
let new100 = n_remaining - new300;
|
||||
@@ -322,14 +313,14 @@ impl<'map> OsuPP<'map> {
|
||||
(None, None, None) => {
|
||||
let mut best_dist = f64::MAX;
|
||||
|
||||
let raw_n300 = (target_total - n_remaining as f64) / 5.0;
|
||||
let min_n300 = n_remaining.min(raw_n300.floor() as usize);
|
||||
let max_n300 = n_remaining.min(raw_n300.ceil() as usize);
|
||||
let raw_n300 = (target_total - f64::from(n_remaining)) / 5.0;
|
||||
let min_n300 = cmp::min(n_remaining, raw_n300.floor() as u32);
|
||||
let max_n300 = cmp::min(n_remaining, raw_n300.ceil() as u32);
|
||||
|
||||
for new300 in min_n300..=max_n300 {
|
||||
let raw_n100 = target_total - (n_remaining + 5 * new300) as f64;
|
||||
let min_n100 = (raw_n100.floor() as usize).min(n_remaining - new300);
|
||||
let max_n100 = (raw_n100.ceil() as usize).min(n_remaining - new300);
|
||||
let raw_n100 = target_total - f64::from(n_remaining + 5 * new300);
|
||||
let min_n100 = cmp::min(raw_n100.floor() as u32, n_remaining - new300);
|
||||
let max_n100 = cmp::min(raw_n100.ceil() as u32, n_remaining - new300);
|
||||
|
||||
for new100 in min_n100..=max_n100 {
|
||||
let new50 = n_remaining - new300 - new100;
|
||||
@@ -401,13 +392,13 @@ impl<'map> OsuPP<'map> {
|
||||
let state = self.generate_state();
|
||||
|
||||
let attrs = match self.map_or_attrs {
|
||||
MapOrElse::Map(ref map) => self.generate_attributes(map.as_ref()),
|
||||
MapOrElse::Else(attrs) => attrs,
|
||||
MapOrAttrs::Map(ref map) => self.generate_attributes(map),
|
||||
MapOrAttrs::Attrs(attrs) => attrs,
|
||||
};
|
||||
|
||||
let effective_miss_count = calculate_effective_misses(&attrs, &state);
|
||||
|
||||
let inner = OsuPpInner {
|
||||
let inner = OsuPerformanceInner {
|
||||
attrs,
|
||||
mods: self.mods,
|
||||
acc: state.accuracy(),
|
||||
@@ -418,36 +409,100 @@ impl<'map> OsuPP<'map> {
|
||||
inner.calculate()
|
||||
}
|
||||
|
||||
fn generate_attributes(&self, map: &Beatmap) -> OsuDifficultyAttributes {
|
||||
let mut calculator = OsuStars::new(map).mods(self.mods);
|
||||
fn generate_attributes(&self, map: &OsuBeatmap<'_>) -> OsuDifficultyAttributes {
|
||||
let mut calculator = ModeDifficulty::new();
|
||||
|
||||
if let Some(passed_objects) = self.passed_objects {
|
||||
calculator = calculator.passed_objects(passed_objects);
|
||||
calculator.passed_objects(passed_objects);
|
||||
}
|
||||
|
||||
if let Some(clock_rate) = self.clock_rate {
|
||||
calculator = calculator.clock_rate(clock_rate);
|
||||
calculator.clock_rate(clock_rate);
|
||||
}
|
||||
|
||||
calculator.calculate()
|
||||
calculator.mods(self.mods).calculate(map)
|
||||
}
|
||||
|
||||
/// Try to create [`OsuPP`] through a [`OsuAttributeProvider`].
|
||||
/// Try to create [`OsuPerformance`] through a [`ModeAttributeProvider`].
|
||||
///
|
||||
/// If you already calculated the attributes for the current map-mod
|
||||
/// combination, the [`Beatmap`] is no longer necessary to calculate
|
||||
/// combination, the [`OsuBeatmap`] is no longer necessary to calculate
|
||||
/// performance attributes so this method can be used instead of
|
||||
/// [`OsuPP::new`].
|
||||
/// [`OsuPerformance::new`].
|
||||
///
|
||||
/// Returns `None` only if the [`OsuAttributeProvider`] did not contain
|
||||
/// Returns `None` only if the [`ModeAttributeProvider`] did not contain
|
||||
/// attributes for osu e.g. if it's [`DifficultyAttributes::Taiko`].
|
||||
#[inline]
|
||||
pub fn try_from_attributes(attributes: impl OsuAttributeProvider) -> Option<Self> {
|
||||
///
|
||||
/// [`DifficultyAttributes::Taiko`]: crate::any::DifficultyAttributes::Taiko
|
||||
pub fn try_from_attributes(attributes: impl ModeAttributeProvider<Osu>) -> Option<Self> {
|
||||
attributes.attributes().map(Self::from)
|
||||
}
|
||||
|
||||
/// Create [`OsuPerformance`] through a [`ModeAttributeProvider`].
|
||||
///
|
||||
/// If you already calculated the attributes for the current map-mod
|
||||
/// combination, the [`OsuBeatmap`] is no longer necessary to calculate
|
||||
/// performance attributes so this method can be used instead of
|
||||
/// [`OsuPerformance::new`].
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if the [`ModeAttributeProvider`] did not contain attributes for
|
||||
/// osu e.g. if it's [`DifficultyAttributes::Taiko`].
|
||||
///
|
||||
/// [`DifficultyAttributes::Taiko`]: crate::any::DifficultyAttributes::Taiko
|
||||
pub fn unchecked_from_attributes(attributes: impl ModeAttributeProvider<Osu>) -> Self {
|
||||
Self::try_from_attributes(attributes).expect("invalid osu attributes")
|
||||
}
|
||||
}
|
||||
|
||||
struct OsuPpInner {
|
||||
impl<'map> From<OsuBeatmap<'map>> for OsuPerformance<'map> {
|
||||
fn from(map: OsuBeatmap<'map>) -> Self {
|
||||
Self {
|
||||
map_or_attrs: MapOrAttrs::Map(map),
|
||||
mods: 0,
|
||||
acc: None,
|
||||
combo: None,
|
||||
|
||||
n300: None,
|
||||
n100: None,
|
||||
n50: None,
|
||||
n_misses: None,
|
||||
passed_objects: None,
|
||||
clock_rate: None,
|
||||
hitresult_priority: HitResultPriority::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<OsuDifficultyAttributes> for OsuPerformance<'_> {
|
||||
fn from(attrs: OsuDifficultyAttributes) -> Self {
|
||||
Self {
|
||||
map_or_attrs: MapOrAttrs::Attrs(attrs),
|
||||
mods: 0,
|
||||
acc: None,
|
||||
combo: None,
|
||||
|
||||
n300: None,
|
||||
n100: None,
|
||||
n50: None,
|
||||
n_misses: None,
|
||||
passed_objects: None,
|
||||
clock_rate: None,
|
||||
hitresult_priority: HitResultPriority::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<OsuPerformanceAttributes> for OsuPerformance<'_> {
|
||||
fn from(attrs: OsuPerformanceAttributes) -> Self {
|
||||
attrs.difficulty.into()
|
||||
}
|
||||
}
|
||||
|
||||
pub const PERFORMANCE_BASE_MULTIPLIER: f64 = 1.14;
|
||||
|
||||
struct OsuPerformanceInner {
|
||||
attrs: OsuDifficultyAttributes,
|
||||
mods: u32,
|
||||
acc: f64,
|
||||
@@ -455,7 +510,7 @@ struct OsuPpInner {
|
||||
effective_miss_count: f64,
|
||||
}
|
||||
|
||||
impl OsuPpInner {
|
||||
impl OsuPerformanceInner {
|
||||
fn calculate(mut self) -> OsuPerformanceAttributes {
|
||||
let total_hits = self.state.total_hits();
|
||||
|
||||
@@ -466,7 +521,7 @@ impl OsuPpInner {
|
||||
};
|
||||
}
|
||||
|
||||
let total_hits = total_hits as f64;
|
||||
let total_hits = f64::from(total_hits);
|
||||
|
||||
let mut multiplier = PERFORMANCE_BASE_MULTIPLIER;
|
||||
|
||||
@@ -475,7 +530,7 @@ impl OsuPpInner {
|
||||
}
|
||||
|
||||
if self.mods.so() && total_hits > 0.0 {
|
||||
multiplier *= 1.0 - (self.attrs.n_spinners as f64 / total_hits).powf(0.85);
|
||||
multiplier *= 1.0 - (f64::from(self.attrs.n_spinners) / total_hits).powf(0.85);
|
||||
}
|
||||
|
||||
if self.mods.rx() {
|
||||
@@ -494,9 +549,9 @@ impl OsuPpInner {
|
||||
// * As we're adding Oks and Mehs to an approximated number of combo breaks the result can be
|
||||
// * higher than total hits in specific scenarios (which breaks some calculations) so we need to clamp it.
|
||||
self.effective_miss_count = (self.effective_miss_count
|
||||
+ self.state.n100 as f64
|
||||
+ f64::from(self.state.n100)
|
||||
+ n100_mult
|
||||
+ self.state.n50 as f64 * n50_mult)
|
||||
+ f64::from(self.state.n50) * n50_mult)
|
||||
.min(total_hits);
|
||||
}
|
||||
|
||||
@@ -563,14 +618,14 @@ impl OsuPpInner {
|
||||
}
|
||||
|
||||
// * We assume 15% of sliders in a map are difficult since there's no way to tell from the performance calculator.
|
||||
let estimate_diff_sliders = self.attrs.n_sliders as f64 * 0.15;
|
||||
let estimate_diff_sliders = f64::from(self.attrs.n_sliders) * 0.15;
|
||||
|
||||
if self.attrs.n_sliders > 0 {
|
||||
let estimate_slider_ends_dropped =
|
||||
((self.state.n100 + self.state.n50 + self.state.n_misses)
|
||||
.min(self.attrs.max_combo.saturating_sub(self.state.max_combo))
|
||||
as f64)
|
||||
.clamp(0.0, estimate_diff_sliders);
|
||||
let estimate_slider_ends_dropped = f64::from(
|
||||
(self.state.n100 + self.state.n50 + self.state.n_misses)
|
||||
.min(self.attrs.max_combo.saturating_sub(self.state.max_combo)),
|
||||
)
|
||||
.clamp(0.0, estimate_diff_sliders);
|
||||
let slider_nerf_factor = (1.0 - self.attrs.slider_factor)
|
||||
* (1.0 - estimate_slider_ends_dropped / estimate_diff_sliders).powi(3)
|
||||
+ self.attrs.slider_factor;
|
||||
@@ -628,15 +683,15 @@ impl OsuPpInner {
|
||||
|
||||
// * Calculate accuracy assuming the worst case scenario
|
||||
let relevant_total_diff = total_hits - self.attrs.speed_note_count;
|
||||
let relevant_n300 = (self.state.n300 as f64 - relevant_total_diff).max(0.0);
|
||||
let relevant_n100 = (self.state.n100 as f64
|
||||
- (relevant_total_diff - self.state.n300 as f64).max(0.0))
|
||||
let relevant_n300 = (f64::from(self.state.n300) - relevant_total_diff).max(0.0);
|
||||
let relevant_n100 = (f64::from(self.state.n100)
|
||||
- (relevant_total_diff - f64::from(self.state.n300)).max(0.0))
|
||||
.max(0.0);
|
||||
let relevant_n50 = (self.state.n50 as f64
|
||||
- (relevant_total_diff - (self.state.n300 + self.state.n100) as f64).max(0.0))
|
||||
let relevant_n50 = (f64::from(self.state.n50)
|
||||
- (relevant_total_diff - f64::from(self.state.n300 + self.state.n100)).max(0.0))
|
||||
.max(0.0);
|
||||
|
||||
let relevant_acc = if self.attrs.speed_note_count.abs() <= f64::EPSILON {
|
||||
let relevant_acc = if self.attrs.speed_note_count.eq(0.0) {
|
||||
0.0
|
||||
} else {
|
||||
(relevant_n300 * 6.0 + relevant_n100 * 2.0 + relevant_n50)
|
||||
@@ -649,8 +704,8 @@ impl OsuPpInner {
|
||||
|
||||
// * Scale the speed value with # of 50s to punish doubletapping.
|
||||
speed_value *= 0.99_f64.powf(
|
||||
f64::from(u8::from(self.state.n50 as f64 >= total_hits / 500.0))
|
||||
* (self.state.n50 as f64 - total_hits / 500.0),
|
||||
f64::from(u8::from(f64::from(self.state.n50) >= total_hits / 500.0))
|
||||
* (f64::from(self.state.n50) - total_hits / 500.0),
|
||||
);
|
||||
|
||||
speed_value
|
||||
@@ -672,8 +727,8 @@ impl OsuPpInner {
|
||||
if self.state.n300 < sub {
|
||||
0.0
|
||||
} else {
|
||||
((self.state.n300 - sub) * 6 + self.state.n100 * 2 + self.state.n50) as f64
|
||||
/ (amount_hit_objects_with_acc * 6) as f64
|
||||
f64::from((self.state.n300 - sub) * 6 + self.state.n100 * 2 + self.state.n50)
|
||||
/ f64::from(amount_hit_objects_with_acc * 6)
|
||||
}
|
||||
} else {
|
||||
0.0
|
||||
@@ -684,7 +739,7 @@ impl OsuPpInner {
|
||||
let mut acc_value = 1.52163_f64.powf(self.attrs.od) * better_acc_percentage.powi(24) * 2.83;
|
||||
|
||||
// * Bonus for many hitcircles - it's harder to keep good accuracy up for longer.
|
||||
acc_value *= (amount_hit_objects_with_acc as f64 / 1000.0)
|
||||
acc_value *= (f64::from(amount_hit_objects_with_acc) / 1000.0)
|
||||
.powf(0.3)
|
||||
.min(1.15);
|
||||
|
||||
@@ -737,7 +792,7 @@ impl OsuPpInner {
|
||||
if self.attrs.max_combo == 0 {
|
||||
1.0
|
||||
} else {
|
||||
((self.state.max_combo as f64).powf(0.8) / (self.attrs.max_combo as f64).powf(0.8))
|
||||
(f64::from(self.state.max_combo).powf(0.8) / f64::from(self.attrs.max_combo).powf(0.8))
|
||||
.min(1.0)
|
||||
}
|
||||
}
|
||||
@@ -752,21 +807,21 @@ fn calculate_effective_misses(attrs: &OsuDifficultyAttributes, state: &OsuScoreS
|
||||
let mut combo_based_miss_count = 0.0;
|
||||
|
||||
if attrs.n_sliders > 0 {
|
||||
let full_combo_threshold = attrs.max_combo as f64 - 0.1 * attrs.n_sliders as f64;
|
||||
let full_combo_threshold = f64::from(attrs.max_combo) - 0.1 * f64::from(attrs.n_sliders);
|
||||
|
||||
if (state.max_combo as f64) < full_combo_threshold {
|
||||
combo_based_miss_count = full_combo_threshold / (state.max_combo as f64).max(1.0);
|
||||
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((state.n100 + state.n50 + state.n_misses) as f64);
|
||||
combo_based_miss_count.min(f64::from(state.n100 + state.n50 + state.n_misses));
|
||||
|
||||
combo_based_miss_count.max(state.n_misses as f64)
|
||||
combo_based_miss_count.max(f64::from(state.n_misses))
|
||||
}
|
||||
|
||||
fn accuracy(n300: usize, n100: usize, n50: usize, n_misses: usize) -> f64 {
|
||||
fn accuracy(n300: u32, n100: u32, n50: u32, n_misses: u32) -> f64 {
|
||||
if n300 + n100 + n50 + n_misses == 0 {
|
||||
return 0.0;
|
||||
}
|
||||
@@ -774,351 +829,5 @@ fn accuracy(n300: usize, n100: usize, n50: usize, n_misses: usize) -> f64 {
|
||||
let numerator = 6 * n300 + 2 * n100 + n50;
|
||||
let denominator = 6 * (n300 + n100 + n50 + n_misses);
|
||||
|
||||
numerator as f64 / denominator as f64
|
||||
}
|
||||
|
||||
impl From<OsuDifficultyAttributes> for OsuPP<'_> {
|
||||
#[inline]
|
||||
fn from(attrs: OsuDifficultyAttributes) -> Self {
|
||||
Self {
|
||||
map_or_attrs: MapOrElse::Else(attrs),
|
||||
mods: 0,
|
||||
acc: None,
|
||||
combo: None,
|
||||
|
||||
n300: None,
|
||||
n100: None,
|
||||
n50: None,
|
||||
n_misses: None,
|
||||
passed_objects: None,
|
||||
clock_rate: None,
|
||||
hitresult_priority: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<OsuPerformanceAttributes> for OsuPP<'_> {
|
||||
#[inline]
|
||||
fn from(attrs: OsuPerformanceAttributes) -> Self {
|
||||
attrs.difficulty.into()
|
||||
}
|
||||
}
|
||||
|
||||
/// Abstract type to provide flexibility when passing difficulty attributes to a performance calculation.
|
||||
pub trait OsuAttributeProvider {
|
||||
/// Provide the actual difficulty attributes.
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
impl OsuAttributeProvider for DifficultyAttributes {
|
||||
#[inline]
|
||||
fn attributes(self) -> Option<OsuDifficultyAttributes> {
|
||||
if let Self::Osu(attributes) = self {
|
||||
Some(attributes)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl OsuAttributeProvider for PerformanceAttributes {
|
||||
#[inline]
|
||||
fn attributes(self) -> Option<OsuDifficultyAttributes> {
|
||||
if let Self::Osu(attributes) = self {
|
||||
Some(attributes.difficulty)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(any(feature = "async_tokio", feature = "async_std")))]
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::*;
|
||||
use crate::Beatmap;
|
||||
use proptest::{option, prelude::*};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
static DATA: OnceLock<(Beatmap, OsuDifficultyAttributes)> = OnceLock::new();
|
||||
|
||||
const N_OBJECTS: usize = 601;
|
||||
|
||||
fn test_data() -> (&'static Beatmap, OsuDifficultyAttributes) {
|
||||
let (map, attrs) = DATA.get_or_init(|| {
|
||||
let path = "./maps/2785319.osu";
|
||||
let map = Beatmap::from_path(path).unwrap();
|
||||
let attrs = OsuStars::new(&map).calculate();
|
||||
|
||||
assert_eq!(
|
||||
(attrs.n_circles, attrs.n_sliders, attrs.n_spinners),
|
||||
(307, 293, 1)
|
||||
);
|
||||
assert_eq!(
|
||||
attrs.n_circles + attrs.n_sliders + attrs.n_spinners,
|
||||
N_OBJECTS,
|
||||
);
|
||||
|
||||
(map, attrs)
|
||||
});
|
||||
|
||||
(map, attrs.to_owned())
|
||||
}
|
||||
|
||||
/// Checks all remaining hitresult combinations w.r.t. the given parameters
|
||||
/// and returns the [`OsuScoreState`] that matches `acc` the best.
|
||||
///
|
||||
/// Very slow but accurate.
|
||||
fn brute_force_best(
|
||||
acc: f64,
|
||||
n300: Option<usize>,
|
||||
n100: Option<usize>,
|
||||
n50: Option<usize>,
|
||||
n_misses: usize,
|
||||
best_case: bool,
|
||||
) -> OsuScoreState {
|
||||
let n_misses = n_misses.min(N_OBJECTS);
|
||||
|
||||
let mut best_state = OsuScoreState {
|
||||
n_misses,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let mut best_dist = f64::INFINITY;
|
||||
|
||||
let n_remaining = N_OBJECTS - n_misses;
|
||||
|
||||
let (min_n300, max_n300) = match (n300, n100, n50) {
|
||||
(Some(n300), ..) => (n_remaining.min(n300), n_remaining.min(n300)),
|
||||
(None, Some(n100), Some(n50)) => (
|
||||
n_remaining.saturating_sub(n100 + n50),
|
||||
n_remaining.saturating_sub(n100 + n50),
|
||||
),
|
||||
(None, ..) => (
|
||||
0,
|
||||
n_remaining.saturating_sub(n100.unwrap_or(0) + n50.unwrap_or(0)),
|
||||
),
|
||||
};
|
||||
|
||||
for new300 in min_n300..=max_n300 {
|
||||
let (min_n100, max_n100) = match (n100, n50) {
|
||||
(Some(n100), _) => (n_remaining.min(n100), n_remaining.min(n100)),
|
||||
(None, Some(n50)) => (
|
||||
n_remaining.saturating_sub(new300 + n50),
|
||||
n_remaining.saturating_sub(new300 + n50),
|
||||
),
|
||||
(None, None) => (0, n_remaining - new300),
|
||||
};
|
||||
|
||||
for new100 in min_n100..=max_n100 {
|
||||
let new50 = match n50 {
|
||||
Some(n50) => n_remaining.min(n50),
|
||||
None => n_remaining.saturating_sub(new300 + new100),
|
||||
};
|
||||
|
||||
let curr_acc = accuracy(new300, new100, new50, n_misses);
|
||||
let curr_dist = (acc - curr_acc).abs();
|
||||
|
||||
if curr_dist < best_dist {
|
||||
best_dist = curr_dist;
|
||||
best_state.n300 = new300;
|
||||
best_state.n100 = new100;
|
||||
best_state.n50 = new50;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if best_state.n300 + best_state.n100 + best_state.n50 < n_remaining {
|
||||
let remaining = n_remaining - (best_state.n300 + best_state.n100 + best_state.n50);
|
||||
|
||||
if best_case {
|
||||
best_state.n300 += remaining;
|
||||
} else {
|
||||
best_state.n50 += remaining;
|
||||
}
|
||||
}
|
||||
|
||||
if n300.is_none() && n100.is_none() && n50.is_none() {
|
||||
if best_case {
|
||||
let n = best_state.n300.min(best_state.n50 / 4);
|
||||
best_state.n300 -= n;
|
||||
best_state.n100 += 5 * n;
|
||||
best_state.n50 -= 4 * n;
|
||||
} else {
|
||||
let n = best_state.n100 / 5;
|
||||
best_state.n300 += n;
|
||||
best_state.n100 -= 5 * n;
|
||||
best_state.n50 += 4 * n;
|
||||
}
|
||||
}
|
||||
|
||||
best_state
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#![proptest_config(ProptestConfig::with_cases(10_000))]
|
||||
#[test]
|
||||
fn osu_hitresults(
|
||||
acc in 0.0..=1.0,
|
||||
n300 in option::weighted(0.10, 0_usize..=N_OBJECTS + 10),
|
||||
n100 in option::weighted(0.10, 0_usize..=N_OBJECTS + 10),
|
||||
n50 in option::weighted(0.10, 0_usize..=N_OBJECTS + 10),
|
||||
n_misses in option::weighted(0.15, 0_usize..=N_OBJECTS + 10),
|
||||
best_case in prop::bool::ANY,
|
||||
) {
|
||||
let (map, attrs) = test_data();
|
||||
let max_combo = attrs.max_combo();
|
||||
|
||||
let priority = if best_case {
|
||||
HitResultPriority::BestCase
|
||||
} else {
|
||||
HitResultPriority::WorstCase
|
||||
};
|
||||
|
||||
let mut state = OsuPP::new(map)
|
||||
.attributes(attrs)
|
||||
.accuracy(acc * 100.0)
|
||||
.hitresult_priority(priority);
|
||||
|
||||
if let Some(n300) = n300 {
|
||||
state = state.n300(n300);
|
||||
}
|
||||
|
||||
if let Some(n100) = n100 {
|
||||
state = state.n100(n100);
|
||||
}
|
||||
|
||||
if let Some(n50) = n50 {
|
||||
state = state.n50(n50);
|
||||
}
|
||||
|
||||
if let Some(n_misses) = n_misses {
|
||||
state = state.n_misses(n_misses);
|
||||
}
|
||||
|
||||
let state = state.generate_state();
|
||||
|
||||
let mut expected = brute_force_best(
|
||||
acc,
|
||||
n300,
|
||||
n100,
|
||||
n50,
|
||||
n_misses.unwrap_or(0),
|
||||
best_case,
|
||||
);
|
||||
expected.max_combo = max_combo.saturating_sub(n_misses.map_or(0, |n| n.min(N_OBJECTS)));
|
||||
|
||||
assert_eq!(state, expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hitresults_n300_n100_n_misses_best() {
|
||||
let (map, attrs) = test_data();
|
||||
|
||||
let state = OsuPP::new(map)
|
||||
.attributes(attrs)
|
||||
.combo(500)
|
||||
.n300(300)
|
||||
.n100(20)
|
||||
.n_misses(2)
|
||||
.hitresult_priority(HitResultPriority::BestCase)
|
||||
.generate_state();
|
||||
|
||||
let expected = OsuScoreState {
|
||||
max_combo: 500,
|
||||
n300: 300,
|
||||
n100: 20,
|
||||
n50: 279,
|
||||
n_misses: 2,
|
||||
};
|
||||
|
||||
assert_eq!(state, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hitresults_n300_n50_n_misses_best() {
|
||||
let (map, attrs) = test_data();
|
||||
|
||||
let state = OsuPP::new(map)
|
||||
.attributes(attrs)
|
||||
.combo(500)
|
||||
.n300(300)
|
||||
.n50(10)
|
||||
.n_misses(2)
|
||||
.hitresult_priority(HitResultPriority::BestCase)
|
||||
.generate_state();
|
||||
|
||||
let expected = OsuScoreState {
|
||||
max_combo: 500,
|
||||
n300: 300,
|
||||
n100: 289,
|
||||
n50: 10,
|
||||
n_misses: 2,
|
||||
};
|
||||
|
||||
assert_eq!(state, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hitresults_n50_n_misses_worst() {
|
||||
let (map, attrs) = test_data();
|
||||
|
||||
let state = OsuPP::new(map)
|
||||
.attributes(attrs)
|
||||
.combo(500)
|
||||
.n50(10)
|
||||
.n_misses(2)
|
||||
.hitresult_priority(HitResultPriority::WorstCase)
|
||||
.generate_state();
|
||||
|
||||
let expected = OsuScoreState {
|
||||
max_combo: 500,
|
||||
n300: 0,
|
||||
n100: 589,
|
||||
n50: 10,
|
||||
n_misses: 2,
|
||||
};
|
||||
|
||||
assert_eq!(state, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hitresults_n300_n100_n50_n_misses_worst() {
|
||||
let (map, attrs) = test_data();
|
||||
|
||||
let state = OsuPP::new(map)
|
||||
.attributes(attrs)
|
||||
.combo(500)
|
||||
.n300(300)
|
||||
.n100(50)
|
||||
.n50(10)
|
||||
.n_misses(2)
|
||||
.hitresult_priority(HitResultPriority::WorstCase)
|
||||
.generate_state();
|
||||
|
||||
let expected = OsuScoreState {
|
||||
max_combo: 500,
|
||||
n300: 300,
|
||||
n100: 50,
|
||||
n50: 249,
|
||||
n_misses: 2,
|
||||
};
|
||||
|
||||
assert_eq!(state, expected);
|
||||
}
|
||||
f64::from(numerator) / f64::from(denominator)
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
use crate::parse::Pos2;
|
||||
|
||||
use super::NORMALIZED_RADIUS;
|
||||
|
||||
const OBJECT_RADIUS: f32 = 64.0;
|
||||
|
||||
#[derive(Copy, Clone, Debug)]
|
||||
pub(crate) struct ScalingFactor {
|
||||
pub(crate) factor: f32,
|
||||
pub(crate) radius: f32,
|
||||
scale: f32,
|
||||
}
|
||||
|
||||
impl ScalingFactor {
|
||||
pub(crate) fn new(cs: f64) -> Self {
|
||||
let scale = (1.0 - 0.7 * (cs as f32 - 5.0) / 5.0) / 2.0;
|
||||
|
||||
let radius = OBJECT_RADIUS * scale;
|
||||
let factor = NORMALIZED_RADIUS / radius;
|
||||
|
||||
let factor = if radius < 30.0 {
|
||||
factor * (1.0 + (30.0 - radius).min(5.0) / 50.0)
|
||||
} else {
|
||||
factor
|
||||
};
|
||||
|
||||
Self {
|
||||
factor,
|
||||
radius,
|
||||
scale,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn stack_offset(&self, stack_height: f32) -> Pos2 {
|
||||
Pos2::new(stack_height * self.scale * -6.4)
|
||||
}
|
||||
}
|
||||
+9
-13
@@ -1,35 +1,31 @@
|
||||
/// Aggregation for a score's current state i.e. what was the
|
||||
/// maximum combo so far and what are the current hitresults.
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq)]
|
||||
/// Aggregation for a score's current state.
|
||||
#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct OsuScoreState {
|
||||
/// Maximum combo that the score has had so far.
|
||||
/// **Not** the maximum possible combo of the map so far.
|
||||
pub max_combo: usize,
|
||||
pub max_combo: u32,
|
||||
/// Amount of current 300s.
|
||||
pub n300: usize,
|
||||
pub n300: u32,
|
||||
/// Amount of current 100s.
|
||||
pub n100: usize,
|
||||
pub n100: u32,
|
||||
/// Amount of current 50s.
|
||||
pub n50: usize,
|
||||
pub n50: u32,
|
||||
/// Amount of current misses.
|
||||
pub n_misses: usize,
|
||||
pub n_misses: u32,
|
||||
}
|
||||
|
||||
impl OsuScoreState {
|
||||
/// Create a new empty score state.
|
||||
#[inline]
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Return the total amount of hits by adding everything up.
|
||||
#[inline]
|
||||
pub const fn total_hits(&self) -> usize {
|
||||
pub const fn total_hits(&self) -> u32 {
|
||||
self.n300 + self.n100 + self.n50 + self.n_misses
|
||||
}
|
||||
|
||||
/// Calculate the accuracy between `0.0` and `1.0` for this state.
|
||||
#[inline]
|
||||
pub fn accuracy(&self) -> f64 {
|
||||
let total_hits = self.total_hits();
|
||||
|
||||
@@ -40,6 +36,6 @@ impl OsuScoreState {
|
||||
let numerator = 6 * self.n300 + 2 * self.n100 + self.n50;
|
||||
let denominator = 6 * total_hits;
|
||||
|
||||
numerator as f64 / denominator as f64
|
||||
f64::from(numerator) / f64::from(denominator)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,236 +0,0 @@
|
||||
use crate::{
|
||||
osu::{difficulty_object::OsuDifficultyObject, osu_object::OsuObjectKind},
|
||||
util::CompactVec,
|
||||
Mods,
|
||||
};
|
||||
|
||||
use super::{previous, previous_start_time, OsuStrainSkill, Skill, StrainSkill};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct Flashlight {
|
||||
curr_strain: f64,
|
||||
curr_section_peak: f64,
|
||||
curr_section_end: f64,
|
||||
pub(crate) strain_peaks: CompactVec,
|
||||
has_hidden_mod: bool,
|
||||
scaling_factor: f64,
|
||||
time_preempt: f64,
|
||||
time_fade_in: f64,
|
||||
}
|
||||
|
||||
impl Flashlight {
|
||||
const SKILL_MULTIPLIER: f64 = 0.052;
|
||||
const STRAIN_DECAY_BASE: f64 = 0.15;
|
||||
|
||||
pub(crate) fn new(mods: u32, radius: f32, time_preempt: f64, time_fade_in: f64) -> Self {
|
||||
Self {
|
||||
curr_strain: 0.0,
|
||||
curr_section_peak: 0.0,
|
||||
curr_section_end: 0.0,
|
||||
strain_peaks: CompactVec::new(),
|
||||
has_hidden_mod: mods.hd(),
|
||||
scaling_factor: 52.0 / f64::from(radius),
|
||||
time_preempt,
|
||||
time_fade_in,
|
||||
}
|
||||
}
|
||||
|
||||
fn strain_decay(ms: f64) -> f64 {
|
||||
Self::STRAIN_DECAY_BASE.powf(ms / 1000.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl Skill for Flashlight {
|
||||
#[inline]
|
||||
fn process(
|
||||
&mut self,
|
||||
curr: &OsuDifficultyObject<'_>,
|
||||
diff_objects: &[OsuDifficultyObject<'_>],
|
||||
) {
|
||||
<Self as StrainSkill>::process(self, curr, diff_objects);
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn difficulty_value(&mut self) -> f64 {
|
||||
<Self as StrainSkill>::difficulty_value(self)
|
||||
}
|
||||
}
|
||||
|
||||
impl StrainSkill for Flashlight {
|
||||
const DECAY_WEIGHT: f64 = 0.9;
|
||||
|
||||
#[inline]
|
||||
fn strain_peaks_mut(&mut self) -> &mut CompactVec {
|
||||
&mut self.strain_peaks
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn curr_section_peak(&mut self) -> &mut f64 {
|
||||
&mut self.curr_section_peak
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn curr_section_end(&mut self) -> &mut f64 {
|
||||
&mut self.curr_section_end
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn strain_value_at(
|
||||
&mut self,
|
||||
curr: &OsuDifficultyObject<'_>,
|
||||
diff_objects: &[OsuDifficultyObject<'_>],
|
||||
) -> f64 {
|
||||
self.curr_strain *= Self::strain_decay(curr.delta_time);
|
||||
self.curr_strain += FlashlightEvaluator::evaluate_diff_of(
|
||||
curr,
|
||||
diff_objects,
|
||||
self.has_hidden_mod,
|
||||
self.scaling_factor,
|
||||
self.time_preempt,
|
||||
self.time_fade_in,
|
||||
) * Self::SKILL_MULTIPLIER;
|
||||
|
||||
self.curr_strain
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn calculate_initial_strain(
|
||||
&self,
|
||||
time: f64,
|
||||
curr: &OsuDifficultyObject<'_>,
|
||||
diff_objects: &[OsuDifficultyObject<'_>],
|
||||
) -> f64 {
|
||||
self.curr_strain * Self::strain_decay(time - previous_start_time(diff_objects, curr.idx, 0))
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn difficulty_value(&mut self) -> f64 {
|
||||
self.get_curr_strain_peaks().sum() * Self::DIFFICULTY_MULTIPLER
|
||||
}
|
||||
}
|
||||
|
||||
impl OsuStrainSkill for Flashlight {}
|
||||
|
||||
struct FlashlightEvaluator;
|
||||
|
||||
impl FlashlightEvaluator {
|
||||
const MAX_OPACITY_BONUS: f64 = 0.4;
|
||||
const HIDDEN_BONUS: f64 = 0.2;
|
||||
|
||||
const MIN_VELOCITY: f64 = 0.5;
|
||||
const SLIDER_MULTIPLIER: f64 = 1.3;
|
||||
|
||||
const MIN_ANGLE_MULTIPLIER: f64 = 0.2;
|
||||
|
||||
fn evaluate_diff_of(
|
||||
curr: &OsuDifficultyObject<'_>,
|
||||
diff_objects: &[OsuDifficultyObject<'_>],
|
||||
hidden: bool,
|
||||
scaling_factor: f64,
|
||||
time_preempt: f64,
|
||||
time_fade_in: f64,
|
||||
) -> f64 {
|
||||
if curr.base.is_spinner() {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
let osu_curr = curr;
|
||||
let osu_hit_obj = curr.base;
|
||||
|
||||
let mut small_dist_nerf = 1.0;
|
||||
let mut cumulative_strain_time = 0.0;
|
||||
|
||||
let mut result = 0.0;
|
||||
|
||||
let mut last_obj = osu_curr;
|
||||
|
||||
let mut angle_repeat_count = 0.0;
|
||||
|
||||
// * This is iterating backwards in time from the current object.
|
||||
for i in 0..curr.idx.min(10) {
|
||||
let Some(curr_obj) = previous(diff_objects, curr.idx, i) else {
|
||||
break;
|
||||
};
|
||||
|
||||
let curr_hit_obj = curr_obj.base;
|
||||
|
||||
if !curr_obj.base.is_spinner() {
|
||||
let jump_dist = f64::from(
|
||||
(osu_hit_obj.stacked_pos() - curr_hit_obj.stacked_end_pos()).length(),
|
||||
);
|
||||
cumulative_strain_time += last_obj.strain_time;
|
||||
|
||||
// * We want to nerf objects that can be easily seen within the Flashlight circle radius.
|
||||
if i == 0 {
|
||||
small_dist_nerf = (jump_dist / 75.0).min(1.0);
|
||||
}
|
||||
|
||||
// * We also want to nerf stacks so that only the first object of the stack is accounted for.
|
||||
let stack_nerf = ((curr_obj.dists.lazy_jump_dist / scaling_factor) / 25.0).min(1.0);
|
||||
|
||||
// * Bonus based on how visible the object is.
|
||||
let opacity_bonus = 1.0
|
||||
+ Self::MAX_OPACITY_BONUS
|
||||
* (1.0
|
||||
- osu_curr.opacity_at(
|
||||
curr_hit_obj.start_time,
|
||||
hidden,
|
||||
time_preempt,
|
||||
time_fade_in,
|
||||
));
|
||||
|
||||
result += stack_nerf * opacity_bonus * scaling_factor * jump_dist
|
||||
/ cumulative_strain_time;
|
||||
|
||||
if let Some((curr_obj_angle, osu_curr_angle)) =
|
||||
curr_obj.dists.angle.zip(osu_curr.dists.angle)
|
||||
{
|
||||
// * Objects further back in time should count less for the nerf.
|
||||
if (curr_obj_angle - osu_curr_angle).abs() < 0.02 {
|
||||
angle_repeat_count += (1.0 - 0.1 * i as f64).max(0.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
last_obj = curr_obj;
|
||||
}
|
||||
|
||||
let base = small_dist_nerf * result;
|
||||
result = base * base;
|
||||
|
||||
// * Additional bonus for Hidden due to there being no approach circles.
|
||||
if hidden {
|
||||
result *= 1.0 + Self::HIDDEN_BONUS;
|
||||
}
|
||||
|
||||
// * Nerf patterns with repeated angles.
|
||||
result *= Self::MIN_ANGLE_MULTIPLIER
|
||||
+ (1.0 - Self::MIN_ANGLE_MULTIPLIER) / (angle_repeat_count + 1.0);
|
||||
|
||||
let mut slider_bonus = 0.0;
|
||||
|
||||
if let OsuObjectKind::Slider(slider) = &osu_curr.base.kind {
|
||||
// * Invert the scaling factor to determine the true travel distance independent of circle size.
|
||||
let pixel_travel_dist = f64::from(osu_curr.dists.lazy_travel_dist) / scaling_factor;
|
||||
|
||||
// * Reward sliders based on velocity.
|
||||
slider_bonus = ((pixel_travel_dist / osu_curr.dists.travel_time - Self::MIN_VELOCITY)
|
||||
.max(0.0))
|
||||
.sqrt();
|
||||
|
||||
// * Longer sliders require more memorisation.
|
||||
slider_bonus *= pixel_travel_dist;
|
||||
|
||||
// * Nerf sliders with repeats, as less memorisation is required.
|
||||
let repeat_count = slider.repeat_count();
|
||||
|
||||
if repeat_count > 0 {
|
||||
slider_bonus /= (repeat_count + 1) as f64;
|
||||
}
|
||||
}
|
||||
|
||||
result += slider_bonus * Self::SLIDER_MULTIPLIER;
|
||||
|
||||
result
|
||||
}
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
mod aim;
|
||||
mod flashlight;
|
||||
mod speed;
|
||||
mod traits;
|
||||
|
||||
use crate::osu::difficulty_object::OsuDifficultyObject;
|
||||
|
||||
pub(crate) use self::{
|
||||
aim::Aim,
|
||||
flashlight::Flashlight,
|
||||
speed::Speed,
|
||||
traits::{OsuStrainSkill, Skill, StrainSkill},
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct Skills {
|
||||
pub aim: Aim,
|
||||
pub aim_no_sliders: Aim,
|
||||
pub speed: Speed,
|
||||
pub flashlight: Flashlight,
|
||||
}
|
||||
|
||||
impl Skills {
|
||||
pub(crate) fn new(
|
||||
mods: u32,
|
||||
radius: f32,
|
||||
time_preempt: f64,
|
||||
time_fade_in: f64,
|
||||
hit_window: f64,
|
||||
) -> Self {
|
||||
Self {
|
||||
aim: Aim::new(true),
|
||||
aim_no_sliders: Aim::new(false),
|
||||
speed: Speed::new(hit_window),
|
||||
flashlight: Flashlight::new(mods, radius, time_preempt, time_fade_in),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn process(
|
||||
&mut self,
|
||||
curr: &OsuDifficultyObject<'_>,
|
||||
diff_objects: &[OsuDifficultyObject<'_>],
|
||||
) {
|
||||
<Aim as Skill>::process(&mut self.aim, curr, diff_objects);
|
||||
<Aim as Skill>::process(&mut self.aim_no_sliders, curr, diff_objects);
|
||||
<Speed as Skill>::process(&mut self.speed, curr, diff_objects);
|
||||
<Flashlight as Skill>::process(&mut self.flashlight, curr, diff_objects);
|
||||
}
|
||||
}
|
||||
|
||||
fn previous<'map, 'objects>(
|
||||
diff_objects: &'objects [OsuDifficultyObject<'map>],
|
||||
curr: usize,
|
||||
backwards_idx: usize,
|
||||
) -> Option<&'objects OsuDifficultyObject<'map>> {
|
||||
curr.checked_sub(backwards_idx + 1)
|
||||
.and_then(|idx| diff_objects.get(idx))
|
||||
}
|
||||
|
||||
fn previous_start_time(
|
||||
diff_objects: &[OsuDifficultyObject<'_>],
|
||||
curr: usize,
|
||||
backwards_idx: usize,
|
||||
) -> f64 {
|
||||
previous(diff_objects, curr, backwards_idx).map_or(0.0, |h| h.start_time)
|
||||
}
|
||||
|
||||
fn next<'map, 'objects>(
|
||||
diff_objects: &'objects [OsuDifficultyObject<'map>],
|
||||
curr: usize,
|
||||
forwards_idx: usize,
|
||||
) -> Option<&'objects OsuDifficultyObject<'map>> {
|
||||
diff_objects.get(curr + (forwards_idx + 1))
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
use std::{cmp::Ordering, mem};
|
||||
|
||||
use crate::{
|
||||
osu::{difficulty_object::OsuDifficultyObject, SECTION_LEN},
|
||||
util::CompactVec,
|
||||
};
|
||||
|
||||
pub(crate) trait Skill {
|
||||
fn process(&mut self, curr: &OsuDifficultyObject<'_>, diff_objects: &[OsuDifficultyObject<'_>]);
|
||||
fn difficulty_value(&mut self) -> f64;
|
||||
}
|
||||
|
||||
pub(crate) trait StrainSkill: Skill + Sized {
|
||||
const DECAY_WEIGHT: f64 = 0.9;
|
||||
|
||||
fn strain_peaks_mut(&mut self) -> &mut CompactVec;
|
||||
fn curr_section_peak(&mut self) -> &mut f64;
|
||||
fn curr_section_end(&mut self) -> &mut f64;
|
||||
|
||||
fn strain_value_at(
|
||||
&mut self,
|
||||
curr: &OsuDifficultyObject<'_>,
|
||||
diff_objects: &[OsuDifficultyObject<'_>],
|
||||
) -> f64;
|
||||
|
||||
fn calculate_initial_strain(
|
||||
&self,
|
||||
time: f64,
|
||||
curr: &OsuDifficultyObject<'_>,
|
||||
diff_objects: &[OsuDifficultyObject<'_>],
|
||||
) -> f64;
|
||||
|
||||
fn process(
|
||||
&mut self,
|
||||
curr: &OsuDifficultyObject<'_>,
|
||||
diff_objects: &[OsuDifficultyObject<'_>],
|
||||
) {
|
||||
// * The first object doesn't generate a strain, so we begin with an incremented section end
|
||||
if curr.idx == 0 {
|
||||
let section_len = SECTION_LEN;
|
||||
*self.curr_section_end() = (curr.start_time / section_len).ceil() * section_len;
|
||||
}
|
||||
|
||||
while curr.start_time > *self.curr_section_end() {
|
||||
self.save_curr_peak();
|
||||
|
||||
{
|
||||
let section_end = *self.curr_section_end();
|
||||
self.start_new_section_from(section_end, curr, diff_objects);
|
||||
}
|
||||
|
||||
*self.curr_section_end() += SECTION_LEN;
|
||||
|
||||
// Optimization to finish the loop early if
|
||||
// the current peak is 0.0 i.e. it can't decay further.
|
||||
// If final values don't coincide perfectly anymore,
|
||||
// this should be looked at and maybe adjusted.
|
||||
if self.curr_section_peak().abs() <= f64::EPSILON
|
||||
&& curr.start_time > *self.curr_section_end()
|
||||
{
|
||||
let remaining_time = curr.start_time - *self.curr_section_end();
|
||||
let remaining_iters = (remaining_time / SECTION_LEN).ceil();
|
||||
*self.curr_section_end() += remaining_iters * SECTION_LEN;
|
||||
}
|
||||
}
|
||||
|
||||
*self.curr_section_peak() = self
|
||||
.strain_value_at(curr, diff_objects)
|
||||
.max(*self.curr_section_peak());
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn save_curr_peak(&mut self) {
|
||||
let peak = *self.curr_section_peak();
|
||||
self.strain_peaks_mut().push(peak);
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn start_new_section_from(
|
||||
&mut self,
|
||||
time: f64,
|
||||
curr: &OsuDifficultyObject<'_>,
|
||||
diff_objects: &[OsuDifficultyObject<'_>],
|
||||
) {
|
||||
// * The maximum strain of the new section is not zero by default
|
||||
// * This means we need to capture the strain level at the beginning of the new section,
|
||||
// * and use that as the initial peak level.
|
||||
*self.curr_section_peak() = self.calculate_initial_strain(time, curr, diff_objects);
|
||||
}
|
||||
|
||||
fn difficulty_value(&mut self) -> f64;
|
||||
|
||||
#[inline]
|
||||
fn get_curr_strain_peaks(&mut self) -> CompactVec {
|
||||
let curr_peak = *self.curr_section_peak();
|
||||
let mut strain_peaks = mem::take(self.strain_peaks_mut());
|
||||
strain_peaks.push(curr_peak);
|
||||
|
||||
strain_peaks
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) trait OsuStrainSkill: StrainSkill + Sized {
|
||||
const REDUCED_SECTION_COUNT: usize = 10;
|
||||
const REDUCED_STRAIN_BASELINE: f64 = 0.75;
|
||||
const DIFFICULTY_MULTIPLER: f64 = 1.06;
|
||||
|
||||
fn difficulty_value(&mut self) -> f64 {
|
||||
let mut difficulty = 0.0;
|
||||
let mut weight = 1.0;
|
||||
|
||||
// * Sections with 0 strain are excluded to avoid worst-case time complexity of the following sort (e.g. /b/2351871).
|
||||
// * These sections will not contribute to the difficulty.
|
||||
let mut peaks = self.get_curr_strain_peaks();
|
||||
peaks.retain(|peak| peak > 0.0);
|
||||
|
||||
let mut peaks = peaks.to_vec();
|
||||
peaks.sort_unstable_by(|a, b| b.partial_cmp(a).unwrap_or(Ordering::Equal));
|
||||
|
||||
let peak_iter = peaks.iter_mut().take(Self::REDUCED_SECTION_COUNT);
|
||||
|
||||
fn lerp(start: f64, end: f64, amount: f64) -> f64 {
|
||||
start + (end - start) * amount
|
||||
}
|
||||
|
||||
// * We are reducing the highest strains first to account for extreme difficulty spikes
|
||||
for (i, strain) in peak_iter.enumerate() {
|
||||
let clamped =
|
||||
f64::from((i as f32 / Self::REDUCED_SECTION_COUNT as f32).clamp(0.0, 1.0));
|
||||
let scale = (lerp(1.0, 10.0, clamped)).log10();
|
||||
*strain *= lerp(Self::REDUCED_STRAIN_BASELINE, 1.0, scale);
|
||||
}
|
||||
|
||||
peaks.sort_unstable_by(|a, b| b.partial_cmp(a).unwrap_or(Ordering::Equal));
|
||||
|
||||
// * Difficulty is the weighted sum of the highest strains from every section.
|
||||
// * We're sorting from highest to lowest strain.
|
||||
for strain in peaks {
|
||||
difficulty += strain * weight;
|
||||
weight *= Self::DECAY_WEIGHT;
|
||||
}
|
||||
|
||||
difficulty * Self::DIFFICULTY_MULTIPLER
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
use crate::any::ModeDifficulty;
|
||||
|
||||
use super::{convert::OsuBeatmap, difficulty::DifficultyValues};
|
||||
|
||||
/// 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 aim skill without sliders.
|
||||
pub aim_no_sliders: Vec<f64>,
|
||||
/// Strain peaks of the speed skill.
|
||||
pub speed: Vec<f64>,
|
||||
/// Strain peaks of the flashlight skill.
|
||||
pub flashlight: Vec<f64>,
|
||||
}
|
||||
|
||||
impl OsuStrains {
|
||||
/// Time between two strains in ms.
|
||||
pub const SECTION_LEN: f64 = 400.0;
|
||||
}
|
||||
|
||||
pub fn strains(difficulty: &ModeDifficulty, converted: &OsuBeatmap<'_>) -> OsuStrains {
|
||||
let DifficultyValues {
|
||||
aim,
|
||||
aim_no_sliders,
|
||||
speed,
|
||||
flashlight,
|
||||
attrs: _,
|
||||
} = DifficultyValues::calculate(difficulty, converted);
|
||||
|
||||
OsuStrains {
|
||||
aim: aim.get_curr_strain_peaks(),
|
||||
aim_no_sliders: aim_no_sliders.get_curr_strain_peaks(),
|
||||
speed: speed.get_curr_strain_peaks(),
|
||||
flashlight: flashlight.get_curr_strain_peaks(),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user