Merge pull request #2 from osuAkatsuki/std-relax-rework
Implement osu!std relax PP system
This commit is contained in:
@@ -64,6 +64,12 @@ pub struct Beatmap {
|
||||
|
||||
/// All break points of the beatmap.
|
||||
pub breaks: Vec<Break>,
|
||||
|
||||
/// The creator of the beatmap
|
||||
pub creator: String,
|
||||
|
||||
/// The beatmap ID of the map
|
||||
pub beatmap_id: u32,
|
||||
}
|
||||
|
||||
impl Beatmap {
|
||||
@@ -164,6 +170,8 @@ impl Beatmap {
|
||||
effect_points: self.effect_points.clone(),
|
||||
stack_leniency: self.stack_leniency,
|
||||
breaks: self.breaks.clone(),
|
||||
creator: self.creator.clone(),
|
||||
beatmap_id: self.beatmap_id,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,6 +188,9 @@ pub mod mania;
|
||||
/// Everything about osu!standard.
|
||||
pub mod osu;
|
||||
|
||||
/// osu! 2019 (for relax)
|
||||
pub mod osu_2019;
|
||||
|
||||
/// Everything about osu!taiko.
|
||||
pub mod taiko;
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ pub trait Mods: Copy {
|
||||
const HT: u32 = 1 << 8;
|
||||
const FL: u32 = 1 << 10;
|
||||
const SO: u32 = 1 << 12;
|
||||
const AP: u32 = 1 << 13;
|
||||
|
||||
/// If the clock rate is affected by the mods.
|
||||
fn change_speed(self) -> bool;
|
||||
|
||||
@@ -0,0 +1,539 @@
|
||||
use std::{borrow::Cow, cmp::Ordering, convert::identity, f64::consts::PI, iter};
|
||||
|
||||
use crate::parse::{PathControlPoint, PathType, Pos2};
|
||||
|
||||
const BEZIER_TOLERANCE: f32 = 0.25;
|
||||
const CATMULL_DETAIL: usize = 50;
|
||||
const CIRCULAR_ARC_TOLERANCE: f32 = 0.1;
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(crate) struct CurveBuffers {
|
||||
path: Vec<Pos2>,
|
||||
lengths: Vec<f64>,
|
||||
vertices: Vec<Pos2>,
|
||||
bezier: BezierBuffers,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
struct BezierBuffers {
|
||||
left: Vec<Pos2>,
|
||||
right: Vec<Pos2>,
|
||||
midpoints: Vec<Pos2>,
|
||||
left_child: Vec<Pos2>,
|
||||
}
|
||||
|
||||
impl BezierBuffers {
|
||||
/// Fill the buffers with new elements until a
|
||||
/// length of `len` is reached. Does nothing if `len`
|
||||
/// is already smaller than the current buffer size.
|
||||
fn extend_exact(&mut self, len: usize) {
|
||||
if len <= self.left.len() {
|
||||
return;
|
||||
}
|
||||
|
||||
let additional = len - self.left.len();
|
||||
|
||||
self.left
|
||||
.extend(iter::repeat(Pos2::zero()).take(additional));
|
||||
self.right
|
||||
.extend(iter::repeat(Pos2::zero()).take(additional));
|
||||
self.midpoints
|
||||
.extend(iter::repeat(Pos2::zero()).take(additional));
|
||||
self.left_child
|
||||
.extend(iter::repeat(Pos2::zero()).take(additional));
|
||||
}
|
||||
}
|
||||
|
||||
struct CircularArcProperties {
|
||||
theta_start: f64,
|
||||
theta_range: f64,
|
||||
direction: f64,
|
||||
radius: f32,
|
||||
centre: Pos2,
|
||||
}
|
||||
|
||||
pub(crate) struct Curve<'bufs> {
|
||||
path: &'bufs [Pos2],
|
||||
lengths: &'bufs [f64],
|
||||
}
|
||||
|
||||
impl<'bufs> Curve<'bufs> {
|
||||
pub(crate) fn new(
|
||||
points: &[PathControlPoint],
|
||||
expected_len: Option<f64>,
|
||||
bufs: &'bufs mut CurveBuffers,
|
||||
) -> Self {
|
||||
Self::calculate_path(points, bufs);
|
||||
Self::calculate_length(points, bufs, expected_len);
|
||||
|
||||
Self {
|
||||
path: &bufs.path,
|
||||
lengths: &bufs.lengths,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn position_at(&self, progress: f64) -> Pos2 {
|
||||
let d = self.progress_to_dist(progress);
|
||||
let i = self.idx_of_dist(d);
|
||||
|
||||
self.interpolate_vertices(i, d)
|
||||
}
|
||||
|
||||
fn progress_to_dist(&self, progress: f64) -> f64 {
|
||||
progress.clamp(0.0, 1.0) * self.dist()
|
||||
}
|
||||
|
||||
pub(crate) fn dist(&self) -> f64 {
|
||||
self.lengths.last().copied().unwrap_or(0.0)
|
||||
}
|
||||
|
||||
fn idx_of_dist(&self, d: f64) -> usize {
|
||||
self.lengths
|
||||
.binary_search_by(|len| len.partial_cmp(&d).unwrap_or(Ordering::Equal))
|
||||
.map_or_else(identity, identity)
|
||||
}
|
||||
|
||||
fn interpolate_vertices(&self, i: usize, d: f64) -> Pos2 {
|
||||
if self.path.is_empty() {
|
||||
return Pos2::zero();
|
||||
}
|
||||
|
||||
let p1 = if i == 0 {
|
||||
return self.path[0];
|
||||
} else if let Some(p) = self.path.get(i) {
|
||||
*p
|
||||
} else {
|
||||
return self.path[self.path.len() - 1];
|
||||
};
|
||||
|
||||
let p0 = self.path[i - 1];
|
||||
|
||||
let d0 = self.lengths[i - 1];
|
||||
let d1 = self.lengths[i];
|
||||
|
||||
// * Avoid division by an almost-zero number in case
|
||||
// * two points are extremely close to each other
|
||||
if (d0 - d1).abs() <= f64::EPSILON {
|
||||
return p0;
|
||||
}
|
||||
|
||||
let w = (d - d0) / (d1 - d0);
|
||||
|
||||
p0 + (p1 - p0) * w as f32
|
||||
}
|
||||
|
||||
fn calculate_path(points: &[PathControlPoint], bufs: &mut CurveBuffers) {
|
||||
bufs.path.clear();
|
||||
|
||||
if points.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let CurveBuffers {
|
||||
vertices,
|
||||
bezier,
|
||||
path,
|
||||
..
|
||||
} = bufs;
|
||||
|
||||
vertices.clear();
|
||||
vertices.extend(points.iter().map(|p| p.pos));
|
||||
|
||||
let mut start = 0;
|
||||
|
||||
for i in 0..points.len() {
|
||||
if points[i].kind.is_none() && i < points.len() - 1 {
|
||||
continue;
|
||||
}
|
||||
|
||||
// * The current vertex ends the segment
|
||||
let segment_vertices = &vertices[start..i + 1];
|
||||
let segment_kind = points[start].kind.unwrap_or(PathType::Linear);
|
||||
|
||||
Self::calculate_subpath(path, segment_vertices, segment_kind, bezier);
|
||||
|
||||
// * Start the new segment at the current vertex
|
||||
start = i;
|
||||
}
|
||||
|
||||
path.dedup();
|
||||
}
|
||||
|
||||
fn calculate_length(
|
||||
points: &[PathControlPoint],
|
||||
bufs: &mut CurveBuffers,
|
||||
expected_len: Option<f64>,
|
||||
) {
|
||||
let CurveBuffers {
|
||||
path,
|
||||
lengths: cumulative_len,
|
||||
..
|
||||
} = bufs;
|
||||
|
||||
cumulative_len.clear();
|
||||
let mut calculated_len = 0.0;
|
||||
cumulative_len.reserve(path.len());
|
||||
cumulative_len.push(0.0);
|
||||
|
||||
let length_iter = path.iter().zip(path.iter().skip(1)).map(|(&curr, &next)| {
|
||||
calculated_len += (next - curr).length() as f64;
|
||||
|
||||
calculated_len
|
||||
});
|
||||
|
||||
cumulative_len.extend(length_iter);
|
||||
|
||||
if let Some(expected_len) = expected_len.filter(|&len| calculated_len != len) {
|
||||
// * In osu-stable, if the last two control points of a slider are equal, extension is not performed
|
||||
let condition_opt = points
|
||||
.len()
|
||||
.checked_sub(2)
|
||||
.and_then(|i| points.get(i..))
|
||||
.filter(|suffix| suffix[0].pos == suffix[1].pos && expected_len > calculated_len);
|
||||
|
||||
if condition_opt.is_some() {
|
||||
cumulative_len.push(calculated_len);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Shortcut when it's just (0,0) since there's nothing to do anyway
|
||||
if cumulative_len.len() == 1 {
|
||||
return;
|
||||
}
|
||||
|
||||
// * The last length is always incorrect
|
||||
cumulative_len.pop();
|
||||
|
||||
let last_valid = cumulative_len
|
||||
.iter()
|
||||
.rev()
|
||||
.position(|l| *l < expected_len)
|
||||
.map_or(0, |idx| cumulative_len.len() - idx);
|
||||
|
||||
// * The path will be shortened further, in which case we should trim
|
||||
// * any more unnecessary lengths and their associated path segments
|
||||
if last_valid < cumulative_len.len() {
|
||||
cumulative_len.truncate(last_valid);
|
||||
path.truncate(last_valid + 1);
|
||||
|
||||
if cumulative_len.is_empty() {
|
||||
// * The expected distance is negative or zero
|
||||
// * Perhaps negative path lengths should be disallowed altogether
|
||||
cumulative_len.push(0.0);
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let end_idx = cumulative_len.len();
|
||||
let prev_idx = end_idx - 1;
|
||||
|
||||
// * The direction of the segment to shorten or lengthen
|
||||
let dir = (path[end_idx] - path[prev_idx]).normalize();
|
||||
|
||||
path[end_idx] = path[prev_idx] + dir * (expected_len - cumulative_len[prev_idx]) as f32;
|
||||
cumulative_len.push(expected_len);
|
||||
}
|
||||
}
|
||||
|
||||
fn calculate_subpath(
|
||||
path: &mut Vec<Pos2>,
|
||||
sub_points: &[Pos2],
|
||||
kind: PathType,
|
||||
bufs: &mut BezierBuffers,
|
||||
) {
|
||||
match kind {
|
||||
PathType::Bezier => Self::approximate_bezier(path, sub_points, bufs),
|
||||
PathType::Catmull => Self::approximate_catmull(path, sub_points),
|
||||
PathType::Linear => Self::approximate_linear(path, sub_points),
|
||||
PathType::PerfectCurve => {
|
||||
if let [a, b, c] = sub_points {
|
||||
if Self::approximate_circular_arc(path, *a, *b, *c) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
Self::approximate_bezier(path, sub_points, bufs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn approximate_bezier(path: &mut Vec<Pos2>, points: &[Pos2], bufs: &mut BezierBuffers) {
|
||||
bufs.extend_exact(points.len());
|
||||
|
||||
Self::approximate_bspline(path, points, bufs);
|
||||
}
|
||||
|
||||
fn approximate_catmull(path: &mut Vec<Pos2>, points: &[Pos2]) {
|
||||
if points.len() == 1 {
|
||||
return;
|
||||
}
|
||||
|
||||
path.reserve_exact((points.len() - 1) * CATMULL_DETAIL * 2);
|
||||
|
||||
// Handle first iteration distinctly because of v1
|
||||
let v1 = points[0];
|
||||
let v2 = points[0];
|
||||
let v3 = points.get(1).copied().unwrap_or(v2);
|
||||
let v4 = points.get(2).copied().unwrap_or_else(|| v3 * 2.0 - v2);
|
||||
|
||||
Self::catmull_subpath(path, v1, v2, v3, v4);
|
||||
|
||||
// Remaining iterations
|
||||
for (i, (&v1, &v2)) in (2..points.len()).zip(points.iter().zip(points.iter().skip(1))) {
|
||||
let v3 = points.get(i).copied().unwrap_or_else(|| v2 * 2.0 - v1);
|
||||
let v4 = points.get(i + 1).copied().unwrap_or_else(|| v3 * 2.0 - v2);
|
||||
|
||||
Self::catmull_subpath(path, v1, v2, v3, v4);
|
||||
}
|
||||
}
|
||||
|
||||
fn approximate_linear(path: &mut Vec<Pos2>, points: &[Pos2]) {
|
||||
path.extend(points)
|
||||
}
|
||||
|
||||
fn approximate_circular_arc(path: &mut Vec<Pos2>, a: Pos2, b: Pos2, c: Pos2) -> bool {
|
||||
let pr = match Self::circular_arc_properties(a, b, c) {
|
||||
Some(pr) => pr,
|
||||
None => return false,
|
||||
};
|
||||
|
||||
// * We select the amount of points for the approximation by requiring the discrete curvature
|
||||
// * to be smaller than the provided tolerance. The exact angle required to meet the tolerance
|
||||
// * is: 2 * Math.Acos(1 - TOLERANCE / r)
|
||||
// * The special case is required for extremely short sliders where the radius is smaller than
|
||||
// * the tolerance. This is a pathological rather than a realistic case.
|
||||
let amount_points = if 2.0 * pr.radius <= CIRCULAR_ARC_TOLERANCE {
|
||||
2
|
||||
} else {
|
||||
let divisor = 2.0 * (1.0 - CIRCULAR_ARC_TOLERANCE / pr.radius).acos();
|
||||
|
||||
((pr.theta_range / divisor as f64).ceil() as usize).max(2)
|
||||
};
|
||||
|
||||
path.reserve_exact(amount_points);
|
||||
let divisor = (amount_points - 1) as f64;
|
||||
let directed_range = pr.direction * pr.theta_range;
|
||||
|
||||
let subpath = (0..amount_points).map(|i| {
|
||||
let fract = i as f64 / divisor;
|
||||
let theta = pr.theta_start + fract * directed_range;
|
||||
let (sin, cos) = theta.sin_cos();
|
||||
|
||||
let origin = Pos2 {
|
||||
x: cos as f32,
|
||||
y: sin as f32,
|
||||
};
|
||||
|
||||
pr.centre + origin * pr.radius
|
||||
});
|
||||
|
||||
path.extend(subpath);
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
fn approximate_bspline(path: &mut Vec<Pos2>, points: &[Pos2], bufs: &mut BezierBuffers) {
|
||||
let p = points.len();
|
||||
|
||||
let mut to_flatten = Vec::new();
|
||||
let mut free_bufs = Vec::new();
|
||||
|
||||
// In osu!lazer's code, `p` is always 0 so the first big `if` can be omitted
|
||||
|
||||
to_flatten.push(Cow::Borrowed(points));
|
||||
|
||||
// * "toFlatten" contains all the curves which are not yet approximated well enough.
|
||||
// * We use a stack to emulate recursion without the risk of running into a stack overflow.
|
||||
// * (More specifically, we iteratively and adaptively refine our curve with a
|
||||
// * <a href="https://en.wikipedia.org/wiki/Depth-first_search">Depth-first search</a>
|
||||
// * over the tree resulting from the subdivisions we make.)
|
||||
|
||||
let BezierBuffers {
|
||||
left,
|
||||
right,
|
||||
midpoints,
|
||||
left_child,
|
||||
} = bufs;
|
||||
|
||||
while let Some(mut parent) = to_flatten.pop() {
|
||||
if Self::bezier_is_flat_enough(&parent) {
|
||||
// * If the control points we currently operate on are sufficiently "flat", we use
|
||||
// * an extension to De Casteljau's algorithm to obtain a piecewise-linear approximation
|
||||
// * of the bezier curve represented by our control points, consisting of the same amount
|
||||
// * of points as there are control points.
|
||||
Self::bezier_approximate(&parent, path, left, right, midpoints);
|
||||
free_bufs.push(parent);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// * If we do not yet have a sufficiently "flat" (in other words, detailed) approximation we keep
|
||||
// * subdividing the curve we are currently operating on.
|
||||
let mut right_child = free_bufs
|
||||
.pop()
|
||||
.unwrap_or_else(|| Cow::Owned(vec![Pos2::zero(); p]));
|
||||
|
||||
Self::bezier_subdivide(&parent, left_child, right_child.to_mut(), midpoints);
|
||||
|
||||
// * We re-use the buffer of the parent for one of the children, so that we save one allocation per iteration.
|
||||
parent.to_mut().copy_from_slice(&left_child[..p]);
|
||||
|
||||
to_flatten.push(right_child);
|
||||
to_flatten.push(parent);
|
||||
}
|
||||
|
||||
path.push(points[p - 1]);
|
||||
}
|
||||
|
||||
fn bezier_is_flat_enough(points: &[Pos2]) -> bool {
|
||||
let limit = BEZIER_TOLERANCE * BEZIER_TOLERANCE * 4.0;
|
||||
|
||||
!points
|
||||
.iter()
|
||||
.zip(points.iter().skip(1))
|
||||
.zip(points.iter().skip(2))
|
||||
.any(|((&prev, &curr), &next)| (prev - curr * 2.0 + next).length_squared() > limit)
|
||||
}
|
||||
|
||||
fn bezier_subdivide(points: &[Pos2], l: &mut [Pos2], r: &mut [Pos2], midpoints: &mut [Pos2]) {
|
||||
let count = points.len();
|
||||
midpoints[..count].copy_from_slice(&points[..count]);
|
||||
|
||||
for i in (1..count).rev() {
|
||||
l[count - i - 1] = midpoints[0];
|
||||
r[i] = midpoints[i];
|
||||
|
||||
for j in 0..i {
|
||||
midpoints[j] = (midpoints[j] + midpoints[j + 1]) / 2.0;
|
||||
}
|
||||
}
|
||||
|
||||
l[count - 1] = midpoints[0];
|
||||
r[0] = midpoints[0];
|
||||
}
|
||||
|
||||
// * https://en.wikipedia.org/wiki/De_Casteljau%27s_algorithm
|
||||
fn bezier_approximate(
|
||||
points: &[Pos2],
|
||||
path: &mut Vec<Pos2>,
|
||||
l: &mut [Pos2],
|
||||
r: &mut [Pos2],
|
||||
midpoints: &mut [Pos2],
|
||||
) {
|
||||
let count = points.len();
|
||||
|
||||
Self::bezier_subdivide(points, l, r, midpoints);
|
||||
path.push(points[0]);
|
||||
|
||||
let l = &l[..count];
|
||||
let r = &r[1..count];
|
||||
|
||||
let subpath = l
|
||||
.iter()
|
||||
.chain(r)
|
||||
.skip(1)
|
||||
.zip(l.iter().chain(r).skip(2))
|
||||
.zip(l.iter().chain(r).skip(3))
|
||||
.step_by(2)
|
||||
.map(|((&prev, &curr), &next)| (prev + curr * 2.0 + next) * 0.25);
|
||||
|
||||
path.extend(subpath);
|
||||
}
|
||||
|
||||
fn catmull_subpath(path: &mut Vec<Pos2>, v1: Pos2, v2: Pos2, v3: Pos2, v4: Pos2) {
|
||||
let x1 = 2.0 * v2.x;
|
||||
let x2 = -v1.x + v3.x;
|
||||
let x3 = 2.0 * v1.x - 5.0 * v2.x + 4.0 * v3.x - v4.x;
|
||||
let x4 = -v1.x + 3.0 * (v2.x - v3.x) + v4.x;
|
||||
|
||||
let y1 = 2.0 * v2.y;
|
||||
let y2 = -v1.y + v3.y;
|
||||
let y3 = 2.0 * v1.y - 5.0 * v2.y + 4.0 * v3.y - v4.y;
|
||||
let y4 = -v1.y + 3.0 * (v2.y - v3.y) + v4.y;
|
||||
|
||||
let catmull_detail = CATMULL_DETAIL as f32;
|
||||
|
||||
let subpath = (0..CATMULL_DETAIL).flat_map(|c| {
|
||||
let c = c as f32;
|
||||
let t1 = c / catmull_detail;
|
||||
let t2 = t1 * t1;
|
||||
let t3 = t2 * t1;
|
||||
|
||||
let pos1 = Pos2 {
|
||||
x: 0.5 * (x1 + x2 * t1 + x3 * t2 + x4 * t3),
|
||||
y: 0.5 * (y1 + y2 * t1 + y3 * t2 + y4 * t3),
|
||||
};
|
||||
|
||||
let t1 = (c + 1.0) / catmull_detail;
|
||||
let t2 = t1 * t1;
|
||||
let t3 = t2 * t1;
|
||||
|
||||
let pos2 = Pos2 {
|
||||
x: 0.5 * (x1 + x2 * t1 + x3 * t2 + x4 * t3),
|
||||
y: 0.5 * (y1 + y2 * t1 + y3 * t2 + y4 * t3),
|
||||
};
|
||||
|
||||
iter::once(pos1).chain(iter::once(pos2))
|
||||
});
|
||||
|
||||
path.extend(subpath);
|
||||
}
|
||||
|
||||
fn circular_arc_properties(a: Pos2, b: Pos2, c: Pos2) -> Option<CircularArcProperties> {
|
||||
// * If we have a degenerate triangle where a side-length is almost zero,
|
||||
// * then give up and fallback to a more numerically stable method.
|
||||
if ((b.y - a.y) * (c.x - a.x) - (b.x - a.x) * (c.y - a.y)).abs() <= f32::EPSILON {
|
||||
return None;
|
||||
}
|
||||
|
||||
// * See: https://en.wikipedia.org/wiki/Circumscribed_circle#Cartesian_coordinates_2
|
||||
let d = 2.0 * (a.x * (b - c).y + b.x * (c - a).y + c.x * (a - b).y);
|
||||
let a_sq = a.length_squared();
|
||||
let b_sq = b.length_squared();
|
||||
let c_sq = c.length_squared();
|
||||
|
||||
let centre = Pos2 {
|
||||
x: (a_sq * (b - c).y + b_sq * (c - a).y + c_sq * (a - b).y) / d,
|
||||
y: (a_sq * (c - b).x + b_sq * (a - c).x + c_sq * (b - a).x) / d,
|
||||
};
|
||||
|
||||
let d_a = a - centre;
|
||||
let d_c = c - centre;
|
||||
|
||||
let radius = d_a.length();
|
||||
|
||||
let theta_start = (d_a.y as f64).atan2(d_a.x as f64);
|
||||
let mut theta_end = (d_c.y as f64).atan2(d_c.x as f64);
|
||||
|
||||
while theta_end < theta_start {
|
||||
theta_end += 2.0 * PI;
|
||||
}
|
||||
|
||||
let mut direction = 1.0;
|
||||
let mut theta_range = theta_end - theta_start;
|
||||
|
||||
// * Decide in which direction to draw the circle,
|
||||
// * depending on which side of AC B lies.
|
||||
let mut ortho_a_to_c = c - a;
|
||||
|
||||
ortho_a_to_c = Pos2 {
|
||||
x: ortho_a_to_c.y,
|
||||
y: -ortho_a_to_c.x,
|
||||
};
|
||||
|
||||
if ortho_a_to_c.dot(b - a) < 0.0 {
|
||||
direction = -direction;
|
||||
theta_range = 2.0 * PI - theta_range;
|
||||
}
|
||||
|
||||
Some(CircularArcProperties {
|
||||
theta_start,
|
||||
theta_range,
|
||||
direction,
|
||||
radius,
|
||||
centre,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
use super::OsuObject;
|
||||
|
||||
pub(crate) struct DifficultyObject<'h> {
|
||||
pub(crate) base: &'h OsuObject,
|
||||
pub(crate) prev: Option<(f32, f32)>, // (jump_dist, strain_time)
|
||||
|
||||
pub(crate) jump_dist: f32,
|
||||
pub(crate) travel_dist: f32,
|
||||
pub(crate) angle: Option<f32>,
|
||||
|
||||
pub(crate) delta: f32,
|
||||
pub(crate) strain_time: f32,
|
||||
}
|
||||
|
||||
impl<'h> DifficultyObject<'h> {
|
||||
pub(crate) fn new(
|
||||
base: &'h OsuObject,
|
||||
prev: &OsuObject,
|
||||
prev_vals: Option<(f32, f32)>, // (jump_dist, strain_time)
|
||||
prev_prev: Option<OsuObject>,
|
||||
clock_rate: f32,
|
||||
scaling_factor: f32,
|
||||
) -> Self {
|
||||
let delta = (base.time - prev.time) / clock_rate;
|
||||
let strain_time = delta.max(50.0);
|
||||
|
||||
let pos = base.pos;
|
||||
let travel_dist = prev.travel_dist.unwrap_or(0.0);
|
||||
let prev_cursor_pos = prev.end_pos;
|
||||
|
||||
let jump_dist = if base.is_spinner() {
|
||||
0.0
|
||||
} else {
|
||||
((pos - prev_cursor_pos) * scaling_factor).length()
|
||||
};
|
||||
|
||||
let angle = prev_prev.map(|prev_prev| {
|
||||
let prev_prev_cursor_pos = prev_prev.end_pos;
|
||||
|
||||
let v1 = prev_prev_cursor_pos - prev.pos;
|
||||
let v2 = pos - prev_cursor_pos;
|
||||
|
||||
let dot = v1.dot(v2);
|
||||
let det = v1.x * v2.y - v1.y * v2.x;
|
||||
|
||||
det.atan2(dot).abs()
|
||||
});
|
||||
|
||||
Self {
|
||||
base,
|
||||
prev: prev_vals,
|
||||
|
||||
jump_dist,
|
||||
travel_dist,
|
||||
angle,
|
||||
|
||||
delta,
|
||||
strain_time,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
mod curve;
|
||||
use curve::Curve;
|
||||
|
||||
mod difficulty_object;
|
||||
use difficulty_object::DifficultyObject;
|
||||
|
||||
mod osu_object;
|
||||
use osu_object::OsuObject;
|
||||
|
||||
mod pp;
|
||||
pub use pp::{OsuAttributeProvider, OsuPP};
|
||||
|
||||
mod skill;
|
||||
use skill::Skill;
|
||||
|
||||
mod skill_kind;
|
||||
use skill_kind::SkillKind;
|
||||
|
||||
mod stars;
|
||||
@@ -0,0 +1,175 @@
|
||||
use super::{curve::CurveBuffers, stars::OsuDifficultyAttributes, Curve};
|
||||
|
||||
use crate::{
|
||||
parse::{HitObject, HitObjectKind, Pos2},
|
||||
Beatmap,
|
||||
};
|
||||
|
||||
const LEGACY_LAST_TICK_OFFSET: f32 = 36.0;
|
||||
|
||||
pub(crate) struct OsuObject {
|
||||
pub(crate) time: f32,
|
||||
pub(crate) pos: Pos2,
|
||||
pub(crate) end_pos: Pos2,
|
||||
// circle: Some(0.0) | slider: Some(_) | spinner: None
|
||||
pub(crate) travel_dist: Option<f32>,
|
||||
}
|
||||
|
||||
impl OsuObject {
|
||||
pub(crate) fn new(
|
||||
h: &HitObject,
|
||||
map: &Beatmap,
|
||||
radius: f32,
|
||||
scaling_factor: f32,
|
||||
ticks: &mut Vec<f32>,
|
||||
attributes: &mut OsuDifficultyAttributes,
|
||||
curve_bufs: &mut CurveBuffers,
|
||||
) -> Option<Self> {
|
||||
attributes.max_combo += 1; // hitcircle, slider head, or spinner
|
||||
|
||||
let obj = match &h.kind {
|
||||
HitObjectKind::Circle => {
|
||||
attributes.n_circles += 1;
|
||||
|
||||
Self {
|
||||
time: h.start_time as f32,
|
||||
pos: h.pos,
|
||||
end_pos: h.pos,
|
||||
travel_dist: Some(0.0),
|
||||
}
|
||||
}
|
||||
HitObjectKind::Slider {
|
||||
pixel_len,
|
||||
repeats,
|
||||
control_points,
|
||||
..
|
||||
} => {
|
||||
let timing_point = map.timing_point_at(h.start_time);
|
||||
let difficulty_point = map.difficulty_point_at(h.start_time).unwrap_or_default();
|
||||
|
||||
// Key values which are computed here
|
||||
let mut end_pos = h.pos;
|
||||
let mut travel_dist = 0.0;
|
||||
|
||||
let approx_follow_circle_radius = radius * 3.0;
|
||||
let mut tick_distance = 100.0 * map.slider_mult as f32 / map.tick_rate as f32;
|
||||
|
||||
if map.version >= 8 {
|
||||
tick_distance /= (100.0 / difficulty_point.slider_vel as f32)
|
||||
.max(10.0)
|
||||
.min(1000.0)
|
||||
/ 100.0;
|
||||
}
|
||||
|
||||
// Build the curve w.r.t. the curve points
|
||||
let curve = Curve::new(control_points, *pixel_len, curve_bufs);
|
||||
|
||||
let pixel_len = pixel_len.unwrap_or(0.0) as f32;
|
||||
let duration = *repeats as f32 * timing_point.beat_len as f32 * pixel_len
|
||||
/ (map.slider_mult as f32 * difficulty_point.slider_vel as f32)
|
||||
/ 100.0;
|
||||
let span_duration = duration / *repeats as f32;
|
||||
|
||||
// Called on each slider object except for the head.
|
||||
// Increases combo and adjusts `end_pos` and `travel_dist`
|
||||
// w.r.t. the object position at the given time on the slider curve.
|
||||
let mut compute_vertex = |time: f32| {
|
||||
attributes.max_combo += 1;
|
||||
|
||||
let mut progress = (time - h.start_time as f32) / span_duration;
|
||||
|
||||
if progress % 2.0 >= 1.0 {
|
||||
progress = 1.0 - progress % 1.0;
|
||||
} else {
|
||||
progress %= 1.0;
|
||||
}
|
||||
|
||||
let curr_pos = h.pos + curve.position_at(progress as f64);
|
||||
|
||||
let diff = curr_pos - end_pos;
|
||||
let mut dist = diff.length();
|
||||
|
||||
if dist > approx_follow_circle_radius {
|
||||
dist -= approx_follow_circle_radius;
|
||||
end_pos += diff.normalize() * dist;
|
||||
travel_dist += dist;
|
||||
}
|
||||
};
|
||||
|
||||
let mut current_distance = tick_distance;
|
||||
let time_add = duration * (tick_distance / (pixel_len * *repeats as f32));
|
||||
|
||||
let target = pixel_len - tick_distance / 8.0;
|
||||
ticks.reserve((target / tick_distance) as usize);
|
||||
|
||||
// Tick of the first span
|
||||
if current_distance < target {
|
||||
for tick_idx in 1.. {
|
||||
let time = h.start_time as f32 + time_add * tick_idx as f32;
|
||||
compute_vertex(time);
|
||||
ticks.push(time);
|
||||
current_distance += tick_distance;
|
||||
|
||||
if current_distance >= target {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Other spans
|
||||
if *repeats > 1 {
|
||||
for repeat_id in 1..*repeats {
|
||||
let time_offset = (duration / *repeats as f32) * repeat_id as f32;
|
||||
|
||||
// Reverse tick
|
||||
compute_vertex(h.start_time as f32 + time_offset);
|
||||
|
||||
// Actual ticks
|
||||
if repeat_id & 1 == 1 {
|
||||
ticks.iter().rev().for_each(|&time| compute_vertex(time));
|
||||
} else {
|
||||
ticks.iter().for_each(|&time| compute_vertex(time));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Slider tail
|
||||
let final_span_idx = repeats.saturating_sub(1);
|
||||
let final_span_start_time =
|
||||
h.start_time as f32 + final_span_idx as f32 * span_duration;
|
||||
let final_span_end_time = (h.start_time as f32 + duration / 2.0)
|
||||
.max(final_span_start_time + span_duration - LEGACY_LAST_TICK_OFFSET);
|
||||
compute_vertex(final_span_end_time);
|
||||
|
||||
ticks.clear();
|
||||
|
||||
travel_dist *= scaling_factor;
|
||||
|
||||
Self {
|
||||
time: h.start_time as f32,
|
||||
pos: h.pos,
|
||||
end_pos,
|
||||
travel_dist: Some(travel_dist),
|
||||
}
|
||||
}
|
||||
HitObjectKind::Spinner { .. } => {
|
||||
attributes.n_spinners += 1;
|
||||
|
||||
Self {
|
||||
time: h.start_time as f32,
|
||||
pos: h.pos,
|
||||
end_pos: h.pos,
|
||||
travel_dist: None,
|
||||
}
|
||||
}
|
||||
HitObjectKind::Hold { .. } => return None,
|
||||
};
|
||||
|
||||
Some(obj)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn is_spinner(&self) -> bool {
|
||||
self.travel_dist.is_none()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,685 @@
|
||||
use super::stars::{stars, OsuDifficultyAttributes, OsuPerformanceAttributes};
|
||||
use crate::{Beatmap, Mods};
|
||||
|
||||
/// Calculator for pp on osu!standard maps.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// # use rosu_pp::{OsuPP, Beatmap};
|
||||
/// # /*
|
||||
/// let map: Beatmap = ...
|
||||
/// # */
|
||||
/// # let map = Beatmap::default();
|
||||
/// let attrs = OsuPP::new(&map)
|
||||
/// .mods(8 + 64) // HDDT
|
||||
/// .combo(1234)
|
||||
/// .misses(1)
|
||||
/// .accuracy(98.5) // should be set last
|
||||
/// .calculate();
|
||||
///
|
||||
/// println!("PP: {} | Stars: {}", attrs.pp(), attrs.stars());
|
||||
///
|
||||
/// let next_result = OsuPP::new(&map)
|
||||
/// .attributes(attrs) // reusing previous results for performance
|
||||
/// .mods(8 + 64) // has to be the same to reuse attributes
|
||||
/// .accuracy(99.5)
|
||||
/// .calculate();
|
||||
///
|
||||
/// println!("PP: {} | Stars: {}", next_result.pp(), next_result.stars());
|
||||
/// ```
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct OsuPP<'m> {
|
||||
map: &'m Beatmap,
|
||||
attributes: Option<OsuDifficultyAttributes>,
|
||||
mods: u32,
|
||||
combo: Option<usize>,
|
||||
acc: Option<f32>,
|
||||
|
||||
n300: Option<usize>,
|
||||
n100: Option<usize>,
|
||||
n50: Option<usize>,
|
||||
n_misses: usize,
|
||||
passed_objects: Option<usize>,
|
||||
}
|
||||
|
||||
impl<'m> OsuPP<'m> {
|
||||
/// Creates a new calculator for the given map.
|
||||
#[inline]
|
||||
pub fn new(map: &'m Beatmap) -> Self {
|
||||
Self {
|
||||
map,
|
||||
attributes: None,
|
||||
mods: 0,
|
||||
combo: None,
|
||||
acc: None,
|
||||
|
||||
n300: None,
|
||||
n100: None,
|
||||
n50: None,
|
||||
n_misses: 0,
|
||||
passed_objects: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// [`OsuAttributeProvider`] is implemented by [`DifficultyAttributes`](crate::osu::DifficultyAttributes)
|
||||
/// and by [`PpResult`](crate::PpResult) meaning you can give the
|
||||
/// result of a star calculation or a pp calculation.
|
||||
/// If you already calculated the attributes for the current map-mod combination,
|
||||
/// be sure to put them in here so that they don't have to be recalculated.
|
||||
#[inline]
|
||||
pub fn attributes(mut self, attributes: impl OsuAttributeProvider) -> Self {
|
||||
if let Some(attributes) = attributes.attributes() {
|
||||
self.attributes.replace(attributes);
|
||||
}
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
/// Specify mods through their bit values.
|
||||
///
|
||||
/// See [https://github.com/ppy/osu-api/wiki#mods](https://github.com/ppy/osu-api/wiki#mods)
|
||||
#[inline]
|
||||
pub fn mods(mut self, mods: u32) -> Self {
|
||||
self.mods = mods;
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
/// Specify the max combo of the play.
|
||||
#[inline]
|
||||
pub fn combo(mut self, combo: usize) -> Self {
|
||||
self.combo.replace(combo);
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
/// Specify the amount of 300s of a play.
|
||||
#[inline]
|
||||
pub fn n300(mut self, n300: usize) -> Self {
|
||||
self.n300.replace(n300);
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
/// Specify the amount of 100s of a play.
|
||||
#[inline]
|
||||
pub fn n100(mut self, n100: usize) -> Self {
|
||||
self.n100.replace(n100);
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
/// Specify the amount of 50s of a play.
|
||||
#[inline]
|
||||
pub fn n50(mut self, n50: usize) -> Self {
|
||||
self.n50.replace(n50);
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
/// Specify the amount of misses of a play.
|
||||
#[inline]
|
||||
pub fn misses(mut self, n_misses: usize) -> Self {
|
||||
self.n_misses = n_misses;
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
/// Amount of passed objects for partial plays, e.g. a fail.
|
||||
#[inline]
|
||||
pub fn passed_objects(mut self, passed_objects: usize) -> Self {
|
||||
self.passed_objects.replace(passed_objects);
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
/// Generate the hit results with respect to the given accuracy between `0` and `100`.
|
||||
///
|
||||
/// Be sure to set `misses` beforehand!
|
||||
/// In case of a partial play, be also sure to set `passed_objects` beforehand!
|
||||
pub fn accuracy(mut self, acc: f32) -> Self {
|
||||
let n_objects = self.passed_objects.unwrap_or(self.map.hit_objects.len());
|
||||
|
||||
let acc = acc / 100.0;
|
||||
|
||||
if self.n100.or(self.n50).is_some() {
|
||||
let mut n100 = self.n100.unwrap_or(0);
|
||||
let mut n50 = self.n50.unwrap_or(0);
|
||||
|
||||
let placed_points = 2 * n100 + n50 + self.n_misses;
|
||||
let missing_objects = n_objects - n100 - n50 - self.n_misses;
|
||||
let missing_points =
|
||||
((6.0 * acc * n_objects as f32).round() as usize).saturating_sub(placed_points);
|
||||
|
||||
let mut n300 = missing_objects.min(missing_points / 6);
|
||||
n50 += missing_objects - n300;
|
||||
|
||||
if let Some(orig_n50) = self.n50.filter(|_| self.n100.is_none()) {
|
||||
// Only n50s were changed, try to load some off again onto n100s
|
||||
let difference = n50 - orig_n50;
|
||||
let n = n300.min(difference / 4);
|
||||
|
||||
n300 -= n;
|
||||
n100 += 5 * n;
|
||||
n50 -= 4 * n;
|
||||
}
|
||||
|
||||
self.n300.replace(n300);
|
||||
self.n100.replace(n100);
|
||||
self.n50.replace(n50);
|
||||
} else {
|
||||
let misses = self.n_misses.min(n_objects);
|
||||
let target_total = (acc * n_objects as f32 * 6.0).round() as usize;
|
||||
let delta = target_total - (n_objects - misses);
|
||||
|
||||
let mut n300 = delta / 5;
|
||||
let mut n100 = delta % 5;
|
||||
let mut n50 = n_objects - n300 - n100 - misses;
|
||||
|
||||
// Sacrifice n300s to transform n50s into n100s
|
||||
let n = n300.min(n50 / 4);
|
||||
n300 -= n;
|
||||
n100 += 5 * n;
|
||||
n50 -= 4 * n;
|
||||
|
||||
self.n300.replace(n300);
|
||||
self.n100.replace(n100);
|
||||
self.n50.replace(n50);
|
||||
}
|
||||
|
||||
let acc = (6 * self.n300.unwrap() + 2 * self.n100.unwrap() + self.n50.unwrap()) as f32
|
||||
/ (6 * n_objects) as f32;
|
||||
|
||||
self.acc.replace(acc);
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
fn assert_hitresults(&mut self) {
|
||||
if self.acc.is_none() {
|
||||
let n_objects = self.passed_objects.unwrap_or(self.map.hit_objects.len());
|
||||
|
||||
let remaining = n_objects
|
||||
.saturating_sub(self.n300.unwrap_or(0))
|
||||
.saturating_sub(self.n100.unwrap_or(0))
|
||||
.saturating_sub(self.n50.unwrap_or(0))
|
||||
.saturating_sub(self.n_misses);
|
||||
|
||||
if remaining > 0 {
|
||||
if self.n300.is_none() {
|
||||
self.n300.replace(remaining);
|
||||
self.n100.get_or_insert(0);
|
||||
self.n50.get_or_insert(0);
|
||||
} else if self.n100.is_none() {
|
||||
self.n100.replace(remaining);
|
||||
self.n50.get_or_insert(0);
|
||||
} else if self.n50.is_none() {
|
||||
self.n50.replace(remaining);
|
||||
} else {
|
||||
*self.n300.as_mut().unwrap() += remaining;
|
||||
}
|
||||
} else {
|
||||
self.n300.get_or_insert(0);
|
||||
self.n100.get_or_insert(0);
|
||||
self.n50.get_or_insert(0);
|
||||
}
|
||||
|
||||
let numerator = self.n50.unwrap() + self.n100.unwrap() * 2 + self.n300.unwrap() * 6;
|
||||
self.acc.replace(numerator as f32 / n_objects as f32 / 6.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns an object which contains the pp and [`DifficultyAttributes`](crate::osu::DifficultyAttributes)
|
||||
/// containing stars and other attributes.
|
||||
pub fn calculate(mut self) -> OsuPerformanceAttributes {
|
||||
if self.attributes.is_none() {
|
||||
let attributes = stars(self.map, self.mods, self.passed_objects);
|
||||
self.attributes.replace(attributes);
|
||||
}
|
||||
|
||||
// Make sure the hitresults and accuracy are set
|
||||
self.assert_hitresults();
|
||||
|
||||
let total_hits = self.total_hits() as f32;
|
||||
let mut multiplier = 1.12;
|
||||
|
||||
let effective_miss_count = self.calculate_effective_miss_count();
|
||||
|
||||
// NF penalty
|
||||
if self.mods.nf() {
|
||||
multiplier *= 0.9_f32.max(1.0 - 0.2 * effective_miss_count);
|
||||
}
|
||||
|
||||
// SO penalty
|
||||
if self.mods.so() {
|
||||
multiplier *=
|
||||
1.0 - (self.attributes.as_ref().unwrap().n_spinners as f32 / total_hits).powf(0.85);
|
||||
}
|
||||
|
||||
let mut aim_value = self.compute_aim_value(total_hits, effective_miss_count);
|
||||
let speed_value = self.compute_speed_value(total_hits, effective_miss_count);
|
||||
let acc_value = self.compute_accuracy_value(total_hits);
|
||||
|
||||
let mut acc_depression = 1.0;
|
||||
if self.mods.rx() {
|
||||
multiplier *= 0.97;
|
||||
|
||||
let streams_nerf = aim_value / speed_value;
|
||||
|
||||
if streams_nerf < 1.0 {
|
||||
let acc_factor = (1.0 - self.acc.unwrap()).abs();
|
||||
acc_depression = (0.84 - acc_factor).max(0.5);
|
||||
|
||||
if acc_depression > 0.0 {
|
||||
aim_value *= acc_depression;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let nodt_bonus = match !self.mods.change_speed() && self.mods.rx() {
|
||||
true => 1.01,
|
||||
false => 1.0,
|
||||
};
|
||||
|
||||
let speed_factor = match self.mods.rx() {
|
||||
true => speed_value.powf(0.83 * acc_depression),
|
||||
false => speed_value.powf(1.1),
|
||||
};
|
||||
|
||||
let aim_factor = match self.mods.rx() {
|
||||
true => aim_value.powf(1.185 * nodt_bonus),
|
||||
false => aim_value.powf(1.1),
|
||||
};
|
||||
|
||||
let acc_factor = match self.mods.rx() {
|
||||
true => acc_value.powf(1.14 * nodt_bonus),
|
||||
false => acc_value.powf(1.1),
|
||||
};
|
||||
|
||||
let mut pp = (aim_factor + speed_factor + acc_factor).powf(1.0 / 1.1) * multiplier;
|
||||
|
||||
if self.mods.rx() {
|
||||
if self.mods.dt() && self.mods.hr() {
|
||||
pp *= 1.025;
|
||||
}
|
||||
|
||||
if self.map.creator == "ParkourWizard" {
|
||||
pp *= 0.9;
|
||||
}
|
||||
|
||||
pp *= match self.map.beatmap_id {
|
||||
// Louder than steel [ok this is epic]
|
||||
1808605 => 0.85,
|
||||
|
||||
// over the top [Above the stars]
|
||||
1821147 => 0.70,
|
||||
|
||||
// Just press F [Parkour's ok this is epic]
|
||||
1844776 => 0.64,
|
||||
|
||||
// Hardawre Store [skyapple mode]
|
||||
1777768 => 0.90,
|
||||
|
||||
// HONESTY [RIGHTEOUSNESS OF MORALITY]
|
||||
2079597 => 0.90,
|
||||
|
||||
// Akatsuki compilation [ok this is akatsuki]
|
||||
1962833 => {
|
||||
pp *= 0.885;
|
||||
|
||||
if self.mods.dt() {
|
||||
0.83
|
||||
} else {
|
||||
1.0
|
||||
}
|
||||
}
|
||||
|
||||
// Songs Compilation [Marathon]
|
||||
2403677 => 0.85,
|
||||
|
||||
_ => 1.0,
|
||||
}
|
||||
}
|
||||
|
||||
OsuPerformanceAttributes {
|
||||
difficulty: self.attributes.unwrap(),
|
||||
pp_acc: acc_value as f64,
|
||||
pp_aim: aim_value as f64,
|
||||
pp_flashlight: 0.0,
|
||||
pp_speed: speed_value as f64,
|
||||
pp: pp as f64,
|
||||
}
|
||||
}
|
||||
|
||||
fn compute_aim_value(&self, total_hits: f32, effective_miss_count: f32) -> f32 {
|
||||
let attributes = self.attributes.as_ref().unwrap();
|
||||
|
||||
// TD penalty
|
||||
let raw_aim = if self.mods.td() {
|
||||
attributes.aim_strain.powf(0.8) as f32
|
||||
} else {
|
||||
attributes.aim_strain as f32
|
||||
};
|
||||
|
||||
let mut aim_value = (5.0 * (raw_aim / 0.0675).max(1.0) - 4.0).powi(3) / 100_000.0;
|
||||
|
||||
// Longer maps are worth more
|
||||
let bonus_factor = if self.mods.rx() { 0.88 } else { 0.95 };
|
||||
let len_bonus = bonus_factor
|
||||
+ 0.4 * (total_hits / 2000.0).min(1.0)
|
||||
+ (total_hits > 2000.0) as u8 as f32 * 0.5 * (total_hits / 2000.0).log10();
|
||||
aim_value *= len_bonus;
|
||||
|
||||
// Penalize misses
|
||||
if effective_miss_count > 0.0 {
|
||||
aim_value *= self.calculate_miss_penalty(
|
||||
attributes.aim_difficult_strain_count as f32,
|
||||
effective_miss_count,
|
||||
);
|
||||
}
|
||||
|
||||
// AR bonus
|
||||
let mut ar_factor = if attributes.ar > 10.33 {
|
||||
0.3 * (attributes.ar - 10.33)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
if attributes.ar < 8.0 {
|
||||
ar_factor = 0.025 * (8.0 - attributes.ar);
|
||||
}
|
||||
|
||||
aim_value *= 1.0 + ar_factor as f32 * len_bonus;
|
||||
|
||||
// HD bonus
|
||||
if self.mods.hd() {
|
||||
aim_value *= match self.mods.rx() {
|
||||
true => 1.0 + 0.05 * (11.0 - attributes.ar) as f32,
|
||||
false => 1.0 + 0.04 * (12.0 - attributes.ar) as f32,
|
||||
}
|
||||
}
|
||||
|
||||
// FL bonus
|
||||
if self.mods.fl() {
|
||||
let first_factor = match self.mods.rx() {
|
||||
true => 0.3,
|
||||
false => 0.35,
|
||||
};
|
||||
|
||||
let second_factor = match self.mods.rx() {
|
||||
true => 0.25,
|
||||
false => 0.3,
|
||||
};
|
||||
|
||||
let third_factor = match self.mods.rx() {
|
||||
true => 1600.0,
|
||||
false => 1200.0,
|
||||
};
|
||||
|
||||
aim_value *= 1.0
|
||||
+ first_factor * (total_hits / 200.0).min(1.0)
|
||||
+ (total_hits > 200.0) as u8 as f32
|
||||
* second_factor
|
||||
* ((total_hits - 200.0) / 300.0).min(1.0)
|
||||
+ (total_hits > 500.0) as u8 as f32 * (total_hits - 500.0) / third_factor;
|
||||
}
|
||||
|
||||
// EZ bonus
|
||||
if self.mods.ez() {
|
||||
let mut base_buff = 1.08_f32;
|
||||
|
||||
if attributes.ar <= 8.0 {
|
||||
base_buff += (7.0 - attributes.ar as f32) / 100.0;
|
||||
}
|
||||
|
||||
aim_value *= base_buff;
|
||||
}
|
||||
|
||||
// Scale with accuracy
|
||||
let acc_factor = if self.mods.rx() { 0.3 } else { 0.5 };
|
||||
aim_value *= acc_factor + self.acc.unwrap() / 2.0;
|
||||
aim_value *= 0.98 + attributes.od as f32 * attributes.od as f32 / 2500.0;
|
||||
|
||||
aim_value
|
||||
}
|
||||
|
||||
fn compute_speed_value(&self, total_hits: f32, effective_miss_count: f32) -> f32 {
|
||||
let attributes = self.attributes.as_ref().unwrap();
|
||||
|
||||
let mut speed_value =
|
||||
(5.0 * (attributes.speed_strain as f32 / 0.0675).max(1.0) - 4.0).powi(3) / 100_000.0;
|
||||
|
||||
// Longer maps are worth more
|
||||
let bonus_factor = if self.mods.rx() { 0.88 } else { 0.95 };
|
||||
let len_bonus = bonus_factor
|
||||
+ 0.4 * (total_hits / 2000.0).min(1.0)
|
||||
+ (total_hits > 2000.0) as u8 as f32 * 0.5 * (total_hits / 2000.0).log10();
|
||||
speed_value *= len_bonus;
|
||||
|
||||
// Penalize misses
|
||||
if effective_miss_count > 0.0 {
|
||||
let mut strain_count = attributes.speed_difficult_strain_count as f32;
|
||||
if self.mods.rx() {
|
||||
strain_count *= 0.5;
|
||||
}
|
||||
|
||||
speed_value *= self.calculate_miss_penalty(strain_count, effective_miss_count);
|
||||
}
|
||||
|
||||
// AR bonus
|
||||
if attributes.ar > 10.33 {
|
||||
let mut ar_factor = if attributes.ar > 10.33 {
|
||||
0.3 * (attributes.ar - 10.33)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
if attributes.ar < 8.0 {
|
||||
ar_factor = 0.025 * (8.0 - attributes.ar);
|
||||
}
|
||||
|
||||
speed_value *= 1.0 + ar_factor as f32 * len_bonus;
|
||||
}
|
||||
|
||||
// HD bonus
|
||||
if self.mods.hd() {
|
||||
speed_value *= match self.mods.rx() {
|
||||
true => 1.0 + 0.05 * (11.0 - attributes.ar) as f32,
|
||||
false => 1.0 + 0.04 * (12.0 - attributes.ar) as f32,
|
||||
}
|
||||
}
|
||||
|
||||
// Scaling the speed value with accuracy and OD
|
||||
let acc_factor = if self.mods.rx() { 0.93 } else { 0.95 };
|
||||
speed_value *= (acc_factor + attributes.od as f32 * attributes.od as f32 / 750.0)
|
||||
* self
|
||||
.acc
|
||||
.unwrap()
|
||||
.powf((14.5 - attributes.od.max(8.0) as f32) / 2.0);
|
||||
|
||||
speed_value *= 0.98_f32.powf(match (self.n50.unwrap() as f32) < total_hits / 500.0 {
|
||||
true => 0.0,
|
||||
false => self.n50.unwrap() as f32 - total_hits / 500.0,
|
||||
});
|
||||
|
||||
speed_value
|
||||
}
|
||||
|
||||
fn compute_accuracy_value(&self, total_hits: f32) -> f32 {
|
||||
let attributes = self.attributes.as_ref().unwrap();
|
||||
let n_circles = attributes.n_circles as f32;
|
||||
let n300 = self.n300.unwrap_or(0) as f32;
|
||||
let n100 = self.n100.unwrap_or(0) as f32;
|
||||
let n50 = self.n50.unwrap_or(0) as f32;
|
||||
|
||||
let better_acc_percentage = (n_circles > 0.0) as u8 as f32
|
||||
* (((n300 - (total_hits - n_circles)) * 6.0 + n100 * 2.0 + n50) / (n_circles * 6.0))
|
||||
.max(0.0);
|
||||
|
||||
let mut acc_value =
|
||||
1.52163_f32.powf(attributes.od as f32) * better_acc_percentage.powi(24) * 2.83;
|
||||
|
||||
// Bonus for many hitcircles
|
||||
acc_value *= ((n_circles as f32 / 1000.0).powf(0.3)).min(1.15);
|
||||
|
||||
// HD bonus
|
||||
if self.mods.hd() {
|
||||
acc_value *= 1.08;
|
||||
}
|
||||
|
||||
// FL bonus
|
||||
if self.mods.fl() {
|
||||
acc_value *= 1.02;
|
||||
}
|
||||
|
||||
acc_value
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn total_hits(&self) -> usize {
|
||||
let n_objects = self.passed_objects.unwrap_or(self.map.hit_objects.len());
|
||||
|
||||
(self.n300.unwrap_or(0) + self.n100.unwrap_or(0) + self.n50.unwrap_or(0) + self.n_misses)
|
||||
.min(n_objects)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn calculate_miss_penalty(&self, strain_count: f32, effective_miss_count: f32) -> f32 {
|
||||
0.95 / ((effective_miss_count / (2.0 * strain_count.sqrt())) + 1.0)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn calculate_effective_miss_count(&self) -> f32 {
|
||||
let mut combo_based_miss_count = 0.0;
|
||||
|
||||
let attributes = self.attributes.as_ref().unwrap();
|
||||
let combo = self.combo.unwrap() as f32;
|
||||
let n100 = self.n100.unwrap_or(0) as f32;
|
||||
let n50 = self.n50.unwrap_or(0) as f32;
|
||||
|
||||
if attributes.n_sliders > 0 {
|
||||
let fc_threshold = attributes.max_combo as f32 - 0.1 * attributes.n_sliders as f32;
|
||||
if combo < fc_threshold {
|
||||
combo_based_miss_count = fc_threshold / combo.max(1.0);
|
||||
}
|
||||
}
|
||||
|
||||
combo_based_miss_count = combo_based_miss_count.min(n100 + n50 + self.n_misses as f32);
|
||||
combo_based_miss_count.max(self.n_misses as f32)
|
||||
}
|
||||
}
|
||||
|
||||
/// Provides attributes for an osu! beatmap.
|
||||
pub trait OsuAttributeProvider {
|
||||
/// Returns the attributes of the map.
|
||||
fn attributes(self) -> Option<OsuDifficultyAttributes>;
|
||||
}
|
||||
|
||||
impl OsuAttributeProvider for OsuDifficultyAttributes {
|
||||
#[inline]
|
||||
fn attributes(self) -> Option<OsuDifficultyAttributes> {
|
||||
Some(self)
|
||||
}
|
||||
}
|
||||
|
||||
impl OsuAttributeProvider for OsuPerformanceAttributes {
|
||||
#[inline]
|
||||
fn attributes(self) -> Option<OsuDifficultyAttributes> {
|
||||
Some(self.difficulty)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::*;
|
||||
use crate::Beatmap;
|
||||
|
||||
#[test]
|
||||
fn osu_only_accuracy() {
|
||||
let map = Beatmap::default();
|
||||
|
||||
let total_objects = 1234;
|
||||
let target_acc = 97.5;
|
||||
|
||||
let calculator = OsuPP::new(&map)
|
||||
.passed_objects(total_objects)
|
||||
.accuracy(target_acc);
|
||||
|
||||
let numerator = 6 * calculator.n300.unwrap_or(0)
|
||||
+ 2 * calculator.n100.unwrap_or(0)
|
||||
+ calculator.n50.unwrap_or(0);
|
||||
let denominator = 6 * total_objects;
|
||||
let acc = 100.0 * numerator as f32 / denominator as f32;
|
||||
|
||||
assert!(
|
||||
(target_acc - acc).abs() < 1.0,
|
||||
"Expected: {} | Actual: {}",
|
||||
target_acc,
|
||||
acc
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osu_accuracy_and_n50() {
|
||||
let map = Beatmap::default();
|
||||
|
||||
let total_objects = 1234;
|
||||
let target_acc = 97.5;
|
||||
let n50 = 30;
|
||||
|
||||
let calculator = OsuPP::new(&map)
|
||||
.passed_objects(total_objects)
|
||||
.n50(n50)
|
||||
.accuracy(target_acc);
|
||||
|
||||
assert!(
|
||||
(calculator.n50.unwrap() as i32 - n50 as i32).abs() <= 4,
|
||||
"Expected: {} | Actual: {}",
|
||||
n50,
|
||||
calculator.n50.unwrap()
|
||||
);
|
||||
|
||||
let numerator = 6 * calculator.n300.unwrap_or(0)
|
||||
+ 2 * calculator.n100.unwrap_or(0)
|
||||
+ calculator.n50.unwrap_or(0);
|
||||
let denominator = 6 * total_objects;
|
||||
let acc = 100.0 * numerator as f32 / denominator as f32;
|
||||
|
||||
assert!(
|
||||
(target_acc - acc).abs() < 1.0,
|
||||
"Expected: {} | Actual: {}",
|
||||
target_acc,
|
||||
acc
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osu_missing_objects() {
|
||||
let map = Beatmap::default();
|
||||
|
||||
let total_objects = 1234;
|
||||
let n300 = 1000;
|
||||
let n100 = 200;
|
||||
let n50 = 30;
|
||||
|
||||
let mut calculator = OsuPP::new(&map)
|
||||
.passed_objects(total_objects)
|
||||
.n300(n300)
|
||||
.n100(n100)
|
||||
.n50(n50);
|
||||
|
||||
calculator.assert_hitresults();
|
||||
|
||||
let n_objects = calculator.n300.unwrap()
|
||||
+ calculator.n100.unwrap()
|
||||
+ calculator.n50.unwrap()
|
||||
+ calculator.n_misses;
|
||||
|
||||
assert_eq!(
|
||||
total_objects, n_objects,
|
||||
"Expected: {} | Actual: {}",
|
||||
total_objects, n_objects
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
use super::{DifficultyObject, SkillKind};
|
||||
|
||||
use std::cmp::Ordering;
|
||||
|
||||
const SPEED_SKILL_MULTIPLIER: f32 = 1400.0;
|
||||
const SPEED_STRAIN_DECAY_BASE: f32 = 0.3;
|
||||
|
||||
const AIM_SKILL_MULTIPLIER: f32 = 26.25;
|
||||
const AIM_STRAIN_DECAY_BASE: f32 = 0.15;
|
||||
|
||||
const DECAY_WEIGHT: f32 = 0.9;
|
||||
|
||||
pub(crate) struct Skill {
|
||||
current_strain: f32,
|
||||
current_section_peak: f32,
|
||||
|
||||
kind: SkillKind,
|
||||
pub(crate) strain_peaks: Vec<f32>,
|
||||
|
||||
prev_time: Option<f32>,
|
||||
pub(crate) object_strains: Vec<f32>,
|
||||
}
|
||||
|
||||
impl Skill {
|
||||
#[inline]
|
||||
pub(crate) fn new(kind: SkillKind) -> Self {
|
||||
Self {
|
||||
current_strain: 1.0,
|
||||
current_section_peak: 1.0,
|
||||
|
||||
kind,
|
||||
strain_peaks: Vec::with_capacity(128),
|
||||
|
||||
prev_time: None,
|
||||
object_strains: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn save_current_peak(&mut self) {
|
||||
self.strain_peaks.push(self.current_section_peak);
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn start_new_section_from(&mut self, time: f32) {
|
||||
self.current_section_peak = self.peak_strain(time - self.prev_time.unwrap());
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn process(&mut self, current: &DifficultyObject<'_>) {
|
||||
self.current_strain *= self.strain_decay(current.delta);
|
||||
self.current_strain += self.kind.strain_value_of(current) * self.skill_multiplier();
|
||||
|
||||
self.object_strains.push(self.current_strain);
|
||||
|
||||
self.current_section_peak = self.current_section_peak.max(self.current_strain);
|
||||
self.prev_time.replace(current.base.time);
|
||||
}
|
||||
|
||||
pub(crate) fn difficulty_value(&mut self) -> f32 {
|
||||
let mut difficulty = 0.0;
|
||||
let mut weight = 1.0;
|
||||
|
||||
self.strain_peaks
|
||||
.sort_unstable_by(|a, b| b.partial_cmp(a).unwrap_or(Ordering::Equal));
|
||||
|
||||
for &strain in self.strain_peaks.iter() {
|
||||
difficulty += strain * weight;
|
||||
weight *= DECAY_WEIGHT;
|
||||
}
|
||||
|
||||
difficulty
|
||||
}
|
||||
|
||||
pub(crate) fn count_difficult_strains(&mut self) -> f64 {
|
||||
let top_strain = self
|
||||
.object_strains
|
||||
.iter()
|
||||
.fold(f64::NEG_INFINITY, |prev, curr| prev.max(*curr as f64));
|
||||
|
||||
self.object_strains
|
||||
.iter()
|
||||
.map(|strain| (strain / top_strain as f32).powi(4))
|
||||
.sum::<f32>() as f64
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn skill_multiplier(&self) -> f32 {
|
||||
match self.kind {
|
||||
SkillKind::Aim => AIM_SKILL_MULTIPLIER,
|
||||
SkillKind::Speed => SPEED_SKILL_MULTIPLIER,
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn strain_decay_base(&self) -> f32 {
|
||||
match self.kind {
|
||||
SkillKind::Aim => AIM_STRAIN_DECAY_BASE,
|
||||
SkillKind::Speed => SPEED_STRAIN_DECAY_BASE,
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn peak_strain(&self, delta_time: f32) -> f32 {
|
||||
self.current_strain * self.strain_decay(delta_time)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn strain_decay(&self, ms: f32) -> f32 {
|
||||
self.strain_decay_base().powf(ms / 1000.0)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
use super::DifficultyObject;
|
||||
|
||||
const SINGLE_SPACING_TRESHOLD: f32 = 125.0;
|
||||
const SPEED_ANGLE_BONUS_BEGIN: f32 = 5.0 * std::f32::consts::FRAC_PI_6;
|
||||
const PI_OVER_4: f32 = std::f32::consts::FRAC_PI_4;
|
||||
const PI_OVER_2: f32 = std::f32::consts::FRAC_PI_2;
|
||||
|
||||
const MIN_SPEED_BONUS: f32 = 75.0;
|
||||
const MAX_SPEED_BONUS: f32 = 45.0;
|
||||
const SPEED_BALANCING_FACTOR: f32 = 40.0;
|
||||
|
||||
const AIM_ANGLE_BONUS_BEGIN: f32 = std::f32::consts::FRAC_PI_3;
|
||||
const TIMING_THRESHOLD: f32 = 107.0;
|
||||
|
||||
#[derive(Copy, Clone)]
|
||||
pub(crate) enum SkillKind {
|
||||
Aim,
|
||||
Speed,
|
||||
}
|
||||
|
||||
impl SkillKind {
|
||||
pub(crate) fn strain_value_of(self, current: &DifficultyObject<'_>) -> f32 {
|
||||
match self {
|
||||
Self::Aim => {
|
||||
if current.base.is_spinner() {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
let mut result = 0.0;
|
||||
|
||||
if let Some((prev_jump_dist, prev_strain_time)) = current.prev {
|
||||
if let Some(angle) = current.angle.filter(|a| *a > AIM_ANGLE_BONUS_BEGIN) {
|
||||
let scale = 90.0;
|
||||
|
||||
let angle_bonus = (((angle - AIM_ANGLE_BONUS_BEGIN).sin()).powi(2)
|
||||
* (prev_jump_dist - scale).max(0.0)
|
||||
* (current.jump_dist - scale).max(0.0))
|
||||
.sqrt();
|
||||
|
||||
result = 1.5 * apply_diminishing_exp(angle_bonus.max(0.0))
|
||||
/ (TIMING_THRESHOLD).max(prev_strain_time)
|
||||
}
|
||||
}
|
||||
|
||||
let jump_dist_exp = apply_diminishing_exp(current.jump_dist);
|
||||
let travel_dist_exp = apply_diminishing_exp(current.travel_dist);
|
||||
|
||||
let dist_exp =
|
||||
jump_dist_exp + travel_dist_exp + (travel_dist_exp * jump_dist_exp).sqrt();
|
||||
|
||||
(result + dist_exp / (current.strain_time).max(TIMING_THRESHOLD))
|
||||
.max(dist_exp / current.strain_time)
|
||||
}
|
||||
Self::Speed => {
|
||||
if current.base.is_spinner() {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
let dist = SINGLE_SPACING_TRESHOLD.min(current.travel_dist + current.jump_dist);
|
||||
let delta_time = MAX_SPEED_BONUS.max(current.delta);
|
||||
|
||||
let mut speed_bonus = 1.0;
|
||||
|
||||
if delta_time < MIN_SPEED_BONUS {
|
||||
let exp_base = (MIN_SPEED_BONUS - delta_time) / SPEED_BALANCING_FACTOR;
|
||||
speed_bonus += exp_base * exp_base;
|
||||
}
|
||||
|
||||
let mut angle_bonus = 1.0;
|
||||
|
||||
if let Some(angle) = current.angle.filter(|a| *a < SPEED_ANGLE_BONUS_BEGIN) {
|
||||
let exp_base = (1.5 * (SPEED_ANGLE_BONUS_BEGIN - angle)).sin();
|
||||
angle_bonus = 1.0 + exp_base * exp_base / 3.57;
|
||||
|
||||
if angle < PI_OVER_2 {
|
||||
angle_bonus = 1.28;
|
||||
|
||||
if dist < 90.0 && angle < PI_OVER_4 {
|
||||
angle_bonus += (1.0 - angle_bonus) * ((90.0 - dist) / 10.0).min(1.0);
|
||||
} else if dist < 90.0 {
|
||||
angle_bonus += (1.0 - angle_bonus)
|
||||
* ((90.0 - dist) / 10.0).min(1.0)
|
||||
* ((PI_OVER_2 - angle) / PI_OVER_4).sin();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(1.0 + (speed_bonus - 1.0) * 0.75)
|
||||
* angle_bonus
|
||||
* (0.95 + speed_bonus * (dist / SINGLE_SPACING_TRESHOLD).powf(3.5))
|
||||
/ current.strain_time
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn apply_diminishing_exp(val: f32) -> f32 {
|
||||
val.powf(0.99)
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
//! The positional offset of notes created by stack leniency is not considered.
|
||||
//! This means the jump distance inbetween notes might be slightly off, resulting in small inaccuracies.
|
||||
//! Since calculating these offsets is relatively expensive though, this version is faster than `all_included`.
|
||||
|
||||
use super::{curve::CurveBuffers, DifficultyObject, OsuObject, Skill, SkillKind};
|
||||
|
||||
use crate::Beatmap;
|
||||
|
||||
const OBJECT_RADIUS: f32 = 64.0;
|
||||
const SECTION_LEN: f32 = 400.0;
|
||||
const DIFFICULTY_MULTIPLIER: f32 = 0.0675;
|
||||
const NORMALIZED_RADIUS: f32 = 52.0;
|
||||
|
||||
/// Star calculation for osu!standard maps.
|
||||
///
|
||||
/// Slider paths are considered but stack leniency is ignored.
|
||||
/// As most maps don't even make use of leniency and even if,
|
||||
/// it has generally little effect on stars, the results are close to perfect.
|
||||
/// This version is considerably more efficient than `all_included` since
|
||||
/// processing stack leniency is relatively expensive.
|
||||
///
|
||||
/// In case of a partial play, e.g. a fail, one can specify the amount of passed objects.
|
||||
pub fn stars(map: &Beatmap, mods: u32, passed_objects: Option<usize>) -> OsuDifficultyAttributes {
|
||||
let take = passed_objects.unwrap_or(map.hit_objects.len());
|
||||
|
||||
let map_attributes = map.attributes().mods(mods).build();
|
||||
|
||||
let mut diff_attributes = OsuDifficultyAttributes {
|
||||
ar: map_attributes.ar,
|
||||
od: map_attributes.od,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
if take < 2 {
|
||||
return diff_attributes;
|
||||
}
|
||||
|
||||
let section_len = SECTION_LEN * map_attributes.clock_rate as f32;
|
||||
let radius = OBJECT_RADIUS * (1.0 - 0.7 * (map_attributes.cs as f32 - 5.0) / 5.0) / 2.0;
|
||||
let mut scaling_factor = NORMALIZED_RADIUS / radius;
|
||||
|
||||
if radius < 30.0 {
|
||||
let small_circle_bonus = (30.0 - radius).min(5.0) / 50.0;
|
||||
scaling_factor *= 1.0 + small_circle_bonus;
|
||||
}
|
||||
|
||||
let mut ticks_buf = Vec::new();
|
||||
let mut curve_bufs = CurveBuffers::default();
|
||||
|
||||
let mut hit_objects = map.hit_objects.iter().take(take).filter_map(|h| {
|
||||
OsuObject::new(
|
||||
h,
|
||||
map,
|
||||
radius,
|
||||
scaling_factor,
|
||||
&mut ticks_buf,
|
||||
&mut diff_attributes,
|
||||
&mut curve_bufs,
|
||||
)
|
||||
});
|
||||
|
||||
let mut aim = Skill::new(SkillKind::Aim);
|
||||
let mut speed = Skill::new(SkillKind::Speed);
|
||||
|
||||
// First object has no predecessor and thus no strain, handle distinctly
|
||||
let mut current_section_end =
|
||||
(map.hit_objects[0].start_time as f32 / section_len).ceil() * section_len;
|
||||
|
||||
let mut prev_prev = None;
|
||||
let mut prev = hit_objects.next().unwrap();
|
||||
let mut prev_vals = None;
|
||||
|
||||
// Handle second object separately to remove later if-branching
|
||||
let curr = hit_objects.next().unwrap();
|
||||
let h = DifficultyObject::new(
|
||||
&curr,
|
||||
&prev,
|
||||
prev_vals,
|
||||
prev_prev,
|
||||
map_attributes.clock_rate as f32,
|
||||
scaling_factor,
|
||||
);
|
||||
|
||||
while h.base.time as f32 > current_section_end {
|
||||
current_section_end += section_len;
|
||||
}
|
||||
|
||||
aim.process(&h);
|
||||
speed.process(&h);
|
||||
|
||||
prev_prev = Some(prev);
|
||||
prev_vals = Some((h.jump_dist, h.strain_time));
|
||||
prev = curr;
|
||||
|
||||
// Handle all other objects
|
||||
for curr in hit_objects {
|
||||
let h = DifficultyObject::new(
|
||||
&curr,
|
||||
&prev,
|
||||
prev_vals,
|
||||
prev_prev,
|
||||
map_attributes.clock_rate as f32,
|
||||
scaling_factor,
|
||||
);
|
||||
|
||||
while h.base.time as f32 > current_section_end {
|
||||
aim.save_current_peak();
|
||||
aim.start_new_section_from(current_section_end);
|
||||
speed.save_current_peak();
|
||||
speed.start_new_section_from(current_section_end);
|
||||
|
||||
current_section_end += section_len;
|
||||
}
|
||||
|
||||
aim.process(&h);
|
||||
speed.process(&h);
|
||||
|
||||
prev_prev = Some(prev);
|
||||
prev_vals = Some((h.jump_dist, h.strain_time));
|
||||
prev = curr;
|
||||
}
|
||||
|
||||
aim.save_current_peak();
|
||||
speed.save_current_peak();
|
||||
|
||||
let aim_strain = aim.difficulty_value().sqrt() * DIFFICULTY_MULTIPLIER;
|
||||
let speed_strain = speed.difficulty_value().sqrt() * DIFFICULTY_MULTIPLIER;
|
||||
|
||||
let aim_difficult_strain_count = aim.count_difficult_strains();
|
||||
let speed_difficult_strain_count = speed.count_difficult_strains();
|
||||
|
||||
let stars = aim_strain + speed_strain + (aim_strain - speed_strain).abs() / 2.0;
|
||||
|
||||
diff_attributes.stars = stars as f64;
|
||||
diff_attributes.speed_strain = speed_strain as f64;
|
||||
diff_attributes.aim_strain = aim_strain as f64;
|
||||
diff_attributes.aim_difficult_strain_count = aim_difficult_strain_count;
|
||||
diff_attributes.speed_difficult_strain_count = speed_difficult_strain_count;
|
||||
|
||||
diff_attributes
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct OsuDifficultyAttributes {
|
||||
pub aim_strain: f64,
|
||||
pub speed_strain: f64,
|
||||
pub ar: f64,
|
||||
pub od: f64,
|
||||
pub hp: f64,
|
||||
pub n_circles: usize,
|
||||
pub n_sliders: usize,
|
||||
pub n_spinners: usize,
|
||||
pub stars: f64,
|
||||
pub max_combo: usize,
|
||||
pub aim_difficult_strain_count: f64,
|
||||
pub speed_difficult_strain_count: f64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct OsuPerformanceAttributes {
|
||||
pub difficulty: OsuDifficultyAttributes,
|
||||
pub pp: f64,
|
||||
pub pp_acc: f64,
|
||||
pub pp_aim: f64,
|
||||
pub pp_flashlight: f64,
|
||||
pub pp_speed: f64,
|
||||
}
|
||||
@@ -151,6 +151,32 @@ macro_rules! parse_general_body {
|
||||
}};
|
||||
}
|
||||
|
||||
macro_rules! parse_metadata_body {
|
||||
($self:ident, $reader:ident, $section:ident) => {{
|
||||
let mut empty = true;
|
||||
|
||||
while next_line!($reader)? != 0 {
|
||||
if let Some(bytes) = $reader.get_section() {
|
||||
*$section = Section::from_bytes(bytes);
|
||||
empty = false;
|
||||
break;
|
||||
}
|
||||
|
||||
let (key, value) = $reader.split_colon().ok_or(ParseError::BadLine)?;
|
||||
|
||||
if key == b"Creator" {
|
||||
$self.creator = value.to_string();
|
||||
} else if key == b"BeatmapID" {
|
||||
if let Some(val) = u32::from_str(value).ok() {
|
||||
$self.beatmap_id = val;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(empty)
|
||||
}};
|
||||
}
|
||||
|
||||
macro_rules! parse_difficulty_body {
|
||||
($self:ident, $reader:ident, $section:ident) => {{
|
||||
let mut ar = None;
|
||||
@@ -752,6 +778,7 @@ macro_rules! parse_body {
|
||||
loop {
|
||||
match section {
|
||||
Section::General => section!(map, parse_general, reader, section),
|
||||
Section::Metadata => section!(map, parse_metadata, reader, section),
|
||||
Section::Difficulty => section!(map, parse_difficulty, reader, section),
|
||||
Section::Events => section!(map, parse_events, reader, section),
|
||||
Section::TimingPoints => section!(map, parse_timingpoints, reader, section),
|
||||
@@ -956,6 +983,14 @@ impl Beatmap {
|
||||
parse_general_body!(self, reader, section)
|
||||
}
|
||||
|
||||
fn parse_metadata<R: Read>(
|
||||
&mut self,
|
||||
reader: &mut FileReader<R>,
|
||||
section: &mut Section,
|
||||
) -> ParseResult<bool> {
|
||||
parse_metadata_body!(self, reader, section)
|
||||
}
|
||||
|
||||
fn parse_difficulty<R: Read>(
|
||||
&mut self,
|
||||
reader: &mut FileReader<R>,
|
||||
@@ -1023,6 +1058,14 @@ impl Beatmap {
|
||||
parse_general_body!(self, reader, section)
|
||||
}
|
||||
|
||||
async fn parse_metadata<R: AsyncRead + Unpin>(
|
||||
&mut self,
|
||||
reader: &mut FileReader<R>,
|
||||
section: &mut Section,
|
||||
) -> ParseResult<bool> {
|
||||
parse_metadata_body!(self, reader, section)
|
||||
}
|
||||
|
||||
async fn parse_difficulty<R: AsyncRead + Unpin>(
|
||||
&mut self,
|
||||
reader: &mut FileReader<R>,
|
||||
@@ -1076,6 +1119,7 @@ enum Section {
|
||||
TimingPoints,
|
||||
HitObjects,
|
||||
Events,
|
||||
Metadata,
|
||||
}
|
||||
|
||||
impl Section {
|
||||
@@ -1086,6 +1130,7 @@ impl Section {
|
||||
b"TimingPoints" => Self::TimingPoints,
|
||||
b"HitObjects" => Self::HitObjects,
|
||||
b"Events" => Self::Events,
|
||||
b"Metadata" => Self::Metadata,
|
||||
_ => Self::None,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user