chore: bump to older tag build
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
name: Clippy and Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- next
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
clippy:
|
||||
name: Clippy
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
feature: [default, async_tokio, async_std]
|
||||
|
||||
steps:
|
||||
- name: Checkout project
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: actions-rs/toolchain@v1
|
||||
with:
|
||||
profile: minimal
|
||||
toolchain: stable
|
||||
components: clippy
|
||||
override: true
|
||||
|
||||
- name: Run clippy
|
||||
uses: actions-rs/cargo@v1
|
||||
with:
|
||||
command: clippy
|
||||
args: --no-default-features --features ${{ matrix.feature }}
|
||||
|
||||
tests:
|
||||
name: Tests
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
feature: [default, async_tokio, async_std]
|
||||
|
||||
steps:
|
||||
- name: Checkout project
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: actions-rs/toolchain@v1
|
||||
with:
|
||||
profile: minimal
|
||||
toolchain: stable
|
||||
components: clippy
|
||||
override: true
|
||||
|
||||
- name: Run tests
|
||||
uses: actions-rs/cargo@v1
|
||||
with:
|
||||
command: test
|
||||
args: --no-default-features --features ${{ matrix.feature }}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
/target
|
||||
Cargo.lock
|
||||
output*
|
||||
/tests/custom.rs
|
||||
|
||||
/pp-gen/.env
|
||||
/pp-gen/target
|
||||
/pp-gen/output.json
|
||||
|
||||
/pp-plot/.env
|
||||
/pp-plot/target
|
||||
/pp-plot/accuracy_*
|
||||
+223
@@ -0,0 +1,223 @@
|
||||
## Upcoming
|
||||
|
||||
Nothing as of now
|
||||
|
||||
# v0.9.2 (2022-11-08)
|
||||
|
||||
- __Adjustments:__
|
||||
- When passing an osu!std map to `TaikoGradualDifficultyAttributes` or `ManiaGradualDifficultyAttributes`, it now automatically converts the map internally. For osu!catch is was already happening trivially.
|
||||
|
||||
- __Fixes:__
|
||||
- Fixed passed object count for taiko by ignoring non-circles
|
||||
- Fixed a niche panic on UTF-16 encoded maps ([#18])
|
||||
- Fixed an occasional underflow when calculating accuracy pp
|
||||
- Fixed an infinite loop on special ctb maps
|
||||
|
||||
## v0.9.1 (2022-10-26)
|
||||
|
||||
- __Adjustments:__
|
||||
- When passing an osu!std map to `TaikoPP` or `ManiaPP`, it now automatically converts the map internally. For osu!catch it was already happening trivially.
|
||||
|
||||
- __Fixes:__
|
||||
- The fields `section_len` for all strain structs no longer depends on the clock rate.
|
||||
|
||||
## v0.9.0 (2022-10-24)
|
||||
|
||||
Big changes including the most recent [osu!](https://osu.ppy.sh/home/news/2022-09-30-changes-to-osu-sr-and-pp), [taiko](https://osu.ppy.sh/home/news/2022-09-28-changes-to-osu-taiko-sr-and-pp), and [mania](https://osu.ppy.sh/home/news/2022-10-09-changes-to-osu-mania-sr-and-pp) updates, aswell as various breaking changes.
|
||||
|
||||
- __Breaking changes:__
|
||||
- `TimingPoint` and `DifficultyPoint` no longer contain a `kiai` field
|
||||
- `DifficultyPoint` now has the additional fields `bpm_mult` and `generate_ticks`
|
||||
- `Beatmap` now stores timing- and difficulty points in a `SortedVec`
|
||||
- `Beatmap` now has the additional field `effect_points`
|
||||
- For the performance calculators `OsuPP`, `TaikoPP`, `ManiaPP`, and `AnyPP` the method `misses` has been renamed to `n_misses`
|
||||
- The accuracy method for `OsuPP`, `TaikoPP`, and `ManiaPP` is no longer required to be called last
|
||||
- `ManiaPP` no longer has a `score` method. Instead it has `n320`, `n300`. `n200`, `n100`, `n50`, and `n_misses` methods, aswell as a `state` method
|
||||
- Gradual performance calculation for mania now requires a `ManiaScoreState` instead of `score`
|
||||
- `ManiaDifficultyAttributes` now have a `max_combo` field and method
|
||||
- `OsuDifficultyAttributes` now have a `speed_note_count` field
|
||||
- `OsuPerformanceAttributes` and `TaikoPerformanceAttributes` now have a `effective_miss_count` field
|
||||
- `TaikoDifficultyAttributes` now have a `peak` and `hit_window` field
|
||||
- Some other things I likely forgot about :S
|
||||
|
||||
- __Additions:__
|
||||
- The performance calculators `OsuPP`, `TaikoPP`, `ManiaPP`, and `AnyPP` now have a `hitresult_priority` method to specify how hitresults should be generated
|
||||
|
||||
- __Fixes:__
|
||||
- Fixed a bunch of fringe yet significant bugs for taiko and mania converts
|
||||
- Fixed various floating point inaccuracies for osu!standard
|
||||
- Fixed parsing difficulty points from .osu files
|
||||
- Instead of throwing an error, invalid lines during parsing will just be ignored in some cases
|
||||
- Fixed an unsafe transmute between incompatible types while parsing sliders
|
||||
|
||||
## v0.8.0 (2022-08-02)
|
||||
|
||||
- __Fixes:__
|
||||
- Fixed stack overflow bug when allocating ticks for some sliders on converted catch maps ([#14])
|
||||
- __Breaking changes:__
|
||||
- `Beatmap::attributes` now returns a new type `BeatmapAttributesBuilder` to allow for more
|
||||
fine-grained calculations. `BeatmapAttributes` now contains expected values and also includes
|
||||
a `BeatmapHitWindows` field containing the AR (preempt) and OD (great) hit windows in
|
||||
milliseconds. ([#15])
|
||||
|
||||
## v0.7.1 (2022-07-12)
|
||||
|
||||
- __Fixes:__
|
||||
- Parsing edge sounds is now mindful about overflowing a byte (ref. ranked map id 80799)
|
||||
- Parsing the event section now attempts to read non-ASCII before eventually failing (ref. ranked map id 49374)
|
||||
|
||||
## v0.7.0 (2022-07-06)
|
||||
|
||||
- __Fixes:__
|
||||
- Slider velocity is now adjusted properly for taiko converts
|
||||
- Fixed missing slider sounds for taiko converts
|
||||
- __Breaking changes:__
|
||||
- Replaced the simple `Strains` struct with a new struct `{Mode}Strains` that contains more detail w.r.t. the mode.
|
||||
- Renamed all `GameMode` variants to more idiomatic names
|
||||
- Renamed `ParseError::IOError` to `ParseError::IoError`
|
||||
|
||||
## v0.6.0 (2022-07-05)
|
||||
|
||||
- __Additions__:
|
||||
- Added the `ControlPoint` and `ControlPointerIter` types to the public interface
|
||||
- `TimingPoint` and `DifficultyPoint` now implement `Default`
|
||||
- Added new methods to `Beatmap`:
|
||||
- `convert_mode`: Convert a map into another mode. (doesn't do anything if the starting map is not osu!standard)
|
||||
- `control_points`: Return an iterator over all control points of a map
|
||||
- `total_break_time`: Return the accumulated break time in milliseconds
|
||||
- `timing_point_at`: Return the timing point for the given timestamp
|
||||
- `difficulty_point_at`: Return the difficulty point for the given timestamp if available
|
||||
- __Breaking changes:__
|
||||
- Moved some types to a different module. The following types can now be found in `rosu_pp::beatmap`:
|
||||
- `Beatmap`
|
||||
- `BeatmapAttributes`
|
||||
- `ControlPoint`
|
||||
- `ControlPointIter`
|
||||
- `DifficultyPoint`
|
||||
- `GameMode`
|
||||
- `TimingPoint`
|
||||
- Added a new field `kiai: bool` to both `TimingPoint` and `DifficultyPoint` to denote whether the current timing section is in kiai mode
|
||||
- Added a new field `breaks: Vec<Break>` to `Beatmap` that contains all breaks throughout the map
|
||||
- Added a new field `edge_sounds: Vec<u8>` to the `Slider` variant of `HitObjectKind` to denote the sample played on slider heads, ends, and repeats
|
||||
- __Other:__
|
||||
- Small performance improvements for osu!taiko calculations
|
||||
|
||||
## v0.5.2 (2022-06-14)
|
||||
|
||||
- __Fixes:__
|
||||
- Fixed parsing non-UTF-8 encoded files and improved parse performance overall ([#9])
|
||||
- Handle missing approach rate properly this time
|
||||
|
||||
## v0.5.1 (2022-03-21)
|
||||
|
||||
- __Fixes:__
|
||||
- Performance calculation for taiko & mania now considers custom clock rates properly
|
||||
|
||||
## v0.5.0 (2022-03-21)
|
||||
|
||||
- __Fixes:__
|
||||
- Fixed panic on maps with 0 objects
|
||||
- Fixed droplet timings on juicestreams with span count >1
|
||||
- Fixed timing point parsing on some (older) maps where "uninherited" value did not coincide with beat length
|
||||
- Fixed handling .osu files with missing difficulty attributes
|
||||
- Fixed huge memory allocations caused by incorrectly parsing .osu files
|
||||
- __Breaking changes:__
|
||||
- The `stars` and `strains` functions for all modes were removed. Instead use the `{Mode}Stars` builder pattern which is similar to `{Mode}PP`.
|
||||
- `BeatmapExt::stars`'s definition was adjusted to use the `AnyStars` builder struct
|
||||
- Store `HitObject::sound` in `Beatmap::sounds` instead to reduce the struct size
|
||||
- Removed the mode features `osu`, `fruits`, `taiko`, and `mania`. Now all modes are always supported.
|
||||
- Renamed the `rosu_pp::fruits` module to `rosu_pp::catch`. Similarly, all structs `Fruits{Name}` were renamed to `Catch{Name}` and enums over the mode have their `Fruits` variant renamed to `Catch`
|
||||
- Renamed `Mods`' method `speed` to `clock_rate`
|
||||
- __Additions:__
|
||||
- Added `AttributeProvider` impl for `{Mode}PerformanceAttributes`
|
||||
- Added the method `clock_rate` to `{Mode}PP` and `{Mode}Stars` to consider a custom clock rate instead of the one dictated by mods.
|
||||
|
||||
## v0.4.0 (2021-11-25)
|
||||
|
||||
- Fixed out of bounds panic on maps with single-control-point linear sliders
|
||||
- Fixed incorrect attributes on maps with only 1 or 2 hit objects for all modes
|
||||
- Added method `Beatmap::from_path` so the file does not have to be created manually for `Beatmap::parse`.
|
||||
- Added a bunch of documentation.
|
||||
- Added method `Beatmap::bpm`
|
||||
- Added method `max_combo` for `DifficultyAttributes`, `PerformanceAttributes`, and all `{Mode}PerformanceAttributes`
|
||||
- Added methods `TaikoDifficultyAttributes::max_combo` and `OsuDifficultyAttributes::max_combo`
|
||||
- Added structs `{Mode}GradualDifficultyAttributes` to calculate a map's difficulty after every or every few objects instead of calling the mode's `stars` function over and over.
|
||||
- Added structs `{Mode}GradualPerformanceAttributes` to calculate the performance on a map after every or every few objects instead of using `{Mode}PP` over and over.
|
||||
- Added `BeatmapExt::gradual_difficulty` and `BeatmapExt::gradual_performance` to gradually calculate the difficulty or performance on maps of any mode, hit object by hit object.
|
||||
- Added methods `{Mode}PP::state` that take a `{Mode}ScoreState` (same for `AnyPP` and `ScoreState`) to set all parameters at once.
|
||||
- [BREAKING] Removed the `ParseError` variants `InvalidPathType` and `InvalidTimingSignature` and renamed `InvalidFloatingPoint` to `InvalidDecimalNumber`.
|
||||
- [BREAKING] Removed the `last_control_point` field of `HitObjectKind::Slider` when neither the `osu` nor the `fruits` feature is enabled.
|
||||
- [BREAKING] Added the field `TaikoDifficultyAttributes::max_combo`
|
||||
- [BREAKING] Renamed the `attributes` field to `difficulty` for all `{Mode}PerformanceAttributes` structs
|
||||
- [BREAKING] Replaced field `FruitsDifficultyAttributes::max_combo` by a method with the same name
|
||||
|
||||
## v0.3.0 (2021-11-14)
|
||||
|
||||
- [BREAKING] With the importance of sliders for osu!standard, the `no_sliders_no_leniency` feature became too inaccurate. Additionally, since considering sliders now inherently drags performance down a little more, the difference between `no_leniency` and `all_included` became too small. Hence, the three osu features `no_sliders_no_leniency`, `no_leniency`, and `all_included` were removed. When the `osu` feature is enabled, it will now essentially use `all_included` under the hood.
|
||||
Additionally, instead of importing through `rosu_pp::osu::{version}`, you now have to import through `rosu_pp::osu`.
|
||||
- [BREAKING] Instead of returning `PpResult`, performance calculations now return `{Mode}PerformanceAttributes` and `PpResult` has been renamed to `PerformanceAttributes`.
|
||||
- [BREAKING] Instead of returning `StarResult`, difficulty calculations now return `{Mode}DifficultyAttributes` and `StarResult` has been renamed to `DifficultyAttributes`.
|
||||
- [BREAKING] Various fields and methods now include `f64` instead of `f32` to stay true to osu!'s original code
|
||||
- Added internal binary crate `pp-gen` to calculate difficulty & pp values via `PerformanceCalculator.dll`
|
||||
- Added internal binary crate `pp-plot` to plot out differences between `pp-gen`'s output and `rosu-pp` values
|
||||
- osu: Updated up to commit [9fb2402781ad91c197d51aeec716b0000f52c4d1](https://github.com/ppy/osu/commit/9fb2402781ad91c197d51aeec716b0000f52c4d1) (2021-11-12)
|
||||
|
||||
## v0.2.3 (2021-08-09)
|
||||
|
||||
- Reduced amount of required features of `async_std` and `async_tokio`
|
||||
- Fixed a panic for some mania difficulty calculations on converts
|
||||
- Updated the difficulty & pp changes from 21.07.27
|
||||
- Fixed dead loop when reading empty `.osu` files ([#2] - [@Pure-Peace])
|
||||
- Updated osu's clockrate bugfix for all modes
|
||||
|
||||
## v0.2.2 (2021-05-05)
|
||||
|
||||
- osu & fruits:
|
||||
- Fixed specific slider patterns
|
||||
- Optimized Bezier, Catmull, and other small things
|
||||
|
||||
Benchmarking for osu!standard showed a 25%+ improvement for performance aswell as accuracy
|
||||
|
||||
- fruits:
|
||||
- Fixed tick timing for reverse sliders
|
||||
|
||||
- taiko:
|
||||
- Micro optimizations
|
||||
|
||||
## v0.2.1 (2021-04-17)
|
||||
|
||||
- parse & osu:
|
||||
- Cleanup and tiny optimizations
|
||||
|
||||
## v0.2.0 (2021-02-25)
|
||||
|
||||
- Async beatmap parsing through features `async_tokio` or `async_std` ([#1] - [@Pure-Peace])
|
||||
- [BREAKING] Hide various parsing related types further inwards, i.e. `rosu_pp::parse::some_type` instead of `rosu_pp::some_type`
|
||||
- Affected types: `DifficultyPoint`, `HitObject`, `Pos2`, `TimingPoint`, `HitObjectKind`, `PathType`, `HitSound`
|
||||
|
||||
## v0.1.1 (2021-02-15)
|
||||
|
||||
- parse:
|
||||
- Efficiently handle huge amounts of curvepoints
|
||||
|
||||
- osu:
|
||||
- Fixed panic on unwrapping unavailable hit results
|
||||
- Fixed occasional underflow when calculating pp with passed_objects
|
||||
|
||||
- taiko:
|
||||
- Fixed missing flooring of hitwindow for pp calculation
|
||||
|
||||
- fruits:
|
||||
- Fixed passed objects in star calculation
|
||||
|
||||
- mania:
|
||||
- Fixed pp calculation on HR
|
||||
|
||||
[@Pure-Peace]: https://github.com/Pure-Peace
|
||||
|
||||
[#1]: https://github.com/MaxOhn/rosu-pp/pull/1
|
||||
[#2]: https://github.com/MaxOhn/rosu-pp/pull/2
|
||||
[#9]: https://github.com/MaxOhn/rosu-pp/pull/9
|
||||
[#14]: https://github.com/MaxOhn/rosu-pp/pull/14
|
||||
[#15]: https://github.com/MaxOhn/rosu-pp/pull/15
|
||||
[#18]: https://github.com/MaxOhn/rosu-pp/pull/18
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
[package]
|
||||
name = "akatsuki-pp"
|
||||
version = "1.0.1"
|
||||
authors = ["MaxOhn <ohn.m@hotmail.de>", "tsunyoku <tsunyoku@gmail.com>"]
|
||||
edition = "2018"
|
||||
license = "MIT"
|
||||
readme = "README.md"
|
||||
repository = "https://github.com/osuAkatsuki/akatsuki-pp"
|
||||
description = "osu! difficulty and pp calculation for all modes"
|
||||
keywords = ["osu", "pp", "stars", "async"]
|
||||
|
||||
[features]
|
||||
default = []
|
||||
async_std = ["async-std"]
|
||||
async_tokio = ["tokio"]
|
||||
|
||||
[dependencies.async-std]
|
||||
version = "1.9"
|
||||
optional = true
|
||||
default-features = false
|
||||
features = ["async-io", "std"]
|
||||
|
||||
[dependencies.tokio]
|
||||
version = "1.2"
|
||||
optional = true
|
||||
default-features = false
|
||||
features = ["fs", "io-util"]
|
||||
|
||||
[dev-dependencies.tokio]
|
||||
version = "1.2"
|
||||
default-features = false
|
||||
features = ["fs", "rt"]
|
||||
|
||||
[dev-dependencies.async-std]
|
||||
version = "1.9"
|
||||
default-features = true
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2021 Max
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,208 @@
|
||||
[](https://crates.io/crates/rosu-pp) [](https://docs.rs/rosu-pp)
|
||||
|
||||
# rosu-pp
|
||||
|
||||
A standalone crate to calculate star ratings and performance points for all [osu!](https://osu.ppy.sh/home) gamemodes.
|
||||
|
||||
Async is supported through features, see below.
|
||||
|
||||
### Usage
|
||||
|
||||
```rust
|
||||
use rosu_pp::{Beatmap, BeatmapExt};
|
||||
|
||||
// Parse the map yourself
|
||||
let map = match Beatmap::from_path("/path/to/file.osu") {
|
||||
Ok(map) => map,
|
||||
Err(why) => panic!("Error while parsing map: {}", why),
|
||||
};
|
||||
|
||||
// If `BeatmapExt` is included, you can make use of
|
||||
// some methods on `Beatmap` to make your life simpler.
|
||||
let result = map.pp()
|
||||
.mods(24) // HDHR
|
||||
.combo(1234)
|
||||
.accuracy(99.2)
|
||||
.misses(2)
|
||||
.calculate();
|
||||
|
||||
println!("PP: {}", result.pp());
|
||||
|
||||
// If you want to reuse the current map-mod combination, make use of the previous result!
|
||||
// If attributes are given, then stars & co don't have to be recalculated.
|
||||
let next_result = map.pp()
|
||||
.mods(24) // HDHR
|
||||
.attributes(result) // recycle
|
||||
.combo(543)
|
||||
.misses(5)
|
||||
.n50(3)
|
||||
.accuracy(96.5)
|
||||
.calculate();
|
||||
|
||||
println!("Next PP: {}", next_result.pp());
|
||||
|
||||
let stars = map.stars()
|
||||
.mods(16) // HR
|
||||
.calculate()
|
||||
.stars();
|
||||
|
||||
let max_pp = map.max_pp(16).pp();
|
||||
|
||||
println!("Stars: {} | Max PP: {}", stars, max_pp);
|
||||
```
|
||||
|
||||
### With async
|
||||
If either the `async_tokio` or `async_std` feature is enabled, beatmap parsing will be async.
|
||||
|
||||
```rust
|
||||
use rosu_pp::{Beatmap, BeatmapExt};
|
||||
|
||||
// Parse the map asynchronously
|
||||
let map = match Beatmap::from_path("/path/to/file.osu").await {
|
||||
Ok(map) => map,
|
||||
Err(why) => panic!("Error while parsing map: {}", why),
|
||||
};
|
||||
|
||||
// The rest stays the same
|
||||
let result = map.pp()
|
||||
.mods(24) // HDHR
|
||||
.combo(1234)
|
||||
.n_misses(2)
|
||||
.accuracy(99.2)
|
||||
.calculate();
|
||||
|
||||
println!("PP: {}", result.pp());
|
||||
```
|
||||
|
||||
### Gradual calculation
|
||||
Sometimes you might want to calculate the difficulty of a map or performance of a score after each hit object.
|
||||
This could be done by using `passed_objects` as the amount of objects that were passed so far.
|
||||
However, this requires to recalculate the beginning again and again, we can be more efficient than that.
|
||||
|
||||
Instead, you should use `GradualDifficultyAttributes` and `GradualPerformanceAttributes`:
|
||||
|
||||
```rust
|
||||
use rosu_pp::{
|
||||
Beatmap, BeatmapExt, GradualPerformanceAttributes, ScoreState, taiko::TaikoScoreState,
|
||||
};
|
||||
|
||||
let map = match Beatmap::from_path("/path/to/file.osu") {
|
||||
Ok(map) => map,
|
||||
Err(why) => panic!("Error while parsing map: {}", why),
|
||||
};
|
||||
|
||||
let mods = 8 + 64; // HDDT
|
||||
|
||||
// If you're only interested in the star rating or other difficulty value,
|
||||
// use `GradualDifficultyAttributes`, either through its function `new`
|
||||
// or through the method `BeatmapExt::gradual_difficulty`.
|
||||
let gradual_difficulty = map.gradual_difficulty(mods);
|
||||
|
||||
// Since `GradualDifficultyAttributes` implements `Iterator`, you can use
|
||||
// any iterate function on it, use it in loops, collect them into a `Vec`, ...
|
||||
for (i, difficulty) in gradual_difficulty.enumerate() {
|
||||
println!("Stars after object {}: {}", i, difficulty.stars());
|
||||
}
|
||||
|
||||
// Gradually calculating performance values does the same as calculating
|
||||
// difficulty attributes but it goes the extra step and also evaluates
|
||||
// the state of a score for these difficulty attributes.
|
||||
let mut gradual_performance = map.gradual_performance(mods);
|
||||
|
||||
// The default score state is kinda chunky because it considers all modes.
|
||||
let state = ScoreState {
|
||||
max_combo: 1,
|
||||
n_geki: 0, // only relevant for mania
|
||||
n_katu: 0, // only relevant for mania and ctb
|
||||
n300: 1,
|
||||
n100: 0,
|
||||
n50: 0,
|
||||
n_misses: 0,
|
||||
};
|
||||
|
||||
// Process the score state after the first object
|
||||
let curr_performance = match gradual_performance.process_next_object(state) {
|
||||
Some(perf) => perf,
|
||||
None => panic!("the map has no hit objects"),
|
||||
};
|
||||
|
||||
println!("PP after the first object: {}", curr_performance.pp());
|
||||
|
||||
// If you're only interested in maps of a specific mode, consider
|
||||
// using the mode's gradual calculator instead of the general one.
|
||||
// Let's assume it's a taiko map.
|
||||
// Instead of starting off with `BeatmapExt::gradual_performance` one could have
|
||||
// created the struct via `TaikoGradualPerformanceAttributes::new`.
|
||||
let mut gradual_performance = match gradual_performance {
|
||||
GradualPerformanceAttributes::Taiko(gradual) => gradual,
|
||||
_ => panic!("the map was not taiko but {:?}", map.mode),
|
||||
};
|
||||
|
||||
// A little simpler than the general score state.
|
||||
let state = TaikoScoreState {
|
||||
max_combo: 11,
|
||||
n300: 9,
|
||||
n100: 1,
|
||||
n_misses: 1,
|
||||
};
|
||||
|
||||
// Process the next 10 objects in one go
|
||||
let curr_performance = match gradual_performance.process_next_n_objects(state, 10) {
|
||||
Some(perf) => perf,
|
||||
None => panic!("the last `process_next_object` already processed the last object"),
|
||||
};
|
||||
|
||||
println!("PP after the first 11 objects: {}", curr_performance.pp());
|
||||
```
|
||||
|
||||
### Features
|
||||
|
||||
| Flag | Description |
|
||||
| ------------- | ---------------------------------------------------------------------------------------- |
|
||||
| `default` | Beatmap parsing will be non-async |
|
||||
| `async_tokio` | Beatmap parsing will be async through [tokio](https://github.com/tokio-rs/tokio) |
|
||||
| `async_std` | Beatmap parsing will be async through [async-std](https://github.com/async-rs/async-std) |
|
||||
|
||||
### Version
|
||||
|
||||
A large portion of this repository is a port of [osu!lazer](https://github.com/ppy/osu)'s difficulty and performance calculation.
|
||||
|
||||
- osu!:
|
||||
- osu!lazer: Commit `85adfc2df7d931164181e145377a6ced8db2bfb3` (Wed Sep 28 18:26:36 2022 +0300)
|
||||
- osu!tools: Commit `146d5916937161ef65906aa97f85d367035f3712` (Sat Oct 8 14:28:49 2022 +0900)
|
||||
- [Article](https://osu.ppy.sh/home/news/2022-09-30-changes-to-osu-sr-and-pp)
|
||||
|
||||
- taiko:
|
||||
- osu!lazer: Commit `234c6ac7998fbc6742503e1a589536255554e56a` (Wed Oct 5 20:21:15 2022 +0900)
|
||||
- osu!tools: Commit `146d5916937161ef65906aa97f85d367035f3712` (Sat Oct 8 14:28:49 2022 +0900)
|
||||
- [Article](https://osu.ppy.sh/home/news/2022-09-28-changes-to-osu-taiko-sr-and-pp)
|
||||
|
||||
- catch: (will be updated on the next rework)
|
||||
- osu!lazer: -
|
||||
- osu!tools: -
|
||||
|
||||
- mania:
|
||||
- osu!lazer: Commit `7342fb7f51b34533a42bffda89c3d6c569cc69ce` (Tue Oct 11 14:34:50 2022 +0900)
|
||||
- osu!tools: Commit `146d5916937161ef65906aa97f85d367035f3712` (Sat Oct 8 14:28:49 2022 +0900)
|
||||
- [Article](https://osu.ppy.sh/home/news/2022-10-09-changes-to-osu-mania-sr-and-pp)
|
||||
|
||||
### Accuracy
|
||||
|
||||
The difficulty and performance attributes generated by [osu-tools](https://github.com/ppy/osu-tools) itself were compared with rosu-pp's results when running on `130,000` different maps. Additionally, multiple mod combinations were tested depending on the mode:
|
||||
|
||||
- osu!: NM, EZ, HD, HR, DT
|
||||
- taiko: NM, HD, HR, DT (+ all osu! converts)
|
||||
- catch: -
|
||||
- mania: NM, DT (+ all osu! converts)
|
||||
|
||||
For every (!) comparison of the star and pp values, the error margin was below `0.000000001`, ensuing a great accuracy.
|
||||
|
||||
### Benchmark
|
||||
|
||||
To be done
|
||||
|
||||
### Bindings
|
||||
|
||||
Using rosu-pp from other languages than Rust:
|
||||
- JavaScript: [rosu-pp-js](https://github.com/MaxOhn/rosu-pp-js)
|
||||
- Python: [rosu-pp-py](https://github.com/MaxOhn/rosu-pp-py)
|
||||
@@ -0,0 +1,356 @@
|
||||
osu file format v14
|
||||
|
||||
[General]
|
||||
AudioFilename: audio.mp3
|
||||
AudioLeadIn: 0
|
||||
PreviewTime: 8074
|
||||
Countdown: 0
|
||||
SampleSet: Normal
|
||||
StackLeniency: 0.7
|
||||
Mode: 1
|
||||
LetterboxInBreaks: 0
|
||||
WidescreenStoryboard: 0
|
||||
|
||||
[Editor]
|
||||
DistanceSpacing: 0.6
|
||||
BeatDivisor: 4
|
||||
GridSize: 16
|
||||
TimelineZoom: 1.5
|
||||
|
||||
[Metadata]
|
||||
Title:Inferno
|
||||
TitleUnicode:インフェルノ
|
||||
Artist:9mm Parabellum Bullet
|
||||
ArtistUnicode:9mm Parabellum Bullet
|
||||
Creator:Nofool
|
||||
Version:Muzukashii
|
||||
Source:ベルセルク
|
||||
Tags:Berserk (2016)
|
||||
BeatmapID:1028484
|
||||
BeatmapSetID:481954
|
||||
|
||||
[Difficulty]
|
||||
HPDrainRate:6
|
||||
CircleSize:2
|
||||
OverallDifficulty:5
|
||||
ApproachRate:8
|
||||
SliderMultiplier:1.4
|
||||
SliderTickRate:1
|
||||
|
||||
[Events]
|
||||
//Background and Video events
|
||||
0,0,"berserk_armor_bg.jpg",0,0
|
||||
//Break Periods
|
||||
//Storyboard Layer 0 (Background)
|
||||
//Storyboard Layer 1 (Fail)
|
||||
//Storyboard Layer 2 (Pass)
|
||||
//Storyboard Layer 3 (Foreground)
|
||||
//Storyboard Sound Samples
|
||||
|
||||
[TimingPoints]
|
||||
690,307.692307692308,3,1,0,100,1,0
|
||||
15459,-100,3,1,0,100,0,1
|
||||
22536,-100,3,1,0,100,0,0
|
||||
37536,-125,3,1,0,70,0,0
|
||||
44920,-100,3,1,0,100,0,0
|
||||
78228,-100,3,1,0,100,0,1
|
||||
85305,-100,3,1,0,100,0,0
|
||||
87382,-83.3333333333333,3,1,0,100,0,0
|
||||
|
||||
|
||||
[HitObjects]
|
||||
256,192,690,1,8,0:0:0:0:
|
||||
256,192,843,1,8,0:0:0:0:
|
||||
256,192,997,1,0,0:0:0:0:
|
||||
256,192,1151,1,8,0:0:0:0:
|
||||
256,192,1459,1,0,0:0:0:0:
|
||||
256,192,1613,1,0,0:0:0:0:
|
||||
256,192,1920,1,0,0:0:0:0:
|
||||
256,192,2228,1,8,0:0:0:0:
|
||||
256,192,2536,1,8,0:0:0:0:
|
||||
256,192,2690,1,8,0:0:0:0:
|
||||
256,192,2843,1,0,0:0:0:0:
|
||||
256,192,2997,1,8,0:0:0:0:
|
||||
256,192,3305,1,0,0:0:0:0:
|
||||
256,192,3459,1,0,0:0:0:0:
|
||||
256,192,3766,1,8,0:0:0:0:
|
||||
256,192,4074,1,8,0:0:0:0:
|
||||
256,192,4382,1,8,0:0:0:0:
|
||||
256,192,4536,1,8,0:0:0:0:
|
||||
256,192,4690,1,0,0:0:0:0:
|
||||
256,192,4843,1,8,0:0:0:0:
|
||||
256,192,5151,1,0,0:0:0:0:
|
||||
256,192,5305,1,8,0:0:0:0:
|
||||
256,192,5613,1,8,0:0:0:0:
|
||||
256,192,5920,1,8,0:0:0:0:
|
||||
256,192,6228,1,8,0:0:0:0:
|
||||
256,192,6382,1,8,0:0:0:0:
|
||||
256,192,6536,1,0,0:0:0:0:
|
||||
256,192,6690,1,8,0:0:0:0:
|
||||
256,192,6997,1,0,0:0:0:0:
|
||||
256,192,7151,1,8,0:0:0:0:
|
||||
256,192,7459,1,0,0:0:0:0:
|
||||
256,192,7766,1,0,0:0:0:0:
|
||||
256,192,8074,5,4,0:0:0:0:
|
||||
256,192,8997,1,4,0:0:0:0:
|
||||
256,192,9920,1,4,0:0:0:0:
|
||||
256,192,10843,1,4,0:0:0:0:
|
||||
256,192,11766,1,8,0:0:0:0:
|
||||
256,192,11920,1,8,0:0:0:0:
|
||||
256,192,12074,1,8,0:0:0:0:
|
||||
256,192,12228,1,8,0:0:0:0:
|
||||
256,192,12382,1,8,0:0:0:0:
|
||||
256,192,12690,1,0,0:0:0:0:
|
||||
256,192,12843,1,0,0:0:0:0:
|
||||
256,192,12997,1,0,0:0:0:0:
|
||||
256,192,13151,1,0,0:0:0:0:
|
||||
256,192,13305,1,0,0:0:0:0:
|
||||
256,192,13613,1,8,0:0:0:0:
|
||||
256,192,13766,1,8,0:0:0:0:
|
||||
256,192,13920,1,8,0:0:0:0:
|
||||
256,192,14074,1,8,0:0:0:0:
|
||||
256,192,14228,1,8,0:0:0:0:
|
||||
256,192,14536,12,0,15305,0:0:0:0:
|
||||
256,192,15459,5,4,0:0:0:0:
|
||||
256,192,15766,1,8,0:0:0:0:
|
||||
256,192,15843,1,8,0:0:0:0:
|
||||
256,192,15920,1,8,0:0:0:0:
|
||||
256,192,16074,1,0,0:0:0:0:
|
||||
256,192,16228,1,8,0:0:0:0:
|
||||
256,192,16382,1,0,0:0:0:0:
|
||||
256,192,16690,1,8,0:0:0:0:
|
||||
256,192,16766,1,8,0:0:0:0:
|
||||
256,192,16843,1,8,0:0:0:0:
|
||||
256,192,16997,1,0,0:0:0:0:
|
||||
256,192,17151,1,8,0:0:0:0:
|
||||
256,192,17305,1,0,0:0:0:0:
|
||||
256,192,17613,1,8,0:0:0:0:
|
||||
256,192,17690,1,8,0:0:0:0:
|
||||
256,192,17766,1,8,0:0:0:0:
|
||||
256,192,17920,1,0,0:0:0:0:
|
||||
256,192,18074,1,8,0:0:0:0:
|
||||
256,192,18228,2,0,L|400:192,2,140
|
||||
256,192,19151,1,12,0:0:0:0:
|
||||
256,192,19459,1,0,0:0:0:0:
|
||||
256,192,19613,1,0,0:0:0:0:
|
||||
256,192,19690,1,0,0:0:0:0:
|
||||
256,192,19766,1,0,0:0:0:0:
|
||||
256,192,19920,1,0,0:0:0:0:
|
||||
256,192,20074,1,8,0:0:0:0:
|
||||
256,192,20382,1,0,0:0:0:0:
|
||||
256,192,20536,1,0,0:0:0:0:
|
||||
256,192,20613,1,0,0:0:0:0:
|
||||
256,192,20690,1,0,0:0:0:0:
|
||||
256,192,20843,1,0,0:0:0:0:
|
||||
256,192,20997,1,8,0:0:0:0:
|
||||
256,192,21305,1,0,0:0:0:0:
|
||||
256,192,21459,1,0,0:0:0:0:
|
||||
256,192,21536,1,0,0:0:0:0:
|
||||
256,192,21613,1,0,0:0:0:0:
|
||||
256,192,21766,1,8,0:0:0:0:
|
||||
256,192,21920,2,0,L|400:192,2,140
|
||||
256,192,22843,1,0,0:0:0:0:
|
||||
256,192,22920,1,0,0:0:0:0:
|
||||
256,192,22997,1,0,0:0:0:0:
|
||||
256,192,23151,1,0,0:0:0:0:
|
||||
256,192,23305,1,0,0:0:0:0:
|
||||
256,192,23613,1,0,0:0:0:0:
|
||||
256,192,23766,1,0,0:0:0:0:
|
||||
256,192,24690,1,8,0:0:0:0:
|
||||
256,192,24766,1,8,0:0:0:0:
|
||||
256,192,24843,1,8,0:0:0:0:
|
||||
256,192,24997,1,8,0:0:0:0:
|
||||
256,192,25151,1,8,0:0:0:0:
|
||||
256,192,25459,1,8,0:0:0:0:
|
||||
256,192,25613,1,8,0:0:0:0:
|
||||
256,192,26536,1,0,0:0:0:0:
|
||||
256,192,26690,1,0,0:0:0:0:
|
||||
256,192,26843,1,0,0:0:0:0:
|
||||
256,192,27459,1,8,0:0:0:0:
|
||||
256,192,27613,1,8,0:0:0:0:
|
||||
256,192,27766,1,8,0:0:0:0:
|
||||
256,192,28382,1,0,0:0:0:0:
|
||||
256,192,28459,1,0,0:0:0:0:
|
||||
256,192,28536,1,0,0:0:0:0:
|
||||
256,192,28690,1,0,0:0:0:0:
|
||||
256,192,28843,1,0,0:0:0:0:
|
||||
256,192,29151,1,0,0:0:0:0:
|
||||
256,192,29305,1,8,0:0:0:0:
|
||||
256,192,30228,1,0,0:0:0:0:
|
||||
256,192,30382,1,0,0:0:0:0:
|
||||
256,192,30536,1,8,0:0:0:0:
|
||||
256,192,30997,1,0,0:0:0:0:
|
||||
256,192,31151,1,0,0:0:0:0:
|
||||
256,192,31305,1,8,0:0:0:0:
|
||||
256,192,31459,1,0,0:0:0:0:
|
||||
256,192,31613,1,8,0:0:0:0:
|
||||
256,192,31690,1,8,0:0:0:0:
|
||||
256,192,31766,1,8,0:0:0:0:
|
||||
256,192,32074,1,0,0:0:0:0:
|
||||
256,192,32228,1,0,0:0:0:0:
|
||||
256,192,32382,1,8,0:0:0:0:
|
||||
256,192,32843,1,0,0:0:0:0:
|
||||
256,192,32997,1,8,0:0:0:0:
|
||||
256,192,33151,1,0,0:0:0:0:
|
||||
256,192,33305,1,0,0:0:0:0:
|
||||
256,192,33459,1,8,0:0:0:0:
|
||||
256,192,33536,1,8,0:0:0:0:
|
||||
256,192,33613,1,8,0:0:0:0:
|
||||
256,192,33920,1,4,0:0:0:0:
|
||||
256,192,34843,1,12,0:0:0:0:
|
||||
256,192,35766,1,4,0:0:0:0:
|
||||
256,192,36228,1,4,0:0:0:0:
|
||||
256,192,36690,1,12,0:0:0:0:
|
||||
256,192,37613,1,4,0:0:0:0:
|
||||
256,192,38536,1,0,0:0:0:0:
|
||||
256,192,39459,1,0,0:0:0:0:
|
||||
256,192,39920,1,0,0:0:0:0:
|
||||
256,192,40382,1,0,0:0:0:0:
|
||||
256,192,41305,1,12,0:0:0:0:
|
||||
256,192,42228,1,8,0:0:0:0:
|
||||
256,192,43151,1,8,0:0:0:0:
|
||||
256,192,43613,1,8,0:0:0:0:
|
||||
256,192,44074,1,8,0:0:0:0:
|
||||
256,192,44997,1,8,0:0:0:0:
|
||||
256,192,45151,1,8,0:0:0:0:
|
||||
256,192,45305,1,0,0:0:0:0:
|
||||
256,192,45459,1,8,0:0:0:0:
|
||||
256,192,45766,1,0,0:0:0:0:
|
||||
256,192,45920,1,8,0:0:0:0:
|
||||
256,192,46228,1,0,0:0:0:0:
|
||||
256,192,46536,1,8,0:0:0:0:
|
||||
256,192,46843,1,8,0:0:0:0:
|
||||
256,192,46997,1,8,0:0:0:0:
|
||||
256,192,47151,1,0,0:0:0:0:
|
||||
256,192,47305,1,8,0:0:0:0:
|
||||
256,192,47613,1,0,0:0:0:0:
|
||||
256,192,47766,1,8,0:0:0:0:
|
||||
256,192,48074,1,8,0:0:0:0:
|
||||
256,192,48382,1,8,0:0:0:0:
|
||||
256,192,48690,1,8,0:0:0:0:
|
||||
256,192,48843,1,8,0:0:0:0:
|
||||
256,192,48997,1,0,0:0:0:0:
|
||||
256,192,49151,1,8,0:0:0:0:
|
||||
256,192,49459,1,0,0:0:0:0:
|
||||
256,192,49613,1,8,0:0:0:0:
|
||||
256,192,49920,1,8,0:0:0:0:
|
||||
256,192,50228,1,0,0:0:0:0:
|
||||
256,192,50536,1,8,0:0:0:0:
|
||||
256,192,50690,1,8,0:0:0:0:
|
||||
256,192,50843,1,0,0:0:0:0:
|
||||
256,192,50997,1,8,0:0:0:0:
|
||||
256,192,51305,1,0,0:0:0:0:
|
||||
256,192,51459,1,8,0:0:0:0:
|
||||
256,192,51766,1,0,0:0:0:0:
|
||||
256,192,52074,1,8,0:0:0:0:
|
||||
256,192,52382,1,4,0:0:0:0:
|
||||
256,192,54228,1,12,0:0:0:0:
|
||||
256,192,54690,1,12,0:0:0:0:
|
||||
256,192,55151,1,12,0:0:0:0:
|
||||
256,192,55459,1,12,0:0:0:0:
|
||||
256,192,56074,1,4,0:0:0:0:
|
||||
256,192,56997,1,4,0:0:0:0:
|
||||
256,192,57920,1,4,0:0:0:0:
|
||||
256,192,58843,1,4,0:0:0:0:
|
||||
256,192,59766,1,8,0:0:0:0:
|
||||
256,192,59920,1,8,0:0:0:0:
|
||||
256,192,60074,1,8,0:0:0:0:
|
||||
256,192,60228,1,8,0:0:0:0:
|
||||
256,192,60382,1,8,0:0:0:0:
|
||||
256,192,60690,1,0,0:0:0:0:
|
||||
256,192,60843,1,0,0:0:0:0:
|
||||
256,192,60997,1,0,0:0:0:0:
|
||||
256,192,61151,1,0,0:0:0:0:
|
||||
256,192,61305,1,0,0:0:0:0:
|
||||
256,192,61613,1,8,0:0:0:0:
|
||||
256,192,61766,1,8,0:0:0:0:
|
||||
256,192,61920,1,8,0:0:0:0:
|
||||
256,192,62074,1,8,0:0:0:0:
|
||||
256,192,62228,1,8,0:0:0:0:
|
||||
256,192,62536,12,0,63305,0:0:0:0:
|
||||
256,192,63459,5,12,0:0:0:0:
|
||||
256,192,63766,1,4,0:0:0:0:
|
||||
256,192,64074,1,4,0:0:0:0:
|
||||
256,192,64382,1,12,0:0:0:0:
|
||||
256,192,64997,1,12,0:0:0:0:
|
||||
256,192,65305,1,4,0:0:0:0:
|
||||
256,192,65766,1,4,0:0:0:0:
|
||||
256,192,66228,1,12,0:0:0:0:
|
||||
256,192,67151,1,12,0:0:0:0:
|
||||
256,192,67459,1,4,0:0:0:0:
|
||||
256,192,67766,1,4,0:0:0:0:
|
||||
256,192,68074,1,12,0:0:0:0:
|
||||
256,192,68997,1,12,0:0:0:0:
|
||||
256,192,69305,1,4,0:0:0:0:
|
||||
256,192,69613,1,4,0:0:0:0:
|
||||
256,192,69920,1,12,0:0:0:0:
|
||||
256,192,70843,5,4,0:0:0:0:
|
||||
256,192,71151,1,8,0:0:0:0:
|
||||
256,192,71305,1,8,0:0:0:0:
|
||||
256,192,71459,1,8,0:0:0:0:
|
||||
256,192,71766,1,8,0:0:0:0:
|
||||
256,192,72074,1,0,0:0:0:0:
|
||||
256,192,72690,1,4,0:0:0:0:
|
||||
256,192,72997,1,8,0:0:0:0:
|
||||
256,192,73151,1,8,0:0:0:0:
|
||||
256,192,73305,1,8,0:0:0:0:
|
||||
256,192,73459,1,8,0:0:0:0:
|
||||
256,192,73613,1,0,0:0:0:0:
|
||||
256,192,73920,1,8,0:0:0:0:
|
||||
256,192,74536,1,4,0:0:0:0:
|
||||
256,192,75459,1,4,0:0:0:0:
|
||||
256,192,76382,1,4,0:0:0:0:
|
||||
256,192,77305,1,12,0:0:0:0:
|
||||
256,192,77459,1,12,0:0:0:0:
|
||||
256,192,77613,1,12,0:0:0:0:
|
||||
256,192,78228,5,4,0:0:0:0:
|
||||
256,192,78536,1,8,0:0:0:0:
|
||||
256,192,78613,1,8,0:0:0:0:
|
||||
256,192,78690,1,8,0:0:0:0:
|
||||
256,192,78843,1,0,0:0:0:0:
|
||||
256,192,78997,1,8,0:0:0:0:
|
||||
256,192,79151,1,0,0:0:0:0:
|
||||
256,192,79459,1,8,0:0:0:0:
|
||||
256,192,79536,1,8,0:0:0:0:
|
||||
256,192,79613,1,8,0:0:0:0:
|
||||
256,192,79766,1,0,0:0:0:0:
|
||||
256,192,79920,1,8,0:0:0:0:
|
||||
256,192,80074,1,0,0:0:0:0:
|
||||
256,192,80382,1,8,0:0:0:0:
|
||||
256,192,80459,1,8,0:0:0:0:
|
||||
256,192,80536,1,8,0:0:0:0:
|
||||
256,192,80690,1,0,0:0:0:0:
|
||||
256,192,80843,1,8,0:0:0:0:
|
||||
256,192,80997,2,0,L|400:192,2,140
|
||||
256,192,81920,1,12,0:0:0:0:
|
||||
256,192,82228,1,0,0:0:0:0:
|
||||
256,192,82382,1,0,0:0:0:0:
|
||||
256,192,82459,1,0,0:0:0:0:
|
||||
256,192,82536,1,0,0:0:0:0:
|
||||
256,192,82690,1,0,0:0:0:0:
|
||||
256,192,82843,1,8,0:0:0:0:
|
||||
256,192,83151,1,0,0:0:0:0:
|
||||
256,192,83305,1,0,0:0:0:0:
|
||||
256,192,83382,1,0,0:0:0:0:
|
||||
256,192,83459,1,0,0:0:0:0:
|
||||
256,192,83613,1,0,0:0:0:0:
|
||||
256,192,83766,1,8,0:0:0:0:
|
||||
256,192,84074,1,0,0:0:0:0:
|
||||
256,192,84228,1,0,0:0:0:0:
|
||||
256,192,84305,1,0,0:0:0:0:
|
||||
256,192,84382,1,0,0:0:0:0:
|
||||
256,192,84536,1,8,0:0:0:0:
|
||||
256,192,84690,2,0,L|400:192,2,140
|
||||
256,192,85613,1,8,0:0:0:0:
|
||||
256,192,85766,1,8,0:0:0:0:
|
||||
256,192,85920,1,0,0:0:0:0:
|
||||
256,192,86074,1,8,0:0:0:0:
|
||||
256,192,86228,1,8,0:0:0:0:
|
||||
256,192,86382,1,0,0:0:0:0:
|
||||
256,192,86536,1,8,0:0:0:0:
|
||||
256,192,86843,1,0,0:0:0:0:
|
||||
256,192,87151,1,8,0:0:0:0:
|
||||
256,192,87459,1,4,0:0:0:0:
|
||||
256,192,87766,1,4,0:0:0:0:
|
||||
256,192,88074,1,4,0:0:0:0:
|
||||
256,192,88382,1,4,0:0:0:0:
|
||||
+5055
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,593 @@
|
||||
osu file format v14
|
||||
|
||||
[General]
|
||||
AudioFilename: -trappola_bewitching-.mp3
|
||||
AudioLeadIn: 0
|
||||
PreviewTime: 36319
|
||||
Countdown: 0
|
||||
SampleSet: Normal
|
||||
StackLeniency: 0.7
|
||||
Mode: 2
|
||||
LetterboxInBreaks: 0
|
||||
WidescreenStoryboard: 1
|
||||
|
||||
[Editor]
|
||||
DistanceSpacing: 1
|
||||
BeatDivisor: 4
|
||||
GridSize: 8
|
||||
TimelineZoom: 1.5
|
||||
|
||||
[Metadata]
|
||||
Title:trappola bewitching
|
||||
TitleUnicode:妖艶魔女 -trappola bewitching-
|
||||
Artist:gmtn. (witch's slave)
|
||||
ArtistUnicode:gmtn. (witch's slave)
|
||||
Creator:Du5t
|
||||
Version:Platter
|
||||
Source:Arcaea
|
||||
Tags:Vicious Labyrinth Conflict アーケア gothic hardcore Hardtrapcore sanyi idust idu5t daletto nuvolina greaper
|
||||
BeatmapID:2118524
|
||||
BeatmapSetID:994994
|
||||
|
||||
[Difficulty]
|
||||
HPDrainRate:5
|
||||
CircleSize:3.5
|
||||
OverallDifficulty:8
|
||||
ApproachRate:8
|
||||
SliderMultiplier:1.45
|
||||
SliderTickRate:1
|
||||
|
||||
[Events]
|
||||
//Background and Video events
|
||||
0,0,"background.jpg",0,0
|
||||
//Break Periods
|
||||
//Storyboard Layer 0 (Background)
|
||||
//Storyboard Layer 1 (Fail)
|
||||
//Storyboard Layer 2 (Pass)
|
||||
//Storyboard Layer 3 (Foreground)
|
||||
//Storyboard Layer 4 (Overlay)
|
||||
//Storyboard Sound Samples
|
||||
|
||||
[TimingPoints]
|
||||
1353,315.789473684211,4,1,11,50,1,0
|
||||
3879,-100,4,1,11,75,0,0
|
||||
5142,-100,4,2,11,50,0,0
|
||||
16195,-100,4,1,22,80,0,0
|
||||
16510,-100,4,1,11,70,0,0
|
||||
18800,-100,4,1,11,70,0,0
|
||||
19037,-100,4,1,11,70,0,0
|
||||
23774,-100,4,1,11,70,0,0
|
||||
24089,-100,4,1,11,74,0,0
|
||||
26300,-100,4,1,22,80,0,0
|
||||
26616,-100,4,1,33,78,0,1
|
||||
26695,-100,4,1,33,78,0,0
|
||||
31353,-100,4,1,22,80,0,0
|
||||
31668,-100,4,1,33,78,0,1
|
||||
31747,-100,4,1,33,78,0,0
|
||||
36405,-100,4,1,33,60,0,0
|
||||
36721,-100,4,1,11,83,0,1
|
||||
56931,-100,4,1,44,73,0,0
|
||||
61984,-100,4,1,44,73,0,0
|
||||
64510,-100,4,1,44,76,0,0
|
||||
65142,-100,4,1,44,79,0,0
|
||||
65458,-100,4,1,44,82,0,0
|
||||
65774,-100,4,1,22,70,0,0
|
||||
66405,-100,4,1,22,75,0,0
|
||||
66721,-100,4,1,22,80,0,0
|
||||
67037,-100,4,2,22,65,0,0
|
||||
75879,-100,4,1,22,70,0,0
|
||||
76510,-100,4,1,22,75,0,0
|
||||
76826,-100,4,1,22,80,0,0
|
||||
77142,-100,4,1,22,60,0,0
|
||||
77774,-100,4,2,22,75,0,0
|
||||
80931,-100,4,2,22,80,0,0
|
||||
81563,-100,4,2,22,80,0,0
|
||||
82195,-100,4,2,22,75,0,0
|
||||
86931,-100,4,1,22,80,0,0
|
||||
87247,-100,4,1,33,70,0,1
|
||||
87326,-100,4,1,33,78,0,0
|
||||
89774,-100,4,1,33,78,0,1
|
||||
89853,-100,4,1,33,78,0,0
|
||||
91984,-100,4,1,22,80,0,0
|
||||
92300,-100,4,1,11,83,0,1
|
||||
97195,-100,4,1,11,83,0,0
|
||||
97353,-100,4,1,44,83,0,1
|
||||
102247,-100,4,1,44,83,0,0
|
||||
102405,-100,4,1,44,87,0,1
|
||||
104931,-100,4,1,11,90,0,1
|
||||
106195,-100,4,1,44,94,0,1
|
||||
107458,-100,4,1,11,80,0,0
|
||||
111879,-100,4,1,22,70,0,0
|
||||
112510,-100,4,1,11,80,0,0
|
||||
115037,-100,4,1,44,70,0,0
|
||||
115353,-100,4,1,44,75,0,0
|
||||
115668,-100,4,1,44,80,0,0
|
||||
115826,-100,4,1,44,85,0,0
|
||||
115984,-100,4,1,44,90,0,0
|
||||
116142,-100,4,1,44,95,0,0
|
||||
116300,-100,4,2,11,60,0,0
|
||||
|
||||
|
||||
[Colours]
|
||||
Combo1 : 128,128,255
|
||||
Combo2 : 105,50,252
|
||||
Combo3 : 87,0,174
|
||||
|
||||
[HitObjects]
|
||||
256,192,1353,12,0,2300,0:0:0:0:
|
||||
104,278,2616,6,0,P|46:250|122:151,1,217.5,4|0,0:0|0:0,0:0:0:0:
|
||||
296,95,3563,1,0,0:0:0:0:
|
||||
223,87,3721,1,0,0:0:0:0:
|
||||
377,239,3879,6,0,L|463:215,3,72.5,8|8|8|8,0:0|0:0|0:0|0:0,0:0:0:0:
|
||||
291,102,4510,2,0,P|227:128|221:155,1,96.6666666666667,8|8,0:0|0:0,0:0:0:0:
|
||||
327,169,4931,1,8,0:0:0:0:
|
||||
47,271,5142,6,0,P|14:234|74:178,1,145,12|8,0:0|0:0,0:0:0:0:
|
||||
322,97,5774,2,0,L|328:177,1,72.5,8|0,0:0|0:0,0:0:0:0:
|
||||
235,159,6089,2,0,L|225:236,1,72.5,8|0,0:0|0:0,0:0:0:0:
|
||||
380,310,6405,6,0,P|415:288|355:217,1,145,8|8,0:0|0:0,0:0:0:0:
|
||||
136,88,7037,2,0,L|231:81,1,72.5,8|0,0:0|0:0,0:0:0:0:
|
||||
288,71,7353,2,0,L|380:65,1,72.5,8|0,0:0|0:0,0:0:0:0:
|
||||
207,294,7668,6,0,P|166:266|225:216,1,145,8|8,0:0|0:0,0:0:0:0:
|
||||
474,87,8300,2,0,L|480:174,1,72.5,8|0,0:0|0:0,0:0:0:0:
|
||||
394,216,8616,2,0,L|377:319,1,72.5,8|0,0:0|0:0,0:0:0:0:
|
||||
228,106,8931,6,0,L|311:98,2,72.5,8|0|8,0:0|0:0|0:0,0:0:0:0:
|
||||
473,80,9563,2,0,L|474:182,1,72.5,8|0,0:0|0:0,0:0:0:0:
|
||||
396,212,9879,2,0,L|477:212,1,72.5,8|0,0:0|0:0,0:0:0:0:
|
||||
314,299,10195,6,0,P|276:242|335:209,1,145,8|8,0:0|0:0,0:0:0:0:
|
||||
65,78,10826,2,0,L|54:167,1,72.5,8|0,0:0|0:0,0:0:0:0:
|
||||
148,151,11142,2,0,L|162:247,1,72.5,8|0,0:0|0:0,0:0:0:0:
|
||||
313,163,11458,6,0,P|351:126|277:73,1,145,8|8,0:0|0:0,0:0:0:0:
|
||||
40,252,12089,2,0,L|121:258,1,72.5,8|0,0:0|0:0,0:0:0:0:
|
||||
192,202,12405,2,0,L|110:194,1,72.5,8|0,0:0|0:0,0:0:0:0:
|
||||
275,280,12721,6,0,P|276:231|182:197,1,145,8|8,0:0|0:0,0:0:0:0:
|
||||
460,55,13353,2,0,L|449:138,1,72.5,8|0,0:0|0:0,0:0:0:0:
|
||||
356,136,13668,2,0,L|381:231,1,72.5,8|0,0:0|0:0,0:0:0:0:
|
||||
219,220,13984,2,0,L|295:225,2,72.5
|
||||
366,148,14616,6,0,L|281:138,1,72.5
|
||||
209,281,14931,2,0,L|293:267,1,72.5
|
||||
374,284,15247,6,0,P|433:267|367:109,1,290
|
||||
83,109,16195,2,0,L|95:208,1,72.5,8|0,0:0|0:0,0:0:0:0:
|
||||
311,291,16510,6,0,P|356:270|299:208,1,145,12|8,0:0|0:0,0:0:0:0:
|
||||
245,198,16984,1,0,0:0:0:0:
|
||||
398,157,17142,2,0,L|424:82,1,72.5,10|2,0:0|0:0,0:0:0:0:
|
||||
386,83,17379,1,2,0:0:0:0:
|
||||
349,79,17458,1,10,0:0:0:0:
|
||||
75,139,17774,6,0,L|58:222,1,72.5,8|0,0:0|0:0,0:0:0:0:
|
||||
147,213,18089,2,0,L|178:299,1,72.5,8|0,0:0|0:0,0:0:0:0:
|
||||
329,203,18405,2,0,L|423:187,1,72.5,10|2,0:0|0:0,0:0:0:0:
|
||||
364,197,18642,1,2,0:0:0:0:
|
||||
55,83,19037,6,0,L|139:93,1,72.5,8|0,0:0|0:0,0:0:0:0:
|
||||
215,142,19353,2,0,L|228:236,1,72.5,8|0,0:0|0:0,0:0:0:0:
|
||||
74,183,19668,2,0,L|57:273,1,72.5,10|2,0:0|0:0,0:0:0:0:
|
||||
96,261,19905,1,2,0:0:0:0:
|
||||
131,268,19984,1,10,0:0:0:0:
|
||||
403,223,20300,6,0,L|481:211,1,72.5,8|0,0:0|0:0,0:0:0:0:
|
||||
387,153,20616,2,0,L|311:141,1,72.5,8|0,0:0|0:0,0:0:0:0:
|
||||
468,127,20931,2,0,L|491:57,1,72.5,10|2,0:0|0:0,0:0:0:0:
|
||||
454,48,21168,1,2,0:0:0:0:
|
||||
417,43,21247,1,10,0:0:0:0:
|
||||
146,171,21563,6,0,L|153:257,1,72.5,8|0,0:0|0:0,0:0:0:0:
|
||||
245,226,21879,2,0,L|225:313,1,72.5,8|0,0:0|0:0,0:0:0:0:
|
||||
74,73,22195,2,0,L|69:160,1,72.5,10|2,0:0|0:0,0:0:0:0:
|
||||
107,140,22431,1,2,0:0:0:0:
|
||||
142,134,22510,1,10,0:0:0:0:
|
||||
417,284,22826,6,0,L|426:199,1,72.5,8|0,0:0|0:0,0:0:0:0:
|
||||
342,207,23142,2,0,L|335:121,1,72.5,8|0,0:0|0:0,0:0:0:0:
|
||||
182,108,23458,2,0,L|104:102,1,72.5,10|2,0:0|0:0,0:0:0:0:
|
||||
146,104,23695,1,2,0:0:0:0:
|
||||
434,284,24089,5,8,0:0:0:0:
|
||||
486,214,24247,1,8,0:0:0:0:
|
||||
462,130,24405,1,8,0:0:0:0:
|
||||
382,95,24563,1,8,0:0:0:0:
|
||||
228,204,24721,2,0,L|226:113,1,72.5,10|10,0:0|0:0,0:0:0:0:
|
||||
263,131,24958,1,2,0:0:0:0:
|
||||
299,131,25037,1,10,0:0:0:0:
|
||||
53,275,25353,6,0,L|37:233,2,36.25,8|8|8,0:0|0:0|0:0,0:0:0:0:
|
||||
153,179,25668,1,8,0:0:0:0:
|
||||
116,176,25747,1,8,0:0:0:0:
|
||||
79,174,25826,1,8,0:0:0:0:
|
||||
175,100,25984,1,8,0:0:0:0:
|
||||
138,97,26063,1,8,0:0:0:0:
|
||||
101,93,26142,1,8,0:0:0:0:
|
||||
338,264,26300,2,0,L|415:255,1,72.5,8|0,0:0|0:0,0:0:0:0:
|
||||
184,220,26616,6,0,P|156:181|204:138,1,108.75,12|8,0:0|0:0,0:0:0:0:
|
||||
386,133,27089,2,0,L|399:7,1,108.75,8|8,0:0|0:0,0:0:0:0:
|
||||
199,27,27563,1,8,0:0:0:0:
|
||||
276,22,27721,1,8,0:0:0:0:
|
||||
49,266,27879,6,0,P|34:222|91:182,1,108.75,8|8,0:0|0:0,0:0:0:0:
|
||||
182,189,28353,1,8,0:0:0:0:
|
||||
352,146,28510,2,0,L|379:103,2,36.25,14|2|2,0:0|0:0|0:0,0:0:0:0:
|
||||
183,88,28826,1,14,0:0:0:0:
|
||||
146,84,28905,1,2,0:0:0:0:
|
||||
110,78,28984,1,2,0:0:0:0:
|
||||
351,297,29142,6,0,P|386:268|342:229,1,108.75,8|8,0:0|0:0,0:0:0:0:
|
||||
155,216,29616,2,0,L|146:99,1,108.75,8|8,0:0|0:0,0:0:0:0:
|
||||
341,71,30089,1,8,0:0:0:0:
|
||||
419,64,30247,1,8,0:0:0:0:
|
||||
187,267,30405,6,0,P|186:225|250:196,1,108.75,8|8,0:0|0:0,0:0:0:0:
|
||||
130,152,30879,1,8,0:0:0:0:
|
||||
299,120,31037,2,0,L|383:111,1,72.5,8|8,0:0|0:0,0:0:0:0:
|
||||
132,37,31353,2,0,L|108:111,1,72.5,8|0,0:0|0:0,0:0:0:0:
|
||||
336,305,31668,6,0,P|386:290|407:220,1,108.75,12|8,0:0|0:0,0:0:0:0:
|
||||
212,226,32142,2,0,P|169:214|142:146,1,108.75,8|8,0:0|0:0,0:0:0:0:
|
||||
336,81,32616,1,8,0:0:0:0:
|
||||
260,70,32774,1,8,0:0:0:0:
|
||||
485,287,32931,6,0,P|493:231|428:210,1,108.75,8|8,0:0|0:0,0:0:0:0:
|
||||
346,209,33405,1,8,0:0:0:0:
|
||||
176,152,33563,2,0,L|151:115,2,36.25,14|2|2,0:0|0:0|0:0,0:0:0:0:
|
||||
346,111,33879,1,14,0:0:0:0:
|
||||
382,107,33958,1,2,0:0:0:0:
|
||||
418,103,34037,1,2,0:0:0:0:
|
||||
193,276,34195,6,0,P|163:247|198:192,1,108.75,8|8,0:0|0:0,0:0:0:0:
|
||||
382,159,34668,2,0,L|264:142,1,108.75,8|8,0:0|0:0,0:0:0:0:
|
||||
80,69,35142,1,8,0:0:0:0:
|
||||
45,143,35300,1,8,0:0:0:0:
|
||||
276,286,35458,6,0,L|361:274,1,72.5,8|0,0:0|0:0,0:0:0:0:
|
||||
311,268,35695,1,8,0:0:0:0:
|
||||
155,177,35853,2,0,L|144:225,1,36.25,0|8,0:0|0:0,0:0:0:0:
|
||||
381,169,36089,1,8,0:0:0:0:
|
||||
426,156,36168,1,8,0:0:0:0:
|
||||
463,127,36247,1,8,0:0:0:0:
|
||||
479,82,36326,1,8,0:0:0:0:
|
||||
477,34,36405,1,0,0:0:0:0:
|
||||
95,267,36721,6,0,L|247:253,1,145,14|8,0:0|0:0,0:0:0:0:
|
||||
161,205,37195,1,2,0:0:0:0:
|
||||
314,206,37353,2,0,L|319:118,1,72.5,10|2,0:0|0:0,0:0:0:0:
|
||||
165,120,37668,2,0,L|167:32,1,72.5,10|2,0:0|0:0,0:0:0:0:
|
||||
430,52,38142,6,0,L|477:54,1,36.25,2|2,0:0|0:0,0:0:0:0:
|
||||
430,52,38300,2,0,L|335:49,1,72.5,10|2,0:0|0:0,0:0:0:0:
|
||||
272,145,38616,2,0,L|216:142,2,36.25,10|2|2,0:0|0:0|0:0,0:0:0:0:
|
||||
427,255,38931,1,10,0:0:0:0:
|
||||
354,247,39089,1,2,0:0:0:0:
|
||||
127,252,39247,6,0,P|97:214|173:172,1,145,10|8,0:0|0:0,0:0:0:0:
|
||||
242,166,39721,1,2,0:0:0:0:
|
||||
87,103,39879,2,0,L|76:189,1,72.5,10|2,0:0|0:0,0:0:0:0:
|
||||
232,60,40195,2,0,L|325:73,1,72.5,10|2,0:0|0:0,0:0:0:0:
|
||||
72,266,40510,6,0,L|28:260,2,36.25,10|2|2,0:0|0:0|0:0,0:0:0:0:
|
||||
156,202,40826,1,10,0:0:0:0:
|
||||
119,198,40905,1,2,0:0:0:0:
|
||||
82,195,40984,1,2,0:0:0:0:
|
||||
241,176,41142,5,10,0:0:0:0:
|
||||
275,149,41221,1,0,0:0:0:0:
|
||||
292,109,41300,1,2,0:0:0:0:
|
||||
288,65,41379,1,0,0:0:0:0:
|
||||
258,33,41458,1,10,0:0:0:0:
|
||||
215,24,41537,1,0,0:0:0:0:
|
||||
172,32,41616,1,10,0:0:0:0:
|
||||
404,302,41774,6,0,P|441:273|379:216,1,145,14|8,0:0|0:0,0:0:0:0:
|
||||
319,211,42247,1,2,0:0:0:0:
|
||||
471,65,42405,2,0,L|484:161,1,72.5,10|2,0:0|0:0,0:0:0:0:
|
||||
326,55,42721,2,0,L|337:150,1,72.5,10|2,0:0|0:0,0:0:0:0:
|
||||
81,177,43195,6,0,L|38:174,1,36.25,2|2,0:0|0:0,0:0:0:0:
|
||||
81,177,43353,2,0,L|164:183,1,72.5,10|2,0:0|0:0,0:0:0:0:
|
||||
240,187,43668,2,0,L|283:188,1,36.25,10|2,0:0|0:0,0:0:0:0:
|
||||
276,187,43826,1,2,0:0:0:0:
|
||||
123,273,43984,1,10,0:0:0:0:
|
||||
200,270,44142,1,2,0:0:0:0:
|
||||
426,279,44300,6,0,P|465:256|407:198,1,145,14|8,0:0|0:0,0:0:0:0:
|
||||
338,197,44774,1,2,0:0:0:0:
|
||||
186,146,44931,2,0,L|107:141,1,72.5,14|2,0:0|0:0,0:0:0:0:
|
||||
268,70,45247,2,0,L|361:75,1,72.5,10|2,0:0|0:0,0:0:0:0:
|
||||
116,301,45563,6,0,L|108:216,1,72.5,14|2,0:0|0:0,0:0:0:0:
|
||||
145,226,45800,1,2,0:0:0:0:
|
||||
181,223,45879,2,0,L|82:227,1,72.5,14|2,0:0|0:0,0:0:0:0:
|
||||
337,187,46195,5,14,0:0:0:0:
|
||||
377,162,46274,1,2,0:0:0:0:
|
||||
391,117,46353,1,10,0:0:0:0:
|
||||
373,74,46431,1,2,0:0:0:0:
|
||||
329,56,46510,1,14,0:0:0:0:
|
||||
368,51,46589,1,2,0:0:0:0:
|
||||
407,42,46668,1,10,0:0:0:0:
|
||||
177,297,46826,6,0,P|128:254|185:206,1,145,14|8,0:0|0:0,0:0:0:0:
|
||||
246,204,47300,1,2,0:0:0:0:
|
||||
94,74,47458,2,0,L|84:157,1,72.5,10|2,0:0|0:0,0:0:0:0:
|
||||
237,55,47774,2,0,L|158:48,1,72.5,10|2,0:0|0:0,0:0:0:0:
|
||||
425,198,48247,6,0,L|430:149,1,36.25,2|2,0:0|0:0,0:0:0:0:
|
||||
392,157,48405,2,0,L|317:152,1,72.5,10|0,0:0|0:0,0:0:0:0:
|
||||
401,84,48721,2,0,L|445:86,2,36.25,10|2|2,0:0|0:0|0:0,0:0:0:0:
|
||||
246,47,49037,1,10,0:0:0:0:
|
||||
319,42,49195,1,2,0:0:0:0:
|
||||
86,272,49353,6,0,P|41:261|41:166,1,145,10|8,0:0|0:0,0:0:0:0:
|
||||
104,173,49826,1,2,0:0:0:0:
|
||||
257,187,49984,2,0,L|263:111,1,72.5,10|2,0:0|0:0,0:0:0:0:
|
||||
110,41,50300,2,0,L|200:48,1,72.5,10|2,0:0|0:0,0:0:0:0:
|
||||
414,255,50616,6,0,L|461:257,2,36.25,10|2|2,0:0|0:0|0:0,0:0:0:0:
|
||||
333,137,50931,2,0,L|330:188,1,36.25,10|2,0:0|0:0,0:0:0:0:
|
||||
368,178,51089,1,2,0:0:0:0:
|
||||
223,249,51247,5,10,0:0:0:0:
|
||||
188,217,51326,1,0,0:0:0:0:
|
||||
178,170,51405,1,2,0:0:0:0:
|
||||
188,123,51484,1,0,0:0:0:0:
|
||||
224,92,51563,1,10,0:0:0:0:
|
||||
262,75,51642,1,0,0:0:0:0:
|
||||
308,67,51721,1,10,0:0:0:0:
|
||||
61,298,51879,6,0,P|62:243|144:201,1,145,14|8,0:0|0:0,0:0:0:0:
|
||||
205,197,52353,1,2,0:0:0:0:
|
||||
356,160,52510,2,0,L|363:248,1,72.5,10|2,0:0|0:0,0:0:0:0:
|
||||
205,92,52826,2,0,L|294:87,1,72.5,10|2,0:0|0:0,0:0:0:0:
|
||||
18,181,53300,6,0,L|24:228,1,36.25,2|2,0:0|0:0,0:0:0:0:
|
||||
59,216,53458,2,0,L|138:205,1,72.5,10|2,0:0|0:0,0:0:0:0:
|
||||
218,195,53774,1,10,0:0:0:0:
|
||||
229,160,53853,1,2,0:0:0:0:
|
||||
240,126,53931,1,2,0:0:0:0:
|
||||
88,71,54089,1,10,0:0:0:0:
|
||||
164,63,54247,1,2,0:0:0:0:
|
||||
392,298,54405,6,0,P|437:265|384:220,1,145,14|8,0:0|0:0,0:0:0:0:
|
||||
312,205,54879,1,2,0:0:0:0:
|
||||
161,118,55037,2,0,L|246:113,1,72.5,14|2,0:0|0:0,0:0:0:0:
|
||||
389,79,55353,2,0,L|391:164,1,72.5,10|2,0:0|0:0,0:0:0:0:
|
||||
161,248,55668,6,0,L|80:251,1,72.5,14|2,0:0|0:0,0:0:0:0:
|
||||
177,178,55984,2,0,L|91:180,1,72.5,14|2,0:0|0:0,0:0:0:0:
|
||||
334,278,56300,5,14,0:0:0:0:
|
||||
368,240,56379,1,2,0:0:0:0:
|
||||
364,189,56458,1,10,0:0:0:0:
|
||||
327,153,56537,1,2,0:0:0:0:
|
||||
276,145,56616,1,14,0:0:0:0:
|
||||
316,130,56695,1,2,0:0:0:0:
|
||||
358,119,56774,1,10,0:0:0:0:
|
||||
117,283,56931,6,0,P|83:259|147:229,1,145,4|8,0:0|0:0,0:0:0:0:
|
||||
296,183,57563,2,0,L|377:188,2,72.5,0|0|8,0:0|0:0|0:0,0:0:0:0:
|
||||
142,74,58037,6,0,L|152:154,1,72.5
|
||||
231,141,58353,2,0,L|246:228,1,72.5,0|8,0:0|0:0,0:0:0:0:
|
||||
161,220,58668,1,0,0:0:0:0:
|
||||
318,272,58826,2,0,L|400:261,1,72.5,10|0,0:0|0:0,0:0:0:0:
|
||||
236,174,59142,2,0,L|153:187,1,72.5,10|0,0:0|0:0,0:0:0:0:
|
||||
394,176,59458,6,0,P|433:145|370:105,1,145,4|8,0:0|0:0,0:0:0:0:
|
||||
228,102,60089,2,0,L|144:98,2,72.5,0|0|8,0:0|0:0|0:0,0:0:0:0:
|
||||
381,182,60563,6,0,L|395:264,1,72.5
|
||||
319,227,60879,2,0,L|301:304,1,72.5,0|8,0:0|0:0,0:0:0:0:
|
||||
382,311,61195,1,0,0:0:0:0:
|
||||
225,218,61353,2,0,L|236:133,1,72.5,10|2,0:0|0:0,0:0:0:0:
|
||||
387,120,61668,2,0,L|375:37,1,72.5,10|2,0:0|0:0,0:0:0:0:
|
||||
147,165,61984,6,0,L|65:161,1,72.5,8|0,0:0|0:0,0:0:0:0:
|
||||
158,93,62300,2,0,L|247:99,1,72.5,8|0,0:0|0:0,0:0:0:0:
|
||||
141,189,62616,2,0,L|132:281,1,72.5,8|0,0:0|0:0,0:0:0:0:
|
||||
219,196,62931,2,0,L|227:102,1,72.5,8|0,0:0|0:0,0:0:0:0:
|
||||
380,285,63247,6,0,L|386:192,1,72.5,8|0,0:0|0:0,0:0:0:0:
|
||||
309,213,63563,2,0,L|316:120,1,72.5,8|0,0:0|0:0,0:0:0:0:
|
||||
161,92,63879,2,0,L|84:87,1,72.5,10|0,0:0|0:0,0:0:0:0:
|
||||
242,33,64195,2,0,L|327:38,1,72.5,10|0,0:0|0:0,0:0:0:0:
|
||||
84,273,64510,5,8,0:0:0:0:
|
||||
40,189,64668,1,8,0:0:0:0:
|
||||
83,105,64826,1,8,0:0:0:0:
|
||||
171,71,64984,1,8,0:0:0:0:
|
||||
323,217,65142,6,0,L|379:218,2,36.25,8|8|8,0:0|0:0|0:0,0:0:0:0:
|
||||
169,146,65458,1,8,0:0:0:0:
|
||||
129,144,65537,1,8,0:0:0:0:
|
||||
89,141,65616,1,8,0:0:0:0:
|
||||
317,303,65774,1,8,0:0:0:0:
|
||||
341,220,65931,1,8,0:0:0:0:
|
||||
303,141,66089,1,8,0:0:0:0:
|
||||
221,111,66247,1,8,0:0:0:0:
|
||||
373,70,66405,6,0,L|433:73,2,36.25,8|8|8,0:0|0:0|0:0,0:0:0:0:
|
||||
219,200,66721,1,8,0:0:0:0:
|
||||
255,204,66800,1,8,0:0:0:0:
|
||||
291,207,66879,1,8,0:0:0:0:
|
||||
61,118,67037,5,2,0:0:0:0:
|
||||
208,137,67353,2,0,L|200:225,1,72.5
|
||||
128,202,67668,1,8,0:0:0:0:
|
||||
272,121,67984,2,0,L|264:220,1,72.5
|
||||
113,181,68300,5,2,0:0:0:0:
|
||||
257,304,68616,2,0,L|262:226,1,72.5,0|0,0:0|0:0,0:0:0:0:
|
||||
184,219,68931,1,8,0:0:0:0:
|
||||
260,147,69089,2,0,L|167:141,2,72.5,0|2|0,0:0|0:0|0:0,0:0:0:0:
|
||||
485,157,69563,5,2,0:0:0:0:
|
||||
328,224,69879,2,0,L|244:219,1,72.5,0|0,0:0|0:0,0:0:0:0:
|
||||
344,149,70195,1,8,0:0:0:0:
|
||||
192,209,70510,2,0,L|110:203,1,72.5,0|0,0:0|0:0,0:0:0:0:
|
||||
273,161,70826,5,2,0:0:0:0:
|
||||
134,141,71142,2,0,L|49:135,1,72.5
|
||||
216,78,71458,2,0,L|209:165,1,72.5,8|0,0:0|0:0,0:0:0:0:
|
||||
363,192,71774,1,2,0:0:0:0:
|
||||
390,269,71931,1,2,0:0:0:0:
|
||||
167,251,72089,5,2,0:0:0:0:
|
||||
308,145,72405,2,0,L|213:140,1,72.5
|
||||
323,73,72721,1,8,0:0:0:0:
|
||||
175,215,73037,2,0,L|259:222,1,72.5
|
||||
93,153,73353,5,2,0:0:0:0:
|
||||
241,50,73668,2,0,L|149:45,1,72.5
|
||||
248,117,73984,1,8,0:0:0:0:
|
||||
159,172,74142,2,0,L|154:270,1,72.5,0|2,0:0|0:0,0:0:0:0:
|
||||
236,249,74458,1,0,0:0:0:0:
|
||||
461,254,74616,5,2,0:0:0:0:
|
||||
490,208,74695,1,0,0:0:0:0:
|
||||
477,155,74774,1,0,0:0:0:0:
|
||||
397,119,74931,2,0,L|486:104,1,72.5,0|0,0:0|0:0,0:0:0:0:
|
||||
379,47,75247,1,8,0:0:0:0:
|
||||
238,212,75563,6,0,L|323:207,1,72.5,0|0,0:0|0:0,0:0:0:0:
|
||||
158,137,75879,1,8,0:0:0:0:
|
||||
129,201,76037,1,8,0:0:0:0:
|
||||
282,122,76195,1,8,0:0:0:0:
|
||||
310,46,76353,1,8,0:0:0:0:
|
||||
157,292,76510,5,8,0:0:0:0:
|
||||
119,242,76589,1,8,0:0:0:0:
|
||||
141,184,76668,1,8,0:0:0:0:
|
||||
189,145,76747,1,8,0:0:0:0:
|
||||
238,136,76826,1,8,0:0:0:0:
|
||||
192,122,76905,1,8,0:0:0:0:
|
||||
146,110,76984,1,8,0:0:0:0:
|
||||
298,237,77142,2,0,L|342:233,1,36.25
|
||||
209,169,77458,2,0,L|155:166,1,36.25,0|0,0:0|0:0,0:0:0:0:
|
||||
484,162,77774,6,0,L|468:87,1,72.5,6|0,0:0|0:0,0:0:0:0:
|
||||
315,228,78089,1,2,0:0:0:0:
|
||||
386,219,78247,1,2,0:0:0:0:
|
||||
320,168,78405,2,0,L|342:81,1,72.5,0|2,0:0|0:0,0:0:0:0:
|
||||
185,227,78721,6,0,L|110:223,1,72.5,2|2,0:0|0:0,0:0:0:0:
|
||||
266,161,79037,2,0,L|344:165,3,72.5,8|0|2|0,0:0|0:0|0:0|0:0,0:0:0:0:
|
||||
105,284,79668,6,0,L|97:205,1,72.5,2|0,0:0|0:0,0:0:0:0:
|
||||
182,136,79984,2,0,L|100:139,1,72.5,2|0,0:0|0:0,0:0:0:0:
|
||||
266,273,80300,2,0,L|306:198,1,72.5,10|0,0:0|0:0,0:0:0:0:
|
||||
147,63,80616,1,2,0:0:0:0:
|
||||
110,139,80774,1,2,0:0:0:0:
|
||||
338,62,80931,5,2,0:0:0:0:
|
||||
416,126,81089,2,0,L|332:117,2,72.5,2|2|2,0:0|0:0|0:0,0:0:0:0:
|
||||
264,275,81563,2,0,L|258:197,1,72.5,8|2,0:0|0:0,0:0:0:0:
|
||||
411,169,81879,1,2,0:0:0:0:
|
||||
331,146,82037,1,2,0:0:0:0:
|
||||
101,146,82195,6,0,L|97:66,1,72.5,6|0,0:0|0:0,0:0:0:0:
|
||||
172,66,82510,1,10,0:0:0:0:
|
||||
408,266,82826,6,0,L|421:172,1,72.5,10|0,0:0|0:0,0:0:0:0:
|
||||
263,123,83142,1,10,0:0:0:0:
|
||||
347,111,83300,1,10,0:0:0:0:
|
||||
360,77,83379,1,10,0:0:0:0:
|
||||
373,43,83458,1,2,0:0:0:0:
|
||||
284,35,83616,5,0,0:0:0:0:
|
||||
195,234,83774,2,0,L|279:240,1,72.5,10|0,0:0|0:0,0:0:0:0:
|
||||
114,116,84089,2,0,L|107:205,1,72.5,10|0,0:0|0:0,0:0:0:0:
|
||||
192,59,84405,1,10,0:0:0:0:
|
||||
155,57,84484,1,8,0:0:0:0:
|
||||
118,53,84563,1,8,0:0:0:0:
|
||||
356,287,84721,5,10,0:0:0:0:
|
||||
417,215,84879,1,8,0:0:0:0:
|
||||
402,121,85037,1,8,0:0:0:0:
|
||||
316,81,85195,1,8,0:0:0:0:
|
||||
165,132,85353,1,8,0:0:0:0:
|
||||
127,206,85510,1,8,0:0:0:0:
|
||||
279,162,85668,1,8,0:0:0:0:
|
||||
310,234,85826,1,8,0:0:0:0:
|
||||
157,295,85984,6,0,L|149:202,1,72.5,10|0,0:0|0:0,0:0:0:0:
|
||||
236,235,86300,1,8,0:0:0:0:
|
||||
149,224,86458,1,8,0:0:0:0:
|
||||
301,168,86616,1,8,0:0:0:0:
|
||||
344,172,86695,1,8,0:0:0:0:
|
||||
387,176,86774,1,8,0:0:0:0:
|
||||
163,87,86931,2,0,L|144:169,1,72.5,8|0,0:0|0:0,0:0:0:0:
|
||||
384,311,87247,6,0,P|406:275|349:230,1,108.75,14|8,0:0|0:0,0:0:0:0:
|
||||
175,137,87721,2,0,L|255:128,2,72.5,10|2|2,0:0|0:0|0:0,0:0:0:0:
|
||||
330,106,88195,2,0,L|344:192,1,72.5,10|10,0:0|0:0,0:0:0:0:
|
||||
116,274,88510,6,0,L|36:265,1,72.5,8|2,0:0|0:0,0:0:0:0:
|
||||
80,266,88747,1,10,0:0:0:0:
|
||||
115,260,88826,1,2,0:0:0:0:
|
||||
43,190,88984,1,10,0:0:0:0:
|
||||
196,181,89142,2,0,L|236:183,2,36.25,14|2|2,0:0|0:0|0:0,0:0:0:0:
|
||||
43,68,89458,1,14,0:0:0:0:
|
||||
82,69,89537,1,2,0:0:0:0:
|
||||
121,72,89616,1,2,0:0:0:0:
|
||||
351,289,89774,6,0,P|383:254|340:206,1,108.75,14|8,0:0|0:0,0:0:0:0:
|
||||
165,135,90247,2,0,L|244:130,2,72.5,10|2|2,0:0|0:0|0:0,0:0:0:0:
|
||||
319,79,90721,2,0,L|399:73,1,72.5,10|10,0:0|0:0,0:0:0:0:
|
||||
163,231,91037,6,0,L|113:233,1,36.25,10|2,0:0|0:0,0:0:0:0:
|
||||
90,232,91195,1,2,0:0:0:0:
|
||||
242,156,91353,2,0,L|244:207,1,36.25,2|2,0:0|0:0,0:0:0:0:
|
||||
207,194,91510,1,10,0:0:0:0:
|
||||
352,108,91668,2,0,L|399:108,2,36.25,10|2|10,0:0|0:0|0:0,0:0:0:0:
|
||||
128,60,91984,2,0,L|118:152,1,72.5,8|0,0:0|0:0,0:0:0:0:
|
||||
361,308,92300,6,0,P|401:281|338:223,1,145,14|8,0:0|0:0,0:0:0:0:
|
||||
282,223,92774,1,2,0:0:0:0:
|
||||
433,165,92931,2,0,L|440:71,1,72.5,10|2,0:0|0:0,0:0:0:0:
|
||||
284,125,93247,2,0,L|278:33,1,72.5,10|2,0:0|0:0,0:0:0:0:
|
||||
39,227,93721,6,0,L|99:219,1,36.25,2|2,0:0|0:0,0:0:0:0:
|
||||
111,218,93879,2,0,L|194:209,1,72.5,10|2,0:0|0:0,0:0:0:0:
|
||||
261,174,94195,1,10,0:0:0:0:
|
||||
270,139,94274,1,2,0:0:0:0:
|
||||
279,103,94353,1,2,0:0:0:0:
|
||||
128,59,94510,1,10,0:0:0:0:
|
||||
200,51,94668,1,2,0:0:0:0:
|
||||
423,295,94826,6,0,P|456:249|392:194,1,145,14|8,0:0|0:0,0:0:0:0:
|
||||
331,197,95300,1,2,0:0:0:0:
|
||||
179,113,95458,2,0,L|170:199,1,72.5,14|2,0:0|0:0,0:0:0:0:
|
||||
324,74,95774,2,0,L|404:77,1,72.5,10|2,0:0|0:0,0:0:0:0:
|
||||
159,307,96089,6,0,L|155:225,1,72.5,14|2,0:0|0:0,0:0:0:0:
|
||||
193,235,96326,1,2,0:0:0:0:
|
||||
228,235,96405,2,0,L|150:235,1,72.5,14|2,0:0|0:0,0:0:0:0:
|
||||
387,222,96721,1,14,0:0:0:0:
|
||||
426,196,96800,1,2,0:0:0:0:
|
||||
437,150,96879,1,10,0:0:0:0:
|
||||
410,110,96958,1,2,0:0:0:0:
|
||||
364,97,97037,2,0,L|369:48,1,36.25,14|2,0:0|0:0,0:0:0:0:
|
||||
407,64,97195,1,10,0:0:0:0:
|
||||
171,293,97353,6,0,P|127:279|109:186,1,145,6|10,0:0|0:0,0:0:0:0:
|
||||
178,189,97826,1,2,0:0:0:0:
|
||||
331,190,97984,2,0,L|345:100,1,72.5,2|0,0:0|0:0,0:0:0:0:
|
||||
259,49,98300,2,0,L|338:56,1,72.5,10|0,0:0|0:0,0:0:0:0:
|
||||
178,246,98616,5,2,0:0:0:0:
|
||||
268,200,98774,2,0,L|275:149,1,36.25,2|2,0:0|0:0,0:0:0:0:
|
||||
237,155,98931,2,0,L|152:143,1,72.5,10|2,0:0|0:0,0:0:0:0:
|
||||
318,97,99247,2,0,L|404:88,1,72.5,2|0,0:0|0:0,0:0:0:0:
|
||||
310,180,99563,2,0,L|395:172,1,72.5,10|2,0:0|0:0,0:0:0:0:
|
||||
153,190,99879,6,0,L|138:287,1,72.5,2|2,0:0|0:0,0:0:0:0:
|
||||
178,264,100116,1,0,0:0:0:0:
|
||||
214,268,100195,1,8,0:0:0:0:
|
||||
293,271,100353,1,2,0:0:0:0:
|
||||
142,89,100510,2,0,L|135:169,1,72.5,2|2,0:0|0:0,0:0:0:0:
|
||||
288,43,100826,1,10,0:0:0:0:
|
||||
210,32,100984,1,2,0:0:0:0:
|
||||
440,297,101142,6,0,L|457:208,1,72.5,2|2,0:0|0:0,0:0:0:0:
|
||||
417,221,101379,1,2,0:0:0:0:
|
||||
380,217,101458,2,0,L|288:209,1,72.5,10|2,0:0|0:0,0:0:0:0:
|
||||
460,153,101774,1,2,0:0:0:0:
|
||||
478,113,101853,1,0,0:0:0:0:
|
||||
457,71,101931,1,0,0:0:0:0:
|
||||
414,50,102010,1,0,0:0:0:0:
|
||||
367,45,102089,2,0,L|442:30,1,72.5,10|2,0:0|0:0,0:0:0:0:
|
||||
210,265,102405,6,0,L|115:258,1,72.5,10|10,0:0|0:0,0:0:0:0:
|
||||
292,211,102721,2,0,L|385:217,1,72.5,10|10,0:0|0:0,0:0:0:0:
|
||||
204,55,103037,2,0,L|216:137,1,72.5,10|10,0:0|0:0,0:0:0:0:
|
||||
304,106,103353,2,0,L|317:179,1,72.5,10|10,0:0|0:0,0:0:0:0:
|
||||
94,264,103668,6,0,L|84:181,1,72.5,10|10,0:0|0:0,0:0:0:0:
|
||||
238,189,103984,2,0,L|247:272,1,72.5,10|10,0:0|0:0,0:0:0:0:
|
||||
93,99,104300,2,0,L|183:91,1,72.5,10|10,0:0|0:0,0:0:0:0:
|
||||
251,83,104616,1,10,0:0:0:0:
|
||||
165,92,104774,1,10,0:0:0:0:
|
||||
399,238,104931,6,0,L|442:242,2,36.25,12|8|8,0:0|0:0|0:0,0:0:0:0:
|
||||
315,171,105247,1,8,0:0:0:0:
|
||||
358,166,105326,1,8,0:0:0:0:
|
||||
401,160,105405,1,10,0:0:0:0:
|
||||
249,115,105563,1,10,0:0:0:0:
|
||||
205,115,105642,1,8,0:0:0:0:
|
||||
161,115,105721,1,10,0:0:0:0:
|
||||
390,62,105879,2,0,L|401:109,1,36.25,10|8,0:0|0:0,0:0:0:0:
|
||||
354,102,106037,1,10,0:0:0:0:
|
||||
117,277,106195,6,0,L|231:265,1,108.75,14|10,0:0|0:0,0:0:0:0:
|
||||
116,179,106668,1,10,0:0:0:0:
|
||||
347,138,106826,1,10,0:0:0:0:
|
||||
263,72,106984,1,10,0:0:0:0:
|
||||
251,114,107063,1,10,0:0:0:0:
|
||||
297,124,107142,2,0,L|395:141,1,72.5,10|10,0:0|0:0,0:0:0:0:
|
||||
135,282,107458,6,0,P|97:254|162:201,1,145,14|10,0:0|0:0,0:0:0:0:
|
||||
404,220,108089,2,0,L|415:128,1,72.5,12|8,0:0|0:0,0:0:0:0:
|
||||
376,144,108326,1,8,0:0:0:0:
|
||||
340,138,108405,1,8,0:0:0:0:
|
||||
188,145,108563,2,0,L|179:65,1,72.5,12|8,0:0|0:0,0:0:0:0:
|
||||
216,70,108800,1,8,0:0:0:0:
|
||||
252,65,108879,1,8,0:0:0:0:
|
||||
406,285,109037,6,0,L|419:206,1,72.5,12|8,0:0|0:0,0:0:0:0:
|
||||
264,188,109353,2,0,L|184:179,1,72.5,10|0,0:0|0:0,0:0:0:0:
|
||||
344,103,109668,2,0,L|431:113,1,72.5,10|0,0:0|0:0,0:0:0:0:
|
||||
193,256,109984,5,8,0:0:0:0:
|
||||
157,251,110063,1,8,0:0:0:0:
|
||||
121,245,110142,2,0,L|102:161,1,72.5,8|14,0:0|0:0,0:0:0:0:
|
||||
366,122,110616,2,0,L|451:114,1,72.5,12|8,0:0|0:0,0:0:0:0:
|
||||
401,121,110853,1,8,0:0:0:0:
|
||||
365,121,110931,1,8,0:0:0:0:
|
||||
213,67,111089,2,0,L|123:75,1,72.5,12|8,0:0|0:0,0:0:0:0:
|
||||
177,72,111326,1,8,0:0:0:0:
|
||||
213,67,111405,1,8,0:0:0:0:
|
||||
365,222,111563,6,0,L|450:213,1,72.5,12|8,0:0|0:0,0:0:0:0:
|
||||
284,141,111879,1,8,0:0:0:0:
|
||||
244,189,111984,1,8,0:0:0:0:
|
||||
247,251,112089,1,8,0:0:0:0:
|
||||
280,304,112195,1,8,0:0:0:0:
|
||||
337,327,112300,1,8,0:0:0:0:
|
||||
49,237,112510,6,0,P|21:210|83:141,1,145,14|10,0:0|0:0,0:0:0:0:
|
||||
341,95,113142,2,0,L|255:88,1,72.5,12|8,0:0|0:0,0:0:0:0:
|
||||
305,92,113379,1,8,0:0:0:0:
|
||||
341,96,113458,1,8,0:0:0:0:
|
||||
190,208,113616,2,0,L|283:201,1,72.5,12|8,0:0|0:0,0:0:0:0:
|
||||
225,205,113853,1,8,0:0:0:0:
|
||||
189,208,113931,1,8,0:0:0:0:
|
||||
341,132,114089,6,0,L|260:126,1,72.5,12|8,0:0|0:0,0:0:0:0:
|
||||
423,290,114405,2,0,L|435:211,1,72.5,10|0,0:0|0:0,0:0:0:0:
|
||||
282,212,114721,2,0,L|295:133,1,72.5,10|0,0:0|0:0,0:0:0:0:
|
||||
64,257,115037,5,14,0:0:0:0:
|
||||
17,183,115195,1,8,0:0:0:0:
|
||||
54,104,115353,1,14,0:0:0:0:
|
||||
135,74,115510,1,8,0:0:0:0:
|
||||
288,75,115668,2,0,L|345:78,2,36.25,12|8|8,0:0|0:0|0:0,0:0:0:0:
|
||||
66,206,115984,1,12,0:0:0:0:
|
||||
103,208,116063,1,8,0:0:0:0:
|
||||
139,209,116142,1,8,0:0:0:0:
|
||||
359,284,116300,6,0,L|437:270,1,72.5,8|8,0:0|0:0,0:0:0:0:
|
||||
335,207,116616,2,0,L|422:193,1,72.5,8|8,0:0|0:0,0:0:0:0:
|
||||
316,127,116931,2,0,L|396:115,1,72.5,8|8,0:0|0:0,0:0:0:0:
|
||||
146,145,117247,1,12,0:0:0:0:
|
||||
@@ -0,0 +1,796 @@
|
||||
osu file format v14
|
||||
|
||||
[General]
|
||||
AudioFilename: audio.mp3
|
||||
AudioLeadIn: 0
|
||||
PreviewTime: 25309
|
||||
Countdown: 0
|
||||
SampleSet: Soft
|
||||
StackLeniency: 0.5
|
||||
Mode: 0
|
||||
LetterboxInBreaks: 0
|
||||
WidescreenStoryboard: 1
|
||||
|
||||
[Editor]
|
||||
DistanceSpacing: 0.7
|
||||
BeatDivisor: 8
|
||||
GridSize: 4
|
||||
TimelineZoom: 1.8
|
||||
|
||||
[Metadata]
|
||||
Title:re[in]flaw
|
||||
TitleUnicode:re[in]flaw
|
||||
Artist:MYUKKE.
|
||||
ArtistUnicode:MYUKKE.
|
||||
Creator:captin1
|
||||
Version:toybot's Expert
|
||||
Source:
|
||||
Tags:fa featured artist anna apple toybot vikala meiikyuu drumstep dubstep electronic instrumental reinflaw
|
||||
BeatmapID:2785319
|
||||
BeatmapSetID:1344871
|
||||
|
||||
[Difficulty]
|
||||
HPDrainRate:5
|
||||
CircleSize:4.5
|
||||
OverallDifficulty:8.8
|
||||
ApproachRate:9.3
|
||||
SliderMultiplier:1.7
|
||||
SliderTickRate:1
|
||||
|
||||
[Events]
|
||||
//Background and Video events
|
||||
0,0,"87195968_p0.jpg",0,0
|
||||
//Break Periods
|
||||
2,48186,51667
|
||||
//Storyboard Layer 0 (Background)
|
||||
//Storyboard Layer 1 (Fail)
|
||||
//Storyboard Layer 2 (Pass)
|
||||
//Storyboard Layer 3 (Foreground)
|
||||
//Storyboard Layer 4 (Overlay)
|
||||
//Storyboard Sound Samples
|
||||
|
||||
[TimingPoints]
|
||||
2810,352.941176470588,4,2,0,60,1,0
|
||||
13751,-500,4,2,1,60,0,0
|
||||
14104,-66.6666666666667,4,2,1,70,0,0
|
||||
19398,-133.333333333333,4,2,1,70,0,0
|
||||
19751,-100,4,2,1,70,0,0
|
||||
22927,-100,4,2,1,80,0,0
|
||||
23633,-100,4,2,1,70,0,0
|
||||
23986,-66.6666666666667,4,2,1,60,0,0
|
||||
25045,-50,4,2,1,60,0,0
|
||||
25398,-50,4,2,1,90,0,0
|
||||
25574,-66.6666666666667,4,2,1,90,0,0
|
||||
26810,-40,4,2,1,90,0,0
|
||||
27339,-66.6666666666667,4,2,1,90,0,0
|
||||
30294,-66.6666666666667,4,2,1,5,0,0
|
||||
30339,-66.6666666666667,4,2,1,90,0,0
|
||||
30559,-66.6666666666667,4,2,1,50,0,0
|
||||
30692,-133.333333333333,4,2,1,50,0,0
|
||||
30868,-133.333333333333,4,2,1,70,0,0
|
||||
31045,-66.6666666666667,4,2,1,90,0,0
|
||||
32457,-40,4,2,1,90,0,0
|
||||
32986,-66.6666666666667,4,2,1,90,0,0
|
||||
35280,-66.6666666666667,4,2,1,80,0,0
|
||||
35457,-100,4,2,1,80,0,0
|
||||
36692,-66.6666666666667,4,2,1,90,0,0
|
||||
37751,-50,4,2,1,90,0,0
|
||||
37883,-50,4,2,1,5,0,0
|
||||
37927,-50,4,2,1,90,0,0
|
||||
38059,-50,4,2,1,5,0,0
|
||||
38104,-66.6666666666667,4,2,1,90,0,0
|
||||
39294,-66.6666666666667,4,2,1,5,0,0
|
||||
39339,-66.6666666666667,4,2,1,60,0,0
|
||||
39515,-66.6666666666667,4,2,1,90,0,0
|
||||
40883,-66.6666666666667,4,2,1,5,0,0
|
||||
40927,-66.6666666666667,4,2,1,90,0,0
|
||||
41854,-66.6666666666667,4,2,1,50,0,0
|
||||
41986,-133.333333333333,4,2,1,50,0,0
|
||||
42162,-133.333333333333,4,2,1,70,0,0
|
||||
42339,-66.6666666666667,4,2,1,90,0,0
|
||||
43398,-50,4,2,1,90,0,0
|
||||
43530,-50,4,2,1,5,0,0
|
||||
43574,-50,4,2,1,90,0,0
|
||||
43706,-50,4,2,1,5,0,0
|
||||
43751,-66.6666666666667,4,2,1,90,0,0
|
||||
44942,-66.6666666666667,4,2,1,5,0,0
|
||||
44986,-66.6666666666667,4,2,1,60,0,0
|
||||
45162,-66.6666666666667,4,2,1,90,0,0
|
||||
45515,-50,4,2,1,90,0,0
|
||||
46574,-100,4,2,1,80,0,0
|
||||
47280,-66.6666666666667,4,2,1,80,0,0
|
||||
47633,-66.6666666666667,4,2,1,70,0,0
|
||||
52574,-66.6666666666667,4,2,1,60,0,0
|
||||
59633,-133.333333333333,4,2,0,60,0,0
|
||||
70574,-100,4,2,1,70,0,0
|
||||
71986,-100,4,2,1,80,0,0
|
||||
76221,-100,4,2,1,70,0,0
|
||||
76751,-80,4,2,1,70,0,0
|
||||
79398,-80,4,2,1,75,0,0
|
||||
79751,-80,4,2,1,80,0,0
|
||||
80457,-66.6666666666667,4,2,1,60,0,0
|
||||
81515,-50,4,2,1,60,0,0
|
||||
81868,-50,4,2,1,90,0,0
|
||||
82353,-50,4,2,1,5,0,0
|
||||
82398,-50,4,2,1,90,0,0
|
||||
83280,-33.3333333333333,4,2,1,90,0,0
|
||||
83633,-50,4,2,1,90,0,0
|
||||
84294,-50,4,2,1,5,0,0
|
||||
84339,-50,4,2,1,90,0,0
|
||||
85177,-50,4,2,1,5,0,0
|
||||
85221,-50,4,2,1,90,0,0
|
||||
86412,-50,4,2,1,5,0,0
|
||||
86457,-50,4,2,1,90,0,0
|
||||
86589,-50,4,2,1,5,0,0
|
||||
86633,-50,4,2,1,90,0,0
|
||||
87162,-50,4,2,1,70,0,0
|
||||
87515,-50,4,2,1,90,0,0
|
||||
87647,-50,4,2,1,5,0,0
|
||||
87692,-50,4,2,1,90,0,0
|
||||
88927,-40,4,2,1,90,0,0
|
||||
89280,-50,4,2,1,90,0,0
|
||||
89942,-50,4,2,1,5,0,0
|
||||
89986,-50,4,2,1,90,0,0
|
||||
90471,-50,4,2,1,5,0,0
|
||||
90515,-50,4,2,1,90,0,0
|
||||
91927,-100,4,2,1,90,0,0
|
||||
93162,-50,4,2,1,90,0,0
|
||||
93515,-40,4,2,1,90,0,0
|
||||
94221,-50,4,2,1,90,0,0
|
||||
94927,-100,4,2,1,90,0,0
|
||||
94971,-100,4,2,1,5,0,0
|
||||
95015,-100,4,2,1,90,0,0
|
||||
95059,-100,4,2,1,5,0,0
|
||||
95104,-100,4,2,1,90,0,0
|
||||
95148,-100,4,2,1,5,0,0
|
||||
95192,-100,4,2,1,90,0,0
|
||||
95236,-100,4,2,1,5,0,0
|
||||
95280,-100,4,2,1,90,0,0
|
||||
95457,-50,4,2,1,90,0,0
|
||||
96339,-40,4,2,1,90,0,0
|
||||
96471,-40,4,2,1,5,0,0
|
||||
96515,-40,4,2,1,90,0,0
|
||||
97045,-50,4,2,1,90,0,0
|
||||
98324,-50,4,2,1,60,0,0
|
||||
98457,-50,4,2,1,70,0,0
|
||||
98810,-50,4,2,1,90,0,0
|
||||
98942,-50,4,2,1,5,0,0
|
||||
98986,-50,4,2,1,90,0,0
|
||||
100000,-50,4,2,1,5,0,0
|
||||
100045,-50,4,2,1,90,0,0
|
||||
100574,-100,4,2,1,90,0,0
|
||||
100618,-100,4,2,1,5,0,0
|
||||
100662,-100,4,2,1,90,0,0
|
||||
100706,-100,4,2,1,5,0,0
|
||||
100751,-100,4,2,1,90,0,0
|
||||
100795,-100,4,2,1,5,0,0
|
||||
100839,-100,4,2,1,90,0,0
|
||||
100883,-100,4,2,1,5,0,0
|
||||
100927,-100,4,2,1,90,0,0
|
||||
101104,-50,4,2,1,90,0,0
|
||||
101412,-50,4,2,1,5,0,0
|
||||
101457,-50,4,2,1,70,0,0
|
||||
101633,-50,4,2,1,90,0,0
|
||||
101765,-50,4,2,1,5,0,0
|
||||
101810,-50,4,2,1,90,0,0
|
||||
101986,-40,4,2,1,90,0,0
|
||||
102339,-50,4,2,1,90,0,0
|
||||
103045,-100,4,2,1,80,0,0
|
||||
104104,-66.6666666666667,4,2,0,60,0,0
|
||||
104456,-66.6666666666667,4,2,1,60,0,0
|
||||
104986,-100,4,2,1,60,0,0
|
||||
113986,-50,4,2,1,60,0,0
|
||||
114339,-66.6666666666667,4,2,1,60,0,0
|
||||
|
||||
|
||||
[Colours]
|
||||
Combo1 : 255,0,0
|
||||
Combo2 : 192,192,192
|
||||
Combo3 : 128,128,128
|
||||
Combo4 : 255,128,128
|
||||
Combo5 : 128,255,255
|
||||
Combo6 : 128,0,255
|
||||
Combo7 : 255,0,128
|
||||
|
||||
[HitObjects]
|
||||
34,31,2810,5,6,2:0:0:0:
|
||||
49,79,2986,1,0,2:0:0:0:
|
||||
66,126,3162,1,2,2:3:0:0:
|
||||
0,225,3339,1,2,2:0:0:0:
|
||||
23,214,3427,1,0,2:0:0:0:
|
||||
40,195,3515,1,0,2:0:0:0:
|
||||
45,220,3604,1,0,2:0:0:0:
|
||||
61,239,3692,1,2,2:3:0:0:
|
||||
14,292,3868,2,0,P|65:271|107:274,1,85,2|0,2:0|2:0,2:0:0:0:
|
||||
145,323,4133,1,0,2:0:0:0:
|
||||
145,323,4221,5,2,2:0:0:0:
|
||||
192,341,4398,1,0,2:0:0:0:
|
||||
239,358,4574,1,2,2:3:0:0:
|
||||
228,285,4751,1,0,2:0:0:0:
|
||||
213,213,4927,1,2,2:0:0:0:
|
||||
284,236,5104,1,2,2:3:0:0:
|
||||
180,230,5280,1,2,2:0:0:0:
|
||||
320,222,5457,2,0,L|330:166,1,42.5,0|0,2:0|2:0,2:0:0:0:
|
||||
368,271,5633,85,2,2:0:0:0:
|
||||
417,258,5810,1,0,2:0:0:0:
|
||||
466,245,5986,1,2,2:3:0:0:
|
||||
450,103,6162,1,2,2:0:0:0:
|
||||
424,105,6251,1,0,2:0:0:0:
|
||||
407,124,6339,1,0,2:0:0:0:
|
||||
406,149,6427,1,0,2:0:0:0:
|
||||
420,170,6515,1,2,2:3:0:0:
|
||||
491,159,6692,2,0,P|481:117|482:75,1,85,2|0,2:0|2:0,2:0:0:0:
|
||||
456,7,6957,1,0,2:0:0:0:
|
||||
456,7,7045,6,0,L|351:27,1,85,2|0,2:0|2:0,2:0:0:0:
|
||||
312,62,7398,1,2,2:3:0:0:
|
||||
262,51,7574,1,0,2:0:0:0:
|
||||
211,42,7751,1,2,2:0:0:0:
|
||||
229,112,7927,1,2,2:3:0:0:
|
||||
253,176,8104,1,2,2:0:0:0:
|
||||
154,225,8280,1,0,2:0:0:0:
|
||||
51,269,8457,5,2,2:0:0:0:
|
||||
41,234,8633,1,2,2:3:0:0:
|
||||
74,243,8810,1,10,2:0:0:0:
|
||||
114,302,8986,1,2,2:0:0:0:
|
||||
124,304,9074,1,0,2:0:0:0:
|
||||
134,307,9162,1,0,2:0:0:0:
|
||||
144,310,9251,1,0,2:0:0:0:
|
||||
155,312,9339,1,2,2:3:0:0:
|
||||
213,358,9515,1,10,2:0:0:0:
|
||||
318,272,9692,5,0,2:0:0:0:
|
||||
294,262,9780,1,0,2:0:0:0:
|
||||
277,281,9868,1,2,2:0:0:0:
|
||||
311,344,10045,1,2,2:3:0:0:
|
||||
277,191,10221,86,0,L|265:140,1,42.5,10|0,2:0|2:0,2:0:0:0:
|
||||
218,130,10398,2,0,L|268:145,1,42.5,0|0,2:0|2:0,2:0:0:0:
|
||||
301,109,10574,2,2,L|262:144,1,42.5,2|0,2:0|2:0,2:0:0:0:
|
||||
210,199,10751,5,0,2:0:0:0:
|
||||
342,166,10927,1,10,2:0:0:0:
|
||||
440,264,11104,1,0,2:0:0:0:
|
||||
438,338,11280,85,2,2:0:0:0:
|
||||
408,361,11457,1,2,2:3:0:0:
|
||||
404,325,11633,1,10,2:0:0:0:
|
||||
496,217,11810,1,2,2:0:0:0:
|
||||
493,206,11898,1,0,2:0:0:0:
|
||||
490,196,11986,1,0,2:0:0:0:
|
||||
488,186,12074,1,0,2:0:0:0:
|
||||
485,175,12162,1,2,2:3:0:0:
|
||||
443,113,12339,1,10,2:0:0:0:
|
||||
450,5,12515,5,0,2:0:0:0:
|
||||
470,19,12604,1,0,2:0:0:0:
|
||||
469,44,12692,1,2,2:0:0:0:
|
||||
396,56,12868,1,2,2:3:0:0:
|
||||
242,161,13045,85,10,2:0:0:0:
|
||||
345,172,13221,1,0,2:0:0:0:
|
||||
512,279,13398,5,2,2:0:0:0:
|
||||
384,297,13574,1,2,2:3:0:0:
|
||||
167,327,13751,6,0,P|169:314|180:309,1,25.5,10|0,2:0|2:0,2:0:0:0:
|
||||
223,367,14104,86,0,P|164:343|100:346,1,127.5,6|2,3:1|2:0,2:0:0:0:
|
||||
36,323,14368,2,0,P|53:351|97:361,1,63.75,0|8,2:0|1:0,2:0:0:0:
|
||||
107,239,14633,2,2,B|90:104|90:104|75:157,1,191.25,2|0,2:1|2:0,2:1:0:0:
|
||||
75,157,14986,5,2,3:2:0:0:
|
||||
145,178,15074,1,0,2:0:0:0:
|
||||
197,133,15162,2,0,L|273:145,1,63.75,10|0,1:0|2:0,2:0:0:0:
|
||||
149,188,15339,85,0,2:0:0:0:
|
||||
202,145,15427,1,0,2:0:0:0:
|
||||
268,162,15515,2,0,P|299:236|276:306,1,127.5,2|2,3:1|2:0,2:0:0:0:
|
||||
260,337,15780,2,0,P|234:319|223:289,1,63.75,0|8,2:0|1:0,2:0:0:0:
|
||||
348,157,16045,2,0,L|360:63,1,63.75,2|0,2:1|2:0,2:0:0:0:
|
||||
416,63,16221,5,2,2:0:0:0:
|
||||
406,27,16310,1,2,2:0:0:0:
|
||||
416,63,16398,1,2,3:2:0:0:
|
||||
512,132,16574,1,10,1:0:0:0:
|
||||
415,218,16751,1,0,2:0:0:0:
|
||||
384,280,16839,1,0,2:0:0:0:
|
||||
441,321,16927,86,0,P|466:243|421:183,1,127.5,2|2,3:1|2:0,2:0:0:0:
|
||||
376,171,17192,2,0,P|388:199|415:218,1,63.75,0|8,2:0|1:0,2:0:0:0:
|
||||
509,53,17457,2,2,B|409:32|429:125|308:96,1,191.25,2|0,2:1|2:0,2:1:0:0:
|
||||
271,65,17810,5,2,3:2:0:0:
|
||||
244,129,17898,1,0,2:0:0:0:
|
||||
177,142,17986,2,0,L|101:118,1,63.75,10|0,1:0|2:0,2:0:0:0:
|
||||
249,142,18162,85,0,2:0:0:0:
|
||||
177,157,18251,1,0,2:0:0:0:
|
||||
109,134,18339,2,0,P|103:73|76:10,1,127.5,2|2,3:1|2:0,2:0:0:0:
|
||||
18,44,18604,2,0,P|37:68|66:80,1,63.75,0|8,2:0|1:0,2:0:0:0:
|
||||
132,220,18868,2,0,L|113:312,1,63.75,2|0,2:1|2:0,2:0:0:0:
|
||||
141,345,19045,6,0,P|168:328|184:301,1,63.75,2|0,2:0|2:0,2:0:0:0:
|
||||
63,194,19221,1,2,3:2:0:0:
|
||||
141,345,19398,86,2,P|184:340|237:382,1,95.6250000000002,10|0,1:0|2:0,2:1:0:0:
|
||||
286,329,19751,6,2,P|275:268|234:232,1,85,10|2,1:0|2:0,2:1:0:0:
|
||||
226,55,20104,2,0,P|237:116|278:152,1,85,8|2,1:0|2:1,2:0:0:0:
|
||||
314,186,20457,2,0,P|350:164|393:162,1,85,8|2,1:0|2:0,2:0:0:0:
|
||||
197,197,20810,2,0,P|161:219|119:222,1,85,10|2,1:0|2:0,2:0:0:0:
|
||||
77,170,21074,1,0,2:0:0:0:
|
||||
77,170,21162,86,2,P|130:179|162:224,1,85,10|2,1:0|2:0,2:1:0:0:
|
||||
435,214,21515,2,0,P|381:204|349:159,1,85,8|2,1:0|2:1,2:0:0:0:
|
||||
302,84,21868,2,0,P|303:41|325:5,1,85,8|2,1:0|2:0,2:0:0:0:
|
||||
209,299,22221,2,0,P|208:342|186:378,1,85,10|0,1:0|2:0,2:0:0:0:
|
||||
125,360,22486,1,0,2:0:0:0:
|
||||
125,360,22574,6,0,P|153:341|193:345,1,42.5,8|0,1:0|2:0,2:0:0:0:
|
||||
207,231,22751,2,0,P|228:232|247:241,1,42.5,8|0,1:0|2:0,2:0:0:0:
|
||||
356,197,22927,2,0,P|369:212|376:232,1,42.5,8|0,1:0|2:0,2:0:0:0:
|
||||
485,278,23104,2,0,P|483:299|474:318,1,42.5,10|0,1:0|2:0,2:0:0:0:
|
||||
474,318,23280,85,8,1:0:0:0:
|
||||
431,294,23368,1,8,1:0:0:0:
|
||||
423,250,23457,1,10,1:0:0:0:
|
||||
446,208,23545,1,8,1:0:0:0:
|
||||
480,186,23633,1,10,1:0:0:0:
|
||||
505,155,23721,1,8,1:0:0:0:
|
||||
497,116,23810,1,8,1:0:0:0:
|
||||
472,100,23898,1,8,1:0:0:0:
|
||||
451,102,23986,54,0,B|418:98|418:98|420:67,1,63.75,2|0,2:1|2:0,2:0:0:0:
|
||||
317,20,24162,1,2,2:3:0:0:
|
||||
317,20,24251,1,2,2:0:0:0:
|
||||
317,20,24339,2,0,B|349:15|349:15|358:74,1,63.75,2|0,2:1|2:0,2:0:0:0:
|
||||
298,89,24515,2,0,L|204:106,1,63.75,2|0,2:1|2:0,2:0:0:0:
|
||||
128,147,24692,6,0,B|152:166|152:166|190:154,1,63.75,2|0,2:3|2:0,2:0:0:0:
|
||||
201,37,24868,2,0,B|177:18|177:18|139:30,1,63.75,2|0,2:1|2:0,2:0:0:0:
|
||||
101,85,25045,2,2,B|113:158|113:158|135:182|135:182|146:251,1,170,10|0,1:0|2:0,2:1:0:0:
|
||||
126,272,25310,1,0,2:0:0:0:
|
||||
41,258,25398,38,0,P|32:232|54:255,1,85,4|0,3:2|2:0,2:0:0:0:
|
||||
163,333,25574,2,2,B|263:311|263:311|315:343,1,127.5,2|8,2:1|1:0,2:1:0:0:
|
||||
428,341,25927,2,2,P|367:370|379:268,1,191.25,2|0,2:1|2:0,2:1:0:0:
|
||||
419,282,26280,6,0,P|433:309|427:340,1,63.75,2|0,3:2|2:0,2:0:0:0:
|
||||
351,277,26457,2,2,B|368:181|464:249|480:123,1,191.25,8|0,1:0|2:0,2:1:0:0:
|
||||
416,50,26810,6,2,B|415:56|415:56|420:60|420:60|415:65|415:65|421:69|421:69|416:73|416:73|422:76|422:76|418:81|418:81|423:85|423:85|418:89|418:89|423:92|423:92|419:97|419:97|424:100|424:100|421:104|421:104|426:108|426:108|423:112|423:112|428:115|428:115|424:119|424:119|429:122|429:122|425:126|425:126|430:130|430:130|426:134|426:134|431:137|431:137|428:143|428:143|433:146|433:146|428:150|428:150|434:153|434:153|430:157|430:157|451:202|515:217|548:156|493:100|441:177,1,318.75,0|2,3:0|2:1,2:1:0:0:
|
||||
382,113,27162,1,8,1:0:0:0:
|
||||
224,163,27339,2,0,B|279:165|358:185|358:185|319:171|303:125,1,191.25,2|0,2:0|2:0,2:0:0:0:
|
||||
315,151,27692,2,0,P|306:111|324:61,1,63.75,2|0,3:1|2:0,2:0:0:0:
|
||||
157,28,27868,70,0,P|142:10|165:18,1,63.75,8|0,1:0|2:0,2:0:0:0:
|
||||
237,24,28045,1,2,2:3:0:0:
|
||||
202,87,28133,1,2,2:3:0:0:
|
||||
128,97,28221,6,0,P|87:110|77:159,1,63.75,0|0,3:0|2:0,2:0:0:0:
|
||||
17,79,28398,2,2,P|45:137|55:199,1,127.5,2|8,2:1|1:0,2:1:0:0:
|
||||
164,326,28751,2,2,P|246:325|224:245,1,191.25,2|0,2:1|2:0,2:1:0:0:
|
||||
223,244,29104,6,0,P|191:241|164:262,1,63.7500024318696,0|0,3:0|2:0,2:0:0:0:
|
||||
29,215,29280,2,2,B|93:212|110:268|81:255|102:332|171:325,1,191.250007295609,10|0,1:0|2:0,2:1:0:0:
|
||||
329,263,29633,69,2,2:3:0:0:
|
||||
366,255,29721,1,2,2:3:0:0:
|
||||
382,324,29810,2,0,P|351:331|323:347,1,63.75,2|0,2:3|2:0,2:0:0:0:
|
||||
291,188,29986,2,0,P|322:189|354:186,1,63.75,8|2,1:0|2:3,2:0:0:0:
|
||||
249,327,30162,6,2,L|225:215,1,95.6249999999999,2|0,2:1|2:0,2:1:0:0:
|
||||
188,177,30339,2,2,P|165:202|162:241,1,63.75,2|0,2:1|2:0,2:1:0:0:
|
||||
303,104,30515,2,0,P|272:86|240:89,1,63.75,0|0,3:0|1:0,1:0:0:0:
|
||||
174,103,30692,6,0,L|129:88,1,31.8750000000001,0|0,1:0|2:0,2:0:0:0:
|
||||
434,212,30868,69,8,2:3:0:0:
|
||||
454,207,30957,1,4,2:3:0:0:
|
||||
391,273,31045,6,0,P|391:299|415:286,1,63.75,0|0,3:0|2:0,2:0:0:0:
|
||||
362,196,31221,2,0,L|236:220,1,127.5,2|8,2:1|1:0,2:0:0:0:
|
||||
106,214,31574,2,2,B|44:162|84:77|166:98|150:178,1,191.25,2|0,2:1|2:0,2:1:0:0:
|
||||
152,158,31927,6,0,P|142:202|109:219,1,63.75,0|0,3:0|2:0,2:0:0:0:
|
||||
0,298,32104,2,2,B|63:288|115:252|115:252|78:286|85:324,1,191.25,10|0,1:0|2:0,2:1:0:0:
|
||||
167,322,32457,70,2,B|220:302|178:206|93:220|60:294|66:356|113:400|193:370|193:286,1,318.75,0|2,3:0|2:1,2:1:0:0:
|
||||
247,358,32810,1,8,1:0:0:0:
|
||||
322,189,32986,2,0,P|294:133|234:119,1,127.5,2|2,2:0|2:0,2:0:0:0:
|
||||
252,195,33251,1,0,2:0:0:0:
|
||||
252,195,33339,2,0,P|278:196|314:160,1,63.75,2|0,3:1|2:0,2:0:0:0:
|
||||
341,48,33515,6,0,P|322:33|333:54,1,63.75,8|0,1:0|2:0,2:0:0:0:
|
||||
390,106,33692,1,2,2:3:0:0:
|
||||
459,89,33780,1,2,2:3:0:0:
|
||||
492,152,33868,6,0,L|472:268,1,63.75,0|0,2:0|2:0,2:0:0:0:
|
||||
492,315,34045,2,2,P|453:244|375:243,1,127.5,2|8,2:1|1:0,2:1:0:0:
|
||||
268,272,34398,2,2,B|215:322|257:375|257:375|317:374|303:304,1,191.25,2|0,2:1|2:0,2:1:0:0:
|
||||
305,330,34751,70,0,P|297:300|274:279,1,63.75,0|0,3:0|2:0,2:0:0:0:
|
||||
181,194,34927,2,8,B|106:168|56:223|81:309|155:262|106:195,1,191.25,10|0,1:0|2:0,1:0:0:0:
|
||||
118,218,35280,69,0,3:0:0:0:
|
||||
65,56,35457,2,0,P|51:46|25:39,2,42.5,2|2|8,2:0|2:0|1:0,2:0:0:0:
|
||||
159,117,35810,1,2,2:0:0:0:
|
||||
159,117,35898,1,0,2:0:0:0:
|
||||
159,117,35986,2,0,P|172:71|198:21,1,85,0|2,2:0|3:1,2:0:0:0:
|
||||
453,167,36339,69,10,1:0:0:0:
|
||||
453,167,36515,2,0,L|392:159,1,42.5,2|2,2:3|2:3,2:0:0:0:
|
||||
343,139,36692,54,0,P|320:137|342:147,1,63.75,4|0,3:2|2:0,2:0:0:0:
|
||||
429,274,36868,2,2,L|441:310,2,31.875,2|0|0,2:1|2:0|2:0,2:1:0:0:
|
||||
366,225,37045,2,2,L|235:256,1,127.5,10|0,1:0|2:0,2:1:0:0:
|
||||
113,342,37398,6,0,P|78:326|70:290,1,63.75,2|0,2:3|2:0,2:0:0:0:
|
||||
134,274,37574,1,0,3:0:0:0:
|
||||
144,88,37751,2,0,P|167:126|126:195,1,127.5,8|0,1:0|2:0,2:0:0:0:
|
||||
65,156,37927,2,0,P|84:98|144:88,1,127.5,2|0,2:1|2:0,2:0:0:0:
|
||||
209,125,38104,6,0,P|241:119|271:111,1,63.75,2|0,3:2|2:0,2:0:0:0:
|
||||
330,58,38280,1,2,2:1:0:0:
|
||||
301,232,38457,1,2,1:3:0:0:
|
||||
347,210,38545,1,2,2:3:0:0:
|
||||
387,236,38633,1,2,2:3:0:0:
|
||||
382,287,38721,1,2,2:3:0:0:
|
||||
310,308,38810,69,2,2:3:0:0:
|
||||
313,321,38898,1,2,2:3:0:0:
|
||||
251,364,38986,6,0,L|235:302,1,63.75,0|0,3:0|2:0,2:0:0:0:
|
||||
140,152,39162,2,0,P|182:149|202:213,1,95.6249999999999,8|0,1:0|2:0,2:0:0:0:
|
||||
191,210,39339,2,0,B|150:222|150:222|192:230,1,63.75,0|0,1:0|1:0,1:0:0:0:
|
||||
346,167,39515,6,0,L|258:151,1,63.75,0|0,3:0|2:0,2:0:0:0:
|
||||
218,68,39692,2,2,L|210:17,2,31.875,2|0|0,2:1|2:0|2:0,2:1:0:0:
|
||||
140,152,39868,2,2,P|175:103|259:108,1,127.5,10|0,1:0|2:0,2:1:0:0:
|
||||
362,252,40221,70,0,L|337:331,1,63.75,2|0,2:3|2:0,2:0:0:0:
|
||||
398,363,40398,1,0,3:0:0:0:
|
||||
370,167,40574,6,0,P|350:152|359:175,1,63.75,8|2,1:0|2:3,2:0:0:0:
|
||||
463,316,40751,2,2,P|488:340|476:307,1,95.6249999999999,2|0,2:1|2:0,2:1:0:0:
|
||||
476,307,40927,6,0,L|406:322,1,63.75,2|0,3:2|2:0,2:0:0:0:
|
||||
292,369,41104,2,2,L|252:362,2,31.875,2|0|0,2:1|2:0|2:0,2:1:0:0:
|
||||
250,292,41280,1,2,1:3:0:0:
|
||||
212,300,41368,1,2,2:3:0:0:
|
||||
174,308,41457,1,2,2:3:0:0:
|
||||
185,328,41545,1,2,2:3:0:0:
|
||||
194,346,41633,69,2,2:3:0:0:
|
||||
126,364,41721,1,2,2:3:0:0:
|
||||
99,298,41810,2,0,P|103:266|112:235,1,63.75,0|0,3:0|1:0,1:0:0:0:
|
||||
52,103,41986,6,0,L|44:59,1,31.8750000000001,0|0,1:0|2:0,2:0:0:0:
|
||||
184,220,42162,5,8,2:3:0:0:
|
||||
214,237,42251,1,4,2:3:0:0:
|
||||
240,171,42339,70,0,P|243:147|227:165,1,63.75,0|0,3:0|2:0,2:0:0:0:
|
||||
101,165,42515,2,2,L|60:182,2,31.875,2|0|0,2:1|2:0|2:0,2:1:0:0:
|
||||
139,104,42692,2,2,P|199:111|227:165,1,127.5,10|0,1:0|2:0,2:1:0:0:
|
||||
335,262,43045,6,0,L|320:344,1,63.75,2|0,2:3|2:0,2:0:0:0:
|
||||
442,229,43221,1,0,3:0:0:0:
|
||||
344,86,43398,2,0,B|352:49|352:49|368:99|385:132,1,127.5,8|0,1:0|2:0,2:0:0:0:
|
||||
356,192,43574,2,2,B|349:231|349:231|333:181|317:149,1,127.5,2|0,2:1|2:0,2:1:0:0:
|
||||
317,149,43751,6,0,P|276:161|242:179,1,63.75,2|0,3:2|2:0,2:0:0:0:
|
||||
206,238,43927,1,2,2:1:0:0:
|
||||
61,88,44104,1,2,1:3:0:0:
|
||||
67,131,44192,1,2,2:3:0:0:
|
||||
106,150,44280,1,2,2:3:0:0:
|
||||
148,136,44368,1,2,2:3:0:0:
|
||||
176,71,44457,69,2,2:3:0:0:
|
||||
174,59,44545,1,2,2:3:0:0:
|
||||
246,75,44633,6,0,L|229:161,1,63.75,0|0,3:0|2:0,2:0:0:0:
|
||||
134,261,44810,2,0,P|154:229|223:223,1,95.6249999999999,8|0,1:0|2:0,2:0:0:0:
|
||||
258,267,44986,2,0,B|247:281|247:281|235:227,1,63.75,0|0,1:0|1:0,1:0:0:0:
|
||||
386,127,45162,6,0,P|420:143|435:180,1,63.75,0|0,3:0|2:0,2:0:0:0:
|
||||
459,235,45339,2,2,L|471:283,2,31.875,2|0|0,2:1|2:0|2:0,2:1:0:0:
|
||||
387,221,45515,2,2,B|317:178|339:91|383:66|422:99|463:115|425:179,1,255,10|2,1:0|2:1,2:1:0:0:
|
||||
378,292,45868,70,0,P|378:325|396:297,1,85,2|0,2:1|2:0,2:0:0:0:
|
||||
214,255,46045,2,0,P|211:222|231:248,1,85,2|0,3:1|2:0,2:0:0:0:
|
||||
387,221,46221,2,2,B|338:205|284:264|324:267|281:350|201:345|161:301,1,255,10|0,1:0|2:0,2:1:0:0:
|
||||
91,358,46574,70,0,P|88:321|76:274,1,85,0|2,3:0|2:0,2:0:0:0:
|
||||
129,223,46839,1,2,2:0:0:0:
|
||||
110,192,46927,1,8,1:0:0:0:
|
||||
227,47,47104,2,0,P|222:67|220:88,1,42.5,0|0,3:0|2:0,2:0:0:0:
|
||||
260,148,47280,6,0,P|287:153|283:126,1,63.75,8|0,1:0|2:0,2:0:0:0:
|
||||
151,74,47457,2,0,L|90:95,1,63.75,8|0,1:0|2:0,2:0:0:0:
|
||||
219,246,47633,53,0,1:0:0:0:
|
||||
231,245,47721,1,0,1:0:0:0:
|
||||
243,249,47810,1,0,1:0:0:0:
|
||||
253,256,47898,1,0,1:0:0:0:
|
||||
258,267,47986,1,0,1:0:0:0:
|
||||
256,192,52221,12,0,53545,2:0:0:0:
|
||||
469,100,53633,5,0,3:0:0:0:
|
||||
401,125,53810,1,2,2:0:0:0:
|
||||
393,198,53986,1,2,2:0:0:0:
|
||||
415,206,54162,1,2,2:0:0:0:
|
||||
338,269,54339,5,8,1:0:0:0:
|
||||
273,198,54515,1,2,2:0:0:0:
|
||||
313,206,54692,1,2,2:0:0:0:
|
||||
225,252,54868,1,2,2:0:0:0:
|
||||
169,61,55045,5,0,3:0:0:0:
|
||||
233,129,55221,1,2,2:0:0:0:
|
||||
151,228,55398,1,2,2:0:0:0:
|
||||
196,222,55574,1,2,2:0:0:0:
|
||||
43,309,55751,85,8,1:0:0:0:
|
||||
105,286,55927,1,2,2:0:0:0:
|
||||
101,359,56104,1,2,2:0:0:0:
|
||||
51,233,56280,1,2,2:0:0:0:
|
||||
148,122,56457,5,0,3:0:0:0:
|
||||
53,63,56633,1,2,2:0:0:0:
|
||||
96,72,56810,1,2,2:0:0:0:
|
||||
53,63,56986,1,2,2:0:0:0:
|
||||
235,27,57162,85,8,1:0:0:0:
|
||||
179,133,57339,1,2,2:0:0:0:
|
||||
227,120,57515,1,2,2:0:0:0:
|
||||
138,48,57692,1,2,2:0:0:0:
|
||||
330,39,57868,5,0,3:0:0:0:
|
||||
385,135,58045,1,2,2:0:0:0:
|
||||
333,173,58221,1,2,2:0:0:0:
|
||||
385,135,58398,1,2,2:0:0:0:
|
||||
487,176,58574,85,8,1:0:0:0:
|
||||
502,105,58751,1,2,2:0:0:0:
|
||||
469,52,58927,1,2,2:0:0:0:
|
||||
425,41,59104,1,0,2:0:0:0:
|
||||
394,55,59280,69,0,1:0:0:0:
|
||||
262,85,59457,1,0,1:0:0:0:
|
||||
306,23,59633,2,0,P|327:63|332:96,1,63.7500000000002,8|2,2:0|2:0,2:0:0:0:
|
||||
379,136,59898,1,0,2:0:0:0:
|
||||
379,136,59986,2,0,L|366:192,2,31.8750000000001,2|0|8,2:0|2:0|2:0,2:0:0:0:
|
||||
297,270,60339,2,0,P|305:233|326:199,1,63.7500000000002,2|2,2:0|2:0,2:0:0:0:
|
||||
352,323,60604,1,0,2:0:0:0:
|
||||
352,323,60692,6,0,P|317:296|280:296,1,63.7500000000002,0|2,1:0|2:0,2:0:0:0:
|
||||
191,239,61045,2,0,P|159:250|145:270,1,31.8750000000001,2|0,2:0|2:0,2:0:0:0:
|
||||
117,300,61221,2,0,P|109:314|108:330,1,31.8750000000001,0|0,1:0|2:0,2:0:0:0:
|
||||
184,269,61398,86,0,P|190:235|180:213,1,31.8750000000001,2|0,2:0|2:0,2:0:0:0:
|
||||
167,173,61574,2,0,P|158:159|145:150,1,31.8750000000001,2|0,2:0|2:0,2:0:0:0:
|
||||
161,248,61751,6,0,P|186:270|210:272,1,31.8750000000001,8|0,2:0|2:0,2:0:0:0:
|
||||
252,281,61927,2,0,P|268:280|282:273,1,31.8750000000001,2|2,2:0|2:0,2:0:0:0:
|
||||
325,216,62104,86,0,L|313:153,1,63.7500000000002,0|0,1:0|1:0,2:0:0:0:
|
||||
232,102,62457,2,0,P|244:66|277:60,1,63.7500000000002,8|2,2:0|2:0,2:0:0:0:
|
||||
335,83,62721,1,0,2:0:0:0:
|
||||
335,83,62810,2,0,L|389:73,2,31.8750000000001,2|0|0,2:0|2:0|1:0,2:0:0:0:
|
||||
187,17,63162,2,0,L|102:7,1,63.7500000000002,2|2,2:0|2:0,2:0:0:0:
|
||||
69,55,63427,1,0,2:0:0:0:
|
||||
69,55,63515,5,0,1:0:0:0:
|
||||
124,104,63692,1,2,2:0:0:0:
|
||||
116,123,63780,1,0,2:0:0:0:
|
||||
138,156,63868,1,2,2:0:0:0:
|
||||
123,166,63957,1,0,2:0:0:0:
|
||||
128,203,64045,85,0,1:0:0:0:
|
||||
80,262,64221,1,2,2:0:0:0:
|
||||
103,280,64310,1,0,2:0:0:0:
|
||||
92,320,64398,1,2,2:0:0:0:
|
||||
113,325,64486,1,0,2:0:0:0:
|
||||
130,359,64574,1,2,2:3:0:0:
|
||||
147,351,64662,1,2,2:3:0:0:
|
||||
130,359,64751,1,2,2:3:0:0:
|
||||
147,351,64839,1,2,2:3:0:0:
|
||||
189,362,64927,5,0,1:0:0:0:
|
||||
325,324,65104,1,0,1:0:0:0:
|
||||
267,374,65280,2,0,P|254:333|249:303,1,63.7500000000002,8|2,2:0|2:0,2:0:0:0:
|
||||
196,272,65545,1,0,2:0:0:0:
|
||||
196,272,65633,2,0,L|166:257,2,31.8750000000001,2|0|8,2:0|2:0|2:0,2:0:0:0:
|
||||
337,153,65986,2,0,P|323:185|314:220,1,63.7500000000002,2|2,2:0|2:0,2:0:0:0:
|
||||
374,259,66251,1,0,2:0:0:0:
|
||||
374,259,66339,86,0,P|398:258|376:243,1,63.7500000000002,0|2,1:0|2:0,2:0:0:0:
|
||||
237,162,66692,2,0,P|213:135|210:117,1,31.8750000000001,2|0,2:0|2:0,2:0:0:0:
|
||||
208,69,66868,2,0,L|228:18,1,31.8750000000001,0|0,1:0|2:0,2:0:0:0:
|
||||
245,131,67045,6,0,P|243:146|236:161,1,31.8750000000001,2|0,2:0|2:0,2:0:0:0:
|
||||
178,202,67221,2,0,L|146:206,1,31.8750000000001,2|0,2:0|2:0,2:0:0:0:
|
||||
215,139,67398,86,0,P|229:132|245:131,1,31.8750000000001,8|0,2:0|2:0,2:0:0:0:
|
||||
309,161,67574,2,0,L|343:203,1,31.8750000000001,2|2,2:0|2:0,2:0:0:0:
|
||||
315,253,67751,6,0,L|402:271,1,63.7500000000002,0|0,1:0|1:0,2:0:0:0:
|
||||
452,339,68104,2,0,P|489:328|508:299,1,63.7500000000002,8|2,2:0|2:0,2:0:0:0:
|
||||
510,241,68368,1,0,2:0:0:0:
|
||||
510,241,68457,2,0,L|498:192,1,31.8750000000001,2|0,2:0|2:0,2:0:0:0:
|
||||
440,176,68633,1,0,1:0:0:0:
|
||||
488,22,68810,2,0,L|473:110,1,63.7500000000002,2|2,2:0|2:0,2:0:0:0:
|
||||
452,106,69074,1,0,2:0:0:0:
|
||||
452,106,69162,85,0,1:0:0:0:
|
||||
384,51,69339,1,2,2:0:0:0:
|
||||
370,66,69427,1,0,2:0:0:0:
|
||||
331,62,69515,1,2,2:0:0:0:
|
||||
329,80,69604,1,0,2:0:0:0:
|
||||
293,92,69692,5,0,1:0:0:0:
|
||||
201,58,69868,1,2,2:0:0:0:
|
||||
210,96,69957,1,0,2:0:0:0:
|
||||
173,108,70045,1,2,2:0:0:0:
|
||||
189,128,70133,1,0,2:0:0:0:
|
||||
176,159,70221,86,0,L|214:169,1,31.8750000000001,8|0,2:0|2:0,2:0:0:0:
|
||||
96,94,70398,1,8,2:0:0:0:
|
||||
57,302,70574,22,0,P|84:269|123:253,1,85,4|0,3:1|2:0,2:0:0:0:
|
||||
304,277,70927,1,10,2:0:0:0:
|
||||
180,360,71104,1,2,2:0:0:0:
|
||||
210,290,71280,5,0,2:0:0:0:
|
||||
325,365,71457,1,2,3:2:0:0:
|
||||
436,310,71633,2,0,P|459:269|453:214,1,85,10|0,2:0|2:0,2:0:0:0:
|
||||
406,179,71898,1,0,2:0:0:0:
|
||||
406,179,71986,86,0,L|423:282,1,85,2|0,3:2|2:0,2:0:0:0:
|
||||
365,310,72251,2,0,L|348:378,1,42.5,0|8,2:0|2:0,2:0:0:0:
|
||||
478,162,72515,1,2,2:0:0:0:
|
||||
302,118,72692,2,0,P|300:75|332:26,1,85,0|2,2:0|3:2,2:0:0:0:
|
||||
370,91,72957,1,0,2:0:0:0:
|
||||
370,91,73045,6,0,P|347:122|323:128,1,42.5,10|0,2:0|2:0,2:0:0:0:
|
||||
234,149,73221,2,0,P|214:145|197:133,1,42.5,2|0,2:0|2:0,2:0:0:0:
|
||||
152,26,73398,86,0,L|46:63,1,85,0|0,3:0|2:0,2:0:0:0:
|
||||
8,277,73751,1,10,2:0:0:0:
|
||||
127,148,73927,1,2,2:0:0:0:
|
||||
21,108,74104,5,0,2:0:0:0:
|
||||
136,240,74280,1,2,3:2:0:0:
|
||||
51,366,74457,2,0,P|94:330|128:325,1,85,10|2,2:0|2:0,2:0:0:0:
|
||||
189,296,74721,1,0,2:0:0:0:
|
||||
189,296,74810,86,0,L|277:324,1,85,0|0,3:0|2:0,2:0:0:0:
|
||||
365,335,75074,2,0,P|335:356|299:352,1,42.5,0|10,2:0|1:2,2:0:0:0:
|
||||
349,198,75339,2,0,P|366:159|369:117,1,85,2|0,2:0|2:0,2:0:0:0:
|
||||
313,73,75604,1,0,2:0:0:0:
|
||||
313,73,75692,5,2,3:2:0:0:
|
||||
398,264,75868,2,0,L|458:281,1,42.5,10|0,1:2|2:0,2:0:0:0:
|
||||
493,222,76045,2,0,P|463:230|438:269,1,42.5,2|0,2:0|2:0,2:0:0:0:
|
||||
365,335,76221,5,8,1:0:0:0:
|
||||
230,230,76398,1,8,1:0:0:0:
|
||||
180,182,76486,1,0,2:0:0:0:
|
||||
215,123,76574,1,10,1:2:0:0:
|
||||
312,259,76751,2,0,L|295:319,1,53.125,10|0,1:0|2:0,2:0:0:0:
|
||||
298,188,76927,86,0,P|312:171|362:165,1,53.125,8|0,1:0|2:0,2:0:0:0:
|
||||
232,336,77104,2,0,P|210:332|176:292,1,53.125,10|0,1:0|2:0,2:0:0:0:
|
||||
398,303,77280,2,0,P|405:323|385:372,1,53.125,10|0,1:0|2:0,2:0:0:0:
|
||||
220,250,77457,1,10,1:2:0:0:
|
||||
228,223,77545,1,0,2:0:0:0:
|
||||
166,193,77633,6,0,P|149:143|148:88,1,106.25,10|8,1:0|1:0,2:0:0:0:
|
||||
168,22,77898,1,0,2:0:0:0:
|
||||
239,12,77986,2,0,P|254:34|258:67,1,53.125,10|0,1:2|2:0,2:0:0:0:
|
||||
138,199,78162,85,10,1:0:0:0:
|
||||
53,96,78339,2,0,P|62:71|82:53,1,53.125,8|0,1:0|2:0,2:0:0:0:
|
||||
261,133,78515,2,0,P|253:157|226:180,1,53.125,10|0,1:0|2:0,2:0:0:0:
|
||||
105,265,78692,2,0,P|83:250|70:227,1,53.125,10|0,1:0|2:0,2:0:0:0:
|
||||
304,76,78868,1,8,1:0:0:0:
|
||||
318,88,78957,1,0,2:0:0:0:
|
||||
327,125,79045,5,10,1:0:0:0:
|
||||
390,156,79133,1,8,2:0:0:0:
|
||||
451,141,79221,1,8,2:0:0:0:
|
||||
470,96,79310,1,8,2:0:0:0:
|
||||
458,61,79398,1,10,1:0:0:0:
|
||||
430,50,79486,1,8,2:0:0:0:
|
||||
410,58,79574,1,10,2:1:0:0:
|
||||
405,75,79662,1,8,2:0:0:0:
|
||||
408,94,79751,1,8,1:0:0:0:
|
||||
424,115,79839,1,8,2:0:0:0:
|
||||
457,132,79927,1,10,2:1:0:0:
|
||||
492,156,80015,1,8,2:0:0:0:
|
||||
512,206,80104,1,10,1:0:0:0:
|
||||
502,259,80192,1,8,2:0:0:0:
|
||||
444,288,80280,1,8,2:0:0:0:
|
||||
381,259,80368,1,8,2:0:0:0:
|
||||
371,247,80457,6,0,B|344:258|344:258|356:291,1,63.7500024318696,2|0,2:1|2:0,2:0:0:0:
|
||||
187,353,80633,2,0,B|154:341|154:341|159:312,1,63.75,2|0,2:3|2:0,2:0:0:0:
|
||||
247,169,80810,2,0,L|227:295,1,127.5,2|2,2:1|2:1,2:0:0:0:
|
||||
295,342,81074,1,0,2:0:0:0:
|
||||
295,342,81162,6,0,P|282:313|256:296,1,63.75,2|0,2:3|2:0,2:0:0:0:
|
||||
96,242,81339,2,0,P|79:259|103:255,1,63.75,2|0,2:1|2:0,2:0:0:0:
|
||||
208,223,81515,2,0,B|220:135|121:163|153:40,1,170,10|0,1:0|2:0,2:0:0:0:
|
||||
164,37,81780,1,0,2:0:0:0:
|
||||
256,36,81868,38,0,B|301:68|280:118|280:118|227:124|208:67,1,170,0|2,3:0|2:1,2:0:0:0:
|
||||
318,238,82221,6,0,P|332:177|359:117,1,127.5,2|0,3:2|2:0,2:0:0:0:
|
||||
350,41,82398,2,2,P|365:19|352:44,1,85,2|0,2:1|2:0,2:1:0:0:
|
||||
395,263,82574,54,0,B|392:294|417:296|417:296|400:283|398:262,1,85,2|0,3:2|2:0,2:0:0:0:
|
||||
512,168,82751,1,2,2:1:0:0:
|
||||
407,190,82927,6,2,B|524:159|525:295|449:257|473:221,1,170,2|2,3:1|2:3,2:1:0:0:
|
||||
470,228,83280,38,2,B|397:211|344:246|382:270|314:344|227:298,1,255,0|0,3:0|2:0,2:1:0:0:
|
||||
57,231,83633,6,0,B|97:250|97:301|97:301|122:253|191:252,1,170,2|2,3:2|2:1,2:0:0:0:
|
||||
182,329,83898,1,0,2:0:0:0:
|
||||
182,329,83986,2,0,P|169:352|179:326,1,85,0|0,3:0|2:0,2:0:0:0:
|
||||
125,192,84162,6,2,B|111:141|148:109|197:144|130:182,1,127.5,2|0,2:1|2:0,2:1:0:0:
|
||||
163,254,84339,2,0,P|198:190|173:152,1,85,2|0,3:2|2:0,2:0:0:0:
|
||||
35,61,84515,70,0,P|39:86|60:104,1,42.5,2|0,2:3|2:0,2:0:0:0:
|
||||
51,66,84604,2,0,P|70:80|107:73,1,42.5,2|0,2:3|2:0,2:0:0:0:
|
||||
56,52,84692,6,0,B|109:39|161:60|161:60|175:21|225:8,1,170,0|2,3:0|2:1,2:0:0:0:
|
||||
393,101,85045,6,0,P|349:56|287:56,1,127.5,2|0,3:2|2:0,2:0:0:0:
|
||||
242,109,85221,2,2,P|243:79|233:108,1,85,2|0,2:1|2:0,2:1:0:0:
|
||||
319,127,85398,70,0,B|294:175|318:157|292:206,1,85,2|0,3:2|2:0,2:0:0:0:
|
||||
310,313,85574,1,2,2:1:0:0:
|
||||
355,253,85751,6,2,B|329:225|329:225|243:245|243:245|241:288,1,170,2|0,3:1|2:0,2:1:0:0:
|
||||
487,39,86104,5,2,3:0:0:0:
|
||||
480,34,86192,1,2,2:3:0:0:
|
||||
487,30,86280,2,0,P|476:91|467:160,1,127.5,2|0,2:3|2:0,2:0:0:0:
|
||||
413,200,86457,2,0,B|419:239|460:239|463:216|501:221|510:262,1,127.5,2|0,3:0|2:0,2:0:0:0:
|
||||
450,296,86633,2,0,B|437:317|443:340|443:340|458:320|447:290,1,85,2|0,2:1|2:0,2:0:0:0:
|
||||
403,87,86810,70,2,P|346:145|266:131,1,170,2|0,3:1|2:0,2:1:0:0:
|
||||
81,289,87162,38,0,L|76:247,1,42.5,8|0,1:0|2:0,2:0:0:0:
|
||||
50,52,87339,2,0,L|56:95,1,42.5,8|0,1:0|2:0,2:0:0:0:
|
||||
3,176,87515,70,0,B|78:158|48:188|133:169,1,127.5,0|0,3:0|2:0,2:0:0:0:
|
||||
193,142,87692,2,2,P|186:110|132:108,1,85,2|0,2:1|2:0,2:1:0:0:
|
||||
97,359,87868,2,0,P|122:360|148:304,1,85,8|0,1:0|2:0,2:0:0:0:
|
||||
194,262,88045,6,2,B|184:245|150:242|150:242|205:218|257:278|207:291|254:353|325:339,1,255,0|0,2:1|2:0,2:1:0:0:
|
||||
373,320,88398,2,0,P|388:292|366:313,1,85,0|0,3:0|2:0,2:0:0:0:
|
||||
336,173,88574,70,2,B|347:146|379:141|379:141|292:125|252:214|311:278,1,255,10|0,1:0|2:0,2:1:0:0:
|
||||
306,340,88927,6,2,P|322:248|269:28,1,318.75,0|2,3:0|2:1,2:1:0:0:
|
||||
464,7,89280,6,0,B|498:19|496:37|496:37|473:35|466:7,1,85,8|0,1:0|2:0,2:0:0:0:
|
||||
399,29,89457,2,0,B|417:81|462:98|462:98|403:94|394:35,1,170,2|2,2:0|2:0,2:0:0:0:
|
||||
243,159,89810,70,0,P|279:116|363:62,1,127.5,0|0,3:0|2:0,2:0:0:0:
|
||||
402,62,89986,2,0,P|398:107|359:136,1,85,10|0,1:0|2:0,2:0:0:0:
|
||||
173,115,90162,6,0,P|154:93|154:56,1,42.5,2|0,2:3|2:0,2:0:0:0:
|
||||
175,80,90251,2,0,P|171:52|196:16,1,42.5,2|0,2:3|2:0,2:0:0:0:
|
||||
193,90,90339,6,0,B|213:177|181:130|206:240,1,127.5,0|0,3:0|2:0,2:0:0:0:
|
||||
200,284,90515,2,2,P|246:272|265:222,1,85,2|0,2:1|2:0,2:1:0:0:
|
||||
71,172,90692,2,0,P|39:225|60:274,1,85,8|0,1:0|2:0,2:0:0:0:
|
||||
146,333,90868,70,2,B|154:351|188:357|188:357|131:376|85:312|136:303|94:237|22:245,1,255,2|0,2:1|2:0,2:1:0:0:
|
||||
18,318,91221,2,0,P|38:341|26:313,1,85,0|0,3:0|2:0,2:0:0:0:
|
||||
130,208,91398,6,2,B|176:139|123:80|123:80|57:108|72:187,1,255,10|0,1:0|2:0,2:1:0:0:
|
||||
70,171,91751,53,0,3:0:0:0:
|
||||
217,118,91927,2,0,P|215:97|207:77,2,42.5,2|2|8,2:0|2:0|1:0,2:0:0:0:
|
||||
290,310,92280,1,0,2:0:0:0:
|
||||
290,310,92368,1,0,2:0:0:0:
|
||||
290,310,92457,2,0,P|311:269|312:220,1,85,0|2,2:0|3:1,2:0:0:0:
|
||||
303,60,92810,69,2,1:0:0:0:
|
||||
298,54,92898,1,8,2:3:0:0:
|
||||
303,49,92986,2,0,L|291:100,1,42.5,8|8,2:3|2:0,2:0:0:0:
|
||||
219,205,93162,54,0,B|248:192|248:192|264:166|264:166|316:156|316:156|320:154|320:154|319:155|319:155|370:145,1,170,4|2,3:2|2:1,2:0:0:0:
|
||||
512,239,93515,2,2,B|516:282|516:282|456:321|456:321|427:258|427:258|450:221,1,212.5,2|2,3:1|2:0,2:1:0:0:
|
||||
133,154,93868,6,2,P|283:191|441:233,1,318.75,2|0,3:1|2:0,2:1:0:0:
|
||||
483,170,94221,2,0,B|492:160|492:160|488:132|488:132|474:157|474:157|481:170,1,85,2|2,3:0|2:3,2:0:0:0:
|
||||
363,286,94398,6,0,B|311:303|350:304|269:336,1,85,2|0,2:1|2:0,2:0:0:0:
|
||||
230,289,94574,2,0,B|304:290|329:348|329:348|298:315|253:332,1,170,0|2,3:0|2:1,2:0:0:0:
|
||||
64,367,94927,70,0,P|53:368|43:364,1,21.25,2|0,3:0|2:0,2:0:0:0:
|
||||
65,301,95015,2,0,P|56:294|52:284,1,21.25,2|0,2:3|2:0,2:0:0:0:
|
||||
110,248,95104,2,0,P|108:237|112:227,1,21.25,2|0,2:3|2:0,2:0:0:0:
|
||||
179,242,95192,2,0,P|185:233|195:229,1,21.25,2|0,2:3|2:0,2:0:0:0:
|
||||
268,223,95280,5,2,3:0:0:0:
|
||||
268,223,95368,1,0,2:0:0:0:
|
||||
268,223,95457,2,2,P|266:181|240:145,1,85,2|0,2:1|2:0,2:1:0:0:
|
||||
122,97,95633,6,2,B|97:78|100:97|66:83|66:83|71:140|198:154|192:48|141:20,1,255,0|0,3:0|2:0,2:1:0:0:
|
||||
91,23,95986,70,0,P|93:65|121:96,1,85,0|0,3:0|2:0,2:0:0:0:
|
||||
305,112,96162,2,2,P|280:78|239:78,1,85,2|0,2:1|2:0,2:1:0:0:
|
||||
125,331,96339,2,0,B|148:309|190:312|190:312|221:279|192:224,1,159.375,2|0,3:1|2:0,2:1:0:0:
|
||||
195,229,96515,6,2,P|176:199|191:230,1,106.25,2|0,2:1|2:0,2:1:0:0:
|
||||
37,259,96692,6,2,P|188:212|345:183,1,318.75,2|0,3:1|2:0,2:1:0:0:
|
||||
419,185,97045,2,0,B|431:179|431:179|456:192|456:192|427:197|427:197|417:186,1,85,2|2,3:0|2:3,2:0:0:0:
|
||||
377,46,97221,70,0,B|396:60|396:60|380:137,1,85,2|0,2:1|2:0,2:0:0:0:
|
||||
353,326,97398,2,0,B|317:308|317:308|332:242|332:242|331:240|331:240|333:237|333:237|333:234|333:234|345:182,1,170,0|2,3:0|2:1,2:0:0:0:
|
||||
236,33,97751,5,2,3:0:0:0:
|
||||
296,64,97839,1,2,2:3:0:0:
|
||||
312,90,97927,1,2,2:3:0:0:
|
||||
296,64,98015,1,2,2:3:0:0:
|
||||
234,38,98104,5,2,3:0:0:0:
|
||||
164,57,98192,1,2,2:3:0:0:
|
||||
138,99,98280,2,0,B|132:187|151:159|161:243,1,127.5,0|0,1:0|1:0,1:0:0:0:
|
||||
135,291,98457,70,0,P|106:280|106:254,1,42.5,8|0,1:0|2:0,2:0:0:0:
|
||||
83,45,98633,2,0,P|69:60|65:81,1,42.5,8|0,1:0|2:0,2:0:0:0:
|
||||
54,183,98810,6,0,P|113:166|179:160,1,127.5,4|0,3:2|2:0,2:0:0:0:
|
||||
236,118,98986,2,2,P|251:162|226:213,1,85,2|0,2:1|2:0,2:1:0:0:
|
||||
414,279,99162,2,2,B|371:293|330:256|372:244|358:163|295:169|237:189|248:263|299:270,1,255,10|0,1:0|2:0,2:1:0:0:
|
||||
242,322,99515,6,2,B|258:343|256:361|256:361|265:347|292:339,1,85,2|0,2:1|2:0,2:1:0:0:
|
||||
501,190,99692,2,0,P|459:196|418:207,1,85,0|0,3:0|2:0,2:0:0:0:
|
||||
345,131,99868,70,0,P|348:71|405:50,1,127.5,10|0,1:0|2:0,2:0:0:0:
|
||||
419,121,100045,2,2,P|372:121|345:84,1,85,2|0,2:1|2:0,2:1:0:0:
|
||||
477,21,100221,5,0,3:0:0:0:
|
||||
417,207,100398,2,2,P|427:235|426:205,1,85,2|0,2:1|2:0,2:1:0:0:
|
||||
261,101,100574,6,0,P|259:91|255:81,1,21.25,2|0,1:3|2:0,2:0:0:0:
|
||||
193,110,100662,2,0,P|184:104|174:100,1,21.25,2|0,2:3|2:0,2:0:0:0:
|
||||
149,164,100751,2,0,P|139:166|129:170,1,21.25,2|0,2:3|2:0,2:0:0:0:
|
||||
160,232,100839,2,0,P|154:241|150:251,1,21.25,2|0,2:3|2:0,2:0:0:0:
|
||||
222,288,100927,69,2,2:3:0:0:
|
||||
222,288,101015,1,2,2:3:0:0:
|
||||
222,288,101104,2,0,B|268:300|268:300|238:278,1,85,0|0,3:0|2:0,2:0:0:0:
|
||||
67,224,101280,2,0,B|55:177|55:177|62:153|62:153|45:85,1,127.5,8|0,1:0|2:0,2:0:0:0:
|
||||
49,100,101457,2,0,B|32:74|32:74|22:106|22:106|65:103,1,85,0|0,1:0|1:0,1:0:0:0:
|
||||
40,296,101633,6,0,P|82:257|167:257,1,127.5,0|0,3:0|2:0,2:0:0:0:
|
||||
214,283,101810,2,2,P|189:316|148:321,1,85,2|0,2:1|2:0,2:1:0:0:
|
||||
67,224,101986,6,2,B|156:208|243:256|278:327|278:327|309:297|363:301,1,318.75,10|0,1:0|2:0,2:1:0:0:
|
||||
390,251,102339,70,2,B|409:264|411:284|411:284|409:258|409:258|375:243,1,85,2|0,2:1|2:0,2:1:0:0:
|
||||
318,47,102515,2,2,B|306:87|306:87|309:91|309:91|330:146,1,85,2|0,3:1|2:0,2:1:0:0:
|
||||
338,301,102692,2,2,B|310:268|310:268|333:182|349:98|349:98|410:80,1,255,10|0,1:0|2:0,2:1:0:0:
|
||||
451,121,103045,70,0,P|439:139|422:151,1,42.5,0|0,3:0|2:0,2:0:0:0:
|
||||
286,191,103221,2,0,P|250:182|234:168,2,42.5,2|2|8,2:0|2:0|1:0,2:0:0:0:
|
||||
113,21,103574,1,2,2:1:0:0:
|
||||
113,21,103662,1,0,2:0:0:0:
|
||||
113,21,103751,6,0,P|149:49|163:93,1,85,0|2,2:0|3:2,2:0:0:0:
|
||||
156,226,104104,2,2,P|176:237|161:220,1,63.75,8|0,2:1|2:0,2:1:0:0:
|
||||
138,155,104280,86,0,B|78:173|118:200|32:236,1,127.5,0|4,2:0|3:1,2:0:0:0:
|
||||
9,303,104633,1,2,2:3:0:0:
|
||||
61,352,104810,1,8,2:0:0:0:
|
||||
130,345,104986,2,2,P|129:323|120:304,3,42.5,2|0|0|0,2:0|2:0|2:0|2:0,2:0:0:0:
|
||||
174,256,105339,1,2,3:0:0:0:
|
||||
248,243,105515,1,10,2:0:0:0:
|
||||
311,283,105692,1,2,2:0:0:0:
|
||||
478,200,105868,5,0,3:0:0:0:
|
||||
358,224,106045,1,2,2:3:0:0:
|
||||
464,184,106221,1,10,2:0:0:0:
|
||||
396,157,106398,1,2,2:0:0:0:
|
||||
467,167,106574,1,2,2:0:0:0:
|
||||
362,91,106751,1,0,3:0:0:0:
|
||||
480,153,106927,1,10,2:0:0:0:
|
||||
446,85,107104,1,0,2:0:0:0:
|
||||
493,18,107280,85,2,3:2:0:0:
|
||||
430,64,107457,1,2,2:3:0:0:
|
||||
354,59,107633,1,10,2:0:0:0:
|
||||
292,17,107810,2,2,P|270:17|251:25,3,42.5,2|0|0|0,2:0|2:0|2:0|2:0,2:0:0:0:
|
||||
179,17,108162,1,2,3:0:0:0:
|
||||
115,58,108339,1,10,2:0:0:0:
|
||||
101,129,108515,1,2,2:0:0:0:
|
||||
200,208,108692,5,2,3:2:0:0:
|
||||
94,251,108868,1,2,2:3:0:0:
|
||||
208,228,109045,1,10,2:0:0:0:
|
||||
162,293,109221,1,2,2:0:0:0:
|
||||
225,242,109398,1,2,2:0:0:0:
|
||||
216,349,109574,1,0,3:0:0:0:
|
||||
249,246,109751,1,8,2:0:0:0:
|
||||
267,282,109927,1,0,2:0:0:0:
|
||||
359,213,110104,86,0,P|377:184|378:134,1,85,8|2,2:0|2:3,2:0:0:0:
|
||||
277,89,110457,2,0,P|248:71|198:70,1,85,8|2,2:0|2:0,2:0:0:0:
|
||||
153,171,110810,2,0,P|135:200|134:250,1,85,8|0,2:0|2:0,2:0:0:0:
|
||||
235,295,111162,2,0,P|264:313|314:314,1,85,8|10,2:0|2:0,2:0:0:0:
|
||||
387,324,111427,1,10,2:0:0:0:
|
||||
387,324,111515,22,0,P|349:347|305:344,1,85,8|2,2:0|2:3,2:0:0:0:
|
||||
125,60,111868,2,0,P|163:37|207:40,1,85,8|2,2:0|2:0,2:0:0:0:
|
||||
300,106,112221,2,0,P|277:141|273:183,1,85,8|0,2:0|2:0,2:0:0:0:
|
||||
215,281,112574,2,0,P|237:245|241:203,1,85,8|8,2:0|2:0,2:0:0:0:
|
||||
186,158,112839,1,8,2:0:0:0:
|
||||
186,158,112927,86,0,P|155:163|137:184,1,42.5,10|8,2:0|2:0,2:0:0:0:
|
||||
75,171,113104,2,0,P|70:202|84:225,1,42.5,8|8,2:0|2:0,2:0:0:0:
|
||||
51,284,113280,2,0,P|79:298|105:291,1,42.5,10|8,2:0|2:0,2:0:0:0:
|
||||
153,347,113457,2,0,P|175:324|177:297,1,42.5,10|8,2:0|2:0,2:0:0:0:
|
||||
240,243,113633,22,8,L|208:253,1,21.25,0|0,2:0|2:0,2:0:0:0:
|
||||
260,236,113721,2,8,L|228:246,1,21.25,0|0,2:0|2:0,2:0:0:0:
|
||||
280,229,113810,2,8,L|248:239,1,21.25,10|8,2:0|2:0,2:0:0:0:
|
||||
300,222,113898,2,8,L|268:232,1,21.25,0|0,2:0|2:0,2:0:0:0:
|
||||
367,255,113986,6,0,B|416:263|416:263|364:115|364:115|331:176,1,255,10|0,1:2|2:0,2:0:0:0:
|
||||
340,157,114339,38,0,L|433:177,1,63.75,8|0,1:0|2:0,2:0:0:0:
|
||||
248,82,114515,1,0,3:0:0:0:
|
||||
261,304,114692,1,0,3:0:0:0:
|
||||
174,230,114868,6,0,L|81:210,1,63.75,8|0,1:0|2:0,2:0:0:0:
|
||||
269,312,115045,1,0,3:0:0:0:
|
||||
241,74,115221,1,0,3:0:0:0:
|
||||
267,197,115398,86,0,P|250:179|258:202,1,63.75,12|0,1:2|2:0,2:0:0:0:
|
||||
@@ -0,0 +1 @@
|
||||
MAP_PATH="path/to/.osu/files"
|
||||
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "pp-plot"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
dotenv = { version = "0.15", default-features = false }
|
||||
futures = { version = "0.3", default-features = false, features = ["std"] }
|
||||
plotters = { version = "0.3" }
|
||||
rosu-pp = { path = "..", features = ["async_tokio"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = { version = "1.0" }
|
||||
tokio = { version = "1.0", default-features = false, features = ["fs", "rt-multi-thread"] }
|
||||
@@ -0,0 +1,9 @@
|
||||
# pp-gen
|
||||
|
||||
Small script to plot value differences between `rosu-pp`'s performance attributes and `pp-gen`'s `output.json`.
|
||||
|
||||
### How to use
|
||||
|
||||
- Rename `.env.example` to `.env` and put proper values for its variables:
|
||||
- `MAP_PATH` is the path to the folder containing a bunch of `{map_id}.osu` files
|
||||
- Run `cargo run --release`. The program will read the file at `../pp-gen/output.json`, calculate `rosu-pp` values, and plot the differences in the files `accuracy_{mode}.svg`.
|
||||
@@ -0,0 +1,174 @@
|
||||
<svg width="1024" height="256" viewBox="0 0 1024 256" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="0" y="0" width="1023" height="255" opacity="1" fill="#FFFFFF" stroke="none"/>
|
||||
<text x="512" y="10" dy="0.76em" text-anchor="middle" font-family="sans-serif" font-size="16.129032258064516" opacity="1" fill="#000000">
|
||||
fruits (98 data points)
|
||||
</text>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="104" y1="210" x2="104" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="114" y1="210" x2="114" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="124" y1="210" x2="124" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="133" y1="210" x2="133" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="143" y1="210" x2="143" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="153" y1="210" x2="153" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="162" y1="210" x2="162" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="172" y1="210" x2="172" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="182" y1="210" x2="182" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="191" y1="210" x2="191" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="201" y1="210" x2="201" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="211" y1="210" x2="211" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="221" y1="210" x2="221" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="230" y1="210" x2="230" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="240" y1="210" x2="240" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="250" y1="210" x2="250" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="259" y1="210" x2="259" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="269" y1="210" x2="269" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="279" y1="210" x2="279" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="288" y1="210" x2="288" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="298" y1="210" x2="298" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="308" y1="210" x2="308" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="317" y1="210" x2="317" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="327" y1="210" x2="327" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="337" y1="210" x2="337" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="347" y1="210" x2="347" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="356" y1="210" x2="356" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="366" y1="210" x2="366" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="376" y1="210" x2="376" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="385" y1="210" x2="385" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="395" y1="210" x2="395" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="405" y1="210" x2="405" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="414" y1="210" x2="414" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="424" y1="210" x2="424" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="434" y1="210" x2="434" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="444" y1="210" x2="444" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="453" y1="210" x2="453" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="463" y1="210" x2="463" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="473" y1="210" x2="473" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="482" y1="210" x2="482" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="492" y1="210" x2="492" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="502" y1="210" x2="502" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="511" y1="210" x2="511" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="521" y1="210" x2="521" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="531" y1="210" x2="531" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="540" y1="210" x2="540" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="550" y1="210" x2="550" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="560" y1="210" x2="560" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="570" y1="210" x2="570" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="579" y1="210" x2="579" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="589" y1="210" x2="589" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="599" y1="210" x2="599" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="608" y1="210" x2="608" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="618" y1="210" x2="618" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="628" y1="210" x2="628" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="637" y1="210" x2="637" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="647" y1="210" x2="647" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="657" y1="210" x2="657" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="667" y1="210" x2="667" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="676" y1="210" x2="676" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="686" y1="210" x2="686" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="696" y1="210" x2="696" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="705" y1="210" x2="705" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="715" y1="210" x2="715" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="725" y1="210" x2="725" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="734" y1="210" x2="734" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="744" y1="210" x2="744" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="754" y1="210" x2="754" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="763" y1="210" x2="763" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="773" y1="210" x2="773" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="783" y1="210" x2="783" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="793" y1="210" x2="793" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="802" y1="210" x2="802" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="812" y1="210" x2="812" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="822" y1="210" x2="822" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="831" y1="210" x2="831" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="841" y1="210" x2="841" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="851" y1="210" x2="851" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="860" y1="210" x2="860" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="870" y1="210" x2="870" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="880" y1="210" x2="880" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="890" y1="210" x2="890" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="899" y1="210" x2="899" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="909" y1="210" x2="909" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="919" y1="210" x2="919" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="928" y1="210" x2="928" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="938" y1="210" x2="938" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="948" y1="210" x2="948" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="957" y1="210" x2="957" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="967" y1="210" x2="967" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="977" y1="210" x2="977" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="986" y1="210" x2="986" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="996" y1="210" x2="996" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="1006" y1="210" x2="1006" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="95" y1="165" x2="1009" y2="165"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="95" y1="75" x2="1009" y2="75"/>
|
||||
<text x="552" y="251" dy="-0.5ex" text-anchor="middle" font-family="sans-serif" font-size="9.67741935483871" opacity="1" fill="#000000">
|
||||
Away from actual value
|
||||
</text>
|
||||
<line opacity="0.2" stroke="#000000" stroke-width="1" x1="191" y1="210" x2="191" y2="30"/>
|
||||
<line opacity="0.2" stroke="#000000" stroke-width="1" x1="288" y1="210" x2="288" y2="30"/>
|
||||
<line opacity="0.2" stroke="#000000" stroke-width="1" x1="385" y1="210" x2="385" y2="30"/>
|
||||
<line opacity="0.2" stroke="#000000" stroke-width="1" x1="482" y1="210" x2="482" y2="30"/>
|
||||
<line opacity="0.2" stroke="#000000" stroke-width="1" x1="579" y1="210" x2="579" y2="30"/>
|
||||
<line opacity="0.2" stroke="#000000" stroke-width="1" x1="676" y1="210" x2="676" y2="30"/>
|
||||
<line opacity="0.2" stroke="#000000" stroke-width="1" x1="773" y1="210" x2="773" y2="30"/>
|
||||
<line opacity="0.2" stroke="#000000" stroke-width="1" x1="870" y1="210" x2="870" y2="30"/>
|
||||
<line opacity="0.2" stroke="#000000" stroke-width="1" x1="967" y1="210" x2="967" y2="30"/>
|
||||
<line opacity="0.2" stroke="#000000" stroke-width="1" x1="95" y1="165" x2="1009" y2="165"/>
|
||||
<line opacity="0.2" stroke="#000000" stroke-width="1" x1="95" y1="75" x2="1009" y2="75"/>
|
||||
<polyline fill="none" opacity="1" stroke="#000000" stroke-width="1" points="94,31 94,211 "/>
|
||||
<text x="85" y="165" dy="0.5ex" text-anchor="end" font-family="sans-serif" font-size="9.67741935483871" opacity="1" fill="#000000">
|
||||
"pp"
|
||||
</text>
|
||||
<polyline fill="none" opacity="1" stroke="#000000" stroke-width="1" points="89,165 94,165 "/>
|
||||
<text x="85" y="75" dy="0.5ex" text-anchor="end" font-family="sans-serif" font-size="9.67741935483871" opacity="1" fill="#000000">
|
||||
"stars"
|
||||
</text>
|
||||
<polyline fill="none" opacity="1" stroke="#000000" stroke-width="1" points="89,75 94,75 "/>
|
||||
<polyline fill="none" opacity="1" stroke="#000000" stroke-width="1" points="95,211 1009,211 "/>
|
||||
<text x="191" y="221" dy="0.76em" text-anchor="middle" font-family="sans-serif" font-size="9.67741935483871" opacity="1" fill="#000000">
|
||||
0.005
|
||||
</text>
|
||||
<polyline fill="none" opacity="1" stroke="#000000" stroke-width="1" points="191,211 191,216 "/>
|
||||
<text x="288" y="221" dy="0.76em" text-anchor="middle" font-family="sans-serif" font-size="9.67741935483871" opacity="1" fill="#000000">
|
||||
0.01
|
||||
</text>
|
||||
<polyline fill="none" opacity="1" stroke="#000000" stroke-width="1" points="288,211 288,216 "/>
|
||||
<text x="385" y="221" dy="0.76em" text-anchor="middle" font-family="sans-serif" font-size="9.67741935483871" opacity="1" fill="#000000">
|
||||
0.015
|
||||
</text>
|
||||
<polyline fill="none" opacity="1" stroke="#000000" stroke-width="1" points="385,211 385,216 "/>
|
||||
<text x="482" y="221" dy="0.76em" text-anchor="middle" font-family="sans-serif" font-size="9.67741935483871" opacity="1" fill="#000000">
|
||||
0.02
|
||||
</text>
|
||||
<polyline fill="none" opacity="1" stroke="#000000" stroke-width="1" points="482,211 482,216 "/>
|
||||
<text x="579" y="221" dy="0.76em" text-anchor="middle" font-family="sans-serif" font-size="9.67741935483871" opacity="1" fill="#000000">
|
||||
0.025
|
||||
</text>
|
||||
<polyline fill="none" opacity="1" stroke="#000000" stroke-width="1" points="579,211 579,216 "/>
|
||||
<text x="676" y="221" dy="0.76em" text-anchor="middle" font-family="sans-serif" font-size="9.67741935483871" opacity="1" fill="#000000">
|
||||
0.03
|
||||
</text>
|
||||
<polyline fill="none" opacity="1" stroke="#000000" stroke-width="1" points="676,211 676,216 "/>
|
||||
<text x="773" y="221" dy="0.76em" text-anchor="middle" font-family="sans-serif" font-size="9.67741935483871" opacity="1" fill="#000000">
|
||||
0.035
|
||||
</text>
|
||||
<polyline fill="none" opacity="1" stroke="#000000" stroke-width="1" points="773,211 773,216 "/>
|
||||
<text x="870" y="221" dy="0.76em" text-anchor="middle" font-family="sans-serif" font-size="9.67741935483871" opacity="1" fill="#000000">
|
||||
0.04
|
||||
</text>
|
||||
<polyline fill="none" opacity="1" stroke="#000000" stroke-width="1" points="870,211 870,216 "/>
|
||||
<text x="967" y="221" dy="0.76em" text-anchor="middle" font-family="sans-serif" font-size="9.67741935483871" opacity="1" fill="#000000">
|
||||
0.045
|
||||
</text>
|
||||
<polyline fill="none" opacity="1" stroke="#000000" stroke-width="1" points="967,211 967,216 "/>
|
||||
<line opacity="1" stroke="#000000" stroke-width="1" x1="95" y1="160" x2="95" y2="170"/>
|
||||
<line opacity="1" stroke="#000000" stroke-width="1" x1="95" y1="165" x2="100" y2="165"/>
|
||||
<rect x="100" y="155" width="303" height="20" opacity="1" fill="none" stroke="#000000"/>
|
||||
<line opacity="1" stroke="#000000" stroke-width="1" x1="133" y1="155" x2="133" y2="175"/>
|
||||
<line opacity="1" stroke="#000000" stroke-width="1" x1="403" y1="165" x2="856" y2="165"/>
|
||||
<line opacity="1" stroke="#000000" stroke-width="1" x1="856" y1="160" x2="856" y2="170"/>
|
||||
<line opacity="1" stroke="#000000" stroke-width="1" x1="95" y1="70" x2="95" y2="80"/>
|
||||
<line opacity="1" stroke="#000000" stroke-width="1" x1="95" y1="75" x2="95" y2="75"/>
|
||||
<rect x="95" y="65" width="2" height="20" opacity="1" fill="none" stroke="#000000"/>
|
||||
<line opacity="1" stroke="#000000" stroke-width="1" x1="95" y1="65" x2="95" y2="85"/>
|
||||
<line opacity="1" stroke="#000000" stroke-width="1" x1="97" y1="75" x2="101" y2="75"/>
|
||||
<line opacity="1" stroke="#000000" stroke-width="1" x1="101" y1="70" x2="101" y2="80"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1,162 @@
|
||||
<svg width="1024" height="512" viewBox="0 0 1024 512" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="0" y="0" width="1023" height="511" opacity="1" fill="#FFFFFF" stroke="none"/>
|
||||
<text x="512" y="10" dy="0.76em" text-anchor="middle" font-family="sans-serif" font-size="16.129032258064516" opacity="1" fill="#000000">
|
||||
mania (168 data points)
|
||||
</text>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="112" y1="466" x2="112" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="129" y1="466" x2="129" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="146" y1="466" x2="146" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="163" y1="466" x2="163" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="180" y1="466" x2="180" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="197" y1="466" x2="197" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="215" y1="466" x2="215" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="232" y1="466" x2="232" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="249" y1="466" x2="249" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="266" y1="466" x2="266" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="283" y1="466" x2="283" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="300" y1="466" x2="300" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="317" y1="466" x2="317" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="335" y1="466" x2="335" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="352" y1="466" x2="352" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="369" y1="466" x2="369" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="386" y1="466" x2="386" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="403" y1="466" x2="403" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="420" y1="466" x2="420" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="438" y1="466" x2="438" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="455" y1="466" x2="455" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="472" y1="466" x2="472" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="489" y1="466" x2="489" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="506" y1="466" x2="506" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="523" y1="466" x2="523" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="540" y1="466" x2="540" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="558" y1="466" x2="558" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="575" y1="466" x2="575" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="592" y1="466" x2="592" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="609" y1="466" x2="609" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="626" y1="466" x2="626" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="643" y1="466" x2="643" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="660" y1="466" x2="660" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="678" y1="466" x2="678" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="695" y1="466" x2="695" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="712" y1="466" x2="712" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="729" y1="466" x2="729" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="746" y1="466" x2="746" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="763" y1="466" x2="763" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="781" y1="466" x2="781" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="798" y1="466" x2="798" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="815" y1="466" x2="815" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="832" y1="466" x2="832" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="849" y1="466" x2="849" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="866" y1="466" x2="866" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="883" y1="466" x2="883" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="901" y1="466" x2="901" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="918" y1="466" x2="918" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="935" y1="466" x2="935" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="952" y1="466" x2="952" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="969" y1="466" x2="969" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="986" y1="466" x2="986" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="1004" y1="466" x2="1004" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="95" y1="411" x2="1009" y2="411"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="95" y1="302" x2="1009" y2="302"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="95" y1="193" x2="1009" y2="193"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="95" y1="85" x2="1009" y2="85"/>
|
||||
<text x="552" y="507" dy="-0.5ex" text-anchor="middle" font-family="sans-serif" font-size="9.67741935483871" opacity="1" fill="#000000">
|
||||
Away from actual value
|
||||
</text>
|
||||
<line opacity="0.2" stroke="#000000" stroke-width="1" x1="180" y1="466" x2="180" y2="30"/>
|
||||
<line opacity="0.2" stroke="#000000" stroke-width="1" x1="266" y1="466" x2="266" y2="30"/>
|
||||
<line opacity="0.2" stroke="#000000" stroke-width="1" x1="352" y1="466" x2="352" y2="30"/>
|
||||
<line opacity="0.2" stroke="#000000" stroke-width="1" x1="438" y1="466" x2="438" y2="30"/>
|
||||
<line opacity="0.2" stroke="#000000" stroke-width="1" x1="523" y1="466" x2="523" y2="30"/>
|
||||
<line opacity="0.2" stroke="#000000" stroke-width="1" x1="609" y1="466" x2="609" y2="30"/>
|
||||
<line opacity="0.2" stroke="#000000" stroke-width="1" x1="695" y1="466" x2="695" y2="30"/>
|
||||
<line opacity="0.2" stroke="#000000" stroke-width="1" x1="781" y1="466" x2="781" y2="30"/>
|
||||
<line opacity="0.2" stroke="#000000" stroke-width="1" x1="866" y1="466" x2="866" y2="30"/>
|
||||
<line opacity="0.2" stroke="#000000" stroke-width="1" x1="952" y1="466" x2="952" y2="30"/>
|
||||
<line opacity="0.2" stroke="#000000" stroke-width="1" x1="95" y1="411" x2="1009" y2="411"/>
|
||||
<line opacity="0.2" stroke="#000000" stroke-width="1" x1="95" y1="302" x2="1009" y2="302"/>
|
||||
<line opacity="0.2" stroke="#000000" stroke-width="1" x1="95" y1="193" x2="1009" y2="193"/>
|
||||
<line opacity="0.2" stroke="#000000" stroke-width="1" x1="95" y1="85" x2="1009" y2="85"/>
|
||||
<polyline fill="none" opacity="1" stroke="#000000" stroke-width="1" points="94,31 94,467 "/>
|
||||
<text x="85" y="411" dy="0.5ex" text-anchor="end" font-family="sans-serif" font-size="9.67741935483871" opacity="1" fill="#000000">
|
||||
"strain pp"
|
||||
</text>
|
||||
<polyline fill="none" opacity="1" stroke="#000000" stroke-width="1" points="89,411 94,411 "/>
|
||||
<text x="85" y="302" dy="0.5ex" text-anchor="end" font-family="sans-serif" font-size="9.67741935483871" opacity="1" fill="#000000">
|
||||
"accuracy pp"
|
||||
</text>
|
||||
<polyline fill="none" opacity="1" stroke="#000000" stroke-width="1" points="89,302 94,302 "/>
|
||||
<text x="85" y="193" dy="0.5ex" text-anchor="end" font-family="sans-serif" font-size="9.67741935483871" opacity="1" fill="#000000">
|
||||
"pp"
|
||||
</text>
|
||||
<polyline fill="none" opacity="1" stroke="#000000" stroke-width="1" points="89,193 94,193 "/>
|
||||
<text x="85" y="85" dy="0.5ex" text-anchor="end" font-family="sans-serif" font-size="9.67741935483871" opacity="1" fill="#000000">
|
||||
"stars"
|
||||
</text>
|
||||
<polyline fill="none" opacity="1" stroke="#000000" stroke-width="1" points="89,85 94,85 "/>
|
||||
<polyline fill="none" opacity="1" stroke="#000000" stroke-width="1" points="95,467 1009,467 "/>
|
||||
<text x="180" y="477" dy="0.76em" text-anchor="middle" font-family="sans-serif" font-size="9.67741935483871" opacity="1" fill="#000000">
|
||||
0.0
|
||||
</text>
|
||||
<polyline fill="none" opacity="1" stroke="#000000" stroke-width="1" points="180,467 180,472 "/>
|
||||
<text x="266" y="477" dy="0.76em" text-anchor="middle" font-family="sans-serif" font-size="9.67741935483871" opacity="1" fill="#000000">
|
||||
0.0
|
||||
</text>
|
||||
<polyline fill="none" opacity="1" stroke="#000000" stroke-width="1" points="266,467 266,472 "/>
|
||||
<text x="352" y="477" dy="0.76em" text-anchor="middle" font-family="sans-serif" font-size="9.67741935483871" opacity="1" fill="#000000">
|
||||
0.0
|
||||
</text>
|
||||
<polyline fill="none" opacity="1" stroke="#000000" stroke-width="1" points="352,467 352,472 "/>
|
||||
<text x="438" y="477" dy="0.76em" text-anchor="middle" font-family="sans-serif" font-size="9.67741935483871" opacity="1" fill="#000000">
|
||||
0.0
|
||||
</text>
|
||||
<polyline fill="none" opacity="1" stroke="#000000" stroke-width="1" points="438,467 438,472 "/>
|
||||
<text x="523" y="477" dy="0.76em" text-anchor="middle" font-family="sans-serif" font-size="9.67741935483871" opacity="1" fill="#000000">
|
||||
0.0
|
||||
</text>
|
||||
<polyline fill="none" opacity="1" stroke="#000000" stroke-width="1" points="523,467 523,472 "/>
|
||||
<text x="609" y="477" dy="0.76em" text-anchor="middle" font-family="sans-serif" font-size="9.67741935483871" opacity="1" fill="#000000">
|
||||
0.0
|
||||
</text>
|
||||
<polyline fill="none" opacity="1" stroke="#000000" stroke-width="1" points="609,467 609,472 "/>
|
||||
<text x="695" y="477" dy="0.76em" text-anchor="middle" font-family="sans-serif" font-size="9.67741935483871" opacity="1" fill="#000000">
|
||||
0.0
|
||||
</text>
|
||||
<polyline fill="none" opacity="1" stroke="#000000" stroke-width="1" points="695,467 695,472 "/>
|
||||
<text x="781" y="477" dy="0.76em" text-anchor="middle" font-family="sans-serif" font-size="9.67741935483871" opacity="1" fill="#000000">
|
||||
0.0
|
||||
</text>
|
||||
<polyline fill="none" opacity="1" stroke="#000000" stroke-width="1" points="781,467 781,472 "/>
|
||||
<text x="866" y="477" dy="0.76em" text-anchor="middle" font-family="sans-serif" font-size="9.67741935483871" opacity="1" fill="#000000">
|
||||
0.0
|
||||
</text>
|
||||
<polyline fill="none" opacity="1" stroke="#000000" stroke-width="1" points="866,467 866,472 "/>
|
||||
<text x="952" y="477" dy="0.76em" text-anchor="middle" font-family="sans-serif" font-size="9.67741935483871" opacity="1" fill="#000000">
|
||||
0.0
|
||||
</text>
|
||||
<polyline fill="none" opacity="1" stroke="#000000" stroke-width="1" points="952,467 952,472 "/>
|
||||
<line opacity="1" stroke="#000000" stroke-width="1" x1="95" y1="406" x2="95" y2="416"/>
|
||||
<line opacity="1" stroke="#000000" stroke-width="1" x1="95" y1="411" x2="95" y2="411"/>
|
||||
<rect x="95" y="401" width="304" height="20" opacity="1" fill="none" stroke="#000000"/>
|
||||
<line opacity="1" stroke="#000000" stroke-width="1" x1="95" y1="401" x2="95" y2="421"/>
|
||||
<line opacity="1" stroke="#000000" stroke-width="1" x1="399" y1="411" x2="856" y2="411"/>
|
||||
<line opacity="1" stroke="#000000" stroke-width="1" x1="856" y1="406" x2="856" y2="416"/>
|
||||
<line opacity="1" stroke="#000000" stroke-width="1" x1="95" y1="297" x2="95" y2="307"/>
|
||||
<line opacity="1" stroke="#000000" stroke-width="1" x1="95" y1="302" x2="95" y2="302"/>
|
||||
<rect x="95" y="292" width="76" height="20" opacity="1" fill="none" stroke="#000000"/>
|
||||
<line opacity="1" stroke="#000000" stroke-width="1" x1="95" y1="292" x2="95" y2="312"/>
|
||||
<line opacity="1" stroke="#000000" stroke-width="1" x1="171" y1="302" x2="285" y2="302"/>
|
||||
<line opacity="1" stroke="#000000" stroke-width="1" x1="285" y1="297" x2="285" y2="307"/>
|
||||
<line opacity="1" stroke="#000000" stroke-width="1" x1="95" y1="188" x2="95" y2="198"/>
|
||||
<line opacity="1" stroke="#000000" stroke-width="1" x1="95" y1="193" x2="95" y2="193"/>
|
||||
<rect x="95" y="183" width="304" height="20" opacity="1" fill="none" stroke="#000000"/>
|
||||
<line opacity="1" stroke="#000000" stroke-width="1" x1="95" y1="183" x2="95" y2="203"/>
|
||||
<line opacity="1" stroke="#000000" stroke-width="1" x1="399" y1="193" x2="856" y2="193"/>
|
||||
<line opacity="1" stroke="#000000" stroke-width="1" x1="856" y1="188" x2="856" y2="198"/>
|
||||
<line opacity="1" stroke="#000000" stroke-width="1" x1="95" y1="80" x2="95" y2="90"/>
|
||||
<line opacity="1" stroke="#000000" stroke-width="1" x1="95" y1="85" x2="95" y2="85"/>
|
||||
<rect x="95" y="75" width="9" height="20" opacity="1" fill="none" stroke="#000000"/>
|
||||
<line opacity="1" stroke="#000000" stroke-width="1" x1="95" y1="75" x2="95" y2="95"/>
|
||||
<line opacity="1" stroke="#000000" stroke-width="1" x1="104" y1="85" x2="118" y2="85"/>
|
||||
<line opacity="1" stroke="#000000" stroke-width="1" x1="118" y1="80" x2="118" y2="90"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 12 KiB |
@@ -0,0 +1,165 @@
|
||||
<svg width="1024" height="768" viewBox="0 0 1024 768" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="0" y="0" width="1023" height="767" opacity="1" fill="#FFFFFF" stroke="none"/>
|
||||
<text x="512" y="10" dy="0.76em" text-anchor="middle" font-family="sans-serif" font-size="16.129032258064516" opacity="1" fill="#000000">
|
||||
osu (4908 data points)
|
||||
</text>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="111" y1="722" x2="111" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="127" y1="722" x2="127" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="143" y1="722" x2="143" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="159" y1="722" x2="159" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="175" y1="722" x2="175" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="191" y1="722" x2="191" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="207" y1="722" x2="207" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="223" y1="722" x2="223" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="239" y1="722" x2="239" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="255" y1="722" x2="255" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="271" y1="722" x2="271" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="287" y1="722" x2="287" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="303" y1="722" x2="303" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="319" y1="722" x2="319" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="335" y1="722" x2="335" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="351" y1="722" x2="351" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="367" y1="722" x2="367" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="383" y1="722" x2="383" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="399" y1="722" x2="399" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="415" y1="722" x2="415" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="431" y1="722" x2="431" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="447" y1="722" x2="447" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="463" y1="722" x2="463" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="479" y1="722" x2="479" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="495" y1="722" x2="495" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="511" y1="722" x2="511" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="527" y1="722" x2="527" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="543" y1="722" x2="543" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="559" y1="722" x2="559" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="575" y1="722" x2="575" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="591" y1="722" x2="591" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="607" y1="722" x2="607" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="623" y1="722" x2="623" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="639" y1="722" x2="639" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="655" y1="722" x2="655" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="671" y1="722" x2="671" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="687" y1="722" x2="687" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="703" y1="722" x2="703" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="719" y1="722" x2="719" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="735" y1="722" x2="735" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="751" y1="722" x2="751" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="767" y1="722" x2="767" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="783" y1="722" x2="783" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="799" y1="722" x2="799" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="815" y1="722" x2="815" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="831" y1="722" x2="831" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="847" y1="722" x2="847" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="863" y1="722" x2="863" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="879" y1="722" x2="879" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="895" y1="722" x2="895" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="911" y1="722" x2="911" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="927" y1="722" x2="927" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="943" y1="722" x2="943" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="959" y1="722" x2="959" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="975" y1="722" x2="975" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="991" y1="722" x2="991" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="1007" y1="722" x2="1007" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="95" y1="664" x2="1009" y2="664"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="95" y1="549" x2="1009" y2="549"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="95" y1="433" x2="1009" y2="433"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="95" y1="318" x2="1009" y2="318"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="95" y1="202" x2="1009" y2="202"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="95" y1="88" x2="1009" y2="88"/>
|
||||
<text x="552" y="763" dy="-0.5ex" text-anchor="middle" font-family="sans-serif" font-size="9.67741935483871" opacity="1" fill="#000000">
|
||||
Away from actual value
|
||||
</text>
|
||||
<line opacity="0.2" stroke="#000000" stroke-width="1" x1="255" y1="722" x2="255" y2="30"/>
|
||||
<line opacity="0.2" stroke="#000000" stroke-width="1" x1="415" y1="722" x2="415" y2="30"/>
|
||||
<line opacity="0.2" stroke="#000000" stroke-width="1" x1="575" y1="722" x2="575" y2="30"/>
|
||||
<line opacity="0.2" stroke="#000000" stroke-width="1" x1="735" y1="722" x2="735" y2="30"/>
|
||||
<line opacity="0.2" stroke="#000000" stroke-width="1" x1="895" y1="722" x2="895" y2="30"/>
|
||||
<line opacity="0.2" stroke="#000000" stroke-width="1" x1="95" y1="664" x2="1009" y2="664"/>
|
||||
<line opacity="0.2" stroke="#000000" stroke-width="1" x1="95" y1="549" x2="1009" y2="549"/>
|
||||
<line opacity="0.2" stroke="#000000" stroke-width="1" x1="95" y1="433" x2="1009" y2="433"/>
|
||||
<line opacity="0.2" stroke="#000000" stroke-width="1" x1="95" y1="318" x2="1009" y2="318"/>
|
||||
<line opacity="0.2" stroke="#000000" stroke-width="1" x1="95" y1="202" x2="1009" y2="202"/>
|
||||
<line opacity="0.2" stroke="#000000" stroke-width="1" x1="95" y1="88" x2="1009" y2="88"/>
|
||||
<polyline fill="none" opacity="1" stroke="#000000" stroke-width="1" points="94,31 94,723 "/>
|
||||
<text x="85" y="664" dy="0.5ex" text-anchor="end" font-family="sans-serif" font-size="9.67741935483871" opacity="1" fill="#000000">
|
||||
"speed pp"
|
||||
</text>
|
||||
<polyline fill="none" opacity="1" stroke="#000000" stroke-width="1" points="89,664 94,664 "/>
|
||||
<text x="85" y="549" dy="0.5ex" text-anchor="end" font-family="sans-serif" font-size="9.67741935483871" opacity="1" fill="#000000">
|
||||
"flashlight pp"
|
||||
</text>
|
||||
<polyline fill="none" opacity="1" stroke="#000000" stroke-width="1" points="89,549 94,549 "/>
|
||||
<text x="85" y="433" dy="0.5ex" text-anchor="end" font-family="sans-serif" font-size="9.67741935483871" opacity="1" fill="#000000">
|
||||
"aim pp"
|
||||
</text>
|
||||
<polyline fill="none" opacity="1" stroke="#000000" stroke-width="1" points="89,433 94,433 "/>
|
||||
<text x="85" y="318" dy="0.5ex" text-anchor="end" font-family="sans-serif" font-size="9.67741935483871" opacity="1" fill="#000000">
|
||||
"accuracy pp"
|
||||
</text>
|
||||
<polyline fill="none" opacity="1" stroke="#000000" stroke-width="1" points="89,318 94,318 "/>
|
||||
<text x="85" y="202" dy="0.5ex" text-anchor="end" font-family="sans-serif" font-size="9.67741935483871" opacity="1" fill="#000000">
|
||||
"pp"
|
||||
</text>
|
||||
<polyline fill="none" opacity="1" stroke="#000000" stroke-width="1" points="89,202 94,202 "/>
|
||||
<text x="85" y="88" dy="0.5ex" text-anchor="end" font-family="sans-serif" font-size="9.67741935483871" opacity="1" fill="#000000">
|
||||
"stars"
|
||||
</text>
|
||||
<polyline fill="none" opacity="1" stroke="#000000" stroke-width="1" points="89,88 94,88 "/>
|
||||
<polyline fill="none" opacity="1" stroke="#000000" stroke-width="1" points="95,723 1009,723 "/>
|
||||
<text x="255" y="733" dy="0.76em" text-anchor="middle" font-family="sans-serif" font-size="9.67741935483871" opacity="1" fill="#000000">
|
||||
0.005
|
||||
</text>
|
||||
<polyline fill="none" opacity="1" stroke="#000000" stroke-width="1" points="255,723 255,728 "/>
|
||||
<text x="415" y="733" dy="0.76em" text-anchor="middle" font-family="sans-serif" font-size="9.67741935483871" opacity="1" fill="#000000">
|
||||
0.01
|
||||
</text>
|
||||
<polyline fill="none" opacity="1" stroke="#000000" stroke-width="1" points="415,723 415,728 "/>
|
||||
<text x="575" y="733" dy="0.76em" text-anchor="middle" font-family="sans-serif" font-size="9.67741935483871" opacity="1" fill="#000000">
|
||||
0.015
|
||||
</text>
|
||||
<polyline fill="none" opacity="1" stroke="#000000" stroke-width="1" points="575,723 575,728 "/>
|
||||
<text x="735" y="733" dy="0.76em" text-anchor="middle" font-family="sans-serif" font-size="9.67741935483871" opacity="1" fill="#000000">
|
||||
0.02
|
||||
</text>
|
||||
<polyline fill="none" opacity="1" stroke="#000000" stroke-width="1" points="735,723 735,728 "/>
|
||||
<text x="895" y="733" dy="0.76em" text-anchor="middle" font-family="sans-serif" font-size="9.67741935483871" opacity="1" fill="#000000">
|
||||
0.025
|
||||
</text>
|
||||
<polyline fill="none" opacity="1" stroke="#000000" stroke-width="1" points="895,723 895,728 "/>
|
||||
<line opacity="1" stroke="#000000" stroke-width="1" x1="95" y1="659" x2="95" y2="669"/>
|
||||
<line opacity="1" stroke="#000000" stroke-width="1" x1="95" y1="664" x2="95" y2="664"/>
|
||||
<rect x="95" y="654" width="0" height="20" opacity="1" fill="none" stroke="#000000"/>
|
||||
<line opacity="1" stroke="#000000" stroke-width="1" x1="95" y1="654" x2="95" y2="674"/>
|
||||
<line opacity="1" stroke="#000000" stroke-width="1" x1="95" y1="664" x2="95" y2="664"/>
|
||||
<line opacity="1" stroke="#000000" stroke-width="1" x1="95" y1="659" x2="95" y2="669"/>
|
||||
<line opacity="1" stroke="#000000" stroke-width="1" x1="95" y1="544" x2="95" y2="554"/>
|
||||
<line opacity="1" stroke="#000000" stroke-width="1" x1="95" y1="549" x2="101" y2="549"/>
|
||||
<rect x="101" y="539" width="302" height="20" opacity="1" fill="none" stroke="#000000"/>
|
||||
<line opacity="1" stroke="#000000" stroke-width="1" x1="165" y1="539" x2="165" y2="559"/>
|
||||
<line opacity="1" stroke="#000000" stroke-width="1" x1="403" y1="549" x2="856" y2="549"/>
|
||||
<line opacity="1" stroke="#000000" stroke-width="1" x1="856" y1="544" x2="856" y2="554"/>
|
||||
<line opacity="1" stroke="#000000" stroke-width="1" x1="95" y1="428" x2="95" y2="438"/>
|
||||
<line opacity="1" stroke="#000000" stroke-width="1" x1="95" y1="433" x2="95" y2="433"/>
|
||||
<rect x="95" y="423" width="0" height="20" opacity="1" fill="none" stroke="#000000"/>
|
||||
<line opacity="1" stroke="#000000" stroke-width="1" x1="95" y1="423" x2="95" y2="443"/>
|
||||
<line opacity="1" stroke="#000000" stroke-width="1" x1="95" y1="433" x2="95" y2="433"/>
|
||||
<line opacity="1" stroke="#000000" stroke-width="1" x1="95" y1="428" x2="95" y2="438"/>
|
||||
<line opacity="1" stroke="#000000" stroke-width="1" x1="95" y1="313" x2="95" y2="323"/>
|
||||
<line opacity="1" stroke="#000000" stroke-width="1" x1="95" y1="318" x2="95" y2="318"/>
|
||||
<rect x="95" y="308" width="0" height="20" opacity="1" fill="none" stroke="#000000"/>
|
||||
<line opacity="1" stroke="#000000" stroke-width="1" x1="95" y1="308" x2="95" y2="328"/>
|
||||
<line opacity="1" stroke="#000000" stroke-width="1" x1="95" y1="318" x2="95" y2="318"/>
|
||||
<line opacity="1" stroke="#000000" stroke-width="1" x1="95" y1="313" x2="95" y2="323"/>
|
||||
<line opacity="1" stroke="#000000" stroke-width="1" x1="95" y1="197" x2="95" y2="207"/>
|
||||
<line opacity="1" stroke="#000000" stroke-width="1" x1="95" y1="202" x2="95" y2="202"/>
|
||||
<rect x="95" y="192" width="0" height="20" opacity="1" fill="none" stroke="#000000"/>
|
||||
<line opacity="1" stroke="#000000" stroke-width="1" x1="95" y1="192" x2="95" y2="212"/>
|
||||
<line opacity="1" stroke="#000000" stroke-width="1" x1="95" y1="202" x2="95" y2="202"/>
|
||||
<line opacity="1" stroke="#000000" stroke-width="1" x1="95" y1="197" x2="95" y2="207"/>
|
||||
<line opacity="1" stroke="#000000" stroke-width="1" x1="95" y1="83" x2="95" y2="93"/>
|
||||
<line opacity="1" stroke="#000000" stroke-width="1" x1="95" y1="88" x2="95" y2="88"/>
|
||||
<rect x="95" y="78" width="0" height="20" opacity="1" fill="none" stroke="#000000"/>
|
||||
<line opacity="1" stroke="#000000" stroke-width="1" x1="95" y1="78" x2="95" y2="98"/>
|
||||
<line opacity="1" stroke="#000000" stroke-width="1" x1="95" y1="88" x2="95" y2="88"/>
|
||||
<line opacity="1" stroke="#000000" stroke-width="1" x1="95" y1="83" x2="95" y2="93"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1,456 @@
|
||||
use std::{env, error::Error as StdError, fmt, fs::File as StdFile};
|
||||
|
||||
use futures::{stream::FuturesUnordered, TryStreamExt};
|
||||
use plotters::{
|
||||
data::fitting_range,
|
||||
prelude::{
|
||||
Boxplot, ChartBuilder, DrawingAreaErrorKind, IntoDrawingArea, IntoSegmentedCoord,
|
||||
Quartiles, SVGBackend, SegmentValue,
|
||||
},
|
||||
style::WHITE,
|
||||
};
|
||||
use rosu_pp::{Beatmap, BeatmapExt, PerformanceAttributes};
|
||||
use serde::Deserialize;
|
||||
use tokio::{fs::File, runtime::Runtime};
|
||||
|
||||
fn main() {
|
||||
dotenv::dotenv().expect("failed to read .env file");
|
||||
|
||||
Runtime::new()
|
||||
.expect("failed to create runtime")
|
||||
.block_on(async_main());
|
||||
}
|
||||
|
||||
async fn async_main() {
|
||||
let map_path_ = env::var("MAP_PATH").expect("missing `MAP_PATH` environment variable");
|
||||
let map_path = map_path_.as_str();
|
||||
|
||||
println!("Deserializing data from output.json...");
|
||||
|
||||
let file = StdFile::open("../pp-gen/output.json").expect("failed to open `output.json` file");
|
||||
let data: Vec<SimulateData> =
|
||||
serde_json::from_reader(file).expect("failed to deserialize data");
|
||||
|
||||
println!(
|
||||
"Calculating values for {} map-mod combinations...",
|
||||
data.len()
|
||||
);
|
||||
|
||||
// Calculate rosu-pp's PerformanceAttributes on all map-mod pairs
|
||||
let result = data
|
||||
.into_iter()
|
||||
.map(|data| async move {
|
||||
let path = format!("{}/{}.osu", map_path, data.score.map_id);
|
||||
let file = File::open(path).await?;
|
||||
let map = Beatmap::parse(file).await?;
|
||||
let mods = parse_mods(&data.score.mods);
|
||||
let attrs = map.max_pp(mods);
|
||||
|
||||
Ok::<_, Error>((data, attrs, mods))
|
||||
})
|
||||
.collect::<FuturesUnordered<_>>()
|
||||
.try_collect::<Vec<_>>()
|
||||
.await;
|
||||
|
||||
let tuples = match result {
|
||||
Ok(attrs) => attrs,
|
||||
Err(err) => return print_err(err),
|
||||
};
|
||||
|
||||
println!("Evaluating values...");
|
||||
|
||||
// Compare the values from output.json with the PerformanceAttribute values
|
||||
let mut evaluators = [
|
||||
Evaluator::new("osu"),
|
||||
Evaluator::new("taiko"),
|
||||
Evaluator::new("catch"),
|
||||
Evaluator::new("mania"),
|
||||
];
|
||||
|
||||
for (data, attributes, mods) in tuples {
|
||||
evaluators[data.score.mode as usize].process(data, attributes, mods);
|
||||
}
|
||||
|
||||
for evaluator in evaluators {
|
||||
let mode = evaluator.mode;
|
||||
|
||||
if let Err(err) = evaluator.plot() {
|
||||
eprintln!("failed to plot for {}", mode);
|
||||
print_err(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Mode specific evaluator containing differences
|
||||
/// of values from `Data` and `PerformanceAttributes`.
|
||||
#[derive(Default)]
|
||||
struct Evaluator {
|
||||
mode: &'static str,
|
||||
count: usize,
|
||||
|
||||
aim: Option<Vec<f64>>,
|
||||
accuracy: Option<Vec<f64>>,
|
||||
flashlight: Option<Vec<f64>>,
|
||||
speed: Option<Vec<f64>>,
|
||||
strain: Option<Vec<f64>>,
|
||||
|
||||
stars: Vec<f64>,
|
||||
pp: Vec<f64>,
|
||||
}
|
||||
|
||||
impl Evaluator {
|
||||
fn new(mode: &'static str) -> Self {
|
||||
Self {
|
||||
mode,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// For all mode-specific data points, calculate the
|
||||
/// differences of `data`'s value and `attrs`' value
|
||||
fn process(&mut self, data: SimulateData, attrs: PerformanceAttributes, mods: u32) {
|
||||
self.count += 1;
|
||||
self.stars
|
||||
.push(difference(data.difficulty.stars, attrs.stars()));
|
||||
self.pp.push(difference(data.performance.pp, attrs.pp()));
|
||||
|
||||
match attrs {
|
||||
PerformanceAttributes::Catch(_) => {}
|
||||
PerformanceAttributes::Mania(attrs) => {
|
||||
if let Some(acc) = data.performance.acc {
|
||||
let values = self.accuracy.get_or_insert_with(Vec::new);
|
||||
let entry = difference(acc, attrs.pp_acc);
|
||||
values.push(entry);
|
||||
}
|
||||
|
||||
if let Some(strain) = data.performance.difficulty {
|
||||
let values = self.strain.get_or_insert_with(Vec::new);
|
||||
let entry = difference(strain, attrs.pp_strain);
|
||||
values.push(entry);
|
||||
}
|
||||
}
|
||||
PerformanceAttributes::Osu(attrs) => {
|
||||
if let Some(acc) = data.performance.acc {
|
||||
let values = self.accuracy.get_or_insert_with(Vec::new);
|
||||
let entry = difference(acc, attrs.pp_acc);
|
||||
values.push(entry);
|
||||
}
|
||||
|
||||
if let Some(aim) = data.performance.aim {
|
||||
let values = self.aim.get_or_insert_with(Vec::new);
|
||||
let entry = difference(aim, attrs.pp_aim);
|
||||
values.push(entry);
|
||||
}
|
||||
|
||||
if mods & 1024 > 0 {
|
||||
if let Some(flashlight) = data.performance.flashlight {
|
||||
let values = self.flashlight.get_or_insert_with(Vec::new);
|
||||
let entry = difference(flashlight, attrs.pp_flashlight);
|
||||
values.push(entry);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(speed) = data.performance.speed {
|
||||
let values = self.speed.get_or_insert_with(Vec::new);
|
||||
let entry = difference(speed, attrs.pp_speed);
|
||||
values.push(entry);
|
||||
}
|
||||
}
|
||||
PerformanceAttributes::Taiko(attrs) => {
|
||||
if let Some(acc) = data.performance.acc {
|
||||
let values = self.accuracy.get_or_insert_with(Vec::new);
|
||||
let entry = difference(acc, attrs.pp_acc);
|
||||
values.push(entry);
|
||||
}
|
||||
|
||||
if let Some(strain) = data.performance.difficulty {
|
||||
let values = self.strain.get_or_insert_with(Vec::new);
|
||||
let entry = difference(strain, attrs.pp_strain);
|
||||
values.push(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Plot all gathered differences
|
||||
fn plot(self) -> Result<(), Error> {
|
||||
let mode = self.mode;
|
||||
let output_path = format!("accuracy_{}.svg", mode);
|
||||
let dataset = self.to_quartiles();
|
||||
let kind_list: Vec<_> = dataset.iter().map(|(kind, _)| *kind).collect();
|
||||
|
||||
let height = kind_list.len() as u32 * 128;
|
||||
let root = SVGBackend::new(&output_path, (1024, height)).into_drawing_area();
|
||||
root.fill(&WHITE)?;
|
||||
let root = root.margin(5, 5, 15, 15);
|
||||
|
||||
let values = dataset
|
||||
.iter()
|
||||
.map(|(_, quartiles)| quartiles.values())
|
||||
.flatten()
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let values_range = fitting_range(values.iter());
|
||||
let caption = format!("{} ({} data points)", mode, self.count);
|
||||
|
||||
// Set the chart structure
|
||||
let mut chart = ChartBuilder::on(&root)
|
||||
.x_label_area_size(40)
|
||||
.y_label_area_size(80)
|
||||
.caption(caption, ("sans-serif", 20))
|
||||
.build_cartesian_2d(
|
||||
0.0..values_range.end + values_range.end * 0.2,
|
||||
kind_list[..].into_segmented(),
|
||||
)?;
|
||||
|
||||
chart
|
||||
.configure_mesh()
|
||||
.x_desc("Away from actual value")
|
||||
.y_labels(kind_list.len())
|
||||
.light_line_style(&WHITE)
|
||||
.draw()?;
|
||||
|
||||
// Insert data into the chart
|
||||
for (kind, quartile) in dataset.iter() {
|
||||
chart.draw_series(std::iter::once(
|
||||
Boxplot::new_horizontal(SegmentValue::CenterOf(kind), quartile)
|
||||
.width(20)
|
||||
.whisker_width(0.5),
|
||||
))?;
|
||||
}
|
||||
|
||||
root.present()?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn to_quartiles(&self) -> Vec<(&'static str, Quartiles)> {
|
||||
let mut vec = Vec::new();
|
||||
|
||||
println!("---");
|
||||
|
||||
let max = self
|
||||
.stars
|
||||
.iter()
|
||||
.fold(0.0, |m, &n| if n > m { n } else { m });
|
||||
let avg = self.stars.iter().copied().sum::<f64>() / self.stars.len() as f64;
|
||||
println!("[{}] Stars: average={} | max={}", self.mode, avg, max);
|
||||
vec.push(("stars", Quartiles::new(&self.stars)));
|
||||
|
||||
let max = self.pp.iter().fold(0.0, |m, &n| if n > m { n } else { m });
|
||||
let avg = self.pp.iter().copied().sum::<f64>() / self.pp.len() as f64;
|
||||
println!("[{}] PP: average={} | max={}", self.mode, avg, max);
|
||||
vec.push(("pp", Quartiles::new(&self.pp)));
|
||||
|
||||
if let Some(ref acc) = self.accuracy {
|
||||
if !acc.is_empty() {
|
||||
let max = acc.iter().fold(0.0, |m, &n| if n > m { n } else { m });
|
||||
let avg = acc.iter().copied().sum::<f64>() / acc.len() as f64;
|
||||
println!("[{}] Accuracy: average={} | max={}", self.mode, avg, max);
|
||||
}
|
||||
|
||||
vec.push(("accuracy pp", Quartiles::new(acc)));
|
||||
}
|
||||
|
||||
if let Some(ref aim) = self.aim {
|
||||
if !aim.is_empty() {
|
||||
let max = aim.iter().fold(0.0, |m, &n| if n > m { n } else { m });
|
||||
let avg = aim.iter().copied().sum::<f64>() / aim.len() as f64;
|
||||
println!("[{}] Aim: average={} | max={}", self.mode, avg, max);
|
||||
}
|
||||
|
||||
vec.push(("aim pp", Quartiles::new(aim)));
|
||||
}
|
||||
|
||||
if let Some(ref fl) = self.flashlight {
|
||||
if !fl.is_empty() {
|
||||
let max = fl.iter().fold(0.0, |m, &n| if n > m { n } else { m });
|
||||
let avg = fl.iter().copied().sum::<f64>() / fl.len() as f64;
|
||||
println!("[{}] Flashlight: average={} | max={}", self.mode, avg, max);
|
||||
}
|
||||
|
||||
vec.push(("flashlight pp", Quartiles::new(fl)));
|
||||
}
|
||||
|
||||
if let Some(ref speed) = self.speed {
|
||||
if !speed.is_empty() {
|
||||
let max = speed.iter().fold(0.0, |m, &n| if n > m { n } else { m });
|
||||
let avg = speed.iter().copied().sum::<f64>() / speed.len() as f64;
|
||||
println!("[{}] Speed: average={} | max={}", self.mode, avg, max);
|
||||
}
|
||||
|
||||
vec.push(("speed pp", Quartiles::new(speed)));
|
||||
}
|
||||
|
||||
if let Some(ref strain) = self.strain {
|
||||
if !strain.is_empty() {
|
||||
let max = strain.iter().fold(0.0, |m, &n| if n > m { n } else { m });
|
||||
let avg = strain.iter().copied().sum::<f64>() / strain.len() as f64;
|
||||
println!("[{}] Strain: average={} | max={}", self.mode, avg, max);
|
||||
}
|
||||
|
||||
vec.push(("strain pp", Quartiles::new(strain)));
|
||||
}
|
||||
|
||||
vec.reverse();
|
||||
|
||||
vec
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum Error {
|
||||
DrawingArea(String),
|
||||
Io(std::io::Error),
|
||||
ParseMap(rosu_pp::ParseError),
|
||||
}
|
||||
|
||||
impl fmt::Display for Error {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::DrawingArea(src) => write!(f, "drawing area error: {}", src),
|
||||
Self::Io(_) => f.write_str("io error"),
|
||||
Self::ParseMap(_) => f.write_str("failed to parse map"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl StdError for Error {
|
||||
fn source(&self) -> Option<&(dyn StdError + 'static)> {
|
||||
match self {
|
||||
Self::DrawingArea(_) => None,
|
||||
Self::Io(src) => Some(src),
|
||||
Self::ParseMap(src) => Some(src),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<E: StdError + Send + Sync> From<DrawingAreaErrorKind<E>> for Error {
|
||||
fn from(e: DrawingAreaErrorKind<E>) -> Self {
|
||||
Self::DrawingArea(e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<std::io::Error> for Error {
|
||||
fn from(e: std::io::Error) -> Self {
|
||||
Self::Io(e)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<rosu_pp::ParseError> for Error {
|
||||
fn from(e: rosu_pp::ParseError) -> Self {
|
||||
Self::ParseMap(e)
|
||||
}
|
||||
}
|
||||
|
||||
fn difference(actual: f64, calculated: f64) -> f64 {
|
||||
(actual - calculated).abs()
|
||||
}
|
||||
|
||||
fn parse_mods(mods_list: &[String]) -> u32 {
|
||||
let mut mods = 0;
|
||||
|
||||
for m in mods_list {
|
||||
match m.as_str() {
|
||||
"NF" => mods += 1,
|
||||
"EZ" => mods += 2,
|
||||
"TD" => mods += 4,
|
||||
"HD" => mods += 8,
|
||||
"HR" => mods += 16,
|
||||
"DT" => mods += 64,
|
||||
"RX" => mods += 128,
|
||||
"HT" => mods += 256,
|
||||
"FL" => mods += 1024,
|
||||
_ => panic!("unrecognized mod: {}", m),
|
||||
}
|
||||
}
|
||||
|
||||
mods
|
||||
}
|
||||
|
||||
fn print_err(err: Error) {
|
||||
let mut e: &dyn StdError = &err;
|
||||
eprintln!("{}", err);
|
||||
|
||||
while let Some(src) = e.source() {
|
||||
eprintln!(" - caused by: {}", src);
|
||||
e = src;
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct SimulateData {
|
||||
score: Score,
|
||||
performance: Performance,
|
||||
difficulty: Difficulty,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Score {
|
||||
mode: u32,
|
||||
map_id: u32,
|
||||
mods: Vec<String>,
|
||||
total_score: u32,
|
||||
acc: f64,
|
||||
combo: u32,
|
||||
stats: Statistics,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Statistics {
|
||||
#[serde(default)]
|
||||
perfect: usize,
|
||||
great: usize,
|
||||
#[serde(default)]
|
||||
good: usize,
|
||||
ok: usize,
|
||||
meh: usize,
|
||||
miss: usize,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Performance {
|
||||
#[serde(default)]
|
||||
aim: Option<f64>,
|
||||
#[serde(default)]
|
||||
speed: Option<f64>,
|
||||
#[serde(default)]
|
||||
acc: Option<f64>,
|
||||
#[serde(default)]
|
||||
flashlight: Option<f64>,
|
||||
#[serde(default)]
|
||||
effective_miss_count: Option<f64>,
|
||||
#[serde(default)]
|
||||
scaled_score: Option<f64>,
|
||||
#[serde(default)]
|
||||
difficulty: Option<f64>,
|
||||
pp: f64,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Difficulty {
|
||||
stars: f64,
|
||||
max_combo: u32,
|
||||
#[serde(default)]
|
||||
aim: Option<f64>,
|
||||
#[serde(default)]
|
||||
speed: Option<f64>,
|
||||
#[serde(default)]
|
||||
flashlight: Option<f64>,
|
||||
#[serde(default)]
|
||||
slider_factor: Option<f64>,
|
||||
#[serde(default)]
|
||||
stamina: Option<f64>,
|
||||
#[serde(default)]
|
||||
rhythm: Option<f64>,
|
||||
#[serde(default)]
|
||||
colour: Option<f64>,
|
||||
#[serde(default)]
|
||||
ar: Option<f64>,
|
||||
#[serde(default)]
|
||||
od: Option<f64>,
|
||||
#[serde(default)]
|
||||
great_hit_window: Option<f64>,
|
||||
#[serde(default)]
|
||||
score_multiplier: Option<f64>,
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
<svg width="1024" height="512" viewBox="0 0 1024 512" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="0" y="0" width="1023" height="511" opacity="1" fill="#FFFFFF" stroke="none"/>
|
||||
<text x="512" y="10" dy="0.76em" text-anchor="middle" font-family="sans-serif" font-size="16.129032258064516" opacity="1" fill="#000000">
|
||||
taiko (343 data points)
|
||||
</text>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="108" y1="466" x2="108" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="122" y1="466" x2="122" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="136" y1="466" x2="136" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="150" y1="466" x2="150" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="164" y1="466" x2="164" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="178" y1="466" x2="178" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="192" y1="466" x2="192" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="206" y1="466" x2="206" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="220" y1="466" x2="220" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="234" y1="466" x2="234" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="248" y1="466" x2="248" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="262" y1="466" x2="262" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="276" y1="466" x2="276" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="290" y1="466" x2="290" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="304" y1="466" x2="304" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="318" y1="466" x2="318" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="332" y1="466" x2="332" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="346" y1="466" x2="346" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="360" y1="466" x2="360" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="374" y1="466" x2="374" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="388" y1="466" x2="388" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="402" y1="466" x2="402" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="416" y1="466" x2="416" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="430" y1="466" x2="430" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="444" y1="466" x2="444" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="458" y1="466" x2="458" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="472" y1="466" x2="472" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="486" y1="466" x2="486" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="499" y1="466" x2="499" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="513" y1="466" x2="513" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="527" y1="466" x2="527" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="541" y1="466" x2="541" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="555" y1="466" x2="555" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="569" y1="466" x2="569" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="583" y1="466" x2="583" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="597" y1="466" x2="597" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="611" y1="466" x2="611" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="625" y1="466" x2="625" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="639" y1="466" x2="639" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="653" y1="466" x2="653" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="667" y1="466" x2="667" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="681" y1="466" x2="681" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="695" y1="466" x2="695" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="709" y1="466" x2="709" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="723" y1="466" x2="723" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="737" y1="466" x2="737" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="751" y1="466" x2="751" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="765" y1="466" x2="765" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="779" y1="466" x2="779" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="793" y1="466" x2="793" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="807" y1="466" x2="807" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="821" y1="466" x2="821" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="835" y1="466" x2="835" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="849" y1="466" x2="849" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="863" y1="466" x2="863" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="877" y1="466" x2="877" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="891" y1="466" x2="891" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="904" y1="466" x2="904" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="918" y1="466" x2="918" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="932" y1="466" x2="932" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="946" y1="466" x2="946" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="960" y1="466" x2="960" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="974" y1="466" x2="974" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="988" y1="466" x2="988" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="1002" y1="466" x2="1002" y2="30"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="95" y1="411" x2="1009" y2="411"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="95" y1="302" x2="1009" y2="302"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="95" y1="193" x2="1009" y2="193"/>
|
||||
<line opacity="1" stroke="#FFFFFF" stroke-width="1" x1="95" y1="85" x2="1009" y2="85"/>
|
||||
<text x="552" y="507" dy="-0.5ex" text-anchor="middle" font-family="sans-serif" font-size="9.67741935483871" opacity="1" fill="#000000">
|
||||
Away from actual value
|
||||
</text>
|
||||
<line opacity="0.2" stroke="#000000" stroke-width="1" x1="234" y1="466" x2="234" y2="30"/>
|
||||
<line opacity="0.2" stroke="#000000" stroke-width="1" x1="374" y1="466" x2="374" y2="30"/>
|
||||
<line opacity="0.2" stroke="#000000" stroke-width="1" x1="513" y1="466" x2="513" y2="30"/>
|
||||
<line opacity="0.2" stroke="#000000" stroke-width="1" x1="653" y1="466" x2="653" y2="30"/>
|
||||
<line opacity="0.2" stroke="#000000" stroke-width="1" x1="793" y1="466" x2="793" y2="30"/>
|
||||
<line opacity="0.2" stroke="#000000" stroke-width="1" x1="932" y1="466" x2="932" y2="30"/>
|
||||
<line opacity="0.2" stroke="#000000" stroke-width="1" x1="95" y1="411" x2="1009" y2="411"/>
|
||||
<line opacity="0.2" stroke="#000000" stroke-width="1" x1="95" y1="302" x2="1009" y2="302"/>
|
||||
<line opacity="0.2" stroke="#000000" stroke-width="1" x1="95" y1="193" x2="1009" y2="193"/>
|
||||
<line opacity="0.2" stroke="#000000" stroke-width="1" x1="95" y1="85" x2="1009" y2="85"/>
|
||||
<polyline fill="none" opacity="1" stroke="#000000" stroke-width="1" points="94,31 94,467 "/>
|
||||
<text x="85" y="411" dy="0.5ex" text-anchor="end" font-family="sans-serif" font-size="9.67741935483871" opacity="1" fill="#000000">
|
||||
"strain pp"
|
||||
</text>
|
||||
<polyline fill="none" opacity="1" stroke="#000000" stroke-width="1" points="89,411 94,411 "/>
|
||||
<text x="85" y="302" dy="0.5ex" text-anchor="end" font-family="sans-serif" font-size="9.67741935483871" opacity="1" fill="#000000">
|
||||
"accuracy pp"
|
||||
</text>
|
||||
<polyline fill="none" opacity="1" stroke="#000000" stroke-width="1" points="89,302 94,302 "/>
|
||||
<text x="85" y="193" dy="0.5ex" text-anchor="end" font-family="sans-serif" font-size="9.67741935483871" opacity="1" fill="#000000">
|
||||
"pp"
|
||||
</text>
|
||||
<polyline fill="none" opacity="1" stroke="#000000" stroke-width="1" points="89,193 94,193 "/>
|
||||
<text x="85" y="85" dy="0.5ex" text-anchor="end" font-family="sans-serif" font-size="9.67741935483871" opacity="1" fill="#000000">
|
||||
"stars"
|
||||
</text>
|
||||
<polyline fill="none" opacity="1" stroke="#000000" stroke-width="1" points="89,85 94,85 "/>
|
||||
<polyline fill="none" opacity="1" stroke="#000000" stroke-width="1" points="95,467 1009,467 "/>
|
||||
<text x="234" y="477" dy="0.76em" text-anchor="middle" font-family="sans-serif" font-size="9.67741935483871" opacity="1" fill="#000000">
|
||||
0.5
|
||||
</text>
|
||||
<polyline fill="none" opacity="1" stroke="#000000" stroke-width="1" points="234,467 234,472 "/>
|
||||
<text x="374" y="477" dy="0.76em" text-anchor="middle" font-family="sans-serif" font-size="9.67741935483871" opacity="1" fill="#000000">
|
||||
1.0
|
||||
</text>
|
||||
<polyline fill="none" opacity="1" stroke="#000000" stroke-width="1" points="374,467 374,472 "/>
|
||||
<text x="513" y="477" dy="0.76em" text-anchor="middle" font-family="sans-serif" font-size="9.67741935483871" opacity="1" fill="#000000">
|
||||
1.5
|
||||
</text>
|
||||
<polyline fill="none" opacity="1" stroke="#000000" stroke-width="1" points="513,467 513,472 "/>
|
||||
<text x="653" y="477" dy="0.76em" text-anchor="middle" font-family="sans-serif" font-size="9.67741935483871" opacity="1" fill="#000000">
|
||||
2.0
|
||||
</text>
|
||||
<polyline fill="none" opacity="1" stroke="#000000" stroke-width="1" points="653,467 653,472 "/>
|
||||
<text x="793" y="477" dy="0.76em" text-anchor="middle" font-family="sans-serif" font-size="9.67741935483871" opacity="1" fill="#000000">
|
||||
2.5
|
||||
</text>
|
||||
<polyline fill="none" opacity="1" stroke="#000000" stroke-width="1" points="793,467 793,472 "/>
|
||||
<text x="932" y="477" dy="0.76em" text-anchor="middle" font-family="sans-serif" font-size="9.67741935483871" opacity="1" fill="#000000">
|
||||
3.0
|
||||
</text>
|
||||
<polyline fill="none" opacity="1" stroke="#000000" stroke-width="1" points="932,467 932,472 "/>
|
||||
<line opacity="1" stroke="#000000" stroke-width="1" x1="95" y1="406" x2="95" y2="416"/>
|
||||
<line opacity="1" stroke="#000000" stroke-width="1" x1="95" y1="411" x2="95" y2="411"/>
|
||||
<rect x="95" y="401" width="8" height="20" opacity="1" fill="none" stroke="#000000"/>
|
||||
<line opacity="1" stroke="#000000" stroke-width="1" x1="96" y1="401" x2="96" y2="421"/>
|
||||
<line opacity="1" stroke="#000000" stroke-width="1" x1="103" y1="411" x2="116" y2="411"/>
|
||||
<line opacity="1" stroke="#000000" stroke-width="1" x1="116" y1="406" x2="116" y2="416"/>
|
||||
<line opacity="1" stroke="#000000" stroke-width="1" x1="95" y1="297" x2="95" y2="307"/>
|
||||
<line opacity="1" stroke="#000000" stroke-width="1" x1="95" y1="302" x2="95" y2="302"/>
|
||||
<rect x="95" y="292" width="285" height="20" opacity="1" fill="none" stroke="#000000"/>
|
||||
<line opacity="1" stroke="#000000" stroke-width="1" x1="95" y1="292" x2="95" y2="312"/>
|
||||
<line opacity="1" stroke="#000000" stroke-width="1" x1="380" y1="302" x2="809" y2="302"/>
|
||||
<line opacity="1" stroke="#000000" stroke-width="1" x1="809" y1="297" x2="809" y2="307"/>
|
||||
<line opacity="1" stroke="#000000" stroke-width="1" x1="95" y1="188" x2="95" y2="198"/>
|
||||
<line opacity="1" stroke="#000000" stroke-width="1" x1="95" y1="193" x2="95" y2="193"/>
|
||||
<rect x="95" y="183" width="305" height="20" opacity="1" fill="none" stroke="#000000"/>
|
||||
<line opacity="1" stroke="#000000" stroke-width="1" x1="103" y1="183" x2="103" y2="203"/>
|
||||
<line opacity="1" stroke="#000000" stroke-width="1" x1="400" y1="193" x2="856" y2="193"/>
|
||||
<line opacity="1" stroke="#000000" stroke-width="1" x1="856" y1="188" x2="856" y2="198"/>
|
||||
<line opacity="1" stroke="#000000" stroke-width="1" x1="95" y1="80" x2="95" y2="90"/>
|
||||
<line opacity="1" stroke="#000000" stroke-width="1" x1="95" y1="85" x2="95" y2="85"/>
|
||||
<rect x="95" y="75" width="0" height="20" opacity="1" fill="none" stroke="#000000"/>
|
||||
<line opacity="1" stroke="#000000" stroke-width="1" x1="95" y1="75" x2="95" y2="95"/>
|
||||
<line opacity="1" stroke="#000000" stroke-width="1" x1="95" y1="85" x2="95" y2="85"/>
|
||||
<line opacity="1" stroke="#000000" stroke-width="1" x1="95" y1="80" x2="95" y2="90"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 12 KiB |
@@ -0,0 +1,256 @@
|
||||
use crate::{Beatmap, GameMode, Mods};
|
||||
|
||||
/// Summary struct for a [`Beatmap`]'s attributes.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct BeatmapAttributes {
|
||||
/// The approach rate.
|
||||
pub ar: f64,
|
||||
/// The overall difficulty.
|
||||
pub od: f64,
|
||||
/// The circle size.
|
||||
pub cs: f64,
|
||||
/// The health drain rate
|
||||
pub hp: f64,
|
||||
/// The clock rate with respect to mods.
|
||||
pub clock_rate: f64,
|
||||
/// The hit windows for approach rate and overall difficulty.
|
||||
pub hit_windows: BeatmapHitWindows,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, PartialEq)]
|
||||
/// AR and OD hit windows
|
||||
pub struct BeatmapHitWindows {
|
||||
/// Hit window for approach rate i.e. TimePreempt in milliseconds.
|
||||
pub ar: f64,
|
||||
/// Hit window for overall difficulty i.e. time to hit a 300 ("Great") in milliseconds.
|
||||
pub od: f64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
/// Specify values for this builder to get [`BeatmapAttributes`] or [`BeatmapHitWindows`] based on
|
||||
/// mods & co.
|
||||
pub struct BeatmapAttributesBuilder {
|
||||
mode: GameMode,
|
||||
ar: f32,
|
||||
od: f32,
|
||||
cs: f32,
|
||||
hp: f32,
|
||||
mods: Option<u32>,
|
||||
clock_rate: Option<f64>,
|
||||
converted: bool,
|
||||
}
|
||||
|
||||
impl BeatmapAttributesBuilder {
|
||||
const OSU_MIN: f64 = 80.0;
|
||||
const OSU_AVG: f64 = 50.0;
|
||||
const OSU_MAX: f64 = 20.0;
|
||||
|
||||
const TAIKO_MIN: f64 = 50.0;
|
||||
const TAIKO_AVG: f64 = 35.0;
|
||||
const TAIKO_MAX: f64 = 20.0;
|
||||
|
||||
#[inline]
|
||||
/// Create a new [`BeatmapAttributesBuilder`].
|
||||
pub fn new(map: &Beatmap) -> Self {
|
||||
Self::from(map)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
/// Specify the mode.
|
||||
pub fn mode(&mut self, mode: GameMode) -> &mut Self {
|
||||
self.mode = mode;
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
#[inline]
|
||||
/// Specify the approach rate.
|
||||
pub fn ar(&mut self, ar: f32) -> &mut Self {
|
||||
self.ar = ar;
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
#[inline]
|
||||
/// Specify the overall difficulty.
|
||||
pub fn od(&mut self, od: f32) -> &mut Self {
|
||||
self.od = od;
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
#[inline]
|
||||
/// Specify the circle size.
|
||||
pub fn cs(&mut self, cs: f32) -> &mut Self {
|
||||
self.cs = cs;
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
#[inline]
|
||||
/// Specify the drain rate.
|
||||
pub fn hp(&mut self, hp: f32) -> &mut Self {
|
||||
self.hp = hp;
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
#[inline]
|
||||
/// Specify the mods.
|
||||
pub fn mods(&mut self, mods: u32) -> &mut Self {
|
||||
self.mods = Some(mods);
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
#[inline]
|
||||
/// Specify a custom clock rate.
|
||||
pub fn clock_rate(&mut self, clock_rate: f64) -> &mut Self {
|
||||
self.clock_rate = Some(clock_rate);
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
#[inline]
|
||||
/// Specify whether it's a converted map.
|
||||
/// Only relevant for mania.
|
||||
pub fn converted(&mut self, converted: bool) -> &mut Self {
|
||||
self.converted = converted;
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
#[inline]
|
||||
/// Calculate the AR and OD hit windows.
|
||||
pub fn hit_windows(&self) -> BeatmapHitWindows {
|
||||
let mods = self.mods.unwrap_or(0);
|
||||
let clock_rate = self.clock_rate.unwrap_or_else(|| mods.clock_rate());
|
||||
|
||||
let mod_mult = |val: f32| {
|
||||
if mods.hr() {
|
||||
(val * 1.4).min(10.0)
|
||||
} else if mods.ez() {
|
||||
val * 0.5
|
||||
} else {
|
||||
val
|
||||
}
|
||||
};
|
||||
|
||||
let raw_ar = mod_mult(self.ar);
|
||||
let preempt = difficulty_range(raw_ar as f64, 1800.0, 1200.0, 450.0) / clock_rate;
|
||||
|
||||
// OD
|
||||
let hit_window = match self.mode {
|
||||
GameMode::Osu | GameMode::Catch => {
|
||||
let raw_od = mod_mult(self.od);
|
||||
|
||||
difficulty_range(raw_od as f64, Self::OSU_MIN, Self::OSU_AVG, Self::OSU_MAX)
|
||||
/ clock_rate
|
||||
}
|
||||
GameMode::Taiko => {
|
||||
let raw_od = mod_mult(self.od);
|
||||
|
||||
let diff_range = difficulty_range(
|
||||
raw_od as f64,
|
||||
Self::TAIKO_MIN,
|
||||
Self::TAIKO_AVG,
|
||||
Self::TAIKO_MAX,
|
||||
);
|
||||
|
||||
diff_range / clock_rate
|
||||
}
|
||||
GameMode::Mania => {
|
||||
let mut value = if !self.converted {
|
||||
34.0 + 3.0 * (10.0 - self.od).clamp(0.0, 10.0)
|
||||
} else if self.od > 4.0 {
|
||||
34.0
|
||||
} else {
|
||||
47.0
|
||||
};
|
||||
|
||||
if mods.hr() {
|
||||
value /= 1.4;
|
||||
} else if mods.ez() {
|
||||
value *= 1.4;
|
||||
}
|
||||
|
||||
((value as f64 * clock_rate).floor() / clock_rate).ceil()
|
||||
}
|
||||
};
|
||||
|
||||
BeatmapHitWindows {
|
||||
ar: preempt,
|
||||
od: hit_window,
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate the [`BeatmapAttributes`].
|
||||
pub fn build(&self) -> BeatmapAttributes {
|
||||
let mods = self.mods.unwrap_or(0);
|
||||
let clock_rate = self.clock_rate.unwrap_or_else(|| mods.clock_rate());
|
||||
|
||||
// HP
|
||||
let hp = (self.hp * mods.od_ar_hp_multiplier() as f32).min(10.0);
|
||||
|
||||
// CS
|
||||
let mut cs = self.cs;
|
||||
|
||||
if mods.hr() {
|
||||
cs = (cs * 1.3).min(10.);
|
||||
} else if mods.ez() {
|
||||
cs *= 0.5;
|
||||
}
|
||||
|
||||
let hit_windows = self.hit_windows();
|
||||
let BeatmapHitWindows { ar, od } = hit_windows;
|
||||
|
||||
// AR
|
||||
let ar = if ar > 1200.0 {
|
||||
(1800.0 - ar) / 120.0
|
||||
} else {
|
||||
(1200.0 - ar) / 150.0 + 5.0
|
||||
};
|
||||
|
||||
// OD
|
||||
let od = match self.mode {
|
||||
GameMode::Osu => (Self::OSU_MIN - od) / 6.0,
|
||||
GameMode::Taiko => (Self::TAIKO_MIN - od) / (Self::TAIKO_MIN - Self::TAIKO_AVG) * 5.0,
|
||||
GameMode::Catch | GameMode::Mania => self.od as f64,
|
||||
};
|
||||
|
||||
BeatmapAttributes {
|
||||
ar,
|
||||
od,
|
||||
cs: cs as f64,
|
||||
hp: hp as f64,
|
||||
clock_rate,
|
||||
hit_windows,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&Beatmap> for BeatmapAttributesBuilder {
|
||||
#[inline]
|
||||
fn from(map: &Beatmap) -> Self {
|
||||
Self {
|
||||
mode: map.mode,
|
||||
ar: map.ar,
|
||||
od: map.od,
|
||||
cs: map.cs,
|
||||
hp: map.hp,
|
||||
mods: None,
|
||||
clock_rate: None,
|
||||
converted: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn difficulty_range(difficulty: f64, min: f64, mid: f64, max: f64) -> f64 {
|
||||
if difficulty > 5.0 {
|
||||
mid + (max - mid) * (difficulty - 5.0) / 5.0
|
||||
} else if difficulty < 5.0 {
|
||||
mid - (mid - min) * (5.0 - difficulty) / 5.0
|
||||
} else {
|
||||
mid
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/// A break point of a [`Beatmap`](crate::beatmap::Beatmap).
|
||||
#[derive(Copy, Clone, Debug, Default, PartialEq)]
|
||||
pub struct Break {
|
||||
/// Start timestamp of the break.
|
||||
pub start_time: f64,
|
||||
/// End timestamp of the break.
|
||||
pub end_time: f64,
|
||||
}
|
||||
|
||||
impl Break {
|
||||
/// Duration of the break.
|
||||
#[inline]
|
||||
pub fn duration(&self) -> f64 {
|
||||
self.end_time - self.start_time
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
use std::cmp::Ordering;
|
||||
|
||||
/// New rhythm speed change.
|
||||
#[derive(Copy, Clone, Debug, PartialEq)]
|
||||
pub struct TimingPoint {
|
||||
/// The beat length for this timing section
|
||||
pub beat_len: f64,
|
||||
/// The start time of this timing section
|
||||
pub time: f64,
|
||||
}
|
||||
|
||||
impl TimingPoint {
|
||||
/// Create a new [`TimingPoint`].
|
||||
#[inline]
|
||||
pub fn new(time: f64, beat_len: f64) -> Self {
|
||||
Self { time, beat_len }
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialOrd for TimingPoint {
|
||||
#[inline]
|
||||
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
|
||||
self.time.partial_cmp(&other.time)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for TimingPoint {
|
||||
#[inline]
|
||||
fn default() -> Self {
|
||||
Self::new(0.0, 60_000.0 / 60.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// [`TimingPoint`] that depends on a previous one.
|
||||
#[derive(Copy, Clone, Debug, PartialEq)]
|
||||
pub struct DifficultyPoint {
|
||||
/// The time at which the control point takes effect.
|
||||
pub time: f64,
|
||||
/// The slider velocity at this control point.
|
||||
pub slider_vel: f64,
|
||||
/// Legacy BPM multiplier that introduces floating-point errors for rulesets that depend on it.
|
||||
pub bpm_mult: f64,
|
||||
/// Whether or not slider ticks should be generated at this control point.
|
||||
/// This exists for backwards compatibility with maps that abuse NaN
|
||||
/// slider velocity behavior on osu!stable (e.g. /b/2628991).
|
||||
pub generate_ticks: bool,
|
||||
}
|
||||
|
||||
impl DifficultyPoint {
|
||||
/// The default slider velocity for a [`DifficultyPoint`]
|
||||
pub const DEFAULT_SLIDER_VEL: f64 = 1.0;
|
||||
/// The default BPM multipler for a [`DifficultyPoint`]
|
||||
pub const DEFAULT_BPM_MULT: f64 = 1.0;
|
||||
/// The default for generating ticks of a [`DifficultyPoint`]
|
||||
pub const DEFAULT_GENERATE_TICKS: bool = true;
|
||||
|
||||
/// Create a new [`DifficultyPoint`].
|
||||
#[inline]
|
||||
pub fn new(time: f64, beat_len: f64, speed_multiplier: f64) -> Self {
|
||||
// * Note: In stable, the division occurs on floats, but with compiler optimisations
|
||||
// * turned on actually seems to occur on doubles via some .NET black magic (possibly inlining?).
|
||||
let bpm_multiplier = if beat_len < 0.0 {
|
||||
((-beat_len) as f32).clamp(10.0, 10_000.0) as f64 / 100.0
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
|
||||
Self {
|
||||
time,
|
||||
slider_vel: speed_multiplier.clamp(0.1, 10.0),
|
||||
bpm_mult: bpm_multiplier as f64,
|
||||
generate_ticks: !beat_len.is_nan(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn is_redundant(&self, existing: &DifficultyPoint) -> bool {
|
||||
(self.slider_vel - existing.slider_vel).abs() <= f64::EPSILON
|
||||
&& self.generate_ticks == existing.generate_ticks
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialOrd for DifficultyPoint {
|
||||
#[inline]
|
||||
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
|
||||
self.time.partial_cmp(&other.time)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for DifficultyPoint {
|
||||
#[inline]
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
time: 0.0,
|
||||
slider_vel: Self::DEFAULT_SLIDER_VEL,
|
||||
bpm_mult: Self::DEFAULT_BPM_MULT,
|
||||
generate_ticks: Self::DEFAULT_GENERATE_TICKS,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Control point storing effects and their timestamps.
|
||||
#[derive(Copy, Clone, Debug, PartialEq)]
|
||||
pub struct EffectPoint {
|
||||
/// The time at which the control point takes effect.
|
||||
pub time: f64,
|
||||
/// Whether this control point enables Kiai mode.
|
||||
pub kiai: bool,
|
||||
}
|
||||
|
||||
impl EffectPoint {
|
||||
/// The default slider velocity for a [`DifficultyPoint`]
|
||||
pub const DEFAULT_KIAI: bool = false;
|
||||
|
||||
/// Create a new [`EffectPoint`].
|
||||
#[inline]
|
||||
pub fn new(time: f64, kiai: bool) -> Self {
|
||||
Self { time, kiai }
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for EffectPoint {
|
||||
#[inline]
|
||||
fn default() -> Self {
|
||||
Self::new(0.0, Self::DEFAULT_KIAI)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
const INT_TO_REAL: f64 = 1.0 / (i32::MAX as f64 + 1.0);
|
||||
const INT_MASK: u32 = 0x7F_FF_FF_FF;
|
||||
|
||||
pub(crate) struct Random {
|
||||
x: u32,
|
||||
y: u32,
|
||||
z: u32,
|
||||
w: u32,
|
||||
}
|
||||
|
||||
impl Random {
|
||||
pub(crate) fn new(seed: i32) -> Self {
|
||||
Self {
|
||||
x: seed as u32,
|
||||
y: 842_502_087,
|
||||
z: 3_579_807_591,
|
||||
w: 273_326_509,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn gen_unsigned(&mut self) -> u32 {
|
||||
let t = self.x ^ (self.x << 11);
|
||||
self.x = self.y;
|
||||
self.y = self.z;
|
||||
self.z = self.w;
|
||||
self.w = self.w ^ (self.w >> 19) ^ t ^ (t >> 8);
|
||||
|
||||
self.w
|
||||
}
|
||||
|
||||
pub(crate) fn gen_signed(&mut self) -> i32 {
|
||||
(INT_MASK & self.gen_unsigned()) as i32
|
||||
}
|
||||
|
||||
pub(crate) fn gen_double(&mut self) -> f64 {
|
||||
INT_TO_REAL * self.gen_signed() as f64
|
||||
}
|
||||
|
||||
pub(crate) fn gen_int_range(&mut self, min: i32, max: i32) -> i32 {
|
||||
(min as f64 + self.gen_double() * (max - min) as f64) as i32
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
use std::cmp::Ordering;
|
||||
|
||||
use crate::{
|
||||
curve::{Curve, CurveBuffers},
|
||||
parse::{legacy_sort, HitObjectKind, Pos2},
|
||||
util::{FloatExt, LimitedQueue},
|
||||
Beatmap, GameMode,
|
||||
};
|
||||
|
||||
use self::{
|
||||
legacy_random::Random,
|
||||
pattern::Pattern,
|
||||
pattern_generator::{
|
||||
distance_object::DistanceObjectPatternGenerator,
|
||||
end_time_object::EndTimeObjectPatternGenerator, hit_object::HitObjectPatternGenerator,
|
||||
},
|
||||
pattern_type::PatternType,
|
||||
};
|
||||
|
||||
mod legacy_random;
|
||||
mod pattern;
|
||||
mod pattern_generator;
|
||||
mod pattern_type;
|
||||
|
||||
const MAX_NOTES_FOR_DENSITY: usize = 7;
|
||||
|
||||
impl Beatmap {
|
||||
pub(in crate::beatmap) fn convert_to_mania(&self) -> Self {
|
||||
let mut map = self.clone_without_hit_objects(false);
|
||||
|
||||
let mut n_circles = 0;
|
||||
let mut n_sliders = 0;
|
||||
|
||||
let seed = (map.hp + map.cs).round_even() as i32 * 20
|
||||
+ (map.od * 41.2) as i32
|
||||
+ map.ar.round_even() as i32;
|
||||
|
||||
let mut random = Random::new(seed);
|
||||
|
||||
let rounded_cs = map.cs.round_even();
|
||||
let rounded_od = map.od.round_even();
|
||||
|
||||
let slider_or_spinner_count = self
|
||||
.hit_objects
|
||||
.iter()
|
||||
.filter(|h| {
|
||||
matches!(
|
||||
h.kind,
|
||||
HitObjectKind::Slider { .. } | HitObjectKind::Spinner { .. }
|
||||
)
|
||||
})
|
||||
.count();
|
||||
|
||||
let percent_slider_or_spinner =
|
||||
(slider_or_spinner_count as f32 / self.hit_objects.len() as f32) as f64;
|
||||
|
||||
let target_columns = if percent_slider_or_spinner < 0.2 {
|
||||
7.0
|
||||
} else if percent_slider_or_spinner < 0.3 || rounded_cs >= 5.0 {
|
||||
(6 + (rounded_od > 5.0) as u8) as f32
|
||||
} else if percent_slider_or_spinner as f64 > 0.6 {
|
||||
(4 + (rounded_od > 4.0) as u8) as f32
|
||||
} else {
|
||||
(rounded_od + 1.0).clamp(4.0, 7.0)
|
||||
};
|
||||
|
||||
map.cs = target_columns;
|
||||
|
||||
let mut prev_note_times: LimitedQueue<f64, MAX_NOTES_FOR_DENSITY> = LimitedQueue::new();
|
||||
let mut density = i32::MAX as f64;
|
||||
|
||||
let mut compute_density = |new_note_time: f64, d: &mut f64| {
|
||||
prev_note_times.push(new_note_time);
|
||||
|
||||
if prev_note_times.len() >= 2 {
|
||||
*d = (prev_note_times.last().unwrap() - prev_note_times[0])
|
||||
/ prev_note_times.len() as f64;
|
||||
}
|
||||
};
|
||||
|
||||
let total_columns = map.cs as i32;
|
||||
let mut last_values = PrevValues::default();
|
||||
let mut curve_bufs = CurveBuffers::default();
|
||||
|
||||
for (obj, sound) in self.hit_objects.iter().zip(self.sounds.iter()) {
|
||||
match obj.kind {
|
||||
HitObjectKind::Circle => {
|
||||
compute_density(obj.start_time, &mut density);
|
||||
|
||||
let mut gen = HitObjectPatternGenerator::new(
|
||||
&mut random,
|
||||
obj,
|
||||
*sound,
|
||||
total_columns,
|
||||
&last_values,
|
||||
density,
|
||||
self,
|
||||
);
|
||||
|
||||
let new_pattern = gen.generate();
|
||||
|
||||
last_values.stair = gen.stair_type;
|
||||
last_values.time = obj.start_time;
|
||||
last_values.pos = obj.pos;
|
||||
|
||||
let new_hit_objects = new_pattern.hit_objects.iter().cloned();
|
||||
map.hit_objects.extend(new_hit_objects);
|
||||
|
||||
n_circles += new_pattern.hit_objects.len();
|
||||
last_values.pattern = new_pattern;
|
||||
}
|
||||
HitObjectKind::Slider {
|
||||
pixel_len,
|
||||
repeats,
|
||||
ref control_points,
|
||||
ref edge_sounds,
|
||||
} => {
|
||||
let curve = Curve::new(control_points, pixel_len, &mut curve_bufs);
|
||||
|
||||
let mut gen = DistanceObjectPatternGenerator::new(
|
||||
&mut random,
|
||||
obj,
|
||||
*sound,
|
||||
total_columns,
|
||||
&last_values.pattern,
|
||||
self,
|
||||
repeats,
|
||||
&curve,
|
||||
edge_sounds,
|
||||
);
|
||||
|
||||
let segment_duration = gen.segment_duration as f64;
|
||||
|
||||
for i in 0..=repeats as i32 + 1 {
|
||||
let time = obj.start_time + segment_duration * i as f64;
|
||||
|
||||
last_values.time = time;
|
||||
last_values.pos = obj.pos;
|
||||
|
||||
compute_density(time, &mut density);
|
||||
}
|
||||
|
||||
for new_pattern in gen.generate() {
|
||||
let new_objects = new_pattern.hit_objects.iter().map(|h| {
|
||||
if h.is_circle() {
|
||||
n_circles += 1;
|
||||
} else {
|
||||
n_sliders += 1;
|
||||
}
|
||||
|
||||
h.to_owned()
|
||||
});
|
||||
|
||||
map.hit_objects.extend(new_objects);
|
||||
|
||||
last_values.pattern = new_pattern;
|
||||
}
|
||||
}
|
||||
HitObjectKind::Spinner { end_time } | HitObjectKind::Hold { end_time } => {
|
||||
let mut gen = EndTimeObjectPatternGenerator::new(
|
||||
&mut random,
|
||||
obj,
|
||||
end_time,
|
||||
*sound,
|
||||
total_columns,
|
||||
&last_values.pattern,
|
||||
);
|
||||
|
||||
last_values.time = end_time;
|
||||
last_values.pos = Pos2 { x: 256.0, y: 192.0 };
|
||||
|
||||
compute_density(end_time, &mut density);
|
||||
|
||||
let new_pattern = gen.generate();
|
||||
|
||||
let new_objects = new_pattern.hit_objects.into_iter().inspect(|h| {
|
||||
if h.is_circle() {
|
||||
n_circles += 1;
|
||||
} else {
|
||||
n_sliders += 1;
|
||||
}
|
||||
});
|
||||
|
||||
map.hit_objects.extend(new_objects);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
map.n_circles = n_circles as u32;
|
||||
map.n_sliders = n_sliders;
|
||||
|
||||
map.hit_objects
|
||||
.sort_by(|p1, p2| p1.partial_cmp(p2).unwrap_or(Ordering::Equal));
|
||||
|
||||
legacy_sort(&mut map.hit_objects);
|
||||
|
||||
map.mode = GameMode::Mania;
|
||||
|
||||
map
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct PrevValues {
|
||||
time: f64,
|
||||
pos: Pos2,
|
||||
pattern: Pattern,
|
||||
stair: PatternType,
|
||||
}
|
||||
|
||||
impl Default for PrevValues {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
time: 0.0,
|
||||
pos: Pos2::default(),
|
||||
pattern: Pattern::default(),
|
||||
stair: PatternType::STAIR,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use crate::{
|
||||
parse::{HitObject, HitObjectKind, Pos2},
|
||||
util::ByteHasher,
|
||||
};
|
||||
|
||||
use super::pattern_generator::{
|
||||
distance_object::DistanceObjectPatternGenerator,
|
||||
end_time_object::EndTimeObjectPatternGenerator, hit_object::HitObjectPatternGenerator,
|
||||
};
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Pattern {
|
||||
pub(crate) hit_objects: Vec<HitObject>,
|
||||
contained_columns: HashSet<u8, ByteHasher>,
|
||||
}
|
||||
|
||||
impl Pattern {
|
||||
pub(crate) fn with_capacity(capacity: usize) -> Self {
|
||||
Self {
|
||||
hit_objects: Vec::with_capacity(capacity),
|
||||
contained_columns: HashSet::with_hasher(ByteHasher),
|
||||
}
|
||||
}
|
||||
|
||||
fn new_single(hit_object: HitObject, column: u8) -> Self {
|
||||
let mut contained_columns = HashSet::with_capacity_and_hasher(1, ByteHasher);
|
||||
contained_columns.insert(column);
|
||||
let hit_objects = vec![hit_object];
|
||||
|
||||
Self {
|
||||
hit_objects,
|
||||
contained_columns,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn new_note(generator: &HitObjectPatternGenerator<'_>, column: u8) -> Self {
|
||||
let hit_object = HitObject {
|
||||
pos: Pos2::new(column_to_pos(column, generator.total_columns)),
|
||||
start_time: generator.hit_object.start_time,
|
||||
kind: HitObjectKind::Circle,
|
||||
};
|
||||
|
||||
Self::new_single(hit_object, column)
|
||||
}
|
||||
|
||||
pub(crate) fn add_note(&mut self, generator: &HitObjectPatternGenerator<'_>, column: u8) {
|
||||
let hit_object = HitObject {
|
||||
pos: Pos2::new(column_to_pos(column, generator.total_columns)),
|
||||
start_time: generator.hit_object.start_time,
|
||||
kind: HitObjectKind::Circle,
|
||||
};
|
||||
|
||||
self.contained_columns.insert(column);
|
||||
self.hit_objects.push(hit_object);
|
||||
}
|
||||
|
||||
pub(crate) fn new_end_time_note(
|
||||
generator: &EndTimeObjectPatternGenerator<'_>,
|
||||
column: u8,
|
||||
hold_note: bool,
|
||||
) -> Self {
|
||||
let pos = Pos2::new(column_to_pos(column, generator.total_columns));
|
||||
|
||||
let hit_object = if hold_note {
|
||||
HitObject {
|
||||
pos,
|
||||
start_time: generator.hit_object.start_time,
|
||||
kind: HitObjectKind::Hold {
|
||||
end_time: generator.end_time,
|
||||
},
|
||||
}
|
||||
} else {
|
||||
HitObject {
|
||||
pos,
|
||||
start_time: generator.hit_object.start_time,
|
||||
kind: HitObjectKind::Circle,
|
||||
}
|
||||
};
|
||||
|
||||
Self::new_single(hit_object, column)
|
||||
}
|
||||
|
||||
pub(crate) fn new_slider_note(
|
||||
generator: &DistanceObjectPatternGenerator<'_>,
|
||||
column: u8,
|
||||
start_time: i32,
|
||||
end_time: i32,
|
||||
) -> Self {
|
||||
let pos = Pos2::new(column_to_pos(column, generator.total_columns));
|
||||
|
||||
let hit_object = if start_time == end_time {
|
||||
HitObject {
|
||||
pos,
|
||||
start_time: start_time as f64,
|
||||
kind: HitObjectKind::Circle,
|
||||
}
|
||||
} else {
|
||||
HitObject {
|
||||
pos,
|
||||
start_time: start_time as f64,
|
||||
kind: HitObjectKind::Hold {
|
||||
end_time: end_time as f64,
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
Self::new_single(hit_object, column)
|
||||
}
|
||||
|
||||
pub(crate) fn add_slider_note(
|
||||
&mut self,
|
||||
generator: &DistanceObjectPatternGenerator<'_>,
|
||||
column: u8,
|
||||
start_time: i32,
|
||||
end_time: i32,
|
||||
) {
|
||||
let pos = Pos2::new(column_to_pos(column, generator.total_columns));
|
||||
|
||||
let hit_object = if start_time == end_time {
|
||||
HitObject {
|
||||
pos,
|
||||
start_time: start_time as f64,
|
||||
kind: HitObjectKind::Circle,
|
||||
}
|
||||
} else {
|
||||
HitObject {
|
||||
pos,
|
||||
start_time: start_time as f64,
|
||||
kind: HitObjectKind::Hold {
|
||||
end_time: end_time as f64,
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
self.contained_columns.insert(column);
|
||||
self.hit_objects.push(hit_object);
|
||||
}
|
||||
|
||||
pub(crate) fn add_object(&mut self, obj: HitObject, column: u8) {
|
||||
self.hit_objects.push(obj);
|
||||
self.contained_columns.insert(column);
|
||||
}
|
||||
|
||||
pub(crate) fn column_has_obj(&self, column: u8) -> bool {
|
||||
self.contained_columns.contains(&column)
|
||||
}
|
||||
|
||||
pub(crate) fn column_with_objs(&self) -> i32 {
|
||||
self.contained_columns.len() as i32
|
||||
}
|
||||
|
||||
/// Moves all values of `other` into `self`,
|
||||
/// leaving `other` empty but keeps the capacities.
|
||||
pub(crate) fn append(&mut self, other: &mut Self) {
|
||||
self.hit_objects.append(&mut other.hit_objects);
|
||||
self.contained_columns
|
||||
.extend(other.contained_columns.drain());
|
||||
}
|
||||
}
|
||||
|
||||
fn column_to_pos(column: u8, total_columns: i32) -> f32 {
|
||||
let divisor = 512.0 / total_columns as f32;
|
||||
|
||||
(column as f32 * divisor).ceil()
|
||||
}
|
||||
@@ -0,0 +1,541 @@
|
||||
use crate::{
|
||||
beatmap::{
|
||||
converts::mania::{legacy_random::Random, pattern::Pattern, pattern_type::PatternType},
|
||||
EffectPoint,
|
||||
},
|
||||
curve::Curve,
|
||||
mania::ManiaObject,
|
||||
parse::{HitObject, HitSound},
|
||||
util::FloatExt,
|
||||
Beatmap,
|
||||
};
|
||||
|
||||
use super::PatternGenerator;
|
||||
|
||||
pub(crate) struct DistanceObjectPatternGenerator<'h> {
|
||||
pub(crate) hit_object: &'h HitObject,
|
||||
pub(crate) segment_duration: i32,
|
||||
pub(crate) total_columns: i32,
|
||||
pub(crate) sample: u8,
|
||||
start_time: i32,
|
||||
end_time: i32,
|
||||
span_count: i32,
|
||||
orig: &'h Beatmap,
|
||||
prev_pattern: &'h Pattern,
|
||||
convert_type: PatternType,
|
||||
random: &'h mut Random,
|
||||
edge_sounds: &'h [u8],
|
||||
}
|
||||
|
||||
impl<'h> DistanceObjectPatternGenerator<'h> {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn new(
|
||||
random: &'h mut Random,
|
||||
hit_object: &'h HitObject,
|
||||
sample: u8,
|
||||
total_columns: i32,
|
||||
prev_pattern: &'h Pattern,
|
||||
orig: &'h Beatmap,
|
||||
repeats: usize,
|
||||
curve: &Curve<'_>,
|
||||
edge_sounds: &'h [u8],
|
||||
) -> Self {
|
||||
let timing_point = orig.timing_point_at(hit_object.start_time);
|
||||
|
||||
let difficulty_point = orig
|
||||
.difficulty_point_at(hit_object.start_time)
|
||||
.unwrap_or_default();
|
||||
|
||||
let kiai = orig
|
||||
.effect_point_at(hit_object.start_time)
|
||||
.map_or(EffectPoint::DEFAULT_KIAI, |point| point.kiai);
|
||||
|
||||
let convert_type = if kiai {
|
||||
PatternType::default()
|
||||
} else {
|
||||
PatternType::LOW_PROBABILITY
|
||||
};
|
||||
|
||||
let beat_len = timing_point.beat_len * difficulty_point.bpm_mult;
|
||||
|
||||
let span_count = (repeats + 1) as i32;
|
||||
let start_time = hit_object.start_time.round_even() as i32;
|
||||
|
||||
// * This matches stable's calculation.
|
||||
let end_time = (start_time as f64
|
||||
+ curve.dist() * beat_len * span_count as f64 * 0.01 / orig.slider_mult)
|
||||
.floor() as i32;
|
||||
|
||||
let segment_duration = (end_time - start_time) / span_count;
|
||||
|
||||
Self {
|
||||
hit_object,
|
||||
segment_duration,
|
||||
total_columns,
|
||||
sample,
|
||||
start_time,
|
||||
end_time,
|
||||
span_count,
|
||||
orig,
|
||||
prev_pattern,
|
||||
convert_type,
|
||||
random,
|
||||
edge_sounds,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn generate(&mut self) -> Vec<Pattern> {
|
||||
let orig_pattern = self.generate_();
|
||||
|
||||
if orig_pattern.hit_objects.len() == 1 {
|
||||
return vec![orig_pattern];
|
||||
}
|
||||
|
||||
// * We need to split the intermediate pattern into two new patterns:
|
||||
// * 1. A pattern containing all objects that do not end at our EndTime.
|
||||
// * 2. A pattern containing all objects that end at our EndTime. This will be used for further pattern generation.
|
||||
let mut intermediate_pattern = Pattern::default();
|
||||
let mut end_time_pattern = Pattern::default();
|
||||
|
||||
for obj in orig_pattern.hit_objects {
|
||||
let col = ManiaObject::column(obj.pos.x, self.total_columns as f32) as u8;
|
||||
|
||||
if self.end_time != obj.end_time().round_even() as i32 {
|
||||
intermediate_pattern.add_object(obj, col);
|
||||
} else {
|
||||
end_time_pattern.add_object(obj, col);
|
||||
}
|
||||
}
|
||||
|
||||
vec![intermediate_pattern, end_time_pattern]
|
||||
}
|
||||
|
||||
fn generate_(&mut self) -> Pattern {
|
||||
let conversion_diff = self.conversion_difficulty();
|
||||
|
||||
if self.total_columns == 1 {
|
||||
Pattern::new_slider_note(self, 0, self.start_time, self.end_time)
|
||||
} else if self.span_count > 1 {
|
||||
if self.segment_duration <= 90 {
|
||||
self.generate_random_hold_notes(self.start_time, 1)
|
||||
} else if self.segment_duration <= 120 {
|
||||
self.convert_type |= PatternType::FORCE_NOT_STACK;
|
||||
|
||||
self.generate_random_notes(self.start_time, self.span_count + 1)
|
||||
} else if self.segment_duration <= 160 {
|
||||
self.generate_stair(self.start_time)
|
||||
} else if self.segment_duration <= 200 && conversion_diff > 3.0 {
|
||||
self.generate_random_multiple_notes(self.start_time)
|
||||
} else if self.end_time - self.start_time >= 4000 {
|
||||
self.generate_n_random_notes(self.start_time, 0.23, 0.0, 0.0)
|
||||
} else if self.segment_duration > 400
|
||||
&& self.span_count < self.total_columns - 1 - self.random_start()
|
||||
{
|
||||
self.generate_tiled_hold_notes(self.start_time)
|
||||
} else {
|
||||
self.generate_hold_and_normal_notes(self.start_time, conversion_diff)
|
||||
}
|
||||
} else if self.segment_duration <= 110 {
|
||||
if self.prev_pattern.column_with_objs() < self.total_columns {
|
||||
self.convert_type |= PatternType::FORCE_NOT_STACK;
|
||||
} else {
|
||||
self.convert_type &= !PatternType::FORCE_NOT_STACK;
|
||||
}
|
||||
|
||||
let note_count = 1 + (self.segment_duration >= 80) as i32;
|
||||
|
||||
self.generate_random_notes(self.start_time, note_count)
|
||||
} else if conversion_diff > 6.5 {
|
||||
if self.convert_type.contains(PatternType::LOW_PROBABILITY) {
|
||||
self.generate_n_random_notes(self.start_time, 0.78, 0.3, 0.0)
|
||||
} else {
|
||||
self.generate_n_random_notes(self.start_time, 0.85, 0.36, 0.03)
|
||||
}
|
||||
} else if conversion_diff > 4.0 {
|
||||
if self.convert_type.contains(PatternType::LOW_PROBABILITY) {
|
||||
self.generate_n_random_notes(self.start_time, 0.43, 0.08, 0.0)
|
||||
} else {
|
||||
self.generate_n_random_notes(self.start_time, 0.56, 0.18, 0.0)
|
||||
}
|
||||
} else if conversion_diff > 2.5 {
|
||||
if self.convert_type.contains(PatternType::LOW_PROBABILITY) {
|
||||
self.generate_n_random_notes(self.start_time, 0.3, 0.0, 0.0)
|
||||
} else {
|
||||
self.generate_n_random_notes(self.start_time, 0.37, 0.08, 0.0)
|
||||
}
|
||||
} else if self.convert_type.contains(PatternType::LOW_PROBABILITY) {
|
||||
self.generate_n_random_notes(self.start_time, 0.17, 0.0, 0.0)
|
||||
} else {
|
||||
self.generate_n_random_notes(self.start_time, 0.27, 0.0, 0.0)
|
||||
}
|
||||
}
|
||||
|
||||
fn generate_random_hold_notes(&mut self, start_time: i32, note_count: i32) -> Pattern {
|
||||
// * - - - -
|
||||
// * ■ - ■ ■
|
||||
// * □ - □ □
|
||||
// * ■ - ■ ■
|
||||
|
||||
let mut pattern = Pattern::default();
|
||||
|
||||
let random_start = self.random_start();
|
||||
let usable_columns =
|
||||
self.total_columns - random_start - self.prev_pattern.column_with_objs();
|
||||
let mut next_column = PatternGenerator::get_random_column(self, None, None);
|
||||
|
||||
for _ in 0..usable_columns.min(note_count) {
|
||||
// * Find available column
|
||||
next_column = self.find_available_column(
|
||||
next_column,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
&[&pattern, self.prev_pattern],
|
||||
);
|
||||
|
||||
pattern.add_slider_note(self, next_column, start_time, self.end_time);
|
||||
}
|
||||
|
||||
// * This is can't be combined with the above loop due to RNG
|
||||
for _ in 0..note_count.saturating_sub(usable_columns) {
|
||||
next_column =
|
||||
self.find_available_column(next_column, None, None, None, None, &[&pattern]);
|
||||
|
||||
pattern.add_slider_note(self, next_column, start_time, self.end_time);
|
||||
}
|
||||
|
||||
pattern
|
||||
}
|
||||
|
||||
fn generate_random_notes(&mut self, mut start_time: i32, note_count: i32) -> Pattern {
|
||||
// * - - - -
|
||||
// * x - - -
|
||||
// * - - x -
|
||||
// * - - - x
|
||||
// * x - - -
|
||||
|
||||
let mut next_column = self.get_column(Some(true));
|
||||
|
||||
if self.convert_type.contains(PatternType::FORCE_NOT_STACK)
|
||||
&& self.prev_pattern.column_with_objs() < self.total_columns
|
||||
{
|
||||
next_column = self.find_available_column(
|
||||
next_column,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
&[self.prev_pattern],
|
||||
);
|
||||
}
|
||||
|
||||
let mut last_column = next_column;
|
||||
let mut pattern = Pattern::with_capacity(note_count as usize);
|
||||
|
||||
for _ in 0..note_count {
|
||||
pattern.add_slider_note(self, next_column, start_time, start_time);
|
||||
|
||||
next_column = self.find_available_column(
|
||||
next_column,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some(&|c| c != last_column as i32),
|
||||
&[],
|
||||
);
|
||||
|
||||
last_column = next_column;
|
||||
start_time += self.segment_duration;
|
||||
}
|
||||
|
||||
pattern
|
||||
}
|
||||
|
||||
fn generate_stair(&mut self, mut start_time: i32) -> Pattern {
|
||||
// * - - - -
|
||||
// * x - - -
|
||||
// * - x - -
|
||||
// * - - x -
|
||||
// * - - - x
|
||||
// * - - x -
|
||||
// * - x - -
|
||||
// * x - - -
|
||||
|
||||
let mut column = self.get_column(Some(true)) as i32;
|
||||
let mut increasing = self.random.gen_double() > 0.5;
|
||||
let mut pattern = Pattern::with_capacity(self.span_count as usize + 1);
|
||||
|
||||
for _ in 0..=self.span_count as usize {
|
||||
pattern.add_slider_note(self, column as u8, start_time, start_time);
|
||||
start_time += self.segment_duration;
|
||||
|
||||
// * Check if we're at the borders of the stage, and invert the pattern if so
|
||||
if increasing {
|
||||
if column >= self.total_columns - 1 {
|
||||
increasing = false;
|
||||
column -= 1;
|
||||
} else {
|
||||
column += 1;
|
||||
}
|
||||
} else if column <= self.random_start() {
|
||||
increasing = true;
|
||||
column += 1;
|
||||
} else {
|
||||
column -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
pattern
|
||||
}
|
||||
|
||||
fn generate_random_multiple_notes(&mut self, mut start_time: i32) -> Pattern {
|
||||
// * - - - -
|
||||
// * x - - -
|
||||
// * - x x -
|
||||
// * - - - x
|
||||
// * x - x -
|
||||
|
||||
let legacy = (4..=8).contains(&self.total_columns);
|
||||
let interval = self
|
||||
.random
|
||||
.gen_int_range(1, self.total_columns as i32 - (legacy as i32));
|
||||
|
||||
let mut next_column = self.get_column(Some(true)) as i32;
|
||||
let random_start = self.random_start();
|
||||
let not_2k = self.total_columns > 2;
|
||||
let mut pattern =
|
||||
Pattern::with_capacity((self.span_count as usize + 1) * (1 + not_2k as usize));
|
||||
|
||||
for _ in 0..=self.span_count as usize {
|
||||
pattern.add_slider_note(self, next_column as u8, start_time, start_time);
|
||||
|
||||
next_column += interval;
|
||||
|
||||
if next_column >= self.total_columns - random_start {
|
||||
next_column = next_column - self.total_columns - random_start + (legacy as i32);
|
||||
}
|
||||
|
||||
next_column += random_start;
|
||||
|
||||
// * If we're in 2K, let's not add many consecutive doubles
|
||||
if not_2k {
|
||||
pattern.add_slider_note(self, next_column as u8, start_time, start_time);
|
||||
}
|
||||
|
||||
next_column = PatternGenerator::get_random_column(self, None, None) as i32;
|
||||
start_time += self.segment_duration;
|
||||
}
|
||||
|
||||
pattern
|
||||
}
|
||||
|
||||
fn generate_n_random_notes(
|
||||
&mut self,
|
||||
start_time: i32,
|
||||
mut p2: f64,
|
||||
mut p3: f64,
|
||||
mut p4: f64,
|
||||
) -> Pattern {
|
||||
// * - - - -
|
||||
// * ■ - ■ ■
|
||||
// * □ - □ □
|
||||
// * ■ - ■ ■
|
||||
|
||||
match self.total_columns {
|
||||
2 => {
|
||||
p2 = 0.0;
|
||||
p3 = 0.0;
|
||||
p4 = 0.0;
|
||||
}
|
||||
3 => {
|
||||
p2 = p2.min(0.1);
|
||||
p3 = 0.0;
|
||||
p4 = 0.0;
|
||||
}
|
||||
4 => {
|
||||
p2 = p2.min(0.3);
|
||||
p3 = p3.min(0.04);
|
||||
p4 = 0.0;
|
||||
}
|
||||
5 => {
|
||||
p2 = p2.min(0.34);
|
||||
p3 = p3.min(0.1);
|
||||
p4 = p4.min(0.03);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
let is_double_sample = |sample: u8| sample.clap() || sample.finish();
|
||||
|
||||
let can_generate_two_notes = !self.convert_type.contains(PatternType::LOW_PROBABILITY)
|
||||
&& (is_double_sample(self.sample)
|
||||
|| is_double_sample(self.sample_info_list_at(self.start_time)));
|
||||
|
||||
if can_generate_two_notes {
|
||||
p2 = 1.0;
|
||||
}
|
||||
|
||||
let note_count = self.get_random_note_count(p2, p3, Some(p4), None, None);
|
||||
|
||||
self.generate_random_hold_notes(start_time, note_count)
|
||||
}
|
||||
|
||||
fn generate_tiled_hold_notes(&mut self, mut start_time: i32) -> Pattern {
|
||||
// * - - - -
|
||||
// * ■ ■ ■ ■
|
||||
// * □ □ □ □
|
||||
// * □ □ □ □
|
||||
// * □ □ □ ■
|
||||
// * □ □ ■ -
|
||||
// * □ ■ - -
|
||||
// * ■ - - -
|
||||
|
||||
let column_repeat = self.span_count.min(self.total_columns) as usize;
|
||||
|
||||
// * Due to integer rounding, this is not guaranteed to be the same as EndTime (the class-level variable).
|
||||
let end_time = start_time + self.segment_duration * self.span_count;
|
||||
|
||||
let mut next_column = self.get_column(Some(true));
|
||||
|
||||
if self.convert_type.contains(PatternType::FORCE_NOT_STACK)
|
||||
&& self.prev_pattern.column_with_objs() < self.total_columns
|
||||
{
|
||||
next_column = self.find_available_column(
|
||||
next_column,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
&[self.prev_pattern],
|
||||
);
|
||||
}
|
||||
|
||||
let mut pattern = Pattern::with_capacity(column_repeat);
|
||||
|
||||
for _ in 0..column_repeat {
|
||||
next_column =
|
||||
self.find_available_column(next_column, None, None, None, None, &[&pattern]);
|
||||
pattern.add_slider_note(self, next_column, start_time, end_time);
|
||||
start_time += self.segment_duration;
|
||||
}
|
||||
|
||||
pattern
|
||||
}
|
||||
|
||||
fn generate_hold_and_normal_notes(
|
||||
&mut self,
|
||||
mut start_time: i32,
|
||||
conversion_diff: f64,
|
||||
) -> Pattern {
|
||||
// * - - - -
|
||||
// * ■ x x -
|
||||
// * ■ - x x
|
||||
// * ■ x - x
|
||||
// * ■ - x x
|
||||
|
||||
let mut pattern = Pattern::default();
|
||||
|
||||
let mut hold_column = self.get_column(Some(true));
|
||||
|
||||
if self.convert_type.contains(PatternType::FORCE_NOT_STACK)
|
||||
&& self.prev_pattern.column_with_objs() < self.total_columns
|
||||
{
|
||||
hold_column = self.find_available_column(
|
||||
hold_column,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
&[self.prev_pattern],
|
||||
);
|
||||
}
|
||||
|
||||
// * Create the hold note
|
||||
pattern.add_slider_note(self, hold_column, start_time, self.end_time);
|
||||
|
||||
let mut next_column = PatternGenerator::get_random_column(self, None, None);
|
||||
|
||||
let mut note_count = if conversion_diff > 6.5 {
|
||||
self.get_random_note_count(0.63, 0.0, None, None, None)
|
||||
} else if conversion_diff > 4.0 {
|
||||
let p2 = if self.total_columns < 6 { 0.12 } else { 0.45 };
|
||||
|
||||
self.get_random_note_count(p2, 0.0, None, None, None)
|
||||
} else if conversion_diff > 2.5 {
|
||||
let p2 = if self.total_columns < 6 { 0.0 } else { 0.24 };
|
||||
|
||||
self.get_random_note_count(p2, 0.0, None, None, None)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
note_count = note_count.min(self.total_columns - 1);
|
||||
|
||||
let sample = self.sample_info_list_at(start_time);
|
||||
let ignore_head = !(sample.whistle() || sample.finish() || sample.clap());
|
||||
|
||||
let mut row_pattern = Pattern::default();
|
||||
let hold_column = hold_column as i32;
|
||||
|
||||
for _ in 0..=self.span_count as usize {
|
||||
if !(ignore_head && start_time == self.start_time) {
|
||||
for _ in 0..note_count {
|
||||
next_column = self.find_available_column(
|
||||
next_column,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some(&|c| c != hold_column),
|
||||
&[&row_pattern],
|
||||
);
|
||||
|
||||
row_pattern.add_slider_note(self, next_column, start_time, start_time);
|
||||
}
|
||||
}
|
||||
|
||||
pattern.append(&mut row_pattern);
|
||||
start_time += self.segment_duration;
|
||||
}
|
||||
|
||||
pattern
|
||||
}
|
||||
|
||||
fn sample_info_list_at(&self, time: i32) -> u8 {
|
||||
self.note_samples_at(time)
|
||||
.first()
|
||||
.map_or(self.sample, |sample| *sample)
|
||||
}
|
||||
|
||||
fn note_samples_at(&self, time: i32) -> &[u8] {
|
||||
let idx = if self.segment_duration == 0 {
|
||||
0
|
||||
} else {
|
||||
((time - self.start_time) / self.segment_duration) as usize
|
||||
};
|
||||
|
||||
&self.edge_sounds[idx..]
|
||||
}
|
||||
}
|
||||
|
||||
impl PatternGenerator for DistanceObjectPatternGenerator<'_> {
|
||||
#[inline]
|
||||
fn hit_object(&self) -> &HitObject {
|
||||
self.hit_object
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn total_columns(&self) -> i32 {
|
||||
self.total_columns
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn random(&mut self) -> &mut Random {
|
||||
self.random
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn original_map(&self) -> &Beatmap {
|
||||
self.orig
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
use crate::{
|
||||
beatmap::converts::mania::{
|
||||
legacy_random::Random, pattern::Pattern, pattern_type::PatternType,
|
||||
},
|
||||
parse::{HitObject, HitSound},
|
||||
Beatmap,
|
||||
};
|
||||
|
||||
use super::PatternGenerator;
|
||||
|
||||
pub(crate) struct EndTimeObjectPatternGenerator<'h> {
|
||||
pub(crate) hit_object: &'h HitObject,
|
||||
pub(crate) end_time: f64,
|
||||
pub(crate) total_columns: i32,
|
||||
pub(crate) sample: u8,
|
||||
convert_type: PatternType,
|
||||
prev_pattern: &'h Pattern,
|
||||
random: &'h mut Random,
|
||||
}
|
||||
|
||||
impl<'h> EndTimeObjectPatternGenerator<'h> {
|
||||
pub(crate) fn new(
|
||||
random: &'h mut Random,
|
||||
hit_object: &'h HitObject,
|
||||
end_time: f64,
|
||||
sample: u8,
|
||||
total_columns: i32,
|
||||
prev_pattern: &'h Pattern,
|
||||
) -> Self {
|
||||
let convert_type = if prev_pattern.column_with_objs() == total_columns {
|
||||
PatternType::default()
|
||||
} else {
|
||||
PatternType::FORCE_NOT_STACK
|
||||
};
|
||||
|
||||
Self {
|
||||
hit_object,
|
||||
end_time,
|
||||
total_columns,
|
||||
sample,
|
||||
convert_type,
|
||||
prev_pattern,
|
||||
random,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn generate(&mut self) -> Pattern {
|
||||
let generate_hold = self.end_time - self.hit_object.start_time >= 100.0;
|
||||
|
||||
match self.total_columns {
|
||||
8 if self.sample.finish() && self.end_time - self.hit_object.start_time < 1000.0 => {
|
||||
Pattern::new_end_time_note(self, 0, generate_hold)
|
||||
}
|
||||
8 => {
|
||||
let column = self.get_random_column(self.random_start());
|
||||
|
||||
Pattern::new_end_time_note(self, column, generate_hold)
|
||||
}
|
||||
_ => {
|
||||
let column = self.get_random_column(0);
|
||||
|
||||
Pattern::new_end_time_note(self, column, generate_hold)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn get_random_column(&mut self, lower: i32) -> u8 {
|
||||
let column = PatternGenerator::get_random_column(self, Some(lower), None);
|
||||
|
||||
if self.convert_type.contains(PatternType::FORCE_NOT_STACK) {
|
||||
self.find_available_column(column, Some(lower), None, None, None, &[self.prev_pattern])
|
||||
} else {
|
||||
self.find_available_column(column, Some(lower), None, None, None, &[])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PatternGenerator for EndTimeObjectPatternGenerator<'_> {
|
||||
#[inline]
|
||||
fn hit_object(&self) -> &HitObject {
|
||||
self.hit_object
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn total_columns(&self) -> i32 {
|
||||
self.total_columns
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn random(&mut self) -> &mut Random {
|
||||
self.random
|
||||
}
|
||||
|
||||
fn original_map(&self) -> &Beatmap {
|
||||
panic!("trait method is not used")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,465 @@
|
||||
use crate::{
|
||||
beatmap::{
|
||||
converts::mania::{
|
||||
legacy_random::Random, pattern::Pattern, pattern_type::PatternType, PrevValues,
|
||||
},
|
||||
EffectPoint,
|
||||
},
|
||||
mania::ManiaObject,
|
||||
parse::{HitObject, HitSound},
|
||||
Beatmap,
|
||||
};
|
||||
|
||||
use super::PatternGenerator;
|
||||
|
||||
pub(crate) struct HitObjectPatternGenerator<'h> {
|
||||
pub(crate) hit_object: &'h HitObject,
|
||||
pub(crate) total_columns: i32,
|
||||
pub(crate) sample: u8,
|
||||
pub(crate) stair_type: PatternType,
|
||||
convert_type: PatternType,
|
||||
prev_pattern: &'h Pattern,
|
||||
random: &'h mut Random,
|
||||
orig: &'h Beatmap,
|
||||
}
|
||||
|
||||
impl<'h> HitObjectPatternGenerator<'h> {
|
||||
pub(crate) fn new(
|
||||
random: &'h mut Random,
|
||||
hit_object: &'h HitObject,
|
||||
sample: u8,
|
||||
total_columns: i32,
|
||||
prev: &'h PrevValues,
|
||||
density: f64,
|
||||
orig: &'h Beatmap,
|
||||
) -> Self {
|
||||
let timing_point = orig.timing_point_at(hit_object.start_time);
|
||||
|
||||
let pos_separation = (hit_object.pos - prev.pos).length();
|
||||
let time_separation = hit_object.start_time - prev.time;
|
||||
|
||||
let mut convert_type = PatternType::default();
|
||||
|
||||
if time_separation <= 80.0 {
|
||||
// * More than 187 BPM
|
||||
convert_type |= PatternType::FORCE_NOT_STACK | PatternType::KEEP_SINGLE;
|
||||
} else if time_separation <= 95.0 {
|
||||
// * More than 157 BPM
|
||||
convert_type |= PatternType::FORCE_NOT_STACK | PatternType::KEEP_SINGLE | prev.stair;
|
||||
} else if time_separation <= 105.0 {
|
||||
// * More than 140 BPM
|
||||
convert_type |= PatternType::FORCE_NOT_STACK | PatternType::LOW_PROBABILITY;
|
||||
} else if time_separation <= 125.0 {
|
||||
// * More than 120 BPM
|
||||
convert_type |= PatternType::FORCE_NOT_STACK;
|
||||
} else if time_separation <= 135.0 && pos_separation < 20.0 {
|
||||
// * More than 111 BPM stream
|
||||
convert_type |= PatternType::CYCLE | PatternType::KEEP_SINGLE;
|
||||
} else if time_separation <= 150.0 && pos_separation < 20.0 {
|
||||
// * More than 100 BPM stream
|
||||
convert_type |= PatternType::FORCE_STACK | PatternType::LOW_PROBABILITY;
|
||||
} else if pos_separation < 20.0 && density >= timing_point.beat_len / 2.5 {
|
||||
// * Low density stream
|
||||
convert_type |= PatternType::REVERSE | PatternType::LOW_PROBABILITY;
|
||||
} else if density < timing_point.beat_len / 2.5 {
|
||||
// * High density
|
||||
} else {
|
||||
let kiai = orig
|
||||
.effect_point_at(hit_object.start_time)
|
||||
.map_or(EffectPoint::DEFAULT_KIAI, |point| point.kiai);
|
||||
|
||||
if kiai {
|
||||
// * High density
|
||||
} else {
|
||||
convert_type |= PatternType::LOW_PROBABILITY;
|
||||
}
|
||||
}
|
||||
|
||||
if !convert_type.contains(PatternType::KEEP_SINGLE) {
|
||||
if sample.finish() && total_columns != 8 {
|
||||
convert_type |= PatternType::MIRROR;
|
||||
} else if sample.clap() {
|
||||
convert_type |= PatternType::GATHERED;
|
||||
}
|
||||
}
|
||||
|
||||
Self {
|
||||
hit_object,
|
||||
stair_type: prev.stair,
|
||||
convert_type,
|
||||
total_columns,
|
||||
sample,
|
||||
prev_pattern: &prev.pattern,
|
||||
random,
|
||||
orig,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn generate(&mut self) -> Pattern {
|
||||
let pattern = self.generate_core();
|
||||
|
||||
for obj in pattern.hit_objects.iter() {
|
||||
let col = ManiaObject::column(obj.pos.x, self.total_columns as f32) as i32;
|
||||
|
||||
if self.convert_type.contains(PatternType::STAIR) && col == self.total_columns - 1 {
|
||||
self.stair_type = PatternType::REVERSE_STAIR;
|
||||
}
|
||||
|
||||
if self.convert_type.contains(PatternType::REVERSE_STAIR) && col == self.random_start()
|
||||
{
|
||||
self.stair_type = PatternType::STAIR;
|
||||
}
|
||||
}
|
||||
|
||||
pattern
|
||||
}
|
||||
|
||||
fn generate_core(&mut self) -> Pattern {
|
||||
if self.total_columns == 1 {
|
||||
return Pattern::new_note(self, 0);
|
||||
}
|
||||
|
||||
let last_column = self.prev_pattern.hit_objects.last().map_or(0, |h| {
|
||||
ManiaObject::column(h.pos.x, self.total_columns as f32) as u8
|
||||
});
|
||||
|
||||
let random_start = self.random_start() as u8;
|
||||
|
||||
if self.convert_type.contains(PatternType::REVERSE)
|
||||
&& !self.prev_pattern.hit_objects.is_empty()
|
||||
{
|
||||
let mut pattern = Pattern::default();
|
||||
|
||||
for i in random_start..self.total_columns as u8 {
|
||||
if self.prev_pattern.column_has_obj(i) {
|
||||
pattern.add_note(self, random_start + self.total_columns as u8 - i - 1);
|
||||
}
|
||||
}
|
||||
|
||||
return pattern;
|
||||
}
|
||||
|
||||
if self.convert_type.contains(PatternType::CYCLE)
|
||||
&& self.prev_pattern.hit_objects.len() == 1
|
||||
// * If we convert to 7K + 1, let's not overload the special key
|
||||
&& (self.total_columns != 8 || last_column != 0)
|
||||
// * Make sure the last column was not the centre column
|
||||
&& (self.total_columns % 2 == 0 || last_column != self.total_columns as u8 / 2)
|
||||
{
|
||||
// * Generate a new pattern by cycling backwards (similar to Reverse but for only one hit object)
|
||||
let column = random_start + self.total_columns as u8 - last_column - 1;
|
||||
|
||||
return Pattern::new_note(self, column);
|
||||
}
|
||||
|
||||
if self.convert_type.contains(PatternType::FORCE_STACK)
|
||||
&& !self.prev_pattern.hit_objects.is_empty()
|
||||
{
|
||||
let mut pattern = Pattern::default();
|
||||
|
||||
// * Generate a new pattern by placing on the already filled columns
|
||||
for i in random_start..self.total_columns as u8 {
|
||||
if self.prev_pattern.column_has_obj(i) {
|
||||
pattern.add_note(self, i);
|
||||
}
|
||||
}
|
||||
|
||||
return pattern;
|
||||
}
|
||||
|
||||
if self.prev_pattern.hit_objects.len() == 1 {
|
||||
if self.convert_type.contains(PatternType::STAIR) {
|
||||
// * Generate a new pattern by placing on the next column,
|
||||
// * cycling back to the start if there is no "next"
|
||||
let mut target_column = last_column + 1;
|
||||
|
||||
if target_column == self.total_columns as u8 {
|
||||
target_column = random_start;
|
||||
}
|
||||
|
||||
return Pattern::new_note(self, target_column);
|
||||
}
|
||||
|
||||
if self.convert_type.contains(PatternType::REVERSE_STAIR) {
|
||||
// * Generate a new pattern by placing on the previous column,
|
||||
// * cycling back to the end if there is no "previous"
|
||||
let mut target_column = last_column as i8 - 1;
|
||||
|
||||
if target_column == random_start as i8 - 1 {
|
||||
target_column = self.total_columns as i8 - 1;
|
||||
}
|
||||
|
||||
return Pattern::new_note(self, target_column as u8);
|
||||
}
|
||||
}
|
||||
|
||||
if self.convert_type.contains(PatternType::KEEP_SINGLE) {
|
||||
return self.generate_random_notes(1);
|
||||
}
|
||||
|
||||
let conversion_diff = self.conversion_difficulty();
|
||||
|
||||
if self.convert_type.contains(PatternType::MIRROR) {
|
||||
if conversion_diff > 6.5 {
|
||||
self.generate_random_pattern_with_mirrored(0.12, 0.38, 0.12)
|
||||
} else if conversion_diff > 4.0 {
|
||||
self.generate_random_pattern_with_mirrored(0.12, 0.17, 0.0)
|
||||
} else {
|
||||
self.generate_random_pattern_with_mirrored(0.12, 0.0, 0.0)
|
||||
}
|
||||
} else if conversion_diff > 6.5 {
|
||||
if self.convert_type.contains(PatternType::LOW_PROBABILITY) {
|
||||
self.generate_random_pattern(0.78, 0.42, 0.0, 0.0)
|
||||
} else {
|
||||
self.generate_random_pattern(1.0, 0.62, 0.0, 0.0)
|
||||
}
|
||||
} else if conversion_diff > 4.0 {
|
||||
if self.convert_type.contains(PatternType::LOW_PROBABILITY) {
|
||||
self.generate_random_pattern(0.35, 0.08, 0.0, 0.0)
|
||||
} else {
|
||||
self.generate_random_pattern(0.52, 0.15, 0.0, 0.0)
|
||||
}
|
||||
} else if conversion_diff > 2.0 {
|
||||
if self.convert_type.contains(PatternType::LOW_PROBABILITY) {
|
||||
self.generate_random_pattern(0.18, 0.0, 0.0, 0.0)
|
||||
} else {
|
||||
self.generate_random_pattern(0.45, 0.0, 0.0, 0.0)
|
||||
}
|
||||
} else {
|
||||
self.generate_random_pattern(0.0, 0.0, 0.0, 0.0)
|
||||
}
|
||||
}
|
||||
|
||||
fn generate_random_notes(&mut self, mut note_count: i32) -> Pattern {
|
||||
let mut pattern = Pattern::default();
|
||||
|
||||
let allow_stacking = !self.convert_type.contains(PatternType::FORCE_NOT_STACK);
|
||||
|
||||
if !allow_stacking {
|
||||
note_count =
|
||||
(self.total_columns - self.random_start() - self.prev_pattern.column_with_objs())
|
||||
.min(note_count);
|
||||
}
|
||||
|
||||
let mut next_column = self.get_column(Some(true));
|
||||
|
||||
for _ in 0..note_count {
|
||||
next_column = if allow_stacking {
|
||||
self.find_available_column(
|
||||
next_column,
|
||||
None,
|
||||
None,
|
||||
Some(Self::get_next_column),
|
||||
None,
|
||||
&[&pattern],
|
||||
)
|
||||
} else {
|
||||
self.find_available_column(
|
||||
next_column,
|
||||
None,
|
||||
None,
|
||||
Some(Self::get_next_column),
|
||||
None,
|
||||
&[&pattern, self.prev_pattern],
|
||||
)
|
||||
};
|
||||
|
||||
pattern.add_note(self, next_column);
|
||||
}
|
||||
|
||||
pattern
|
||||
}
|
||||
|
||||
fn get_next_column(&mut self, mut last: u8) -> u8 {
|
||||
if self.convert_type.contains(PatternType::GATHERED) {
|
||||
last += 1;
|
||||
|
||||
if last == self.total_columns as u8 {
|
||||
last = self.random_start() as u8;
|
||||
}
|
||||
} else {
|
||||
last = PatternGenerator::get_random_column(self, None, None);
|
||||
}
|
||||
|
||||
last
|
||||
}
|
||||
|
||||
fn has_special_column(&self) -> bool {
|
||||
self.sample.clap() && self.sample.finish()
|
||||
}
|
||||
|
||||
fn generate_random_pattern(&mut self, p2: f64, p3: f64, p4: f64, p5: f64) -> Pattern {
|
||||
let random_note_count = self.get_random_note_count(p2, p3, p4, p5);
|
||||
let mut pattern = self.generate_random_notes(random_note_count);
|
||||
|
||||
if self.random_start() > 0 && self.has_special_column() {
|
||||
pattern.add_note(self, 0);
|
||||
}
|
||||
|
||||
pattern
|
||||
}
|
||||
|
||||
fn get_random_note_count(&mut self, mut p2: f64, mut p3: f64, mut p4: f64, mut p5: f64) -> i32 {
|
||||
match self.total_columns {
|
||||
2 => {
|
||||
p2 = 0.0;
|
||||
p3 = 0.0;
|
||||
p4 = 0.0;
|
||||
p5 = 0.0;
|
||||
}
|
||||
3 => {
|
||||
p2 = p2.min(0.1);
|
||||
p3 = 0.0;
|
||||
p4 = 0.0;
|
||||
p5 = 0.0;
|
||||
}
|
||||
4 => {
|
||||
p2 = p2.min(0.23);
|
||||
p3 = p3.min(0.04);
|
||||
p4 = 0.0;
|
||||
p5 = 0.0;
|
||||
}
|
||||
5 => {
|
||||
p3 = p3.min(0.15);
|
||||
p4 = p4.min(0.03);
|
||||
p5 = 0.0;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
if self.sample.clap() {
|
||||
p2 = 1.0;
|
||||
}
|
||||
|
||||
PatternGenerator::get_random_note_count(self, p2, p3, Some(p4), Some(p5), None)
|
||||
}
|
||||
|
||||
fn generate_random_pattern_with_mirrored(
|
||||
&mut self,
|
||||
centre_probability: f64,
|
||||
p2: f64,
|
||||
p3: f64,
|
||||
) -> Pattern {
|
||||
if self.convert_type.contains(PatternType::FORCE_NOT_STACK) {
|
||||
return self.generate_random_pattern(1.0 / 2.0 + p2 / 2.0, p2, (p2 + p3) / 2.0, p3);
|
||||
}
|
||||
|
||||
let mut pattern = Pattern::default();
|
||||
|
||||
let (note_count, add_to_centre) =
|
||||
self.get_random_note_count_mirrored(centre_probability, p2, p3);
|
||||
|
||||
let column_limit = if self.total_columns % 2 == 0 {
|
||||
self.total_columns / 2
|
||||
} else {
|
||||
(self.total_columns - 1) / 2
|
||||
};
|
||||
|
||||
let mut next_column = PatternGenerator::get_random_column(self, None, Some(column_limit));
|
||||
|
||||
for _ in 0..note_count {
|
||||
next_column = self.find_available_column(
|
||||
next_column,
|
||||
None,
|
||||
Some(column_limit),
|
||||
None,
|
||||
None,
|
||||
&[&pattern],
|
||||
);
|
||||
|
||||
// * Add normal note
|
||||
pattern.add_note(self, next_column);
|
||||
|
||||
// * Add mirrored note
|
||||
let column = (self.random_start() + self.total_columns) as u8 - next_column - 1;
|
||||
pattern.add_note(self, column);
|
||||
}
|
||||
|
||||
if add_to_centre {
|
||||
pattern.add_note(self, self.total_columns as u8 / 2);
|
||||
}
|
||||
|
||||
if self.random_start() > 0 && self.has_special_column() {
|
||||
pattern.add_note(self, 0);
|
||||
}
|
||||
|
||||
pattern
|
||||
}
|
||||
|
||||
fn get_random_note_count_mirrored(
|
||||
&mut self,
|
||||
mut centre_probability: f64,
|
||||
mut p2: f64,
|
||||
mut p3: f64,
|
||||
) -> (i32, bool) {
|
||||
match self.total_columns {
|
||||
2 => {
|
||||
centre_probability = 0.0;
|
||||
p2 = 0.0;
|
||||
p3 = 0.0;
|
||||
}
|
||||
3 => {
|
||||
centre_probability = centre_probability.min(0.03);
|
||||
p2 = 0.0;
|
||||
p3 = 0.0;
|
||||
}
|
||||
4 => {
|
||||
centre_probability = 0.0;
|
||||
|
||||
// * Stable requires rngValue > x, which is an inverse-probability. Lazer uses true probability (1 - x).
|
||||
// * But multiplying this value by 2 (stable) is not the same operation as dividing it by 2 (lazer),
|
||||
// * so it needs to be converted to from a probability and then back after the multiplication.
|
||||
p2 = 1.0 - ((1.0 - p2) * 2.0).max(0.8);
|
||||
p3 = 0.0;
|
||||
}
|
||||
5 => {
|
||||
centre_probability = centre_probability.min(0.03);
|
||||
p3 = 0.0;
|
||||
}
|
||||
6 => {
|
||||
centre_probability = 0.0;
|
||||
|
||||
// * Stable requires rngValue > x, which is an inverse-probability. Lazer uses true probability (1 - x).
|
||||
// * But multiplying this value by 2 (stable) is not the same operation as dividing it by 2 (lazer),
|
||||
// * so it needs to be converted to from a probability and then back after the multiplication.
|
||||
p2 = 1.0 - ((1.0 - p2) * 2.0).max(0.05);
|
||||
p3 = 1.0 - ((1.0 - p3) * 2.0).max(0.85);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// * The stable values were allowed to exceed 1, which indicate <0% probability.
|
||||
// * These values needs to be clamped otherwise GetRandomNoteCount() will throw an exception.
|
||||
p2 = p2.clamp(0.0, 1.0);
|
||||
p3 = p3.clamp(0.0, 1.0);
|
||||
|
||||
let centre_val = self.random.gen_double();
|
||||
let note_count = PatternGenerator::get_random_note_count(self, p2, p3, None, None, None);
|
||||
let add_to_centre =
|
||||
self.total_columns % 2 != 0 && note_count != 3 && centre_val > 1.0 - centre_probability;
|
||||
|
||||
(note_count, add_to_centre)
|
||||
}
|
||||
}
|
||||
|
||||
impl PatternGenerator for HitObjectPatternGenerator<'_> {
|
||||
#[inline]
|
||||
fn hit_object(&self) -> &HitObject {
|
||||
self.hit_object
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn total_columns(&self) -> i32 {
|
||||
self.total_columns
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn random(&mut self) -> &mut Random {
|
||||
self.random
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn original_map(&self) -> &Beatmap {
|
||||
self.orig
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
use crate::{mania::ManiaObject, parse::HitObject, Beatmap};
|
||||
|
||||
use super::{legacy_random::Random, pattern::Pattern};
|
||||
|
||||
pub(super) mod distance_object;
|
||||
pub(super) mod end_time_object;
|
||||
pub(super) mod hit_object;
|
||||
|
||||
trait PatternGenerator {
|
||||
fn hit_object(&self) -> &HitObject;
|
||||
fn total_columns(&self) -> i32;
|
||||
fn random(&mut self) -> &mut Random;
|
||||
fn original_map(&self) -> &Beatmap;
|
||||
|
||||
// ----------------------------------
|
||||
|
||||
fn random_start(&self) -> i32 {
|
||||
(self.total_columns() == 8) as i32
|
||||
}
|
||||
|
||||
fn get_column(&self, allow_special: Option<bool>) -> u8 {
|
||||
let allow_special = allow_special.unwrap_or(false);
|
||||
|
||||
if allow_special && self.total_columns() == 8 {
|
||||
const LOCAL_X_DIVISOR: f32 = 512.0 / 7.0;
|
||||
|
||||
((self.hit_object().pos.x / LOCAL_X_DIVISOR).floor() as u8).clamp(0, 6) + 1
|
||||
} else {
|
||||
ManiaObject::column(self.hit_object().pos.x, self.total_columns() as f32) as u8
|
||||
}
|
||||
}
|
||||
|
||||
fn get_random_note_count(
|
||||
&mut self,
|
||||
p2: f64,
|
||||
p3: f64,
|
||||
p4: Option<f64>,
|
||||
p5: Option<f64>,
|
||||
p6: Option<f64>,
|
||||
) -> i32 {
|
||||
let p4 = p4.unwrap_or(0.0);
|
||||
let p5 = p5.unwrap_or(0.0);
|
||||
let p6 = p6.unwrap_or(0.0);
|
||||
|
||||
let val = self.random().gen_double();
|
||||
|
||||
if val >= 1.0 - p6 {
|
||||
6
|
||||
} else if val >= 1.0 - p5 {
|
||||
5
|
||||
} else if val >= 1.0 - p4 {
|
||||
4
|
||||
} else if val >= 1.0 - p3 {
|
||||
3
|
||||
} else {
|
||||
1 + (val >= 1.0 - p2) as i32
|
||||
}
|
||||
}
|
||||
|
||||
fn conversion_difficulty(&self) -> f64 {
|
||||
let orig = self.original_map();
|
||||
let last_obj_time = orig.hit_objects.last().map_or(0.0, |h| h.start_time);
|
||||
let first_obj_time = orig.hit_objects.first().map_or(0.0, |h| h.start_time);
|
||||
|
||||
// * Drain time in seconds
|
||||
let total_break_time = orig.total_break_time();
|
||||
let mut drain_time = ((last_obj_time - first_obj_time - total_break_time) / 1000.0) as i32;
|
||||
|
||||
if drain_time == 0 {
|
||||
drain_time = 10_000;
|
||||
}
|
||||
|
||||
let mut conversion_difficulty = 0.0;
|
||||
conversion_difficulty += (orig.hp + orig.ar.clamp(4.0, 7.0)) as f64 / 1.5;
|
||||
conversion_difficulty += orig.hit_objects.len() as f64 / drain_time as f64 * 9.0;
|
||||
conversion_difficulty /= 38.0;
|
||||
conversion_difficulty *= 5.0;
|
||||
conversion_difficulty /= 1.15;
|
||||
conversion_difficulty = conversion_difficulty.min(12.0);
|
||||
|
||||
conversion_difficulty
|
||||
}
|
||||
|
||||
fn get_random_column(&mut self, lower: Option<i32>, upper: Option<i32>) -> u8 {
|
||||
let lower = lower.unwrap_or_else(|| self.random_start());
|
||||
let upper = upper.unwrap_or_else(|| self.total_columns());
|
||||
|
||||
self.random().gen_int_range(lower, upper) as u8
|
||||
}
|
||||
|
||||
fn find_available_column(
|
||||
&mut self,
|
||||
mut initial_column: u8,
|
||||
lower: Option<i32>,
|
||||
upper: Option<i32>,
|
||||
next_column: Option<fn(&mut Self, u8) -> u8>,
|
||||
validation: Option<&dyn Fn(i32) -> bool>,
|
||||
patterns: &[&Pattern],
|
||||
) -> u8 {
|
||||
let lower = lower.unwrap_or_else(|| self.random_start());
|
||||
let upper = upper.unwrap_or_else(|| self.total_columns());
|
||||
|
||||
let is_valid = |column: i32| {
|
||||
if let Some(fun) = validation {
|
||||
if !(fun)(column) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
let column = column as u8;
|
||||
|
||||
patterns
|
||||
.iter()
|
||||
.all(|pattern| !pattern.column_has_obj(column))
|
||||
};
|
||||
|
||||
// * Check for the initial column
|
||||
if is_valid(initial_column as i32) {
|
||||
return initial_column;
|
||||
}
|
||||
|
||||
// * Ensure that we have at least one free column, so that an endless loop is avoided
|
||||
let has_valid_column = (lower..upper).any(is_valid);
|
||||
assert!(has_valid_column);
|
||||
|
||||
// * Iterate until a valid column is found. This is a random iteration in the default case.
|
||||
while {
|
||||
initial_column = if let Some(fun) = next_column {
|
||||
(fun)(self, initial_column)
|
||||
} else {
|
||||
PatternGenerator::get_random_column(self, Some(lower), Some(upper))
|
||||
};
|
||||
|
||||
!is_valid(initial_column as i32)
|
||||
} {}
|
||||
|
||||
initial_column
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
use std::{
|
||||
fmt,
|
||||
ops::{BitAndAssign, BitOr, BitOrAssign, Not},
|
||||
};
|
||||
|
||||
#[derive(Copy, Clone, Default)]
|
||||
pub(crate) struct PatternType(u16);
|
||||
|
||||
#[rustfmt::skip]
|
||||
impl PatternType {
|
||||
pub(crate) const FORCE_STACK: Self = Self(1 << 0);
|
||||
pub(crate) const FORCE_NOT_STACK: Self = Self(1 << 1);
|
||||
pub(crate) const KEEP_SINGLE: Self = Self(1 << 2);
|
||||
pub(crate) const LOW_PROBABILITY: Self = Self(1 << 3);
|
||||
// pub(crate) const ALTERNATE: Self = Self(1 << 4);
|
||||
// pub(crate) const FORCE_SIG_SLIDER: Self = Self(1 << 5);
|
||||
// pub(crate) const FORCE_NOT_SLIDER: Self = Self(1 << 6);
|
||||
pub(crate) const GATHERED: Self = Self(1 << 7);
|
||||
pub(crate) const MIRROR: Self = Self(1 << 8);
|
||||
pub(crate) const REVERSE: Self = Self(1 << 9);
|
||||
pub(crate) const CYCLE: Self = Self(1 << 10);
|
||||
pub(crate) const STAIR: Self = Self(1 << 11);
|
||||
pub(crate) const REVERSE_STAIR: Self = Self(1 << 12);
|
||||
}
|
||||
|
||||
impl fmt::Display for PatternType {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
let mut written = false;
|
||||
|
||||
macro_rules! write_pattern {
|
||||
($self:ident, $f:ident, $written:ident: $($pat:ident,)*) => {
|
||||
$(
|
||||
if $self.contains(Self::$pat) {
|
||||
if $written {
|
||||
$f.write_str(", ")?;
|
||||
} else {
|
||||
$written = true;
|
||||
}
|
||||
|
||||
$f.write_str(stringify!($pat))?;
|
||||
}
|
||||
)*
|
||||
}
|
||||
}
|
||||
|
||||
write_pattern! {
|
||||
self, f, written:
|
||||
FORCE_STACK,
|
||||
FORCE_NOT_STACK,
|
||||
KEEP_SINGLE,
|
||||
LOW_PROBABILITY,
|
||||
GATHERED,
|
||||
MIRROR,
|
||||
REVERSE,
|
||||
CYCLE,
|
||||
STAIR,
|
||||
REVERSE_STAIR,
|
||||
}
|
||||
|
||||
if !written {
|
||||
f.write_str("NONE")?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl PatternType {
|
||||
pub(crate) fn contains(self, other: Self) -> bool {
|
||||
self.0 & other.0 == other.0
|
||||
}
|
||||
}
|
||||
|
||||
impl BitOr for PatternType {
|
||||
type Output = Self;
|
||||
|
||||
#[inline]
|
||||
fn bitor(self, rhs: Self) -> Self::Output {
|
||||
Self(self.0 | rhs.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl BitOrAssign for PatternType {
|
||||
#[inline]
|
||||
fn bitor_assign(&mut self, rhs: Self) {
|
||||
self.0 |= rhs.0;
|
||||
}
|
||||
}
|
||||
|
||||
impl BitAndAssign for PatternType {
|
||||
#[inline]
|
||||
fn bitand_assign(&mut self, rhs: Self) {
|
||||
self.0 &= rhs.0;
|
||||
}
|
||||
}
|
||||
|
||||
impl Not for PatternType {
|
||||
type Output = Self;
|
||||
|
||||
#[inline]
|
||||
fn not(self) -> Self::Output {
|
||||
Self(!self.0)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
mod mania;
|
||||
mod taiko;
|
||||
@@ -0,0 +1,156 @@
|
||||
use crate::{
|
||||
curve::{Curve, CurveBuffers},
|
||||
parse::{HitObject, HitObjectKind},
|
||||
util::TandemSorter,
|
||||
Beatmap, GameMode,
|
||||
};
|
||||
|
||||
const LEGACY_TAIKO_VELOCITY_MULTIPLIER: f32 = 1.4;
|
||||
const OSU_BASE_SCORING_DIST: f32 = 100.0;
|
||||
|
||||
impl Beatmap {
|
||||
pub(in crate::beatmap) fn convert_to_taiko(&self) -> Self {
|
||||
let mut map = self.clone_without_hit_objects(true);
|
||||
let mut curve_bufs = CurveBuffers::default();
|
||||
|
||||
map.slider_mult *= LEGACY_TAIKO_VELOCITY_MULTIPLIER as f64;
|
||||
|
||||
for (obj, sound) in self.hit_objects.iter().zip(self.sounds.iter()) {
|
||||
match obj.kind {
|
||||
HitObjectKind::Circle => {
|
||||
map.hit_objects.push(obj.to_owned());
|
||||
map.sounds.push(*sound);
|
||||
map.n_circles += 1;
|
||||
}
|
||||
HitObjectKind::Slider {
|
||||
pixel_len,
|
||||
repeats,
|
||||
ref control_points,
|
||||
ref edge_sounds,
|
||||
} => {
|
||||
let curve = Curve::new(control_points, pixel_len, &mut curve_bufs);
|
||||
let mut params = SliderParams::new(obj.start_time, repeats, &curve);
|
||||
|
||||
if map.should_convert_slider_to_taiko_hits(&mut params) {
|
||||
let mut i = 0;
|
||||
let mut j = obj.start_time;
|
||||
|
||||
let edge_sound_count = edge_sounds.len().max(1);
|
||||
|
||||
while j
|
||||
<= obj.start_time + params.duration as f64 + params.tick_spacing / 8.0
|
||||
{
|
||||
let h = HitObject {
|
||||
pos: Default::default(),
|
||||
start_time: j,
|
||||
kind: HitObjectKind::Circle,
|
||||
};
|
||||
|
||||
map.hit_objects.push(h);
|
||||
map.sounds.push(*edge_sounds.get(i).unwrap_or(sound));
|
||||
map.n_circles += 1;
|
||||
|
||||
if params.tick_spacing.abs() <= f64::EPSILON {
|
||||
break;
|
||||
}
|
||||
|
||||
j += params.tick_spacing;
|
||||
i = (i + 1) % edge_sound_count;
|
||||
}
|
||||
} else {
|
||||
map.hit_objects.push(obj.to_owned());
|
||||
map.sounds.push(*sound);
|
||||
map.n_sliders += 1;
|
||||
}
|
||||
}
|
||||
HitObjectKind::Spinner { .. } => {
|
||||
map.hit_objects.push(obj.to_owned());
|
||||
map.sounds.push(*sound);
|
||||
map.n_spinners += 1;
|
||||
}
|
||||
// Pathological case; shouldn't realistically happen
|
||||
HitObjectKind::Hold { end_time } => {
|
||||
let obj = HitObject {
|
||||
pos: obj.pos,
|
||||
start_time: obj.start_time,
|
||||
kind: HitObjectKind::Spinner { end_time },
|
||||
};
|
||||
|
||||
map.hit_objects.push(obj);
|
||||
map.sounds.push(*sound);
|
||||
map.n_spinners += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// We only convert STD to TKO so we don't need to remove objects
|
||||
// with the same timestamp that would appear only in MNA
|
||||
|
||||
let mut sorter = TandemSorter::new(&map.hit_objects, true);
|
||||
sorter.sort(&mut map.hit_objects);
|
||||
sorter.toggle_marks();
|
||||
sorter.sort(&mut map.sounds);
|
||||
|
||||
map.mode = GameMode::Taiko;
|
||||
|
||||
map
|
||||
}
|
||||
|
||||
fn should_convert_slider_to_taiko_hits(&self, params: &mut SliderParams<'_>) -> bool {
|
||||
let SliderParams {
|
||||
curve,
|
||||
duration,
|
||||
repeats,
|
||||
start_time,
|
||||
tick_spacing,
|
||||
} = params;
|
||||
|
||||
// * The true distance, accounting for any repeats. This ends up being the drum roll distance later
|
||||
let spans = (*repeats + 1) as f64;
|
||||
let dist = curve.dist() * spans * LEGACY_TAIKO_VELOCITY_MULTIPLIER as f64;
|
||||
|
||||
let timing_point = self.timing_point_at(*start_time);
|
||||
let difficulty_point = self.difficulty_point_at(*start_time).unwrap_or_default();
|
||||
|
||||
let mut beat_len = timing_point.beat_len * difficulty_point.bpm_mult;
|
||||
|
||||
let slider_scoring_point_dist =
|
||||
OSU_BASE_SCORING_DIST as f64 * self.slider_mult / self.tick_rate;
|
||||
|
||||
// * The velocity and duration of the taiko hit object - calculated as the velocity of a drum roll.
|
||||
let taiko_vel = slider_scoring_point_dist * self.tick_rate;
|
||||
*duration = (dist / taiko_vel * beat_len) as u32;
|
||||
|
||||
let osu_vel = taiko_vel * (1000.0_f32 as f64 / beat_len);
|
||||
|
||||
// * osu-stable always uses the speed-adjusted beatlength to determine the osu! velocity, but only uses it for conversion if beatmap version < 8
|
||||
if self.version >= 8 {
|
||||
beat_len = timing_point.beat_len;
|
||||
}
|
||||
|
||||
// * If the drum roll is to be split into hit circles, assume the ticks are 1/8 spaced within the duration of one beat
|
||||
*tick_spacing = (beat_len / self.tick_rate).min(*duration as f64 / spans);
|
||||
|
||||
*tick_spacing > 0.0 && dist / osu_vel * 1000.0 < 2.0 * beat_len
|
||||
}
|
||||
}
|
||||
|
||||
struct SliderParams<'c> {
|
||||
curve: &'c Curve<'c>,
|
||||
duration: u32,
|
||||
repeats: usize,
|
||||
start_time: f64,
|
||||
tick_spacing: f64,
|
||||
}
|
||||
|
||||
impl<'c> SliderParams<'c> {
|
||||
fn new(start_time: f64, repeats: usize, curve: &'c Curve<'c>) -> Self {
|
||||
Self {
|
||||
curve,
|
||||
repeats,
|
||||
start_time,
|
||||
duration: 0,
|
||||
tick_spacing: 0.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
use std::{borrow::Cow, cmp::Ordering};
|
||||
|
||||
use crate::{parse::HitObject, util::SortedVec};
|
||||
|
||||
pub use self::{
|
||||
attributes::{BeatmapAttributes, BeatmapAttributesBuilder, BeatmapHitWindows},
|
||||
breaks::Break,
|
||||
control_points::{DifficultyPoint, EffectPoint, TimingPoint},
|
||||
mode::GameMode,
|
||||
};
|
||||
|
||||
mod attributes;
|
||||
mod breaks;
|
||||
mod control_points;
|
||||
mod converts;
|
||||
mod mode;
|
||||
|
||||
/// The main beatmap struct containing all data relevant
|
||||
/// for difficulty and performance calculation
|
||||
#[derive(Clone, Default, Debug)]
|
||||
pub struct Beatmap {
|
||||
/// The game mode.
|
||||
pub mode: GameMode,
|
||||
/// The version of the .osu file.
|
||||
pub version: u8,
|
||||
|
||||
/// The amount of circles.
|
||||
pub n_circles: u32,
|
||||
/// The amount of sliders.
|
||||
pub n_sliders: u32,
|
||||
/// The amount of spinners.
|
||||
pub n_spinners: u32,
|
||||
|
||||
/// The approach rate.
|
||||
pub ar: f32,
|
||||
/// The overall difficulty.
|
||||
pub od: f32,
|
||||
/// The circle size.
|
||||
pub cs: f32,
|
||||
/// The health drain rate.
|
||||
pub hp: f32,
|
||||
/// Base slider velocity in pixels per beat
|
||||
pub slider_mult: f64,
|
||||
/// Amount of slider ticks per beat.
|
||||
pub tick_rate: f64,
|
||||
/// All hitobjects of the beatmap.
|
||||
pub hit_objects: Vec<HitObject>,
|
||||
/// Store the sounds for all objects in their own Vec to minimize the struct size.
|
||||
/// Hitsounds are only used in osu!taiko in which they represent color.
|
||||
pub sounds: Vec<u8>,
|
||||
|
||||
/// Timing points that indicate a new timing section.
|
||||
pub timing_points: SortedVec<TimingPoint>,
|
||||
|
||||
/// Timing point for the current timing section.
|
||||
pub difficulty_points: SortedVec<DifficultyPoint>,
|
||||
|
||||
/// Control points for effect sections.
|
||||
pub effect_points: SortedVec<EffectPoint>,
|
||||
|
||||
/// The stack leniency that is used to calculate
|
||||
/// the stack offset for stacked positions.
|
||||
pub stack_leniency: f32,
|
||||
|
||||
/// 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 {
|
||||
/// Extract a beatmap's attributes into their own type.
|
||||
#[inline]
|
||||
pub fn attributes(&self) -> BeatmapAttributesBuilder {
|
||||
BeatmapAttributesBuilder::new(self)
|
||||
}
|
||||
|
||||
/// The beats per minute of the map.
|
||||
#[inline]
|
||||
pub fn bpm(&self) -> f64 {
|
||||
match self.timing_points.first() {
|
||||
Some(point) => point.beat_len.recip() * 1000.0 * 60.0,
|
||||
None => 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Sum up the duration of all breaks (in milliseconds).
|
||||
#[inline]
|
||||
pub fn total_break_time(&self) -> f64 {
|
||||
self.breaks.iter().map(Break::duration).sum()
|
||||
}
|
||||
|
||||
/// Return the [`TimingPoint`] for the given timestamp.
|
||||
#[inline]
|
||||
pub fn timing_point_at(&self, time: f64) -> TimingPoint {
|
||||
let idx_result = self
|
||||
.timing_points
|
||||
.binary_search_by(|probe| probe.time.partial_cmp(&time).unwrap_or(Ordering::Less));
|
||||
|
||||
match idx_result {
|
||||
Ok(idx) => self.timing_points[idx],
|
||||
Err(0) => self.timing_points.first().copied().unwrap_or_default(),
|
||||
Err(idx) => self.timing_points[idx - 1],
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the [`DifficultyPoint`] for the given timestamp.
|
||||
///
|
||||
/// If `time` is before the first difficulty point, `None` is returned.
|
||||
#[inline]
|
||||
pub fn difficulty_point_at(&self, time: f64) -> Option<DifficultyPoint> {
|
||||
self.difficulty_points
|
||||
.binary_search_by(|probe| probe.time.partial_cmp(&time).unwrap_or(Ordering::Less))
|
||||
.map_or_else(|i| i.checked_sub(1), Some)
|
||||
.map(|i| self.difficulty_points[i])
|
||||
}
|
||||
|
||||
/// Return the [`EffectPoint`] for the given timestamp.
|
||||
///
|
||||
/// If `time` is before the first effect point, `None` is returned.
|
||||
#[inline]
|
||||
pub fn effect_point_at(&self, time: f64) -> Option<EffectPoint> {
|
||||
self.effect_points
|
||||
.binary_search_by(|probe| probe.time.partial_cmp(&time).unwrap_or(Ordering::Less))
|
||||
.map_or_else(|i| i.checked_sub(1), Some)
|
||||
.map(|i| self.effect_points[i])
|
||||
}
|
||||
|
||||
/// Convert a [`Beatmap`] of some mode into a different mode.
|
||||
///
|
||||
/// # Note
|
||||
/// - Since hitsounds are irrelevant for difficulty and performance calculations
|
||||
/// in osu!mania, the resulting map of a conversion to mania will not contain hitsounds.
|
||||
/// - To avoid having to clone the map for osu!catch conversions, the field `Beatmap::mode`
|
||||
/// will not be adjusted in a osu!catch-converted map.
|
||||
#[inline]
|
||||
pub fn convert_mode(&self, mode: GameMode) -> Cow<'_, Self> {
|
||||
if mode == self.mode {
|
||||
return Cow::Borrowed(self);
|
||||
}
|
||||
|
||||
match mode {
|
||||
GameMode::Osu | GameMode::Catch => Cow::Borrowed(self),
|
||||
GameMode::Taiko => Cow::Owned(self.convert_to_taiko()),
|
||||
GameMode::Mania => Cow::Owned(self.convert_to_mania()),
|
||||
}
|
||||
}
|
||||
|
||||
fn clone_without_hit_objects(&self, with_sounds: bool) -> Self {
|
||||
Self {
|
||||
mode: self.mode,
|
||||
version: self.version,
|
||||
n_circles: 0,
|
||||
n_sliders: 0,
|
||||
n_spinners: 0,
|
||||
ar: self.ar,
|
||||
od: self.od,
|
||||
cs: self.cs,
|
||||
hp: self.hp,
|
||||
slider_mult: self.slider_mult,
|
||||
tick_rate: self.tick_rate,
|
||||
hit_objects: Vec::with_capacity(self.hit_objects.len()),
|
||||
sounds: Vec::with_capacity((with_sounds as usize) * self.sounds.len()),
|
||||
timing_points: self.timing_points.clone(),
|
||||
difficulty_points: self.difficulty_points.clone(),
|
||||
effect_points: self.effect_points.clone(),
|
||||
stack_leniency: self.stack_leniency,
|
||||
breaks: self.breaks.clone(),
|
||||
creator: self.creator.clone(),
|
||||
beatmap_id: self.beatmap_id,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/// The mode of a beatmap.
|
||||
#[derive(Copy, Clone, Debug, Hash, PartialEq, Eq)]
|
||||
pub enum GameMode {
|
||||
/// osu!standard
|
||||
Osu = 0,
|
||||
/// osu!taiko
|
||||
Taiko = 1,
|
||||
/// osu!catch
|
||||
Catch = 2,
|
||||
/// osu!mania
|
||||
Mania = 3,
|
||||
}
|
||||
|
||||
impl Default for GameMode {
|
||||
#[inline]
|
||||
fn default() -> Self {
|
||||
Self::Osu
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
use crate::parse::Pos2;
|
||||
|
||||
use super::fruit_or_juice::FruitParams;
|
||||
|
||||
const PLAYFIELD_WIDTH: f32 = 512.0;
|
||||
const BASE_SPEED: f64 = 1.0;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct CatchObject {
|
||||
pub(crate) pos: f32,
|
||||
pub(crate) time: f64,
|
||||
|
||||
pub(crate) hyper_dash: bool,
|
||||
pub(crate) hyper_dist: f32,
|
||||
}
|
||||
|
||||
impl CatchObject {
|
||||
#[inline]
|
||||
pub(crate) fn new((pos, time): (Pos2, f64)) -> Self {
|
||||
Self {
|
||||
pos: pos.x,
|
||||
time,
|
||||
hyper_dash: false,
|
||||
hyper_dist: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn with_hr(mut self, params: &mut FruitParams<'_>) -> Self {
|
||||
let mut offset_pos = self.pos;
|
||||
let time_diff = self.time - params.last_time;
|
||||
|
||||
if let Some(last_pos_ref) = params.last_pos.filter(|_| time_diff <= 1000.0) {
|
||||
let pos_diff = offset_pos - last_pos_ref;
|
||||
|
||||
if pos_diff.abs() > f32::EPSILON {
|
||||
if pos_diff.abs() < (time_diff as f32 / 3.0).floor() {
|
||||
if pos_diff > 0.0 {
|
||||
if offset_pos + pos_diff < PLAYFIELD_WIDTH {
|
||||
offset_pos += pos_diff;
|
||||
}
|
||||
} else if offset_pos + pos_diff > 0.0 {
|
||||
offset_pos += pos_diff;
|
||||
}
|
||||
}
|
||||
|
||||
params.last_pos.replace(offset_pos);
|
||||
params.last_time = self.time;
|
||||
}
|
||||
|
||||
self.pos = offset_pos;
|
||||
} else {
|
||||
params.last_pos.replace(offset_pos);
|
||||
params.last_time = self.time;
|
||||
}
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn init_hyper_dash(
|
||||
&mut self,
|
||||
half_catcher_width: f64,
|
||||
next: &CatchObject,
|
||||
last_direction: &mut i8,
|
||||
last_excess: &mut f64,
|
||||
) {
|
||||
let next_x = next.pos;
|
||||
let curr_x = self.pos;
|
||||
|
||||
let this_direction = (next_x > curr_x) as i8 * 2 - 1;
|
||||
let time_to_next = next.time - self.time - 1000.0 / 60.0 / 4.0;
|
||||
|
||||
let sub = if *last_direction == this_direction {
|
||||
*last_excess
|
||||
} else {
|
||||
half_catcher_width
|
||||
};
|
||||
|
||||
let dist_to_next = (next_x - curr_x).abs() as f64 - sub;
|
||||
let hyper_dist = (time_to_next * BASE_SPEED - dist_to_next) as f32;
|
||||
|
||||
if hyper_dist < 0.0 {
|
||||
self.hyper_dash = true;
|
||||
*last_excess = half_catcher_width;
|
||||
} else {
|
||||
self.hyper_dist = hyper_dist;
|
||||
*last_excess = (hyper_dist as f64).max(0.0).min(half_catcher_width);
|
||||
}
|
||||
|
||||
*last_direction = this_direction;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
use super::CatchObject;
|
||||
|
||||
const NORMALIZED_HITOBJECT_RADIUS: f32 = 41.0;
|
||||
|
||||
pub(crate) struct DifficultyObject<'o> {
|
||||
pub(crate) base: &'o CatchObject,
|
||||
pub(crate) last: &'o CatchObject,
|
||||
|
||||
pub(crate) delta: f64,
|
||||
pub(crate) start_time: f64,
|
||||
|
||||
pub(crate) normalized_pos: f32,
|
||||
pub(crate) last_normalized_pos: f32,
|
||||
|
||||
pub(crate) strain_time: f64,
|
||||
pub(crate) clock_rate: f64,
|
||||
}
|
||||
|
||||
impl<'o> DifficultyObject<'o> {
|
||||
#[inline]
|
||||
pub(crate) fn new(
|
||||
base: &'o CatchObject,
|
||||
last: &'o CatchObject,
|
||||
half_catcher_width: f32,
|
||||
clock_rate: f64,
|
||||
) -> Self {
|
||||
let delta = (base.time - last.time) / clock_rate;
|
||||
let start_time = base.time / clock_rate;
|
||||
let strain_time = delta.max(40.0);
|
||||
|
||||
let scaling_factor = NORMALIZED_HITOBJECT_RADIUS / half_catcher_width;
|
||||
let normalized_pos = base.pos * scaling_factor;
|
||||
let last_normalized_pos = last.pos * scaling_factor;
|
||||
|
||||
Self {
|
||||
base,
|
||||
last,
|
||||
delta,
|
||||
start_time,
|
||||
normalized_pos,
|
||||
last_normalized_pos,
|
||||
strain_time,
|
||||
clock_rate,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
use std::{iter::Map, vec::IntoIter};
|
||||
|
||||
use crate::{
|
||||
curve::{Curve, CurveBuffers},
|
||||
parse::{HitObject, HitObjectKind, Pos2},
|
||||
Beatmap,
|
||||
};
|
||||
|
||||
use super::{catch_object::CatchObject, CatchDifficultyAttributes};
|
||||
|
||||
const LEGACY_LAST_TICK_OFFSET: f64 = 36.0;
|
||||
const BASE_SCORING_DISTANCE: f64 = 100.0;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct FruitParams<'a> {
|
||||
pub(crate) attributes: CatchDifficultyAttributes,
|
||||
pub(crate) curve_bufs: CurveBuffers,
|
||||
pub(crate) last_pos: Option<f32>,
|
||||
pub(crate) last_time: f64,
|
||||
pub(crate) map: &'a Beatmap,
|
||||
pub(crate) ticks: Vec<(Pos2, f64)>,
|
||||
pub(crate) with_hr: bool,
|
||||
}
|
||||
|
||||
type JuiceStream = Map<IntoIter<(Pos2, f64)>, fn((Pos2, f64)) -> CatchObject>;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) enum FruitOrJuice {
|
||||
Fruit(Option<CatchObject>),
|
||||
Juice(JuiceStream),
|
||||
}
|
||||
|
||||
impl FruitOrJuice {
|
||||
pub(crate) fn new(h: &HitObject, params: &mut FruitParams<'_>) -> Option<Self> {
|
||||
match &h.kind {
|
||||
HitObjectKind::Circle => {
|
||||
let mut h = CatchObject::new((h.pos, h.start_time));
|
||||
|
||||
if params.with_hr {
|
||||
h = h.with_hr(params);
|
||||
}
|
||||
|
||||
params.attributes.n_fruits += 1;
|
||||
|
||||
Some(FruitOrJuice::Fruit(Some(h)))
|
||||
}
|
||||
HitObjectKind::Slider {
|
||||
pixel_len,
|
||||
repeats,
|
||||
control_points,
|
||||
..
|
||||
} => {
|
||||
// HR business
|
||||
params.last_pos = Some(h.pos.x + control_points[control_points.len() - 1].pos.x);
|
||||
params.last_time = h.start_time;
|
||||
|
||||
let timing_point = params.map.timing_point_at(h.start_time);
|
||||
|
||||
let difficulty_point = params
|
||||
.map
|
||||
.difficulty_point_at(h.start_time)
|
||||
.unwrap_or_default();
|
||||
|
||||
let vel_factor =
|
||||
BASE_SCORING_DISTANCE * params.map.slider_mult / timing_point.beat_len;
|
||||
let tick_dist_factor =
|
||||
BASE_SCORING_DISTANCE * params.map.slider_mult / params.map.tick_rate;
|
||||
|
||||
let vel = vel_factor * difficulty_point.slider_vel;
|
||||
|
||||
let mut tick_dist = tick_dist_factor * difficulty_point.slider_vel;
|
||||
|
||||
let span_count = (*repeats + 1) as f64;
|
||||
|
||||
// Build the curve w.r.t. the control points
|
||||
let curve = Curve::new(control_points, *pixel_len, &mut params.curve_bufs);
|
||||
|
||||
let total_duration = span_count * curve.dist() / vel;
|
||||
let span_duration = total_duration / span_count;
|
||||
|
||||
// * A very lenient maximum length of a slider for ticks to be generated.
|
||||
// * This exists for edge cases such as /b/1573664 where the beatmap has
|
||||
// * been edited by the user, and should never be reached in normal usage.
|
||||
let max_len = 100_000.0;
|
||||
|
||||
let len = curve.dist().min(max_len);
|
||||
tick_dist = tick_dist.clamp(0.0, len);
|
||||
let min_dist_from_end = vel * 10.0;
|
||||
|
||||
let mut curr_dist = tick_dist;
|
||||
let pixel_len = pixel_len.unwrap_or(0.0);
|
||||
|
||||
let target = pixel_len - tick_dist / 8.0;
|
||||
|
||||
let mut slider_objects = vec![(h.pos, h.start_time)];
|
||||
|
||||
if tick_dist > 0.0 {
|
||||
params.ticks.reserve((target / tick_dist) as usize);
|
||||
|
||||
// Tick of the first span
|
||||
while curr_dist < len - min_dist_from_end {
|
||||
let progress = curr_dist / len;
|
||||
let pos = h.pos + curve.position_at(progress);
|
||||
let time = h.start_time + progress * span_duration;
|
||||
params.ticks.push((pos, time));
|
||||
curr_dist += tick_dist;
|
||||
}
|
||||
|
||||
if pixel_len > 0.0 {
|
||||
let time_add = total_duration * tick_dist / (pixel_len * span_count);
|
||||
|
||||
params.attributes.n_tiny_droplets += tiny_droplet_count(
|
||||
h.start_time,
|
||||
time_add,
|
||||
total_duration,
|
||||
span_count as usize,
|
||||
¶ms.ticks,
|
||||
);
|
||||
}
|
||||
|
||||
slider_objects.reserve(span_count as usize * (params.ticks.len()));
|
||||
|
||||
// Other spans
|
||||
if *repeats == 0 {
|
||||
slider_objects.append(&mut params.ticks); // automatically empties buffer for next slider
|
||||
} else {
|
||||
slider_objects.extend(¶ms.ticks);
|
||||
|
||||
for span_idx in 1..=*repeats {
|
||||
let progress = (span_idx % 2 == 1) as u8 as f64;
|
||||
let pos = h.pos + curve.position_at(progress);
|
||||
let time_offset = span_duration * span_idx as f64;
|
||||
|
||||
// Reverse tick
|
||||
slider_objects.push((pos, h.start_time + time_offset));
|
||||
|
||||
// Actual ticks
|
||||
if span_idx & 1 == 1 {
|
||||
let tick_iter = params
|
||||
.ticks
|
||||
.iter()
|
||||
.rev()
|
||||
.zip(params.ticks.iter())
|
||||
.map(|((pos, _), (_, time))| (*pos, *time + time_offset));
|
||||
|
||||
slider_objects.extend(tick_iter);
|
||||
} else {
|
||||
let tick_iter = params
|
||||
.ticks
|
||||
.iter()
|
||||
.map(|(pos, time)| (*pos, *time + time_offset));
|
||||
|
||||
slider_objects.extend(tick_iter);
|
||||
}
|
||||
}
|
||||
|
||||
params.ticks.clear();
|
||||
}
|
||||
}
|
||||
|
||||
// Slider tail
|
||||
let progress = (*repeats % 2 == 0) as u8 as f64;
|
||||
let pos = h.pos + curve.position_at(progress);
|
||||
slider_objects.push((pos, h.start_time + total_duration));
|
||||
|
||||
let new_fruits = 2 + (tick_dist > 0.0) as usize * *repeats;
|
||||
params.attributes.n_fruits += new_fruits;
|
||||
params.attributes.n_droplets += slider_objects.len() - new_fruits;
|
||||
|
||||
let iter = slider_objects
|
||||
.into_iter()
|
||||
.map(CatchObject::new as fn(_) -> _);
|
||||
|
||||
Some(FruitOrJuice::Juice(iter))
|
||||
}
|
||||
HitObjectKind::Spinner { .. } | HitObjectKind::Hold { .. } => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Iterator for FruitOrJuice {
|
||||
type Item = CatchObject;
|
||||
|
||||
#[inline]
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
match self {
|
||||
Self::Fruit(fruit) => fruit.take(),
|
||||
Self::Juice(slider) => slider.next(),
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn size_hint(&self) -> (usize, Option<usize>) {
|
||||
let len = self.len();
|
||||
|
||||
(len, Some(len))
|
||||
}
|
||||
}
|
||||
|
||||
impl ExactSizeIterator for FruitOrJuice {
|
||||
#[inline]
|
||||
fn len(&self) -> usize {
|
||||
match self {
|
||||
FruitOrJuice::Fruit(Some(_)) => 1,
|
||||
FruitOrJuice::Fruit(None) => 0,
|
||||
FruitOrJuice::Juice(slider) => slider.len(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// BUG: Sometimes there are off-by-one errors,
|
||||
// presumably caused by floating point inaccuracies
|
||||
fn tiny_droplet_count(
|
||||
start_time: f64,
|
||||
time_between_ticks: f64,
|
||||
duration: f64,
|
||||
span_count: usize,
|
||||
ticks: &[(Pos2, f64)],
|
||||
) -> usize {
|
||||
// tiny droplets preceeding a _tick_
|
||||
let per_tick = if !ticks.is_empty() && time_between_ticks > 80.0 {
|
||||
let time_between_tiny = shrink_down(time_between_ticks);
|
||||
|
||||
// add a little for floating point inaccuracies
|
||||
let start = time_between_tiny + 0.001;
|
||||
|
||||
count_iterations(start, time_between_tiny, time_between_ticks)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
// tiny droplets preceeding a _reverse_
|
||||
let last = ticks.last().map_or(start_time, |(_, last)| *last);
|
||||
let repeat_time = start_time + duration / span_count as f64;
|
||||
let since_last_tick = repeat_time - last;
|
||||
|
||||
let span_last_section = if since_last_tick > 80.0 {
|
||||
let time_between_tiny = shrink_down(since_last_tick);
|
||||
|
||||
count_iterations(time_between_tiny, time_between_tiny, since_last_tick)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
// tiny droplets preceeding the slider tail
|
||||
// necessary to handle distinctly because of the legacy last tick
|
||||
let last = ticks.last().map_or(start_time, |(_, last)| *last);
|
||||
let end_time = start_time + duration / span_count as f64 - LEGACY_LAST_TICK_OFFSET;
|
||||
let since_last_tick = end_time - last;
|
||||
|
||||
let last_section = if since_last_tick > 80.0 {
|
||||
let time_between_tiny = shrink_down(since_last_tick);
|
||||
|
||||
count_iterations(time_between_tiny, time_between_tiny, since_last_tick)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
// Combine tiny droplets counts
|
||||
per_tick * ticks.len() * span_count
|
||||
+ span_last_section * (span_count.saturating_sub(1))
|
||||
+ last_section
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn shrink_down(mut val: f64) -> f64 {
|
||||
while val > 100.0 {
|
||||
val /= 2.0;
|
||||
}
|
||||
|
||||
val
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn count_iterations(mut start: f64, step: f64, end: f64) -> usize {
|
||||
let mut count = 0;
|
||||
|
||||
while start < end {
|
||||
count += 1;
|
||||
start += step;
|
||||
}
|
||||
|
||||
count
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
use std::{iter, slice::Iter};
|
||||
|
||||
use crate::{
|
||||
catch::{difficulty_object::DifficultyObject, SECTION_LENGTH, STAR_SCALING_FACTOR},
|
||||
curve::CurveBuffers,
|
||||
parse::{HitObject, Pos2},
|
||||
Beatmap, Mods,
|
||||
};
|
||||
|
||||
use super::{
|
||||
calculate_catch_width,
|
||||
catch_object::CatchObject,
|
||||
fruit_or_juice::{FruitOrJuice, FruitParams},
|
||||
movement::Movement,
|
||||
CatchDifficultyAttributes, ALLOWED_CATCH_RANGE,
|
||||
};
|
||||
|
||||
/// Gradually calculate the difficulty attributes of an osu!catch map.
|
||||
///
|
||||
/// Note that this struct implements [`Iterator`](std::iter::Iterator).
|
||||
/// On every call of [`Iterator::next`](std::iter::Iterator::next), the map's next fruit or droplet
|
||||
/// will be processed and the [`CatchDifficultyAttributes`] will be updated and returned.
|
||||
///
|
||||
/// Note that it does not return attributes after a tiny droplet. Only for fruits and droplets.
|
||||
///
|
||||
/// If you want to calculate performance attributes, use
|
||||
/// [`CatchGradualPerformanceAttributes`](crate::catch::CatchGradualPerformanceAttributes) instead.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use rosu_pp::{Beatmap, catch::CatchGradualDifficultyAttributes};
|
||||
///
|
||||
/// # /*
|
||||
/// let map: Beatmap = ...
|
||||
/// # */
|
||||
/// # let map = Beatmap::default();
|
||||
///
|
||||
/// let mods = 64; // DT
|
||||
/// let mut iter = CatchGradualDifficultyAttributes::new(&map, mods);
|
||||
///
|
||||
/// let attrs1 = iter.next(); // the difficulty of the map after the first hit object
|
||||
/// let attrs2 = iter.next(); // after the second hit object
|
||||
///
|
||||
/// // Remaining hit objects
|
||||
/// for difficulty in iter {
|
||||
/// // ...
|
||||
/// }
|
||||
/// ```
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct CatchGradualDifficultyAttributes<'map> {
|
||||
pub(crate) idx: usize,
|
||||
clock_rate: f64,
|
||||
hit_objects: CatchObjectIter<'map>,
|
||||
movement: Movement,
|
||||
prev: CatchObject,
|
||||
half_catcher_width: f64,
|
||||
last_direction: i8,
|
||||
last_excess: f64,
|
||||
curr_section_end: f64,
|
||||
strain_peak_buf: Vec<f64>,
|
||||
}
|
||||
|
||||
impl<'map> CatchGradualDifficultyAttributes<'map> {
|
||||
/// Create a new difficulty attributes iterator for osu!catch maps.
|
||||
pub fn new(map: &'map Beatmap, mods: u32) -> Self {
|
||||
let map_attributes = map.attributes().mods(mods).build();
|
||||
|
||||
let attributes = CatchDifficultyAttributes {
|
||||
ar: map_attributes.ar,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let hit_objects = CatchObjectIter::new(map, mods, attributes);
|
||||
|
||||
let half_catcher_width =
|
||||
(calculate_catch_width(map_attributes.cs as f32) / 2.0 / ALLOWED_CATCH_RANGE) as f64;
|
||||
let last_direction = 0;
|
||||
let last_excess = half_catcher_width;
|
||||
|
||||
let movement = Movement::new(map_attributes.cs as f32);
|
||||
let prev = CatchObject::new((Pos2::zero(), 0.0));
|
||||
|
||||
Self {
|
||||
idx: 0,
|
||||
clock_rate: mods.clock_rate(),
|
||||
hit_objects,
|
||||
movement,
|
||||
prev,
|
||||
half_catcher_width,
|
||||
last_direction,
|
||||
last_excess,
|
||||
curr_section_end: 0.0,
|
||||
strain_peak_buf: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn init_hyper_dash(&mut self, next: &CatchObject) {
|
||||
self.prev.init_hyper_dash(
|
||||
self.half_catcher_width,
|
||||
next,
|
||||
&mut self.last_direction,
|
||||
&mut self.last_excess,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
impl Iterator for CatchGradualDifficultyAttributes<'_> {
|
||||
type Item = CatchDifficultyAttributes;
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
let curr = self.hit_objects.next()?;
|
||||
self.idx += 1;
|
||||
|
||||
if self.idx == 1 {
|
||||
self.prev = curr;
|
||||
|
||||
return Some(self.hit_objects.attributes());
|
||||
}
|
||||
|
||||
self.init_hyper_dash(&curr);
|
||||
|
||||
let h = DifficultyObject::new(
|
||||
&curr,
|
||||
&self.prev,
|
||||
self.movement.half_catcher_width,
|
||||
self.clock_rate,
|
||||
);
|
||||
|
||||
if self.idx == 2 {
|
||||
self.curr_section_end =
|
||||
(h.base.time / self.clock_rate / SECTION_LENGTH).ceil() * SECTION_LENGTH;
|
||||
} else {
|
||||
let base_time = h.base.time / self.clock_rate;
|
||||
|
||||
while base_time > self.curr_section_end {
|
||||
self.movement.save_current_peak();
|
||||
self.movement.start_new_section_from(self.curr_section_end);
|
||||
self.curr_section_end += SECTION_LENGTH;
|
||||
}
|
||||
}
|
||||
|
||||
self.movement.process(&h);
|
||||
self.prev = curr;
|
||||
|
||||
let len = self.movement.strain_peaks.len();
|
||||
let missing = len + 1 - self.strain_peak_buf.len();
|
||||
self.strain_peak_buf.extend(iter::repeat(0.0).take(missing));
|
||||
|
||||
self.strain_peak_buf[..len].copy_from_slice(&self.movement.strain_peaks);
|
||||
|
||||
if let Some(last) = self.strain_peak_buf.last_mut() {
|
||||
*last = self.movement.curr_section_peak;
|
||||
}
|
||||
|
||||
let mut attributes = self.hit_objects.attributes();
|
||||
attributes.stars =
|
||||
Movement::difficulty_value(&mut self.strain_peak_buf).sqrt() * STAR_SCALING_FACTOR;
|
||||
|
||||
Some(attributes)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct CatchObjectIter<'map> {
|
||||
last_object: Option<FruitOrJuice>,
|
||||
hit_objects: Iter<'map, HitObject>,
|
||||
params: FruitParams<'map>,
|
||||
}
|
||||
|
||||
impl<'map> CatchObjectIter<'map> {
|
||||
fn new(map: &'map Beatmap, mods: impl Mods, attributes: CatchDifficultyAttributes) -> Self {
|
||||
let params = FruitParams {
|
||||
attributes,
|
||||
curve_bufs: CurveBuffers::default(),
|
||||
last_pos: None,
|
||||
last_time: 0.0,
|
||||
map,
|
||||
ticks: Vec::new(),
|
||||
with_hr: mods.hr(),
|
||||
};
|
||||
|
||||
Self {
|
||||
last_object: None,
|
||||
hit_objects: map.hit_objects.iter(),
|
||||
params,
|
||||
}
|
||||
}
|
||||
|
||||
fn attributes(&self) -> CatchDifficultyAttributes {
|
||||
self.params.attributes.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Iterator for CatchObjectIter<'_> {
|
||||
type Item = CatchObject;
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
if let Some(h) = self.last_object.as_mut().and_then(Iterator::next) {
|
||||
return Some(h);
|
||||
}
|
||||
|
||||
for h in &mut self.hit_objects {
|
||||
if let Some(h) = FruitOrJuice::new(h, &mut self.params) {
|
||||
return self.last_object.insert(h).next();
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
use crate::{Beatmap, CatchPP};
|
||||
|
||||
use super::{CatchGradualDifficultyAttributes, CatchPerformanceAttributes};
|
||||
|
||||
/// Aggregation for a score's current state i.e. what was the
|
||||
/// maximum combo so far and what are the current hitresults.
|
||||
///
|
||||
/// This struct is used for [`CatchGradualPerformanceAttributes`].
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq)]
|
||||
pub struct CatchScoreState {
|
||||
/// Maximum combo that the score has had so far.
|
||||
/// **Not** the maximum possible combo of the map so far.
|
||||
///
|
||||
/// Note that only fruits and droplets are considered for osu!catch combo.
|
||||
pub max_combo: usize,
|
||||
/// Amount of current fruits (300s).
|
||||
pub n_fruits: usize,
|
||||
/// Amount of current droplets (100s).
|
||||
pub n_droplets: usize,
|
||||
/// Amount of current tiny droplets (50s).
|
||||
pub n_tiny_droplets: usize,
|
||||
/// Amount of current tiny droplet misses (katus).
|
||||
pub n_tiny_droplet_misses: usize,
|
||||
/// Amount of current misses (fruits and droplets).
|
||||
pub n_misses: usize,
|
||||
}
|
||||
|
||||
impl CatchScoreState {
|
||||
/// Create a new empty score state.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Gradually calculate the performance attributes of an osu!catch map.
|
||||
///
|
||||
/// After each hit object you can call
|
||||
/// [`process_next_object`](`CatchGradualPerformanceAttributes::process_next_object`)
|
||||
/// and it will return the resulting current [`CatchPerformanceAttributes`].
|
||||
/// To process multiple objects at once, use
|
||||
/// [`process_next_n_objects`](`CatchGradualPerformanceAttributes::process_next_n_objects`) instead.
|
||||
///
|
||||
/// Both methods require a [`CatchScoreState`] that contains the current
|
||||
/// hitresults as well as the maximum combo so far.
|
||||
///
|
||||
/// Note that neither hits nor misses of tiny droplets require
|
||||
/// to be processed. Only fruits and droplets do.
|
||||
///
|
||||
/// If you only want to calculate difficulty attributes use
|
||||
/// [`CatchGradualDifficultyAttributes`](crate::catch::CatchGradualDifficultyAttributes) instead.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use rosu_pp::{Beatmap, catch::{CatchGradualPerformanceAttributes, CatchScoreState}};
|
||||
///
|
||||
/// # /*
|
||||
/// let map: Beatmap = ...
|
||||
/// # */
|
||||
/// # let map = Beatmap::default();
|
||||
///
|
||||
/// let mods = 64; // DT
|
||||
/// let mut gradual_perf = CatchGradualPerformanceAttributes::new(&map, mods);
|
||||
/// let mut state = CatchScoreState::new(); // empty state, everything is on 0.
|
||||
///
|
||||
/// // The first 10 hitresults are only fruits
|
||||
/// for _ in 0..10 {
|
||||
/// state.n_fruits += 1;
|
||||
/// state.max_combo += 1;
|
||||
///
|
||||
/// # /*
|
||||
/// let performance = gradual_perf.process_next_object(state.clone()).unwrap();
|
||||
/// println!("PP: {}", performance.pp);
|
||||
/// # */
|
||||
/// # let _ = gradual_perf.process_next_object(state.clone());
|
||||
/// }
|
||||
///
|
||||
/// // Then comes a miss.
|
||||
/// // Note that state's max combo won't be incremented for
|
||||
/// // the next few objects because the combo is reset.
|
||||
/// state.n_misses += 1;
|
||||
/// # /*
|
||||
/// let performance = gradual_perf.process_next_object(state.clone()).unwrap();
|
||||
/// println!("PP: {}", performance.pp);
|
||||
/// # */
|
||||
/// # let _ = gradual_perf.process_next_object(state.clone());
|
||||
///
|
||||
/// // The next 10 objects will be a mixture of fruits and droplets.
|
||||
/// // Notice how tiny droplets from sliders do not count as hit objects
|
||||
/// // that require processing. Only fruits and droplets do.
|
||||
/// // Also notice how all 10 objects will be processed in one go.
|
||||
/// state.n_fruits += 4;
|
||||
/// state.n_droplets += 6;
|
||||
/// state.n_tiny_droplets += 12;
|
||||
/// # /*
|
||||
/// let performance = gradual_perf.process_next_n_objects(state.clone(), 10).unwrap();
|
||||
/// println!("PP: {}", performance.pp);
|
||||
/// # */
|
||||
/// # let _ = gradual_perf.process_next_n_objects(state.clone(), 10);
|
||||
///
|
||||
/// // Now comes another fruit. Note that the max combo gets incremented again.
|
||||
/// state.n_fruits += 1;
|
||||
/// state.max_combo += 1;
|
||||
/// # /*
|
||||
/// let performance = gradual_perf.process_next_object(state.clone()).unwrap();
|
||||
/// println!("PP: {}", performance.pp);
|
||||
/// # */
|
||||
/// # let _ = gradual_perf.process_next_object(state.clone());
|
||||
///
|
||||
/// // Skip to the end
|
||||
/// # /*
|
||||
/// state.max_combo = ...
|
||||
/// state.n_fruits = ...
|
||||
/// state.n_droplets = ...
|
||||
/// state.n_tiny_droplets = ...
|
||||
/// state.n_tiny_droplet_misses = ...
|
||||
/// state.n_misses = ...
|
||||
/// let final_performance = gradual_perf.process_next_n_objects(state.clone(), usize::MAX).unwrap();
|
||||
/// println!("PP: {}", performance.pp);
|
||||
/// # */
|
||||
/// # let _ = gradual_perf.process_next_n_objects(state.clone(), usize::MAX);
|
||||
///
|
||||
/// // Once the final performance was calculated,
|
||||
/// // attempting to process further objects will return `None`.
|
||||
/// assert!(gradual_perf.process_next_object(state).is_none());
|
||||
/// ```
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct CatchGradualPerformanceAttributes<'map> {
|
||||
difficulty: CatchGradualDifficultyAttributes<'map>,
|
||||
performance: CatchPP<'map>,
|
||||
}
|
||||
|
||||
impl<'map> CatchGradualPerformanceAttributes<'map> {
|
||||
/// Create a new gradual performance calculator for osu!standard maps.
|
||||
pub fn new(map: &'map Beatmap, mods: u32) -> Self {
|
||||
let difficulty = CatchGradualDifficultyAttributes::new(map, mods);
|
||||
let performance = CatchPP::new(map).mods(mods).passed_objects(0);
|
||||
|
||||
Self {
|
||||
difficulty,
|
||||
performance,
|
||||
}
|
||||
}
|
||||
|
||||
/// Process the next hit object and calculate the
|
||||
/// performance attributes for the resulting score state.
|
||||
///
|
||||
/// Note that neither hits nor misses of tiny droplets require
|
||||
/// to be processed. Only fruits and droplets do.
|
||||
pub fn process_next_object(
|
||||
&mut self,
|
||||
state: CatchScoreState,
|
||||
) -> Option<CatchPerformanceAttributes> {
|
||||
self.process_next_n_objects(state, 1)
|
||||
}
|
||||
|
||||
/// Same as [`process_next_object`](`CatchGradualPerformanceAttributes::process_next_object`)
|
||||
/// but instead of processing only one object it process `n` many.
|
||||
///
|
||||
/// If `n` is 0 it will be considered as 1.
|
||||
/// If there are still objects to be processed but `n` is larger than the amount
|
||||
/// of remaining objects, `n` will be considered as the amount of remaining objects.
|
||||
pub fn process_next_n_objects(
|
||||
&mut self,
|
||||
state: CatchScoreState,
|
||||
n: usize,
|
||||
) -> Option<CatchPerformanceAttributes> {
|
||||
let mut difficulty = None;
|
||||
|
||||
for _ in 0..n.max(1) {
|
||||
match self.difficulty.next() {
|
||||
Some(attrs) => difficulty = Some(attrs),
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
|
||||
let difficulty = difficulty?;
|
||||
|
||||
let performance = self
|
||||
.performance
|
||||
.clone()
|
||||
.attributes(difficulty)
|
||||
.state(state)
|
||||
.passed_objects(self.difficulty.idx)
|
||||
.calculate();
|
||||
|
||||
Some(performance)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
mod catch_object;
|
||||
mod difficulty_object;
|
||||
mod fruit_or_juice;
|
||||
mod gradual_difficulty;
|
||||
mod gradual_performance;
|
||||
mod movement;
|
||||
mod pp;
|
||||
|
||||
use catch_object::CatchObject;
|
||||
use difficulty_object::DifficultyObject;
|
||||
use fruit_or_juice::FruitOrJuice;
|
||||
pub use gradual_difficulty::*;
|
||||
pub use gradual_performance::*;
|
||||
use movement::Movement;
|
||||
pub use pp::*;
|
||||
|
||||
use crate::{catch::fruit_or_juice::FruitParams, curve::CurveBuffers, Beatmap, Mods, OsuStars};
|
||||
|
||||
const SECTION_LENGTH: f64 = 750.0;
|
||||
const STAR_SCALING_FACTOR: f64 = 0.153;
|
||||
|
||||
const ALLOWED_CATCH_RANGE: f32 = 0.8;
|
||||
const CATCHER_SIZE: f32 = 106.75;
|
||||
|
||||
/// Difficulty calculator on osu!catch maps.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use rosu_pp::{CatchStars, Beatmap};
|
||||
///
|
||||
/// # /*
|
||||
/// let map: Beatmap = ...
|
||||
/// # */
|
||||
/// # let map = Beatmap::default();
|
||||
///
|
||||
/// let difficulty_attrs = CatchStars::new(&map)
|
||||
/// .mods(8 + 64) // HDDT
|
||||
/// .calculate();
|
||||
///
|
||||
/// println!("Stars: {}", difficulty_attrs.stars);
|
||||
/// ```
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct CatchStars<'map> {
|
||||
map: &'map Beatmap,
|
||||
mods: u32,
|
||||
passed_objects: Option<usize>,
|
||||
clock_rate: Option<f64>,
|
||||
}
|
||||
|
||||
impl<'map> CatchStars<'map> {
|
||||
/// Create a new difficulty calculator for osu!catch maps.
|
||||
#[inline]
|
||||
pub fn new(map: &'map Beatmap) -> Self {
|
||||
Self {
|
||||
map,
|
||||
mods: 0,
|
||||
passed_objects: None,
|
||||
clock_rate: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
}
|
||||
|
||||
/// Amount of passed objects for partial plays, e.g. a fail.
|
||||
///
|
||||
/// If you want to calculate the difficulty after every few objects, instead of
|
||||
/// using [`CatchStars`] multiple times with different `passed_objects`, you should use
|
||||
/// [`CatchGradualDifficultyAttributes`](crate::catch::CatchGradualDifficultyAttributes).
|
||||
#[inline]
|
||||
pub fn passed_objects(mut self, passed_objects: usize) -> Self {
|
||||
self.passed_objects = Some(passed_objects);
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
/// Adjust the clock rate used in the calculation.
|
||||
/// If none is specified, it will take the clock rate based on the mods
|
||||
/// i.e. 1.5 for DT, 0.75 for HT and 1.0 otherwise.
|
||||
#[inline]
|
||||
pub fn clock_rate(mut self, clock_rate: f64) -> Self {
|
||||
self.clock_rate = Some(clock_rate);
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
/// Calculate all difficulty related values, including stars.
|
||||
#[inline]
|
||||
pub fn calculate(self) -> CatchDifficultyAttributes {
|
||||
let (mut movement, mut attributes) = calculate_movement(self);
|
||||
attributes.stars =
|
||||
Movement::difficulty_value(&mut movement.strain_peaks).sqrt() * STAR_SCALING_FACTOR;
|
||||
|
||||
attributes
|
||||
}
|
||||
|
||||
/// Calculate the skill strains.
|
||||
///
|
||||
/// Suitable to plot the difficulty of a map over time.
|
||||
#[inline]
|
||||
pub fn strains(self) -> CatchStrains {
|
||||
let (movement, _) = calculate_movement(self);
|
||||
|
||||
CatchStrains {
|
||||
section_len: SECTION_LENGTH,
|
||||
movement: movement.strain_peaks,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The result of calculating the strains on a osu!catch map.
|
||||
/// Suitable to plot the difficulty of a map over time.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct CatchStrains {
|
||||
/// Time in ms inbetween two strains.
|
||||
pub section_len: f64,
|
||||
/// Strain peaks of the movement skill.
|
||||
pub movement: Vec<f64>,
|
||||
}
|
||||
|
||||
impl CatchStrains {
|
||||
/// Returns the number of strain peaks per skill.
|
||||
#[inline]
|
||||
#[allow(clippy::len_without_is_empty)]
|
||||
pub fn len(&self) -> usize {
|
||||
self.movement.len()
|
||||
}
|
||||
}
|
||||
|
||||
fn calculate_movement(params: CatchStars<'_>) -> (Movement, CatchDifficultyAttributes) {
|
||||
let CatchStars {
|
||||
map,
|
||||
mods,
|
||||
passed_objects,
|
||||
clock_rate,
|
||||
} = params;
|
||||
|
||||
let take = passed_objects.unwrap_or(usize::MAX);
|
||||
let clock_rate = clock_rate.unwrap_or_else(|| mods.clock_rate());
|
||||
let map_attributes = map.attributes().mods(mods).clock_rate(clock_rate).build();
|
||||
|
||||
let attributes = CatchDifficultyAttributes {
|
||||
ar: map_attributes.ar,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let mut params = FruitParams {
|
||||
attributes,
|
||||
curve_bufs: CurveBuffers::default(),
|
||||
last_pos: None,
|
||||
last_time: 0.0,
|
||||
map,
|
||||
ticks: Vec::new(), // using the same buffer for all sliders
|
||||
with_hr: mods.hr(),
|
||||
};
|
||||
|
||||
// BUG: Incorrect object order on 2B maps that have fruits within sliders
|
||||
let mut hit_objects = map
|
||||
.hit_objects
|
||||
.iter()
|
||||
.filter_map(|h| FruitOrJuice::new(h, &mut params))
|
||||
.flatten()
|
||||
.take(take);
|
||||
|
||||
// Hyper dash business
|
||||
let half_catcher_width =
|
||||
(calculate_catch_width(map_attributes.cs as f32) / 2.0 / ALLOWED_CATCH_RANGE) as f64;
|
||||
let mut last_direction = 0;
|
||||
let mut last_excess = half_catcher_width;
|
||||
|
||||
// Strain business
|
||||
let mut movement = Movement::new(map_attributes.cs as f32);
|
||||
|
||||
let (mut prev, curr) = match (hit_objects.next(), hit_objects.next()) {
|
||||
(Some(prev), Some(curr)) => (prev, curr),
|
||||
(Some(_), None) | (None, None) => return (movement, params.attributes),
|
||||
(None, Some(_)) => unreachable!(),
|
||||
};
|
||||
|
||||
let mut curr_section_end = (curr.time / clock_rate / SECTION_LENGTH).ceil() * SECTION_LENGTH;
|
||||
|
||||
prev.init_hyper_dash(
|
||||
half_catcher_width,
|
||||
&curr,
|
||||
&mut last_direction,
|
||||
&mut last_excess,
|
||||
);
|
||||
|
||||
// Handle first object distinctly
|
||||
let h = DifficultyObject::new(&curr, &prev, movement.half_catcher_width, clock_rate);
|
||||
|
||||
movement.process(&h);
|
||||
prev = curr;
|
||||
|
||||
// Handle all other objects
|
||||
for curr in hit_objects {
|
||||
prev.init_hyper_dash(
|
||||
half_catcher_width,
|
||||
&curr,
|
||||
&mut last_direction,
|
||||
&mut last_excess,
|
||||
);
|
||||
|
||||
let h = DifficultyObject::new(&curr, &prev, movement.half_catcher_width, clock_rate);
|
||||
|
||||
let base_time = h.base.time / clock_rate;
|
||||
|
||||
while base_time > curr_section_end {
|
||||
movement.save_current_peak();
|
||||
movement.start_new_section_from(curr_section_end);
|
||||
curr_section_end += SECTION_LENGTH;
|
||||
}
|
||||
|
||||
movement.process(&h);
|
||||
prev = curr;
|
||||
}
|
||||
|
||||
movement.save_current_peak();
|
||||
|
||||
(movement, params.attributes)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn calculate_catch_width(cs: f32) -> f32 {
|
||||
let scale = 1.0 - 0.7 * (cs - 5.0) / 5.0;
|
||||
|
||||
CATCHER_SIZE * scale.abs() * ALLOWED_CATCH_RANGE
|
||||
}
|
||||
|
||||
/// The result of a difficulty calculation on an osu!catch map.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct CatchDifficultyAttributes {
|
||||
/// The final star rating
|
||||
pub stars: f64,
|
||||
/// The approach rate.
|
||||
pub ar: f64,
|
||||
/// The amount of fruits.
|
||||
pub n_fruits: usize,
|
||||
/// The amount of droplets.
|
||||
pub n_droplets: usize,
|
||||
/// The amount of tiny droplets.
|
||||
pub n_tiny_droplets: usize,
|
||||
}
|
||||
|
||||
impl CatchDifficultyAttributes {
|
||||
/// Return the maximum combo.
|
||||
#[inline]
|
||||
pub fn max_combo(&self) -> usize {
|
||||
self.n_fruits + self.n_droplets
|
||||
}
|
||||
}
|
||||
|
||||
/// The result of a performance calculation on an osu!catch map.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct CatchPerformanceAttributes {
|
||||
/// The difficulty attributes that were used for the performance calculation
|
||||
pub difficulty: CatchDifficultyAttributes,
|
||||
/// The final performance points.
|
||||
pub pp: f64,
|
||||
}
|
||||
|
||||
impl CatchPerformanceAttributes {
|
||||
/// Return the star value.
|
||||
#[inline]
|
||||
pub fn stars(&self) -> f64 {
|
||||
self.difficulty.stars
|
||||
}
|
||||
|
||||
/// Return the performance point value.
|
||||
#[inline]
|
||||
pub fn pp(&self) -> f64 {
|
||||
self.pp
|
||||
}
|
||||
|
||||
/// Return the maximum combo of the map.
|
||||
#[inline]
|
||||
pub fn max_combo(&self) -> usize {
|
||||
self.difficulty.max_combo()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<CatchPerformanceAttributes> for CatchDifficultyAttributes {
|
||||
#[inline]
|
||||
fn from(attributes: CatchPerformanceAttributes) -> Self {
|
||||
attributes.difficulty
|
||||
}
|
||||
}
|
||||
|
||||
impl<'map> From<OsuStars<'map>> for CatchStars<'map> {
|
||||
#[inline]
|
||||
fn from(osu: OsuStars<'map>) -> Self {
|
||||
let OsuStars {
|
||||
map,
|
||||
mods,
|
||||
passed_objects,
|
||||
clock_rate,
|
||||
} = osu;
|
||||
|
||||
Self {
|
||||
map,
|
||||
mods,
|
||||
passed_objects,
|
||||
clock_rate,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
use super::DifficultyObject;
|
||||
|
||||
use std::cmp::Ordering;
|
||||
|
||||
const ABSOLUTE_PLAYER_POSITIONING_ERROR: f32 = 16.0;
|
||||
const NORMALIZED_HITOBJECT_RADIUS: f32 = 41.0;
|
||||
const POSITION_EPSILON: f32 = NORMALIZED_HITOBJECT_RADIUS - ABSOLUTE_PLAYER_POSITIONING_ERROR;
|
||||
const DIRECTION_CHANGE_BONUS: f64 = 21.0;
|
||||
const SKILL_MULTIPLIER: f64 = 900.0;
|
||||
const STRAIN_DECAY_BASE: f64 = 0.2;
|
||||
const DECAY_WEIGHT: f64 = 0.94;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct Movement {
|
||||
pub(crate) half_catcher_width: f32,
|
||||
|
||||
last_player_position: Option<f32>,
|
||||
last_distance_moved: f32,
|
||||
last_strain_time: f64,
|
||||
|
||||
current_strain: f64,
|
||||
pub(crate) curr_section_peak: f64,
|
||||
|
||||
pub(crate) strain_peaks: Vec<f64>,
|
||||
prev_time: Option<f64>,
|
||||
}
|
||||
|
||||
impl Movement {
|
||||
#[inline]
|
||||
pub(crate) fn new(cs: f32) -> Self {
|
||||
let mut half_catcher_width = super::calculate_catch_width(cs) * 0.5;
|
||||
half_catcher_width *= 1.0 - ((cs - 5.5).max(0.0) * 0.0625);
|
||||
|
||||
Self {
|
||||
half_catcher_width,
|
||||
|
||||
last_player_position: None,
|
||||
last_distance_moved: 0.0,
|
||||
last_strain_time: 0.0,
|
||||
|
||||
current_strain: 1.0,
|
||||
curr_section_peak: 1.0,
|
||||
|
||||
strain_peaks: Vec::with_capacity(128),
|
||||
prev_time: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn save_current_peak(&mut self) {
|
||||
self.strain_peaks.push(self.curr_section_peak);
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn start_new_section_from(&mut self, time: f64) {
|
||||
self.curr_section_peak = self.peak_strain(time - self.prev_time.unwrap());
|
||||
}
|
||||
|
||||
pub(crate) fn process(&mut self, current: &DifficultyObject<'_>) {
|
||||
self.current_strain *= strain_decay(current.delta);
|
||||
self.current_strain += self.strain_value_of(current) * SKILL_MULTIPLIER;
|
||||
self.curr_section_peak = self.current_strain.max(self.curr_section_peak);
|
||||
self.prev_time.replace(current.start_time);
|
||||
}
|
||||
|
||||
pub(crate) fn difficulty_value(strain_peaks: &mut [f64]) -> f64 {
|
||||
let mut difficulty = 0.0;
|
||||
let mut weight = 1.0;
|
||||
|
||||
strain_peaks.sort_unstable_by(|a, b| b.partial_cmp(a).unwrap_or(Ordering::Equal));
|
||||
|
||||
for &strain in strain_peaks.iter() {
|
||||
difficulty += strain * weight;
|
||||
weight *= DECAY_WEIGHT;
|
||||
}
|
||||
|
||||
difficulty
|
||||
}
|
||||
|
||||
fn strain_value_of(&mut self, current: &DifficultyObject<'_>) -> f64 {
|
||||
let last_player_pos = self
|
||||
.last_player_position
|
||||
.unwrap_or(current.last_normalized_pos);
|
||||
|
||||
let mut pos = last_player_pos
|
||||
.max(current.normalized_pos - POSITION_EPSILON)
|
||||
.min(current.normalized_pos + POSITION_EPSILON);
|
||||
|
||||
let dist_moved = pos - last_player_pos;
|
||||
let weighted_strain_time = current.strain_time + 13.0 + (3.0 / current.clock_rate);
|
||||
|
||||
let mut dist_addition = (dist_moved.abs().powf(1.3) / 510.0) as f64;
|
||||
|
||||
if dist_moved.abs() > 0.1 {
|
||||
if self.last_distance_moved.abs() > 0.1
|
||||
&& dist_moved.signum() != self.last_distance_moved.signum()
|
||||
{
|
||||
let bonus_factor = (dist_moved.abs().min(50.0) / 50.0) as f64;
|
||||
let anti_flow_factor =
|
||||
(self.last_distance_moved.abs().min(70.0) / 70.0).max(0.38) as f64;
|
||||
|
||||
dist_addition += DIRECTION_CHANGE_BONUS / (self.last_strain_time + 16.0).sqrt()
|
||||
* bonus_factor
|
||||
* anti_flow_factor
|
||||
* (1.0 - (weighted_strain_time / 1000.0).powi(3)).max(0.0);
|
||||
}
|
||||
|
||||
dist_addition += (12.5 * dist_moved.abs().min(NORMALIZED_HITOBJECT_RADIUS * 2.0)
|
||||
/ (NORMALIZED_HITOBJECT_RADIUS * 6.0)) as f64
|
||||
/ weighted_strain_time.sqrt();
|
||||
}
|
||||
|
||||
let mut edge_dash_bonus = 0.0;
|
||||
|
||||
if current.last.hyper_dist <= 20.0 {
|
||||
if !current.last.hyper_dash {
|
||||
edge_dash_bonus += 5.7;
|
||||
} else {
|
||||
pos = current.normalized_pos;
|
||||
}
|
||||
|
||||
dist_addition *= 1.0
|
||||
+ edge_dash_bonus
|
||||
* ((20.0 - current.last.hyper_dist) / 20.0) as f64
|
||||
* ((current.strain_time * current.clock_rate).min(265.0) / 265.0).powf(1.5);
|
||||
}
|
||||
|
||||
self.last_player_position.replace(pos);
|
||||
self.last_distance_moved = dist_moved;
|
||||
self.last_strain_time = current.strain_time;
|
||||
|
||||
dist_addition / weighted_strain_time
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn peak_strain(&self, delta_time: f64) -> f64 {
|
||||
self.current_strain * strain_decay(delta_time)
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn strain_decay(ms: f64) -> f64 {
|
||||
STRAIN_DECAY_BASE.powf(ms / 1000.0)
|
||||
}
|
||||
+653
@@ -0,0 +1,653 @@
|
||||
use super::{CatchDifficultyAttributes, CatchPerformanceAttributes, CatchScoreState, CatchStars};
|
||||
use crate::{Beatmap, DifficultyAttributes, Mods, OsuPP, PerformanceAttributes};
|
||||
|
||||
/// Performance calculator on osu!catch maps.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use rosu_pp::{CatchPP, Beatmap};
|
||||
///
|
||||
/// # /*
|
||||
/// let map: Beatmap = ...
|
||||
/// # */
|
||||
/// # let map = Beatmap::default();
|
||||
///
|
||||
/// let pp_result = CatchPP::new(&map)
|
||||
/// .mods(8 + 64) // HDDT
|
||||
/// .combo(1234)
|
||||
/// .accuracy(98.5)
|
||||
/// .misses(1)
|
||||
/// .calculate();
|
||||
///
|
||||
/// println!("PP: {} | Stars: {}", pp_result.pp(), pp_result.stars());
|
||||
///
|
||||
/// let next_result = CatchPP::new(&map)
|
||||
/// .attributes(pp_result) // reusing previous results for performance
|
||||
/// .mods(8 + 64) // has to be the same to reuse attributes
|
||||
/// .accuracy(99.5)
|
||||
/// .calculate();
|
||||
///
|
||||
/// println!("PP: {} | Stars: {}", next_result.pp(), next_result.stars());
|
||||
/// ```
|
||||
#[derive(Clone, Debug)]
|
||||
#[allow(clippy::upper_case_acronyms)]
|
||||
pub struct CatchPP<'map> {
|
||||
map: &'map Beatmap,
|
||||
attributes: Option<CatchDifficultyAttributes>,
|
||||
mods: u32,
|
||||
combo: Option<usize>,
|
||||
|
||||
pub(crate) n_fruits: Option<usize>,
|
||||
pub(crate) n_droplets: Option<usize>,
|
||||
pub(crate) n_tiny_droplets: Option<usize>,
|
||||
pub(crate) n_tiny_droplet_misses: Option<usize>,
|
||||
pub(crate) n_misses: Option<usize>,
|
||||
passed_objects: Option<usize>,
|
||||
clock_rate: Option<f64>,
|
||||
}
|
||||
|
||||
impl<'map> CatchPP<'map> {
|
||||
/// Create a new performance calculator for osu!catch maps.
|
||||
#[inline]
|
||||
pub fn new(map: &'map Beatmap) -> Self {
|
||||
Self {
|
||||
map,
|
||||
attributes: None,
|
||||
mods: 0,
|
||||
combo: None,
|
||||
|
||||
n_fruits: None,
|
||||
n_droplets: None,
|
||||
n_tiny_droplets: None,
|
||||
n_tiny_droplet_misses: None,
|
||||
n_misses: None,
|
||||
passed_objects: None,
|
||||
clock_rate: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Provide the result of a previous difficulty or performance calculation.
|
||||
/// If you already calculated the attributes for the current map-mod combination,
|
||||
/// be sure to put them in here so that they don't have to be recalculated.
|
||||
#[inline]
|
||||
pub fn attributes(mut self, attributes: impl CatchAttributeProvider) -> 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 fruits of a play i.e. n300.
|
||||
#[inline]
|
||||
pub fn fruits(mut self, n_fruits: usize) -> Self {
|
||||
self.n_fruits.replace(n_fruits);
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
/// Specify the amount of droplets of a play i.e. n100.
|
||||
#[inline]
|
||||
pub fn droplets(mut self, n_droplets: usize) -> Self {
|
||||
self.n_droplets.replace(n_droplets);
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
/// Specify the amount of tiny droplets of a play i.e. n50.
|
||||
#[inline]
|
||||
pub fn tiny_droplets(mut self, n_tiny_droplets: usize) -> Self {
|
||||
self.n_tiny_droplets.replace(n_tiny_droplets);
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
/// Specify the amount of tiny droplet misses of a play i.e. n_katu.
|
||||
#[inline]
|
||||
pub fn tiny_droplet_misses(mut self, n_tiny_droplet_misses: usize) -> Self {
|
||||
self.n_tiny_droplet_misses.replace(n_tiny_droplet_misses);
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
/// Specify the amount of fruit / droplet misses of the play.
|
||||
#[inline]
|
||||
pub fn misses(mut self, n_misses: usize) -> Self {
|
||||
self.n_misses = Some(n_misses);
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
/// Amount of passed objects for partial plays, e.g. a fail.
|
||||
///
|
||||
/// If you want to calculate the performance after every few objects, instead of
|
||||
/// using [`CatchPP`] multiple times with different `passed_objects`, you should use
|
||||
/// [`CatchGradualPerformanceAttributes`](crate::catch::CatchGradualPerformanceAttributes).
|
||||
#[inline]
|
||||
pub fn passed_objects(mut self, passed_objects: usize) -> Self {
|
||||
self.passed_objects.replace(passed_objects);
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
/// Adjust the clock rate used in the calculation.
|
||||
/// If none is specified, it will take the clock rate based on the mods
|
||||
/// i.e. 1.5 for DT, 0.75 for HT and 1.0 otherwise.
|
||||
#[inline]
|
||||
pub fn clock_rate(mut self, clock_rate: f64) -> Self {
|
||||
self.clock_rate = Some(clock_rate);
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
/// Provide parameters through an [`CatchScoreState`].
|
||||
#[inline]
|
||||
pub fn state(mut self, state: CatchScoreState) -> Self {
|
||||
let CatchScoreState {
|
||||
max_combo,
|
||||
n_fruits,
|
||||
n_droplets,
|
||||
n_tiny_droplets,
|
||||
n_tiny_droplet_misses,
|
||||
n_misses,
|
||||
} = state;
|
||||
|
||||
self.combo = Some(max_combo);
|
||||
self.n_fruits = Some(n_fruits);
|
||||
self.n_droplets = Some(n_droplets);
|
||||
self.n_tiny_droplets = Some(n_tiny_droplets);
|
||||
self.n_tiny_droplet_misses = Some(n_tiny_droplet_misses);
|
||||
self.n_misses = Some(n_misses);
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
// TODO: adjust this on the next rework
|
||||
/// Generate the hit results with respect to the given accuracy between `0.0` and `100.0`.
|
||||
///
|
||||
/// Be sure to set `misses` beforehand! Also, if available, set `attributes` beforehand.
|
||||
pub fn accuracy(mut self, mut acc: f64) -> Self {
|
||||
if self.attributes.is_none() {
|
||||
let mut calculator = CatchStars::new(self.map).mods(self.mods);
|
||||
|
||||
if let Some(passed_objects) = self.passed_objects {
|
||||
calculator = calculator.passed_objects(passed_objects);
|
||||
}
|
||||
|
||||
if let Some(clock_rate) = self.clock_rate {
|
||||
calculator = calculator.clock_rate(clock_rate);
|
||||
}
|
||||
|
||||
self.attributes = Some(calculator.calculate());
|
||||
}
|
||||
|
||||
let attributes = self.attributes.as_ref().unwrap();
|
||||
|
||||
let n_droplets = self.n_droplets.unwrap_or_else(|| {
|
||||
attributes
|
||||
.n_droplets
|
||||
.saturating_sub(self.n_misses.unwrap_or(0))
|
||||
});
|
||||
|
||||
let max_combo = attributes.max_combo();
|
||||
|
||||
let n_fruits = self.n_fruits.unwrap_or_else(|| {
|
||||
max_combo
|
||||
.saturating_sub(self.n_misses.unwrap_or(0))
|
||||
.saturating_sub(n_droplets)
|
||||
});
|
||||
|
||||
let max_tiny_droplets = attributes.n_tiny_droplets;
|
||||
acc /= 100.0;
|
||||
|
||||
let n_tiny_droplets = self.n_tiny_droplets.unwrap_or_else(|| {
|
||||
((acc * (max_combo + max_tiny_droplets) as f64).round() as usize)
|
||||
.saturating_sub(n_fruits)
|
||||
.saturating_sub(n_droplets)
|
||||
});
|
||||
|
||||
let n_tiny_droplet_misses = max_tiny_droplets.saturating_sub(n_tiny_droplets);
|
||||
|
||||
self.n_fruits.replace(n_fruits);
|
||||
self.n_droplets.replace(n_droplets);
|
||||
self.n_tiny_droplets.replace(n_tiny_droplets);
|
||||
self.n_tiny_droplet_misses.replace(n_tiny_droplet_misses);
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
fn assert_hitresults(self, attributes: CatchDifficultyAttributes) -> CatchPPInner {
|
||||
let max_combo = attributes.max_combo();
|
||||
|
||||
let correct_combo_hits = self
|
||||
.n_fruits
|
||||
.and_then(|f| self.n_droplets.map(|d| f + d + self.n_misses.unwrap_or(0)))
|
||||
.filter(|h| *h == max_combo);
|
||||
|
||||
let correct_fruits = self.n_fruits.filter(|f| {
|
||||
*f >= attributes
|
||||
.n_fruits
|
||||
.saturating_sub(self.n_misses.unwrap_or(0))
|
||||
});
|
||||
|
||||
let correct_droplets = self.n_droplets.filter(|d| {
|
||||
*d >= attributes
|
||||
.n_droplets
|
||||
.saturating_sub(self.n_misses.unwrap_or(0))
|
||||
});
|
||||
|
||||
let correct_tinies = self
|
||||
.n_tiny_droplets
|
||||
.and_then(|t| self.n_tiny_droplet_misses.map(|m| t + m))
|
||||
.filter(|h| *h == attributes.n_tiny_droplets);
|
||||
|
||||
if correct_combo_hits
|
||||
.and(correct_fruits)
|
||||
.and(correct_droplets)
|
||||
.and(correct_tinies)
|
||||
.is_none()
|
||||
{
|
||||
let mut n_fruits = self.n_fruits.unwrap_or(0);
|
||||
let mut n_droplets = self.n_droplets.unwrap_or(0);
|
||||
let mut n_tiny_droplets = self.n_tiny_droplets.unwrap_or(0);
|
||||
let n_tiny_droplet_misses = self.n_tiny_droplet_misses.unwrap_or(0);
|
||||
|
||||
let missing = max_combo
|
||||
.saturating_sub(n_fruits)
|
||||
.saturating_sub(n_droplets)
|
||||
.saturating_sub(self.n_misses.unwrap_or(0));
|
||||
|
||||
let missing_fruits =
|
||||
missing.saturating_sub(attributes.n_droplets.saturating_sub(n_droplets));
|
||||
|
||||
n_fruits += missing_fruits;
|
||||
n_droplets += missing.saturating_sub(missing_fruits);
|
||||
n_tiny_droplets += attributes
|
||||
.n_tiny_droplets
|
||||
.saturating_sub(n_tiny_droplets)
|
||||
.saturating_sub(n_tiny_droplet_misses);
|
||||
|
||||
return CatchPPInner {
|
||||
attributes,
|
||||
mods: self.mods,
|
||||
combo: self.combo,
|
||||
n_fruits,
|
||||
n_droplets,
|
||||
n_tiny_droplets,
|
||||
n_tiny_droplet_misses,
|
||||
n_misses: self.n_misses.unwrap_or(0),
|
||||
};
|
||||
}
|
||||
|
||||
CatchPPInner {
|
||||
attributes,
|
||||
mods: self.mods,
|
||||
combo: self.combo,
|
||||
n_fruits: self.n_fruits.unwrap_or(0),
|
||||
n_droplets: self.n_droplets.unwrap_or(0),
|
||||
n_tiny_droplets: self.n_tiny_droplets.unwrap_or(0),
|
||||
n_tiny_droplet_misses: self.n_tiny_droplet_misses.unwrap_or(0),
|
||||
n_misses: self.n_misses.unwrap_or(0),
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate all performance related values, including pp and stars.
|
||||
pub fn calculate(mut self) -> CatchPerformanceAttributes {
|
||||
let attributes = self.attributes.take().unwrap_or_else(|| {
|
||||
let mut calculator = CatchStars::new(self.map).mods(self.mods);
|
||||
|
||||
if let Some(passed_objects) = self.passed_objects {
|
||||
calculator = calculator.passed_objects(passed_objects);
|
||||
}
|
||||
|
||||
if let Some(clock_rate) = self.clock_rate {
|
||||
calculator = calculator.clock_rate(clock_rate);
|
||||
}
|
||||
|
||||
calculator.calculate()
|
||||
});
|
||||
|
||||
self.assert_hitresults(attributes).calculate()
|
||||
}
|
||||
}
|
||||
|
||||
struct CatchPPInner {
|
||||
attributes: CatchDifficultyAttributes,
|
||||
mods: u32,
|
||||
combo: Option<usize>,
|
||||
n_fruits: usize,
|
||||
n_droplets: usize,
|
||||
n_tiny_droplets: usize,
|
||||
n_tiny_droplet_misses: usize,
|
||||
n_misses: usize,
|
||||
}
|
||||
|
||||
impl CatchPPInner {
|
||||
fn calculate(self) -> CatchPerformanceAttributes {
|
||||
let attributes = &self.attributes;
|
||||
let stars = attributes.stars;
|
||||
let max_combo = attributes.max_combo();
|
||||
|
||||
// Relying heavily on aim
|
||||
let mut pp = (5.0 * (stars / 0.0049).max(1.0) - 4.0).powi(2) / 100_000.0;
|
||||
|
||||
let mut combo_hits = self.combo_hits();
|
||||
|
||||
if combo_hits == 0 {
|
||||
combo_hits = max_combo;
|
||||
}
|
||||
|
||||
// Longer maps are worth more
|
||||
let len_bonus = 0.95
|
||||
+ 0.3 * (combo_hits as f64 / 2500.0).min(1.0)
|
||||
+ (combo_hits > 2500) as u8 as f64 * (combo_hits as f64 / 2500.0).log10() * 0.475;
|
||||
|
||||
pp *= len_bonus;
|
||||
|
||||
// Penalize misses exponentially
|
||||
pp *= 0.97_f64.powi(self.n_misses as i32);
|
||||
|
||||
// Combo scaling
|
||||
if let Some(combo) = self.combo.filter(|_| max_combo > 0) {
|
||||
pp *= (combo as f64 / max_combo as f64).powf(0.8).min(1.0);
|
||||
}
|
||||
|
||||
// AR scaling
|
||||
let ar = attributes.ar;
|
||||
let mut ar_factor = 1.0;
|
||||
if ar > 9.0 {
|
||||
ar_factor += 0.1 * (ar - 9.0) + (ar > 10.0) as u8 as f64 * 0.1 * (ar - 10.0);
|
||||
} else if ar < 8.0 {
|
||||
ar_factor += 0.025 * (8.0 - ar);
|
||||
}
|
||||
pp *= ar_factor;
|
||||
|
||||
// HD bonus
|
||||
if self.mods.hd() {
|
||||
if ar <= 10.0 {
|
||||
pp *= 1.05 + 0.075 * (10.0 - ar);
|
||||
} else if ar > 10.0 {
|
||||
pp *= 1.01 + 0.04 * (11.0 - ar.min(11.0));
|
||||
}
|
||||
}
|
||||
|
||||
// FL bonus
|
||||
if self.mods.fl() {
|
||||
pp *= 1.35 * len_bonus;
|
||||
}
|
||||
|
||||
// Accuracy scaling
|
||||
pp *= self.acc().powf(5.5);
|
||||
|
||||
// NF penalty
|
||||
if self.mods.nf() {
|
||||
pp *= 0.9;
|
||||
}
|
||||
|
||||
CatchPerformanceAttributes {
|
||||
difficulty: self.attributes,
|
||||
pp,
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn combo_hits(&self) -> usize {
|
||||
self.n_fruits + self.n_droplets + self.n_misses
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn successful_hits(&self) -> usize {
|
||||
self.n_fruits + self.n_droplets + self.n_tiny_droplets
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn total_hits(&self) -> usize {
|
||||
self.successful_hits() + self.n_tiny_droplet_misses + self.n_misses
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn acc(&self) -> f64 {
|
||||
let total_hits = self.total_hits();
|
||||
|
||||
if total_hits == 0 {
|
||||
1.0
|
||||
} else {
|
||||
(self.successful_hits() as f64 / total_hits as f64)
|
||||
.max(0.0)
|
||||
.min(1.0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'map> From<OsuPP<'map>> for CatchPP<'map> {
|
||||
#[inline]
|
||||
fn from(osu: OsuPP<'map>) -> Self {
|
||||
let OsuPP {
|
||||
map,
|
||||
mods,
|
||||
acc,
|
||||
combo,
|
||||
n300,
|
||||
n100,
|
||||
n50,
|
||||
n_misses,
|
||||
passed_objects,
|
||||
clock_rate,
|
||||
..
|
||||
} = osu;
|
||||
|
||||
let res = Self {
|
||||
map,
|
||||
attributes: None,
|
||||
mods,
|
||||
combo,
|
||||
n_fruits: n300,
|
||||
n_droplets: n100,
|
||||
n_tiny_droplets: n50,
|
||||
n_tiny_droplet_misses: None,
|
||||
n_misses,
|
||||
passed_objects,
|
||||
clock_rate,
|
||||
};
|
||||
|
||||
match acc {
|
||||
Some(acc) => res.accuracy(acc),
|
||||
None => res,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Abstract type to provide flexibility when passing difficulty attributes to a performance calculation.
|
||||
pub trait CatchAttributeProvider {
|
||||
/// Provide the actual difficulty attributes.
|
||||
fn attributes(self) -> Option<CatchDifficultyAttributes>;
|
||||
}
|
||||
|
||||
impl CatchAttributeProvider for CatchDifficultyAttributes {
|
||||
#[inline]
|
||||
fn attributes(self) -> Option<CatchDifficultyAttributes> {
|
||||
Some(self)
|
||||
}
|
||||
}
|
||||
|
||||
impl CatchAttributeProvider for CatchPerformanceAttributes {
|
||||
#[inline]
|
||||
fn attributes(self) -> Option<CatchDifficultyAttributes> {
|
||||
Some(self.difficulty)
|
||||
}
|
||||
}
|
||||
|
||||
impl CatchAttributeProvider for DifficultyAttributes {
|
||||
#[inline]
|
||||
fn attributes(self) -> Option<CatchDifficultyAttributes> {
|
||||
#[allow(irrefutable_let_patterns)]
|
||||
if let Self::Catch(attributes) = self {
|
||||
Some(attributes)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CatchAttributeProvider for PerformanceAttributes {
|
||||
#[inline]
|
||||
fn attributes(self) -> Option<CatchDifficultyAttributes> {
|
||||
#[allow(irrefutable_let_patterns)]
|
||||
if let Self::Catch(attributes) = self {
|
||||
Some(attributes.difficulty)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::*;
|
||||
use crate::Beatmap;
|
||||
|
||||
fn attributes() -> CatchDifficultyAttributes {
|
||||
CatchDifficultyAttributes {
|
||||
n_fruits: 1234,
|
||||
n_droplets: 567,
|
||||
n_tiny_droplets: 2345,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fruits_only_accuracy() {
|
||||
let map = Beatmap::default();
|
||||
let attributes = attributes();
|
||||
|
||||
let total_objects = attributes.n_fruits + attributes.n_droplets;
|
||||
let target_acc = 97.5;
|
||||
|
||||
let calculator = CatchPP::new(&map)
|
||||
.attributes(attributes)
|
||||
.passed_objects(total_objects)
|
||||
.accuracy(target_acc);
|
||||
|
||||
let numerator = calculator.n_fruits.unwrap_or(0)
|
||||
+ calculator.n_droplets.unwrap_or(0)
|
||||
+ calculator.n_tiny_droplets.unwrap_or(0);
|
||||
let denominator = numerator
|
||||
+ calculator.n_tiny_droplet_misses.unwrap_or(0)
|
||||
+ calculator.n_misses.unwrap_or(0);
|
||||
let acc = 100.0 * numerator as f64 / denominator as f64;
|
||||
|
||||
assert!(
|
||||
(target_acc - acc).abs() < 1.0,
|
||||
"Expected: {} | Actual: {}",
|
||||
target_acc,
|
||||
acc
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fruits_accuracy_droplets_and_tiny_droplets() {
|
||||
let map = Beatmap::default();
|
||||
let attributes = attributes();
|
||||
|
||||
let total_objects = attributes.n_fruits + attributes.n_droplets;
|
||||
let target_acc = 97.5;
|
||||
let n_droplets = 550;
|
||||
let n_tiny_droplets = 2222;
|
||||
|
||||
let calculator = CatchPP::new(&map)
|
||||
.attributes(attributes)
|
||||
.passed_objects(total_objects)
|
||||
.droplets(n_droplets)
|
||||
.tiny_droplets(n_tiny_droplets)
|
||||
.accuracy(target_acc);
|
||||
|
||||
assert_eq!(
|
||||
n_droplets,
|
||||
calculator.n_droplets.unwrap(),
|
||||
"Expected: {} | Actual: {}",
|
||||
n_droplets,
|
||||
calculator.n_droplets.unwrap()
|
||||
);
|
||||
|
||||
let numerator = calculator.n_fruits.unwrap_or(0)
|
||||
+ calculator.n_droplets.unwrap_or(0)
|
||||
+ calculator.n_tiny_droplets.unwrap_or(0);
|
||||
let denominator = numerator
|
||||
+ calculator.n_tiny_droplet_misses.unwrap_or(0)
|
||||
+ calculator.n_misses.unwrap_or(0);
|
||||
let acc = 100.0 * numerator as f64 / denominator as f64;
|
||||
|
||||
assert!(
|
||||
(target_acc - acc).abs() < 1.0,
|
||||
"Expected: {} | Actual: {}",
|
||||
target_acc,
|
||||
acc
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fruits_missing_objects() {
|
||||
let map = Beatmap::default();
|
||||
let attributes = attributes();
|
||||
|
||||
let total_objects = attributes.n_fruits + attributes.n_droplets;
|
||||
let n_fruits = attributes.n_fruits - 10;
|
||||
let n_droplets = attributes.n_droplets - 5;
|
||||
let n_tiny_droplets = attributes.n_tiny_droplets - 50;
|
||||
let n_tiny_droplet_misses = 20;
|
||||
let n_misses = 2;
|
||||
|
||||
let calculator = CatchPP::new(&map)
|
||||
.attributes(attributes.clone())
|
||||
.passed_objects(total_objects)
|
||||
.fruits(n_fruits)
|
||||
.droplets(n_droplets)
|
||||
.tiny_droplets(n_tiny_droplets)
|
||||
.tiny_droplet_misses(n_tiny_droplet_misses)
|
||||
.misses(n_misses)
|
||||
.assert_hitresults(attributes.clone());
|
||||
|
||||
assert!(
|
||||
(attributes.n_fruits as i32 - calculator.n_fruits as i32).abs() <= n_misses as i32,
|
||||
"Expected: {} | Actual: {} [+/- {} misses]",
|
||||
attributes.n_fruits,
|
||||
calculator.n_fruits,
|
||||
n_misses
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
attributes.n_droplets,
|
||||
calculator.n_droplets - (n_misses - (attributes.n_fruits - calculator.n_fruits)),
|
||||
"Expected: {} | Actual: {}",
|
||||
attributes.n_droplets,
|
||||
calculator.n_droplets - (n_misses - (attributes.n_fruits - calculator.n_fruits)),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
attributes.n_tiny_droplets,
|
||||
calculator.n_tiny_droplets + calculator.n_tiny_droplet_misses,
|
||||
"Expected: {} | Actual: {}",
|
||||
attributes.n_tiny_droplets,
|
||||
calculator.n_tiny_droplets + calculator.n_tiny_droplet_misses,
|
||||
);
|
||||
}
|
||||
}
|
||||
+539
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
+326
@@ -0,0 +1,326 @@
|
||||
use crate::{
|
||||
catch::{CatchGradualDifficultyAttributes, CatchGradualPerformanceAttributes, CatchScoreState},
|
||||
mania::{ManiaGradualDifficultyAttributes, ManiaGradualPerformanceAttributes, ManiaScoreState},
|
||||
osu::{OsuGradualDifficultyAttributes, OsuGradualPerformanceAttributes, OsuScoreState},
|
||||
taiko::{TaikoGradualDifficultyAttributes, TaikoGradualPerformanceAttributes, TaikoScoreState},
|
||||
Beatmap, DifficultyAttributes, GameMode, PerformanceAttributes,
|
||||
};
|
||||
|
||||
/// Gradually calculate the difficulty attributes on maps of any mode.
|
||||
///
|
||||
/// Note that this struct implements [`Iterator`](std::iter::Iterator).
|
||||
/// On every call of [`Iterator::next`](std::iter::Iterator::next), the map's next hit object will
|
||||
/// be processed and the [`DifficultyAttributes`] will be updated and returned.
|
||||
///
|
||||
/// If you want to calculate performance attributes, use
|
||||
/// [`GradualPerformanceAttributes`](crate::GradualPerformanceAttributes) instead.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```no_run
|
||||
/// use rosu_pp::{Beatmap, GradualDifficultyAttributes};
|
||||
///
|
||||
/// # /*
|
||||
/// let map: Beatmap = ...
|
||||
/// # */
|
||||
/// # let map = Beatmap::default();
|
||||
///
|
||||
/// let mods = 64; // DT
|
||||
/// let mut iter = GradualDifficultyAttributes::new(&map, mods);
|
||||
///
|
||||
/// let attrs1 = iter.next(); // the difficulty of the map after the first hit object
|
||||
/// let attrs2 = iter.next(); // after the second hit object
|
||||
///
|
||||
/// // Remaining hit objects
|
||||
/// for difficulty in iter {
|
||||
/// // ...
|
||||
/// }
|
||||
/// ```
|
||||
#[derive(Debug)]
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
pub enum GradualDifficultyAttributes<'map> {
|
||||
/// Gradual osu!standard difficulty attributes.
|
||||
Osu(OsuGradualDifficultyAttributes),
|
||||
/// Gradual osu!taiko difficulty attributes.
|
||||
Taiko(TaikoGradualDifficultyAttributes),
|
||||
/// Gradual osu!catch difficulty attributes.
|
||||
Catch(CatchGradualDifficultyAttributes<'map>),
|
||||
/// Gradual osu!mania difficulty attributes.
|
||||
Mania(ManiaGradualDifficultyAttributes<'map>),
|
||||
}
|
||||
|
||||
impl<'map> GradualDifficultyAttributes<'map> {
|
||||
/// Create a new gradual difficulty calculator for maps of any mode.
|
||||
pub fn new(map: &'map Beatmap, mods: u32) -> Self {
|
||||
match map.mode {
|
||||
GameMode::Osu => Self::Osu(OsuGradualDifficultyAttributes::new(map, mods)),
|
||||
GameMode::Taiko => Self::Taiko(TaikoGradualDifficultyAttributes::new(map, mods)),
|
||||
GameMode::Catch => Self::Catch(CatchGradualDifficultyAttributes::new(map, mods)),
|
||||
GameMode::Mania => Self::Mania(ManiaGradualDifficultyAttributes::new(map, mods)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Iterator for GradualDifficultyAttributes<'_> {
|
||||
type Item = DifficultyAttributes;
|
||||
|
||||
#[inline]
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
match self {
|
||||
GradualDifficultyAttributes::Osu(o) => o.next().map(DifficultyAttributes::Osu),
|
||||
GradualDifficultyAttributes::Taiko(t) => t.next().map(DifficultyAttributes::Taiko),
|
||||
GradualDifficultyAttributes::Catch(f) => f.next().map(DifficultyAttributes::Catch),
|
||||
GradualDifficultyAttributes::Mania(m) => m.next().map(DifficultyAttributes::Mania),
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn size_hint(&self) -> (usize, Option<usize>) {
|
||||
match self {
|
||||
GradualDifficultyAttributes::Osu(o) => o.size_hint(),
|
||||
GradualDifficultyAttributes::Taiko(t) => t.size_hint(),
|
||||
GradualDifficultyAttributes::Catch(f) => f.size_hint(),
|
||||
GradualDifficultyAttributes::Mania(m) => m.size_hint(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Aggregation for a score's current state i.e. what is
|
||||
/// the maximum combo so far, what are the current
|
||||
/// hitresults and what is the current score.
|
||||
///
|
||||
/// This struct is used for [`GradualPerformanceAttributes`].
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq)]
|
||||
pub struct ScoreState {
|
||||
/// Maximum combo that the score has had so far.
|
||||
/// **Not** the maximum possible combo of the map so far.
|
||||
///
|
||||
/// Note that for osu!catch only fruits and droplets are considered for combo.
|
||||
///
|
||||
/// Irrelevant for osu!mania.
|
||||
pub max_combo: usize,
|
||||
/// Amount of current gekis (n320 for osu!mania).
|
||||
pub n_geki: usize,
|
||||
/// Amount of current katus (tiny droplet misses for osu!catch / n200 for osu!mania).
|
||||
pub n_katu: usize,
|
||||
/// Amount of current 300s (fruits for osu!catch).
|
||||
pub n300: usize,
|
||||
/// Amount of current 100s (droplets for osu!catch).
|
||||
pub n100: usize,
|
||||
/// Amount of current 50s (tiny droplets for osu!catch).
|
||||
pub n50: usize,
|
||||
/// Amount of current misses (fruits + droplets for osu!catch).
|
||||
pub n_misses: usize,
|
||||
}
|
||||
|
||||
impl ScoreState {
|
||||
/// Create a new empty score state.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ScoreState> for OsuScoreState {
|
||||
#[inline]
|
||||
fn from(state: ScoreState) -> Self {
|
||||
Self {
|
||||
max_combo: state.max_combo,
|
||||
n300: state.n300,
|
||||
n100: state.n100,
|
||||
n50: state.n50,
|
||||
n_misses: state.n_misses,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ScoreState> for TaikoScoreState {
|
||||
#[inline]
|
||||
fn from(state: ScoreState) -> Self {
|
||||
Self {
|
||||
max_combo: state.max_combo,
|
||||
n300: state.n300,
|
||||
n100: state.n100,
|
||||
n_misses: state.n_misses,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ScoreState> for CatchScoreState {
|
||||
#[inline]
|
||||
fn from(state: ScoreState) -> Self {
|
||||
Self {
|
||||
max_combo: state.max_combo,
|
||||
n_fruits: state.n300,
|
||||
n_droplets: state.n100,
|
||||
n_tiny_droplets: state.n50,
|
||||
n_tiny_droplet_misses: state.n_katu,
|
||||
n_misses: state.n_misses,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ScoreState> for ManiaScoreState {
|
||||
#[inline]
|
||||
fn from(state: ScoreState) -> Self {
|
||||
Self {
|
||||
n320: state.n_geki,
|
||||
n300: state.n300,
|
||||
n200: state.n_katu,
|
||||
n100: state.n100,
|
||||
n50: state.n50,
|
||||
n_misses: state.n_misses,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Gradually calculate the performance attributes on maps of any mode.
|
||||
///
|
||||
/// After each hit object you can call
|
||||
/// [`process_next_object`](`GradualPerformanceAttributes::process_next_object`)
|
||||
/// and it will return the resulting current [`PerformanceAttributes`].
|
||||
/// To process multiple objects at once, use
|
||||
/// [`process_next_n_objects`](`GradualPerformanceAttributes::process_next_n_objects`) instead.
|
||||
///
|
||||
/// Both methods require a [`ScoreState`] that contains the current hitresults
|
||||
/// as well as the maximum combo so far or just the current score for osu!mania.
|
||||
/// Since the map could have any mode, all fields of `ScoreState` could be of use
|
||||
/// and should be updated properly.
|
||||
///
|
||||
/// Alternatively, you can match on the map's mode yourself and use the gradual
|
||||
/// performance attribute struct for the corresponding mode, i.e.
|
||||
/// [`OsuGradualPerformanceAttributes`],
|
||||
/// [`TaikoGradualPerformanceAttributes`],
|
||||
/// [`CatchGradualPerformanceAttributes`], or
|
||||
/// [`ManiaGradualPerformanceAttributes`].
|
||||
///
|
||||
/// If you only want to calculate difficulty attributes use
|
||||
/// [`GradualDifficultyAttributes`](crate::GradualDifficultyAttributes) instead.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```no_run
|
||||
/// use rosu_pp::{Beatmap, GradualPerformanceAttributes, ScoreState};
|
||||
///
|
||||
/// # /*
|
||||
/// let map: Beatmap = ...
|
||||
/// # */
|
||||
/// # let map = Beatmap::default();
|
||||
///
|
||||
/// let mods = 64; // DT
|
||||
/// let mut gradual_perf = GradualPerformanceAttributes::new(&map, mods);
|
||||
/// let mut state = ScoreState::new(); // empty state, everything is on 0.
|
||||
///
|
||||
/// // The first 10 hitresults are 300s and increase the score by 123 each.
|
||||
/// for _ in 0..10 {
|
||||
/// state.n300 += 1;
|
||||
/// state.max_combo += 1;
|
||||
///
|
||||
/// # /*
|
||||
/// let performance = gradual_perf.process_next_object(state.clone()).unwrap();
|
||||
/// println!("PP: {}", performance.pp);
|
||||
/// # */
|
||||
/// # let _ = gradual_perf.process_next_object(state.clone());
|
||||
/// }
|
||||
///
|
||||
/// // Then comes a miss.
|
||||
/// // Note that state's max combo won't be incremented for
|
||||
/// // the next few objects because the combo is reset.
|
||||
/// state.n_misses += 1;
|
||||
/// # /*
|
||||
/// let performance = gradual_perf.process_next_object(state.clone()).unwrap();
|
||||
/// println!("PP: {}", performance.pp);
|
||||
/// # */
|
||||
/// # let _ = gradual_perf.process_next_object(state.clone());
|
||||
///
|
||||
/// // The next 10 objects will be a mixture of 300s, 100s, and 50s.
|
||||
/// // Notice how all 10 objects will be processed in one go.
|
||||
/// state.n300 += 2;
|
||||
/// state.n100 += 7;
|
||||
/// state.n50 += 1;
|
||||
/// // Don't forget state.n_katu
|
||||
/// # /*
|
||||
/// let performance = gradual_perf.process_next_n_objects(state.clone(), 10).unwrap();
|
||||
/// println!("PP: {}", performance.pp);
|
||||
/// # */
|
||||
/// # let _ = gradual_perf.process_next_n_objects(state.clone(), 10);
|
||||
///
|
||||
/// // Now comes another 300. Note that the max combo gets incremented again.
|
||||
/// state.n300 += 1;
|
||||
/// state.max_combo += 1;
|
||||
/// # /*
|
||||
/// let performance = gradual_perf.process_next_object(state.clone()).unwrap();
|
||||
/// println!("PP: {}", performance.pp);
|
||||
/// # */
|
||||
/// # let _ = gradual_perf.process_next_object(state.clone());
|
||||
///
|
||||
/// // Skip to the end
|
||||
/// # /*
|
||||
/// state.max_combo = ...
|
||||
/// state.n300 = ...
|
||||
/// ...
|
||||
/// let final_performance = gradual_perf.process_next_n_objects(state.clone(), usize::MAX).unwrap();
|
||||
/// println!("PP: {}", performance.pp);
|
||||
/// # */
|
||||
/// # let _ = gradual_perf.process_next_n_objects(state.clone(), usize::MAX);
|
||||
///
|
||||
/// // Once the final performance was calculated,
|
||||
/// // attempting to process further objects will return `None`.
|
||||
/// assert!(gradual_perf.process_next_object(state).is_none());
|
||||
/// ```
|
||||
#[derive(Debug)]
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
pub enum GradualPerformanceAttributes<'map> {
|
||||
/// Gradual osu!standard performance attributes.
|
||||
Osu(OsuGradualPerformanceAttributes<'map>),
|
||||
/// Gradual osu!taiko performance attributes.
|
||||
Taiko(TaikoGradualPerformanceAttributes<'map>),
|
||||
/// Gradual osu!catch performance attributes.
|
||||
Catch(CatchGradualPerformanceAttributes<'map>),
|
||||
/// Gradual osu!mania performance attributes.
|
||||
Mania(ManiaGradualPerformanceAttributes<'map>),
|
||||
}
|
||||
|
||||
impl<'map> GradualPerformanceAttributes<'map> {
|
||||
/// Create a new gradual performance calculator for maps of any mode.
|
||||
pub fn new(map: &'map Beatmap, mods: u32) -> Self {
|
||||
match map.mode {
|
||||
GameMode::Osu => Self::Osu(OsuGradualPerformanceAttributes::new(map, mods)),
|
||||
GameMode::Taiko => Self::Taiko(TaikoGradualPerformanceAttributes::new(map, mods)),
|
||||
GameMode::Catch => Self::Catch(CatchGradualPerformanceAttributes::new(map, mods)),
|
||||
GameMode::Mania => Self::Mania(ManiaGradualPerformanceAttributes::new(map, mods)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Process the next hit object and calculate the
|
||||
/// performance attributes for the resulting score.
|
||||
pub fn process_next_object(&mut self, state: ScoreState) -> Option<PerformanceAttributes> {
|
||||
self.process_next_n_objects(state, 1)
|
||||
}
|
||||
|
||||
/// Same as [`process_next_object`](`GradualPerformanceAttributes::process_next_object`)
|
||||
/// but instead of processing only one object it process `n` many.
|
||||
///
|
||||
/// If `n` is 0 it will be considered as 1.
|
||||
/// If there are still objects to be processed but `n` is larger than the amount
|
||||
/// of remaining objects, `n` will be considered as the amount of remaining objects.
|
||||
pub fn process_next_n_objects(
|
||||
&mut self,
|
||||
state: ScoreState,
|
||||
n: usize,
|
||||
) -> Option<PerformanceAttributes> {
|
||||
match self {
|
||||
GradualPerformanceAttributes::Osu(o) => o
|
||||
.process_next_n_objects(state.into(), n)
|
||||
.map(PerformanceAttributes::Osu),
|
||||
GradualPerformanceAttributes::Taiko(t) => t
|
||||
.process_next_n_objects(state.into(), n)
|
||||
.map(PerformanceAttributes::Taiko),
|
||||
GradualPerformanceAttributes::Catch(f) => f
|
||||
.process_next_n_objects(state.into(), n)
|
||||
.map(PerformanceAttributes::Catch),
|
||||
GradualPerformanceAttributes::Mania(m) => m
|
||||
.process_next_n_objects(state.into(), n)
|
||||
.map(PerformanceAttributes::Mania),
|
||||
}
|
||||
}
|
||||
}
|
||||
+514
@@ -0,0 +1,514 @@
|
||||
//! A standalone crate to calculate star ratings and performance points for all [osu!](https://osu.ppy.sh/home) gamemodes.
|
||||
//!
|
||||
//! Async is supported through features, see below.
|
||||
//!
|
||||
//! ## Usage
|
||||
//!
|
||||
//! ```no_run
|
||||
//! use rosu_pp::{Beatmap, BeatmapExt};
|
||||
//!
|
||||
//! # /*
|
||||
//! // Parse the map yourself
|
||||
//! let map = match Beatmap::from_path("/path/to/file.osu") {
|
||||
//! Ok(map) => map,
|
||||
//! Err(why) => panic!("Error while parsing map: {}", why),
|
||||
//! };
|
||||
//! # */ let map = Beatmap::default();
|
||||
//!
|
||||
//! // If `BeatmapExt` is included, you can make use of some methods
|
||||
//! // on `Beatmap` to make your life simpler like `BeatmapExt::pp`.
|
||||
//! let result = map.pp()
|
||||
//! .mods(24) // HDHR
|
||||
//! .combo(1234)
|
||||
//! .accuracy(99.2)
|
||||
//! .n_misses(2)
|
||||
//! .calculate();
|
||||
//!
|
||||
//! println!("PP: {}", result.pp());
|
||||
//!
|
||||
//! // If you want to reuse the current map-mod combination, make use of the previous result!
|
||||
//! // If attributes are given, then stars & co don't have to be recalculated.
|
||||
//! let next_result = map.pp()
|
||||
//! .mods(24) // HDHR
|
||||
//! .attributes(result) // recycle
|
||||
//! .combo(543)
|
||||
//! .n_misses(5)
|
||||
//! .n50(3)
|
||||
//! .accuracy(96.5)
|
||||
//! .calculate();
|
||||
//!
|
||||
//! println!("Next PP: {}", next_result.pp());
|
||||
//!
|
||||
//! let stars = map.stars()
|
||||
//! .mods(16) // HR
|
||||
//! .calculate()
|
||||
//! .stars();
|
||||
//!
|
||||
//! let max_pp = map.max_pp(16).pp();
|
||||
//!
|
||||
//! println!("Stars: {} | Max PP: {}", stars, max_pp);
|
||||
//! ```
|
||||
//!
|
||||
//! ## With async
|
||||
//! If either the `async_tokio` or `async_std` feature is enabled, beatmap parsing will be async.
|
||||
//!
|
||||
//! ```no_run
|
||||
//! use rosu_pp::{Beatmap, BeatmapExt};
|
||||
//!
|
||||
//! # /*
|
||||
//! // Parse the map asynchronously
|
||||
//! let map = match Beatmap::from_path("/path/to/file.osu").await {
|
||||
//! Ok(map) => map,
|
||||
//! Err(why) => panic!("Error while parsing map: {}", why),
|
||||
//! };
|
||||
//! # */ let map = Beatmap::default();
|
||||
//!
|
||||
//! // The rest stays the same
|
||||
//! let result = map.pp()
|
||||
//! .mods(24) // HDHR
|
||||
//! .combo(1234)
|
||||
//! .n_misses(2)
|
||||
//! .accuracy(99.2)
|
||||
//! .calculate();
|
||||
//!
|
||||
//! println!("PP: {}", result.pp());
|
||||
//! ```
|
||||
//!
|
||||
//! ## Gradual calculation
|
||||
//! Sometimes you might want to calculate the difficulty of a map or performance of a score after each hit object.
|
||||
//! This could be done by using `passed_objects` as the amount of objects that were passed so far.
|
||||
//! However, this requires to recalculate the beginning again and again, we can be more efficient than that.
|
||||
//!
|
||||
//! Instead, you should use [`GradualDifficultyAttributes`] and [`GradualPerformanceAttributes`]:
|
||||
//!
|
||||
//! ```no_run
|
||||
//! use rosu_pp::{
|
||||
//! Beatmap, BeatmapExt, GradualPerformanceAttributes, ScoreState,
|
||||
//! taiko::TaikoScoreState,
|
||||
//! };
|
||||
//!
|
||||
//! # /*
|
||||
//! let map = match Beatmap::from_path("/path/to/file.osu") {
|
||||
//! Ok(map) => map,
|
||||
//! Err(why) => panic!("Error while parsing map: {}", why),
|
||||
//! };
|
||||
//! # */
|
||||
//! # let map = Beatmap::default();
|
||||
//!
|
||||
//! let mods = 8 + 64; // HDDT
|
||||
//!
|
||||
//! // If you're only interested in the star rating or other difficulty value,
|
||||
//! // use `GradualDifficultyAttributes`, either through its function `new`
|
||||
//! // or through the method `BeatmapExt::gradual_difficulty`.
|
||||
//! let gradual_difficulty = map.gradual_difficulty(mods);
|
||||
//!
|
||||
//! // Since `GradualDifficultyAttributes` implements `Iterator`, you can use
|
||||
//! // any iterate function on it, use it in loops, collect them into a `Vec`, ...
|
||||
//! for (i, difficulty) in gradual_difficulty.enumerate() {
|
||||
//! println!("Stars after object {}: {}", i, difficulty.stars());
|
||||
//! }
|
||||
//!
|
||||
//! // Gradually calculating performance values does the same as calculating
|
||||
//! // difficulty attributes but it goes the extra step and also evaluates
|
||||
//! // the state of a score for these difficulty attributes.
|
||||
//! let mut gradual_performance = map.gradual_performance(mods);
|
||||
//!
|
||||
//! // The default score state is kinda chunky because it considers all modes.
|
||||
//! let state = ScoreState {
|
||||
//! max_combo: 1,
|
||||
//! n_geki: 0, // only relevant for mania
|
||||
//! n_katu: 0, // only relevant for mania and ctb
|
||||
//! n300: 1,
|
||||
//! n100: 0,
|
||||
//! n50: 0,
|
||||
//! n_misses: 0,
|
||||
//! };
|
||||
//!
|
||||
//! // Process the score state after the first object
|
||||
//! let curr_performance = match gradual_performance.process_next_object(state) {
|
||||
//! Some(perf) => perf,
|
||||
//! None => panic!("the map has no hit objects"),
|
||||
//! };
|
||||
//!
|
||||
//! println!("PP after the first object: {}", curr_performance.pp());
|
||||
//!
|
||||
//! // If you're only interested in maps of a specific mode, consider
|
||||
//! // using the mode's gradual calculator instead of the general one.
|
||||
//! // Let's assume it's a taiko map.
|
||||
//! // Instead of starting off with `BeatmapExt::gradual_performance` one could have
|
||||
//! // created the struct via `TaikoGradualPerformanceAttributes::new`.
|
||||
//! let mut gradual_performance = match gradual_performance {
|
||||
//! GradualPerformanceAttributes::Taiko(gradual) => gradual,
|
||||
//! _ => panic!("the map was not taiko but {:?}", map.mode),
|
||||
//! };
|
||||
//!
|
||||
//! // A little simpler than the general score state.
|
||||
//! let state = TaikoScoreState {
|
||||
//! max_combo: 11,
|
||||
//! n300: 9,
|
||||
//! n100: 1,
|
||||
//! n_misses: 1,
|
||||
//! };
|
||||
//!
|
||||
//! // Process the next 10 objects in one go
|
||||
//! let curr_performance = match gradual_performance.process_next_n_objects(state, 10) {
|
||||
//! Some(perf) => perf,
|
||||
//! None => panic!("the last `process_next_object` already processed the last object"),
|
||||
//! };
|
||||
//!
|
||||
//! println!("PP after the first 11 objects: {}", curr_performance.pp());
|
||||
//! ```
|
||||
//!
|
||||
//! ## Features
|
||||
//!
|
||||
//! | Flag | Description |
|
||||
//! |-----|-----|
|
||||
//! | `default` | Beatmap parsing will be non-async |
|
||||
//! | `async_tokio` | Beatmap parsing will be async through [tokio](https://github.com/tokio-rs/tokio) |
|
||||
//! | `async_std` | Beatmap parsing will be async through [async-std](https://github.com/async-rs/async-std) |
|
||||
//!
|
||||
|
||||
#![cfg_attr(docsrs, feature(doc_cfg), deny(broken_intra_doc_links))]
|
||||
#![deny(
|
||||
clippy::all,
|
||||
nonstandard_style,
|
||||
rust_2018_idioms,
|
||||
missing_debug_implementations
|
||||
)]
|
||||
|
||||
/// Everything about osu!catch.
|
||||
pub mod catch;
|
||||
|
||||
/// Everything about osu!mania.
|
||||
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;
|
||||
|
||||
/// Beatmap parsing
|
||||
pub mod parse;
|
||||
|
||||
/// Beatmap and contained types
|
||||
pub mod beatmap;
|
||||
pub use beatmap::{Beatmap, GameMode};
|
||||
|
||||
mod gradual;
|
||||
pub use gradual::{GradualDifficultyAttributes, GradualPerformanceAttributes, ScoreState};
|
||||
|
||||
mod pp;
|
||||
pub use pp::{AnyPP, AttributeProvider, HitResultPriority};
|
||||
|
||||
mod stars;
|
||||
pub use stars::AnyStars;
|
||||
|
||||
mod curve;
|
||||
mod mods;
|
||||
mod util;
|
||||
|
||||
pub use catch::{CatchPP, CatchStars};
|
||||
pub use mania::{ManiaPP, ManiaStars};
|
||||
pub use osu::{OsuPP, OsuStars};
|
||||
pub use taiko::{TaikoPP, TaikoStars};
|
||||
|
||||
pub use mods::Mods;
|
||||
pub use parse::{ParseError, ParseResult};
|
||||
pub use util::SortedVec;
|
||||
|
||||
/// Provides some additional methods on [`Beatmap`].
|
||||
pub trait BeatmapExt {
|
||||
/// Calculate the stars and other attributes of a beatmap which are required for pp calculation.
|
||||
fn stars(&self) -> AnyStars<'_>;
|
||||
|
||||
/// Calculate the max pp of a beatmap.
|
||||
///
|
||||
/// If you seek more fine-tuning you can use the [`pp`](BeatmapExt::pp) method.
|
||||
fn max_pp(&self, mods: u32) -> PerformanceAttributes;
|
||||
|
||||
/// Returns a builder for performance calculation.
|
||||
///
|
||||
/// Convenient method that matches on the map's mode to choose the appropriate calculator.
|
||||
fn pp(&self) -> AnyPP<'_>;
|
||||
|
||||
/// Calculate the strains of a map.
|
||||
/// This essentially performs the same calculation as [`BeatmapExt::stars`] but
|
||||
/// instead of evaluating the final strains, they are just returned as is.
|
||||
///
|
||||
/// Suitable to plot the difficulty of a map over time.
|
||||
fn strains(&self, mods: u32) -> Strains;
|
||||
|
||||
/// Return an iterator that gives you the [`DifficultyAttributes`] after each hit object.
|
||||
///
|
||||
/// Suitable to efficiently get the map's star rating after multiple different locations.
|
||||
fn gradual_difficulty(&self, mods: u32) -> GradualDifficultyAttributes<'_>;
|
||||
|
||||
/// Return a struct that gives you the [`PerformanceAttributes`] after every (few) hit object(s).
|
||||
///
|
||||
/// Suitable to efficiently get a score's performance after multiple different locations,
|
||||
/// i.e. live update a score's pp.
|
||||
fn gradual_performance(&self, mods: u32) -> GradualPerformanceAttributes<'_>;
|
||||
}
|
||||
|
||||
impl BeatmapExt for Beatmap {
|
||||
#[inline]
|
||||
fn stars(&self) -> AnyStars<'_> {
|
||||
match self.mode {
|
||||
GameMode::Osu => AnyStars::Osu(OsuStars::new(self)),
|
||||
GameMode::Taiko => AnyStars::Taiko(TaikoStars::new(self)),
|
||||
GameMode::Catch => AnyStars::Catch(CatchStars::new(self)),
|
||||
GameMode::Mania => AnyStars::Mania(ManiaStars::new(self)),
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn max_pp(&self, mods: u32) -> PerformanceAttributes {
|
||||
match self.mode {
|
||||
GameMode::Osu => PerformanceAttributes::Osu(OsuPP::new(self).mods(mods).calculate()),
|
||||
GameMode::Taiko => {
|
||||
PerformanceAttributes::Taiko(TaikoPP::new(self).mods(mods).calculate())
|
||||
}
|
||||
GameMode::Catch => {
|
||||
PerformanceAttributes::Catch(CatchPP::new(self).mods(mods).calculate())
|
||||
}
|
||||
GameMode::Mania => {
|
||||
PerformanceAttributes::Mania(ManiaPP::new(self).mods(mods).calculate())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn pp(&self) -> AnyPP<'_> {
|
||||
AnyPP::new(self)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn strains(&self, mods: u32) -> Strains {
|
||||
match self.mode {
|
||||
GameMode::Osu => Strains::Osu(OsuStars::new(self).mods(mods).strains()),
|
||||
GameMode::Taiko => Strains::Taiko(TaikoStars::new(self).mods(mods).strains()),
|
||||
GameMode::Catch => Strains::Catch(CatchStars::new(self).mods(mods).strains()),
|
||||
GameMode::Mania => Strains::Mania(ManiaStars::new(self).mods(mods).strains()),
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn gradual_difficulty(&self, mods: u32) -> GradualDifficultyAttributes<'_> {
|
||||
GradualDifficultyAttributes::new(self, mods)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn gradual_performance(&self, mods: u32) -> GradualPerformanceAttributes<'_> {
|
||||
GradualPerformanceAttributes::new(self, mods)
|
||||
}
|
||||
}
|
||||
|
||||
/// The result of calculating the strains on a map.
|
||||
/// Suitable to plot the difficulty of a map over time.
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum Strains {
|
||||
/// osu!standard strain values.
|
||||
Osu(osu::OsuStrains),
|
||||
/// osu!taiko strain values.
|
||||
Taiko(taiko::TaikoStrains),
|
||||
/// osu!catch strain values.
|
||||
Catch(catch::CatchStrains),
|
||||
/// osu!mania strain values.
|
||||
Mania(mania::ManiaStrains),
|
||||
}
|
||||
|
||||
impl Strains {
|
||||
/// Time in ms inbetween two strains.
|
||||
#[inline]
|
||||
pub fn section_len(&self) -> f64 {
|
||||
match self {
|
||||
Strains::Osu(strains) => strains.section_len,
|
||||
Strains::Taiko(strains) => strains.section_len,
|
||||
Strains::Catch(strains) => strains.section_len,
|
||||
Strains::Mania(strains) => strains.section_len,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the number of strain peaks per skill.
|
||||
#[inline]
|
||||
#[allow(clippy::len_without_is_empty)]
|
||||
pub fn len(&self) -> usize {
|
||||
match self {
|
||||
Strains::Osu(strains) => strains.len(),
|
||||
Strains::Taiko(strains) => strains.len(),
|
||||
Strains::Catch(strains) => strains.len(),
|
||||
Strains::Mania(strains) => strains.len(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The result of a difficulty calculation based on the mode.
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum DifficultyAttributes {
|
||||
/// osu!standard difficulty calculation result.
|
||||
Osu(osu::OsuDifficultyAttributes),
|
||||
/// osu!taiko difficulty calculation result.
|
||||
Taiko(taiko::TaikoDifficultyAttributes),
|
||||
/// osu!catch difficulty calculation result.
|
||||
Catch(catch::CatchDifficultyAttributes),
|
||||
/// osu!mania difficulty calculation result.
|
||||
Mania(mania::ManiaDifficultyAttributes),
|
||||
}
|
||||
|
||||
impl DifficultyAttributes {
|
||||
/// The star value.
|
||||
#[inline]
|
||||
pub fn stars(&self) -> f64 {
|
||||
match self {
|
||||
Self::Osu(attrs) => attrs.stars,
|
||||
Self::Taiko(attrs) => attrs.stars,
|
||||
Self::Catch(attrs) => attrs.stars,
|
||||
Self::Mania(attrs) => attrs.stars,
|
||||
}
|
||||
}
|
||||
|
||||
/// The maximum combo of the map.
|
||||
#[inline]
|
||||
pub fn max_combo(&self) -> usize {
|
||||
match self {
|
||||
Self::Osu(attrs) => attrs.max_combo,
|
||||
Self::Taiko(attrs) => attrs.max_combo,
|
||||
Self::Catch(attrs) => attrs.max_combo(),
|
||||
Self::Mania(attrs) => attrs.max_combo,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<osu::OsuDifficultyAttributes> for DifficultyAttributes {
|
||||
#[inline]
|
||||
fn from(attributes: osu::OsuDifficultyAttributes) -> Self {
|
||||
Self::Osu(attributes)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<taiko::TaikoDifficultyAttributes> for DifficultyAttributes {
|
||||
#[inline]
|
||||
fn from(attributes: taiko::TaikoDifficultyAttributes) -> Self {
|
||||
Self::Taiko(attributes)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<catch::CatchDifficultyAttributes> for DifficultyAttributes {
|
||||
#[inline]
|
||||
fn from(attributes: catch::CatchDifficultyAttributes) -> Self {
|
||||
Self::Catch(attributes)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<mania::ManiaDifficultyAttributes> for DifficultyAttributes {
|
||||
#[inline]
|
||||
fn from(attributes: mania::ManiaDifficultyAttributes) -> Self {
|
||||
Self::Mania(attributes)
|
||||
}
|
||||
}
|
||||
|
||||
/// The result of a performance calculation based on the mode.
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum PerformanceAttributes {
|
||||
/// osu!standard performance calculation result.
|
||||
Osu(osu::OsuPerformanceAttributes),
|
||||
/// osu!taiko performance calculation result.
|
||||
Taiko(taiko::TaikoPerformanceAttributes),
|
||||
/// osu!catch performance calculation result.
|
||||
Catch(catch::CatchPerformanceAttributes),
|
||||
/// osu!mania performance calculation result.
|
||||
Mania(mania::ManiaPerformanceAttributes),
|
||||
}
|
||||
|
||||
impl PerformanceAttributes {
|
||||
/// The pp value.
|
||||
#[inline]
|
||||
pub fn pp(&self) -> f64 {
|
||||
match self {
|
||||
Self::Osu(attrs) => attrs.pp,
|
||||
Self::Taiko(attrs) => attrs.pp,
|
||||
Self::Catch(attrs) => attrs.pp,
|
||||
Self::Mania(attrs) => attrs.pp,
|
||||
}
|
||||
}
|
||||
|
||||
/// The star value.
|
||||
#[inline]
|
||||
pub fn stars(&self) -> f64 {
|
||||
match self {
|
||||
Self::Osu(attrs) => attrs.stars(),
|
||||
Self::Taiko(attrs) => attrs.stars(),
|
||||
Self::Catch(attrs) => attrs.stars(),
|
||||
Self::Mania(attrs) => attrs.stars(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Difficulty attributes that were used for the performance calculation.
|
||||
#[inline]
|
||||
pub fn difficulty_attributes(&self) -> DifficultyAttributes {
|
||||
match self {
|
||||
Self::Osu(attrs) => DifficultyAttributes::Osu(attrs.difficulty.clone()),
|
||||
Self::Taiko(attrs) => DifficultyAttributes::Taiko(attrs.difficulty.clone()),
|
||||
Self::Catch(attrs) => DifficultyAttributes::Catch(attrs.difficulty.clone()),
|
||||
Self::Mania(attrs) => DifficultyAttributes::Mania(attrs.difficulty),
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
/// The maximum combo of the map.
|
||||
pub fn max_combo(&self) -> usize {
|
||||
match self {
|
||||
Self::Osu(attrs) => attrs.difficulty.max_combo,
|
||||
Self::Taiko(attrs) => attrs.difficulty.max_combo,
|
||||
Self::Catch(attrs) => attrs.difficulty.max_combo(),
|
||||
Self::Mania(attrs) => attrs.difficulty.max_combo,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<PerformanceAttributes> for DifficultyAttributes {
|
||||
#[inline]
|
||||
fn from(attributes: PerformanceAttributes) -> Self {
|
||||
match attributes {
|
||||
PerformanceAttributes::Osu(attrs) => Self::Osu(attrs.difficulty),
|
||||
PerformanceAttributes::Taiko(attrs) => Self::Taiko(attrs.difficulty),
|
||||
PerformanceAttributes::Catch(attrs) => Self::Catch(attrs.difficulty),
|
||||
PerformanceAttributes::Mania(attrs) => Self::Mania(attrs.difficulty),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<osu::OsuPerformanceAttributes> for PerformanceAttributes {
|
||||
#[inline]
|
||||
fn from(attributes: osu::OsuPerformanceAttributes) -> Self {
|
||||
Self::Osu(attributes)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<taiko::TaikoPerformanceAttributes> for PerformanceAttributes {
|
||||
#[inline]
|
||||
fn from(attributes: taiko::TaikoPerformanceAttributes) -> Self {
|
||||
Self::Taiko(attributes)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<catch::CatchPerformanceAttributes> for PerformanceAttributes {
|
||||
#[inline]
|
||||
fn from(attributes: catch::CatchPerformanceAttributes) -> Self {
|
||||
Self::Catch(attributes)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<mania::ManiaPerformanceAttributes> for PerformanceAttributes {
|
||||
#[inline]
|
||||
fn from(attributes: mania::ManiaPerformanceAttributes) -> Self {
|
||||
Self::Mania(attributes)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "async_tokio", feature = "async_std"))]
|
||||
compile_error!("Only one of the features `async_tokio` and `async_std` should be enabled");
|
||||
@@ -0,0 +1,22 @@
|
||||
use super::mania_object::ManiaObject;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct ManiaDifficultyObject {
|
||||
pub(crate) idx: usize,
|
||||
pub(crate) base_column: usize,
|
||||
pub(crate) delta_time: f64,
|
||||
pub(crate) start_time: f64,
|
||||
pub(crate) end_time: f64,
|
||||
}
|
||||
|
||||
impl ManiaDifficultyObject {
|
||||
pub(crate) fn new(base: &ManiaObject, last: &ManiaObject, clock_rate: f64, idx: usize) -> Self {
|
||||
Self {
|
||||
idx,
|
||||
base_column: base.column,
|
||||
delta_time: (base.start_time - last.start_time) / clock_rate,
|
||||
start_time: base.start_time / clock_rate,
|
||||
end_time: base.end_time / clock_rate,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
use std::borrow::Cow;
|
||||
|
||||
use crate::{
|
||||
beatmap::BeatmapHitWindows,
|
||||
parse::{HitObject, HitObjectKind},
|
||||
util::FloatExt,
|
||||
Beatmap, GameMode, Mods,
|
||||
};
|
||||
|
||||
use super::{
|
||||
difficulty_object::ManiaDifficultyObject,
|
||||
mania_object::ObjectParameters,
|
||||
skills::{Skill, Strain},
|
||||
ManiaDifficultyAttributes, ManiaObject, STAR_SCALING_FACTOR,
|
||||
};
|
||||
|
||||
/// Gradually calculate the difficulty attributes of an osu!mania map.
|
||||
///
|
||||
/// Note that this struct implements [`Iterator`](std::iter::Iterator).
|
||||
/// On every call of [`Iterator::next`](std::iter::Iterator::next), the map's next hit object will
|
||||
/// be processed and the [`ManiaDifficultyAttributes`] will be updated and returned.
|
||||
///
|
||||
/// If you want to calculate performance attributes, use
|
||||
/// [`ManiaGradualPerformanceAttributes`](crate::mania::ManiaGradualPerformanceAttributes) instead.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use rosu_pp::{Beatmap, mania::ManiaGradualDifficultyAttributes};
|
||||
///
|
||||
/// # /*
|
||||
/// let map: Beatmap = ...
|
||||
/// # */
|
||||
/// # let map = Beatmap::default();
|
||||
///
|
||||
/// let mods = 64; // DT
|
||||
/// let mut iter = ManiaGradualDifficultyAttributes::new(&map, mods);
|
||||
///
|
||||
/// let attrs1 = iter.next(); // the difficulty of the map after the first hit object
|
||||
/// let attrs2 = iter.next(); // after the second hit object
|
||||
///
|
||||
/// // Remaining hit objects
|
||||
/// for difficulty in iter {
|
||||
/// // ...
|
||||
/// }
|
||||
/// ```
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ManiaGradualDifficultyAttributes<'map> {
|
||||
pub(crate) idx: usize,
|
||||
map: Cow<'map, Beatmap>,
|
||||
hit_window: f64,
|
||||
strain: Strain,
|
||||
diff_objects: Vec<ManiaDifficultyObject>,
|
||||
curr_combo: usize,
|
||||
clock_rate: f64,
|
||||
}
|
||||
|
||||
impl<'map> ManiaGradualDifficultyAttributes<'map> {
|
||||
/// Create a new difficulty attributes iterator for osu!mania maps.
|
||||
pub fn new(map: &'map Beatmap, mods: u32) -> Self {
|
||||
let map = map.convert_mode(GameMode::Mania);
|
||||
let total_columns = map.cs.round_even().max(1.0);
|
||||
let clock_rate = mods.clock_rate();
|
||||
let strain = Strain::new(total_columns as usize);
|
||||
|
||||
let BeatmapHitWindows { od: hit_window, .. } = map
|
||||
.attributes()
|
||||
.mods(mods)
|
||||
.converted(matches!(map, Cow::Owned(_)))
|
||||
.clock_rate(clock_rate)
|
||||
.hit_windows();
|
||||
|
||||
let mut params = ObjectParameters::new(map.as_ref());
|
||||
let mut hit_objects = map.hit_objects.iter();
|
||||
|
||||
let first = match hit_objects.next() {
|
||||
Some(h) => ManiaObject::new(h, total_columns, &mut params),
|
||||
None => {
|
||||
return Self {
|
||||
idx: 0,
|
||||
map,
|
||||
hit_window,
|
||||
strain,
|
||||
diff_objects: Vec::new(),
|
||||
curr_combo: 0,
|
||||
clock_rate,
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let curr_combo = params.max_combo;
|
||||
|
||||
let diff_objects_iter = hit_objects.enumerate().scan(first, |last, (i, h)| {
|
||||
let base = ManiaObject::new(h, total_columns, &mut params);
|
||||
let diff_object = ManiaDifficultyObject::new(&base, &*last, clock_rate, i);
|
||||
*last = base;
|
||||
|
||||
Some(diff_object)
|
||||
});
|
||||
|
||||
let mut diff_objects = Vec::with_capacity(map.hit_objects.len().saturating_sub(1));
|
||||
diff_objects.extend(diff_objects_iter);
|
||||
|
||||
Self {
|
||||
idx: 0,
|
||||
map,
|
||||
hit_window,
|
||||
strain,
|
||||
diff_objects,
|
||||
curr_combo,
|
||||
clock_rate,
|
||||
}
|
||||
}
|
||||
|
||||
fn increment_combo(
|
||||
h: &HitObject,
|
||||
diff_obj: &ManiaDifficultyObject,
|
||||
curr_combo: &mut usize,
|
||||
clock_rate: f64,
|
||||
) {
|
||||
match &h.kind {
|
||||
HitObjectKind::Circle => *curr_combo += 1,
|
||||
_ => {
|
||||
let start_time = diff_obj.start_time * clock_rate;
|
||||
let end_time = diff_obj.end_time * clock_rate;
|
||||
let duration = end_time - start_time;
|
||||
|
||||
*curr_combo += 1 + (duration / 100.0) as usize;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Iterator for ManiaGradualDifficultyAttributes<'_> {
|
||||
type Item = ManiaDifficultyAttributes;
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
let curr = self.diff_objects.get(self.idx)?;
|
||||
self.idx += 1;
|
||||
|
||||
if let Some(h) = self.map.hit_objects.get(self.idx) {
|
||||
Self::increment_combo(h, curr, &mut self.curr_combo, self.clock_rate);
|
||||
}
|
||||
|
||||
self.strain.process(curr, &self.diff_objects);
|
||||
|
||||
Some(ManiaDifficultyAttributes {
|
||||
stars: self.strain.clone().difficulty_value() * STAR_SCALING_FACTOR,
|
||||
hit_window: self.hit_window,
|
||||
max_combo: self.curr_combo,
|
||||
})
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn size_hint(&self) -> (usize, Option<usize>) {
|
||||
let len = self.len();
|
||||
|
||||
(len, Some(len))
|
||||
}
|
||||
|
||||
fn nth(&mut self, n: usize) -> Option<Self::Item> {
|
||||
let skip = n.min(self.len()).saturating_sub(1);
|
||||
|
||||
for _ in 0..skip {
|
||||
let curr = self.diff_objects.get(self.idx)?;
|
||||
self.idx += 1;
|
||||
|
||||
if let Some(h) = self.map.hit_objects.get(self.idx) {
|
||||
Self::increment_combo(h, curr, &mut self.curr_combo, self.clock_rate);
|
||||
}
|
||||
|
||||
self.strain.process(curr, &self.diff_objects);
|
||||
}
|
||||
|
||||
self.next()
|
||||
}
|
||||
}
|
||||
|
||||
impl ExactSizeIterator for ManiaGradualDifficultyAttributes<'_> {
|
||||
#[inline]
|
||||
fn len(&self) -> usize {
|
||||
self.diff_objects.len() - self.idx
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
use crate::{Beatmap, ManiaPP};
|
||||
|
||||
use super::{ManiaGradualDifficultyAttributes, ManiaPerformanceAttributes};
|
||||
|
||||
/// Aggregation for a score's current state
|
||||
/// i.e. what are the current hitresults.
|
||||
///
|
||||
/// This struct is used for [`ManiaGradualPerformanceAttributes`].
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq)]
|
||||
pub struct ManiaScoreState {
|
||||
/// Amount of current 320s.
|
||||
pub n320: usize,
|
||||
/// Amount of current 300s.
|
||||
pub n300: usize,
|
||||
/// Amount of current 200s.
|
||||
pub n200: usize,
|
||||
/// Amount of current 100s.
|
||||
pub n100: usize,
|
||||
/// Amount of current 50s.
|
||||
pub n50: usize,
|
||||
/// Amount of current misses.
|
||||
pub n_misses: usize,
|
||||
}
|
||||
|
||||
impl ManiaScoreState {
|
||||
/// Create a new empty score state.
|
||||
#[inline]
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Return the total amount of hits by adding everything up.
|
||||
#[inline]
|
||||
pub fn total_hits(&self) -> usize {
|
||||
self.n320 + self.n300 + self.n200 + self.n100 + self.n50 + self.n_misses
|
||||
}
|
||||
|
||||
/// Calculate the accuracy between `0.0` and `1.0` for this state.
|
||||
#[inline]
|
||||
pub fn accuracy(&self) -> f64 {
|
||||
let total_hits = self.total_hits();
|
||||
|
||||
if total_hits == 0 {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
let numerator = 6 * (self.n320 + self.n300) + 4 * self.n200 + 2 * self.n100 + self.n50;
|
||||
let denominator = 6 * total_hits;
|
||||
|
||||
numerator as f64 / denominator as f64
|
||||
}
|
||||
}
|
||||
|
||||
/// Gradually calculate the performance attributes of an osu!mania map.
|
||||
///
|
||||
/// After each hit object you can call
|
||||
/// [`process_next_object`](`ManiaGradualPerformanceAttributes::process_next_object`)
|
||||
/// and it will return the resulting current [`ManiaPerformanceAttributes`].
|
||||
/// To process multiple objects at once, use
|
||||
/// [`process_next_n_objects`](`ManiaGradualPerformanceAttributes::process_next_n_objects`) instead.
|
||||
///
|
||||
/// Both methods require a play's current score so far.
|
||||
/// Be sure the given score is adjusted with respect to mods.
|
||||
///
|
||||
/// If you only want to calculate difficulty attributes use
|
||||
/// [`ManiaGradualDifficultyAttributes`](crate::mania::ManiaGradualDifficultyAttributes) instead.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use rosu_pp::{Beatmap, mania::{ManiaGradualPerformanceAttributes, ManiaScoreState}};
|
||||
///
|
||||
/// # /*
|
||||
/// let map: Beatmap = ...
|
||||
/// # */
|
||||
/// # let map = Beatmap::default();
|
||||
///
|
||||
/// let mods = 64; // DT
|
||||
/// let mut gradual_perf = ManiaGradualPerformanceAttributes::new(&map, mods);
|
||||
/// let mut state = ManiaScoreState::new(); // empty state, everything is on 0.
|
||||
///
|
||||
/// // The first 10 hitresults are 320s
|
||||
/// for _ in 0..10 {
|
||||
/// state.n320 += 1;
|
||||
///
|
||||
/// # /*
|
||||
/// let performance = gradual_perf.process_next_object(score).unwrap();
|
||||
/// println!("PP: {}", performance.pp);
|
||||
/// # */
|
||||
/// # let _ = gradual_perf.process_next_object(state.clone());
|
||||
/// }
|
||||
///
|
||||
/// // Then comes a miss.
|
||||
/// state.n_misses += 1;
|
||||
/// # /*
|
||||
/// let performance = gradual_perf.process_next_object(score).unwrap();
|
||||
/// println!("PP: {}", performance.pp);
|
||||
/// # */
|
||||
/// # let _ = gradual_perf.process_next_object(state.clone());
|
||||
///
|
||||
/// // The next 10 objects will be a mixture of 320s and 100s.
|
||||
/// // Notice how all 10 objects will be processed in one go.
|
||||
/// state.n320 += 3;
|
||||
/// state.n100 += 7;
|
||||
/// # /*
|
||||
/// let performance = gradual_perf.process_next_n_objects(score, 10).unwrap();
|
||||
/// println!("PP: {}", performance.pp);
|
||||
/// # */
|
||||
/// # let _ = gradual_perf.process_next_n_objects(state.clone(), 10);
|
||||
///
|
||||
/// // Skip to the end
|
||||
/// # /*
|
||||
/// state.max_combo = ...
|
||||
/// state.n300 = ...
|
||||
/// state.n100 = ...
|
||||
/// state.n_misses = ...
|
||||
/// let final_performance = gradual_perf.process_next_n_objects(state.clone(), usize::MAX).unwrap();
|
||||
/// println!("PP: {}", performance.pp);
|
||||
/// # */
|
||||
/// # let _ = gradual_perf.process_next_n_objects(state.clone(), usize::MAX);
|
||||
///
|
||||
/// // Once the final performance was calculated,
|
||||
/// // attempting to process further objects will return `None`.
|
||||
/// assert!(gradual_perf.process_next_object(state).is_none());
|
||||
/// ```
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ManiaGradualPerformanceAttributes<'map> {
|
||||
difficulty: ManiaGradualDifficultyAttributes<'map>,
|
||||
performance: ManiaPP<'map>,
|
||||
}
|
||||
|
||||
impl<'map> ManiaGradualPerformanceAttributes<'map> {
|
||||
/// Create a new gradual performance calculator for osu!mania maps.
|
||||
pub fn new(map: &'map Beatmap, mods: u32) -> Self {
|
||||
let difficulty = ManiaGradualDifficultyAttributes::new(map, mods);
|
||||
let performance = ManiaPP::new(map).mods(mods).passed_objects(0);
|
||||
|
||||
Self {
|
||||
difficulty,
|
||||
performance,
|
||||
}
|
||||
}
|
||||
|
||||
/// Process the next hit object and calculate the
|
||||
/// performance attributes for the resulting score.
|
||||
pub fn process_next_object(
|
||||
&mut self,
|
||||
state: ManiaScoreState,
|
||||
) -> Option<ManiaPerformanceAttributes> {
|
||||
self.process_next_n_objects(state, 1)
|
||||
}
|
||||
|
||||
/// Same as [`process_next_object`](`ManiaGradualPerformanceAttributes::process_next_object`)
|
||||
/// but instead of processing only one object it process `n` many.
|
||||
///
|
||||
/// If `n` is 0 it will be considered as 1.
|
||||
/// If there are still objects to be processed but `n` is larger than the amount
|
||||
/// of remaining objects, `n` will be considered as the amount of remaining objects.
|
||||
pub fn process_next_n_objects(
|
||||
&mut self,
|
||||
state: ManiaScoreState,
|
||||
n: usize,
|
||||
) -> Option<ManiaPerformanceAttributes> {
|
||||
let sub = (self.difficulty.idx == 0) as usize;
|
||||
let difficulty = self.difficulty.nth(n.saturating_sub(sub))?;
|
||||
|
||||
let performance = self
|
||||
.performance
|
||||
.clone()
|
||||
.attributes(difficulty)
|
||||
.state(state)
|
||||
.passed_objects(self.difficulty.idx)
|
||||
.calculate();
|
||||
|
||||
Some(performance)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
use crate::{
|
||||
curve::{Curve, CurveBuffers},
|
||||
parse::{HitObject, HitObjectKind},
|
||||
Beatmap,
|
||||
};
|
||||
|
||||
const BASE_SCORING_DISTANCE: f64 = 100.0;
|
||||
|
||||
pub(crate) struct ObjectParameters<'a> {
|
||||
pub(crate) map: &'a Beatmap,
|
||||
pub(crate) max_combo: usize,
|
||||
pub(crate) curve_bufs: CurveBuffers,
|
||||
}
|
||||
|
||||
impl<'a> ObjectParameters<'a> {
|
||||
pub(crate) fn new(map: &'a Beatmap) -> Self {
|
||||
Self {
|
||||
map,
|
||||
max_combo: 0,
|
||||
curve_bufs: CurveBuffers::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct ManiaObject {
|
||||
pub(crate) start_time: f64,
|
||||
pub(crate) end_time: f64,
|
||||
pub(crate) column: usize,
|
||||
}
|
||||
|
||||
impl ManiaObject {
|
||||
pub(crate) fn column(x: f32, total_columns: f32) -> usize {
|
||||
let x_divisor = 512.0 / total_columns;
|
||||
|
||||
(x / x_divisor).floor().min(total_columns - 1.0) as usize
|
||||
}
|
||||
|
||||
pub(crate) fn new(
|
||||
h: &HitObject,
|
||||
total_columns: f32,
|
||||
params: &mut ObjectParameters<'_>,
|
||||
) -> Self {
|
||||
let ObjectParameters {
|
||||
map,
|
||||
max_combo,
|
||||
curve_bufs,
|
||||
} = params;
|
||||
|
||||
let column = Self::column(h.pos.x, total_columns);
|
||||
*max_combo += 1;
|
||||
|
||||
match &h.kind {
|
||||
HitObjectKind::Circle => Self {
|
||||
start_time: h.start_time,
|
||||
end_time: h.start_time,
|
||||
column,
|
||||
},
|
||||
HitObjectKind::Slider {
|
||||
pixel_len,
|
||||
repeats,
|
||||
control_points,
|
||||
..
|
||||
} => {
|
||||
let span_count = *repeats as f64 + 1.0;
|
||||
|
||||
let curve = Curve::new(control_points, *pixel_len, curve_bufs);
|
||||
let dist = curve.dist();
|
||||
|
||||
let timing_point = map.timing_point_at(h.start_time);
|
||||
let difficulty_point = map.difficulty_point_at(h.start_time).unwrap_or_default();
|
||||
|
||||
let scoring_dist =
|
||||
BASE_SCORING_DISTANCE * map.slider_mult * difficulty_point.slider_vel;
|
||||
|
||||
let vel = scoring_dist / timing_point.beat_len;
|
||||
let duration = span_count * dist / vel;
|
||||
let end_time = h.start_time + duration;
|
||||
|
||||
*max_combo += (duration / 100.0) as usize;
|
||||
|
||||
Self {
|
||||
start_time: h.start_time,
|
||||
end_time,
|
||||
column,
|
||||
}
|
||||
}
|
||||
HitObjectKind::Spinner { end_time } | HitObjectKind::Hold { end_time } => {
|
||||
*max_combo += ((*end_time - h.start_time) / 100.0) as usize;
|
||||
|
||||
Self {
|
||||
start_time: h.start_time,
|
||||
end_time: *end_time,
|
||||
column,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
mod difficulty_object;
|
||||
mod gradual_difficulty;
|
||||
mod gradual_performance;
|
||||
mod mania_object;
|
||||
mod pp;
|
||||
mod skills;
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
use crate::{beatmap::BeatmapHitWindows, util::FloatExt, Beatmap, GameMode, Mods, OsuStars};
|
||||
|
||||
pub use self::{gradual_difficulty::*, gradual_performance::*, pp::*};
|
||||
|
||||
pub(crate) use self::mania_object::ManiaObject;
|
||||
|
||||
use self::{
|
||||
difficulty_object::ManiaDifficultyObject,
|
||||
mania_object::ObjectParameters,
|
||||
skills::{Skill, Strain},
|
||||
};
|
||||
|
||||
const SECTION_LEN: f64 = 400.0;
|
||||
const STAR_SCALING_FACTOR: f64 = 0.018;
|
||||
|
||||
/// Difficulty calculator on osu!mania maps.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use rosu_pp::{ManiaStars, Beatmap};
|
||||
///
|
||||
/// # /*
|
||||
/// let map: Beatmap = ...
|
||||
/// # */
|
||||
/// # let map = Beatmap::default();
|
||||
///
|
||||
/// let difficulty_attrs = ManiaStars::new(&map)
|
||||
/// .mods(8 + 64) // HDDT
|
||||
/// .calculate();
|
||||
///
|
||||
/// println!("Stars: {}", difficulty_attrs.stars);
|
||||
/// ```
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ManiaStars<'map> {
|
||||
map: Cow<'map, Beatmap>,
|
||||
mods: u32,
|
||||
passed_objects: Option<usize>,
|
||||
clock_rate: Option<f64>,
|
||||
is_convert: bool,
|
||||
}
|
||||
|
||||
impl<'map> ManiaStars<'map> {
|
||||
/// Create a new difficulty calculator for osu!mania maps.
|
||||
#[inline]
|
||||
pub fn new(map: &'map Beatmap) -> Self {
|
||||
let map = map.convert_mode(GameMode::Mania);
|
||||
let is_convert = matches!(map, Cow::Owned(_));
|
||||
|
||||
Self {
|
||||
map,
|
||||
mods: 0,
|
||||
passed_objects: None,
|
||||
clock_rate: None,
|
||||
is_convert,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
}
|
||||
|
||||
/// Amount of passed objects for partial plays, e.g. a fail.
|
||||
///
|
||||
/// If you want to calculate the difficulty after every few objects, instead of
|
||||
/// using [`ManiaStars`] multiple times with different `passed_objects`, you should use
|
||||
/// [`ManiaGradualDifficultyAttributes`](crate::mania::ManiaGradualDifficultyAttributes).
|
||||
#[inline]
|
||||
pub fn passed_objects(mut self, passed_objects: usize) -> Self {
|
||||
self.passed_objects = Some(passed_objects);
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
/// Adjust the clock rate used in the calculation.
|
||||
/// If none is specified, it will take the clock rate based on the mods
|
||||
/// i.e. 1.5 for DT, 0.75 for HT and 1.0 otherwise.
|
||||
#[inline]
|
||||
pub fn clock_rate(mut self, clock_rate: f64) -> Self {
|
||||
self.clock_rate = Some(clock_rate);
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
/// Specify whether the map is a convert i.e. an osu!standard map.
|
||||
#[inline]
|
||||
pub fn is_convert(mut self, is_convert: bool) -> Self {
|
||||
self.is_convert = is_convert;
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
/// Calculate all difficulty related values, including stars.
|
||||
#[inline]
|
||||
pub fn calculate(self) -> ManiaDifficultyAttributes {
|
||||
let is_convert = self.is_convert || matches!(self.map, Cow::Owned(_));
|
||||
|
||||
let clock_rate = self.clock_rate.unwrap_or_else(|| self.mods.clock_rate());
|
||||
|
||||
let BeatmapHitWindows { od: hit_window, .. } = self
|
||||
.map
|
||||
.attributes()
|
||||
.mods(self.mods)
|
||||
.converted(is_convert)
|
||||
.clock_rate(clock_rate)
|
||||
.hit_windows();
|
||||
|
||||
let ManiaResult { strain, max_combo } = calculate_result(self);
|
||||
|
||||
ManiaDifficultyAttributes {
|
||||
stars: strain.difficulty_value() * STAR_SCALING_FACTOR,
|
||||
hit_window,
|
||||
max_combo,
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate the skill strains.
|
||||
///
|
||||
/// Suitable to plot the difficulty of a map over time.
|
||||
#[inline]
|
||||
pub fn strains(self) -> ManiaStrains {
|
||||
let ManiaResult { strain, .. } = calculate_result(self);
|
||||
|
||||
ManiaStrains {
|
||||
section_len: SECTION_LEN,
|
||||
strains: strain.strain_peaks,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The result of calculating the strains on a osu!taiko map.
|
||||
/// Suitable to plot the difficulty of a map over time.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ManiaStrains {
|
||||
/// Time in ms inbetween two strains.
|
||||
pub section_len: f64,
|
||||
/// Strain peaks of the strain skill.
|
||||
pub strains: Vec<f64>,
|
||||
}
|
||||
|
||||
impl ManiaStrains {
|
||||
/// Returns the number of strain peaks per skill.
|
||||
#[inline]
|
||||
#[allow(clippy::len_without_is_empty)]
|
||||
pub fn len(&self) -> usize {
|
||||
self.strains.len()
|
||||
}
|
||||
}
|
||||
|
||||
fn calculate_result(params: ManiaStars<'_>) -> ManiaResult {
|
||||
let ManiaStars {
|
||||
map,
|
||||
mods,
|
||||
passed_objects,
|
||||
clock_rate,
|
||||
is_convert: _,
|
||||
} = params;
|
||||
|
||||
let take = passed_objects.unwrap_or(map.hit_objects.len());
|
||||
let total_columns = map.cs.round_even().max(1.0);
|
||||
|
||||
let clock_rate = clock_rate.unwrap_or_else(|| mods.clock_rate());
|
||||
let mut strain = Strain::new(total_columns as usize);
|
||||
let mut params = ObjectParameters::new(map.as_ref());
|
||||
let mut hit_objects = map.hit_objects.iter().take(take);
|
||||
|
||||
let first = match hit_objects.next() {
|
||||
Some(h) => ManiaObject::new(h, total_columns, &mut params),
|
||||
None => {
|
||||
return ManiaResult {
|
||||
strain,
|
||||
max_combo: 0,
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let diff_objects_iter = hit_objects.enumerate().scan(first, |last, (i, h)| {
|
||||
let base = ManiaObject::new(h, total_columns, &mut params);
|
||||
let diff_object = ManiaDifficultyObject::new(&base, &*last, clock_rate, i);
|
||||
*last = base;
|
||||
|
||||
Some(diff_object)
|
||||
});
|
||||
|
||||
let mut diff_objects = Vec::with_capacity(map.hit_objects.len().min(take).saturating_sub(1));
|
||||
diff_objects.extend(diff_objects_iter);
|
||||
|
||||
for curr in diff_objects.iter() {
|
||||
strain.process(curr, &diff_objects);
|
||||
}
|
||||
|
||||
ManiaResult {
|
||||
strain,
|
||||
max_combo: params.max_combo,
|
||||
}
|
||||
}
|
||||
|
||||
struct ManiaResult {
|
||||
strain: Strain,
|
||||
max_combo: usize,
|
||||
}
|
||||
|
||||
/// The result of a difficulty calculation on an osu!mania map.
|
||||
#[derive(Copy, Clone, Debug, Default, PartialEq)]
|
||||
pub struct ManiaDifficultyAttributes {
|
||||
/// The final star rating.
|
||||
pub stars: f64,
|
||||
/// The perceived hit window for an n300 inclusive of rate-adjusting mods (DT/HT/etc).
|
||||
pub hit_window: f64,
|
||||
/// The maximum achievable combo.
|
||||
pub max_combo: usize,
|
||||
}
|
||||
|
||||
impl ManiaDifficultyAttributes {
|
||||
/// Return the maximum combo.
|
||||
#[inline]
|
||||
pub fn max_combo(&self) -> usize {
|
||||
self.max_combo
|
||||
}
|
||||
}
|
||||
|
||||
/// The result of a performance calculation on an osu!mania map.
|
||||
#[derive(Copy, Clone, Debug, Default, PartialEq)]
|
||||
pub struct ManiaPerformanceAttributes {
|
||||
/// The difficulty attributes that were used for the performance calculation.
|
||||
pub difficulty: ManiaDifficultyAttributes,
|
||||
/// The final performance points.
|
||||
pub pp: f64,
|
||||
/// The difficulty portion of the final pp.
|
||||
pub pp_difficulty: f64,
|
||||
}
|
||||
|
||||
impl ManiaPerformanceAttributes {
|
||||
/// Return the star value.
|
||||
#[inline]
|
||||
pub fn stars(&self) -> f64 {
|
||||
self.difficulty.stars
|
||||
}
|
||||
|
||||
/// Return the performance point value.
|
||||
#[inline]
|
||||
pub fn pp(&self) -> f64 {
|
||||
self.pp
|
||||
}
|
||||
|
||||
/// Return the maximum combo of the map.
|
||||
#[inline]
|
||||
pub fn max_combo(&self) -> usize {
|
||||
self.difficulty.max_combo
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ManiaPerformanceAttributes> for ManiaDifficultyAttributes {
|
||||
#[inline]
|
||||
fn from(attributes: ManiaPerformanceAttributes) -> Self {
|
||||
attributes.difficulty
|
||||
}
|
||||
}
|
||||
|
||||
impl<'map> From<OsuStars<'map>> for ManiaStars<'map> {
|
||||
#[inline]
|
||||
fn from(osu: OsuStars<'map>) -> Self {
|
||||
let OsuStars {
|
||||
map,
|
||||
mods,
|
||||
passed_objects,
|
||||
clock_rate,
|
||||
} = osu;
|
||||
|
||||
Self {
|
||||
map: map.convert_mode(GameMode::Mania),
|
||||
mods,
|
||||
passed_objects,
|
||||
clock_rate,
|
||||
is_convert: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
+1063
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,18 @@
|
||||
mod strain;
|
||||
mod traits;
|
||||
|
||||
pub(crate) use self::{
|
||||
strain::Strain,
|
||||
traits::{Skill, StrainDecaySkill, StrainSkill},
|
||||
};
|
||||
|
||||
use super::difficulty_object::ManiaDifficultyObject;
|
||||
|
||||
fn previous(
|
||||
diff_objects: &[ManiaDifficultyObject],
|
||||
curr: usize,
|
||||
backwards_idx: usize,
|
||||
) -> Option<&ManiaDifficultyObject> {
|
||||
curr.checked_sub(backwards_idx + 1)
|
||||
.and_then(|idx| diff_objects.get(idx))
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
use crate::mania::difficulty_object::ManiaDifficultyObject;
|
||||
|
||||
use super::{previous, Skill, StrainDecaySkill, StrainSkill};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct Strain {
|
||||
start_times: Vec<f64>,
|
||||
end_times: Vec<f64>,
|
||||
individual_strains: Vec<f64>,
|
||||
|
||||
individual_strain: f64,
|
||||
overall_strain: f64,
|
||||
|
||||
curr_strain: f64,
|
||||
curr_section_peak: f64,
|
||||
curr_section_end: f64,
|
||||
|
||||
pub(crate) strain_peaks: Vec<f64>,
|
||||
}
|
||||
|
||||
impl Strain {
|
||||
const INDIVIDUAL_DECAY_BASE: f64 = 0.125;
|
||||
const OVERALL_DECAY_BASE: f64 = 0.3;
|
||||
const RELEASE_THRESHOLD: f64 = 24.0;
|
||||
|
||||
pub(crate) fn new(total_columns: usize) -> Self {
|
||||
Self {
|
||||
start_times: vec![0.0; total_columns],
|
||||
end_times: vec![0.0; total_columns],
|
||||
individual_strains: vec![0.0; total_columns],
|
||||
individual_strain: 0.0,
|
||||
overall_strain: 1.0,
|
||||
curr_strain: 0.0,
|
||||
curr_section_peak: 0.0,
|
||||
curr_section_end: 0.0,
|
||||
strain_peaks: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_decay(value: f64, delta_time: f64, decay_base: f64) -> f64 {
|
||||
value * decay_base.powf(delta_time / 1000.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl Skill for Strain {
|
||||
#[inline]
|
||||
fn process(&mut self, curr: &ManiaDifficultyObject, diff_objects: &[ManiaDifficultyObject]) {
|
||||
<Self as StrainSkill>::process(self, curr, diff_objects)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn difficulty_value(self) -> f64 {
|
||||
<Self as StrainSkill>::difficulty_value(self)
|
||||
}
|
||||
}
|
||||
|
||||
impl StrainSkill for Strain {
|
||||
const DECAY_WEIGHT: f64 = 0.9;
|
||||
|
||||
#[inline]
|
||||
fn curr_section_end(&self) -> f64 {
|
||||
self.curr_section_end
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn curr_section_end_mut(&mut self) -> &mut f64 {
|
||||
&mut self.curr_section_end
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn curr_section_peak(&self) -> f64 {
|
||||
self.curr_section_peak
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn curr_section_peak_mut(&mut self) -> &mut f64 {
|
||||
&mut self.curr_section_peak
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn strain_peaks_mut(&mut self) -> &mut Vec<f64> {
|
||||
&mut self.strain_peaks
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn strain_value_at(&mut self, curr: &ManiaDifficultyObject) -> f64 {
|
||||
<Self as StrainDecaySkill>::strain_value_at(self, curr)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn calculate_initial_strain(
|
||||
&self,
|
||||
time: f64,
|
||||
curr: &ManiaDifficultyObject,
|
||||
diff_objects: &[ManiaDifficultyObject],
|
||||
) -> f64 {
|
||||
<Self as StrainDecaySkill>::calculate_initial_strain(self, time, curr, diff_objects)
|
||||
}
|
||||
}
|
||||
|
||||
impl StrainDecaySkill for Strain {
|
||||
const SKILL_MULTIPLIER: f64 = 1.0;
|
||||
const STRAIN_DECAY_BASE: f64 = 1.0;
|
||||
|
||||
#[inline]
|
||||
fn curr_strain(&self) -> f64 {
|
||||
self.curr_strain
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn curr_strain_mut(&mut self) -> &mut f64 {
|
||||
&mut self.curr_strain
|
||||
}
|
||||
|
||||
fn strain_value_of(&mut self, curr: &ManiaDifficultyObject) -> f64 {
|
||||
let mania_curr = curr;
|
||||
let start_time = mania_curr.start_time;
|
||||
let end_time = mania_curr.end_time;
|
||||
let col = mania_curr.base_column;
|
||||
let mut is_overlapping = false;
|
||||
|
||||
// * Lowest value we can assume with the current information
|
||||
let mut closest_end_time = (end_time - start_time).abs();
|
||||
// * Factor to all additional strains in case something else is held
|
||||
let mut hold_factor = 1.0;
|
||||
// * Addition to the current note in case it's a hold and has to be released awkwardly
|
||||
let mut hold_addition = 0.0;
|
||||
|
||||
for i in 0..self.end_times.len() {
|
||||
// * The current note is overlapped if a previous note or end is overlapping the current note body
|
||||
is_overlapping |=
|
||||
self.end_times[i] > start_time + 1.0 && end_time > self.end_times[i] + 1.0;
|
||||
|
||||
// * We give a slight bonus to everything if something is held meanwhile
|
||||
if self.end_times[i] > end_time + 1.0 {
|
||||
hold_factor = 1.25;
|
||||
}
|
||||
|
||||
closest_end_time = (end_time - self.end_times[i]).abs().min(closest_end_time);
|
||||
}
|
||||
|
||||
// * The hold addition is given if there was an overlap, however it is only valid if there are no other note with a similar ending.
|
||||
// * Releasing multiple notes is just as easy as releasing 1. Nerfs the hold addition by half if the closest release is release_threshold away.
|
||||
// * holdAddition
|
||||
// * ^
|
||||
// * 1.0 + - - - - - -+-----------
|
||||
// * | /
|
||||
// * 0.5 + - - - - -/ Sigmoid Curve
|
||||
// * | /|
|
||||
// * 0.0 +--------+-+---------------> Release Difference / ms
|
||||
// * release_threshold
|
||||
if is_overlapping {
|
||||
hold_addition =
|
||||
(1.0 + (0.5 * (Self::RELEASE_THRESHOLD - closest_end_time)).exp()).recip();
|
||||
}
|
||||
|
||||
// * Decay and increase individualStrains in own column
|
||||
self.individual_strains[col] = Self::apply_decay(
|
||||
self.individual_strains[col],
|
||||
start_time - self.start_times[col],
|
||||
Self::INDIVIDUAL_DECAY_BASE,
|
||||
);
|
||||
self.individual_strains[col] += 2.0 * hold_factor;
|
||||
|
||||
// * For notes at the same time (in a chord), the individualStrain should be the hardest individualStrain out of those columns
|
||||
self.individual_strain = if mania_curr.delta_time <= 1.0 {
|
||||
self.individual_strain.max(self.individual_strains[col])
|
||||
} else {
|
||||
self.individual_strains[col]
|
||||
};
|
||||
|
||||
// * Decay and increase overallStrain
|
||||
self.overall_strain = Self::apply_decay(
|
||||
self.overall_strain,
|
||||
curr.delta_time,
|
||||
Self::OVERALL_DECAY_BASE,
|
||||
);
|
||||
self.overall_strain += (1.0 + hold_addition) * hold_factor;
|
||||
|
||||
// * Update startTimes and endTimes arrays
|
||||
self.start_times[col] = start_time;
|
||||
self.end_times[col] = end_time;
|
||||
|
||||
// * By subtracting CurrentStrain, this skill effectively only considers the maximum strain of any one hitobject within each strain section.
|
||||
self.individual_strain + self.overall_strain - self.curr_strain
|
||||
}
|
||||
|
||||
fn calculate_initial_strain(
|
||||
&self,
|
||||
offset: f64,
|
||||
curr: &ManiaDifficultyObject,
|
||||
diff_objects: &[ManiaDifficultyObject],
|
||||
) -> f64 {
|
||||
let prev_start = previous(diff_objects, curr.idx, 0).map_or(0.0, |h| h.start_time);
|
||||
|
||||
let individual_decay = Self::apply_decay(
|
||||
self.individual_strain,
|
||||
offset - prev_start,
|
||||
Self::INDIVIDUAL_DECAY_BASE,
|
||||
);
|
||||
|
||||
let overall_decay = Self::apply_decay(
|
||||
self.overall_strain,
|
||||
offset - prev_start,
|
||||
Self::OVERALL_DECAY_BASE,
|
||||
);
|
||||
|
||||
individual_decay + overall_decay
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
use std::{cmp::Ordering, mem};
|
||||
|
||||
use crate::mania::{difficulty_object::ManiaDifficultyObject, SECTION_LEN};
|
||||
|
||||
pub(crate) trait Skill {
|
||||
fn process(&mut self, curr: &ManiaDifficultyObject, diff_objects: &[ManiaDifficultyObject]);
|
||||
fn difficulty_value(self) -> f64;
|
||||
}
|
||||
|
||||
pub(crate) trait StrainSkill: Sized + Skill {
|
||||
const DECAY_WEIGHT: f64 = 0.9;
|
||||
|
||||
fn curr_section_end(&self) -> f64;
|
||||
fn curr_section_end_mut(&mut self) -> &mut f64;
|
||||
|
||||
fn curr_section_peak(&self) -> f64;
|
||||
fn curr_section_peak_mut(&mut self) -> &mut f64;
|
||||
|
||||
fn strain_peaks_mut(&mut self) -> &mut Vec<f64>;
|
||||
|
||||
fn strain_value_at(&mut self, curr: &ManiaDifficultyObject) -> f64;
|
||||
|
||||
fn process(&mut self, curr: &ManiaDifficultyObject, diff_objects: &[ManiaDifficultyObject]) {
|
||||
// * The first object doesn't generate a strain, so we begin with an incremented section end
|
||||
if curr.idx == 0 {
|
||||
*self.curr_section_end_mut() = (curr.start_time / SECTION_LEN).ceil() * SECTION_LEN;
|
||||
}
|
||||
|
||||
while curr.start_time > self.curr_section_end() {
|
||||
self.save_curr_peak();
|
||||
self.start_new_section_from(self.curr_section_end(), curr, diff_objects);
|
||||
*self.curr_section_end_mut() += SECTION_LEN;
|
||||
}
|
||||
|
||||
*self.curr_section_peak_mut() = self.strain_value_at(curr).max(self.curr_section_peak());
|
||||
}
|
||||
|
||||
fn save_curr_peak(&mut self) {
|
||||
let curr_section_peak = self.curr_section_peak();
|
||||
self.strain_peaks_mut().push(curr_section_peak);
|
||||
}
|
||||
|
||||
fn start_new_section_from(
|
||||
&mut self,
|
||||
time: f64,
|
||||
curr: &ManiaDifficultyObject,
|
||||
diff_objects: &[ManiaDifficultyObject],
|
||||
) {
|
||||
*self.curr_section_peak_mut() = self.calculate_initial_strain(time, curr, diff_objects);
|
||||
}
|
||||
|
||||
fn calculate_initial_strain(
|
||||
&self,
|
||||
time: f64,
|
||||
curr: &ManiaDifficultyObject,
|
||||
diff_objects: &[ManiaDifficultyObject],
|
||||
) -> f64;
|
||||
|
||||
fn get_curr_strain_peaks(mut self) -> Vec<f64> {
|
||||
let mut peaks = mem::take(self.strain_peaks_mut());
|
||||
peaks.push(self.curr_section_peak());
|
||||
|
||||
peaks
|
||||
}
|
||||
|
||||
fn difficulty_value(self) -> f64 {
|
||||
let mut difficulty = 0.0;
|
||||
let mut weight = 1.0;
|
||||
|
||||
// * Sections with 0 strain are excluded to avoid worst-case time complexity of the following sort (e.g. /b/2351871).
|
||||
// * These sections will not contribute to the difficulty.
|
||||
let mut peaks = self.get_curr_strain_peaks();
|
||||
peaks.retain(|&peak| peak > 0.0);
|
||||
peaks.sort_unstable_by(|a, b| b.partial_cmp(a).unwrap_or(Ordering::Equal));
|
||||
|
||||
// * Difficulty is the weighted sum of the highest strains from every section.
|
||||
// * We're sorting from highest to lowest strain.
|
||||
for strain in peaks {
|
||||
difficulty += strain * weight;
|
||||
weight *= Self::DECAY_WEIGHT;
|
||||
}
|
||||
|
||||
difficulty
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) trait StrainDecaySkill: StrainSkill {
|
||||
const SKILL_MULTIPLIER: f64;
|
||||
const STRAIN_DECAY_BASE: f64;
|
||||
|
||||
fn curr_strain(&self) -> f64;
|
||||
fn curr_strain_mut(&mut self) -> &mut f64;
|
||||
|
||||
fn strain_value_of(&mut self, curr: &ManiaDifficultyObject) -> f64;
|
||||
|
||||
fn calculate_initial_strain(
|
||||
&self,
|
||||
time: f64,
|
||||
curr: &ManiaDifficultyObject,
|
||||
diff_objects: &[ManiaDifficultyObject],
|
||||
) -> f64;
|
||||
|
||||
fn strain_value_at(&mut self, curr: &ManiaDifficultyObject) -> f64 {
|
||||
*self.curr_strain_mut() *= self.strain_decay(curr.delta_time);
|
||||
*self.curr_strain_mut() += self.strain_value_of(curr) * Self::SKILL_MULTIPLIER;
|
||||
|
||||
self.curr_strain()
|
||||
}
|
||||
|
||||
fn strain_decay(&self, ms: f64) -> f64 {
|
||||
Self::STRAIN_DECAY_BASE.powf(ms / 1000.0)
|
||||
}
|
||||
}
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
macro_rules! impl_mods {
|
||||
($func_name:ident, $const_name:ident) => {
|
||||
#[inline]
|
||||
fn $func_name(self) -> bool {
|
||||
self & Self::$const_name > 0
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// Abstract type to define mods.
|
||||
#[allow(missing_docs)]
|
||||
pub trait Mods: Copy {
|
||||
const NF: u32 = 1 << 0;
|
||||
const EZ: u32 = 1 << 1;
|
||||
const TD: u32 = 1 << 2;
|
||||
const HD: u32 = 1 << 3;
|
||||
const HR: u32 = 1 << 4;
|
||||
const DT: u32 = 1 << 6;
|
||||
const RX: u32 = 1 << 7;
|
||||
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;
|
||||
/// If object time's or positions are affected by the mods.
|
||||
fn change_map(self) -> bool;
|
||||
/// The clock rate with the mods.
|
||||
fn clock_rate(self) -> f64;
|
||||
/// Multiplier for beatmap attributes with respect to the mods.
|
||||
fn od_ar_hp_multiplier(self) -> f64;
|
||||
fn nf(self) -> bool;
|
||||
fn ez(self) -> bool;
|
||||
fn td(self) -> bool;
|
||||
fn hd(self) -> bool;
|
||||
fn hr(self) -> bool;
|
||||
fn dt(self) -> bool;
|
||||
fn rx(self) -> bool;
|
||||
fn ht(self) -> bool;
|
||||
fn fl(self) -> bool;
|
||||
fn so(self) -> bool;
|
||||
fn ap(self) -> bool;
|
||||
}
|
||||
|
||||
impl Mods for u32 {
|
||||
#[inline]
|
||||
fn change_speed(self) -> bool {
|
||||
self & (Self::HT | Self::DT) > 0
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn change_map(self) -> bool {
|
||||
self & (Self::HT | Self::DT | Self::HR | Self::EZ) > 0
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn clock_rate(self) -> f64 {
|
||||
if self & Self::DT > 0 {
|
||||
1.5
|
||||
} else if self & Self::HT > 0 {
|
||||
0.75
|
||||
} else {
|
||||
1.0
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn od_ar_hp_multiplier(self) -> f64 {
|
||||
if self & Self::HR > 0 {
|
||||
1.4
|
||||
} else if self & Self::EZ > 0 {
|
||||
0.5
|
||||
} else {
|
||||
1.0
|
||||
}
|
||||
}
|
||||
|
||||
impl_mods!(nf, NF);
|
||||
impl_mods!(ez, EZ);
|
||||
impl_mods!(td, TD);
|
||||
impl_mods!(hd, HD);
|
||||
impl_mods!(hr, HR);
|
||||
impl_mods!(dt, DT);
|
||||
impl_mods!(rx, RX);
|
||||
impl_mods!(ht, HT);
|
||||
impl_mods!(fl, FL);
|
||||
impl_mods!(so, SO);
|
||||
impl_mods!(ap, AP);
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
use crate::{
|
||||
osu::osu_object::{NestedObjectKind, OsuObjectKind},
|
||||
parse::Pos2,
|
||||
};
|
||||
|
||||
use super::{osu_object::OsuSlider, OsuObject, ScalingFactor};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct OsuDifficultyObject<'h> {
|
||||
pub(crate) start_time: f64,
|
||||
pub(crate) delta_time: f64,
|
||||
pub(crate) base: &'h OsuObject,
|
||||
pub(crate) strain_time: f64,
|
||||
pub(crate) dists: Distances,
|
||||
pub(crate) idx: usize,
|
||||
}
|
||||
|
||||
impl<'h> OsuDifficultyObject<'h> {
|
||||
pub(crate) const MIN_DELTA_TIME: u32 = 25;
|
||||
|
||||
pub(crate) fn new(
|
||||
base: &'h OsuObject,
|
||||
last: &'h OsuObject,
|
||||
clock_rate: f64,
|
||||
idx: usize,
|
||||
dists: Distances,
|
||||
) -> Self {
|
||||
let start_time = base.start_time / clock_rate;
|
||||
let delta_time = (base.start_time - last.start_time) / clock_rate;
|
||||
|
||||
// * Capped to 25ms to prevent difficulty calculation breaking from simultaneous objects.
|
||||
let strain_time = delta_time.max(Self::MIN_DELTA_TIME as f64);
|
||||
|
||||
Self {
|
||||
start_time,
|
||||
delta_time,
|
||||
base,
|
||||
strain_time,
|
||||
dists,
|
||||
idx,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn opacity_at(
|
||||
&self,
|
||||
time: f64,
|
||||
hidden: bool,
|
||||
time_preempt: f64,
|
||||
time_fade_in: f64,
|
||||
) -> f64 {
|
||||
if time > self.base.start_time {
|
||||
// * Consider a hitobject as being invisible when its start time is passed.
|
||||
// * In reality the hitobject will be visible beyond its start time up until its hittable window has passed,
|
||||
// * but this is an approximation and such a case is unlikely to be hit where this function is used.
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
let fade_in_start_time = self.base.start_time - time_preempt;
|
||||
let fade_in_duration = time_fade_in;
|
||||
|
||||
if hidden {
|
||||
// * Taken from OsuModHidden.
|
||||
let fade_out_start_time = self.base.start_time - time_preempt + time_fade_in;
|
||||
const FADE_OUT_DURATION_MULTIPLIER: f64 = 0.3;
|
||||
let fade_out_duration = time_preempt * FADE_OUT_DURATION_MULTIPLIER;
|
||||
|
||||
(((time - fade_in_start_time) / fade_in_duration).clamp(0.0, 1.0))
|
||||
.min(1.0 - ((time - fade_out_start_time) / fade_out_duration).clamp(0.0, 1.0))
|
||||
} else {
|
||||
((time - fade_in_start_time) / fade_in_duration).clamp(0.0, 1.0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(crate) struct Distances {
|
||||
pub(crate) lazy_jump_dist: f64,
|
||||
pub(crate) lazy_travel_dist: f32,
|
||||
pub(crate) min_jump_dist: f64,
|
||||
pub(crate) min_jump_time: f64,
|
||||
pub(crate) travel_dist: f64,
|
||||
pub(crate) travel_time: f64,
|
||||
pub(crate) angle: Option<f64>,
|
||||
}
|
||||
|
||||
impl Distances {
|
||||
pub(crate) const NORMALISED_RADIUS: f32 = 50.0;
|
||||
|
||||
const MAXIMUM_SLIDER_RADIUS: f32 = Self::NORMALISED_RADIUS * 2.4;
|
||||
const ASSUMED_SLIDER_RADIUS: f32 = Self::NORMALISED_RADIUS * 1.8;
|
||||
|
||||
pub(crate) fn new(
|
||||
base: &mut OsuObject,
|
||||
last: &OsuObject,
|
||||
last_last: Option<&OsuObject>,
|
||||
clock_rate: f64,
|
||||
strain_time: f64,
|
||||
scaling_factor_: &ScalingFactor,
|
||||
) -> Self {
|
||||
let mut this =
|
||||
if let Some(slider_values) = Self::compute_slider_cursor_pos(base, scaling_factor_) {
|
||||
let SliderValues {
|
||||
lazy_travel_dist,
|
||||
slider,
|
||||
} = slider_values;
|
||||
|
||||
let repeat_count = slider.repeat_count();
|
||||
|
||||
Self {
|
||||
// * Bonus for repeat sliders until a better per nested object strain system can be achieved.
|
||||
travel_dist: (lazy_travel_dist
|
||||
* (1.0 + repeat_count as f64 / 2.5).powf(1.0 / 2.5) as f32)
|
||||
as f64,
|
||||
travel_time: (base.lazy_travel_time() / clock_rate)
|
||||
.max(OsuDifficultyObject::MIN_DELTA_TIME as f64),
|
||||
lazy_travel_dist,
|
||||
..Default::default()
|
||||
}
|
||||
} else {
|
||||
Self::default()
|
||||
};
|
||||
|
||||
// * We don't need to calculate either angle or distance when
|
||||
// * one of the last->curr objects is a spinner
|
||||
if base.is_spinner() || last.is_spinner() {
|
||||
return this;
|
||||
}
|
||||
|
||||
// * We will scale distances by this factor, so we can assume a uniform CircleSize among beatmaps.
|
||||
let scaling_factor = scaling_factor_.factor;
|
||||
|
||||
let last_cursor_pos = Self::get_end_cursor_pos(last);
|
||||
|
||||
this.lazy_jump_dist = (base.stacked_pos() * scaling_factor
|
||||
- last_cursor_pos * scaling_factor)
|
||||
.length() as f64;
|
||||
this.min_jump_time = strain_time;
|
||||
this.min_jump_dist = this.lazy_jump_dist;
|
||||
|
||||
if let OsuObjectKind::Slider(slider) = &last.kind {
|
||||
let last_travel_time = (last.lazy_travel_time() / clock_rate)
|
||||
.max(OsuDifficultyObject::MIN_DELTA_TIME as f64);
|
||||
this.min_jump_time =
|
||||
(strain_time - last_travel_time).max(OsuDifficultyObject::MIN_DELTA_TIME as f64);
|
||||
|
||||
// * There are two types of slider-to-object patterns to consider in order
|
||||
// * to better approximate the real movement a player will take to jump between the hitobjects.
|
||||
// *
|
||||
// * 1. The anti-flow pattern, where players cut the slider short in order to move to the next hitobject.
|
||||
// *
|
||||
// * <======o==> ← slider
|
||||
// * | ← most natural jump path
|
||||
// * o ← a follow-up hitcircle
|
||||
// *
|
||||
// * In this case the most natural jump path is approximated by LazyJumpDistance.
|
||||
// *
|
||||
// * 2. The flow pattern, where players follow through the slider to its
|
||||
// * visual extent into the next hitobject.
|
||||
// *
|
||||
// * <======o==>---o
|
||||
// * ↑
|
||||
// * most natural jump path
|
||||
// *
|
||||
// * In this case the most natural jump path is better approximated by a new distance
|
||||
// * called "tailJumpDistance" - the distance between the slider's tail and the next hitobject.
|
||||
// *
|
||||
// * Thus, the player is assumed to jump the minimum of these two distances in all cases.
|
||||
|
||||
let stacked_tail_pos =
|
||||
slider.tail().map_or_else(|| last.pos(), |tail| tail.pos) + last.stack_offset;
|
||||
|
||||
let tail_jump_dist = (stacked_tail_pos - base.stacked_pos()).length() * scaling_factor;
|
||||
|
||||
this.min_jump_dist = (this.lazy_jump_dist
|
||||
- (Self::MAXIMUM_SLIDER_RADIUS - Self::ASSUMED_SLIDER_RADIUS) as f64)
|
||||
.min((tail_jump_dist - Self::MAXIMUM_SLIDER_RADIUS) as f64)
|
||||
.max(0.0);
|
||||
}
|
||||
|
||||
if let Some(last_last) = last_last.filter(|obj| !obj.is_spinner()) {
|
||||
let last_last_cursor_pos = Self::get_end_cursor_pos(last_last);
|
||||
|
||||
let v1 = last_last_cursor_pos - last.stacked_pos();
|
||||
let v2 = base.stacked_pos() - last_cursor_pos;
|
||||
|
||||
let dot = v1.dot(v2) as f64;
|
||||
let det = (v1.x * v2.y - v1.y * v2.x) as f64;
|
||||
|
||||
this.angle = Some(det.atan2(dot).abs());
|
||||
}
|
||||
|
||||
this
|
||||
}
|
||||
|
||||
pub(crate) fn compute_slider_cursor_pos<'h>(
|
||||
hit_object: &'h mut OsuObject,
|
||||
scaling_factor_: &ScalingFactor,
|
||||
) -> Option<SliderValues<'h>> {
|
||||
let pos = hit_object.pos();
|
||||
|
||||
let slider = if let OsuObjectKind::Slider(slider) = &mut hit_object.kind {
|
||||
slider
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
|
||||
let mut curr_cursor_pos = pos + hit_object.stack_offset;
|
||||
let scaling_factor = Self::NORMALISED_RADIUS as f64 / scaling_factor_.radius as f64;
|
||||
|
||||
let mut lazy_travel_dist: f32 = 0.0;
|
||||
|
||||
for (curr_movement_obj, i) in slider.nested_iter().zip(1..) {
|
||||
let mut curr_movement =
|
||||
(curr_movement_obj.pos + hit_object.stack_offset) - curr_cursor_pos;
|
||||
let mut curr_movement_len = scaling_factor * curr_movement.length() as f64;
|
||||
|
||||
// * Amount of movement required so that the cursor position needs to be updated.
|
||||
let mut required_movement = Self::ASSUMED_SLIDER_RADIUS as f64;
|
||||
|
||||
if i == slider.nested_len() {
|
||||
// * The end of a slider has special aim rules due
|
||||
// * to the relaxed time constraint on position.
|
||||
// * There is both a lazy end position as well as the actual end slider position.
|
||||
// * We assume the player takes the simpler movement.
|
||||
// * For sliders that are circular, the lazy end position
|
||||
// * may actually be farther away than the sliders true end.
|
||||
// * This code is designed to prevent buffing situations
|
||||
// * where lazy end is actually a less efficient movement.
|
||||
let lazy_movement = slider.lazy_end_pos - curr_cursor_pos;
|
||||
|
||||
if lazy_movement.length() < curr_movement.length() {
|
||||
curr_movement = lazy_movement;
|
||||
}
|
||||
|
||||
curr_movement_len = scaling_factor * curr_movement.length() as f64;
|
||||
} else if let NestedObjectKind::Repeat = curr_movement_obj.kind {
|
||||
// * For a slider repeat, assume a tighter movement threshold to better assess repeat sliders.
|
||||
required_movement = Self::NORMALISED_RADIUS as f64;
|
||||
}
|
||||
|
||||
if curr_movement_len > required_movement {
|
||||
// * this finds the positional delta from the required radius and the current position, and updates the currCursorPosition accordingly, as well as rewarding distance.
|
||||
curr_cursor_pos += curr_movement
|
||||
* ((curr_movement_len - required_movement) / curr_movement_len) as f32;
|
||||
curr_movement_len *= (curr_movement_len - required_movement) / curr_movement_len;
|
||||
lazy_travel_dist += curr_movement_len as f32;
|
||||
}
|
||||
}
|
||||
|
||||
slider.lazy_end_pos = curr_cursor_pos;
|
||||
|
||||
Some(SliderValues {
|
||||
lazy_travel_dist,
|
||||
slider,
|
||||
})
|
||||
}
|
||||
|
||||
fn get_end_cursor_pos(hit_object: &OsuObject) -> Pos2 {
|
||||
hit_object.lazy_end_pos()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct SliderValues<'s> {
|
||||
lazy_travel_dist: f32,
|
||||
slider: &'s OsuSlider,
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
use std::{
|
||||
fmt::{Debug, Formatter, Result as FmtResult},
|
||||
mem,
|
||||
};
|
||||
|
||||
use crate::{curve::CurveBuffers, Beatmap, Mods};
|
||||
|
||||
use super::{
|
||||
difficulty_object::{Distances, OsuDifficultyObject},
|
||||
old_stacking,
|
||||
osu_object::{ObjectParameters, OsuObject, OsuObjectKind},
|
||||
scaling_factor::ScalingFactor,
|
||||
skills::{OsuStrainSkill, Skills},
|
||||
stacking, OsuDifficultyAttributes, DIFFICULTY_MULTIPLIER, FADE_IN_DURATION_MULTIPLIER,
|
||||
PERFORMANCE_BASE_MULTIPLIER, PREEMPT_MIN,
|
||||
};
|
||||
|
||||
/// Gradually calculate the difficulty attributes of an osu!standard map.
|
||||
///
|
||||
/// Note that this struct implements [`Iterator`](std::iter::Iterator).
|
||||
/// On every call of [`Iterator::next`](std::iter::Iterator::next), the map's next hit object will
|
||||
/// be processed and the [`OsuDifficultyAttributes`] will be updated and returned.
|
||||
///
|
||||
/// If you want to calculate performance attributes, use
|
||||
/// [`OsuGradualPerformanceAttributes`](crate::osu::OsuGradualPerformanceAttributes) instead.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use rosu_pp::{Beatmap, osu::OsuGradualDifficultyAttributes};
|
||||
///
|
||||
/// # /*
|
||||
/// let map: Beatmap = ...
|
||||
/// # */
|
||||
/// # let map = Beatmap::default();
|
||||
///
|
||||
/// let mods = 64; // DT
|
||||
/// let mut iter = OsuGradualDifficultyAttributes::new(&map, mods);
|
||||
///
|
||||
/// let attrs1 = iter.next(); // the difficulty of the map after the first hit object
|
||||
/// let attrs2 = iter.next(); // after the second hit object
|
||||
///
|
||||
/// // Remaining hit objects
|
||||
/// for difficulty in iter {
|
||||
/// // ...
|
||||
/// }
|
||||
/// ```
|
||||
#[derive(Clone)]
|
||||
pub struct OsuGradualDifficultyAttributes {
|
||||
pub(crate) idx: usize,
|
||||
mods: u32,
|
||||
attrs: OsuDifficultyAttributes,
|
||||
// Unused but `diff_objects`' lifetimes secretly depend on it
|
||||
#[allow(unused)]
|
||||
hit_objects: Vec<OsuObject>,
|
||||
diff_objects: Vec<OsuDifficultyObject<'static>>,
|
||||
skills: Skills,
|
||||
}
|
||||
|
||||
impl Debug for OsuGradualDifficultyAttributes {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
|
||||
f.debug_struct("OsuGradualDifficultyAttributes")
|
||||
.field("idx", &self.idx)
|
||||
.field("attrs", &self.attrs)
|
||||
.field("diff_objects", &self.diff_objects)
|
||||
.field("skills", &self.skills)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl OsuGradualDifficultyAttributes {
|
||||
/// Create a new difficulty attributes iterator for osu!standard maps.
|
||||
pub fn new(map: &Beatmap, mods: u32) -> Self {
|
||||
let clock_rate = mods.clock_rate();
|
||||
let map_attrs = map.attributes().mods(mods).build();
|
||||
let scaling_factor = ScalingFactor::new(map_attrs.cs);
|
||||
let hr = mods.hr();
|
||||
let hit_window = 2.0 * map_attrs.hit_windows.od;
|
||||
let time_preempt = (map_attrs.hit_windows.ar * clock_rate) as f32 as f64;
|
||||
|
||||
// * Preempt time can go below 450ms. Normally, this is achieved via the DT mod
|
||||
// * which uniformly speeds up all animations game wide regardless of AR.
|
||||
// * This uniform speedup is hard to match 1:1, however we can at least make
|
||||
// * AR>10 (via mods) feel good by extending the upper linear function above.
|
||||
// * Note that this doesn't exactly match the AR>10 visuals as they're
|
||||
// * classically known, but it feels good.
|
||||
// * This adjustment is necessary for AR>10, otherwise TimePreempt can
|
||||
// * become smaller leading to hitcircles not fully fading in.
|
||||
let time_fade_in = if mods.hd() {
|
||||
time_preempt * FADE_IN_DURATION_MULTIPLIER
|
||||
} else {
|
||||
400.0 * (time_preempt / PREEMPT_MIN).min(1.0)
|
||||
};
|
||||
|
||||
let mut attrs = OsuDifficultyAttributes {
|
||||
ar: map_attrs.ar,
|
||||
hp: map_attrs.hp,
|
||||
od: map_attrs.od,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let mut params = ObjectParameters {
|
||||
map,
|
||||
attrs: &mut attrs,
|
||||
ticks: Vec::new(),
|
||||
curve_bufs: CurveBuffers::default(),
|
||||
};
|
||||
|
||||
let mut hit_objects: Vec<_> = map
|
||||
.hit_objects
|
||||
.iter()
|
||||
.map(|h| OsuObject::new(h, &mut params))
|
||||
.collect();
|
||||
|
||||
attrs.n_circles = 0;
|
||||
attrs.n_sliders = 0;
|
||||
attrs.n_spinners = 0;
|
||||
attrs.max_combo = 0;
|
||||
|
||||
let stack_threshold = time_preempt * map.stack_leniency as f64;
|
||||
|
||||
if map.version >= 6 {
|
||||
stacking(&mut hit_objects, stack_threshold);
|
||||
} else {
|
||||
old_stacking(&mut hit_objects, stack_threshold);
|
||||
}
|
||||
|
||||
let mut hit_objects_iter = hit_objects.iter_mut().map(|h| {
|
||||
h.post_process(hr, &scaling_factor);
|
||||
|
||||
h
|
||||
});
|
||||
|
||||
let skills = Skills::new(
|
||||
mods,
|
||||
scaling_factor.radius,
|
||||
time_preempt,
|
||||
time_fade_in,
|
||||
hit_window,
|
||||
);
|
||||
|
||||
let last = match hit_objects_iter.next() {
|
||||
Some(prev) => prev,
|
||||
None => {
|
||||
return Self {
|
||||
idx: 0,
|
||||
mods,
|
||||
attrs,
|
||||
hit_objects: Vec::new(),
|
||||
diff_objects: Vec::new(),
|
||||
skills,
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Self::increment_combo(last, &mut attrs);
|
||||
|
||||
let mut last_last = None;
|
||||
|
||||
// Prepare `lazy_travel_dist` and `lazy_end_pos` for `last` manually
|
||||
Distances::compute_slider_cursor_pos(last, &scaling_factor);
|
||||
|
||||
let mut last = &*last;
|
||||
let mut diff_objects = Vec::with_capacity(map.hit_objects.len().saturating_sub(2));
|
||||
|
||||
for (i, curr) in hit_objects_iter.enumerate() {
|
||||
let delta_time = (curr.start_time - last.start_time) / clock_rate;
|
||||
|
||||
// * Capped to 25ms to prevent difficulty calculation breaking from simultaneous objects.
|
||||
let strain_time = delta_time.max(OsuDifficultyObject::MIN_DELTA_TIME as f64);
|
||||
|
||||
let dists = Distances::new(
|
||||
curr,
|
||||
last,
|
||||
last_last,
|
||||
clock_rate,
|
||||
strain_time,
|
||||
&scaling_factor,
|
||||
);
|
||||
|
||||
let diff_obj = OsuDifficultyObject::new(curr, last, clock_rate, i, dists);
|
||||
diff_objects.push(diff_obj);
|
||||
|
||||
last_last = Some(last);
|
||||
last = &*curr;
|
||||
}
|
||||
|
||||
Self {
|
||||
idx: 0,
|
||||
mods,
|
||||
attrs,
|
||||
diff_objects: extend_lifetime(diff_objects),
|
||||
hit_objects,
|
||||
skills,
|
||||
}
|
||||
}
|
||||
|
||||
fn increment_combo(h: &OsuObject, attrs: &mut OsuDifficultyAttributes) {
|
||||
attrs.max_combo += 1;
|
||||
|
||||
match &h.kind {
|
||||
OsuObjectKind::Circle => attrs.n_circles += 1,
|
||||
OsuObjectKind::Slider(slider) => {
|
||||
attrs.n_sliders += 1;
|
||||
attrs.max_combo += slider.nested_len();
|
||||
}
|
||||
OsuObjectKind::Spinner { .. } => attrs.n_spinners += 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn extend_lifetime(
|
||||
diff_objects: Vec<OsuDifficultyObject<'_>>,
|
||||
) -> Vec<OsuDifficultyObject<'static>> {
|
||||
// SAFETY: Owned values of the references will be contained
|
||||
// in the same struct and hence live just as long as this vec.
|
||||
unsafe { mem::transmute(diff_objects) }
|
||||
}
|
||||
|
||||
impl Iterator for OsuGradualDifficultyAttributes {
|
||||
type Item = OsuDifficultyAttributes;
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
let curr = self.diff_objects.get(self.idx)?;
|
||||
self.idx += 1;
|
||||
|
||||
self.skills.process(curr, &self.diff_objects);
|
||||
|
||||
Self::increment_combo(curr.base, &mut self.attrs);
|
||||
|
||||
let Skills {
|
||||
mut aim,
|
||||
mut aim_no_sliders,
|
||||
mut speed,
|
||||
mut flashlight,
|
||||
} = self.skills.clone();
|
||||
|
||||
let mut aim_rating = aim.difficulty_value().sqrt() * DIFFICULTY_MULTIPLIER;
|
||||
let aim_rating_no_sliders =
|
||||
aim_no_sliders.difficulty_value().sqrt() * DIFFICULTY_MULTIPLIER;
|
||||
|
||||
let speed_notes = speed.relevant_note_count();
|
||||
let mut speed_rating = speed.difficulty_value().sqrt() * DIFFICULTY_MULTIPLIER;
|
||||
|
||||
let mut flashlight_rating = flashlight.difficulty_value().sqrt() * DIFFICULTY_MULTIPLIER;
|
||||
|
||||
let slider_factor = if aim_rating > 0.0 {
|
||||
aim_rating_no_sliders / aim_rating
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
|
||||
if self.mods.td() {
|
||||
aim_rating = aim_rating.powf(0.8);
|
||||
flashlight_rating = flashlight_rating.powf(0.8);
|
||||
}
|
||||
|
||||
if self.mods.rx() {
|
||||
aim_rating *= 0.9;
|
||||
speed_rating = 0.0;
|
||||
flashlight_rating *= 0.7;
|
||||
}
|
||||
|
||||
let base_aim_performance = (5.0 * (aim_rating / 0.0675).max(1.0) - 4.0).powi(3) / 100_000.0;
|
||||
let base_speed_performance =
|
||||
(5.0 * (speed_rating / 0.0675).max(1.0) - 4.0).powi(3) / 100_000.0;
|
||||
|
||||
let base_flashlight_performance = if self.mods.fl() {
|
||||
flashlight_rating * flashlight_rating * 25.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
let base_performance = ((base_aim_performance).powf(1.1)
|
||||
+ (base_speed_performance).powf(1.1)
|
||||
+ (base_flashlight_performance).powf(1.1))
|
||||
.powf(1.0 / 1.1);
|
||||
|
||||
let star_rating = if base_performance > 0.00001 {
|
||||
PERFORMANCE_BASE_MULTIPLIER.cbrt()
|
||||
* 0.027
|
||||
* ((100_000.0 / 2.0_f64.powf(1.0 / 1.1) * base_performance).cbrt() + 4.0)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
let mut attrs = self.attrs.clone();
|
||||
attrs.aim = aim_rating;
|
||||
attrs.speed = speed_rating;
|
||||
attrs.flashlight = flashlight_rating;
|
||||
attrs.slider_factor = slider_factor;
|
||||
attrs.stars = star_rating;
|
||||
attrs.speed_note_count = speed_notes;
|
||||
attrs.aim_difficult_strain_count = aim.count_difficult_strains();
|
||||
attrs.speed_difficult_strain_count = speed.count_difficult_strains();
|
||||
|
||||
Some(attrs)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn size_hint(&self) -> (usize, Option<usize>) {
|
||||
let len = self.len();
|
||||
|
||||
(len, Some(len))
|
||||
}
|
||||
|
||||
fn nth(&mut self, n: usize) -> Option<Self::Item> {
|
||||
let skip = n.min(self.len()).saturating_sub(1);
|
||||
|
||||
for _ in 0..skip {
|
||||
let curr = self.diff_objects.get(self.idx)?;
|
||||
self.idx += 1;
|
||||
|
||||
self.skills.process(curr, &self.diff_objects);
|
||||
|
||||
Self::increment_combo(curr.base, &mut self.attrs);
|
||||
}
|
||||
|
||||
self.next()
|
||||
}
|
||||
}
|
||||
|
||||
impl ExactSizeIterator for OsuGradualDifficultyAttributes {
|
||||
#[inline]
|
||||
fn len(&self) -> usize {
|
||||
self.diff_objects.len() - self.idx
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
use crate::{Beatmap, OsuPP};
|
||||
|
||||
use super::{OsuGradualDifficultyAttributes, OsuPerformanceAttributes};
|
||||
|
||||
/// Aggregation for a score's current state i.e. what was the
|
||||
/// maximum combo so far and what are the current hitresults.
|
||||
///
|
||||
/// This struct is used for [`OsuGradualPerformanceAttributes`].
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq)]
|
||||
pub struct OsuScoreState {
|
||||
/// Maximum combo that the score has had so far.
|
||||
/// **Not** the maximum possible combo of the map so far.
|
||||
pub max_combo: usize,
|
||||
/// Amount of current 300s.
|
||||
pub n300: usize,
|
||||
/// Amount of current 100s.
|
||||
pub n100: usize,
|
||||
/// Amount of current 50s.
|
||||
pub n50: usize,
|
||||
/// Amount of current misses.
|
||||
pub n_misses: usize,
|
||||
}
|
||||
|
||||
impl OsuScoreState {
|
||||
/// Create a new empty score state.
|
||||
#[inline]
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Return the total amount of hits by adding everything up.
|
||||
#[inline]
|
||||
pub fn total_hits(&self) -> usize {
|
||||
self.n300 + self.n100 + self.n50 + self.n_misses
|
||||
}
|
||||
|
||||
/// Calculate the accuracy between `0.0` and `1.0` for this state.
|
||||
#[inline]
|
||||
pub fn accuracy(&self) -> f64 {
|
||||
let total_hits = self.total_hits();
|
||||
|
||||
if total_hits == 0 {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
let numerator = 6 * self.n300 + 2 * self.n100 + self.n50;
|
||||
let denominator = 6 * total_hits;
|
||||
|
||||
numerator as f64 / denominator as f64
|
||||
}
|
||||
}
|
||||
|
||||
/// Gradually calculate the performance attributes of an osu!standard map.
|
||||
///
|
||||
/// After each hit object you can call
|
||||
/// [`process_next_object`](`OsuGradualPerformanceAttributes::process_next_object`)
|
||||
/// and it will return the resulting current [`OsuPerformanceAttributes`].
|
||||
/// To process multiple objects at once, use
|
||||
/// [`process_next_n_objects`](`OsuGradualPerformanceAttributes::process_next_n_objects`) instead.
|
||||
///
|
||||
/// Both methods require an [`OsuScoreState`] that contains the current
|
||||
/// hitresults as well as the maximum combo so far.
|
||||
///
|
||||
/// If you only want to calculate difficulty attributes use
|
||||
/// [`OsuGradualDifficultyAttributes`](crate::osu::OsuGradualDifficultyAttributes) instead.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use rosu_pp::{Beatmap, osu::{OsuGradualPerformanceAttributes, OsuScoreState}};
|
||||
///
|
||||
/// # /*
|
||||
/// let map: Beatmap = ...
|
||||
/// # */
|
||||
/// # let map = Beatmap::default();
|
||||
///
|
||||
/// let mods = 64; // DT
|
||||
/// let mut gradual_perf = OsuGradualPerformanceAttributes::new(&map, mods);
|
||||
/// let mut state = OsuScoreState::new(); // empty state, everything is on 0.
|
||||
///
|
||||
/// // The first 10 hitresults are 300s and there are no sliders for additional combo
|
||||
/// for _ in 0..10 {
|
||||
/// state.n300 += 1;
|
||||
/// state.max_combo += 1;
|
||||
///
|
||||
/// # /*
|
||||
/// let performance = gradual_perf.process_next_object(state.clone()).unwrap();
|
||||
/// println!("PP: {}", performance.pp);
|
||||
/// # */
|
||||
/// # let _ = gradual_perf.process_next_object(state.clone());
|
||||
/// }
|
||||
///
|
||||
/// // Then comes a miss.
|
||||
/// // Note that state's max combo won't be incremented for
|
||||
/// // the next few objects because the combo is reset.
|
||||
/// state.n_misses += 1;
|
||||
/// # /*
|
||||
/// let performance = gradual_perf.process_next_object(state.clone()).unwrap();
|
||||
/// println!("PP: {}", performance.pp);
|
||||
/// # */
|
||||
/// # let _ = gradual_perf.process_next_object(state.clone());
|
||||
///
|
||||
/// // The next 10 objects will be a mixture of 300s, 100s, and 50s.
|
||||
/// // Notice how all 10 objects will be processed in one go.
|
||||
/// state.n300 += 2;
|
||||
/// state.n100 += 7;
|
||||
/// state.n50 += 1;
|
||||
/// # /*
|
||||
/// let performance = gradual_perf.process_next_n_objects(state.clone(), 10).unwrap();
|
||||
/// println!("PP: {}", performance.pp);
|
||||
/// # */
|
||||
/// # let _ = gradual_perf.process_next_n_objects(state.clone(), 10);
|
||||
///
|
||||
/// // Now comes another 300. Note that the max combo gets incremented again.
|
||||
/// state.n300 += 1;
|
||||
/// state.max_combo += 1;
|
||||
/// # /*
|
||||
/// let performance = gradual_perf.process_next_object(state.clone()).unwrap();
|
||||
/// println!("PP: {}", performance.pp);
|
||||
/// # */
|
||||
/// # let _ = gradual_perf.process_next_object(state.clone());
|
||||
///
|
||||
/// // Skip to the end
|
||||
/// # /*
|
||||
/// state.max_combo = ...
|
||||
/// state.n300 = ...
|
||||
/// state.n100 = ...
|
||||
/// state.n50 = ...
|
||||
/// state.n_misses = ...
|
||||
/// let final_performance = gradual_perf.process_next_n_objects(state.clone(), usize::MAX).unwrap();
|
||||
/// println!("PP: {}", performance.pp);
|
||||
/// # */
|
||||
/// # let _ = gradual_perf.process_next_n_objects(state.clone(), usize::MAX);
|
||||
///
|
||||
/// // Once the final performance was calculated,
|
||||
/// // attempting to process further objects will return `None`.
|
||||
/// assert!(gradual_perf.process_next_object(state).is_none());
|
||||
/// ```
|
||||
#[derive(Debug)]
|
||||
pub struct OsuGradualPerformanceAttributes<'map> {
|
||||
difficulty: OsuGradualDifficultyAttributes,
|
||||
performance: OsuPP<'map>,
|
||||
}
|
||||
|
||||
impl<'map> OsuGradualPerformanceAttributes<'map> {
|
||||
/// Create a new gradual performance calculator for osu!standard maps.
|
||||
pub fn new(map: &'map Beatmap, mods: u32) -> Self {
|
||||
let difficulty = OsuGradualDifficultyAttributes::new(map, mods);
|
||||
let performance = OsuPP::new(map).mods(mods).passed_objects(0);
|
||||
|
||||
Self {
|
||||
difficulty,
|
||||
performance,
|
||||
}
|
||||
}
|
||||
|
||||
/// Process the next hit object and calculate the
|
||||
/// performance attributes for the resulting score state.
|
||||
pub fn process_next_object(
|
||||
&mut self,
|
||||
state: OsuScoreState,
|
||||
) -> Option<OsuPerformanceAttributes> {
|
||||
self.process_next_n_objects(state, 1)
|
||||
}
|
||||
|
||||
/// Same as [`process_next_object`](`OsuGradualPerformanceAttributes::process_next_object`)
|
||||
/// but instead of processing only one object it process `n` many.
|
||||
///
|
||||
/// If `n` is 0 it will be considered as 1.
|
||||
/// If there are still objects to be processed but `n` is larger than the amount
|
||||
/// of remaining objects, `n` will be considered as the amount of remaining objects.
|
||||
pub fn process_next_n_objects(
|
||||
&mut self,
|
||||
state: OsuScoreState,
|
||||
n: usize,
|
||||
) -> Option<OsuPerformanceAttributes> {
|
||||
let sub = (self.difficulty.idx == 0) as usize;
|
||||
let difficulty = self.difficulty.nth(n.saturating_sub(sub))?;
|
||||
|
||||
let performance = self
|
||||
.performance
|
||||
.clone()
|
||||
.attributes(difficulty)
|
||||
.state(state)
|
||||
.passed_objects(self.difficulty.idx + 1)
|
||||
.calculate();
|
||||
|
||||
Some(performance)
|
||||
}
|
||||
}
|
||||
+596
@@ -0,0 +1,596 @@
|
||||
mod difficulty_object;
|
||||
mod gradual_difficulty;
|
||||
mod gradual_performance;
|
||||
mod osu_object;
|
||||
mod pp;
|
||||
mod scaling_factor;
|
||||
mod skills;
|
||||
|
||||
use skills::OsuStrainSkill;
|
||||
|
||||
use crate::{curve::CurveBuffers, parse::Pos2, AnyStars, Beatmap, GameMode, Mods};
|
||||
|
||||
use self::{
|
||||
difficulty_object::{Distances, OsuDifficultyObject},
|
||||
osu_object::{ObjectParameters, OsuObject},
|
||||
scaling_factor::ScalingFactor,
|
||||
skills::Skills,
|
||||
};
|
||||
|
||||
pub use self::{gradual_difficulty::*, gradual_performance::*, pp::*};
|
||||
|
||||
const SECTION_LEN: f64 = 400.0;
|
||||
const DIFFICULTY_MULTIPLIER: f64 = 0.0675;
|
||||
// * Change radius to 50 to make 100 the diameter. Easier for mental maths.
|
||||
const NORMALIZED_RADIUS: f32 = 50.0;
|
||||
const STACK_DISTANCE: f32 = 3.0;
|
||||
// * This is being adjusted to keep the final pp value scaled around what it used to be when changing things.
|
||||
const PERFORMANCE_BASE_MULTIPLIER: f64 = 1.14;
|
||||
const PREEMPT_MIN: f64 = 450.0;
|
||||
const FADE_IN_DURATION_MULTIPLIER: f64 = 0.4;
|
||||
const PLAYFIELD_BASE_SIZE: Pos2 = Pos2 { x: 512.0, y: 384.0 };
|
||||
|
||||
/// Difficulty calculator on osu!standard maps.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use rosu_pp::{OsuStars, Beatmap};
|
||||
///
|
||||
/// # /*
|
||||
/// let map: Beatmap = ...
|
||||
/// # */
|
||||
/// # let map = Beatmap::default();
|
||||
///
|
||||
/// let difficulty_attrs = OsuStars::new(&map)
|
||||
/// .mods(8 + 64) // HDDT
|
||||
/// .calculate();
|
||||
///
|
||||
/// println!("Stars: {}", difficulty_attrs.stars);
|
||||
/// ```
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct OsuStars<'map> {
|
||||
pub(crate) map: &'map Beatmap,
|
||||
pub(crate) mods: u32,
|
||||
pub(crate) passed_objects: Option<usize>,
|
||||
pub(crate) clock_rate: Option<f64>,
|
||||
}
|
||||
|
||||
impl<'map> OsuStars<'map> {
|
||||
/// Create a new difficulty calculator for osu!standard maps.
|
||||
#[inline]
|
||||
pub fn new(map: &'map Beatmap) -> Self {
|
||||
Self {
|
||||
map,
|
||||
mods: 0,
|
||||
passed_objects: None,
|
||||
clock_rate: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert the map into another mode.
|
||||
#[inline]
|
||||
pub fn mode(self, mode: GameMode) -> AnyStars<'map> {
|
||||
match mode {
|
||||
GameMode::Osu => AnyStars::Osu(self),
|
||||
GameMode::Taiko => AnyStars::Taiko(self.into()),
|
||||
GameMode::Catch => AnyStars::Catch(self.into()),
|
||||
GameMode::Mania => AnyStars::Mania(self.into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
}
|
||||
|
||||
/// Amount of passed objects for partial plays, e.g. a fail.
|
||||
///
|
||||
/// If you want to calculate the difficulty after every few objects, instead of
|
||||
/// using [`OsuStars`] multiple times with different `passed_objects`, you should use
|
||||
/// [`OsuGradualDifficultyAttributes`](crate::osu::OsuGradualDifficultyAttributes).
|
||||
#[inline]
|
||||
pub fn passed_objects(mut self, passed_objects: usize) -> Self {
|
||||
self.passed_objects = Some(passed_objects);
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
/// Adjust the clock rate used in the calculation.
|
||||
/// If none is specified, it will take the clock rate based on the mods
|
||||
/// i.e. 1.5 for DT, 0.75 for HT and 1.0 otherwise.
|
||||
#[inline]
|
||||
pub fn clock_rate(mut self, clock_rate: f64) -> Self {
|
||||
self.clock_rate = Some(clock_rate);
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
/// Calculate all difficulty related values, including stars.
|
||||
#[inline]
|
||||
pub fn calculate(self) -> OsuDifficultyAttributes {
|
||||
let mods = self.mods;
|
||||
|
||||
let (skills, mut attrs) = calculate_skills(self);
|
||||
|
||||
let Skills {
|
||||
mut aim,
|
||||
mut aim_no_sliders,
|
||||
mut speed,
|
||||
mut flashlight,
|
||||
} = skills;
|
||||
|
||||
let mut aim_rating = aim.difficulty_value().sqrt() * DIFFICULTY_MULTIPLIER;
|
||||
let aim_rating_no_sliders =
|
||||
aim_no_sliders.difficulty_value().sqrt() * DIFFICULTY_MULTIPLIER;
|
||||
|
||||
let speed_notes = speed.relevant_note_count();
|
||||
let mut speed_rating = speed.difficulty_value().sqrt() * DIFFICULTY_MULTIPLIER;
|
||||
|
||||
let mut flashlight_rating = flashlight.difficulty_value().sqrt() * DIFFICULTY_MULTIPLIER;
|
||||
|
||||
let slider_factor = if aim_rating > 0.0 {
|
||||
aim_rating_no_sliders / aim_rating
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
|
||||
if mods.td() {
|
||||
aim_rating = aim_rating.powf(0.8);
|
||||
flashlight_rating = flashlight_rating.powf(0.8);
|
||||
}
|
||||
|
||||
if mods.rx() {
|
||||
aim_rating *= 0.9;
|
||||
speed_rating = 0.0;
|
||||
flashlight_rating *= 0.7;
|
||||
}
|
||||
|
||||
if mods.ap() {
|
||||
aim_rating = 0.0;
|
||||
flashlight_rating *= 0.4;
|
||||
}
|
||||
|
||||
let base_aim_performance = (5.0 * (aim_rating / 0.0675).max(1.0) - 4.0).powi(3) / 100_000.0;
|
||||
let base_speed_performance =
|
||||
(5.0 * (speed_rating / 0.0675).max(1.0) - 4.0).powi(3) / 100_000.0;
|
||||
|
||||
let base_flashlight_performance = if mods.fl() {
|
||||
flashlight_rating * flashlight_rating * 25.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
let base_performance = ((base_aim_performance).powf(1.1)
|
||||
+ (base_speed_performance).powf(1.1)
|
||||
+ (base_flashlight_performance).powf(1.1))
|
||||
.powf(1.0 / 1.1);
|
||||
|
||||
let star_rating = if base_performance > 0.00001 {
|
||||
PERFORMANCE_BASE_MULTIPLIER.cbrt()
|
||||
* 0.027
|
||||
* ((100_000.0 / 2.0_f64.powf(1.0 / 1.1) * base_performance).cbrt() + 4.0)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
attrs.aim = aim_rating;
|
||||
attrs.speed = speed_rating;
|
||||
attrs.flashlight = flashlight_rating;
|
||||
attrs.slider_factor = slider_factor;
|
||||
attrs.stars = star_rating;
|
||||
attrs.speed_note_count = speed_notes;
|
||||
attrs.aim_difficult_strain_count = aim.count_difficult_strains();
|
||||
attrs.speed_difficult_strain_count = speed.count_difficult_strains();
|
||||
|
||||
attrs
|
||||
}
|
||||
|
||||
/// Calculate the skill strains.
|
||||
///
|
||||
/// Suitable to plot the difficulty of a map over time.
|
||||
#[inline]
|
||||
pub fn strains(self) -> OsuStrains {
|
||||
let (skills, _) = calculate_skills(self);
|
||||
|
||||
let Skills {
|
||||
aim,
|
||||
aim_no_sliders,
|
||||
speed,
|
||||
flashlight,
|
||||
} = skills;
|
||||
|
||||
OsuStrains {
|
||||
section_len: SECTION_LEN,
|
||||
aim: aim.strain_peaks,
|
||||
aim_no_sliders: aim_no_sliders.strain_peaks,
|
||||
speed: speed.strain_peaks,
|
||||
flashlight: flashlight.strain_peaks,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The result of calculating the strains on a osu! map.
|
||||
/// Suitable to plot the difficulty of a map over time.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct OsuStrains {
|
||||
/// Time in ms inbetween two strains.
|
||||
pub section_len: f64, // TODO: remove field, make it a method
|
||||
/// Strain peaks of the aim skill.
|
||||
pub aim: Vec<f64>,
|
||||
/// Strain peaks of the aim skill without sliders.
|
||||
pub aim_no_sliders: Vec<f64>,
|
||||
/// Strain peaks of the speed skill.
|
||||
pub speed: Vec<f64>,
|
||||
/// Strain peaks of the flashlight skill.
|
||||
pub flashlight: Vec<f64>,
|
||||
}
|
||||
|
||||
impl OsuStrains {
|
||||
/// Returns the number of strain peaks per skill.
|
||||
#[inline]
|
||||
#[allow(clippy::len_without_is_empty)]
|
||||
pub fn len(&self) -> usize {
|
||||
self.aim.len()
|
||||
}
|
||||
}
|
||||
|
||||
fn calculate_skills(params: OsuStars<'_>) -> (Skills, OsuDifficultyAttributes) {
|
||||
let OsuStars {
|
||||
map,
|
||||
mods,
|
||||
passed_objects,
|
||||
clock_rate,
|
||||
} = params;
|
||||
|
||||
let take = passed_objects.unwrap_or(map.hit_objects.len());
|
||||
let clock_rate = clock_rate.unwrap_or_else(|| mods.clock_rate());
|
||||
|
||||
let map_attrs = map.attributes().mods(mods).clock_rate(clock_rate).build();
|
||||
let scaling_factor = ScalingFactor::new(map_attrs.cs);
|
||||
let hr = mods.hr();
|
||||
let hit_window = 2.0 * map_attrs.hit_windows.od;
|
||||
let time_preempt = (map_attrs.hit_windows.ar * clock_rate) as f32 as f64;
|
||||
|
||||
// * Preempt time can go below 450ms. Normally, this is achieved via the DT mod
|
||||
// * which uniformly speeds up all animations game wide regardless of AR.
|
||||
// * This uniform speedup is hard to match 1:1, however we can at least make
|
||||
// * AR>10 (via mods) feel good by extending the upper linear function above.
|
||||
// * Note that this doesn't exactly match the AR>10 visuals as they're
|
||||
// * classically known, but it feels good.
|
||||
// * This adjustment is necessary for AR>10, otherwise TimePreempt can
|
||||
// * become smaller leading to hitcircles not fully fading in.
|
||||
let time_fade_in = if mods.hd() {
|
||||
time_preempt * FADE_IN_DURATION_MULTIPLIER
|
||||
} else {
|
||||
400.0 * (time_preempt / PREEMPT_MIN).min(1.0)
|
||||
};
|
||||
|
||||
let mut attrs = OsuDifficultyAttributes {
|
||||
ar: map_attrs.ar,
|
||||
hp: map_attrs.hp,
|
||||
od: map_attrs.od,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let mut params = ObjectParameters {
|
||||
map,
|
||||
attrs: &mut attrs,
|
||||
ticks: Vec::new(),
|
||||
curve_bufs: CurveBuffers::default(),
|
||||
};
|
||||
|
||||
let mut hit_objects: Vec<_> = map
|
||||
.hit_objects
|
||||
.iter()
|
||||
.take(take)
|
||||
.map(|h| OsuObject::new(h, &mut params))
|
||||
.collect();
|
||||
|
||||
let stack_threshold = time_preempt * map.stack_leniency as f64;
|
||||
|
||||
if map.version >= 6 {
|
||||
stacking(&mut hit_objects, stack_threshold);
|
||||
} else {
|
||||
old_stacking(&mut hit_objects, stack_threshold);
|
||||
}
|
||||
|
||||
let mut hit_objects = hit_objects.iter_mut().map(|h| {
|
||||
h.post_process(hr, &scaling_factor);
|
||||
|
||||
h
|
||||
});
|
||||
|
||||
let mut skills = Skills::new(
|
||||
mods,
|
||||
scaling_factor.radius,
|
||||
time_preempt,
|
||||
time_fade_in,
|
||||
hit_window,
|
||||
);
|
||||
|
||||
let last = match hit_objects.next() {
|
||||
Some(prev) => prev,
|
||||
None => return (skills, attrs),
|
||||
};
|
||||
|
||||
let mut last_last = None;
|
||||
|
||||
// Prepare `lazy_travel_dist` and `lazy_end_pos` for `last` manually
|
||||
Distances::compute_slider_cursor_pos(last, &scaling_factor);
|
||||
|
||||
let mut last = &*last;
|
||||
let mut diff_objects = Vec::with_capacity(hit_objects.len());
|
||||
|
||||
for (i, curr) in hit_objects.enumerate() {
|
||||
let delta_time = (curr.start_time - last.start_time) / clock_rate;
|
||||
|
||||
// * Capped to 25ms to prevent difficulty calculation breaking from simultaneous objects.
|
||||
let strain_time = delta_time.max(OsuDifficultyObject::MIN_DELTA_TIME as f64);
|
||||
|
||||
let dists = Distances::new(
|
||||
curr,
|
||||
last,
|
||||
last_last,
|
||||
clock_rate,
|
||||
strain_time,
|
||||
&scaling_factor,
|
||||
);
|
||||
|
||||
let diff_obj = OsuDifficultyObject::new(curr, last, clock_rate, i, dists);
|
||||
diff_objects.push(diff_obj);
|
||||
|
||||
last_last = Some(last);
|
||||
last = &*curr;
|
||||
}
|
||||
|
||||
for curr in diff_objects.iter() {
|
||||
skills.process(curr, &diff_objects);
|
||||
}
|
||||
|
||||
(skills, attrs)
|
||||
}
|
||||
|
||||
fn stacking(hit_objects: &mut [OsuObject], stack_threshold: f64) {
|
||||
let mut extended_start_idx = 0;
|
||||
|
||||
let extended_end_idx = match hit_objects.len().checked_sub(1) {
|
||||
Some(idx) => idx,
|
||||
None => return,
|
||||
};
|
||||
|
||||
// First big `if` in osu!lazer's function can be skipped
|
||||
|
||||
for i in (1..=extended_end_idx).rev() {
|
||||
let mut n = i;
|
||||
let mut obj_i_idx = i;
|
||||
// * We should check every note which has not yet got a stack.
|
||||
// * Consider the case we have two interwound stacks and this will make sense.
|
||||
// * o <-1 o <-2
|
||||
// * o <-3 o <-4
|
||||
// * We first process starting from 4 and handle 2,
|
||||
// * then we come backwards on the i loop iteration until we reach 3 and handle 1.
|
||||
// * 2 and 1 will be ignored in the i loop because they already have a stack value.
|
||||
|
||||
if hit_objects[obj_i_idx].stack_height.abs() > 0.0 || hit_objects[obj_i_idx].is_spinner() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// * If this object is a hitcircle, then we enter this "special" case.
|
||||
// * It either ends with a stack of hitcircles only,
|
||||
// * or a stack of hitcircles that are underneath a slider.
|
||||
// * Any other case is handled by the "is_slider" code below this.
|
||||
if hit_objects[obj_i_idx].is_circle() {
|
||||
loop {
|
||||
n = match n.checked_sub(1) {
|
||||
Some(n) => n,
|
||||
None => break,
|
||||
};
|
||||
|
||||
if hit_objects[n].is_spinner() {
|
||||
continue;
|
||||
} else if hit_objects[obj_i_idx].start_time - hit_objects[n].end_time()
|
||||
> stack_threshold
|
||||
{
|
||||
break; // * We are no longer within stacking range of the previous object.
|
||||
}
|
||||
|
||||
// * HitObjects before the specified update range haven't been reset yet
|
||||
if n < extended_start_idx {
|
||||
hit_objects[n].stack_height = 0.0;
|
||||
extended_start_idx = n;
|
||||
}
|
||||
|
||||
// * This is a special case where hticircles are moved DOWN and RIGHT (negative stacking)
|
||||
// * if they are under the *last* slider in a stacked pattern.
|
||||
// * o==o <- slider is at original location
|
||||
// * o <- hitCircle has stack of -1
|
||||
// * o <- hitCircle has stack of -2
|
||||
if hit_objects[n].is_slider()
|
||||
&& hit_objects[n]
|
||||
.pre_stacked_end_pos()
|
||||
.distance(hit_objects[obj_i_idx].pos())
|
||||
< STACK_DISTANCE
|
||||
{
|
||||
let offset =
|
||||
hit_objects[obj_i_idx].stack_height - hit_objects[n].stack_height + 1.0;
|
||||
|
||||
for j in n + 1..=i {
|
||||
// * For each object which was declared under this slider, we will offset
|
||||
// * it to appear *below* the slider end (rather than above).
|
||||
if hit_objects[n]
|
||||
.pre_stacked_end_pos()
|
||||
.distance(hit_objects[j].pos())
|
||||
< STACK_DISTANCE
|
||||
{
|
||||
hit_objects[j].stack_height -= offset;
|
||||
}
|
||||
}
|
||||
|
||||
// * We have hit a slider. We should restart calculation using this as the new base.
|
||||
// * Breaking here will mean that the slider still has StackCount of 0,
|
||||
// * so will be handled in the i-outer-loop.
|
||||
break;
|
||||
}
|
||||
|
||||
if hit_objects[n].pos().distance(hit_objects[obj_i_idx].pos()) < STACK_DISTANCE {
|
||||
// * Keep processing as if there are no sliders.
|
||||
// * If we come across a slider, this gets cancelled out.
|
||||
// * NOTE: Sliders with start positions stacking
|
||||
// * are a special case that is also handled here.
|
||||
|
||||
hit_objects[n].stack_height = hit_objects[obj_i_idx].stack_height + 1.0;
|
||||
obj_i_idx = n;
|
||||
}
|
||||
}
|
||||
} else if hit_objects[obj_i_idx].is_slider() {
|
||||
// * We have hit the first slider in a possible stack.
|
||||
// * From this point on, we ALWAYS stack positive regardless.
|
||||
loop {
|
||||
n = match n.checked_sub(1) {
|
||||
Some(n) => n,
|
||||
None => break,
|
||||
};
|
||||
|
||||
if hit_objects[n].is_spinner() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if hit_objects[obj_i_idx].start_time - hit_objects[n].start_time > stack_threshold {
|
||||
break; // * We are no longer within stacking range of the previous object.
|
||||
}
|
||||
|
||||
if hit_objects[n]
|
||||
.pre_stacked_end_pos()
|
||||
.distance(hit_objects[obj_i_idx].pos())
|
||||
< STACK_DISTANCE
|
||||
{
|
||||
hit_objects[n].stack_height = hit_objects[obj_i_idx].stack_height + 1.0;
|
||||
obj_i_idx = n;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn old_stacking(hit_objects: &mut [OsuObject], stack_threshold: f64) {
|
||||
for i in 0..hit_objects.len() {
|
||||
if hit_objects[i].stack_height != 0.0 && !hit_objects[i].is_slider() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut start_time = hit_objects[i].end_time();
|
||||
let pos2 = hit_objects[i].old_stacking_pos2();
|
||||
|
||||
let mut slider_stack = 0.0;
|
||||
|
||||
for j in i + 1..hit_objects.len() {
|
||||
if hit_objects[j].start_time - stack_threshold > start_time {
|
||||
break;
|
||||
}
|
||||
|
||||
if hit_objects[j].pos().distance(hit_objects[i].pos()) < STACK_DISTANCE {
|
||||
hit_objects[i].stack_height += 1.0;
|
||||
start_time = hit_objects[j].end_time();
|
||||
} else if hit_objects[j].pos().distance(pos2) < STACK_DISTANCE {
|
||||
slider_stack += 1.0;
|
||||
hit_objects[j].stack_height -= slider_stack;
|
||||
start_time = hit_objects[j].end_time();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The result of a difficulty calculation on an osu!standard map.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct OsuDifficultyAttributes {
|
||||
/// The aim portion of the total strain.
|
||||
pub aim: f64,
|
||||
/// The speed portion of the total strain.
|
||||
pub speed: f64,
|
||||
/// The flashlight portion of the total strain.
|
||||
pub flashlight: f64,
|
||||
/// The ratio of the aim strain with and without considering sliders
|
||||
pub slider_factor: f64,
|
||||
/// The number of difficult aim strains.
|
||||
pub aim_difficult_strain_count: f64,
|
||||
/// The number of difficult speed strains.
|
||||
pub speed_difficult_strain_count: f64,
|
||||
/// The number of clickable objects weighted by difficulty.
|
||||
pub speed_note_count: f64,
|
||||
/// The approach rate.
|
||||
pub ar: f64,
|
||||
/// The overall difficulty
|
||||
pub od: f64,
|
||||
/// The health drain rate.
|
||||
pub hp: f64,
|
||||
/// The amount of circles.
|
||||
pub n_circles: usize,
|
||||
/// The amount of sliders.
|
||||
pub n_sliders: usize,
|
||||
/// The amount of spinners.
|
||||
pub n_spinners: usize,
|
||||
/// The final star rating
|
||||
pub stars: f64,
|
||||
/// The maximum combo.
|
||||
pub max_combo: usize,
|
||||
}
|
||||
|
||||
impl OsuDifficultyAttributes {
|
||||
/// Return the maximum combo.
|
||||
#[inline]
|
||||
pub fn max_combo(&self) -> usize {
|
||||
self.max_combo
|
||||
}
|
||||
}
|
||||
|
||||
/// The result of a performance calculation on an osu!standard map.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct OsuPerformanceAttributes {
|
||||
/// The difficulty attributes that were used for the performance calculation
|
||||
pub difficulty: OsuDifficultyAttributes,
|
||||
/// The final performance points.
|
||||
pub pp: f64,
|
||||
/// The accuracy portion of the final pp.
|
||||
pub pp_acc: f64,
|
||||
/// The aim portion of the final pp.
|
||||
pub pp_aim: f64,
|
||||
/// The flashlight portion of the final pp.
|
||||
pub pp_flashlight: f64,
|
||||
/// The speed portion of the final pp.
|
||||
pub pp_speed: f64,
|
||||
/// Misses including an approximated amount of slider breaks
|
||||
pub effective_miss_count: f64,
|
||||
}
|
||||
|
||||
impl OsuPerformanceAttributes {
|
||||
/// Return the star value.
|
||||
#[inline]
|
||||
pub fn stars(&self) -> f64 {
|
||||
self.difficulty.stars
|
||||
}
|
||||
|
||||
/// Return the performance point value.
|
||||
#[inline]
|
||||
pub fn pp(&self) -> f64 {
|
||||
self.pp
|
||||
}
|
||||
|
||||
/// Return the maximum combo of the map.
|
||||
#[inline]
|
||||
pub fn max_combo(&self) -> usize {
|
||||
self.difficulty.max_combo
|
||||
}
|
||||
}
|
||||
|
||||
impl From<OsuPerformanceAttributes> for OsuDifficultyAttributes {
|
||||
#[inline]
|
||||
fn from(attributes: OsuPerformanceAttributes) -> Self {
|
||||
attributes.difficulty
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,486 @@
|
||||
use std::slice::Iter;
|
||||
|
||||
use super::{scaling_factor::ScalingFactor, OsuDifficultyAttributes, PLAYFIELD_BASE_SIZE};
|
||||
|
||||
use crate::{
|
||||
curve::{Curve, CurveBuffers},
|
||||
parse::{HitObject, HitObjectKind, Pos2},
|
||||
Beatmap,
|
||||
};
|
||||
|
||||
const LEGACY_LAST_TICK_OFFSET: f64 = 36.0;
|
||||
const BASE_SCORING_DISTANCE: f64 = 100.0;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct OsuObject {
|
||||
pos: Pos2,
|
||||
pub(crate) start_time: f64,
|
||||
pub(crate) stack_offset: Pos2,
|
||||
pub(crate) stack_height: f32,
|
||||
pub(crate) kind: OsuObjectKind,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) enum OsuObjectKind {
|
||||
Circle,
|
||||
Slider(OsuSlider),
|
||||
Spinner { end_time: f64 },
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct OsuSlider {
|
||||
pub(crate) end_time: f64,
|
||||
pub(crate) lazy_end_pos: Pos2,
|
||||
nested_objects: Vec<NestedObject>,
|
||||
}
|
||||
|
||||
impl OsuSlider {
|
||||
pub(crate) fn nested_len(&self) -> usize {
|
||||
self.nested_objects.len()
|
||||
}
|
||||
|
||||
pub(crate) fn nested_iter(&self) -> Iter<'_, NestedObject> {
|
||||
self.nested_objects.iter()
|
||||
}
|
||||
|
||||
pub(crate) fn repeat_count(&self) -> usize {
|
||||
self.nested_objects.iter().fold(0, |count, nested| {
|
||||
count + matches!(nested.kind, NestedObjectKind::Repeat) as usize
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn end_pos(&self) -> Option<Pos2> {
|
||||
self.tail().map(|tail| tail.pos)
|
||||
}
|
||||
|
||||
pub(crate) fn tail(&self) -> Option<&NestedObject> {
|
||||
self.nested_objects
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|nested| matches!(nested.kind, NestedObjectKind::Tail))
|
||||
}
|
||||
|
||||
pub(crate) fn tail_mut(&mut self) -> Option<(usize, &mut NestedObject)> {
|
||||
self.nested_objects
|
||||
.iter_mut()
|
||||
.enumerate()
|
||||
.rev()
|
||||
.find(|(_, nested)| matches!(nested.kind, NestedObjectKind::Tail))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct NestedObject {
|
||||
/// Note: `pos` does not include stacking!
|
||||
pub(crate) pos: Pos2,
|
||||
pub(crate) start_time: f64,
|
||||
pub(crate) kind: NestedObjectKind,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug)]
|
||||
pub(crate) enum NestedObjectKind {
|
||||
Repeat,
|
||||
Tail,
|
||||
Tick,
|
||||
}
|
||||
|
||||
pub(crate) struct ObjectParameters<'a> {
|
||||
pub(crate) map: &'a Beatmap,
|
||||
pub(crate) attrs: &'a mut OsuDifficultyAttributes,
|
||||
pub(crate) ticks: Vec<(Pos2, f64)>,
|
||||
pub(crate) curve_bufs: CurveBuffers,
|
||||
}
|
||||
|
||||
impl OsuObject {
|
||||
pub(crate) fn new(h: &HitObject, params: &mut ObjectParameters<'_>) -> Self {
|
||||
let ObjectParameters {
|
||||
map,
|
||||
attrs,
|
||||
ticks,
|
||||
curve_bufs,
|
||||
} = params;
|
||||
|
||||
attrs.max_combo += 1; // hitcircle, slider head, or spinner
|
||||
let pos = h.pos;
|
||||
|
||||
match &h.kind {
|
||||
HitObjectKind::Circle => {
|
||||
attrs.n_circles += 1;
|
||||
|
||||
Self {
|
||||
start_time: h.start_time,
|
||||
pos,
|
||||
stack_offset: Pos2::default(),
|
||||
stack_height: 0.0,
|
||||
kind: OsuObjectKind::Circle,
|
||||
}
|
||||
}
|
||||
HitObjectKind::Slider {
|
||||
pixel_len,
|
||||
repeats,
|
||||
control_points,
|
||||
..
|
||||
} => {
|
||||
attrs.n_sliders += 1;
|
||||
|
||||
let timing_point = map.timing_point_at(h.start_time);
|
||||
let difficulty_point = map.difficulty_point_at(h.start_time).unwrap_or_default();
|
||||
|
||||
let scoring_dist =
|
||||
BASE_SCORING_DISTANCE * map.slider_mult * difficulty_point.slider_vel;
|
||||
|
||||
let vel = scoring_dist / timing_point.beat_len;
|
||||
|
||||
// * prior to v8, speed multipliers don't adjust for how many ticks are generated over the same distance.
|
||||
// * this results in more (or less) ticks being generated in <v8 maps for the same time duration.
|
||||
let tick_dist_mult = if map.version < 8 {
|
||||
difficulty_point.slider_vel.recip()
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
|
||||
let mut tick_dist = if difficulty_point.generate_ticks {
|
||||
scoring_dist / map.tick_rate * tick_dist_mult
|
||||
} else {
|
||||
f64::INFINITY
|
||||
};
|
||||
|
||||
let span_count = (*repeats + 1) as f64;
|
||||
|
||||
// Build the curve w.r.t. the control points
|
||||
let curve = Curve::new(control_points, *pixel_len, curve_bufs);
|
||||
|
||||
let end_time = h.start_time + span_count * curve.dist() / vel;
|
||||
let total_duration = end_time - h.start_time;
|
||||
let span_duration = total_duration / span_count;
|
||||
|
||||
// * A very lenient maximum length of a slider for ticks to be generated.
|
||||
// * This exists for edge cases such as /b/1573664 where the beatmap has
|
||||
// * been edited by the user, and should never be reached in normal usage.
|
||||
let max_len = 100_000.0;
|
||||
|
||||
let len = curve.dist().min(max_len);
|
||||
tick_dist = tick_dist.clamp(0.0, len);
|
||||
let min_dist_from_end = vel * 10.0;
|
||||
|
||||
let mut curr_dist = tick_dist;
|
||||
|
||||
ticks.clear();
|
||||
|
||||
let mut nested_objects = if tick_dist != 0.0 {
|
||||
ticks.reserve((len / tick_dist) as usize);
|
||||
let mut nested_objects =
|
||||
Vec::with_capacity((len * span_count / tick_dist) as usize);
|
||||
|
||||
// Ticks of the first span
|
||||
while curr_dist < len - min_dist_from_end {
|
||||
let progress = curr_dist / len;
|
||||
|
||||
let curr_time = h.start_time + progress * span_duration;
|
||||
let curr_pos = h.pos + curve.position_at(progress);
|
||||
|
||||
let tick = NestedObject {
|
||||
pos: curr_pos,
|
||||
start_time: curr_time,
|
||||
kind: NestedObjectKind::Tick,
|
||||
};
|
||||
|
||||
nested_objects.push(tick);
|
||||
ticks.push((curr_pos, curr_time));
|
||||
|
||||
curr_dist += tick_dist;
|
||||
}
|
||||
|
||||
// Other spans
|
||||
for span_idx in 1..=*repeats {
|
||||
let progress = (span_idx % 2 == 1) as u8 as f64;
|
||||
let span_idx_f64 = span_idx as f64;
|
||||
|
||||
// Repeat point
|
||||
let curr_time = h.start_time + span_duration * span_idx_f64;
|
||||
let curr_pos = h.pos + curve.position_at(progress);
|
||||
|
||||
let repeat = NestedObject {
|
||||
pos: curr_pos,
|
||||
start_time: curr_time,
|
||||
kind: NestedObjectKind::Repeat,
|
||||
};
|
||||
|
||||
nested_objects.push(repeat);
|
||||
let span_offset = span_idx_f64 * span_duration;
|
||||
|
||||
// Ticks
|
||||
if span_idx & 1 == 1 {
|
||||
// S-------->R | Span 0
|
||||
// 2 4 6 8 | => span_duration = 8
|
||||
// R<--------- | Span 1
|
||||
// 16 14 12 10 | => offset = 1 * span_duration
|
||||
// --------->R | Span 2
|
||||
// 18 20 22 24 | => not reverse; simple case
|
||||
// T<--------- | Span 3
|
||||
// 32 30 28 26 | => offset = 3 * span_duration
|
||||
//
|
||||
// n = offset + tick
|
||||
// 26 = 24 + 2
|
||||
// 28 = 24 + 4
|
||||
// 30 = 24 + 6
|
||||
// 32 = 24 + 8
|
||||
|
||||
let base = h.start_time + h.start_time + span_duration;
|
||||
|
||||
let tick_iter = ticks.iter().rev().map(|(pos, time)| NestedObject {
|
||||
pos: *pos,
|
||||
start_time: span_offset + base - time,
|
||||
kind: NestedObjectKind::Tick,
|
||||
});
|
||||
|
||||
nested_objects.extend(tick_iter);
|
||||
} else {
|
||||
let tick_iter = ticks.iter().map(|(pos, time)| NestedObject {
|
||||
pos: *pos,
|
||||
start_time: time + span_offset,
|
||||
kind: NestedObjectKind::Tick,
|
||||
});
|
||||
|
||||
nested_objects.extend(tick_iter);
|
||||
}
|
||||
}
|
||||
|
||||
nested_objects
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
// Slider tail
|
||||
let final_span_start_time = h.start_time + *repeats as f64 * span_duration;
|
||||
let final_span_end_time = (h.start_time + total_duration / 2.0)
|
||||
.max(final_span_start_time + span_duration - LEGACY_LAST_TICK_OFFSET);
|
||||
|
||||
let progress = (*repeats % 2 == 0) as u8 as f64;
|
||||
let end_pos = curve.position_at(progress);
|
||||
|
||||
// * we need to use the LegacyLastTick here for compatibility reasons (difficulty).
|
||||
// * it is *okay* to use this because the TailCircle is not used for any meaningful purpose in gameplay.
|
||||
// * if this is to change, we should revisit this.
|
||||
let legacy_last_tick = NestedObject {
|
||||
pos: end_pos,
|
||||
start_time: final_span_end_time,
|
||||
kind: NestedObjectKind::Tail,
|
||||
};
|
||||
|
||||
// On very short buzz sliders it can happen that the
|
||||
// legacy last tick is not the last object time-wise
|
||||
match nested_objects.last() {
|
||||
Some(last) if last.start_time > final_span_end_time => {
|
||||
let idx = nested_objects
|
||||
.iter()
|
||||
.rev()
|
||||
.position(|nested| nested.start_time <= final_span_end_time)
|
||||
.map_or(0, |i| nested_objects.len() - i);
|
||||
|
||||
nested_objects.insert(idx, legacy_last_tick);
|
||||
}
|
||||
_ => nested_objects.push(legacy_last_tick),
|
||||
};
|
||||
|
||||
attrs.max_combo += nested_objects.len();
|
||||
|
||||
let last_time = nested_objects
|
||||
.last()
|
||||
.map_or(final_span_end_time, |nested| nested.start_time);
|
||||
|
||||
let lazy_travel_time = last_time - h.start_time;
|
||||
let mut end_time_min = lazy_travel_time / span_duration;
|
||||
|
||||
if end_time_min % 2.0 >= 1.0 {
|
||||
end_time_min = 1.0 - end_time_min % 1.0;
|
||||
} else {
|
||||
end_time_min %= 1.0;
|
||||
}
|
||||
|
||||
// * temporary lazy end position until a real result can be derived.
|
||||
// The position is added after the stacking for the correct order of
|
||||
// floating point operations.
|
||||
let lazy_end_pos = curve.position_at(end_time_min);
|
||||
|
||||
let slider = OsuSlider {
|
||||
end_time,
|
||||
lazy_end_pos,
|
||||
nested_objects,
|
||||
};
|
||||
|
||||
Self {
|
||||
start_time: h.start_time,
|
||||
pos,
|
||||
stack_offset: Pos2::default(),
|
||||
stack_height: 0.0,
|
||||
kind: OsuObjectKind::Slider(slider),
|
||||
}
|
||||
}
|
||||
HitObjectKind::Spinner { end_time } | HitObjectKind::Hold { end_time } => {
|
||||
attrs.n_spinners += 1;
|
||||
|
||||
Self {
|
||||
start_time: h.start_time,
|
||||
pos,
|
||||
stack_offset: Pos2::default(),
|
||||
stack_height: 0.0,
|
||||
kind: OsuObjectKind::Spinner {
|
||||
end_time: *end_time,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn end_time(&self) -> f64 {
|
||||
match &self.kind {
|
||||
OsuObjectKind::Circle => self.start_time,
|
||||
OsuObjectKind::Slider(slider) => slider.end_time,
|
||||
OsuObjectKind::Spinner { end_time } => *end_time,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn end_pos(&self) -> Pos2 {
|
||||
match &self.kind {
|
||||
OsuObjectKind::Circle | OsuObjectKind::Spinner { .. } => self.pos,
|
||||
OsuObjectKind::Slider(slider) => slider.end_pos().unwrap_or(self.pos),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn pre_stacked_end_pos(&self) -> Pos2 {
|
||||
match &self.kind {
|
||||
OsuObjectKind::Circle | OsuObjectKind::Spinner { .. } => self.pos,
|
||||
OsuObjectKind::Slider(slider) => slider
|
||||
.end_pos()
|
||||
.map_or(self.pos, |end_pos| self.pos + end_pos),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn old_stacking_pos2(&self) -> Pos2 {
|
||||
match &self.kind {
|
||||
OsuObjectKind::Circle | OsuObjectKind::Spinner { .. } => self.pos,
|
||||
OsuObjectKind::Slider(slider) => {
|
||||
// Old stacking requires the path end position
|
||||
// instead of slider end position
|
||||
let repeat_count = slider.repeat_count();
|
||||
|
||||
if repeat_count % 2 == 0 {
|
||||
slider
|
||||
.end_pos()
|
||||
.map_or(self.pos, |end_pos| self.pos + end_pos)
|
||||
} else {
|
||||
slider
|
||||
.nested_iter()
|
||||
.find(|nested| matches!(nested.kind, NestedObjectKind::Repeat))
|
||||
.map_or(self.pos, |repeat| repeat.pos)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) const fn pos(&self) -> Pos2 {
|
||||
self.pos
|
||||
}
|
||||
|
||||
pub(crate) fn stacked_pos(&self) -> Pos2 {
|
||||
self.pos + self.stack_offset
|
||||
}
|
||||
|
||||
pub(crate) fn stacked_end_pos(&self) -> Pos2 {
|
||||
self.end_pos() + self.stack_offset
|
||||
}
|
||||
|
||||
pub(crate) fn lazy_end_pos(&self) -> Pos2 {
|
||||
match &self.kind {
|
||||
OsuObjectKind::Circle | OsuObjectKind::Spinner { .. } => self.stacked_pos(),
|
||||
OsuObjectKind::Slider(slider) => slider.lazy_end_pos,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn lazy_travel_time(&self) -> f64 {
|
||||
match &self.kind {
|
||||
OsuObjectKind::Circle | OsuObjectKind::Spinner { .. } => 0.0,
|
||||
OsuObjectKind::Slider(slider) => slider
|
||||
.nested_objects
|
||||
.last()
|
||||
.map_or(0.0, |nested| nested.start_time - self.start_time),
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn is_circle(&self) -> bool {
|
||||
matches!(self.kind, OsuObjectKind::Circle)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn is_slider(&self) -> bool {
|
||||
matches!(self.kind, OsuObjectKind::Slider { .. })
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn is_spinner(&self) -> bool {
|
||||
matches!(self.kind, OsuObjectKind::Spinner { .. })
|
||||
}
|
||||
|
||||
/// Applies stack offset, flips playfield for HR,
|
||||
/// and adjusts slider tails and lazy_end_positions.
|
||||
pub(crate) fn post_process(&mut self, hr: bool, scaling_factor: &ScalingFactor) {
|
||||
self.stack_offset = scaling_factor.stack_offset(self.stack_height);
|
||||
let pos = self.pos();
|
||||
|
||||
if let OsuObjectKind::Slider(slider) = &mut self.kind {
|
||||
if hr {
|
||||
let mut lazy_end_pos = pos;
|
||||
lazy_end_pos.y = PLAYFIELD_BASE_SIZE.y - lazy_end_pos.y;
|
||||
lazy_end_pos += self.stack_offset;
|
||||
|
||||
lazy_end_pos += Pos2 {
|
||||
x: slider.lazy_end_pos.x,
|
||||
y: -slider.lazy_end_pos.y,
|
||||
};
|
||||
|
||||
slider.lazy_end_pos = lazy_end_pos;
|
||||
|
||||
let tail_idx = slider.tail_mut().map(|(tail_idx, tail)| {
|
||||
let mut tail_pos = pos;
|
||||
tail_pos.y = PLAYFIELD_BASE_SIZE.y - tail_pos.y;
|
||||
|
||||
tail_pos += Pos2 {
|
||||
x: tail.pos.x,
|
||||
y: -tail.pos.y,
|
||||
};
|
||||
|
||||
tail.pos = tail_pos;
|
||||
|
||||
tail_idx
|
||||
});
|
||||
|
||||
if let Some(tail_idx) = tail_idx {
|
||||
for nested in slider.nested_objects[..tail_idx].iter_mut() {
|
||||
nested.pos.y = PLAYFIELD_BASE_SIZE.y - nested.pos.y;
|
||||
}
|
||||
|
||||
for nested in slider.nested_objects[tail_idx + 1..].iter_mut() {
|
||||
nested.pos.y = PLAYFIELD_BASE_SIZE.y - nested.pos.y;
|
||||
}
|
||||
} else {
|
||||
// Should never happen since sliders are bound to have a tail
|
||||
for nested in slider.nested_objects.iter_mut() {
|
||||
nested.pos.y = PLAYFIELD_BASE_SIZE.y - nested.pos.y;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
slider.lazy_end_pos += pos + self.stack_offset;
|
||||
|
||||
if let Some((_, tail)) = slider.tail_mut() {
|
||||
tail.pos += pos;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if hr {
|
||||
self.pos.y = PLAYFIELD_BASE_SIZE.y - pos.y
|
||||
}
|
||||
}
|
||||
}
|
||||
+1024
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,37 @@
|
||||
use crate::parse::Pos2;
|
||||
|
||||
use super::NORMALIZED_RADIUS;
|
||||
|
||||
const OBJECT_RADIUS: f32 = 64.0;
|
||||
|
||||
#[derive(Copy, Clone, Debug)]
|
||||
pub(crate) struct ScalingFactor {
|
||||
pub(crate) factor: f32,
|
||||
pub(crate) radius: f32,
|
||||
scale: f32,
|
||||
}
|
||||
|
||||
impl ScalingFactor {
|
||||
pub(crate) fn new(cs: f64) -> Self {
|
||||
let scale = (1.0 - 0.7 * (cs as f32 - 5.0) / 5.0) / 2.0;
|
||||
|
||||
let radius = OBJECT_RADIUS * scale;
|
||||
let factor = NORMALIZED_RADIUS / radius;
|
||||
|
||||
let factor = if radius < 30.0 {
|
||||
factor * (1.0 + (30.0 - radius).min(5.0) / 50.0)
|
||||
} else {
|
||||
factor
|
||||
};
|
||||
|
||||
Self {
|
||||
factor,
|
||||
radius,
|
||||
scale,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn stack_offset(&self, stack_height: f32) -> Pos2 {
|
||||
Pos2::new(stack_height * self.scale * -6.4)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
use std::f64::consts::{FRAC_PI_2, PI};
|
||||
|
||||
use crate::osu::difficulty_object::OsuDifficultyObject;
|
||||
|
||||
use super::{previous, previous_start_time, OsuStrainSkill, Skill, StrainSkill};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct Aim {
|
||||
curr_strain: f64,
|
||||
curr_section_peak: f64,
|
||||
curr_section_end: f64,
|
||||
pub(crate) strain_peaks: Vec<f64>,
|
||||
with_sliders: bool,
|
||||
object_strains: Vec<f64>,
|
||||
difficulty: f64,
|
||||
}
|
||||
|
||||
impl Aim {
|
||||
const SKILL_MULTIPLIER: f64 = 23.55;
|
||||
const STRAIN_DECAY_BASE: f64 = 0.15;
|
||||
|
||||
pub(crate) fn new(with_sliders: bool) -> Self {
|
||||
Self {
|
||||
curr_strain: 0.0,
|
||||
curr_section_peak: 0.0,
|
||||
curr_section_end: 0.0,
|
||||
strain_peaks: Vec::new(),
|
||||
with_sliders,
|
||||
object_strains: Vec::new(),
|
||||
difficulty: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
fn strain_decay(ms: f64) -> f64 {
|
||||
Self::STRAIN_DECAY_BASE.powf(ms / 1000.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl Skill for Aim {
|
||||
#[inline]
|
||||
fn process(
|
||||
&mut self,
|
||||
curr: &OsuDifficultyObject<'_>,
|
||||
diff_objects: &[OsuDifficultyObject<'_>],
|
||||
) {
|
||||
<Self as StrainSkill>::process(self, curr, diff_objects)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn difficulty_value(&mut self) -> f64 {
|
||||
<Self as OsuStrainSkill>::difficulty_value(self)
|
||||
}
|
||||
}
|
||||
|
||||
impl StrainSkill for Aim {
|
||||
#[inline]
|
||||
fn strain_peaks_mut(&mut self) -> &mut Vec<f64> {
|
||||
&mut self.strain_peaks
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn curr_section_peak(&mut self) -> &mut f64 {
|
||||
&mut self.curr_section_peak
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn curr_section_end(&mut self) -> &mut f64 {
|
||||
&mut self.curr_section_end
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn strain_value_at(
|
||||
&mut self,
|
||||
curr: &OsuDifficultyObject<'_>,
|
||||
diff_objects: &[OsuDifficultyObject<'_>],
|
||||
) -> f64 {
|
||||
self.curr_strain *= Self::strain_decay(curr.delta_time);
|
||||
self.curr_strain += AimEvaluator::evaluate_diff_of(curr, diff_objects, self.with_sliders)
|
||||
* Self::SKILL_MULTIPLIER;
|
||||
|
||||
self.object_strains.push(self.curr_strain);
|
||||
|
||||
self.curr_strain
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn calculate_initial_strain(
|
||||
&self,
|
||||
time: f64,
|
||||
curr: &OsuDifficultyObject<'_>,
|
||||
diff_objects: &[OsuDifficultyObject<'_>],
|
||||
) -> f64 {
|
||||
self.curr_strain * Self::strain_decay(time - previous_start_time(diff_objects, curr.idx, 0))
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn difficulty_value(&mut self) -> f64 {
|
||||
<Self as OsuStrainSkill>::difficulty_value(self)
|
||||
}
|
||||
}
|
||||
|
||||
impl OsuStrainSkill for Aim {
|
||||
fn strains(&self) -> &Vec<f64> {
|
||||
&self.object_strains
|
||||
}
|
||||
|
||||
fn set_raw_difficulty_value(&mut self, value: f64) {
|
||||
self.difficulty = value;
|
||||
}
|
||||
|
||||
fn get_raw_difficulty_value(&self) -> f64 {
|
||||
self.difficulty
|
||||
}
|
||||
}
|
||||
|
||||
struct AimEvaluator;
|
||||
|
||||
impl AimEvaluator {
|
||||
const WIDE_ANGLE_MULTIPLIER: f64 = 1.5;
|
||||
const ACUTE_ANGLE_MULTIPLIER: f64 = 1.95;
|
||||
const SLIDER_MULTIPLIER: f64 = 1.35;
|
||||
const VELOCITY_CHANGE_MULTIPLIER: f64 = 0.75;
|
||||
|
||||
fn evaluate_diff_of(
|
||||
curr: &OsuDifficultyObject<'_>,
|
||||
diff_objects: &[OsuDifficultyObject<'_>],
|
||||
with_sliders: bool,
|
||||
) -> f64 {
|
||||
let osu_curr_obj = curr;
|
||||
|
||||
let (osu_last_last_obj, osu_last_obj) = if let Some(tuple) =
|
||||
previous(diff_objects, curr.idx, 1)
|
||||
.zip(previous(diff_objects, curr.idx, 0))
|
||||
.filter(|(_, last)| !(curr.base.is_spinner() || last.base.is_spinner()))
|
||||
{
|
||||
tuple
|
||||
} else {
|
||||
return 0.0;
|
||||
};
|
||||
|
||||
// * Calculate the velocity to the current hitobject, which starts
|
||||
// * with a base distance / time assuming the last object is a hitcircle.
|
||||
let mut curr_vel = osu_curr_obj.dists.lazy_jump_dist / osu_curr_obj.strain_time;
|
||||
|
||||
// * But if the last object is a slider, then we extend the travel
|
||||
// * velocity through the slider into the current object.
|
||||
if osu_last_obj.base.is_slider() && with_sliders {
|
||||
// * calculate the slider velocity from slider head to slider end.
|
||||
let travel_vel = osu_last_obj.dists.travel_dist / osu_last_obj.dists.travel_time;
|
||||
// * calculate the movement velocity from slider end to current object
|
||||
let movement_vel = osu_curr_obj.dists.min_jump_dist / osu_curr_obj.dists.min_jump_time;
|
||||
|
||||
// * take the larger total combined velocity.
|
||||
curr_vel = curr_vel.max(movement_vel + travel_vel);
|
||||
}
|
||||
|
||||
// * As above, do the same for the previous hitobject.
|
||||
let mut prev_vel = osu_last_obj.dists.lazy_jump_dist / osu_last_obj.strain_time;
|
||||
|
||||
if osu_last_last_obj.base.is_slider() && with_sliders {
|
||||
let travel_vel =
|
||||
osu_last_last_obj.dists.travel_dist / osu_last_last_obj.dists.travel_time;
|
||||
let movement_vel = osu_last_obj.dists.min_jump_dist / osu_last_obj.dists.min_jump_time;
|
||||
|
||||
prev_vel = prev_vel.max(movement_vel + travel_vel);
|
||||
}
|
||||
|
||||
let mut wide_angle_bonus = 0.0;
|
||||
let mut acute_angle_bonus = 0.0;
|
||||
let mut slider_bonus = 0.0;
|
||||
let mut vel_change_bonus = 0.0;
|
||||
|
||||
// * Start strain with regular velocity.
|
||||
let mut aim_strain = curr_vel;
|
||||
|
||||
// * If rhythms are the same.
|
||||
if osu_curr_obj.strain_time.max(osu_last_obj.strain_time)
|
||||
< 1.25 * osu_curr_obj.strain_time.min(osu_last_obj.strain_time)
|
||||
{
|
||||
if let Some(((curr_angle, last_angle), last_last_angle)) = osu_curr_obj
|
||||
.dists
|
||||
.angle
|
||||
.zip(osu_last_obj.dists.angle)
|
||||
.zip(osu_last_last_obj.dists.angle)
|
||||
{
|
||||
// * Rewarding angles, take the smaller velocity as base.
|
||||
let angle_bonus = curr_vel.min(prev_vel);
|
||||
|
||||
wide_angle_bonus = Self::calc_wide_angle_bonus(curr_angle);
|
||||
acute_angle_bonus = Self::calc_acute_angle_bonus(curr_angle);
|
||||
|
||||
// * Only buff deltaTime exceeding 300 bpm 1/2.
|
||||
if osu_curr_obj.strain_time > 100.0 {
|
||||
acute_angle_bonus = 0.0;
|
||||
} else {
|
||||
let base1 =
|
||||
(FRAC_PI_2 * ((100.0 - osu_curr_obj.strain_time) / 25.0).min(1.0)).sin();
|
||||
|
||||
let base2 = (FRAC_PI_2
|
||||
* ((osu_curr_obj.dists.lazy_jump_dist).clamp(50.0, 100.0) - 50.0)
|
||||
/ 50.0)
|
||||
.sin();
|
||||
|
||||
// * Multiply by previous angle, we don't want to buff unless this is a wiggle type pattern.
|
||||
acute_angle_bonus *= Self::calc_acute_angle_bonus(last_angle)
|
||||
// * The maximum velocity we buff is equal to 125 / strainTime
|
||||
* angle_bonus.min(125.0 / osu_curr_obj.strain_time)
|
||||
// * scale buff from 150 bpm 1/4 to 200 bpm 1/4
|
||||
* base1
|
||||
* base1
|
||||
// * Buff distance exceeding 50 (radius) up to 100 (diameter).
|
||||
* base2
|
||||
* base2;
|
||||
}
|
||||
|
||||
// * Penalize wide angles if they're repeated, reducing the penalty as the lastAngle gets more acute.
|
||||
wide_angle_bonus *= angle_bonus
|
||||
* (1.0 - wide_angle_bonus.min(Self::calc_wide_angle_bonus(last_angle).powi(3)));
|
||||
// * Penalize acute angles if they're repeated, reducing the penalty as the lastLastAngle gets more obtuse.
|
||||
acute_angle_bonus *= 0.5
|
||||
+ 0.5
|
||||
* (1.0
|
||||
- acute_angle_bonus
|
||||
.min(Self::calc_acute_angle_bonus(last_last_angle).powi(3)));
|
||||
}
|
||||
}
|
||||
|
||||
if prev_vel.max(curr_vel).abs() > f64::EPSILON {
|
||||
// * We want to use the average velocity over the whole object when awarding
|
||||
// * differences, not the individual jump and slider path velocities.
|
||||
prev_vel = (osu_last_obj.dists.lazy_jump_dist + osu_last_last_obj.dists.travel_dist)
|
||||
/ osu_last_obj.strain_time;
|
||||
curr_vel = (osu_curr_obj.dists.lazy_jump_dist + osu_last_obj.dists.travel_dist)
|
||||
/ osu_curr_obj.strain_time;
|
||||
|
||||
// * Scale with ratio of difference compared to 0.5 * max dist.
|
||||
let dist_ratio_base =
|
||||
(FRAC_PI_2 * (prev_vel - curr_vel).abs() / prev_vel.max(curr_vel)).sin();
|
||||
let dist_ratio = dist_ratio_base * dist_ratio_base;
|
||||
|
||||
// * Reward for % distance up to 125 / strainTime for overlaps where velocity is still changing.
|
||||
let overlap_vel_buff = (125.0 / osu_curr_obj.strain_time.min(osu_last_obj.strain_time))
|
||||
.min((prev_vel - curr_vel).abs());
|
||||
|
||||
vel_change_bonus = overlap_vel_buff * dist_ratio;
|
||||
|
||||
// * Penalize for rhythm changes.
|
||||
let bonus_base = (osu_curr_obj.strain_time).min(osu_last_obj.strain_time)
|
||||
/ (osu_curr_obj.strain_time).max(osu_last_obj.strain_time);
|
||||
vel_change_bonus *= bonus_base * bonus_base;
|
||||
}
|
||||
|
||||
if osu_last_obj.base.is_slider() {
|
||||
// * Reward sliders based on velocity.
|
||||
slider_bonus = osu_last_obj.dists.travel_dist / osu_last_obj.dists.travel_time
|
||||
}
|
||||
|
||||
// * Add in acute angle bonus or wide angle bonus + velocity change bonus, whichever is larger.
|
||||
aim_strain += (acute_angle_bonus * Self::ACUTE_ANGLE_MULTIPLIER).max(
|
||||
wide_angle_bonus * Self::WIDE_ANGLE_MULTIPLIER
|
||||
+ vel_change_bonus * Self::VELOCITY_CHANGE_MULTIPLIER,
|
||||
);
|
||||
|
||||
// * Add in additional slider velocity bonus.
|
||||
if with_sliders {
|
||||
aim_strain += slider_bonus * Self::SLIDER_MULTIPLIER;
|
||||
}
|
||||
|
||||
aim_strain
|
||||
}
|
||||
|
||||
fn calc_wide_angle_bonus(angle: f64) -> f64 {
|
||||
let base = (3.0 / 4.0 * ((5.0 / 6.0 * PI).min(angle.max(PI / 6.0)) - PI / 6.0)).sin();
|
||||
|
||||
base * base
|
||||
}
|
||||
|
||||
fn calc_acute_angle_bonus(angle: f64) -> f64 {
|
||||
1.0 - Self::calc_wide_angle_bonus(angle)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
use crate::{
|
||||
osu::{difficulty_object::OsuDifficultyObject, osu_object::OsuObjectKind},
|
||||
Mods,
|
||||
};
|
||||
|
||||
use super::{previous, previous_start_time, OsuStrainSkill, Skill, StrainSkill};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct Flashlight {
|
||||
curr_strain: f64,
|
||||
curr_section_peak: f64,
|
||||
curr_section_end: f64,
|
||||
pub(crate) strain_peaks: Vec<f64>,
|
||||
has_hidden_mod: bool,
|
||||
scaling_factor: f64,
|
||||
time_preempt: f64,
|
||||
time_fade_in: f64,
|
||||
}
|
||||
|
||||
impl Flashlight {
|
||||
const SKILL_MULTIPLIER: f64 = 0.052;
|
||||
const STRAIN_DECAY_BASE: f64 = 0.15;
|
||||
|
||||
pub(crate) fn new(mods: u32, radius: f32, time_preempt: f64, time_fade_in: f64) -> Self {
|
||||
Self {
|
||||
curr_strain: 0.0,
|
||||
curr_section_peak: 0.0,
|
||||
curr_section_end: 0.0,
|
||||
strain_peaks: Vec::new(),
|
||||
has_hidden_mod: mods.hd(),
|
||||
scaling_factor: 52.0 / radius as f64,
|
||||
time_preempt,
|
||||
time_fade_in,
|
||||
}
|
||||
}
|
||||
|
||||
fn strain_decay(ms: f64) -> f64 {
|
||||
Self::STRAIN_DECAY_BASE.powf(ms / 1000.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl Skill for Flashlight {
|
||||
#[inline]
|
||||
fn process(
|
||||
&mut self,
|
||||
curr: &OsuDifficultyObject<'_>,
|
||||
diff_objects: &[OsuDifficultyObject<'_>],
|
||||
) {
|
||||
<Self as StrainSkill>::process(self, curr, diff_objects)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn difficulty_value(&mut self) -> f64 {
|
||||
<Self as StrainSkill>::difficulty_value(self)
|
||||
}
|
||||
}
|
||||
|
||||
impl StrainSkill for Flashlight {
|
||||
const DECAY_WEIGHT: f64 = 0.9;
|
||||
|
||||
#[inline]
|
||||
fn strain_peaks_mut(&mut self) -> &mut Vec<f64> {
|
||||
&mut self.strain_peaks
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn curr_section_peak(&mut self) -> &mut f64 {
|
||||
&mut self.curr_section_peak
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn curr_section_end(&mut self) -> &mut f64 {
|
||||
&mut self.curr_section_end
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn strain_value_at(
|
||||
&mut self,
|
||||
curr: &OsuDifficultyObject<'_>,
|
||||
diff_objects: &[OsuDifficultyObject<'_>],
|
||||
) -> f64 {
|
||||
self.curr_strain *= Self::strain_decay(curr.delta_time);
|
||||
self.curr_strain += FlashlightEvaluator::evaluate_diff_of(
|
||||
curr,
|
||||
diff_objects,
|
||||
self.has_hidden_mod,
|
||||
self.scaling_factor,
|
||||
self.time_preempt,
|
||||
self.time_fade_in,
|
||||
) * Self::SKILL_MULTIPLIER;
|
||||
|
||||
self.curr_strain
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn calculate_initial_strain(
|
||||
&self,
|
||||
time: f64,
|
||||
curr: &OsuDifficultyObject<'_>,
|
||||
diff_objects: &[OsuDifficultyObject<'_>],
|
||||
) -> f64 {
|
||||
self.curr_strain * Self::strain_decay(time - previous_start_time(diff_objects, curr.idx, 0))
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn difficulty_value(&mut self) -> f64 {
|
||||
self.get_curr_strain_peaks().into_iter().sum::<f64>() * Self::DIFFICULTY_MULTIPLER
|
||||
}
|
||||
}
|
||||
|
||||
const HOLDING_VEC: &Vec<f64> = &vec![];
|
||||
|
||||
impl OsuStrainSkill for Flashlight {
|
||||
fn strains(&self) -> &Vec<f64> {
|
||||
HOLDING_VEC
|
||||
}
|
||||
|
||||
fn set_raw_difficulty_value(&mut self, _value: f64) {}
|
||||
|
||||
fn get_raw_difficulty_value(&self) -> f64 {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
|
||||
struct FlashlightEvaluator;
|
||||
|
||||
impl FlashlightEvaluator {
|
||||
const MAX_OPACITY_BONUS: f64 = 0.4;
|
||||
const HIDDEN_BONUS: f64 = 0.2;
|
||||
|
||||
const MIN_VELOCITY: f64 = 0.5;
|
||||
const SLIDER_MULTIPLIER: f64 = 1.3;
|
||||
|
||||
const MIN_ANGLE_MULTIPLIER: f64 = 0.2;
|
||||
|
||||
fn evaluate_diff_of(
|
||||
curr: &OsuDifficultyObject<'_>,
|
||||
diff_objects: &[OsuDifficultyObject<'_>],
|
||||
hidden: bool,
|
||||
scaling_factor: f64,
|
||||
time_preempt: f64,
|
||||
time_fade_in: f64,
|
||||
) -> f64 {
|
||||
if curr.base.is_spinner() {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
let osu_curr = curr;
|
||||
let osu_hit_obj = curr.base;
|
||||
|
||||
let mut small_dist_nerf = 1.0;
|
||||
let mut cumulative_strain_time = 0.0;
|
||||
|
||||
let mut result = 0.0;
|
||||
|
||||
let mut last_obj = osu_curr;
|
||||
|
||||
let mut angle_repeat_count = 0.0;
|
||||
|
||||
// * This is iterating backwards in time from the current object.
|
||||
for i in 0..curr.idx.min(10) {
|
||||
let curr_obj = if let Some(curr_obj) = previous(diff_objects, curr.idx, i) {
|
||||
curr_obj
|
||||
} else {
|
||||
break;
|
||||
};
|
||||
|
||||
let curr_hit_obj = curr_obj.base;
|
||||
|
||||
if !curr_obj.base.is_spinner() {
|
||||
let jump_dist =
|
||||
(osu_hit_obj.stacked_pos() - curr_hit_obj.stacked_end_pos()).length() as f64;
|
||||
cumulative_strain_time += last_obj.strain_time;
|
||||
|
||||
// * We want to nerf objects that can be easily seen within the Flashlight circle radius.
|
||||
if i == 0 {
|
||||
small_dist_nerf = (jump_dist / 75.0).min(1.0);
|
||||
}
|
||||
|
||||
// * We also want to nerf stacks so that only the first object of the stack is accounted for.
|
||||
let stack_nerf = ((curr_obj.dists.lazy_jump_dist / scaling_factor) / 25.0).min(1.0);
|
||||
|
||||
// * Bonus based on how visible the object is.
|
||||
let opacity_bonus = 1.0
|
||||
+ Self::MAX_OPACITY_BONUS
|
||||
* (1.0
|
||||
- osu_curr.opacity_at(
|
||||
curr_hit_obj.start_time,
|
||||
hidden,
|
||||
time_preempt,
|
||||
time_fade_in,
|
||||
));
|
||||
|
||||
result += stack_nerf * opacity_bonus * scaling_factor * jump_dist
|
||||
/ cumulative_strain_time;
|
||||
|
||||
if let Some((curr_obj_angle, osu_curr_angle)) =
|
||||
curr_obj.dists.angle.zip(osu_curr.dists.angle)
|
||||
{
|
||||
// * Objects further back in time should count less for the nerf.
|
||||
if (curr_obj_angle - osu_curr_angle).abs() < 0.02 {
|
||||
angle_repeat_count += (1.0 - 0.1 * i as f64).max(0.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
last_obj = curr_obj;
|
||||
}
|
||||
|
||||
let base = small_dist_nerf * result;
|
||||
result = base * base;
|
||||
|
||||
// * Additional bonus for Hidden due to there being no approach circles.
|
||||
if hidden {
|
||||
result *= 1.0 + Self::HIDDEN_BONUS;
|
||||
}
|
||||
|
||||
// * Nerf patterns with repeated angles.
|
||||
result *= Self::MIN_ANGLE_MULTIPLIER
|
||||
+ (1.0 - Self::MIN_ANGLE_MULTIPLIER) / (angle_repeat_count + 1.0);
|
||||
|
||||
let mut slider_bonus = 0.0;
|
||||
|
||||
if let OsuObjectKind::Slider(slider) = &osu_curr.base.kind {
|
||||
// * Invert the scaling factor to determine the true travel distance independent of circle size.
|
||||
let pixel_travel_dist = osu_curr.dists.lazy_travel_dist as f64 / scaling_factor;
|
||||
|
||||
// * Reward sliders based on velocity.
|
||||
slider_bonus = ((pixel_travel_dist / osu_curr.dists.travel_time as f64
|
||||
- Self::MIN_VELOCITY)
|
||||
.max(0.0))
|
||||
.sqrt();
|
||||
|
||||
// * Longer sliders require more memorisation.
|
||||
slider_bonus *= pixel_travel_dist;
|
||||
|
||||
// * Nerf sliders with repeats, as less memorisation is required.
|
||||
let repeat_count = slider.repeat_count();
|
||||
|
||||
if repeat_count > 0 {
|
||||
slider_bonus /= (repeat_count + 1) as f64;
|
||||
}
|
||||
}
|
||||
|
||||
result += slider_bonus * Self::SLIDER_MULTIPLIER;
|
||||
|
||||
result
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
mod aim;
|
||||
mod flashlight;
|
||||
mod speed;
|
||||
mod traits;
|
||||
|
||||
use crate::osu::difficulty_object::OsuDifficultyObject;
|
||||
|
||||
pub(crate) use self::{
|
||||
aim::Aim,
|
||||
flashlight::Flashlight,
|
||||
speed::Speed,
|
||||
traits::{OsuStrainSkill, Skill, StrainSkill},
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct Skills {
|
||||
pub aim: Aim,
|
||||
pub aim_no_sliders: Aim,
|
||||
pub speed: Speed,
|
||||
pub flashlight: Flashlight,
|
||||
}
|
||||
|
||||
impl Skills {
|
||||
pub(crate) fn new(
|
||||
mods: u32,
|
||||
radius: f32,
|
||||
time_preempt: f64,
|
||||
time_fade_in: f64,
|
||||
hit_window: f64,
|
||||
) -> Self {
|
||||
Self {
|
||||
aim: Aim::new(true),
|
||||
aim_no_sliders: Aim::new(false),
|
||||
speed: Speed::new(hit_window, mods),
|
||||
flashlight: Flashlight::new(mods, radius, time_preempt, time_fade_in),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn process(
|
||||
&mut self,
|
||||
curr: &OsuDifficultyObject<'_>,
|
||||
diff_objects: &[OsuDifficultyObject<'_>],
|
||||
) {
|
||||
<Aim as Skill>::process(&mut self.aim, curr, diff_objects);
|
||||
<Aim as Skill>::process(&mut self.aim_no_sliders, curr, diff_objects);
|
||||
<Speed as Skill>::process(&mut self.speed, curr, diff_objects);
|
||||
<Flashlight as Skill>::process(&mut self.flashlight, curr, diff_objects);
|
||||
}
|
||||
}
|
||||
|
||||
fn previous<'map, 'objects>(
|
||||
diff_objects: &'objects [OsuDifficultyObject<'map>],
|
||||
curr: usize,
|
||||
backwards_idx: usize,
|
||||
) -> Option<&'objects OsuDifficultyObject<'map>> {
|
||||
curr.checked_sub(backwards_idx + 1)
|
||||
.and_then(|idx| diff_objects.get(idx))
|
||||
}
|
||||
|
||||
fn previous_start_time(
|
||||
diff_objects: &[OsuDifficultyObject<'_>],
|
||||
curr: usize,
|
||||
backwards_idx: usize,
|
||||
) -> f64 {
|
||||
previous(diff_objects, curr, backwards_idx).map_or(0.0, |h| h.start_time)
|
||||
}
|
||||
|
||||
fn next<'map, 'objects>(
|
||||
diff_objects: &'objects [OsuDifficultyObject<'map>],
|
||||
curr: usize,
|
||||
forwards_idx: usize,
|
||||
) -> Option<&'objects OsuDifficultyObject<'map>> {
|
||||
diff_objects.get(curr + (forwards_idx + 1))
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
use std::{cmp::Ordering, f64::consts::PI};
|
||||
|
||||
use crate::{osu::difficulty_object::OsuDifficultyObject, Mods};
|
||||
|
||||
use super::{next, previous, previous_start_time, OsuStrainSkill, Skill, StrainSkill};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct Speed {
|
||||
curr_strain: f64,
|
||||
curr_section_peak: f64,
|
||||
curr_section_end: f64,
|
||||
curr_rhythm: f64,
|
||||
pub(crate) strain_peaks: Vec<f64>,
|
||||
object_strains: Vec<f64>,
|
||||
hit_window: f64,
|
||||
mods: u32,
|
||||
difficulty: f64,
|
||||
}
|
||||
|
||||
impl Speed {
|
||||
const SKILL_MULTIPLIER: f64 = 1375.0;
|
||||
const STRAIN_DECAY_BASE: f64 = 0.3;
|
||||
|
||||
pub(crate) fn new(hit_window: f64, mods: u32) -> Self {
|
||||
Self {
|
||||
curr_strain: 0.0,
|
||||
curr_section_peak: 0.0,
|
||||
curr_section_end: 0.0,
|
||||
curr_rhythm: 0.0,
|
||||
strain_peaks: Vec::new(),
|
||||
object_strains: Vec::new(),
|
||||
hit_window,
|
||||
mods,
|
||||
difficulty: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
fn strain_decay(ms: f64) -> f64 {
|
||||
Self::STRAIN_DECAY_BASE.powf(ms / 1000.0)
|
||||
}
|
||||
|
||||
pub(crate) fn relevant_note_count(&self) -> f64 {
|
||||
self.object_strains
|
||||
.iter()
|
||||
.max_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal))
|
||||
.copied()
|
||||
.filter(|&n| n > 0.0)
|
||||
.map_or(0.0, |max_strain| {
|
||||
self.object_strains.iter().fold(0.0, |sum, strain| {
|
||||
sum + (1.0 + (-(strain / max_strain * 12.0 - 6.0)).exp()).recip()
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Skill for Speed {
|
||||
#[inline]
|
||||
fn process(
|
||||
&mut self,
|
||||
curr: &OsuDifficultyObject<'_>,
|
||||
diff_objects: &[OsuDifficultyObject<'_>],
|
||||
) {
|
||||
<Self as StrainSkill>::process(self, curr, diff_objects)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn difficulty_value(&mut self) -> f64 {
|
||||
<Self as OsuStrainSkill>::difficulty_value(self)
|
||||
}
|
||||
}
|
||||
|
||||
impl StrainSkill for Speed {
|
||||
#[inline]
|
||||
fn strain_peaks_mut(&mut self) -> &mut Vec<f64> {
|
||||
&mut self.strain_peaks
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn curr_section_peak(&mut self) -> &mut f64 {
|
||||
&mut self.curr_section_peak
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn curr_section_end(&mut self) -> &mut f64 {
|
||||
&mut self.curr_section_end
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn strain_value_at(
|
||||
&mut self,
|
||||
curr: &OsuDifficultyObject<'_>,
|
||||
diff_objects: &[OsuDifficultyObject<'_>],
|
||||
) -> f64 {
|
||||
self.curr_strain *= Self::strain_decay(curr.strain_time);
|
||||
self.curr_strain +=
|
||||
SpeedEvaluator::evaluate_diff_of(curr, diff_objects, self.hit_window, self.mods)
|
||||
* Self::SKILL_MULTIPLIER;
|
||||
self.curr_rhythm = RhythmEvaluator::evaluate_diff_of(curr, diff_objects, self.hit_window);
|
||||
|
||||
let total_strain = self.curr_strain * self.curr_rhythm;
|
||||
self.object_strains.push(total_strain);
|
||||
|
||||
total_strain
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn calculate_initial_strain(
|
||||
&self,
|
||||
time: f64,
|
||||
curr: &OsuDifficultyObject<'_>,
|
||||
diff_objects: &[OsuDifficultyObject<'_>],
|
||||
) -> f64 {
|
||||
(self.curr_strain * self.curr_rhythm)
|
||||
* Self::strain_decay(time - previous_start_time(diff_objects, curr.idx, 0))
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn difficulty_value(&mut self) -> f64 {
|
||||
<Self as OsuStrainSkill>::difficulty_value(self)
|
||||
}
|
||||
}
|
||||
|
||||
impl OsuStrainSkill for Speed {
|
||||
const REDUCED_SECTION_COUNT: usize = 5;
|
||||
const DIFFICULTY_MULTIPLER: f64 = 1.04;
|
||||
|
||||
fn strains(&self) -> &Vec<f64> {
|
||||
&self.object_strains
|
||||
}
|
||||
|
||||
fn set_raw_difficulty_value(&mut self, value: f64) {
|
||||
self.difficulty = value;
|
||||
}
|
||||
|
||||
fn get_raw_difficulty_value(&self) -> f64 {
|
||||
self.difficulty
|
||||
}
|
||||
}
|
||||
|
||||
struct SpeedEvaluator;
|
||||
|
||||
impl SpeedEvaluator {
|
||||
const SINGLE_SPACING_THRESHOLD: f64 = 125.0;
|
||||
const MIN_SPEED_BONUS: f64 = 75.0; // ~200BPM
|
||||
const SPEED_BALANCING_FACTOR: f64 = 40.;
|
||||
|
||||
fn evaluate_diff_of(
|
||||
curr: &OsuDifficultyObject<'_>,
|
||||
diff_objects: &[OsuDifficultyObject<'_>],
|
||||
hit_window: f64,
|
||||
mods: u32,
|
||||
) -> f64 {
|
||||
if curr.base.is_spinner() {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
// * derive strainTime for calculation
|
||||
let osu_curr_obj = curr;
|
||||
let osu_prev_obj = previous(diff_objects, curr.idx, 0);
|
||||
let osu_next_obj = next(diff_objects, curr.idx, 0);
|
||||
|
||||
let mut strain_time = curr.strain_time;
|
||||
let mut doubletapness = 1.0;
|
||||
|
||||
// * Nerf doubletappable doubles.
|
||||
if let Some(osu_next_obj) = osu_next_obj {
|
||||
let curr_delta_time = osu_curr_obj.delta_time.max(1.0);
|
||||
let next_delta_time = osu_next_obj.delta_time.max(1.0);
|
||||
let delta_diff = (next_delta_time - curr_delta_time).abs();
|
||||
let speed_ratio = curr_delta_time / curr_delta_time.max(delta_diff);
|
||||
let window_ratio_base = (curr_delta_time / hit_window).min(1.0);
|
||||
let window_ratio = window_ratio_base * window_ratio_base;
|
||||
doubletapness = speed_ratio.powf(1.0 - window_ratio);
|
||||
}
|
||||
|
||||
// * Cap deltatime to the OD 300 hitwindow.
|
||||
// * 0.93 is derived from making sure 260bpm OD8 streams aren't nerfed harshly, whilst 0.92 limits the effect of the cap.
|
||||
strain_time /= ((strain_time / hit_window) / 0.93).clamp(0.92, 1.0);
|
||||
|
||||
// * derive speedBonus for calculation
|
||||
let speed_bonus = if strain_time < Self::MIN_SPEED_BONUS {
|
||||
let base = (Self::MIN_SPEED_BONUS - strain_time) / Self::SPEED_BALANCING_FACTOR;
|
||||
|
||||
1.0 + 0.75 * base * base
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
|
||||
let travel_dist = osu_prev_obj.map_or(0.0, |obj| obj.dists.travel_dist);
|
||||
let dist = match mods.ap() {
|
||||
true => 0.0,
|
||||
false => {
|
||||
Self::SINGLE_SPACING_THRESHOLD.min(travel_dist + osu_curr_obj.dists.min_jump_dist)
|
||||
}
|
||||
};
|
||||
|
||||
(speed_bonus + speed_bonus * (dist / Self::SINGLE_SPACING_THRESHOLD).powf(3.5))
|
||||
* doubletapness
|
||||
/ strain_time
|
||||
}
|
||||
}
|
||||
|
||||
struct RhythmEvaluator;
|
||||
|
||||
impl RhythmEvaluator {
|
||||
// * 5 seconds of calculatingRhythmBonus max.
|
||||
const HISTORY_TIME_MAX: u32 = 5000;
|
||||
const RHYTHM_MULTIPLIER: f64 = 0.75;
|
||||
|
||||
fn evaluate_diff_of(
|
||||
curr: &OsuDifficultyObject<'_>,
|
||||
diff_objects: &[OsuDifficultyObject<'_>],
|
||||
hit_window: f64,
|
||||
) -> f64 {
|
||||
if curr.base.is_spinner() {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
let mut prev_island_size = 0;
|
||||
|
||||
let mut rhythm_complexity_sum = 0.0;
|
||||
let mut island_size = 1;
|
||||
// * store the ratio of the current start of an island to buff for tighter rhythms
|
||||
let mut start_ratio = 0.0;
|
||||
|
||||
let mut first_delta_switch = false;
|
||||
|
||||
let historical_note_count = curr.idx.min(32);
|
||||
|
||||
let mut rhythm_start = 0;
|
||||
|
||||
while previous(diff_objects, curr.idx, rhythm_start)
|
||||
.filter(|prev| {
|
||||
rhythm_start + 2 < historical_note_count
|
||||
&& curr.start_time - prev.start_time < Self::HISTORY_TIME_MAX as f64
|
||||
})
|
||||
.is_some()
|
||||
{
|
||||
rhythm_start += 1;
|
||||
}
|
||||
|
||||
for i in (1..=rhythm_start).rev() {
|
||||
let (curr_obj, prev_obj, last_obj) = if let Some(((curr, prev), last)) =
|
||||
previous(diff_objects, curr.idx, i - 1)
|
||||
.zip(previous(diff_objects, curr.idx, i))
|
||||
.zip(previous(diff_objects, curr.idx, i + 1))
|
||||
{
|
||||
(curr, prev, last)
|
||||
} else {
|
||||
break;
|
||||
};
|
||||
|
||||
// * scales note 0 to 1 from history to now
|
||||
let mut curr_historical_decay = (Self::HISTORY_TIME_MAX as f64
|
||||
- (curr.start_time - curr_obj.start_time))
|
||||
/ Self::HISTORY_TIME_MAX as f64;
|
||||
|
||||
// * either we're limited by time or limited by object count.
|
||||
curr_historical_decay = curr_historical_decay
|
||||
.min((historical_note_count - i) as f64 / historical_note_count as f64);
|
||||
|
||||
let curr_delta = curr_obj.strain_time;
|
||||
let prev_delta = prev_obj.strain_time;
|
||||
let last_delta = last_obj.strain_time;
|
||||
|
||||
// * fancy function to calculate rhythmbonuses.
|
||||
let base = (PI / (prev_delta.min(curr_delta) / prev_delta.max(curr_delta))).sin();
|
||||
let curr_ratio = 1.0 + 6.0 * (base * base).min(0.5);
|
||||
|
||||
let hit_window = !curr_obj.base.is_spinner() as u64 as f64 * hit_window;
|
||||
|
||||
let mut window_penalty = ((((prev_delta - curr_delta).abs() - hit_window * 0.3)
|
||||
.max(0.0))
|
||||
/ (hit_window * 0.3))
|
||||
.min(1.0);
|
||||
|
||||
window_penalty = window_penalty.min(1.0);
|
||||
|
||||
let mut effective_ratio = window_penalty * curr_ratio;
|
||||
|
||||
if first_delta_switch {
|
||||
if !(prev_delta > 1.25 * curr_delta || prev_delta * 1.25 < curr_delta) {
|
||||
if island_size < 7 {
|
||||
// * island is still progressing, count size.
|
||||
island_size += 1;
|
||||
}
|
||||
} else {
|
||||
// * bpm change is into slider, this is easy acc window
|
||||
if curr_obj.base.is_slider() {
|
||||
effective_ratio *= 0.125;
|
||||
}
|
||||
|
||||
// * bpm change was from a slider, this is easier typically than circle -> circle
|
||||
if prev_obj.base.is_slider() {
|
||||
effective_ratio *= 0.25;
|
||||
}
|
||||
|
||||
// * repeated island size (ex: triplet -> triplet)
|
||||
if prev_island_size == island_size {
|
||||
effective_ratio *= 0.25;
|
||||
}
|
||||
|
||||
// * repeated island polartiy (2 -> 4, 3 -> 5)
|
||||
if prev_island_size % 2 == island_size % 2 {
|
||||
effective_ratio *= 0.5;
|
||||
}
|
||||
|
||||
// * previous increase happened a note ago, 1/1->1/2-1/4, dont want to buff this.
|
||||
if last_delta > prev_delta + 10.0 && prev_delta > curr_delta + 10.0 {
|
||||
effective_ratio *= 0.125;
|
||||
}
|
||||
|
||||
rhythm_complexity_sum += (effective_ratio * start_ratio).sqrt()
|
||||
* curr_historical_decay
|
||||
* ((4 + island_size) as f64).sqrt()
|
||||
/ 2.0
|
||||
* ((4 + prev_island_size) as f64).sqrt()
|
||||
/ 2.0;
|
||||
|
||||
start_ratio = effective_ratio;
|
||||
|
||||
// * log the last island size.
|
||||
prev_island_size = island_size;
|
||||
|
||||
// * we're slowing down, stop counting
|
||||
if prev_delta * 1.25 < curr_delta {
|
||||
// * if we're speeding up, this stays true and we keep counting island size.
|
||||
first_delta_switch = false;
|
||||
}
|
||||
|
||||
island_size = 1;
|
||||
}
|
||||
} else if prev_delta > 1.25 * curr_delta {
|
||||
// * we want to be speeding up.
|
||||
// * Begin counting island until we change speed again.
|
||||
first_delta_switch = true;
|
||||
start_ratio = effective_ratio;
|
||||
island_size = 1;
|
||||
}
|
||||
}
|
||||
|
||||
// * produces multiplier that can be applied to strain. range [1, infinity) (not really though)
|
||||
(4.0 + rhythm_complexity_sum * Self::RHYTHM_MULTIPLIER).sqrt() / 2.0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
use std::{cmp::Ordering, mem};
|
||||
|
||||
use crate::osu::{difficulty_object::OsuDifficultyObject, SECTION_LEN};
|
||||
|
||||
pub(crate) trait Skill {
|
||||
fn process(&mut self, curr: &OsuDifficultyObject<'_>, diff_objects: &[OsuDifficultyObject<'_>]);
|
||||
fn difficulty_value(&mut self) -> f64;
|
||||
}
|
||||
|
||||
pub(crate) trait StrainSkill: Skill + Sized {
|
||||
const DECAY_WEIGHT: f64 = 0.9;
|
||||
|
||||
fn strain_peaks_mut(&mut self) -> &mut Vec<f64>;
|
||||
fn curr_section_peak(&mut self) -> &mut f64;
|
||||
fn curr_section_end(&mut self) -> &mut f64;
|
||||
|
||||
fn strain_value_at(
|
||||
&mut self,
|
||||
curr: &OsuDifficultyObject<'_>,
|
||||
diff_objects: &[OsuDifficultyObject<'_>],
|
||||
) -> f64;
|
||||
|
||||
fn calculate_initial_strain(
|
||||
&self,
|
||||
time: f64,
|
||||
curr: &OsuDifficultyObject<'_>,
|
||||
diff_objects: &[OsuDifficultyObject<'_>],
|
||||
) -> f64;
|
||||
|
||||
fn process(
|
||||
&mut self,
|
||||
curr: &OsuDifficultyObject<'_>,
|
||||
diff_objects: &[OsuDifficultyObject<'_>],
|
||||
) {
|
||||
// * The first object doesn't generate a strain, so we begin with an incremented section end
|
||||
if curr.idx == 0 {
|
||||
let section_len = SECTION_LEN as f64;
|
||||
*self.curr_section_end() = (curr.start_time / section_len).ceil() * section_len;
|
||||
}
|
||||
|
||||
while curr.start_time > *self.curr_section_end() {
|
||||
self.save_curr_peak();
|
||||
|
||||
{
|
||||
let section_end = *self.curr_section_end();
|
||||
self.start_new_section_from(section_end, curr, diff_objects);
|
||||
}
|
||||
|
||||
*self.curr_section_end() += SECTION_LEN as f64;
|
||||
}
|
||||
|
||||
*self.curr_section_peak() = self
|
||||
.strain_value_at(curr, diff_objects)
|
||||
.max(*self.curr_section_peak());
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn save_curr_peak(&mut self) {
|
||||
let peak = *self.curr_section_peak();
|
||||
self.strain_peaks_mut().push(peak);
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn start_new_section_from(
|
||||
&mut self,
|
||||
time: f64,
|
||||
curr: &OsuDifficultyObject<'_>,
|
||||
diff_objects: &[OsuDifficultyObject<'_>],
|
||||
) {
|
||||
// * The maximum strain of the new section is not zero by default
|
||||
// * This means we need to capture the strain level at the beginning of the new section,
|
||||
// * and use that as the initial peak level.
|
||||
*self.curr_section_peak() = self.calculate_initial_strain(time, curr, diff_objects);
|
||||
}
|
||||
|
||||
fn difficulty_value(&mut self) -> f64;
|
||||
|
||||
#[inline]
|
||||
fn get_curr_strain_peaks(&mut self) -> Vec<f64> {
|
||||
let curr_peak = *self.curr_section_peak();
|
||||
let mut strain_peaks = mem::take(self.strain_peaks_mut());
|
||||
strain_peaks.push(curr_peak);
|
||||
|
||||
strain_peaks
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) trait OsuStrainSkill: StrainSkill + Sized {
|
||||
const REDUCED_SECTION_COUNT: usize = 10;
|
||||
const REDUCED_STRAIN_BASELINE: f64 = 0.75;
|
||||
const DIFFICULTY_MULTIPLER: f64 = 1.06;
|
||||
|
||||
fn difficulty_value(&mut self) -> f64 {
|
||||
let mut difficulty = 0.0;
|
||||
let mut weight = 1.0;
|
||||
|
||||
// * Sections with 0 strain are excluded to avoid worst-case time complexity of the following sort (e.g. /b/2351871).
|
||||
// * These sections will not contribute to the difficulty.
|
||||
let mut peaks = self.get_curr_strain_peaks();
|
||||
|
||||
peaks.retain(|&peak| peak > 0.0);
|
||||
peaks.sort_unstable_by(|a, b| b.partial_cmp(a).unwrap_or(Ordering::Equal));
|
||||
|
||||
let peak_iter = peaks.iter_mut().take(Self::REDUCED_SECTION_COUNT);
|
||||
|
||||
fn lerp(start: f64, end: f64, amount: f64) -> f64 {
|
||||
start + (end - start) * amount
|
||||
}
|
||||
|
||||
// * We are reducing the highest strains first to account for extreme difficulty spikes
|
||||
for (i, strain) in peak_iter.enumerate() {
|
||||
let clamped = (i as f32 / Self::REDUCED_SECTION_COUNT as f32).clamp(0.0, 1.0) as f64;
|
||||
let scale = (lerp(1.0, 10.0, clamped)).log10();
|
||||
*strain *= lerp(Self::REDUCED_STRAIN_BASELINE, 1.0, scale);
|
||||
}
|
||||
|
||||
peaks.sort_unstable_by(|a, b| b.partial_cmp(a).unwrap_or(Ordering::Equal));
|
||||
|
||||
// * Difficulty is the weighted sum of the highest strains from every section.
|
||||
// * We're sorting from highest to lowest strain.
|
||||
for strain in peaks {
|
||||
difficulty += strain * weight;
|
||||
weight *= Self::DECAY_WEIGHT;
|
||||
}
|
||||
|
||||
self.set_raw_difficulty_value(difficulty);
|
||||
difficulty * Self::DIFFICULTY_MULTIPLER
|
||||
}
|
||||
|
||||
fn strains(&self) -> &Vec<f64>;
|
||||
|
||||
fn set_raw_difficulty_value(&mut self, value: f64);
|
||||
fn get_raw_difficulty_value(&self) -> f64;
|
||||
|
||||
fn count_difficult_strains(&mut self) -> f64 {
|
||||
let difficulty_value = self.get_raw_difficulty_value();
|
||||
if difficulty_value == 0.0 {
|
||||
0.0
|
||||
} else {
|
||||
// * What would the top strain be if all strain values were identical
|
||||
let consistent_top_strain = difficulty_value / 10.0;
|
||||
|
||||
let strains = self.strains();
|
||||
|
||||
// Use a weighted sum of all strains. Constants are arbitrary and give nice values
|
||||
strains
|
||||
.iter()
|
||||
.map(|&s| 1.1 / (1.0 + (-10.0 * (s / consistent_top_strain - 0.88)).exp()))
|
||||
.sum()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
pub 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,656 @@
|
||||
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.09;
|
||||
|
||||
let effective_miss_count = self.calculate_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;
|
||||
|
||||
let difficulty = self.attributes.as_ref().unwrap();
|
||||
let streams_nerf =
|
||||
((difficulty.aim_strain / difficulty.speed_strain) * 100.0).round() / 100.0;
|
||||
|
||||
if streams_nerf < 1.09 {
|
||||
let acc_factor = (1.0 - self.acc.unwrap()).abs();
|
||||
acc_depression = (0.86 - acc_factor).max(0.5);
|
||||
|
||||
if acc_depression > 0.0 {
|
||||
aim_value *= acc_depression;
|
||||
}
|
||||
}
|
||||
|
||||
let nodt_bonus = match !self.mods.change_speed() {
|
||||
true => 1.02,
|
||||
false => 1.0,
|
||||
};
|
||||
|
||||
let mut pp = (aim_value.powf(1.185 * nodt_bonus)
|
||||
+ speed_value.powf(0.83 * acc_depression)
|
||||
+ acc_value.powf(1.14 * nodt_bonus))
|
||||
.powf(1.0 / 1.1)
|
||||
* multiplier;
|
||||
|
||||
if self.mods.dt() && self.mods.hr() {
|
||||
pp *= 1.025;
|
||||
}
|
||||
|
||||
if self.map.creator == "gwb" || self.map.creator == "Plasma" {
|
||||
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,
|
||||
|
||||
// Hardware Store [skyapple mode]
|
||||
1777768 => 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,
|
||||
|
||||
// Songs Compilation [Remembrance]
|
||||
2174272 => 0.85,
|
||||
|
||||
// Apocalypse 1992 [Universal Annihilation]
|
||||
2382377 => 0.85,
|
||||
|
||||
_ => 1.0,
|
||||
};
|
||||
|
||||
OsuPerformanceAttributes {
|
||||
difficulty: self.attributes.unwrap(),
|
||||
pp_acc: 0.0,
|
||||
pp_aim: aim_value as f64,
|
||||
pp_flashlight: 0.0,
|
||||
pp_speed: speed_value as f64,
|
||||
pp: pp as f64,
|
||||
effective_miss_count: effective_miss_count 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 len_bonus = 0.88
|
||||
+ 0.4 * (total_hits / 1400.0).min(1.0)
|
||||
+ (total_hits > 1400.0) as u8 as f32 * 0.2 * (total_hits / 1400.0).log10();
|
||||
aim_value *= len_bonus;
|
||||
|
||||
// Penalize misses
|
||||
if effective_miss_count > 0.0 {
|
||||
let miss_penalty = self.calculate_miss_penalty(
|
||||
effective_miss_count,
|
||||
attributes.aim_difficult_strain_count as f32,
|
||||
);
|
||||
aim_value *= miss_penalty;
|
||||
}
|
||||
|
||||
// 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() {
|
||||
let ar_capped = attributes.ar.min(10.67).max(0.0);
|
||||
let hd_bonus_factor = (10.67 - ar_capped) as f32;
|
||||
aim_value *= 1.0 + 0.075 * hd_bonus_factor;
|
||||
}
|
||||
|
||||
// FL bonus
|
||||
if self.mods.fl() {
|
||||
aim_value *= 1.0
|
||||
+ 0.2 * (total_hits / 200.0).min(1.0)
|
||||
+ (total_hits > 200.0) as u8 as f32
|
||||
* 0.15
|
||||
* ((total_hits - 200.0) / 300.0).min(1.0)
|
||||
+ (total_hits > 500.0) as u8 as f32 * (total_hits - 500.0) / 2500.0;
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
// Precision buff (reading)
|
||||
if attributes.cs > 5.58 {
|
||||
aim_value *= ((attributes.cs as f32 - 5.46).powf(1.8) + 1.0).powf(0.03);
|
||||
}
|
||||
|
||||
// Scale with accuracy
|
||||
aim_value *= 0.3 + 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 len_bonus = 0.88
|
||||
+ 0.4 * (total_hits / 1600.0).min(1.0)
|
||||
+ (total_hits > 1600.0) as u8 as f32 * 0.5 * (total_hits / 1600.0).log10();
|
||||
speed_value *= len_bonus;
|
||||
|
||||
// Penalize misses
|
||||
if effective_miss_count > 0.0 {
|
||||
let miss_penalty = self.calculate_miss_penalty(
|
||||
effective_miss_count,
|
||||
attributes.speed_difficult_strain_count as f32,
|
||||
);
|
||||
speed_value *= miss_penalty;
|
||||
}
|
||||
|
||||
// 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 *= 1.0 + 0.05 * (11.0 - attributes.ar) as f32;
|
||||
}
|
||||
|
||||
// Scaling the speed value with accuracy and OD
|
||||
speed_value *= (0.93 + 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,
|
||||
effective_miss_count: f32,
|
||||
difficult_strain_count: f32,
|
||||
) -> f32 {
|
||||
0.96 / ((effective_miss_count / (4.0 * difficult_strain_count.ln().powf(0.94))) + 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_or(attributes.max_combo) 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.2 * 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,185 @@
|
||||
//! 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,
|
||||
cs: map_attributes.cs,
|
||||
..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.05 {
|
||||
let small_circle_bonus = ((30.05 - radius) / 50.0).powf(1.1) * 1.45;
|
||||
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_raw = (aim.difficulty_value().sqrt() as f64) * (DIFFICULTY_MULTIPLIER as f64);
|
||||
let speed_strain = (speed.difficulty_value().sqrt() as f64) * (DIFFICULTY_MULTIPLIER as f64);
|
||||
|
||||
let aim_difficult_strain_count = aim.count_difficult_strains() as f64;
|
||||
let speed_difficult_strain_count = speed.count_difficult_strains() as f64;
|
||||
|
||||
let ar = diff_attributes.ar;
|
||||
let ar_multiplier: f64 = if ar <= 9.0 {
|
||||
1.25
|
||||
} else if ar <= 10.67 {
|
||||
1.25 - (ar - 9.0) * (0.25 / 1.67)
|
||||
} else {
|
||||
1.0 - (ar - 10.67) * (0.05 / 0.33)
|
||||
};
|
||||
|
||||
let intensity_gate = ((aim_difficult_strain_count - 2.0) / 5.0).clamp(0.0, 1.0);
|
||||
let final_ar_bonus = 1.0 + (ar_multiplier - 1.0) * intensity_gate;
|
||||
|
||||
let aim_strain = aim_strain_raw * final_ar_bonus;
|
||||
|
||||
let stars = aim_strain + speed_strain + (aim_strain - speed_strain).abs() / 2.0;
|
||||
|
||||
diff_attributes.stars = stars;
|
||||
diff_attributes.speed_strain = speed_strain;
|
||||
diff_attributes.aim_strain = aim_strain;
|
||||
diff_attributes.aim_difficult_strain_count = aim_difficult_strain_count;
|
||||
diff_attributes.speed_difficult_strain_count = speed_difficult_strain_count;
|
||||
diff_attributes.n_sliders = map.n_sliders as usize;
|
||||
|
||||
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 cs: 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,
|
||||
pub effective_miss_count: f64,
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
use std::{error::Error as StdError, fmt, io::Error as IoError, num::ParseFloatError};
|
||||
|
||||
/// `Result<_, ParseError>`
|
||||
pub type ParseResult<T> = Result<T, ParseError>;
|
||||
|
||||
/// Anything that could go wrong while parsing a [`Beatmap`](crate::Beatmap).
|
||||
#[derive(Debug)]
|
||||
#[allow(clippy::upper_case_acronyms)]
|
||||
pub enum ParseError {
|
||||
/// Some IO operation failed.
|
||||
IoError(IoError),
|
||||
/// The initial data of an `.osu` file was incorrect.
|
||||
IncorrectFileHeader,
|
||||
/// Line in `.osu` was unexpectedly not of the form `key:value`.
|
||||
BadLine,
|
||||
/// Line in `.osu` that contains a slider was not in the proper format.
|
||||
InvalidCurvePoints,
|
||||
/// Expected a decimal number, got something else.
|
||||
InvalidDecimalNumber,
|
||||
/// Failed to parse game mode.
|
||||
InvalidMode,
|
||||
/// Expected an additional field.
|
||||
MissingField(&'static str),
|
||||
/// Failed to recognized specified type for hitobjects.
|
||||
UnknownHitObjectKind,
|
||||
}
|
||||
|
||||
impl fmt::Display for ParseError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::IoError(_) => f.write_str("IO error"),
|
||||
Self::IncorrectFileHeader => {
|
||||
write!(f, "expected `osu file format v` at file begin")
|
||||
}
|
||||
Self::BadLine => f.write_str("line not in `Key:Value` pattern"),
|
||||
Self::InvalidCurvePoints => f.write_str("invalid curve point"),
|
||||
Self::InvalidDecimalNumber => f.write_str("invalid float number"),
|
||||
Self::InvalidMode => f.write_str("invalid mode"),
|
||||
Self::MissingField(field) => write!(f, "missing field `{}`", field),
|
||||
Self::UnknownHitObjectKind => f.write_str("unsupported hitobject kind"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl StdError for ParseError {
|
||||
fn source(&self) -> Option<&(dyn StdError + 'static)> {
|
||||
match self {
|
||||
Self::IoError(inner) => Some(inner),
|
||||
Self::IncorrectFileHeader => None,
|
||||
Self::BadLine => None,
|
||||
Self::InvalidCurvePoints => None,
|
||||
Self::InvalidDecimalNumber => None,
|
||||
Self::InvalidMode => None,
|
||||
Self::MissingField(_) => None,
|
||||
Self::UnknownHitObjectKind => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<IoError> for ParseError {
|
||||
fn from(other: IoError) -> Self {
|
||||
Self::IoError(other)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ParseFloatError> for ParseError {
|
||||
fn from(_: ParseFloatError) -> Self {
|
||||
Self::InvalidDecimalNumber
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
use std::cmp::Ordering;
|
||||
|
||||
use super::{PathControlPoint, Pos2};
|
||||
|
||||
/// "Intermediate" hitobject created through parsing.
|
||||
/// Each mode will handle them differently.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct HitObject {
|
||||
/// The position of the object.
|
||||
pub pos: Pos2,
|
||||
/// The start time of the object.
|
||||
pub start_time: f64,
|
||||
/// The type of the object.
|
||||
pub kind: HitObjectKind,
|
||||
}
|
||||
|
||||
impl HitObject {
|
||||
/// The end time of the object.
|
||||
#[inline]
|
||||
pub fn end_time(&self) -> f64 {
|
||||
match &self.kind {
|
||||
HitObjectKind::Circle => self.start_time,
|
||||
// incorrect, only called in mania which has no sliders though
|
||||
HitObjectKind::Slider { .. } => self.start_time,
|
||||
HitObjectKind::Spinner { end_time } => *end_time,
|
||||
HitObjectKind::Hold { end_time, .. } => *end_time,
|
||||
}
|
||||
}
|
||||
|
||||
/// If the object is a circle.
|
||||
#[inline]
|
||||
pub fn is_circle(&self) -> bool {
|
||||
matches!(self.kind, HitObjectKind::Circle)
|
||||
}
|
||||
|
||||
/// If the object is a slider.
|
||||
#[inline]
|
||||
pub fn is_slider(&self) -> bool {
|
||||
matches!(self.kind, HitObjectKind::Slider { .. })
|
||||
}
|
||||
|
||||
/// If the object is a spinner.
|
||||
#[inline]
|
||||
pub fn is_spinner(&self) -> bool {
|
||||
matches!(self.kind, HitObjectKind::Spinner { .. })
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialOrd for HitObject {
|
||||
#[inline]
|
||||
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
|
||||
self.start_time.partial_cmp(&other.start_time)
|
||||
}
|
||||
}
|
||||
|
||||
/// Further data related to specific object types.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum HitObjectKind {
|
||||
/// A circle object.
|
||||
Circle,
|
||||
/// A full slider object.
|
||||
Slider {
|
||||
/// Total length of the slider in pixels.
|
||||
pixel_len: Option<f64>,
|
||||
/// The amount of repeat points of the slider.
|
||||
repeats: usize,
|
||||
/// The control points of the slider.
|
||||
control_points: Vec<PathControlPoint>,
|
||||
/// Sample sounds for the slider head, end, and repeat points.
|
||||
/// Required for converts.
|
||||
edge_sounds: Vec<u8>,
|
||||
},
|
||||
/// A spinner object.
|
||||
Spinner {
|
||||
/// The end time of the spinner.
|
||||
end_time: f64,
|
||||
},
|
||||
/// A hold note object for osu!mania.
|
||||
Hold {
|
||||
/// The end time of the hold object.
|
||||
end_time: f64,
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/// Abstract type to define hitsounds.
|
||||
#[allow(missing_docs)]
|
||||
pub trait HitSound {
|
||||
const HITSOUND_WHISTLE: u8 = 1 << 1;
|
||||
const HITSOUND_FINISH: u8 = 1 << 2;
|
||||
const HITSOUND_CLAP: u8 = 1 << 3;
|
||||
|
||||
fn normal(self) -> bool;
|
||||
fn whistle(self) -> bool;
|
||||
fn finish(self) -> bool;
|
||||
fn clap(self) -> bool;
|
||||
}
|
||||
|
||||
impl HitSound for u8 {
|
||||
#[inline]
|
||||
fn normal(self) -> bool {
|
||||
self == 0
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn whistle(self) -> bool {
|
||||
self & Self::HITSOUND_WHISTLE > 0
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn finish(self) -> bool {
|
||||
self & Self::HITSOUND_FINISH > 0
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn clap(self) -> bool {
|
||||
self & Self::HITSOUND_CLAP > 0
|
||||
}
|
||||
}
|
||||
+1137
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,128 @@
|
||||
use std::fmt;
|
||||
use std::ops;
|
||||
|
||||
/// Simple (x, y) coordinate / vector
|
||||
#[derive(Clone, Copy, Default, PartialEq)]
|
||||
pub struct Pos2 {
|
||||
/// Position on the x-axis.
|
||||
pub x: f32,
|
||||
/// Position on the y-axis.
|
||||
pub y: f32,
|
||||
}
|
||||
|
||||
impl Pos2 {
|
||||
/// Return the null vector.
|
||||
#[inline]
|
||||
pub fn zero() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Return a position with both coordinates on the given value.
|
||||
#[inline]
|
||||
pub fn new(value: f32) -> Self {
|
||||
Self { x: value, y: value }
|
||||
}
|
||||
|
||||
/// Return the position's length squared.
|
||||
#[inline]
|
||||
pub fn length_squared(&self) -> f32 {
|
||||
self.dot(*self)
|
||||
}
|
||||
|
||||
/// Return the position's length.
|
||||
#[inline]
|
||||
pub fn length(&self) -> f32 {
|
||||
((self.x * self.x + self.y * self.y) as f64).sqrt() as f32
|
||||
}
|
||||
|
||||
/// Return the dot product.
|
||||
#[inline]
|
||||
pub fn dot(&self, other: Self) -> f32 {
|
||||
(self.x * other.x) + (self.y * other.y)
|
||||
}
|
||||
|
||||
/// Return the distance to another position.
|
||||
#[inline]
|
||||
pub fn distance(&self, other: Self) -> f32 {
|
||||
(*self - other).length()
|
||||
}
|
||||
|
||||
/// Normalize the coordinates with respect to the vector's length.
|
||||
#[inline]
|
||||
pub fn normalize(mut self) -> Pos2 {
|
||||
let scale = self.length().recip();
|
||||
self.x *= scale;
|
||||
self.y *= scale;
|
||||
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl ops::Add<Pos2> for Pos2 {
|
||||
type Output = Self;
|
||||
|
||||
#[inline]
|
||||
fn add(self, rhs: Self) -> Self::Output {
|
||||
Self {
|
||||
x: self.x + rhs.x,
|
||||
y: self.y + rhs.y,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ops::Sub<Pos2> for Pos2 {
|
||||
type Output = Self;
|
||||
|
||||
#[inline]
|
||||
fn sub(self, rhs: Self) -> Self::Output {
|
||||
Self {
|
||||
x: self.x - rhs.x,
|
||||
y: self.y - rhs.y,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ops::Mul<f32> for Pos2 {
|
||||
type Output = Self;
|
||||
|
||||
#[inline]
|
||||
fn mul(self, rhs: f32) -> Self::Output {
|
||||
Self {
|
||||
x: self.x * rhs,
|
||||
y: self.y * rhs,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ops::Div<f32> for Pos2 {
|
||||
type Output = Self;
|
||||
|
||||
#[inline]
|
||||
fn div(self, rhs: f32) -> Self::Output {
|
||||
Self {
|
||||
x: self.x / rhs,
|
||||
y: self.y / rhs,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ops::AddAssign for Pos2 {
|
||||
fn add_assign(&mut self, other: Self) {
|
||||
*self = Self {
|
||||
x: self.x + other.x,
|
||||
y: self.y + other.y,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for Pos2 {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
fmt::Debug::fmt(self, f)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for Pos2 {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "({}, {})", self.x, self.y)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
use std::io::{Error as IoError, ErrorKind as IoErrorKind};
|
||||
|
||||
#[cfg(not(any(feature = "async_std", feature = "async_tokio")))]
|
||||
use std::io::{BufRead, BufReader, Read};
|
||||
|
||||
#[cfg(feature = "async_tokio")]
|
||||
use tokio::io::{AsyncBufReadExt, AsyncRead as Read, BufReader};
|
||||
|
||||
#[cfg(feature = "async_std")]
|
||||
use async_std::io::{prelude::BufReadExt, BufReader, Read};
|
||||
|
||||
use crate::ParseError;
|
||||
|
||||
#[derive(Eq, PartialEq)]
|
||||
enum Encoding {
|
||||
Utf8,
|
||||
Utf16,
|
||||
}
|
||||
|
||||
pub(crate) struct FileReader<R> {
|
||||
buf: Vec<u8>,
|
||||
encoding: Encoding,
|
||||
|
||||
#[cfg(feature = "async_std")]
|
||||
inner: BufReader<R>,
|
||||
#[cfg(feature = "async_tokio")]
|
||||
inner: BufReader<R>,
|
||||
#[cfg(not(any(feature = "async_std", feature = "async_tokio")))]
|
||||
inner: BufReader<R>,
|
||||
}
|
||||
|
||||
macro_rules! read_until {
|
||||
($self:expr) => {{
|
||||
#[cfg(not(any(feature = "async_std", feature = "async_tokio")))]
|
||||
{
|
||||
$self.inner.read_until(b'\n', &mut $self.buf)
|
||||
}
|
||||
|
||||
#[cfg(any(feature = "async_std", feature = "async_tokio"))]
|
||||
{
|
||||
$self.inner.read_until(b'\n', &mut $self.buf).await
|
||||
}
|
||||
}};
|
||||
}
|
||||
|
||||
#[allow(unused_macro_rules)]
|
||||
macro_rules! impl_reader {
|
||||
() => {
|
||||
impl<R: Read> FileReader<R> {
|
||||
impl_reader!(@NEW);
|
||||
impl_reader!(@NEXT_LINE);
|
||||
}
|
||||
};
|
||||
(async) => {
|
||||
impl<R: Read + Unpin> FileReader<R> {
|
||||
impl_reader!(@NEW);
|
||||
impl_reader!(@ASYNC NEXT_LINE);
|
||||
}
|
||||
};
|
||||
(@NEW) => {
|
||||
pub(crate) fn new(src: R) -> Self {
|
||||
Self {
|
||||
buf: Vec::with_capacity(32),
|
||||
encoding: Encoding::Utf8,
|
||||
inner: BufReader::new(src),
|
||||
}
|
||||
}
|
||||
};
|
||||
(@NEXT_LINE) => {
|
||||
pub(crate) fn next_line(&mut self) -> Result<usize, IoError> {
|
||||
impl_reader!(@NEXT_LINE_BODY, self);
|
||||
}
|
||||
};
|
||||
(@ASYNC NEXT_LINE) => {
|
||||
pub(crate) async fn next_line(&mut self) -> Result<usize, IoError> {
|
||||
impl_reader!(@NEXT_LINE_BODY, self);
|
||||
}
|
||||
};
|
||||
(@NEXT_LINE_BODY, $self:ident) => {
|
||||
loop {
|
||||
$self.buf.clear();
|
||||
let bytes = read_until!($self)?;
|
||||
|
||||
if bytes == 0 {
|
||||
return Ok(bytes);
|
||||
}
|
||||
|
||||
$self.truncate();
|
||||
|
||||
if !$self.buf.is_empty() {
|
||||
return Ok(bytes);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
#[cfg(not(any(feature = "async_std", feature = "async_tokio")))]
|
||||
impl_reader!();
|
||||
|
||||
#[cfg(any(feature = "async_tokio", feature = "async_std"))]
|
||||
impl_reader!(async);
|
||||
|
||||
impl<R> FileReader<R> {
|
||||
#[allow(clippy::wrong_self_convention)]
|
||||
pub(crate) fn is_initial_empty_line(&mut self) -> bool {
|
||||
if self.buf.starts_with(&[239, 187, 191]) {
|
||||
// UTF-8
|
||||
self.buf.rotate_left(3);
|
||||
|
||||
self.buf.len() == 3
|
||||
} else {
|
||||
if self.buf.starts_with(&[255, 254]) {
|
||||
// UTF-16
|
||||
self.encoding = Encoding::Utf16;
|
||||
// will rotate by 1 so this truncates `255`
|
||||
let mut sub = 1;
|
||||
|
||||
if self.buf.len() >= 3 {
|
||||
// truncate one `0` that was left after truncating `\n` when reading the line.
|
||||
// additionally truncate `0\r` if possible.
|
||||
sub += 1 + 2
|
||||
* (self.buf.len() >= 4 && self.buf[self.buf.len() - 2] == b'\r') as usize;
|
||||
}
|
||||
|
||||
self.buf.rotate_left(1);
|
||||
self.buf.truncate(self.buf.len() - sub);
|
||||
self.decode_utf16();
|
||||
}
|
||||
|
||||
self.buf.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn version(&self) -> Result<u8, ParseError> {
|
||||
self.buf
|
||||
.iter()
|
||||
.position(|&byte| byte == b'o')
|
||||
.and_then(|idx| {
|
||||
self.buf[idx..]
|
||||
.starts_with(b"osu file format v")
|
||||
.then_some(idx + 17)
|
||||
})
|
||||
.map(|idx| {
|
||||
let mut n = 0;
|
||||
|
||||
for byte in &self.buf[idx..] {
|
||||
if !(b'0'..=b'9').contains(byte) {
|
||||
break;
|
||||
}
|
||||
|
||||
n = 10 * n + (*byte & 0xF);
|
||||
}
|
||||
|
||||
n
|
||||
})
|
||||
.ok_or(ParseError::IncorrectFileHeader)
|
||||
}
|
||||
|
||||
/// Returns the bytes inbetween '[' and ']'.
|
||||
pub(crate) fn get_section(&self) -> Option<&[u8]> {
|
||||
if self.buf[0] == b'[' {
|
||||
if let Some(end) = self.buf[1..].iter().position(|&byte| byte == b']') {
|
||||
return Some(&self.buf[1..=end]);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Parse the buffer into a string, returning `None` if the UTF-8 validation fails.
|
||||
pub(crate) fn get_line(&self) -> Result<&str, ParseError> {
|
||||
std::str::from_utf8(&self.buf)
|
||||
.map_err(|e| ParseError::IoError(IoError::new(IoErrorKind::InvalidData, Box::new(e))))
|
||||
}
|
||||
|
||||
pub(crate) fn get_line_ascii(&mut self) -> Result<&str, ParseError> {
|
||||
self.buf.iter_mut().for_each(|byte| {
|
||||
if *byte >= 128 {
|
||||
*byte = b'?';
|
||||
}
|
||||
});
|
||||
|
||||
std::str::from_utf8(&self.buf)
|
||||
.map_err(|e| ParseError::IoError(IoError::new(IoErrorKind::InvalidData, Box::new(e))))
|
||||
}
|
||||
|
||||
/// Split the buffer at the first ':', then parse the second half into a string.
|
||||
///
|
||||
/// Returns `None` if there is no ':' or if the second half is invalid UTF-8.
|
||||
pub(crate) fn split_colon(&self) -> Option<(&[u8], &str)> {
|
||||
let idx = self.buf.iter().position(|&byte| byte == b':')?;
|
||||
let front = &self.buf[..idx];
|
||||
let back = std::str::from_utf8(&self.buf[idx + 1..]).ok()?;
|
||||
|
||||
Some((front, back.trim_start()))
|
||||
}
|
||||
|
||||
/// Truncate away trailing `\r\n`, `\n`, content after `//` and whitespace
|
||||
fn truncate(&mut self) {
|
||||
if self.encoding == Encoding::Utf16 {
|
||||
self.decode_utf16();
|
||||
}
|
||||
|
||||
// necessary check for the edge case `//\r\n`
|
||||
if self.buf.starts_with(&[b'/', b'/']) {
|
||||
return self.buf.clear();
|
||||
}
|
||||
|
||||
// len without "//" or alternatively len without trailing "(\r)\n"
|
||||
let len = self
|
||||
.buf
|
||||
.windows(3)
|
||||
.rev()
|
||||
.step_by(2)
|
||||
.zip(1..)
|
||||
.find_map(|(window, i)| {
|
||||
if window[1] == b'/' {
|
||||
if window[0] == b'/' {
|
||||
return Some(self.buf.len() - 2 * i - 1);
|
||||
} else if window[2] == b'/' {
|
||||
return Some(self.buf.len() - 2 * i);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
})
|
||||
.unwrap_or_else(|| match &self.buf[..] {
|
||||
[.., b'\r', b'\n'] => self.buf.len() - 2,
|
||||
[.., b'\n'] => self.buf.len() - 1,
|
||||
_ => self.buf.len(),
|
||||
});
|
||||
|
||||
// trim whitespace
|
||||
let len = self.buf[..len]
|
||||
.iter()
|
||||
.enumerate()
|
||||
.rev()
|
||||
.find_map(|(i, byte)| (!matches!(byte, b' ' | b'\t')).then_some(i + 1))
|
||||
.unwrap_or(0);
|
||||
|
||||
self.buf.truncate(len);
|
||||
}
|
||||
|
||||
/// Assumes the buffer is of the form `[_, a, 0, b, 0, c, ...]` so it removes
|
||||
/// the first element and all the 0's, turning it into `[a, b, c, ...]`.
|
||||
fn decode_utf16(&mut self) {
|
||||
// remove the 0's
|
||||
let limit = self.buf.len() / 2 + 1;
|
||||
|
||||
for i in 2..limit {
|
||||
self.buf.swap(i, i * 2 - 1);
|
||||
}
|
||||
|
||||
self.buf.truncate(limit);
|
||||
|
||||
// remove the first element
|
||||
// panics if buffer is empty
|
||||
self.buf.rotate_left(1);
|
||||
self.buf.truncate(self.buf.len() - 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
use super::HitObject;
|
||||
|
||||
use std::cmp::Ordering;
|
||||
|
||||
const QUICK_SORT_DEPTH_THRESHOLD: usize = 32;
|
||||
|
||||
/// Algorithm from https://github.com/ppy/osu/blob/master/osu.Game.Rulesets.Mania/MathUtils/LegacySortHelper.cs#L21
|
||||
pub(crate) fn legacy_sort(keys: &mut [HitObject]) {
|
||||
if keys.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
depth_limited_quick_sort(keys, 0, keys.len() - 1, QUICK_SORT_DEPTH_THRESHOLD);
|
||||
}
|
||||
|
||||
fn depth_limited_quick_sort(
|
||||
keys: &mut [HitObject],
|
||||
mut left: usize,
|
||||
mut right: usize,
|
||||
mut depth_limit: usize,
|
||||
) {
|
||||
loop {
|
||||
if depth_limit == 0 {
|
||||
heap_sort(keys, left, right);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
let mut i = left;
|
||||
let mut j = right;
|
||||
|
||||
let mid = i + ((j - i) >> 1);
|
||||
|
||||
if keys[i] > keys[mid] {
|
||||
keys.swap(i, mid);
|
||||
}
|
||||
|
||||
if keys[i] > keys[j] {
|
||||
keys.swap(i, j);
|
||||
}
|
||||
|
||||
if keys[mid] > keys[j] {
|
||||
keys.swap(mid, j);
|
||||
}
|
||||
|
||||
loop {
|
||||
while keys[i] < keys[mid] {
|
||||
i += 1;
|
||||
}
|
||||
|
||||
while keys[mid] < keys[j] {
|
||||
j -= 1;
|
||||
}
|
||||
|
||||
match i.cmp(&j) {
|
||||
Ordering::Less => keys.swap(i, j),
|
||||
Ordering::Equal => {}
|
||||
Ordering::Greater => break,
|
||||
}
|
||||
|
||||
i += 1;
|
||||
j = j.saturating_sub(1);
|
||||
|
||||
if i > j {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
depth_limit -= 1;
|
||||
|
||||
if j.saturating_sub(left) <= right - i {
|
||||
if left < j {
|
||||
depth_limited_quick_sort(keys, left, j, depth_limit);
|
||||
}
|
||||
|
||||
left = i;
|
||||
} else {
|
||||
if i < right {
|
||||
depth_limited_quick_sort(keys, i, right, depth_limit);
|
||||
}
|
||||
|
||||
right = j;
|
||||
}
|
||||
|
||||
if left >= right {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn heap_sort(keys: &mut [HitObject], lo: usize, hi: usize) {
|
||||
let n = hi - lo + 1;
|
||||
|
||||
for i in (1..=n / 2).rev() {
|
||||
down_heap(keys, i, n, lo);
|
||||
}
|
||||
|
||||
for i in (2..=n).rev() {
|
||||
keys.swap(lo, lo + i - 1);
|
||||
down_heap(keys, 1, i - 1, lo);
|
||||
}
|
||||
}
|
||||
|
||||
fn down_heap(keys: &mut [HitObject], mut i: usize, n: usize, lo: usize) {
|
||||
while i <= n / 2 {
|
||||
let mut child = 2 * i;
|
||||
|
||||
if child < n && keys[lo + child - 1] < keys[lo + child] {
|
||||
child += 1;
|
||||
}
|
||||
|
||||
if keys[lo + i - 1] >= keys[lo + child - 1] {
|
||||
break;
|
||||
}
|
||||
|
||||
keys.swap(lo + i - 1, lo + child - 1);
|
||||
i = child;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
use crate::{
|
||||
catch::{CatchDifficultyAttributes, CatchPP, CatchPerformanceAttributes},
|
||||
mania::{ManiaDifficultyAttributes, ManiaPP, ManiaPerformanceAttributes},
|
||||
osu::{OsuDifficultyAttributes, OsuPP, OsuPerformanceAttributes},
|
||||
taiko::{TaikoDifficultyAttributes, TaikoPP, TaikoPerformanceAttributes},
|
||||
Beatmap, DifficultyAttributes, GameMode, PerformanceAttributes, ScoreState,
|
||||
};
|
||||
|
||||
/// Performance calculator on maps of any mode.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```no_run
|
||||
/// use rosu_pp::{AnyPP, Beatmap};
|
||||
///
|
||||
/// # /*
|
||||
/// let map: Beatmap = ...
|
||||
/// # */
|
||||
///
|
||||
/// # let map = Beatmap::default();
|
||||
/// let pp_result = AnyPP::new(&map)
|
||||
/// .mods(8 + 64) // HDDT
|
||||
/// .combo(1234)
|
||||
/// .accuracy(98.5)
|
||||
/// .n_misses(1)
|
||||
/// .calculate();
|
||||
///
|
||||
/// println!("PP: {} | Stars: {}", pp_result.pp(), pp_result.stars());
|
||||
///
|
||||
/// let next_result = AnyPP::new(&map)
|
||||
/// .attributes(pp_result) // reusing previous results for performance
|
||||
/// .mods(8 + 64) // has to be the same to reuse attributes
|
||||
/// .accuracy(99.5)
|
||||
/// .calculate();
|
||||
///
|
||||
/// println!("PP: {} | Stars: {}", next_result.pp(), next_result.stars());
|
||||
/// ```
|
||||
#[allow(clippy::upper_case_acronyms)]
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum AnyPP<'map> {
|
||||
/// osu!standard performance calculator
|
||||
Osu(OsuPP<'map>),
|
||||
/// osu!taiko performance calculator
|
||||
Taiko(TaikoPP<'map>),
|
||||
/// osu!catch performance calculator
|
||||
Catch(CatchPP<'map>),
|
||||
/// osu!mania performance calculator
|
||||
Mania(ManiaPP<'map>),
|
||||
}
|
||||
|
||||
impl<'map> AnyPP<'map> {
|
||||
/// Create a new performance calculator for maps of any mode.
|
||||
#[inline]
|
||||
pub fn new(map: &'map Beatmap) -> Self {
|
||||
match map.mode {
|
||||
GameMode::Osu => Self::Osu(OsuPP::new(map)),
|
||||
GameMode::Taiko => Self::Taiko(TaikoPP::new(map)),
|
||||
GameMode::Catch => Self::Catch(CatchPP::new(map)),
|
||||
GameMode::Mania => Self::Mania(ManiaPP::new(map)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Consume the performance calculator and calculate
|
||||
/// performance attributes for the given parameters.
|
||||
#[inline]
|
||||
pub fn calculate(self) -> PerformanceAttributes {
|
||||
match self {
|
||||
Self::Osu(o) => PerformanceAttributes::Osu(o.calculate()),
|
||||
Self::Taiko(t) => PerformanceAttributes::Taiko(t.calculate()),
|
||||
Self::Catch(f) => PerformanceAttributes::Catch(f.calculate()),
|
||||
Self::Mania(m) => PerformanceAttributes::Mania(m.calculate()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Provide the result of a previous difficulty or performance calculation.
|
||||
/// If you already calculated the attributes for the current map-mod combination,
|
||||
/// be sure to put them in here so that they don't have to be recalculated.
|
||||
#[inline]
|
||||
pub fn attributes(self, attributes: impl AttributeProvider) -> Self {
|
||||
match self {
|
||||
Self::Osu(o) => Self::Osu(o.attributes(attributes.attributes())),
|
||||
Self::Taiko(t) => Self::Taiko(t.attributes(attributes.attributes())),
|
||||
Self::Catch(f) => Self::Catch(f.attributes(attributes.attributes())),
|
||||
Self::Mania(m) => Self::Mania(m.attributes(attributes.attributes())),
|
||||
}
|
||||
}
|
||||
|
||||
/// If the map is an osu!standard map, convert it to another mode.
|
||||
#[inline]
|
||||
pub fn mode(self, mode: GameMode) -> Self {
|
||||
match self {
|
||||
AnyPP::Osu(o) => match mode {
|
||||
GameMode::Osu => AnyPP::Osu(o),
|
||||
GameMode::Taiko => AnyPP::Taiko(o.into()),
|
||||
GameMode::Catch => AnyPP::Catch(o.into()),
|
||||
GameMode::Mania => AnyPP::Mania(o.into()),
|
||||
},
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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(self, mods: u32) -> Self {
|
||||
match self {
|
||||
Self::Osu(o) => Self::Osu(o.mods(mods)),
|
||||
Self::Taiko(t) => Self::Taiko(t.mods(mods)),
|
||||
Self::Catch(f) => Self::Catch(f.mods(mods)),
|
||||
Self::Mania(m) => Self::Mania(m.mods(mods)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Amount of passed objects for partial plays, e.g. a fail.
|
||||
///
|
||||
/// If you want to calculate the performance after every few objects, instead of
|
||||
/// using [`AnyPP`] multiple times with different `passed_objects`, you should use
|
||||
/// [`GradualPerformanceAttributes`](crate::GradualPerformanceAttributes).
|
||||
#[inline]
|
||||
pub fn passed_objects(self, passed_objects: usize) -> Self {
|
||||
match self {
|
||||
Self::Osu(o) => Self::Osu(o.passed_objects(passed_objects)),
|
||||
Self::Taiko(t) => Self::Taiko(t.passed_objects(passed_objects)),
|
||||
Self::Catch(f) => Self::Catch(f.passed_objects(passed_objects)),
|
||||
Self::Mania(m) => Self::Mania(m.passed_objects(passed_objects)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Adjust the clock rate used in the calculation.
|
||||
/// If none is specified, it will take the clock rate based on the mods
|
||||
/// i.e. 1.5 for DT, 0.75 for HT and 1.0 otherwise.
|
||||
#[inline]
|
||||
pub fn clock_rate(self, clock_rate: f64) -> Self {
|
||||
match self {
|
||||
Self::Osu(o) => Self::Osu(o.clock_rate(clock_rate)),
|
||||
Self::Taiko(t) => Self::Taiko(t.clock_rate(clock_rate)),
|
||||
Self::Catch(f) => Self::Catch(f.clock_rate(clock_rate)),
|
||||
Self::Mania(m) => Self::Mania(m.clock_rate(clock_rate)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Provide parameters through a [`ScoreState`].
|
||||
#[inline]
|
||||
pub fn state(self, state: ScoreState) -> Self {
|
||||
match self {
|
||||
Self::Osu(o) => Self::Osu(o.state(state.into())),
|
||||
Self::Taiko(t) => Self::Taiko(t.state(state.into())),
|
||||
Self::Catch(f) => Self::Catch(f.state(state.into())),
|
||||
Self::Mania(m) => Self::Mania(m.state(state.into())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the accuracy between `0.0` and `100.0`.
|
||||
#[inline]
|
||||
pub fn accuracy(self, acc: f64) -> Self {
|
||||
match self {
|
||||
Self::Osu(o) => Self::Osu(o.accuracy(acc)),
|
||||
Self::Taiko(t) => Self::Taiko(t.accuracy(acc)),
|
||||
Self::Catch(f) => Self::Catch(f.accuracy(acc)),
|
||||
Self::Mania(m) => Self::Mania(m.accuracy(acc)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Specify the amount of misses of a play.
|
||||
#[inline]
|
||||
pub fn n_misses(self, n_misses: usize) -> Self {
|
||||
match self {
|
||||
Self::Osu(o) => Self::Osu(o.n_misses(n_misses)),
|
||||
Self::Taiko(t) => Self::Taiko(t.n_misses(n_misses)),
|
||||
Self::Catch(f) => Self::Catch(f.misses(n_misses)),
|
||||
Self::Mania(m) => Self::Mania(m.n_misses(n_misses)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Specify the max combo of the play.
|
||||
///
|
||||
/// Irrelevant for osu!mania.
|
||||
#[inline]
|
||||
pub fn combo(self, combo: usize) -> Self {
|
||||
match self {
|
||||
Self::Osu(o) => Self::Osu(o.combo(combo)),
|
||||
Self::Taiko(t) => Self::Taiko(t.combo(combo)),
|
||||
Self::Catch(f) => Self::Catch(f.combo(combo)),
|
||||
Self::Mania(_) => self,
|
||||
}
|
||||
}
|
||||
|
||||
/// Specify the amount of 300s of a play.
|
||||
#[inline]
|
||||
pub fn n300(self, n300: usize) -> Self {
|
||||
match self {
|
||||
Self::Osu(o) => Self::Osu(o.n300(n300)),
|
||||
Self::Taiko(t) => Self::Taiko(t.n300(n300)),
|
||||
Self::Catch(f) => Self::Catch(f.fruits(n300)),
|
||||
Self::Mania(m) => Self::Mania(m.n300(n300)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Specify the amount of 100s of a play.
|
||||
#[inline]
|
||||
pub fn n100(self, n100: usize) -> Self {
|
||||
match self {
|
||||
Self::Osu(o) => Self::Osu(o.n100(n100)),
|
||||
Self::Taiko(t) => Self::Taiko(t.n100(n100)),
|
||||
Self::Catch(f) => Self::Catch(f.droplets(n100)),
|
||||
Self::Mania(m) => Self::Mania(m.n100(n100)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Specify the amount of 50s of a play.
|
||||
///
|
||||
/// Irrelevant for osu!taiko.
|
||||
#[inline]
|
||||
pub fn n50(self, n50: usize) -> Self {
|
||||
match self {
|
||||
Self::Osu(o) => Self::Osu(o.n50(n50)),
|
||||
Self::Taiko(_) => self,
|
||||
Self::Catch(f) => Self::Catch(f.tiny_droplets(n50)),
|
||||
Self::Mania(m) => Self::Mania(m.n50(n50)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Specify the amount of katus of a play.
|
||||
///
|
||||
/// This value is only relevant for osu!catch for which it represents
|
||||
/// the amount of tiny droplet misses and osu!mania for which it.
|
||||
/// repesents the amount of n200.
|
||||
#[inline]
|
||||
pub fn n_katu(self, n_katu: usize) -> Self {
|
||||
match self {
|
||||
Self::Osu(_) => self,
|
||||
Self::Taiko(_) => self,
|
||||
Self::Catch(f) => Self::Catch(f.tiny_droplet_misses(n_katu)),
|
||||
Self::Mania(m) => Self::Mania(m.n200(n_katu)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Specify the amount of gekis of a play.
|
||||
///
|
||||
/// This value is only relevant for osu!mania for which it.
|
||||
/// repesents the amount of n320.
|
||||
#[inline]
|
||||
pub fn n_geki(self, n_geki: usize) -> Self {
|
||||
match self {
|
||||
Self::Osu(_) => self,
|
||||
Self::Taiko(_) => self,
|
||||
Self::Catch(_) => self,
|
||||
Self::Mania(m) => Self::Mania(m.n320(n_geki)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// While generating remaining hitresults, decide how they should be distributed.
|
||||
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
|
||||
pub enum HitResultPriority {
|
||||
/// Prioritize good hitresults over bad ones
|
||||
BestCase,
|
||||
/// Prioritize bad hitresults over good ones
|
||||
WorstCase,
|
||||
}
|
||||
|
||||
impl Default for HitResultPriority {
|
||||
#[inline]
|
||||
fn default() -> Self {
|
||||
Self::BestCase
|
||||
}
|
||||
}
|
||||
|
||||
/// Abstract type to provide flexibility when passing difficulty attributes to a performance calculation.
|
||||
pub trait AttributeProvider {
|
||||
/// Provide the actual difficulty attributes.
|
||||
fn attributes(self) -> DifficultyAttributes;
|
||||
}
|
||||
|
||||
impl AttributeProvider for DifficultyAttributes {
|
||||
#[inline]
|
||||
fn attributes(self) -> DifficultyAttributes {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl AttributeProvider for PerformanceAttributes {
|
||||
#[inline]
|
||||
fn attributes(self) -> DifficultyAttributes {
|
||||
match self {
|
||||
Self::Osu(attrs) => DifficultyAttributes::Osu(attrs.difficulty),
|
||||
Self::Taiko(attrs) => DifficultyAttributes::Taiko(attrs.difficulty),
|
||||
Self::Catch(attrs) => DifficultyAttributes::Catch(attrs.difficulty),
|
||||
Self::Mania(attrs) => DifficultyAttributes::Mania(attrs.difficulty),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
macro_rules! impl_attr_provider {
|
||||
($mode:ident: $difficulty:ident, $performance:ident) => {
|
||||
impl AttributeProvider for $difficulty {
|
||||
#[inline]
|
||||
fn attributes(self) -> DifficultyAttributes {
|
||||
DifficultyAttributes::$mode(self)
|
||||
}
|
||||
}
|
||||
|
||||
impl AttributeProvider for $performance {
|
||||
#[inline]
|
||||
fn attributes(self) -> DifficultyAttributes {
|
||||
DifficultyAttributes::$mode(self.difficulty)
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
impl_attr_provider!(Catch: CatchDifficultyAttributes, CatchPerformanceAttributes);
|
||||
impl_attr_provider!(Mania: ManiaDifficultyAttributes, ManiaPerformanceAttributes);
|
||||
impl_attr_provider!(Osu: OsuDifficultyAttributes, OsuPerformanceAttributes);
|
||||
impl_attr_provider!(Taiko: TaikoDifficultyAttributes, TaikoPerformanceAttributes);
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
use crate::{
|
||||
Beatmap, CatchStars, DifficultyAttributes, GameMode, ManiaStars, OsuStars, Strains, TaikoStars,
|
||||
};
|
||||
|
||||
/// Difficulty calculator on maps of any mode.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use rosu_pp::{AnyStars, Beatmap};
|
||||
///
|
||||
/// # /*
|
||||
/// let map: Beatmap = ...
|
||||
/// # */
|
||||
/// # let map = Beatmap::default();
|
||||
///
|
||||
/// let difficulty_attrs = AnyStars::new(&map)
|
||||
/// .mods(8 + 64) // HDDT
|
||||
/// .calculate();
|
||||
///
|
||||
/// println!("Stars: {}", difficulty_attrs.stars());
|
||||
/// ```
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum AnyStars<'map> {
|
||||
/// osu!standard difficulty calculator
|
||||
Osu(OsuStars<'map>),
|
||||
/// osu!taiko difficulty calculator
|
||||
Taiko(TaikoStars<'map>),
|
||||
/// osu!catch difficulty calculator
|
||||
Catch(CatchStars<'map>),
|
||||
/// osu!mania difficulty calculator
|
||||
Mania(ManiaStars<'map>),
|
||||
}
|
||||
|
||||
impl<'map> AnyStars<'map> {
|
||||
/// Create a new difficulty calculator for maps of any mode.
|
||||
#[inline]
|
||||
pub fn new(map: &'map Beatmap) -> Self {
|
||||
match map.mode {
|
||||
GameMode::Osu => Self::Osu(OsuStars::new(map)),
|
||||
GameMode::Taiko => Self::Taiko(TaikoStars::new(map)),
|
||||
GameMode::Catch => Self::Catch(CatchStars::new(map)),
|
||||
GameMode::Mania => Self::Mania(ManiaStars::new(map)),
|
||||
}
|
||||
}
|
||||
|
||||
/// If the map is an osu!standard map, convert it to another mode.
|
||||
#[inline]
|
||||
pub fn mode(self, mode: GameMode) -> Self {
|
||||
match self {
|
||||
AnyStars::Osu(o) => match mode {
|
||||
GameMode::Osu => AnyStars::Osu(o),
|
||||
GameMode::Taiko => AnyStars::Taiko(o.into()),
|
||||
GameMode::Catch => AnyStars::Catch(o.into()),
|
||||
GameMode::Mania => AnyStars::Mania(o.into()),
|
||||
},
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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(self, mods: u32) -> Self {
|
||||
match self {
|
||||
Self::Osu(o) => Self::Osu(o.mods(mods)),
|
||||
Self::Taiko(t) => Self::Taiko(t.mods(mods)),
|
||||
Self::Catch(f) => Self::Catch(f.mods(mods)),
|
||||
Self::Mania(m) => Self::Mania(m.mods(mods)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Amount of passed objects for partial plays, e.g. a fail.
|
||||
///
|
||||
/// If you want to calculate the performance after every few objects, instead of
|
||||
/// using [`AnyStars`] multiple times with different `passed_objects`, you should use
|
||||
/// [`GradualDifficultyAttributes`](crate::GradualDifficultyAttributes).
|
||||
#[inline]
|
||||
pub fn passed_objects(self, passed_objects: usize) -> Self {
|
||||
match self {
|
||||
Self::Osu(o) => Self::Osu(o.passed_objects(passed_objects)),
|
||||
Self::Taiko(t) => Self::Taiko(t.passed_objects(passed_objects)),
|
||||
Self::Catch(f) => Self::Catch(f.passed_objects(passed_objects)),
|
||||
Self::Mania(m) => Self::Mania(m.passed_objects(passed_objects)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Adjust the clock rate used in the calculation.
|
||||
/// If none is specified, it will take the clock rate based on the mods
|
||||
/// i.e. 1.5 for DT, 0.75 for HT and 1.0 otherwise.
|
||||
#[inline]
|
||||
pub fn clock_rate(self, clock_rate: f64) -> Self {
|
||||
match self {
|
||||
Self::Osu(o) => Self::Osu(o.clock_rate(clock_rate)),
|
||||
Self::Taiko(t) => Self::Taiko(t.clock_rate(clock_rate)),
|
||||
Self::Catch(f) => Self::Catch(f.clock_rate(clock_rate)),
|
||||
Self::Mania(m) => Self::Mania(m.clock_rate(clock_rate)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Consume the difficulty calculator and calculate
|
||||
/// difficulty attributes for the given parameters.
|
||||
#[inline]
|
||||
pub fn calculate(self) -> DifficultyAttributes {
|
||||
match self {
|
||||
Self::Osu(o) => DifficultyAttributes::Osu(o.calculate()),
|
||||
Self::Taiko(t) => DifficultyAttributes::Taiko(t.calculate()),
|
||||
Self::Catch(f) => DifficultyAttributes::Catch(f.calculate()),
|
||||
Self::Mania(m) => DifficultyAttributes::Mania(m.calculate()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Consume the difficulty calculator and calculate
|
||||
/// skill strains for the given parameters.
|
||||
///
|
||||
/// Suitable to plot the difficulty of a map over time.
|
||||
#[inline]
|
||||
pub fn strains(self) -> Strains {
|
||||
match self {
|
||||
Self::Osu(o) => Strains::Osu(o.strains()),
|
||||
Self::Taiko(t) => Strains::Taiko(t.strains()),
|
||||
Self::Catch(f) => Strains::Catch(f.strains()),
|
||||
Self::Mania(m) => Strains::Mania(m.strains()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
use std::{
|
||||
cell::RefCell,
|
||||
fmt::{Debug, Formatter, Result as FmtResult},
|
||||
rc::{Rc, Weak},
|
||||
};
|
||||
|
||||
use crate::taiko::difficulty_object::TaikoDifficultyObject;
|
||||
|
||||
use super::{mono_streak::MonoStreak, repeating_hit_patterns::RepeatingHitPatterns};
|
||||
|
||||
pub(crate) struct AlternatingMonoPattern {
|
||||
pub(crate) mono_streaks: Vec<Rc<RefCell<MonoStreak>>>,
|
||||
pub(crate) parent: Option<Weak<RefCell<RepeatingHitPatterns>>>,
|
||||
pub(crate) idx: usize,
|
||||
}
|
||||
|
||||
impl Debug for AlternatingMonoPattern {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
|
||||
write!(
|
||||
f,
|
||||
"(idx={}, mono_len={}, has_parent={})",
|
||||
self.idx,
|
||||
self.mono_streaks.len(),
|
||||
self.parent.is_some()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl AlternatingMonoPattern {
|
||||
pub(crate) fn new() -> Rc<RefCell<Self>> {
|
||||
let this = Self {
|
||||
mono_streaks: Vec::new(),
|
||||
parent: None,
|
||||
idx: 0,
|
||||
};
|
||||
|
||||
Rc::new(RefCell::new(this))
|
||||
}
|
||||
|
||||
pub(crate) fn first_hit_object(&self) -> Option<Weak<RefCell<TaikoDifficultyObject>>> {
|
||||
self.mono_streaks
|
||||
.first()
|
||||
.and_then(|streak| streak.borrow().first_hit_object())
|
||||
}
|
||||
|
||||
pub(crate) fn is_repetition_of(&self, other: &Self) -> bool {
|
||||
self.has_identical_mono_len(other)
|
||||
&& other.mono_streaks.len() == self.mono_streaks.len()
|
||||
&& other
|
||||
.mono_streaks
|
||||
.first()
|
||||
.map(|streak| streak.borrow().hit_kind())
|
||||
== self
|
||||
.mono_streaks
|
||||
.first()
|
||||
.map(|streak| streak.borrow().hit_kind())
|
||||
}
|
||||
|
||||
pub(crate) fn has_identical_mono_len(&self, other: &Self) -> bool {
|
||||
let other_len = other
|
||||
.mono_streaks
|
||||
.first()
|
||||
.map(|streak| streak.borrow().run_len());
|
||||
|
||||
let self_len = self
|
||||
.mono_streaks
|
||||
.first()
|
||||
.map(|streak| streak.borrow().run_len());
|
||||
|
||||
other_len == self_len
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
use std::{
|
||||
cell::RefCell,
|
||||
rc::{Rc, Weak},
|
||||
};
|
||||
|
||||
pub(crate) use self::{
|
||||
alternating_mono_pattern::AlternatingMonoPattern, mono_streak::MonoStreak,
|
||||
preprocessor::ColourDifficultyPreprocessor, repeating_hit_patterns::RepeatingHitPatterns,
|
||||
};
|
||||
|
||||
mod alternating_mono_pattern;
|
||||
mod mono_streak;
|
||||
mod preprocessor;
|
||||
mod repeating_hit_patterns;
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(crate) struct TaikoDifficultyColour {
|
||||
pub(crate) mono_streak: Option<Weak<RefCell<MonoStreak>>>,
|
||||
pub(crate) alternating_mono_pattern: Option<Weak<RefCell<AlternatingMonoPattern>>>,
|
||||
pub(crate) repeating_hit_patterns: Option<Rc<RefCell<RepeatingHitPatterns>>>,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Eq, PartialEq)]
|
||||
pub(crate) enum HitKind {
|
||||
Centre,
|
||||
Rim,
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
use std::{
|
||||
cell::RefCell,
|
||||
fmt::{Debug, Formatter, Result as FmtResult},
|
||||
rc::{Rc, Weak},
|
||||
};
|
||||
|
||||
use crate::taiko::difficulty_object::{MonoIndex, TaikoDifficultyObject};
|
||||
|
||||
use super::{alternating_mono_pattern::AlternatingMonoPattern, HitKind};
|
||||
|
||||
pub(crate) struct MonoStreak {
|
||||
pub(crate) hit_objects: Vec<Weak<RefCell<TaikoDifficultyObject>>>,
|
||||
pub(crate) parent: Option<Weak<RefCell<AlternatingMonoPattern>>>,
|
||||
pub(crate) idx: usize,
|
||||
}
|
||||
|
||||
impl Debug for MonoStreak {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
|
||||
write!(
|
||||
f,
|
||||
"(idx={}, obj_len={}, has_parent={})",
|
||||
self.idx,
|
||||
self.hit_objects.len(),
|
||||
self.parent.is_some()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl MonoStreak {
|
||||
pub(crate) fn new() -> Rc<RefCell<Self>> {
|
||||
let this = Self {
|
||||
hit_objects: Vec::new(),
|
||||
parent: None,
|
||||
idx: 0,
|
||||
};
|
||||
|
||||
Rc::new(RefCell::new(this))
|
||||
}
|
||||
|
||||
pub(crate) fn first_hit_object(&self) -> Option<Weak<RefCell<TaikoDifficultyObject>>> {
|
||||
self.hit_objects.first().map(Weak::clone)
|
||||
}
|
||||
|
||||
pub(crate) fn hit_kind(&self) -> Option<HitKind> {
|
||||
self.hit_objects
|
||||
.first()
|
||||
.and_then(Weak::upgrade)
|
||||
.and_then(|obj| match obj.borrow().mono_idx {
|
||||
MonoIndex::Centre(_) => Some(HitKind::Centre),
|
||||
MonoIndex::Rim(_) => Some(HitKind::Rim),
|
||||
MonoIndex::None => None,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn run_len(&self) -> usize {
|
||||
self.hit_objects.len()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
use std::{
|
||||
cell::RefCell,
|
||||
collections::VecDeque,
|
||||
rc::{Rc, Weak},
|
||||
};
|
||||
|
||||
use crate::taiko::difficulty_object::ObjectLists;
|
||||
|
||||
use super::{
|
||||
alternating_mono_pattern::AlternatingMonoPattern, mono_streak::MonoStreak,
|
||||
repeating_hit_patterns::RepeatingHitPatterns,
|
||||
};
|
||||
|
||||
pub(crate) struct ColourDifficultyPreprocessor;
|
||||
|
||||
impl ColourDifficultyPreprocessor {
|
||||
pub(crate) fn process_and_assign(lists: &mut ObjectLists) {
|
||||
// * Assign indexing and encoding data to all relevant objects. Only the first note of each encoding type is
|
||||
// * assigned with the relevant encodings.
|
||||
for repeating_hit_pattern in Self::encode(lists) {
|
||||
if let Some(obj) = repeating_hit_pattern
|
||||
.borrow()
|
||||
.first_hit_object()
|
||||
.as_ref()
|
||||
.and_then(Weak::upgrade)
|
||||
{
|
||||
obj.borrow_mut().colour.repeating_hit_patterns =
|
||||
Some(Rc::clone(&repeating_hit_pattern));
|
||||
}
|
||||
|
||||
// * The outermost loop is kept a ForEach loop since it doesn't need index information, and we want to
|
||||
// * keep i and j for AlternatingMonoPattern's and MonoStreak's index respectively, to keep it in line with
|
||||
// * documentation.
|
||||
for i in 0..repeating_hit_pattern
|
||||
.borrow()
|
||||
.alternating_mono_patterns
|
||||
.len()
|
||||
{
|
||||
let borrowed_repeating_hit_pattern = repeating_hit_pattern.borrow();
|
||||
let mono_pattern = &borrowed_repeating_hit_pattern.alternating_mono_patterns[i];
|
||||
|
||||
{
|
||||
let mut borrowed = mono_pattern.borrow_mut();
|
||||
borrowed.parent = Some(Rc::downgrade(&repeating_hit_pattern));
|
||||
borrowed.idx = i;
|
||||
}
|
||||
|
||||
if let Some(obj) = mono_pattern
|
||||
.borrow()
|
||||
.first_hit_object()
|
||||
.as_ref()
|
||||
.and_then(Weak::upgrade)
|
||||
{
|
||||
obj.borrow_mut().colour.alternating_mono_pattern =
|
||||
Some(Rc::downgrade(mono_pattern));
|
||||
}
|
||||
|
||||
for j in 0..mono_pattern.borrow().mono_streaks.len() {
|
||||
let borrowed_mono_pattern = mono_pattern.borrow();
|
||||
let mono_streak = &borrowed_mono_pattern.mono_streaks[j];
|
||||
|
||||
{
|
||||
let mut borrowed = mono_streak.borrow_mut();
|
||||
borrowed.parent = Some(Rc::downgrade(mono_pattern));
|
||||
borrowed.idx = j;
|
||||
}
|
||||
|
||||
if let Some(obj) = mono_streak
|
||||
.borrow()
|
||||
.first_hit_object()
|
||||
.as_ref()
|
||||
.and_then(Weak::upgrade)
|
||||
{
|
||||
obj.borrow_mut().colour.mono_streak = Some(Rc::downgrade(mono_streak));
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn encode(data: &mut ObjectLists) -> Vec<Rc<RefCell<RepeatingHitPatterns>>> {
|
||||
let mono_streaks = Self::encode_mono_streak(data);
|
||||
let alternating_mono_patterns = Self::encode_alternating_mono_pattern(mono_streaks);
|
||||
|
||||
Self::encode_repeating_hit_pattern(alternating_mono_patterns)
|
||||
}
|
||||
|
||||
fn encode_mono_streak(data: &mut ObjectLists) -> Vec<Rc<RefCell<MonoStreak>>> {
|
||||
let mut mono_streaks = vec![MonoStreak::new()];
|
||||
let mut curr_mono_streak = mono_streaks.last_mut();
|
||||
let mut data_iter = data.all.iter();
|
||||
|
||||
if let (Some(curr), Some(taiko_obj)) = (&curr_mono_streak, data_iter.next()) {
|
||||
curr.borrow_mut().hit_objects.push(Rc::downgrade(taiko_obj));
|
||||
}
|
||||
|
||||
for taiko_obj in data_iter {
|
||||
// * This ignores all non-note objects, which may or may not be the desired behaviour
|
||||
let prev = data.prev_note(taiko_obj.borrow().idx, 0);
|
||||
|
||||
// * If this is the first object in the list or the colour changed, create a new mono streak
|
||||
let condition = prev.filter(|prev| {
|
||||
!(taiko_obj.borrow().base.is_hit
|
||||
&& prev.borrow().base.is_hit
|
||||
&& (taiko_obj.borrow().base.is_rim != prev.borrow().base.is_rim))
|
||||
});
|
||||
|
||||
if condition.is_none() {
|
||||
mono_streaks.push(MonoStreak::new());
|
||||
curr_mono_streak = mono_streaks.last_mut();
|
||||
}
|
||||
|
||||
// * Add the current object to the encoded payload.
|
||||
if let Some(ref curr) = curr_mono_streak {
|
||||
curr.borrow_mut().hit_objects.push(Rc::downgrade(taiko_obj));
|
||||
}
|
||||
}
|
||||
|
||||
mono_streaks
|
||||
}
|
||||
|
||||
fn encode_alternating_mono_pattern(
|
||||
data: Vec<Rc<RefCell<MonoStreak>>>,
|
||||
) -> VecDeque<Rc<RefCell<AlternatingMonoPattern>>> {
|
||||
let mut mono_patterns = VecDeque::new();
|
||||
mono_patterns.push_back(AlternatingMonoPattern::new());
|
||||
let mut curr_mono_pattern = mono_patterns.back_mut();
|
||||
|
||||
if let (Some(curr), Some(mono)) = (&curr_mono_pattern, data.first()) {
|
||||
curr.borrow_mut().mono_streaks.push(Rc::clone(mono));
|
||||
}
|
||||
|
||||
for (prev, curr) in data.iter().zip(data.iter().skip(1)) {
|
||||
// * Start a new AlternatingMonoPattern if the previous MonoStreak has a different mono length,
|
||||
// * or if this is the first MonoStreak in the list.
|
||||
if curr.borrow().run_len() != prev.borrow().run_len() {
|
||||
mono_patterns.push_back(AlternatingMonoPattern::new());
|
||||
curr_mono_pattern = mono_patterns.back_mut();
|
||||
}
|
||||
|
||||
// * Add the current MonoStreak to the encoded payload.
|
||||
if let Some(ref curr_mono_pattern) = curr_mono_pattern {
|
||||
curr_mono_pattern
|
||||
.borrow_mut()
|
||||
.mono_streaks
|
||||
.push(Rc::clone(curr));
|
||||
}
|
||||
}
|
||||
|
||||
mono_patterns
|
||||
}
|
||||
|
||||
fn encode_repeating_hit_pattern(
|
||||
mut data: VecDeque<Rc<RefCell<AlternatingMonoPattern>>>,
|
||||
) -> Vec<Rc<RefCell<RepeatingHitPatterns>>> {
|
||||
let mut hit_patterns = Vec::new();
|
||||
let mut curr_hit_pattern: Option<Rc<std::cell::RefCell<_>>> = None;
|
||||
|
||||
while !data.is_empty() {
|
||||
let old = curr_hit_pattern.as_ref().map(Rc::downgrade);
|
||||
let curr_hit_pattern = curr_hit_pattern.insert(RepeatingHitPatterns::new(old));
|
||||
|
||||
let mut is_coupled = data.get(2).map_or(false, |other| {
|
||||
data[0].borrow().is_repetition_of(&other.borrow())
|
||||
});
|
||||
|
||||
if is_coupled {
|
||||
// * If so, add the current AlternatingMonoPattern to the encoded payload and start repeatedly checking if the
|
||||
// * subsequent AlternatingMonoPatterns should be grouped by increasing i and doing the appropriate isCoupled check.
|
||||
while is_coupled {
|
||||
curr_hit_pattern
|
||||
.borrow_mut()
|
||||
.alternating_mono_patterns
|
||||
.push(data.pop_front().unwrap());
|
||||
|
||||
is_coupled = data.get(2).map_or(false, |other| {
|
||||
data[0].borrow().is_repetition_of(&other.borrow())
|
||||
});
|
||||
}
|
||||
|
||||
// * Skip over viewed data and add the rest to the payload
|
||||
for front in data.drain(..2) {
|
||||
curr_hit_pattern
|
||||
.borrow_mut()
|
||||
.alternating_mono_patterns
|
||||
.push(front);
|
||||
}
|
||||
} else {
|
||||
// * If not, add the current AlternatingMonoPattern to the encoded payload and continue.
|
||||
curr_hit_pattern
|
||||
.borrow_mut()
|
||||
.alternating_mono_patterns
|
||||
.push(data.pop_front().unwrap());
|
||||
}
|
||||
|
||||
hit_patterns.push(Rc::clone(&*curr_hit_pattern));
|
||||
}
|
||||
|
||||
hit_patterns
|
||||
.iter_mut()
|
||||
.for_each(|pattern| pattern.borrow_mut().find_repetition_interval());
|
||||
|
||||
hit_patterns
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
use std::{
|
||||
cell::RefCell,
|
||||
fmt::{Debug, Formatter, Result as FmtResult},
|
||||
rc::{Rc, Weak},
|
||||
};
|
||||
|
||||
use crate::taiko::difficulty_object::TaikoDifficultyObject;
|
||||
|
||||
use super::alternating_mono_pattern::AlternatingMonoPattern;
|
||||
|
||||
pub(crate) struct RepeatingHitPatterns {
|
||||
pub(crate) alternating_mono_patterns: Vec<Rc<RefCell<AlternatingMonoPattern>>>,
|
||||
pub(crate) prev: Option<Weak<RefCell<Self>>>,
|
||||
pub(crate) repetition_interval: usize,
|
||||
}
|
||||
|
||||
impl Debug for RepeatingHitPatterns {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
|
||||
write!(
|
||||
f,
|
||||
"(interval={}, alt_len={}, has_prev={})",
|
||||
self.repetition_interval,
|
||||
self.alternating_mono_patterns.len(),
|
||||
self.prev.is_some()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl RepeatingHitPatterns {
|
||||
const MAX_REPETITION_INTERVAL: usize = 16;
|
||||
|
||||
pub(crate) fn new(prev: Option<Weak<RefCell<Self>>>) -> Rc<RefCell<Self>> {
|
||||
let this = Self {
|
||||
alternating_mono_patterns: Vec::new(),
|
||||
prev,
|
||||
repetition_interval: 0,
|
||||
};
|
||||
|
||||
Rc::new(RefCell::new(this))
|
||||
}
|
||||
|
||||
pub(crate) fn first_hit_object(&self) -> Option<Weak<RefCell<TaikoDifficultyObject>>> {
|
||||
self.alternating_mono_patterns
|
||||
.first()
|
||||
.and_then(|pattern| pattern.borrow().first_hit_object())
|
||||
}
|
||||
|
||||
fn is_repetition_of(&self, other: &Self) -> bool {
|
||||
if self.alternating_mono_patterns.len() != other.alternating_mono_patterns.len() {
|
||||
return false;
|
||||
}
|
||||
|
||||
self.alternating_mono_patterns
|
||||
.iter()
|
||||
.zip(other.alternating_mono_patterns.iter())
|
||||
.take(2)
|
||||
.all(|(self_pat, other_pat)| {
|
||||
self_pat
|
||||
.borrow()
|
||||
.has_identical_mono_len(&other_pat.borrow())
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn find_repetition_interval(&mut self) {
|
||||
let mut other = match self.prev.as_ref().and_then(Weak::upgrade) {
|
||||
Some(prev) => prev,
|
||||
None => return self.repetition_interval = Self::MAX_REPETITION_INTERVAL + 1,
|
||||
};
|
||||
|
||||
let mut interval = 1;
|
||||
|
||||
while interval < Self::MAX_REPETITION_INTERVAL {
|
||||
if self.is_repetition_of(&other.borrow()) {
|
||||
return self.repetition_interval = interval.min(Self::MAX_REPETITION_INTERVAL);
|
||||
}
|
||||
|
||||
let next = match other.borrow().prev.as_ref().and_then(Weak::upgrade) {
|
||||
Some(prev) => prev,
|
||||
None => break,
|
||||
};
|
||||
|
||||
// gotta love NLL...
|
||||
other = next;
|
||||
|
||||
interval += 1;
|
||||
}
|
||||
|
||||
self.repetition_interval = Self::MAX_REPETITION_INTERVAL + 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
use std::{cell::RefCell, cmp::Ordering, rc::Rc};
|
||||
|
||||
use super::{colours::TaikoDifficultyColour, taiko_object::TaikoObject};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct TaikoDifficultyObject {
|
||||
pub(crate) base: TaikoObject,
|
||||
pub(crate) prev_time: f64,
|
||||
pub(crate) colour: TaikoDifficultyColour,
|
||||
pub(crate) rhythm: &'static HitObjectRhythm,
|
||||
pub(crate) mono_idx: MonoIndex,
|
||||
pub(crate) note_idx: Option<usize>,
|
||||
pub(crate) idx: usize,
|
||||
pub(crate) start_time: f64,
|
||||
pub(crate) delta: f64,
|
||||
}
|
||||
|
||||
impl TaikoDifficultyObject {
|
||||
pub(crate) fn new(
|
||||
base: TaikoObject,
|
||||
base_start_time: f64,
|
||||
last_start_time: f64,
|
||||
last_last_start_time: f64,
|
||||
clock_rate: f64,
|
||||
lists: &ObjectLists,
|
||||
idx: usize,
|
||||
) -> Self {
|
||||
// * Create the Colour object, its properties should be filled in by TaikoDifficultyPreprocessor
|
||||
let colour = TaikoDifficultyColour::default();
|
||||
|
||||
let delta = (base_start_time - last_start_time) / clock_rate;
|
||||
let rhythm = closest_rhythm(delta, last_start_time, last_last_start_time, clock_rate);
|
||||
|
||||
let mono_idx = if !base.is_hit {
|
||||
MonoIndex::None
|
||||
} else if base.is_rim {
|
||||
MonoIndex::Rim(lists.rims.len())
|
||||
} else {
|
||||
MonoIndex::Centre(lists.centres.len())
|
||||
};
|
||||
|
||||
let note_idx = base.is_hit.then_some(lists.notes.len());
|
||||
|
||||
Self {
|
||||
base,
|
||||
prev_time: last_start_time / clock_rate,
|
||||
colour,
|
||||
rhythm,
|
||||
mono_idx,
|
||||
note_idx,
|
||||
idx,
|
||||
start_time: base_start_time / clock_rate,
|
||||
delta,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[rustfmt::skip]
|
||||
pub(crate) static COMMON_RHYTHMS: [HitObjectRhythm; 9] = [
|
||||
HitObjectRhythm { id: 0, ratio: 1.0, difficulty: 0.0 },
|
||||
HitObjectRhythm { id: 1, ratio: 2.0 / 1.0, difficulty: 0.3 },
|
||||
HitObjectRhythm { id: 2, ratio: 1.0 / 2.0, difficulty: 0.5 },
|
||||
HitObjectRhythm { id: 3, ratio: 3.0 / 1.0, difficulty: 0.3 },
|
||||
HitObjectRhythm { id: 4, ratio: 1.0 / 3.0, difficulty: 0.35 },
|
||||
// * purposefully higher (requires hand switch in full alternating gameplay style)
|
||||
HitObjectRhythm { id: 5, ratio: 3.0 / 2.0, difficulty: 0.6 },
|
||||
HitObjectRhythm { id: 6, ratio: 2.0 / 3.0, difficulty: 0.4 },
|
||||
HitObjectRhythm { id: 7, ratio: 5.0 / 4.0, difficulty: 0.5 },
|
||||
HitObjectRhythm { id: 8, ratio: 4.0 / 5.0, difficulty: 0.7 },
|
||||
];
|
||||
|
||||
#[derive(Copy, Clone, Debug)]
|
||||
pub(crate) struct HitObjectRhythm {
|
||||
id: u8,
|
||||
pub(crate) ratio: f64,
|
||||
pub(crate) difficulty: f64,
|
||||
}
|
||||
|
||||
impl HitObjectRhythm {
|
||||
pub(crate) fn static_ref() -> &'static Self {
|
||||
&COMMON_RHYTHMS[0]
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq for HitObjectRhythm {
|
||||
#[inline]
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.id == other.id
|
||||
}
|
||||
}
|
||||
|
||||
impl Eq for HitObjectRhythm {}
|
||||
|
||||
fn closest_rhythm(
|
||||
delta_time: f64,
|
||||
last_start_time: f64,
|
||||
last_last_start_time: f64,
|
||||
clock_rate: f64,
|
||||
) -> &'static HitObjectRhythm {
|
||||
let prev_len = (last_start_time - last_last_start_time) / clock_rate;
|
||||
let ratio = delta_time / prev_len;
|
||||
|
||||
COMMON_RHYTHMS
|
||||
.iter()
|
||||
.min_by(|r1, r2| {
|
||||
(r1.ratio - ratio)
|
||||
.abs()
|
||||
.partial_cmp(&(r2.ratio - ratio).abs())
|
||||
.unwrap_or(Ordering::Equal)
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(crate) struct ObjectLists {
|
||||
pub(crate) all: Vec<Rc<RefCell<TaikoDifficultyObject>>>,
|
||||
pub(crate) centres: Vec<usize>,
|
||||
pub(crate) rims: Vec<usize>,
|
||||
pub(crate) notes: Vec<usize>,
|
||||
}
|
||||
|
||||
impl ObjectLists {
|
||||
pub(crate) fn prev_mono(
|
||||
&self,
|
||||
curr: usize,
|
||||
backwards_idx: usize,
|
||||
) -> Option<&'_ Rc<RefCell<TaikoDifficultyObject>>> {
|
||||
let curr = &self.all[curr];
|
||||
|
||||
let prev = match curr.borrow().mono_idx {
|
||||
MonoIndex::Centre(idx) => idx
|
||||
.checked_sub(backwards_idx + 1)
|
||||
.and_then(|idx| self.centres.get(idx))?,
|
||||
MonoIndex::Rim(idx) => idx
|
||||
.checked_sub(backwards_idx + 1)
|
||||
.and_then(|idx| self.rims.get(idx))?,
|
||||
MonoIndex::None => return None,
|
||||
};
|
||||
|
||||
self.all.get(*prev)
|
||||
}
|
||||
|
||||
#[allow(unused)]
|
||||
pub(crate) fn next_mono(
|
||||
&self,
|
||||
curr: usize,
|
||||
forwards_idx: usize,
|
||||
) -> Option<&'_ Rc<RefCell<TaikoDifficultyObject>>> {
|
||||
let curr = &self.all[curr];
|
||||
|
||||
let next = match curr.borrow().mono_idx {
|
||||
MonoIndex::Centre(idx) => self.centres.get(idx + (forwards_idx + 1))?,
|
||||
MonoIndex::Rim(idx) => self.rims.get(idx + (forwards_idx + 1))?,
|
||||
MonoIndex::None => return None,
|
||||
};
|
||||
|
||||
self.all.get(*next)
|
||||
}
|
||||
|
||||
pub(crate) fn prev_note(
|
||||
&self,
|
||||
curr: usize,
|
||||
backwards_idx: usize,
|
||||
) -> Option<&'_ Rc<RefCell<TaikoDifficultyObject>>> {
|
||||
let curr = &self.all[curr];
|
||||
let note_idx = curr.borrow().note_idx?;
|
||||
let idx = note_idx.checked_sub(backwards_idx + 1)?;
|
||||
let prev = self.notes.get(idx)?;
|
||||
|
||||
self.all.get(*prev)
|
||||
}
|
||||
|
||||
#[allow(unused)]
|
||||
pub(crate) fn next_note(
|
||||
&self,
|
||||
curr: usize,
|
||||
forwards_idx: usize,
|
||||
) -> Option<&'_ Rc<RefCell<TaikoDifficultyObject>>> {
|
||||
let curr = &self.all[curr];
|
||||
let note_idx = curr.borrow().note_idx?;
|
||||
let idx = note_idx + (forwards_idx + 1);
|
||||
let prev = self.notes.get(idx)?;
|
||||
|
||||
self.all.get(*prev)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug)]
|
||||
pub(crate) enum MonoIndex {
|
||||
Centre(usize),
|
||||
Rim(usize),
|
||||
None,
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
use std::{borrow::Cow, cell::RefCell, rc::Rc, vec::IntoIter};
|
||||
|
||||
use crate::{beatmap::BeatmapHitWindows, taiko::rescale, Beatmap, GameMode, Mods};
|
||||
|
||||
use super::{
|
||||
colours::ColourDifficultyPreprocessor,
|
||||
difficulty_object::{MonoIndex, ObjectLists, TaikoDifficultyObject},
|
||||
skills::{Peaks, PeaksDifficultyValues, Skill},
|
||||
taiko_object::IntoTaikoObjectIter,
|
||||
TaikoDifficultyAttributes, DIFFICULTY_MULTIPLIER,
|
||||
};
|
||||
|
||||
/// Gradually calculate the difficulty attributes of an osu!taiko map.
|
||||
///
|
||||
/// Note that this struct implements [`Iterator`](std::iter::Iterator).
|
||||
/// On every call of [`Iterator::next`](std::iter::Iterator::next), the map's next hit object will
|
||||
/// be processed and the [`TaikoDifficultyAttributes`] will be updated and returned.
|
||||
///
|
||||
/// If you want to calculate performance attributes, use
|
||||
/// [`TaikoGradualPerformanceAttributes`](crate::taiko::TaikoGradualPerformanceAttributes) instead.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use rosu_pp::{Beatmap, taiko::TaikoGradualDifficultyAttributes};
|
||||
///
|
||||
/// # /*
|
||||
/// let map: Beatmap = ...
|
||||
/// # */
|
||||
/// # let map = Beatmap::default();
|
||||
///
|
||||
/// let mods = 64; // DT
|
||||
/// let mut iter = TaikoGradualDifficultyAttributes::new(&map, mods);
|
||||
///
|
||||
/// let attrs1 = iter.next(); // the difficulty of the map after the first hit object
|
||||
/// let attrs2 = iter.next(); // after the second hit object
|
||||
///
|
||||
/// // Remaining hit objects
|
||||
/// for difficulty in iter {
|
||||
/// // ...
|
||||
/// }
|
||||
/// ```
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct TaikoGradualDifficultyAttributes {
|
||||
attrs: TaikoDifficultyAttributes,
|
||||
hit_objects: IntoIter<Rc<RefCell<TaikoDifficultyObject>>>,
|
||||
lists: ObjectLists,
|
||||
peaks: Peaks,
|
||||
total_hits: usize,
|
||||
is_convert: bool,
|
||||
pub(crate) started: bool,
|
||||
}
|
||||
|
||||
impl TaikoGradualDifficultyAttributes {
|
||||
/// Create a new difficulty attributes iterator for osu!taiko maps.
|
||||
pub fn new(map: &Beatmap, mods: u32) -> Self {
|
||||
let map = map.convert_mode(GameMode::Taiko);
|
||||
let is_convert = matches!(map, Cow::Owned(_));
|
||||
let peaks = Peaks::new();
|
||||
let clock_rate = mods.clock_rate();
|
||||
|
||||
let BeatmapHitWindows { od: hit_window, .. } = map
|
||||
.attributes()
|
||||
.mods(mods)
|
||||
.clock_rate(clock_rate)
|
||||
.hit_windows();
|
||||
|
||||
let mut attrs = TaikoDifficultyAttributes {
|
||||
stamina: 0.0,
|
||||
rhythm: 0.0,
|
||||
colour: 0.0,
|
||||
peak: 0.0,
|
||||
hit_window,
|
||||
stars: 0.0,
|
||||
max_combo: 0,
|
||||
};
|
||||
|
||||
if map.hit_objects.len() < 2 {
|
||||
return Self {
|
||||
hit_objects: Vec::new().into_iter(),
|
||||
lists: ObjectLists::default(),
|
||||
peaks,
|
||||
attrs,
|
||||
total_hits: 0,
|
||||
is_convert,
|
||||
started: false,
|
||||
};
|
||||
}
|
||||
|
||||
attrs.max_combo += map.hit_objects[0].is_circle() as usize;
|
||||
attrs.max_combo += map.hit_objects[1].is_circle() as usize;
|
||||
let mut total_hits = attrs.max_combo;
|
||||
|
||||
let mut diff_objects = map
|
||||
.taiko_objects()
|
||||
.skip(2)
|
||||
.zip(map.hit_objects.iter().skip(1))
|
||||
.zip(map.hit_objects.iter())
|
||||
.enumerate()
|
||||
.fold(
|
||||
ObjectLists::default(),
|
||||
|mut lists, (idx, (((base, base_start_time), last), last_last))| {
|
||||
total_hits += base.is_hit as usize;
|
||||
|
||||
let diff_obj = TaikoDifficultyObject::new(
|
||||
base,
|
||||
base_start_time,
|
||||
last.start_time,
|
||||
last_last.start_time,
|
||||
clock_rate,
|
||||
&lists,
|
||||
idx,
|
||||
);
|
||||
|
||||
match &diff_obj.mono_idx {
|
||||
MonoIndex::Centre(_) => lists.centres.push(idx),
|
||||
MonoIndex::Rim(_) => lists.rims.push(idx),
|
||||
MonoIndex::None => {}
|
||||
}
|
||||
|
||||
if diff_obj.note_idx.is_some() {
|
||||
lists.notes.push(idx);
|
||||
}
|
||||
|
||||
lists.all.push(Rc::new(RefCell::new(diff_obj)));
|
||||
|
||||
lists
|
||||
},
|
||||
);
|
||||
|
||||
ColourDifficultyPreprocessor::process_and_assign(&mut diff_objects);
|
||||
|
||||
Self {
|
||||
hit_objects: diff_objects.all.clone().into_iter(),
|
||||
lists: diff_objects,
|
||||
peaks,
|
||||
attrs,
|
||||
total_hits,
|
||||
is_convert,
|
||||
started: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Iterator for TaikoGradualDifficultyAttributes {
|
||||
type Item = TaikoDifficultyAttributes;
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
self.started = true;
|
||||
|
||||
loop {
|
||||
let curr = self.hit_objects.next()?;
|
||||
let borrowed = curr.borrow();
|
||||
self.peaks.process(&borrowed, &self.lists);
|
||||
|
||||
if borrowed.base.is_hit {
|
||||
self.attrs.max_combo += 1;
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let PeaksDifficultyValues {
|
||||
mut colour_rating,
|
||||
mut rhythm_rating,
|
||||
mut stamina_rating,
|
||||
mut combined_rating,
|
||||
} = self.peaks.clone().difficulty_values();
|
||||
|
||||
colour_rating *= DIFFICULTY_MULTIPLIER;
|
||||
rhythm_rating *= DIFFICULTY_MULTIPLIER;
|
||||
stamina_rating *= DIFFICULTY_MULTIPLIER;
|
||||
combined_rating *= DIFFICULTY_MULTIPLIER;
|
||||
|
||||
let mut star_rating = rescale(combined_rating * 1.4);
|
||||
|
||||
// * TODO: This is temporary measure as we don't detect abuse of multiple-input
|
||||
// * playstyles of converts within the current system.
|
||||
if self.is_convert {
|
||||
star_rating *= 0.925;
|
||||
|
||||
// * For maps with low colour variance and high stamina requirement,
|
||||
// * multiple inputs are more likely to be abused.
|
||||
if colour_rating < 2.0 && stamina_rating > 8.0 {
|
||||
star_rating *= 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
self.attrs.stamina = stamina_rating;
|
||||
self.attrs.colour = colour_rating;
|
||||
self.attrs.rhythm = rhythm_rating;
|
||||
self.attrs.peak = combined_rating;
|
||||
self.attrs.stars = star_rating;
|
||||
|
||||
Some(self.attrs.clone())
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn size_hint(&self) -> (usize, Option<usize>) {
|
||||
let len = self.len();
|
||||
|
||||
(len, Some(len))
|
||||
}
|
||||
|
||||
fn nth(&mut self, n: usize) -> Option<Self::Item> {
|
||||
let skip = n
|
||||
.min(self.total_hits - self.attrs.max_combo)
|
||||
.saturating_sub(1);
|
||||
|
||||
for _ in 0..skip {
|
||||
loop {
|
||||
let curr = self.hit_objects.next()?;
|
||||
let borrowed = curr.borrow();
|
||||
self.peaks.process(&borrowed, &self.lists);
|
||||
|
||||
if borrowed.base.is_hit {
|
||||
self.attrs.max_combo += 1;
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.next()
|
||||
}
|
||||
}
|
||||
|
||||
impl ExactSizeIterator for TaikoGradualDifficultyAttributes {
|
||||
#[inline]
|
||||
fn len(&self) -> usize {
|
||||
self.hit_objects.len()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
use crate::{Beatmap, TaikoPP};
|
||||
|
||||
use super::{TaikoGradualDifficultyAttributes, TaikoPerformanceAttributes};
|
||||
|
||||
/// Aggregation for a score's current state i.e. what was the
|
||||
/// maximum combo so far and what are the current hitresults.
|
||||
///
|
||||
/// This struct is used for [`TaikoGradualPerformanceAttributes`].
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq)]
|
||||
pub struct TaikoScoreState {
|
||||
/// Maximum combo that the score has had so far.
|
||||
/// **Not** the maximum possible combo of the map so far.
|
||||
pub max_combo: usize,
|
||||
/// Amount of current 300s.
|
||||
pub n300: usize,
|
||||
/// Amount of current 100s.
|
||||
pub n100: usize,
|
||||
/// Amount of current misses.
|
||||
pub n_misses: usize,
|
||||
}
|
||||
|
||||
impl TaikoScoreState {
|
||||
/// Create a new empty score state.
|
||||
#[inline]
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Return the total amount of hits by adding everything up.
|
||||
#[inline]
|
||||
pub fn total_hits(&self) -> usize {
|
||||
self.n300 + self.n100 + self.n_misses
|
||||
}
|
||||
|
||||
/// Calculate the accuracy between `0.0` and `1.0` for this state.
|
||||
#[inline]
|
||||
pub fn accuracy(&self) -> f64 {
|
||||
let total_hits = self.total_hits();
|
||||
|
||||
if total_hits == 0 {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
let numerator = 2 * self.n300 + self.n100;
|
||||
let denominator = 2 * total_hits;
|
||||
|
||||
numerator as f64 / denominator as f64
|
||||
}
|
||||
}
|
||||
|
||||
/// Gradually calculate the performance attributes of an osu!taiko map.
|
||||
///
|
||||
/// After each hit object you can call
|
||||
/// [`process_next_object`](`TaikoGradualPerformanceAttributes::process_next_object`)
|
||||
/// and it will return the resulting current [`TaikoPerformanceAttributes`].
|
||||
/// To process multiple objects at once, use
|
||||
/// [`process_next_n_objects`](`TaikoGradualPerformanceAttributes::process_next_n_objects`) instead.
|
||||
///
|
||||
/// Both methods require a [`TaikoScoreState`] that contains the current
|
||||
/// hitresults as well as the maximum combo so far.
|
||||
///
|
||||
/// If you only want to calculate difficulty attributes use
|
||||
/// [`TaikoGradualDifficultyAttributes`](crate::taiko::TaikoGradualDifficultyAttributes) instead.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use rosu_pp::{Beatmap, taiko::{TaikoGradualPerformanceAttributes, TaikoScoreState}};
|
||||
///
|
||||
/// # /*
|
||||
/// let map: Beatmap = ...
|
||||
/// # */
|
||||
/// # let map = Beatmap::default();
|
||||
///
|
||||
/// let mods = 64; // DT
|
||||
/// let mut gradual_perf = TaikoGradualPerformanceAttributes::new(&map, mods);
|
||||
/// let mut state = TaikoScoreState::new(); // empty state, everything is on 0.
|
||||
///
|
||||
/// // The first 10 hitresults are 300s
|
||||
/// for _ in 0..10 {
|
||||
/// state.n300 += 1;
|
||||
/// state.max_combo += 1;
|
||||
///
|
||||
/// # /*
|
||||
/// let performance = gradual_perf.process_next_object(state.clone()).unwrap();
|
||||
/// println!("PP: {}", performance.pp);
|
||||
/// # */
|
||||
/// # let _ = gradual_perf.process_next_object(state.clone());
|
||||
/// }
|
||||
///
|
||||
/// // Then comes a miss.
|
||||
/// // Note that state's max combo won't be incremented for
|
||||
/// // the next few objects because the combo is reset.
|
||||
/// state.n_misses += 1;
|
||||
/// # /*
|
||||
/// let performance = gradual_perf.process_next_object(state.clone()).unwrap();
|
||||
/// println!("PP: {}", performance.pp);
|
||||
/// # */
|
||||
/// # let _ = gradual_perf.process_next_object(state.clone());
|
||||
///
|
||||
/// // The next 10 objects will be a mixture of 300s and 100s.
|
||||
/// // Notice how all 10 objects will be processed in one go.
|
||||
/// state.n300 += 3;
|
||||
/// state.n100 += 7;
|
||||
/// # /*
|
||||
/// let performance = gradual_perf.process_next_n_objects(state.clone(), 10).unwrap();
|
||||
/// println!("PP: {}", performance.pp);
|
||||
/// # */
|
||||
/// # let _ = gradual_perf.process_next_n_objects(state.clone(), 10);
|
||||
///
|
||||
/// // Now comes another 300. Note that the max combo gets incremented again.
|
||||
/// state.n300 += 1;
|
||||
/// state.max_combo += 1;
|
||||
/// # /*
|
||||
/// let performance = gradual_perf.process_next_object(state.clone()).unwrap();
|
||||
/// println!("PP: {}", performance.pp);
|
||||
/// # */
|
||||
/// # let _ = gradual_perf.process_next_object(state.clone());
|
||||
///
|
||||
/// // Skip to the end
|
||||
/// # /*
|
||||
/// state.max_combo = ...
|
||||
/// state.n300 = ...
|
||||
/// state.n100 = ...
|
||||
/// state.n_misses = ...
|
||||
/// let final_performance = gradual_perf.process_next_n_objects(state.clone(), usize::MAX).unwrap();
|
||||
/// println!("PP: {}", performance.pp);
|
||||
/// # */
|
||||
/// # let _ = gradual_perf.process_next_n_objects(state.clone(), usize::MAX);
|
||||
///
|
||||
/// // Once the final performance was calculated,
|
||||
/// // attempting to process further objects will return `None`.
|
||||
/// assert!(gradual_perf.process_next_object(state).is_none());
|
||||
/// ```
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct TaikoGradualPerformanceAttributes<'map> {
|
||||
difficulty: TaikoGradualDifficultyAttributes,
|
||||
performance: TaikoPP<'map>,
|
||||
}
|
||||
|
||||
impl<'map> TaikoGradualPerformanceAttributes<'map> {
|
||||
/// Create a new gradual performance calculator for osu!taiko maps.
|
||||
pub fn new(map: &'map Beatmap, mods: u32) -> Self {
|
||||
let difficulty = TaikoGradualDifficultyAttributes::new(map, mods);
|
||||
let performance = TaikoPP::new(map).mods(mods).passed_objects(0);
|
||||
|
||||
Self {
|
||||
difficulty,
|
||||
performance,
|
||||
}
|
||||
}
|
||||
|
||||
/// Process the next hit object and calculate the
|
||||
/// performance attributes for the resulting score.
|
||||
pub fn process_next_object(
|
||||
&mut self,
|
||||
state: TaikoScoreState,
|
||||
) -> Option<TaikoPerformanceAttributes> {
|
||||
self.process_next_n_objects(state, 1)
|
||||
}
|
||||
|
||||
/// Same as [`process_next_object`](`TaikoGradualPerformanceAttributes::process_next_object`)
|
||||
/// but instead of processing only one object it process `n` many.
|
||||
///
|
||||
/// If `n` is 0 it will be considered as 1.
|
||||
/// If there are still objects to be processed but `n` is larger than the amount
|
||||
/// of remaining objects, `n` will be considered as the amount of remaining objects.
|
||||
pub fn process_next_n_objects(
|
||||
&mut self,
|
||||
state: TaikoScoreState,
|
||||
n: usize,
|
||||
) -> Option<TaikoPerformanceAttributes> {
|
||||
let sub = 2 * !self.difficulty.started as usize;
|
||||
let difficulty = self.difficulty.nth(n.saturating_sub(sub))?;
|
||||
let passed_objects = difficulty.max_combo;
|
||||
|
||||
let performance = self
|
||||
.performance
|
||||
.clone()
|
||||
.attributes(difficulty)
|
||||
.state(state)
|
||||
.passed_objects(passed_objects)
|
||||
.calculate();
|
||||
|
||||
Some(performance)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
mod colours;
|
||||
mod difficulty_object;
|
||||
mod gradual_difficulty;
|
||||
mod gradual_performance;
|
||||
mod pp;
|
||||
mod rim;
|
||||
mod skills;
|
||||
mod taiko_object;
|
||||
|
||||
use std::{borrow::Cow, cell::RefCell, rc::Rc};
|
||||
|
||||
pub use self::{gradual_difficulty::*, gradual_performance::*, pp::*};
|
||||
|
||||
use crate::{beatmap::BeatmapHitWindows, Beatmap, GameMode, Mods, OsuStars};
|
||||
|
||||
use self::{
|
||||
colours::ColourDifficultyPreprocessor,
|
||||
difficulty_object::{MonoIndex, ObjectLists, TaikoDifficultyObject},
|
||||
skills::{Peaks, PeaksDifficultyValues, PeaksRaw, Skill},
|
||||
taiko_object::IntoTaikoObjectIter,
|
||||
};
|
||||
|
||||
const SECTION_LEN: usize = 400;
|
||||
|
||||
const DIFFICULTY_MULTIPLIER: f64 = 1.35;
|
||||
|
||||
/// Difficulty calculator on osu!taiko maps.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use rosu_pp::{TaikoStars, Beatmap};
|
||||
///
|
||||
/// # /*
|
||||
/// let map: Beatmap = ...
|
||||
/// # */
|
||||
/// # let map = Beatmap::default();
|
||||
///
|
||||
/// let difficulty_attrs = TaikoStars::new(&map)
|
||||
/// .mods(8 + 64) // HDDT
|
||||
/// .calculate();
|
||||
///
|
||||
/// println!("Stars: {}", difficulty_attrs.stars);
|
||||
/// ```
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct TaikoStars<'map> {
|
||||
map: Cow<'map, Beatmap>,
|
||||
mods: u32,
|
||||
passed_objects: Option<usize>,
|
||||
clock_rate: Option<f64>,
|
||||
is_convert: bool,
|
||||
}
|
||||
|
||||
impl<'map> TaikoStars<'map> {
|
||||
/// Create a new difficulty calculator for osu!taiko maps.
|
||||
#[inline]
|
||||
pub fn new(map: &'map Beatmap) -> Self {
|
||||
let map = map.convert_mode(GameMode::Taiko);
|
||||
let is_convert = matches!(map, Cow::Owned(_));
|
||||
|
||||
Self {
|
||||
map,
|
||||
mods: 0,
|
||||
passed_objects: None,
|
||||
clock_rate: None,
|
||||
is_convert,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
}
|
||||
|
||||
/// Amount of passed objects for partial plays, e.g. a fail.
|
||||
///
|
||||
/// If you want to calculate the difficulty after every few objects, instead of
|
||||
/// using [`TaikoStars`] multiple times with different `passed_objects`, you should use
|
||||
/// [`TaikoGradualDifficultyAttributes`](crate::taiko::TaikoGradualDifficultyAttributes).
|
||||
#[inline]
|
||||
pub fn passed_objects(mut self, passed_objects: usize) -> Self {
|
||||
self.passed_objects = Some(passed_objects);
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
/// Adjust the clock rate used in the calculation.
|
||||
/// If none is specified, it will take the clock rate based on the mods
|
||||
/// i.e. 1.5 for DT, 0.75 for HT and 1.0 otherwise.
|
||||
#[inline]
|
||||
pub fn clock_rate(mut self, clock_rate: f64) -> Self {
|
||||
self.clock_rate = Some(clock_rate);
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
/// Specify whether the map is a convert i.e. an osu!standard map.
|
||||
#[inline]
|
||||
pub fn is_convert(mut self, is_convert: bool) -> Self {
|
||||
self.is_convert = is_convert;
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
/// Calculate all difficulty related values, including stars.
|
||||
#[inline]
|
||||
pub fn calculate(self) -> TaikoDifficultyAttributes {
|
||||
let clock_rate = self.clock_rate.unwrap_or_else(|| self.mods.clock_rate());
|
||||
|
||||
let BeatmapHitWindows { od: hit_window, .. } = self
|
||||
.map
|
||||
.attributes()
|
||||
.mods(self.mods)
|
||||
.clock_rate(clock_rate)
|
||||
.hit_windows();
|
||||
|
||||
let is_convert = self.is_convert || matches!(self.map, Cow::Owned(_));
|
||||
|
||||
let (peaks, max_combo) = calculate_skills(self);
|
||||
|
||||
let PeaksDifficultyValues {
|
||||
mut colour_rating,
|
||||
mut rhythm_rating,
|
||||
mut stamina_rating,
|
||||
mut combined_rating,
|
||||
} = peaks.difficulty_values();
|
||||
|
||||
colour_rating *= DIFFICULTY_MULTIPLIER;
|
||||
rhythm_rating *= DIFFICULTY_MULTIPLIER;
|
||||
stamina_rating *= DIFFICULTY_MULTIPLIER;
|
||||
combined_rating *= DIFFICULTY_MULTIPLIER;
|
||||
|
||||
let mut star_rating = rescale(combined_rating * 1.4);
|
||||
|
||||
// * TODO: This is temporary measure as we don't detect abuse of multiple-input
|
||||
// * playstyles of converts within the current system.
|
||||
if is_convert {
|
||||
star_rating *= 0.925;
|
||||
|
||||
// * For maps with low colour variance and high stamina requirement,
|
||||
// * multiple inputs are more likely to be abused.
|
||||
if colour_rating < 2.0 && stamina_rating > 8.0 {
|
||||
star_rating *= 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
TaikoDifficultyAttributes {
|
||||
stamina: stamina_rating,
|
||||
rhythm: rhythm_rating,
|
||||
colour: colour_rating,
|
||||
peak: combined_rating,
|
||||
hit_window,
|
||||
stars: star_rating,
|
||||
max_combo,
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate the skill strains.
|
||||
///
|
||||
/// Suitable to plot the difficulty of a map over time.
|
||||
#[inline]
|
||||
pub fn strains(self) -> TaikoStrains {
|
||||
let (peaks, _) = calculate_skills(self);
|
||||
|
||||
let PeaksRaw {
|
||||
colour,
|
||||
rhythm,
|
||||
stamina,
|
||||
} = peaks.into_raw();
|
||||
|
||||
TaikoStrains {
|
||||
section_len: SECTION_LEN as f64,
|
||||
color: colour,
|
||||
rhythm,
|
||||
stamina,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The result of calculating the strains on a osu!taiko map.
|
||||
/// Suitable to plot the difficulty of a map over time.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct TaikoStrains {
|
||||
/// Time in ms inbetween two strains.
|
||||
pub section_len: f64,
|
||||
/// Strain peaks of the color skill.
|
||||
pub color: Vec<f64>,
|
||||
/// Strain peaks of the rhythm skill.
|
||||
pub rhythm: Vec<f64>,
|
||||
/// Strain peaks of the stamina skill.
|
||||
pub stamina: Vec<f64>,
|
||||
}
|
||||
|
||||
impl TaikoStrains {
|
||||
/// Returns the number of strain peaks per skill.
|
||||
#[inline]
|
||||
#[allow(clippy::len_without_is_empty)]
|
||||
pub fn len(&self) -> usize {
|
||||
self.color.len()
|
||||
}
|
||||
}
|
||||
|
||||
fn calculate_skills(params: TaikoStars<'_>) -> (Peaks, usize) {
|
||||
let TaikoStars {
|
||||
map,
|
||||
mods,
|
||||
passed_objects,
|
||||
clock_rate,
|
||||
is_convert: _,
|
||||
} = params;
|
||||
|
||||
let mut take = passed_objects.unwrap_or(map.hit_objects.len());
|
||||
let clock_rate = clock_rate.unwrap_or_else(|| mods.clock_rate());
|
||||
|
||||
let mut peaks = Peaks::new();
|
||||
let mut max_combo = 0;
|
||||
|
||||
let mut diff_objects = map
|
||||
.taiko_objects()
|
||||
.take_while(|(h, _)| {
|
||||
if h.is_hit {
|
||||
if take == 0 {
|
||||
return false;
|
||||
}
|
||||
|
||||
max_combo += 1;
|
||||
take -= 1;
|
||||
}
|
||||
|
||||
true
|
||||
})
|
||||
.skip(2)
|
||||
.zip(map.hit_objects.iter().skip(1))
|
||||
.zip(map.hit_objects.iter())
|
||||
.enumerate()
|
||||
.fold(
|
||||
ObjectLists::default(),
|
||||
|mut lists, (idx, (((base, base_start_time), last), last_last))| {
|
||||
let diff_obj = TaikoDifficultyObject::new(
|
||||
base,
|
||||
base_start_time,
|
||||
last.start_time,
|
||||
last_last.start_time,
|
||||
clock_rate,
|
||||
&lists,
|
||||
idx,
|
||||
);
|
||||
|
||||
match &diff_obj.mono_idx {
|
||||
MonoIndex::Centre(_) => lists.centres.push(idx),
|
||||
MonoIndex::Rim(_) => lists.rims.push(idx),
|
||||
MonoIndex::None => {}
|
||||
}
|
||||
|
||||
if diff_obj.note_idx.is_some() {
|
||||
lists.notes.push(idx);
|
||||
}
|
||||
|
||||
lists.all.push(Rc::new(RefCell::new(diff_obj)));
|
||||
|
||||
lists
|
||||
},
|
||||
);
|
||||
|
||||
ColourDifficultyPreprocessor::process_and_assign(&mut diff_objects);
|
||||
|
||||
for hit_object in diff_objects.all.iter() {
|
||||
peaks.process(&hit_object.borrow(), &diff_objects);
|
||||
}
|
||||
|
||||
(peaks, max_combo)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn rescale(stars: f64) -> f64 {
|
||||
if stars < 0.0 {
|
||||
stars
|
||||
} else {
|
||||
10.43 * (stars / 8.0 + 1.0).ln()
|
||||
}
|
||||
}
|
||||
|
||||
/// The result of a difficulty calculation on an osu!taiko map.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct TaikoDifficultyAttributes {
|
||||
/// The difficulty corresponding to the stamina skill.
|
||||
pub stamina: f64,
|
||||
/// The difficulty corresponding to the rhythm skill.
|
||||
pub rhythm: f64,
|
||||
/// The difficulty corresponding to the colour skill.
|
||||
pub colour: f64,
|
||||
/// The difficulty corresponding to the hardest parts of the map.
|
||||
pub peak: f64,
|
||||
/// The perceived hit window for an n300 inclusive of rate-adjusting mods (DT/HT/etc)
|
||||
pub hit_window: f64,
|
||||
/// The final star rating.
|
||||
pub stars: f64,
|
||||
/// The maximum combo.
|
||||
pub max_combo: usize,
|
||||
}
|
||||
|
||||
impl TaikoDifficultyAttributes {
|
||||
/// Return the maximum combo.
|
||||
#[inline]
|
||||
pub fn max_combo(&self) -> usize {
|
||||
self.max_combo
|
||||
}
|
||||
}
|
||||
|
||||
/// The result of a performance calculation on an osu!taiko map.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct TaikoPerformanceAttributes {
|
||||
/// The difficulty attributes that were used for the performance calculation
|
||||
pub difficulty: TaikoDifficultyAttributes,
|
||||
/// The final performance points.
|
||||
pub pp: f64,
|
||||
/// The accuracy portion of the final pp.
|
||||
pub pp_acc: f64,
|
||||
/// The strain portion of the final pp.
|
||||
pub pp_difficulty: f64,
|
||||
/// Scaled miss count based on total hits.
|
||||
pub effective_miss_count: f64,
|
||||
}
|
||||
|
||||
impl TaikoPerformanceAttributes {
|
||||
/// Return the star value.
|
||||
#[inline]
|
||||
pub fn stars(&self) -> f64 {
|
||||
self.difficulty.stars
|
||||
}
|
||||
|
||||
/// Return the performance point value.
|
||||
#[inline]
|
||||
pub fn pp(&self) -> f64 {
|
||||
self.pp
|
||||
}
|
||||
|
||||
/// Return the maximum combo of the map.
|
||||
#[inline]
|
||||
pub fn max_combo(&self) -> usize {
|
||||
self.difficulty.max_combo
|
||||
}
|
||||
}
|
||||
|
||||
impl From<TaikoPerformanceAttributes> for TaikoDifficultyAttributes {
|
||||
#[inline]
|
||||
fn from(attributes: TaikoPerformanceAttributes) -> Self {
|
||||
attributes.difficulty
|
||||
}
|
||||
}
|
||||
|
||||
impl<'map> From<OsuStars<'map>> for TaikoStars<'map> {
|
||||
#[inline]
|
||||
fn from(osu: OsuStars<'map>) -> Self {
|
||||
let OsuStars {
|
||||
map,
|
||||
mods,
|
||||
passed_objects,
|
||||
clock_rate,
|
||||
} = osu;
|
||||
|
||||
Self {
|
||||
map: map.convert_mode(GameMode::Taiko),
|
||||
mods,
|
||||
passed_objects,
|
||||
clock_rate,
|
||||
is_convert: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
+563
@@ -0,0 +1,563 @@
|
||||
use std::borrow::Cow;
|
||||
|
||||
use super::{TaikoDifficultyAttributes, TaikoPerformanceAttributes, TaikoScoreState, TaikoStars};
|
||||
use crate::{
|
||||
Beatmap, DifficultyAttributes, GameMode, HitResultPriority, Mods, OsuPP, PerformanceAttributes,
|
||||
};
|
||||
|
||||
/// Performance calculator on osu!taiko maps.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use rosu_pp::{TaikoPP, Beatmap};
|
||||
///
|
||||
/// # /*
|
||||
/// let map: Beatmap = ...
|
||||
/// # */
|
||||
/// # let map = Beatmap::default();
|
||||
///
|
||||
/// let pp_result = TaikoPP::new(&map)
|
||||
/// .mods(8 + 64) // HDDT
|
||||
/// .combo(1234)
|
||||
/// .accuracy(98.5)
|
||||
/// .n_misses(1)
|
||||
/// .calculate();
|
||||
///
|
||||
/// println!("PP: {} | Stars: {}", pp_result.pp(), pp_result.stars());
|
||||
///
|
||||
/// let next_result = TaikoPP::new(&map)
|
||||
/// .attributes(pp_result) // reusing previous results for performance
|
||||
/// .mods(8 + 64) // has to be the same to reuse attributes
|
||||
/// .accuracy(99.5)
|
||||
/// .calculate();
|
||||
///
|
||||
/// println!("PP: {} | Stars: {}", next_result.pp(), next_result.stars());
|
||||
/// ```
|
||||
#[derive(Clone, Debug)]
|
||||
#[allow(clippy::upper_case_acronyms)]
|
||||
pub struct TaikoPP<'map> {
|
||||
pub(crate) map: Cow<'map, Beatmap>,
|
||||
attributes: Option<TaikoDifficultyAttributes>,
|
||||
mods: u32,
|
||||
combo: Option<usize>,
|
||||
acc: Option<f64>,
|
||||
passed_objects: Option<usize>,
|
||||
clock_rate: Option<f64>,
|
||||
hitresult_priority: Option<HitResultPriority>,
|
||||
|
||||
pub(crate) n300: Option<usize>,
|
||||
pub(crate) n100: Option<usize>,
|
||||
pub(crate) n_misses: Option<usize>,
|
||||
}
|
||||
|
||||
impl<'map> TaikoPP<'map> {
|
||||
/// Create a new performance calculator for osu!taiko maps.
|
||||
#[inline]
|
||||
pub fn new(map: &'map Beatmap) -> Self {
|
||||
Self {
|
||||
map: map.convert_mode(GameMode::Taiko),
|
||||
attributes: None,
|
||||
mods: 0,
|
||||
combo: None,
|
||||
acc: None,
|
||||
n_misses: None,
|
||||
passed_objects: None,
|
||||
clock_rate: None,
|
||||
n300: None,
|
||||
n100: None,
|
||||
hitresult_priority: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Provide the result of a previous difficulty or performance calculation.
|
||||
/// If you already calculated the attributes for the current map-mod combination,
|
||||
/// be sure to put them in here so that they don't have to be recalculated.
|
||||
#[inline]
|
||||
pub fn attributes(mut self, attrs: impl TaikoAttributeProvider) -> Self {
|
||||
if let Some(attrs) = attrs.attributes() {
|
||||
self.attributes = Some(attrs);
|
||||
}
|
||||
|
||||
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 = Some(combo);
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
/// Specify how hitresults should be generated.
|
||||
///
|
||||
/// Defauls to [`HitResultPriority::BestCase`].
|
||||
#[inline]
|
||||
pub fn hitresult_priority(mut self, priority: HitResultPriority) -> Self {
|
||||
self.hitresult_priority = Some(priority);
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
/// Specify the amount of 300s of a play.
|
||||
#[inline]
|
||||
pub fn n300(mut self, n300: usize) -> Self {
|
||||
self.n300 = Some(n300);
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
/// Specify the amount of 100s of a play.
|
||||
#[inline]
|
||||
pub fn n100(mut self, n100: usize) -> Self {
|
||||
self.n100 = Some(n100);
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
/// Specify the amount of misses of the play.
|
||||
#[inline]
|
||||
pub fn n_misses(mut self, n_misses: usize) -> Self {
|
||||
self.n_misses = Some(n_misses.min(self.map.n_circles as usize));
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
/// Specify the accuracy of a play between `0.0` and `100.0`.
|
||||
/// This will be used to generate matching hitresults.
|
||||
#[inline]
|
||||
pub fn accuracy(mut self, acc: f64) -> Self {
|
||||
self.acc = Some(acc / 100.0);
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
/// Amount of passed objects for partial plays, e.g. a fail.
|
||||
///
|
||||
/// If you want to calculate the performance after every few objects, instead of
|
||||
/// using [`TaikoPP`] multiple times with different `passed_objects`, you should use
|
||||
/// [`TaikoGradualPerformanceAttributes`](crate::taiko::TaikoGradualPerformanceAttributes).
|
||||
#[inline]
|
||||
pub fn passed_objects(mut self, passed_objects: usize) -> Self {
|
||||
self.passed_objects = Some(passed_objects);
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
/// Adjust the clock rate used in the calculation.
|
||||
/// If none is specified, it will take the clock rate based on the mods
|
||||
/// i.e. 1.5 for DT, 0.75 for HT and 1.0 otherwise.
|
||||
#[inline]
|
||||
pub fn clock_rate(mut self, clock_rate: f64) -> Self {
|
||||
self.clock_rate = Some(clock_rate);
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
/// Provide parameters through a [`TaikoScoreState`].
|
||||
#[inline]
|
||||
pub fn state(mut self, state: TaikoScoreState) -> Self {
|
||||
let TaikoScoreState {
|
||||
max_combo,
|
||||
n300,
|
||||
n100,
|
||||
n_misses,
|
||||
} = state;
|
||||
|
||||
self.combo = Some(max_combo);
|
||||
self.n300 = Some(n300);
|
||||
self.n100 = Some(n100);
|
||||
self.n_misses = Some(n_misses);
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
/// Calculate all performance related values, including pp and stars.
|
||||
pub fn calculate(mut self) -> TaikoPerformanceAttributes {
|
||||
let attrs = self.attributes.take().unwrap_or_else(|| {
|
||||
let mut calculator = TaikoStars::new(self.map.as_ref())
|
||||
.mods(self.mods)
|
||||
.is_convert(matches!(self.map, Cow::Owned(_)));
|
||||
|
||||
if let Some(passed_objects) = self.passed_objects {
|
||||
calculator = calculator.passed_objects(passed_objects);
|
||||
}
|
||||
|
||||
if let Some(clock_rate) = self.clock_rate {
|
||||
calculator = calculator.clock_rate(clock_rate);
|
||||
}
|
||||
|
||||
calculator.calculate()
|
||||
});
|
||||
|
||||
let inner = TaikoPpInner {
|
||||
mods: self.mods,
|
||||
state: self.generate_hitresults(attrs.max_combo),
|
||||
attrs,
|
||||
};
|
||||
|
||||
inner.calculate()
|
||||
}
|
||||
|
||||
fn generate_hitresults(&self, max_combo: usize) -> TaikoScoreState {
|
||||
let total_result_count = if let Some(passed_objects) = self.passed_objects {
|
||||
max_combo.min(passed_objects)
|
||||
} else {
|
||||
max_combo
|
||||
};
|
||||
|
||||
let priority = self.hitresult_priority.unwrap_or_default();
|
||||
|
||||
let mut n300 = self.n300.unwrap_or(0);
|
||||
let mut n100 = self.n100.unwrap_or(0);
|
||||
let n_misses = self.n_misses.unwrap_or(0);
|
||||
|
||||
if let Some(acc) = self.acc {
|
||||
match (self.n300, self.n100) {
|
||||
(Some(_), Some(_)) => {
|
||||
let remaining = total_result_count.saturating_sub(n300 + n100 + n_misses);
|
||||
|
||||
match priority {
|
||||
HitResultPriority::BestCase => n300 += remaining,
|
||||
HitResultPriority::WorstCase => n100 += remaining,
|
||||
}
|
||||
}
|
||||
(Some(_), None) => n100 += total_result_count.saturating_sub(n300 + n_misses),
|
||||
(None, Some(_)) => n300 += total_result_count.saturating_sub(n100 + n_misses),
|
||||
(None, None) => {
|
||||
let target_total = (acc * (total_result_count * 2) as f64).round() as usize;
|
||||
n300 = target_total - (total_result_count.saturating_sub(n_misses));
|
||||
n100 = total_result_count.saturating_sub(n300 + n_misses);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let remaining = total_result_count.saturating_sub(n300 + n100 + n_misses);
|
||||
|
||||
match priority {
|
||||
HitResultPriority::BestCase => match (self.n300, self.n100) {
|
||||
(Some(_), None) => n100 = remaining,
|
||||
(Some(_), Some(_)) => n300 += remaining,
|
||||
(None, _) => n300 = remaining,
|
||||
},
|
||||
HitResultPriority::WorstCase => match (self.n300, self.n100) {
|
||||
(None, Some(_)) => n300 = remaining,
|
||||
(Some(_), Some(_)) => n100 += remaining,
|
||||
(_, None) => n100 = remaining,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
let max_combo = self.combo.map_or(max_combo, |combo| combo.min(max_combo));
|
||||
|
||||
TaikoScoreState {
|
||||
max_combo,
|
||||
n300,
|
||||
n100,
|
||||
n_misses,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct TaikoPpInner {
|
||||
attrs: TaikoDifficultyAttributes,
|
||||
mods: u32,
|
||||
state: TaikoScoreState,
|
||||
}
|
||||
|
||||
impl TaikoPpInner {
|
||||
fn calculate(self) -> TaikoPerformanceAttributes {
|
||||
// * The effectiveMissCount is calculated by gaining a ratio for totalSuccessfulHits
|
||||
// * and increasing the miss penalty for shorter object counts lower than 1000.
|
||||
let total_successful_hits = self.total_successful_hits();
|
||||
|
||||
let effective_miss_count = if total_successful_hits > 0 {
|
||||
(1000.0 / (total_successful_hits as f64)).max(1.0) * self.state.n_misses as f64
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
let mut multiplier = 1.13;
|
||||
|
||||
if self.mods.hd() {
|
||||
multiplier *= 1.075;
|
||||
}
|
||||
|
||||
if self.mods.ez() {
|
||||
multiplier *= 0.975;
|
||||
}
|
||||
|
||||
let diff_value = self.compute_difficulty_value(effective_miss_count);
|
||||
let acc_value = self.compute_accuracy_value();
|
||||
|
||||
let pp = (diff_value.powf(1.1) + acc_value.powf(1.1)).powf(1.0 / 1.1) * multiplier;
|
||||
|
||||
TaikoPerformanceAttributes {
|
||||
difficulty: self.attrs,
|
||||
pp,
|
||||
pp_acc: acc_value,
|
||||
pp_difficulty: diff_value,
|
||||
effective_miss_count,
|
||||
}
|
||||
}
|
||||
|
||||
fn compute_difficulty_value(&self, effective_miss_count: f64) -> f64 {
|
||||
let attrs = &self.attrs;
|
||||
let exp_base = 5.0 * (attrs.stars / 0.115).max(1.0) - 4.0;
|
||||
let mut diff_value = exp_base.powf(2.25) / 1150.0;
|
||||
|
||||
let len_bonus = 1.0 + 0.1 * (attrs.max_combo as f64 / 1500.0).min(1.0);
|
||||
diff_value *= len_bonus;
|
||||
|
||||
diff_value *= 0.986_f64.powf(effective_miss_count);
|
||||
|
||||
if self.mods.ez() {
|
||||
diff_value *= 0.985;
|
||||
}
|
||||
|
||||
if self.mods.hd() {
|
||||
diff_value *= 1.025;
|
||||
}
|
||||
|
||||
if self.mods.hr() {
|
||||
diff_value *= 1.05;
|
||||
}
|
||||
|
||||
if self.mods.fl() {
|
||||
diff_value *= 1.05 * len_bonus;
|
||||
}
|
||||
|
||||
let acc = self.custom_accuracy();
|
||||
|
||||
diff_value * acc * acc
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn compute_accuracy_value(&self) -> f64 {
|
||||
if self.attrs.hit_window <= 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
let mut acc_value = (60.0 / self.attrs.hit_window).powf(1.1)
|
||||
* self.custom_accuracy().powi(8)
|
||||
* self.attrs.stars.powf(0.4)
|
||||
* 27.0;
|
||||
|
||||
let len_bonus = (self.total_hits() / 1500.0).powf(0.3).min(1.15);
|
||||
acc_value *= len_bonus;
|
||||
|
||||
// * Slight HDFL Bonus for accuracy. A clamp is used to prevent against negative values
|
||||
if self.mods.hd() && self.mods.fl() {
|
||||
acc_value *= (1.075 * len_bonus).max(1.05);
|
||||
}
|
||||
|
||||
acc_value
|
||||
}
|
||||
|
||||
fn total_hits(&self) -> f64 {
|
||||
self.state.total_hits() as f64
|
||||
}
|
||||
|
||||
fn total_successful_hits(&self) -> usize {
|
||||
self.state.n300 + self.state.n100
|
||||
}
|
||||
|
||||
fn custom_accuracy(&self) -> f64 {
|
||||
let total_hits = self.state.total_hits();
|
||||
|
||||
if total_hits == 0 {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
let numerator = self.state.n300 * 300 + self.state.n100 * 150;
|
||||
let denominator = total_hits * 300;
|
||||
|
||||
numerator as f64 / denominator as f64
|
||||
}
|
||||
}
|
||||
|
||||
impl<'map> From<OsuPP<'map>> for TaikoPP<'map> {
|
||||
#[inline]
|
||||
fn from(osu: OsuPP<'map>) -> Self {
|
||||
let OsuPP {
|
||||
map,
|
||||
attributes: _,
|
||||
mods,
|
||||
acc,
|
||||
combo,
|
||||
n300,
|
||||
n100,
|
||||
n50: _,
|
||||
n_misses,
|
||||
passed_objects,
|
||||
clock_rate,
|
||||
hitresult_priority,
|
||||
} = osu;
|
||||
|
||||
Self {
|
||||
map: map.convert_mode(GameMode::Taiko),
|
||||
attributes: None,
|
||||
mods,
|
||||
combo,
|
||||
acc,
|
||||
passed_objects,
|
||||
clock_rate,
|
||||
hitresult_priority,
|
||||
n300,
|
||||
n100,
|
||||
n_misses,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Abstract type to provide flexibility when passing difficulty attributes to a performance calculation.
|
||||
pub trait TaikoAttributeProvider {
|
||||
/// Provide the actual difficulty attributes.
|
||||
fn attributes(self) -> Option<TaikoDifficultyAttributes>;
|
||||
}
|
||||
|
||||
impl TaikoAttributeProvider for TaikoDifficultyAttributes {
|
||||
#[inline]
|
||||
fn attributes(self) -> Option<TaikoDifficultyAttributes> {
|
||||
Some(self)
|
||||
}
|
||||
}
|
||||
|
||||
impl TaikoAttributeProvider for TaikoPerformanceAttributes {
|
||||
#[inline]
|
||||
fn attributes(self) -> Option<TaikoDifficultyAttributes> {
|
||||
Some(self.difficulty)
|
||||
}
|
||||
}
|
||||
|
||||
impl TaikoAttributeProvider for DifficultyAttributes {
|
||||
#[inline]
|
||||
fn attributes(self) -> Option<TaikoDifficultyAttributes> {
|
||||
#[allow(irrefutable_let_patterns)]
|
||||
if let Self::Taiko(attributes) = self {
|
||||
Some(attributes)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TaikoAttributeProvider for PerformanceAttributes {
|
||||
#[inline]
|
||||
fn attributes(self) -> Option<TaikoDifficultyAttributes> {
|
||||
#[allow(irrefutable_let_patterns)]
|
||||
if let Self::Taiko(attributes) = self {
|
||||
Some(attributes.difficulty)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(any(feature = "async_tokio", feature = "async_std")))]
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::*;
|
||||
use crate::Beatmap;
|
||||
|
||||
fn test_data() -> (Beatmap, TaikoDifficultyAttributes) {
|
||||
let path = "./maps/1028484.osu";
|
||||
let map = Beatmap::from_path(path).unwrap();
|
||||
|
||||
let attrs = TaikoDifficultyAttributes {
|
||||
stamina: 1.4528845068865617,
|
||||
rhythm: 0.20130047251681948,
|
||||
colour: 1.0487315549761433,
|
||||
peak: 1.8881824429738323,
|
||||
hit_window: 35.0,
|
||||
stars: 2.9778030386845606,
|
||||
max_combo: 289,
|
||||
};
|
||||
|
||||
(map, attrs)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hitresults_n300_n_misses_best() {
|
||||
let (map, attrs) = test_data();
|
||||
let max_combo = attrs.max_combo();
|
||||
|
||||
let state = TaikoPP::new(&map)
|
||||
.attributes(attrs)
|
||||
.combo(100)
|
||||
.n300(150)
|
||||
.n_misses(2)
|
||||
.hitresult_priority(HitResultPriority::BestCase)
|
||||
.generate_hitresults(max_combo);
|
||||
|
||||
let expected = TaikoScoreState {
|
||||
max_combo: 100,
|
||||
n300: 150,
|
||||
n100: 137,
|
||||
n_misses: 2,
|
||||
};
|
||||
|
||||
assert_eq!(state, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hitresults_n_misses_best() {
|
||||
let (map, attrs) = test_data();
|
||||
let max_combo = attrs.max_combo();
|
||||
|
||||
let state = TaikoPP::new(&map)
|
||||
.attributes(attrs)
|
||||
.combo(100)
|
||||
.n_misses(2)
|
||||
.hitresult_priority(HitResultPriority::BestCase)
|
||||
.generate_hitresults(max_combo);
|
||||
|
||||
let expected = TaikoScoreState {
|
||||
max_combo: 100,
|
||||
n300: 287,
|
||||
n100: 0,
|
||||
n_misses: 2,
|
||||
};
|
||||
|
||||
assert_eq!(state, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hitresults_acc_n_misses_worst() {
|
||||
let (map, attrs) = test_data();
|
||||
let max_combo = attrs.max_combo();
|
||||
|
||||
let state = TaikoPP::new(&map)
|
||||
.attributes(attrs)
|
||||
.combo(100)
|
||||
.accuracy(97.2)
|
||||
.n_misses(2)
|
||||
.hitresult_priority(HitResultPriority::WorstCase)
|
||||
.generate_hitresults(max_combo);
|
||||
|
||||
let expected = TaikoScoreState {
|
||||
max_combo: 100,
|
||||
n300: 275,
|
||||
n100: 12,
|
||||
n_misses: 2,
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
state,
|
||||
expected,
|
||||
"{}% vs {}%",
|
||||
state.accuracy(),
|
||||
expected.accuracy()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
use crate::parse::HitSound;
|
||||
|
||||
pub(crate) trait Rim {
|
||||
fn is_rim(&self) -> bool;
|
||||
}
|
||||
|
||||
impl Rim for u8 {
|
||||
#[inline]
|
||||
fn is_rim(&self) -> bool {
|
||||
self.clap() || self.whistle()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
use std::{
|
||||
cell::RefCell,
|
||||
rc::{Rc, Weak},
|
||||
};
|
||||
|
||||
use crate::taiko::{
|
||||
colours::{AlternatingMonoPattern, MonoStreak, RepeatingHitPatterns},
|
||||
difficulty_object::{ObjectLists, TaikoDifficultyObject},
|
||||
};
|
||||
|
||||
use super::{Skill, StrainDecaySkill, StrainSkill};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct Colour {
|
||||
curr_strain: f64,
|
||||
curr_section_peak: f64,
|
||||
curr_section_end: f64,
|
||||
pub(crate) strain_peaks: Vec<f64>,
|
||||
}
|
||||
|
||||
impl Colour {
|
||||
pub(crate) fn new() -> Self {
|
||||
Self {
|
||||
curr_strain: 0.0,
|
||||
curr_section_peak: 0.0,
|
||||
curr_section_end: 0.0,
|
||||
strain_peaks: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Skill for Colour {
|
||||
#[inline]
|
||||
fn process(&mut self, curr: &TaikoDifficultyObject, hit_objects: &ObjectLists) {
|
||||
<Self as StrainSkill>::process(self, curr, hit_objects)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn difficulty_value(self) -> f64 {
|
||||
<Self as StrainSkill>::difficulty_value(self)
|
||||
}
|
||||
}
|
||||
|
||||
impl StrainSkill for Colour {
|
||||
#[inline]
|
||||
fn strain_peaks_mut(&mut self) -> &mut Vec<f64> {
|
||||
&mut self.strain_peaks
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn curr_section_peak(&mut self) -> &mut f64 {
|
||||
&mut self.curr_section_peak
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn curr_section_end(&mut self) -> &mut f64 {
|
||||
&mut self.curr_section_end
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn strain_value_at(&mut self, curr: &TaikoDifficultyObject, hit_objects: &ObjectLists) -> f64 {
|
||||
<Self as StrainDecaySkill>::strain_value_at(self, curr, hit_objects)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn calculate_initial_strain(&self, time: f64, curr: &TaikoDifficultyObject) -> f64 {
|
||||
<Self as StrainDecaySkill>::calculate_initial_strain(self, time, curr)
|
||||
}
|
||||
}
|
||||
|
||||
impl StrainDecaySkill for Colour {
|
||||
const SKILL_MULTIPLIER: f64 = 0.12;
|
||||
const STRAIN_DECAY_BASE: f64 = 0.8;
|
||||
|
||||
#[inline]
|
||||
fn curr_strain(&self) -> f64 {
|
||||
self.curr_strain
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn curr_strain_mut(&mut self) -> &mut f64 {
|
||||
&mut self.curr_strain
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn strain_value_of(&mut self, curr: &TaikoDifficultyObject, _: &ObjectLists) -> f64 {
|
||||
ColourEvaluator::evaluate_diff_of(curr)
|
||||
}
|
||||
}
|
||||
|
||||
struct ColourEvaluator;
|
||||
|
||||
impl ColourEvaluator {
|
||||
fn sigmoid(val: f64, center: f64, width: f64, middle: f64, height: f64) -> f64 {
|
||||
let sigmoid = (std::f64::consts::E * -(val - center) / width).tanh();
|
||||
|
||||
sigmoid * (height / 2.0) + middle
|
||||
}
|
||||
|
||||
fn evaluate_diff_of_mono_streak(mono_streak: Rc<RefCell<MonoStreak>>) -> f64 {
|
||||
let mono_streak = mono_streak.borrow();
|
||||
|
||||
let parent_eval = mono_streak
|
||||
.parent
|
||||
.as_ref()
|
||||
.and_then(Weak::upgrade)
|
||||
.map_or(1.0, Self::evaluate_diff_of_alternating_mono_pattern);
|
||||
|
||||
Self::sigmoid(mono_streak.idx as f64, 2.0, 2.0, 0.5, 1.0) * parent_eval * 0.5
|
||||
}
|
||||
|
||||
fn evaluate_diff_of_alternating_mono_pattern(
|
||||
alternating_mono_pattern: Rc<RefCell<AlternatingMonoPattern>>,
|
||||
) -> f64 {
|
||||
let alternating_mono_pattern = alternating_mono_pattern.borrow();
|
||||
|
||||
let parent_eval = alternating_mono_pattern
|
||||
.parent
|
||||
.as_ref()
|
||||
.and_then(Weak::upgrade)
|
||||
.map_or(1.0, Self::evaluate_diff_of_repeating_hit_patterns);
|
||||
|
||||
Self::sigmoid(alternating_mono_pattern.idx as f64, 2.0, 2.0, 0.5, 1.0) * parent_eval
|
||||
}
|
||||
|
||||
fn evaluate_diff_of_repeating_hit_patterns(
|
||||
repeating_hit_patterns: Rc<RefCell<RepeatingHitPatterns>>,
|
||||
) -> f64 {
|
||||
let repetition_interval = repeating_hit_patterns.borrow().repetition_interval as f64;
|
||||
|
||||
2.0 * (1.0 - Self::sigmoid(repetition_interval, 2.0, 2.0, 0.5, 1.0))
|
||||
}
|
||||
|
||||
fn evaluate_diff_of(hit_object: &TaikoDifficultyObject) -> f64 {
|
||||
let colour = &hit_object.colour;
|
||||
let mut difficulty = 0.0;
|
||||
|
||||
// * Difficulty for MonoStreak
|
||||
if let Some(mono_streak) = colour.mono_streak.as_ref().and_then(Weak::upgrade) {
|
||||
difficulty += Self::evaluate_diff_of_mono_streak(mono_streak);
|
||||
}
|
||||
|
||||
// * Difficulty for AlternatingMonoPattern
|
||||
if let Some(alternating_mono_pattern) = colour
|
||||
.alternating_mono_pattern
|
||||
.as_ref()
|
||||
.and_then(Weak::upgrade)
|
||||
{
|
||||
difficulty += Self::evaluate_diff_of_alternating_mono_pattern(alternating_mono_pattern);
|
||||
}
|
||||
|
||||
// * Difficulty for RepeatingHitPattern
|
||||
if let Some(repeating_hit_patterns) = colour.repeating_hit_patterns.as_ref().map(Rc::clone)
|
||||
{
|
||||
difficulty += Self::evaluate_diff_of_repeating_hit_patterns(repeating_hit_patterns);
|
||||
}
|
||||
|
||||
difficulty
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
mod colour;
|
||||
mod peaks;
|
||||
mod rhythm;
|
||||
mod stamina;
|
||||
mod traits;
|
||||
|
||||
pub(crate) use self::{
|
||||
peaks::{Peaks, PeaksDifficultyValues, PeaksRaw},
|
||||
traits::{Skill, StrainDecaySkill, StrainSkill},
|
||||
};
|
||||
@@ -0,0 +1,122 @@
|
||||
use std::cmp::Ordering;
|
||||
|
||||
use crate::taiko::difficulty_object::{ObjectLists, TaikoDifficultyObject};
|
||||
|
||||
use super::{colour::Colour, rhythm::Rhythm, stamina::Stamina, Skill, StrainSkill};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct Peaks {
|
||||
colour: Colour,
|
||||
rhythm: Rhythm,
|
||||
stamina: Stamina,
|
||||
}
|
||||
|
||||
impl Peaks {
|
||||
const RHYTHM_SKILL_MULTIPLIER: f64 = 0.2 * Self::FINAL_MULTIPLIER;
|
||||
const COLOUR_SKILL_MULTIPLIER: f64 = 0.375 * Self::FINAL_MULTIPLIER;
|
||||
const STAMINA_SKILL_MULTIPLIER: f64 = 0.375 * Self::FINAL_MULTIPLIER;
|
||||
|
||||
const FINAL_MULTIPLIER: f64 = 0.0625;
|
||||
|
||||
pub(crate) fn new() -> Self {
|
||||
Self {
|
||||
colour: Colour::new(),
|
||||
rhythm: Rhythm::new(),
|
||||
stamina: Stamina::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn difficulty_values(self) -> PeaksDifficultyValues {
|
||||
let colour_rating = <Colour as StrainSkill>::difficulty_value(self.colour.clone())
|
||||
* Self::COLOUR_SKILL_MULTIPLIER;
|
||||
let rhythm_rating = <Rhythm as StrainSkill>::difficulty_value(self.rhythm.clone())
|
||||
* Self::RHYTHM_SKILL_MULTIPLIER;
|
||||
let stamina_rating = <Stamina as StrainSkill>::difficulty_value(self.stamina.clone())
|
||||
* Self::STAMINA_SKILL_MULTIPLIER;
|
||||
|
||||
PeaksDifficultyValues {
|
||||
colour_rating,
|
||||
rhythm_rating,
|
||||
stamina_rating,
|
||||
combined_rating: self.difficulty_value(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn into_raw(self) -> PeaksRaw {
|
||||
PeaksRaw {
|
||||
colour: self.colour.strain_peaks,
|
||||
rhythm: self.rhythm.strain_peaks,
|
||||
stamina: self.stamina.strain_peaks,
|
||||
}
|
||||
}
|
||||
|
||||
fn norm(p: f64, values: impl IntoIterator<Item = f64>) -> f64 {
|
||||
values
|
||||
.into_iter()
|
||||
.fold(0.0, |sum, x| sum + x.powf(p))
|
||||
.powf(p.recip())
|
||||
}
|
||||
}
|
||||
|
||||
impl Skill for Peaks {
|
||||
#[inline]
|
||||
fn process(&mut self, curr: &TaikoDifficultyObject, hit_objects: &ObjectLists) {
|
||||
<Colour as Skill>::process(&mut self.colour, curr, hit_objects);
|
||||
<Rhythm as Skill>::process(&mut self.rhythm, curr, hit_objects);
|
||||
<Stamina as Skill>::process(&mut self.stamina, curr, hit_objects);
|
||||
}
|
||||
|
||||
fn difficulty_value(self) -> f64 {
|
||||
let mut peaks = Vec::new();
|
||||
|
||||
let colour_peaks = self.colour.get_curr_strain_peaks();
|
||||
let rhythm_peaks = self.rhythm.get_curr_strain_peaks();
|
||||
let stamina_peaks = self.stamina.get_curr_strain_peaks();
|
||||
|
||||
let zip = colour_peaks
|
||||
.into_iter()
|
||||
.zip(rhythm_peaks)
|
||||
.zip(stamina_peaks);
|
||||
|
||||
for ((mut colour_peak, mut rhythm_peak), mut stamina_peak) in zip {
|
||||
colour_peak *= Self::COLOUR_SKILL_MULTIPLIER;
|
||||
rhythm_peak *= Self::RHYTHM_SKILL_MULTIPLIER;
|
||||
stamina_peak *= Self::STAMINA_SKILL_MULTIPLIER;
|
||||
|
||||
let mut peak = Self::norm(1.5, [colour_peak, stamina_peak]);
|
||||
peak = Self::norm(2.0, [peak, rhythm_peak]);
|
||||
|
||||
// * Sections with 0 strain are excluded to avoid worst-case
|
||||
// * time complexity of the following sort (e.g. /b/2351871).
|
||||
// * These sections will not contribute to the difficulty.
|
||||
if peak > 0.0 {
|
||||
peaks.push(peak);
|
||||
}
|
||||
}
|
||||
|
||||
let mut difficulty = 0.0;
|
||||
let mut weight = 1.0;
|
||||
|
||||
peaks.sort_unstable_by(|a, b| b.partial_cmp(a).unwrap_or(Ordering::Equal));
|
||||
|
||||
for strain in peaks {
|
||||
difficulty += strain * weight;
|
||||
weight *= 0.9;
|
||||
}
|
||||
|
||||
difficulty
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct PeaksDifficultyValues {
|
||||
pub(crate) colour_rating: f64,
|
||||
pub(crate) rhythm_rating: f64,
|
||||
pub(crate) stamina_rating: f64,
|
||||
pub(crate) combined_rating: f64,
|
||||
}
|
||||
|
||||
pub(crate) struct PeaksRaw {
|
||||
pub(crate) colour: Vec<f64>,
|
||||
pub(crate) rhythm: Vec<f64>,
|
||||
pub(crate) stamina: Vec<f64>,
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
use crate::{
|
||||
taiko::difficulty_object::{HitObjectRhythm, ObjectLists, TaikoDifficultyObject},
|
||||
util::LimitedQueue,
|
||||
};
|
||||
|
||||
use super::{Skill, StrainDecaySkill, StrainSkill};
|
||||
|
||||
const HISTORY_MAX_LEN: usize = 8;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct Rhythm {
|
||||
// Equal to osu's CurrentStrain from abstract class StrainDecaySkill
|
||||
curr_decay_strain: f64,
|
||||
// Equal to osu's currentStrain from class Rhythm
|
||||
curr_strain: f64,
|
||||
notes_since_rhythm_change: usize,
|
||||
history: LimitedQueue<HistoryElement, HISTORY_MAX_LEN>,
|
||||
curr_section_peak: f64,
|
||||
curr_section_end: f64,
|
||||
pub(crate) strain_peaks: Vec<f64>,
|
||||
}
|
||||
|
||||
impl Rhythm {
|
||||
const STRAIN_DECAY: f64 = 0.96;
|
||||
|
||||
pub(crate) fn new() -> Self {
|
||||
Self {
|
||||
curr_decay_strain: 0.0,
|
||||
curr_strain: 0.0,
|
||||
notes_since_rhythm_change: 0,
|
||||
history: LimitedQueue::new(),
|
||||
curr_section_peak: 0.0,
|
||||
curr_section_end: 0.0,
|
||||
strain_peaks: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn reset_rhythm_and_strain(&mut self) {
|
||||
self.curr_strain = 0.0;
|
||||
self.notes_since_rhythm_change = 0;
|
||||
}
|
||||
|
||||
fn repetition_penalties(&mut self, hit_object: &TaikoDifficultyObject) -> f64 {
|
||||
let mut penalty = 1.0;
|
||||
|
||||
self.history.push(HistoryElement::new(hit_object));
|
||||
|
||||
for most_recent_patterns_to_compare in 2..=(HISTORY_MAX_LEN / 2).min(self.history.len()) {
|
||||
for start in (0..self.history.len() - most_recent_patterns_to_compare).rev() {
|
||||
if !self.same_pattern(start, most_recent_patterns_to_compare) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let notes_since = hit_object.idx - self.history[start].idx;
|
||||
penalty *= Self::repetition_penalty(notes_since);
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
penalty
|
||||
}
|
||||
|
||||
fn same_pattern(&self, start: usize, most_recent_patterns_to_compare: usize) -> bool {
|
||||
let start = self.history.iter().skip(start);
|
||||
|
||||
let most_recent_patterns_to_compare = self
|
||||
.history
|
||||
.iter()
|
||||
.skip(self.history.len() - most_recent_patterns_to_compare);
|
||||
|
||||
start
|
||||
.zip(most_recent_patterns_to_compare)
|
||||
.all(|(a, b)| a.rhythm == b.rhythm)
|
||||
}
|
||||
|
||||
fn repetition_penalty(notes_since: usize) -> f64 {
|
||||
(0.032 * notes_since as f64).min(1.0)
|
||||
}
|
||||
|
||||
fn pattern_len_penalty(pattern_len: usize) -> f64 {
|
||||
let pattern_len = pattern_len as f64;
|
||||
let short_pattern_penalty = (0.15 * pattern_len).min(1.0);
|
||||
let long_pattern_penalty = (2.5 - 0.15 * pattern_len).clamp(0.0, 1.0);
|
||||
|
||||
short_pattern_penalty.min(long_pattern_penalty)
|
||||
}
|
||||
|
||||
fn speed_penalty(&mut self, delta: f64) -> f64 {
|
||||
if delta < 80.0 {
|
||||
return 1.0;
|
||||
} else if delta < 210.0 {
|
||||
return (1.4 - 0.005 * delta).max(0.0);
|
||||
}
|
||||
|
||||
self.reset_rhythm_and_strain();
|
||||
|
||||
0.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Skill for Rhythm {
|
||||
#[inline]
|
||||
fn process(&mut self, curr: &TaikoDifficultyObject, hit_objects: &ObjectLists) {
|
||||
<Self as StrainSkill>::process(self, curr, hit_objects)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn difficulty_value(self) -> f64 {
|
||||
<Self as StrainSkill>::difficulty_value(self)
|
||||
}
|
||||
}
|
||||
|
||||
impl StrainSkill for Rhythm {
|
||||
#[inline]
|
||||
fn strain_peaks_mut(&mut self) -> &mut Vec<f64> {
|
||||
&mut self.strain_peaks
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn curr_section_peak(&mut self) -> &mut f64 {
|
||||
&mut self.curr_section_peak
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn curr_section_end(&mut self) -> &mut f64 {
|
||||
&mut self.curr_section_end
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn strain_value_at(&mut self, curr: &TaikoDifficultyObject, hit_objects: &ObjectLists) -> f64 {
|
||||
<Self as StrainDecaySkill>::strain_value_at(self, curr, hit_objects)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn calculate_initial_strain(&self, time: f64, curr: &TaikoDifficultyObject) -> f64 {
|
||||
<Self as StrainDecaySkill>::calculate_initial_strain(self, time, curr)
|
||||
}
|
||||
}
|
||||
|
||||
impl StrainDecaySkill for Rhythm {
|
||||
const SKILL_MULTIPLIER: f64 = 10.0;
|
||||
const STRAIN_DECAY_BASE: f64 = 0.0;
|
||||
|
||||
#[inline]
|
||||
fn curr_strain(&self) -> f64 {
|
||||
self.curr_decay_strain
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn curr_strain_mut(&mut self) -> &mut f64 {
|
||||
&mut self.curr_decay_strain
|
||||
}
|
||||
|
||||
fn strain_value_of(&mut self, curr: &TaikoDifficultyObject, _: &ObjectLists) -> f64 {
|
||||
let base_is_circle = curr.base.is_hit;
|
||||
|
||||
// * drum rolls and swells are exempt.
|
||||
if !base_is_circle {
|
||||
self.reset_rhythm_and_strain();
|
||||
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
self.curr_strain *= Self::STRAIN_DECAY;
|
||||
self.notes_since_rhythm_change += 1;
|
||||
|
||||
// * rhythm difficulty zero (due to rhythm not changing) => no rhythm strain.
|
||||
if curr.rhythm.difficulty.abs() <= f64::EPSILON {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
let mut obj_strain = curr.rhythm.difficulty;
|
||||
|
||||
obj_strain *= self.repetition_penalties(curr);
|
||||
obj_strain *= Self::pattern_len_penalty(self.notes_since_rhythm_change);
|
||||
obj_strain *= self.speed_penalty(curr.delta);
|
||||
|
||||
// * careful - needs to be done here since calls above read this value
|
||||
self.notes_since_rhythm_change = 0;
|
||||
|
||||
self.curr_strain += obj_strain;
|
||||
|
||||
self.curr_strain
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug)]
|
||||
pub(crate) struct HistoryElement {
|
||||
idx: usize,
|
||||
rhythm: &'static HitObjectRhythm,
|
||||
}
|
||||
|
||||
impl HistoryElement {
|
||||
fn new(difficulty_object: &TaikoDifficultyObject) -> Self {
|
||||
Self {
|
||||
idx: difficulty_object.idx,
|
||||
rhythm: difficulty_object.rhythm,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for HistoryElement {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
idx: 0,
|
||||
rhythm: HitObjectRhythm::static_ref(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
use crate::taiko::difficulty_object::{ObjectLists, TaikoDifficultyObject};
|
||||
|
||||
use super::{Skill, StrainDecaySkill, StrainSkill};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct Stamina {
|
||||
curr_strain: f64,
|
||||
curr_section_peak: f64,
|
||||
curr_section_end: f64,
|
||||
pub(crate) strain_peaks: Vec<f64>,
|
||||
}
|
||||
|
||||
impl Stamina {
|
||||
pub(crate) fn new() -> Self {
|
||||
Self {
|
||||
curr_strain: 0.0,
|
||||
curr_section_peak: 0.0,
|
||||
curr_section_end: 0.0,
|
||||
strain_peaks: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Skill for Stamina {
|
||||
#[inline]
|
||||
fn process(&mut self, curr: &TaikoDifficultyObject, hit_objects: &ObjectLists) {
|
||||
<Self as StrainSkill>::process(self, curr, hit_objects)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn difficulty_value(self) -> f64 {
|
||||
<Self as StrainSkill>::difficulty_value(self)
|
||||
}
|
||||
}
|
||||
|
||||
impl StrainSkill for Stamina {
|
||||
#[inline]
|
||||
fn strain_peaks_mut(&mut self) -> &mut Vec<f64> {
|
||||
&mut self.strain_peaks
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn curr_section_peak(&mut self) -> &mut f64 {
|
||||
&mut self.curr_section_peak
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn curr_section_end(&mut self) -> &mut f64 {
|
||||
&mut self.curr_section_end
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn strain_value_at(&mut self, curr: &TaikoDifficultyObject, hit_objects: &ObjectLists) -> f64 {
|
||||
<Self as StrainDecaySkill>::strain_value_at(self, curr, hit_objects)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn calculate_initial_strain(&self, time: f64, curr: &TaikoDifficultyObject) -> f64 {
|
||||
<Self as StrainDecaySkill>::calculate_initial_strain(self, time, curr)
|
||||
}
|
||||
}
|
||||
|
||||
impl StrainDecaySkill for Stamina {
|
||||
const SKILL_MULTIPLIER: f64 = 1.1;
|
||||
const STRAIN_DECAY_BASE: f64 = 0.4;
|
||||
|
||||
#[inline]
|
||||
fn curr_strain(&self) -> f64 {
|
||||
self.curr_strain
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn curr_strain_mut(&mut self) -> &mut f64 {
|
||||
&mut self.curr_strain
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn strain_value_of(&mut self, curr: &TaikoDifficultyObject, hit_objects: &ObjectLists) -> f64 {
|
||||
StaminaEvaluator::evaluate_diff_of(curr, hit_objects)
|
||||
}
|
||||
}
|
||||
|
||||
struct StaminaEvaluator;
|
||||
|
||||
impl StaminaEvaluator {
|
||||
fn speed_bonus(mut interval: f64) -> f64 {
|
||||
// * Cap to 600bpm 1/4, 25ms note interval, 50ms key interval
|
||||
// * Interval will be capped at a very small value to avoid infinite/negative speed bonuses.
|
||||
// * TODO - This is a temporary measure as we need to implement methods of detecting playstyle-abuse of SpeedBonus.
|
||||
interval = interval.max(50.0);
|
||||
|
||||
30.0 / interval
|
||||
}
|
||||
|
||||
fn evaluate_diff_of(hit_object: &TaikoDifficultyObject, hit_objects: &ObjectLists) -> f64 {
|
||||
if !hit_object.base.is_hit {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
// * Find the previous hit object hit by the current key, which is two notes of the same colour prior.
|
||||
let curr = hit_object;
|
||||
let key_prev = hit_objects.prev_mono(curr.idx, 1);
|
||||
|
||||
if let Some(key_prev) = key_prev {
|
||||
// * Add a base strain to all objects
|
||||
0.5 + Self::speed_bonus(curr.start_time - key_prev.borrow().start_time)
|
||||
} else {
|
||||
// * There is no previous hit object hit by the current key
|
||||
0.0
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
use std::{cmp::Ordering, mem};
|
||||
|
||||
use crate::taiko::{
|
||||
difficulty_object::{ObjectLists, TaikoDifficultyObject},
|
||||
SECTION_LEN,
|
||||
};
|
||||
|
||||
pub(crate) trait Skill: Sized {
|
||||
fn process(&mut self, curr: &TaikoDifficultyObject, hit_objects: &ObjectLists);
|
||||
fn difficulty_value(self) -> f64;
|
||||
}
|
||||
|
||||
pub(crate) trait StrainSkill: Skill {
|
||||
const DECAY_WEIGHT: f64 = 0.9;
|
||||
|
||||
fn strain_peaks_mut(&mut self) -> &mut Vec<f64>;
|
||||
fn curr_section_peak(&mut self) -> &mut f64;
|
||||
fn curr_section_end(&mut self) -> &mut f64;
|
||||
|
||||
fn strain_value_at(&mut self, curr: &TaikoDifficultyObject, hit_objects: &ObjectLists) -> f64;
|
||||
|
||||
fn calculate_initial_strain(&self, time: f64, curr: &TaikoDifficultyObject) -> f64;
|
||||
|
||||
fn process(&mut self, curr: &TaikoDifficultyObject, hit_objects: &ObjectLists) {
|
||||
// * The first object doesn't generate a strain, so we begin with an incremented section end
|
||||
if curr.idx == 0 {
|
||||
let section_len = SECTION_LEN as f64;
|
||||
*self.curr_section_end() = (curr.start_time / section_len).ceil() * section_len;
|
||||
}
|
||||
|
||||
while curr.start_time > *self.curr_section_end() {
|
||||
self.save_curr_peak();
|
||||
|
||||
{
|
||||
let section_end = *self.curr_section_end();
|
||||
self.start_new_section_from(section_end, curr);
|
||||
}
|
||||
|
||||
*self.curr_section_end() += SECTION_LEN as f64;
|
||||
}
|
||||
|
||||
*self.curr_section_peak() = self
|
||||
.strain_value_at(curr, hit_objects)
|
||||
.max(*self.curr_section_peak());
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn save_curr_peak(&mut self) {
|
||||
let peak = *self.curr_section_peak();
|
||||
self.strain_peaks_mut().push(peak);
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn start_new_section_from(&mut self, time: f64, curr: &TaikoDifficultyObject) {
|
||||
// * The maximum strain of the new section is not zero by default
|
||||
// * This means we need to capture the strain level at the beginning of the new section,
|
||||
// * and use that as the initial peak level.
|
||||
*self.curr_section_peak() = self.calculate_initial_strain(time, curr);
|
||||
}
|
||||
|
||||
fn difficulty_value(self) -> f64 {
|
||||
let mut difficulty = 0.0;
|
||||
let mut weight = 1.0;
|
||||
|
||||
// * Sections with 0 strain are excluded to avoid worst-case time complexity of the following sort (e.g. /b/2351871).
|
||||
// * These sections will not contribute to the difficulty.
|
||||
let mut peaks = self.get_curr_strain_peaks();
|
||||
|
||||
peaks.retain(|&peak| peak > 0.0);
|
||||
peaks.sort_unstable_by(|a, b| b.partial_cmp(a).unwrap_or(Ordering::Equal));
|
||||
|
||||
// * Difficulty is the weighted sum of the highest strains from every section.
|
||||
// * We're sorting from highest to lowest strain.
|
||||
for strain in peaks {
|
||||
difficulty += strain * weight;
|
||||
weight *= Self::DECAY_WEIGHT;
|
||||
}
|
||||
|
||||
difficulty
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn get_curr_strain_peaks(mut self) -> Vec<f64> {
|
||||
let curr_peak = *self.curr_section_peak();
|
||||
let mut strain_peaks = mem::take(self.strain_peaks_mut());
|
||||
strain_peaks.push(curr_peak);
|
||||
|
||||
strain_peaks
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) trait StrainDecaySkill: StrainSkill {
|
||||
const SKILL_MULTIPLIER: f64;
|
||||
const STRAIN_DECAY_BASE: f64;
|
||||
|
||||
fn curr_strain(&self) -> f64;
|
||||
fn curr_strain_mut(&mut self) -> &mut f64;
|
||||
|
||||
fn strain_value_of(&mut self, curr: &TaikoDifficultyObject, hit_objects: &ObjectLists) -> f64;
|
||||
|
||||
#[inline]
|
||||
fn calculate_initial_strain(&self, time: f64, curr: &TaikoDifficultyObject) -> f64 {
|
||||
self.curr_strain() * self.strain_decay(time - curr.prev_time)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn strain_value_at(&mut self, curr: &TaikoDifficultyObject, hit_objects: &ObjectLists) -> f64 {
|
||||
*self.curr_strain_mut() *= self.strain_decay(curr.delta);
|
||||
*self.curr_strain_mut() += self.strain_value_of(curr, hit_objects) * Self::SKILL_MULTIPLIER;
|
||||
|
||||
self.curr_strain()
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn strain_decay(&self, ms: f64) -> f64 {
|
||||
Self::STRAIN_DECAY_BASE.powf(ms / 1000.0)
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user