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.
|
/// All break points of the beatmap.
|
||||||
pub breaks: Vec<Break>,
|
pub breaks: Vec<Break>,
|
||||||
|
|
||||||
|
/// The creator of the beatmap
|
||||||
|
pub creator: String,
|
||||||
|
|
||||||
|
/// The beatmap ID of the map
|
||||||
|
pub beatmap_id: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Beatmap {
|
impl Beatmap {
|
||||||
@@ -164,6 +170,8 @@ impl Beatmap {
|
|||||||
effect_points: self.effect_points.clone(),
|
effect_points: self.effect_points.clone(),
|
||||||
stack_leniency: self.stack_leniency,
|
stack_leniency: self.stack_leniency,
|
||||||
breaks: self.breaks.clone(),
|
breaks: self.breaks.clone(),
|
||||||
|
creator: self.creator.clone(),
|
||||||
|
beatmap_id: self.beatmap_id,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -188,6 +188,9 @@ pub mod mania;
|
|||||||
/// Everything about osu!standard.
|
/// Everything about osu!standard.
|
||||||
pub mod osu;
|
pub mod osu;
|
||||||
|
|
||||||
|
/// osu! 2019 (for relax)
|
||||||
|
pub mod osu_2019;
|
||||||
|
|
||||||
/// Everything about osu!taiko.
|
/// Everything about osu!taiko.
|
||||||
pub mod taiko;
|
pub mod taiko;
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ pub trait Mods: Copy {
|
|||||||
const HT: u32 = 1 << 8;
|
const HT: u32 = 1 << 8;
|
||||||
const FL: u32 = 1 << 10;
|
const FL: u32 = 1 << 10;
|
||||||
const SO: u32 = 1 << 12;
|
const SO: u32 = 1 << 12;
|
||||||
|
const AP: u32 = 1 << 13;
|
||||||
|
|
||||||
/// If the clock rate is affected by the mods.
|
/// If the clock rate is affected by the mods.
|
||||||
fn change_speed(self) -> bool;
|
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 {
|
macro_rules! parse_difficulty_body {
|
||||||
($self:ident, $reader:ident, $section:ident) => {{
|
($self:ident, $reader:ident, $section:ident) => {{
|
||||||
let mut ar = None;
|
let mut ar = None;
|
||||||
@@ -752,6 +778,7 @@ macro_rules! parse_body {
|
|||||||
loop {
|
loop {
|
||||||
match section {
|
match section {
|
||||||
Section::General => section!(map, parse_general, reader, 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::Difficulty => section!(map, parse_difficulty, reader, section),
|
||||||
Section::Events => section!(map, parse_events, reader, section),
|
Section::Events => section!(map, parse_events, reader, section),
|
||||||
Section::TimingPoints => section!(map, parse_timingpoints, reader, section),
|
Section::TimingPoints => section!(map, parse_timingpoints, reader, section),
|
||||||
@@ -956,6 +983,14 @@ impl Beatmap {
|
|||||||
parse_general_body!(self, reader, section)
|
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>(
|
fn parse_difficulty<R: Read>(
|
||||||
&mut self,
|
&mut self,
|
||||||
reader: &mut FileReader<R>,
|
reader: &mut FileReader<R>,
|
||||||
@@ -1023,6 +1058,14 @@ impl Beatmap {
|
|||||||
parse_general_body!(self, reader, section)
|
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>(
|
async fn parse_difficulty<R: AsyncRead + Unpin>(
|
||||||
&mut self,
|
&mut self,
|
||||||
reader: &mut FileReader<R>,
|
reader: &mut FileReader<R>,
|
||||||
@@ -1076,6 +1119,7 @@ enum Section {
|
|||||||
TimingPoints,
|
TimingPoints,
|
||||||
HitObjects,
|
HitObjects,
|
||||||
Events,
|
Events,
|
||||||
|
Metadata,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Section {
|
impl Section {
|
||||||
@@ -1086,6 +1130,7 @@ impl Section {
|
|||||||
b"TimingPoints" => Self::TimingPoints,
|
b"TimingPoints" => Self::TimingPoints,
|
||||||
b"HitObjects" => Self::HitObjects,
|
b"HitObjects" => Self::HitObjects,
|
||||||
b"Events" => Self::Events,
|
b"Events" => Self::Events,
|
||||||
|
b"Metadata" => Self::Metadata,
|
||||||
_ => Self::None,
|
_ => Self::None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user