initial rewrite
This commit is contained in:
@@ -12,13 +12,6 @@ jobs:
|
||||
name: Clippy
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
feature:
|
||||
- default,gradual
|
||||
- async_tokio
|
||||
- async_std
|
||||
|
||||
steps:
|
||||
- name: Checkout project
|
||||
uses: actions/checkout@v3
|
||||
@@ -30,19 +23,12 @@ jobs:
|
||||
uses: Swatinem/rust-cache@v2
|
||||
|
||||
- name: Run clippy
|
||||
run: cargo clippy --no-default-features --features ${{ matrix.feature }} --all-targets
|
||||
run: cargo clippy --all-targets
|
||||
|
||||
tests:
|
||||
name: Tests
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
feature:
|
||||
- default,gradual
|
||||
- async_tokio
|
||||
- async_std
|
||||
|
||||
steps:
|
||||
- name: Checkout project
|
||||
uses: actions/checkout@v3
|
||||
@@ -57,7 +43,7 @@ jobs:
|
||||
uses: taiki-e/install-action@nextest
|
||||
|
||||
- name: Run tests
|
||||
run: cargo nextest run --no-default-features --features ${{ matrix.feature }} --no-fail-fast --failure-output=immediate-final
|
||||
run: cargo nextest run --no-fail-fast --failure-output=immediate-final
|
||||
|
||||
- name: Run doctests
|
||||
run: cargo test --doc
|
||||
|
||||
+1
-10
@@ -2,13 +2,4 @@
|
||||
Cargo.lock
|
||||
output*
|
||||
/.idea
|
||||
/tests/custom.rs
|
||||
/.idea
|
||||
|
||||
/pp-gen/.env
|
||||
/pp-gen/target
|
||||
/pp-gen/output.json
|
||||
|
||||
/pp-plot/.env
|
||||
/pp-plot/target
|
||||
/pp-plot/accuracy_*
|
||||
/tests/custom.rs
|
||||
+4
-32
@@ -1,40 +1,12 @@
|
||||
[package]
|
||||
name = "rosu-pp"
|
||||
version = "0.10.0"
|
||||
authors = ["MaxOhn <ohn.m@hotmail.de>"]
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
readme = "README.md"
|
||||
repository = "https://github.com/MaxOhn/rosu-pp"
|
||||
documentation = "https://docs.rs/rosu-pp/"
|
||||
description = "osu! difficulty and pp calculation for all modes"
|
||||
keywords = ["osu", "pp", "stars", "async"]
|
||||
|
||||
[features]
|
||||
default = []
|
||||
async_std = ["async-std"]
|
||||
async_tokio = ["tokio"]
|
||||
gradual = []
|
||||
tracing = ["rosu-map/tracing"]
|
||||
|
||||
[dependencies]
|
||||
async-std = { version = "1.9", optional = true }
|
||||
tokio = { version = "1.2", optional = true, default-features = false, features = ["fs", "io-util"] }
|
||||
|
||||
[dev-dependencies]
|
||||
proptest = "1.3.1"
|
||||
tokio = { version = "1.2", default-features = false, features = ["fs", "rt"] }
|
||||
|
||||
[profile.test]
|
||||
opt-level = 2
|
||||
|
||||
[profile.test.package.proptest]
|
||||
opt-level = 3
|
||||
|
||||
[profile.test.package.rand_chacha]
|
||||
opt-level = 3
|
||||
|
||||
[package.metadata.docs.rs]
|
||||
# document these features
|
||||
features = ["gradual"]
|
||||
# defines the configuration attribute `docsrs`
|
||||
rustdoc-args = ["--cfg", "docsrs"]
|
||||
rosu-map = { path = "../rosu-map" }
|
||||
thiserror = { version = "1.0.50" }
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
# rosu-pp
|
||||
|
||||
TODO: Rewrite readme
|
||||
|
||||
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.
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
MAP_PATH="path/to/.osu/files"
|
||||
@@ -1,13 +0,0 @@
|
||||
[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"] }
|
||||
@@ -1,9 +0,0 @@
|
||||
# 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`.
|
||||
@@ -1,174 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 14 KiB |
@@ -1,162 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 12 KiB |
@@ -1,165 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 13 KiB |
@@ -1,456 +0,0 @@
|
||||
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>,
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 12 KiB |
@@ -0,0 +1,186 @@
|
||||
use crate::{
|
||||
catch::{Catch, CatchDifficultyAttributes, CatchPerformanceAttributes},
|
||||
mania::{Mania, ManiaDifficultyAttributes, ManiaPerformanceAttributes},
|
||||
model::mode::IGameMode,
|
||||
osu::{Osu, OsuDifficultyAttributes, OsuPerformanceAttributes},
|
||||
taiko::{Taiko, TaikoDifficultyAttributes, TaikoPerformanceAttributes},
|
||||
};
|
||||
|
||||
use super::performance::Performance;
|
||||
|
||||
/// The result of a difficulty calculation based on the mode.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum DifficultyAttributes {
|
||||
/// osu!standard difficulty calculation result.
|
||||
Osu(OsuDifficultyAttributes),
|
||||
/// osu!taiko difficulty calculation result.
|
||||
Taiko(TaikoDifficultyAttributes),
|
||||
/// osu!catch difficulty calculation result.
|
||||
Catch(CatchDifficultyAttributes),
|
||||
/// osu!mania difficulty calculation result.
|
||||
Mania(ManiaDifficultyAttributes),
|
||||
}
|
||||
|
||||
impl DifficultyAttributes {
|
||||
/// The star value.
|
||||
pub const 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.
|
||||
pub const fn max_combo(&self) -> u32 {
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a builder for performance calculation.
|
||||
pub fn pp<'a>(self) -> Performance<'a> {
|
||||
self.into()
|
||||
}
|
||||
}
|
||||
|
||||
/// The result of a performance calculation based on the mode.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum PerformanceAttributes {
|
||||
/// osu!standard performance calculation result.
|
||||
Osu(OsuPerformanceAttributes),
|
||||
/// osu!taiko performance calculation result.
|
||||
Taiko(TaikoPerformanceAttributes),
|
||||
/// osu!catch performance calculation result.
|
||||
Catch(CatchPerformanceAttributes),
|
||||
/// osu!mania performance calculation result.
|
||||
Mania(ManiaPerformanceAttributes),
|
||||
}
|
||||
|
||||
impl PerformanceAttributes {
|
||||
/// The pp value.
|
||||
pub const 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.
|
||||
pub const 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.
|
||||
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.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The maximum combo of the map.
|
||||
pub const fn max_combo(&self) -> u32 {
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
fn attributes(self) -> DifficultyAttributes {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl AttributeProvider for PerformanceAttributes {
|
||||
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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Abstract type to provide flexibility when passing difficulty attributes to a performance calculation.
|
||||
pub trait ModeAttributeProvider<M: IGameMode> {
|
||||
/// Provide the actual difficulty attributes.
|
||||
fn attributes(self) -> Option<M::DifficultyAttributes>;
|
||||
}
|
||||
|
||||
impl<M: IGameMode> ModeAttributeProvider<M> for M::DifficultyAttributes {
|
||||
fn attributes(self) -> Option<M::DifficultyAttributes> {
|
||||
Some(self)
|
||||
}
|
||||
}
|
||||
|
||||
macro_rules! impl_attr_provider {
|
||||
( $mode:ident: $difficulty:ident, $performance:ident ) => {
|
||||
impl AttributeProvider for $difficulty {
|
||||
fn attributes(self) -> DifficultyAttributes {
|
||||
DifficultyAttributes::$mode(self)
|
||||
}
|
||||
}
|
||||
|
||||
impl AttributeProvider for $performance {
|
||||
fn attributes(self) -> DifficultyAttributes {
|
||||
DifficultyAttributes::$mode(self.difficulty)
|
||||
}
|
||||
}
|
||||
|
||||
impl ModeAttributeProvider<$mode> for $performance {
|
||||
fn attributes(self) -> Option<<$mode as IGameMode>::DifficultyAttributes> {
|
||||
Some(self.difficulty)
|
||||
}
|
||||
}
|
||||
|
||||
impl ModeAttributeProvider<$mode> for DifficultyAttributes {
|
||||
fn attributes(self) -> Option<<$mode as IGameMode>::DifficultyAttributes> {
|
||||
if let Self::$mode(attrs) = self {
|
||||
Some(attrs)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ModeAttributeProvider<$mode> for PerformanceAttributes {
|
||||
fn attributes(self) -> Option<<$mode as IGameMode>::DifficultyAttributes> {
|
||||
if let Self::$mode(attrs) = self {
|
||||
Some(attrs.difficulty)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
impl_attr_provider!(Catch: CatchDifficultyAttributes, CatchPerformanceAttributes);
|
||||
impl_attr_provider!(Mania: ManiaDifficultyAttributes, ManiaPerformanceAttributes);
|
||||
impl_attr_provider!(Osu: OsuDifficultyAttributes, OsuPerformanceAttributes);
|
||||
impl_attr_provider!(Taiko: TaikoDifficultyAttributes, TaikoPerformanceAttributes);
|
||||
@@ -0,0 +1,210 @@
|
||||
use std::borrow::Cow;
|
||||
|
||||
use rosu_map::section::general::GameMode;
|
||||
|
||||
use crate::{
|
||||
catch::{Catch, CatchBeatmap},
|
||||
mania::{Mania, ManiaBeatmap},
|
||||
model::beatmap::{Beatmap, Converted},
|
||||
osu::{Osu, OsuBeatmap},
|
||||
taiko::{Taiko, TaikoBeatmap},
|
||||
};
|
||||
|
||||
pub use self::mode::ModeDifficulty;
|
||||
|
||||
use super::attributes::DifficultyAttributes;
|
||||
|
||||
mod mode;
|
||||
pub mod object;
|
||||
pub mod skills;
|
||||
|
||||
/// Difficulty calculator on maps of any mode.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Difficulty<'map> {
|
||||
map: Cow<'map, Beatmap>,
|
||||
is_convert: bool,
|
||||
inner: ModeDifficulty,
|
||||
}
|
||||
|
||||
impl<'map> Difficulty<'map> {
|
||||
/// Create a new difficulty calculator for the given beatmap.
|
||||
pub fn new(map: &'map Beatmap) -> Self {
|
||||
Self::new_with_is_convert(Cow::Borrowed(map), false)
|
||||
}
|
||||
|
||||
fn new_with_is_convert(map: Cow<'map, Beatmap>, is_convert: bool) -> Self {
|
||||
Self {
|
||||
map,
|
||||
is_convert,
|
||||
inner: ModeDifficulty::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
macro_rules! impl_from_mode {
|
||||
( $mode:ident ) => {
|
||||
impl<'a> From<Converted<'a, $mode>> for Difficulty<'a> {
|
||||
fn from(converted: Converted<'a, $mode>) -> Self {
|
||||
Self::new_with_is_convert(converted.map, converted.is_convert)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a, 'b: 'a> From<&'b Converted<'a, $mode>> for Difficulty<'a> {
|
||||
fn from(converted: &'b Converted<'a, $mode>) -> Self {
|
||||
Self::new_with_is_convert(
|
||||
Cow::Borrowed(converted.map.as_ref()),
|
||||
converted.is_convert,
|
||||
)
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
impl_from_mode!(Osu);
|
||||
impl_from_mode!(Taiko);
|
||||
impl_from_mode!(Catch);
|
||||
impl_from_mode!(Mania);
|
||||
|
||||
impl Difficulty<'_> {
|
||||
/// Attempt to convert the map to the specified mode.
|
||||
///
|
||||
/// If the conversion is incompatible, `None` is returned.
|
||||
///
|
||||
/// If the given mode should be ignored in case it is incompatible, use
|
||||
/// [`mode_or_ignore`] instead.
|
||||
///
|
||||
/// [`mode_or_ignore`]: Self::mode_or_ignore
|
||||
pub fn try_mode(&mut self, mode: GameMode) -> Option<&mut Self> {
|
||||
let (map, is_convert) = match mode {
|
||||
GameMode::Osu => {
|
||||
let converted = OsuBeatmap::try_from_ref(self.map.as_ref())?;
|
||||
|
||||
(converted.map, converted.is_convert)
|
||||
}
|
||||
GameMode::Taiko => {
|
||||
let converted = TaikoBeatmap::try_from_ref(self.map.as_ref())?;
|
||||
|
||||
(converted.map, converted.is_convert)
|
||||
}
|
||||
GameMode::Catch => {
|
||||
let converted = CatchBeatmap::try_from_ref(self.map.as_ref())?;
|
||||
|
||||
(converted.map, converted.is_convert)
|
||||
}
|
||||
GameMode::Mania => {
|
||||
let converted = ManiaBeatmap::try_from_ref(self.map.as_ref())?;
|
||||
|
||||
(converted.map, converted.is_convert)
|
||||
}
|
||||
};
|
||||
|
||||
if matches!(map, Cow::Owned(_)) {
|
||||
self.is_convert |= is_convert;
|
||||
let map = map.into_owned();
|
||||
self.map = Cow::Owned(map);
|
||||
}
|
||||
|
||||
Some(self)
|
||||
}
|
||||
|
||||
/// Attempt to convert the map to the specified mode.
|
||||
///
|
||||
/// If the conversion is incompatible, the map won't be modified.
|
||||
///
|
||||
/// To see whether the given mode is incompatible, use [`try_mode`]
|
||||
/// instead.
|
||||
///
|
||||
/// [`try_mode`]: Self::try_mode
|
||||
pub fn mode_or_ignore(&mut self, mode: GameMode) -> &mut Self {
|
||||
let (map, is_convert) = match mode {
|
||||
GameMode::Osu => {
|
||||
let Some(converted) = OsuBeatmap::try_from_ref(self.map.as_ref()) else {
|
||||
return self;
|
||||
};
|
||||
|
||||
(converted.map, converted.is_convert)
|
||||
}
|
||||
GameMode::Taiko => {
|
||||
let Some(converted) = TaikoBeatmap::try_from_ref(self.map.as_ref()) else {
|
||||
return self;
|
||||
};
|
||||
|
||||
(converted.map, converted.is_convert)
|
||||
}
|
||||
GameMode::Catch => {
|
||||
let Some(converted) = CatchBeatmap::try_from_ref(self.map.as_ref()) else {
|
||||
return self;
|
||||
};
|
||||
|
||||
(converted.map, converted.is_convert)
|
||||
}
|
||||
GameMode::Mania => {
|
||||
let Some(converted) = ManiaBeatmap::try_from_ref(self.map.as_ref()) else {
|
||||
return self;
|
||||
};
|
||||
|
||||
(converted.map, converted.is_convert)
|
||||
}
|
||||
};
|
||||
|
||||
if matches!(map, Cow::Owned(_)) {
|
||||
self.is_convert |= is_convert;
|
||||
let map = map.into_owned();
|
||||
self.map = Cow::Owned(map);
|
||||
}
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
/// Specify mods through their bit values.
|
||||
///
|
||||
/// See [https://github.com/ppy/osu-api/wiki#mods](https://github.com/ppy/osu-api/wiki#mods)
|
||||
pub fn mods(&mut self, mods: u32) -> &mut Self {
|
||||
self.inner.mods(mods);
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
/// Amount of passed objects for partial plays, e.g. a fail.
|
||||
pub fn passed_objects(&mut self, passed_objects: u32) -> &mut Self {
|
||||
self.inner.passed_objects(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.
|
||||
pub fn clock_rate(&mut self, clock_rate: f64) -> &mut Self {
|
||||
self.inner.clock_rate(clock_rate);
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
/// Perform the difficulty calculation.
|
||||
///
|
||||
/// The returned attributes depend on the map's mode.
|
||||
pub fn calculate(&self) -> DifficultyAttributes {
|
||||
let map = Cow::Borrowed(self.map.as_ref());
|
||||
|
||||
match self.map.mode {
|
||||
GameMode::Osu => DifficultyAttributes::Osu(
|
||||
self.inner
|
||||
.calculate(&Converted::<Osu>::new(map, self.is_convert)),
|
||||
),
|
||||
GameMode::Taiko => DifficultyAttributes::Taiko(
|
||||
self.inner
|
||||
.calculate(&Converted::<Taiko>::new(map, self.is_convert)),
|
||||
),
|
||||
GameMode::Catch => DifficultyAttributes::Catch(
|
||||
self.inner
|
||||
.calculate(&Converted::<Catch>::new(map, self.is_convert)),
|
||||
),
|
||||
GameMode::Mania => DifficultyAttributes::Mania(
|
||||
self.inner
|
||||
.calculate(&Converted::<Mania>::new(map, self.is_convert)),
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
use crate::{
|
||||
model::{beatmap::Converted, mode::IGameMode},
|
||||
util::mods::Mods,
|
||||
};
|
||||
|
||||
/// Difficulty calculator on maps of a given mode.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct ModeDifficulty {
|
||||
mods: u32,
|
||||
passed_objects: Option<u32>,
|
||||
clock_rate: Option<f64>,
|
||||
}
|
||||
|
||||
impl ModeDifficulty {
|
||||
/// Create a new difficulty calculator.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Specify mods through their bit values.
|
||||
///
|
||||
/// See [https://github.com/ppy/osu-api/wiki#mods](https://github.com/ppy/osu-api/wiki#mods)
|
||||
pub fn mods(&mut self, mods: u32) -> &mut Self {
|
||||
self.mods = mods;
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
/// Amount of passed objects for partial plays, e.g. a fail.
|
||||
pub fn passed_objects(&mut self, passed_objects: u32) -> &mut 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.
|
||||
pub fn clock_rate(&mut self, clock_rate: f64) -> &mut Self {
|
||||
self.clock_rate = Some(clock_rate);
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
/// Perform the difficulty calculation for a [`Converted`] beatmap and
|
||||
/// process the final skill values.
|
||||
pub fn calculate<M: IGameMode>(&self, map: &Converted<'_, M>) -> M::DifficultyAttributes {
|
||||
M::difficulty(self, map)
|
||||
}
|
||||
|
||||
/// Perform a difficulty calculation for a [`Converted`] beatmap without
|
||||
/// processing the final skill values.
|
||||
pub fn strains<M: IGameMode>(&self, map: &Converted<'_, M>) -> M::Strains {
|
||||
M::strains(self, map)
|
||||
}
|
||||
|
||||
pub(crate) const fn get_mods(&self) -> u32 {
|
||||
self.mods
|
||||
}
|
||||
|
||||
pub(crate) fn get_clock_rate(&self) -> f64 {
|
||||
self.clock_rate.unwrap_or_else(|| self.mods.clock_rate())
|
||||
}
|
||||
|
||||
pub(crate) fn get_passed_objects(&self) -> usize {
|
||||
self.passed_objects.map_or(usize::MAX, |n| n as usize)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
pub trait IDifficultyObject: Sized {
|
||||
fn idx(&self) -> usize;
|
||||
|
||||
fn previous<'a, D>(&self, backwards_idx: usize, diff_objects: &'a [D]) -> Option<&'a D> {
|
||||
self.idx()
|
||||
.checked_sub(backwards_idx + 1)
|
||||
.and_then(|idx| diff_objects.get(idx))
|
||||
}
|
||||
|
||||
fn next<'a, D>(&self, forwards_idx: usize, diff_objects: &'a [D]) -> Option<&'a D> {
|
||||
diff_objects.get(self.idx() + (forwards_idx + 1))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
pub fn strain_decay(ms: f64, strain_decay_base: f64) -> f64 {
|
||||
strain_decay_base.powf(ms / 1000.0)
|
||||
}
|
||||
|
||||
/// Wrapper around a difficulty skill that carries a list of all difficulty
|
||||
/// objects.
|
||||
pub struct Skill<'a, S: ISkill> {
|
||||
pub inner: S,
|
||||
pub diff_objects: &'a S::DifficultyObjects<'a>,
|
||||
}
|
||||
|
||||
impl<'a, S: ISkill> Skill<'a, S> {
|
||||
pub const fn new(skill: S, diff_objects: &'a S::DifficultyObjects<'a>) -> Self {
|
||||
Self {
|
||||
inner: skill,
|
||||
diff_objects,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Trait required for [`Skill`].
|
||||
pub trait ISkill {
|
||||
type DifficultyObjects<'a>: ?Sized;
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct StrainSkill {
|
||||
pub curr_section_peak: f64,
|
||||
pub curr_section_end: f64,
|
||||
pub strain_peaks: Vec<f64>, // TODO: default capacity
|
||||
}
|
||||
|
||||
impl StrainSkill {
|
||||
pub const DECAY_WEIGHT: f64 = 0.9;
|
||||
pub const SECTION_LEN: f64 = 400.0;
|
||||
|
||||
pub fn save_curr_peak(&mut self) {
|
||||
self.strain_peaks.push(self.curr_section_peak);
|
||||
}
|
||||
|
||||
pub fn start_new_section_from(&mut self, initial_strain: f64) {
|
||||
self.curr_section_peak = initial_strain;
|
||||
}
|
||||
|
||||
pub fn get_curr_strain_peaks(self) -> Vec<f64> {
|
||||
let mut strain_peaks = self.strain_peaks;
|
||||
strain_peaks.push(self.curr_section_peak);
|
||||
|
||||
strain_peaks
|
||||
}
|
||||
|
||||
pub fn difficulty_value(self, decay_weight: f64) -> f64 {
|
||||
let mut difficulty = 0.0;
|
||||
let mut weight = 1.0;
|
||||
|
||||
let mut peaks = self.get_curr_strain_peaks();
|
||||
peaks.retain(|&strain| strain > 0.0);
|
||||
peaks.sort_by(|a, b| b.total_cmp(a));
|
||||
|
||||
for strain in peaks {
|
||||
difficulty += strain * weight;
|
||||
weight *= decay_weight;
|
||||
}
|
||||
|
||||
difficulty
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct StrainDecaySkill {
|
||||
pub inner: StrainSkill,
|
||||
pub curr_strain: f64,
|
||||
}
|
||||
|
||||
impl StrainDecaySkill {
|
||||
pub const DECAY_WEIGHT: f64 = StrainSkill::DECAY_WEIGHT;
|
||||
pub const SECTION_LEN: f64 = StrainSkill::SECTION_LEN;
|
||||
|
||||
pub fn save_curr_peak(&mut self) {
|
||||
self.inner.save_curr_peak();
|
||||
}
|
||||
|
||||
pub fn start_new_section_from(&mut self, initial_strain: f64) {
|
||||
self.inner.start_new_section_from(initial_strain);
|
||||
}
|
||||
|
||||
pub fn get_curr_strain_peaks(self) -> Vec<f64> {
|
||||
self.inner.get_curr_strain_peaks()
|
||||
}
|
||||
|
||||
pub fn difficulty_value(self, decay_weight: f64) -> f64 {
|
||||
self.inner.difficulty_value(decay_weight)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
pub use self::{
|
||||
attributes::{
|
||||
AttributeProvider, DifficultyAttributes, ModeAttributeProvider, PerformanceAttributes,
|
||||
},
|
||||
difficulty::{Difficulty, ModeDifficulty},
|
||||
performance::{HitResultPriority, Performance},
|
||||
score_state::ScoreState,
|
||||
};
|
||||
|
||||
mod attributes;
|
||||
pub(crate) mod difficulty;
|
||||
mod performance;
|
||||
mod score_state;
|
||||
@@ -1,63 +1,41 @@
|
||||
use std::borrow::Cow;
|
||||
|
||||
use rosu_map::section::general::GameMode;
|
||||
|
||||
use crate::{
|
||||
catch::{CatchDifficultyAttributes, CatchPP, CatchPerformanceAttributes},
|
||||
mania::{ManiaDifficultyAttributes, ManiaPP, ManiaPerformanceAttributes},
|
||||
osu::{OsuDifficultyAttributes, OsuPP, OsuPerformanceAttributes},
|
||||
taiko::{TaikoDifficultyAttributes, TaikoPP, TaikoPerformanceAttributes},
|
||||
Beatmap, DifficultyAttributes, GameMode, PerformanceAttributes, ScoreState,
|
||||
any::attributes::DifficultyAttributes,
|
||||
catch::{Catch, CatchPerformance},
|
||||
mania::{Mania, ManiaPerformance},
|
||||
model::beatmap::{Beatmap, Converted},
|
||||
osu::{Osu, OsuPerformance},
|
||||
taiko::{Taiko, TaikoPerformance},
|
||||
};
|
||||
|
||||
use super::{
|
||||
attributes::{AttributeProvider, PerformanceAttributes},
|
||||
score_state::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)]
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
#[must_use]
|
||||
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>),
|
||||
pub enum Performance<'map> {
|
||||
Osu(OsuPerformance<'map>),
|
||||
Taiko(TaikoPerformance<'map>),
|
||||
Catch(CatchPerformance<'map>),
|
||||
Mania(ManiaPerformance<'map>),
|
||||
}
|
||||
|
||||
impl<'map> AnyPP<'map> {
|
||||
impl<'map> Performance<'map> {
|
||||
/// Create a new performance calculator for maps of any mode.
|
||||
#[inline]
|
||||
pub fn new(map: &'map Beatmap) -> Self {
|
||||
let map = Cow::Borrowed(map);
|
||||
|
||||
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)),
|
||||
GameMode::Osu => Self::Osu(OsuPerformance::new(Converted::new(map, false))),
|
||||
GameMode::Taiko => Self::Taiko(TaikoPerformance::new(Converted::new(map, false))),
|
||||
GameMode::Catch => Self::Catch(CatchPerformance::new(Converted::new(map, false))),
|
||||
GameMode::Mania => Self::Mania(ManiaPerformance::new(Converted::new(map, false))),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,14 +44,12 @@ impl<'map> AnyPP<'map> {
|
||||
///
|
||||
/// Note that the map, mods, and passed object count should be the same
|
||||
/// as when the attributes were calculated.
|
||||
#[inline]
|
||||
pub fn from_attributes(attributes: impl AttributeProvider) -> Self {
|
||||
Self::from(attributes)
|
||||
}
|
||||
|
||||
/// 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()),
|
||||
@@ -86,7 +62,6 @@ impl<'map> AnyPP<'map> {
|
||||
/// 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())),
|
||||
@@ -96,23 +71,47 @@ impl<'map> AnyPP<'map> {
|
||||
}
|
||||
}
|
||||
|
||||
/// If the map is an osu!standard map, convert it to another mode.
|
||||
/// Attempt to convert the map to the specified mode.
|
||||
///
|
||||
/// Returns `None` if `self` already replaced it's internal [`Beatmap`]
|
||||
/// with difficulty attributes, i.e. if [`AnyPP::attributes`]
|
||||
/// or [`AnyPP::generate_state`] was called.
|
||||
#[inline]
|
||||
/// Returns `None` if the conversion is incompatible or the internal
|
||||
/// beatmap was already replaced with difficulty attributes, i.e. if
|
||||
/// [`Performance::attributes`] or [`Performance::generate_state`] was
|
||||
/// called.
|
||||
///
|
||||
/// If the given mode should be ignored in case it is incompatible or if
|
||||
/// the internal beatmap was replaced, use [`mode_or_ignore`] instead.
|
||||
///
|
||||
/// [`mode_or_ignore`]: Self::mode_or_ignore
|
||||
pub fn try_mode(self, mode: GameMode) -> Option<Self> {
|
||||
match self {
|
||||
AnyPP::Osu(o) => o.try_mode(mode),
|
||||
other => Some(other),
|
||||
match (self, mode) {
|
||||
(Self::Osu(o), _) => o.try_mode(mode),
|
||||
(this @ Self::Taiko(_), GameMode::Taiko)
|
||||
| (this @ Self::Catch(_), GameMode::Catch)
|
||||
| (this @ Self::Mania(_), GameMode::Mania) => Some(this),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Attempt to convert the map to the specified mode.
|
||||
///
|
||||
/// If the conversion is incompatible or if the internal beatmap was
|
||||
/// already replaced with difficulty attributes, the map won't be modified.
|
||||
///
|
||||
/// To see whether the given mode is incompatible or the internal beatmap
|
||||
/// was replaced, use [`try_mode`] instead.
|
||||
///
|
||||
/// [`try_mode`]: Self::try_mode
|
||||
pub fn mode_or_ignore(self, mode: GameMode) -> Self {
|
||||
if let Self::Osu(osu) = self {
|
||||
osu.mode_or_ignore(mode)
|
||||
} else {
|
||||
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(self, mods: u32) -> Self {
|
||||
match self {
|
||||
Self::Osu(o) => Self::Osu(o.mods(mods)),
|
||||
@@ -130,8 +129,7 @@ impl<'map> AnyPP<'map> {
|
||||
using [`AnyPP`] multiple times with different `passed_objects`, you should use
|
||||
[`GradualPerformanceAttributes`](crate::GradualPerformance)."
|
||||
)]
|
||||
#[inline]
|
||||
pub fn passed_objects(self, passed_objects: usize) -> Self {
|
||||
pub fn passed_objects(self, passed_objects: u32) -> Self {
|
||||
match self {
|
||||
Self::Osu(o) => Self::Osu(o.passed_objects(passed_objects)),
|
||||
Self::Taiko(t) => Self::Taiko(t.passed_objects(passed_objects)),
|
||||
@@ -143,7 +141,6 @@ impl<'map> AnyPP<'map> {
|
||||
/// 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)),
|
||||
@@ -153,20 +150,7 @@ impl<'map> AnyPP<'map> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Specify whether the map is a convert.
|
||||
///
|
||||
/// This only needs to be specified if the map was converted manually beforehand.
|
||||
#[inline]
|
||||
pub fn is_convert(self, is_convert: bool) -> Self {
|
||||
match self {
|
||||
Self::Osu(_) | Self::Catch(_) => self,
|
||||
Self::Taiko(t) => Self::Taiko(t.is_convert(is_convert)),
|
||||
Self::Mania(m) => Self::Mania(m.is_convert(is_convert)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Provide parameters through a [`ScoreState`].
|
||||
#[inline]
|
||||
pub fn state(self, state: ScoreState) -> Self {
|
||||
match self {
|
||||
Self::Osu(o) => Self::Osu(o.state(state.into())),
|
||||
@@ -177,7 +161,6 @@ impl<'map> AnyPP<'map> {
|
||||
}
|
||||
|
||||
/// 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)),
|
||||
@@ -188,8 +171,7 @@ impl<'map> AnyPP<'map> {
|
||||
}
|
||||
|
||||
/// Specify the amount of misses of a play.
|
||||
#[inline]
|
||||
pub fn n_misses(self, n_misses: usize) -> Self {
|
||||
pub fn n_misses(self, n_misses: u32) -> Self {
|
||||
match self {
|
||||
Self::Osu(o) => Self::Osu(o.n_misses(n_misses)),
|
||||
Self::Taiko(t) => Self::Taiko(t.n_misses(n_misses)),
|
||||
@@ -201,8 +183,7 @@ impl<'map> AnyPP<'map> {
|
||||
/// Specify the max combo of the play.
|
||||
///
|
||||
/// Irrelevant for osu!mania.
|
||||
#[inline]
|
||||
pub fn combo(self, combo: usize) -> Self {
|
||||
pub fn combo(self, combo: u32) -> Self {
|
||||
match self {
|
||||
Self::Osu(o) => Self::Osu(o.combo(combo)),
|
||||
Self::Taiko(t) => Self::Taiko(t.combo(combo)),
|
||||
@@ -214,7 +195,6 @@ impl<'map> AnyPP<'map> {
|
||||
/// Specify how hitresults should be generated.
|
||||
///
|
||||
/// Defauls to [`HitResultPriority::BestCase`].
|
||||
#[inline]
|
||||
pub fn hitresult_priority(self, priority: HitResultPriority) -> Self {
|
||||
match self {
|
||||
Self::Osu(o) => Self::Osu(o.hitresult_priority(priority)),
|
||||
@@ -225,8 +205,7 @@ impl<'map> AnyPP<'map> {
|
||||
}
|
||||
|
||||
/// Specify the amount of 300s of a play.
|
||||
#[inline]
|
||||
pub fn n300(self, n300: usize) -> Self {
|
||||
pub fn n300(self, n300: u32) -> Self {
|
||||
match self {
|
||||
Self::Osu(o) => Self::Osu(o.n300(n300)),
|
||||
Self::Taiko(t) => Self::Taiko(t.n300(n300)),
|
||||
@@ -236,8 +215,7 @@ impl<'map> AnyPP<'map> {
|
||||
}
|
||||
|
||||
/// Specify the amount of 100s of a play.
|
||||
#[inline]
|
||||
pub fn n100(self, n100: usize) -> Self {
|
||||
pub fn n100(self, n100: u32) -> Self {
|
||||
match self {
|
||||
Self::Osu(o) => Self::Osu(o.n100(n100)),
|
||||
Self::Taiko(t) => Self::Taiko(t.n100(n100)),
|
||||
@@ -249,8 +227,7 @@ impl<'map> AnyPP<'map> {
|
||||
/// Specify the amount of 50s of a play.
|
||||
///
|
||||
/// Irrelevant for osu!taiko.
|
||||
#[inline]
|
||||
pub fn n50(self, n50: usize) -> Self {
|
||||
pub fn n50(self, n50: u32) -> Self {
|
||||
match self {
|
||||
Self::Osu(o) => Self::Osu(o.n50(n50)),
|
||||
Self::Taiko(_) => self,
|
||||
@@ -264,8 +241,7 @@ impl<'map> AnyPP<'map> {
|
||||
/// 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 {
|
||||
pub fn n_katu(self, n_katu: u32) -> Self {
|
||||
match self {
|
||||
Self::Osu(_) | Self::Taiko(_) => self,
|
||||
Self::Catch(f) => Self::Catch(f.tiny_droplet_misses(n_katu)),
|
||||
@@ -277,8 +253,7 @@ impl<'map> AnyPP<'map> {
|
||||
///
|
||||
/// 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 {
|
||||
pub fn n_geki(self, n_geki: u32) -> Self {
|
||||
match self {
|
||||
Self::Osu(_) | Self::Taiko(_) | Self::Catch(_) => self,
|
||||
Self::Mania(m) => Self::Mania(m.n320(n_geki)),
|
||||
@@ -286,7 +261,6 @@ impl<'map> AnyPP<'map> {
|
||||
}
|
||||
|
||||
/// Create the [`ScoreState`] that will be used for performance calculation.
|
||||
#[inline]
|
||||
pub fn generate_state(&mut self) -> ScoreState {
|
||||
match self {
|
||||
Self::Osu(o) => o.generate_state().into(),
|
||||
@@ -297,15 +271,14 @@ impl<'map> AnyPP<'map> {
|
||||
}
|
||||
}
|
||||
|
||||
impl<A: AttributeProvider> From<A> for AnyPP<'_> {
|
||||
#[inline]
|
||||
impl<A: AttributeProvider> From<A> for Performance<'_> {
|
||||
fn from(attrs: A) -> Self {
|
||||
fn inner(attrs: DifficultyAttributes) -> AnyPP<'static> {
|
||||
fn inner(attrs: DifficultyAttributes) -> Performance<'static> {
|
||||
match attrs {
|
||||
DifficultyAttributes::Osu(attrs) => AnyPP::Osu(attrs.pp()),
|
||||
DifficultyAttributes::Taiko(attrs) => AnyPP::Taiko(attrs.pp()),
|
||||
DifficultyAttributes::Catch(attrs) => AnyPP::Catch(attrs.pp()),
|
||||
DifficultyAttributes::Mania(attrs) => AnyPP::Mania(attrs.pp()),
|
||||
DifficultyAttributes::Osu(attrs) => Performance::Osu(attrs.pp()),
|
||||
DifficultyAttributes::Taiko(attrs) => Performance::Taiko(attrs.pp()),
|
||||
DifficultyAttributes::Catch(attrs) => Performance::Catch(attrs.pp()),
|
||||
DifficultyAttributes::Mania(attrs) => Performance::Mania(attrs.pp()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -313,66 +286,33 @@ impl<A: AttributeProvider> From<A> for AnyPP<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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)
|
||||
macro_rules! impl_from_mode {
|
||||
( $mode:ident: $performance:ident ) => {
|
||||
impl<'a> From<Converted<'a, $mode>> for Performance<'a> {
|
||||
fn from(converted: Converted<'a, $mode>) -> Self {
|
||||
Self::$mode($performance::new(converted))
|
||||
}
|
||||
}
|
||||
|
||||
impl AttributeProvider for $performance {
|
||||
#[inline]
|
||||
fn attributes(self) -> DifficultyAttributes {
|
||||
DifficultyAttributes::$mode(self.difficulty)
|
||||
impl<'a, 'b: 'a> From<&'b Converted<'a, $mode>> for Performance<'a> {
|
||||
fn from(converted: &'b Converted<'a, $mode>) -> Self {
|
||||
Self::$mode($performance::new(converted.as_owned()))
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
impl_attr_provider!(Catch: CatchDifficultyAttributes, CatchPerformanceAttributes);
|
||||
impl_attr_provider!(Mania: ManiaDifficultyAttributes, ManiaPerformanceAttributes);
|
||||
impl_attr_provider!(Osu: OsuDifficultyAttributes, OsuPerformanceAttributes);
|
||||
impl_attr_provider!(Taiko: TaikoDifficultyAttributes, TaikoPerformanceAttributes);
|
||||
impl_from_mode!(Osu: OsuPerformance);
|
||||
impl_from_mode!(Taiko: TaikoPerformance);
|
||||
impl_from_mode!(Catch: CatchPerformance);
|
||||
impl_from_mode!(Mania: ManiaPerformance);
|
||||
|
||||
/// While generating remaining hitresults, decide how they should be distributed.
|
||||
#[derive(Copy, Clone, Debug, Default, Eq, PartialEq)]
|
||||
pub enum HitResultPriority {
|
||||
/// Prioritize good hitresults over bad ones
|
||||
#[default]
|
||||
BestCase,
|
||||
/// Prioritize bad hitresults over good ones
|
||||
WorstCase,
|
||||
}
|
||||
@@ -1,44 +1,44 @@
|
||||
use rosu_map::section::general::GameMode;
|
||||
|
||||
use crate::{
|
||||
catch::CatchScoreState, mania::ManiaScoreState, osu::OsuScoreState, taiko::TaikoScoreState,
|
||||
GameMode,
|
||||
};
|
||||
|
||||
/// 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.
|
||||
/// Aggregation for a score's current state.
|
||||
#[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.
|
||||
/// 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.
|
||||
/// Note that for osu!catch only fruits and droplets are considered for
|
||||
/// combo.
|
||||
///
|
||||
/// Irrelevant for osu!mania.
|
||||
pub max_combo: usize,
|
||||
pub max_combo: u32,
|
||||
/// 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,
|
||||
pub n_geki: u32,
|
||||
/// Amount of current katus (tiny droplet misses for osu!catch / n200 for
|
||||
/// osu!mania).
|
||||
pub n_katu: u32,
|
||||
/// Amount of current 300s (fruits for osu!catch).
|
||||
pub n300: usize,
|
||||
pub n300: u32,
|
||||
/// Amount of current 100s (droplets for osu!catch).
|
||||
pub n100: usize,
|
||||
pub n100: u32,
|
||||
/// Amount of current 50s (tiny droplets for osu!catch).
|
||||
pub n50: usize,
|
||||
pub n50: u32,
|
||||
/// Amount of current misses (fruits + droplets for osu!catch).
|
||||
pub n_misses: usize,
|
||||
pub n_misses: u32,
|
||||
}
|
||||
|
||||
impl ScoreState {
|
||||
/// Create a new empty score state.
|
||||
#[inline]
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Return the total amount of hits by adding everything up based on the mode.
|
||||
#[inline]
|
||||
pub fn total_hits(&self, mode: GameMode) -> usize {
|
||||
/// Return the total amount of hits by adding everything up based on the
|
||||
/// mode.
|
||||
pub fn total_hits(&self, mode: GameMode) -> u32 {
|
||||
let mut amount = self.n300 + self.n100 + self.n_misses;
|
||||
|
||||
if mode != GameMode::Taiko {
|
||||
@@ -46,7 +46,7 @@ impl ScoreState {
|
||||
|
||||
if mode != GameMode::Osu {
|
||||
amount += self.n_katu;
|
||||
amount += usize::from(mode != GameMode::Catch) * self.n_geki;
|
||||
amount += u32::from(mode != GameMode::Catch) * self.n_geki;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,7 +55,6 @@ impl ScoreState {
|
||||
}
|
||||
|
||||
impl From<ScoreState> for OsuScoreState {
|
||||
#[inline]
|
||||
fn from(state: ScoreState) -> Self {
|
||||
Self {
|
||||
max_combo: state.max_combo,
|
||||
@@ -68,7 +67,6 @@ impl From<ScoreState> for OsuScoreState {
|
||||
}
|
||||
|
||||
impl From<ScoreState> for TaikoScoreState {
|
||||
#[inline]
|
||||
fn from(state: ScoreState) -> Self {
|
||||
Self {
|
||||
max_combo: state.max_combo,
|
||||
@@ -80,7 +78,6 @@ impl From<ScoreState> for TaikoScoreState {
|
||||
}
|
||||
|
||||
impl From<ScoreState> for CatchScoreState {
|
||||
#[inline]
|
||||
fn from(state: ScoreState) -> Self {
|
||||
Self {
|
||||
max_combo: state.max_combo,
|
||||
@@ -94,7 +91,6 @@ impl From<ScoreState> for CatchScoreState {
|
||||
}
|
||||
|
||||
impl From<ScoreState> for ManiaScoreState {
|
||||
#[inline]
|
||||
fn from(state: ScoreState) -> Self {
|
||||
Self {
|
||||
n320: state.n_geki,
|
||||
@@ -108,7 +104,6 @@ impl From<ScoreState> for ManiaScoreState {
|
||||
}
|
||||
|
||||
impl From<OsuScoreState> for ScoreState {
|
||||
#[inline]
|
||||
fn from(state: OsuScoreState) -> Self {
|
||||
Self {
|
||||
max_combo: state.max_combo,
|
||||
@@ -123,7 +118,6 @@ impl From<OsuScoreState> for ScoreState {
|
||||
}
|
||||
|
||||
impl From<TaikoScoreState> for ScoreState {
|
||||
#[inline]
|
||||
fn from(state: TaikoScoreState) -> Self {
|
||||
Self {
|
||||
max_combo: state.max_combo,
|
||||
@@ -138,7 +132,6 @@ impl From<TaikoScoreState> for ScoreState {
|
||||
}
|
||||
|
||||
impl From<CatchScoreState> for ScoreState {
|
||||
#[inline]
|
||||
fn from(state: CatchScoreState) -> Self {
|
||||
Self {
|
||||
max_combo: state.max_combo,
|
||||
@@ -153,7 +146,6 @@ impl From<CatchScoreState> for ScoreState {
|
||||
}
|
||||
|
||||
impl From<ManiaScoreState> for ScoreState {
|
||||
#[inline]
|
||||
fn from(state: ManiaScoreState) -> Self {
|
||||
Self {
|
||||
max_combo: 0,
|
||||
@@ -1,16 +0,0 @@
|
||||
/// 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
|
||||
}
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
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 const fn new(time: f64, beat_len: f64) -> Self {
|
||||
Self { beat_len, time }
|
||||
}
|
||||
}
|
||||
|
||||
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_mult = if beat_len < 0.0 {
|
||||
f64::from(((-beat_len) as f32).clamp(10.0, 10_000.0)) / 100.0
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
|
||||
Self {
|
||||
time,
|
||||
slider_vel: speed_multiplier.clamp(0.1, 10.0),
|
||||
bpm_mult,
|
||||
generate_ticks: !beat_len.is_nan(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn is_redundant(&self, existing: &Self) -> 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 const fn new(time: f64, kiai: bool) -> Self {
|
||||
Self { time, kiai }
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialOrd for EffectPoint {
|
||||
#[inline]
|
||||
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
|
||||
self.time.partial_cmp(&other.time)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for EffectPoint {
|
||||
#[inline]
|
||||
fn default() -> Self {
|
||||
Self::new(0.0, Self::DEFAULT_KIAI)
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
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) const 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 * f64::from(self.gen_signed())
|
||||
}
|
||||
|
||||
pub(crate) fn gen_int_range(&mut self, min: i32, max: i32) -> i32 {
|
||||
(f64::from(min) + self.gen_double() * f64::from(max - min)) as i32
|
||||
}
|
||||
}
|
||||
@@ -1,220 +0,0 @@
|
||||
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 {
|
||||
#[allow(clippy::too_many_lines)]
|
||||
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 =
|
||||
f64::from(slider_or_spinner_count as f32 / self.hit_objects.len() as f32);
|
||||
|
||||
let target_columns = if percent_slider_or_spinner < 0.2 {
|
||||
7.0
|
||||
} else if percent_slider_or_spinner < 0.3 || rounded_cs >= 5.0 {
|
||||
f32::from(6 + u8::from(rounded_od > 5.0))
|
||||
} else if percent_slider_or_spinner > 0.6 {
|
||||
f32::from(4 + u8::from(rounded_od > 4.0))
|
||||
} else {
|
||||
(rounded_od + 1.0).clamp(4.0, 7.0)
|
||||
};
|
||||
|
||||
map.cs = target_columns;
|
||||
|
||||
let mut prev_note_times: LimitedQueue<f64, MAX_NOTES_FOR_DENSITY> = LimitedQueue::new();
|
||||
let mut density = f64::from(i32::MAX);
|
||||
|
||||
let mut compute_density = |new_note_time: f64, d: &mut f64| {
|
||||
prev_note_times.push(new_note_time);
|
||||
|
||||
if 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 = f64::from(gen.segment_duration);
|
||||
|
||||
for i in 0..=repeats as i32 + 1 {
|
||||
let time = obj.start_time + segment_duration * f64::from(i);
|
||||
|
||||
last_values.time = time;
|
||||
last_values.pos = obj.pos;
|
||||
|
||||
compute_density(time, &mut density);
|
||||
}
|
||||
|
||||
for new_pattern in gen.generate() {
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
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 {
|
||||
i32::from(self.total_columns() == 8)
|
||||
}
|
||||
|
||||
fn get_column(&self, allow_special: Option<bool>) -> u8 {
|
||||
let allow_special = allow_special.unwrap_or(false);
|
||||
|
||||
if allow_special && self.total_columns() == 8 {
|
||||
const LOCAL_X_DIVISOR: f32 = 512.0 / 7.0;
|
||||
|
||||
((self.hit_object().pos.x / LOCAL_X_DIVISOR).floor() as u8).clamp(0, 6) + 1
|
||||
} else {
|
||||
ManiaObject::column(self.hit_object().pos.x, self.total_columns() as f32) as u8
|
||||
}
|
||||
}
|
||||
|
||||
fn get_random_note_count(
|
||||
&mut self,
|
||||
p2: f64,
|
||||
p3: f64,
|
||||
p4: Option<f64>,
|
||||
p5: Option<f64>,
|
||||
p6: Option<f64>,
|
||||
) -> i32 {
|
||||
let p4 = p4.unwrap_or(0.0);
|
||||
let p5 = p5.unwrap_or(0.0);
|
||||
let p6 = p6.unwrap_or(0.0);
|
||||
|
||||
let val = self.random().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 + i32::from(val >= 1.0 - p2)
|
||||
}
|
||||
}
|
||||
|
||||
fn conversion_difficulty(&self) -> f64 {
|
||||
let orig = self.original_map();
|
||||
let last_obj_time = orig.hit_objects.last().map_or(0.0, |h| h.start_time);
|
||||
let first_obj_time = orig.hit_objects.first().map_or(0.0, |h| h.start_time);
|
||||
|
||||
// * Drain time in seconds
|
||||
let total_break_time = orig.total_break_time();
|
||||
let mut drain_time = ((last_obj_time - first_obj_time - total_break_time) / 1000.0) as i32;
|
||||
|
||||
if drain_time == 0 {
|
||||
drain_time = 10_000;
|
||||
}
|
||||
|
||||
let mut conversion_difficulty = 0.0;
|
||||
conversion_difficulty += f64::from(orig.hp + orig.ar.clamp(4.0, 7.0)) / 1.5;
|
||||
conversion_difficulty += orig.hit_objects.len() as f64 / f64::from(drain_time) * 9.0;
|
||||
conversion_difficulty /= 38.0;
|
||||
conversion_difficulty *= 5.0;
|
||||
conversion_difficulty /= 1.15;
|
||||
conversion_difficulty = conversion_difficulty.min(12.0);
|
||||
|
||||
conversion_difficulty
|
||||
}
|
||||
|
||||
fn get_random_column(&mut self, lower: Option<i32>, upper: Option<i32>) -> u8 {
|
||||
let lower = lower.unwrap_or_else(|| self.random_start());
|
||||
let upper = upper.unwrap_or_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(i32::from(initial_column)) {
|
||||
return initial_column;
|
||||
}
|
||||
|
||||
// * Ensure that we have at least one free column, so that an endless loop is avoided
|
||||
let has_valid_column = (lower..upper).any(is_valid);
|
||||
assert!(has_valid_column);
|
||||
|
||||
// * Iterate until a valid column is found. This is a random iteration in the default case.
|
||||
while {
|
||||
initial_column = if let Some(fun) = next_column {
|
||||
(fun)(self, initial_column)
|
||||
} else {
|
||||
PatternGenerator::get_random_column(self, Some(lower), Some(upper))
|
||||
};
|
||||
|
||||
!is_valid(i32::from(initial_column))
|
||||
} {}
|
||||
|
||||
initial_column
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
mod mania;
|
||||
mod taiko;
|
||||
@@ -1,158 +0,0 @@
|
||||
use crate::{
|
||||
curve::{Curve, CurveBuffers},
|
||||
parse::{HitObject, HitObjectKind, Pos2},
|
||||
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 *= f64::from(LEGACY_TAIKO_VELOCITY_MULTIPLIER);
|
||||
|
||||
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
|
||||
+ f64::from(params.duration)
|
||||
+ params.tick_spacing / 8.0
|
||||
{
|
||||
let h = HitObject {
|
||||
pos: Pos2::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 * f64::from(LEGACY_TAIKO_VELOCITY_MULTIPLIER);
|
||||
|
||||
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 =
|
||||
f64::from(OSU_BASE_SCORING_DIST) * 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 * (f64::from(1000.0_f32) / 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(f64::from(*duration) / 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> {
|
||||
const fn new(start_time: f64, repeats: usize, curve: &'c Curve<'c>) -> Self {
|
||||
Self {
|
||||
curve,
|
||||
repeats,
|
||||
start_time,
|
||||
duration: 0,
|
||||
tick_spacing: 0.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,182 +0,0 @@
|
||||
use crate::{
|
||||
catch::{
|
||||
calculate_catch_width, CatchDifficultyAttributes, CatchObject, FruitOrJuice, FruitParams,
|
||||
ALLOWED_CATCH_RANGE,
|
||||
},
|
||||
curve::CurveBuffers,
|
||||
mania::{ManiaObject, ObjectParameters},
|
||||
osu::{OsuDifficultyAttributes, OsuObject, ScalingFactor},
|
||||
taiko::{IntoTaikoObjectIter, TaikoObject},
|
||||
util::FloatExt,
|
||||
AnyPP, AnyStars, Beatmap, CatchPP, CatchStars, GameMode, ManiaPP, ManiaStars, Mods, OsuPP,
|
||||
OsuStars, PerformanceAttributes, Strains, TaikoPP, TaikoStars,
|
||||
};
|
||||
|
||||
/// 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;
|
||||
|
||||
/// Process each [`HitObject`](crate::parse::HitObject) into a an osu!-specific [`OsuObject`],
|
||||
/// just like the difficulty calculation does.
|
||||
fn osu_hitobjects(&self, mods: u32) -> Vec<OsuObject>;
|
||||
|
||||
/// Process each [`HitObject`](crate::parse::HitObject) into a an osu!taiko-specific [`TaikoObject`],
|
||||
/// just like the difficulty calculation does.
|
||||
///
|
||||
/// Clockrate is *not* considered.
|
||||
fn taiko_hitobjects(&self) -> Vec<TaikoObject>;
|
||||
|
||||
/// Process each [`HitObject`](crate::parse::HitObject) into a an osu!ctb-specific [`CatchObject`],
|
||||
/// just like the difficulty calculation does.
|
||||
///
|
||||
/// A [`CatchObject`] is either a fruit or a droplet which means
|
||||
/// tiny droplets and bananas are not included.
|
||||
fn catch_hitobjects(&self, mods: u32) -> Vec<CatchObject>;
|
||||
|
||||
/// Process each [`HitObject`](crate::parse::HitObject) into a an osu!mania-specific [`ManiaObject`],
|
||||
/// just like the difficulty calculation does.
|
||||
///
|
||||
/// Clockrate is *not* considered.
|
||||
fn mania_hitobjects(&self) -> Vec<ManiaObject>;
|
||||
}
|
||||
|
||||
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()),
|
||||
}
|
||||
}
|
||||
|
||||
fn osu_hitobjects(&self, mods: u32) -> Vec<OsuObject> {
|
||||
let attrs = self.attributes().mods(mods).build();
|
||||
let scaling_factor = ScalingFactor::new(attrs.cs);
|
||||
let hr = mods.hr();
|
||||
let time_preempt = f64::from((attrs.hit_windows.ar * attrs.clock_rate) as f32);
|
||||
let mut attrs = OsuDifficultyAttributes::default();
|
||||
|
||||
crate::osu::create_osu_objects(
|
||||
self,
|
||||
&mut attrs,
|
||||
&scaling_factor,
|
||||
usize::MAX,
|
||||
hr,
|
||||
time_preempt,
|
||||
)
|
||||
}
|
||||
|
||||
fn taiko_hitobjects(&self) -> Vec<TaikoObject> {
|
||||
let map = self.convert_mode(GameMode::Taiko);
|
||||
|
||||
map.taiko_objects()
|
||||
.map(|(h, start_time)| TaikoObject {
|
||||
start_time,
|
||||
is_hit: h.is_hit,
|
||||
is_rim: h.is_rim,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn catch_hitobjects(&self, mods: u32) -> Vec<CatchObject> {
|
||||
let attrs = self.attributes().mods(mods).build();
|
||||
|
||||
let mut params = FruitParams {
|
||||
attributes: CatchDifficultyAttributes::default(),
|
||||
curve_bufs: CurveBuffers::default(),
|
||||
last_pos: None,
|
||||
last_time: 0.0,
|
||||
ticks: Vec::new(),
|
||||
with_hr: mods.hr(),
|
||||
};
|
||||
|
||||
let mut hit_objects: Vec<_> = self
|
||||
.hit_objects
|
||||
.iter()
|
||||
.filter_map(|h| FruitOrJuice::new(h, &mut params, self))
|
||||
.flatten()
|
||||
.collect();
|
||||
|
||||
let half_catcher_width =
|
||||
f64::from(calculate_catch_width(attrs.cs as f32) / 2.0 / ALLOWED_CATCH_RANGE);
|
||||
let mut last_direction = 0;
|
||||
let mut last_excess = half_catcher_width;
|
||||
|
||||
for i in 1..hit_objects.len() {
|
||||
// SAFETY: The indices are guaranteed the be included based on the loop condition
|
||||
let window = unsafe { hit_objects.get_unchecked_mut(i - 1..=i) };
|
||||
let [curr, next] = window else { unreachable!() };
|
||||
|
||||
curr.init_hyper_dash(
|
||||
half_catcher_width,
|
||||
&*next,
|
||||
&mut last_direction,
|
||||
&mut last_excess,
|
||||
);
|
||||
}
|
||||
|
||||
hit_objects
|
||||
}
|
||||
|
||||
fn mania_hitobjects(&self) -> Vec<ManiaObject> {
|
||||
let map = self.convert_mode(GameMode::Mania);
|
||||
let total_columns = map.cs.round_even().max(1.0);
|
||||
let mut params = ObjectParameters::new(map.as_ref());
|
||||
|
||||
self.hit_objects
|
||||
.iter()
|
||||
.map(|h| ManiaObject::new(h, total_columns, &mut params))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
@@ -1,232 +0,0 @@
|
||||
use std::{borrow::Cow, cmp::Ordering, collections::HashMap};
|
||||
|
||||
use crate::parse::HitObject;
|
||||
|
||||
pub use self::{
|
||||
attributes::{BeatmapAttributes, BeatmapAttributesBuilder, BeatmapHitWindows},
|
||||
breaks::Break,
|
||||
control_points::{DifficultyPoint, EffectPoint, TimingPoint},
|
||||
ext::*,
|
||||
mode::GameMode,
|
||||
sorted_vec::{Sortable, SortedVec},
|
||||
};
|
||||
|
||||
mod attributes;
|
||||
mod breaks;
|
||||
mod control_points;
|
||||
mod converts;
|
||||
mod ext;
|
||||
mod mode;
|
||||
mod sorted_vec;
|
||||
|
||||
/// 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>,
|
||||
}
|
||||
|
||||
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 {
|
||||
// This is incorrect if the last object is a slider since there
|
||||
// is no reasonable way to get the slider end time at this point.
|
||||
let last_time = self
|
||||
.hit_objects
|
||||
.last()
|
||||
.map(HitObject::end_time)
|
||||
.or_else(|| self.timing_points.last().map(|t| t.time))
|
||||
.unwrap_or(0.0);
|
||||
|
||||
/// Maps `beat_len` to a cumulative duration
|
||||
#[derive(Debug)]
|
||||
struct BeatLenDuration {
|
||||
last_time: f64,
|
||||
map: HashMap<u64, f64>,
|
||||
}
|
||||
|
||||
impl BeatLenDuration {
|
||||
fn new(last_time: f64) -> Self {
|
||||
Self {
|
||||
last_time,
|
||||
map: HashMap::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn add(&mut self, beat_len: f64, curr_time: f64, next_time: f64) {
|
||||
let beat_len = (1000.0 * beat_len).round() / 1000.0;
|
||||
let entry = self.map.entry(beat_len.to_bits()).or_default();
|
||||
|
||||
if curr_time <= self.last_time {
|
||||
*entry += next_time - curr_time;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut bpm_points = BeatLenDuration::new(last_time);
|
||||
|
||||
// * osu-stable forced the first control point to start at 0.
|
||||
// * This is reproduced here to maintain compatibility around
|
||||
// * osu!mania scroll speed and song select display.
|
||||
match &self.timing_points[..] {
|
||||
[curr] => bpm_points.add(curr.beat_len, 0.0, last_time),
|
||||
[curr, next, ..] => bpm_points.add(curr.beat_len, 0.0, next.time),
|
||||
[] => {}
|
||||
}
|
||||
|
||||
self.timing_points
|
||||
.iter()
|
||||
.skip(1)
|
||||
.zip(self.timing_points.iter().skip(2).map(|t| t.time))
|
||||
.for_each(|(curr, next_time)| bpm_points.add(curr.beat_len, curr.time, next_time));
|
||||
|
||||
if let [.., _, curr] = &self.timing_points[..] {
|
||||
bpm_points.add(curr.beat_len, curr.time, last_time);
|
||||
}
|
||||
|
||||
let most_common_beat_len = bpm_points
|
||||
.map
|
||||
.into_iter()
|
||||
// * Get the most common one, or 0 as a suitable default
|
||||
.max_by(|(_, a), (_, b)| a.total_cmp(b))
|
||||
.map_or(0.0, |(beat_len, _)| f64::from_bits(beat_len));
|
||||
|
||||
60_000.0 / most_common_beat_len
|
||||
}
|
||||
|
||||
/// 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((usize::from(with_sounds)) * 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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
/// The mode of a beatmap.
|
||||
#[derive(Copy, Clone, Debug, Default, Hash, PartialEq, Eq)]
|
||||
pub enum GameMode {
|
||||
/// osu!standard
|
||||
#[default]
|
||||
Osu = 0,
|
||||
/// osu!taiko
|
||||
Taiko = 1,
|
||||
/// osu!catch
|
||||
Catch = 2,
|
||||
/// osu!mania
|
||||
Mania = 3,
|
||||
}
|
||||
|
||||
impl From<u8> for GameMode {
|
||||
#[inline]
|
||||
fn from(mode: u8) -> Self {
|
||||
// `0` will happen most commonly so it should be the first branch
|
||||
#[allow(clippy::match_same_arms)]
|
||||
match mode {
|
||||
0 => Self::Osu,
|
||||
1 => Self::Taiko,
|
||||
2 => Self::Catch,
|
||||
3 => Self::Mania,
|
||||
_ => Self::Osu,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,293 +0,0 @@
|
||||
use std::{
|
||||
cmp::Ordering,
|
||||
fmt::{Debug, Formatter, Result as FmtResult},
|
||||
ops::{Deref, Index},
|
||||
slice::SliceIndex,
|
||||
};
|
||||
|
||||
use crate::beatmap::{DifficultyPoint, EffectPoint, TimingPoint};
|
||||
|
||||
/// A [`Vec`] whose elements are guaranteed to be unique and in order.
|
||||
#[derive(Clone)]
|
||||
pub struct SortedVec<T> {
|
||||
inner: Vec<T>,
|
||||
}
|
||||
|
||||
impl<T> SortedVec<T> {
|
||||
/// Constructs a new, empty `SortedVec<T>`.
|
||||
#[inline]
|
||||
pub const fn new() -> Self {
|
||||
Self { inner: Vec::new() }
|
||||
}
|
||||
|
||||
/// Constructs a new, empty `SortedVec<T>` with at least the specified capacity.
|
||||
#[inline]
|
||||
pub fn with_capacity(capacity: usize) -> Self {
|
||||
Self {
|
||||
inner: Vec::with_capacity(capacity),
|
||||
}
|
||||
}
|
||||
|
||||
/// Extracts the inner [`Vec`].
|
||||
#[inline]
|
||||
pub fn into_inner(self) -> Vec<T> {
|
||||
self.inner
|
||||
}
|
||||
|
||||
/// Extracts a slice containing the entire sorted vector.
|
||||
pub fn as_slice(&self) -> &[T] {
|
||||
self.inner.as_slice()
|
||||
}
|
||||
|
||||
/// Returns a mutable reference to the underlying `Vec`.
|
||||
///
|
||||
/// # Safety
|
||||
///
|
||||
/// The caller must ensure that the items stay in order.
|
||||
pub unsafe fn as_inner_mut(&mut self) -> &mut Vec<T> {
|
||||
&mut self.inner
|
||||
}
|
||||
|
||||
/// Removes the last element and returns it, or `None` if the vec is empty.
|
||||
pub fn pop(&mut self) -> Option<T> {
|
||||
self.inner.pop()
|
||||
}
|
||||
|
||||
/// Retains only the elements specified by the predicate.
|
||||
///
|
||||
/// In other words, remove all items `i` for which `f(&i)` returns `false`.
|
||||
pub fn retain<F>(&mut self, f: F)
|
||||
where
|
||||
F: FnMut(&T) -> bool,
|
||||
{
|
||||
self.inner.retain(f);
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Sortable> SortedVec<T> {
|
||||
/// Same as [`slice::binary_search_by`] with the function
|
||||
/// [`<T as Sortable>::cmp`](Sortable::cmp).
|
||||
#[inline]
|
||||
pub fn find(&self, value: &T) -> Result<usize, usize> {
|
||||
self.inner
|
||||
.binary_search_by(|probe| <T as Sortable>::cmp(probe, value))
|
||||
}
|
||||
|
||||
/// Push a new value into the sorted list based on [`<T as Sortable>::push`](Sortable::push).
|
||||
pub fn push(&mut self, value: T) {
|
||||
<T as Sortable>::push(value, self);
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Deref for SortedVec<T> {
|
||||
type Target = [T];
|
||||
|
||||
#[inline]
|
||||
fn deref(&self) -> &Self::Target {
|
||||
<Vec<T> as Deref>::deref(&self.inner)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T, I> Index<I> for SortedVec<T>
|
||||
where
|
||||
I: SliceIndex<[T]>,
|
||||
{
|
||||
type Output = <I as SliceIndex<[T]>>::Output;
|
||||
|
||||
#[inline]
|
||||
fn index(&self, index: I) -> &Self::Output {
|
||||
<Vec<T> as Index<I>>::index(&self.inner, index)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Debug> Debug for SortedVec<T> {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
|
||||
<Vec<T> as Debug>::fmt(&self.inner, f)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Default for SortedVec<T> {
|
||||
#[inline]
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Sortable> From<Vec<T>> for SortedVec<T> {
|
||||
fn from(mut v: Vec<T>) -> Self {
|
||||
v.sort_by(<T as Sortable>::cmp);
|
||||
v.dedup_by(|a, b| {
|
||||
<T as Sortable>::cmp(a, b) == Ordering::Equal || <T as Sortable>::is_redundant(b, a)
|
||||
});
|
||||
|
||||
Self { inner: v }
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Sortable> FromIterator<T> for SortedVec<T> {
|
||||
fn from_iter<I: IntoIterator<Item = T>>(iter: I) -> Self {
|
||||
Self::from(Vec::from_iter(iter))
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Sortable> Extend<T> for SortedVec<T> {
|
||||
fn extend<I: IntoIterator<Item = T>>(&mut self, iter: I) {
|
||||
for value in iter {
|
||||
self.push(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Trait for types that can be sorted in a [`SortedVec`].
|
||||
pub trait Sortable: Sized {
|
||||
/// An [`Ordering`] between `self` and `other`.
|
||||
fn cmp(&self, other: &Self) -> Ordering;
|
||||
|
||||
/// Indicates whether `self` and `_existing` are identical.
|
||||
#[inline]
|
||||
fn is_redundant(&self, _existing: &Self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Pushes a value into the [`SortedVec`].
|
||||
#[inline]
|
||||
fn push(self, sorted_vec: &mut SortedVec<Self>) {
|
||||
match sorted_vec.find(&self) {
|
||||
Ok(i) => sorted_vec.inner[i] = self,
|
||||
Err(i) if i == sorted_vec.len() => sorted_vec.inner.push(self),
|
||||
Err(i) => sorted_vec.inner.insert(i, self),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Sortable for TimingPoint {
|
||||
#[inline]
|
||||
fn cmp(&self, other: &Self) -> Ordering {
|
||||
self.partial_cmp(other).unwrap_or(Ordering::Equal)
|
||||
}
|
||||
}
|
||||
|
||||
impl Sortable for DifficultyPoint {
|
||||
#[inline]
|
||||
fn cmp(&self, other: &Self) -> Ordering {
|
||||
self.partial_cmp(other).unwrap_or(Ordering::Equal)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn is_redundant(&self, existing: &Self) -> bool {
|
||||
self.is_redundant(existing)
|
||||
}
|
||||
|
||||
fn push(self, sorted_vec: &mut SortedVec<Self>) {
|
||||
enum Action {
|
||||
Insert(usize),
|
||||
Replace(usize),
|
||||
Push,
|
||||
Skip,
|
||||
}
|
||||
|
||||
let action = match sorted_vec.find(&self).map_err(|idx| idx.checked_sub(1)) {
|
||||
Ok(i) | Err(Some(i)) if self.is_redundant(&sorted_vec[i]) => Action::Skip,
|
||||
Ok(i) => Action::Replace(i),
|
||||
Err(Some(i)) if i == sorted_vec.len() - 1 => Action::Push,
|
||||
Err(Some(i)) => Action::Insert(i),
|
||||
Err(None) if self.is_redundant(&Self::default()) => Action::Skip,
|
||||
Err(None) => Action::Insert(0),
|
||||
};
|
||||
|
||||
match action {
|
||||
Action::Insert(i) => sorted_vec.inner.insert(i, self),
|
||||
Action::Replace(i) => sorted_vec.inner[i] = self,
|
||||
Action::Push => sorted_vec.inner.push(self),
|
||||
Action::Skip => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Sortable for EffectPoint {
|
||||
#[inline]
|
||||
fn cmp(&self, other: &Self) -> Ordering {
|
||||
self.partial_cmp(other).unwrap_or(Ordering::Equal)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Ord> Sortable for T {
|
||||
#[inline]
|
||||
fn cmp(&self, other: &Self) -> Ordering {
|
||||
<Self as Ord>::cmp(self, other)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::beatmap::DifficultyPoint;
|
||||
|
||||
use super::SortedVec;
|
||||
|
||||
#[test]
|
||||
fn sorts_on_push() {
|
||||
let mut v = SortedVec::with_capacity(4);
|
||||
|
||||
v.push(42);
|
||||
v.push(13);
|
||||
v.push(20);
|
||||
v.push(0);
|
||||
|
||||
assert_eq!(v.as_slice(), &[0_i32, 13, 20, 42]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_push_if_redundant() {
|
||||
let mut v = SortedVec::default();
|
||||
|
||||
v.push(DifficultyPoint::default());
|
||||
assert_eq!(v.len(), 0);
|
||||
|
||||
v.push(DifficultyPoint::new(1.0, 2.0, 3.0));
|
||||
assert_eq!(v.len(), 1);
|
||||
|
||||
v.push(DifficultyPoint::new(2.0, 2.0, 3.0));
|
||||
v.push(DifficultyPoint::default());
|
||||
assert_eq!(v.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_iter() {
|
||||
let base = vec![
|
||||
DifficultyPoint {
|
||||
time: 5.0,
|
||||
slider_vel: 10.0,
|
||||
bpm_mult: 1.0,
|
||||
generate_ticks: true,
|
||||
},
|
||||
DifficultyPoint {
|
||||
time: 3.0,
|
||||
slider_vel: 20.0,
|
||||
bpm_mult: 2.0,
|
||||
generate_ticks: false,
|
||||
},
|
||||
DifficultyPoint {
|
||||
time: 6.0,
|
||||
slider_vel: 10.0,
|
||||
bpm_mult: 3.0,
|
||||
generate_ticks: true,
|
||||
},
|
||||
DifficultyPoint {
|
||||
time: 10.0,
|
||||
slider_vel: 15.0,
|
||||
bpm_mult: 4.0,
|
||||
generate_ticks: true,
|
||||
},
|
||||
];
|
||||
|
||||
let sorted = SortedVec::from_iter(base);
|
||||
|
||||
let v: Vec<_> = sorted
|
||||
.into_inner()
|
||||
.into_iter()
|
||||
.map(|tp| tp.bpm_mult)
|
||||
.collect();
|
||||
|
||||
assert_eq!(v, vec![2.0, 1.0, 4.0]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
use crate::catch::performance::CatchPerformance;
|
||||
|
||||
/// 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: u32,
|
||||
/// The amount of droplets.
|
||||
pub n_droplets: u32,
|
||||
/// The amount of tiny droplets.
|
||||
pub n_tiny_droplets: u32,
|
||||
/// Whether the [`Beatmap`] was a convert i.e. an osu!standard map.
|
||||
///
|
||||
/// [`Beatmap`]: crate::model::beatmap::Beatmap
|
||||
pub is_convert: bool,
|
||||
}
|
||||
|
||||
impl CatchDifficultyAttributes {
|
||||
/// Return the maximum combo.
|
||||
pub const fn max_combo(&self) -> u32 {
|
||||
self.n_fruits + self.n_droplets
|
||||
}
|
||||
|
||||
/// Whether the [`Beatmap`] was a convert i.e. an osu!standard map.
|
||||
///
|
||||
/// [`Beatmap`]: crate::model::beatmap::Beatmap
|
||||
pub const fn is_convert(&self) -> bool {
|
||||
self.is_convert
|
||||
}
|
||||
|
||||
/// Returns a builder for performance calculation.
|
||||
pub fn pp<'a>(self) -> CatchPerformance<'a> {
|
||||
self.into()
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
pub const fn stars(&self) -> f64 {
|
||||
self.difficulty.stars
|
||||
}
|
||||
|
||||
/// Return the performance point value.
|
||||
pub const fn pp(&self) -> f64 {
|
||||
self.pp
|
||||
}
|
||||
|
||||
/// Return the maximum combo of the map.
|
||||
pub const fn max_combo(&self) -> u32 {
|
||||
self.difficulty.max_combo()
|
||||
}
|
||||
|
||||
/// Whether the [`Beatmap`] was a convert i.e. an osu!standard map.
|
||||
///
|
||||
/// [`Beatmap`]: crate::model::beatmap::Beatmap
|
||||
pub const fn is_convert(&self) -> bool {
|
||||
self.difficulty.is_convert
|
||||
}
|
||||
}
|
||||
|
||||
pub struct CatchDifficultyAttributesBuilder {
|
||||
inner: CatchDifficultyAttributes,
|
||||
take: usize,
|
||||
}
|
||||
|
||||
impl CatchDifficultyAttributesBuilder {
|
||||
pub const fn new(attrs: CatchDifficultyAttributes, take: usize) -> Self {
|
||||
Self { inner: attrs, take }
|
||||
}
|
||||
|
||||
pub const fn into_inner(self) -> CatchDifficultyAttributes {
|
||||
self.inner
|
||||
}
|
||||
|
||||
pub const fn take_more(&self) -> bool {
|
||||
self.take > 0
|
||||
}
|
||||
|
||||
pub fn inc_fruits(&mut self) {
|
||||
Self::inc_value(&mut self.inner.n_fruits, &mut self.take);
|
||||
}
|
||||
|
||||
pub fn inc_droplets(&mut self) {
|
||||
Self::inc_value(&mut self.inner.n_droplets, &mut self.take);
|
||||
}
|
||||
|
||||
/// Should only be used if [`take_more`] returns `true`.
|
||||
///
|
||||
/// [`take_more`]: Self::take_more
|
||||
pub fn inc_tiny_droplets(&mut self) {
|
||||
self.inner.n_tiny_droplets += 1;
|
||||
}
|
||||
|
||||
fn inc_value(value: &mut u32, take: &mut usize) {
|
||||
if *take > 0 {
|
||||
*value += 1;
|
||||
*take -= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
use crate::parse::Pos2;
|
||||
|
||||
use super::fruit_or_juice::FruitParams;
|
||||
|
||||
const PLAYFIELD_WIDTH: f32 = 512.0;
|
||||
const BASE_SPEED: f64 = 1.0;
|
||||
|
||||
/// A [`HitObject`](crate::parse::HitObject) that was processed for the osu!ctb gamemode.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct CatchObject {
|
||||
/// The X position of the object.
|
||||
pub pos: f32,
|
||||
/// The time of the object.
|
||||
pub time: f64,
|
||||
/// Whether the object is a hyper dash.
|
||||
pub hyper_dash: bool,
|
||||
/// The hyper distance to the next object
|
||||
pub hyper_dist: f32,
|
||||
}
|
||||
|
||||
impl CatchObject {
|
||||
#[inline]
|
||||
pub(crate) const 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 = i8::from(next_x > curr_x) * 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 = f64::from((next_x - curr_x).abs()) - 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 = f64::from(hyper_dist).clamp(0.0, half_catcher_width);
|
||||
}
|
||||
|
||||
*last_direction = this_direction;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
pub struct Catcher;
|
||||
|
||||
const AREA_CATCHER_SIZE: f32 = 106.75;
|
||||
|
||||
impl Catcher {
|
||||
pub const BASE_SPEED: f64 = 1.0;
|
||||
pub const ALLOWED_CATCH_RANGE: f32 = 0.8;
|
||||
|
||||
pub fn calculate_catch_width(cs: f32) -> f32 {
|
||||
Self::calculate_catch_width_by_scale(Self::calculate_scale(cs))
|
||||
}
|
||||
|
||||
fn calculate_catch_width_by_scale(scale: f32) -> f32 {
|
||||
AREA_CATCHER_SIZE * scale.abs() * Self::ALLOWED_CATCH_RANGE
|
||||
}
|
||||
|
||||
fn calculate_scale(cs: f32) -> f32 {
|
||||
1.0 - 0.7 * (cs - 5.0) / 5.0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
use std::borrow::Cow;
|
||||
|
||||
use rosu_map::section::{general::GameMode, hit_objects::CurveBuffers};
|
||||
|
||||
use crate::{
|
||||
model::{
|
||||
beatmap::{Beatmap, Converted},
|
||||
hit_object::{HitObject, HitObjectKind, HoldNote, Spinner},
|
||||
mode::ConvertStatus,
|
||||
},
|
||||
util::{float_ext::FloatExt, random::Random},
|
||||
};
|
||||
|
||||
use super::{
|
||||
attributes::CatchDifficultyAttributesBuilder,
|
||||
catcher::Catcher,
|
||||
object::{
|
||||
banana_shower::BananaShower,
|
||||
fruit::Fruit,
|
||||
juice_stream::{JuiceStream, JuiceStreamBufs, NestedJuiceStreamObjectKind},
|
||||
palpable::PalpableObject,
|
||||
},
|
||||
Catch, PLAYFIELD_WIDTH,
|
||||
};
|
||||
|
||||
const RNG_SEED: i32 = 1337;
|
||||
|
||||
/// A [`Beatmap`] for [`Catch`] calculations.
|
||||
pub type CatchBeatmap<'a> = Converted<'a, Catch>;
|
||||
|
||||
pub fn try_convert(map: &mut Cow<'_, Beatmap>) -> ConvertStatus {
|
||||
match map.mode {
|
||||
GameMode::Osu => {
|
||||
map.to_mut().mode = GameMode::Catch;
|
||||
|
||||
ConvertStatus::Done
|
||||
}
|
||||
GameMode::Catch => ConvertStatus::Noop,
|
||||
GameMode::Taiko | GameMode::Mania => ConvertStatus::Incompatible,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn convert_objects(
|
||||
converted: &CatchBeatmap<'_>,
|
||||
attrs: &mut CatchDifficultyAttributesBuilder,
|
||||
hr: bool,
|
||||
cs: f32,
|
||||
) -> Vec<PalpableObject> {
|
||||
// TODO: check for better default capacity
|
||||
let mut palpable_objects = Vec::with_capacity(converted.map.hit_objects.len() * 2);
|
||||
|
||||
let mut bufs = JuiceStreamBufs {
|
||||
curve: CurveBuffers::default(),
|
||||
nested_objects: Vec::new(), // TODO: default capacity
|
||||
ticks: Vec::new(), // TODO: default capacity
|
||||
};
|
||||
|
||||
let mut rng = Random::new(RNG_SEED);
|
||||
let mut last_pos = None;
|
||||
let mut last_start_time = 0.0;
|
||||
|
||||
for h in converted.map.hit_objects.iter() {
|
||||
let mut new_objects = convert_object(h, converted, attrs, &mut bufs);
|
||||
|
||||
apply_pos_offset(
|
||||
&mut new_objects,
|
||||
hr,
|
||||
&mut last_pos,
|
||||
&mut last_start_time,
|
||||
&mut rng,
|
||||
);
|
||||
|
||||
palpable_objects.extend(new_objects);
|
||||
}
|
||||
|
||||
palpable_objects.sort_by(|a, b| a.start_time.total_cmp(&b.start_time));
|
||||
|
||||
initialize_hyper_dash(cs, &mut palpable_objects);
|
||||
|
||||
palpable_objects
|
||||
}
|
||||
|
||||
fn convert_object<'a>(
|
||||
h: &'a HitObject,
|
||||
converted: &CatchBeatmap<'_>,
|
||||
attrs: &mut CatchDifficultyAttributesBuilder,
|
||||
bufs: &'a mut JuiceStreamBufs,
|
||||
) -> ObjectIter<'a> {
|
||||
let state = match h.kind {
|
||||
HitObjectKind::Circle => ObjectIterState::Fruit(Some(Fruit::new(attrs))),
|
||||
HitObjectKind::Slider(ref slider) => {
|
||||
let stream = JuiceStream::new(h.pos.x, h.start_time, slider, converted, attrs, bufs);
|
||||
|
||||
ObjectIterState::JuiceStream(stream)
|
||||
}
|
||||
HitObjectKind::Spinner(Spinner { end_time })
|
||||
| HitObjectKind::Hold(HoldNote { end_time }) => {
|
||||
ObjectIterState::BananaShower(BananaShower::new(h.start_time, end_time))
|
||||
}
|
||||
};
|
||||
|
||||
ObjectIter {
|
||||
x: h.pos.x,
|
||||
start_time: h.start_time,
|
||||
state,
|
||||
}
|
||||
}
|
||||
|
||||
struct ObjectIter<'a> {
|
||||
x: f32,
|
||||
start_time: f64,
|
||||
state: ObjectIterState<'a>,
|
||||
}
|
||||
|
||||
enum ObjectIterState<'a> {
|
||||
Fruit(Option<Fruit>),
|
||||
JuiceStream(JuiceStream<'a>),
|
||||
BananaShower(BananaShower),
|
||||
}
|
||||
|
||||
impl Iterator for ObjectIter<'_> {
|
||||
type Item = PalpableObject;
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
match self.state {
|
||||
ObjectIterState::Fruit(ref mut fruit) => fruit
|
||||
.take()
|
||||
.map(|fruit| PalpableObject::new(self.x, fruit.x_offset, self.start_time)),
|
||||
ObjectIterState::JuiceStream(ref mut stream) => stream
|
||||
.nested_objects
|
||||
.find(|nested| !matches!(nested.kind, NestedJuiceStreamObjectKind::TinyDroplet))
|
||||
.map(|nested| PalpableObject::new(nested.pos, 0.0, nested.start_time)),
|
||||
ObjectIterState::BananaShower(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn size_hint(&self) -> (usize, Option<usize>) {
|
||||
let len = self.len();
|
||||
|
||||
(len, Some(len))
|
||||
}
|
||||
}
|
||||
|
||||
impl ExactSizeIterator for ObjectIter<'_> {
|
||||
fn len(&self) -> usize {
|
||||
match self.state {
|
||||
ObjectIterState::Fruit(ref fruit) => usize::from(fruit.is_some()),
|
||||
ObjectIterState::JuiceStream(ref stream) => stream.nested_objects.len(),
|
||||
ObjectIterState::BananaShower(_) => 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_pos_offset(
|
||||
hit_object: &mut ObjectIter<'_>,
|
||||
should_apply_hr_offset: bool,
|
||||
last_pos: &mut Option<f32>,
|
||||
last_start_time: &mut f64,
|
||||
rng: &mut Random,
|
||||
) {
|
||||
match hit_object.state {
|
||||
ObjectIterState::Fruit(Some(ref mut fruit)) => {
|
||||
if should_apply_hr_offset {
|
||||
apply_hr_offset(
|
||||
hit_object.x,
|
||||
&mut fruit.x_offset,
|
||||
hit_object.start_time,
|
||||
last_pos,
|
||||
last_start_time,
|
||||
rng,
|
||||
);
|
||||
}
|
||||
}
|
||||
ObjectIterState::JuiceStream(ref stream) => {
|
||||
let pos = hit_object.x
|
||||
+ stream
|
||||
.control_points
|
||||
.last()
|
||||
.map_or(0.0, |control_point| control_point.pos.x);
|
||||
|
||||
*last_pos = Some(pos);
|
||||
*last_start_time = hit_object.start_time;
|
||||
|
||||
for nested in stream.nested_objects.as_slice() {
|
||||
if let NestedJuiceStreamObjectKind::Droplet
|
||||
| NestedJuiceStreamObjectKind::TinyDroplet = nested.kind
|
||||
{
|
||||
let _ = rng.next_int();
|
||||
}
|
||||
}
|
||||
}
|
||||
ObjectIterState::BananaShower(ref shower) => {
|
||||
for _ in 0..shower.n_bananas {
|
||||
let _ = rng.next_double();
|
||||
let _ = rng.next_int();
|
||||
let _ = rng.next_int();
|
||||
let _ = rng.next_int();
|
||||
}
|
||||
}
|
||||
ObjectIterState::Fruit(None) => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_hr_offset(
|
||||
x: f32,
|
||||
x_offset: &mut f32,
|
||||
start_time: f64,
|
||||
last_pos: &mut Option<f32>,
|
||||
last_start_time: &mut f64,
|
||||
rng: &mut Random,
|
||||
) {
|
||||
let mut offset_pos = x;
|
||||
|
||||
let Some(last_pos) = last_pos else {
|
||||
*last_pos = Some(offset_pos);
|
||||
*last_start_time = start_time;
|
||||
|
||||
return;
|
||||
};
|
||||
|
||||
let pos_diff = offset_pos - *last_pos;
|
||||
let time_diff = (start_time - *last_start_time) as i32;
|
||||
|
||||
if time_diff > 1000 {
|
||||
*last_pos = offset_pos;
|
||||
*last_start_time = start_time;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if pos_diff.eq(0.0) {
|
||||
apply_random_offset(&mut offset_pos, f64::from(time_diff) / 4.0, rng);
|
||||
*x_offset = offset_pos - x;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if pos_diff.abs() < (time_diff / 3) as f32 {
|
||||
apply_offset(&mut offset_pos, pos_diff);
|
||||
}
|
||||
|
||||
*x_offset = offset_pos - x;
|
||||
|
||||
*last_pos = offset_pos;
|
||||
*last_start_time = start_time;
|
||||
}
|
||||
|
||||
fn apply_random_offset(pos: &mut f32, max_offset: f64, rng: &mut Random) {
|
||||
let right = rng.next_bool();
|
||||
let rand = (rng.next_double_range(0.0, max_offset.max(0.0)) as f32).min(20.0);
|
||||
|
||||
if right {
|
||||
if *pos + rand <= PLAYFIELD_WIDTH {
|
||||
*pos += rand;
|
||||
} else {
|
||||
*pos -= rand;
|
||||
}
|
||||
} else if *pos - rand >= 0.0 {
|
||||
*pos -= rand;
|
||||
} else {
|
||||
*pos += rand;
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_offset(pos: &mut f32, amount: f32) {
|
||||
if amount > 0.0 {
|
||||
if *pos + amount < PLAYFIELD_WIDTH {
|
||||
*pos += amount;
|
||||
}
|
||||
} else if *pos + amount > 0.0 {
|
||||
*pos += amount;
|
||||
}
|
||||
}
|
||||
|
||||
fn initialize_hyper_dash(cs: f32, palpable_objects: &mut [PalpableObject]) {
|
||||
let mut half_catcher_width = f64::from(Catcher::calculate_catch_width(cs) / 2.0);
|
||||
half_catcher_width /= f64::from(Catcher::ALLOWED_CATCH_RANGE);
|
||||
|
||||
let mut last_dir = 0;
|
||||
let mut last_excess = half_catcher_width;
|
||||
|
||||
for i in 0..palpable_objects.len() - 1 {
|
||||
let next = &palpable_objects[i + 1];
|
||||
let curr = &palpable_objects[i];
|
||||
|
||||
let this_dir = if next.effective_x() > curr.effective_x() {
|
||||
1
|
||||
} else {
|
||||
-1
|
||||
};
|
||||
|
||||
let time_to_next = next.start_time - curr.start_time - f64::from(1000.0_f32 / 60.0 / 4.0);
|
||||
|
||||
let dist_to_next = f64::from((next.effective_x() - curr.effective_x()).abs())
|
||||
- if last_dir == this_dir {
|
||||
last_excess
|
||||
} else {
|
||||
half_catcher_width
|
||||
};
|
||||
|
||||
let dist_to_hyper = (time_to_next * Catcher::BASE_SPEED - dist_to_next) as f32;
|
||||
|
||||
let curr = &mut palpable_objects[i];
|
||||
|
||||
if dist_to_hyper < 0.0 {
|
||||
curr.hyper_dash = true;
|
||||
last_excess = half_catcher_width;
|
||||
} else {
|
||||
curr.dist_to_hyper_dash = dist_to_hyper;
|
||||
last_excess = f64::from(dist_to_hyper).clamp(0.0, half_catcher_width);
|
||||
}
|
||||
|
||||
last_dir = this_dir;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
use crate::{
|
||||
any::difficulty::{skills::Skill, ModeDifficulty},
|
||||
catch::{
|
||||
catcher::Catcher, convert::convert_objects, difficulty::object::CatchDifficultyObject,
|
||||
},
|
||||
util::mods::Mods,
|
||||
};
|
||||
|
||||
use self::skills::movement::Movement;
|
||||
|
||||
use super::{
|
||||
attributes::{CatchDifficultyAttributes, CatchDifficultyAttributesBuilder},
|
||||
convert::CatchBeatmap,
|
||||
};
|
||||
|
||||
mod object;
|
||||
mod skills;
|
||||
|
||||
const STAR_SCALING_FACTOR: f64 = 0.153;
|
||||
|
||||
pub fn difficulty(
|
||||
difficulty: &ModeDifficulty,
|
||||
converted: &CatchBeatmap<'_>,
|
||||
) -> CatchDifficultyAttributes {
|
||||
let DifficultyValues {
|
||||
movement,
|
||||
mut attrs,
|
||||
} = DifficultyValues::calculate(difficulty, converted);
|
||||
|
||||
attrs.stars = movement.difficulty_value().sqrt() * STAR_SCALING_FACTOR;
|
||||
|
||||
attrs
|
||||
}
|
||||
|
||||
pub struct DifficultyValues {
|
||||
pub movement: Movement,
|
||||
pub attrs: CatchDifficultyAttributes,
|
||||
}
|
||||
|
||||
impl DifficultyValues {
|
||||
pub fn calculate(difficulty: &ModeDifficulty, converted: &CatchBeatmap<'_>) -> Self {
|
||||
let take = difficulty.get_passed_objects();
|
||||
let clock_rate = difficulty.get_clock_rate();
|
||||
|
||||
let map_attrs = converted
|
||||
.attributes()
|
||||
.mods(difficulty.get_mods())
|
||||
.clock_rate(clock_rate)
|
||||
.build();
|
||||
|
||||
let attrs = CatchDifficultyAttributes {
|
||||
ar: map_attrs.ar,
|
||||
is_convert: converted.is_convert,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let mut attrs = CatchDifficultyAttributesBuilder::new(attrs, take);
|
||||
|
||||
let hr = difficulty.get_mods().hr();
|
||||
let movement = Movement::new(clock_rate);
|
||||
|
||||
let palpable_objects = convert_objects(converted, &mut attrs, hr, map_attrs.cs as f32);
|
||||
let mut palpable_objects_iter = palpable_objects.iter().take(take);
|
||||
|
||||
let Some(mut last_object) = palpable_objects_iter.next() else {
|
||||
return Self {
|
||||
movement,
|
||||
attrs: attrs.into_inner(),
|
||||
};
|
||||
};
|
||||
|
||||
let mut half_catcher_width = Catcher::calculate_catch_width(map_attrs.cs as f32) * 0.5;
|
||||
half_catcher_width *= 1.0 - ((map_attrs.cs as f32 - 5.5).max(0.0) * 0.0625);
|
||||
let scaling_factor =
|
||||
CatchDifficultyObject::NORMALIZED_HITOBJECT_RADIUS / half_catcher_width;
|
||||
|
||||
let diff_objects: Vec<_> = palpable_objects_iter
|
||||
.enumerate()
|
||||
.map(|(i, hit_object)| {
|
||||
let diff_object = CatchDifficultyObject::new(
|
||||
hit_object,
|
||||
last_object,
|
||||
clock_rate,
|
||||
scaling_factor,
|
||||
i,
|
||||
);
|
||||
last_object = hit_object;
|
||||
|
||||
diff_object
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut movement = Skill::new(movement, &diff_objects);
|
||||
|
||||
for curr in diff_objects.iter() {
|
||||
movement.process(curr);
|
||||
}
|
||||
|
||||
Self {
|
||||
movement: movement.inner,
|
||||
attrs: attrs.into_inner(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
use crate::{any::difficulty::object::IDifficultyObject, catch::object::palpable::PalpableObject};
|
||||
|
||||
pub struct CatchDifficultyObject {
|
||||
pub idx: usize,
|
||||
pub start_time: f64,
|
||||
pub delta_time: f64,
|
||||
pub normalized_pos: f32,
|
||||
pub last_normalized_pos: f32,
|
||||
pub strain_time: f64,
|
||||
pub last_object: LastObject,
|
||||
}
|
||||
|
||||
impl CatchDifficultyObject {
|
||||
pub const NORMALIZED_HITOBJECT_RADIUS: f32 = 41.0;
|
||||
|
||||
pub fn new(
|
||||
hit_object: &PalpableObject,
|
||||
last_object: &PalpableObject,
|
||||
clock_rate: f64,
|
||||
scaling_factor: f32,
|
||||
idx: usize,
|
||||
) -> Self {
|
||||
let normalized_pos = hit_object.effective_x() * scaling_factor;
|
||||
let last_normalized_pos = last_object.effective_x() * scaling_factor;
|
||||
|
||||
let start_time = hit_object.start_time / clock_rate;
|
||||
let delta_time = (hit_object.start_time - last_object.start_time) / clock_rate;
|
||||
let strain_time = delta_time.max(40.0);
|
||||
|
||||
let last_object = LastObject {
|
||||
hyper_dash: last_object.hyper_dash,
|
||||
dist_to_hyper_dash: last_object.dist_to_hyper_dash,
|
||||
};
|
||||
|
||||
Self {
|
||||
idx,
|
||||
start_time,
|
||||
delta_time,
|
||||
normalized_pos,
|
||||
last_normalized_pos,
|
||||
strain_time,
|
||||
last_object,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct LastObject {
|
||||
pub hyper_dash: bool,
|
||||
pub dist_to_hyper_dash: f32,
|
||||
}
|
||||
|
||||
impl IDifficultyObject for CatchDifficultyObject {
|
||||
fn idx(&self) -> usize {
|
||||
self.idx
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub mod movement;
|
||||
@@ -0,0 +1,163 @@
|
||||
use crate::{
|
||||
any::difficulty::{
|
||||
object::IDifficultyObject,
|
||||
skills::{strain_decay, ISkill, Skill, StrainDecaySkill},
|
||||
},
|
||||
catch::difficulty::object::CatchDifficultyObject,
|
||||
};
|
||||
|
||||
const ABSOLUTE_PLAYER_POSITIONING_ERROR: f32 = 16.0;
|
||||
const NORMALIZED_HITOBJECT_RADIUS: f32 = 41.0;
|
||||
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;
|
||||
|
||||
const SECTION_LEN: f64 = 750.0;
|
||||
|
||||
pub struct Movement {
|
||||
inner: StrainDecaySkill,
|
||||
last_player_pos: Option<f32>,
|
||||
last_dist_moved: f32,
|
||||
last_strain_time: f64,
|
||||
clock_rate: f64,
|
||||
}
|
||||
|
||||
impl Movement {
|
||||
pub fn new(clock_rate: f64) -> Self {
|
||||
Self {
|
||||
inner: StrainDecaySkill::default(),
|
||||
last_player_pos: None,
|
||||
last_dist_moved: 0.0,
|
||||
last_strain_time: 0.0,
|
||||
clock_rate,
|
||||
}
|
||||
}
|
||||
|
||||
const fn curr_strain(&self) -> f64 {
|
||||
self.inner.curr_strain
|
||||
}
|
||||
|
||||
fn curr_strain_mut(&mut self) -> &mut f64 {
|
||||
&mut self.inner.curr_strain
|
||||
}
|
||||
|
||||
fn strain_value_at(&mut self, curr: &CatchDifficultyObject) -> f64 {
|
||||
*self.curr_strain_mut() *= strain_decay(curr.delta_time, STRAIN_DECAY_BASE);
|
||||
*self.curr_strain_mut() += self.strain_value_of(curr) * SKILL_MULTIPLIER;
|
||||
|
||||
self.curr_strain()
|
||||
}
|
||||
|
||||
fn strain_value_of(&mut self, curr: &CatchDifficultyObject) -> f64 {
|
||||
let last_player_pos = self.last_player_pos.unwrap_or(curr.last_normalized_pos);
|
||||
|
||||
let term = NORMALIZED_HITOBJECT_RADIUS - ABSOLUTE_PLAYER_POSITIONING_ERROR;
|
||||
let mut player_pos =
|
||||
last_player_pos.clamp(curr.normalized_pos - term, curr.normalized_pos + term);
|
||||
|
||||
let dist_moved = player_pos - last_player_pos;
|
||||
|
||||
let weighted_strain_time = curr.strain_time + 13.0 + (3.0 / self.clock_rate);
|
||||
|
||||
let mut dist_addition = f64::from(dist_moved.abs()).powf(1.3) / 510.0;
|
||||
let sqrt_strain = weighted_strain_time.sqrt();
|
||||
|
||||
let mut edge_dash_bonus: f64 = 0.0;
|
||||
|
||||
if dist_moved.abs() > 0.1 {
|
||||
if self.last_dist_moved.abs() > 0.1
|
||||
&& dist_moved.signum() != self.last_dist_moved.signum()
|
||||
{
|
||||
let bonus_factor = f64::from(dist_moved.abs().min(50.0) / 50.0);
|
||||
let anti_flow_factor =
|
||||
f64::from(self.last_dist_moved.abs().min(70.0) / 70.0).max(0.38);
|
||||
|
||||
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
|
||||
* f64::from(dist_moved.abs().min(NORMALIZED_HITOBJECT_RADIUS * 2.0))
|
||||
/ f64::from(NORMALIZED_HITOBJECT_RADIUS * 6.0)
|
||||
/ sqrt_strain;
|
||||
}
|
||||
|
||||
if curr.last_object.dist_to_hyper_dash <= 20.0 {
|
||||
if curr.last_object.hyper_dash {
|
||||
player_pos = curr.normalized_pos;
|
||||
} else {
|
||||
edge_dash_bonus += 5.7;
|
||||
}
|
||||
|
||||
dist_addition *= 1.0
|
||||
+ edge_dash_bonus
|
||||
* f64::from((20.0 - curr.last_object.dist_to_hyper_dash) / 20.0)
|
||||
* ((curr.strain_time * self.clock_rate).min(265.0) / 265.0).powf(1.5);
|
||||
}
|
||||
|
||||
self.last_player_pos = Some(player_pos);
|
||||
self.last_dist_moved = dist_moved;
|
||||
self.last_strain_time = curr.strain_time;
|
||||
|
||||
dist_addition / weighted_strain_time
|
||||
}
|
||||
|
||||
pub fn get_curr_strain_peaks(self) -> Vec<f64> {
|
||||
self.inner.get_curr_strain_peaks()
|
||||
}
|
||||
|
||||
pub fn difficulty_value(self) -> f64 {
|
||||
self.inner.difficulty_value(DECAY_WEIGHT)
|
||||
}
|
||||
}
|
||||
|
||||
impl ISkill for Movement {
|
||||
type DifficultyObjects<'a> = [CatchDifficultyObject];
|
||||
}
|
||||
|
||||
impl<'a> Skill<'a, Movement> {
|
||||
fn calculate_initial_strain(&mut self, time: f64, curr: &CatchDifficultyObject) -> f64 {
|
||||
let prev_start_time = curr
|
||||
.previous(0, self.diff_objects)
|
||||
.map_or(0.0, |prev| prev.start_time);
|
||||
|
||||
self.inner.curr_strain() * strain_decay(time - prev_start_time, STRAIN_DECAY_BASE)
|
||||
}
|
||||
|
||||
const fn curr_section_peak(&self) -> f64 {
|
||||
self.inner.inner.inner.curr_section_peak
|
||||
}
|
||||
|
||||
fn curr_section_peak_mut(&mut self) -> &mut f64 {
|
||||
&mut self.inner.inner.inner.curr_section_peak
|
||||
}
|
||||
|
||||
const fn curr_section_end(&self) -> f64 {
|
||||
self.inner.inner.inner.curr_section_end
|
||||
}
|
||||
|
||||
fn curr_section_end_mut(&mut self) -> &mut f64 {
|
||||
&mut self.inner.inner.inner.curr_section_end
|
||||
}
|
||||
|
||||
pub fn process(&mut self, curr: &CatchDifficultyObject) {
|
||||
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.inner.inner.save_curr_peak();
|
||||
let initial_strain = self.calculate_initial_strain(self.curr_section_end(), curr);
|
||||
self.inner.inner.start_new_section_from(initial_strain);
|
||||
*self.curr_section_end_mut() += SECTION_LEN;
|
||||
}
|
||||
|
||||
let strain_value_at = self.inner.strain_value_at(curr);
|
||||
*self.curr_section_peak_mut() = strain_value_at.max(self.curr_section_peak());
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,278 +0,0 @@
|
||||
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 {
|
||||
pub(crate) attributes: CatchDifficultyAttributes,
|
||||
pub(crate) curve_bufs: CurveBuffers,
|
||||
pub(crate) last_pos: Option<f32>,
|
||||
pub(crate) last_time: f64,
|
||||
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, map: &Beatmap) -> 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 = map.timing_point_at(h.start_time);
|
||||
|
||||
let difficulty_point = map.difficulty_point_at(h.start_time).unwrap_or_default();
|
||||
|
||||
let vel_factor = BASE_SCORING_DISTANCE * map.slider_mult / timing_point.beat_len;
|
||||
let tick_dist_factor = BASE_SCORING_DISTANCE * map.slider_mult / 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 = f64::from(u8::from(span_idx % 2 == 1));
|
||||
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 = f64::from(u8::from(*repeats % 2 == 0));
|
||||
let pos = h.pos + curve.position_at(progress);
|
||||
slider_objects.push((pos, h.start_time + total_duration));
|
||||
|
||||
let new_fruits = 2 + usize::from(tick_dist > 0.0) * *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
|
||||
}
|
||||
@@ -1,269 +0,0 @@
|
||||
use std::iter;
|
||||
|
||||
use crate::{
|
||||
catch::{difficulty_object::DifficultyObject, SECTION_LENGTH, STAR_SCALING_FACTOR},
|
||||
curve::CurveBuffers,
|
||||
parse::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`].
|
||||
/// On every call of [`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
|
||||
/// [`CatchGradualPerformance`](crate::catch::CatchGradualPerformance) instead.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use rosu_pp::{Beatmap, catch::CatchGradualDifficulty};
|
||||
///
|
||||
/// # /*
|
||||
/// let map: Beatmap = ...
|
||||
/// # */
|
||||
/// # let map = Beatmap::default();
|
||||
///
|
||||
/// let mods = 64; // DT
|
||||
/// let mut iter = CatchGradualDifficulty::new(&map, mods);
|
||||
///
|
||||
/// let attrs1 = iter.next(); // the difficulty of the map after the first hit object
|
||||
/// let attrs2 = iter.next(); // after the second hit object
|
||||
///
|
||||
/// // Remaining hit objects
|
||||
/// for difficulty in iter {
|
||||
/// // ...
|
||||
/// }
|
||||
/// ```
|
||||
#[cfg_attr(docsrs, doc(cfg(feature = "gradual")))]
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct CatchGradualDifficulty<'map> {
|
||||
map: &'map Beatmap,
|
||||
inner: CatchGradualDifficultyInner,
|
||||
}
|
||||
|
||||
impl<'map> CatchGradualDifficulty<'map> {
|
||||
/// Create a new difficulty attributes iterator for osu!catch maps.
|
||||
pub fn new(map: &'map Beatmap, mods: u32) -> Self {
|
||||
let inner = CatchGradualDifficultyInner::new(map, mods);
|
||||
|
||||
Self { map, inner }
|
||||
}
|
||||
|
||||
pub(crate) const fn idx(&self) -> usize {
|
||||
self.inner.idx
|
||||
}
|
||||
}
|
||||
|
||||
impl Iterator for CatchGradualDifficulty<'_> {
|
||||
type Item = CatchDifficultyAttributes;
|
||||
|
||||
#[inline]
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
self.inner.next(self.map)
|
||||
}
|
||||
}
|
||||
|
||||
/// Gradually calculate the difficulty attributes of an osu!catch map.
|
||||
///
|
||||
/// Check [`CatchGradualDifficulty`] for more information. This struct does the same
|
||||
/// but takes ownership of [`Beatmap`] to avoid being bound to a lifetime.
|
||||
#[cfg_attr(docsrs, doc(cfg(feature = "gradual")))]
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct CatchOwnedGradualDifficulty {
|
||||
pub(crate) map: Beatmap,
|
||||
inner: CatchGradualDifficultyInner,
|
||||
}
|
||||
|
||||
impl CatchOwnedGradualDifficulty {
|
||||
/// Create a new difficulty attributes iterator for osu!catch maps.
|
||||
pub fn new(map: Beatmap, mods: u32) -> Self {
|
||||
let inner = CatchGradualDifficultyInner::new(&map, mods);
|
||||
|
||||
Self { map, inner }
|
||||
}
|
||||
|
||||
#[allow(unused)]
|
||||
pub(crate) const fn idx(&self) -> usize {
|
||||
self.inner.idx
|
||||
}
|
||||
}
|
||||
|
||||
impl Iterator for CatchOwnedGradualDifficulty {
|
||||
type Item = CatchDifficultyAttributes;
|
||||
|
||||
#[inline]
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
self.inner.next(&self.map)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct CatchObjectIter {
|
||||
hit_object_idx: usize,
|
||||
last_object: Option<FruitOrJuice>,
|
||||
params: FruitParams,
|
||||
}
|
||||
|
||||
impl CatchObjectIter {
|
||||
fn new(mods: impl Mods, attributes: CatchDifficultyAttributes) -> Self {
|
||||
let params = FruitParams {
|
||||
attributes,
|
||||
curve_bufs: CurveBuffers::default(),
|
||||
last_pos: None,
|
||||
last_time: 0.0,
|
||||
ticks: Vec::new(),
|
||||
with_hr: mods.hr(),
|
||||
};
|
||||
|
||||
Self {
|
||||
hit_object_idx: 0,
|
||||
last_object: None,
|
||||
params,
|
||||
}
|
||||
}
|
||||
|
||||
fn attributes(&self) -> CatchDifficultyAttributes {
|
||||
self.params.attributes.clone()
|
||||
}
|
||||
|
||||
fn next(&mut self, map: &Beatmap) -> Option<CatchObject> {
|
||||
if let opt @ Some(_) = self.last_object.as_mut().and_then(Iterator::next) {
|
||||
return opt;
|
||||
}
|
||||
|
||||
map.hit_objects[self.hit_object_idx..]
|
||||
.iter()
|
||||
.find_map(|h| {
|
||||
self.hit_object_idx += 1;
|
||||
|
||||
FruitOrJuice::new(h, &mut self.params, map)
|
||||
})
|
||||
.and_then(|h| self.last_object.insert(h).next())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct CatchGradualDifficultyInner {
|
||||
idx: usize,
|
||||
clock_rate: f64,
|
||||
hit_objects: CatchObjectIter,
|
||||
movement: Movement,
|
||||
prev: CatchObject,
|
||||
half_catcher_width: f64,
|
||||
last_direction: i8,
|
||||
last_excess: f64,
|
||||
curr_section_end: f64,
|
||||
strain_peak_buf: Vec<f64>,
|
||||
}
|
||||
|
||||
impl CatchGradualDifficultyInner {
|
||||
fn new(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(mods, attributes);
|
||||
|
||||
let half_catcher_width =
|
||||
f64::from(calculate_catch_width(map_attributes.cs as f32) / 2.0 / ALLOWED_CATCH_RANGE);
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
fn next(&mut self, map: &Beatmap) -> Option<CatchDifficultyAttributes> {
|
||||
let curr = self.hit_objects.next(map)?;
|
||||
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 stars =
|
||||
Movement::difficulty_value(&mut self.strain_peak_buf).sqrt() * STAR_SCALING_FACTOR;
|
||||
|
||||
let attrs = CatchDifficultyAttributes {
|
||||
stars,
|
||||
..self.hit_objects.attributes()
|
||||
};
|
||||
|
||||
Some(attrs)
|
||||
}
|
||||
}
|
||||
@@ -1,201 +0,0 @@
|
||||
use crate::{Beatmap, CatchPP};
|
||||
|
||||
use super::{
|
||||
CatchGradualDifficulty, CatchOwnedGradualDifficulty, CatchPerformanceAttributes,
|
||||
CatchScoreState,
|
||||
};
|
||||
|
||||
/// Gradually calculate the performance attributes of an osu!catch map.
|
||||
///
|
||||
/// After each hit object you can call [`next`](`CatchGradualPerformance::next`)
|
||||
/// and it will return the resulting current [`CatchPerformanceAttributes`].
|
||||
/// To process multiple objects at once, use [`nth`](`CatchGradualPerformance::nth`) 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 [`CatchGradualDifficulty`] instead.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use rosu_pp::{Beatmap, catch::{CatchGradualPerformance, CatchScoreState}};
|
||||
///
|
||||
/// # /*
|
||||
/// let map: Beatmap = ...
|
||||
/// # */
|
||||
/// # let map = Beatmap::default();
|
||||
///
|
||||
/// let mods = 64; // DT
|
||||
/// let mut gradual_perf = CatchGradualPerformance::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.next(state.clone()).unwrap();
|
||||
/// println!("PP: {}", performance.pp);
|
||||
/// # */
|
||||
/// # let _ = gradual_perf.next(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.next(state.clone()).unwrap();
|
||||
/// println!("PP: {}", performance.pp);
|
||||
/// # */
|
||||
/// # let _ = gradual_perf.next(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;
|
||||
/// // The `nth` method takes a zero-based value.
|
||||
/// # /*
|
||||
/// let performance = gradual_perf.nth(state.clone(), 9).unwrap();
|
||||
/// println!("PP: {}", performance.pp);
|
||||
/// # */
|
||||
/// # let _ = gradual_perf.nth(state.clone(), 9);
|
||||
///
|
||||
/// // Now comes another fruit. Note that the max combo gets incremented again.
|
||||
/// state.n_fruits += 1;
|
||||
/// state.max_combo += 1;
|
||||
/// # /*
|
||||
/// let performance = gradual_perf.next(state.clone()).unwrap();
|
||||
/// println!("PP: {}", performance.pp);
|
||||
/// # */
|
||||
/// # let _ = gradual_perf.next(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.nth(state.clone(), usize::MAX).unwrap();
|
||||
/// println!("PP: {}", performance.pp);
|
||||
/// # */
|
||||
/// # let _ = gradual_perf.nth(state.clone(), usize::MAX);
|
||||
///
|
||||
/// // Once the final performance was calculated,
|
||||
/// // attempting to process further objects will return `None`.
|
||||
/// assert!(gradual_perf.next(state).is_none());
|
||||
/// ```
|
||||
#[cfg_attr(docsrs, doc(cfg(feature = "gradual")))]
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct CatchGradualPerformance<'map> {
|
||||
difficulty: CatchGradualDifficulty<'map>,
|
||||
performance: CatchPP<'map>,
|
||||
}
|
||||
|
||||
impl<'map> CatchGradualPerformance<'map> {
|
||||
/// Create a new gradual performance calculator for osu!standard maps.
|
||||
pub fn new(map: &'map Beatmap, mods: u32) -> Self {
|
||||
let difficulty = CatchGradualDifficulty::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 next(&mut self, state: CatchScoreState) -> Option<CatchPerformanceAttributes> {
|
||||
self.nth(state, 0)
|
||||
}
|
||||
|
||||
/// Process all remaining hit objects and calculate the final performance attributes.
|
||||
pub fn last(&mut self, state: CatchScoreState) -> Option<CatchPerformanceAttributes> {
|
||||
self.nth(state, usize::MAX)
|
||||
}
|
||||
|
||||
/// Process everything up the the next `n`th hit object and calculate the performance
|
||||
/// attributes for the resulting score state.
|
||||
///
|
||||
/// Note that the count is zero-indexed, so `n=0` will process 1 object, `n=1` will process 2,
|
||||
/// and so on.
|
||||
pub fn nth(&mut self, state: CatchScoreState, n: usize) -> Option<CatchPerformanceAttributes> {
|
||||
let difficulty = self.difficulty.by_ref().take(n.saturating_add(1)).last()?;
|
||||
|
||||
let performance = self
|
||||
.performance
|
||||
.clone()
|
||||
.attributes(difficulty)
|
||||
.state(state)
|
||||
.passed_objects(self.difficulty.idx())
|
||||
.calculate();
|
||||
|
||||
Some(performance)
|
||||
}
|
||||
}
|
||||
|
||||
/// Gradually calculate the performance attributes of an osu!catch map.
|
||||
///
|
||||
/// Check [`CatchGradualPerformance`] for more information. This struct does the same
|
||||
/// but takes ownership of [`Beatmap`] to avoid being bound to a lifetime.
|
||||
#[cfg_attr(docsrs, doc(cfg(feature = "gradual")))]
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct CatchOwnedGradualPerformance {
|
||||
difficulty: CatchOwnedGradualDifficulty,
|
||||
mods: u32,
|
||||
}
|
||||
|
||||
impl CatchOwnedGradualPerformance {
|
||||
/// Create a new gradual performance calculator for osu!standard maps.
|
||||
pub fn new(map: Beatmap, mods: u32) -> Self {
|
||||
let difficulty = CatchOwnedGradualDifficulty::new(map, mods);
|
||||
|
||||
Self { difficulty, mods }
|
||||
}
|
||||
|
||||
/// 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 next(&mut self, state: CatchScoreState) -> Option<CatchPerformanceAttributes> {
|
||||
self.nth(state, 0)
|
||||
}
|
||||
|
||||
/// Process all remaining hit objects and calculate the final performance attributes.
|
||||
pub fn last(&mut self, state: CatchScoreState) -> Option<CatchPerformanceAttributes> {
|
||||
self.nth(state, usize::MAX)
|
||||
}
|
||||
|
||||
/// Process everything up the the next `n`th hit object and calculate the performance
|
||||
/// attributes for the resulting score state.
|
||||
///
|
||||
/// Note that the count is zero-indexed, so `n=0` will process 1 object, `n=1` will process 2,
|
||||
/// and so on.
|
||||
pub fn nth(&mut self, state: CatchScoreState, n: usize) -> Option<CatchPerformanceAttributes> {
|
||||
let difficulty = self.difficulty.by_ref().take(n.saturating_add(1)).last()?;
|
||||
|
||||
let performance = CatchPP::new(&self.difficulty.map)
|
||||
.mods(self.mods)
|
||||
.attributes(difficulty)
|
||||
.state(state)
|
||||
.passed_objects(self.difficulty.idx())
|
||||
.calculate();
|
||||
|
||||
Some(performance)
|
||||
}
|
||||
}
|
||||
+39
-329
@@ -1,343 +1,53 @@
|
||||
mod catch_object;
|
||||
mod difficulty_object;
|
||||
mod fruit_or_juice;
|
||||
mod movement;
|
||||
mod pp;
|
||||
mod score_state;
|
||||
use std::borrow::Cow;
|
||||
|
||||
#[cfg(feature = "gradual")]
|
||||
mod gradual_difficulty;
|
||||
#[cfg(feature = "gradual")]
|
||||
mod gradual_performance;
|
||||
|
||||
use difficulty_object::DifficultyObject;
|
||||
use movement::Movement;
|
||||
|
||||
pub use self::{catch_object::CatchObject, pp::*, score_state::CatchScoreState};
|
||||
|
||||
#[cfg(feature = "gradual")]
|
||||
pub use self::{
|
||||
gradual_difficulty::{CatchGradualDifficulty, CatchOwnedGradualDifficulty},
|
||||
gradual_performance::{CatchGradualPerformance, CatchOwnedGradualPerformance},
|
||||
use crate::{
|
||||
any::ModeDifficulty,
|
||||
model::{
|
||||
beatmap::Beatmap,
|
||||
mode::{ConvertStatus, IGameMode},
|
||||
},
|
||||
};
|
||||
|
||||
pub(crate) use self::fruit_or_juice::{FruitOrJuice, FruitParams};
|
||||
pub use self::{
|
||||
attributes::{CatchDifficultyAttributes, CatchPerformanceAttributes},
|
||||
convert::CatchBeatmap,
|
||||
performance::CatchPerformance,
|
||||
score_state::CatchScoreState,
|
||||
strains::CatchStrains,
|
||||
};
|
||||
|
||||
use crate::{curve::CurveBuffers, Beatmap, Mods, OsuStars};
|
||||
mod attributes;
|
||||
mod catcher;
|
||||
mod convert;
|
||||
mod difficulty;
|
||||
mod object;
|
||||
mod performance;
|
||||
mod score_state;
|
||||
mod strains;
|
||||
|
||||
const SECTION_LENGTH: f64 = 750.0;
|
||||
const STAR_SCALING_FACTOR: f64 = 0.153;
|
||||
const PLAYFIELD_WIDTH: f32 = 512.0;
|
||||
|
||||
pub(crate) const ALLOWED_CATCH_RANGE: f32 = 0.8;
|
||||
const CATCHER_SIZE: f32 = 106.75;
|
||||
|
||||
/// Difficulty calculator on osu!catch maps.
|
||||
/// Marker type for [`GameMode::Catch`].
|
||||
///
|
||||
/// # 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)]
|
||||
#[must_use]
|
||||
pub struct CatchStars<'map> {
|
||||
map: &'map Beatmap,
|
||||
mods: u32,
|
||||
passed_objects: Option<usize>,
|
||||
clock_rate: Option<f64>,
|
||||
}
|
||||
/// [`GameMode::Catch`]: rosu_map::section::general::GameMode::Catch
|
||||
pub struct Catch;
|
||||
|
||||
impl<'map> CatchStars<'map> {
|
||||
/// Create a new difficulty calculator for osu!catch maps.
|
||||
#[inline]
|
||||
pub const fn new(map: &'map Beatmap) -> Self {
|
||||
Self {
|
||||
map,
|
||||
mods: 0,
|
||||
passed_objects: None,
|
||||
clock_rate: None,
|
||||
}
|
||||
impl IGameMode for Catch {
|
||||
type DifficultyAttributes = CatchDifficultyAttributes;
|
||||
type Strains = CatchStrains;
|
||||
|
||||
fn try_convert(map: &mut Cow<'_, Beatmap>) -> ConvertStatus {
|
||||
convert::try_convert(map)
|
||||
}
|
||||
|
||||
/// Specify mods through their bit values.
|
||||
///
|
||||
/// See [https://github.com/ppy/osu-api/wiki#mods](https://github.com/ppy/osu-api/wiki#mods)
|
||||
#[inline]
|
||||
pub const fn mods(mut self, mods: u32) -> Self {
|
||||
self.mods = mods;
|
||||
|
||||
self
|
||||
fn difficulty(
|
||||
difficulty: &ModeDifficulty,
|
||||
converted: &CatchBeatmap<'_>,
|
||||
) -> Self::DifficultyAttributes {
|
||||
difficulty::difficulty(difficulty, converted)
|
||||
}
|
||||
|
||||
/// Amount of passed objects for partial plays, e.g. a fail.
|
||||
///
|
||||
#[cfg_attr(
|
||||
feature = "gradual",
|
||||
doc = "If you want to calculate the difficulty after every few objects, instead of
|
||||
using [`CatchStars`] multiple times with different `passed_objects`, you should use
|
||||
[`CatchGradualDifficultyAttributes`](crate::catch::CatchGradualDifficulty)."
|
||||
)]
|
||||
#[inline]
|
||||
pub const fn passed_objects(mut self, passed_objects: usize) -> Self {
|
||||
self.passed_objects = Some(passed_objects);
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
/// Adjust the clock rate used in the calculation.
|
||||
/// If none is specified, it will take the clock rate based on the mods
|
||||
/// i.e. 1.5 for DT, 0.75 for HT and 1.0 otherwise.
|
||||
#[inline]
|
||||
pub const fn clock_rate(mut self, clock_rate: f64) -> Self {
|
||||
self.clock_rate = Some(clock_rate);
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
/// 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()
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::needless_pass_by_value)]
|
||||
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,
|
||||
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, map))
|
||||
.flatten()
|
||||
.take(take);
|
||||
|
||||
// Hyper dash business
|
||||
let half_catcher_width =
|
||||
f64::from(calculate_catch_width(map_attributes.cs as f32) / 2.0 / ALLOWED_CATCH_RANGE);
|
||||
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),
|
||||
(_, 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;
|
||||
|
||||
// Optimization to finish the loop early if
|
||||
// the current peak is 0.0 i.e. it can't decay further.
|
||||
// If final values don't coincide perfectly anymore,
|
||||
// this should be looked at and maybe adjusted.
|
||||
if movement.curr_section_peak.abs() <= f64::EPSILON && base_time > curr_section_end {
|
||||
let remaining_time = base_time - curr_section_end;
|
||||
let remaining_iters = (remaining_time / SECTION_LENGTH).ceil();
|
||||
curr_section_end += remaining_iters * 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 const fn max_combo(&self) -> usize {
|
||||
self.n_fruits + self.n_droplets
|
||||
}
|
||||
|
||||
/// Returns a builder for performance calculation.
|
||||
#[inline]
|
||||
pub fn pp(self) -> CatchPP<'static> {
|
||||
CatchPP::from(self)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 const fn stars(&self) -> f64 {
|
||||
self.difficulty.stars
|
||||
}
|
||||
|
||||
/// Return the performance point value.
|
||||
#[inline]
|
||||
pub const fn pp(&self) -> f64 {
|
||||
self.pp
|
||||
}
|
||||
|
||||
/// Return the maximum combo of the map.
|
||||
#[inline]
|
||||
pub const fn max_combo(&self) -> usize {
|
||||
self.difficulty.max_combo()
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
fn strains(difficulty: &ModeDifficulty, converted: &CatchBeatmap<'_>) -> Self::Strains {
|
||||
strains::strains(difficulty, converted)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,147 +0,0 @@
|
||||
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.clamp(
|
||||
current.normalized_pos - POSITION_EPSILON,
|
||||
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 = f64::from(dist_moved.abs().powf(1.3) / 510.0);
|
||||
|
||||
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 = f64::from(dist_moved.abs().min(50.0) / 50.0);
|
||||
let anti_flow_factor =
|
||||
f64::from((self.last_distance_moved.abs().min(70.0) / 70.0).max(0.38));
|
||||
|
||||
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 += f64::from(
|
||||
12.5 * dist_moved.abs().min(NORMALIZED_HITOBJECT_RADIUS * 2.0)
|
||||
/ (NORMALIZED_HITOBJECT_RADIUS * 6.0),
|
||||
) / weighted_strain_time.sqrt();
|
||||
}
|
||||
|
||||
let mut edge_dash_bonus = 0.0;
|
||||
|
||||
if current.last.hyper_dist <= 20.0 {
|
||||
#[allow(clippy::if_not_else)]
|
||||
if !current.last.hyper_dash {
|
||||
edge_dash_bonus += 5.7;
|
||||
} else {
|
||||
pos = current.normalized_pos;
|
||||
}
|
||||
|
||||
dist_addition *= 1.0
|
||||
+ edge_dash_bonus
|
||||
* f64::from((20.0 - current.last.hyper_dist) / 20.0)
|
||||
* ((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)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
pub struct BananaShower {
|
||||
pub n_bananas: usize,
|
||||
}
|
||||
|
||||
impl BananaShower {
|
||||
pub fn new(start_time: f64, end_time: f64) -> Self {
|
||||
let duration = end_time - start_time;
|
||||
let mut spacing = duration;
|
||||
|
||||
while spacing > 100.0 {
|
||||
spacing /= 2.0;
|
||||
}
|
||||
|
||||
let n_bananas = if spacing <= 0.0 {
|
||||
0
|
||||
} else {
|
||||
let mut time = start_time;
|
||||
let mut i = 0;
|
||||
|
||||
while time <= end_time {
|
||||
time += spacing;
|
||||
i += 1;
|
||||
}
|
||||
|
||||
i
|
||||
};
|
||||
|
||||
Self { n_bananas }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
use crate::catch::attributes::CatchDifficultyAttributesBuilder;
|
||||
|
||||
pub struct Fruit {
|
||||
pub x_offset: f32,
|
||||
}
|
||||
|
||||
impl Fruit {
|
||||
pub fn new(attrs: &mut CatchDifficultyAttributesBuilder) -> Self {
|
||||
attrs.inc_fruits();
|
||||
|
||||
Self { x_offset: 0.0 }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
use std::vec::Drain;
|
||||
|
||||
use rosu_map::section::hit_objects::{
|
||||
CurveBuffers, PathControlPoint, SliderEvent, SliderEventType, SliderEventsIter,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
catch::{attributes::CatchDifficultyAttributesBuilder, convert::CatchBeatmap},
|
||||
model::{
|
||||
control_point::{DifficultyPoint, TimingPoint},
|
||||
hit_object::Slider,
|
||||
},
|
||||
};
|
||||
|
||||
pub struct JuiceStream<'a> {
|
||||
pub control_points: &'a [PathControlPoint], // needed for applying hr offset
|
||||
pub nested_objects: Drain<'a, NestedJuiceStreamObject>,
|
||||
}
|
||||
|
||||
impl<'a> JuiceStream<'a> {
|
||||
pub const BASE_SCORING_DIST: f64 = 100.0;
|
||||
|
||||
pub fn new(
|
||||
x: f32,
|
||||
start_time: f64,
|
||||
slider: &'a Slider,
|
||||
converted: &CatchBeatmap<'_>,
|
||||
attrs: &mut CatchDifficultyAttributesBuilder,
|
||||
bufs: &'a mut JuiceStreamBufs,
|
||||
) -> Self {
|
||||
let slider_multiplier = converted.map.slider_multiplier;
|
||||
let slider_tick_rate = converted.map.slider_tick_rate;
|
||||
|
||||
let beat_len = converted
|
||||
.map
|
||||
.timing_point_at(start_time)
|
||||
.map_or(TimingPoint::DEFAULT_BEAT_LEN, |point| point.beat_len);
|
||||
|
||||
let slider_velocity = converted
|
||||
.map
|
||||
.difficulty_point_at(start_time)
|
||||
.map_or(DifficultyPoint::DEFAULT_SLIDER_VELOCITY, |point| {
|
||||
point.slider_velocity
|
||||
});
|
||||
|
||||
let path = slider.curve(&mut bufs.curve);
|
||||
|
||||
let velocity_factor = JuiceStream::BASE_SCORING_DIST * slider_multiplier / beat_len;
|
||||
let velocity = velocity_factor * slider_velocity;
|
||||
let tick_dist_factor =
|
||||
JuiceStream::BASE_SCORING_DIST * slider_multiplier / slider_tick_rate;
|
||||
|
||||
let tick_dist_multiplier = if converted.map.version < 8 {
|
||||
1.0
|
||||
} else {
|
||||
slider_velocity
|
||||
};
|
||||
|
||||
let tick_dist = tick_dist_factor * tick_dist_multiplier;
|
||||
|
||||
let span_count = slider.span_count() as f64;
|
||||
let duration = span_count * path.dist() / velocity;
|
||||
let span_duration = duration / span_count;
|
||||
|
||||
let events = SliderEventsIter::new(
|
||||
start_time,
|
||||
span_duration,
|
||||
velocity,
|
||||
tick_dist,
|
||||
path.dist(),
|
||||
slider.span_count() as i32,
|
||||
&mut bufs.ticks,
|
||||
);
|
||||
|
||||
let mut last_event_time = None;
|
||||
|
||||
for e in events {
|
||||
if let Some(last_event_time) = last_event_time.filter(|_| attrs.take_more()) {
|
||||
let since_last_tick = e.time - last_event_time;
|
||||
|
||||
if since_last_tick > 80.0 {
|
||||
let mut time_between_tiny = since_last_tick;
|
||||
|
||||
while time_between_tiny > 100.0 {
|
||||
time_between_tiny /= 2.0;
|
||||
}
|
||||
|
||||
let mut t = time_between_tiny;
|
||||
|
||||
while t < since_last_tick {
|
||||
attrs.inc_tiny_droplets();
|
||||
|
||||
let nested = NestedJuiceStreamObject {
|
||||
pos: 0.0, // not important
|
||||
start_time: 0.0, // not important
|
||||
kind: NestedJuiceStreamObjectKind::TinyDroplet,
|
||||
};
|
||||
|
||||
bufs.nested_objects.push(nested);
|
||||
|
||||
t += time_between_tiny;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
last_event_time = Some(e.time);
|
||||
|
||||
let kind = match e.kind {
|
||||
SliderEventType::Tick => {
|
||||
attrs.inc_droplets();
|
||||
|
||||
NestedJuiceStreamObjectKind::Droplet
|
||||
}
|
||||
SliderEventType::Head | SliderEventType::Repeat | SliderEventType::Tail => {
|
||||
attrs.inc_fruits();
|
||||
|
||||
NestedJuiceStreamObjectKind::Fruit
|
||||
}
|
||||
SliderEventType::LastTick => continue,
|
||||
};
|
||||
|
||||
let nested = NestedJuiceStreamObject {
|
||||
pos: x + path.position_at(e.path_progress).x,
|
||||
start_time: e.time,
|
||||
kind,
|
||||
};
|
||||
|
||||
bufs.nested_objects.push(nested);
|
||||
}
|
||||
|
||||
Self {
|
||||
control_points: slider.control_points.as_ref(),
|
||||
nested_objects: bufs.nested_objects.drain(..),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct NestedJuiceStreamObject {
|
||||
pub pos: f32,
|
||||
pub start_time: f64,
|
||||
pub kind: NestedJuiceStreamObjectKind,
|
||||
}
|
||||
|
||||
pub enum NestedJuiceStreamObjectKind {
|
||||
Fruit,
|
||||
Droplet,
|
||||
TinyDroplet,
|
||||
}
|
||||
|
||||
pub struct JuiceStreamBufs {
|
||||
pub nested_objects: Vec<NestedJuiceStreamObject>,
|
||||
pub curve: CurveBuffers,
|
||||
pub ticks: Vec<SliderEvent>,
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
pub mod banana_shower;
|
||||
pub mod fruit;
|
||||
pub mod juice_stream;
|
||||
pub mod palpable;
|
||||
@@ -0,0 +1,23 @@
|
||||
pub struct PalpableObject {
|
||||
pub x: f32,
|
||||
pub x_offset: f32,
|
||||
pub start_time: f64,
|
||||
pub dist_to_hyper_dash: f32,
|
||||
pub hyper_dash: bool,
|
||||
}
|
||||
|
||||
impl PalpableObject {
|
||||
pub const fn new(x: f32, x_offset: f32, start_time: f64) -> Self {
|
||||
Self {
|
||||
x,
|
||||
x_offset,
|
||||
start_time,
|
||||
dist_to_hyper_dash: 0.0,
|
||||
hyper_dash: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn effective_x(&self) -> f32 {
|
||||
self.x + self.x_offset
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,555 @@
|
||||
use std::cmp::{self, Ordering};
|
||||
|
||||
use crate::{
|
||||
any::ModeAttributeProvider,
|
||||
any::ModeDifficulty,
|
||||
osu::OsuPerformance,
|
||||
util::{map_or_attrs::MapOrAttrs, mods::Mods},
|
||||
};
|
||||
|
||||
use super::{
|
||||
attributes::{CatchDifficultyAttributes, CatchPerformanceAttributes},
|
||||
convert::CatchBeatmap,
|
||||
score_state::CatchScoreState,
|
||||
Catch,
|
||||
};
|
||||
|
||||
/// Performance calculator on osu!catch maps.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
#[must_use]
|
||||
pub struct CatchPerformance<'map> {
|
||||
pub(crate) map_or_attrs: MapOrAttrs<'map, Catch>,
|
||||
pub(crate) mods: u32,
|
||||
pub(crate) acc: Option<f64>,
|
||||
pub(crate) combo: Option<u32>,
|
||||
|
||||
pub(crate) n_fruits: Option<u32>,
|
||||
pub(crate) n_droplets: Option<u32>,
|
||||
pub(crate) n_tiny_droplets: Option<u32>,
|
||||
pub(crate) n_tiny_droplet_misses: Option<u32>,
|
||||
pub(crate) n_misses: Option<u32>,
|
||||
pub(crate) passed_objects: Option<u32>,
|
||||
pub(crate) clock_rate: Option<f64>,
|
||||
}
|
||||
|
||||
impl<'map> CatchPerformance<'map> {
|
||||
/// Create a new performance calculator for osu!catch maps.
|
||||
pub fn new(map: CatchBeatmap<'map>) -> Self {
|
||||
map.into()
|
||||
}
|
||||
|
||||
/// 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.
|
||||
pub fn attributes(mut self, attributes: impl ModeAttributeProvider<Catch>) -> Self {
|
||||
if let Some(attrs) = attributes.attributes() {
|
||||
self.map_or_attrs = MapOrAttrs::Attrs(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)
|
||||
pub const fn mods(mut self, mods: u32) -> Self {
|
||||
self.mods = mods;
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
/// Specify the max combo of the play.
|
||||
pub const fn combo(mut self, combo: u32) -> Self {
|
||||
self.combo = Some(combo);
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
/// Specify the amount of fruits of a play i.e. n300.
|
||||
pub const fn fruits(mut self, n_fruits: u32) -> Self {
|
||||
self.n_fruits = Some(n_fruits);
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
/// Specify the amount of droplets of a play i.e. n100.
|
||||
pub const fn droplets(mut self, n_droplets: u32) -> Self {
|
||||
self.n_droplets = Some(n_droplets);
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
/// Specify the amount of tiny droplets of a play i.e. n50.
|
||||
pub const fn tiny_droplets(mut self, n_tiny_droplets: u32) -> Self {
|
||||
self.n_tiny_droplets = Some(n_tiny_droplets);
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
/// Specify the amount of tiny droplet misses of a play i.e. `n_katu`.
|
||||
pub const fn tiny_droplet_misses(mut self, n_tiny_droplet_misses: u32) -> Self {
|
||||
self.n_tiny_droplet_misses = Some(n_tiny_droplet_misses);
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
/// Specify the amount of fruit / droplet misses of the play.
|
||||
pub const fn misses(mut self, n_misses: u32) -> Self {
|
||||
self.n_misses = Some(n_misses);
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
/// Amount of passed objects for partial plays, e.g. a fail.
|
||||
///
|
||||
#[cfg_attr(
|
||||
feature = "gradual",
|
||||
doc = "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::CatchGradualPerformance)."
|
||||
)]
|
||||
pub const fn passed_objects(mut self, passed_objects: u32) -> 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.
|
||||
pub const fn clock_rate(mut self, clock_rate: f64) -> Self {
|
||||
self.clock_rate = Some(clock_rate);
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
/// Provide parameters through an [`CatchScoreState`].
|
||||
#[allow(clippy::needless_pass_by_value)]
|
||||
pub const 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
|
||||
}
|
||||
|
||||
/// Specify the accuracy of a play between `0.0` and `100.0`.
|
||||
/// This will be used to generate matching hitresults.
|
||||
pub fn accuracy(mut self, acc: f64) -> Self {
|
||||
self.acc = Some(acc / 100.0);
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
/// Create the [`CatchScoreState`] that will be used for performance calculation.
|
||||
#[allow(clippy::too_many_lines)]
|
||||
pub fn generate_state(&mut self) -> CatchScoreState {
|
||||
let attrs = match self.map_or_attrs {
|
||||
MapOrAttrs::Map(ref map) => {
|
||||
let attrs = self.generate_attributes(map);
|
||||
|
||||
self.map_or_attrs.attrs_or_insert(attrs)
|
||||
}
|
||||
MapOrAttrs::Attrs(ref attrs) => attrs,
|
||||
};
|
||||
|
||||
let n_misses = self
|
||||
.n_misses
|
||||
.map_or(0, |n| n.min(attrs.n_fruits + attrs.n_droplets));
|
||||
|
||||
let max_combo = self.combo.unwrap_or_else(|| attrs.max_combo() - n_misses);
|
||||
|
||||
let mut best_state = CatchScoreState {
|
||||
max_combo,
|
||||
n_misses,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let mut best_dist = f64::INFINITY;
|
||||
|
||||
let (n_fruits, n_droplets) = match (self.n_fruits, self.n_droplets) {
|
||||
(Some(mut n_fruits), Some(mut n_droplets)) => {
|
||||
let n_remaining = (attrs.n_fruits + attrs.n_droplets)
|
||||
.saturating_sub(n_fruits + n_droplets + n_misses);
|
||||
|
||||
let new_droplets = n_remaining.min(attrs.n_droplets.saturating_sub(n_droplets));
|
||||
n_droplets += new_droplets;
|
||||
n_fruits += n_remaining - new_droplets;
|
||||
|
||||
n_fruits = n_fruits
|
||||
.min((attrs.n_fruits + attrs.n_droplets).saturating_sub(n_droplets + n_misses));
|
||||
n_droplets =
|
||||
n_droplets.min(attrs.n_fruits + attrs.n_droplets - n_fruits - n_misses);
|
||||
|
||||
(n_fruits, n_droplets)
|
||||
}
|
||||
(Some(mut n_fruits), None) => {
|
||||
let n_droplets = attrs.n_droplets.saturating_sub(
|
||||
n_misses.saturating_sub(attrs.n_fruits.saturating_sub(n_fruits)),
|
||||
);
|
||||
|
||||
n_fruits = attrs.n_fruits + attrs.n_droplets - n_misses - n_droplets;
|
||||
|
||||
(n_fruits, n_droplets)
|
||||
}
|
||||
(None, Some(mut n_droplets)) => {
|
||||
let n_fruits = attrs.n_fruits.saturating_sub(
|
||||
n_misses.saturating_sub(attrs.n_droplets.saturating_sub(n_droplets)),
|
||||
);
|
||||
|
||||
n_droplets = attrs.n_fruits + attrs.n_droplets - n_misses - n_fruits;
|
||||
|
||||
(n_fruits, n_droplets)
|
||||
}
|
||||
(None, None) => {
|
||||
let n_droplets = attrs.n_droplets.saturating_sub(n_misses);
|
||||
let n_fruits =
|
||||
attrs.n_fruits - (n_misses - (attrs.n_droplets.saturating_sub(n_droplets)));
|
||||
|
||||
(n_fruits, n_droplets)
|
||||
}
|
||||
};
|
||||
|
||||
best_state.n_fruits = n_fruits;
|
||||
best_state.n_droplets = n_droplets;
|
||||
|
||||
let mut find_best_tiny_droplets = |acc: f64| {
|
||||
let raw_tiny_droplets = acc
|
||||
* f64::from(attrs.n_fruits + attrs.n_droplets + attrs.n_tiny_droplets)
|
||||
- f64::from(n_fruits + n_droplets);
|
||||
let min_tiny_droplets =
|
||||
cmp::min(attrs.n_tiny_droplets, raw_tiny_droplets.floor() as u32);
|
||||
let max_tiny_droplets =
|
||||
cmp::min(attrs.n_tiny_droplets, raw_tiny_droplets.ceil() as u32);
|
||||
|
||||
for n_tiny_droplets in min_tiny_droplets..=max_tiny_droplets {
|
||||
let n_tiny_droplet_misses = attrs.n_tiny_droplets - n_tiny_droplets;
|
||||
|
||||
let curr_acc = accuracy(
|
||||
n_fruits,
|
||||
n_droplets,
|
||||
n_tiny_droplets,
|
||||
n_tiny_droplet_misses,
|
||||
n_misses,
|
||||
);
|
||||
let curr_dist = (acc - curr_acc).abs();
|
||||
|
||||
if curr_dist < best_dist {
|
||||
best_dist = curr_dist;
|
||||
best_state.n_tiny_droplets = n_tiny_droplets;
|
||||
best_state.n_tiny_droplet_misses = n_tiny_droplet_misses;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
#[allow(clippy::single_match_else)]
|
||||
match (self.n_tiny_droplets, self.n_tiny_droplet_misses) {
|
||||
(Some(n_tiny_droplets), Some(n_tiny_droplet_misses)) => match self.acc {
|
||||
Some(acc) => {
|
||||
match (n_tiny_droplets + n_tiny_droplet_misses).cmp(&attrs.n_tiny_droplets) {
|
||||
Ordering::Equal => {
|
||||
best_state.n_tiny_droplets = n_tiny_droplets;
|
||||
best_state.n_tiny_droplet_misses = n_tiny_droplet_misses;
|
||||
}
|
||||
Ordering::Less | Ordering::Greater => find_best_tiny_droplets(acc),
|
||||
}
|
||||
}
|
||||
None => {
|
||||
let n_remaining = attrs
|
||||
.n_tiny_droplets
|
||||
.saturating_sub(n_tiny_droplets + n_tiny_droplet_misses);
|
||||
|
||||
best_state.n_tiny_droplets = n_tiny_droplets + n_remaining;
|
||||
best_state.n_tiny_droplet_misses = n_tiny_droplet_misses;
|
||||
}
|
||||
},
|
||||
(Some(n_tiny_droplets), None) => {
|
||||
best_state.n_tiny_droplets = attrs.n_tiny_droplets.min(n_tiny_droplets);
|
||||
best_state.n_tiny_droplet_misses =
|
||||
attrs.n_tiny_droplets.saturating_sub(n_tiny_droplets);
|
||||
}
|
||||
(None, Some(n_tiny_droplet_misses)) => {
|
||||
best_state.n_tiny_droplets =
|
||||
attrs.n_tiny_droplets.saturating_sub(n_tiny_droplet_misses);
|
||||
best_state.n_tiny_droplet_misses = attrs.n_tiny_droplets.min(n_tiny_droplet_misses);
|
||||
}
|
||||
(None, None) => match self.acc {
|
||||
Some(acc) => find_best_tiny_droplets(acc),
|
||||
None => best_state.n_tiny_droplets = attrs.n_tiny_droplets,
|
||||
},
|
||||
}
|
||||
|
||||
best_state
|
||||
}
|
||||
|
||||
/// Calculate all performance related values, including pp and stars.
|
||||
pub fn calculate(mut self) -> CatchPerformanceAttributes {
|
||||
let state = self.generate_state();
|
||||
|
||||
let attrs = match self.map_or_attrs {
|
||||
MapOrAttrs::Map(ref map) => self.generate_attributes(map),
|
||||
MapOrAttrs::Attrs(attrs) => attrs,
|
||||
};
|
||||
|
||||
let inner = CatchPerformanceInner {
|
||||
attrs,
|
||||
mods: self.mods,
|
||||
state,
|
||||
};
|
||||
|
||||
inner.calculate()
|
||||
}
|
||||
|
||||
fn generate_attributes(&self, map: &CatchBeatmap<'_>) -> CatchDifficultyAttributes {
|
||||
let mut calculator = ModeDifficulty::new();
|
||||
|
||||
if let Some(passed_objects) = self.passed_objects {
|
||||
calculator.passed_objects(passed_objects);
|
||||
}
|
||||
|
||||
if let Some(clock_rate) = self.clock_rate {
|
||||
calculator.clock_rate(clock_rate);
|
||||
}
|
||||
|
||||
calculator.mods(self.mods).calculate(map)
|
||||
}
|
||||
|
||||
/// Try to create [`CatchPerformance`] through a [`ModeAttributeProvider`].
|
||||
///
|
||||
/// If you already calculated the attributes for the current map-mod
|
||||
/// combination, the [`CatchBeatmap`] is no longer necessary to calculate
|
||||
/// performance attributes so this method can be used instead of
|
||||
/// [`CatchPerformance::new`].
|
||||
///
|
||||
/// Returns `None` only if the [`ModeAttributeProvider`] did not contain
|
||||
/// attributes for catch e.g. if it's [`DifficultyAttributes::Taiko`].
|
||||
///
|
||||
/// [`DifficultyAttributes::Taiko`]: crate::any::DifficultyAttributes::Taiko
|
||||
pub fn try_from_attributes(attributes: impl ModeAttributeProvider<Catch>) -> Option<Self> {
|
||||
attributes.attributes().map(Self::from)
|
||||
}
|
||||
|
||||
/// Create [`CatchPerformance`] through a [`ModeAttributeProvider`].
|
||||
///
|
||||
/// If you already calculated the attributes for the current map-mod
|
||||
/// combination, the [`CatchBeatmap`] is no longer necessary to calculate
|
||||
/// performance attributes so this method can be used instead of
|
||||
/// [`CatchPerformance::new`].
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if the [`ModeAttributeProvider`] did not contain attributes for
|
||||
/// catch e.g. if it's [`DifficultyAttributes::Taiko`].
|
||||
///
|
||||
/// [`DifficultyAttributes::Taiko`]: crate::any::DifficultyAttributes::Taiko
|
||||
pub fn unchecked_from_attributes(attributes: impl ModeAttributeProvider<Catch>) -> Self {
|
||||
Self::try_from_attributes(attributes).expect("invalid catch attributes")
|
||||
}
|
||||
}
|
||||
|
||||
impl<'map> TryFrom<OsuPerformance<'map>> for CatchPerformance<'map> {
|
||||
type Error = OsuPerformance<'map>;
|
||||
|
||||
/// Try to create [`CatchPerformance`] through [`OsuPerformance`].
|
||||
///
|
||||
/// Returns `None` if [`OsuPerformance`] already replaced its internal
|
||||
/// beatmap with [`OsuDifficultyAttributes`], i.e. if
|
||||
/// [`OsuPerformance::attributes`] or [`OsuPerformance::generate_state`]
|
||||
/// was called.
|
||||
///
|
||||
/// [`OsuDifficultyAttributes`]: crate::osu::OsuDifficultyAttributes
|
||||
fn try_from(mut osu: OsuPerformance<'map>) -> Result<Self, Self::Error> {
|
||||
let MapOrAttrs::Map(converted) = osu.map_or_attrs else {
|
||||
return Err(osu);
|
||||
};
|
||||
|
||||
let map = match converted.try_convert() {
|
||||
Ok(map) => map,
|
||||
Err(map) => {
|
||||
osu.map_or_attrs = MapOrAttrs::Map(map);
|
||||
|
||||
return Err(osu);
|
||||
}
|
||||
};
|
||||
|
||||
let OsuPerformance {
|
||||
map_or_attrs: _,
|
||||
mods,
|
||||
acc,
|
||||
combo,
|
||||
n300,
|
||||
n100,
|
||||
n50,
|
||||
n_misses,
|
||||
passed_objects,
|
||||
clock_rate,
|
||||
hitresult_priority: _,
|
||||
} = osu;
|
||||
|
||||
Ok(Self {
|
||||
map_or_attrs: MapOrAttrs::Map(map),
|
||||
mods,
|
||||
acc,
|
||||
combo,
|
||||
n_fruits: n300,
|
||||
n_droplets: n100,
|
||||
n_tiny_droplets: n50,
|
||||
n_tiny_droplet_misses: None,
|
||||
n_misses,
|
||||
passed_objects,
|
||||
clock_rate,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl<'map> From<CatchBeatmap<'map>> for CatchPerformance<'map> {
|
||||
fn from(map: CatchBeatmap<'map>) -> Self {
|
||||
Self {
|
||||
map_or_attrs: MapOrAttrs::Map(map),
|
||||
mods: 0,
|
||||
acc: None,
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<CatchDifficultyAttributes> for CatchPerformance<'_> {
|
||||
fn from(attrs: CatchDifficultyAttributes) -> Self {
|
||||
Self {
|
||||
map_or_attrs: MapOrAttrs::Attrs(attrs),
|
||||
mods: 0,
|
||||
acc: None,
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<CatchPerformanceAttributes> for CatchPerformance<'_> {
|
||||
fn from(attrs: CatchPerformanceAttributes) -> Self {
|
||||
attrs.difficulty.into()
|
||||
}
|
||||
}
|
||||
|
||||
struct CatchPerformanceInner {
|
||||
attrs: CatchDifficultyAttributes,
|
||||
mods: u32,
|
||||
state: CatchScoreState,
|
||||
}
|
||||
|
||||
impl CatchPerformanceInner {
|
||||
fn calculate(self) -> CatchPerformanceAttributes {
|
||||
let attributes = &self.attrs;
|
||||
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 * (f64::from(combo_hits) / 2500.0).min(1.0)
|
||||
+ f64::from(u8::from(combo_hits > 2500))
|
||||
* (f64::from(combo_hits) / 2500.0).log10()
|
||||
* 0.475;
|
||||
|
||||
pp *= len_bonus;
|
||||
|
||||
// Penalize misses exponentially
|
||||
pp *= 0.97_f64.powi(self.state.n_misses as i32);
|
||||
|
||||
// Combo scaling
|
||||
if self.state.max_combo > 0 {
|
||||
pp *= (f64::from(self.state.max_combo) / f64::from(max_combo))
|
||||
.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) + f64::from(u8::from(ar > 10.0)) * 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.state.accuracy().powf(5.5);
|
||||
|
||||
// NF penalty
|
||||
if self.mods.nf() {
|
||||
pp *= 0.9;
|
||||
}
|
||||
|
||||
CatchPerformanceAttributes {
|
||||
difficulty: self.attrs,
|
||||
pp,
|
||||
}
|
||||
}
|
||||
|
||||
const fn combo_hits(&self) -> u32 {
|
||||
self.state.n_fruits + self.state.n_droplets + self.state.n_misses
|
||||
}
|
||||
}
|
||||
|
||||
fn accuracy(
|
||||
n_fruits: u32,
|
||||
n_droplets: u32,
|
||||
n_tiny_droplets: u32,
|
||||
n_tiny_droplet_misses: u32,
|
||||
n_misses: u32,
|
||||
) -> f64 {
|
||||
let numerator = n_fruits + n_droplets + n_tiny_droplets;
|
||||
let denominator = numerator + n_tiny_droplet_misses + n_misses;
|
||||
|
||||
f64::from(numerator) / f64::from(denominator)
|
||||
}
|
||||
-807
@@ -1,807 +0,0 @@
|
||||
use super::{CatchDifficultyAttributes, CatchPerformanceAttributes, CatchScoreState, CatchStars};
|
||||
use crate::{
|
||||
util::{MapOrElse, MapRef},
|
||||
Beatmap, DifficultyAttributes, Mods, OsuPP, PerformanceAttributes,
|
||||
};
|
||||
use std::cmp::Ordering;
|
||||
|
||||
/// 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)]
|
||||
#[must_use]
|
||||
pub struct CatchPP<'map> {
|
||||
pub(crate) map_or_attrs: MapOrElse<MapRef<'map>, CatchDifficultyAttributes>,
|
||||
pub(crate) mods: u32,
|
||||
pub(crate) acc: Option<f64>,
|
||||
pub(crate) 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>,
|
||||
pub(crate) passed_objects: Option<usize>,
|
||||
pub(crate) 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_or_attrs: MapOrElse::from(map),
|
||||
mods: 0,
|
||||
acc: None,
|
||||
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(attrs) = attributes.attributes() {
|
||||
self.map_or_attrs = MapOrElse::Else(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 const fn mods(mut self, mods: u32) -> Self {
|
||||
self.mods = mods;
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
/// Specify the max combo of the play.
|
||||
#[inline]
|
||||
pub const fn combo(mut self, combo: usize) -> Self {
|
||||
self.combo = Some(combo);
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
/// Specify the amount of fruits of a play i.e. n300.
|
||||
#[inline]
|
||||
pub const fn fruits(mut self, n_fruits: usize) -> Self {
|
||||
self.n_fruits = Some(n_fruits);
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
/// Specify the amount of droplets of a play i.e. n100.
|
||||
#[inline]
|
||||
pub const fn droplets(mut self, n_droplets: usize) -> Self {
|
||||
self.n_droplets = Some(n_droplets);
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
/// Specify the amount of tiny droplets of a play i.e. n50.
|
||||
#[inline]
|
||||
pub const fn tiny_droplets(mut self, n_tiny_droplets: usize) -> Self {
|
||||
self.n_tiny_droplets = Some(n_tiny_droplets);
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
/// Specify the amount of tiny droplet misses of a play i.e. `n_katu`.
|
||||
#[inline]
|
||||
pub const fn tiny_droplet_misses(mut self, n_tiny_droplet_misses: usize) -> Self {
|
||||
self.n_tiny_droplet_misses = Some(n_tiny_droplet_misses);
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
/// Specify the amount of fruit / droplet misses of the play.
|
||||
#[inline]
|
||||
pub const 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.
|
||||
///
|
||||
#[cfg_attr(
|
||||
feature = "gradual",
|
||||
doc = "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::CatchGradualPerformance)."
|
||||
)]
|
||||
#[inline]
|
||||
pub const fn passed_objects(mut self, passed_objects: usize) -> Self {
|
||||
self.passed_objects = Some(passed_objects);
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
/// Adjust the clock rate used in the calculation.
|
||||
/// If none is specified, it will take the clock rate based on the mods
|
||||
/// i.e. 1.5 for DT, 0.75 for HT and 1.0 otherwise.
|
||||
#[inline]
|
||||
pub const fn clock_rate(mut self, clock_rate: f64) -> Self {
|
||||
self.clock_rate = Some(clock_rate);
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
/// Provide parameters through an [`CatchScoreState`].
|
||||
#[inline]
|
||||
#[allow(clippy::needless_pass_by_value)]
|
||||
pub const 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
|
||||
}
|
||||
|
||||
/// 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
|
||||
}
|
||||
|
||||
/// Create the [`CatchScoreState`] that will be used for performance calculation.
|
||||
#[allow(clippy::too_many_lines)]
|
||||
pub fn generate_state(&mut self) -> CatchScoreState {
|
||||
let attrs = match self.map_or_attrs {
|
||||
MapOrElse::Map(ref map) => {
|
||||
let attrs = self.generate_attributes(map.as_ref());
|
||||
|
||||
self.map_or_attrs.else_or_insert(attrs)
|
||||
}
|
||||
MapOrElse::Else(ref attrs) => attrs,
|
||||
};
|
||||
|
||||
let n_misses = self
|
||||
.n_misses
|
||||
.map_or(0, |n| n.min(attrs.n_fruits + attrs.n_droplets));
|
||||
|
||||
let max_combo = self.combo.unwrap_or_else(|| attrs.max_combo() - n_misses);
|
||||
|
||||
let mut best_state = CatchScoreState {
|
||||
max_combo,
|
||||
n_misses,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let mut best_dist = f64::INFINITY;
|
||||
|
||||
let (n_fruits, n_droplets) = match (self.n_fruits, self.n_droplets) {
|
||||
(Some(mut n_fruits), Some(mut n_droplets)) => {
|
||||
let n_remaining = (attrs.n_fruits + attrs.n_droplets)
|
||||
.saturating_sub(n_fruits + n_droplets + n_misses);
|
||||
|
||||
let new_droplets = n_remaining.min(attrs.n_droplets.saturating_sub(n_droplets));
|
||||
n_droplets += new_droplets;
|
||||
n_fruits += n_remaining - new_droplets;
|
||||
|
||||
n_fruits = n_fruits
|
||||
.min((attrs.n_fruits + attrs.n_droplets).saturating_sub(n_droplets + n_misses));
|
||||
n_droplets =
|
||||
n_droplets.min(attrs.n_fruits + attrs.n_droplets - n_fruits - n_misses);
|
||||
|
||||
(n_fruits, n_droplets)
|
||||
}
|
||||
(Some(mut n_fruits), None) => {
|
||||
let n_droplets = attrs.n_droplets.saturating_sub(
|
||||
n_misses.saturating_sub(attrs.n_fruits.saturating_sub(n_fruits)),
|
||||
);
|
||||
|
||||
n_fruits = attrs.n_fruits + attrs.n_droplets - n_misses - n_droplets;
|
||||
|
||||
(n_fruits, n_droplets)
|
||||
}
|
||||
(None, Some(mut n_droplets)) => {
|
||||
let n_fruits = attrs.n_fruits.saturating_sub(
|
||||
n_misses.saturating_sub(attrs.n_droplets.saturating_sub(n_droplets)),
|
||||
);
|
||||
|
||||
n_droplets = attrs.n_fruits + attrs.n_droplets - n_misses - n_fruits;
|
||||
|
||||
(n_fruits, n_droplets)
|
||||
}
|
||||
(None, None) => {
|
||||
let n_droplets = attrs.n_droplets.saturating_sub(n_misses);
|
||||
let n_fruits =
|
||||
attrs.n_fruits - (n_misses - (attrs.n_droplets.saturating_sub(n_droplets)));
|
||||
|
||||
(n_fruits, n_droplets)
|
||||
}
|
||||
};
|
||||
|
||||
best_state.n_fruits = n_fruits;
|
||||
best_state.n_droplets = n_droplets;
|
||||
|
||||
let mut find_best_tiny_droplets = |acc: f64| {
|
||||
let raw_tiny_droplets = acc
|
||||
* (attrs.n_fruits + attrs.n_droplets + attrs.n_tiny_droplets) as f64
|
||||
- (n_fruits + n_droplets) as f64;
|
||||
let min_tiny_droplets = attrs
|
||||
.n_tiny_droplets
|
||||
.min(raw_tiny_droplets.floor() as usize);
|
||||
let max_tiny_droplets = attrs.n_tiny_droplets.min(raw_tiny_droplets.ceil() as usize);
|
||||
|
||||
for n_tiny_droplets in min_tiny_droplets..=max_tiny_droplets {
|
||||
let n_tiny_droplet_misses = attrs.n_tiny_droplets - n_tiny_droplets;
|
||||
|
||||
let curr_acc = accuracy(
|
||||
n_fruits,
|
||||
n_droplets,
|
||||
n_tiny_droplets,
|
||||
n_tiny_droplet_misses,
|
||||
n_misses,
|
||||
);
|
||||
let curr_dist = (acc - curr_acc).abs();
|
||||
|
||||
if curr_dist < best_dist {
|
||||
best_dist = curr_dist;
|
||||
best_state.n_tiny_droplets = n_tiny_droplets;
|
||||
best_state.n_tiny_droplet_misses = n_tiny_droplet_misses;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
#[allow(clippy::single_match_else)]
|
||||
match (self.n_tiny_droplets, self.n_tiny_droplet_misses) {
|
||||
(Some(n_tiny_droplets), Some(n_tiny_droplet_misses)) => match self.acc {
|
||||
Some(acc) => {
|
||||
match (n_tiny_droplets + n_tiny_droplet_misses).cmp(&attrs.n_tiny_droplets) {
|
||||
Ordering::Equal => {
|
||||
best_state.n_tiny_droplets = n_tiny_droplets;
|
||||
best_state.n_tiny_droplet_misses = n_tiny_droplet_misses;
|
||||
}
|
||||
Ordering::Less | Ordering::Greater => find_best_tiny_droplets(acc),
|
||||
}
|
||||
}
|
||||
None => {
|
||||
let n_remaining = attrs
|
||||
.n_tiny_droplets
|
||||
.saturating_sub(n_tiny_droplets + n_tiny_droplet_misses);
|
||||
|
||||
best_state.n_tiny_droplets = n_tiny_droplets + n_remaining;
|
||||
best_state.n_tiny_droplet_misses = n_tiny_droplet_misses;
|
||||
}
|
||||
},
|
||||
(Some(n_tiny_droplets), None) => {
|
||||
best_state.n_tiny_droplets = attrs.n_tiny_droplets.min(n_tiny_droplets);
|
||||
best_state.n_tiny_droplet_misses =
|
||||
attrs.n_tiny_droplets.saturating_sub(n_tiny_droplets);
|
||||
}
|
||||
(None, Some(n_tiny_droplet_misses)) => {
|
||||
best_state.n_tiny_droplets =
|
||||
attrs.n_tiny_droplets.saturating_sub(n_tiny_droplet_misses);
|
||||
best_state.n_tiny_droplet_misses = attrs.n_tiny_droplets.min(n_tiny_droplet_misses);
|
||||
}
|
||||
(None, None) => match self.acc {
|
||||
Some(acc) => find_best_tiny_droplets(acc),
|
||||
None => best_state.n_tiny_droplets = attrs.n_tiny_droplets,
|
||||
},
|
||||
}
|
||||
|
||||
best_state
|
||||
}
|
||||
|
||||
/// Calculate all performance related values, including pp and stars.
|
||||
pub fn calculate(mut self) -> CatchPerformanceAttributes {
|
||||
let state = self.generate_state();
|
||||
|
||||
let attrs = match self.map_or_attrs {
|
||||
MapOrElse::Map(ref map) => self.generate_attributes(map.as_ref()),
|
||||
MapOrElse::Else(attrs) => attrs,
|
||||
};
|
||||
|
||||
let inner = CatchPPInner {
|
||||
attrs,
|
||||
mods: self.mods,
|
||||
state,
|
||||
};
|
||||
|
||||
inner.calculate()
|
||||
}
|
||||
|
||||
fn generate_attributes(&self, map: &Beatmap) -> CatchDifficultyAttributes {
|
||||
let mut calculator = CatchStars::new(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()
|
||||
}
|
||||
|
||||
/// Try to create [`CatchPP`] through [`OsuPP`].
|
||||
///
|
||||
/// Returns `None` if [`OsuPP`] already replaced its internal [`Beatmap`]
|
||||
/// with [`OsuDifficultyAttributes`], i.e. if [`OsuPP::attributes`]
|
||||
/// or [`OsuPP::generate_state`] was called.
|
||||
///
|
||||
/// [`OsuDifficultyAttributes`]: crate::osu::OsuDifficultyAttributes
|
||||
#[inline]
|
||||
pub const fn try_from_osu(osu: OsuPP<'map>) -> Option<Self> {
|
||||
let OsuPP {
|
||||
map_or_attrs,
|
||||
mods,
|
||||
acc,
|
||||
combo,
|
||||
n300,
|
||||
n100,
|
||||
n50,
|
||||
n_misses,
|
||||
passed_objects,
|
||||
clock_rate,
|
||||
hitresult_priority: _,
|
||||
} = osu;
|
||||
|
||||
let MapOrElse::Map(map) = map_or_attrs else {
|
||||
return None;
|
||||
};
|
||||
|
||||
Some(Self {
|
||||
map_or_attrs: MapOrElse::Map(map),
|
||||
mods,
|
||||
acc,
|
||||
combo,
|
||||
n_fruits: n300,
|
||||
n_droplets: n100,
|
||||
n_tiny_droplets: n50,
|
||||
n_tiny_droplet_misses: None,
|
||||
n_misses,
|
||||
passed_objects,
|
||||
clock_rate,
|
||||
})
|
||||
}
|
||||
|
||||
/// Try to create [`CatchPP`] through a [`CatchAttributeProvider`].
|
||||
///
|
||||
/// If you already calculated the attributes for the current map-mod
|
||||
/// combination, the [`Beatmap`] is no longer necessary to calculate
|
||||
/// performance attributes so this method can be used instead of
|
||||
/// [`CatchPP::new`].
|
||||
///
|
||||
/// Returns `None` only if the [`CatchAttributeProvider`] did not contain
|
||||
/// attributes for catch e.g. if it's [`DifficultyAttributes::Taiko`].
|
||||
#[inline]
|
||||
pub fn try_from_attributes(attributes: impl CatchAttributeProvider) -> Option<Self> {
|
||||
attributes.attributes().map(Self::from)
|
||||
}
|
||||
}
|
||||
|
||||
struct CatchPPInner {
|
||||
attrs: CatchDifficultyAttributes,
|
||||
mods: u32,
|
||||
state: CatchScoreState,
|
||||
}
|
||||
|
||||
impl CatchPPInner {
|
||||
fn calculate(self) -> CatchPerformanceAttributes {
|
||||
let attributes = &self.attrs;
|
||||
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)
|
||||
+ f64::from(u8::from(combo_hits > 2500)) * (combo_hits as f64 / 2500.0).log10() * 0.475;
|
||||
|
||||
pp *= len_bonus;
|
||||
|
||||
// Penalize misses exponentially
|
||||
pp *= 0.97_f64.powi(self.state.n_misses as i32);
|
||||
|
||||
// Combo scaling
|
||||
if self.state.max_combo > 0 {
|
||||
pp *= (self.state.max_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) + f64::from(u8::from(ar > 10.0)) * 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.state.accuracy().powf(5.5);
|
||||
|
||||
// NF penalty
|
||||
if self.mods.nf() {
|
||||
pp *= 0.9;
|
||||
}
|
||||
|
||||
CatchPerformanceAttributes {
|
||||
difficulty: self.attrs,
|
||||
pp,
|
||||
}
|
||||
}
|
||||
|
||||
const fn combo_hits(&self) -> usize {
|
||||
self.state.n_fruits + self.state.n_droplets + self.state.n_misses
|
||||
}
|
||||
}
|
||||
|
||||
fn accuracy(
|
||||
n_fruits: usize,
|
||||
n_droplets: usize,
|
||||
n_tiny_droplets: usize,
|
||||
n_tiny_droplet_misses: usize,
|
||||
n_misses: usize,
|
||||
) -> f64 {
|
||||
let numerator = n_fruits + n_droplets + n_tiny_droplets;
|
||||
let denominator = numerator + n_tiny_droplet_misses + n_misses;
|
||||
|
||||
numerator as f64 / denominator as f64
|
||||
}
|
||||
|
||||
impl From<CatchDifficultyAttributes> for CatchPP<'_> {
|
||||
fn from(attrs: CatchDifficultyAttributes) -> Self {
|
||||
Self {
|
||||
map_or_attrs: MapOrElse::Else(attrs),
|
||||
mods: 0,
|
||||
acc: None,
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<CatchPerformanceAttributes> for CatchPP<'_> {
|
||||
fn from(attrs: CatchPerformanceAttributes) -> Self {
|
||||
attrs.difficulty.into()
|
||||
}
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
if let Self::Catch(attributes) = self {
|
||||
Some(attributes)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CatchAttributeProvider for PerformanceAttributes {
|
||||
#[inline]
|
||||
fn attributes(self) -> Option<CatchDifficultyAttributes> {
|
||||
if let Self::Catch(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;
|
||||
use proptest::{option, prelude::*};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
static DATA: OnceLock<(Beatmap, CatchDifficultyAttributes)> = OnceLock::new();
|
||||
|
||||
const N_FRUITS: usize = 728;
|
||||
const N_DROPLETS: usize = 2;
|
||||
const N_TINY_DROPLETS: usize = 291;
|
||||
|
||||
fn test_data() -> (&'static Beatmap, CatchDifficultyAttributes) {
|
||||
let (map, attrs) = DATA.get_or_init(|| {
|
||||
let path = "./maps/2118524.osu";
|
||||
let map = Beatmap::from_path(path).unwrap();
|
||||
let attrs = CatchStars::new(&map).calculate();
|
||||
|
||||
assert_eq!(
|
||||
(N_FRUITS, N_DROPLETS, N_TINY_DROPLETS),
|
||||
(attrs.n_fruits, attrs.n_droplets, attrs.n_tiny_droplets)
|
||||
);
|
||||
|
||||
(map, attrs)
|
||||
});
|
||||
|
||||
(map, attrs.to_owned())
|
||||
}
|
||||
|
||||
/// Checks all remaining hitresult combinations w.r.t. the given parameters
|
||||
/// and returns the [`OsuScoreState`] that matches `acc` the best.
|
||||
///
|
||||
/// Very slow but accurate.
|
||||
fn brute_force_best(
|
||||
acc: f64,
|
||||
n_fruits: Option<usize>,
|
||||
n_droplets: Option<usize>,
|
||||
n_tiny_droplets: Option<usize>,
|
||||
n_tiny_droplet_misses: Option<usize>,
|
||||
n_misses: usize,
|
||||
) -> CatchScoreState {
|
||||
let n_misses = n_misses.min(N_FRUITS + N_DROPLETS);
|
||||
|
||||
let mut best_state = CatchScoreState {
|
||||
max_combo: N_FRUITS + N_DROPLETS - n_misses,
|
||||
n_misses,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let mut best_dist = f64::INFINITY;
|
||||
|
||||
let (new_fruits, new_droplets) = match (n_fruits, n_droplets) {
|
||||
(Some(mut n_fruits), Some(mut n_droplets)) => {
|
||||
let n_remaining =
|
||||
(N_FRUITS + N_DROPLETS).saturating_sub(n_fruits + n_droplets + n_misses);
|
||||
|
||||
let new_droplets = n_remaining.min(N_DROPLETS.saturating_sub(n_droplets));
|
||||
n_droplets += new_droplets;
|
||||
n_fruits += n_remaining - new_droplets;
|
||||
|
||||
n_fruits =
|
||||
n_fruits.min((N_FRUITS + N_DROPLETS).saturating_sub(n_droplets + n_misses));
|
||||
n_droplets = n_droplets.min(N_FRUITS + N_DROPLETS - n_fruits - n_misses);
|
||||
|
||||
(n_fruits, n_droplets)
|
||||
}
|
||||
(Some(mut n_fruits), None) => {
|
||||
let n_droplets = N_DROPLETS
|
||||
.saturating_sub(n_misses.saturating_sub(N_FRUITS.saturating_sub(n_fruits)));
|
||||
n_fruits = N_FRUITS + N_DROPLETS - n_misses - n_droplets;
|
||||
|
||||
(n_fruits, n_droplets)
|
||||
}
|
||||
(None, Some(mut n_droplets)) => {
|
||||
let n_fruits = N_FRUITS
|
||||
.saturating_sub(n_misses.saturating_sub(N_DROPLETS.saturating_sub(n_droplets)));
|
||||
n_droplets = N_FRUITS + N_DROPLETS - n_misses - n_fruits;
|
||||
|
||||
(n_fruits, n_droplets)
|
||||
}
|
||||
(None, None) => {
|
||||
let n_droplets = N_DROPLETS.saturating_sub(n_misses);
|
||||
let n_fruits = N_FRUITS - (n_misses - (N_DROPLETS.saturating_sub(n_droplets)));
|
||||
|
||||
(n_fruits, n_droplets)
|
||||
}
|
||||
};
|
||||
|
||||
best_state.n_fruits = new_fruits;
|
||||
best_state.n_droplets = new_droplets;
|
||||
|
||||
let (min_tiny_droplets, max_tiny_droplets) = match (n_tiny_droplets, n_tiny_droplet_misses)
|
||||
{
|
||||
(Some(n_tiny_droplets), Some(n_tiny_droplet_misses)) => {
|
||||
match (n_tiny_droplets + n_tiny_droplet_misses).cmp(&N_TINY_DROPLETS) {
|
||||
Ordering::Equal => (
|
||||
N_TINY_DROPLETS.min(n_tiny_droplets),
|
||||
N_TINY_DROPLETS.min(n_tiny_droplets),
|
||||
),
|
||||
Ordering::Less | Ordering::Greater => (0, N_TINY_DROPLETS),
|
||||
}
|
||||
}
|
||||
(Some(n_tiny_droplets), None) => (
|
||||
N_TINY_DROPLETS.min(n_tiny_droplets),
|
||||
N_TINY_DROPLETS.min(n_tiny_droplets),
|
||||
),
|
||||
(None, Some(n_tiny_droplet_misses)) => (
|
||||
N_TINY_DROPLETS.saturating_sub(n_tiny_droplet_misses),
|
||||
N_TINY_DROPLETS.saturating_sub(n_tiny_droplet_misses),
|
||||
),
|
||||
(None, None) => (0, N_TINY_DROPLETS),
|
||||
};
|
||||
|
||||
for new_tiny_droplets in min_tiny_droplets..=max_tiny_droplets {
|
||||
let new_tiny_droplet_misses = N_TINY_DROPLETS - new_tiny_droplets;
|
||||
|
||||
let curr_acc = accuracy(
|
||||
new_fruits,
|
||||
new_droplets,
|
||||
new_tiny_droplets,
|
||||
new_tiny_droplet_misses,
|
||||
n_misses,
|
||||
);
|
||||
|
||||
let curr_dist = (acc - curr_acc).abs();
|
||||
|
||||
if curr_dist < best_dist {
|
||||
best_dist = curr_dist;
|
||||
best_state.n_tiny_droplets = new_tiny_droplets;
|
||||
best_state.n_tiny_droplet_misses = new_tiny_droplet_misses;
|
||||
}
|
||||
}
|
||||
|
||||
best_state
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#![proptest_config(ProptestConfig::with_cases(20_000))]
|
||||
#[test]
|
||||
fn catch_hitresults(
|
||||
acc in 0.0..=1.0,
|
||||
n_fruits in option::weighted(0.10, 0_usize..=N_FRUITS + 10),
|
||||
n_droplets in option::weighted(0.10, 0_usize..=N_DROPLETS + 10),
|
||||
n_tiny_droplets in option::weighted(0.10, 0_usize..=N_TINY_DROPLETS + 10),
|
||||
n_tiny_droplet_misses in option::weighted(0.10, 0_usize..=N_TINY_DROPLETS + 10),
|
||||
n_misses in option::weighted(0.15, 0_usize..=N_FRUITS + N_DROPLETS + 10),
|
||||
) {
|
||||
let (map, attrs) = test_data();
|
||||
|
||||
let mut state = CatchPP::new(map)
|
||||
.attributes(attrs)
|
||||
.accuracy(acc * 100.0);
|
||||
|
||||
if let Some(n_fruits) = n_fruits {
|
||||
state = state.fruits(n_fruits);
|
||||
}
|
||||
|
||||
if let Some(n_droplets) = n_droplets {
|
||||
state = state.droplets(n_droplets);
|
||||
}
|
||||
|
||||
if let Some(n_tiny_droplets) = n_tiny_droplets {
|
||||
state = state.tiny_droplets(n_tiny_droplets);
|
||||
}
|
||||
|
||||
if let Some(n_tiny_droplet_misses) = n_tiny_droplet_misses {
|
||||
state = state.tiny_droplet_misses(n_tiny_droplet_misses);
|
||||
}
|
||||
|
||||
if let Some(n_misses) = n_misses {
|
||||
state = state.misses(n_misses);
|
||||
}
|
||||
|
||||
let state = state.generate_state();
|
||||
|
||||
let expected = brute_force_best(
|
||||
acc,
|
||||
n_fruits,
|
||||
n_droplets,
|
||||
n_tiny_droplets,
|
||||
n_tiny_droplet_misses,
|
||||
n_misses.unwrap_or(0),
|
||||
);
|
||||
|
||||
assert_eq!(state, expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fruits_missing_objects() {
|
||||
let (map, attrs) = test_data();
|
||||
|
||||
let state = CatchPP::new(map)
|
||||
.attributes(attrs)
|
||||
.fruits(N_FRUITS - 10)
|
||||
.droplets(N_DROPLETS - 1)
|
||||
.tiny_droplets(N_TINY_DROPLETS - 50)
|
||||
.tiny_droplet_misses(20)
|
||||
.misses(2)
|
||||
.generate_state();
|
||||
|
||||
let expected = CatchScoreState {
|
||||
max_combo: N_FRUITS + N_DROPLETS - 2,
|
||||
n_fruits: N_FRUITS - 2,
|
||||
n_droplets: N_DROPLETS,
|
||||
n_tiny_droplets: N_TINY_DROPLETS - 20,
|
||||
n_tiny_droplet_misses: 20,
|
||||
n_misses: 2,
|
||||
};
|
||||
|
||||
assert_eq!(state, expected);
|
||||
}
|
||||
}
|
||||
+10
-13
@@ -1,22 +1,21 @@
|
||||
/// Aggregation for a score's current state i.e. what was the
|
||||
/// maximum combo so far and what are the current hitresults.
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq)]
|
||||
/// Aggregation for a score's current state.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct 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,
|
||||
pub max_combo: u32,
|
||||
/// Amount of current fruits (300s).
|
||||
pub n_fruits: usize,
|
||||
pub n_fruits: u32,
|
||||
/// Amount of current droplets (100s).
|
||||
pub n_droplets: usize,
|
||||
pub n_droplets: u32,
|
||||
/// Amount of current tiny droplets (50s).
|
||||
pub n_tiny_droplets: usize,
|
||||
pub n_tiny_droplets: u32,
|
||||
/// Amount of current tiny droplet misses (katus).
|
||||
pub n_tiny_droplet_misses: usize,
|
||||
pub n_tiny_droplet_misses: u32,
|
||||
/// Amount of current misses (fruits and droplets).
|
||||
pub n_misses: usize,
|
||||
pub n_misses: u32,
|
||||
}
|
||||
|
||||
impl CatchScoreState {
|
||||
@@ -26,8 +25,7 @@ impl CatchScoreState {
|
||||
}
|
||||
|
||||
/// Return the total amount of hits by adding everything up.
|
||||
#[inline]
|
||||
pub const fn total_hits(&self) -> usize {
|
||||
pub const fn total_hits(&self) -> u32 {
|
||||
self.n_fruits
|
||||
+ self.n_droplets
|
||||
+ self.n_tiny_droplets
|
||||
@@ -36,7 +34,6 @@ impl CatchScoreState {
|
||||
}
|
||||
|
||||
/// Calculate the accuracy between `0.0` and `1.0` for this state.
|
||||
#[inline]
|
||||
pub fn accuracy(&self) -> f64 {
|
||||
let total_hits = self.total_hits();
|
||||
|
||||
@@ -47,6 +44,6 @@ impl CatchScoreState {
|
||||
let numerator = self.n_fruits + self.n_droplets + self.n_tiny_droplets;
|
||||
let denominator = total_hits;
|
||||
|
||||
numerator as f64 / denominator as f64
|
||||
f64::from(numerator) / f64::from(denominator)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
use crate::{any::ModeDifficulty, catch::difficulty::DifficultyValues};
|
||||
|
||||
use super::convert::CatchBeatmap;
|
||||
|
||||
/// The result of calculating the strains on a osu!catch map.
|
||||
///
|
||||
/// Suitable to plot the difficulty of a map over time.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct CatchStrains {
|
||||
/// Strain peaks of the movement skill.
|
||||
pub movement: Vec<f64>,
|
||||
}
|
||||
|
||||
impl CatchStrains {
|
||||
/// Time between two strains in ms.
|
||||
pub const SECTION_LEN: f64 = 750.0;
|
||||
}
|
||||
|
||||
pub fn strains(difficulty: &ModeDifficulty, converted: &CatchBeatmap<'_>) -> CatchStrains {
|
||||
let DifficultyValues { movement, .. } = DifficultyValues::calculate(difficulty, converted);
|
||||
|
||||
CatchStrains {
|
||||
movement: movement.get_curr_strain_peaks(),
|
||||
}
|
||||
}
|
||||
-546
@@ -1,546 +0,0 @@
|
||||
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];
|
||||
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 += f64::from((next - curr).length());
|
||||
|
||||
calculated_len
|
||||
});
|
||||
|
||||
cumulative_len.extend(length_iter);
|
||||
|
||||
if let Some(expected_len) =
|
||||
expected_len.filter(|&len| (calculated_len - len).abs() >= f64::EPSILON)
|
||||
{
|
||||
// * 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 Some(pr) = Self::circular_arc_properties(a, b, c) else {
|
||||
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();
|
||||
|
||||
// In C# it holds `(int)Infinity == -2147483648` whereas in Rust it's 2147483647
|
||||
// so we need to workaround this edge case, see map id 2568364
|
||||
if divisor.abs() <= f32::EPSILON {
|
||||
2
|
||||
} else {
|
||||
((pr.theta_range / f64::from(divisor)).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 = f64::from(d_a.y).atan2(f64::from(d_a.x));
|
||||
let mut theta_end = f64::from(d_c.y).atan2(f64::from(d_c.x));
|
||||
|
||||
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,
|
||||
})
|
||||
}
|
||||
}
|
||||
-363
@@ -1,363 +0,0 @@
|
||||
#![cfg(feature = "gradual")]
|
||||
|
||||
use crate::catch::{CatchOwnedGradualDifficulty, CatchOwnedGradualPerformance};
|
||||
use crate::mania::{ManiaOwnedGradualDifficulty, ManiaOwnedGradualPerformance};
|
||||
use crate::osu::OsuOwnedGradualPerformance;
|
||||
use crate::taiko::TaikoOwnedGradualPerformance;
|
||||
use crate::{
|
||||
catch::{CatchGradualDifficulty, CatchGradualPerformance},
|
||||
mania::{ManiaGradualDifficulty, ManiaGradualPerformance},
|
||||
osu::{OsuGradualDifficulty, OsuGradualPerformance},
|
||||
taiko::{TaikoGradualDifficulty, TaikoGradualPerformance},
|
||||
Beatmap, DifficultyAttributes, GameMode, PerformanceAttributes, ScoreState,
|
||||
};
|
||||
|
||||
/// Gradually calculate the difficulty attributes on maps of any mode.
|
||||
///
|
||||
/// Note that this struct implements [`Iterator`].
|
||||
/// On every call of [`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 [`GradualPerformance`] instead.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```no_run
|
||||
/// use rosu_pp::{Beatmap, GradualDifficulty};
|
||||
///
|
||||
/// # /*
|
||||
/// let map: Beatmap = ...
|
||||
/// # */
|
||||
/// # let map = Beatmap::default();
|
||||
///
|
||||
/// let mods = 64; // DT
|
||||
/// let mut iter = GradualDifficulty::new(&map, mods);
|
||||
///
|
||||
/// let attrs1 = iter.next(); // the difficulty of the map after the first hit object
|
||||
/// let attrs2 = iter.next(); // after the second hit object
|
||||
///
|
||||
/// // Remaining hit objects
|
||||
/// for difficulty in iter {
|
||||
/// // ...
|
||||
/// }
|
||||
/// ```
|
||||
#[cfg_attr(docsrs, doc(cfg(feature = "gradual")))]
|
||||
#[derive(Debug)]
|
||||
pub enum GradualDifficulty<'map> {
|
||||
/// Gradual osu!standard difficulty attributes.
|
||||
Osu(OsuGradualDifficulty),
|
||||
/// Gradual osu!taiko difficulty attributes.
|
||||
Taiko(TaikoGradualDifficulty),
|
||||
/// Gradual osu!catch difficulty attributes.
|
||||
Catch(CatchGradualDifficulty<'map>),
|
||||
/// Gradual osu!mania difficulty attributes.
|
||||
Mania(ManiaGradualDifficulty<'map>),
|
||||
}
|
||||
|
||||
impl<'map> GradualDifficulty<'map> {
|
||||
// FIXME: converted catch maps will always count as osu!std since their mode is not modified
|
||||
/// Create a new gradual difficulty calculator for maps of any mode.
|
||||
#[inline]
|
||||
pub fn new(map: &'map Beatmap, mods: u32) -> Self {
|
||||
match map.mode {
|
||||
GameMode::Osu => Self::Osu(OsuGradualDifficulty::new(map, mods)),
|
||||
GameMode::Taiko => Self::Taiko(TaikoGradualDifficulty::new(map, mods)),
|
||||
GameMode::Catch => Self::Catch(CatchGradualDifficulty::new(map, mods)),
|
||||
GameMode::Mania => Self::Mania(ManiaGradualDifficulty::new(map, mods)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Iterator for GradualDifficulty<'_> {
|
||||
type Item = DifficultyAttributes;
|
||||
|
||||
#[inline]
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
match self {
|
||||
Self::Osu(o) => o.next().map(DifficultyAttributes::Osu),
|
||||
Self::Taiko(t) => t.next().map(DifficultyAttributes::Taiko),
|
||||
Self::Catch(f) => f.next().map(DifficultyAttributes::Catch),
|
||||
Self::Mania(m) => m.next().map(DifficultyAttributes::Mania),
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn size_hint(&self) -> (usize, Option<usize>) {
|
||||
match self {
|
||||
Self::Osu(o) => o.size_hint(),
|
||||
Self::Taiko(t) => t.size_hint(),
|
||||
Self::Catch(f) => f.size_hint(),
|
||||
Self::Mania(m) => m.size_hint(),
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn nth(&mut self, n: usize) -> Option<Self::Item> {
|
||||
match self {
|
||||
Self::Osu(o) => o.nth(n).map(DifficultyAttributes::Osu),
|
||||
Self::Taiko(t) => t.nth(n).map(DifficultyAttributes::Taiko),
|
||||
Self::Catch(c) => c.nth(n).map(DifficultyAttributes::Catch),
|
||||
Self::Mania(m) => m.nth(n).map(DifficultyAttributes::Mania),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Gradually calculate the difficulty attributes on maps of any mode.
|
||||
///
|
||||
/// Check [`GradualDifficulty`] for more information. This type does the same
|
||||
/// but depending on the mode it might clone [`Beatmap`] to avoid being bound to a lifetime.
|
||||
#[cfg_attr(docsrs, doc(cfg(feature = "gradual")))]
|
||||
#[derive(Debug)]
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
pub enum OwnedGradualDifficulty {
|
||||
/// Gradual osu!standard difficulty attributes.
|
||||
Osu(OsuGradualDifficulty),
|
||||
/// Gradual osu!taiko difficulty attributes.
|
||||
Taiko(TaikoGradualDifficulty),
|
||||
/// Gradual osu!catch difficulty attributes.
|
||||
Catch(CatchOwnedGradualDifficulty),
|
||||
/// Gradual osu!mania difficulty attributes.
|
||||
Mania(ManiaOwnedGradualDifficulty),
|
||||
}
|
||||
|
||||
impl OwnedGradualDifficulty {
|
||||
// FIXME: converted catch maps will always count as osu!std since their mode is not modified
|
||||
/// Create a new gradual difficulty calculator for maps of any mode.
|
||||
#[inline]
|
||||
pub fn new(map: &Beatmap, mods: u32) -> Self {
|
||||
match map.mode {
|
||||
GameMode::Osu => Self::Osu(OsuGradualDifficulty::new(map, mods)),
|
||||
GameMode::Taiko => Self::Taiko(TaikoGradualDifficulty::new(map, mods)),
|
||||
GameMode::Catch => Self::Catch(CatchOwnedGradualDifficulty::new(map.to_owned(), mods)),
|
||||
GameMode::Mania => Self::Mania(ManiaOwnedGradualDifficulty::new(map.to_owned(), mods)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Iterator for OwnedGradualDifficulty {
|
||||
type Item = DifficultyAttributes;
|
||||
|
||||
#[inline]
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
match self {
|
||||
Self::Osu(o) => o.next().map(DifficultyAttributes::Osu),
|
||||
Self::Taiko(t) => t.next().map(DifficultyAttributes::Taiko),
|
||||
Self::Catch(f) => f.next().map(DifficultyAttributes::Catch),
|
||||
Self::Mania(m) => m.next().map(DifficultyAttributes::Mania),
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn size_hint(&self) -> (usize, Option<usize>) {
|
||||
match self {
|
||||
Self::Osu(o) => o.size_hint(),
|
||||
Self::Taiko(t) => t.size_hint(),
|
||||
Self::Catch(f) => f.size_hint(),
|
||||
Self::Mania(m) => m.size_hint(),
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn nth(&mut self, n: usize) -> Option<Self::Item> {
|
||||
match self {
|
||||
Self::Osu(o) => o.nth(n).map(DifficultyAttributes::Osu),
|
||||
Self::Taiko(t) => t.nth(n).map(DifficultyAttributes::Taiko),
|
||||
Self::Catch(c) => c.nth(n).map(DifficultyAttributes::Catch),
|
||||
Self::Mania(m) => m.nth(n).map(DifficultyAttributes::Mania),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Gradually calculate the performance attributes on maps of any mode.
|
||||
///
|
||||
/// After each hit object you can call [`next`](`GradualPerformance::next`)
|
||||
/// and it will return the resulting current [`PerformanceAttributes`].
|
||||
/// To process multiple objects at once, use [`nth`](`GradualPerformance::nth`) 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. [`OsuGradualPerformance`],
|
||||
/// [`TaikoGradualPerformance`], [`CatchGradualPerformance`], or [`ManiaGradualPerformance`].
|
||||
///
|
||||
/// If you only want to calculate difficulty attributes use [`GradualDifficulty`] instead.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```no_run
|
||||
/// use rosu_pp::{Beatmap, GradualPerformance, ScoreState};
|
||||
///
|
||||
/// # /*
|
||||
/// let map: Beatmap = ...
|
||||
/// # */
|
||||
/// # let map = Beatmap::default();
|
||||
///
|
||||
/// let mods = 64; // DT
|
||||
/// let mut gradual_perf = GradualPerformance::new(&map, mods);
|
||||
/// let mut state = ScoreState::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.next(state.clone()).unwrap();
|
||||
/// println!("PP: {}", performance.pp());
|
||||
/// }
|
||||
///
|
||||
/// // 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.next(state.clone()).unwrap();
|
||||
/// println!("PP: {}", performance.pp());
|
||||
///
|
||||
/// // 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;
|
||||
///
|
||||
/// // The `nth` method takes a zero-based value.
|
||||
/// let performance = gradual_perf.nth(state.clone(), 9).unwrap();
|
||||
/// println!("PP: {}", performance.pp());
|
||||
///
|
||||
/// // Now comes another 300. Note that the max combo gets incremented again.
|
||||
/// state.n300 += 1;
|
||||
/// state.max_combo += 1;
|
||||
///
|
||||
/// let performance = gradual_perf.next(state.clone()).unwrap();
|
||||
/// println!("PP: {}", performance.pp());
|
||||
///
|
||||
/// // Skip to the end
|
||||
/// # /*
|
||||
/// state.max_combo = ...
|
||||
/// state.n300 = ...
|
||||
/// ...
|
||||
/// # */
|
||||
/// let final_performance = gradual_perf.last(state.clone()).unwrap();
|
||||
/// println!("PP: {}", performance.pp());
|
||||
///
|
||||
/// // Once the final performance was calculated,
|
||||
/// // attempting to process further objects will return `None`.
|
||||
/// assert!(gradual_perf.next(state).is_none());
|
||||
/// ```
|
||||
#[cfg_attr(docsrs, doc(cfg(feature = "gradual")))]
|
||||
#[derive(Debug)]
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
pub enum GradualPerformance<'map> {
|
||||
/// Gradual osu!standard performance calculator.
|
||||
Osu(OsuGradualPerformance<'map>),
|
||||
/// Gradual osu!taiko performance calculator.
|
||||
Taiko(TaikoGradualPerformance<'map>),
|
||||
/// Gradual osu!catch performance calculator.
|
||||
Catch(CatchGradualPerformance<'map>),
|
||||
/// Gradual osu!mania performance calculator.
|
||||
Mania(ManiaGradualPerformance<'map>),
|
||||
}
|
||||
|
||||
impl<'map> GradualPerformance<'map> {
|
||||
// FIXME: converted catch maps will always count as osu!std since their mode is not modified
|
||||
/// Create a new gradual performance calculator for maps of any mode.
|
||||
#[inline]
|
||||
pub fn new(map: &'map Beatmap, mods: u32) -> Self {
|
||||
match map.mode {
|
||||
GameMode::Osu => Self::Osu(OsuGradualPerformance::new(map, mods)),
|
||||
GameMode::Taiko => Self::Taiko(TaikoGradualPerformance::new(map, mods)),
|
||||
GameMode::Catch => Self::Catch(CatchGradualPerformance::new(map, mods)),
|
||||
GameMode::Mania => Self::Mania(ManiaGradualPerformance::new(map, mods)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Process the next hit object and calculate the
|
||||
/// performance attributes for the resulting score.
|
||||
#[inline]
|
||||
pub fn next(&mut self, state: ScoreState) -> Option<PerformanceAttributes> {
|
||||
self.nth(state, 0)
|
||||
}
|
||||
|
||||
/// Process all remaining hit objects and calculate the final performance attributes.
|
||||
#[inline]
|
||||
pub fn last(&mut self, state: ScoreState) -> Option<PerformanceAttributes> {
|
||||
self.nth(state, usize::MAX)
|
||||
}
|
||||
|
||||
/// Process everything up the the next `n`th hit object and calculate the performance
|
||||
/// attributes for the resulting score state.
|
||||
///
|
||||
/// Note that the count is zero-indexed, so `n=0` will process 1 object, `n=1` will process 2,
|
||||
/// and so on.
|
||||
#[inline]
|
||||
pub fn nth(&mut self, state: ScoreState, n: usize) -> Option<PerformanceAttributes> {
|
||||
match self {
|
||||
Self::Osu(o) => o.nth(state.into(), n).map(PerformanceAttributes::Osu),
|
||||
Self::Taiko(t) => t.nth(state.into(), n).map(PerformanceAttributes::Taiko),
|
||||
Self::Catch(f) => f.nth(state.into(), n).map(PerformanceAttributes::Catch),
|
||||
Self::Mania(m) => m.nth(state.into(), n).map(PerformanceAttributes::Mania),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Gradually calculate the performance attributes on maps of any mode.
|
||||
///
|
||||
/// Check [`GradualPerformance`] for more information. This type does the same
|
||||
/// but takes ownership of [`Beatmap`] to avoid being bound to a lifetime.
|
||||
#[cfg_attr(docsrs, doc(cfg(feature = "gradual")))]
|
||||
#[derive(Debug)]
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
pub enum OwnedGradualPerformance {
|
||||
/// Gradual osu!standard performance calculator.
|
||||
Osu(OsuOwnedGradualPerformance),
|
||||
/// Gradual osu!taiko performance calculator.
|
||||
Taiko(TaikoOwnedGradualPerformance),
|
||||
/// Gradual osu!catch performance calculator.
|
||||
Catch(CatchOwnedGradualPerformance),
|
||||
/// Gradual osu!mania performance calculator.
|
||||
Mania(ManiaOwnedGradualPerformance),
|
||||
}
|
||||
|
||||
impl OwnedGradualPerformance {
|
||||
// FIXME: converted catch maps will always count as osu!std since their mode is not modified
|
||||
/// Create a new gradual performance calculator for maps of any mode.
|
||||
#[inline]
|
||||
pub fn new(map: Beatmap, mods: u32) -> Self {
|
||||
match map.mode {
|
||||
GameMode::Osu => Self::Osu(OsuOwnedGradualPerformance::new(map, mods)),
|
||||
GameMode::Taiko => Self::Taiko(TaikoOwnedGradualPerformance::new(map, mods)),
|
||||
GameMode::Catch => Self::Catch(CatchOwnedGradualPerformance::new(map, mods)),
|
||||
GameMode::Mania => Self::Mania(ManiaOwnedGradualPerformance::new(map, mods)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Process the next hit object and calculate the
|
||||
/// performance attributes for the resulting score.
|
||||
#[inline]
|
||||
pub fn next(&mut self, state: ScoreState) -> Option<PerformanceAttributes> {
|
||||
self.nth(state, 0)
|
||||
}
|
||||
|
||||
/// Process all remaining hit objects and calculate the final performance attributes.
|
||||
#[inline]
|
||||
pub fn last(&mut self, state: ScoreState) -> Option<PerformanceAttributes> {
|
||||
self.nth(state, usize::MAX)
|
||||
}
|
||||
|
||||
/// Process everything up the the next `n`th hit object and calculate the performance
|
||||
/// attributes for the resulting score state.
|
||||
///
|
||||
/// Note that the count is zero-indexed, so `n=0` will process 1 object, `n=1` will process 2,
|
||||
/// and so on.
|
||||
#[inline]
|
||||
pub fn nth(&mut self, state: ScoreState, n: usize) -> Option<PerformanceAttributes> {
|
||||
match self {
|
||||
Self::Osu(o) => o.nth(state.into(), n).map(PerformanceAttributes::Osu),
|
||||
Self::Taiko(t) => t.nth(state.into(), n).map(PerformanceAttributes::Taiko),
|
||||
Self::Catch(f) => f.nth(state.into(), n).map(PerformanceAttributes::Catch),
|
||||
Self::Mania(m) => m.nth(state.into(), n).map(PerformanceAttributes::Mania),
|
||||
}
|
||||
}
|
||||
}
|
||||
+32
-448
@@ -1,459 +1,43 @@
|
||||
#![cfg_attr(docsrs, feature(doc_cfg))]
|
||||
#![warn(
|
||||
clippy::all,
|
||||
clippy::pedantic,
|
||||
clippy::missing_const_for_fn,
|
||||
nonstandard_style,
|
||||
rust_2018_idioms,
|
||||
unused,
|
||||
warnings,
|
||||
missing_debug_implementations,
|
||||
missing_docs,
|
||||
rustdoc::broken_intra_doc_links
|
||||
)]
|
||||
#![deny(rustdoc::broken_intra_doc_links)]
|
||||
#![warn(clippy::missing_const_for_fn, clippy::pedantic)]
|
||||
#![allow(
|
||||
clippy::must_use_candidate,
|
||||
clippy::inline_always,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_sign_loss,
|
||||
clippy::cast_possible_wrap,
|
||||
clippy::explicit_iter_loop,
|
||||
clippy::items_after_statements,
|
||||
clippy::missing_errors_doc,
|
||||
clippy::module_name_repetitions
|
||||
clippy::module_name_repetitions,
|
||||
clippy::must_use_candidate,
|
||||
clippy::struct_excessive_bools,
|
||||
clippy::match_same_arms,
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::cast_sign_loss,
|
||||
clippy::explicit_iter_loop,
|
||||
clippy::similar_names,
|
||||
clippy::cast_possible_wrap
|
||||
)]
|
||||
|
||||
//! 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());
|
||||
//! ```
|
||||
//!
|
||||
#![cfg_attr(
|
||||
feature = "gradual",
|
||||
doc = r#"
|
||||
## 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 enable the `gradual` feature and use [`GradualDifficulty`] and [`GradualPerformance`]:
|
||||
|
||||
```no_run
|
||||
use rosu_pp::{
|
||||
Beatmap, BeatmapExt, GradualDifficulty, GradualPerformance, 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 values,
|
||||
// use `GradualDifficulty`.
|
||||
let gradual_difficulty = GradualDifficulty::new(&map, mods);
|
||||
|
||||
// Since `GradualDifficulty` 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 = GradualPerformance::new(&map, 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.next(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 `GradualPerformance` one could have
|
||||
// used `TaikoGradualPerformance`.
|
||||
let mut gradual_performance = match gradual_performance {
|
||||
GradualPerformance::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 (`nth` takes a zero-based value).
|
||||
let curr_performance = match gradual_performance.nth(state, 9) {
|
||||
Some(perf) => perf,
|
||||
None => panic!("the last `next` 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) |
|
||||
//! | `gradual` | Enable gradual difficulty and performance calculation |
|
||||
//!
|
||||
|
||||
/// Everything about osu!catch.
|
||||
pub mod catch;
|
||||
|
||||
/// Everything about osu!mania.
|
||||
pub mod mania;
|
||||
|
||||
/// Everything about osu!standard.
|
||||
pub mod osu;
|
||||
|
||||
/// Everything about osu!taiko.
|
||||
pub mod taiko;
|
||||
|
||||
/// Beatmap parsing
|
||||
pub mod parse;
|
||||
|
||||
/// Beatmap and contained types
|
||||
pub mod beatmap;
|
||||
pub use beatmap::{Beatmap, BeatmapExt, GameMode};
|
||||
|
||||
#[cfg(feature = "gradual")]
|
||||
mod gradual;
|
||||
#[cfg(feature = "gradual")]
|
||||
pub use gradual::{
|
||||
GradualDifficulty, GradualPerformance, OwnedGradualDifficulty, OwnedGradualPerformance,
|
||||
#[doc(inline)]
|
||||
pub use self::{
|
||||
any::{Difficulty, ModeDifficulty, Performance},
|
||||
model::beatmap::{Beatmap, Converted},
|
||||
};
|
||||
|
||||
mod pp;
|
||||
pub use pp::{AnyPP, AttributeProvider, HitResultPriority};
|
||||
/// Types for calculations of any mode.
|
||||
pub mod any;
|
||||
|
||||
mod stars;
|
||||
pub use stars::AnyStars;
|
||||
/// Types for osu!standard calculations.
|
||||
pub mod osu;
|
||||
|
||||
mod score_state;
|
||||
pub use score_state::*;
|
||||
/// Types for osu!taiko calculations.
|
||||
pub mod taiko;
|
||||
|
||||
/// Types for osu!catch calculations.
|
||||
pub mod catch;
|
||||
|
||||
/// Types for osu!mania calculations.
|
||||
pub mod mania;
|
||||
|
||||
/// Types used in and around this crate.
|
||||
pub mod model;
|
||||
|
||||
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};
|
||||
|
||||
/// 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 const 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 const 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 const 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,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a builder for performance calculation.
|
||||
#[inline]
|
||||
pub fn pp(self) -> AnyPP<'static> {
|
||||
AnyPP::from(self)
|
||||
}
|
||||
}
|
||||
|
||||
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 const 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 const 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.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
/// The maximum combo of the map.
|
||||
pub const 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");
|
||||
// TODO: fix very low custom clock rates
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
use crate::mania::performance::ManiaPerformance;
|
||||
|
||||
/// The result of a difficulty calculation on an osu!mania map.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct ManiaDifficultyAttributes {
|
||||
/// The final star rating.
|
||||
pub stars: f64,
|
||||
/// The perceived hit window for an n300 inclusive of rate-adjusting mods (DT/HT/etc).
|
||||
pub hit_window: f64,
|
||||
/// The amount of hitobjects in the map.
|
||||
pub n_objects: u32,
|
||||
/// The maximum achievable combo.
|
||||
pub max_combo: u32,
|
||||
/// Whether the [`Beatmap`] was a convert i.e. an osu!standard map.
|
||||
///
|
||||
/// [`Beatmap`]: crate::model::beatmap::Beatmap
|
||||
pub is_convert: bool,
|
||||
}
|
||||
|
||||
impl ManiaDifficultyAttributes {
|
||||
/// Return the maximum combo.
|
||||
pub const fn max_combo(&self) -> u32 {
|
||||
self.max_combo
|
||||
}
|
||||
|
||||
/// Return the amount of hitobjects.
|
||||
pub const fn n_objects(&self) -> u32 {
|
||||
self.n_objects
|
||||
}
|
||||
|
||||
/// Whether the [`Beatmap`] was a convert i.e. an osu!standard map.
|
||||
///
|
||||
/// [`Beatmap`]: crate::model::beatmap::Beatmap
|
||||
pub const fn is_convert(&self) -> bool {
|
||||
self.is_convert
|
||||
}
|
||||
|
||||
/// Returns a builder for performance calculation.
|
||||
pub fn pp<'a>(self) -> ManiaPerformance<'a> {
|
||||
self.into()
|
||||
}
|
||||
}
|
||||
|
||||
/// The result of a performance calculation on an osu!mania map.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct ManiaPerformanceAttributes {
|
||||
/// The difficulty attributes that were used for the performance calculation.
|
||||
pub difficulty: ManiaDifficultyAttributes,
|
||||
/// The final performance points.
|
||||
pub pp: f64,
|
||||
/// The difficulty portion of the final pp.
|
||||
pub pp_difficulty: f64,
|
||||
}
|
||||
|
||||
impl ManiaPerformanceAttributes {
|
||||
/// Return the star value.
|
||||
pub const fn stars(&self) -> f64 {
|
||||
self.difficulty.stars
|
||||
}
|
||||
|
||||
/// Return the performance point value.
|
||||
pub const fn pp(&self) -> f64 {
|
||||
self.pp
|
||||
}
|
||||
|
||||
/// Return the maximum combo of the map.
|
||||
pub const fn max_combo(&self) -> u32 {
|
||||
self.difficulty.max_combo
|
||||
}
|
||||
|
||||
/// Return the amount of hitobjects.
|
||||
pub const fn n_objects(&self) -> u32 {
|
||||
self.difficulty.n_objects
|
||||
}
|
||||
|
||||
/// Whether the [`Beatmap`] was a convert i.e. an osu!standard map.
|
||||
///
|
||||
/// [`Beatmap`]: crate::model::beatmap::Beatmap
|
||||
pub const fn is_convert(&self) -> bool {
|
||||
self.difficulty.is_convert
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
use std::borrow::Cow;
|
||||
|
||||
use rosu_map::{
|
||||
section::{
|
||||
general::GameMode,
|
||||
hit_objects::{BorrowedCurve, CurveBuffers},
|
||||
},
|
||||
util::Pos,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
model::{
|
||||
beatmap::{Beatmap, Converted},
|
||||
hit_object::{HitObjectKind, HoldNote, Spinner},
|
||||
mode::ConvertStatus,
|
||||
},
|
||||
util::{
|
||||
float_ext::FloatExt, legacy_sort::legacy_sort, limited_queue::LimitedQueue, random::Random,
|
||||
},
|
||||
};
|
||||
|
||||
use self::{
|
||||
pattern::Pattern,
|
||||
pattern_generator::{
|
||||
distance_object::DistanceObjectPatternGenerator,
|
||||
end_time_object::EndTimeObjectPatternGenerator, hit_object::HitObjectPatternGenerator,
|
||||
},
|
||||
pattern_type::PatternType,
|
||||
};
|
||||
|
||||
use super::Mania;
|
||||
|
||||
mod pattern;
|
||||
mod pattern_generator;
|
||||
mod pattern_type;
|
||||
|
||||
/// A [`Beatmap`] for [`Mania`] calculations.
|
||||
pub type ManiaBeatmap<'a> = Converted<'a, Mania>;
|
||||
|
||||
const MAX_NOTES_FOR_DENSITY: usize = 7;
|
||||
|
||||
pub fn try_convert(map: &mut Cow<'_, Beatmap>) -> ConvertStatus {
|
||||
match map.mode {
|
||||
GameMode::Osu => {
|
||||
convert(map.to_mut());
|
||||
|
||||
ConvertStatus::Done
|
||||
}
|
||||
GameMode::Mania => ConvertStatus::Noop,
|
||||
GameMode::Taiko | GameMode::Catch => ConvertStatus::Incompatible,
|
||||
}
|
||||
}
|
||||
|
||||
fn convert(map: &mut Beatmap) {
|
||||
let seed = (map.hp + map.cs).round_even() as i32 * 20
|
||||
+ (map.od * 41.2) as i32
|
||||
+ map.ar.round_even() as i32;
|
||||
|
||||
let mut random = Random::new(seed);
|
||||
|
||||
map.cs = target_columns(map);
|
||||
|
||||
let mut prev_note_times = LimitedQueue::<f64, MAX_NOTES_FOR_DENSITY>::new();
|
||||
let mut density = f64::from(i32::MAX);
|
||||
|
||||
let mut compute_density = |new_note_time: f64, d: &mut f64| {
|
||||
prev_note_times.push(new_note_time);
|
||||
|
||||
if let ([first, ..], [.., last]) | ([], [first, .., last]) | ([first, .., last], []) =
|
||||
prev_note_times.as_slices()
|
||||
{
|
||||
*d = (last - first) / prev_note_times.len() as f64;
|
||||
}
|
||||
};
|
||||
|
||||
let total_columns = map.cs as i32;
|
||||
let mut last_values = PrevValues::default();
|
||||
let mut curve_bufs = CurveBuffers::default();
|
||||
|
||||
// TODO: find proper default
|
||||
let capacity = map.hit_objects.len() * 2;
|
||||
let mut new_hit_objects = Vec::with_capacity(capacity);
|
||||
|
||||
for (obj, sound) in map.hit_objects.iter().zip(map.hit_sounds.iter().copied()) {
|
||||
match obj.kind {
|
||||
HitObjectKind::Circle => {
|
||||
compute_density(obj.start_time, &mut density);
|
||||
|
||||
let mut gen = HitObjectPatternGenerator::new(
|
||||
&mut random,
|
||||
obj,
|
||||
sound,
|
||||
total_columns,
|
||||
&last_values,
|
||||
density,
|
||||
map,
|
||||
);
|
||||
|
||||
let new_pattern = gen.generate();
|
||||
|
||||
last_values.stair = gen.stair_type;
|
||||
last_values.time = obj.start_time;
|
||||
last_values.pos = obj.pos;
|
||||
|
||||
let new_hit_objects_iter = new_pattern.hit_objects.iter().cloned();
|
||||
new_hit_objects.extend(new_hit_objects_iter);
|
||||
|
||||
last_values.pattern = new_pattern;
|
||||
}
|
||||
HitObjectKind::Slider(ref slider) => {
|
||||
let curve = BorrowedCurve::new(
|
||||
&slider.control_points,
|
||||
slider.expected_dist,
|
||||
&mut curve_bufs,
|
||||
);
|
||||
|
||||
let mut gen = DistanceObjectPatternGenerator::new(
|
||||
&mut random,
|
||||
obj,
|
||||
sound,
|
||||
total_columns,
|
||||
&last_values.pattern,
|
||||
map,
|
||||
slider.repeats,
|
||||
&curve,
|
||||
&slider.node_sounds,
|
||||
);
|
||||
|
||||
let segment_duration = f64::from(gen.segment_duration);
|
||||
|
||||
for i in 0..=slider.repeats as i32 + 1 {
|
||||
let time = obj.start_time + segment_duration * f64::from(i);
|
||||
|
||||
last_values.time = time;
|
||||
last_values.pos = obj.pos;
|
||||
|
||||
compute_density(time, &mut density);
|
||||
}
|
||||
|
||||
for new_pattern in gen.generate() {
|
||||
new_hit_objects.extend_from_slice(&new_pattern.hit_objects);
|
||||
last_values.pattern = new_pattern;
|
||||
}
|
||||
}
|
||||
HitObjectKind::Spinner(Spinner { end_time })
|
||||
| HitObjectKind::Hold(HoldNote { end_time }) => {
|
||||
let mut gen = EndTimeObjectPatternGenerator::new(
|
||||
&mut random,
|
||||
obj,
|
||||
end_time,
|
||||
sound,
|
||||
total_columns,
|
||||
&last_values.pattern,
|
||||
map,
|
||||
);
|
||||
|
||||
last_values.time = end_time;
|
||||
last_values.pos = Pos::new(256.0, 192.0);
|
||||
|
||||
compute_density(end_time, &mut density);
|
||||
|
||||
let new_pattern = gen.generate();
|
||||
new_hit_objects.extend(new_pattern.hit_objects);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
map.hit_sounds.clear();
|
||||
map.hit_objects = new_hit_objects;
|
||||
map.hit_objects
|
||||
.sort_by(|a, b| a.start_time.total_cmp(&b.start_time));
|
||||
legacy_sort(&mut map.hit_objects);
|
||||
|
||||
map.mode = GameMode::Mania;
|
||||
}
|
||||
|
||||
pub struct PrevValues {
|
||||
time: f64,
|
||||
pos: Pos,
|
||||
pattern: Pattern,
|
||||
stair: PatternType,
|
||||
}
|
||||
|
||||
impl Default for PrevValues {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
time: 0.0,
|
||||
pos: Pos::default(),
|
||||
pattern: Pattern::default(),
|
||||
stair: PatternType::STAIR,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn target_columns(map: &Beatmap) -> f32 {
|
||||
let rounded_cs = map.cs.round_even();
|
||||
let rounded_od = map.od.round_even();
|
||||
|
||||
let slider_or_spinner_count = map
|
||||
.hit_objects
|
||||
.iter()
|
||||
.filter(|h| matches!(h.kind, HitObjectKind::Slider(_) | HitObjectKind::Spinner(_)))
|
||||
.count();
|
||||
|
||||
let len = map.hit_objects.len();
|
||||
let percent_slider_or_spinner = f64::from(slider_or_spinner_count as f32 / len as f32);
|
||||
|
||||
if percent_slider_or_spinner < 0.2 {
|
||||
7.0
|
||||
} else if percent_slider_or_spinner < 0.3 || rounded_cs >= 5.0 {
|
||||
f32::from(6 + u8::from(rounded_od > 5.0))
|
||||
} else if percent_slider_or_spinner > 0.6 {
|
||||
f32::from(4 + u8::from(rounded_od > 4.0))
|
||||
} else {
|
||||
(rounded_od + 1.0).clamp(4.0, 7.0)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn convert_mania() {
|
||||
let converted = Beatmap::from_path("./resources/2785319.osu")
|
||||
.unwrap()
|
||||
.unchecked_into_converted::<Mania>();
|
||||
|
||||
assert!(converted.is_convert);
|
||||
|
||||
let map = converted.map;
|
||||
|
||||
assert_eq!(map.mode, GameMode::Mania);
|
||||
assert_eq!(map.version, 14);
|
||||
assert!(map.ar.eq(9.3), "{} != 9.3", map.ar);
|
||||
assert!(map.od.eq(8.8), "{} != 8.8", map.od);
|
||||
assert!(map.cs.eq(7.0), "{} != 7.0", map.cs);
|
||||
assert!(map.hp.eq(5.0), "{} != 5.0", map.hp);
|
||||
assert!(
|
||||
map.slider_multiplier.eq(1.7),
|
||||
"{} != 1.7",
|
||||
map.slider_multiplier
|
||||
);
|
||||
assert!(
|
||||
map.slider_tick_rate.eq(1.0),
|
||||
"{} != 1.0",
|
||||
map.slider_tick_rate
|
||||
);
|
||||
assert_eq!(map.hit_objects.len(), 1046);
|
||||
assert_eq!(map.hit_sounds.len(), 0);
|
||||
assert_eq!(map.timing_points.len(), 1);
|
||||
assert_eq!(map.difficulty_points.len(), 50);
|
||||
assert_eq!(map.effect_points.len(), 0);
|
||||
assert!(map.stack_leniency.eq(0.5), "{} != 0.5", map.stack_leniency);
|
||||
assert_eq!(map.breaks.len(), 1);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,6 @@
|
||||
use std::collections::HashSet;
|
||||
use rosu_map::util::Pos;
|
||||
|
||||
use crate::{
|
||||
parse::{HitObject, HitObjectKind, Pos2},
|
||||
util::ByteHasher,
|
||||
};
|
||||
use crate::model::hit_object::{HitObject, HitObjectKind, HoldNote};
|
||||
|
||||
use super::pattern_generator::{
|
||||
distance_object::DistanceObjectPatternGenerator,
|
||||
@@ -11,21 +8,21 @@ use super::pattern_generator::{
|
||||
};
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Pattern {
|
||||
pub(crate) hit_objects: Vec<HitObject>,
|
||||
contained_columns: HashSet<u8, ByteHasher>,
|
||||
pub struct Pattern {
|
||||
pub hit_objects: Vec<HitObject>,
|
||||
contained_columns: ContainedColumns,
|
||||
}
|
||||
|
||||
impl Pattern {
|
||||
pub(crate) fn with_capacity(capacity: usize) -> Self {
|
||||
pub fn with_capacity(capacity: usize) -> Self {
|
||||
Self {
|
||||
hit_objects: Vec::with_capacity(capacity),
|
||||
contained_columns: HashSet::with_hasher(ByteHasher),
|
||||
contained_columns: ContainedColumns::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn new_single(hit_object: HitObject, column: u8) -> Self {
|
||||
let mut contained_columns = HashSet::with_capacity_and_hasher(1, ByteHasher);
|
||||
let mut contained_columns = ContainedColumns::default();
|
||||
contained_columns.insert(column);
|
||||
let hit_objects = vec![hit_object];
|
||||
|
||||
@@ -35,20 +32,24 @@ impl Pattern {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn new_note(generator: &HitObjectPatternGenerator<'_>, column: u8) -> Self {
|
||||
pub fn new_note(generator: &HitObjectPatternGenerator<'_>, column: u8) -> Self {
|
||||
let pos = column_to_pos(column, generator.inner.total_columns);
|
||||
|
||||
let hit_object = HitObject {
|
||||
pos: Pos2::new(column_to_pos(column, generator.total_columns)),
|
||||
start_time: generator.hit_object.start_time,
|
||||
pos: Pos::new(pos, pos),
|
||||
start_time: generator.inner.hit_object.start_time,
|
||||
kind: HitObjectKind::Circle,
|
||||
};
|
||||
|
||||
Self::new_single(hit_object, column)
|
||||
}
|
||||
|
||||
pub(crate) fn add_note(&mut self, generator: &HitObjectPatternGenerator<'_>, column: u8) {
|
||||
pub fn add_note(&mut self, generator: &HitObjectPatternGenerator<'_>, column: u8) {
|
||||
let pos = column_to_pos(column, generator.inner.total_columns);
|
||||
|
||||
let hit_object = HitObject {
|
||||
pos: Pos2::new(column_to_pos(column, generator.total_columns)),
|
||||
start_time: generator.hit_object.start_time,
|
||||
pos: Pos::new(pos, pos),
|
||||
start_time: generator.inner.hit_object.start_time,
|
||||
kind: HitObjectKind::Circle,
|
||||
};
|
||||
|
||||
@@ -56,25 +57,26 @@ impl Pattern {
|
||||
self.hit_objects.push(hit_object);
|
||||
}
|
||||
|
||||
pub(crate) fn new_end_time_note(
|
||||
pub 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 pos = column_to_pos(column, generator.inner.total_columns);
|
||||
let pos = Pos::new(pos, pos);
|
||||
|
||||
let hit_object = if hold_note {
|
||||
HitObject {
|
||||
pos,
|
||||
start_time: generator.hit_object.start_time,
|
||||
kind: HitObjectKind::Hold {
|
||||
start_time: generator.inner.hit_object.start_time,
|
||||
kind: HitObjectKind::Hold(HoldNote {
|
||||
end_time: generator.end_time,
|
||||
},
|
||||
}),
|
||||
}
|
||||
} else {
|
||||
HitObject {
|
||||
pos,
|
||||
start_time: generator.hit_object.start_time,
|
||||
start_time: generator.inner.hit_object.start_time,
|
||||
kind: HitObjectKind::Circle,
|
||||
}
|
||||
};
|
||||
@@ -82,13 +84,14 @@ impl Pattern {
|
||||
Self::new_single(hit_object, column)
|
||||
}
|
||||
|
||||
pub(crate) fn new_slider_note(
|
||||
pub 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 pos = column_to_pos(column, generator.inner.total_columns);
|
||||
let pos = Pos::new(pos, pos);
|
||||
|
||||
let hit_object = if start_time == end_time {
|
||||
HitObject {
|
||||
@@ -100,23 +103,24 @@ impl Pattern {
|
||||
HitObject {
|
||||
pos,
|
||||
start_time: f64::from(start_time),
|
||||
kind: HitObjectKind::Hold {
|
||||
kind: HitObjectKind::Hold(HoldNote {
|
||||
end_time: f64::from(end_time),
|
||||
},
|
||||
}),
|
||||
}
|
||||
};
|
||||
|
||||
Self::new_single(hit_object, column)
|
||||
}
|
||||
|
||||
pub(crate) fn add_slider_note(
|
||||
pub 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 pos = column_to_pos(column, generator.inner.total_columns);
|
||||
let pos = Pos::new(pos, pos);
|
||||
|
||||
let hit_object = if start_time == end_time {
|
||||
HitObject {
|
||||
@@ -128,9 +132,9 @@ impl Pattern {
|
||||
HitObject {
|
||||
pos,
|
||||
start_time: f64::from(start_time),
|
||||
kind: HitObjectKind::Hold {
|
||||
kind: HitObjectKind::Hold(HoldNote {
|
||||
end_time: f64::from(end_time),
|
||||
},
|
||||
}),
|
||||
}
|
||||
};
|
||||
|
||||
@@ -138,25 +142,24 @@ impl Pattern {
|
||||
self.hit_objects.push(hit_object);
|
||||
}
|
||||
|
||||
pub(crate) fn add_object(&mut self, obj: HitObject, column: u8) {
|
||||
pub 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 const fn column_has_obj(&self, column: u8) -> bool {
|
||||
self.contained_columns.contains(column)
|
||||
}
|
||||
|
||||
pub(crate) fn column_with_objs(&self) -> i32 {
|
||||
pub const fn column_with_objs(&self) -> i32 {
|
||||
self.contained_columns.len() as i32
|
||||
}
|
||||
|
||||
/// Moves all values of `other` into `self`,
|
||||
/// leaving `other` empty but keeps the capacities.
|
||||
pub(crate) fn append(&mut self, other: &mut Self) {
|
||||
pub fn append(&mut self, other: &mut Self) {
|
||||
self.hit_objects.append(&mut other.hit_objects);
|
||||
self.contained_columns
|
||||
.extend(other.contained_columns.drain());
|
||||
self.contained_columns.extend(other.contained_columns);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,3 +168,24 @@ fn column_to_pos(column: u8, total_columns: i32) -> f32 {
|
||||
|
||||
(f32::from(column) * divisor).ceil()
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Default)]
|
||||
pub struct ContainedColumns(u16);
|
||||
|
||||
impl ContainedColumns {
|
||||
pub fn insert(&mut self, column: u8) {
|
||||
self.0 |= 1 << column;
|
||||
}
|
||||
|
||||
pub fn extend(&mut self, other: Self) {
|
||||
self.0 |= other.0;
|
||||
}
|
||||
|
||||
pub const fn len(self) -> u32 {
|
||||
self.0.count_ones()
|
||||
}
|
||||
|
||||
pub const fn contains(self, column: u8) -> bool {
|
||||
self.0 & (1 << column) != 0
|
||||
}
|
||||
}
|
||||
+136
-138
@@ -1,50 +1,48 @@
|
||||
use rosu_map::section::hit_objects::{hit_samples::HitSoundType, BorrowedCurve};
|
||||
|
||||
use crate::{
|
||||
beatmap::{
|
||||
converts::mania::{legacy_random::Random, pattern::Pattern, pattern_type::PatternType},
|
||||
EffectPoint,
|
||||
mania::{
|
||||
convert::{pattern::Pattern, pattern_type::PatternType},
|
||||
object::ManiaObject,
|
||||
},
|
||||
curve::Curve,
|
||||
mania::ManiaObject,
|
||||
parse::{HitObject, HitSound},
|
||||
util::FloatExt,
|
||||
Beatmap,
|
||||
model::{
|
||||
beatmap::{get_precision_adjusted_beat_len_taiko_mania, Beatmap},
|
||||
control_point::{EffectPoint, TimingPoint},
|
||||
hit_object::HitObject,
|
||||
},
|
||||
util::{float_ext::FloatExt, random::Random},
|
||||
};
|
||||
|
||||
use super::PatternGenerator;
|
||||
|
||||
pub(crate) struct DistanceObjectPatternGenerator<'h> {
|
||||
pub(crate) hit_object: &'h HitObject,
|
||||
pub(crate) segment_duration: i32,
|
||||
pub(crate) total_columns: i32,
|
||||
pub(crate) sample: u8,
|
||||
pub struct DistanceObjectPatternGenerator<'h> {
|
||||
pub segment_duration: i32,
|
||||
pub sample: HitSoundType,
|
||||
pub inner: PatternGenerator<'h>,
|
||||
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],
|
||||
node_sounds: &'h [HitSoundType],
|
||||
}
|
||||
|
||||
impl<'h> DistanceObjectPatternGenerator<'h> {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn new(
|
||||
pub fn new(
|
||||
random: &'h mut Random,
|
||||
hit_object: &'h HitObject,
|
||||
sample: u8,
|
||||
sample: HitSoundType,
|
||||
total_columns: i32,
|
||||
prev_pattern: &'h Pattern,
|
||||
orig: &'h Beatmap,
|
||||
repeats: usize,
|
||||
curve: &Curve<'_>,
|
||||
edge_sounds: &'h [u8],
|
||||
curve: &BorrowedCurve<'_>,
|
||||
node_sounds: &'h [HitSoundType],
|
||||
) -> 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 beat_len = orig
|
||||
.timing_point_at(hit_object.start_time)
|
||||
.map_or(TimingPoint::DEFAULT_BEAT_LEN, |point| point.beat_len);
|
||||
|
||||
let kiai = orig
|
||||
.effect_point_at(hit_object.start_time)
|
||||
@@ -56,35 +54,35 @@ impl<'h> DistanceObjectPatternGenerator<'h> {
|
||||
PatternType::LOW_PROBABILITY
|
||||
};
|
||||
|
||||
let beat_len = timing_point.beat_len * difficulty_point.bpm_mult;
|
||||
let beat_len =
|
||||
get_precision_adjusted_beat_len_taiko_mania(orig, beat_len, hit_object.start_time);
|
||||
|
||||
let span_count = (repeats + 1) as i32;
|
||||
let start_time = hit_object.start_time.round_even() as i32;
|
||||
|
||||
// * This matches stable's calculation.
|
||||
let end_time = (f64::from(start_time)
|
||||
+ curve.dist() * beat_len * f64::from(span_count) * 0.01 / orig.slider_mult)
|
||||
+ curve.dist() * beat_len * f64::from(span_count) * 0.01 / orig.slider_multiplier)
|
||||
.floor() as i32;
|
||||
|
||||
let segment_duration = (end_time - start_time) / span_count;
|
||||
|
||||
let inner = PatternGenerator::new(hit_object, total_columns, random, orig);
|
||||
|
||||
Self {
|
||||
hit_object,
|
||||
segment_duration,
|
||||
total_columns,
|
||||
sample,
|
||||
inner,
|
||||
start_time,
|
||||
end_time,
|
||||
span_count,
|
||||
orig,
|
||||
prev_pattern,
|
||||
convert_type,
|
||||
random,
|
||||
edge_sounds,
|
||||
node_sounds,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn generate(&mut self) -> Vec<Pattern> {
|
||||
pub fn generate(&mut self) -> Vec<Pattern> {
|
||||
let orig_pattern = self.generate_();
|
||||
|
||||
if orig_pattern.hit_objects.len() == 1 {
|
||||
@@ -98,7 +96,7 @@ impl<'h> DistanceObjectPatternGenerator<'h> {
|
||||
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;
|
||||
let col = ManiaObject::column(obj.pos.x, self.inner.total_columns as f32) as u8;
|
||||
|
||||
// Keeping it in-sync with lazer
|
||||
#[allow(clippy::if_not_else)]
|
||||
@@ -113,9 +111,9 @@ impl<'h> DistanceObjectPatternGenerator<'h> {
|
||||
}
|
||||
|
||||
fn generate_(&mut self) -> Pattern {
|
||||
let conversion_diff = self.conversion_difficulty();
|
||||
let conversion_diff = self.inner.conversion_difficulty();
|
||||
|
||||
if self.total_columns == 1 {
|
||||
if self.inner.total_columns == 1 {
|
||||
Pattern::new_slider_note(self, 0, self.start_time, self.end_time)
|
||||
} else if self.span_count > 1 {
|
||||
if self.segment_duration <= 90 {
|
||||
@@ -131,14 +129,14 @@ impl<'h> DistanceObjectPatternGenerator<'h> {
|
||||
} 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.span_count < self.inner.total_columns - 1 - self.inner.random_start()
|
||||
{
|
||||
self.generate_tiled_hold_notes(self.start_time)
|
||||
} else {
|
||||
self.generate_hold_and_normal_notes(self.start_time, conversion_diff)
|
||||
}
|
||||
} else if self.segment_duration <= 110 {
|
||||
if self.prev_pattern.column_with_objs() < self.total_columns {
|
||||
if self.prev_pattern.column_with_objs() < self.inner.total_columns {
|
||||
self.convert_type |= PatternType::FORCE_NOT_STACK;
|
||||
} else {
|
||||
self.convert_type &= !PatternType::FORCE_NOT_STACK;
|
||||
@@ -180,29 +178,22 @@ impl<'h> DistanceObjectPatternGenerator<'h> {
|
||||
|
||||
let mut pattern = Pattern::default();
|
||||
|
||||
let random_start = self.random_start();
|
||||
let random_start = self.inner.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);
|
||||
self.inner.total_columns - random_start - self.prev_pattern.column_with_objs();
|
||||
let mut next_column = self.inner.get_random_column(None, None);
|
||||
|
||||
for _ in 0..usable_columns.min(note_count) {
|
||||
// * Find available column
|
||||
next_column = self.find_available_column(
|
||||
next_column,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
&[&pattern, self.prev_pattern],
|
||||
);
|
||||
next_column =
|
||||
self.find_available_column(next_column, None, &[&pattern, self.prev_pattern]);
|
||||
|
||||
pattern.add_slider_note(self, next_column, start_time, self.end_time);
|
||||
}
|
||||
|
||||
// * This is can't be combined with the above loop due to RNG
|
||||
for _ in 0..note_count.saturating_sub(usable_columns) {
|
||||
next_column =
|
||||
self.find_available_column(next_column, None, None, None, None, &[&pattern]);
|
||||
next_column = self.find_available_column(next_column, None, &[&pattern]);
|
||||
|
||||
pattern.add_slider_note(self, next_column, start_time, self.end_time);
|
||||
}
|
||||
@@ -217,19 +208,12 @@ impl<'h> DistanceObjectPatternGenerator<'h> {
|
||||
// * - - - x
|
||||
// * x - - -
|
||||
|
||||
let mut next_column = self.get_column(Some(true));
|
||||
let mut next_column = self.inner.get_column(Some(true));
|
||||
|
||||
if self.convert_type.contains(PatternType::FORCE_NOT_STACK)
|
||||
&& self.prev_pattern.column_with_objs() < self.total_columns
|
||||
&& self.prev_pattern.column_with_objs() < self.inner.total_columns
|
||||
{
|
||||
next_column = self.find_available_column(
|
||||
next_column,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
&[self.prev_pattern],
|
||||
);
|
||||
next_column = self.find_available_column(next_column, None, &[self.prev_pattern]);
|
||||
}
|
||||
|
||||
let mut last_column = next_column;
|
||||
@@ -240,9 +224,6 @@ impl<'h> DistanceObjectPatternGenerator<'h> {
|
||||
|
||||
next_column = self.find_available_column(
|
||||
next_column,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some(&|c| c != i32::from(last_column)),
|
||||
&[],
|
||||
);
|
||||
@@ -264,8 +245,8 @@ impl<'h> DistanceObjectPatternGenerator<'h> {
|
||||
// * - x - -
|
||||
// * x - - -
|
||||
|
||||
let mut column = i32::from(self.get_column(Some(true)));
|
||||
let mut increasing = self.random.gen_double() > 0.5;
|
||||
let mut column = i32::from(self.inner.get_column(Some(true)));
|
||||
let mut increasing = self.inner.random.next_double() > 0.5;
|
||||
let mut pattern = Pattern::with_capacity(self.span_count as usize + 1);
|
||||
|
||||
for _ in 0..=self.span_count as usize {
|
||||
@@ -274,13 +255,13 @@ impl<'h> DistanceObjectPatternGenerator<'h> {
|
||||
|
||||
// * Check if we're at the borders of the stage, and invert the pattern if so
|
||||
if increasing {
|
||||
if column >= self.total_columns - 1 {
|
||||
if column >= self.inner.total_columns - 1 {
|
||||
increasing = false;
|
||||
column -= 1;
|
||||
} else {
|
||||
column += 1;
|
||||
}
|
||||
} else if column <= self.random_start() {
|
||||
} else if column <= self.inner.random_start() {
|
||||
increasing = true;
|
||||
column += 1;
|
||||
} else {
|
||||
@@ -298,14 +279,15 @@ impl<'h> DistanceObjectPatternGenerator<'h> {
|
||||
// * - - - x
|
||||
// * x - x -
|
||||
|
||||
let legacy = (4..=8).contains(&self.total_columns);
|
||||
let legacy = (4..=8).contains(&self.inner.total_columns);
|
||||
let interval = self
|
||||
.inner
|
||||
.random
|
||||
.gen_int_range(1, self.total_columns - i32::from(legacy));
|
||||
.next_int_range(1, self.inner.total_columns - i32::from(legacy));
|
||||
|
||||
let mut next_column = i32::from(self.get_column(Some(true)));
|
||||
let random_start = self.random_start();
|
||||
let not_2k = self.total_columns > 2;
|
||||
let mut next_column = i32::from(self.inner.get_column(Some(true)));
|
||||
let random_start = self.inner.random_start();
|
||||
let not_2k = self.inner.total_columns > 2;
|
||||
let mut pattern =
|
||||
Pattern::with_capacity((self.span_count as usize + 1) * (1 + usize::from(not_2k)));
|
||||
|
||||
@@ -314,8 +296,9 @@ impl<'h> DistanceObjectPatternGenerator<'h> {
|
||||
|
||||
next_column += interval;
|
||||
|
||||
if next_column >= self.total_columns - random_start {
|
||||
next_column = next_column - self.total_columns - random_start + i32::from(legacy);
|
||||
if next_column >= self.inner.total_columns - random_start {
|
||||
next_column =
|
||||
next_column - self.inner.total_columns - random_start + i32::from(legacy);
|
||||
}
|
||||
|
||||
next_column += random_start;
|
||||
@@ -325,7 +308,7 @@ impl<'h> DistanceObjectPatternGenerator<'h> {
|
||||
pattern.add_slider_note(self, next_column as u8, start_time, start_time);
|
||||
}
|
||||
|
||||
next_column = i32::from(PatternGenerator::get_random_column(self, None, None));
|
||||
next_column = i32::from(self.inner.get_random_column(None, None));
|
||||
start_time += self.segment_duration;
|
||||
}
|
||||
|
||||
@@ -344,7 +327,7 @@ impl<'h> DistanceObjectPatternGenerator<'h> {
|
||||
// * □ - □ □
|
||||
// * ■ - ■ ■
|
||||
|
||||
match self.total_columns {
|
||||
match self.inner.total_columns {
|
||||
2 => {
|
||||
p2 = 0.0;
|
||||
p3 = 0.0;
|
||||
@@ -368,7 +351,8 @@ impl<'h> DistanceObjectPatternGenerator<'h> {
|
||||
_ => {}
|
||||
}
|
||||
|
||||
let is_double_sample = |sample: u8| sample.clap() || sample.finish();
|
||||
let is_double_sample =
|
||||
|sample: HitSoundType| sample.has_flag(HitSoundType::CLAP | HitSoundType::FINISH);
|
||||
|
||||
let can_generate_two_notes = !self.convert_type.contains(PatternType::LOW_PROBABILITY)
|
||||
&& (is_double_sample(self.sample)
|
||||
@@ -378,7 +362,9 @@ impl<'h> DistanceObjectPatternGenerator<'h> {
|
||||
p2 = 1.0;
|
||||
}
|
||||
|
||||
let note_count = self.get_random_note_count(p2, p3, Some(p4), None, None);
|
||||
let note_count = self
|
||||
.inner
|
||||
.get_random_note_count(p2, p3, Some(p4), None, None);
|
||||
|
||||
self.generate_random_hold_notes(start_time, note_count)
|
||||
}
|
||||
@@ -393,31 +379,23 @@ impl<'h> DistanceObjectPatternGenerator<'h> {
|
||||
// * □ ■ - -
|
||||
// * ■ - - -
|
||||
|
||||
let column_repeat = self.span_count.min(self.total_columns) as usize;
|
||||
let column_repeat = self.span_count.min(self.inner.total_columns) as usize;
|
||||
|
||||
// * Due to integer rounding, this is not guaranteed to be the same as EndTime (the class-level variable).
|
||||
let end_time = start_time + self.segment_duration * self.span_count;
|
||||
|
||||
let mut next_column = self.get_column(Some(true));
|
||||
let mut next_column = self.inner.get_column(Some(true));
|
||||
|
||||
if self.convert_type.contains(PatternType::FORCE_NOT_STACK)
|
||||
&& self.prev_pattern.column_with_objs() < self.total_columns
|
||||
&& self.prev_pattern.column_with_objs() < self.inner.total_columns
|
||||
{
|
||||
next_column = self.find_available_column(
|
||||
next_column,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
&[self.prev_pattern],
|
||||
);
|
||||
next_column = self.find_available_column(next_column, None, &[self.prev_pattern]);
|
||||
}
|
||||
|
||||
let mut pattern = Pattern::with_capacity(column_repeat);
|
||||
|
||||
for _ in 0..column_repeat {
|
||||
next_column =
|
||||
self.find_available_column(next_column, None, None, None, None, &[&pattern]);
|
||||
next_column = self.find_available_column(next_column, None, &[&pattern]);
|
||||
pattern.add_slider_note(self, next_column, start_time, end_time);
|
||||
start_time += self.segment_duration;
|
||||
}
|
||||
@@ -438,44 +416,47 @@ impl<'h> DistanceObjectPatternGenerator<'h> {
|
||||
|
||||
let mut pattern = Pattern::default();
|
||||
|
||||
let mut hold_column = self.get_column(Some(true));
|
||||
let mut hold_column = self.inner.get_column(Some(true));
|
||||
|
||||
if self.convert_type.contains(PatternType::FORCE_NOT_STACK)
|
||||
&& self.prev_pattern.column_with_objs() < self.total_columns
|
||||
&& self.prev_pattern.column_with_objs() < self.inner.total_columns
|
||||
{
|
||||
hold_column = self.find_available_column(
|
||||
hold_column,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
&[self.prev_pattern],
|
||||
);
|
||||
hold_column = self.find_available_column(hold_column, None, &[self.prev_pattern]);
|
||||
}
|
||||
|
||||
// * Create the hold note
|
||||
pattern.add_slider_note(self, hold_column, start_time, self.end_time);
|
||||
|
||||
let mut next_column = PatternGenerator::get_random_column(self, None, None);
|
||||
let mut next_column = self.inner.get_random_column(None, None);
|
||||
|
||||
let mut note_count = if conversion_diff > 6.5 {
|
||||
self.get_random_note_count(0.63, 0.0, None, None, None)
|
||||
self.inner
|
||||
.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 };
|
||||
let p2 = if self.inner.total_columns < 6 {
|
||||
0.12
|
||||
} else {
|
||||
0.45
|
||||
};
|
||||
|
||||
self.get_random_note_count(p2, 0.0, None, None, None)
|
||||
self.inner.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 };
|
||||
let p2 = if self.inner.total_columns < 6 {
|
||||
0.0
|
||||
} else {
|
||||
0.24
|
||||
};
|
||||
|
||||
self.get_random_note_count(p2, 0.0, None, None, None)
|
||||
self.inner.get_random_note_count(p2, 0.0, None, None, None)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
note_count = note_count.min(self.total_columns - 1);
|
||||
note_count = note_count.min(self.inner.total_columns - 1);
|
||||
|
||||
let sample = self.sample_info_list_at(start_time);
|
||||
let ignore_head = !(sample.whistle() || sample.finish() || sample.clap());
|
||||
let ignore_head =
|
||||
!sample.has_flag(HitSoundType::WHISTLE | HitSoundType::FINISH | HitSoundType::CLAP);
|
||||
|
||||
let mut row_pattern = Pattern::default();
|
||||
let hold_column = i32::from(hold_column);
|
||||
@@ -485,9 +466,6 @@ impl<'h> DistanceObjectPatternGenerator<'h> {
|
||||
for _ in 0..note_count {
|
||||
next_column = self.find_available_column(
|
||||
next_column,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some(&|c| c != hold_column),
|
||||
&[&row_pattern],
|
||||
);
|
||||
@@ -503,41 +481,61 @@ impl<'h> DistanceObjectPatternGenerator<'h> {
|
||||
pattern
|
||||
}
|
||||
|
||||
fn sample_info_list_at(&self, time: i32) -> u8 {
|
||||
fn sample_info_list_at(&self, time: i32) -> HitSoundType {
|
||||
self.note_samples_at(time)
|
||||
.first()
|
||||
.map_or(self.sample, |sample| *sample)
|
||||
}
|
||||
|
||||
fn note_samples_at(&self, time: i32) -> &[u8] {
|
||||
fn note_samples_at(&self, time: i32) -> &[HitSoundType] {
|
||||
let idx = if self.segment_duration == 0 {
|
||||
0
|
||||
} else {
|
||||
((time - self.start_time) / self.segment_duration) as usize
|
||||
};
|
||||
|
||||
&self.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
|
||||
&self.node_sounds[idx..]
|
||||
}
|
||||
|
||||
fn find_available_column(
|
||||
&mut self,
|
||||
mut initial_column: u8,
|
||||
validation: Option<&dyn Fn(i32) -> bool>,
|
||||
patterns: &[&Pattern],
|
||||
) -> u8 {
|
||||
let lower = self.inner.random_start();
|
||||
let upper = self.inner.total_columns;
|
||||
|
||||
let is_valid = |column: i32| {
|
||||
if let Some(fun) = validation {
|
||||
if !(fun)(column) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
let column = column as u8;
|
||||
|
||||
patterns
|
||||
.iter()
|
||||
.all(|pattern| !pattern.column_has_obj(column))
|
||||
};
|
||||
|
||||
// * Check for the initial column
|
||||
if is_valid(i32::from(initial_column)) {
|
||||
return initial_column;
|
||||
}
|
||||
|
||||
// * Ensure that we have at least one free column, so that an endless loop is avoided
|
||||
let has_valid_column = (lower..upper).any(is_valid);
|
||||
assert!(has_valid_column);
|
||||
|
||||
// * Iterate until a valid column is found. This is a random iteration in the default case.
|
||||
while {
|
||||
initial_column = self.inner.get_random_column(Some(lower), Some(upper));
|
||||
|
||||
!is_valid(i32::from(initial_column))
|
||||
} {}
|
||||
|
||||
initial_column
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
use rosu_map::section::hit_objects::hit_samples::HitSoundType;
|
||||
|
||||
use crate::{
|
||||
mania::convert::{pattern::Pattern, pattern_type::PatternType},
|
||||
model::hit_object::HitObject,
|
||||
util::random::Random,
|
||||
Beatmap,
|
||||
};
|
||||
|
||||
use super::PatternGenerator;
|
||||
|
||||
pub struct EndTimeObjectPatternGenerator<'h> {
|
||||
pub end_time: f64,
|
||||
pub sample: HitSoundType,
|
||||
pub inner: PatternGenerator<'h>,
|
||||
convert_type: PatternType,
|
||||
prev_pattern: &'h Pattern,
|
||||
}
|
||||
|
||||
impl<'h> EndTimeObjectPatternGenerator<'h> {
|
||||
pub fn new(
|
||||
random: &'h mut Random,
|
||||
hit_object: &'h HitObject,
|
||||
end_time: f64,
|
||||
sample: HitSoundType,
|
||||
total_columns: i32,
|
||||
prev_pattern: &'h Pattern,
|
||||
orig: &'h Beatmap,
|
||||
) -> Self {
|
||||
let convert_type = if prev_pattern.column_with_objs() == total_columns {
|
||||
PatternType::default()
|
||||
} else {
|
||||
PatternType::FORCE_NOT_STACK
|
||||
};
|
||||
|
||||
let inner = PatternGenerator::new(hit_object, total_columns, random, orig);
|
||||
|
||||
Self {
|
||||
end_time,
|
||||
sample,
|
||||
inner,
|
||||
convert_type,
|
||||
prev_pattern,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn generate(&mut self) -> Pattern {
|
||||
let generate_hold = self.end_time - self.inner.hit_object.start_time >= 100.0;
|
||||
|
||||
match self.inner.total_columns {
|
||||
8 if self.sample.has_flag(HitSoundType::FINISH)
|
||||
&& self.end_time - self.inner.hit_object.start_time < 1000.0 =>
|
||||
{
|
||||
Pattern::new_end_time_note(self, 0, generate_hold)
|
||||
}
|
||||
8 => {
|
||||
let column = self.get_random_column(self.inner.random_start());
|
||||
|
||||
Pattern::new_end_time_note(self, column, generate_hold)
|
||||
}
|
||||
_ => {
|
||||
let column = self.get_random_column(0);
|
||||
|
||||
Pattern::new_end_time_note(self, column, generate_hold)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn get_random_column(&mut self, lower: i32) -> u8 {
|
||||
let column = self.inner.get_random_column(Some(lower), None);
|
||||
|
||||
if self.convert_type.contains(PatternType::FORCE_NOT_STACK) {
|
||||
self.find_available_column(column, Some(lower), &[self.prev_pattern])
|
||||
} else {
|
||||
self.find_available_column(column, Some(lower), &[])
|
||||
}
|
||||
}
|
||||
|
||||
fn find_available_column(
|
||||
&mut self,
|
||||
mut initial_column: u8,
|
||||
lower: Option<i32>,
|
||||
// upper: Option<i32>,
|
||||
// next_column: Option<&dyn Fn(u8) -> u8>,
|
||||
// validation: Option<&dyn Fn(i32) -> bool>,
|
||||
patterns: &[&Pattern],
|
||||
) -> u8 {
|
||||
let lower = lower.unwrap_or_else(|| self.inner.random_start());
|
||||
let upper = self.inner.total_columns;
|
||||
|
||||
let is_valid = |column: i32| {
|
||||
let column = column as u8;
|
||||
|
||||
patterns
|
||||
.iter()
|
||||
.all(|pattern| !pattern.column_has_obj(column))
|
||||
};
|
||||
|
||||
// * Check for the initial column
|
||||
if is_valid(i32::from(initial_column)) {
|
||||
return initial_column;
|
||||
}
|
||||
|
||||
// * Ensure that we have at least one free column, so that an endless loop is avoided
|
||||
let has_valid_column = (lower..upper).any(is_valid);
|
||||
assert!(has_valid_column);
|
||||
|
||||
// * Iterate until a valid column is found. This is a random iteration in the default case.
|
||||
while {
|
||||
initial_column = self.inner.get_random_column(Some(lower), Some(upper));
|
||||
|
||||
!is_valid(i32::from(initial_column))
|
||||
} {}
|
||||
|
||||
initial_column
|
||||
}
|
||||
}
|
||||
+112
-96
@@ -1,39 +1,41 @@
|
||||
use rosu_map::section::hit_objects::hit_samples::HitSoundType;
|
||||
|
||||
use crate::{
|
||||
beatmap::{
|
||||
converts::mania::{
|
||||
legacy_random::Random, pattern::Pattern, pattern_type::PatternType, PrevValues,
|
||||
},
|
||||
EffectPoint,
|
||||
mania::{
|
||||
convert::{pattern::Pattern, pattern_type::PatternType, PrevValues},
|
||||
object::ManiaObject,
|
||||
},
|
||||
mania::ManiaObject,
|
||||
parse::{HitObject, HitSound},
|
||||
Beatmap,
|
||||
model::{
|
||||
beatmap::Beatmap,
|
||||
control_point::{EffectPoint, TimingPoint},
|
||||
hit_object::HitObject,
|
||||
},
|
||||
util::random::Random,
|
||||
};
|
||||
|
||||
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,
|
||||
pub struct HitObjectPatternGenerator<'h> {
|
||||
pub sample: HitSoundType,
|
||||
pub stair_type: PatternType,
|
||||
pub inner: PatternGenerator<'h>,
|
||||
convert_type: PatternType,
|
||||
prev_pattern: &'h Pattern,
|
||||
random: &'h mut Random,
|
||||
orig: &'h Beatmap,
|
||||
}
|
||||
|
||||
impl<'h> HitObjectPatternGenerator<'h> {
|
||||
pub(crate) fn new(
|
||||
pub fn new(
|
||||
random: &'h mut Random,
|
||||
hit_object: &'h HitObject,
|
||||
sample: u8,
|
||||
sample: HitSoundType,
|
||||
total_columns: i32,
|
||||
prev: &'h PrevValues,
|
||||
density: f64,
|
||||
orig: &'h Beatmap,
|
||||
) -> Self {
|
||||
let timing_point = orig.timing_point_at(hit_object.start_time);
|
||||
let beat_len = orig
|
||||
.timing_point_at(hit_object.start_time)
|
||||
.map_or(TimingPoint::DEFAULT_BEAT_LEN, |point| point.beat_len);
|
||||
|
||||
let pos_separation = (hit_object.pos - prev.pos).length();
|
||||
let time_separation = hit_object.start_time - prev.time;
|
||||
@@ -58,10 +60,10 @@ impl<'h> HitObjectPatternGenerator<'h> {
|
||||
} 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 {
|
||||
} else if pos_separation < 20.0 && density >= beat_len / 2.5 {
|
||||
// * Low density stream
|
||||
convert_type |= PatternType::REVERSE | PatternType::LOW_PROBABILITY;
|
||||
} else if density < timing_point.beat_len / 2.5 {
|
||||
} else if density < beat_len / 2.5 {
|
||||
// * High density
|
||||
} else {
|
||||
let kiai = orig
|
||||
@@ -76,36 +78,37 @@ impl<'h> HitObjectPatternGenerator<'h> {
|
||||
}
|
||||
|
||||
if !convert_type.contains(PatternType::KEEP_SINGLE) {
|
||||
if sample.finish() && total_columns != 8 {
|
||||
if sample.has_flag(HitSoundType::FINISH) && total_columns != 8 {
|
||||
convert_type |= PatternType::MIRROR;
|
||||
} else if sample.clap() {
|
||||
} else if sample.has_flag(HitSoundType::CLAP) {
|
||||
convert_type |= PatternType::GATHERED;
|
||||
}
|
||||
}
|
||||
|
||||
let inner = PatternGenerator::new(hit_object, total_columns, random, orig);
|
||||
|
||||
Self {
|
||||
hit_object,
|
||||
stair_type: prev.stair,
|
||||
convert_type,
|
||||
total_columns,
|
||||
sample,
|
||||
prev_pattern: &prev.pattern,
|
||||
random,
|
||||
orig,
|
||||
inner,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn generate(&mut self) -> Pattern {
|
||||
pub fn generate(&mut self) -> Pattern {
|
||||
let pattern = self.generate_core();
|
||||
|
||||
for obj in pattern.hit_objects.iter() {
|
||||
let col = ManiaObject::column(obj.pos.x, self.total_columns as f32) as i32;
|
||||
let col = ManiaObject::column(obj.pos.x, self.inner.total_columns as f32) as i32;
|
||||
|
||||
if self.convert_type.contains(PatternType::STAIR) && col == self.total_columns - 1 {
|
||||
if self.convert_type.contains(PatternType::STAIR) && col == self.inner.total_columns - 1
|
||||
{
|
||||
self.stair_type = PatternType::REVERSE_STAIR;
|
||||
}
|
||||
|
||||
if self.convert_type.contains(PatternType::REVERSE_STAIR) && col == self.random_start()
|
||||
if self.convert_type.contains(PatternType::REVERSE_STAIR)
|
||||
&& col == self.inner.random_start()
|
||||
{
|
||||
self.stair_type = PatternType::STAIR;
|
||||
}
|
||||
@@ -115,24 +118,24 @@ impl<'h> HitObjectPatternGenerator<'h> {
|
||||
}
|
||||
|
||||
fn generate_core(&mut self) -> Pattern {
|
||||
if self.total_columns == 1 {
|
||||
if self.inner.total_columns == 1 {
|
||||
return Pattern::new_note(self, 0);
|
||||
}
|
||||
|
||||
let last_column = self.prev_pattern.hit_objects.last().map_or(0, |h| {
|
||||
ManiaObject::column(h.pos.x, self.total_columns as f32) as u8
|
||||
ManiaObject::column(h.pos.x, self.inner.total_columns as f32) as u8
|
||||
});
|
||||
|
||||
let random_start = self.random_start() as u8;
|
||||
let random_start = self.inner.random_start() as u8;
|
||||
|
||||
if self.convert_type.contains(PatternType::REVERSE)
|
||||
&& !self.prev_pattern.hit_objects.is_empty()
|
||||
{
|
||||
let mut pattern = Pattern::default();
|
||||
|
||||
for i in random_start..self.total_columns as u8 {
|
||||
for i in random_start..self.inner.total_columns as u8 {
|
||||
if self.prev_pattern.column_has_obj(i) {
|
||||
pattern.add_note(self, random_start + self.total_columns as u8 - i - 1);
|
||||
pattern.add_note(self, random_start + self.inner.total_columns as u8 - i - 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,12 +145,12 @@ impl<'h> HitObjectPatternGenerator<'h> {
|
||||
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)
|
||||
&& (self.inner.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)
|
||||
&& (self.inner.total_columns % 2 == 0 || last_column != self.inner.total_columns as u8 / 2)
|
||||
{
|
||||
// * Generate a new pattern by cycling backwards (similar to Reverse but for only one hit object)
|
||||
let column = random_start + self.total_columns as u8 - last_column - 1;
|
||||
let column = random_start + self.inner.total_columns as u8 - last_column - 1;
|
||||
|
||||
return Pattern::new_note(self, column);
|
||||
}
|
||||
@@ -158,7 +161,7 @@ impl<'h> HitObjectPatternGenerator<'h> {
|
||||
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 {
|
||||
for i in random_start..self.inner.total_columns as u8 {
|
||||
if self.prev_pattern.column_has_obj(i) {
|
||||
pattern.add_note(self, i);
|
||||
}
|
||||
@@ -173,7 +176,7 @@ impl<'h> HitObjectPatternGenerator<'h> {
|
||||
// * 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 {
|
||||
if target_column == self.inner.total_columns as u8 {
|
||||
target_column = random_start;
|
||||
}
|
||||
|
||||
@@ -186,7 +189,7 @@ impl<'h> HitObjectPatternGenerator<'h> {
|
||||
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;
|
||||
target_column = self.inner.total_columns as i8 - 1;
|
||||
}
|
||||
|
||||
return Pattern::new_note(self, target_column as u8);
|
||||
@@ -197,7 +200,7 @@ impl<'h> HitObjectPatternGenerator<'h> {
|
||||
return self.generate_random_notes(1);
|
||||
}
|
||||
|
||||
let conversion_diff = self.conversion_difficulty();
|
||||
let conversion_diff = self.inner.conversion_difficulty();
|
||||
|
||||
if self.convert_type.contains(PatternType::MIRROR) {
|
||||
if conversion_diff > 6.5 {
|
||||
@@ -236,30 +239,27 @@ impl<'h> HitObjectPatternGenerator<'h> {
|
||||
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);
|
||||
note_count = (self.inner.total_columns
|
||||
- self.inner.random_start()
|
||||
- self.prev_pattern.column_with_objs())
|
||||
.min(note_count);
|
||||
}
|
||||
|
||||
let mut next_column = self.get_column(Some(true));
|
||||
let mut next_column = self.inner.get_column(Some(true));
|
||||
|
||||
for _ in 0..note_count {
|
||||
next_column = if allow_stacking {
|
||||
self.find_available_column(
|
||||
next_column,
|
||||
None,
|
||||
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],
|
||||
)
|
||||
};
|
||||
@@ -274,25 +274,25 @@ impl<'h> HitObjectPatternGenerator<'h> {
|
||||
if self.convert_type.contains(PatternType::GATHERED) {
|
||||
last += 1;
|
||||
|
||||
if last == self.total_columns as u8 {
|
||||
last = self.random_start() as u8;
|
||||
if last == self.inner.total_columns as u8 {
|
||||
last = self.inner.random_start() as u8;
|
||||
}
|
||||
} else {
|
||||
last = PatternGenerator::get_random_column(self, None, None);
|
||||
last = self.inner.get_random_column(None, None);
|
||||
}
|
||||
|
||||
last
|
||||
}
|
||||
|
||||
fn has_special_column(&self) -> bool {
|
||||
self.sample.clap() && self.sample.finish()
|
||||
const fn has_special_column(&self) -> bool {
|
||||
self.sample.has_flag(HitSoundType::CLAP) && self.sample.has_flag(HitSoundType::FINISH)
|
||||
}
|
||||
|
||||
fn generate_random_pattern(&mut self, p2: f64, p3: f64, p4: f64, p5: f64) -> Pattern {
|
||||
let random_note_count = self.get_random_note_count(p2, p3, p4, p5);
|
||||
let mut pattern = self.generate_random_notes(random_note_count);
|
||||
|
||||
if self.random_start() > 0 && self.has_special_column() {
|
||||
if self.inner.random_start() > 0 && self.has_special_column() {
|
||||
pattern.add_note(self, 0);
|
||||
}
|
||||
|
||||
@@ -300,7 +300,7 @@ impl<'h> HitObjectPatternGenerator<'h> {
|
||||
}
|
||||
|
||||
fn get_random_note_count(&mut self, mut p2: f64, mut p3: f64, mut p4: f64, mut p5: f64) -> i32 {
|
||||
match self.total_columns {
|
||||
match self.inner.total_columns {
|
||||
2 => {
|
||||
p2 = 0.0;
|
||||
p3 = 0.0;
|
||||
@@ -327,11 +327,12 @@ impl<'h> HitObjectPatternGenerator<'h> {
|
||||
_ => {}
|
||||
}
|
||||
|
||||
if self.sample.clap() {
|
||||
if self.sample.has_flag(HitSoundType::CLAP) {
|
||||
p2 = 1.0;
|
||||
}
|
||||
|
||||
PatternGenerator::get_random_note_count(self, p2, p3, Some(p4), Some(p5), None)
|
||||
self.inner
|
||||
.get_random_note_count(p2, p3, Some(p4), Some(p5), None)
|
||||
}
|
||||
|
||||
fn generate_random_pattern_with_mirrored(
|
||||
@@ -349,37 +350,32 @@ impl<'h> HitObjectPatternGenerator<'h> {
|
||||
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
|
||||
let column_limit = if self.inner.total_columns % 2 == 0 {
|
||||
self.inner.total_columns / 2
|
||||
} else {
|
||||
(self.total_columns - 1) / 2
|
||||
(self.inner.total_columns - 1) / 2
|
||||
};
|
||||
|
||||
let mut next_column = PatternGenerator::get_random_column(self, None, Some(column_limit));
|
||||
let mut next_column = self.inner.get_random_column(None, Some(column_limit));
|
||||
|
||||
for _ in 0..note_count {
|
||||
next_column = self.find_available_column(
|
||||
next_column,
|
||||
None,
|
||||
Some(column_limit),
|
||||
None,
|
||||
None,
|
||||
&[&pattern],
|
||||
);
|
||||
next_column =
|
||||
self.find_available_column(next_column, Some(column_limit), None, &[&pattern]);
|
||||
|
||||
// * Add normal note
|
||||
pattern.add_note(self, next_column);
|
||||
|
||||
// * Add mirrored note
|
||||
let column = (self.random_start() + self.total_columns) as u8 - next_column - 1;
|
||||
let column =
|
||||
(self.inner.random_start() + self.inner.total_columns) as u8 - next_column - 1;
|
||||
pattern.add_note(self, column);
|
||||
}
|
||||
|
||||
if add_to_centre {
|
||||
pattern.add_note(self, self.total_columns as u8 / 2);
|
||||
pattern.add_note(self, self.inner.total_columns as u8 / 2);
|
||||
}
|
||||
|
||||
if self.random_start() > 0 && self.has_special_column() {
|
||||
if self.inner.random_start() > 0 && self.has_special_column() {
|
||||
pattern.add_note(self, 0);
|
||||
}
|
||||
|
||||
@@ -392,7 +388,7 @@ impl<'h> HitObjectPatternGenerator<'h> {
|
||||
mut p2: f64,
|
||||
mut p3: f64,
|
||||
) -> (i32, bool) {
|
||||
match self.total_columns {
|
||||
match self.inner.total_columns {
|
||||
2 => {
|
||||
centre_probability = 0.0;
|
||||
p2 = 0.0;
|
||||
@@ -433,33 +429,53 @@ impl<'h> HitObjectPatternGenerator<'h> {
|
||||
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;
|
||||
let centre_val = self.inner.random.next_double();
|
||||
let note_count = self.inner.get_random_note_count(p2, p3, None, None, None);
|
||||
let add_to_centre = self.inner.total_columns % 2 != 0
|
||||
&& note_count != 3
|
||||
&& centre_val > 1.0 - centre_probability;
|
||||
|
||||
(note_count, add_to_centre)
|
||||
}
|
||||
}
|
||||
|
||||
impl PatternGenerator for HitObjectPatternGenerator<'_> {
|
||||
#[inline]
|
||||
fn hit_object(&self) -> &HitObject {
|
||||
self.hit_object
|
||||
}
|
||||
fn find_available_column(
|
||||
&mut self,
|
||||
mut initial_column: u8,
|
||||
upper: Option<i32>,
|
||||
next_column: Option<fn(&mut Self, u8) -> u8>,
|
||||
patterns: &[&Pattern],
|
||||
) -> u8 {
|
||||
let lower = self.inner.random_start();
|
||||
let upper = upper.unwrap_or(self.inner.total_columns);
|
||||
|
||||
#[inline]
|
||||
fn total_columns(&self) -> i32 {
|
||||
self.total_columns
|
||||
}
|
||||
let is_valid = |column: i32| {
|
||||
let column = column as u8;
|
||||
|
||||
#[inline]
|
||||
fn random(&mut self) -> &mut Random {
|
||||
self.random
|
||||
}
|
||||
patterns
|
||||
.iter()
|
||||
.all(|pattern| !pattern.column_has_obj(column))
|
||||
};
|
||||
|
||||
#[inline]
|
||||
fn original_map(&self) -> &Beatmap {
|
||||
self.orig
|
||||
// * Check for the initial column
|
||||
if is_valid(i32::from(initial_column)) {
|
||||
return initial_column;
|
||||
}
|
||||
|
||||
// * Ensure that we have at least one free column, so that an endless loop is avoided
|
||||
let has_valid_column = (lower..upper).any(is_valid);
|
||||
assert!(has_valid_column);
|
||||
|
||||
// * Iterate until a valid column is found. This is a random iteration in the default case.
|
||||
while {
|
||||
initial_column = if let Some(fun) = next_column {
|
||||
(fun)(self, initial_column)
|
||||
} else {
|
||||
self.inner.get_random_column(Some(lower), Some(upper))
|
||||
};
|
||||
|
||||
!is_valid(i32::from(initial_column))
|
||||
} {}
|
||||
|
||||
initial_column
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
use crate::{
|
||||
mania::object::ManiaObject,
|
||||
model::{beatmap::Beatmap, hit_object::HitObject},
|
||||
util::random::Random,
|
||||
};
|
||||
|
||||
pub(super) mod distance_object;
|
||||
pub(super) mod end_time_object;
|
||||
pub(super) mod hit_object;
|
||||
|
||||
pub struct PatternGenerator<'a> {
|
||||
pub hit_object: &'a HitObject,
|
||||
pub total_columns: i32,
|
||||
random: &'a mut Random,
|
||||
original_map: &'a Beatmap,
|
||||
}
|
||||
|
||||
impl<'a> PatternGenerator<'a> {
|
||||
fn new(
|
||||
hit_object: &'a HitObject,
|
||||
total_columns: i32,
|
||||
random: &'a mut Random,
|
||||
original_map: &'a Beatmap,
|
||||
) -> Self {
|
||||
Self {
|
||||
hit_object,
|
||||
total_columns,
|
||||
random,
|
||||
original_map,
|
||||
}
|
||||
}
|
||||
|
||||
fn random_start(&self) -> i32 {
|
||||
i32::from(self.total_columns == 8)
|
||||
}
|
||||
|
||||
fn get_column(&self, allow_special: Option<bool>) -> u8 {
|
||||
let allow_special = allow_special.unwrap_or(false);
|
||||
|
||||
if allow_special && self.total_columns == 8 {
|
||||
const LOCAL_X_DIVISOR: f32 = 512.0 / 7.0;
|
||||
|
||||
((self.hit_object.pos.x / LOCAL_X_DIVISOR).floor() as u8).clamp(0, 6) + 1
|
||||
} else {
|
||||
ManiaObject::column(self.hit_object.pos.x, self.total_columns as f32) as u8
|
||||
}
|
||||
}
|
||||
|
||||
fn get_random_note_count(
|
||||
&mut self,
|
||||
p2: f64,
|
||||
p3: f64,
|
||||
p4: Option<f64>,
|
||||
p5: Option<f64>,
|
||||
p6: Option<f64>,
|
||||
) -> i32 {
|
||||
let p4 = p4.unwrap_or(0.0);
|
||||
let p5 = p5.unwrap_or(0.0);
|
||||
let p6 = p6.unwrap_or(0.0);
|
||||
|
||||
let val = self.random.next_double();
|
||||
|
||||
if val >= 1.0 - p6 {
|
||||
6
|
||||
} else if val >= 1.0 - p5 {
|
||||
5
|
||||
} else if val >= 1.0 - p4 {
|
||||
4
|
||||
} else if val >= 1.0 - p3 {
|
||||
3
|
||||
} else {
|
||||
1 + i32::from(val >= 1.0 - p2)
|
||||
}
|
||||
}
|
||||
|
||||
fn conversion_difficulty(&self) -> f64 {
|
||||
let orig = self.original_map;
|
||||
let last_obj_time = orig.hit_objects.last().map_or(0.0, |h| h.start_time);
|
||||
let first_obj_time = orig.hit_objects.first().map_or(0.0, |h| h.start_time);
|
||||
|
||||
// * Drain time in seconds
|
||||
let total_break_time = orig.total_break_time();
|
||||
let mut drain_time = ((last_obj_time - first_obj_time - total_break_time) / 1000.0) as i32;
|
||||
|
||||
if drain_time == 0 {
|
||||
drain_time = 10_000;
|
||||
}
|
||||
|
||||
let mut conversion_difficulty = 0.0;
|
||||
conversion_difficulty += f64::from(orig.hp + orig.ar.clamp(4.0, 7.0)) / 1.5;
|
||||
conversion_difficulty += orig.hit_objects.len() as f64 / f64::from(drain_time) * 9.0;
|
||||
conversion_difficulty /= 38.0;
|
||||
conversion_difficulty *= 5.0;
|
||||
conversion_difficulty /= 1.15;
|
||||
conversion_difficulty = conversion_difficulty.min(12.0);
|
||||
|
||||
conversion_difficulty
|
||||
}
|
||||
|
||||
fn get_random_column(&mut self, lower: Option<i32>, upper: Option<i32>) -> u8 {
|
||||
let lower = lower.unwrap_or_else(|| self.random_start());
|
||||
let upper = upper.unwrap_or(self.total_columns);
|
||||
|
||||
self.random.next_int_range(lower, upper) as u8
|
||||
}
|
||||
}
|
||||
@@ -4,23 +4,23 @@ use std::{
|
||||
};
|
||||
|
||||
#[derive(Copy, Clone, Default)]
|
||||
pub(crate) struct PatternType(u16);
|
||||
pub 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);
|
||||
pub const FORCE_STACK: Self = Self(1 << 0);
|
||||
pub const FORCE_NOT_STACK: Self = Self(1 << 1);
|
||||
pub const KEEP_SINGLE: Self = Self(1 << 2);
|
||||
pub const LOW_PROBABILITY: Self = Self(1 << 3);
|
||||
// pub const ALTERNATE: Self = Self(1 << 4);
|
||||
// pub const FORCE_SIG_SLIDER: Self = Self(1 << 5);
|
||||
// pub const FORCE_NOT_SLIDER: Self = Self(1 << 6);
|
||||
pub const GATHERED: Self = Self(1 << 7);
|
||||
pub const MIRROR: Self = Self(1 << 8);
|
||||
pub const REVERSE: Self = Self(1 << 9);
|
||||
pub const CYCLE: Self = Self(1 << 10);
|
||||
pub const STAIR: Self = Self(1 << 11);
|
||||
pub const REVERSE_STAIR: Self = Self(1 << 12);
|
||||
}
|
||||
|
||||
impl fmt::Display for PatternType {
|
||||
@@ -66,7 +66,7 @@ impl fmt::Display for PatternType {
|
||||
}
|
||||
|
||||
impl PatternType {
|
||||
pub(crate) const fn contains(self, other: Self) -> bool {
|
||||
pub const fn contains(self, other: Self) -> bool {
|
||||
self.0 & other.0 == other.0
|
||||
}
|
||||
}
|
||||
@@ -74,21 +74,18 @@ impl PatternType {
|
||||
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;
|
||||
}
|
||||
@@ -97,7 +94,6 @@ impl BitAndAssign for PatternType {
|
||||
impl Not for PatternType {
|
||||
type Output = Self;
|
||||
|
||||
#[inline]
|
||||
fn not(self) -> Self::Output {
|
||||
Self(!self.0)
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
use crate::{
|
||||
any::difficulty::{skills::Skill, ModeDifficulty},
|
||||
mania::{
|
||||
difficulty::{object::ManiaDifficultyObject, skills::strain::Strain},
|
||||
object::{ManiaObject, ObjectParams},
|
||||
},
|
||||
util::float_ext::FloatExt,
|
||||
};
|
||||
|
||||
use super::{attributes::ManiaDifficultyAttributes, convert::ManiaBeatmap};
|
||||
|
||||
mod object;
|
||||
mod skills;
|
||||
|
||||
const STAR_SCALING_FACTOR: f64 = 0.018;
|
||||
|
||||
pub fn difficulty(
|
||||
difficulty: &ModeDifficulty,
|
||||
converted: &ManiaBeatmap<'_>,
|
||||
) -> ManiaDifficultyAttributes {
|
||||
let hit_window = converted
|
||||
.attributes()
|
||||
.mods(difficulty.get_mods())
|
||||
.clock_rate(difficulty.get_clock_rate())
|
||||
.hit_windows()
|
||||
.od;
|
||||
|
||||
let n_objects = converted.map.hit_objects.len() as u32;
|
||||
let values = DifficultyValues::calculate(difficulty, converted);
|
||||
|
||||
ManiaDifficultyAttributes {
|
||||
stars: values.strain.difficulty_value() * STAR_SCALING_FACTOR,
|
||||
hit_window,
|
||||
max_combo: values.max_combo,
|
||||
n_objects,
|
||||
is_convert: converted.is_convert,
|
||||
}
|
||||
}
|
||||
|
||||
pub struct DifficultyValues {
|
||||
pub strain: Strain,
|
||||
pub max_combo: u32,
|
||||
}
|
||||
|
||||
impl DifficultyValues {
|
||||
pub fn calculate(difficulty: &ModeDifficulty, converted: &ManiaBeatmap<'_>) -> Self {
|
||||
let take = difficulty.get_passed_objects();
|
||||
let total_columns = converted.map.cs.round_even().max(1.0);
|
||||
let clock_rate = difficulty.get_clock_rate();
|
||||
let mut params = ObjectParams::new(converted.map.as_ref());
|
||||
|
||||
let mut mania_objects = converted
|
||||
.map
|
||||
.hit_objects
|
||||
.iter()
|
||||
.map(|h| ManiaObject::new(h, total_columns, &mut params))
|
||||
.take(take);
|
||||
|
||||
let Some(first) = mania_objects.next() else {
|
||||
return DifficultyValues {
|
||||
strain: Strain::new(total_columns as usize),
|
||||
max_combo: 0,
|
||||
};
|
||||
};
|
||||
|
||||
let n_diff_objects = mania_objects.len();
|
||||
|
||||
let diff_objects_iter = mania_objects.enumerate().scan(first, |last, (i, base)| {
|
||||
let diff_object = ManiaDifficultyObject::new(&base, last, clock_rate, i);
|
||||
*last = base;
|
||||
|
||||
Some(diff_object)
|
||||
});
|
||||
|
||||
let mut diff_objects = Vec::with_capacity(n_diff_objects);
|
||||
diff_objects.extend(diff_objects_iter);
|
||||
|
||||
let mut strain = Skill::new(Strain::new(total_columns as usize), &diff_objects);
|
||||
|
||||
for curr in diff_objects.iter() {
|
||||
strain.process(curr);
|
||||
}
|
||||
|
||||
Self {
|
||||
strain: strain.inner,
|
||||
max_combo: params.into_max_combo(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
use crate::{any::difficulty::object::IDifficultyObject, mania::object::ManiaObject};
|
||||
|
||||
pub struct ManiaDifficultyObject {
|
||||
pub idx: usize,
|
||||
pub base_column: usize,
|
||||
pub delta_time: f64,
|
||||
pub start_time: f64,
|
||||
pub end_time: f64,
|
||||
}
|
||||
|
||||
impl ManiaDifficultyObject {
|
||||
pub fn new(base: &ManiaObject, last: &ManiaObject, clock_rate: f64, idx: usize) -> Self {
|
||||
Self {
|
||||
idx,
|
||||
base_column: base.column,
|
||||
delta_time: (base.start_time - last.start_time) / clock_rate,
|
||||
start_time: base.start_time / clock_rate,
|
||||
end_time: base.end_time / clock_rate,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IDifficultyObject for ManiaDifficultyObject {
|
||||
fn idx(&self) -> usize {
|
||||
self.idx
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub mod strain;
|
||||
@@ -0,0 +1,186 @@
|
||||
use crate::{
|
||||
any::difficulty::{
|
||||
object::IDifficultyObject,
|
||||
skills::{strain_decay, ISkill, Skill, StrainDecaySkill},
|
||||
},
|
||||
mania::difficulty::object::ManiaDifficultyObject,
|
||||
};
|
||||
|
||||
const INDIVIDUAL_DECAY_BASE: f64 = 0.125;
|
||||
const OVERALL_DECAY_BASE: f64 = 0.3;
|
||||
const RELEASE_THRESHOLD: f64 = 24.0;
|
||||
|
||||
const SKILL_MULTIPLIER: f64 = 1.0;
|
||||
const STRAIN_DECAY_BASE: f64 = 1.0;
|
||||
|
||||
#[allow(clippy::struct_field_names)]
|
||||
pub struct Strain {
|
||||
start_times: Box<[f64]>,
|
||||
end_times: Box<[f64]>,
|
||||
individual_strains: Box<[f64]>,
|
||||
|
||||
individual_strain: f64,
|
||||
overall_strain: f64,
|
||||
|
||||
inner: StrainDecaySkill,
|
||||
}
|
||||
|
||||
impl Strain {
|
||||
pub fn new(total_columns: usize) -> Self {
|
||||
Self {
|
||||
start_times: vec![0.0; total_columns].into_boxed_slice(),
|
||||
end_times: vec![0.0; total_columns].into_boxed_slice(),
|
||||
individual_strains: vec![0.0; total_columns].into_boxed_slice(),
|
||||
individual_strain: 0.0,
|
||||
overall_strain: 1.0,
|
||||
inner: StrainDecaySkill::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_curr_strain_peaks(self) -> Vec<f64> {
|
||||
self.inner.get_curr_strain_peaks()
|
||||
}
|
||||
|
||||
pub fn difficulty_value(self) -> f64 {
|
||||
self.inner.difficulty_value(StrainDecaySkill::DECAY_WEIGHT)
|
||||
}
|
||||
|
||||
const fn curr_strain(&self) -> f64 {
|
||||
self.inner.curr_strain
|
||||
}
|
||||
|
||||
fn curr_strain_mut(&mut self) -> &mut f64 {
|
||||
&mut self.inner.curr_strain
|
||||
}
|
||||
|
||||
fn strain_value_at(&mut self, curr: &ManiaDifficultyObject) -> f64 {
|
||||
*self.curr_strain_mut() *= strain_decay(curr.delta_time, STRAIN_DECAY_BASE);
|
||||
*self.curr_strain_mut() += self.strain_value_of(curr) * SKILL_MULTIPLIER;
|
||||
|
||||
self.curr_strain()
|
||||
}
|
||||
|
||||
fn strain_value_of(&mut self, curr: &ManiaDifficultyObject) -> f64 {
|
||||
let mania_curr = curr;
|
||||
let start_time = mania_curr.start_time;
|
||||
let end_time = mania_curr.end_time;
|
||||
let column = mania_curr.base_column;
|
||||
let mut is_overlapping = false;
|
||||
|
||||
// * Lowest value we can assume with the current information
|
||||
let mut closest_end_time = (end_time - start_time).abs();
|
||||
// * Factor to all additional strains in case something else is held
|
||||
let mut hold_factor = 1.0;
|
||||
// * Addition to the current note in case it's a hold and has to be released awkwardly
|
||||
let mut hold_addition = 0.0;
|
||||
|
||||
for i in 0..self.end_times.len() {
|
||||
// * The current note is overlapped if a previous note or end is overlapping the current note body
|
||||
is_overlapping |=
|
||||
self.end_times[i] > start_time + 1.0 && end_time > self.end_times[i] + 1.0;
|
||||
|
||||
// * We give a slight bonus to everything if something is held meanwhile
|
||||
if self.end_times[i] > end_time + 1.0 {
|
||||
hold_factor = 1.25;
|
||||
}
|
||||
|
||||
closest_end_time = (end_time - self.end_times[i]).abs().min(closest_end_time);
|
||||
}
|
||||
|
||||
// * The hold addition is given if there was an overlap, however it is only valid if there are no other note with a similar ending.
|
||||
// * Releasing multiple notes is just as easy as releasing 1. Nerfs the hold addition by half if the closest release is release_threshold away.
|
||||
// * holdAddition
|
||||
// * ^
|
||||
// * 1.0 + - - - - - -+-----------
|
||||
// * | /
|
||||
// * 0.5 + - - - - -/ Sigmoid Curve
|
||||
// * | /|
|
||||
// * 0.0 +--------+-+---------------> Release Difference / ms
|
||||
// * release_threshold
|
||||
if is_overlapping {
|
||||
hold_addition = (1.0 + (0.5 * (RELEASE_THRESHOLD - closest_end_time)).exp()).recip();
|
||||
}
|
||||
|
||||
// * Decay and increase individualStrains in own column
|
||||
self.individual_strains[column] = apply_decay(
|
||||
self.individual_strains[column],
|
||||
start_time - self.start_times[column],
|
||||
INDIVIDUAL_DECAY_BASE,
|
||||
);
|
||||
self.individual_strains[column] += 2.0 * hold_factor;
|
||||
|
||||
// * For notes at the same time (in a chord), the individualStrain should be the hardest individualStrain out of those columns
|
||||
self.individual_strain = if mania_curr.delta_time <= 1.0 {
|
||||
self.individual_strain.max(self.individual_strains[column])
|
||||
} else {
|
||||
self.individual_strains[column]
|
||||
};
|
||||
|
||||
// * Decay and increase overallStrain
|
||||
self.overall_strain = apply_decay(self.overall_strain, curr.delta_time, OVERALL_DECAY_BASE);
|
||||
self.overall_strain += (1.0 + hold_addition) * hold_factor;
|
||||
|
||||
// * Update startTimes and endTimes arrays
|
||||
self.start_times[column] = start_time;
|
||||
self.end_times[column] = end_time;
|
||||
|
||||
// * By subtracting CurrentStrain, this skill effectively only considers the maximum strain of any one hitobject within each strain section.
|
||||
self.individual_strain + self.overall_strain - self.curr_strain()
|
||||
}
|
||||
}
|
||||
|
||||
impl ISkill for Strain {
|
||||
type DifficultyObjects<'a> = [ManiaDifficultyObject];
|
||||
}
|
||||
|
||||
impl Skill<'_, Strain> {
|
||||
fn calculate_initial_strain(&mut self, offset: f64, curr: &ManiaDifficultyObject) -> f64 {
|
||||
let prev_start_time = curr
|
||||
.previous(0, self.diff_objects)
|
||||
.map_or(0.0, |prev| prev.start_time);
|
||||
|
||||
let time = offset - prev_start_time;
|
||||
|
||||
let individual = apply_decay(self.inner.individual_strain, time, INDIVIDUAL_DECAY_BASE);
|
||||
let overall = apply_decay(self.inner.overall_strain, time, OVERALL_DECAY_BASE);
|
||||
|
||||
individual + overall
|
||||
}
|
||||
|
||||
const fn curr_section_peak(&self) -> f64 {
|
||||
self.inner.inner.inner.curr_section_peak
|
||||
}
|
||||
|
||||
fn curr_section_peak_mut(&mut self) -> &mut f64 {
|
||||
&mut self.inner.inner.inner.curr_section_peak
|
||||
}
|
||||
|
||||
const fn curr_section_end(&self) -> f64 {
|
||||
self.inner.inner.inner.curr_section_end
|
||||
}
|
||||
|
||||
fn curr_section_end_mut(&mut self) -> &mut f64 {
|
||||
&mut self.inner.inner.inner.curr_section_end
|
||||
}
|
||||
|
||||
pub fn process(&mut self, curr: &ManiaDifficultyObject) {
|
||||
if curr.idx == 0 {
|
||||
*self.curr_section_end_mut() = (curr.start_time / StrainDecaySkill::SECTION_LEN).ceil()
|
||||
* StrainDecaySkill::SECTION_LEN;
|
||||
}
|
||||
|
||||
while curr.start_time > self.curr_section_end() {
|
||||
self.inner.inner.save_curr_peak();
|
||||
let initial_strain = self.calculate_initial_strain(self.curr_section_end(), curr);
|
||||
self.inner.inner.start_new_section_from(initial_strain);
|
||||
*self.curr_section_end_mut() += StrainDecaySkill::SECTION_LEN;
|
||||
}
|
||||
|
||||
let strain_value_at = self.inner.strain_value_at(curr);
|
||||
*self.curr_section_peak_mut() = strain_value_at.max(self.curr_section_peak());
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_decay(value: f64, delta_time: f64, decay_base: f64) -> f64 {
|
||||
value * decay_base.powf(delta_time / 1000.0)
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
use super::mania_object::ManiaObject;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct ManiaDifficultyObject {
|
||||
pub(crate) idx: usize,
|
||||
pub(crate) base_column: usize,
|
||||
pub(crate) delta_time: f64,
|
||||
pub(crate) start_time: f64,
|
||||
pub(crate) end_time: f64,
|
||||
}
|
||||
|
||||
impl ManiaDifficultyObject {
|
||||
pub(crate) fn new(base: &ManiaObject, last: &ManiaObject, clock_rate: f64, idx: usize) -> Self {
|
||||
Self {
|
||||
idx,
|
||||
base_column: base.column,
|
||||
delta_time: (base.start_time - last.start_time) / clock_rate,
|
||||
start_time: base.start_time / clock_rate,
|
||||
end_time: base.end_time / clock_rate,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,314 +0,0 @@
|
||||
#![cfg(feature = "gradual")]
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
use crate::{
|
||||
beatmap::BeatmapHitWindows,
|
||||
parse::{HitObject, HitObjectKind},
|
||||
util::FloatExt,
|
||||
Beatmap, GameMode, Mods,
|
||||
};
|
||||
|
||||
use super::{
|
||||
difficulty_object::ManiaDifficultyObject,
|
||||
mania_object::ObjectParameters,
|
||||
skills::{Skill, Strain},
|
||||
ManiaDifficultyAttributes, ManiaObject, STAR_SCALING_FACTOR,
|
||||
};
|
||||
|
||||
/// Gradually calculate the difficulty attributes of an osu!mania map.
|
||||
///
|
||||
/// Note that this struct implements [`Iterator`].
|
||||
/// On every call of [`Iterator::next`], the map's next hit object will
|
||||
/// be processed and the [`ManiaDifficultyAttributes`] will be updated and returned.
|
||||
///
|
||||
/// If you want to calculate performance attributes, use
|
||||
/// [`ManiaGradualPerformance`](crate::mania::ManiaGradualPerformance) instead.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use rosu_pp::{Beatmap, mania::ManiaGradualDifficulty};
|
||||
///
|
||||
/// # /*
|
||||
/// let map: Beatmap = ...
|
||||
/// # */
|
||||
/// # let map = Beatmap::default();
|
||||
///
|
||||
/// let mods = 64; // DT
|
||||
/// let mut iter = ManiaGradualDifficulty::new(&map, mods);
|
||||
///
|
||||
/// let attrs1 = iter.next(); // the difficulty of the map after the first hit object
|
||||
/// let attrs2 = iter.next(); // after the second hit object
|
||||
///
|
||||
/// // Remaining hit objects
|
||||
/// for difficulty in iter {
|
||||
/// // ...
|
||||
/// }
|
||||
/// ```
|
||||
#[cfg_attr(docsrs, doc(cfg(feature = "gradual")))]
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ManiaGradualDifficulty<'map> {
|
||||
map: Cow<'map, Beatmap>,
|
||||
inner: ManiaGradualDifficultyInner,
|
||||
}
|
||||
|
||||
impl<'map> ManiaGradualDifficulty<'map> {
|
||||
/// Create a new difficulty attributes iterator for osu!mania maps.
|
||||
pub fn new(map: &'map Beatmap, mods: u32) -> Self {
|
||||
let map = map.convert_mode(GameMode::Mania);
|
||||
let is_convert = matches!(map, Cow::Owned(_));
|
||||
let inner = ManiaGradualDifficultyInner::new(map.as_ref(), is_convert, mods);
|
||||
|
||||
Self { map, inner }
|
||||
}
|
||||
|
||||
pub(crate) const fn idx(&self) -> usize {
|
||||
self.inner.idx
|
||||
}
|
||||
}
|
||||
|
||||
impl Iterator for ManiaGradualDifficulty<'_> {
|
||||
type Item = ManiaDifficultyAttributes;
|
||||
|
||||
#[inline]
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
self.inner.next(&self.map.hit_objects)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn size_hint(&self) -> (usize, Option<usize>) {
|
||||
self.inner.size_hint()
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn nth(&mut self, n: usize) -> Option<Self::Item> {
|
||||
self.inner.nth(n, &self.map.hit_objects)
|
||||
}
|
||||
}
|
||||
|
||||
impl ExactSizeIterator for ManiaGradualDifficulty<'_> {
|
||||
#[inline]
|
||||
fn len(&self) -> usize {
|
||||
self.inner.len()
|
||||
}
|
||||
}
|
||||
|
||||
/// Gradually calculate the difficulty attributes of an osu!mania map.
|
||||
///
|
||||
/// Check [`ManiaGradualDifficulty`] for more information. This struct does the same
|
||||
/// but takes ownership of [`Beatmap`] to avoid being bound to a lifetime.
|
||||
#[cfg_attr(docsrs, doc(cfg(feature = "gradual")))]
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ManiaOwnedGradualDifficulty {
|
||||
// Technically only `Beatmap::hit_objects` are required here but storing
|
||||
// the full map lets us get away with not storing the map in `ManiaOwnedGradualPerformance`.
|
||||
pub(crate) map: Beatmap,
|
||||
inner: ManiaGradualDifficultyInner,
|
||||
}
|
||||
|
||||
impl ManiaOwnedGradualDifficulty {
|
||||
/// Create a new owned difficulty attributes iterator for osu!mania maps.
|
||||
pub fn new(map: Beatmap, mods: u32) -> Self {
|
||||
let converted_map = map.convert_mode(GameMode::Mania);
|
||||
let is_convert = matches!(converted_map, Cow::Owned(_));
|
||||
let inner = ManiaGradualDifficultyInner::new(&converted_map, is_convert, mods);
|
||||
|
||||
let map = match converted_map {
|
||||
Cow::Owned(map) => map,
|
||||
Cow::Borrowed(_) => map,
|
||||
};
|
||||
|
||||
Self { map, inner }
|
||||
}
|
||||
|
||||
#[allow(unused)]
|
||||
pub(crate) const fn idx(&self) -> usize {
|
||||
self.inner.idx
|
||||
}
|
||||
}
|
||||
|
||||
impl Iterator for ManiaOwnedGradualDifficulty {
|
||||
type Item = ManiaDifficultyAttributes;
|
||||
|
||||
#[inline]
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
self.inner.next(&self.map.hit_objects)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn size_hint(&self) -> (usize, Option<usize>) {
|
||||
self.inner.size_hint()
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn nth(&mut self, n: usize) -> Option<Self::Item> {
|
||||
self.inner.nth(n, &self.map.hit_objects)
|
||||
}
|
||||
}
|
||||
|
||||
impl ExactSizeIterator for ManiaOwnedGradualDifficulty {
|
||||
#[inline]
|
||||
fn len(&self) -> usize {
|
||||
self.inner.len()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct ManiaGradualDifficultyInner {
|
||||
pub(crate) idx: usize,
|
||||
hit_window: f64,
|
||||
strain: Strain,
|
||||
diff_objects: Box<[ManiaDifficultyObject]>,
|
||||
curr_combo: usize,
|
||||
clock_rate: f64,
|
||||
is_convert: bool,
|
||||
}
|
||||
|
||||
impl ManiaGradualDifficultyInner {
|
||||
fn new(map: &Beatmap, is_convert: bool, mods: u32) -> Self {
|
||||
let total_columns = map.cs.round_even().max(1.0);
|
||||
let clock_rate = mods.clock_rate();
|
||||
let strain = Strain::new(total_columns as usize);
|
||||
|
||||
let BeatmapHitWindows { od: hit_window, .. } = map
|
||||
.attributes()
|
||||
.mods(mods)
|
||||
.converted(is_convert)
|
||||
.clock_rate(clock_rate)
|
||||
.hit_windows();
|
||||
|
||||
let mut params = ObjectParameters::new(map);
|
||||
let mut curr_combo = 0;
|
||||
let mut hit_objects = map.hit_objects.iter();
|
||||
|
||||
let first = match hit_objects.next() {
|
||||
Some(h) => {
|
||||
let hit_object = ManiaObject::new(h, total_columns, &mut params);
|
||||
|
||||
increment_combo_raw(
|
||||
h,
|
||||
hit_object.start_time,
|
||||
hit_object.end_time,
|
||||
&mut curr_combo,
|
||||
);
|
||||
|
||||
hit_object
|
||||
}
|
||||
None => {
|
||||
return Self {
|
||||
idx: 0,
|
||||
hit_window,
|
||||
strain,
|
||||
diff_objects: Box::from([]),
|
||||
curr_combo: 0,
|
||||
clock_rate,
|
||||
is_convert,
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let diff_objects_iter = hit_objects.enumerate().scan(first, |last, (i, h)| {
|
||||
let base = ManiaObject::new(h, total_columns, &mut params);
|
||||
let diff_object = ManiaDifficultyObject::new(&base, &*last, clock_rate, i);
|
||||
*last = base;
|
||||
|
||||
Some(diff_object)
|
||||
});
|
||||
|
||||
let mut diff_objects = Vec::with_capacity(map.hit_objects.len() - 1);
|
||||
diff_objects.extend(diff_objects_iter);
|
||||
|
||||
debug_assert_eq!(diff_objects.len(), diff_objects.capacity());
|
||||
|
||||
Self {
|
||||
idx: 0,
|
||||
hit_window,
|
||||
strain,
|
||||
diff_objects: diff_objects.into_boxed_slice(),
|
||||
curr_combo,
|
||||
clock_rate,
|
||||
is_convert,
|
||||
}
|
||||
}
|
||||
|
||||
fn next(&mut self, hit_objects: &[HitObject]) -> Option<ManiaDifficultyAttributes> {
|
||||
// The first difficulty object belongs to the second note since each difficulty
|
||||
// object requires the current and the last note. Hence, if we're still on the first
|
||||
// object, we don't have a difficulty object yet and just skip processing.
|
||||
if self.idx > 0 {
|
||||
let curr = self.diff_objects.get(self.idx - 1)?;
|
||||
self.strain.process(curr, &self.diff_objects);
|
||||
|
||||
let h = &hit_objects[self.idx];
|
||||
increment_combo(h, curr, &mut self.curr_combo, self.clock_rate);
|
||||
} else if hit_objects.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
self.idx += 1;
|
||||
|
||||
Some(ManiaDifficultyAttributes {
|
||||
stars: self.strain.clone().difficulty_value() * STAR_SCALING_FACTOR,
|
||||
hit_window: self.hit_window,
|
||||
max_combo: self.curr_combo,
|
||||
is_convert: self.is_convert,
|
||||
n_objects: self.diff_objects.len() + 1,
|
||||
})
|
||||
}
|
||||
|
||||
const fn size_hint(&self) -> (usize, Option<usize>) {
|
||||
let len = self.len();
|
||||
|
||||
(len, Some(len))
|
||||
}
|
||||
|
||||
fn nth(&mut self, n: usize, hit_objects: &[HitObject]) -> Option<ManiaDifficultyAttributes> {
|
||||
let skip_iter = self
|
||||
.diff_objects
|
||||
.iter()
|
||||
.zip(hit_objects.iter().skip(1))
|
||||
.skip(self.idx.saturating_sub(1));
|
||||
|
||||
let mut take = n.min(self.len().saturating_sub(1));
|
||||
|
||||
// The first note has no difficulty object
|
||||
if self.idx == 0 && take > 0 {
|
||||
take -= 1;
|
||||
self.idx += 1;
|
||||
}
|
||||
|
||||
for (curr, h) in skip_iter.take(take) {
|
||||
increment_combo(h, curr, &mut self.curr_combo, self.clock_rate);
|
||||
self.strain.process(curr, &self.diff_objects);
|
||||
self.idx += 1;
|
||||
}
|
||||
|
||||
self.next(hit_objects)
|
||||
}
|
||||
|
||||
const fn len(&self) -> usize {
|
||||
self.diff_objects.len() + 1 - self.idx
|
||||
}
|
||||
}
|
||||
|
||||
fn increment_combo(
|
||||
h: &HitObject,
|
||||
diff_obj: &ManiaDifficultyObject,
|
||||
curr_combo: &mut usize,
|
||||
clock_rate: f64,
|
||||
) {
|
||||
increment_combo_raw(
|
||||
h,
|
||||
diff_obj.start_time * clock_rate,
|
||||
diff_obj.end_time * clock_rate,
|
||||
curr_combo,
|
||||
);
|
||||
}
|
||||
|
||||
fn increment_combo_raw(h: &HitObject, start_time: f64, end_time: f64, curr_combo: &mut usize) {
|
||||
match h.kind {
|
||||
HitObjectKind::Circle => *curr_combo += 1,
|
||||
_ => *curr_combo += 1 + ((end_time - start_time) / 100.0) as usize,
|
||||
}
|
||||
}
|
||||
@@ -1,178 +0,0 @@
|
||||
#![cfg(feature = "gradual")]
|
||||
|
||||
use crate::{Beatmap, ManiaPP};
|
||||
|
||||
use super::{
|
||||
ManiaGradualDifficulty, ManiaOwnedGradualDifficulty, ManiaPerformanceAttributes,
|
||||
ManiaScoreState,
|
||||
};
|
||||
|
||||
/// Gradually calculate the performance attributes of an osu!mania map.
|
||||
///
|
||||
/// After each hit object you can call [`next`](`ManiaGradualPerformance::next`)
|
||||
/// and it will return the resulting current [`ManiaPerformanceAttributes`].
|
||||
/// To process multiple objects at once, use [`nth`](`ManiaGradualPerformance::nth`) instead.
|
||||
///
|
||||
/// Both methods require a play's current score so far.
|
||||
/// Be sure the given score is adjusted with respect to mods.
|
||||
///
|
||||
/// If you only want to calculate difficulty attributes use
|
||||
/// [`ManiaGradualDifficulty`] instead.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use rosu_pp::{Beatmap, mania::{ManiaGradualPerformance, ManiaScoreState}};
|
||||
///
|
||||
/// # /*
|
||||
/// let map: Beatmap = ...
|
||||
/// # */
|
||||
/// # let map = Beatmap::default();
|
||||
///
|
||||
/// let mods = 64; // DT
|
||||
/// let mut gradual_perf = ManiaGradualPerformance::new(&map, mods);
|
||||
/// let mut state = ManiaScoreState::new(); // empty state, everything is on 0.
|
||||
///
|
||||
/// // The first 10 hitresults are 320s
|
||||
/// for _ in 0..10 {
|
||||
/// state.n320 += 1;
|
||||
///
|
||||
/// # /*
|
||||
/// let performance = gradual_perf.next(score).unwrap();
|
||||
/// println!("PP: {}", performance.pp);
|
||||
/// # */
|
||||
/// # let _ = gradual_perf.next(state.clone());
|
||||
/// }
|
||||
///
|
||||
/// // Then comes a miss.
|
||||
/// state.n_misses += 1;
|
||||
/// # /*
|
||||
/// let performance = gradual_perf.next(score).unwrap();
|
||||
/// println!("PP: {}", performance.pp);
|
||||
/// # */
|
||||
/// # let _ = gradual_perf.next(state.clone());
|
||||
///
|
||||
/// // The next 10 objects will be a mixture of 320s and 100s.
|
||||
/// // Notice how all 10 objects will be processed in one go.
|
||||
/// state.n320 += 3;
|
||||
/// state.n100 += 7;
|
||||
/// // The `nth` method takes a zero-based value.
|
||||
/// # /*
|
||||
/// let performance = gradual_perf.nth(score, 9).unwrap();
|
||||
/// println!("PP: {}", performance.pp);
|
||||
/// # */
|
||||
/// # let _ = gradual_perf.nth(state.clone(), 9);
|
||||
///
|
||||
/// // Skip to the end
|
||||
/// # /*
|
||||
/// state.max_combo = ...
|
||||
/// state.n300 = ...
|
||||
/// state.n100 = ...
|
||||
/// state.n_misses = ...
|
||||
/// let final_performance = gradual_perf.nth(state.clone(), usize::MAX).unwrap();
|
||||
/// println!("PP: {}", performance.pp);
|
||||
/// # */
|
||||
/// # let _ = gradual_perf.nth(state.clone(), usize::MAX);
|
||||
///
|
||||
/// // Once the final performance was calculated,
|
||||
/// // attempting to process further objects will return `None`.
|
||||
/// assert!(gradual_perf.next(state).is_none());
|
||||
/// ```
|
||||
#[cfg_attr(docsrs, doc(cfg(feature = "gradual")))]
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ManiaGradualPerformance<'map> {
|
||||
difficulty: ManiaGradualDifficulty<'map>,
|
||||
performance: ManiaPP<'map>,
|
||||
}
|
||||
|
||||
impl<'map> ManiaGradualPerformance<'map> {
|
||||
/// Create a new gradual performance calculator for osu!mania maps.
|
||||
pub fn new(map: &'map Beatmap, mods: u32) -> Self {
|
||||
let difficulty = ManiaGradualDifficulty::new(map, mods);
|
||||
let performance = ManiaPP::new(map).mods(mods).passed_objects(0);
|
||||
|
||||
Self {
|
||||
difficulty,
|
||||
performance,
|
||||
}
|
||||
}
|
||||
|
||||
/// Process the next hit object and calculate the
|
||||
/// performance attributes for the resulting score.
|
||||
pub fn next(&mut self, state: ManiaScoreState) -> Option<ManiaPerformanceAttributes> {
|
||||
self.nth(state, 0)
|
||||
}
|
||||
|
||||
/// Process all remaining hit objects and calculate the final performance attributes.
|
||||
pub fn last(&mut self, state: ManiaScoreState) -> Option<ManiaPerformanceAttributes> {
|
||||
self.nth(state, usize::MAX)
|
||||
}
|
||||
|
||||
/// Process everything up the the next `n`th hit object and calculate the performance
|
||||
/// attributes for the resulting score state.
|
||||
///
|
||||
/// Note that the count is zero-indexed, so `n=0` will process 1 object, `n=1` will process 2,
|
||||
/// and so on.
|
||||
pub fn nth(&mut self, state: ManiaScoreState, n: usize) -> Option<ManiaPerformanceAttributes> {
|
||||
let difficulty = self.difficulty.nth(n)?;
|
||||
|
||||
let performance = self
|
||||
.performance
|
||||
.clone()
|
||||
.attributes(difficulty)
|
||||
.state(state)
|
||||
.passed_objects(self.difficulty.idx())
|
||||
.calculate();
|
||||
|
||||
Some(performance)
|
||||
}
|
||||
}
|
||||
|
||||
/// Gradually calculate the performance attributes of an osu!mania map.
|
||||
///
|
||||
/// Check [`ManiaGradualPerformance`] for more information. This struct does the same
|
||||
/// but takes ownership of [`Beatmap`] to avoid being bound to a lifetime.
|
||||
#[cfg_attr(docsrs, doc(cfg(feature = "gradual")))]
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ManiaOwnedGradualPerformance {
|
||||
difficulty: ManiaOwnedGradualDifficulty,
|
||||
mods: u32,
|
||||
}
|
||||
|
||||
impl ManiaOwnedGradualPerformance {
|
||||
/// Create a new gradual performance calculator for osu!mania maps.
|
||||
pub fn new(map: Beatmap, mods: u32) -> Self {
|
||||
let difficulty = ManiaOwnedGradualDifficulty::new(map, mods);
|
||||
|
||||
Self { difficulty, mods }
|
||||
}
|
||||
|
||||
/// Process the next hit object and calculate the
|
||||
/// performance attributes for the resulting score.
|
||||
pub fn next(&mut self, state: ManiaScoreState) -> Option<ManiaPerformanceAttributes> {
|
||||
self.nth(state, 0)
|
||||
}
|
||||
|
||||
/// Process all remaining hit objects and calculate the final performance attributes.
|
||||
pub fn last(&mut self, state: ManiaScoreState) -> Option<ManiaPerformanceAttributes> {
|
||||
self.nth(state, usize::MAX)
|
||||
}
|
||||
|
||||
/// Process everything up the the next `n`th hit object and calculate the performance
|
||||
/// attributes for the resulting score state.
|
||||
///
|
||||
/// Note that the count is zero-indexed, so `n=0` will process 1 object, `n=1` will process 2,
|
||||
/// and so on.
|
||||
pub fn nth(&mut self, state: ManiaScoreState, n: usize) -> Option<ManiaPerformanceAttributes> {
|
||||
let difficulty = self.difficulty.nth(n)?;
|
||||
|
||||
let performance = ManiaPP::new(&self.difficulty.map)
|
||||
.mods(self.mods)
|
||||
.attributes(difficulty)
|
||||
.state(state)
|
||||
.passed_objects(self.difficulty.idx())
|
||||
.calculate();
|
||||
|
||||
Some(performance)
|
||||
}
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
use crate::{
|
||||
curve::{Curve, CurveBuffers},
|
||||
parse::{HitObject, HitObjectKind},
|
||||
Beatmap,
|
||||
};
|
||||
|
||||
const BASE_SCORING_DISTANCE: f64 = 100.0;
|
||||
|
||||
pub(crate) struct ObjectParameters<'a> {
|
||||
pub(crate) map: &'a Beatmap,
|
||||
pub(crate) max_combo: usize,
|
||||
pub(crate) curve_bufs: CurveBuffers,
|
||||
}
|
||||
|
||||
impl<'a> ObjectParameters<'a> {
|
||||
pub(crate) fn new(map: &'a Beatmap) -> Self {
|
||||
Self {
|
||||
map,
|
||||
max_combo: 0,
|
||||
curve_bufs: CurveBuffers::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A [`HitObject`] that was processed for the osu!mania gamemode.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct ManiaObject {
|
||||
/// Start time of the object.
|
||||
pub start_time: f64,
|
||||
/// Endtime of the object.
|
||||
pub end_time: f64,
|
||||
/// Column of the object.
|
||||
pub column: usize,
|
||||
}
|
||||
|
||||
impl ManiaObject {
|
||||
pub(crate) fn column(x: f32, total_columns: f32) -> usize {
|
||||
let x_divisor = 512.0 / total_columns;
|
||||
|
||||
(x / x_divisor).floor().min(total_columns - 1.0) as usize
|
||||
}
|
||||
|
||||
pub(crate) fn new(
|
||||
h: &HitObject,
|
||||
total_columns: f32,
|
||||
params: &mut ObjectParameters<'_>,
|
||||
) -> Self {
|
||||
let ObjectParameters {
|
||||
map,
|
||||
max_combo,
|
||||
curve_bufs,
|
||||
} = params;
|
||||
|
||||
let column = Self::column(h.pos.x, total_columns);
|
||||
*max_combo += 1;
|
||||
|
||||
match &h.kind {
|
||||
HitObjectKind::Circle => Self {
|
||||
start_time: h.start_time,
|
||||
end_time: h.start_time,
|
||||
column,
|
||||
},
|
||||
HitObjectKind::Slider {
|
||||
pixel_len,
|
||||
repeats,
|
||||
control_points,
|
||||
..
|
||||
} => {
|
||||
let span_count = *repeats as f64 + 1.0;
|
||||
|
||||
let curve = Curve::new(control_points, *pixel_len, curve_bufs);
|
||||
let dist = curve.dist();
|
||||
|
||||
let timing_point = map.timing_point_at(h.start_time);
|
||||
let difficulty_point = map.difficulty_point_at(h.start_time).unwrap_or_default();
|
||||
|
||||
let scoring_dist =
|
||||
BASE_SCORING_DISTANCE * map.slider_mult * difficulty_point.slider_vel;
|
||||
|
||||
let vel = scoring_dist / timing_point.beat_len;
|
||||
let duration = span_count * dist / vel;
|
||||
let end_time = h.start_time + duration;
|
||||
|
||||
*max_combo += (duration / 100.0) as usize;
|
||||
|
||||
Self {
|
||||
start_time: h.start_time,
|
||||
end_time,
|
||||
column,
|
||||
}
|
||||
}
|
||||
HitObjectKind::Spinner { end_time } | HitObjectKind::Hold { end_time } => {
|
||||
*max_combo += ((*end_time - h.start_time) / 100.0) as usize;
|
||||
|
||||
Self {
|
||||
start_time: h.start_time,
|
||||
end_time: *end_time,
|
||||
column,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+34
-329
@@ -1,345 +1,50 @@
|
||||
mod difficulty_object;
|
||||
mod mania_object;
|
||||
mod pp;
|
||||
mod score_state;
|
||||
mod skills;
|
||||
|
||||
#[cfg(feature = "gradual")]
|
||||
mod gradual_difficulty;
|
||||
#[cfg(feature = "gradual")]
|
||||
mod gradual_performance;
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
use crate::{beatmap::BeatmapHitWindows, util::FloatExt, Beatmap, GameMode, Mods, OsuStars};
|
||||
use crate::{
|
||||
any::ModeDifficulty,
|
||||
model::{
|
||||
beatmap::Beatmap,
|
||||
mode::{ConvertStatus, IGameMode},
|
||||
},
|
||||
};
|
||||
|
||||
pub use self::{mania_object::ManiaObject, pp::*, score_state::ManiaScoreState};
|
||||
|
||||
#[cfg(feature = "gradual")]
|
||||
pub use self::{
|
||||
gradual_difficulty::{ManiaGradualDifficulty, ManiaOwnedGradualDifficulty},
|
||||
gradual_performance::{ManiaGradualPerformance, ManiaOwnedGradualPerformance},
|
||||
attributes::{ManiaDifficultyAttributes, ManiaPerformanceAttributes},
|
||||
convert::ManiaBeatmap,
|
||||
performance::ManiaPerformance,
|
||||
score_state::ManiaScoreState,
|
||||
strains::ManiaStrains,
|
||||
};
|
||||
|
||||
pub(crate) use self::mania_object::ObjectParameters;
|
||||
mod attributes;
|
||||
mod convert;
|
||||
mod difficulty;
|
||||
mod object;
|
||||
mod performance;
|
||||
mod score_state;
|
||||
mod strains;
|
||||
|
||||
use self::{
|
||||
difficulty_object::ManiaDifficultyObject,
|
||||
skills::{Skill, Strain},
|
||||
};
|
||||
|
||||
const SECTION_LEN: f64 = 400.0;
|
||||
const STAR_SCALING_FACTOR: f64 = 0.018;
|
||||
|
||||
/// Difficulty calculator on osu!mania maps.
|
||||
/// Marker type for [`GameMode::Mania`].
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use rosu_pp::{ManiaStars, Beatmap};
|
||||
///
|
||||
/// # /*
|
||||
/// let map: Beatmap = ...
|
||||
/// # */
|
||||
/// # let map = Beatmap::default();
|
||||
///
|
||||
/// let difficulty_attrs = ManiaStars::new(&map)
|
||||
/// .mods(8 + 64) // HDDT
|
||||
/// .calculate();
|
||||
///
|
||||
/// println!("Stars: {}", difficulty_attrs.stars);
|
||||
/// ```
|
||||
#[derive(Clone, Debug)]
|
||||
#[must_use]
|
||||
pub struct ManiaStars<'map> {
|
||||
map: Cow<'map, Beatmap>,
|
||||
mods: u32,
|
||||
passed_objects: Option<usize>,
|
||||
clock_rate: Option<f64>,
|
||||
is_convert: bool,
|
||||
}
|
||||
/// [`GameMode::Mania`]: rosu_map::section::general::GameMode::Mania
|
||||
pub struct Mania;
|
||||
|
||||
impl<'map> ManiaStars<'map> {
|
||||
/// Create a new difficulty calculator for osu!mania maps.
|
||||
#[inline]
|
||||
pub fn new(map: &'map Beatmap) -> Self {
|
||||
let map = map.convert_mode(GameMode::Mania);
|
||||
let is_convert = matches!(map, Cow::Owned(_));
|
||||
impl IGameMode for Mania {
|
||||
type DifficultyAttributes = ManiaDifficultyAttributes;
|
||||
type Strains = ManiaStrains;
|
||||
|
||||
Self {
|
||||
map,
|
||||
mods: 0,
|
||||
passed_objects: None,
|
||||
clock_rate: None,
|
||||
is_convert,
|
||||
}
|
||||
fn try_convert(map: &mut Cow<'_, Beatmap>) -> ConvertStatus {
|
||||
convert::try_convert(map)
|
||||
}
|
||||
|
||||
/// Specify mods through their bit values.
|
||||
///
|
||||
/// See [https://github.com/ppy/osu-api/wiki#mods](https://github.com/ppy/osu-api/wiki#mods)
|
||||
#[inline]
|
||||
pub const fn mods(mut self, mods: u32) -> Self {
|
||||
self.mods = mods;
|
||||
|
||||
self
|
||||
fn difficulty(
|
||||
difficulty: &ModeDifficulty,
|
||||
converted: &ManiaBeatmap<'_>,
|
||||
) -> Self::DifficultyAttributes {
|
||||
difficulty::difficulty(difficulty, converted)
|
||||
}
|
||||
|
||||
/// Amount of passed objects for partial plays, e.g. a fail.
|
||||
///
|
||||
#[cfg_attr(
|
||||
feature = "gradual",
|
||||
doc = "If you want to calculate the difficulty after every few objects, instead of
|
||||
using [`ManiaStars`] multiple times with different `passed_objects`, you should use
|
||||
[`ManiaGradualDifficultyAttributes`](crate::mania::ManiaGradualDifficulty)."
|
||||
)]
|
||||
#[inline]
|
||||
pub const fn passed_objects(mut self, passed_objects: usize) -> Self {
|
||||
self.passed_objects = Some(passed_objects);
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
/// Adjust the clock rate used in the calculation.
|
||||
/// If none is specified, it will take the clock rate based on the mods
|
||||
/// i.e. 1.5 for DT, 0.75 for HT and 1.0 otherwise.
|
||||
#[inline]
|
||||
pub const fn clock_rate(mut self, clock_rate: f64) -> Self {
|
||||
self.clock_rate = Some(clock_rate);
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
/// Specify whether the map is a convert i.e. an osu!standard map.
|
||||
///
|
||||
/// This only needs to be specified if the map was converted manually beforehand.
|
||||
#[inline]
|
||||
pub const fn is_convert(mut self, is_convert: bool) -> Self {
|
||||
self.is_convert = is_convert;
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
/// Calculate all difficulty related values, including stars.
|
||||
#[inline]
|
||||
pub fn calculate(self) -> ManiaDifficultyAttributes {
|
||||
let is_convert = self.is_convert || matches!(self.map, Cow::Owned(_));
|
||||
|
||||
let clock_rate = self.clock_rate.unwrap_or_else(|| self.mods.clock_rate());
|
||||
|
||||
let BeatmapHitWindows { od: hit_window, .. } = self
|
||||
.map
|
||||
.attributes()
|
||||
.mods(self.mods)
|
||||
.converted(is_convert)
|
||||
.clock_rate(clock_rate)
|
||||
.hit_windows();
|
||||
|
||||
let n_objects = self.map.hit_objects.len();
|
||||
let ManiaResult { strain, max_combo } = calculate_result(self);
|
||||
|
||||
ManiaDifficultyAttributes {
|
||||
stars: strain.difficulty_value() * STAR_SCALING_FACTOR,
|
||||
hit_window,
|
||||
max_combo,
|
||||
n_objects,
|
||||
is_convert,
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate the skill strains.
|
||||
///
|
||||
/// Suitable to plot the difficulty of a map over time.
|
||||
#[inline]
|
||||
pub fn strains(self) -> ManiaStrains {
|
||||
let ManiaResult { strain, .. } = calculate_result(self);
|
||||
|
||||
ManiaStrains {
|
||||
section_len: SECTION_LEN,
|
||||
strains: strain.strain_peaks.to_vec(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The result of calculating the strains on a osu!taiko map.
|
||||
/// Suitable to plot the difficulty of a map over time.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ManiaStrains {
|
||||
/// Time in ms inbetween two strains.
|
||||
pub section_len: f64,
|
||||
/// Strain peaks of the strain skill.
|
||||
pub strains: Vec<f64>,
|
||||
}
|
||||
|
||||
impl ManiaStrains {
|
||||
/// Returns the number of strain peaks per skill.
|
||||
#[inline]
|
||||
#[allow(clippy::len_without_is_empty)]
|
||||
pub fn len(&self) -> usize {
|
||||
self.strains.len()
|
||||
}
|
||||
}
|
||||
|
||||
fn calculate_result(params: ManiaStars<'_>) -> ManiaResult {
|
||||
let ManiaStars {
|
||||
map,
|
||||
mods,
|
||||
passed_objects,
|
||||
clock_rate,
|
||||
is_convert: _,
|
||||
} = params;
|
||||
|
||||
let take = passed_objects.unwrap_or(map.hit_objects.len());
|
||||
let total_columns = map.cs.round_even().max(1.0);
|
||||
|
||||
let clock_rate = clock_rate.unwrap_or_else(|| mods.clock_rate());
|
||||
let mut strain = Strain::new(total_columns as usize);
|
||||
let mut params = ObjectParameters::new(map.as_ref());
|
||||
let mut hit_objects = map.hit_objects.iter().take(take);
|
||||
|
||||
let first = match hit_objects.next() {
|
||||
Some(h) => ManiaObject::new(h, total_columns, &mut params),
|
||||
None => {
|
||||
return ManiaResult {
|
||||
strain,
|
||||
max_combo: 0,
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let diff_objects_iter = hit_objects.enumerate().scan(first, |last, (i, h)| {
|
||||
let base = ManiaObject::new(h, total_columns, &mut params);
|
||||
let diff_object = ManiaDifficultyObject::new(&base, &*last, clock_rate, i);
|
||||
*last = base;
|
||||
|
||||
Some(diff_object)
|
||||
});
|
||||
|
||||
let mut diff_objects = Vec::with_capacity(map.hit_objects.len().min(take).saturating_sub(1));
|
||||
diff_objects.extend(diff_objects_iter);
|
||||
|
||||
for curr in diff_objects.iter() {
|
||||
strain.process(curr, &diff_objects);
|
||||
}
|
||||
|
||||
ManiaResult {
|
||||
strain,
|
||||
max_combo: params.max_combo,
|
||||
}
|
||||
}
|
||||
|
||||
struct ManiaResult {
|
||||
strain: Strain,
|
||||
max_combo: usize,
|
||||
}
|
||||
|
||||
/// The result of a difficulty calculation on an osu!mania map.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct ManiaDifficultyAttributes {
|
||||
/// The final star rating.
|
||||
pub stars: f64,
|
||||
/// The perceived hit window for an n300 inclusive of rate-adjusting mods (DT/HT/etc).
|
||||
pub hit_window: f64,
|
||||
/// The amount of hitobjects in the map.
|
||||
pub n_objects: usize,
|
||||
/// The maximum achievable combo.
|
||||
pub max_combo: usize,
|
||||
/// Whether the [`Beatmap`] was a convert i.e. an osu!standard map.
|
||||
pub is_convert: bool,
|
||||
}
|
||||
|
||||
impl ManiaDifficultyAttributes {
|
||||
/// Return the maximum combo.
|
||||
#[inline]
|
||||
pub const fn max_combo(&self) -> usize {
|
||||
self.max_combo
|
||||
}
|
||||
|
||||
/// Return the amount of hitobjects.
|
||||
#[inline]
|
||||
pub const fn n_objects(&self) -> usize {
|
||||
self.n_objects
|
||||
}
|
||||
|
||||
/// Whether the [`Beatmap`] was a convert i.e. an osu!standard map.
|
||||
#[inline]
|
||||
pub const fn is_convert(&self) -> bool {
|
||||
self.is_convert
|
||||
}
|
||||
|
||||
/// Returns a builder for performance calculation.
|
||||
#[inline]
|
||||
pub fn pp(self) -> ManiaPP<'static> {
|
||||
ManiaPP::from(self)
|
||||
}
|
||||
}
|
||||
|
||||
/// The result of a performance calculation on an osu!mania map.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct ManiaPerformanceAttributes {
|
||||
/// The difficulty attributes that were used for the performance calculation.
|
||||
pub difficulty: ManiaDifficultyAttributes,
|
||||
/// The final performance points.
|
||||
pub pp: f64,
|
||||
/// The difficulty portion of the final pp.
|
||||
pub pp_difficulty: f64,
|
||||
}
|
||||
|
||||
impl ManiaPerformanceAttributes {
|
||||
/// Return the star value.
|
||||
#[inline]
|
||||
pub const fn stars(&self) -> f64 {
|
||||
self.difficulty.stars
|
||||
}
|
||||
|
||||
/// Return the performance point value.
|
||||
#[inline]
|
||||
pub const fn pp(&self) -> f64 {
|
||||
self.pp
|
||||
}
|
||||
|
||||
/// Return the maximum combo of the map.
|
||||
#[inline]
|
||||
pub const fn max_combo(&self) -> usize {
|
||||
self.difficulty.max_combo
|
||||
}
|
||||
|
||||
/// Return the amount of hitobjects.
|
||||
#[inline]
|
||||
pub const fn n_objects(&self) -> usize {
|
||||
self.difficulty.n_objects
|
||||
}
|
||||
|
||||
/// Whether the [`Beatmap`] was a convert i.e. an osu!standard map.
|
||||
#[inline]
|
||||
pub const fn is_convert(&self) -> bool {
|
||||
self.difficulty.is_convert
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ManiaPerformanceAttributes> for ManiaDifficultyAttributes {
|
||||
#[inline]
|
||||
fn from(attributes: ManiaPerformanceAttributes) -> Self {
|
||||
attributes.difficulty
|
||||
}
|
||||
}
|
||||
|
||||
impl<'map> From<OsuStars<'map>> for ManiaStars<'map> {
|
||||
#[inline]
|
||||
fn from(osu: OsuStars<'map>) -> Self {
|
||||
let OsuStars {
|
||||
map,
|
||||
mods,
|
||||
passed_objects,
|
||||
clock_rate,
|
||||
} = osu;
|
||||
|
||||
Self {
|
||||
map: map.convert_mode(GameMode::Mania),
|
||||
mods,
|
||||
passed_objects,
|
||||
clock_rate,
|
||||
is_convert: true,
|
||||
}
|
||||
fn strains(difficulty: &ModeDifficulty, converted: &ManiaBeatmap<'_>) -> Self::Strains {
|
||||
strains::strains(difficulty, converted)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
use rosu_map::section::hit_objects::CurveBuffers;
|
||||
|
||||
use crate::model::{
|
||||
beatmap::Beatmap,
|
||||
control_point::{DifficultyPoint, TimingPoint},
|
||||
hit_object::{HitObject, HitObjectKind, HoldNote, Spinner},
|
||||
};
|
||||
|
||||
pub struct ManiaObject {
|
||||
pub start_time: f64,
|
||||
pub end_time: f64,
|
||||
pub column: usize,
|
||||
}
|
||||
|
||||
impl ManiaObject {
|
||||
pub fn new(h: &HitObject, total_columns: f32, params: &mut ObjectParams<'_>) -> Self {
|
||||
let column = Self::column(h.pos.x, total_columns);
|
||||
params.max_combo += 1;
|
||||
|
||||
match h.kind {
|
||||
HitObjectKind::Circle => Self {
|
||||
start_time: h.start_time,
|
||||
end_time: h.start_time,
|
||||
column,
|
||||
},
|
||||
HitObjectKind::Slider(ref slider) => {
|
||||
const BASE_SCORING_DIST: f32 = 100.0;
|
||||
|
||||
let dist = slider.curve(&mut params.curve_bufs).dist();
|
||||
|
||||
let beat_len = params
|
||||
.map
|
||||
.timing_point_at(h.start_time)
|
||||
.map_or(TimingPoint::DEFAULT_BEAT_LEN, |point| point.beat_len);
|
||||
|
||||
let slider_velocity = params
|
||||
.map
|
||||
.difficulty_point_at(h.start_time)
|
||||
.map_or(DifficultyPoint::DEFAULT_SLIDER_VELOCITY, |point| {
|
||||
point.slider_velocity
|
||||
});
|
||||
|
||||
let scoring_dist =
|
||||
f64::from(BASE_SCORING_DIST) * params.map.slider_multiplier * slider_velocity;
|
||||
let velocity = scoring_dist / beat_len;
|
||||
|
||||
let duration = (slider.span_count() as f64) * dist / velocity;
|
||||
|
||||
params.max_combo += (duration / 100.0) as u32;
|
||||
|
||||
Self {
|
||||
start_time: h.start_time,
|
||||
end_time: h.start_time + duration,
|
||||
column,
|
||||
}
|
||||
}
|
||||
HitObjectKind::Spinner(Spinner { end_time })
|
||||
| HitObjectKind::Hold(HoldNote { end_time }) => {
|
||||
params.max_combo += ((end_time - h.start_time) / 100.0) as u32;
|
||||
|
||||
Self {
|
||||
start_time: h.start_time,
|
||||
end_time,
|
||||
column,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn column(x: f32, total_columns: f32) -> usize {
|
||||
let x_divisor = 512.0 / total_columns;
|
||||
|
||||
(x / x_divisor).floor().min(total_columns - 1.0) as usize
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ObjectParams<'a> {
|
||||
map: &'a Beatmap,
|
||||
max_combo: u32,
|
||||
curve_bufs: CurveBuffers,
|
||||
}
|
||||
|
||||
impl<'a> ObjectParams<'a> {
|
||||
pub fn new(map: &'a Beatmap) -> Self {
|
||||
Self {
|
||||
map,
|
||||
max_combo: 0,
|
||||
curve_bufs: CurveBuffers::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn into_max_combo(self) -> u32 {
|
||||
self.max_combo
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+10
-13
@@ -1,35 +1,32 @@
|
||||
/// Aggregation for a score's current state i.e. what are the current hitresults.
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq)]
|
||||
/// Aggregation for a score's current state.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct ManiaScoreState {
|
||||
/// Amount of current 320s.
|
||||
pub n320: usize,
|
||||
pub n320: u32,
|
||||
/// Amount of current 300s.
|
||||
pub n300: usize,
|
||||
pub n300: u32,
|
||||
/// Amount of current 200s.
|
||||
pub n200: usize,
|
||||
pub n200: u32,
|
||||
/// Amount of current 100s.
|
||||
pub n100: usize,
|
||||
pub n100: u32,
|
||||
/// Amount of current 50s.
|
||||
pub n50: usize,
|
||||
pub n50: u32,
|
||||
/// Amount of current misses.
|
||||
pub n_misses: usize,
|
||||
pub n_misses: u32,
|
||||
}
|
||||
|
||||
impl ManiaScoreState {
|
||||
/// Create a new empty score state.
|
||||
#[inline]
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Return the total amount of hits by adding everything up.
|
||||
#[inline]
|
||||
pub const fn total_hits(&self) -> usize {
|
||||
pub const fn total_hits(&self) -> u32 {
|
||||
self.n320 + self.n300 + self.n200 + self.n100 + self.n50 + self.n_misses
|
||||
}
|
||||
|
||||
/// Calculate the accuracy between `0.0` and `1.0` for this state.
|
||||
#[inline]
|
||||
pub fn accuracy(&self) -> f64 {
|
||||
let total_hits = self.total_hits();
|
||||
|
||||
@@ -40,6 +37,6 @@ impl ManiaScoreState {
|
||||
let numerator = 6 * (self.n320 + self.n300) + 4 * self.n200 + 2 * self.n100 + self.n50;
|
||||
let denominator = 6 * total_hits;
|
||||
|
||||
numerator as f64 / denominator as f64
|
||||
f64::from(numerator) / f64::from(denominator)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
mod strain;
|
||||
mod traits;
|
||||
|
||||
pub(crate) use self::{
|
||||
strain::Strain,
|
||||
traits::{Skill, StrainDecaySkill, StrainSkill},
|
||||
};
|
||||
|
||||
use super::difficulty_object::ManiaDifficultyObject;
|
||||
|
||||
fn previous(
|
||||
diff_objects: &[ManiaDifficultyObject],
|
||||
curr: usize,
|
||||
backwards_idx: usize,
|
||||
) -> Option<&ManiaDifficultyObject> {
|
||||
curr.checked_sub(backwards_idx + 1)
|
||||
.and_then(|idx| diff_objects.get(idx))
|
||||
}
|
||||
@@ -1,211 +0,0 @@
|
||||
use crate::{mania::difficulty_object::ManiaDifficultyObject, util::CompactVec};
|
||||
|
||||
use super::{previous, Skill, StrainDecaySkill, StrainSkill};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
#[allow(clippy::struct_field_names)]
|
||||
pub(crate) struct Strain {
|
||||
start_times: Vec<f64>,
|
||||
end_times: Vec<f64>,
|
||||
individual_strains: Vec<f64>,
|
||||
|
||||
individual_strain: f64,
|
||||
overall_strain: f64,
|
||||
|
||||
curr_strain: f64,
|
||||
curr_section_peak: f64,
|
||||
curr_section_end: f64,
|
||||
|
||||
pub(crate) strain_peaks: CompactVec,
|
||||
}
|
||||
|
||||
impl Strain {
|
||||
const INDIVIDUAL_DECAY_BASE: f64 = 0.125;
|
||||
const OVERALL_DECAY_BASE: f64 = 0.3;
|
||||
const RELEASE_THRESHOLD: f64 = 24.0;
|
||||
|
||||
pub(crate) fn new(total_columns: usize) -> Self {
|
||||
Self {
|
||||
start_times: vec![0.0; total_columns],
|
||||
end_times: vec![0.0; total_columns],
|
||||
individual_strains: vec![0.0; total_columns],
|
||||
individual_strain: 0.0,
|
||||
overall_strain: 1.0,
|
||||
curr_strain: 0.0,
|
||||
curr_section_peak: 0.0,
|
||||
curr_section_end: 0.0,
|
||||
strain_peaks: CompactVec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_decay(value: f64, delta_time: f64, decay_base: f64) -> f64 {
|
||||
value * decay_base.powf(delta_time / 1000.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl Skill for Strain {
|
||||
#[inline]
|
||||
fn process(&mut self, curr: &ManiaDifficultyObject, diff_objects: &[ManiaDifficultyObject]) {
|
||||
<Self as StrainSkill>::process(self, curr, diff_objects);
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn difficulty_value(self) -> f64 {
|
||||
<Self as StrainSkill>::difficulty_value(self)
|
||||
}
|
||||
}
|
||||
|
||||
impl StrainSkill for Strain {
|
||||
const DECAY_WEIGHT: f64 = 0.9;
|
||||
|
||||
#[inline]
|
||||
fn curr_section_end(&self) -> f64 {
|
||||
self.curr_section_end
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn curr_section_end_mut(&mut self) -> &mut f64 {
|
||||
&mut self.curr_section_end
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn curr_section_peak(&self) -> f64 {
|
||||
self.curr_section_peak
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn curr_section_peak_mut(&mut self) -> &mut f64 {
|
||||
&mut self.curr_section_peak
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn strain_peaks_mut(&mut self) -> &mut CompactVec {
|
||||
&mut self.strain_peaks
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn strain_value_at(&mut self, curr: &ManiaDifficultyObject) -> f64 {
|
||||
<Self as StrainDecaySkill>::strain_value_at(self, curr)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn calculate_initial_strain(
|
||||
&self,
|
||||
time: f64,
|
||||
curr: &ManiaDifficultyObject,
|
||||
diff_objects: &[ManiaDifficultyObject],
|
||||
) -> f64 {
|
||||
<Self as StrainDecaySkill>::calculate_initial_strain(self, time, curr, diff_objects)
|
||||
}
|
||||
}
|
||||
|
||||
impl StrainDecaySkill for Strain {
|
||||
const SKILL_MULTIPLIER: f64 = 1.0;
|
||||
const STRAIN_DECAY_BASE: f64 = 1.0;
|
||||
|
||||
#[inline]
|
||||
fn curr_strain(&self) -> f64 {
|
||||
self.curr_strain
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn curr_strain_mut(&mut self) -> &mut f64 {
|
||||
&mut self.curr_strain
|
||||
}
|
||||
|
||||
fn strain_value_of(&mut self, curr: &ManiaDifficultyObject) -> f64 {
|
||||
let mania_curr = curr;
|
||||
let start_time = mania_curr.start_time;
|
||||
let end_time = mania_curr.end_time;
|
||||
let col = mania_curr.base_column;
|
||||
let mut is_overlapping = false;
|
||||
|
||||
// * Lowest value we can assume with the current information
|
||||
let mut closest_end_time = (end_time - start_time).abs();
|
||||
// * Factor to all additional strains in case something else is held
|
||||
let mut hold_factor = 1.0;
|
||||
// * Addition to the current note in case it's a hold and has to be released awkwardly
|
||||
let mut hold_addition = 0.0;
|
||||
|
||||
for i in 0..self.end_times.len() {
|
||||
// * The current note is overlapped if a previous note or end is overlapping the current note body
|
||||
is_overlapping |=
|
||||
self.end_times[i] > start_time + 1.0 && end_time > self.end_times[i] + 1.0;
|
||||
|
||||
// * We give a slight bonus to everything if something is held meanwhile
|
||||
if self.end_times[i] > end_time + 1.0 {
|
||||
hold_factor = 1.25;
|
||||
}
|
||||
|
||||
closest_end_time = (end_time - self.end_times[i]).abs().min(closest_end_time);
|
||||
}
|
||||
|
||||
// * The hold addition is given if there was an overlap, however it is only valid if there are no other note with a similar ending.
|
||||
// * Releasing multiple notes is just as easy as releasing 1. Nerfs the hold addition by half if the closest release is release_threshold away.
|
||||
// * holdAddition
|
||||
// * ^
|
||||
// * 1.0 + - - - - - -+-----------
|
||||
// * | /
|
||||
// * 0.5 + - - - - -/ Sigmoid Curve
|
||||
// * | /|
|
||||
// * 0.0 +--------+-+---------------> Release Difference / ms
|
||||
// * release_threshold
|
||||
if is_overlapping {
|
||||
hold_addition =
|
||||
(1.0 + (0.5 * (Self::RELEASE_THRESHOLD - closest_end_time)).exp()).recip();
|
||||
}
|
||||
|
||||
// * Decay and increase individualStrains in own column
|
||||
self.individual_strains[col] = Self::apply_decay(
|
||||
self.individual_strains[col],
|
||||
start_time - self.start_times[col],
|
||||
Self::INDIVIDUAL_DECAY_BASE,
|
||||
);
|
||||
self.individual_strains[col] += 2.0 * hold_factor;
|
||||
|
||||
// * For notes at the same time (in a chord), the individualStrain should be the hardest individualStrain out of those columns
|
||||
self.individual_strain = if mania_curr.delta_time <= 1.0 {
|
||||
self.individual_strain.max(self.individual_strains[col])
|
||||
} else {
|
||||
self.individual_strains[col]
|
||||
};
|
||||
|
||||
// * Decay and increase overallStrain
|
||||
self.overall_strain = Self::apply_decay(
|
||||
self.overall_strain,
|
||||
curr.delta_time,
|
||||
Self::OVERALL_DECAY_BASE,
|
||||
);
|
||||
self.overall_strain += (1.0 + hold_addition) * hold_factor;
|
||||
|
||||
// * Update startTimes and endTimes arrays
|
||||
self.start_times[col] = start_time;
|
||||
self.end_times[col] = end_time;
|
||||
|
||||
// * By subtracting CurrentStrain, this skill effectively only considers the maximum strain of any one hitobject within each strain section.
|
||||
self.individual_strain + self.overall_strain - self.curr_strain
|
||||
}
|
||||
|
||||
fn calculate_initial_strain(
|
||||
&self,
|
||||
offset: f64,
|
||||
curr: &ManiaDifficultyObject,
|
||||
diff_objects: &[ManiaDifficultyObject],
|
||||
) -> f64 {
|
||||
let prev_start = previous(diff_objects, curr.idx, 0).map_or(0.0, |h| h.start_time);
|
||||
|
||||
let individual_decay = Self::apply_decay(
|
||||
self.individual_strain,
|
||||
offset - prev_start,
|
||||
Self::INDIVIDUAL_DECAY_BASE,
|
||||
);
|
||||
|
||||
let overall_decay = Self::apply_decay(
|
||||
self.overall_strain,
|
||||
offset - prev_start,
|
||||
Self::OVERALL_DECAY_BASE,
|
||||
);
|
||||
|
||||
individual_decay + overall_decay
|
||||
}
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
use std::{cmp::Ordering, mem};
|
||||
|
||||
use crate::{
|
||||
mania::{difficulty_object::ManiaDifficultyObject, SECTION_LEN},
|
||||
util::CompactVec,
|
||||
};
|
||||
|
||||
pub(crate) trait Skill {
|
||||
fn process(&mut self, curr: &ManiaDifficultyObject, diff_objects: &[ManiaDifficultyObject]);
|
||||
fn difficulty_value(self) -> f64;
|
||||
}
|
||||
|
||||
pub(crate) trait StrainSkill: Sized + Skill {
|
||||
const DECAY_WEIGHT: f64 = 0.9;
|
||||
|
||||
fn curr_section_end(&self) -> f64;
|
||||
fn curr_section_end_mut(&mut self) -> &mut f64;
|
||||
|
||||
fn curr_section_peak(&self) -> f64;
|
||||
fn curr_section_peak_mut(&mut self) -> &mut f64;
|
||||
|
||||
fn strain_peaks_mut(&mut self) -> &mut CompactVec;
|
||||
|
||||
fn strain_value_at(&mut self, curr: &ManiaDifficultyObject) -> f64;
|
||||
|
||||
fn process(&mut self, curr: &ManiaDifficultyObject, diff_objects: &[ManiaDifficultyObject]) {
|
||||
// * The first object doesn't generate a strain, so we begin with an incremented section end
|
||||
if curr.idx == 0 {
|
||||
*self.curr_section_end_mut() = (curr.start_time / SECTION_LEN).ceil() * SECTION_LEN;
|
||||
}
|
||||
|
||||
while curr.start_time > self.curr_section_end() {
|
||||
self.save_curr_peak();
|
||||
self.start_new_section_from(self.curr_section_end(), curr, diff_objects);
|
||||
*self.curr_section_end_mut() += SECTION_LEN;
|
||||
|
||||
// Optimization to finish the loop early if
|
||||
// the current peak is 0.0 i.e. it can't decay further.
|
||||
// If final values don't coincide perfectly anymore,
|
||||
// this should be looked at and maybe adjusted.
|
||||
if self.curr_section_peak().abs() <= f64::EPSILON
|
||||
&& curr.start_time > self.curr_section_end()
|
||||
{
|
||||
let remaining_time = curr.start_time - self.curr_section_end();
|
||||
let remaining_iters = (remaining_time / SECTION_LEN).ceil();
|
||||
*self.curr_section_end_mut() += remaining_iters * SECTION_LEN;
|
||||
}
|
||||
}
|
||||
|
||||
*self.curr_section_peak_mut() = self.strain_value_at(curr).max(self.curr_section_peak());
|
||||
}
|
||||
|
||||
fn save_curr_peak(&mut self) {
|
||||
let curr_section_peak = self.curr_section_peak();
|
||||
self.strain_peaks_mut().push(curr_section_peak);
|
||||
}
|
||||
|
||||
fn start_new_section_from(
|
||||
&mut self,
|
||||
time: f64,
|
||||
curr: &ManiaDifficultyObject,
|
||||
diff_objects: &[ManiaDifficultyObject],
|
||||
) {
|
||||
*self.curr_section_peak_mut() = self.calculate_initial_strain(time, curr, diff_objects);
|
||||
}
|
||||
|
||||
fn calculate_initial_strain(
|
||||
&self,
|
||||
time: f64,
|
||||
curr: &ManiaDifficultyObject,
|
||||
diff_objects: &[ManiaDifficultyObject],
|
||||
) -> f64;
|
||||
|
||||
fn get_curr_strain_peaks(mut self) -> CompactVec {
|
||||
let mut peaks = mem::take(self.strain_peaks_mut());
|
||||
peaks.push(self.curr_section_peak());
|
||||
|
||||
peaks
|
||||
}
|
||||
|
||||
fn difficulty_value(self) -> f64 {
|
||||
let mut difficulty = 0.0;
|
||||
let mut weight = 1.0;
|
||||
|
||||
// * Sections with 0 strain are excluded to avoid worst-case time complexity of the following sort (e.g. /b/2351871).
|
||||
// * These sections will not contribute to the difficulty.
|
||||
let mut peaks = self.get_curr_strain_peaks();
|
||||
peaks.retain(|peak| peak > 0.0);
|
||||
let mut peaks = peaks.to_vec();
|
||||
peaks.sort_unstable_by(|a, b| b.partial_cmp(a).unwrap_or(Ordering::Equal));
|
||||
|
||||
// * Difficulty is the weighted sum of the highest strains from every section.
|
||||
// * We're sorting from highest to lowest strain.
|
||||
for strain in peaks {
|
||||
difficulty += strain * weight;
|
||||
weight *= Self::DECAY_WEIGHT;
|
||||
}
|
||||
|
||||
difficulty
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) trait StrainDecaySkill: StrainSkill {
|
||||
const SKILL_MULTIPLIER: f64;
|
||||
const STRAIN_DECAY_BASE: f64;
|
||||
|
||||
fn curr_strain(&self) -> f64;
|
||||
fn curr_strain_mut(&mut self) -> &mut f64;
|
||||
|
||||
fn strain_value_of(&mut self, curr: &ManiaDifficultyObject) -> f64;
|
||||
|
||||
fn calculate_initial_strain(
|
||||
&self,
|
||||
time: f64,
|
||||
curr: &ManiaDifficultyObject,
|
||||
diff_objects: &[ManiaDifficultyObject],
|
||||
) -> f64;
|
||||
|
||||
fn strain_value_at(&mut self, curr: &ManiaDifficultyObject) -> f64 {
|
||||
*self.curr_strain_mut() *= self.strain_decay(curr.delta_time);
|
||||
*self.curr_strain_mut() += self.strain_value_of(curr) * Self::SKILL_MULTIPLIER;
|
||||
|
||||
self.curr_strain()
|
||||
}
|
||||
|
||||
fn strain_decay(&self, ms: f64) -> f64 {
|
||||
Self::STRAIN_DECAY_BASE.powf(ms / 1000.0)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
use crate::{any::ModeDifficulty, mania::difficulty::DifficultyValues};
|
||||
|
||||
use super::convert::ManiaBeatmap;
|
||||
|
||||
/// The result of calculating the strains on a osu!mania map.
|
||||
///
|
||||
/// Suitable to plot the difficulty of a map over time.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct ManiaStrains {
|
||||
/// Strain peaks of the strain skill.
|
||||
pub strains: Vec<f64>,
|
||||
}
|
||||
|
||||
impl ManiaStrains {
|
||||
/// Time between two strains in ms.
|
||||
pub const SECTION_LEN: f64 = 400.0;
|
||||
}
|
||||
|
||||
pub fn strains(difficulty: &ModeDifficulty, converted: &ManiaBeatmap<'_>) -> ManiaStrains {
|
||||
let values = DifficultyValues::calculate(difficulty, converted);
|
||||
|
||||
ManiaStrains {
|
||||
strains: values.strain.get_curr_strain_peaks(),
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,8 @@
|
||||
use crate::{Beatmap, GameMode, Mods};
|
||||
use rosu_map::section::general::GameMode;
|
||||
|
||||
use crate::util::mods::Mods;
|
||||
|
||||
use super::{converted::Converted, Beatmap};
|
||||
|
||||
/// Summary struct for a [`Beatmap`]'s attributes.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
@@ -14,30 +18,29 @@ pub struct BeatmapAttributes {
|
||||
/// The clock rate with respect to mods.
|
||||
pub clock_rate: f64,
|
||||
/// The hit windows for approach rate and overall difficulty.
|
||||
pub hit_windows: BeatmapHitWindows,
|
||||
pub hit_windows: HitWindows,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, PartialEq)]
|
||||
/// AR and OD hit windows
|
||||
pub struct BeatmapHitWindows {
|
||||
#[derive(Copy, Clone, Debug, PartialEq)]
|
||||
pub struct HitWindows {
|
||||
/// 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,
|
||||
}
|
||||
|
||||
/// A builder for [`BeatmapAttributes`] and [`HitWindows`].
|
||||
#[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>,
|
||||
mods: u32,
|
||||
clock_rate: Option<f64>,
|
||||
converted: bool,
|
||||
is_convert: bool,
|
||||
}
|
||||
|
||||
impl BeatmapAttributesBuilder {
|
||||
@@ -49,21 +52,24 @@ impl BeatmapAttributesBuilder {
|
||||
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)
|
||||
map.into()
|
||||
}
|
||||
|
||||
#[inline]
|
||||
/// Specify the mode.
|
||||
pub fn mode(&mut self, mode: GameMode) -> &mut Self {
|
||||
self.mode = mode;
|
||||
|
||||
self
|
||||
const fn new_internal(map: &Beatmap, is_convert: bool) -> Self {
|
||||
Self {
|
||||
mode: map.mode,
|
||||
ar: map.ar,
|
||||
od: map.od,
|
||||
cs: map.cs,
|
||||
hp: map.hp,
|
||||
mods: 0,
|
||||
clock_rate: None,
|
||||
is_convert,
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
/// Specify the approach rate.
|
||||
pub fn ar(&mut self, ar: f32) -> &mut Self {
|
||||
self.ar = ar;
|
||||
@@ -71,7 +77,6 @@ impl BeatmapAttributesBuilder {
|
||||
self
|
||||
}
|
||||
|
||||
#[inline]
|
||||
/// Specify the overall difficulty.
|
||||
pub fn od(&mut self, od: f32) -> &mut Self {
|
||||
self.od = od;
|
||||
@@ -79,7 +84,6 @@ impl BeatmapAttributesBuilder {
|
||||
self
|
||||
}
|
||||
|
||||
#[inline]
|
||||
/// Specify the circle size.
|
||||
pub fn cs(&mut self, cs: f32) -> &mut Self {
|
||||
self.cs = cs;
|
||||
@@ -87,7 +91,6 @@ impl BeatmapAttributesBuilder {
|
||||
self
|
||||
}
|
||||
|
||||
#[inline]
|
||||
/// Specify the drain rate.
|
||||
pub fn hp(&mut self, hp: f32) -> &mut Self {
|
||||
self.hp = hp;
|
||||
@@ -95,15 +98,13 @@ impl BeatmapAttributesBuilder {
|
||||
self
|
||||
}
|
||||
|
||||
#[inline]
|
||||
/// Specify the mods.
|
||||
pub fn mods(&mut self, mods: u32) -> &mut Self {
|
||||
self.mods = Some(mods);
|
||||
self.mods = 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);
|
||||
@@ -111,19 +112,9 @@ impl BeatmapAttributesBuilder {
|
||||
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);
|
||||
pub fn hit_windows(&self) -> HitWindows {
|
||||
let mods = self.mods;
|
||||
let clock_rate = self.clock_rate.unwrap_or_else(|| mods.clock_rate());
|
||||
|
||||
let mod_mult = |val: f32| {
|
||||
@@ -164,7 +155,7 @@ impl BeatmapAttributesBuilder {
|
||||
diff_range / clock_rate
|
||||
}
|
||||
GameMode::Mania => {
|
||||
let mut value = if !self.converted {
|
||||
let mut value = if !self.is_convert {
|
||||
34.0 + 3.0 * (10.0 - self.od).clamp(0.0, 10.0)
|
||||
} else if self.od > 4.0 {
|
||||
34.0
|
||||
@@ -182,7 +173,7 @@ impl BeatmapAttributesBuilder {
|
||||
}
|
||||
};
|
||||
|
||||
BeatmapHitWindows {
|
||||
HitWindows {
|
||||
ar: preempt,
|
||||
od: hit_window,
|
||||
}
|
||||
@@ -190,7 +181,7 @@ impl BeatmapAttributesBuilder {
|
||||
|
||||
/// Calculate the [`BeatmapAttributes`].
|
||||
pub fn build(&self) -> BeatmapAttributes {
|
||||
let mods = self.mods.unwrap_or(0);
|
||||
let mods = self.mods;
|
||||
let clock_rate = self.clock_rate.unwrap_or_else(|| mods.clock_rate());
|
||||
|
||||
// HP
|
||||
@@ -206,7 +197,7 @@ impl BeatmapAttributesBuilder {
|
||||
}
|
||||
|
||||
let hit_windows = self.hit_windows();
|
||||
let BeatmapHitWindows { ar, od } = hit_windows;
|
||||
let HitWindows { ar, od } = hit_windows;
|
||||
|
||||
// AR
|
||||
let ar = if ar > 1200.0 {
|
||||
@@ -234,22 +225,17 @@ impl BeatmapAttributesBuilder {
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
Self::new_internal(map, false)
|
||||
}
|
||||
}
|
||||
|
||||
impl<M> From<&Converted<'_, M>> for BeatmapAttributesBuilder {
|
||||
fn from(converted: &Converted<'_, M>) -> Self {
|
||||
Self::new_internal(converted.map.as_ref(), converted.is_convert)
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::similar_names)]
|
||||
fn difficulty_range(difficulty: f64, min: f64, mid: f64, max: f64) -> f64 {
|
||||
if difficulty > 5.0 {
|
||||
mid + (max - mid) * (difficulty - 5.0) / 5.0
|
||||
@@ -0,0 +1,66 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::model::{control_point::TimingPoint, hit_object::HitObject};
|
||||
|
||||
pub fn bpm(last_hit_object: Option<&HitObject>, timing_points: &[TimingPoint]) -> f64 {
|
||||
// This is incorrect if the last object is a slider since there
|
||||
// is no reasonable way to get the slider end time at this point.
|
||||
let last_time = last_hit_object
|
||||
.map(HitObject::end_time)
|
||||
.or_else(|| timing_points.last().map(|t| t.time))
|
||||
.unwrap_or(0.0);
|
||||
|
||||
let mut bpm_points = BeatLenDuration::new(last_time);
|
||||
|
||||
// * osu-stable forced the first control point to start at 0.
|
||||
// * This is reproduced here to maintain compatibility around
|
||||
// * osu!mania scroll speed and song select display.
|
||||
match timing_points {
|
||||
[curr] => bpm_points.add(curr.beat_len, 0.0, last_time),
|
||||
[curr, next, ..] => bpm_points.add(curr.beat_len, 0.0, next.time),
|
||||
[] => {}
|
||||
}
|
||||
|
||||
timing_points
|
||||
.iter()
|
||||
.skip(1)
|
||||
.zip(timing_points.iter().skip(2).map(|t| t.time))
|
||||
.for_each(|(curr, next_time)| bpm_points.add(curr.beat_len, curr.time, next_time));
|
||||
|
||||
if let [.., _, curr] = timing_points {
|
||||
bpm_points.add(curr.beat_len, curr.time, last_time);
|
||||
}
|
||||
|
||||
let most_common_beat_len = bpm_points
|
||||
.map
|
||||
.into_iter()
|
||||
// * Get the most common one, or 0 as a suitable default
|
||||
.max_by(|(_, a), (_, b)| a.total_cmp(b))
|
||||
.map_or(0.0, |(beat_len, _)| f64::from_bits(beat_len));
|
||||
|
||||
60_000.0 / most_common_beat_len
|
||||
}
|
||||
|
||||
/// Maps `beat_len` to a cumulative duration
|
||||
struct BeatLenDuration {
|
||||
last_time: f64,
|
||||
map: HashMap<u64, f64>,
|
||||
}
|
||||
|
||||
impl BeatLenDuration {
|
||||
fn new(last_time: f64) -> Self {
|
||||
Self {
|
||||
last_time,
|
||||
map: HashMap::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn add(&mut self, beat_len: f64, curr_time: f64, next_time: f64) {
|
||||
let beat_len = (1000.0 * beat_len).round() / 1000.0;
|
||||
let entry = self.map.entry(beat_len.to_bits()).or_default();
|
||||
|
||||
if curr_time <= self.last_time {
|
||||
*entry += next_time - curr_time;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
use std::{
|
||||
any,
|
||||
borrow::Cow,
|
||||
fmt::{Debug, Formatter, Result as FmtResult},
|
||||
marker::PhantomData,
|
||||
};
|
||||
|
||||
use crate::model::mode::{ConvertStatus, IGameMode};
|
||||
|
||||
use super::{attributes::BeatmapAttributesBuilder, Beatmap};
|
||||
|
||||
const INCOMPATIBLE_MODES: &str = "the gamemodes were incompatible";
|
||||
|
||||
/// A [`Beatmap`] that is attached to a mode.
|
||||
///
|
||||
/// # Incompatibility
|
||||
///
|
||||
/// The following conversions are compatible:
|
||||
/// - `Osu` → `Osu`
|
||||
/// - `Taiko` → `Taiko`
|
||||
/// - `Catch` → `Catch`
|
||||
/// - `Mania` → `Mania`
|
||||
/// - `Osu` → `Taiko`
|
||||
/// - `Osu` → `Catch`
|
||||
/// - `Osu` → `Mania`
|
||||
///
|
||||
/// All other conversions are incompatible.
|
||||
pub struct Converted<'a, M> {
|
||||
pub(crate) map: Cow<'a, Beatmap>,
|
||||
pub(crate) is_convert: bool,
|
||||
mode: PhantomData<M>,
|
||||
}
|
||||
|
||||
impl<'a, M> Converted<'a, M> {
|
||||
/// Initialize a [`Converted`] beatmap by promising the given map's mode
|
||||
/// matches the generic type `M`.
|
||||
pub(crate) const fn new(map: Cow<'a, Beatmap>, is_convert: bool) -> Self {
|
||||
Self {
|
||||
map,
|
||||
is_convert,
|
||||
mode: PhantomData,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<M: IGameMode> Converted<'_, M> {
|
||||
/// Attempt to convert a [`Beatmap`] to the specified mode.
|
||||
///
|
||||
/// If the conversion is incompatible the [`Beatmap`] will be returned
|
||||
/// unchanged as `Err`.
|
||||
#[allow(clippy::result_large_err)]
|
||||
pub fn try_from_owned(map: Beatmap) -> Result<Self, Beatmap> {
|
||||
let mut map = Cow::Owned(map);
|
||||
|
||||
match M::try_convert(&mut map) {
|
||||
ConvertStatus::Noop => Ok(Self::new(map, false)),
|
||||
ConvertStatus::Done => Ok(Self::new(map, true)),
|
||||
ConvertStatus::Incompatible => {
|
||||
let Cow::Owned(map) = map else { unreachable!() };
|
||||
|
||||
Err(map)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a [`Beatmap`] to the specified mode.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if the conversion is incompatible.
|
||||
pub fn unchecked_from_owned(map: Beatmap) -> Self {
|
||||
Self::try_from_owned(map).unwrap_or_else(|_| panic!("{}", INCOMPATIBLE_MODES))
|
||||
}
|
||||
|
||||
/// Sum up the duration of all breaks (in milliseconds).
|
||||
pub fn total_break_time(&self) -> f64 {
|
||||
self.map.total_break_time()
|
||||
}
|
||||
|
||||
/// Returns a [`BeatmapAttributesBuilder`] to calculate modified beatmap
|
||||
/// attributes.
|
||||
pub fn attributes(&self) -> BeatmapAttributesBuilder {
|
||||
self.into()
|
||||
}
|
||||
|
||||
/// The beats per minute of the map.
|
||||
pub fn bpm(&self) -> f64 {
|
||||
self.map.bpm()
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a, M: IGameMode> Converted<'a, M> {
|
||||
/// Borrow the contained [`Beatmap`] to cheaply create a new owned
|
||||
/// [`Converted`].
|
||||
///
|
||||
/// This is the same as `.clone()` except cheap but its lifetime might be
|
||||
/// shorter.
|
||||
#[must_use]
|
||||
pub fn as_owned(&'a self) -> Self {
|
||||
Self::new(Cow::Borrowed(self.map.as_ref()), self.is_convert)
|
||||
}
|
||||
|
||||
/// Attempt to convert a [`&Beatmap`] to the specified mode.
|
||||
///
|
||||
/// If the conversion is incompatible, `None` is returned.
|
||||
///
|
||||
/// [`&Beatmap`]: Beatmap
|
||||
pub fn try_from_ref(map: &'a Beatmap) -> Option<Self> {
|
||||
let mut map = Cow::Borrowed(map);
|
||||
|
||||
match M::try_convert(&mut map) {
|
||||
ConvertStatus::Noop => Some(Self::new(map, false)),
|
||||
ConvertStatus::Done => Some(Self::new(map, true)),
|
||||
ConvertStatus::Incompatible => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a [`&Beatmap`] to the specified mode.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if the conversion is incompatible.
|
||||
///
|
||||
/// [`&Beatmap`]: Beatmap
|
||||
pub fn unchecked_from_ref(map: &'a Beatmap) -> Self {
|
||||
Self::try_from_ref(map).expect(INCOMPATIBLE_MODES)
|
||||
}
|
||||
|
||||
/// Attempt to convert a [`Converted`] from mode `M` to mode `N`.
|
||||
///
|
||||
/// If the conversion is incompatible the [`Converted`] will be returned
|
||||
/// unchanged as `Err`.
|
||||
#[allow(clippy::result_large_err)]
|
||||
pub fn try_convert<N: IGameMode>(self) -> Result<Converted<'a, N>, Self> {
|
||||
match self.map {
|
||||
Cow::Borrowed(map) => Converted::<N>::try_from_ref(map).ok_or(self),
|
||||
Cow::Owned(map) => Converted::<N>::try_from_owned(map)
|
||||
.map_err(|map| Self::new(Cow::Owned(map), self.is_convert)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<M> Clone for Converted<'_, M> {
|
||||
fn clone(&self) -> Self {
|
||||
Self::new(self.map.clone(), self.is_convert)
|
||||
}
|
||||
}
|
||||
|
||||
impl<M> Debug for Converted<'_, M> {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
|
||||
struct GenericFormatter<T>(PhantomData<T>);
|
||||
|
||||
impl<T> Default for GenericFormatter<T> {
|
||||
fn default() -> Self {
|
||||
Self(PhantomData)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Debug for GenericFormatter<T> {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
|
||||
let full_type_name = any::type_name::<T>();
|
||||
|
||||
// Strip fully qualified syntax
|
||||
if let Some(position) = full_type_name.rfind("::") {
|
||||
if let Some(type_name) = full_type_name.get(position + 2..) {
|
||||
f.write_str(type_name)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
f.debug_struct("Converted")
|
||||
.field("map", &self.map)
|
||||
.field("is_convert", &self.is_convert)
|
||||
.field("mode", &GenericFormatter::<M>::default())
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl<M> PartialEq for Converted<'_, M> {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.map == other.map && self.is_convert == other.is_convert
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,706 @@
|
||||
use std::{cmp, slice};
|
||||
|
||||
use rosu_map::{
|
||||
section::{
|
||||
difficulty::{Difficulty, DifficultyState, ParseDifficultyError},
|
||||
events::{BreakPeriod, EventType, ParseEventTypeError},
|
||||
general::{GameMode, GeneralKey, ParseGameModeError},
|
||||
hit_objects::{
|
||||
hit_samples::{HitSoundType, ParseHitSoundTypeError},
|
||||
HitObjectType, ParseHitObjectTypeError, PathControlPoint, PathType,
|
||||
},
|
||||
timing_points::{ControlPoint, EffectFlags, ParseEffectFlagsError},
|
||||
},
|
||||
util::{KeyValue, ParseNumber, ParseNumberError, Pos, StrExt, MAX_PARSE_VALUE},
|
||||
DecodeBeatmap, DecodeState,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
model::{
|
||||
control_point::{
|
||||
difficulty_point_at, effect_point_at, DifficultyPoint, EffectPoint, TimingPoint,
|
||||
},
|
||||
hit_object::{HitObject, HitObjectKind, HoldNote, Slider, Spinner},
|
||||
},
|
||||
util::{float_ext::FloatExt, legacy_sort::legacy_sort, tandem_sort::TandemSorter},
|
||||
};
|
||||
|
||||
use super::{Beatmap, DEFAULT_SLIDER_LENIENCY};
|
||||
|
||||
/// The state of a [`Beatmap`] for [`DecodeBeatmap`].
|
||||
pub struct BeatmapState {
|
||||
version: i32,
|
||||
stack_leniency: f32,
|
||||
mode: GameMode,
|
||||
difficulty: DifficultyState,
|
||||
breaks: Vec<BreakPeriod>,
|
||||
timing_points: Vec<TimingPoint>,
|
||||
difficulty_points: Vec<DifficultyPoint>,
|
||||
effect_points: Vec<EffectPoint>,
|
||||
hit_objects: Vec<HitObject>,
|
||||
hit_sounds: Vec<HitSoundType>,
|
||||
|
||||
pending_control_points_time: f64,
|
||||
pending_timing_point: Option<TimingPoint>,
|
||||
pending_difficulty_point: Option<DifficultyPoint>,
|
||||
pending_effect_point: Option<EffectPoint>,
|
||||
|
||||
curve_points: Vec<PathControlPoint>,
|
||||
vertices: Vec<PathControlPoint>,
|
||||
point_split: Vec<*const str>,
|
||||
}
|
||||
|
||||
impl BeatmapState {
|
||||
fn add_pending_point<P: Pending>(&mut self, time: f64, point: P, timing_change: bool) {
|
||||
if time.not_eq(self.pending_control_points_time) {
|
||||
self.flush_pending_points();
|
||||
}
|
||||
|
||||
if timing_change {
|
||||
point.push_front(self);
|
||||
} else {
|
||||
point.push_back(self);
|
||||
}
|
||||
|
||||
self.pending_control_points_time = time;
|
||||
}
|
||||
|
||||
fn flush_pending_points(&mut self) {
|
||||
if let Some(point) = self.pending_timing_point.take() {
|
||||
self.add_control_point(point);
|
||||
}
|
||||
|
||||
if let Some(point) = self.pending_difficulty_point.take() {
|
||||
self.add_control_point(point);
|
||||
}
|
||||
|
||||
if let Some(point) = self.pending_effect_point.take() {
|
||||
self.add_control_point(point);
|
||||
}
|
||||
}
|
||||
|
||||
fn add_control_point<P: ControlPoint<Self>>(&mut self, point: P) {
|
||||
if !point.check_already_existing(self) {
|
||||
point.add(self);
|
||||
}
|
||||
}
|
||||
|
||||
fn convert_path_str(&mut self, point_str: &str, offset: Pos) -> Result<(), ParseBeatmapError> {
|
||||
let f = |this: &mut Self, point_split: &[&str]| {
|
||||
let mut start_idx = 0;
|
||||
let mut end_idx = 0;
|
||||
let mut first = true;
|
||||
|
||||
while {
|
||||
end_idx += 1;
|
||||
|
||||
end_idx < point_split.len()
|
||||
} {
|
||||
let is_letter = point_split[end_idx]
|
||||
.chars()
|
||||
.next()
|
||||
.ok_or(ParseBeatmapError::InvalidHitObjectLine)?
|
||||
.is_ascii_alphabetic();
|
||||
|
||||
if !is_letter {
|
||||
continue;
|
||||
}
|
||||
|
||||
let end_point = point_split.get(end_idx + 1).copied();
|
||||
this.convert_points(&point_split[start_idx..end_idx], end_point, first, offset)?;
|
||||
|
||||
start_idx = end_idx;
|
||||
first = false;
|
||||
}
|
||||
|
||||
if end_idx > start_idx {
|
||||
this.convert_points(&point_split[start_idx..end_idx], None, first, offset)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
};
|
||||
|
||||
self.point_split(point_str.split('|'), f)
|
||||
}
|
||||
|
||||
fn convert_points(
|
||||
&mut self,
|
||||
points: &[&str],
|
||||
end_point: Option<&str>,
|
||||
first: bool,
|
||||
offset: Pos,
|
||||
) -> Result<(), ParseBeatmapError> {
|
||||
fn read_point(value: &str, start_pos: Pos) -> Result<PathControlPoint, ParseBeatmapError> {
|
||||
let mut v = value
|
||||
.split(':')
|
||||
.map(|s| s.parse_with_limits(f64::from(MAX_COORDINATE_VALUE)));
|
||||
|
||||
let (x, y) = v
|
||||
.next()
|
||||
.zip(v.next())
|
||||
.ok_or(ParseBeatmapError::InvalidHitObjectLine)?;
|
||||
|
||||
let pos = Pos::new(x? as i32 as f32, y? as i32 as f32);
|
||||
|
||||
Ok(PathControlPoint::new(pos - start_pos))
|
||||
}
|
||||
|
||||
fn is_linear(p0: Pos, p1: Pos, p2: Pos) -> bool {
|
||||
((p1.y - p0.y) * (p2.x - p0.x)).eq((p1.x - p0.x) * (p2.y - p0.y))
|
||||
}
|
||||
|
||||
let mut path_type = points
|
||||
.first()
|
||||
.copied()
|
||||
.map(PathType::new_from_str)
|
||||
.ok_or(ParseBeatmapError::InvalidHitObjectLine)?;
|
||||
|
||||
let read_offset = usize::from(first);
|
||||
let readable_points = points.len() - 1;
|
||||
let end_point_len = usize::from(end_point.is_some());
|
||||
|
||||
self.vertices.clear();
|
||||
self.vertices
|
||||
.reserve(read_offset + readable_points + end_point_len);
|
||||
|
||||
if first {
|
||||
self.vertices.push(PathControlPoint::default());
|
||||
}
|
||||
|
||||
for &point in points.iter().skip(1) {
|
||||
self.vertices.push(read_point(point, offset)?);
|
||||
}
|
||||
|
||||
if let Some(end_point) = end_point {
|
||||
self.vertices.push(read_point(end_point, offset)?);
|
||||
}
|
||||
|
||||
if path_type == PathType::PERFECT_CURVE {
|
||||
if let [a, b, c] = self.vertices.as_slice() {
|
||||
if is_linear(a.pos, b.pos, c.pos) {
|
||||
path_type = PathType::LINEAR;
|
||||
}
|
||||
} else {
|
||||
path_type = PathType::BEZIER;
|
||||
}
|
||||
}
|
||||
|
||||
self.vertices[0].path_type = Some(path_type);
|
||||
|
||||
let mut start_idx = 0;
|
||||
let mut end_idx = 0;
|
||||
|
||||
while {
|
||||
end_idx += 1;
|
||||
|
||||
end_idx < self.vertices.len() - end_point_len
|
||||
} {
|
||||
if self.vertices[end_idx].pos != self.vertices[end_idx - 1].pos {
|
||||
continue;
|
||||
}
|
||||
|
||||
if path_type == PathType::CATMULL && end_idx > 1 {
|
||||
continue;
|
||||
}
|
||||
|
||||
if end_idx == self.vertices.len() - end_point_len - 1 {
|
||||
continue;
|
||||
}
|
||||
|
||||
self.vertices[end_idx - 1].path_type = Some(path_type);
|
||||
|
||||
self.curve_points.extend(&self.vertices[start_idx..end_idx]);
|
||||
|
||||
start_idx = end_idx + 1;
|
||||
}
|
||||
|
||||
if end_idx > start_idx {
|
||||
self.curve_points.extend(&self.vertices[start_idx..end_idx]);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn point_split<'a, I, F, O>(&mut self, point_split: I, f: F) -> O
|
||||
where
|
||||
I: Iterator<Item = &'a str>,
|
||||
F: FnOnce(&mut Self, &[&'a str]) -> O,
|
||||
{
|
||||
self.point_split.extend(point_split.map(|s| s as *const _));
|
||||
let ptr = self.point_split.as_ptr();
|
||||
let len = self.point_split.len();
|
||||
|
||||
// SAFETY:
|
||||
// - *const str and &str have the same layout.
|
||||
// - `self.point_split` is cleared after every use, ensuring that it
|
||||
// does not contain any invalid pointers.
|
||||
let point_split = unsafe { slice::from_raw_parts(ptr.cast(), len) };
|
||||
let res = f(self, point_split);
|
||||
self.point_split.clear();
|
||||
|
||||
res
|
||||
}
|
||||
}
|
||||
|
||||
impl DecodeState for BeatmapState {
|
||||
fn create(version: i32) -> Self {
|
||||
Self {
|
||||
version,
|
||||
stack_leniency: DEFAULT_SLIDER_LENIENCY,
|
||||
mode: GameMode::Osu,
|
||||
difficulty: DifficultyState::create(version),
|
||||
breaks: Vec::new(),
|
||||
timing_points: Vec::with_capacity(1),
|
||||
difficulty_points: Vec::new(),
|
||||
effect_points: Vec::with_capacity(32),
|
||||
hit_objects: Vec::with_capacity(512),
|
||||
hit_sounds: Vec::with_capacity(512),
|
||||
pending_control_points_time: 0.0,
|
||||
pending_timing_point: None,
|
||||
pending_difficulty_point: None,
|
||||
pending_effect_point: None,
|
||||
curve_points: Vec::new(), // TODO: check for default size
|
||||
vertices: Vec::new(), // TODO: check for default size
|
||||
point_split: Vec::new(), // TODO: check for default size
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<BeatmapState> for Beatmap {
|
||||
fn from(mut state: BeatmapState) -> Self {
|
||||
state.flush_pending_points();
|
||||
|
||||
let Difficulty {
|
||||
hp_drain_rate,
|
||||
circle_size,
|
||||
overall_difficulty,
|
||||
approach_rate,
|
||||
slider_multiplier,
|
||||
slider_tick_rate,
|
||||
} = state.difficulty.into();
|
||||
|
||||
let mut sorter = TandemSorter::new(
|
||||
&state.hit_objects,
|
||||
|a, b| a.start_time.total_cmp(&b.start_time),
|
||||
true,
|
||||
);
|
||||
|
||||
sorter.sort(&mut state.hit_objects);
|
||||
sorter.sort(&mut state.hit_sounds);
|
||||
|
||||
if state.mode == GameMode::Mania {
|
||||
legacy_sort(&mut state.hit_objects);
|
||||
}
|
||||
|
||||
Beatmap {
|
||||
version: state.version,
|
||||
stack_leniency: state.stack_leniency,
|
||||
mode: state.mode,
|
||||
ar: approach_rate,
|
||||
cs: circle_size,
|
||||
hp: hp_drain_rate,
|
||||
od: overall_difficulty,
|
||||
slider_multiplier,
|
||||
slider_tick_rate,
|
||||
breaks: state.breaks,
|
||||
timing_points: state.timing_points,
|
||||
difficulty_points: state.difficulty_points,
|
||||
effect_points: state.effect_points,
|
||||
hit_objects: state.hit_objects,
|
||||
hit_sounds: state.hit_sounds,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// All the ways that parsing a [`Beatmap`] can fail.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ParseBeatmapError {
|
||||
#[error("failed to parse effect flags")]
|
||||
EffectFlags(#[from] ParseEffectFlagsError),
|
||||
#[error("failed to parse event type")]
|
||||
EventType(#[from] ParseEventTypeError),
|
||||
#[error("failed to parse hit object type")]
|
||||
HitObjectType(#[from] ParseHitObjectTypeError),
|
||||
#[error("failed to parse hit sound type")]
|
||||
HitSoundType(#[from] ParseHitSoundTypeError),
|
||||
#[error("invalid event line")]
|
||||
InvalidEventLine,
|
||||
#[error("repeat count is way too high")]
|
||||
InvalidRepeatCount,
|
||||
#[error("invalid timing point line")]
|
||||
InvalidTimingPointLine,
|
||||
#[error("invalid hit object line")]
|
||||
InvalidHitObjectLine,
|
||||
#[error("failed to parse mode")]
|
||||
Mode(#[from] ParseGameModeError),
|
||||
#[error("failed to parse number")]
|
||||
Number(#[from] ParseNumberError),
|
||||
#[error("beat length cannot be NaN in a timing control point")]
|
||||
TimingControlPointNaN,
|
||||
#[error("unknown hit object type")]
|
||||
UnknownHitObjectType,
|
||||
}
|
||||
|
||||
impl From<ParseDifficultyError> for ParseBeatmapError {
|
||||
fn from(e: ParseDifficultyError) -> Self {
|
||||
match e {
|
||||
ParseDifficultyError::Number(e) => Self::Number(e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const MAX_COORDINATE_VALUE: i32 = 131_072;
|
||||
|
||||
impl DecodeBeatmap for Beatmap {
|
||||
type Error = ParseBeatmapError;
|
||||
type State = BeatmapState;
|
||||
|
||||
fn parse_general(state: &mut Self::State, line: &str) -> Result<(), Self::Error> {
|
||||
let Ok(KeyValue { key, value }) = KeyValue::parse(line.trim_comment()) else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
match key {
|
||||
GeneralKey::StackLeniency => state.stack_leniency = value.parse_num()?,
|
||||
GeneralKey::Mode => state.mode = value.parse()?,
|
||||
_ => {}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_editor(_: &mut Self::State, _: &str) -> Result<(), Self::Error> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_metadata(_: &mut Self::State, _: &str) -> Result<(), Self::Error> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_difficulty(state: &mut Self::State, line: &str) -> Result<(), Self::Error> {
|
||||
Difficulty::parse_difficulty(&mut state.difficulty, line).map_err(ParseBeatmapError::from)
|
||||
}
|
||||
|
||||
fn parse_events(state: &mut Self::State, line: &str) -> Result<(), Self::Error> {
|
||||
let mut split = line.trim_comment().split(',');
|
||||
|
||||
let event_type: EventType = split
|
||||
.next()
|
||||
.ok_or(ParseBeatmapError::InvalidEventLine)?
|
||||
.parse()?;
|
||||
|
||||
if event_type == EventType::Break {
|
||||
let Some((start_time, end_time)) = split.next().zip(split.next()) else {
|
||||
return Err(ParseBeatmapError::InvalidEventLine);
|
||||
};
|
||||
|
||||
let start_time = f64::parse(start_time)?;
|
||||
let end_time = start_time.max(f64::parse(end_time)?);
|
||||
|
||||
state.breaks.push(BreakPeriod {
|
||||
start_time,
|
||||
end_time,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_timing_points(state: &mut Self::State, line: &str) -> Result<(), Self::Error> {
|
||||
let mut split = line.trim_comment().split(',');
|
||||
|
||||
let (time, beat_len) = split
|
||||
.next()
|
||||
.zip(split.next())
|
||||
.ok_or(ParseBeatmapError::InvalidTimingPointLine)?;
|
||||
|
||||
let time = time.parse_num::<f64>()?;
|
||||
|
||||
// Manual `str::parse_num::<f64>` so that NaN does not cause an error
|
||||
let beat_len = beat_len
|
||||
.trim()
|
||||
.parse::<f64>()
|
||||
.map_err(ParseNumberError::InvalidFloat)?;
|
||||
|
||||
if beat_len < f64::from(-MAX_PARSE_VALUE) {
|
||||
return Err(ParseNumberError::NumberUnderflow.into());
|
||||
} else if beat_len > f64::from(MAX_PARSE_VALUE) {
|
||||
return Err(ParseNumberError::NumberOverflow.into());
|
||||
}
|
||||
|
||||
let speed_multiplier = if beat_len < 0.0 {
|
||||
100.0 / -beat_len
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
|
||||
let _ = split.next(); // timing signature
|
||||
let _ = split.next(); // sample set
|
||||
let _ = split.next(); // custom sample bank
|
||||
let _ = split.next(); // sample volume
|
||||
|
||||
let timing_change = split
|
||||
.next()
|
||||
.map_or(true, |next| matches!(next.chars().next(), Some('1')));
|
||||
|
||||
let kiai = split
|
||||
.next()
|
||||
.map(str::parse::<EffectFlags>)
|
||||
.transpose()?
|
||||
.is_some_and(|flags| flags.has_flag(EffectFlags::KIAI));
|
||||
|
||||
if timing_change {
|
||||
if beat_len.is_nan() {
|
||||
return Err(ParseBeatmapError::TimingControlPointNaN);
|
||||
}
|
||||
|
||||
let timing = TimingPoint::new(time, beat_len);
|
||||
state.add_pending_point(time, timing, timing_change);
|
||||
}
|
||||
|
||||
let difficulty = DifficultyPoint::new(time, beat_len, speed_multiplier);
|
||||
state.add_pending_point(time, difficulty, timing_change);
|
||||
|
||||
let effect = EffectPoint::new(time, kiai);
|
||||
state.add_pending_point(time, effect, timing_change);
|
||||
|
||||
state.pending_control_points_time = time;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_colors(_: &mut Self::State, _: &str) -> Result<(), Self::Error> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_hit_objects(state: &mut Self::State, line: &str) -> Result<(), Self::Error> {
|
||||
let mut split = line.trim_comment().split(',');
|
||||
|
||||
let (Some(x), Some(y), Some(start_time), Some(kind), Some(sound_type)) = (
|
||||
split.next(),
|
||||
split.next(),
|
||||
split.next(),
|
||||
split.next(),
|
||||
split.next(),
|
||||
) else {
|
||||
return Err(ParseBeatmapError::InvalidHitObjectLine);
|
||||
};
|
||||
|
||||
let pos = Pos {
|
||||
x: x.parse_with_limits(MAX_COORDINATE_VALUE as f32)? as i32 as f32,
|
||||
y: y.parse_with_limits(MAX_COORDINATE_VALUE as f32)? as i32 as f32,
|
||||
};
|
||||
|
||||
let start_time = f64::parse(start_time)?;
|
||||
let hit_object_type: HitObjectType = kind.parse()?;
|
||||
|
||||
let mut sound: HitSoundType = sound_type.parse()?;
|
||||
|
||||
let mut parse_custom_sound = |bank_info: Option<&str>| {
|
||||
let mut split = match bank_info {
|
||||
Some(s) if !s.is_empty() => s.split(':'),
|
||||
_ => return Ok::<_, ParseNumberError>(()),
|
||||
};
|
||||
|
||||
let _ = split.next().map(i32::parse).transpose()?; // normal bank
|
||||
let _ = split.next().map(i32::parse).transpose()?; // additional bank
|
||||
let _ = split.next().map(i32::parse).transpose()?; // custom sample bank
|
||||
let _ = split.next().map(i32::parse).transpose()?; // volume
|
||||
|
||||
// filename
|
||||
match split.next() {
|
||||
None | Some("") => {}
|
||||
Some(_) => sound = HitSoundType::default(),
|
||||
}
|
||||
|
||||
Ok(())
|
||||
};
|
||||
|
||||
let kind = if hit_object_type.has_flag(HitObjectType::CIRCLE) {
|
||||
parse_custom_sound(split.next())?;
|
||||
|
||||
HitObjectKind::Circle
|
||||
} else if hit_object_type.has_flag(HitObjectType::SLIDER) {
|
||||
let (point_str, repeat_count) = split
|
||||
.next()
|
||||
.zip(split.next())
|
||||
.ok_or(ParseBeatmapError::InvalidHitObjectLine)?;
|
||||
|
||||
let mut len = None;
|
||||
|
||||
let mut repeats = repeat_count.parse_num::<i32>()?;
|
||||
|
||||
if repeats > 9000 {
|
||||
return Err(ParseBeatmapError::InvalidRepeatCount);
|
||||
}
|
||||
|
||||
repeats = cmp::max(0, repeats - 1);
|
||||
|
||||
if let Some(next) = split.next() {
|
||||
let new_len = next
|
||||
.parse_with_limits(f64::from(MAX_COORDINATE_VALUE))?
|
||||
.max(0.0);
|
||||
|
||||
if new_len.not_eq(0.0) {
|
||||
len = Some(new_len);
|
||||
}
|
||||
}
|
||||
|
||||
let node_sounds = if let Some(sounds) = split.next().map(|sounds| sounds.split('|')) {
|
||||
sounds.map(|s| s.parse().unwrap_or_default()).collect()
|
||||
} else {
|
||||
Box::default()
|
||||
};
|
||||
|
||||
let _ = split.next(); // node banks
|
||||
parse_custom_sound(split.next())?;
|
||||
|
||||
state.convert_path_str(point_str, pos)?;
|
||||
let mut control_points = Vec::with_capacity(state.curve_points.len());
|
||||
control_points.append(&mut state.curve_points);
|
||||
|
||||
let slider = Slider {
|
||||
expected_dist: len,
|
||||
repeats: repeats as usize,
|
||||
control_points: control_points.into_boxed_slice(),
|
||||
node_sounds,
|
||||
};
|
||||
|
||||
HitObjectKind::Slider(slider)
|
||||
} else if hit_object_type.has_flag(HitObjectType::SPINNER) {
|
||||
let end_time = split
|
||||
.next()
|
||||
.ok_or(ParseBeatmapError::InvalidHitObjectLine)?
|
||||
.parse_num::<f64>()?;
|
||||
|
||||
parse_custom_sound(split.next())?;
|
||||
|
||||
HitObjectKind::Spinner(Spinner { end_time })
|
||||
} else if hit_object_type.has_flag(HitObjectType::HOLD) {
|
||||
let end_time = if let Some(s) = split.next().filter(|s| !s.is_empty()) {
|
||||
let (end_time, bank_info) = s
|
||||
.split_once(':')
|
||||
.ok_or(ParseBeatmapError::InvalidHitObjectLine)?;
|
||||
|
||||
parse_custom_sound(Some(bank_info))?;
|
||||
|
||||
end_time.parse_num::<f64>()?.max(start_time)
|
||||
} else {
|
||||
start_time
|
||||
};
|
||||
|
||||
HitObjectKind::Hold(HoldNote { end_time })
|
||||
} else {
|
||||
return Err(ParseBeatmapError::UnknownHitObjectType);
|
||||
};
|
||||
|
||||
state.hit_objects.push(HitObject {
|
||||
pos,
|
||||
start_time,
|
||||
kind,
|
||||
});
|
||||
state.hit_sounds.push(sound);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_variables(_: &mut Self::State, _: &str) -> Result<(), Self::Error> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_catch_the_beat(_: &mut Self::State, _: &str) -> Result<(), Self::Error> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_mania(_: &mut Self::State, _: &str) -> Result<(), Self::Error> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
trait Pending: Sized {
|
||||
fn pending(state: &mut BeatmapState) -> &mut Option<Self>;
|
||||
|
||||
fn push_front(self, state: &mut BeatmapState) {
|
||||
let pending = Self::pending(state);
|
||||
|
||||
if pending.is_none() {
|
||||
*pending = Some(self);
|
||||
}
|
||||
}
|
||||
|
||||
fn push_back(self, state: &mut BeatmapState) {
|
||||
*Self::pending(state) = Some(self);
|
||||
}
|
||||
}
|
||||
|
||||
impl Pending for TimingPoint {
|
||||
fn pending(state: &mut BeatmapState) -> &mut Option<Self> {
|
||||
&mut state.pending_timing_point
|
||||
}
|
||||
}
|
||||
|
||||
impl Pending for DifficultyPoint {
|
||||
fn pending(state: &mut BeatmapState) -> &mut Option<Self> {
|
||||
&mut state.pending_difficulty_point
|
||||
}
|
||||
}
|
||||
|
||||
impl Pending for EffectPoint {
|
||||
fn pending(state: &mut BeatmapState) -> &mut Option<Self> {
|
||||
&mut state.pending_effect_point
|
||||
}
|
||||
}
|
||||
|
||||
impl ControlPoint<BeatmapState> for TimingPoint {
|
||||
fn check_already_existing(&self, _: &BeatmapState) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn add(self, state: &mut BeatmapState) {
|
||||
match state
|
||||
.timing_points
|
||||
.binary_search_by(|probe| probe.time.total_cmp(&self.time))
|
||||
{
|
||||
Err(i) => state.timing_points.insert(i, self),
|
||||
Ok(i) => state.timing_points[i] = self,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ControlPoint<BeatmapState> for DifficultyPoint {
|
||||
fn check_already_existing(&self, state: &BeatmapState) -> bool {
|
||||
match difficulty_point_at(&state.difficulty_points, self.time) {
|
||||
Some(existing) => self.is_redundant(existing),
|
||||
None => self.is_redundant(&DifficultyPoint::default()),
|
||||
}
|
||||
}
|
||||
|
||||
fn add(self, state: &mut BeatmapState) {
|
||||
match state
|
||||
.difficulty_points
|
||||
.binary_search_by(|probe| probe.time.total_cmp(&self.time))
|
||||
{
|
||||
Err(i) => state.difficulty_points.insert(i, self),
|
||||
Ok(i) => state.difficulty_points[i] = self,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ControlPoint<BeatmapState> for EffectPoint {
|
||||
fn check_already_existing(&self, state: &BeatmapState) -> bool {
|
||||
match effect_point_at(&state.effect_points, self.time) {
|
||||
Some(existing) => self.is_redundant(existing),
|
||||
None => self.is_redundant(&EffectPoint::default()),
|
||||
}
|
||||
}
|
||||
|
||||
fn add(self, state: &mut BeatmapState) {
|
||||
match state
|
||||
.effect_points
|
||||
.binary_search_by(|probe| probe.time.total_cmp(&self.time))
|
||||
{
|
||||
Err(i) => state.effect_points.insert(i, self),
|
||||
Ok(i) => state.effect_points[i] = self,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
use std::{io, path::Path, str::FromStr};
|
||||
|
||||
use rosu_map::{
|
||||
section::{events::BreakPeriod, general::GameMode, hit_objects::hit_samples::HitSoundType},
|
||||
LATEST_FORMAT_VERSION,
|
||||
};
|
||||
|
||||
pub use self::{
|
||||
attributes::{BeatmapAttributes, BeatmapAttributesBuilder, HitWindows},
|
||||
converted::Converted,
|
||||
decode::{BeatmapState, ParseBeatmapError},
|
||||
};
|
||||
|
||||
use super::{
|
||||
control_point::{
|
||||
difficulty_point_at, effect_point_at, timing_point_at, DifficultyPoint, EffectPoint,
|
||||
TimingPoint,
|
||||
},
|
||||
hit_object::HitObject,
|
||||
mode::IGameMode,
|
||||
};
|
||||
|
||||
mod attributes;
|
||||
mod bpm;
|
||||
mod converted;
|
||||
mod decode;
|
||||
|
||||
/// All beatmap data that is relevant for difficulty and performance
|
||||
/// calculation.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Beatmap {
|
||||
pub version: i32,
|
||||
|
||||
// General
|
||||
pub stack_leniency: f32,
|
||||
pub mode: GameMode,
|
||||
|
||||
// Difficulty
|
||||
pub ar: f32,
|
||||
pub cs: f32,
|
||||
pub hp: f32,
|
||||
pub od: f32,
|
||||
pub slider_multiplier: f64,
|
||||
pub slider_tick_rate: f64,
|
||||
|
||||
// Events
|
||||
pub breaks: Vec<BreakPeriod>,
|
||||
|
||||
// TimingPoints
|
||||
pub timing_points: Vec<TimingPoint>,
|
||||
pub difficulty_points: Vec<DifficultyPoint>,
|
||||
pub effect_points: Vec<EffectPoint>,
|
||||
|
||||
// HitObjects
|
||||
pub hit_objects: Vec<HitObject>,
|
||||
pub hit_sounds: Vec<HitSoundType>,
|
||||
}
|
||||
|
||||
impl Beatmap {
|
||||
/// Parse a [`Beatmap`] by providing a path to a `.osu` file.
|
||||
pub fn from_path<P: AsRef<Path>>(path: P) -> Result<Self, io::Error> {
|
||||
rosu_map::from_path(path)
|
||||
}
|
||||
|
||||
/// Parse a [`Beatmap`] by providing the content of a `.osu` file as a
|
||||
/// slice of bytes.
|
||||
pub fn from_bytes(bytes: &[u8]) -> Result<Self, io::Error> {
|
||||
rosu_map::from_bytes(bytes)
|
||||
}
|
||||
|
||||
/// Finds the [`TimingPoint`] that is active at the given time.
|
||||
pub(crate) fn timing_point_at(&self, time: f64) -> Option<&TimingPoint> {
|
||||
timing_point_at(&self.timing_points, time)
|
||||
}
|
||||
|
||||
/// Finds the [`DifficultyPoint`] that is active at the given time.
|
||||
pub(crate) fn difficulty_point_at(&self, time: f64) -> Option<&DifficultyPoint> {
|
||||
difficulty_point_at(&self.difficulty_points, time)
|
||||
}
|
||||
|
||||
/// Finds the [`EffectPoint`] that is active at the given time.
|
||||
pub(crate) fn effect_point_at(&self, time: f64) -> Option<&EffectPoint> {
|
||||
effect_point_at(&self.effect_points, time)
|
||||
}
|
||||
|
||||
/// Sum up the duration of all breaks (in milliseconds).
|
||||
pub fn total_break_time(&self) -> f64 {
|
||||
self.breaks.iter().map(BreakPeriod::duration).sum()
|
||||
}
|
||||
|
||||
/// Attempt to convert a [`&Beatmap`] to the specified mode.
|
||||
///
|
||||
/// If the conversion is incompatible, `None` is returned.
|
||||
///
|
||||
/// [`&Beatmap`]: Beatmap
|
||||
pub fn try_as_converted<M: IGameMode>(&self) -> Option<Converted<'_, M>> {
|
||||
Converted::try_from_ref(self)
|
||||
}
|
||||
|
||||
/// Convert a [`&Beatmap`] to the specified mode.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if the conversion is incompatible.
|
||||
///
|
||||
/// [`&Beatmap`]: Beatmap
|
||||
pub fn unchecked_as_converted<M: IGameMode>(&self) -> Converted<'_, M> {
|
||||
Converted::unchecked_from_ref(self)
|
||||
}
|
||||
|
||||
/// Attempt to convert a [`Beatmap`] to the specified mode.
|
||||
///
|
||||
/// If the conversion is incompatible the [`Beatmap`] will be returned
|
||||
/// unchanged as `Err`.
|
||||
#[allow(clippy::result_large_err)]
|
||||
pub fn try_into_converted<'a, M: IGameMode>(self) -> Result<Converted<'a, M>, Self> {
|
||||
Converted::try_from_owned(self)
|
||||
}
|
||||
|
||||
/// Convert a [`Beatmap`] to the specified mode.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if the conversion is incompatible.
|
||||
pub fn unchecked_into_converted<'a, M: IGameMode>(self) -> Converted<'a, M> {
|
||||
Converted::unchecked_from_owned(self)
|
||||
}
|
||||
|
||||
/// Returns a [`BeatmapAttributesBuilder`] to calculate modified beatmap
|
||||
/// attributes.
|
||||
pub fn attributes(&self) -> BeatmapAttributesBuilder {
|
||||
self.into()
|
||||
}
|
||||
|
||||
/// The beats per minute of the map.
|
||||
pub fn bpm(&self) -> f64 {
|
||||
bpm::bpm(self.hit_objects.last(), &self.timing_points)
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for Beatmap {
|
||||
type Err = io::Error;
|
||||
|
||||
/// Parse a [`Beatmap`] by providing the content of a `.osu` file as a
|
||||
/// string.
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
rosu_map::from_str(s)
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_SLIDER_LENIENCY: f32 = 0.7;
|
||||
|
||||
impl Default for Beatmap {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
version: LATEST_FORMAT_VERSION,
|
||||
stack_leniency: DEFAULT_SLIDER_LENIENCY,
|
||||
mode: GameMode::default(),
|
||||
ar: 5.0,
|
||||
cs: 5.0,
|
||||
hp: 5.0,
|
||||
od: 5.0,
|
||||
slider_multiplier: 1.4,
|
||||
slider_tick_rate: 1.0,
|
||||
breaks: Vec::default(),
|
||||
timing_points: Vec::default(),
|
||||
difficulty_points: Vec::default(),
|
||||
effect_points: Vec::default(),
|
||||
hit_objects: Vec::default(),
|
||||
hit_sounds: Vec::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
macro_rules! define_beat_len_fn {
|
||||
( $fn_name:ident, $clamp:literal) => {
|
||||
#[allow(unused)]
|
||||
pub(crate) fn $fn_name(map: &Beatmap, beat_len: f64, time: f64) -> f64 {
|
||||
let slider_velocity = map
|
||||
.difficulty_point_at(time)
|
||||
.map_or(DifficultyPoint::DEFAULT_SLIDER_VELOCITY, |point| {
|
||||
point.slider_velocity
|
||||
});
|
||||
|
||||
let slider_velocity_as_beat_len = -100.0 / slider_velocity;
|
||||
|
||||
let bpm_mult = if slider_velocity_as_beat_len < 0.0 {
|
||||
(((-slider_velocity_as_beat_len) as f32).clamp(10.0, $clamp) / 100.0) as f64
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
|
||||
beat_len * bpm_mult
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
define_beat_len_fn!(get_precision_adjusted_beat_len_taiko_mania, 10_000.0);
|
||||
define_beat_len_fn!(get_precision_adjusted_beat_len_osu_catch, 1000.0);
|
||||
@@ -0,0 +1,8 @@
|
||||
pub use rosu_map::section::timing_points::DifficultyPoint;
|
||||
|
||||
pub fn difficulty_point_at(points: &[DifficultyPoint], time: f64) -> Option<&DifficultyPoint> {
|
||||
points
|
||||
.binary_search_by(|probe| probe.time.total_cmp(&time))
|
||||
.map_or_else(|i| i.checked_sub(1), Some)
|
||||
.map(|i| &points[i])
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/// Effect-related info about this control point.
|
||||
#[derive(Copy, Clone, Debug, PartialEq)]
|
||||
pub struct EffectPoint {
|
||||
pub time: f64,
|
||||
pub kiai: bool,
|
||||
}
|
||||
|
||||
impl EffectPoint {
|
||||
pub const DEFAULT_KIAI: bool = rosu_map::section::timing_points::EffectPoint::DEFAULT_KIAI;
|
||||
|
||||
pub const fn new(time: f64, kiai: bool) -> Self {
|
||||
Self { time, kiai }
|
||||
}
|
||||
|
||||
pub const fn is_redundant(&self, existing: &Self) -> bool {
|
||||
self.kiai == existing.kiai
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for EffectPoint {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
time: 0.0,
|
||||
kiai: Self::DEFAULT_KIAI,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn effect_point_at(points: &[EffectPoint], time: f64) -> Option<&EffectPoint> {
|
||||
points
|
||||
.binary_search_by(|probe| probe.time.total_cmp(&time))
|
||||
.map_or_else(|i| i.checked_sub(1), Some)
|
||||
.map(|i| &points[i])
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
pub use self::{difficulty::DifficultyPoint, effect::EffectPoint, timing::TimingPoint};
|
||||
|
||||
pub(crate) use self::{
|
||||
difficulty::difficulty_point_at, effect::effect_point_at, timing::timing_point_at,
|
||||
};
|
||||
|
||||
mod difficulty;
|
||||
mod effect;
|
||||
mod timing;
|
||||
@@ -0,0 +1,35 @@
|
||||
/// Timing-related info about this control point.
|
||||
#[derive(Copy, Clone, Debug, PartialEq)]
|
||||
pub struct TimingPoint {
|
||||
pub time: f64,
|
||||
pub beat_len: f64,
|
||||
}
|
||||
|
||||
impl TimingPoint {
|
||||
pub const DEFAULT_BEAT_LEN: f64 =
|
||||
rosu_map::section::timing_points::TimingPoint::DEFAULT_BEAT_LEN;
|
||||
|
||||
pub fn new(time: f64, beat_len: f64) -> Self {
|
||||
Self {
|
||||
time,
|
||||
beat_len: beat_len.clamp(6.0, 60_000.0),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for TimingPoint {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
time: 0.0,
|
||||
beat_len: Self::DEFAULT_BEAT_LEN,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn timing_point_at(points: &[TimingPoint], time: f64) -> Option<&TimingPoint> {
|
||||
let i = points
|
||||
.binary_search_by(|probe| probe.time.total_cmp(&time))
|
||||
.unwrap_or_else(|i| i.saturating_sub(1));
|
||||
|
||||
points.get(i)
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
use std::cmp::Ordering;
|
||||
|
||||
use rosu_map::section::hit_objects::{BorrowedCurve, CurveBuffers};
|
||||
|
||||
pub use rosu_map::{
|
||||
section::hit_objects::{hit_samples::HitSoundType, PathControlPoint},
|
||||
util::Pos,
|
||||
};
|
||||
|
||||
/// All hitobject related data required for difficulty and performance
|
||||
/// calculation except for the [`HitSoundType`].
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct HitObject {
|
||||
pub pos: Pos,
|
||||
pub start_time: f64,
|
||||
pub kind: HitObjectKind,
|
||||
}
|
||||
|
||||
impl HitObject {
|
||||
/// Whether the hitobject is a circle.
|
||||
pub const fn is_circle(&self) -> bool {
|
||||
matches!(&self.kind, HitObjectKind::Circle)
|
||||
}
|
||||
|
||||
/// Whether the hitobject is a slider.
|
||||
pub const fn is_slider(&self) -> bool {
|
||||
matches!(&self.kind, HitObjectKind::Slider(_))
|
||||
}
|
||||
|
||||
/// Whether the hitobject is a spinner.
|
||||
pub const fn is_spinner(&self) -> bool {
|
||||
matches!(&self.kind, HitObjectKind::Spinner(_))
|
||||
}
|
||||
|
||||
/// The end time of the object.
|
||||
///
|
||||
/// Note that this will not return the correct value for sliders.
|
||||
pub(crate) const fn end_time(&self) -> f64 {
|
||||
match &self.kind {
|
||||
HitObjectKind::Circle | HitObjectKind::Slider { .. } => self.start_time,
|
||||
HitObjectKind::Spinner(Spinner { end_time })
|
||||
| HitObjectKind::Hold(HoldNote { end_time }) => *end_time,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialOrd for HitObject {
|
||||
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
|
||||
self.start_time.partial_cmp(&other.start_time)
|
||||
}
|
||||
}
|
||||
|
||||
/// Additional data for a [`HitObject`].
|
||||
///
|
||||
/// Note that each mode handles hit objects differently.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum HitObjectKind {
|
||||
Circle,
|
||||
Slider(Slider),
|
||||
Spinner(Spinner),
|
||||
Hold(HoldNote),
|
||||
}
|
||||
|
||||
/// A slider.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Slider {
|
||||
pub expected_dist: Option<f64>,
|
||||
pub repeats: usize,
|
||||
pub control_points: Box<[PathControlPoint]>,
|
||||
pub node_sounds: Box<[HitSoundType]>,
|
||||
}
|
||||
|
||||
impl Slider {
|
||||
/// The amount of spans of the slider.
|
||||
pub const fn span_count(&self) -> usize {
|
||||
self.repeats + 1
|
||||
}
|
||||
|
||||
pub(crate) fn curve<'a>(&self, bufs: &'a mut CurveBuffers) -> BorrowedCurve<'a> {
|
||||
BorrowedCurve::new(&self.control_points, self.expected_dist, bufs)
|
||||
}
|
||||
}
|
||||
|
||||
/// A spinner.
|
||||
#[derive(Copy, Clone, Debug, PartialEq)]
|
||||
pub struct Spinner {
|
||||
pub end_time: f64,
|
||||
}
|
||||
|
||||
/// A hold note.
|
||||
#[derive(Copy, Clone, Debug, PartialEq)]
|
||||
pub struct HoldNote {
|
||||
pub end_time: f64,
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/// Beatmap related types.
|
||||
pub mod beatmap;
|
||||
|
||||
/// Control point related types.
|
||||
pub mod control_point;
|
||||
|
||||
/// Hitobject related types.
|
||||
pub mod hit_object;
|
||||
|
||||
/// Gamemode related types.
|
||||
pub mod mode;
|
||||
@@ -0,0 +1,52 @@
|
||||
use std::borrow::Cow;
|
||||
|
||||
pub use rosu_map::section::general::GameMode;
|
||||
|
||||
use crate::any::ModeDifficulty;
|
||||
|
||||
use super::beatmap::{Beatmap, Converted};
|
||||
|
||||
/// A way to specify a gamemode at compile-time.
|
||||
///
|
||||
/// Notably, this is implemented for the marker types [`Osu`], [`Taiko`],
|
||||
/// [`Catch`], and [`Mania`].
|
||||
///
|
||||
/// [`Osu`]: crate::osu::Osu
|
||||
/// [`Taiko`]: crate::taiko::Taiko
|
||||
/// [`Catch`]: crate::catch::Catch
|
||||
/// [`Mania`]: crate::mania::Mania
|
||||
pub trait IGameMode: Sized {
|
||||
/// The resulting type of a difficulty calculation.
|
||||
type DifficultyAttributes;
|
||||
|
||||
/// The resulting type of a strain calculation.
|
||||
type Strains;
|
||||
|
||||
/// Attempt to convert a beatmap.
|
||||
///
|
||||
/// In case [`ConvertStatus::Incompatible`] is returned, the map should
|
||||
/// **not** be modified.
|
||||
fn try_convert(map: &mut Cow<'_, Beatmap>) -> ConvertStatus;
|
||||
|
||||
/// Perform a difficulty calculation for a [`Converted`] beatmap and
|
||||
/// process the final skill values.
|
||||
fn difficulty(
|
||||
difficulty: &ModeDifficulty,
|
||||
map: &Converted<'_, Self>,
|
||||
) -> Self::DifficultyAttributes;
|
||||
|
||||
/// Perform a difficulty calculation for a [`Converted`] beatmap without
|
||||
/// processing the final skill values.
|
||||
fn strains(difficulty: &ModeDifficulty, map: &Converted<'_, Self>) -> Self::Strains;
|
||||
}
|
||||
|
||||
/// The status of a conversion through [`IGameMode::try_convert`].
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
||||
pub enum ConvertStatus {
|
||||
/// Conversion was not necessary.
|
||||
Noop,
|
||||
/// Conversion was successful.
|
||||
Done,
|
||||
/// Conversion was not possible.
|
||||
Incompatible,
|
||||
}
|
||||
-87
@@ -1,87 +0,0 @@
|
||||
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;
|
||||
|
||||
/// 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;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
use crate::osu::performance::OsuPerformance;
|
||||
|
||||
/// The result of a difficulty calculation on an osu!standard map.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct OsuDifficultyAttributes {
|
||||
/// The difficulty of the aim skill.
|
||||
pub aim: f64,
|
||||
/// The difficulty of the speed skill.
|
||||
pub speed: f64,
|
||||
/// The difficulty of the flashlight skill.
|
||||
pub flashlight: f64,
|
||||
/// The ratio of the aim strain with and without considering sliders
|
||||
pub slider_factor: 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: u32,
|
||||
/// The amount of sliders.
|
||||
pub n_sliders: u32,
|
||||
/// The amount of spinners.
|
||||
pub n_spinners: u32,
|
||||
/// The final star rating
|
||||
pub stars: f64,
|
||||
/// The maximum combo.
|
||||
pub max_combo: u32,
|
||||
}
|
||||
|
||||
impl OsuDifficultyAttributes {
|
||||
/// Return the maximum combo.
|
||||
pub const fn max_combo(&self) -> u32 {
|
||||
self.max_combo
|
||||
}
|
||||
|
||||
/// Return the amount of hitobjects.
|
||||
pub const fn n_objects(&self) -> u32 {
|
||||
self.n_circles + self.n_sliders + self.n_spinners
|
||||
}
|
||||
|
||||
/// Returns a builder for performance calculation.
|
||||
pub fn pp<'a>(self) -> OsuPerformance<'a> {
|
||||
self.into()
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
pub const fn stars(&self) -> f64 {
|
||||
self.difficulty.stars
|
||||
}
|
||||
|
||||
/// Return the performance point value.
|
||||
pub const fn pp(&self) -> f64 {
|
||||
self.pp
|
||||
}
|
||||
|
||||
/// Return the maximum combo of the map.
|
||||
pub const fn max_combo(&self) -> u32 {
|
||||
self.difficulty.max_combo
|
||||
}
|
||||
/// Return the amount of hitobjects.
|
||||
pub const fn n_objects(&self) -> u32 {
|
||||
self.difficulty.n_objects()
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user