initial rewrite

This commit is contained in:
MaxOhn
2024-02-09 17:54:14 +01:00
parent ecf1b2c7f4
commit a8bf9601b6
190 changed files with 10892 additions and 18142 deletions
+82
View File
@@ -0,0 +1,82 @@
use crate::mania::performance::ManiaPerformance;
/// The result of a difficulty calculation on an osu!mania map.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct ManiaDifficultyAttributes {
/// The final star rating.
pub stars: f64,
/// The perceived hit window for an n300 inclusive of rate-adjusting mods (DT/HT/etc).
pub hit_window: f64,
/// The amount of hitobjects in the map.
pub n_objects: u32,
/// The maximum achievable combo.
pub max_combo: u32,
/// Whether the [`Beatmap`] was a convert i.e. an osu!standard map.
///
/// [`Beatmap`]: crate::model::beatmap::Beatmap
pub is_convert: bool,
}
impl ManiaDifficultyAttributes {
/// 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_objects
}
/// Whether the [`Beatmap`] was a convert i.e. an osu!standard map.
///
/// [`Beatmap`]: crate::model::beatmap::Beatmap
pub const fn is_convert(&self) -> bool {
self.is_convert
}
/// Returns a builder for performance calculation.
pub fn pp<'a>(self) -> ManiaPerformance<'a> {
self.into()
}
}
/// The result of a performance calculation on an osu!mania map.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct ManiaPerformanceAttributes {
/// The difficulty attributes that were used for the performance calculation.
pub difficulty: ManiaDifficultyAttributes,
/// The final performance points.
pub pp: f64,
/// The difficulty portion of the final pp.
pub pp_difficulty: f64,
}
impl ManiaPerformanceAttributes {
/// 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
}
/// Whether the [`Beatmap`] was a convert i.e. an osu!standard map.
///
/// [`Beatmap`]: crate::model::beatmap::Beatmap
pub const fn is_convert(&self) -> bool {
self.difficulty.is_convert
}
}
+257
View File
@@ -0,0 +1,257 @@
use std::borrow::Cow;
use rosu_map::{
section::{
general::GameMode,
hit_objects::{BorrowedCurve, CurveBuffers},
},
util::Pos,
};
use crate::{
model::{
beatmap::{Beatmap, Converted},
hit_object::{HitObjectKind, HoldNote, Spinner},
mode::ConvertStatus,
},
util::{
float_ext::FloatExt, legacy_sort::legacy_sort, limited_queue::LimitedQueue, random::Random,
},
};
use self::{
pattern::Pattern,
pattern_generator::{
distance_object::DistanceObjectPatternGenerator,
end_time_object::EndTimeObjectPatternGenerator, hit_object::HitObjectPatternGenerator,
},
pattern_type::PatternType,
};
use super::Mania;
mod pattern;
mod pattern_generator;
mod pattern_type;
/// A [`Beatmap`] for [`Mania`] calculations.
pub type ManiaBeatmap<'a> = Converted<'a, Mania>;
const MAX_NOTES_FOR_DENSITY: usize = 7;
pub fn try_convert(map: &mut Cow<'_, Beatmap>) -> ConvertStatus {
match map.mode {
GameMode::Osu => {
convert(map.to_mut());
ConvertStatus::Done
}
GameMode::Mania => ConvertStatus::Noop,
GameMode::Taiko | GameMode::Catch => ConvertStatus::Incompatible,
}
}
fn convert(map: &mut Beatmap) {
let seed = (map.hp + map.cs).round_even() as i32 * 20
+ (map.od * 41.2) as i32
+ map.ar.round_even() as i32;
let mut random = Random::new(seed);
map.cs = target_columns(map);
let mut prev_note_times = LimitedQueue::<f64, MAX_NOTES_FOR_DENSITY>::new();
let mut density = f64::from(i32::MAX);
let mut compute_density = |new_note_time: f64, d: &mut f64| {
prev_note_times.push(new_note_time);
if let ([first, ..], [.., last]) | ([], [first, .., last]) | ([first, .., last], []) =
prev_note_times.as_slices()
{
*d = (last - first) / prev_note_times.len() as f64;
}
};
let total_columns = map.cs as i32;
let mut last_values = PrevValues::default();
let mut curve_bufs = CurveBuffers::default();
// TODO: find proper default
let capacity = map.hit_objects.len() * 2;
let mut new_hit_objects = Vec::with_capacity(capacity);
for (obj, sound) in map.hit_objects.iter().zip(map.hit_sounds.iter().copied()) {
match obj.kind {
HitObjectKind::Circle => {
compute_density(obj.start_time, &mut density);
let mut gen = HitObjectPatternGenerator::new(
&mut random,
obj,
sound,
total_columns,
&last_values,
density,
map,
);
let new_pattern = gen.generate();
last_values.stair = gen.stair_type;
last_values.time = obj.start_time;
last_values.pos = obj.pos;
let new_hit_objects_iter = new_pattern.hit_objects.iter().cloned();
new_hit_objects.extend(new_hit_objects_iter);
last_values.pattern = new_pattern;
}
HitObjectKind::Slider(ref slider) => {
let curve = BorrowedCurve::new(
&slider.control_points,
slider.expected_dist,
&mut curve_bufs,
);
let mut gen = DistanceObjectPatternGenerator::new(
&mut random,
obj,
sound,
total_columns,
&last_values.pattern,
map,
slider.repeats,
&curve,
&slider.node_sounds,
);
let segment_duration = f64::from(gen.segment_duration);
for i in 0..=slider.repeats as i32 + 1 {
let time = obj.start_time + segment_duration * f64::from(i);
last_values.time = time;
last_values.pos = obj.pos;
compute_density(time, &mut density);
}
for new_pattern in gen.generate() {
new_hit_objects.extend_from_slice(&new_pattern.hit_objects);
last_values.pattern = new_pattern;
}
}
HitObjectKind::Spinner(Spinner { end_time })
| HitObjectKind::Hold(HoldNote { end_time }) => {
let mut gen = EndTimeObjectPatternGenerator::new(
&mut random,
obj,
end_time,
sound,
total_columns,
&last_values.pattern,
map,
);
last_values.time = end_time;
last_values.pos = Pos::new(256.0, 192.0);
compute_density(end_time, &mut density);
let new_pattern = gen.generate();
new_hit_objects.extend(new_pattern.hit_objects);
}
}
}
map.hit_sounds.clear();
map.hit_objects = new_hit_objects;
map.hit_objects
.sort_by(|a, b| a.start_time.total_cmp(&b.start_time));
legacy_sort(&mut map.hit_objects);
map.mode = GameMode::Mania;
}
pub struct PrevValues {
time: f64,
pos: Pos,
pattern: Pattern,
stair: PatternType,
}
impl Default for PrevValues {
fn default() -> Self {
Self {
time: 0.0,
pos: Pos::default(),
pattern: Pattern::default(),
stair: PatternType::STAIR,
}
}
}
fn target_columns(map: &Beatmap) -> f32 {
let rounded_cs = map.cs.round_even();
let rounded_od = map.od.round_even();
let slider_or_spinner_count = map
.hit_objects
.iter()
.filter(|h| matches!(h.kind, HitObjectKind::Slider(_) | HitObjectKind::Spinner(_)))
.count();
let len = map.hit_objects.len();
let percent_slider_or_spinner = f64::from(slider_or_spinner_count as f32 / len as f32);
if percent_slider_or_spinner < 0.2 {
7.0
} else if percent_slider_or_spinner < 0.3 || rounded_cs >= 5.0 {
f32::from(6 + u8::from(rounded_od > 5.0))
} else if percent_slider_or_spinner > 0.6 {
f32::from(4 + u8::from(rounded_od > 4.0))
} else {
(rounded_od + 1.0).clamp(4.0, 7.0)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn convert_mania() {
let converted = Beatmap::from_path("./resources/2785319.osu")
.unwrap()
.unchecked_into_converted::<Mania>();
assert!(converted.is_convert);
let map = converted.map;
assert_eq!(map.mode, GameMode::Mania);
assert_eq!(map.version, 14);
assert!(map.ar.eq(9.3), "{} != 9.3", map.ar);
assert!(map.od.eq(8.8), "{} != 8.8", map.od);
assert!(map.cs.eq(7.0), "{} != 7.0", map.cs);
assert!(map.hp.eq(5.0), "{} != 5.0", map.hp);
assert!(
map.slider_multiplier.eq(1.7),
"{} != 1.7",
map.slider_multiplier
);
assert!(
map.slider_tick_rate.eq(1.0),
"{} != 1.0",
map.slider_tick_rate
);
assert_eq!(map.hit_objects.len(), 1046);
assert_eq!(map.hit_sounds.len(), 0);
assert_eq!(map.timing_points.len(), 1);
assert_eq!(map.difficulty_points.len(), 50);
assert_eq!(map.effect_points.len(), 0);
assert!(map.stack_leniency.eq(0.5), "{} != 0.5", map.stack_leniency);
assert_eq!(map.breaks.len(), 1);
}
}
+191
View File
@@ -0,0 +1,191 @@
use rosu_map::util::Pos;
use crate::model::hit_object::{HitObject, HitObjectKind, HoldNote};
use super::pattern_generator::{
distance_object::DistanceObjectPatternGenerator,
end_time_object::EndTimeObjectPatternGenerator, hit_object::HitObjectPatternGenerator,
};
#[derive(Default)]
pub struct Pattern {
pub hit_objects: Vec<HitObject>,
contained_columns: ContainedColumns,
}
impl Pattern {
pub fn with_capacity(capacity: usize) -> Self {
Self {
hit_objects: Vec::with_capacity(capacity),
contained_columns: ContainedColumns::default(),
}
}
fn new_single(hit_object: HitObject, column: u8) -> Self {
let mut contained_columns = ContainedColumns::default();
contained_columns.insert(column);
let hit_objects = vec![hit_object];
Self {
hit_objects,
contained_columns,
}
}
pub fn new_note(generator: &HitObjectPatternGenerator<'_>, column: u8) -> Self {
let pos = column_to_pos(column, generator.inner.total_columns);
let hit_object = HitObject {
pos: Pos::new(pos, pos),
start_time: generator.inner.hit_object.start_time,
kind: HitObjectKind::Circle,
};
Self::new_single(hit_object, column)
}
pub fn add_note(&mut self, generator: &HitObjectPatternGenerator<'_>, column: u8) {
let pos = column_to_pos(column, generator.inner.total_columns);
let hit_object = HitObject {
pos: Pos::new(pos, pos),
start_time: generator.inner.hit_object.start_time,
kind: HitObjectKind::Circle,
};
self.contained_columns.insert(column);
self.hit_objects.push(hit_object);
}
pub fn new_end_time_note(
generator: &EndTimeObjectPatternGenerator<'_>,
column: u8,
hold_note: bool,
) -> Self {
let pos = column_to_pos(column, generator.inner.total_columns);
let pos = Pos::new(pos, pos);
let hit_object = if hold_note {
HitObject {
pos,
start_time: generator.inner.hit_object.start_time,
kind: HitObjectKind::Hold(HoldNote {
end_time: generator.end_time,
}),
}
} else {
HitObject {
pos,
start_time: generator.inner.hit_object.start_time,
kind: HitObjectKind::Circle,
}
};
Self::new_single(hit_object, column)
}
pub fn new_slider_note(
generator: &DistanceObjectPatternGenerator<'_>,
column: u8,
start_time: i32,
end_time: i32,
) -> Self {
let pos = column_to_pos(column, generator.inner.total_columns);
let pos = Pos::new(pos, pos);
let hit_object = if start_time == end_time {
HitObject {
pos,
start_time: f64::from(start_time),
kind: HitObjectKind::Circle,
}
} else {
HitObject {
pos,
start_time: f64::from(start_time),
kind: HitObjectKind::Hold(HoldNote {
end_time: f64::from(end_time),
}),
}
};
Self::new_single(hit_object, column)
}
pub fn add_slider_note(
&mut self,
generator: &DistanceObjectPatternGenerator<'_>,
column: u8,
start_time: i32,
end_time: i32,
) {
let pos = column_to_pos(column, generator.inner.total_columns);
let pos = Pos::new(pos, pos);
let hit_object = if start_time == end_time {
HitObject {
pos,
start_time: f64::from(start_time),
kind: HitObjectKind::Circle,
}
} else {
HitObject {
pos,
start_time: f64::from(start_time),
kind: HitObjectKind::Hold(HoldNote {
end_time: f64::from(end_time),
}),
}
};
self.contained_columns.insert(column);
self.hit_objects.push(hit_object);
}
pub fn add_object(&mut self, obj: HitObject, column: u8) {
self.hit_objects.push(obj);
self.contained_columns.insert(column);
}
pub const fn column_has_obj(&self, column: u8) -> bool {
self.contained_columns.contains(column)
}
pub const fn column_with_objs(&self) -> i32 {
self.contained_columns.len() as i32
}
/// Moves all values of `other` into `self`,
/// leaving `other` empty but keeps the capacities.
pub fn append(&mut self, other: &mut Self) {
self.hit_objects.append(&mut other.hit_objects);
self.contained_columns.extend(other.contained_columns);
}
}
fn column_to_pos(column: u8, total_columns: i32) -> f32 {
let divisor = 512.0 / total_columns as f32;
(f32::from(column) * divisor).ceil()
}
#[derive(Copy, Clone, Default)]
pub struct ContainedColumns(u16);
impl ContainedColumns {
pub fn insert(&mut self, column: u8) {
self.0 |= 1 << column;
}
pub fn extend(&mut self, other: Self) {
self.0 |= other.0;
}
pub const fn len(self) -> u32 {
self.0.count_ones()
}
pub const fn contains(self, column: u8) -> bool {
self.0 & (1 << column) != 0
}
}
@@ -0,0 +1,541 @@
use rosu_map::section::hit_objects::{hit_samples::HitSoundType, BorrowedCurve};
use crate::{
mania::{
convert::{pattern::Pattern, pattern_type::PatternType},
object::ManiaObject,
},
model::{
beatmap::{get_precision_adjusted_beat_len_taiko_mania, Beatmap},
control_point::{EffectPoint, TimingPoint},
hit_object::HitObject,
},
util::{float_ext::FloatExt, random::Random},
};
use super::PatternGenerator;
pub struct DistanceObjectPatternGenerator<'h> {
pub segment_duration: i32,
pub sample: HitSoundType,
pub inner: PatternGenerator<'h>,
start_time: i32,
end_time: i32,
span_count: i32,
prev_pattern: &'h Pattern,
convert_type: PatternType,
node_sounds: &'h [HitSoundType],
}
impl<'h> DistanceObjectPatternGenerator<'h> {
#[allow(clippy::too_many_arguments)]
pub fn new(
random: &'h mut Random,
hit_object: &'h HitObject,
sample: HitSoundType,
total_columns: i32,
prev_pattern: &'h Pattern,
orig: &'h Beatmap,
repeats: usize,
curve: &BorrowedCurve<'_>,
node_sounds: &'h [HitSoundType],
) -> Self {
let beat_len = orig
.timing_point_at(hit_object.start_time)
.map_or(TimingPoint::DEFAULT_BEAT_LEN, |point| point.beat_len);
let kiai = orig
.effect_point_at(hit_object.start_time)
.map_or(EffectPoint::DEFAULT_KIAI, |point| point.kiai);
let convert_type = if kiai {
PatternType::default()
} else {
PatternType::LOW_PROBABILITY
};
let beat_len =
get_precision_adjusted_beat_len_taiko_mania(orig, beat_len, hit_object.start_time);
let span_count = (repeats + 1) as i32;
let start_time = hit_object.start_time.round_even() as i32;
// * This matches stable's calculation.
let end_time = (f64::from(start_time)
+ curve.dist() * beat_len * f64::from(span_count) * 0.01 / orig.slider_multiplier)
.floor() as i32;
let segment_duration = (end_time - start_time) / span_count;
let inner = PatternGenerator::new(hit_object, total_columns, random, orig);
Self {
segment_duration,
sample,
inner,
start_time,
end_time,
span_count,
prev_pattern,
convert_type,
node_sounds,
}
}
pub fn generate(&mut self) -> Vec<Pattern> {
let orig_pattern = self.generate_();
if orig_pattern.hit_objects.len() == 1 {
return vec![orig_pattern];
}
// * We need to split the intermediate pattern into two new patterns:
// * 1. A pattern containing all objects that do not end at our EndTime.
// * 2. A pattern containing all objects that end at our EndTime. This will be used for further pattern generation.
let mut intermediate_pattern = Pattern::default();
let mut end_time_pattern = Pattern::default();
for obj in orig_pattern.hit_objects {
let col = ManiaObject::column(obj.pos.x, self.inner.total_columns as f32) as u8;
// Keeping it in-sync with lazer
#[allow(clippy::if_not_else)]
if self.end_time != obj.end_time().round_even() as i32 {
intermediate_pattern.add_object(obj, col);
} else {
end_time_pattern.add_object(obj, col);
}
}
vec![intermediate_pattern, end_time_pattern]
}
fn generate_(&mut self) -> Pattern {
let conversion_diff = self.inner.conversion_difficulty();
if self.inner.total_columns == 1 {
Pattern::new_slider_note(self, 0, self.start_time, self.end_time)
} else if self.span_count > 1 {
if self.segment_duration <= 90 {
self.generate_random_hold_notes(self.start_time, 1)
} else if self.segment_duration <= 120 {
self.convert_type |= PatternType::FORCE_NOT_STACK;
self.generate_random_notes(self.start_time, self.span_count + 1)
} else if self.segment_duration <= 160 {
self.generate_stair(self.start_time)
} else if self.segment_duration <= 200 && conversion_diff > 3.0 {
self.generate_random_multiple_notes(self.start_time)
} else if self.end_time - self.start_time >= 4000 {
self.generate_n_random_notes(self.start_time, 0.23, 0.0, 0.0)
} else if self.segment_duration > 400
&& self.span_count < self.inner.total_columns - 1 - self.inner.random_start()
{
self.generate_tiled_hold_notes(self.start_time)
} else {
self.generate_hold_and_normal_notes(self.start_time, conversion_diff)
}
} else if self.segment_duration <= 110 {
if self.prev_pattern.column_with_objs() < self.inner.total_columns {
self.convert_type |= PatternType::FORCE_NOT_STACK;
} else {
self.convert_type &= !PatternType::FORCE_NOT_STACK;
}
let note_count = 1 + i32::from(self.segment_duration >= 80);
self.generate_random_notes(self.start_time, note_count)
} else if conversion_diff > 6.5 {
if self.convert_type.contains(PatternType::LOW_PROBABILITY) {
self.generate_n_random_notes(self.start_time, 0.78, 0.3, 0.0)
} else {
self.generate_n_random_notes(self.start_time, 0.85, 0.36, 0.03)
}
} else if conversion_diff > 4.0 {
if self.convert_type.contains(PatternType::LOW_PROBABILITY) {
self.generate_n_random_notes(self.start_time, 0.43, 0.08, 0.0)
} else {
self.generate_n_random_notes(self.start_time, 0.56, 0.18, 0.0)
}
} else if conversion_diff > 2.5 {
if self.convert_type.contains(PatternType::LOW_PROBABILITY) {
self.generate_n_random_notes(self.start_time, 0.3, 0.0, 0.0)
} else {
self.generate_n_random_notes(self.start_time, 0.37, 0.08, 0.0)
}
} else if self.convert_type.contains(PatternType::LOW_PROBABILITY) {
self.generate_n_random_notes(self.start_time, 0.17, 0.0, 0.0)
} else {
self.generate_n_random_notes(self.start_time, 0.27, 0.0, 0.0)
}
}
fn generate_random_hold_notes(&mut self, start_time: i32, note_count: i32) -> Pattern {
// * - - - -
// * ■ - ■ ■
// * □ - □ □
// * ■ - ■ ■
let mut pattern = Pattern::default();
let random_start = self.inner.random_start();
let usable_columns =
self.inner.total_columns - random_start - self.prev_pattern.column_with_objs();
let mut next_column = self.inner.get_random_column(None, None);
for _ in 0..usable_columns.min(note_count) {
// * Find available column
next_column =
self.find_available_column(next_column, None, &[&pattern, self.prev_pattern]);
pattern.add_slider_note(self, next_column, start_time, self.end_time);
}
// * This is can't be combined with the above loop due to RNG
for _ in 0..note_count.saturating_sub(usable_columns) {
next_column = self.find_available_column(next_column, None, &[&pattern]);
pattern.add_slider_note(self, next_column, start_time, self.end_time);
}
pattern
}
fn generate_random_notes(&mut self, mut start_time: i32, note_count: i32) -> Pattern {
// * - - - -
// * x - - -
// * - - x -
// * - - - x
// * x - - -
let mut next_column = self.inner.get_column(Some(true));
if self.convert_type.contains(PatternType::FORCE_NOT_STACK)
&& self.prev_pattern.column_with_objs() < self.inner.total_columns
{
next_column = self.find_available_column(next_column, None, &[self.prev_pattern]);
}
let mut last_column = next_column;
let mut pattern = Pattern::with_capacity(note_count as usize);
for _ in 0..note_count {
pattern.add_slider_note(self, next_column, start_time, start_time);
next_column = self.find_available_column(
next_column,
Some(&|c| c != i32::from(last_column)),
&[],
);
last_column = next_column;
start_time += self.segment_duration;
}
pattern
}
fn generate_stair(&mut self, mut start_time: i32) -> Pattern {
// * - - - -
// * x - - -
// * - x - -
// * - - x -
// * - - - x
// * - - x -
// * - x - -
// * x - - -
let mut column = i32::from(self.inner.get_column(Some(true)));
let mut increasing = self.inner.random.next_double() > 0.5;
let mut pattern = Pattern::with_capacity(self.span_count as usize + 1);
for _ in 0..=self.span_count as usize {
pattern.add_slider_note(self, column as u8, start_time, start_time);
start_time += self.segment_duration;
// * Check if we're at the borders of the stage, and invert the pattern if so
if increasing {
if column >= self.inner.total_columns - 1 {
increasing = false;
column -= 1;
} else {
column += 1;
}
} else if column <= self.inner.random_start() {
increasing = true;
column += 1;
} else {
column -= 1;
}
}
pattern
}
fn generate_random_multiple_notes(&mut self, mut start_time: i32) -> Pattern {
// * - - - -
// * x - - -
// * - x x -
// * - - - x
// * x - x -
let legacy = (4..=8).contains(&self.inner.total_columns);
let interval = self
.inner
.random
.next_int_range(1, self.inner.total_columns - i32::from(legacy));
let mut next_column = i32::from(self.inner.get_column(Some(true)));
let random_start = self.inner.random_start();
let not_2k = self.inner.total_columns > 2;
let mut pattern =
Pattern::with_capacity((self.span_count as usize + 1) * (1 + usize::from(not_2k)));
for _ in 0..=self.span_count as usize {
pattern.add_slider_note(self, next_column as u8, start_time, start_time);
next_column += interval;
if next_column >= self.inner.total_columns - random_start {
next_column =
next_column - self.inner.total_columns - random_start + i32::from(legacy);
}
next_column += random_start;
// * If we're in 2K, let's not add many consecutive doubles
if not_2k {
pattern.add_slider_note(self, next_column as u8, start_time, start_time);
}
next_column = i32::from(self.inner.get_random_column(None, None));
start_time += self.segment_duration;
}
pattern
}
fn generate_n_random_notes(
&mut self,
start_time: i32,
mut p2: f64,
mut p3: f64,
mut p4: f64,
) -> Pattern {
// * - - - -
// * ■ - ■ ■
// * □ - □ □
// * ■ - ■ ■
match self.inner.total_columns {
2 => {
p2 = 0.0;
p3 = 0.0;
p4 = 0.0;
}
3 => {
p2 = p2.min(0.1);
p3 = 0.0;
p4 = 0.0;
}
4 => {
p2 = p2.min(0.3);
p3 = p3.min(0.04);
p4 = 0.0;
}
5 => {
p2 = p2.min(0.34);
p3 = p3.min(0.1);
p4 = p4.min(0.03);
}
_ => {}
}
let is_double_sample =
|sample: HitSoundType| sample.has_flag(HitSoundType::CLAP | HitSoundType::FINISH);
let can_generate_two_notes = !self.convert_type.contains(PatternType::LOW_PROBABILITY)
&& (is_double_sample(self.sample)
|| is_double_sample(self.sample_info_list_at(self.start_time)));
if can_generate_two_notes {
p2 = 1.0;
}
let note_count = self
.inner
.get_random_note_count(p2, p3, Some(p4), None, None);
self.generate_random_hold_notes(start_time, note_count)
}
fn generate_tiled_hold_notes(&mut self, mut start_time: i32) -> Pattern {
// * - - - -
// * ■ ■ ■ ■
// * □ □ □ □
// * □ □ □ □
// * □ □ □ ■
// * □ □ ■ -
// * □ ■ - -
// * ■ - - -
let column_repeat = self.span_count.min(self.inner.total_columns) as usize;
// * Due to integer rounding, this is not guaranteed to be the same as EndTime (the class-level variable).
let end_time = start_time + self.segment_duration * self.span_count;
let mut next_column = self.inner.get_column(Some(true));
if self.convert_type.contains(PatternType::FORCE_NOT_STACK)
&& self.prev_pattern.column_with_objs() < self.inner.total_columns
{
next_column = self.find_available_column(next_column, None, &[self.prev_pattern]);
}
let mut pattern = Pattern::with_capacity(column_repeat);
for _ in 0..column_repeat {
next_column = self.find_available_column(next_column, None, &[&pattern]);
pattern.add_slider_note(self, next_column, start_time, end_time);
start_time += self.segment_duration;
}
pattern
}
fn generate_hold_and_normal_notes(
&mut self,
mut start_time: i32,
conversion_diff: f64,
) -> Pattern {
// * - - - -
// * ■ x x -
// * ■ - x x
// * ■ x - x
// * ■ - x x
let mut pattern = Pattern::default();
let mut hold_column = self.inner.get_column(Some(true));
if self.convert_type.contains(PatternType::FORCE_NOT_STACK)
&& self.prev_pattern.column_with_objs() < self.inner.total_columns
{
hold_column = self.find_available_column(hold_column, None, &[self.prev_pattern]);
}
// * Create the hold note
pattern.add_slider_note(self, hold_column, start_time, self.end_time);
let mut next_column = self.inner.get_random_column(None, None);
let mut note_count = if conversion_diff > 6.5 {
self.inner
.get_random_note_count(0.63, 0.0, None, None, None)
} else if conversion_diff > 4.0 {
let p2 = if self.inner.total_columns < 6 {
0.12
} else {
0.45
};
self.inner.get_random_note_count(p2, 0.0, None, None, None)
} else if conversion_diff > 2.5 {
let p2 = if self.inner.total_columns < 6 {
0.0
} else {
0.24
};
self.inner.get_random_note_count(p2, 0.0, None, None, None)
} else {
0
};
note_count = note_count.min(self.inner.total_columns - 1);
let sample = self.sample_info_list_at(start_time);
let ignore_head =
!sample.has_flag(HitSoundType::WHISTLE | HitSoundType::FINISH | HitSoundType::CLAP);
let mut row_pattern = Pattern::default();
let hold_column = i32::from(hold_column);
for _ in 0..=self.span_count as usize {
if !(ignore_head && start_time == self.start_time) {
for _ in 0..note_count {
next_column = self.find_available_column(
next_column,
Some(&|c| c != hold_column),
&[&row_pattern],
);
row_pattern.add_slider_note(self, next_column, start_time, start_time);
}
}
pattern.append(&mut row_pattern);
start_time += self.segment_duration;
}
pattern
}
fn sample_info_list_at(&self, time: i32) -> HitSoundType {
self.note_samples_at(time)
.first()
.map_or(self.sample, |sample| *sample)
}
fn note_samples_at(&self, time: i32) -> &[HitSoundType] {
let idx = if self.segment_duration == 0 {
0
} else {
((time - self.start_time) / self.segment_duration) as usize
};
&self.node_sounds[idx..]
}
fn find_available_column(
&mut self,
mut initial_column: u8,
validation: Option<&dyn Fn(i32) -> bool>,
patterns: &[&Pattern],
) -> u8 {
let lower = self.inner.random_start();
let upper = self.inner.total_columns;
let is_valid = |column: i32| {
if let Some(fun) = validation {
if !(fun)(column) {
return false;
}
}
let column = column as u8;
patterns
.iter()
.all(|pattern| !pattern.column_has_obj(column))
};
// * Check for the initial column
if is_valid(i32::from(initial_column)) {
return initial_column;
}
// * Ensure that we have at least one free column, so that an endless loop is avoided
let has_valid_column = (lower..upper).any(is_valid);
assert!(has_valid_column);
// * Iterate until a valid column is found. This is a random iteration in the default case.
while {
initial_column = self.inner.get_random_column(Some(lower), Some(upper));
!is_valid(i32::from(initial_column))
} {}
initial_column
}
}
@@ -0,0 +1,117 @@
use rosu_map::section::hit_objects::hit_samples::HitSoundType;
use crate::{
mania::convert::{pattern::Pattern, pattern_type::PatternType},
model::hit_object::HitObject,
util::random::Random,
Beatmap,
};
use super::PatternGenerator;
pub struct EndTimeObjectPatternGenerator<'h> {
pub end_time: f64,
pub sample: HitSoundType,
pub inner: PatternGenerator<'h>,
convert_type: PatternType,
prev_pattern: &'h Pattern,
}
impl<'h> EndTimeObjectPatternGenerator<'h> {
pub fn new(
random: &'h mut Random,
hit_object: &'h HitObject,
end_time: f64,
sample: HitSoundType,
total_columns: i32,
prev_pattern: &'h Pattern,
orig: &'h Beatmap,
) -> Self {
let convert_type = if prev_pattern.column_with_objs() == total_columns {
PatternType::default()
} else {
PatternType::FORCE_NOT_STACK
};
let inner = PatternGenerator::new(hit_object, total_columns, random, orig);
Self {
end_time,
sample,
inner,
convert_type,
prev_pattern,
}
}
pub fn generate(&mut self) -> Pattern {
let generate_hold = self.end_time - self.inner.hit_object.start_time >= 100.0;
match self.inner.total_columns {
8 if self.sample.has_flag(HitSoundType::FINISH)
&& self.end_time - self.inner.hit_object.start_time < 1000.0 =>
{
Pattern::new_end_time_note(self, 0, generate_hold)
}
8 => {
let column = self.get_random_column(self.inner.random_start());
Pattern::new_end_time_note(self, column, generate_hold)
}
_ => {
let column = self.get_random_column(0);
Pattern::new_end_time_note(self, column, generate_hold)
}
}
}
fn get_random_column(&mut self, lower: i32) -> u8 {
let column = self.inner.get_random_column(Some(lower), None);
if self.convert_type.contains(PatternType::FORCE_NOT_STACK) {
self.find_available_column(column, Some(lower), &[self.prev_pattern])
} else {
self.find_available_column(column, Some(lower), &[])
}
}
fn find_available_column(
&mut self,
mut initial_column: u8,
lower: Option<i32>,
// upper: Option<i32>,
// next_column: Option<&dyn Fn(u8) -> u8>,
// validation: Option<&dyn Fn(i32) -> bool>,
patterns: &[&Pattern],
) -> u8 {
let lower = lower.unwrap_or_else(|| self.inner.random_start());
let upper = self.inner.total_columns;
let is_valid = |column: i32| {
let column = column as u8;
patterns
.iter()
.all(|pattern| !pattern.column_has_obj(column))
};
// * Check for the initial column
if is_valid(i32::from(initial_column)) {
return initial_column;
}
// * Ensure that we have at least one free column, so that an endless loop is avoided
let has_valid_column = (lower..upper).any(is_valid);
assert!(has_valid_column);
// * Iterate until a valid column is found. This is a random iteration in the default case.
while {
initial_column = self.inner.get_random_column(Some(lower), Some(upper));
!is_valid(i32::from(initial_column))
} {}
initial_column
}
}
@@ -0,0 +1,481 @@
use rosu_map::section::hit_objects::hit_samples::HitSoundType;
use crate::{
mania::{
convert::{pattern::Pattern, pattern_type::PatternType, PrevValues},
object::ManiaObject,
},
model::{
beatmap::Beatmap,
control_point::{EffectPoint, TimingPoint},
hit_object::HitObject,
},
util::random::Random,
};
use super::PatternGenerator;
pub struct HitObjectPatternGenerator<'h> {
pub sample: HitSoundType,
pub stair_type: PatternType,
pub inner: PatternGenerator<'h>,
convert_type: PatternType,
prev_pattern: &'h Pattern,
}
impl<'h> HitObjectPatternGenerator<'h> {
pub fn new(
random: &'h mut Random,
hit_object: &'h HitObject,
sample: HitSoundType,
total_columns: i32,
prev: &'h PrevValues,
density: f64,
orig: &'h Beatmap,
) -> Self {
let beat_len = orig
.timing_point_at(hit_object.start_time)
.map_or(TimingPoint::DEFAULT_BEAT_LEN, |point| point.beat_len);
let pos_separation = (hit_object.pos - prev.pos).length();
let time_separation = hit_object.start_time - prev.time;
let mut convert_type = PatternType::default();
if time_separation <= 80.0 {
// * More than 187 BPM
convert_type |= PatternType::FORCE_NOT_STACK | PatternType::KEEP_SINGLE;
} else if time_separation <= 95.0 {
// * More than 157 BPM
convert_type |= PatternType::FORCE_NOT_STACK | PatternType::KEEP_SINGLE | prev.stair;
} else if time_separation <= 105.0 {
// * More than 140 BPM
convert_type |= PatternType::FORCE_NOT_STACK | PatternType::LOW_PROBABILITY;
} else if time_separation <= 125.0 {
// * More than 120 BPM
convert_type |= PatternType::FORCE_NOT_STACK;
} else if time_separation <= 135.0 && pos_separation < 20.0 {
// * More than 111 BPM stream
convert_type |= PatternType::CYCLE | PatternType::KEEP_SINGLE;
} else if time_separation <= 150.0 && pos_separation < 20.0 {
// * More than 100 BPM stream
convert_type |= PatternType::FORCE_STACK | PatternType::LOW_PROBABILITY;
} else if pos_separation < 20.0 && density >= beat_len / 2.5 {
// * Low density stream
convert_type |= PatternType::REVERSE | PatternType::LOW_PROBABILITY;
} else if density < beat_len / 2.5 {
// * High density
} else {
let kiai = orig
.effect_point_at(hit_object.start_time)
.map_or(EffectPoint::DEFAULT_KIAI, |point| point.kiai);
if kiai {
// * High density
} else {
convert_type |= PatternType::LOW_PROBABILITY;
}
}
if !convert_type.contains(PatternType::KEEP_SINGLE) {
if sample.has_flag(HitSoundType::FINISH) && total_columns != 8 {
convert_type |= PatternType::MIRROR;
} else if sample.has_flag(HitSoundType::CLAP) {
convert_type |= PatternType::GATHERED;
}
}
let inner = PatternGenerator::new(hit_object, total_columns, random, orig);
Self {
stair_type: prev.stair,
convert_type,
sample,
prev_pattern: &prev.pattern,
inner,
}
}
pub fn generate(&mut self) -> Pattern {
let pattern = self.generate_core();
for obj in pattern.hit_objects.iter() {
let col = ManiaObject::column(obj.pos.x, self.inner.total_columns as f32) as i32;
if self.convert_type.contains(PatternType::STAIR) && col == self.inner.total_columns - 1
{
self.stair_type = PatternType::REVERSE_STAIR;
}
if self.convert_type.contains(PatternType::REVERSE_STAIR)
&& col == self.inner.random_start()
{
self.stair_type = PatternType::STAIR;
}
}
pattern
}
fn generate_core(&mut self) -> Pattern {
if self.inner.total_columns == 1 {
return Pattern::new_note(self, 0);
}
let last_column = self.prev_pattern.hit_objects.last().map_or(0, |h| {
ManiaObject::column(h.pos.x, self.inner.total_columns as f32) as u8
});
let random_start = self.inner.random_start() as u8;
if self.convert_type.contains(PatternType::REVERSE)
&& !self.prev_pattern.hit_objects.is_empty()
{
let mut pattern = Pattern::default();
for i in random_start..self.inner.total_columns as u8 {
if self.prev_pattern.column_has_obj(i) {
pattern.add_note(self, random_start + self.inner.total_columns as u8 - i - 1);
}
}
return pattern;
}
if self.convert_type.contains(PatternType::CYCLE)
&& self.prev_pattern.hit_objects.len() == 1
// * If we convert to 7K + 1, let's not overload the special key
&& (self.inner.total_columns != 8 || last_column != 0)
// * Make sure the last column was not the centre column
&& (self.inner.total_columns % 2 == 0 || last_column != self.inner.total_columns as u8 / 2)
{
// * Generate a new pattern by cycling backwards (similar to Reverse but for only one hit object)
let column = random_start + self.inner.total_columns as u8 - last_column - 1;
return Pattern::new_note(self, column);
}
if self.convert_type.contains(PatternType::FORCE_STACK)
&& !self.prev_pattern.hit_objects.is_empty()
{
let mut pattern = Pattern::default();
// * Generate a new pattern by placing on the already filled columns
for i in random_start..self.inner.total_columns as u8 {
if self.prev_pattern.column_has_obj(i) {
pattern.add_note(self, i);
}
}
return pattern;
}
if self.prev_pattern.hit_objects.len() == 1 {
if self.convert_type.contains(PatternType::STAIR) {
// * Generate a new pattern by placing on the next column,
// * cycling back to the start if there is no "next"
let mut target_column = last_column + 1;
if target_column == self.inner.total_columns as u8 {
target_column = random_start;
}
return Pattern::new_note(self, target_column);
}
if self.convert_type.contains(PatternType::REVERSE_STAIR) {
// * Generate a new pattern by placing on the previous column,
// * cycling back to the end if there is no "previous"
let mut target_column = last_column as i8 - 1;
if target_column == random_start as i8 - 1 {
target_column = self.inner.total_columns as i8 - 1;
}
return Pattern::new_note(self, target_column as u8);
}
}
if self.convert_type.contains(PatternType::KEEP_SINGLE) {
return self.generate_random_notes(1);
}
let conversion_diff = self.inner.conversion_difficulty();
if self.convert_type.contains(PatternType::MIRROR) {
if conversion_diff > 6.5 {
self.generate_random_pattern_with_mirrored(0.12, 0.38, 0.12)
} else if conversion_diff > 4.0 {
self.generate_random_pattern_with_mirrored(0.12, 0.17, 0.0)
} else {
self.generate_random_pattern_with_mirrored(0.12, 0.0, 0.0)
}
} else if conversion_diff > 6.5 {
if self.convert_type.contains(PatternType::LOW_PROBABILITY) {
self.generate_random_pattern(0.78, 0.42, 0.0, 0.0)
} else {
self.generate_random_pattern(1.0, 0.62, 0.0, 0.0)
}
} else if conversion_diff > 4.0 {
if self.convert_type.contains(PatternType::LOW_PROBABILITY) {
self.generate_random_pattern(0.35, 0.08, 0.0, 0.0)
} else {
self.generate_random_pattern(0.52, 0.15, 0.0, 0.0)
}
} else if conversion_diff > 2.0 {
if self.convert_type.contains(PatternType::LOW_PROBABILITY) {
self.generate_random_pattern(0.18, 0.0, 0.0, 0.0)
} else {
self.generate_random_pattern(0.45, 0.0, 0.0, 0.0)
}
} else {
self.generate_random_pattern(0.0, 0.0, 0.0, 0.0)
}
}
fn generate_random_notes(&mut self, mut note_count: i32) -> Pattern {
let mut pattern = Pattern::default();
let allow_stacking = !self.convert_type.contains(PatternType::FORCE_NOT_STACK);
if !allow_stacking {
note_count = (self.inner.total_columns
- self.inner.random_start()
- self.prev_pattern.column_with_objs())
.min(note_count);
}
let mut next_column = self.inner.get_column(Some(true));
for _ in 0..note_count {
next_column = if allow_stacking {
self.find_available_column(
next_column,
None,
Some(Self::get_next_column),
&[&pattern],
)
} else {
self.find_available_column(
next_column,
None,
Some(Self::get_next_column),
&[&pattern, self.prev_pattern],
)
};
pattern.add_note(self, next_column);
}
pattern
}
fn get_next_column(&mut self, mut last: u8) -> u8 {
if self.convert_type.contains(PatternType::GATHERED) {
last += 1;
if last == self.inner.total_columns as u8 {
last = self.inner.random_start() as u8;
}
} else {
last = self.inner.get_random_column(None, None);
}
last
}
const fn has_special_column(&self) -> bool {
self.sample.has_flag(HitSoundType::CLAP) && self.sample.has_flag(HitSoundType::FINISH)
}
fn generate_random_pattern(&mut self, p2: f64, p3: f64, p4: f64, p5: f64) -> Pattern {
let random_note_count = self.get_random_note_count(p2, p3, p4, p5);
let mut pattern = self.generate_random_notes(random_note_count);
if self.inner.random_start() > 0 && self.has_special_column() {
pattern.add_note(self, 0);
}
pattern
}
fn get_random_note_count(&mut self, mut p2: f64, mut p3: f64, mut p4: f64, mut p5: f64) -> i32 {
match self.inner.total_columns {
2 => {
p2 = 0.0;
p3 = 0.0;
p4 = 0.0;
p5 = 0.0;
}
3 => {
p2 = p2.min(0.1);
p3 = 0.0;
p4 = 0.0;
p5 = 0.0;
}
4 => {
p2 = p2.min(0.23);
p3 = p3.min(0.04);
p4 = 0.0;
p5 = 0.0;
}
5 => {
p3 = p3.min(0.15);
p4 = p4.min(0.03);
p5 = 0.0;
}
_ => {}
}
if self.sample.has_flag(HitSoundType::CLAP) {
p2 = 1.0;
}
self.inner
.get_random_note_count(p2, p3, Some(p4), Some(p5), None)
}
fn generate_random_pattern_with_mirrored(
&mut self,
centre_probability: f64,
p2: f64,
p3: f64,
) -> Pattern {
if self.convert_type.contains(PatternType::FORCE_NOT_STACK) {
return self.generate_random_pattern(1.0 / 2.0 + p2 / 2.0, p2, (p2 + p3) / 2.0, p3);
}
let mut pattern = Pattern::default();
let (note_count, add_to_centre) =
self.get_random_note_count_mirrored(centre_probability, p2, p3);
let column_limit = if self.inner.total_columns % 2 == 0 {
self.inner.total_columns / 2
} else {
(self.inner.total_columns - 1) / 2
};
let mut next_column = self.inner.get_random_column(None, Some(column_limit));
for _ in 0..note_count {
next_column =
self.find_available_column(next_column, Some(column_limit), None, &[&pattern]);
// * Add normal note
pattern.add_note(self, next_column);
// * Add mirrored note
let column =
(self.inner.random_start() + self.inner.total_columns) as u8 - next_column - 1;
pattern.add_note(self, column);
}
if add_to_centre {
pattern.add_note(self, self.inner.total_columns as u8 / 2);
}
if self.inner.random_start() > 0 && self.has_special_column() {
pattern.add_note(self, 0);
}
pattern
}
fn get_random_note_count_mirrored(
&mut self,
mut centre_probability: f64,
mut p2: f64,
mut p3: f64,
) -> (i32, bool) {
match self.inner.total_columns {
2 => {
centre_probability = 0.0;
p2 = 0.0;
p3 = 0.0;
}
3 => {
centre_probability = centre_probability.min(0.03);
p2 = 0.0;
p3 = 0.0;
}
4 => {
centre_probability = 0.0;
// * Stable requires rngValue > x, which is an inverse-probability. Lazer uses true probability (1 - x).
// * But multiplying this value by 2 (stable) is not the same operation as dividing it by 2 (lazer),
// * so it needs to be converted to from a probability and then back after the multiplication.
p2 = 1.0 - ((1.0 - p2) * 2.0).max(0.8);
p3 = 0.0;
}
5 => {
centre_probability = centre_probability.min(0.03);
p3 = 0.0;
}
6 => {
centre_probability = 0.0;
// * Stable requires rngValue > x, which is an inverse-probability. Lazer uses true probability (1 - x).
// * But multiplying this value by 2 (stable) is not the same operation as dividing it by 2 (lazer),
// * so it needs to be converted to from a probability and then back after the multiplication.
p2 = 1.0 - ((1.0 - p2) * 2.0).max(0.05);
p3 = 1.0 - ((1.0 - p3) * 2.0).max(0.85);
}
_ => {}
}
// * The stable values were allowed to exceed 1, which indicate <0% probability.
// * These values needs to be clamped otherwise GetRandomNoteCount() will throw an exception.
p2 = p2.clamp(0.0, 1.0);
p3 = p3.clamp(0.0, 1.0);
let centre_val = self.inner.random.next_double();
let note_count = self.inner.get_random_note_count(p2, p3, None, None, None);
let add_to_centre = self.inner.total_columns % 2 != 0
&& note_count != 3
&& centre_val > 1.0 - centre_probability;
(note_count, add_to_centre)
}
fn find_available_column(
&mut self,
mut initial_column: u8,
upper: Option<i32>,
next_column: Option<fn(&mut Self, u8) -> u8>,
patterns: &[&Pattern],
) -> u8 {
let lower = self.inner.random_start();
let upper = upper.unwrap_or(self.inner.total_columns);
let is_valid = |column: i32| {
let column = column as u8;
patterns
.iter()
.all(|pattern| !pattern.column_has_obj(column))
};
// * Check for the initial column
if is_valid(i32::from(initial_column)) {
return initial_column;
}
// * Ensure that we have at least one free column, so that an endless loop is avoided
let has_valid_column = (lower..upper).any(is_valid);
assert!(has_valid_column);
// * Iterate until a valid column is found. This is a random iteration in the default case.
while {
initial_column = if let Some(fun) = next_column {
(fun)(self, initial_column)
} else {
self.inner.get_random_column(Some(lower), Some(upper))
};
!is_valid(i32::from(initial_column))
} {}
initial_column
}
}
+106
View File
@@ -0,0 +1,106 @@
use crate::{
mania::object::ManiaObject,
model::{beatmap::Beatmap, hit_object::HitObject},
util::random::Random,
};
pub(super) mod distance_object;
pub(super) mod end_time_object;
pub(super) mod hit_object;
pub struct PatternGenerator<'a> {
pub hit_object: &'a HitObject,
pub total_columns: i32,
random: &'a mut Random,
original_map: &'a Beatmap,
}
impl<'a> PatternGenerator<'a> {
fn new(
hit_object: &'a HitObject,
total_columns: i32,
random: &'a mut Random,
original_map: &'a Beatmap,
) -> Self {
Self {
hit_object,
total_columns,
random,
original_map,
}
}
fn random_start(&self) -> i32 {
i32::from(self.total_columns == 8)
}
fn get_column(&self, allow_special: Option<bool>) -> u8 {
let allow_special = allow_special.unwrap_or(false);
if allow_special && self.total_columns == 8 {
const LOCAL_X_DIVISOR: f32 = 512.0 / 7.0;
((self.hit_object.pos.x / LOCAL_X_DIVISOR).floor() as u8).clamp(0, 6) + 1
} else {
ManiaObject::column(self.hit_object.pos.x, self.total_columns as f32) as u8
}
}
fn get_random_note_count(
&mut self,
p2: f64,
p3: f64,
p4: Option<f64>,
p5: Option<f64>,
p6: Option<f64>,
) -> i32 {
let p4 = p4.unwrap_or(0.0);
let p5 = p5.unwrap_or(0.0);
let p6 = p6.unwrap_or(0.0);
let val = self.random.next_double();
if val >= 1.0 - p6 {
6
} else if val >= 1.0 - p5 {
5
} else if val >= 1.0 - p4 {
4
} else if val >= 1.0 - p3 {
3
} else {
1 + i32::from(val >= 1.0 - p2)
}
}
fn conversion_difficulty(&self) -> f64 {
let orig = self.original_map;
let last_obj_time = orig.hit_objects.last().map_or(0.0, |h| h.start_time);
let first_obj_time = orig.hit_objects.first().map_or(0.0, |h| h.start_time);
// * Drain time in seconds
let total_break_time = orig.total_break_time();
let mut drain_time = ((last_obj_time - first_obj_time - total_break_time) / 1000.0) as i32;
if drain_time == 0 {
drain_time = 10_000;
}
let mut conversion_difficulty = 0.0;
conversion_difficulty += f64::from(orig.hp + orig.ar.clamp(4.0, 7.0)) / 1.5;
conversion_difficulty += orig.hit_objects.len() as f64 / f64::from(drain_time) * 9.0;
conversion_difficulty /= 38.0;
conversion_difficulty *= 5.0;
conversion_difficulty /= 1.15;
conversion_difficulty = conversion_difficulty.min(12.0);
conversion_difficulty
}
fn get_random_column(&mut self, lower: Option<i32>, upper: Option<i32>) -> u8 {
let lower = lower.unwrap_or_else(|| self.random_start());
let upper = upper.unwrap_or(self.total_columns);
self.random.next_int_range(lower, upper) as u8
}
}
+100
View File
@@ -0,0 +1,100 @@
use std::{
fmt,
ops::{BitAndAssign, BitOr, BitOrAssign, Not},
};
#[derive(Copy, Clone, Default)]
pub struct PatternType(u16);
#[rustfmt::skip]
impl PatternType {
pub const FORCE_STACK: Self = Self(1 << 0);
pub const FORCE_NOT_STACK: Self = Self(1 << 1);
pub const KEEP_SINGLE: Self = Self(1 << 2);
pub const LOW_PROBABILITY: Self = Self(1 << 3);
// pub const ALTERNATE: Self = Self(1 << 4);
// pub const FORCE_SIG_SLIDER: Self = Self(1 << 5);
// pub const FORCE_NOT_SLIDER: Self = Self(1 << 6);
pub const GATHERED: Self = Self(1 << 7);
pub const MIRROR: Self = Self(1 << 8);
pub const REVERSE: Self = Self(1 << 9);
pub const CYCLE: Self = Self(1 << 10);
pub const STAIR: Self = Self(1 << 11);
pub const REVERSE_STAIR: Self = Self(1 << 12);
}
impl fmt::Display for PatternType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let mut written = false;
macro_rules! write_pattern {
($self:ident, $f:ident, $written:ident: $($pat:ident,)*) => {
$(
if $self.contains(Self::$pat) {
if $written {
$f.write_str(", ")?;
} else {
$written = true;
}
$f.write_str(stringify!($pat))?;
}
)*
}
}
write_pattern! {
self, f, written:
FORCE_STACK,
FORCE_NOT_STACK,
KEEP_SINGLE,
LOW_PROBABILITY,
GATHERED,
MIRROR,
REVERSE,
CYCLE,
STAIR,
REVERSE_STAIR,
}
if !written {
f.write_str("NONE")?;
}
Ok(())
}
}
impl PatternType {
pub const fn contains(self, other: Self) -> bool {
self.0 & other.0 == other.0
}
}
impl BitOr for PatternType {
type Output = Self;
fn bitor(self, rhs: Self) -> Self::Output {
Self(self.0 | rhs.0)
}
}
impl BitOrAssign for PatternType {
fn bitor_assign(&mut self, rhs: Self) {
self.0 |= rhs.0;
}
}
impl BitAndAssign for PatternType {
fn bitand_assign(&mut self, rhs: Self) {
self.0 &= rhs.0;
}
}
impl Not for PatternType {
type Output = Self;
fn not(self) -> Self::Output {
Self(!self.0)
}
}
+89
View File
@@ -0,0 +1,89 @@
use crate::{
any::difficulty::{skills::Skill, ModeDifficulty},
mania::{
difficulty::{object::ManiaDifficultyObject, skills::strain::Strain},
object::{ManiaObject, ObjectParams},
},
util::float_ext::FloatExt,
};
use super::{attributes::ManiaDifficultyAttributes, convert::ManiaBeatmap};
mod object;
mod skills;
const STAR_SCALING_FACTOR: f64 = 0.018;
pub fn difficulty(
difficulty: &ModeDifficulty,
converted: &ManiaBeatmap<'_>,
) -> ManiaDifficultyAttributes {
let hit_window = converted
.attributes()
.mods(difficulty.get_mods())
.clock_rate(difficulty.get_clock_rate())
.hit_windows()
.od;
let n_objects = converted.map.hit_objects.len() as u32;
let values = DifficultyValues::calculate(difficulty, converted);
ManiaDifficultyAttributes {
stars: values.strain.difficulty_value() * STAR_SCALING_FACTOR,
hit_window,
max_combo: values.max_combo,
n_objects,
is_convert: converted.is_convert,
}
}
pub struct DifficultyValues {
pub strain: Strain,
pub max_combo: u32,
}
impl DifficultyValues {
pub fn calculate(difficulty: &ModeDifficulty, converted: &ManiaBeatmap<'_>) -> Self {
let take = difficulty.get_passed_objects();
let total_columns = converted.map.cs.round_even().max(1.0);
let clock_rate = difficulty.get_clock_rate();
let mut params = ObjectParams::new(converted.map.as_ref());
let mut mania_objects = converted
.map
.hit_objects
.iter()
.map(|h| ManiaObject::new(h, total_columns, &mut params))
.take(take);
let Some(first) = mania_objects.next() else {
return DifficultyValues {
strain: Strain::new(total_columns as usize),
max_combo: 0,
};
};
let n_diff_objects = mania_objects.len();
let diff_objects_iter = mania_objects.enumerate().scan(first, |last, (i, base)| {
let diff_object = ManiaDifficultyObject::new(&base, last, clock_rate, i);
*last = base;
Some(diff_object)
});
let mut diff_objects = Vec::with_capacity(n_diff_objects);
diff_objects.extend(diff_objects_iter);
let mut strain = Skill::new(Strain::new(total_columns as usize), &diff_objects);
for curr in diff_objects.iter() {
strain.process(curr);
}
Self {
strain: strain.inner,
max_combo: params.into_max_combo(),
}
}
}
+27
View File
@@ -0,0 +1,27 @@
use crate::{any::difficulty::object::IDifficultyObject, mania::object::ManiaObject};
pub struct ManiaDifficultyObject {
pub idx: usize,
pub base_column: usize,
pub delta_time: f64,
pub start_time: f64,
pub end_time: f64,
}
impl ManiaDifficultyObject {
pub fn new(base: &ManiaObject, last: &ManiaObject, clock_rate: f64, idx: usize) -> Self {
Self {
idx,
base_column: base.column,
delta_time: (base.start_time - last.start_time) / clock_rate,
start_time: base.start_time / clock_rate,
end_time: base.end_time / clock_rate,
}
}
}
impl IDifficultyObject for ManiaDifficultyObject {
fn idx(&self) -> usize {
self.idx
}
}
+1
View File
@@ -0,0 +1 @@
pub mod strain;
+186
View File
@@ -0,0 +1,186 @@
use crate::{
any::difficulty::{
object::IDifficultyObject,
skills::{strain_decay, ISkill, Skill, StrainDecaySkill},
},
mania::difficulty::object::ManiaDifficultyObject,
};
const INDIVIDUAL_DECAY_BASE: f64 = 0.125;
const OVERALL_DECAY_BASE: f64 = 0.3;
const RELEASE_THRESHOLD: f64 = 24.0;
const SKILL_MULTIPLIER: f64 = 1.0;
const STRAIN_DECAY_BASE: f64 = 1.0;
#[allow(clippy::struct_field_names)]
pub struct Strain {
start_times: Box<[f64]>,
end_times: Box<[f64]>,
individual_strains: Box<[f64]>,
individual_strain: f64,
overall_strain: f64,
inner: StrainDecaySkill,
}
impl Strain {
pub fn new(total_columns: usize) -> Self {
Self {
start_times: vec![0.0; total_columns].into_boxed_slice(),
end_times: vec![0.0; total_columns].into_boxed_slice(),
individual_strains: vec![0.0; total_columns].into_boxed_slice(),
individual_strain: 0.0,
overall_strain: 1.0,
inner: StrainDecaySkill::default(),
}
}
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(StrainDecaySkill::DECAY_WEIGHT)
}
const fn curr_strain(&self) -> f64 {
self.inner.curr_strain
}
fn curr_strain_mut(&mut self) -> &mut f64 {
&mut self.inner.curr_strain
}
fn strain_value_at(&mut self, curr: &ManiaDifficultyObject) -> f64 {
*self.curr_strain_mut() *= strain_decay(curr.delta_time, STRAIN_DECAY_BASE);
*self.curr_strain_mut() += self.strain_value_of(curr) * SKILL_MULTIPLIER;
self.curr_strain()
}
fn strain_value_of(&mut self, curr: &ManiaDifficultyObject) -> f64 {
let mania_curr = curr;
let start_time = mania_curr.start_time;
let end_time = mania_curr.end_time;
let column = mania_curr.base_column;
let mut is_overlapping = false;
// * Lowest value we can assume with the current information
let mut closest_end_time = (end_time - start_time).abs();
// * Factor to all additional strains in case something else is held
let mut hold_factor = 1.0;
// * Addition to the current note in case it's a hold and has to be released awkwardly
let mut hold_addition = 0.0;
for i in 0..self.end_times.len() {
// * The current note is overlapped if a previous note or end is overlapping the current note body
is_overlapping |=
self.end_times[i] > start_time + 1.0 && end_time > self.end_times[i] + 1.0;
// * We give a slight bonus to everything if something is held meanwhile
if self.end_times[i] > end_time + 1.0 {
hold_factor = 1.25;
}
closest_end_time = (end_time - self.end_times[i]).abs().min(closest_end_time);
}
// * The hold addition is given if there was an overlap, however it is only valid if there are no other note with a similar ending.
// * Releasing multiple notes is just as easy as releasing 1. Nerfs the hold addition by half if the closest release is release_threshold away.
// * holdAddition
// * ^
// * 1.0 + - - - - - -+-----------
// * | /
// * 0.5 + - - - - -/ Sigmoid Curve
// * | /|
// * 0.0 +--------+-+---------------> Release Difference / ms
// * release_threshold
if is_overlapping {
hold_addition = (1.0 + (0.5 * (RELEASE_THRESHOLD - closest_end_time)).exp()).recip();
}
// * Decay and increase individualStrains in own column
self.individual_strains[column] = apply_decay(
self.individual_strains[column],
start_time - self.start_times[column],
INDIVIDUAL_DECAY_BASE,
);
self.individual_strains[column] += 2.0 * hold_factor;
// * For notes at the same time (in a chord), the individualStrain should be the hardest individualStrain out of those columns
self.individual_strain = if mania_curr.delta_time <= 1.0 {
self.individual_strain.max(self.individual_strains[column])
} else {
self.individual_strains[column]
};
// * Decay and increase overallStrain
self.overall_strain = apply_decay(self.overall_strain, curr.delta_time, OVERALL_DECAY_BASE);
self.overall_strain += (1.0 + hold_addition) * hold_factor;
// * Update startTimes and endTimes arrays
self.start_times[column] = start_time;
self.end_times[column] = end_time;
// * By subtracting CurrentStrain, this skill effectively only considers the maximum strain of any one hitobject within each strain section.
self.individual_strain + self.overall_strain - self.curr_strain()
}
}
impl ISkill for Strain {
type DifficultyObjects<'a> = [ManiaDifficultyObject];
}
impl Skill<'_, Strain> {
fn calculate_initial_strain(&mut self, offset: f64, curr: &ManiaDifficultyObject) -> f64 {
let prev_start_time = curr
.previous(0, self.diff_objects)
.map_or(0.0, |prev| prev.start_time);
let time = offset - prev_start_time;
let individual = apply_decay(self.inner.individual_strain, time, INDIVIDUAL_DECAY_BASE);
let overall = apply_decay(self.inner.overall_strain, time, OVERALL_DECAY_BASE);
individual + overall
}
const fn curr_section_peak(&self) -> f64 {
self.inner.inner.inner.curr_section_peak
}
fn curr_section_peak_mut(&mut self) -> &mut f64 {
&mut self.inner.inner.inner.curr_section_peak
}
const fn curr_section_end(&self) -> f64 {
self.inner.inner.inner.curr_section_end
}
fn curr_section_end_mut(&mut self) -> &mut f64 {
&mut self.inner.inner.inner.curr_section_end
}
pub fn process(&mut self, curr: &ManiaDifficultyObject) {
if curr.idx == 0 {
*self.curr_section_end_mut() = (curr.start_time / StrainDecaySkill::SECTION_LEN).ceil()
* StrainDecaySkill::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() += StrainDecaySkill::SECTION_LEN;
}
let strain_value_at = self.inner.strain_value_at(curr);
*self.curr_section_peak_mut() = strain_value_at.max(self.curr_section_peak());
}
}
fn apply_decay(value: f64, delta_time: f64, decay_base: f64) -> f64 {
value * decay_base.powf(delta_time / 1000.0)
}
-22
View File
@@ -1,22 +0,0 @@
use super::mania_object::ManiaObject;
#[derive(Clone, Debug)]
pub(crate) struct ManiaDifficultyObject {
pub(crate) idx: usize,
pub(crate) base_column: usize,
pub(crate) delta_time: f64,
pub(crate) start_time: f64,
pub(crate) end_time: f64,
}
impl ManiaDifficultyObject {
pub(crate) fn new(base: &ManiaObject, last: &ManiaObject, clock_rate: f64, idx: usize) -> Self {
Self {
idx,
base_column: base.column,
delta_time: (base.start_time - last.start_time) / clock_rate,
start_time: base.start_time / clock_rate,
end_time: base.end_time / clock_rate,
}
}
}
-314
View File
@@ -1,314 +0,0 @@
#![cfg(feature = "gradual")]
use std::borrow::Cow;
use crate::{
beatmap::BeatmapHitWindows,
parse::{HitObject, HitObjectKind},
util::FloatExt,
Beatmap, GameMode, Mods,
};
use super::{
difficulty_object::ManiaDifficultyObject,
mania_object::ObjectParameters,
skills::{Skill, Strain},
ManiaDifficultyAttributes, ManiaObject, STAR_SCALING_FACTOR,
};
/// Gradually calculate the difficulty attributes of an osu!mania map.
///
/// Note that this struct implements [`Iterator`].
/// On every call of [`Iterator::next`], the map's next hit object will
/// be processed and the [`ManiaDifficultyAttributes`] will be updated and returned.
///
/// If you want to calculate performance attributes, use
/// [`ManiaGradualPerformance`](crate::mania::ManiaGradualPerformance) instead.
///
/// # Example
///
/// ```
/// use rosu_pp::{Beatmap, mania::ManiaGradualDifficulty};
///
/// # /*
/// let map: Beatmap = ...
/// # */
/// # let map = Beatmap::default();
///
/// let mods = 64; // DT
/// let mut iter = ManiaGradualDifficulty::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")))]
#[derive(Clone, Debug)]
pub struct ManiaGradualDifficulty<'map> {
map: Cow<'map, Beatmap>,
inner: ManiaGradualDifficultyInner,
}
impl<'map> ManiaGradualDifficulty<'map> {
/// Create a new difficulty attributes iterator for osu!mania maps.
pub fn new(map: &'map Beatmap, mods: u32) -> Self {
let map = map.convert_mode(GameMode::Mania);
let is_convert = matches!(map, Cow::Owned(_));
let inner = ManiaGradualDifficultyInner::new(map.as_ref(), is_convert, mods);
Self { map, inner }
}
pub(crate) const fn idx(&self) -> usize {
self.inner.idx
}
}
impl Iterator for ManiaGradualDifficulty<'_> {
type Item = ManiaDifficultyAttributes;
#[inline]
fn next(&mut self) -> Option<Self::Item> {
self.inner.next(&self.map.hit_objects)
}
#[inline]
fn size_hint(&self) -> (usize, Option<usize>) {
self.inner.size_hint()
}
#[inline]
fn nth(&mut self, n: usize) -> Option<Self::Item> {
self.inner.nth(n, &self.map.hit_objects)
}
}
impl ExactSizeIterator for ManiaGradualDifficulty<'_> {
#[inline]
fn len(&self) -> usize {
self.inner.len()
}
}
/// Gradually calculate the difficulty attributes of an osu!mania map.
///
/// Check [`ManiaGradualDifficulty`] 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(Clone, Debug)]
pub struct ManiaOwnedGradualDifficulty {
// Technically only `Beatmap::hit_objects` are required here but storing
// the full map lets us get away with not storing the map in `ManiaOwnedGradualPerformance`.
pub(crate) map: Beatmap,
inner: ManiaGradualDifficultyInner,
}
impl ManiaOwnedGradualDifficulty {
/// Create a new owned difficulty attributes iterator for osu!mania maps.
pub fn new(map: Beatmap, mods: u32) -> Self {
let converted_map = map.convert_mode(GameMode::Mania);
let is_convert = matches!(converted_map, Cow::Owned(_));
let inner = ManiaGradualDifficultyInner::new(&converted_map, is_convert, mods);
let map = match converted_map {
Cow::Owned(map) => map,
Cow::Borrowed(_) => map,
};
Self { map, inner }
}
#[allow(unused)]
pub(crate) const fn idx(&self) -> usize {
self.inner.idx
}
}
impl Iterator for ManiaOwnedGradualDifficulty {
type Item = ManiaDifficultyAttributes;
#[inline]
fn next(&mut self) -> Option<Self::Item> {
self.inner.next(&self.map.hit_objects)
}
#[inline]
fn size_hint(&self) -> (usize, Option<usize>) {
self.inner.size_hint()
}
#[inline]
fn nth(&mut self, n: usize) -> Option<Self::Item> {
self.inner.nth(n, &self.map.hit_objects)
}
}
impl ExactSizeIterator for ManiaOwnedGradualDifficulty {
#[inline]
fn len(&self) -> usize {
self.inner.len()
}
}
#[derive(Clone, Debug)]
struct ManiaGradualDifficultyInner {
pub(crate) idx: usize,
hit_window: f64,
strain: Strain,
diff_objects: Box<[ManiaDifficultyObject]>,
curr_combo: usize,
clock_rate: f64,
is_convert: bool,
}
impl ManiaGradualDifficultyInner {
fn new(map: &Beatmap, is_convert: bool, mods: u32) -> Self {
let total_columns = map.cs.round_even().max(1.0);
let clock_rate = mods.clock_rate();
let strain = Strain::new(total_columns as usize);
let BeatmapHitWindows { od: hit_window, .. } = map
.attributes()
.mods(mods)
.converted(is_convert)
.clock_rate(clock_rate)
.hit_windows();
let mut params = ObjectParameters::new(map);
let mut curr_combo = 0;
let mut hit_objects = map.hit_objects.iter();
let first = match hit_objects.next() {
Some(h) => {
let hit_object = ManiaObject::new(h, total_columns, &mut params);
increment_combo_raw(
h,
hit_object.start_time,
hit_object.end_time,
&mut curr_combo,
);
hit_object
}
None => {
return Self {
idx: 0,
hit_window,
strain,
diff_objects: Box::from([]),
curr_combo: 0,
clock_rate,
is_convert,
}
}
};
let diff_objects_iter = hit_objects.enumerate().scan(first, |last, (i, h)| {
let base = ManiaObject::new(h, total_columns, &mut params);
let diff_object = ManiaDifficultyObject::new(&base, &*last, clock_rate, i);
*last = base;
Some(diff_object)
});
let mut diff_objects = Vec::with_capacity(map.hit_objects.len() - 1);
diff_objects.extend(diff_objects_iter);
debug_assert_eq!(diff_objects.len(), diff_objects.capacity());
Self {
idx: 0,
hit_window,
strain,
diff_objects: diff_objects.into_boxed_slice(),
curr_combo,
clock_rate,
is_convert,
}
}
fn next(&mut self, hit_objects: &[HitObject]) -> Option<ManiaDifficultyAttributes> {
// 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.strain.process(curr, &self.diff_objects);
let h = &hit_objects[self.idx];
increment_combo(h, curr, &mut self.curr_combo, self.clock_rate);
} else if hit_objects.is_empty() {
return None;
}
self.idx += 1;
Some(ManiaDifficultyAttributes {
stars: self.strain.clone().difficulty_value() * STAR_SCALING_FACTOR,
hit_window: self.hit_window,
max_combo: self.curr_combo,
is_convert: self.is_convert,
n_objects: self.diff_objects.len() + 1,
})
}
const fn size_hint(&self) -> (usize, Option<usize>) {
let len = self.len();
(len, Some(len))
}
fn nth(&mut self, n: usize, hit_objects: &[HitObject]) -> Option<ManiaDifficultyAttributes> {
let skip_iter = self
.diff_objects
.iter()
.zip(hit_objects.iter().skip(1))
.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, h) in skip_iter.take(take) {
increment_combo(h, curr, &mut self.curr_combo, self.clock_rate);
self.strain.process(curr, &self.diff_objects);
self.idx += 1;
}
self.next(hit_objects)
}
const fn len(&self) -> usize {
self.diff_objects.len() + 1 - self.idx
}
}
fn increment_combo(
h: &HitObject,
diff_obj: &ManiaDifficultyObject,
curr_combo: &mut usize,
clock_rate: f64,
) {
increment_combo_raw(
h,
diff_obj.start_time * clock_rate,
diff_obj.end_time * clock_rate,
curr_combo,
);
}
fn increment_combo_raw(h: &HitObject, start_time: f64, end_time: f64, curr_combo: &mut usize) {
match h.kind {
HitObjectKind::Circle => *curr_combo += 1,
_ => *curr_combo += 1 + ((end_time - start_time) / 100.0) as usize,
}
}
-178
View File
@@ -1,178 +0,0 @@
#![cfg(feature = "gradual")]
use crate::{Beatmap, ManiaPP};
use super::{
ManiaGradualDifficulty, ManiaOwnedGradualDifficulty, ManiaPerformanceAttributes,
ManiaScoreState,
};
/// Gradually calculate the performance attributes of an osu!mania map.
///
/// After each hit object you can call [`next`](`ManiaGradualPerformance::next`)
/// and it will return the resulting current [`ManiaPerformanceAttributes`].
/// To process multiple objects at once, use [`nth`](`ManiaGradualPerformance::nth`) instead.
///
/// Both methods require a play's current score so far.
/// Be sure the given score is adjusted with respect to mods.
///
/// If you only want to calculate difficulty attributes use
/// [`ManiaGradualDifficulty`] instead.
///
/// # Example
///
/// ```
/// use rosu_pp::{Beatmap, mania::{ManiaGradualPerformance, ManiaScoreState}};
///
/// # /*
/// let map: Beatmap = ...
/// # */
/// # let map = Beatmap::default();
///
/// let mods = 64; // DT
/// let mut gradual_perf = ManiaGradualPerformance::new(&map, mods);
/// let mut state = ManiaScoreState::new(); // empty state, everything is on 0.
///
/// // The first 10 hitresults are 320s
/// for _ in 0..10 {
/// state.n320 += 1;
///
/// # /*
/// let performance = gradual_perf.next(score).unwrap();
/// println!("PP: {}", performance.pp);
/// # */
/// # let _ = gradual_perf.next(state.clone());
/// }
///
/// // Then comes a miss.
/// state.n_misses += 1;
/// # /*
/// let performance = gradual_perf.next(score).unwrap();
/// println!("PP: {}", performance.pp);
/// # */
/// # let _ = gradual_perf.next(state.clone());
///
/// // The next 10 objects will be a mixture of 320s and 100s.
/// // Notice how all 10 objects will be processed in one go.
/// state.n320 += 3;
/// state.n100 += 7;
/// // The `nth` method takes a zero-based value.
/// # /*
/// let performance = gradual_perf.nth(score, 9).unwrap();
/// println!("PP: {}", performance.pp);
/// # */
/// # let _ = gradual_perf.nth(state.clone(), 9);
///
/// // Skip to the end
/// # /*
/// state.max_combo = ...
/// state.n300 = ...
/// state.n100 = ...
/// 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(Clone, Debug)]
pub struct ManiaGradualPerformance<'map> {
difficulty: ManiaGradualDifficulty<'map>,
performance: ManiaPP<'map>,
}
impl<'map> ManiaGradualPerformance<'map> {
/// Create a new gradual performance calculator for osu!mania maps.
pub fn new(map: &'map Beatmap, mods: u32) -> Self {
let difficulty = ManiaGradualDifficulty::new(map, mods);
let performance = ManiaPP::new(map).mods(mods).passed_objects(0);
Self {
difficulty,
performance,
}
}
/// Process the next hit object and calculate the
/// performance attributes for the resulting score.
pub fn next(&mut self, state: ManiaScoreState) -> Option<ManiaPerformanceAttributes> {
self.nth(state, 0)
}
/// Process all remaining hit objects and calculate the final performance attributes.
pub fn last(&mut self, state: ManiaScoreState) -> Option<ManiaPerformanceAttributes> {
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: ManiaScoreState, n: usize) -> Option<ManiaPerformanceAttributes> {
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!mania map.
///
/// Check [`ManiaGradualPerformance`] 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(Clone, Debug)]
pub struct ManiaOwnedGradualPerformance {
difficulty: ManiaOwnedGradualDifficulty,
mods: u32,
}
impl ManiaOwnedGradualPerformance {
/// Create a new gradual performance calculator for osu!mania maps.
pub fn new(map: Beatmap, mods: u32) -> Self {
let difficulty = ManiaOwnedGradualDifficulty::new(map, mods);
Self { difficulty, mods }
}
/// Process the next hit object and calculate the
/// performance attributes for the resulting score.
pub fn next(&mut self, state: ManiaScoreState) -> Option<ManiaPerformanceAttributes> {
self.nth(state, 0)
}
/// Process all remaining hit objects and calculate the final performance attributes.
pub fn last(&mut self, state: ManiaScoreState) -> Option<ManiaPerformanceAttributes> {
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: ManiaScoreState, n: usize) -> Option<ManiaPerformanceAttributes> {
let difficulty = self.difficulty.nth(n)?;
let performance = ManiaPP::new(&self.difficulty.map)
.mods(self.mods)
.attributes(difficulty)
.state(state)
.passed_objects(self.difficulty.idx())
.calculate();
Some(performance)
}
}
-103
View File
@@ -1,103 +0,0 @@
use crate::{
curve::{Curve, CurveBuffers},
parse::{HitObject, HitObjectKind},
Beatmap,
};
const BASE_SCORING_DISTANCE: f64 = 100.0;
pub(crate) struct ObjectParameters<'a> {
pub(crate) map: &'a Beatmap,
pub(crate) max_combo: usize,
pub(crate) curve_bufs: CurveBuffers,
}
impl<'a> ObjectParameters<'a> {
pub(crate) fn new(map: &'a Beatmap) -> Self {
Self {
map,
max_combo: 0,
curve_bufs: CurveBuffers::default(),
}
}
}
/// A [`HitObject`] that was processed for the osu!mania gamemode.
#[derive(Clone, Debug, PartialEq)]
pub struct ManiaObject {
/// Start time of the object.
pub start_time: f64,
/// Endtime of the object.
pub end_time: f64,
/// Column of the object.
pub column: usize,
}
impl ManiaObject {
pub(crate) fn column(x: f32, total_columns: f32) -> usize {
let x_divisor = 512.0 / total_columns;
(x / x_divisor).floor().min(total_columns - 1.0) as usize
}
pub(crate) fn new(
h: &HitObject,
total_columns: f32,
params: &mut ObjectParameters<'_>,
) -> Self {
let ObjectParameters {
map,
max_combo,
curve_bufs,
} = params;
let column = Self::column(h.pos.x, total_columns);
*max_combo += 1;
match &h.kind {
HitObjectKind::Circle => Self {
start_time: h.start_time,
end_time: h.start_time,
column,
},
HitObjectKind::Slider {
pixel_len,
repeats,
control_points,
..
} => {
let span_count = *repeats as f64 + 1.0;
let curve = Curve::new(control_points, *pixel_len, curve_bufs);
let dist = curve.dist();
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;
let duration = span_count * dist / vel;
let end_time = h.start_time + duration;
*max_combo += (duration / 100.0) as usize;
Self {
start_time: h.start_time,
end_time,
column,
}
}
HitObjectKind::Spinner { end_time } | HitObjectKind::Hold { end_time } => {
*max_combo += ((*end_time - h.start_time) / 100.0) as usize;
Self {
start_time: h.start_time,
end_time: *end_time,
column,
}
}
}
}
}
+34 -329
View File
@@ -1,345 +1,50 @@
mod difficulty_object;
mod mania_object;
mod pp;
mod score_state;
mod skills;
#[cfg(feature = "gradual")]
mod gradual_difficulty;
#[cfg(feature = "gradual")]
mod gradual_performance;
use std::borrow::Cow;
use crate::{beatmap::BeatmapHitWindows, util::FloatExt, Beatmap, GameMode, Mods, OsuStars};
use crate::{
any::ModeDifficulty,
model::{
beatmap::Beatmap,
mode::{ConvertStatus, IGameMode},
},
};
pub use self::{mania_object::ManiaObject, pp::*, score_state::ManiaScoreState};
#[cfg(feature = "gradual")]
pub use self::{
gradual_difficulty::{ManiaGradualDifficulty, ManiaOwnedGradualDifficulty},
gradual_performance::{ManiaGradualPerformance, ManiaOwnedGradualPerformance},
attributes::{ManiaDifficultyAttributes, ManiaPerformanceAttributes},
convert::ManiaBeatmap,
performance::ManiaPerformance,
score_state::ManiaScoreState,
strains::ManiaStrains,
};
pub(crate) use self::mania_object::ObjectParameters;
mod attributes;
mod convert;
mod difficulty;
mod object;
mod performance;
mod score_state;
mod strains;
use self::{
difficulty_object::ManiaDifficultyObject,
skills::{Skill, Strain},
};
const SECTION_LEN: f64 = 400.0;
const STAR_SCALING_FACTOR: f64 = 0.018;
/// Difficulty calculator on osu!mania maps.
/// Marker type for [`GameMode::Mania`].
///
/// # Example
///
/// ```
/// use rosu_pp::{ManiaStars, Beatmap};
///
/// # /*
/// let map: Beatmap = ...
/// # */
/// # let map = Beatmap::default();
///
/// let difficulty_attrs = ManiaStars::new(&map)
/// .mods(8 + 64) // HDDT
/// .calculate();
///
/// println!("Stars: {}", difficulty_attrs.stars);
/// ```
#[derive(Clone, Debug)]
#[must_use]
pub struct ManiaStars<'map> {
map: Cow<'map, Beatmap>,
mods: u32,
passed_objects: Option<usize>,
clock_rate: Option<f64>,
is_convert: bool,
}
/// [`GameMode::Mania`]: rosu_map::section::general::GameMode::Mania
pub struct Mania;
impl<'map> ManiaStars<'map> {
/// Create a new difficulty calculator for osu!mania maps.
#[inline]
pub fn new(map: &'map Beatmap) -> Self {
let map = map.convert_mode(GameMode::Mania);
let is_convert = matches!(map, Cow::Owned(_));
impl IGameMode for Mania {
type DifficultyAttributes = ManiaDifficultyAttributes;
type Strains = ManiaStrains;
Self {
map,
mods: 0,
passed_objects: None,
clock_rate: None,
is_convert,
}
fn try_convert(map: &mut Cow<'_, Beatmap>) -> ConvertStatus {
convert::try_convert(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;
self
fn difficulty(
difficulty: &ModeDifficulty,
converted: &ManiaBeatmap<'_>,
) -> Self::DifficultyAttributes {
difficulty::difficulty(difficulty, converted)
}
/// 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 [`ManiaStars`] multiple times with different `passed_objects`, you should use
[`ManiaGradualDifficultyAttributes`](crate::mania::ManiaGradualDifficulty)."
)]
#[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
}
/// Specify whether the map is a convert i.e. an osu!standard map.
///
/// This only needs to be specified if the map was converted manually beforehand.
#[inline]
pub const fn is_convert(mut self, is_convert: bool) -> Self {
self.is_convert = is_convert;
self
}
/// Calculate all difficulty related values, including stars.
#[inline]
pub fn calculate(self) -> ManiaDifficultyAttributes {
let is_convert = self.is_convert || matches!(self.map, Cow::Owned(_));
let clock_rate = self.clock_rate.unwrap_or_else(|| self.mods.clock_rate());
let BeatmapHitWindows { od: hit_window, .. } = self
.map
.attributes()
.mods(self.mods)
.converted(is_convert)
.clock_rate(clock_rate)
.hit_windows();
let n_objects = self.map.hit_objects.len();
let ManiaResult { strain, max_combo } = calculate_result(self);
ManiaDifficultyAttributes {
stars: strain.difficulty_value() * STAR_SCALING_FACTOR,
hit_window,
max_combo,
n_objects,
is_convert,
}
}
/// Calculate the skill strains.
///
/// Suitable to plot the difficulty of a map over time.
#[inline]
pub fn strains(self) -> ManiaStrains {
let ManiaResult { strain, .. } = calculate_result(self);
ManiaStrains {
section_len: SECTION_LEN,
strains: strain.strain_peaks.to_vec(),
}
}
}
/// The result of calculating the strains on a osu!taiko map.
/// Suitable to plot the difficulty of a map over time.
#[derive(Clone, Debug)]
pub struct ManiaStrains {
/// Time in ms inbetween two strains.
pub section_len: f64,
/// Strain peaks of the strain skill.
pub strains: Vec<f64>,
}
impl ManiaStrains {
/// Returns the number of strain peaks per skill.
#[inline]
#[allow(clippy::len_without_is_empty)]
pub fn len(&self) -> usize {
self.strains.len()
}
}
fn calculate_result(params: ManiaStars<'_>) -> ManiaResult {
let ManiaStars {
map,
mods,
passed_objects,
clock_rate,
is_convert: _,
} = params;
let take = passed_objects.unwrap_or(map.hit_objects.len());
let total_columns = map.cs.round_even().max(1.0);
let clock_rate = clock_rate.unwrap_or_else(|| mods.clock_rate());
let mut strain = Strain::new(total_columns as usize);
let mut params = ObjectParameters::new(map.as_ref());
let mut hit_objects = map.hit_objects.iter().take(take);
let first = match hit_objects.next() {
Some(h) => ManiaObject::new(h, total_columns, &mut params),
None => {
return ManiaResult {
strain,
max_combo: 0,
}
}
};
let diff_objects_iter = hit_objects.enumerate().scan(first, |last, (i, h)| {
let base = ManiaObject::new(h, total_columns, &mut params);
let diff_object = ManiaDifficultyObject::new(&base, &*last, clock_rate, i);
*last = base;
Some(diff_object)
});
let mut diff_objects = Vec::with_capacity(map.hit_objects.len().min(take).saturating_sub(1));
diff_objects.extend(diff_objects_iter);
for curr in diff_objects.iter() {
strain.process(curr, &diff_objects);
}
ManiaResult {
strain,
max_combo: params.max_combo,
}
}
struct ManiaResult {
strain: Strain,
max_combo: usize,
}
/// The result of a difficulty calculation on an osu!mania map.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct ManiaDifficultyAttributes {
/// The final star rating.
pub stars: f64,
/// The perceived hit window for an n300 inclusive of rate-adjusting mods (DT/HT/etc).
pub hit_window: f64,
/// The amount of hitobjects in the map.
pub n_objects: usize,
/// The maximum achievable combo.
pub max_combo: usize,
/// Whether the [`Beatmap`] was a convert i.e. an osu!standard map.
pub is_convert: bool,
}
impl ManiaDifficultyAttributes {
/// 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_objects
}
/// Whether the [`Beatmap`] was a convert i.e. an osu!standard map.
#[inline]
pub const fn is_convert(&self) -> bool {
self.is_convert
}
/// Returns a builder for performance calculation.
#[inline]
pub fn pp(self) -> ManiaPP<'static> {
ManiaPP::from(self)
}
}
/// The result of a performance calculation on an osu!mania map.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct ManiaPerformanceAttributes {
/// The difficulty attributes that were used for the performance calculation.
pub difficulty: ManiaDifficultyAttributes,
/// The final performance points.
pub pp: f64,
/// The difficulty portion of the final pp.
pub pp_difficulty: f64,
}
impl ManiaPerformanceAttributes {
/// 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
}
/// Whether the [`Beatmap`] was a convert i.e. an osu!standard map.
#[inline]
pub const fn is_convert(&self) -> bool {
self.difficulty.is_convert
}
}
impl From<ManiaPerformanceAttributes> for ManiaDifficultyAttributes {
#[inline]
fn from(attributes: ManiaPerformanceAttributes) -> Self {
attributes.difficulty
}
}
impl<'map> From<OsuStars<'map>> for ManiaStars<'map> {
#[inline]
fn from(osu: OsuStars<'map>) -> Self {
let OsuStars {
map,
mods,
passed_objects,
clock_rate,
} = osu;
Self {
map: map.convert_mode(GameMode::Mania),
mods,
passed_objects,
clock_rate,
is_convert: true,
}
fn strains(difficulty: &ModeDifficulty, converted: &ManiaBeatmap<'_>) -> Self::Strains {
strains::strains(difficulty, converted)
}
}
+95
View File
@@ -0,0 +1,95 @@
use rosu_map::section::hit_objects::CurveBuffers;
use crate::model::{
beatmap::Beatmap,
control_point::{DifficultyPoint, TimingPoint},
hit_object::{HitObject, HitObjectKind, HoldNote, Spinner},
};
pub struct ManiaObject {
pub start_time: f64,
pub end_time: f64,
pub column: usize,
}
impl ManiaObject {
pub fn new(h: &HitObject, total_columns: f32, params: &mut ObjectParams<'_>) -> Self {
let column = Self::column(h.pos.x, total_columns);
params.max_combo += 1;
match h.kind {
HitObjectKind::Circle => Self {
start_time: h.start_time,
end_time: h.start_time,
column,
},
HitObjectKind::Slider(ref slider) => {
const BASE_SCORING_DIST: f32 = 100.0;
let dist = slider.curve(&mut params.curve_bufs).dist();
let beat_len = params
.map
.timing_point_at(h.start_time)
.map_or(TimingPoint::DEFAULT_BEAT_LEN, |point| point.beat_len);
let slider_velocity = params
.map
.difficulty_point_at(h.start_time)
.map_or(DifficultyPoint::DEFAULT_SLIDER_VELOCITY, |point| {
point.slider_velocity
});
let scoring_dist =
f64::from(BASE_SCORING_DIST) * params.map.slider_multiplier * slider_velocity;
let velocity = scoring_dist / beat_len;
let duration = (slider.span_count() as f64) * dist / velocity;
params.max_combo += (duration / 100.0) as u32;
Self {
start_time: h.start_time,
end_time: h.start_time + duration,
column,
}
}
HitObjectKind::Spinner(Spinner { end_time })
| HitObjectKind::Hold(HoldNote { end_time }) => {
params.max_combo += ((end_time - h.start_time) / 100.0) as u32;
Self {
start_time: h.start_time,
end_time,
column,
}
}
}
}
pub fn column(x: f32, total_columns: f32) -> usize {
let x_divisor = 512.0 / total_columns;
(x / x_divisor).floor().min(total_columns - 1.0) as usize
}
}
pub struct ObjectParams<'a> {
map: &'a Beatmap,
max_combo: u32,
curve_bufs: CurveBuffers,
}
impl<'a> ObjectParams<'a> {
pub fn new(map: &'a Beatmap) -> Self {
Self {
map,
max_combo: 0,
curve_bufs: CurveBuffers::default(),
}
}
pub fn into_max_combo(self) -> u32 {
self.max_combo
}
}
File diff suppressed because it is too large Load Diff
+10 -13
View File
@@ -1,35 +1,32 @@
/// Aggregation for a score's current state i.e. what are the current hitresults.
#[derive(Clone, Debug, Default, Eq, PartialEq)]
/// Aggregation for a score's current state.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct ManiaScoreState {
/// Amount of current 320s.
pub n320: usize,
pub n320: u32,
/// Amount of current 300s.
pub n300: usize,
pub n300: u32,
/// Amount of current 200s.
pub n200: usize,
pub n200: 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 ManiaScoreState {
/// 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.n320 + self.n300 + self.n200 + 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 +37,6 @@ impl ManiaScoreState {
let numerator = 6 * (self.n320 + self.n300) + 4 * self.n200 + 2 * self.n100 + self.n50;
let denominator = 6 * total_hits;
numerator as f64 / denominator as f64
f64::from(numerator) / f64::from(denominator)
}
}
-18
View File
@@ -1,18 +0,0 @@
mod strain;
mod traits;
pub(crate) use self::{
strain::Strain,
traits::{Skill, StrainDecaySkill, StrainSkill},
};
use super::difficulty_object::ManiaDifficultyObject;
fn previous(
diff_objects: &[ManiaDifficultyObject],
curr: usize,
backwards_idx: usize,
) -> Option<&ManiaDifficultyObject> {
curr.checked_sub(backwards_idx + 1)
.and_then(|idx| diff_objects.get(idx))
}
-211
View File
@@ -1,211 +0,0 @@
use crate::{mania::difficulty_object::ManiaDifficultyObject, util::CompactVec};
use super::{previous, Skill, StrainDecaySkill, StrainSkill};
#[derive(Clone, Debug)]
#[allow(clippy::struct_field_names)]
pub(crate) struct Strain {
start_times: Vec<f64>,
end_times: Vec<f64>,
individual_strains: Vec<f64>,
individual_strain: f64,
overall_strain: f64,
curr_strain: f64,
curr_section_peak: f64,
curr_section_end: f64,
pub(crate) strain_peaks: CompactVec,
}
impl Strain {
const INDIVIDUAL_DECAY_BASE: f64 = 0.125;
const OVERALL_DECAY_BASE: f64 = 0.3;
const RELEASE_THRESHOLD: f64 = 24.0;
pub(crate) fn new(total_columns: usize) -> Self {
Self {
start_times: vec![0.0; total_columns],
end_times: vec![0.0; total_columns],
individual_strains: vec![0.0; total_columns],
individual_strain: 0.0,
overall_strain: 1.0,
curr_strain: 0.0,
curr_section_peak: 0.0,
curr_section_end: 0.0,
strain_peaks: CompactVec::new(),
}
}
fn apply_decay(value: f64, delta_time: f64, decay_base: f64) -> f64 {
value * decay_base.powf(delta_time / 1000.0)
}
}
impl Skill for Strain {
#[inline]
fn process(&mut self, curr: &ManiaDifficultyObject, diff_objects: &[ManiaDifficultyObject]) {
<Self as StrainSkill>::process(self, curr, diff_objects);
}
#[inline]
fn difficulty_value(self) -> f64 {
<Self as StrainSkill>::difficulty_value(self)
}
}
impl StrainSkill for Strain {
const DECAY_WEIGHT: f64 = 0.9;
#[inline]
fn curr_section_end(&self) -> f64 {
self.curr_section_end
}
#[inline]
fn curr_section_end_mut(&mut self) -> &mut f64 {
&mut self.curr_section_end
}
#[inline]
fn curr_section_peak(&self) -> f64 {
self.curr_section_peak
}
#[inline]
fn curr_section_peak_mut(&mut self) -> &mut f64 {
&mut self.curr_section_peak
}
#[inline]
fn strain_peaks_mut(&mut self) -> &mut CompactVec {
&mut self.strain_peaks
}
#[inline]
fn strain_value_at(&mut self, curr: &ManiaDifficultyObject) -> f64 {
<Self as StrainDecaySkill>::strain_value_at(self, curr)
}
#[inline]
fn calculate_initial_strain(
&self,
time: f64,
curr: &ManiaDifficultyObject,
diff_objects: &[ManiaDifficultyObject],
) -> f64 {
<Self as StrainDecaySkill>::calculate_initial_strain(self, time, curr, diff_objects)
}
}
impl StrainDecaySkill for Strain {
const SKILL_MULTIPLIER: f64 = 1.0;
const STRAIN_DECAY_BASE: f64 = 1.0;
#[inline]
fn curr_strain(&self) -> f64 {
self.curr_strain
}
#[inline]
fn curr_strain_mut(&mut self) -> &mut f64 {
&mut self.curr_strain
}
fn strain_value_of(&mut self, curr: &ManiaDifficultyObject) -> f64 {
let mania_curr = curr;
let start_time = mania_curr.start_time;
let end_time = mania_curr.end_time;
let col = mania_curr.base_column;
let mut is_overlapping = false;
// * Lowest value we can assume with the current information
let mut closest_end_time = (end_time - start_time).abs();
// * Factor to all additional strains in case something else is held
let mut hold_factor = 1.0;
// * Addition to the current note in case it's a hold and has to be released awkwardly
let mut hold_addition = 0.0;
for i in 0..self.end_times.len() {
// * The current note is overlapped if a previous note or end is overlapping the current note body
is_overlapping |=
self.end_times[i] > start_time + 1.0 && end_time > self.end_times[i] + 1.0;
// * We give a slight bonus to everything if something is held meanwhile
if self.end_times[i] > end_time + 1.0 {
hold_factor = 1.25;
}
closest_end_time = (end_time - self.end_times[i]).abs().min(closest_end_time);
}
// * The hold addition is given if there was an overlap, however it is only valid if there are no other note with a similar ending.
// * Releasing multiple notes is just as easy as releasing 1. Nerfs the hold addition by half if the closest release is release_threshold away.
// * holdAddition
// * ^
// * 1.0 + - - - - - -+-----------
// * | /
// * 0.5 + - - - - -/ Sigmoid Curve
// * | /|
// * 0.0 +--------+-+---------------> Release Difference / ms
// * release_threshold
if is_overlapping {
hold_addition =
(1.0 + (0.5 * (Self::RELEASE_THRESHOLD - closest_end_time)).exp()).recip();
}
// * Decay and increase individualStrains in own column
self.individual_strains[col] = Self::apply_decay(
self.individual_strains[col],
start_time - self.start_times[col],
Self::INDIVIDUAL_DECAY_BASE,
);
self.individual_strains[col] += 2.0 * hold_factor;
// * For notes at the same time (in a chord), the individualStrain should be the hardest individualStrain out of those columns
self.individual_strain = if mania_curr.delta_time <= 1.0 {
self.individual_strain.max(self.individual_strains[col])
} else {
self.individual_strains[col]
};
// * Decay and increase overallStrain
self.overall_strain = Self::apply_decay(
self.overall_strain,
curr.delta_time,
Self::OVERALL_DECAY_BASE,
);
self.overall_strain += (1.0 + hold_addition) * hold_factor;
// * Update startTimes and endTimes arrays
self.start_times[col] = start_time;
self.end_times[col] = end_time;
// * By subtracting CurrentStrain, this skill effectively only considers the maximum strain of any one hitobject within each strain section.
self.individual_strain + self.overall_strain - self.curr_strain
}
fn calculate_initial_strain(
&self,
offset: f64,
curr: &ManiaDifficultyObject,
diff_objects: &[ManiaDifficultyObject],
) -> f64 {
let prev_start = previous(diff_objects, curr.idx, 0).map_or(0.0, |h| h.start_time);
let individual_decay = Self::apply_decay(
self.individual_strain,
offset - prev_start,
Self::INDIVIDUAL_DECAY_BASE,
);
let overall_decay = Self::apply_decay(
self.overall_strain,
offset - prev_start,
Self::OVERALL_DECAY_BASE,
);
individual_decay + overall_decay
}
}
-129
View File
@@ -1,129 +0,0 @@
use std::{cmp::Ordering, mem};
use crate::{
mania::{difficulty_object::ManiaDifficultyObject, SECTION_LEN},
util::CompactVec,
};
pub(crate) trait Skill {
fn process(&mut self, curr: &ManiaDifficultyObject, diff_objects: &[ManiaDifficultyObject]);
fn difficulty_value(self) -> f64;
}
pub(crate) trait StrainSkill: Sized + Skill {
const DECAY_WEIGHT: f64 = 0.9;
fn curr_section_end(&self) -> f64;
fn curr_section_end_mut(&mut self) -> &mut f64;
fn curr_section_peak(&self) -> f64;
fn curr_section_peak_mut(&mut self) -> &mut f64;
fn strain_peaks_mut(&mut self) -> &mut CompactVec;
fn strain_value_at(&mut self, curr: &ManiaDifficultyObject) -> f64;
fn process(&mut self, curr: &ManiaDifficultyObject, diff_objects: &[ManiaDifficultyObject]) {
// * The first object doesn't generate a strain, so we begin with an incremented section end
if curr.idx == 0 {
*self.curr_section_end_mut() = (curr.start_time / SECTION_LEN).ceil() * SECTION_LEN;
}
while curr.start_time > self.curr_section_end() {
self.save_curr_peak();
self.start_new_section_from(self.curr_section_end(), curr, diff_objects);
*self.curr_section_end_mut() += 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_mut() += remaining_iters * SECTION_LEN;
}
}
*self.curr_section_peak_mut() = self.strain_value_at(curr).max(self.curr_section_peak());
}
fn save_curr_peak(&mut self) {
let curr_section_peak = self.curr_section_peak();
self.strain_peaks_mut().push(curr_section_peak);
}
fn start_new_section_from(
&mut self,
time: f64,
curr: &ManiaDifficultyObject,
diff_objects: &[ManiaDifficultyObject],
) {
*self.curr_section_peak_mut() = self.calculate_initial_strain(time, curr, diff_objects);
}
fn calculate_initial_strain(
&self,
time: f64,
curr: &ManiaDifficultyObject,
diff_objects: &[ManiaDifficultyObject],
) -> f64;
fn get_curr_strain_peaks(mut self) -> CompactVec {
let mut peaks = mem::take(self.strain_peaks_mut());
peaks.push(self.curr_section_peak());
peaks
}
fn difficulty_value(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));
// * 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
}
}
pub(crate) trait StrainDecaySkill: StrainSkill {
const SKILL_MULTIPLIER: f64;
const STRAIN_DECAY_BASE: f64;
fn curr_strain(&self) -> f64;
fn curr_strain_mut(&mut self) -> &mut f64;
fn strain_value_of(&mut self, curr: &ManiaDifficultyObject) -> f64;
fn calculate_initial_strain(
&self,
time: f64,
curr: &ManiaDifficultyObject,
diff_objects: &[ManiaDifficultyObject],
) -> f64;
fn strain_value_at(&mut self, curr: &ManiaDifficultyObject) -> f64 {
*self.curr_strain_mut() *= self.strain_decay(curr.delta_time);
*self.curr_strain_mut() += self.strain_value_of(curr) * Self::SKILL_MULTIPLIER;
self.curr_strain()
}
fn strain_decay(&self, ms: f64) -> f64 {
Self::STRAIN_DECAY_BASE.powf(ms / 1000.0)
}
}
+25
View File
@@ -0,0 +1,25 @@
use crate::{any::ModeDifficulty, mania::difficulty::DifficultyValues};
use super::convert::ManiaBeatmap;
/// The result of calculating the strains on a osu!mania map.
///
/// Suitable to plot the difficulty of a map over time.
#[derive(Clone, Debug, PartialEq)]
pub struct ManiaStrains {
/// Strain peaks of the strain skill.
pub strains: Vec<f64>,
}
impl ManiaStrains {
/// Time between two strains in ms.
pub const SECTION_LEN: f64 = 400.0;
}
pub fn strains(difficulty: &ModeDifficulty, converted: &ManiaBeatmap<'_>) -> ManiaStrains {
let values = DifficultyValues::calculate(difficulty, converted);
ManiaStrains {
strains: values.strain.get_curr_strain_peaks(),
}
}