wip osu pp update
This commit is contained in:
@@ -0,0 +1,273 @@
|
||||
use std::{
|
||||
any::Any,
|
||||
f64::consts::{FRAC_PI_2, FRAC_PI_6, PI},
|
||||
mem,
|
||||
};
|
||||
|
||||
use crate::osu::difficulty_object::OsuDifficultyObject;
|
||||
|
||||
use super::{previous, previous_start_time, OsuStrainSkill, Skill, StrainSkill};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct Aim {
|
||||
curr_strain: f64,
|
||||
curr_section_peak: f64,
|
||||
curr_section_end: f64,
|
||||
strain_peaks: Vec<f64>,
|
||||
with_sliders: bool,
|
||||
}
|
||||
|
||||
impl Aim {
|
||||
const SKILL_MULTIPLIER: f64 = 23.55;
|
||||
const STRAIN_DECAY_BASE: f64 = 0.15;
|
||||
|
||||
pub(crate) fn new(with_sliders: bool) -> Self {
|
||||
Self {
|
||||
curr_strain: 0.0,
|
||||
curr_section_peak: 0.0,
|
||||
curr_section_end: 0.0,
|
||||
strain_peaks: Vec::new(),
|
||||
with_sliders,
|
||||
}
|
||||
}
|
||||
|
||||
fn strain_decay(ms: f64) -> f64 {
|
||||
Self::STRAIN_DECAY_BASE.powf(ms / 1000.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl Skill for Aim {
|
||||
fn process(
|
||||
&mut self,
|
||||
curr: &OsuDifficultyObject<'_>,
|
||||
diff_objects: &[OsuDifficultyObject<'_>],
|
||||
hit_window: f64,
|
||||
) {
|
||||
<Self as StrainSkill>::process(self, curr, diff_objects, hit_window)
|
||||
}
|
||||
|
||||
fn difficulty_value(&mut self) -> f64 {
|
||||
<Self as OsuStrainSkill>::difficulty_value(self)
|
||||
}
|
||||
|
||||
fn as_any(&self) -> &dyn Any {
|
||||
self
|
||||
}
|
||||
|
||||
fn as_any_mut(&mut self) -> &mut dyn Any {
|
||||
self
|
||||
}
|
||||
|
||||
fn take_strain_peaks(&mut self) -> Vec<f64> {
|
||||
mem::take(&mut self.strain_peaks)
|
||||
}
|
||||
}
|
||||
|
||||
impl StrainSkill for Aim {
|
||||
fn strain_peaks_mut(&mut self) -> &mut Vec<f64> {
|
||||
&mut self.strain_peaks
|
||||
}
|
||||
|
||||
fn curr_section_peak(&mut self) -> &mut f64 {
|
||||
&mut self.curr_section_peak
|
||||
}
|
||||
|
||||
fn curr_section_end(&mut self) -> &mut f64 {
|
||||
&mut self.curr_section_end
|
||||
}
|
||||
|
||||
fn strain_value_at(
|
||||
&mut self,
|
||||
curr: &OsuDifficultyObject<'_>,
|
||||
diff_objects: &[OsuDifficultyObject<'_>],
|
||||
_hit_window: f64,
|
||||
) -> 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
|
||||
}
|
||||
|
||||
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 difficulty_value(&mut self) -> f64 {
|
||||
<Self as OsuStrainSkill>::difficulty_value(self)
|
||||
}
|
||||
}
|
||||
|
||||
impl OsuStrainSkill for Aim {}
|
||||
|
||||
struct AimEvaluator;
|
||||
|
||||
impl AimEvaluator {
|
||||
const WIDE_ANGLE_MULTIPLIER: f64 = 1.5;
|
||||
const ACUTE_ANGLE_MULTIPLIER: f64 = 1.95;
|
||||
const SLIDER_MULTIPLIER: f64 = 1.35;
|
||||
const VELOCITY_CHANGE_MULTIPLIER: f64 = 0.75;
|
||||
|
||||
fn evaluate_diff_of(
|
||||
curr: &OsuDifficultyObject<'_>,
|
||||
diff_objects: &[OsuDifficultyObject<'_>],
|
||||
with_sliders: bool,
|
||||
) -> f64 {
|
||||
let osu_curr_obj = curr;
|
||||
|
||||
let (osu_last_last_obj, osu_last_obj) = if let Some(tuple) =
|
||||
previous(diff_objects, curr.idx, 1)
|
||||
.zip(previous(diff_objects, curr.idx, 0))
|
||||
.filter(|(_, last)| !(curr.base.is_spinner() || last.base.is_spinner()))
|
||||
{
|
||||
tuple
|
||||
} else {
|
||||
return 0.0;
|
||||
};
|
||||
|
||||
// * 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;
|
||||
|
||||
// * 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;
|
||||
// * 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;
|
||||
|
||||
// * 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;
|
||||
|
||||
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;
|
||||
|
||||
prev_vel = prev_vel.max(movement_vel + travel_vel);
|
||||
}
|
||||
|
||||
let mut wide_angle_bonus = 0.0;
|
||||
let mut acute_angle_bonus = 0.0;
|
||||
let mut slider_bonus = 0.0;
|
||||
let mut vel_change_bonus = 0.0;
|
||||
|
||||
// * Start strain with regular velocity.
|
||||
let mut aim_strain = curr_vel;
|
||||
|
||||
// * If rhythms are the same.
|
||||
if osu_curr_obj.strain_time.max(osu_last_obj.strain_time)
|
||||
< 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)
|
||||
{
|
||||
// * Rewarding angles, take the smaller velocity as base.
|
||||
let angle_bonus = curr_vel.min(prev_vel);
|
||||
|
||||
wide_angle_bonus = Self::calc_wide_angle_bonus(curr_angle);
|
||||
acute_angle_bonus = Self::calc_acute_angle_bonus(curr_angle);
|
||||
|
||||
// * Only buff deltaTime exceeding 300 bpm 1/2.
|
||||
if osu_curr_obj.strain_time > 100.0 {
|
||||
acute_angle_bonus = 0.0;
|
||||
} else {
|
||||
let base1 =
|
||||
(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)
|
||||
/ 50.0)
|
||||
.sin();
|
||||
|
||||
// * Multiply by previous angle, we don't want to buff unless this is a wiggle type pattern.
|
||||
acute_angle_bonus *= Self::calc_acute_angle_bonus(last_angle)
|
||||
// * The maximum velocity we buff is equal to 125 / strainTime
|
||||
* angle_bonus.min(125.0 / osu_curr_obj.strain_time)
|
||||
// * scale buff from 150 bpm 1/4 to 200 bpm 1/4
|
||||
* base1
|
||||
* base1
|
||||
// * Buff distance exceeding 50 (radius) up to 100 (diameter).
|
||||
* base2
|
||||
* base2;
|
||||
}
|
||||
|
||||
// * Penalize wide angles if they're repeated, reducing the penalty as the lastAngle gets more acute.
|
||||
wide_angle_bonus *= angle_bonus
|
||||
* (1.0 - wide_angle_bonus.min(Self::calc_wide_angle_bonus(last_angle).powi(3)));
|
||||
// * Penalize acute angles if they're repeated, reducing the penalty as the lastLastAngle gets more obtuse.
|
||||
acute_angle_bonus *= 0.5
|
||||
+ 0.5
|
||||
* (1.0
|
||||
- acute_angle_bonus
|
||||
.min(Self::calc_acute_angle_bonus(last_last_angle).powi(3)));
|
||||
}
|
||||
}
|
||||
|
||||
if prev_vel.max(curr_vel).abs() > f64::EPSILON {
|
||||
// * 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)
|
||||
/ 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;
|
||||
|
||||
// * Scale with ratio of difference compared to 0.5 * max dist.
|
||||
let dist_ratio_base =
|
||||
(FRAC_PI_2 * (prev_vel - curr_vel).abs() / prev_vel.max(curr_vel)).sin();
|
||||
let dist_ratio = dist_ratio_base * dist_ratio_base;
|
||||
|
||||
// * Reward for % distance up to 125 / strainTime for overlaps where velocity is still changing.
|
||||
let overlap_vel_buff = (125.0 / osu_curr_obj.strain_time.min(osu_last_obj.strain_time))
|
||||
.min((prev_vel - curr_vel).abs());
|
||||
|
||||
vel_change_bonus = overlap_vel_buff * dist_ratio;
|
||||
|
||||
// * Penalize for rhythm changes.
|
||||
let bonus_base = (osu_curr_obj.strain_time).min(osu_last_obj.strain_time)
|
||||
/ (osu_curr_obj.strain_time).max(osu_last_obj.strain_time);
|
||||
vel_change_bonus *= bonus_base * bonus_base;
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// * Add in acute angle bonus or wide angle bonus + velocity change bonus, whichever is larger.
|
||||
aim_strain += (acute_angle_bonus * Self::ACUTE_ANGLE_MULTIPLIER).max(
|
||||
wide_angle_bonus * Self::WIDE_ANGLE_MULTIPLIER
|
||||
+ vel_change_bonus * Self::VELOCITY_CHANGE_MULTIPLIER,
|
||||
);
|
||||
|
||||
// * Add in additional slider velocity bonus.
|
||||
if with_sliders {
|
||||
aim_strain += slider_bonus * Self::SLIDER_MULTIPLIER;
|
||||
}
|
||||
|
||||
aim_strain
|
||||
}
|
||||
|
||||
fn calc_wide_angle_bonus(angle: f64) -> f64 {
|
||||
let base = (3.0 / 4.0 * ((5.0 / 6.0 * PI).min(angle.max(FRAC_PI_6)) - FRAC_PI_6)).sin();
|
||||
|
||||
base * base
|
||||
}
|
||||
|
||||
fn calc_acute_angle_bonus(angle: f64) -> f64 {
|
||||
1.0 - Self::calc_wide_angle_bonus(angle)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
use std::{any::Any, mem};
|
||||
|
||||
use crate::{
|
||||
osu::{
|
||||
difficulty_object::OsuDifficultyObject,
|
||||
osu_object::{NestedObjectKind, OsuObjectKind},
|
||||
},
|
||||
Mods,
|
||||
};
|
||||
|
||||
use super::{previous, previous_start_time, OsuStrainSkill, Skill, StrainSkill};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct Flashlight {
|
||||
curr_strain: f64,
|
||||
curr_section_peak: f64,
|
||||
curr_section_end: f64,
|
||||
strain_peaks: Vec<f64>,
|
||||
has_hidden_mod: bool,
|
||||
scaling_factor: f64,
|
||||
}
|
||||
|
||||
impl Flashlight {
|
||||
const SKILL_MULTIPLIER: f64 = 0.052;
|
||||
const STRAIN_DECAY_BASE: f64 = 0.15;
|
||||
|
||||
pub(crate) fn new(mods: u32, radius: f32) -> Self {
|
||||
Self {
|
||||
curr_strain: 0.0,
|
||||
curr_section_peak: 0.0,
|
||||
curr_section_end: 0.0,
|
||||
strain_peaks: Vec::new(),
|
||||
has_hidden_mod: mods.hd(),
|
||||
scaling_factor: 52.0 / radius as f64,
|
||||
}
|
||||
}
|
||||
|
||||
fn strain_decay(ms: f64) -> f64 {
|
||||
Self::STRAIN_DECAY_BASE.powf(ms / 1000.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl Skill for Flashlight {
|
||||
fn process(
|
||||
&mut self,
|
||||
curr: &OsuDifficultyObject<'_>,
|
||||
diff_objects: &[OsuDifficultyObject<'_>],
|
||||
hit_window: f64,
|
||||
) {
|
||||
<Self as StrainSkill>::process(self, curr, diff_objects, hit_window)
|
||||
}
|
||||
|
||||
fn difficulty_value(&mut self) -> f64 {
|
||||
<Self as StrainSkill>::difficulty_value(self)
|
||||
}
|
||||
|
||||
fn as_any(&self) -> &dyn Any {
|
||||
self
|
||||
}
|
||||
|
||||
fn as_any_mut(&mut self) -> &mut dyn Any {
|
||||
self
|
||||
}
|
||||
|
||||
fn take_strain_peaks(&mut self) -> Vec<f64> {
|
||||
mem::take(&mut self.strain_peaks)
|
||||
}
|
||||
}
|
||||
|
||||
impl StrainSkill for Flashlight {
|
||||
const DECAY_WEIGHT: f64 = 0.9;
|
||||
|
||||
fn strain_peaks_mut(&mut self) -> &mut Vec<f64> {
|
||||
&mut self.strain_peaks
|
||||
}
|
||||
|
||||
fn curr_section_peak(&mut self) -> &mut f64 {
|
||||
&mut self.curr_section_peak
|
||||
}
|
||||
|
||||
fn curr_section_end(&mut self) -> &mut f64 {
|
||||
&mut self.curr_section_end
|
||||
}
|
||||
|
||||
fn strain_value_at(
|
||||
&mut self,
|
||||
curr: &OsuDifficultyObject<'_>,
|
||||
diff_objects: &[OsuDifficultyObject<'_>],
|
||||
_hit_window: f64,
|
||||
) -> 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::SKILL_MULTIPLIER;
|
||||
|
||||
self.curr_strain
|
||||
}
|
||||
|
||||
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 difficulty_value(&mut self) -> f64 {
|
||||
self.get_curr_strain_peaks().into_iter().sum::<f64>() * 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,
|
||||
) -> 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 curr_obj = if let Some(curr_obj) = previous(diff_objects, curr.idx, i) {
|
||||
curr_obj
|
||||
} else {
|
||||
break;
|
||||
};
|
||||
|
||||
let curr_hit_obj = curr_obj.base;
|
||||
|
||||
if !curr_obj.base.is_spinner() {
|
||||
let jump_dist = (osu_hit_obj.pos - curr_hit_obj.end_pos()).length() as f64;
|
||||
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));
|
||||
|
||||
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 { nested_objects, .. } = &osu_curr.base.kind {
|
||||
// * Invert the scaling factor to determine the true travel distance independent of circle size.
|
||||
let pixel_travel_dist = osu_curr.dists.lazy_travel_dist as f64 / scaling_factor;
|
||||
|
||||
// * Reward sliders based on velocity.
|
||||
slider_bonus = ((pixel_travel_dist / osu_curr.dists.travel_time as f64
|
||||
- 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 = nested_objects.iter().fold(0, |count, nested| {
|
||||
count + matches!(nested.kind, NestedObjectKind::Repeat) as usize
|
||||
});
|
||||
|
||||
if repeat_count > 0 {
|
||||
slider_bonus /= (repeat_count + 1) as f64;
|
||||
}
|
||||
}
|
||||
|
||||
result += slider_bonus * Self::SLIDER_MULTIPLIER;
|
||||
|
||||
result
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
mod aim;
|
||||
mod flashlight;
|
||||
mod speed;
|
||||
|
||||
use std::{any::Any, cmp::Ordering, mem};
|
||||
|
||||
pub(crate) use self::{aim::Aim, flashlight::Flashlight, speed::Speed};
|
||||
|
||||
use super::{difficulty_object::OsuDifficultyObject, SECTION_LEN};
|
||||
|
||||
pub(crate) trait Skill {
|
||||
fn process(
|
||||
&mut self,
|
||||
curr: &OsuDifficultyObject<'_>,
|
||||
diff_objects: &[OsuDifficultyObject<'_>],
|
||||
hit_window: f64,
|
||||
);
|
||||
fn difficulty_value(&mut self) -> f64;
|
||||
fn as_any(&self) -> &dyn Any;
|
||||
fn as_any_mut(&mut self) -> &mut dyn Any;
|
||||
fn take_strain_peaks(&mut self) -> Vec<f64>;
|
||||
}
|
||||
|
||||
pub(crate) trait StrainSkill: Skill + Sized {
|
||||
const DECAY_WEIGHT: f64 = 0.9;
|
||||
|
||||
fn strain_peaks_mut(&mut self) -> &mut Vec<f64>;
|
||||
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<'_>],
|
||||
hit_window: f64,
|
||||
) -> f64;
|
||||
|
||||
fn calculate_initial_strain(
|
||||
&self,
|
||||
time: f64,
|
||||
curr: &OsuDifficultyObject<'_>,
|
||||
diff_objects: &[OsuDifficultyObject<'_>],
|
||||
) -> f64;
|
||||
|
||||
fn process(
|
||||
&mut self,
|
||||
curr: &OsuDifficultyObject<'_>,
|
||||
diff_objects: &[OsuDifficultyObject<'_>],
|
||||
hit_window: f64,
|
||||
) {
|
||||
// * 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 as f64;
|
||||
*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 as f64;
|
||||
}
|
||||
|
||||
*self.curr_section_peak() = self
|
||||
.strain_value_at(curr, diff_objects, hit_window)
|
||||
.max(*self.curr_section_peak());
|
||||
}
|
||||
|
||||
fn save_curr_peak(&mut self) {
|
||||
let peak = *self.curr_section_peak();
|
||||
self.strain_peaks_mut().push(peak);
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
fn get_curr_strain_peaks(&mut self) -> Vec<f64> {
|
||||
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);
|
||||
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 = (i as f32 / Self::REDUCED_SECTION_COUNT as f32).clamp(0.0, 1.0) as f64;
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
use std::{any::Any, cmp::Ordering, f64::consts::PI, mem};
|
||||
|
||||
use crate::osu::difficulty_object::OsuDifficultyObject;
|
||||
|
||||
use super::{next, previous, previous_start_time, OsuStrainSkill, Skill, StrainSkill};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct Speed {
|
||||
curr_strain: f64,
|
||||
curr_section_peak: f64,
|
||||
curr_section_end: f64,
|
||||
curr_rhythm: f64,
|
||||
strain_peaks: Vec<f64>,
|
||||
object_strains: Vec<f64>,
|
||||
}
|
||||
|
||||
impl Speed {
|
||||
const SKILL_MULTIPLIER: f64 = 1375.0;
|
||||
const STRAIN_DECAY_BASE: f64 = 0.3;
|
||||
|
||||
pub(crate) fn new() -> Self {
|
||||
Self {
|
||||
curr_strain: 0.0,
|
||||
curr_section_peak: 0.0,
|
||||
curr_section_end: 0.0,
|
||||
curr_rhythm: 0.0,
|
||||
strain_peaks: Vec::new(),
|
||||
object_strains: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn strain_decay(ms: f64) -> f64 {
|
||||
Self::STRAIN_DECAY_BASE.powf(ms / 1000.0)
|
||||
}
|
||||
|
||||
pub(crate) fn relevant_note_count(&self) -> f64 {
|
||||
self.object_strains
|
||||
.iter()
|
||||
.max_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal))
|
||||
.copied()
|
||||
.filter(|&n| n > 0.0)
|
||||
.map_or(0.0, |max_strain| {
|
||||
self.object_strains.iter().fold(0.0, |sum, strain| {
|
||||
sum + (1.0 + (-(strain / max_strain * 12.0 - 6.0)).exp()).recip()
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Skill for Speed {
|
||||
fn process(
|
||||
&mut self,
|
||||
curr: &OsuDifficultyObject<'_>,
|
||||
diff_objects: &[OsuDifficultyObject<'_>],
|
||||
hit_window: f64,
|
||||
) {
|
||||
<Self as StrainSkill>::process(self, curr, diff_objects, hit_window)
|
||||
}
|
||||
|
||||
fn difficulty_value(&mut self) -> f64 {
|
||||
<Self as OsuStrainSkill>::difficulty_value(self)
|
||||
}
|
||||
|
||||
fn as_any(&self) -> &dyn Any {
|
||||
self
|
||||
}
|
||||
|
||||
fn as_any_mut(&mut self) -> &mut dyn Any {
|
||||
self
|
||||
}
|
||||
|
||||
fn take_strain_peaks(&mut self) -> Vec<f64> {
|
||||
mem::take(&mut self.strain_peaks)
|
||||
}
|
||||
}
|
||||
|
||||
impl StrainSkill for Speed {
|
||||
fn strain_peaks_mut(&mut self) -> &mut Vec<f64> {
|
||||
&mut self.strain_peaks
|
||||
}
|
||||
|
||||
fn curr_section_peak(&mut self) -> &mut f64 {
|
||||
&mut self.curr_section_peak
|
||||
}
|
||||
|
||||
fn curr_section_end(&mut self) -> &mut f64 {
|
||||
&mut self.curr_section_end
|
||||
}
|
||||
|
||||
fn strain_value_at(
|
||||
&mut self,
|
||||
curr: &OsuDifficultyObject<'_>,
|
||||
diff_objects: &[OsuDifficultyObject<'_>],
|
||||
hit_window: f64,
|
||||
) -> f64 {
|
||||
self.curr_strain *= Self::strain_decay(curr.strain_time);
|
||||
self.curr_strain += SpeedEvaluator::evaluate_diff_of(curr, diff_objects, hit_window)
|
||||
* Self::SKILL_MULTIPLIER;
|
||||
self.curr_rhythm = RhythmEvaluator::evaluate_diff_of(curr, diff_objects, hit_window);
|
||||
|
||||
let total_strain = self.curr_strain * self.curr_rhythm;
|
||||
self.object_strains.push(total_strain);
|
||||
|
||||
total_strain
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
impl SpeedEvaluator {
|
||||
const SINGLE_SPACING_THRESHOLD: f64 = 125.0;
|
||||
const MIN_SPEED_BONUS: f64 = 75.0; // ~200BPM
|
||||
const SPEED_BALANCING_FACTOR: f64 = 40.;
|
||||
|
||||
fn evaluate_diff_of(
|
||||
curr: &OsuDifficultyObject<'_>,
|
||||
diff_objects: &[OsuDifficultyObject<'_>],
|
||||
hit_window: f64,
|
||||
) -> f64 {
|
||||
if curr.base.is_spinner() {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
// * 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 mut strain_time = curr.strain_time;
|
||||
let mut doubletapness = 1.0;
|
||||
|
||||
// * Nerf doubletappable doubles.
|
||||
if let Some(osu_next_obj) = osu_next_obj {
|
||||
let curr_delta_time = osu_curr_obj.delta_time.max(1.0);
|
||||
let next_delta_time = osu_next_obj.delta_time.max(1.0);
|
||||
let delta_diff = (next_delta_time - curr_delta_time).abs();
|
||||
let speed_ratio = curr_delta_time / curr_delta_time.max(delta_diff);
|
||||
let window_ratio_base = (curr_delta_time / hit_window).min(1.0);
|
||||
let window_ratio = window_ratio_base * window_ratio_base;
|
||||
doubletapness = speed_ratio.powf(1.0 - window_ratio);
|
||||
}
|
||||
|
||||
// * Cap deltatime to the OD 300 hitwindow.
|
||||
// * 0.93 is derived from making sure 260bpm OD8 streams aren't nerfed harshly, whilst 0.92 limits the effect of the cap.
|
||||
strain_time /= ((strain_time / hit_window) / 0.93).clamp(0.92, 1.0);
|
||||
|
||||
// * derive speedBonus for calculation
|
||||
let speed_bonus = if strain_time < Self::MIN_SPEED_BONUS {
|
||||
let base = (Self::MIN_SPEED_BONUS - strain_time) / Self::SPEED_BALANCING_FACTOR;
|
||||
|
||||
1.0 + 0.75 * base * base
|
||||
} else {
|
||||
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);
|
||||
|
||||
(speed_bonus + speed_bonus * (dist / Self::SINGLE_SPACING_THRESHOLD).powf(3.5))
|
||||
* doubletapness
|
||||
/ strain_time
|
||||
}
|
||||
}
|
||||
|
||||
struct RhythmEvaluator;
|
||||
|
||||
impl RhythmEvaluator {
|
||||
// * 5 seconds of calculatingRhythmBonus max.
|
||||
const HISTORY_TIME_MAX: u32 = 5000;
|
||||
const RHYTHM_MULTIPLIER: f64 = 0.75;
|
||||
|
||||
fn evaluate_diff_of(
|
||||
curr: &OsuDifficultyObject<'_>,
|
||||
diff_objects: &[OsuDifficultyObject<'_>],
|
||||
hit_window: f64,
|
||||
) -> f64 {
|
||||
if curr.base.is_spinner() {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
let mut prev_island_size = 0;
|
||||
|
||||
let mut rhythm_complexity_sum = 0.0;
|
||||
let mut island_size = 1;
|
||||
// * store the ratio of the current start of an island to buff for tighter rhythms
|
||||
let mut start_ratio = 0.0;
|
||||
|
||||
let mut first_delta_switch = false;
|
||||
|
||||
let historical_note_count = curr.idx.min(32);
|
||||
|
||||
let mut rhythm_start = 0;
|
||||
|
||||
while previous(diff_objects, curr.idx, rhythm_start)
|
||||
.filter(|prev| {
|
||||
rhythm_start + 2 < historical_note_count
|
||||
&& curr.start_time - prev.start_time < Self::HISTORY_TIME_MAX as f64
|
||||
})
|
||||
.is_some()
|
||||
{
|
||||
rhythm_start += 1;
|
||||
}
|
||||
|
||||
for i in (1..=rhythm_start).rev() {
|
||||
let (curr_obj, prev_obj, last_obj) = if let Some(((curr, prev), last)) =
|
||||
previous(diff_objects, curr.idx, i - 1)
|
||||
.zip(previous(diff_objects, curr.idx, i))
|
||||
.zip(previous(diff_objects, curr.idx, i + 1))
|
||||
{
|
||||
(curr, prev, last)
|
||||
} else {
|
||||
break;
|
||||
};
|
||||
|
||||
// * scales note 0 to 1 from history to now
|
||||
let mut curr_historical_decay = (Self::HISTORY_TIME_MAX as f64
|
||||
- (curr.start_time - curr_obj.start_time))
|
||||
/ Self::HISTORY_TIME_MAX as f64;
|
||||
|
||||
// * either we're limited by time or limited by object count.
|
||||
curr_historical_decay = curr_historical_decay
|
||||
.min((historical_note_count - i) as f64 / historical_note_count as f64);
|
||||
|
||||
let curr_delta = curr_obj.strain_time;
|
||||
let prev_delta = prev_obj.strain_time;
|
||||
let last_delta = last_obj.strain_time;
|
||||
|
||||
// * fancy function to calculate rhythmbonuses.
|
||||
let base = (PI / (prev_delta.min(curr_delta) / prev_delta.max(curr_delta))).sin();
|
||||
let curr_ratio = 1.0 + 6.0 * (base * base).min(0.5);
|
||||
|
||||
let mut window_penalty = ((((prev_delta - curr_delta).abs() - hit_window * 0.3)
|
||||
.max(0.0))
|
||||
/ (hit_window * 0.3))
|
||||
.min(1.0);
|
||||
|
||||
println!("window_penalty: prev={prev_delta} | curr={curr_delta} | window={hit_window} => {window_penalty}");
|
||||
|
||||
window_penalty = window_penalty.min(1.0);
|
||||
|
||||
let mut effective_ratio = window_penalty * curr_ratio;
|
||||
|
||||
if first_delta_switch {
|
||||
if !(prev_delta > 1.25 * curr_delta || prev_delta * 1.25 < curr_delta) {
|
||||
if island_size < 7 {
|
||||
// * island is still progressing, count size.
|
||||
island_size += 1;
|
||||
}
|
||||
} else {
|
||||
// * bpm change is into slider, this is easy acc window
|
||||
if curr_obj.base.is_slider() {
|
||||
effective_ratio *= 0.125;
|
||||
}
|
||||
|
||||
// * bpm change was from a slider, this is easier typically than circle -> circle
|
||||
if prev_obj.base.is_slider() {
|
||||
effective_ratio *= 0.25;
|
||||
}
|
||||
|
||||
// * repeated island size (ex: triplet -> triplet)
|
||||
if prev_island_size == island_size {
|
||||
effective_ratio *= 0.25;
|
||||
}
|
||||
|
||||
// * repeated island polartiy (2 -> 4, 3 -> 5)
|
||||
if prev_island_size % 2 == island_size % 2 {
|
||||
effective_ratio *= 0.5;
|
||||
}
|
||||
|
||||
// * previous increase happened a note ago, 1/1->1/2-1/4, dont want to buff this.
|
||||
if last_delta > prev_delta + 10.0 && prev_delta > curr_delta + 10.0 {
|
||||
effective_ratio *= 0.125;
|
||||
}
|
||||
|
||||
rhythm_complexity_sum += (effective_ratio * start_ratio).sqrt()
|
||||
* curr_historical_decay
|
||||
* ((4 + island_size) as f64).sqrt()
|
||||
/ 2.0
|
||||
* ((4 + prev_island_size) as f64).sqrt()
|
||||
/ 2.0;
|
||||
|
||||
start_ratio = effective_ratio;
|
||||
|
||||
// * log the last island size.
|
||||
prev_island_size = island_size;
|
||||
|
||||
// * we're slowing down, stop counting
|
||||
if prev_delta * 1.25 < curr_delta {
|
||||
// * if we're speeding up, this stays true and we keep counting island size.
|
||||
first_delta_switch = false;
|
||||
}
|
||||
|
||||
island_size = 1;
|
||||
}
|
||||
} else if prev_delta > 1.25 * curr_delta {
|
||||
// * we want to be speeding up.
|
||||
// * Begin counting island until we change speed again.
|
||||
first_delta_switch = true;
|
||||
start_ratio = effective_ratio;
|
||||
island_size = 1;
|
||||
}
|
||||
}
|
||||
|
||||
// * produces multiplier that can be applied to strain. range [1, infinity) (not really though)
|
||||
let res = (4.0 + rhythm_complexity_sum * Self::RHYTHM_MULTIPLIER).sqrt() / 2.0;
|
||||
|
||||
println!("res={res}");
|
||||
|
||||
res
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user