@rian8337/osu-difficulty-calculator 4.0.0-beta.4 → 4.0.0-beta.40
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +1847 -1582
- package/package.json +9 -6
- package/typings/index.d.ts +593 -494
- package/dist/index.js.map +0 -1
package/dist/index.js
CHANGED
|
@@ -8,10 +8,6 @@ var osuBase = require('@rian8337/osu-base');
|
|
|
8
8
|
* This class should be considered an "evaluating" class and not persisted.
|
|
9
9
|
*/
|
|
10
10
|
class AimEvaluator {
|
|
11
|
-
static wideAngleMultiplier = 1.5;
|
|
12
|
-
static acuteAngleMultiplier = 1.95;
|
|
13
|
-
static sliderMultiplier = 1.35;
|
|
14
|
-
static velocityChangeMultiplier = 0.75;
|
|
15
11
|
/**
|
|
16
12
|
* Calculates the bonus of wide angles.
|
|
17
13
|
*/
|
|
@@ -27,154 +23,258 @@ class AimEvaluator {
|
|
|
27
23
|
return 1 - this.calculateWideAngleBonus(angle);
|
|
28
24
|
}
|
|
29
25
|
}
|
|
26
|
+
AimEvaluator.wideAngleMultiplier = 1.5;
|
|
27
|
+
AimEvaluator.acuteAngleMultiplier = 1.95;
|
|
28
|
+
AimEvaluator.sliderMultiplier = 1.35;
|
|
29
|
+
AimEvaluator.velocityChangeMultiplier = 0.75;
|
|
30
30
|
|
|
31
31
|
/**
|
|
32
|
-
*
|
|
32
|
+
* The base of a difficulty calculator.
|
|
33
33
|
*/
|
|
34
|
-
class
|
|
35
|
-
/**
|
|
36
|
-
* The underlying hitobject.
|
|
37
|
-
*/
|
|
38
|
-
object;
|
|
39
|
-
/**
|
|
40
|
-
* The index of this hitobject in the list of all hitobjects.
|
|
41
|
-
*
|
|
42
|
-
* This is one less than the actual index of the hitobject in the beatmap.
|
|
43
|
-
*/
|
|
44
|
-
index = 0;
|
|
45
|
-
/**
|
|
46
|
-
* The preempt time of the hitobject.
|
|
47
|
-
*/
|
|
48
|
-
baseTimePreempt = 600;
|
|
49
|
-
/**
|
|
50
|
-
* Adjusted preempt time of the hitobject, taking speed multiplier into account.
|
|
51
|
-
*/
|
|
52
|
-
timePreempt = 600;
|
|
53
|
-
/**
|
|
54
|
-
* The fade in time of the hitobject.
|
|
55
|
-
*/
|
|
56
|
-
timeFadeIn = 400;
|
|
57
|
-
/**
|
|
58
|
-
* The aim strain generated by the hitobject if sliders are considered.
|
|
59
|
-
*/
|
|
60
|
-
aimStrainWithSliders = 0;
|
|
61
|
-
/**
|
|
62
|
-
* The aim strain generated by the hitobject if sliders are not considered.
|
|
63
|
-
*/
|
|
64
|
-
aimStrainWithoutSliders = 0;
|
|
65
|
-
/**
|
|
66
|
-
* The tap strain generated by the hitobject.
|
|
67
|
-
*
|
|
68
|
-
* This is also used for osu!standard as opposed to "speed strain".
|
|
69
|
-
*/
|
|
70
|
-
tapStrain = 0;
|
|
71
|
-
/**
|
|
72
|
-
* The tap strain generated by the hitobject if `strainTime` isn't modified by
|
|
73
|
-
* OD. This is used in three-finger detection.
|
|
74
|
-
*/
|
|
75
|
-
originalTapStrain = 0;
|
|
76
|
-
/**
|
|
77
|
-
* The rhythm multiplier generated by the hitobject. This is used to alter tap strain.
|
|
78
|
-
*/
|
|
79
|
-
rhythmMultiplier = 0;
|
|
80
|
-
/**
|
|
81
|
-
* The rhythm strain generated by the hitobject.
|
|
82
|
-
*/
|
|
83
|
-
rhythmStrain = 0;
|
|
84
|
-
/**
|
|
85
|
-
* The flashlight strain generated by the hitobject if sliders are considered.
|
|
86
|
-
*/
|
|
87
|
-
flashlightStrainWithSliders = 0;
|
|
88
|
-
/**
|
|
89
|
-
* The flashlight strain generated by the hitobject if sliders are not considered.
|
|
90
|
-
*/
|
|
91
|
-
flashlightStrainWithoutSliders = 0;
|
|
34
|
+
class DifficultyCalculator {
|
|
92
35
|
/**
|
|
93
|
-
* The
|
|
36
|
+
* The difficulty objects of the beatmap.
|
|
94
37
|
*/
|
|
95
|
-
|
|
38
|
+
get objects() {
|
|
39
|
+
return this._objects;
|
|
40
|
+
}
|
|
96
41
|
/**
|
|
97
|
-
* The
|
|
42
|
+
* The total star rating of the beatmap.
|
|
98
43
|
*/
|
|
99
|
-
|
|
44
|
+
get total() {
|
|
45
|
+
return this.attributes.starRating;
|
|
46
|
+
}
|
|
100
47
|
/**
|
|
101
|
-
*
|
|
48
|
+
* Constructs a new instance of the calculator.
|
|
102
49
|
*
|
|
103
|
-
*
|
|
50
|
+
* @param beatmap The beatmap to calculate.
|
|
104
51
|
*/
|
|
105
|
-
|
|
52
|
+
constructor(beatmap) {
|
|
53
|
+
/**
|
|
54
|
+
* The difficulty objects of the beatmap.
|
|
55
|
+
*/
|
|
56
|
+
this._objects = [];
|
|
57
|
+
/**
|
|
58
|
+
* The modifications applied.
|
|
59
|
+
*/
|
|
60
|
+
this.mods = [];
|
|
61
|
+
/**
|
|
62
|
+
* The strain peaks of various calculated difficulties.
|
|
63
|
+
*/
|
|
64
|
+
this.strainPeaks = {
|
|
65
|
+
aimWithSliders: [],
|
|
66
|
+
aimWithoutSliders: [],
|
|
67
|
+
speed: [],
|
|
68
|
+
flashlight: [],
|
|
69
|
+
};
|
|
70
|
+
this.beatmap = beatmap;
|
|
71
|
+
}
|
|
106
72
|
/**
|
|
107
|
-
*
|
|
73
|
+
* Calculates the star rating of the specified beatmap.
|
|
108
74
|
*
|
|
109
|
-
*
|
|
75
|
+
* The beatmap is analyzed in chunks of `sectionLength` duration.
|
|
76
|
+
* For each chunk the highest hitobject strains are added to
|
|
77
|
+
* a list which is then collapsed into a weighted sum, much
|
|
78
|
+
* like scores are weighted on a user's profile.
|
|
110
79
|
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
80
|
+
* For subsequent chunks, the initial max strain is calculated
|
|
81
|
+
* by decaying the previous hitobject's strain until the
|
|
82
|
+
* beginning of the new chunk.
|
|
114
83
|
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*/
|
|
118
|
-
minimumJumpDistance = 0;
|
|
119
|
-
/**
|
|
120
|
-
* The time taken to travel through `minimumJumpDistance`, with a minimum value of 25ms.
|
|
121
|
-
*/
|
|
122
|
-
minimumJumpTime = 0;
|
|
123
|
-
/**
|
|
124
|
-
* The normalized distance between the start and end position of this hitobject.
|
|
125
|
-
*/
|
|
126
|
-
travelDistance = 0;
|
|
127
|
-
/**
|
|
128
|
-
* The time taken to travel through `travelDistance`, with a minimum value of 25ms for sliders.
|
|
84
|
+
* @param options Options for the difficulty calculation.
|
|
85
|
+
* @returns The current instance.
|
|
129
86
|
*/
|
|
130
|
-
|
|
87
|
+
calculate(options) {
|
|
88
|
+
var _a;
|
|
89
|
+
this.mods = (_a = options === null || options === void 0 ? void 0 : options.mods) !== null && _a !== void 0 ? _a : [];
|
|
90
|
+
const playableBeatmap = this.beatmap.createPlayableBeatmap({
|
|
91
|
+
mode: this.mode,
|
|
92
|
+
mods: this.mods,
|
|
93
|
+
customSpeedMultiplier: options === null || options === void 0 ? void 0 : options.customSpeedMultiplier,
|
|
94
|
+
});
|
|
95
|
+
const clockRate = this.calculateClockRate(options);
|
|
96
|
+
this.populateDifficultyAttributes(playableBeatmap, clockRate);
|
|
97
|
+
this._objects = this.generateDifficultyHitObjects(playableBeatmap, clockRate);
|
|
98
|
+
this.calculateAll();
|
|
99
|
+
return this;
|
|
100
|
+
}
|
|
131
101
|
/**
|
|
132
|
-
*
|
|
102
|
+
* Calculates the skills provided.
|
|
133
103
|
*
|
|
134
|
-
*
|
|
135
|
-
*/
|
|
136
|
-
angle = null;
|
|
137
|
-
/**
|
|
138
|
-
* The amount of milliseconds elapsed between this hitobject and the last hitobject.
|
|
139
|
-
*/
|
|
140
|
-
deltaTime = 0;
|
|
141
|
-
/**
|
|
142
|
-
* The amount of milliseconds elapsed since the start time of the previous hitobject, with a minimum of 25ms.
|
|
143
|
-
*/
|
|
144
|
-
strainTime = 0;
|
|
145
|
-
/**
|
|
146
|
-
* Adjusted start time of the hitobject, taking speed multiplier into account.
|
|
147
|
-
*/
|
|
148
|
-
startTime = 0;
|
|
149
|
-
/**
|
|
150
|
-
* Adjusted end time of the hitobject, taking speed multiplier into account.
|
|
104
|
+
* @param skills The skills to calculate.
|
|
151
105
|
*/
|
|
152
|
-
|
|
106
|
+
calculateSkills(...skills) {
|
|
107
|
+
// The first object doesn't generate a strain, so we begin calculating from the second object.
|
|
108
|
+
for (const object of this.objects.slice(1)) {
|
|
109
|
+
for (const skill of skills) {
|
|
110
|
+
skill.process(object);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
153
114
|
/**
|
|
154
|
-
*
|
|
115
|
+
* Obtains the clock rate of the beatmap.
|
|
116
|
+
*
|
|
117
|
+
* @param options The options to obtain the clock rate with.
|
|
118
|
+
* @returns The clock rate of the beatmap.
|
|
155
119
|
*/
|
|
156
|
-
|
|
120
|
+
calculateClockRate(options) {
|
|
121
|
+
var _a, _b;
|
|
122
|
+
return (osuBase.ModUtil.calculateRateWithMods((_a = options === null || options === void 0 ? void 0 : options.mods) !== null && _a !== void 0 ? _a : []) *
|
|
123
|
+
((_b = options === null || options === void 0 ? void 0 : options.customSpeedMultiplier) !== null && _b !== void 0 ? _b : 1));
|
|
124
|
+
}
|
|
157
125
|
/**
|
|
158
|
-
*
|
|
126
|
+
* Populates the stored difficulty attributes with necessary data.
|
|
159
127
|
*
|
|
160
|
-
*
|
|
128
|
+
* @param beatmap The beatmap to populate the attributes with.
|
|
129
|
+
* @param clockRate The clock rate of the beatmap.
|
|
161
130
|
*/
|
|
162
|
-
|
|
131
|
+
populateDifficultyAttributes(beatmap, clockRate) {
|
|
132
|
+
this.attributes.hitCircleCount = this.beatmap.hitObjects.circles;
|
|
133
|
+
this.attributes.maxCombo = this.beatmap.maxCombo;
|
|
134
|
+
this.attributes.mods = this.mods.slice();
|
|
135
|
+
this.attributes.sliderCount = this.beatmap.hitObjects.sliders;
|
|
136
|
+
this.attributes.spinnerCount = this.beatmap.hitObjects.spinners;
|
|
137
|
+
this.attributes.clockRate = clockRate;
|
|
138
|
+
let greatWindow;
|
|
139
|
+
switch (this.mode) {
|
|
140
|
+
case osuBase.Modes.droid:
|
|
141
|
+
if (this.mods.some((m) => m instanceof osuBase.ModPrecise)) {
|
|
142
|
+
greatWindow = new osuBase.PreciseDroidHitWindow(beatmap.difficulty.od).greatWindow;
|
|
143
|
+
}
|
|
144
|
+
else {
|
|
145
|
+
greatWindow = new osuBase.DroidHitWindow(beatmap.difficulty.od)
|
|
146
|
+
.greatWindow;
|
|
147
|
+
}
|
|
148
|
+
break;
|
|
149
|
+
case osuBase.Modes.osu:
|
|
150
|
+
greatWindow = new osuBase.OsuHitWindow(beatmap.difficulty.od)
|
|
151
|
+
.greatWindow;
|
|
152
|
+
break;
|
|
153
|
+
}
|
|
154
|
+
this.attributes.overallDifficulty = osuBase.OsuHitWindow.greatWindowToOD(greatWindow / clockRate);
|
|
155
|
+
}
|
|
163
156
|
/**
|
|
164
|
-
*
|
|
157
|
+
* Calculates the star rating value of a difficulty.
|
|
158
|
+
*
|
|
159
|
+
* @param difficulty The difficulty to calculate.
|
|
165
160
|
*/
|
|
166
|
-
|
|
161
|
+
starValue(difficulty) {
|
|
162
|
+
return Math.sqrt(difficulty) * this.difficultyMultiplier;
|
|
163
|
+
}
|
|
167
164
|
/**
|
|
168
|
-
*
|
|
165
|
+
* Calculates the base performance value of a difficulty rating.
|
|
166
|
+
*
|
|
167
|
+
* @param rating The difficulty rating.
|
|
169
168
|
*/
|
|
170
|
-
|
|
169
|
+
basePerformanceValue(rating) {
|
|
170
|
+
return Math.pow(5 * Math.max(1, rating / 0.0675) - 4, 3) / 100000;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Represents a hit object with difficulty calculation values.
|
|
176
|
+
*/
|
|
177
|
+
class DifficultyHitObject {
|
|
171
178
|
/**
|
|
179
|
+
* Note: You **must** call `computeProperties` at some point due to how TypeScript handles
|
|
180
|
+
* overridden properties (see [this](https://github.com/microsoft/TypeScript/issues/1617) GitHub issue).
|
|
181
|
+
*
|
|
172
182
|
* @param object The underlying hitobject.
|
|
173
|
-
* @param
|
|
174
|
-
|
|
175
|
-
|
|
183
|
+
* @param lastObject The hitobject before this hitobject.
|
|
184
|
+
* @param lastLastObject The hitobject before the last hitobject.
|
|
185
|
+
* @param difficultyHitObjects All difficulty hitobjects in the processed beatmap.
|
|
186
|
+
* @param clockRate The clock rate of the beatmap.
|
|
187
|
+
*/
|
|
188
|
+
constructor(object, lastObject, lastLastObject, difficultyHitObjects, clockRate) {
|
|
189
|
+
var _a, _b, _c, _d;
|
|
190
|
+
/**
|
|
191
|
+
* The aim strain generated by the hitobject if sliders are considered.
|
|
192
|
+
*/
|
|
193
|
+
this.aimStrainWithSliders = 0;
|
|
194
|
+
/**
|
|
195
|
+
* The aim strain generated by the hitobject if sliders are not considered.
|
|
196
|
+
*/
|
|
197
|
+
this.aimStrainWithoutSliders = 0;
|
|
198
|
+
/**
|
|
199
|
+
* The rhythm multiplier generated by the hitobject. This is used to alter tap strain.
|
|
200
|
+
*/
|
|
201
|
+
this.rhythmMultiplier = 0;
|
|
202
|
+
/**
|
|
203
|
+
* The normalized distance from the "lazy" end position of the previous hitobject to the start position of this hitobject.
|
|
204
|
+
*
|
|
205
|
+
* The "lazy" end position is the position at which the cursor ends up if the previous hitobject is followed with as minimal movement as possible (i.e. on the edge of slider follow circles).
|
|
206
|
+
*/
|
|
207
|
+
this.lazyJumpDistance = 0;
|
|
208
|
+
/**
|
|
209
|
+
* The normalized shortest distance to consider for a jump between the previous hitobject and this hitobject.
|
|
210
|
+
*
|
|
211
|
+
* This is bounded from above by `lazyJumpDistance`, and is smaller than the former if a more natural path is able to be taken through the previous hitobject.
|
|
212
|
+
*
|
|
213
|
+
* Suppose a linear slider - circle pattern. Following the slider lazily (see: `lazyJumpDistance`) will result in underestimating the true end position of the slider as being closer towards the start position.
|
|
214
|
+
* As a result, `lazyJumpDistance` overestimates the jump distance because the player is able to take a more natural path by following through the slider to its end,
|
|
215
|
+
* such that the jump is felt as only starting from the slider's true end position.
|
|
216
|
+
*
|
|
217
|
+
* Now consider a slider - circle pattern where the circle is stacked along the path inside the slider.
|
|
218
|
+
* In this case, the lazy end position correctly estimates the true end position of the slider and provides the more natural movement path.
|
|
219
|
+
*/
|
|
220
|
+
this.minimumJumpDistance = 0;
|
|
221
|
+
/**
|
|
222
|
+
* The time taken to travel through `minimumJumpDistance`, with a minimum value of 25ms.
|
|
223
|
+
*/
|
|
224
|
+
this.minimumJumpTime = 0;
|
|
225
|
+
/**
|
|
226
|
+
* The normalized distance between the start and end position of this hitobject.
|
|
227
|
+
*/
|
|
228
|
+
this.travelDistance = 0;
|
|
229
|
+
/**
|
|
230
|
+
* The time taken to travel through `travelDistance`, with a minimum value of 25ms for sliders.
|
|
231
|
+
*/
|
|
232
|
+
this.travelTime = 0;
|
|
233
|
+
/**
|
|
234
|
+
* Angle the player has to take to hit this hitobject.
|
|
235
|
+
*
|
|
236
|
+
* Calculated as the angle between the circles (current-2, current-1, current).
|
|
237
|
+
*/
|
|
238
|
+
this.angle = null;
|
|
239
|
+
this.normalizedRadius = 50;
|
|
240
|
+
this.maximumSliderRadius = this.normalizedRadius * 2.4;
|
|
241
|
+
this.assumedSliderRadius = this.normalizedRadius * 1.8;
|
|
176
242
|
this.object = object;
|
|
177
|
-
this.
|
|
243
|
+
this.lastObject = lastObject;
|
|
244
|
+
this.lastLastObject = lastLastObject;
|
|
245
|
+
this.hitObjects = difficultyHitObjects;
|
|
246
|
+
if (object instanceof osuBase.Slider) {
|
|
247
|
+
this.fullGreatWindow =
|
|
248
|
+
((_b = (_a = object.head.hitWindow) === null || _a === void 0 ? void 0 : _a.greatWindow) !== null && _b !== void 0 ? _b : 1200) * 2;
|
|
249
|
+
}
|
|
250
|
+
else {
|
|
251
|
+
this.fullGreatWindow = ((_d = (_c = object.hitWindow) === null || _c === void 0 ? void 0 : _c.greatWindow) !== null && _d !== void 0 ? _d : 1200) * 2;
|
|
252
|
+
}
|
|
253
|
+
this.fullGreatWindow /= clockRate;
|
|
254
|
+
this.index = difficultyHitObjects.length - 1;
|
|
255
|
+
// Capped to 25ms to prevent difficulty calculation breaking from simultaneous objects.
|
|
256
|
+
this.startTime = object.startTime / clockRate;
|
|
257
|
+
this.endTime = object.endTime / clockRate;
|
|
258
|
+
if (lastObject) {
|
|
259
|
+
this.deltaTime = this.startTime - lastObject.startTime / clockRate;
|
|
260
|
+
this.strainTime = Math.max(this.deltaTime, DifficultyHitObject.minDeltaTime);
|
|
261
|
+
}
|
|
262
|
+
else {
|
|
263
|
+
this.deltaTime = 0;
|
|
264
|
+
this.strainTime = 0;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* Computes the properties of this hitobject.
|
|
269
|
+
*
|
|
270
|
+
* @param clockRate The clock rate of the beatmap.
|
|
271
|
+
* @param hitObjects The hitobjects in the beatmap.
|
|
272
|
+
*/
|
|
273
|
+
computeProperties(clockRate,
|
|
274
|
+
// Required for `DroidDifficultyHitObject` override.
|
|
275
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
276
|
+
hitObjects) {
|
|
277
|
+
this.setDistances(clockRate);
|
|
178
278
|
}
|
|
179
279
|
/**
|
|
180
280
|
* Gets the difficulty hitobject at a specific index with respect to the current
|
|
@@ -187,7 +287,8 @@ class DifficultyHitObject {
|
|
|
187
287
|
* difficulty hitobject's index, `null` if the index is out of range.
|
|
188
288
|
*/
|
|
189
289
|
previous(backwardsIndex) {
|
|
190
|
-
|
|
290
|
+
var _a;
|
|
291
|
+
return (_a = this.hitObjects[this.index - backwardsIndex]) !== null && _a !== void 0 ? _a : null;
|
|
191
292
|
}
|
|
192
293
|
/**
|
|
193
294
|
* Gets the difficulty hitobject at a specific index with respect to the current
|
|
@@ -200,264 +301,128 @@ class DifficultyHitObject {
|
|
|
200
301
|
* difficulty hitobject's index, `null` if the index is out of range.
|
|
201
302
|
*/
|
|
202
303
|
next(forwardsIndex) {
|
|
203
|
-
|
|
304
|
+
var _a;
|
|
305
|
+
return ((_a = this.hitObjects[this.index + forwardsIndex + 2]) !== null && _a !== void 0 ? _a : null);
|
|
204
306
|
}
|
|
205
307
|
/**
|
|
206
308
|
* Calculates the opacity of the hitobject at a given time.
|
|
207
309
|
*
|
|
208
310
|
* @param time The time to calculate the hitobject's opacity at.
|
|
209
311
|
* @param isHidden Whether Hidden mod is used.
|
|
210
|
-
* @param mode The gamemode to calculate the opacity for.
|
|
211
312
|
* @returns The opacity of the hitobject at the given time.
|
|
212
313
|
*/
|
|
213
|
-
opacityAt(time, isHidden
|
|
314
|
+
opacityAt(time, isHidden) {
|
|
214
315
|
if (time > this.object.startTime) {
|
|
215
316
|
// Consider a hitobject as being invisible when its start time is passed.
|
|
216
317
|
// In reality the hitobject will be visible beyond its start time up until its hittable window has passed,
|
|
217
318
|
// but this is an approximation and such a case is unlikely to be hit where this function is used.
|
|
218
319
|
return 0;
|
|
219
320
|
}
|
|
220
|
-
const fadeInStartTime = this.object.startTime - this.
|
|
221
|
-
const fadeInDuration = this.timeFadeIn;
|
|
321
|
+
const fadeInStartTime = this.object.startTime - this.object.timePreempt;
|
|
322
|
+
const fadeInDuration = this.object.timeFadeIn;
|
|
222
323
|
if (isHidden) {
|
|
223
324
|
const fadeOutStartTime = fadeInStartTime + fadeInDuration;
|
|
224
|
-
const fadeOutDuration = this.
|
|
225
|
-
(mode === osuBase.Modes.droid
|
|
226
|
-
? 0.35
|
|
227
|
-
: osuBase.ModHidden.fadeOutDurationMultiplier);
|
|
325
|
+
const fadeOutDuration = this.object.timePreempt * osuBase.ModHidden.fadeOutDurationMultiplier;
|
|
228
326
|
return Math.min(osuBase.MathUtils.clamp((time - fadeInStartTime) / fadeInDuration, 0, 1), 1 -
|
|
229
327
|
osuBase.MathUtils.clamp((time - fadeOutStartTime) / fadeOutDuration, 0, 1));
|
|
230
328
|
}
|
|
231
329
|
return osuBase.MathUtils.clamp((time - fadeInStartTime) / fadeInDuration, 0, 1);
|
|
232
330
|
}
|
|
233
331
|
/**
|
|
234
|
-
*
|
|
332
|
+
* How possible is it to doubletap this object together with the next one and get perfect
|
|
333
|
+
* judgement in range from 0 to 1.
|
|
235
334
|
*
|
|
236
|
-
*
|
|
237
|
-
* can be hit with just a single tap in osu!droid.
|
|
238
|
-
*
|
|
239
|
-
* @param considerDistance Whether to consider the distance between both hitobjects.
|
|
240
|
-
* @returns Whether the hitobject is considered overlapping.
|
|
335
|
+
* A value closer to 1 indicates a higher possibility.
|
|
241
336
|
*/
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
const previous = this.previous(0);
|
|
247
|
-
if (!previous || previous.object instanceof osuBase.Spinner) {
|
|
248
|
-
return false;
|
|
249
|
-
}
|
|
250
|
-
if (this.deltaTime >= 5) {
|
|
251
|
-
return false;
|
|
337
|
+
get doubletapness() {
|
|
338
|
+
const next = this.next(0);
|
|
339
|
+
if (!next) {
|
|
340
|
+
return 0;
|
|
252
341
|
}
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
342
|
+
const currentDeltaTime = Math.max(1, this.deltaTime);
|
|
343
|
+
const nextDeltaTime = Math.max(1, next.deltaTime);
|
|
344
|
+
const deltaDifference = Math.abs(nextDeltaTime - currentDeltaTime);
|
|
345
|
+
const speedRatio = currentDeltaTime / Math.max(currentDeltaTime, deltaDifference);
|
|
346
|
+
const windowRatio = Math.pow(Math.min(1, currentDeltaTime / this.fullGreatWindow), 2);
|
|
347
|
+
return 1 - Math.pow(speedRatio, 1 - windowRatio);
|
|
348
|
+
}
|
|
349
|
+
setDistances(clockRate) {
|
|
350
|
+
if (this.object instanceof osuBase.Slider) {
|
|
351
|
+
this.calculateSliderCursorPosition(this.object);
|
|
352
|
+
this.travelDistance = this.object.lazyTravelDistance;
|
|
353
|
+
// Bonus for repeat sliders until a better per nested object strain system can be achieved.
|
|
354
|
+
if (this.mode === osuBase.Modes.droid) {
|
|
355
|
+
this.travelDistance *= Math.pow(1 + this.object.repeatCount / 4, 1 / 4);
|
|
356
|
+
}
|
|
357
|
+
else {
|
|
358
|
+
this.travelDistance *= Math.pow(1 + this.object.repeatCount / 2.5, 1 / 2.5);
|
|
261
359
|
}
|
|
262
|
-
|
|
360
|
+
this.travelTime = Math.max(this.object.lazyTravelTime / clockRate, DifficultyHitObject.minDeltaTime);
|
|
263
361
|
}
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
* A converter used to convert normal hitobjects into difficulty hitobjects.
|
|
270
|
-
*/
|
|
271
|
-
class DifficultyHitObjectCreator {
|
|
272
|
-
/**
|
|
273
|
-
* The threshold for small circle buff for osu!droid.
|
|
274
|
-
*/
|
|
275
|
-
DROID_CIRCLESIZE_BUFF_THRESHOLD = 70;
|
|
276
|
-
/**
|
|
277
|
-
* The threshold for small circle buff for osu!standard.
|
|
278
|
-
*/
|
|
279
|
-
PC_CIRCLESIZE_BUFF_THRESHOLD = 30;
|
|
280
|
-
/**
|
|
281
|
-
* The gamemode this creator is creating for.
|
|
282
|
-
*/
|
|
283
|
-
mode = osuBase.Modes.osu;
|
|
284
|
-
/**
|
|
285
|
-
* The base normalized radius of hitobjects.
|
|
286
|
-
*/
|
|
287
|
-
normalizedRadius = 50;
|
|
288
|
-
maximumSliderRadius = this.normalizedRadius * 2.4;
|
|
289
|
-
assumedSliderRadius = this.normalizedRadius * 1.8;
|
|
290
|
-
minDeltaTime = 25;
|
|
291
|
-
/**
|
|
292
|
-
* Generates difficulty hitobjects for difficulty calculation.
|
|
293
|
-
*/
|
|
294
|
-
generateDifficultyObjects(params) {
|
|
295
|
-
params.preempt ??= 600;
|
|
296
|
-
this.mode = params.mode;
|
|
297
|
-
if (this.mode === osuBase.Modes.droid) {
|
|
298
|
-
this.maximumSliderRadius = this.normalizedRadius * 2;
|
|
362
|
+
// We don't need to calculate either angle or distance when one of the last->curr objects is a spinner.
|
|
363
|
+
if (!this.lastObject ||
|
|
364
|
+
this.object instanceof osuBase.Spinner ||
|
|
365
|
+
this.lastObject instanceof osuBase.Spinner) {
|
|
366
|
+
return;
|
|
299
367
|
}
|
|
300
|
-
|
|
301
|
-
const
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
// Cap to 25ms to prevent difficulty calculation breaking from simultaneous objects.
|
|
333
|
-
object.strainTime = Math.max(this.minDeltaTime, object.deltaTime);
|
|
334
|
-
if (object.object instanceof osuBase.Spinner) {
|
|
335
|
-
difficultyObjects.push(object);
|
|
336
|
-
continue;
|
|
337
|
-
}
|
|
338
|
-
// We'll have two visible object arrays. The first array contains objects before the current object starts in a reversed order,
|
|
339
|
-
// while the second array contains objects after the current object ends.
|
|
340
|
-
// For overlapping factor, we also need to consider previous visible objects.
|
|
341
|
-
const prevVisibleObjects = [];
|
|
342
|
-
const nextVisibleObjects = [];
|
|
343
|
-
for (let j = i + 1; j < params.objects.length; ++j) {
|
|
344
|
-
const o = params.objects[j];
|
|
345
|
-
if (o instanceof osuBase.Spinner) {
|
|
346
|
-
continue;
|
|
347
|
-
}
|
|
348
|
-
if (o.startTime / params.speedMultiplier >
|
|
349
|
-
object.endTime + object.timePreempt) {
|
|
350
|
-
break;
|
|
351
|
-
}
|
|
352
|
-
nextVisibleObjects.push(o);
|
|
353
|
-
}
|
|
354
|
-
for (let j = 0; j < object.index; ++j) {
|
|
355
|
-
const prev = object.previous(j);
|
|
356
|
-
if (prev.object instanceof osuBase.Spinner) {
|
|
357
|
-
continue;
|
|
358
|
-
}
|
|
359
|
-
if (prev.startTime >= object.startTime) {
|
|
360
|
-
continue;
|
|
361
|
-
}
|
|
362
|
-
if (prev.startTime < object.startTime - object.timePreempt) {
|
|
363
|
-
break;
|
|
364
|
-
}
|
|
365
|
-
prevVisibleObjects.push(prev.object);
|
|
366
|
-
}
|
|
367
|
-
for (const hitObject of prevVisibleObjects) {
|
|
368
|
-
const distance = object.object
|
|
369
|
-
.getStackedPosition(this.mode)
|
|
370
|
-
.getDistance(hitObject.getStackedEndPosition(this.mode));
|
|
371
|
-
const deltaTime = object.startTime -
|
|
372
|
-
hitObject.endTime / params.speedMultiplier;
|
|
373
|
-
this.applyToOverlappingFactor(object, distance, deltaTime);
|
|
374
|
-
}
|
|
375
|
-
for (const hitObject of nextVisibleObjects) {
|
|
376
|
-
const distance = hitObject
|
|
377
|
-
.getStackedPosition(this.mode)
|
|
378
|
-
.getDistance(object.object.getStackedEndPosition(this.mode));
|
|
379
|
-
const deltaTime = hitObject.startTime / params.speedMultiplier -
|
|
380
|
-
object.endTime;
|
|
381
|
-
if (deltaTime >= 0) {
|
|
382
|
-
object.noteDensity += 1 - deltaTime / object.timePreempt;
|
|
383
|
-
}
|
|
384
|
-
this.applyToOverlappingFactor(object, distance, deltaTime);
|
|
385
|
-
}
|
|
386
|
-
if (lastObject.object instanceof osuBase.Spinner) {
|
|
387
|
-
difficultyObjects.push(object);
|
|
388
|
-
continue;
|
|
389
|
-
}
|
|
390
|
-
const lastCursorPosition = this.getEndCursorPosition(lastObject.object);
|
|
391
|
-
object.lazyJumpDistance = object.object
|
|
368
|
+
// We will scale distances by this factor, so we can assume a uniform CircleSize among beatmaps.
|
|
369
|
+
const { scalingFactor } = this;
|
|
370
|
+
const lastCursorPosition = this.getEndCursorPosition(this.lastObject);
|
|
371
|
+
this.lazyJumpDistance = this.object
|
|
372
|
+
.getStackedPosition(this.mode)
|
|
373
|
+
.scale(scalingFactor)
|
|
374
|
+
.subtract(lastCursorPosition.scale(scalingFactor)).length;
|
|
375
|
+
this.minimumJumpTime = this.strainTime;
|
|
376
|
+
this.minimumJumpDistance = this.lazyJumpDistance;
|
|
377
|
+
if (this.lastObject instanceof osuBase.Slider) {
|
|
378
|
+
const lastTravelTime = Math.max(this.lastObject.lazyTravelTime / clockRate, DifficultyHitObject.minDeltaTime);
|
|
379
|
+
this.minimumJumpTime = Math.max(this.strainTime - lastTravelTime, DifficultyHitObject.minDeltaTime);
|
|
380
|
+
// There are two types of slider-to-object patterns to consider in order to better approximate the real movement a player will take to jump between the hitobjects.
|
|
381
|
+
//
|
|
382
|
+
// 1. The anti-flow pattern, where players cut the slider short in order to move to the next hitobject.
|
|
383
|
+
//
|
|
384
|
+
// <======o==> ← slider
|
|
385
|
+
// | ← most natural jump path
|
|
386
|
+
// o ← a follow-up hitcircle
|
|
387
|
+
//
|
|
388
|
+
// In this case the most natural jump path is approximated by LazyJumpDistance.
|
|
389
|
+
//
|
|
390
|
+
// 2. The flow pattern, where players follow through the slider to its visual extent into the next hitobject.
|
|
391
|
+
//
|
|
392
|
+
// <======o==>---o
|
|
393
|
+
// ↑
|
|
394
|
+
// most natural jump path
|
|
395
|
+
//
|
|
396
|
+
// In this case the most natural jump path is better approximated by a new distance called "tailJumpDistance" - the distance between the slider's tail and the next hitobject.
|
|
397
|
+
//
|
|
398
|
+
// Thus, the player is assumed to jump the minimum of these two distances in all cases.
|
|
399
|
+
const tailJumpDistance = this.lastObject.tail
|
|
392
400
|
.getStackedPosition(this.mode)
|
|
393
|
-
.
|
|
394
|
-
.
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
// In this case the most natural jump path is approximated by LazyJumpDistance.
|
|
408
|
-
//
|
|
409
|
-
// 2. The flow pattern, where players follow through the slider to its visual extent into the next hitobject.
|
|
410
|
-
//
|
|
411
|
-
// <======o==>---o
|
|
412
|
-
// ↑
|
|
413
|
-
// most natural jump path
|
|
414
|
-
//
|
|
415
|
-
// In this case the most natural jump path is better approximated by a new distance called "tailJumpDistance" - the distance between the slider's tail and the next hitobject.
|
|
416
|
-
//
|
|
417
|
-
// Thus, the player is assumed to jump the minimum of these two distances in all cases.
|
|
418
|
-
const tailJumpDistance = lastObject.object.tail
|
|
419
|
-
.getStackedPosition(this.mode)
|
|
420
|
-
.subtract(object.object.getStackedPosition(this.mode))
|
|
421
|
-
.length * scalingFactor;
|
|
422
|
-
object.minimumJumpDistance = Math.max(0, Math.min(object.lazyJumpDistance -
|
|
423
|
-
(this.maximumSliderRadius -
|
|
424
|
-
this.assumedSliderRadius), tailJumpDistance - this.maximumSliderRadius));
|
|
425
|
-
}
|
|
426
|
-
if (lastLastObject && !(lastLastObject.object instanceof osuBase.Spinner)) {
|
|
427
|
-
const lastLastCursorPosition = this.getEndCursorPosition(lastLastObject.object);
|
|
428
|
-
const v1 = lastLastCursorPosition.subtract(lastObject.object.getStackedPosition(this.mode));
|
|
429
|
-
const v2 = object.object
|
|
430
|
-
.getStackedPosition(this.mode)
|
|
431
|
-
.subtract(lastCursorPosition);
|
|
432
|
-
const dot = v1.dot(v2);
|
|
433
|
-
const det = v1.x * v2.y - v1.y * v2.x;
|
|
434
|
-
object.angle = Math.abs(Math.atan2(det, dot));
|
|
435
|
-
}
|
|
436
|
-
difficultyObjects.push(object);
|
|
401
|
+
.subtract(this.object.getStackedPosition(this.mode))
|
|
402
|
+
.length * scalingFactor;
|
|
403
|
+
this.minimumJumpDistance = Math.max(0, Math.min(this.lazyJumpDistance -
|
|
404
|
+
(this.maximumSliderRadius - this.assumedSliderRadius), tailJumpDistance - this.maximumSliderRadius));
|
|
405
|
+
}
|
|
406
|
+
if (this.lastLastObject && !(this.lastLastObject instanceof osuBase.Spinner)) {
|
|
407
|
+
const lastLastCursorPosition = this.getEndCursorPosition(this.lastLastObject);
|
|
408
|
+
const v1 = lastLastCursorPosition.subtract(this.lastObject.getStackedPosition(this.mode));
|
|
409
|
+
const v2 = this.object
|
|
410
|
+
.getStackedPosition(this.mode)
|
|
411
|
+
.subtract(lastCursorPosition);
|
|
412
|
+
const dot = v1.dot(v2);
|
|
413
|
+
const det = v1.x * v2.y - v1.y * v2.x;
|
|
414
|
+
this.angle = Math.abs(Math.atan2(det, dot));
|
|
437
415
|
}
|
|
438
|
-
return difficultyObjects;
|
|
439
416
|
}
|
|
440
|
-
/**
|
|
441
|
-
* Calculates a slider's cursor position.
|
|
442
|
-
*/
|
|
443
417
|
calculateSliderCursorPosition(slider) {
|
|
444
418
|
if (slider.lazyEndPosition) {
|
|
445
419
|
return;
|
|
446
420
|
}
|
|
447
|
-
// Droid doesn't have a legacy slider tail. Since beatmap parser defaults slider tail
|
|
448
|
-
// to legacy slider tail, it needs to be changed to real slider tail first.
|
|
449
421
|
if (this.mode === osuBase.Modes.droid) {
|
|
450
|
-
slider.tail.startTime += osuBase.Slider.legacyLastTickOffset;
|
|
451
|
-
slider.tail.endTime += osuBase.Slider.legacyLastTickOffset;
|
|
452
|
-
slider.nestedHitObjects.sort((a, b) => a.startTime - b.startTime);
|
|
453
422
|
// Temporary lazy end position until a real result can be derived.
|
|
454
423
|
slider.lazyEndPosition = slider.getStackedPosition(this.mode);
|
|
455
|
-
// Stop here if the slider has too short duration
|
|
456
|
-
//
|
|
457
|
-
// end position heavily, which we do not want to happen.
|
|
458
|
-
//
|
|
459
|
-
// In the real game, this shouldn't happen. Perhaps we need to reinvestigate this
|
|
460
|
-
// in the future.
|
|
424
|
+
// Stop here if the slider has too short duration, allowing the player to essentially
|
|
425
|
+
// complete the slider without movement, making travel distance and time irrelevant.
|
|
461
426
|
if (osuBase.Precision.almostEqualsNumber(slider.startTime, slider.endTime)) {
|
|
462
427
|
return;
|
|
463
428
|
}
|
|
@@ -477,7 +442,7 @@ class DifficultyHitObjectCreator {
|
|
|
477
442
|
.getStackedPosition(this.mode)
|
|
478
443
|
.add(slider.path.positionAt(endTimeMin));
|
|
479
444
|
let currentCursorPosition = slider.getStackedPosition(this.mode);
|
|
480
|
-
const scalingFactor = this.normalizedRadius / slider.
|
|
445
|
+
const scalingFactor = this.normalizedRadius / slider.radius;
|
|
481
446
|
for (let i = 1; i < slider.nestedHitObjects.length; ++i) {
|
|
482
447
|
const currentMovementObject = slider.nestedHitObjects[i];
|
|
483
448
|
let currentMovement = currentMovementObject
|
|
@@ -501,224 +466,40 @@ class DifficultyHitObjectCreator {
|
|
|
501
466
|
// For a slider repeat, assume a tighter movement threshold to better assess repeat sliders.
|
|
502
467
|
requiredMovement = this.normalizedRadius;
|
|
503
468
|
}
|
|
504
|
-
if (currentMovementLength > requiredMovement) {
|
|
505
|
-
// This finds the positional delta from the required radius and the current position,
|
|
506
|
-
// and updates the currentCursorPosition accordingly, as well as rewarding distance.
|
|
507
|
-
currentCursorPosition = currentCursorPosition.add(currentMovement.scale((currentMovementLength - requiredMovement) /
|
|
508
|
-
currentMovementLength));
|
|
509
|
-
currentMovementLength *=
|
|
510
|
-
(currentMovementLength - requiredMovement) /
|
|
511
|
-
currentMovementLength;
|
|
512
|
-
slider.lazyTravelDistance += currentMovementLength;
|
|
513
|
-
}
|
|
514
|
-
if (i === slider.nestedHitObjects.length - 1) {
|
|
515
|
-
slider.lazyEndPosition = currentCursorPosition;
|
|
516
|
-
}
|
|
517
|
-
}
|
|
518
|
-
}
|
|
519
|
-
/**
|
|
520
|
-
* Gets the scaling factor of a radius.
|
|
521
|
-
*
|
|
522
|
-
* @param radius The radius to get the scaling factor from.
|
|
523
|
-
*/
|
|
524
|
-
getScalingFactor(radius) {
|
|
525
|
-
// We will scale distances by this factor, so we can assume a uniform CircleSize among beatmaps.
|
|
526
|
-
let scalingFactor = this.normalizedRadius / radius;
|
|
527
|
-
// High circle size (small CS) bonus
|
|
528
|
-
switch (this.mode) {
|
|
529
|
-
case osuBase.Modes.droid:
|
|
530
|
-
if (radius < this.DROID_CIRCLESIZE_BUFF_THRESHOLD) {
|
|
531
|
-
scalingFactor *=
|
|
532
|
-
1 +
|
|
533
|
-
Math.pow((this.DROID_CIRCLESIZE_BUFF_THRESHOLD - radius) /
|
|
534
|
-
50, 2);
|
|
535
|
-
}
|
|
536
|
-
break;
|
|
537
|
-
case osuBase.Modes.osu:
|
|
538
|
-
if (radius < this.PC_CIRCLESIZE_BUFF_THRESHOLD) {
|
|
539
|
-
scalingFactor *=
|
|
540
|
-
1 +
|
|
541
|
-
Math.min(this.PC_CIRCLESIZE_BUFF_THRESHOLD - radius, 5) /
|
|
542
|
-
50;
|
|
543
|
-
}
|
|
544
|
-
}
|
|
545
|
-
return scalingFactor;
|
|
546
|
-
}
|
|
547
|
-
/**
|
|
548
|
-
* Returns the end cursor position of a hitobject.
|
|
549
|
-
*/
|
|
550
|
-
getEndCursorPosition(object) {
|
|
551
|
-
let pos = object.getStackedPosition(this.mode);
|
|
552
|
-
if (object instanceof osuBase.Slider) {
|
|
553
|
-
this.calculateSliderCursorPosition(object);
|
|
554
|
-
pos = object.lazyEndPosition ?? pos;
|
|
555
|
-
}
|
|
556
|
-
return pos;
|
|
557
|
-
}
|
|
558
|
-
applyToOverlappingFactor(object, distance, deltaTime) {
|
|
559
|
-
// Penalize objects that are too close to the object in both distance
|
|
560
|
-
// and delta time to prevent stream maps from being overweighted.
|
|
561
|
-
object.overlappingFactor +=
|
|
562
|
-
Math.max(0, 1 - distance / (3 * object.object.getRadius(this.mode))) *
|
|
563
|
-
(7.5 /
|
|
564
|
-
(1 +
|
|
565
|
-
Math.exp(0.15 * (Math.max(deltaTime, this.minDeltaTime) - 75))));
|
|
566
|
-
}
|
|
567
|
-
}
|
|
568
|
-
|
|
569
|
-
/**
|
|
570
|
-
* The base of difficulty calculators.
|
|
571
|
-
*/
|
|
572
|
-
class DifficultyCalculator {
|
|
573
|
-
/**
|
|
574
|
-
* The calculated beatmap.
|
|
575
|
-
*/
|
|
576
|
-
beatmap;
|
|
577
|
-
/**
|
|
578
|
-
* The difficulty objects of the beatmap.
|
|
579
|
-
*/
|
|
580
|
-
objects = [];
|
|
581
|
-
/**
|
|
582
|
-
* The modifications applied.
|
|
583
|
-
*/
|
|
584
|
-
mods = [];
|
|
585
|
-
/**
|
|
586
|
-
* The total star rating of the beatmap.
|
|
587
|
-
*/
|
|
588
|
-
total = 0;
|
|
589
|
-
/**
|
|
590
|
-
* The map statistics of the beatmap after modifications are applied.
|
|
591
|
-
*/
|
|
592
|
-
stats = new osuBase.MapStats();
|
|
593
|
-
/**
|
|
594
|
-
* The strain peaks of various calculated difficulties.
|
|
595
|
-
*/
|
|
596
|
-
strainPeaks = {
|
|
597
|
-
aimWithSliders: [],
|
|
598
|
-
aimWithoutSliders: [],
|
|
599
|
-
speed: [],
|
|
600
|
-
flashlight: [],
|
|
601
|
-
};
|
|
602
|
-
sectionLength = 400;
|
|
603
|
-
/**
|
|
604
|
-
* Constructs a new instance of the calculator.
|
|
605
|
-
*
|
|
606
|
-
* @param beatmap The beatmap to calculate. This beatmap will be deep-cloned to prevent reference changes.
|
|
607
|
-
*/
|
|
608
|
-
constructor(beatmap) {
|
|
609
|
-
this.beatmap = osuBase.Utils.deepCopy(beatmap);
|
|
610
|
-
}
|
|
611
|
-
/**
|
|
612
|
-
* Calculates the star rating of the specified beatmap.
|
|
613
|
-
*
|
|
614
|
-
* The beatmap is analyzed in chunks of `sectionLength` duration.
|
|
615
|
-
* For each chunk the highest hitobject strains are added to
|
|
616
|
-
* a list which is then collapsed into a weighted sum, much
|
|
617
|
-
* like scores are weighted on a user's profile.
|
|
618
|
-
*
|
|
619
|
-
* For subsequent chunks, the initial max strain is calculated
|
|
620
|
-
* by decaying the previous hitobject's strain until the
|
|
621
|
-
* beginning of the new chunk.
|
|
622
|
-
*
|
|
623
|
-
* @param options Options for the difficulty calculation.
|
|
624
|
-
* @returns The current instance.
|
|
625
|
-
*/
|
|
626
|
-
calculate(options) {
|
|
627
|
-
this.mods = options?.mods ?? [];
|
|
628
|
-
this.stats = new osuBase.MapStats({
|
|
629
|
-
cs: this.beatmap.difficulty.cs,
|
|
630
|
-
ar: this.beatmap.difficulty.ar,
|
|
631
|
-
od: this.beatmap.difficulty.od,
|
|
632
|
-
hp: this.beatmap.difficulty.hp,
|
|
633
|
-
mods: options?.mods,
|
|
634
|
-
speedMultiplier: options?.stats?.speedMultiplier,
|
|
635
|
-
oldStatistics: options?.stats?.oldStatistics,
|
|
636
|
-
}).calculate({ mode: this.mode });
|
|
637
|
-
this.preProcess();
|
|
638
|
-
this.populateDifficultyAttributes();
|
|
639
|
-
this.generateDifficultyHitObjects();
|
|
640
|
-
this.calculateAll();
|
|
641
|
-
return this;
|
|
642
|
-
}
|
|
643
|
-
/**
|
|
644
|
-
* Generates difficulty hitobjects for this calculator.
|
|
645
|
-
*/
|
|
646
|
-
generateDifficultyHitObjects() {
|
|
647
|
-
this.objects.length = 0;
|
|
648
|
-
this.objects.push(...new DifficultyHitObjectCreator().generateDifficultyObjects({
|
|
649
|
-
objects: this.beatmap.hitObjects.objects,
|
|
650
|
-
circleSize: this.beatmap.difficulty.cs,
|
|
651
|
-
mods: this.mods,
|
|
652
|
-
speedMultiplier: this.stats.speedMultiplier,
|
|
653
|
-
mode: this.mode,
|
|
654
|
-
preempt: osuBase.MapStats.arToMS(this.stats.ar),
|
|
655
|
-
}));
|
|
656
|
-
}
|
|
657
|
-
/**
|
|
658
|
-
* Performs some pre-processing before proceeding with difficulty calculation.
|
|
659
|
-
*/
|
|
660
|
-
preProcess() {
|
|
661
|
-
}
|
|
662
|
-
/**
|
|
663
|
-
* Calculates the skills provided.
|
|
664
|
-
*
|
|
665
|
-
* @param skills The skills to calculate.
|
|
666
|
-
*/
|
|
667
|
-
calculateSkills(...skills) {
|
|
668
|
-
// The first object doesn't generate a strain, so we begin calculating from the second object.
|
|
669
|
-
this.objects.slice(1).forEach((h, i) => {
|
|
670
|
-
skills.forEach((skill) => {
|
|
671
|
-
skill.process(h);
|
|
672
|
-
if (i === this.objects.length - 2) {
|
|
673
|
-
// Don't forget to save the last strain peak, which would otherwise be ignored.
|
|
674
|
-
skill.saveCurrentPeak();
|
|
675
|
-
}
|
|
676
|
-
});
|
|
677
|
-
});
|
|
678
|
-
}
|
|
679
|
-
/**
|
|
680
|
-
* Populates the stored difficulty attributes with necessary data.
|
|
681
|
-
*/
|
|
682
|
-
populateDifficultyAttributes() {
|
|
683
|
-
this.attributes.approachRate = this.stats.ar;
|
|
684
|
-
this.attributes.hitCircleCount = this.beatmap.hitObjects.circles;
|
|
685
|
-
this.attributes.maxCombo = this.beatmap.maxCombo;
|
|
686
|
-
this.attributes.mods = this.mods.slice();
|
|
687
|
-
this.attributes.overallDifficulty = this.stats.od;
|
|
688
|
-
this.attributes.sliderCount = this.beatmap.hitObjects.sliders;
|
|
689
|
-
this.attributes.spinnerCount = this.beatmap.hitObjects.spinners;
|
|
690
|
-
}
|
|
691
|
-
/**
|
|
692
|
-
* Calculates the star rating value of a difficulty.
|
|
693
|
-
*
|
|
694
|
-
* @param difficulty The difficulty to calculate.
|
|
695
|
-
*/
|
|
696
|
-
starValue(difficulty) {
|
|
697
|
-
return Math.sqrt(difficulty) * this.difficultyMultiplier;
|
|
469
|
+
if (currentMovementLength > requiredMovement) {
|
|
470
|
+
// This finds the positional delta from the required radius and the current position,
|
|
471
|
+
// and updates the currentCursorPosition accordingly, as well as rewarding distance.
|
|
472
|
+
currentCursorPosition = currentCursorPosition.add(currentMovement.scale((currentMovementLength - requiredMovement) /
|
|
473
|
+
currentMovementLength));
|
|
474
|
+
currentMovementLength *=
|
|
475
|
+
(currentMovementLength - requiredMovement) /
|
|
476
|
+
currentMovementLength;
|
|
477
|
+
slider.lazyTravelDistance += currentMovementLength;
|
|
478
|
+
}
|
|
479
|
+
if (i === slider.nestedHitObjects.length - 1) {
|
|
480
|
+
slider.lazyEndPosition = currentCursorPosition;
|
|
481
|
+
}
|
|
482
|
+
}
|
|
698
483
|
}
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
484
|
+
getEndCursorPosition(object) {
|
|
485
|
+
var _a;
|
|
486
|
+
let pos = object.getStackedPosition(this.mode);
|
|
487
|
+
if (object instanceof osuBase.Slider) {
|
|
488
|
+
this.calculateSliderCursorPosition(object);
|
|
489
|
+
pos = (_a = object.lazyEndPosition) !== null && _a !== void 0 ? _a : pos;
|
|
490
|
+
}
|
|
491
|
+
return pos;
|
|
706
492
|
}
|
|
707
493
|
}
|
|
494
|
+
/**
|
|
495
|
+
* The lowest possible delta time value.
|
|
496
|
+
*/
|
|
497
|
+
DifficultyHitObject.minDeltaTime = 25;
|
|
708
498
|
|
|
709
499
|
/**
|
|
710
500
|
* An evaluator for calculating osu!droid Aim skill.
|
|
711
501
|
*/
|
|
712
502
|
class DroidAimEvaluator extends AimEvaluator {
|
|
713
|
-
static wideAngleMultiplier = 1.65;
|
|
714
|
-
static sliderMultiplier = 1.5;
|
|
715
|
-
static velocityChangeMultiplier = 0.85;
|
|
716
|
-
/**
|
|
717
|
-
* Spacing threshold for a single hitobject spacing.
|
|
718
|
-
*/
|
|
719
|
-
static SINGLE_SPACING_THRESHOLD = 175;
|
|
720
|
-
// ~200 1/2 BPM jumps
|
|
721
|
-
static minSpeedBonus = 150;
|
|
722
503
|
/**
|
|
723
504
|
* Evaluates the difficulty of aiming the current object, based on:
|
|
724
505
|
*
|
|
@@ -736,15 +517,16 @@ class DroidAimEvaluator extends AimEvaluator {
|
|
|
736
517
|
current.isOverlapping(true)) {
|
|
737
518
|
return 0;
|
|
738
519
|
}
|
|
739
|
-
return (this.
|
|
740
|
-
this.
|
|
520
|
+
return (this.snapAimStrainOf(current, withSliders) +
|
|
521
|
+
this.flowAimStrainOf(current));
|
|
741
522
|
}
|
|
742
523
|
/**
|
|
743
|
-
* Calculates the aim strain of a hitobject.
|
|
524
|
+
* Calculates the snap aim strain of a hitobject.
|
|
744
525
|
*/
|
|
745
|
-
static
|
|
526
|
+
static snapAimStrainOf(current, withSliders) {
|
|
527
|
+
var _a;
|
|
746
528
|
if (current.index <= 1 ||
|
|
747
|
-
current.previous(0)
|
|
529
|
+
((_a = current.previous(0)) === null || _a === void 0 ? void 0 : _a.object) instanceof osuBase.Spinner) {
|
|
748
530
|
return 0;
|
|
749
531
|
}
|
|
750
532
|
const last = current.previous(0);
|
|
@@ -846,28 +628,33 @@ class DroidAimEvaluator extends AimEvaluator {
|
|
|
846
628
|
velocityChangeBonus * this.velocityChangeMultiplier);
|
|
847
629
|
// Add in additional slider velocity bonus.
|
|
848
630
|
if (withSliders) {
|
|
849
|
-
strain +=
|
|
631
|
+
strain +=
|
|
632
|
+
Math.pow(1 + sliderBonus * this.sliderMultiplier, 1.25) - 1;
|
|
850
633
|
}
|
|
851
634
|
return strain;
|
|
852
635
|
}
|
|
853
636
|
/**
|
|
854
|
-
* Calculates the
|
|
637
|
+
* Calculates the flow aim strain of a hitobject.
|
|
855
638
|
*/
|
|
856
|
-
static
|
|
639
|
+
static flowAimStrainOf(current) {
|
|
640
|
+
var _a, _b;
|
|
857
641
|
let speedBonus = 1;
|
|
858
642
|
if (current.strainTime < this.minSpeedBonus) {
|
|
859
643
|
speedBonus +=
|
|
860
644
|
0.75 *
|
|
861
|
-
Math.pow((this.minSpeedBonus - current.strainTime) /
|
|
645
|
+
Math.pow((this.minSpeedBonus - current.strainTime) / 40, 2);
|
|
862
646
|
}
|
|
863
|
-
const travelDistance = current.previous(0)
|
|
864
|
-
const
|
|
865
|
-
return (
|
|
866
|
-
speedBonus *
|
|
867
|
-
Math.pow(distance / this.SINGLE_SPACING_THRESHOLD, 5)) /
|
|
868
|
-
current.strainTime);
|
|
647
|
+
const travelDistance = (_b = (_a = current.previous(0)) === null || _a === void 0 ? void 0 : _a.travelDistance) !== null && _b !== void 0 ? _b : 0;
|
|
648
|
+
const shortDistancePenalty = Math.pow(Math.min(this.singleSpacingThreshold, travelDistance + current.minimumJumpDistance) / this.singleSpacingThreshold, 3.5);
|
|
649
|
+
return (200 * speedBonus * shortDistancePenalty) / current.strainTime;
|
|
869
650
|
}
|
|
870
651
|
}
|
|
652
|
+
DroidAimEvaluator.wideAngleMultiplier = 1.65;
|
|
653
|
+
DroidAimEvaluator.sliderMultiplier = 1.5;
|
|
654
|
+
DroidAimEvaluator.velocityChangeMultiplier = 0.85;
|
|
655
|
+
DroidAimEvaluator.singleSpacingThreshold = 100;
|
|
656
|
+
// 200 1/4 BPM delta time
|
|
657
|
+
DroidAimEvaluator.minSpeedBonus = 75;
|
|
871
658
|
|
|
872
659
|
/**
|
|
873
660
|
* A bare minimal abstract skill for fully custom skill implementations.
|
|
@@ -875,10 +662,6 @@ class DroidAimEvaluator extends AimEvaluator {
|
|
|
875
662
|
* This class should be considered a "processing" class and not persisted.
|
|
876
663
|
*/
|
|
877
664
|
class Skill {
|
|
878
|
-
/**
|
|
879
|
-
* The mods that this skill processes.
|
|
880
|
-
*/
|
|
881
|
-
mods;
|
|
882
665
|
constructor(mods) {
|
|
883
666
|
this.mods = mods;
|
|
884
667
|
}
|
|
@@ -889,33 +672,29 @@ class Skill {
|
|
|
889
672
|
* and to calculate a final difficulty value representing the difficulty of hitting all the processed objects.
|
|
890
673
|
*/
|
|
891
674
|
class StrainSkill extends Skill {
|
|
675
|
+
constructor() {
|
|
676
|
+
super(...arguments);
|
|
677
|
+
/**
|
|
678
|
+
* Strain peaks are stored here.
|
|
679
|
+
*/
|
|
680
|
+
this.strainPeaks = [];
|
|
681
|
+
this._objectStrains = [];
|
|
682
|
+
this.difficulty = 0;
|
|
683
|
+
this.sectionLength = 400;
|
|
684
|
+
this.currentStrain = 0;
|
|
685
|
+
this.currentSectionPeak = 0;
|
|
686
|
+
this.currentSectionEnd = 0;
|
|
687
|
+
}
|
|
892
688
|
/**
|
|
893
|
-
* The
|
|
894
|
-
*/
|
|
895
|
-
currentStrain = 0;
|
|
896
|
-
/**
|
|
897
|
-
* The current section's strain peak.
|
|
898
|
-
*/
|
|
899
|
-
currentSectionPeak = 0;
|
|
900
|
-
/**
|
|
901
|
-
* Strain peaks are stored here.
|
|
902
|
-
*/
|
|
903
|
-
strainPeaks = [];
|
|
904
|
-
sectionLength = 400;
|
|
905
|
-
currentSectionEnd = 0;
|
|
906
|
-
isFirstObject = true;
|
|
907
|
-
/**
|
|
908
|
-
* Calculates the strain value of a hitobject and stores the value in it. This value is affected by previously processed objects.
|
|
909
|
-
*
|
|
910
|
-
* @param current The hitobject to process.
|
|
689
|
+
* The strains of hitobjects.
|
|
911
690
|
*/
|
|
691
|
+
get objectStrains() {
|
|
692
|
+
return this._objectStrains;
|
|
693
|
+
}
|
|
912
694
|
process(current) {
|
|
913
695
|
// The first object doesn't generate a strain, so we begin with an incremented section end
|
|
914
|
-
if (
|
|
915
|
-
this.currentSectionEnd =
|
|
916
|
-
Math.ceil(current.startTime / this.sectionLength) *
|
|
917
|
-
this.sectionLength;
|
|
918
|
-
this.isFirstObject = false;
|
|
696
|
+
if (current.index === 0) {
|
|
697
|
+
this.currentSectionEnd = this.calculateCurrentSectionStart(current);
|
|
919
698
|
}
|
|
920
699
|
while (current.startTime > this.currentSectionEnd) {
|
|
921
700
|
this.saveCurrentPeak();
|
|
@@ -926,6 +705,10 @@ class StrainSkill extends Skill {
|
|
|
926
705
|
this.currentStrain = this.strainValueAt(current);
|
|
927
706
|
this.saveToHitObject(current);
|
|
928
707
|
this.currentSectionPeak = Math.max(this.currentStrain, this.currentSectionPeak);
|
|
708
|
+
if (!current.next(0)) {
|
|
709
|
+
// Don't forget to save the last strain peak, which would otherwise be ignored.
|
|
710
|
+
this.saveCurrentPeak();
|
|
711
|
+
}
|
|
929
712
|
}
|
|
930
713
|
/**
|
|
931
714
|
* Saves the current peak strain level to the list of strain peaks, which will be used to calculate an overall difficulty.
|
|
@@ -933,6 +716,21 @@ class StrainSkill extends Skill {
|
|
|
933
716
|
saveCurrentPeak() {
|
|
934
717
|
this.strainPeaks.push(this.currentSectionPeak);
|
|
935
718
|
}
|
|
719
|
+
/**
|
|
720
|
+
* Returns the number of strains weighed against the top strain.
|
|
721
|
+
*
|
|
722
|
+
* The result is scaled by clock rate as it affects the total number of strains.
|
|
723
|
+
*/
|
|
724
|
+
countDifficultStrains() {
|
|
725
|
+
if (this.difficulty === 0) {
|
|
726
|
+
return 0;
|
|
727
|
+
}
|
|
728
|
+
// This is what the top strain is if all strain values were identical.
|
|
729
|
+
const consistentTopStrain = this.difficulty / 10;
|
|
730
|
+
// Use a weighted sum of all strains.
|
|
731
|
+
return this._objectStrains.reduce((total, next) => total +
|
|
732
|
+
1.1 / (1 + Math.exp(-10 * (next / consistentTopStrain - 0.88))), 0);
|
|
733
|
+
}
|
|
936
734
|
/**
|
|
937
735
|
* Calculates strain decay for a specified time frame.
|
|
938
736
|
*
|
|
@@ -941,18 +739,26 @@ class StrainSkill extends Skill {
|
|
|
941
739
|
strainDecay(ms) {
|
|
942
740
|
return Math.pow(this.strainDecayBase, ms / 1000);
|
|
943
741
|
}
|
|
742
|
+
/**
|
|
743
|
+
* Calculates the starting time of a strain section at an object.
|
|
744
|
+
*
|
|
745
|
+
* @param current The object at which the strain section starts.
|
|
746
|
+
* @returns The start time of the strain section.
|
|
747
|
+
*/
|
|
748
|
+
calculateCurrentSectionStart(current) {
|
|
749
|
+
return (Math.ceil(current.startTime / this.sectionLength) *
|
|
750
|
+
this.sectionLength);
|
|
751
|
+
}
|
|
944
752
|
/**
|
|
945
753
|
* Sets the initial strain level for a new section.
|
|
946
754
|
*
|
|
947
|
-
* @param
|
|
755
|
+
* @param time The beginning of the new section in milliseconds.
|
|
948
756
|
* @param current The current hitobject.
|
|
949
757
|
*/
|
|
950
|
-
startNewSectionFrom(
|
|
951
|
-
// The maximum strain of the new section is not zero by default
|
|
758
|
+
startNewSectionFrom(time, current) {
|
|
759
|
+
// The maximum strain of the new section is not zero by default.
|
|
952
760
|
// This means we need to capture the strain level at the beginning of the new section, and use that as the initial peak level.
|
|
953
|
-
this.currentSectionPeak =
|
|
954
|
-
this.currentStrain *
|
|
955
|
-
this.strainDecay(offset - current.previous(0).startTime);
|
|
761
|
+
this.currentSectionPeak = this.calculateInitialStrain(time, current);
|
|
956
762
|
}
|
|
957
763
|
}
|
|
958
764
|
|
|
@@ -961,6 +767,10 @@ class StrainSkill extends Skill {
|
|
|
961
767
|
* and to calculate a final difficulty value representing the difficulty of hitting all the processed objects.
|
|
962
768
|
*/
|
|
963
769
|
class DroidSkill extends StrainSkill {
|
|
770
|
+
process(current) {
|
|
771
|
+
super.process(current);
|
|
772
|
+
this._objectStrains.push(this.getObjectStrain(current));
|
|
773
|
+
}
|
|
964
774
|
difficultyValue() {
|
|
965
775
|
const strains = this.strainPeaks.slice();
|
|
966
776
|
if (this.reducedSectionCount > 0) {
|
|
@@ -973,12 +783,15 @@ class DroidSkill extends StrainSkill {
|
|
|
973
783
|
}
|
|
974
784
|
// Math here preserves the property that two notes of equal difficulty x, we have their summed difficulty = x * starsPerDouble.
|
|
975
785
|
// This also applies to two sets of notes with equal difficulty.
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
786
|
+
this.difficulty = 0;
|
|
787
|
+
for (const strain of strains) {
|
|
788
|
+
this.difficulty += Math.pow(strain, 1 / Math.log2(this.starsPerDouble));
|
|
789
|
+
}
|
|
790
|
+
this.difficulty = Math.pow(this.difficulty, Math.log2(this.starsPerDouble));
|
|
791
|
+
return this.difficulty;
|
|
792
|
+
}
|
|
793
|
+
calculateCurrentSectionStart(current) {
|
|
794
|
+
return current.startTime;
|
|
982
795
|
}
|
|
983
796
|
}
|
|
984
797
|
|
|
@@ -986,35 +799,40 @@ class DroidSkill extends StrainSkill {
|
|
|
986
799
|
* Represents the skill required to correctly aim at every object in the map with a uniform CircleSize and normalized distances.
|
|
987
800
|
*/
|
|
988
801
|
class DroidAim extends DroidSkill {
|
|
989
|
-
skillMultiplier = 24.55;
|
|
990
|
-
strainDecayBase = 0.15;
|
|
991
|
-
reducedSectionCount = 10;
|
|
992
|
-
reducedSectionBaseline = 0.75;
|
|
993
|
-
starsPerDouble = 1.05;
|
|
994
|
-
withSliders;
|
|
995
802
|
constructor(mods, withSliders) {
|
|
996
803
|
super(mods);
|
|
804
|
+
this.strainDecayBase = 0.15;
|
|
805
|
+
this.reducedSectionCount = 10;
|
|
806
|
+
this.reducedSectionBaseline = 0.75;
|
|
807
|
+
this.starsPerDouble = 1.05;
|
|
808
|
+
this.skillMultiplier = 24.55;
|
|
809
|
+
this.currentAimStrain = 0;
|
|
997
810
|
this.withSliders = withSliders;
|
|
998
811
|
}
|
|
999
|
-
/**
|
|
1000
|
-
* @param current The hitobject to calculate.
|
|
1001
|
-
*/
|
|
1002
812
|
strainValueAt(current) {
|
|
1003
|
-
this.
|
|
1004
|
-
this.
|
|
813
|
+
this.currentAimStrain *= this.strainDecay(current.deltaTime);
|
|
814
|
+
this.currentAimStrain +=
|
|
1005
815
|
DroidAimEvaluator.evaluateDifficultyOf(current, this.withSliders) *
|
|
1006
816
|
this.skillMultiplier;
|
|
1007
|
-
return this.
|
|
817
|
+
return this.currentAimStrain;
|
|
818
|
+
}
|
|
819
|
+
calculateInitialStrain(time, current) {
|
|
820
|
+
var _a, _b;
|
|
821
|
+
return (this.currentAimStrain *
|
|
822
|
+
this.strainDecay(time - ((_b = (_a = current.previous(0)) === null || _a === void 0 ? void 0 : _a.startTime) !== null && _b !== void 0 ? _b : 0)));
|
|
823
|
+
}
|
|
824
|
+
getObjectStrain() {
|
|
825
|
+
return this.currentAimStrain;
|
|
1008
826
|
}
|
|
1009
827
|
/**
|
|
1010
828
|
* @param current The hitobject to save to.
|
|
1011
829
|
*/
|
|
1012
830
|
saveToHitObject(current) {
|
|
1013
831
|
if (this.withSliders) {
|
|
1014
|
-
current.aimStrainWithSliders = this.
|
|
832
|
+
current.aimStrainWithSliders = this.currentAimStrain;
|
|
1015
833
|
}
|
|
1016
834
|
else {
|
|
1017
|
-
current.aimStrainWithoutSliders = this.
|
|
835
|
+
current.aimStrainWithoutSliders = this.currentAimStrain;
|
|
1018
836
|
}
|
|
1019
837
|
}
|
|
1020
838
|
}
|
|
@@ -1025,9 +843,9 @@ class DroidAim extends DroidSkill {
|
|
|
1025
843
|
* This class should be considered an "evaluating" class and not persisted.
|
|
1026
844
|
*/
|
|
1027
845
|
class SpeedEvaluator {
|
|
1028
|
-
// ~200 1/4 BPM streams
|
|
1029
|
-
static minSpeedBonus = 75;
|
|
1030
846
|
}
|
|
847
|
+
// ~200 1/4 BPM streams
|
|
848
|
+
SpeedEvaluator.minSpeedBonus = 75;
|
|
1031
849
|
|
|
1032
850
|
/**
|
|
1033
851
|
* An evaluator for calculating osu!droid tap skill.
|
|
@@ -1038,43 +856,35 @@ class DroidTapEvaluator extends SpeedEvaluator {
|
|
|
1038
856
|
*
|
|
1039
857
|
* - time between pressing the previous and current object,
|
|
1040
858
|
* - distance between those objects,
|
|
1041
|
-
* -
|
|
859
|
+
* - how easily they can be cheesed,
|
|
860
|
+
* - and the strain time cap.
|
|
1042
861
|
*
|
|
1043
862
|
* @param current The current object.
|
|
1044
863
|
* @param greatWindow The great hit window of the current object.
|
|
1045
864
|
* @param considerCheesability Whether to consider cheesability.
|
|
865
|
+
* @param strainTimeCap The strain time to cap the object's strain time to.
|
|
1046
866
|
*/
|
|
1047
|
-
static evaluateDifficultyOf(current,
|
|
867
|
+
static evaluateDifficultyOf(current, considerCheesability, strainTimeCap) {
|
|
1048
868
|
if (current.object instanceof osuBase.Spinner ||
|
|
1049
869
|
// Exclude overlapping objects that can be tapped at once.
|
|
1050
870
|
current.isOverlapping(false)) {
|
|
1051
871
|
return 0;
|
|
1052
872
|
}
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
const nextDeltaTime = Math.max(1, next.deltaTime);
|
|
1062
|
-
const deltaDifference = Math.abs(nextDeltaTime - currentDeltaTime);
|
|
1063
|
-
const speedRatio = currentDeltaTime /
|
|
1064
|
-
Math.max(currentDeltaTime, deltaDifference);
|
|
1065
|
-
const windowRatio = Math.pow(Math.min(1, currentDeltaTime / greatWindowFull), 2);
|
|
1066
|
-
doubletapness = Math.pow(speedRatio, 1 - windowRatio);
|
|
1067
|
-
}
|
|
1068
|
-
// Cap deltatime to the OD 300 hitwindow.
|
|
1069
|
-
// 0.58 is derived from making sure 260 BPM 1/4 OD5 streams aren't nerfed harshly, whilst 0.91 limits the effect of the cap.
|
|
1070
|
-
strainTime /= osuBase.MathUtils.clamp(strainTime / greatWindowFull / 0.58, 0.91, 1);
|
|
1071
|
-
}
|
|
873
|
+
// Nerf doubletappable doubles.
|
|
874
|
+
const doubletapness = considerCheesability
|
|
875
|
+
? 1 - current.doubletapness
|
|
876
|
+
: 1;
|
|
877
|
+
const strainTime = strainTimeCap !== undefined
|
|
878
|
+
? // We cap the strain time to 50 here as the chance of vibro is higher in any BPM higher than 300.
|
|
879
|
+
Math.max(50, strainTimeCap, current.strainTime)
|
|
880
|
+
: current.strainTime;
|
|
1072
881
|
let speedBonus = 1;
|
|
1073
882
|
if (strainTime < this.minSpeedBonus) {
|
|
1074
883
|
speedBonus +=
|
|
1075
|
-
0.75 *
|
|
884
|
+
0.75 *
|
|
885
|
+
Math.pow(osuBase.ErrorFunction.erf((this.minSpeedBonus - strainTime) / 40), 2);
|
|
1076
886
|
}
|
|
1077
|
-
return (speedBonus * doubletapness) / strainTime;
|
|
887
|
+
return (speedBonus * Math.pow(doubletapness, 1.5)) / strainTime;
|
|
1078
888
|
}
|
|
1079
889
|
}
|
|
1080
890
|
|
|
@@ -1082,38 +892,88 @@ class DroidTapEvaluator extends SpeedEvaluator {
|
|
|
1082
892
|
* Represents the skill required to press keys or tap with regards to keeping up with the speed at which objects need to be hit.
|
|
1083
893
|
*/
|
|
1084
894
|
class DroidTap extends DroidSkill {
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
greatWindow;
|
|
1093
|
-
constructor(mods, overallDifficulty) {
|
|
895
|
+
/**
|
|
896
|
+
* The delta time of hitobjects.
|
|
897
|
+
*/
|
|
898
|
+
get objectDeltaTimes() {
|
|
899
|
+
return this._objectDeltaTimes;
|
|
900
|
+
}
|
|
901
|
+
constructor(mods, considerCheesability, strainTimeCap) {
|
|
1094
902
|
super(mods);
|
|
1095
|
-
this.
|
|
903
|
+
this.reducedSectionCount = 10;
|
|
904
|
+
this.reducedSectionBaseline = 0.75;
|
|
905
|
+
this.strainDecayBase = 0.3;
|
|
906
|
+
this.starsPerDouble = 1.1;
|
|
907
|
+
this.currentTapStrain = 0;
|
|
908
|
+
this.currentRhythmMultiplier = 0;
|
|
909
|
+
this.skillMultiplier = 1375;
|
|
910
|
+
this._objectDeltaTimes = [];
|
|
911
|
+
this.considerCheesability = considerCheesability;
|
|
912
|
+
this.strainTimeCap = strainTimeCap;
|
|
913
|
+
}
|
|
914
|
+
/**
|
|
915
|
+
* The amount of notes that are relevant to the difficulty.
|
|
916
|
+
*/
|
|
917
|
+
relevantNoteCount() {
|
|
918
|
+
if (this._objectStrains.length === 0) {
|
|
919
|
+
return 0;
|
|
920
|
+
}
|
|
921
|
+
const maxStrain = Math.max(...this._objectStrains);
|
|
922
|
+
if (maxStrain === 0) {
|
|
923
|
+
return 0;
|
|
924
|
+
}
|
|
925
|
+
return this._objectStrains.reduce((total, next) => total + 1 / (1 + Math.exp(-((next / maxStrain) * 12 - 6))), 0);
|
|
1096
926
|
}
|
|
1097
927
|
/**
|
|
1098
|
-
*
|
|
928
|
+
* The delta time relevant to the difficulty.
|
|
1099
929
|
*/
|
|
930
|
+
relevantDeltaTime() {
|
|
931
|
+
if (this._objectStrains.length === 0) {
|
|
932
|
+
return 0;
|
|
933
|
+
}
|
|
934
|
+
const maxStrain = Math.max(...this._objectStrains);
|
|
935
|
+
if (maxStrain === 0) {
|
|
936
|
+
return 0;
|
|
937
|
+
}
|
|
938
|
+
return (this._objectDeltaTimes.reduce((total, next, index) => total +
|
|
939
|
+
(next * 1) /
|
|
940
|
+
(1 +
|
|
941
|
+
Math.exp(-((this._objectStrains[index] / maxStrain) *
|
|
942
|
+
25 -
|
|
943
|
+
20))), 0) /
|
|
944
|
+
this._objectStrains.reduce((total, next) => total + 1 / (1 + Math.exp(-((next / maxStrain) * 25 - 20))), 0));
|
|
945
|
+
}
|
|
1100
946
|
strainValueAt(current) {
|
|
1101
|
-
|
|
1102
|
-
this.currentTapStrain *= decay;
|
|
947
|
+
this.currentTapStrain *= this.strainDecay(current.strainTime);
|
|
1103
948
|
this.currentTapStrain +=
|
|
1104
|
-
DroidTapEvaluator.evaluateDifficultyOf(current, this.
|
|
1105
|
-
this.
|
|
1106
|
-
this.
|
|
1107
|
-
DroidTapEvaluator.evaluateDifficultyOf(current, this.greatWindow, false) * this.skillMultiplier;
|
|
1108
|
-
this.currentOriginalTapStrain *= current.rhythmMultiplier;
|
|
949
|
+
DroidTapEvaluator.evaluateDifficultyOf(current, this.considerCheesability, this.strainTimeCap) * this.skillMultiplier;
|
|
950
|
+
this.currentRhythmMultiplier = current.rhythmMultiplier;
|
|
951
|
+
this._objectDeltaTimes.push(current.deltaTime);
|
|
1109
952
|
return this.currentTapStrain * current.rhythmMultiplier;
|
|
1110
953
|
}
|
|
954
|
+
calculateInitialStrain(time, current) {
|
|
955
|
+
var _a, _b;
|
|
956
|
+
return (this.currentTapStrain *
|
|
957
|
+
this.currentRhythmMultiplier *
|
|
958
|
+
this.strainDecay(time - ((_b = (_a = current.previous(0)) === null || _a === void 0 ? void 0 : _a.startTime) !== null && _b !== void 0 ? _b : 0)));
|
|
959
|
+
}
|
|
960
|
+
getObjectStrain() {
|
|
961
|
+
return this.currentTapStrain * this.currentRhythmMultiplier;
|
|
962
|
+
}
|
|
1111
963
|
/**
|
|
1112
964
|
* @param current The hitobject to save to.
|
|
1113
965
|
*/
|
|
1114
966
|
saveToHitObject(current) {
|
|
1115
|
-
|
|
1116
|
-
|
|
967
|
+
if (this.strainTimeCap !== undefined) {
|
|
968
|
+
return;
|
|
969
|
+
}
|
|
970
|
+
const strain = this.currentTapStrain * this.currentRhythmMultiplier;
|
|
971
|
+
if (this.considerCheesability) {
|
|
972
|
+
current.tapStrain = strain;
|
|
973
|
+
}
|
|
974
|
+
else {
|
|
975
|
+
current.originalTapStrain = strain;
|
|
976
|
+
}
|
|
1117
977
|
}
|
|
1118
978
|
}
|
|
1119
979
|
|
|
@@ -1123,12 +983,12 @@ class DroidTap extends DroidSkill {
|
|
|
1123
983
|
* This class should be considered an "evaluating" class and not persisted.
|
|
1124
984
|
*/
|
|
1125
985
|
class FlashlightEvaluator {
|
|
1126
|
-
static maxOpacityBonus = 0.4;
|
|
1127
|
-
static hiddenBonus = 0.2;
|
|
1128
|
-
static minVelocity = 0.5;
|
|
1129
|
-
static sliderMultiplier = 1.3;
|
|
1130
|
-
static minAngleMultiplier = 0.2;
|
|
1131
986
|
}
|
|
987
|
+
FlashlightEvaluator.maxOpacityBonus = 0.4;
|
|
988
|
+
FlashlightEvaluator.hiddenBonus = 0.2;
|
|
989
|
+
FlashlightEvaluator.minVelocity = 0.5;
|
|
990
|
+
FlashlightEvaluator.sliderMultiplier = 1.3;
|
|
991
|
+
FlashlightEvaluator.minAngleMultiplier = 0.2;
|
|
1132
992
|
|
|
1133
993
|
/**
|
|
1134
994
|
* An evaluator for calculating osu!droid Flashlight skill.
|
|
@@ -1153,7 +1013,7 @@ class DroidFlashlightEvaluator extends FlashlightEvaluator {
|
|
|
1153
1013
|
current.isOverlapping(true)) {
|
|
1154
1014
|
return 0;
|
|
1155
1015
|
}
|
|
1156
|
-
const scalingFactor = 52 / current.object.
|
|
1016
|
+
const scalingFactor = 52 / current.object.radius;
|
|
1157
1017
|
let smallDistNerf = 1;
|
|
1158
1018
|
let cumulativeStrainTime = 0;
|
|
1159
1019
|
let result = 0;
|
|
@@ -1178,7 +1038,7 @@ class DroidFlashlightEvaluator extends FlashlightEvaluator {
|
|
|
1178
1038
|
const opacityBonus = 1 +
|
|
1179
1039
|
this.maxOpacityBonus *
|
|
1180
1040
|
(1 -
|
|
1181
|
-
current.opacityAt(currentObject.object.startTime, isHiddenMod
|
|
1041
|
+
current.opacityAt(currentObject.object.startTime, isHiddenMod));
|
|
1182
1042
|
result +=
|
|
1183
1043
|
(stackNerf * opacityBonus * scalingFactor * jumpDistance) /
|
|
1184
1044
|
cumulativeStrainTime;
|
|
@@ -1209,8 +1069,8 @@ class DroidFlashlightEvaluator extends FlashlightEvaluator {
|
|
|
1209
1069
|
// Longer sliders require more memorization.
|
|
1210
1070
|
sliderBonus *= pixelTravelDistance;
|
|
1211
1071
|
// Nerf sliders with repeats, as less memorization is required.
|
|
1212
|
-
if (current.object.
|
|
1213
|
-
sliderBonus /= current.object.
|
|
1072
|
+
if (current.object.repeatCount > 0)
|
|
1073
|
+
sliderBonus /= current.object.repeatCount + 1;
|
|
1214
1074
|
}
|
|
1215
1075
|
result += sliderBonus * this.sliderMultiplier;
|
|
1216
1076
|
return result;
|
|
@@ -1221,72 +1081,98 @@ class DroidFlashlightEvaluator extends FlashlightEvaluator {
|
|
|
1221
1081
|
* Represents the skill required to memorize and hit every object in a beatmap with the Flashlight mod enabled.
|
|
1222
1082
|
*/
|
|
1223
1083
|
class DroidFlashlight extends DroidSkill {
|
|
1224
|
-
skillMultiplier = 0.125;
|
|
1225
|
-
strainDecayBase = 0.15;
|
|
1226
|
-
reducedSectionCount = 0;
|
|
1227
|
-
reducedSectionBaseline = 1;
|
|
1228
|
-
starsPerDouble = 1.05;
|
|
1229
|
-
isHidden;
|
|
1230
|
-
withSliders;
|
|
1231
1084
|
constructor(mods, withSliders) {
|
|
1232
1085
|
super(mods);
|
|
1086
|
+
this.strainDecayBase = 0.15;
|
|
1087
|
+
this.reducedSectionCount = 0;
|
|
1088
|
+
this.reducedSectionBaseline = 1;
|
|
1089
|
+
this.starsPerDouble = 1.06;
|
|
1090
|
+
this.skillMultiplier = 0.02;
|
|
1091
|
+
this.currentFlashlightStrain = 0;
|
|
1233
1092
|
this.isHidden = mods.some((m) => m instanceof osuBase.ModHidden);
|
|
1234
1093
|
this.withSliders = withSliders;
|
|
1235
1094
|
}
|
|
1236
|
-
/**
|
|
1237
|
-
* @param current The hitobject to calculate.
|
|
1238
|
-
*/
|
|
1239
1095
|
strainValueAt(current) {
|
|
1240
|
-
this.
|
|
1241
|
-
this.
|
|
1096
|
+
this.currentFlashlightStrain *= this.strainDecay(current.deltaTime);
|
|
1097
|
+
this.currentFlashlightStrain +=
|
|
1242
1098
|
DroidFlashlightEvaluator.evaluateDifficultyOf(current, this.isHidden, this.withSliders) * this.skillMultiplier;
|
|
1243
|
-
return this.
|
|
1099
|
+
return this.currentFlashlightStrain;
|
|
1100
|
+
}
|
|
1101
|
+
calculateInitialStrain(time, current) {
|
|
1102
|
+
var _a, _b;
|
|
1103
|
+
return (this.currentFlashlightStrain *
|
|
1104
|
+
this.strainDecay(time - ((_b = (_a = current.previous(0)) === null || _a === void 0 ? void 0 : _a.startTime) !== null && _b !== void 0 ? _b : 0)));
|
|
1105
|
+
}
|
|
1106
|
+
getObjectStrain() {
|
|
1107
|
+
return this.currentFlashlightStrain;
|
|
1244
1108
|
}
|
|
1245
1109
|
saveToHitObject(current) {
|
|
1246
1110
|
if (this.withSliders) {
|
|
1247
|
-
current.flashlightStrainWithSliders = this.
|
|
1111
|
+
current.flashlightStrainWithSliders = this.currentFlashlightStrain;
|
|
1248
1112
|
}
|
|
1249
1113
|
else {
|
|
1250
|
-
current.flashlightStrainWithoutSliders =
|
|
1114
|
+
current.flashlightStrainWithoutSliders =
|
|
1115
|
+
this.currentFlashlightStrain;
|
|
1251
1116
|
}
|
|
1252
1117
|
}
|
|
1118
|
+
difficultyValue() {
|
|
1119
|
+
return (this.strainPeaks.reduce((a, v) => a + v, 0) * this.starsPerDouble);
|
|
1120
|
+
}
|
|
1253
1121
|
}
|
|
1254
1122
|
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1123
|
+
class Island {
|
|
1124
|
+
constructor(delta, deltaDifferenceEpsilon) {
|
|
1125
|
+
this.delta = Number.MAX_SAFE_INTEGER;
|
|
1126
|
+
this.deltaCount = 0;
|
|
1127
|
+
if (deltaDifferenceEpsilon === undefined) {
|
|
1128
|
+
this.deltaDifferenceEpsilon = delta;
|
|
1129
|
+
}
|
|
1130
|
+
else {
|
|
1131
|
+
this.deltaDifferenceEpsilon = deltaDifferenceEpsilon;
|
|
1132
|
+
this.addDelta(delta);
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
addDelta(delta) {
|
|
1136
|
+
if (this.delta === Number.MAX_SAFE_INTEGER) {
|
|
1137
|
+
this.delta = Math.max(Math.trunc(delta), DifficultyHitObject.minDeltaTime);
|
|
1138
|
+
}
|
|
1139
|
+
++this.deltaCount;
|
|
1140
|
+
}
|
|
1141
|
+
isSimilarPolarity(other) {
|
|
1142
|
+
// TODO: consider islands to be of similar polarity only if they're having the same average delta (we don't want to consider 3 singletaps similar to a triple)
|
|
1143
|
+
// naively adding delta check here breaks _a lot_ of maps because of the flawed ratio calculation
|
|
1144
|
+
return this.deltaCount % 2 == other.deltaCount % 2;
|
|
1145
|
+
}
|
|
1146
|
+
equals(other) {
|
|
1147
|
+
return (Math.abs(this.delta - other.delta) < this.deltaDifferenceEpsilon &&
|
|
1148
|
+
this.deltaCount === other.deltaCount);
|
|
1149
|
+
}
|
|
1263
1150
|
}
|
|
1264
1151
|
|
|
1265
1152
|
/**
|
|
1266
1153
|
* An evaluator for calculating osu!droid Rhythm skill.
|
|
1267
1154
|
*/
|
|
1268
|
-
class DroidRhythmEvaluator
|
|
1155
|
+
class DroidRhythmEvaluator {
|
|
1269
1156
|
/**
|
|
1270
1157
|
* Calculates a rhythm multiplier for the difficulty of the tap associated
|
|
1271
1158
|
* with historic data of the current object.
|
|
1272
1159
|
*
|
|
1273
1160
|
* @param current The current object.
|
|
1274
|
-
* @param greatWindow The great hit window of the current object.
|
|
1275
1161
|
*/
|
|
1276
|
-
static evaluateDifficultyOf(current
|
|
1277
|
-
if (current.object instanceof osuBase.Spinner
|
|
1278
|
-
// Exclude overlapping objects that can be tapped at once.
|
|
1279
|
-
current.isOverlapping(false)) {
|
|
1162
|
+
static evaluateDifficultyOf(current) {
|
|
1163
|
+
if (current.object instanceof osuBase.Spinner) {
|
|
1280
1164
|
return 1;
|
|
1281
1165
|
}
|
|
1282
|
-
|
|
1166
|
+
const deltaDifferenceEpsilon = current.fullGreatWindow * 0.3;
|
|
1283
1167
|
let rhythmComplexitySum = 0;
|
|
1284
|
-
let
|
|
1168
|
+
let island = new Island(deltaDifferenceEpsilon);
|
|
1169
|
+
let previousIsland = new Island(deltaDifferenceEpsilon);
|
|
1170
|
+
const islandCounts = new Map();
|
|
1285
1171
|
// Store the ratio of the current start of an island to buff for tighter rhythms.
|
|
1286
1172
|
let startRatio = 0;
|
|
1287
1173
|
let firstDeltaSwitch = false;
|
|
1288
1174
|
let rhythmStart = 0;
|
|
1289
|
-
const historicalNoteCount = Math.min(current.index,
|
|
1175
|
+
const historicalNoteCount = Math.min(current.index, this.historyObjectsMax);
|
|
1290
1176
|
// Exclude overlapping objects that can be tapped at once.
|
|
1291
1177
|
const validPrevious = [];
|
|
1292
1178
|
for (let i = 0; i < historicalNoteCount; ++i) {
|
|
@@ -1304,123 +1190,160 @@ class DroidRhythmEvaluator extends RhythmEvaluator {
|
|
|
1304
1190
|
++rhythmStart;
|
|
1305
1191
|
}
|
|
1306
1192
|
for (let i = rhythmStart; i > 0; --i) {
|
|
1193
|
+
const currentObject = validPrevious[i - 1];
|
|
1194
|
+
const prevObject = validPrevious[i];
|
|
1195
|
+
const lastObject = validPrevious[i + 1];
|
|
1307
1196
|
// Scale note 0 to 1 from history to now.
|
|
1308
|
-
|
|
1309
|
-
(current.startTime -
|
|
1197
|
+
const timeDecay = (this.historyTimeMax -
|
|
1198
|
+
(current.startTime - currentObject.startTime)) /
|
|
1310
1199
|
this.historyTimeMax;
|
|
1200
|
+
const noteDecay = (validPrevious.length - i) / validPrevious.length;
|
|
1311
1201
|
// Either we're limited by time or limited by object count.
|
|
1312
|
-
currentHistoricalDecay = Math.min(
|
|
1313
|
-
const currentDelta =
|
|
1314
|
-
const prevDelta =
|
|
1315
|
-
const lastDelta =
|
|
1202
|
+
const currentHistoricalDecay = Math.min(timeDecay, noteDecay);
|
|
1203
|
+
const currentDelta = currentObject.strainTime;
|
|
1204
|
+
const prevDelta = prevObject.strainTime;
|
|
1205
|
+
const lastDelta = lastObject.strainTime;
|
|
1206
|
+
// Calculate how much current delta difference deserves a rhythm bonus
|
|
1207
|
+
// This function is meant to reduce rhythm bonus for deltas that are multiples of each other (i.e. 100 and 200)
|
|
1208
|
+
const deltaDifferenceRatio = Math.min(prevDelta, currentDelta) /
|
|
1209
|
+
Math.max(prevDelta, currentDelta);
|
|
1316
1210
|
const currentRatio = 1 +
|
|
1317
|
-
|
|
1318
|
-
Math.min(0.5, Math.pow(Math.sin(Math.PI /
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
const
|
|
1322
|
-
|
|
1323
|
-
let effectiveRatio = windowPenalty * currentRatio;
|
|
1211
|
+
this.rhythmRatioMultiplier *
|
|
1212
|
+
Math.min(0.5, Math.pow(Math.sin(Math.PI / deltaDifferenceRatio), 2));
|
|
1213
|
+
// Reduce ratio bonus if delta difference is too big
|
|
1214
|
+
const fraction = Math.max(prevDelta / currentDelta, currentDelta / prevDelta);
|
|
1215
|
+
const fractionMultiplier = osuBase.MathUtils.clamp(2 - fraction / 8, 0, 1);
|
|
1216
|
+
const windowPenalty = Math.min(1, Math.max(0, Math.abs(prevDelta - currentDelta) - deltaDifferenceEpsilon) / deltaDifferenceEpsilon);
|
|
1217
|
+
let effectiveRatio = windowPenalty * currentRatio * fractionMultiplier;
|
|
1324
1218
|
if (firstDeltaSwitch) {
|
|
1325
|
-
if (prevDelta
|
|
1326
|
-
prevDelta * 1.25 >= currentDelta) {
|
|
1219
|
+
if (Math.abs(prevDelta - currentDelta) < deltaDifferenceEpsilon) {
|
|
1327
1220
|
// Island is still progressing, count size.
|
|
1328
|
-
|
|
1329
|
-
++islandSize;
|
|
1330
|
-
}
|
|
1221
|
+
island.addDelta(currentDelta);
|
|
1331
1222
|
}
|
|
1332
1223
|
else {
|
|
1333
|
-
|
|
1334
|
-
|
|
1224
|
+
// BPM change is into slider, this is easy acc window.
|
|
1225
|
+
if (currentObject.object instanceof osuBase.Slider) {
|
|
1335
1226
|
effectiveRatio /= 8;
|
|
1336
1227
|
}
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
// Repeated island size (ex: triplet -> triplet).
|
|
1343
|
-
effectiveRatio /= 4;
|
|
1228
|
+
// BPM change was from a slider, this is easier typically than circle -> circle.
|
|
1229
|
+
// Unintentional side effect is that bursts with kicksliders at the ends might have lower difficulty
|
|
1230
|
+
// than bursts without sliders.
|
|
1231
|
+
if (prevObject.object instanceof osuBase.Slider) {
|
|
1232
|
+
effectiveRatio *= 0.3;
|
|
1344
1233
|
}
|
|
1345
|
-
|
|
1346
|
-
|
|
1234
|
+
// Repeated island polarity (2 -> 4, 3 -> 5).
|
|
1235
|
+
if (island.isSimilarPolarity(previousIsland)) {
|
|
1347
1236
|
effectiveRatio /= 2;
|
|
1348
1237
|
}
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1238
|
+
// Previous increase happened a note ago.
|
|
1239
|
+
// Albeit this is a 1/1 -> 1/2-1/4 type of transition, we don't want to buff this.
|
|
1240
|
+
if (lastDelta > prevDelta + deltaDifferenceEpsilon &&
|
|
1241
|
+
prevDelta > currentDelta + deltaDifferenceEpsilon) {
|
|
1353
1242
|
effectiveRatio /= 8;
|
|
1354
1243
|
}
|
|
1244
|
+
// Repeated island size (ex: triplet -> triplet).
|
|
1245
|
+
// TODO: remove this nerf since its staying here only for balancing purposes because of the flawed ratio calculation
|
|
1246
|
+
if (previousIsland.deltaCount == island.deltaCount) {
|
|
1247
|
+
effectiveRatio /= 2;
|
|
1248
|
+
}
|
|
1249
|
+
let islandFound = false;
|
|
1250
|
+
for (const [currentIsland, count] of islandCounts) {
|
|
1251
|
+
if (!island.equals(currentIsland)) {
|
|
1252
|
+
continue;
|
|
1253
|
+
}
|
|
1254
|
+
islandFound = true;
|
|
1255
|
+
let islandCount = count;
|
|
1256
|
+
if (previousIsland.equals(island)) {
|
|
1257
|
+
// Only add island to island counts if they're going one after another.
|
|
1258
|
+
++islandCount;
|
|
1259
|
+
islandCounts.set(currentIsland, islandCount);
|
|
1260
|
+
}
|
|
1261
|
+
// Repeated island (ex: triplet -> triplet).
|
|
1262
|
+
// Graph: https://www.desmos.com/calculator/pj7an56zwf
|
|
1263
|
+
effectiveRatio *= Math.min(3 / islandCount, Math.pow(1 / islandCount, 2.75 / (1 + Math.exp(14 - 0.24 * island.delta))));
|
|
1264
|
+
break;
|
|
1265
|
+
}
|
|
1266
|
+
if (!islandFound) {
|
|
1267
|
+
islandCounts.set(island, 1);
|
|
1268
|
+
}
|
|
1269
|
+
// Scale down the difficulty if the object is doubletappable.
|
|
1270
|
+
effectiveRatio *= 1 - prevObject.doubletapness * 0.75;
|
|
1355
1271
|
rhythmComplexitySum +=
|
|
1356
|
-
|
|
1357
|
-
currentHistoricalDecay
|
|
1358
|
-
Math.sqrt(4 + islandSize)) /
|
|
1359
|
-
2) *
|
|
1360
|
-
Math.sqrt(4 + previousIslandSize)) /
|
|
1361
|
-
2;
|
|
1272
|
+
Math.sqrt(effectiveRatio * startRatio) *
|
|
1273
|
+
currentHistoricalDecay;
|
|
1362
1274
|
startRatio = effectiveRatio;
|
|
1363
|
-
|
|
1364
|
-
if (prevDelta
|
|
1275
|
+
previousIsland = island;
|
|
1276
|
+
if (prevDelta + deltaDifferenceEpsilon < currentDelta) {
|
|
1365
1277
|
// We're slowing down, stop counting.
|
|
1366
1278
|
// If we're speeding up, this stays as is and we keep counting island size.
|
|
1367
1279
|
firstDeltaSwitch = false;
|
|
1368
1280
|
}
|
|
1369
|
-
|
|
1281
|
+
island = new Island(currentDelta, deltaDifferenceEpsilon);
|
|
1370
1282
|
}
|
|
1371
1283
|
}
|
|
1372
|
-
else if (prevDelta >
|
|
1373
|
-
// We
|
|
1284
|
+
else if (prevDelta > currentDelta + deltaDifferenceEpsilon) {
|
|
1285
|
+
// We are speeding up.
|
|
1374
1286
|
// Begin counting island until we change speed again.
|
|
1375
1287
|
firstDeltaSwitch = true;
|
|
1288
|
+
// BPM change is into slider, this is easy acc window.
|
|
1289
|
+
if (currentObject.object instanceof osuBase.Slider) {
|
|
1290
|
+
effectiveRatio *= 0.6;
|
|
1291
|
+
}
|
|
1292
|
+
// BPM change was from a slider, this is easier typically than circle -> circle
|
|
1293
|
+
// Unintentional side effect is that bursts with kicksliders at the ends might have lower difficulty
|
|
1294
|
+
// than bursts without sliders
|
|
1295
|
+
if (prevObject.object instanceof osuBase.Slider) {
|
|
1296
|
+
effectiveRatio *= 0.6;
|
|
1297
|
+
}
|
|
1376
1298
|
startRatio = effectiveRatio;
|
|
1377
|
-
|
|
1299
|
+
island = new Island(currentDelta, deltaDifferenceEpsilon);
|
|
1378
1300
|
}
|
|
1379
1301
|
}
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
let doubletapness = 1;
|
|
1383
|
-
if (next) {
|
|
1384
|
-
const currentDeltaTime = Math.max(1, current.deltaTime);
|
|
1385
|
-
const nextDeltaTime = Math.max(1, next.deltaTime);
|
|
1386
|
-
const deltaDifference = Math.abs(nextDeltaTime - currentDeltaTime);
|
|
1387
|
-
const speedRatio = currentDeltaTime / Math.max(currentDeltaTime, deltaDifference);
|
|
1388
|
-
const windowRatio = Math.pow(Math.min(1, currentDeltaTime / (greatWindow * 2)), 2);
|
|
1389
|
-
doubletapness = Math.pow(speedRatio, 1 - windowRatio);
|
|
1390
|
-
}
|
|
1391
|
-
return (Math.sqrt(4 + rhythmComplexitySum * this.rhythmMultiplier * doubletapness) / 2);
|
|
1302
|
+
return (Math.sqrt(4 + rhythmComplexitySum * this.rhythmOverallMultiplier) /
|
|
1303
|
+
2);
|
|
1392
1304
|
}
|
|
1393
1305
|
}
|
|
1306
|
+
DroidRhythmEvaluator.historyTimeMax = 5000; // 5 seconds of calculateRhythmBonus max.
|
|
1307
|
+
DroidRhythmEvaluator.historyObjectsMax = 32;
|
|
1308
|
+
DroidRhythmEvaluator.rhythmOverallMultiplier = 0.95;
|
|
1309
|
+
DroidRhythmEvaluator.rhythmRatioMultiplier = 12;
|
|
1394
1310
|
|
|
1395
1311
|
/**
|
|
1396
1312
|
* Represents the skill required to properly follow a beatmap's rhythm.
|
|
1397
1313
|
*/
|
|
1398
1314
|
class DroidRhythm extends DroidSkill {
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
super(mods);
|
|
1408
|
-
this.hitWindow = new osuBase.OsuHitWindow(overallDifficulty);
|
|
1315
|
+
constructor() {
|
|
1316
|
+
super(...arguments);
|
|
1317
|
+
this.reducedSectionCount = 5;
|
|
1318
|
+
this.reducedSectionBaseline = 0.75;
|
|
1319
|
+
this.strainDecayBase = 0.3;
|
|
1320
|
+
this.starsPerDouble = 1.75;
|
|
1321
|
+
this.currentRhythmStrain = 0;
|
|
1322
|
+
this.currentRhythmMultiplier = 1;
|
|
1409
1323
|
}
|
|
1410
1324
|
strainValueAt(current) {
|
|
1411
|
-
this.
|
|
1412
|
-
|
|
1413
|
-
this.
|
|
1414
|
-
|
|
1325
|
+
this.currentRhythmMultiplier =
|
|
1326
|
+
DroidRhythmEvaluator.evaluateDifficultyOf(current);
|
|
1327
|
+
this.currentRhythmStrain *= this.strainDecay(current.deltaTime);
|
|
1328
|
+
this.currentRhythmStrain += this.currentRhythmMultiplier - 1;
|
|
1329
|
+
return this.currentRhythmStrain;
|
|
1330
|
+
}
|
|
1331
|
+
calculateInitialStrain(time, current) {
|
|
1332
|
+
var _a, _b;
|
|
1333
|
+
return (this.currentRhythmStrain *
|
|
1334
|
+
this.strainDecay(time - ((_b = (_a = current.previous(0)) === null || _a === void 0 ? void 0 : _a.startTime) !== null && _b !== void 0 ? _b : 0)));
|
|
1335
|
+
}
|
|
1336
|
+
getObjectStrain() {
|
|
1337
|
+
return this.currentRhythmStrain;
|
|
1415
1338
|
}
|
|
1416
1339
|
saveToHitObject(current) {
|
|
1417
|
-
current.rhythmStrain = this.
|
|
1418
|
-
current.rhythmMultiplier = this.
|
|
1340
|
+
current.rhythmStrain = this.currentRhythmStrain;
|
|
1341
|
+
current.rhythmMultiplier = this.currentRhythmMultiplier;
|
|
1419
1342
|
}
|
|
1420
1343
|
}
|
|
1421
1344
|
|
|
1422
1345
|
/**
|
|
1423
|
-
* An evaluator for calculating osu!droid
|
|
1346
|
+
* An evaluator for calculating osu!droid visual skill.
|
|
1424
1347
|
*/
|
|
1425
1348
|
class DroidVisualEvaluator {
|
|
1426
1349
|
/**
|
|
@@ -1441,7 +1364,8 @@ class DroidVisualEvaluator {
|
|
|
1441
1364
|
static evaluateDifficultyOf(current, isHiddenMod, withSliders) {
|
|
1442
1365
|
if (current.object instanceof osuBase.Spinner ||
|
|
1443
1366
|
// Exclude overlapping objects that can be tapped at once.
|
|
1444
|
-
current.isOverlapping(true)
|
|
1367
|
+
current.isOverlapping(true) ||
|
|
1368
|
+
current.index === 0) {
|
|
1445
1369
|
return 0;
|
|
1446
1370
|
}
|
|
1447
1371
|
// Start with base density and give global bonus for Hidden.
|
|
@@ -1463,28 +1387,31 @@ class DroidVisualEvaluator {
|
|
|
1463
1387
|
}
|
|
1464
1388
|
// Do not consider objects that don't fall under time preempt.
|
|
1465
1389
|
if (current.object.startTime - previous.object.endTime >
|
|
1466
|
-
current.
|
|
1390
|
+
current.object.timePreempt) {
|
|
1467
1391
|
break;
|
|
1468
1392
|
}
|
|
1469
1393
|
strain +=
|
|
1470
1394
|
(1 -
|
|
1471
|
-
current.opacityAt(previous.object.startTime, isHiddenMod
|
|
1395
|
+
current.opacityAt(previous.object.startTime, isHiddenMod)) /
|
|
1472
1396
|
4;
|
|
1473
1397
|
}
|
|
1474
|
-
// Scale the value with overlapping factor.
|
|
1475
|
-
strain /= 10 * (1 + current.overlappingFactor);
|
|
1476
1398
|
if (current.timePreempt < 400) {
|
|
1477
1399
|
// Give bonus for AR higher than 10.33.
|
|
1478
|
-
strain += Math.pow(400 - current.timePreempt, 1.
|
|
1400
|
+
strain += Math.pow(400 - current.timePreempt, 1.35) / 100;
|
|
1479
1401
|
}
|
|
1402
|
+
// Scale the value with overlapping factor.
|
|
1403
|
+
strain /= 10 * (1 + current.overlappingFactor);
|
|
1480
1404
|
if (current.object instanceof osuBase.Slider && withSliders) {
|
|
1481
|
-
const scalingFactor = 50 / current.object.
|
|
1482
|
-
//
|
|
1405
|
+
const scalingFactor = 50 / current.object.radius;
|
|
1406
|
+
// Invert the scaling factor to determine the true travel distance independent of circle size.
|
|
1407
|
+
const pixelTravelDistance = current.object.lazyTravelDistance / scalingFactor;
|
|
1408
|
+
const currentVelocity = pixelTravelDistance / current.travelTime;
|
|
1409
|
+
const spanTravelDistance = pixelTravelDistance / current.object.spanCount;
|
|
1483
1410
|
strain +=
|
|
1484
|
-
//
|
|
1485
|
-
Math.min(6,
|
|
1486
|
-
//
|
|
1487
|
-
|
|
1411
|
+
// Reward sliders based on velocity, while also avoiding overbuffing extremely fast sliders.
|
|
1412
|
+
Math.min(6, currentVelocity * 1.5) *
|
|
1413
|
+
// Longer sliders require more reading.
|
|
1414
|
+
(spanTravelDistance / 100);
|
|
1488
1415
|
let cumulativeStrainTime = 0;
|
|
1489
1416
|
// Reward for velocity changes based on last few sliders.
|
|
1490
1417
|
for (let i = 0; i < Math.min(current.index, 4); ++i) {
|
|
@@ -1495,52 +1422,19 @@ class DroidVisualEvaluator {
|
|
|
1495
1422
|
last.isOverlapping(true)) {
|
|
1496
1423
|
continue;
|
|
1497
1424
|
}
|
|
1425
|
+
// Invert the scaling factor to determine the true travel distance independent of circle size.
|
|
1426
|
+
const lastPixelTravelDistance = last.object.lazyTravelDistance / scalingFactor;
|
|
1427
|
+
const lastVelocity = lastPixelTravelDistance / last.travelTime;
|
|
1428
|
+
const lastSpanTravelDistance = lastPixelTravelDistance / last.object.spanCount;
|
|
1498
1429
|
strain +=
|
|
1499
|
-
//
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
}
|
|
1506
|
-
}
|
|
1507
|
-
// Reward for rhythm changes.
|
|
1508
|
-
if (current.rhythmMultiplier > 1) {
|
|
1509
|
-
let rhythmBonus = (current.rhythmMultiplier - 1) / 20;
|
|
1510
|
-
// Rhythm changes are harder to read in Hidden.
|
|
1511
|
-
// Add additional bonus for Hidden.
|
|
1512
|
-
if (isHiddenMod) {
|
|
1513
|
-
rhythmBonus += (current.rhythmMultiplier - 1) / 25;
|
|
1514
|
-
}
|
|
1515
|
-
// Rhythm changes are harder to read when objects are stacked together.
|
|
1516
|
-
// Scale rhythm bonus based on the stack of past objects.
|
|
1517
|
-
const diameter = 2 * current.object.getRadius(osuBase.Modes.droid);
|
|
1518
|
-
let cumulativeStrainTime = 0;
|
|
1519
|
-
for (let i = 0; i < Math.min(current.index, 5); ++i) {
|
|
1520
|
-
const previous = current.previous(i);
|
|
1521
|
-
if (previous.object instanceof osuBase.Spinner ||
|
|
1522
|
-
// Exclude overlapping objects that can be tapped at once.
|
|
1523
|
-
previous.isOverlapping(true)) {
|
|
1524
|
-
continue;
|
|
1525
|
-
}
|
|
1526
|
-
const jumpDistance = current.object
|
|
1527
|
-
.getStackedPosition(osuBase.Modes.droid)
|
|
1528
|
-
.getDistance(previous.object.getStackedEndPosition(osuBase.Modes.droid));
|
|
1529
|
-
cumulativeStrainTime += previous.strainTime;
|
|
1530
|
-
rhythmBonus +=
|
|
1531
|
-
// Scale the bonus with diameter.
|
|
1532
|
-
osuBase.MathUtils.clamp((0.5 - jumpDistance / diameter) / 10, 0, 0.05) *
|
|
1533
|
-
// Scale with cumulative strain time to avoid overbuffing past objects.
|
|
1430
|
+
// Reward past sliders based on velocity changes, while also
|
|
1431
|
+
// avoiding overbuffing extremely fast velocity changes.
|
|
1432
|
+
Math.min(10, 2.5 * Math.abs(currentVelocity - lastVelocity)) *
|
|
1433
|
+
// Longer sliders require more reading.
|
|
1434
|
+
(lastSpanTravelDistance / 125) *
|
|
1435
|
+
// Avoid overbuffing past sliders.
|
|
1534
1436
|
Math.min(1, 300 / cumulativeStrainTime);
|
|
1535
|
-
// Give a larger bonus for Hidden.
|
|
1536
|
-
if (isHiddenMod) {
|
|
1537
|
-
rhythmBonus +=
|
|
1538
|
-
(1 -
|
|
1539
|
-
current.opacityAt(previous.object.startTime, isHiddenMod, osuBase.Modes.droid)) /
|
|
1540
|
-
20;
|
|
1541
|
-
}
|
|
1542
1437
|
}
|
|
1543
|
-
strain += rhythmBonus;
|
|
1544
1438
|
}
|
|
1545
1439
|
return strain;
|
|
1546
1440
|
}
|
|
@@ -1550,31 +1444,238 @@ class DroidVisualEvaluator {
|
|
|
1550
1444
|
* Represents the skill required to read every object in the map.
|
|
1551
1445
|
*/
|
|
1552
1446
|
class DroidVisual extends DroidSkill {
|
|
1553
|
-
starsPerDouble = 1.025;
|
|
1554
|
-
reducedSectionCount = 10;
|
|
1555
|
-
reducedSectionBaseline = 0.75;
|
|
1556
|
-
skillMultiplier = 10;
|
|
1557
|
-
strainDecayBase = 0.1;
|
|
1558
|
-
isHidden;
|
|
1559
|
-
withSliders;
|
|
1560
1447
|
constructor(mods, withSliders) {
|
|
1561
1448
|
super(mods);
|
|
1449
|
+
this.starsPerDouble = 1.025;
|
|
1450
|
+
this.reducedSectionCount = 10;
|
|
1451
|
+
this.reducedSectionBaseline = 0.75;
|
|
1452
|
+
this.strainDecayBase = 0.1;
|
|
1453
|
+
this.currentVisualStrain = 0;
|
|
1454
|
+
this.currentRhythmMultiplier = 1;
|
|
1455
|
+
this.skillMultiplier = 10;
|
|
1562
1456
|
this.isHidden = mods.some((m) => m instanceof osuBase.ModHidden);
|
|
1563
1457
|
this.withSliders = withSliders;
|
|
1564
1458
|
}
|
|
1565
1459
|
strainValueAt(current) {
|
|
1566
|
-
this.
|
|
1567
|
-
this.
|
|
1460
|
+
this.currentVisualStrain *= this.strainDecay(current.deltaTime);
|
|
1461
|
+
this.currentVisualStrain +=
|
|
1568
1462
|
DroidVisualEvaluator.evaluateDifficultyOf(current, this.isHidden, this.withSliders) * this.skillMultiplier;
|
|
1569
|
-
|
|
1463
|
+
this.currentRhythmMultiplier = current.rhythmMultiplier;
|
|
1464
|
+
return this.currentVisualStrain * this.currentRhythmMultiplier;
|
|
1465
|
+
}
|
|
1466
|
+
calculateInitialStrain(time, current) {
|
|
1467
|
+
var _a, _b;
|
|
1468
|
+
return (this.currentVisualStrain *
|
|
1469
|
+
this.currentRhythmMultiplier *
|
|
1470
|
+
this.strainDecay(time - ((_b = (_a = current.previous(0)) === null || _a === void 0 ? void 0 : _a.startTime) !== null && _b !== void 0 ? _b : 0)));
|
|
1471
|
+
}
|
|
1472
|
+
getObjectStrain() {
|
|
1473
|
+
return this.currentVisualStrain * this.currentRhythmMultiplier;
|
|
1570
1474
|
}
|
|
1571
1475
|
saveToHitObject(current) {
|
|
1476
|
+
const strain = this.currentVisualStrain * this.currentRhythmMultiplier;
|
|
1572
1477
|
if (this.withSliders) {
|
|
1573
|
-
current.visualStrainWithSliders =
|
|
1478
|
+
current.visualStrainWithSliders = strain;
|
|
1574
1479
|
}
|
|
1575
1480
|
else {
|
|
1576
|
-
current.visualStrainWithoutSliders =
|
|
1481
|
+
current.visualStrainWithoutSliders = strain;
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
/**
|
|
1487
|
+
* Represents an osu!droid hit object with difficulty calculation values.
|
|
1488
|
+
*/
|
|
1489
|
+
class DroidDifficultyHitObject extends DifficultyHitObject {
|
|
1490
|
+
get scalingFactor() {
|
|
1491
|
+
const radius = this.object.radius;
|
|
1492
|
+
// We will scale distances by this factor, so we can assume a uniform CircleSize among beatmaps.
|
|
1493
|
+
let scalingFactor = this.normalizedRadius / radius;
|
|
1494
|
+
// High circle size (small CS) bonus
|
|
1495
|
+
if (radius < this.radiusBuffThreshold) {
|
|
1496
|
+
scalingFactor *=
|
|
1497
|
+
1 + Math.pow((this.radiusBuffThreshold - radius) / 50, 2);
|
|
1498
|
+
}
|
|
1499
|
+
return scalingFactor;
|
|
1500
|
+
}
|
|
1501
|
+
/**
|
|
1502
|
+
* Note: You **must** call `computeProperties` at some point due to how TypeScript handles
|
|
1503
|
+
* overridden properties (see [this](https://github.com/microsoft/TypeScript/issues/1617) GitHub issue).
|
|
1504
|
+
*
|
|
1505
|
+
* @param object The underlying hitobject.
|
|
1506
|
+
* @param lastObject The hitobject before this hitobject.
|
|
1507
|
+
* @param lastLastObject The hitobject before the last hitobject.
|
|
1508
|
+
* @param difficultyHitObjects All difficulty hitobjects in the processed beatmap.
|
|
1509
|
+
* @param clockRate The clock rate of the beatmap.
|
|
1510
|
+
*/
|
|
1511
|
+
constructor(object, lastObject, lastLastObject, difficultyHitObjects, clockRate) {
|
|
1512
|
+
super(object, lastObject, lastLastObject, difficultyHitObjects, clockRate);
|
|
1513
|
+
/**
|
|
1514
|
+
* The tap strain generated by the hitobject.
|
|
1515
|
+
*/
|
|
1516
|
+
this.tapStrain = 0;
|
|
1517
|
+
/**
|
|
1518
|
+
* The tap strain generated by the hitobject if `strainTime` isn't modified by
|
|
1519
|
+
* OD. This is used in three-finger detection.
|
|
1520
|
+
*/
|
|
1521
|
+
this.originalTapStrain = 0;
|
|
1522
|
+
/**
|
|
1523
|
+
* The rhythm strain generated by the hitobject.
|
|
1524
|
+
*/
|
|
1525
|
+
this.rhythmStrain = 0;
|
|
1526
|
+
/**
|
|
1527
|
+
* The flashlight strain generated by the hitobject if sliders are considered.
|
|
1528
|
+
*/
|
|
1529
|
+
this.flashlightStrainWithSliders = 0;
|
|
1530
|
+
/**
|
|
1531
|
+
* The flashlight strain generated by the hitobject if sliders are not considered.
|
|
1532
|
+
*/
|
|
1533
|
+
this.flashlightStrainWithoutSliders = 0;
|
|
1534
|
+
/**
|
|
1535
|
+
* The visual strain generated by the hitobject if sliders are considered.
|
|
1536
|
+
*/
|
|
1537
|
+
this.visualStrainWithSliders = 0;
|
|
1538
|
+
/**
|
|
1539
|
+
* The visual strain generated by the hitobject if sliders are not considered.
|
|
1540
|
+
*/
|
|
1541
|
+
this.visualStrainWithoutSliders = 0;
|
|
1542
|
+
/**
|
|
1543
|
+
* The note density of the hitobject.
|
|
1544
|
+
*/
|
|
1545
|
+
this.noteDensity = 1;
|
|
1546
|
+
/**
|
|
1547
|
+
* The overlapping factor of the hitobject.
|
|
1548
|
+
*
|
|
1549
|
+
* This is used to scale visual skill.
|
|
1550
|
+
*/
|
|
1551
|
+
this.overlappingFactor = 0;
|
|
1552
|
+
this.radiusBuffThreshold = 70;
|
|
1553
|
+
this.mode = osuBase.Modes.droid;
|
|
1554
|
+
this.maximumSliderRadius = this.normalizedRadius * 2;
|
|
1555
|
+
this.timePreempt = object.timePreempt / clockRate;
|
|
1556
|
+
}
|
|
1557
|
+
computeProperties(clockRate, hitObjects) {
|
|
1558
|
+
super.computeProperties(clockRate, hitObjects);
|
|
1559
|
+
this.setVisuals(clockRate, hitObjects);
|
|
1560
|
+
}
|
|
1561
|
+
/**
|
|
1562
|
+
* Determines whether this hitobject is considered overlapping with the hitobject before it.
|
|
1563
|
+
*
|
|
1564
|
+
* Keep in mind that "overlapping" in this case is overlapping to the point where both hitobjects
|
|
1565
|
+
* can be hit with just a single tap in osu!droid.
|
|
1566
|
+
*
|
|
1567
|
+
* In the case of sliders, it is considered overlapping if all nested hitobjects can be hit with
|
|
1568
|
+
* one aim motion.
|
|
1569
|
+
*
|
|
1570
|
+
* @param considerDistance Whether to consider the distance between both hitobjects.
|
|
1571
|
+
* @returns Whether the hitobject is considered overlapping.
|
|
1572
|
+
*/
|
|
1573
|
+
isOverlapping(considerDistance) {
|
|
1574
|
+
if (this.object instanceof osuBase.Spinner) {
|
|
1575
|
+
return false;
|
|
1576
|
+
}
|
|
1577
|
+
const prev = this.previous(0);
|
|
1578
|
+
if (!prev || prev.object instanceof osuBase.Spinner) {
|
|
1579
|
+
return false;
|
|
1577
1580
|
}
|
|
1581
|
+
if (this.object.startTime !== prev.object.startTime) {
|
|
1582
|
+
return false;
|
|
1583
|
+
}
|
|
1584
|
+
if (!considerDistance) {
|
|
1585
|
+
return true;
|
|
1586
|
+
}
|
|
1587
|
+
const distanceThreshold = 2 * this.object.radius;
|
|
1588
|
+
const startPosition = this.object.getStackedPosition(osuBase.Modes.droid);
|
|
1589
|
+
const prevStartPosition = prev.object.getStackedPosition(osuBase.Modes.droid);
|
|
1590
|
+
// We need to consider two cases:
|
|
1591
|
+
//
|
|
1592
|
+
// Case 1: Current object is a circle, or previous object is a circle.
|
|
1593
|
+
// In this case, we only need to check if their positions are close enough to be tapped together.
|
|
1594
|
+
//
|
|
1595
|
+
// Case 2: Both objects are sliders.
|
|
1596
|
+
// In this case, we need to check if all nested hitobjects can be hit together.
|
|
1597
|
+
// To start with, check if the starting positions can be tapped together.
|
|
1598
|
+
if (startPosition.getDistance(prevStartPosition) > distanceThreshold) {
|
|
1599
|
+
return false;
|
|
1600
|
+
}
|
|
1601
|
+
if (this.object instanceof osuBase.Circle || prev.object instanceof osuBase.Circle) {
|
|
1602
|
+
return true;
|
|
1603
|
+
}
|
|
1604
|
+
// Check if all nested hitobjects can be hit together.
|
|
1605
|
+
for (let i = 1; i < this.object.nestedHitObjects.length; ++i) {
|
|
1606
|
+
const position = this.object.nestedHitObjects[i].getStackedPosition(osuBase.Modes.droid);
|
|
1607
|
+
const prevPosition = prevStartPosition.add(prev.object.curvePositionAt(i / (this.object.nestedHitObjects.length - 1)));
|
|
1608
|
+
if (position.getDistance(prevPosition) > distanceThreshold) {
|
|
1609
|
+
return false;
|
|
1610
|
+
}
|
|
1611
|
+
}
|
|
1612
|
+
// Do the same for the previous slider as well.
|
|
1613
|
+
for (let i = 1; i < prev.object.nestedHitObjects.length; ++i) {
|
|
1614
|
+
const prevPosition = prev.object.nestedHitObjects[i].getStackedPosition(osuBase.Modes.droid);
|
|
1615
|
+
const position = startPosition.add(this.object.curvePositionAt(i / (prev.object.nestedHitObjects.length - 1)));
|
|
1616
|
+
if (prevPosition.getDistance(position) > distanceThreshold) {
|
|
1617
|
+
return false;
|
|
1618
|
+
}
|
|
1619
|
+
}
|
|
1620
|
+
return true;
|
|
1621
|
+
}
|
|
1622
|
+
setVisuals(clockRate, hitObjects) {
|
|
1623
|
+
// We'll have two visible object arrays. The first array contains objects before the current object starts in a reversed order,
|
|
1624
|
+
// while the second array contains objects after the current object ends.
|
|
1625
|
+
// For overlapping factor, we also need to consider previous visible objects.
|
|
1626
|
+
const prevVisibleObjects = [];
|
|
1627
|
+
const nextVisibleObjects = [];
|
|
1628
|
+
for (let j = this.index + 2; j < hitObjects.length; ++j) {
|
|
1629
|
+
const o = hitObjects[j];
|
|
1630
|
+
if (o instanceof osuBase.Spinner) {
|
|
1631
|
+
continue;
|
|
1632
|
+
}
|
|
1633
|
+
if (o.startTime / clockRate > this.endTime + this.timePreempt) {
|
|
1634
|
+
break;
|
|
1635
|
+
}
|
|
1636
|
+
nextVisibleObjects.push(o);
|
|
1637
|
+
}
|
|
1638
|
+
for (let j = 0; j < this.index; ++j) {
|
|
1639
|
+
const prev = this.previous(j);
|
|
1640
|
+
if (prev.object instanceof osuBase.Spinner) {
|
|
1641
|
+
continue;
|
|
1642
|
+
}
|
|
1643
|
+
if (prev.startTime >= this.startTime) {
|
|
1644
|
+
continue;
|
|
1645
|
+
}
|
|
1646
|
+
if (prev.startTime < this.startTime - this.timePreempt) {
|
|
1647
|
+
break;
|
|
1648
|
+
}
|
|
1649
|
+
prevVisibleObjects.push(prev.object);
|
|
1650
|
+
}
|
|
1651
|
+
for (const hitObject of prevVisibleObjects) {
|
|
1652
|
+
const distance = this.object
|
|
1653
|
+
.getStackedPosition(this.mode)
|
|
1654
|
+
.getDistance(hitObject.getStackedEndPosition(this.mode));
|
|
1655
|
+
const deltaTime = this.startTime - hitObject.endTime / clockRate;
|
|
1656
|
+
this.applyToOverlappingFactor(distance, deltaTime);
|
|
1657
|
+
}
|
|
1658
|
+
for (const hitObject of nextVisibleObjects) {
|
|
1659
|
+
const distance = hitObject
|
|
1660
|
+
.getStackedPosition(this.mode)
|
|
1661
|
+
.getDistance(this.object.getStackedEndPosition(this.mode));
|
|
1662
|
+
const deltaTime = hitObject.startTime / clockRate - this.endTime;
|
|
1663
|
+
if (deltaTime >= 0) {
|
|
1664
|
+
this.noteDensity += 1 - deltaTime / this.timePreempt;
|
|
1665
|
+
}
|
|
1666
|
+
this.applyToOverlappingFactor(distance, deltaTime);
|
|
1667
|
+
}
|
|
1668
|
+
}
|
|
1669
|
+
applyToOverlappingFactor(distance, deltaTime) {
|
|
1670
|
+
// Penalize objects that are too close to the object in both distance
|
|
1671
|
+
// and delta time to prevent stream maps from being overweighted.
|
|
1672
|
+
this.overlappingFactor +=
|
|
1673
|
+
Math.max(0, 1 - distance / (2.5 * this.object.radius)) *
|
|
1674
|
+
(7.5 /
|
|
1675
|
+
(1 +
|
|
1676
|
+
Math.exp(0.15 *
|
|
1677
|
+
(Math.max(deltaTime, DifficultyHitObject.minDeltaTime) -
|
|
1678
|
+
75))));
|
|
1578
1679
|
}
|
|
1579
1680
|
}
|
|
1580
1681
|
|
|
@@ -1582,56 +1683,77 @@ class DroidVisual extends DroidSkill {
|
|
|
1582
1683
|
* A difficulty calculator for osu!droid gamemode.
|
|
1583
1684
|
*/
|
|
1584
1685
|
class DroidDifficultyCalculator extends DifficultyCalculator {
|
|
1686
|
+
constructor() {
|
|
1687
|
+
super(...arguments);
|
|
1688
|
+
this.attributes = {
|
|
1689
|
+
mode: "live",
|
|
1690
|
+
tapDifficulty: 0,
|
|
1691
|
+
rhythmDifficulty: 0,
|
|
1692
|
+
visualDifficulty: 0,
|
|
1693
|
+
aimNoteCount: 0,
|
|
1694
|
+
mods: [],
|
|
1695
|
+
starRating: 0,
|
|
1696
|
+
maxCombo: 0,
|
|
1697
|
+
aimDifficulty: 0,
|
|
1698
|
+
flashlightDifficulty: 0,
|
|
1699
|
+
speedNoteCount: 0,
|
|
1700
|
+
sliderFactor: 0,
|
|
1701
|
+
clockRate: 1,
|
|
1702
|
+
overallDifficulty: 0,
|
|
1703
|
+
hitCircleCount: 0,
|
|
1704
|
+
sliderCount: 0,
|
|
1705
|
+
spinnerCount: 0,
|
|
1706
|
+
aimDifficultStrainCount: 0,
|
|
1707
|
+
tapDifficultStrainCount: 0,
|
|
1708
|
+
flashlightDifficultStrainCount: 0,
|
|
1709
|
+
visualDifficultStrainCount: 0,
|
|
1710
|
+
flashlightSliderFactor: 0,
|
|
1711
|
+
visualSliderFactor: 0,
|
|
1712
|
+
possibleThreeFingeredSections: [],
|
|
1713
|
+
difficultSliders: [],
|
|
1714
|
+
averageSpeedDeltaTime: 0,
|
|
1715
|
+
vibroFactor: 1,
|
|
1716
|
+
};
|
|
1717
|
+
this.difficultyMultiplier = 0.18;
|
|
1718
|
+
this.mode = osuBase.Modes.droid;
|
|
1719
|
+
}
|
|
1585
1720
|
/**
|
|
1586
1721
|
* The aim star rating of the beatmap.
|
|
1587
1722
|
*/
|
|
1588
|
-
aim
|
|
1723
|
+
get aim() {
|
|
1724
|
+
return this.attributes.aimDifficulty;
|
|
1725
|
+
}
|
|
1589
1726
|
/**
|
|
1590
1727
|
* The tap star rating of the beatmap.
|
|
1591
1728
|
*/
|
|
1592
|
-
tap
|
|
1729
|
+
get tap() {
|
|
1730
|
+
return this.attributes.tapDifficulty;
|
|
1731
|
+
}
|
|
1593
1732
|
/**
|
|
1594
1733
|
* The rhythm star rating of the beatmap.
|
|
1595
1734
|
*/
|
|
1596
|
-
rhythm
|
|
1735
|
+
get rhythm() {
|
|
1736
|
+
return this.attributes.rhythmDifficulty;
|
|
1737
|
+
}
|
|
1597
1738
|
/**
|
|
1598
1739
|
* The flashlight star rating of the beatmap.
|
|
1599
1740
|
*/
|
|
1600
|
-
flashlight
|
|
1741
|
+
get flashlight() {
|
|
1742
|
+
return this.attributes.flashlightDifficulty;
|
|
1743
|
+
}
|
|
1601
1744
|
/**
|
|
1602
1745
|
* The visual star rating of the beatmap.
|
|
1603
1746
|
*/
|
|
1604
|
-
visual
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
visualDifficulty: 0,
|
|
1615
|
-
aimNoteCount: 0,
|
|
1616
|
-
mods: [],
|
|
1617
|
-
starRating: 0,
|
|
1618
|
-
maxCombo: 0,
|
|
1619
|
-
aimDifficulty: 0,
|
|
1620
|
-
flashlightDifficulty: 0,
|
|
1621
|
-
speedNoteCount: 0,
|
|
1622
|
-
sliderFactor: 0,
|
|
1623
|
-
approachRate: 0,
|
|
1624
|
-
overallDifficulty: 0,
|
|
1625
|
-
hitCircleCount: 0,
|
|
1626
|
-
sliderCount: 0,
|
|
1627
|
-
spinnerCount: 0,
|
|
1628
|
-
flashlightSliderFactor: 0,
|
|
1629
|
-
visualSliderFactor: 0,
|
|
1630
|
-
possibleThreeFingeredSections: [],
|
|
1631
|
-
difficultSliders: [],
|
|
1632
|
-
};
|
|
1633
|
-
difficultyMultiplier = 0.18;
|
|
1634
|
-
mode = osuBase.Modes.droid;
|
|
1747
|
+
get visual() {
|
|
1748
|
+
return this.attributes.visualDifficulty;
|
|
1749
|
+
}
|
|
1750
|
+
get cacheableAttributes() {
|
|
1751
|
+
return Object.assign(Object.assign({}, this.attributes), { mods: osuBase.ModUtil.modsToOsuString(this.attributes.mods) });
|
|
1752
|
+
}
|
|
1753
|
+
// Override to use DroidDifficultyCalculationOptions
|
|
1754
|
+
calculate(options) {
|
|
1755
|
+
return super.calculate(options);
|
|
1756
|
+
}
|
|
1635
1757
|
/**
|
|
1636
1758
|
* Calculates the aim star rating of the beatmap and stores it in this instance.
|
|
1637
1759
|
*/
|
|
@@ -1645,26 +1767,18 @@ class DroidDifficultyCalculator extends DifficultyCalculator {
|
|
|
1645
1767
|
* Calculates the tap star rating of the beatmap and stores it in this instance.
|
|
1646
1768
|
*/
|
|
1647
1769
|
calculateTap() {
|
|
1648
|
-
const
|
|
1649
|
-
this.
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
else {
|
|
1655
|
-
this.postCalculateTap(tapSkill);
|
|
1656
|
-
}
|
|
1657
|
-
this.calculateSpeedAttributes();
|
|
1770
|
+
const tapSkillCheese = new DroidTap(this.mods, true);
|
|
1771
|
+
const tapSkillNoCheese = new DroidTap(this.mods, false);
|
|
1772
|
+
this.calculateSkills(tapSkillCheese, tapSkillNoCheese);
|
|
1773
|
+
const tapSkillVibro = new DroidTap(this.mods, true, tapSkillCheese.relevantDeltaTime());
|
|
1774
|
+
this.calculateSkills(tapSkillVibro);
|
|
1775
|
+
this.postCalculateTap(tapSkillCheese, tapSkillVibro);
|
|
1658
1776
|
}
|
|
1659
1777
|
/**
|
|
1660
1778
|
* Calculates the rhythm star rating of the beatmap and stores it in this instance.
|
|
1661
1779
|
*/
|
|
1662
1780
|
calculateRhythm() {
|
|
1663
|
-
|
|
1664
|
-
this.rhythm = this.attributes.rhythmDifficulty = 0;
|
|
1665
|
-
return;
|
|
1666
|
-
}
|
|
1667
|
-
const rhythmSkill = new DroidRhythm(this.mods, this.stats.od);
|
|
1781
|
+
const rhythmSkill = new DroidRhythm(this.mods);
|
|
1668
1782
|
this.calculateSkills(rhythmSkill);
|
|
1669
1783
|
this.postCalculateRhythm(rhythmSkill);
|
|
1670
1784
|
}
|
|
@@ -1682,7 +1796,7 @@ class DroidDifficultyCalculator extends DifficultyCalculator {
|
|
|
1682
1796
|
*/
|
|
1683
1797
|
calculateVisual() {
|
|
1684
1798
|
if (this.mods.some((m) => m instanceof osuBase.ModRelax)) {
|
|
1685
|
-
this.
|
|
1799
|
+
this.attributes.visualDifficulty = 0;
|
|
1686
1800
|
return;
|
|
1687
1801
|
}
|
|
1688
1802
|
const visualSkill = new DroidVisual(this.mods, true);
|
|
@@ -1704,50 +1818,33 @@ class DroidDifficultyCalculator extends DifficultyCalculator {
|
|
|
1704
1818
|
if (basePerformanceValue > 1e-5) {
|
|
1705
1819
|
// Document for formula derivation:
|
|
1706
1820
|
// https://docs.google.com/document/d/10DZGYYSsT_yjz2Mtp6yIJld0Rqx4E-vVHupCqiM4TNI/edit
|
|
1707
|
-
this.
|
|
1821
|
+
this.attributes.starRating =
|
|
1708
1822
|
0.027 *
|
|
1709
1823
|
(Math.cbrt((100000 / Math.pow(2, 1 / 1.1)) * basePerformanceValue) +
|
|
1710
1824
|
4);
|
|
1711
1825
|
}
|
|
1712
1826
|
else {
|
|
1713
|
-
this.
|
|
1827
|
+
this.attributes.starRating = 0;
|
|
1714
1828
|
}
|
|
1715
1829
|
}
|
|
1716
1830
|
calculateAll() {
|
|
1717
1831
|
const skills = this.createSkills();
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
const
|
|
1726
|
-
const
|
|
1727
|
-
const
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
const visualSkillWithoutSliders = skills[7] ?? null;
|
|
1733
|
-
this.postCalculateAim(aimSkill, aimSkillWithoutSliders);
|
|
1734
|
-
if (isRelax) {
|
|
1735
|
-
this.tap = this.attributes.tapDifficulty = 0;
|
|
1736
|
-
}
|
|
1737
|
-
else {
|
|
1738
|
-
this.postCalculateTap(tapSkill);
|
|
1739
|
-
}
|
|
1740
|
-
this.calculateSpeedAttributes();
|
|
1741
|
-
if (!isRelax) {
|
|
1742
|
-
this.postCalculateRhythm(rhythmSkill);
|
|
1743
|
-
}
|
|
1832
|
+
this.calculateSkills(...skills);
|
|
1833
|
+
const aimSkill = skills[0];
|
|
1834
|
+
const aimSkillWithoutSliders = skills[1];
|
|
1835
|
+
const rhythmSkill = skills[2];
|
|
1836
|
+
const tapSkillCheese = skills[3];
|
|
1837
|
+
const flashlightSkill = skills[5];
|
|
1838
|
+
const flashlightSkillWithoutSliders = skills[6];
|
|
1839
|
+
const visualSkill = skills[7];
|
|
1840
|
+
const visualSkillWithoutSliders = skills[8];
|
|
1841
|
+
const tapSkillVibro = new DroidTap(this.mods, true, tapSkillCheese.relevantDeltaTime());
|
|
1842
|
+
this.calculateSkills(tapSkillVibro);
|
|
1843
|
+
this.postCalculateAim(aimSkill, aimSkillWithoutSliders);
|
|
1844
|
+
this.postCalculateTap(tapSkillCheese, tapSkillVibro);
|
|
1845
|
+
this.postCalculateRhythm(rhythmSkill);
|
|
1744
1846
|
this.postCalculateFlashlight(flashlightSkill, flashlightSkillWithoutSliders);
|
|
1745
|
-
|
|
1746
|
-
this.postCalculateVisual(visualSkill, visualSkillWithoutSliders);
|
|
1747
|
-
}
|
|
1748
|
-
else {
|
|
1749
|
-
this.visual = this.attributes.visualDifficulty = 0;
|
|
1750
|
-
}
|
|
1847
|
+
this.postCalculateVisual(visualSkill, visualSkillWithoutSliders);
|
|
1751
1848
|
this.calculateTotal();
|
|
1752
1849
|
}
|
|
1753
1850
|
toString() {
|
|
@@ -1764,26 +1861,37 @@ class DroidDifficultyCalculator extends DifficultyCalculator {
|
|
|
1764
1861
|
this.visual.toFixed(2) +
|
|
1765
1862
|
" visual)");
|
|
1766
1863
|
}
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1864
|
+
generateDifficultyHitObjects(beatmap, clockRate) {
|
|
1865
|
+
var _a, _b;
|
|
1866
|
+
const difficultyObjects = [];
|
|
1867
|
+
const { objects } = beatmap.hitObjects;
|
|
1868
|
+
for (let i = 0; i < objects.length; ++i) {
|
|
1869
|
+
const difficultyObject = new DroidDifficultyHitObject(objects[i], (_a = objects[i - 1]) !== null && _a !== void 0 ? _a : null, (_b = objects[i - 2]) !== null && _b !== void 0 ? _b : null, difficultyObjects, clockRate);
|
|
1870
|
+
difficultyObject.computeProperties(clockRate, objects);
|
|
1871
|
+
difficultyObjects.push(difficultyObject);
|
|
1771
1872
|
}
|
|
1772
|
-
|
|
1873
|
+
return difficultyObjects;
|
|
1773
1874
|
}
|
|
1774
1875
|
createSkills() {
|
|
1775
1876
|
return [
|
|
1776
1877
|
new DroidAim(this.mods, true),
|
|
1777
1878
|
new DroidAim(this.mods, false),
|
|
1778
1879
|
// Tap skill depends on rhythm skill, so we put it first
|
|
1779
|
-
new DroidRhythm(this.mods
|
|
1780
|
-
|
|
1880
|
+
new DroidRhythm(this.mods),
|
|
1881
|
+
// Cheesability tap
|
|
1882
|
+
new DroidTap(this.mods, true),
|
|
1883
|
+
// Non-cheesability tap
|
|
1884
|
+
new DroidTap(this.mods, false),
|
|
1781
1885
|
new DroidFlashlight(this.mods, true),
|
|
1782
1886
|
new DroidFlashlight(this.mods, false),
|
|
1783
1887
|
new DroidVisual(this.mods, true),
|
|
1784
1888
|
new DroidVisual(this.mods, false),
|
|
1785
1889
|
];
|
|
1786
1890
|
}
|
|
1891
|
+
calculateClockRate(options) {
|
|
1892
|
+
var _a, _b;
|
|
1893
|
+
return (osuBase.ModUtil.calculateRateWithMods((_a = options === null || options === void 0 ? void 0 : options.mods) !== null && _a !== void 0 ? _a : [], options === null || options === void 0 ? void 0 : options.oldStatistics) * ((_b = options === null || options === void 0 ? void 0 : options.customSpeedMultiplier) !== null && _b !== void 0 ? _b : 1));
|
|
1894
|
+
}
|
|
1787
1895
|
/**
|
|
1788
1896
|
* Called after aim skill calculation.
|
|
1789
1897
|
*
|
|
@@ -1793,152 +1901,122 @@ class DroidDifficultyCalculator extends DifficultyCalculator {
|
|
|
1793
1901
|
postCalculateAim(aimSkill, aimSkillWithoutSliders) {
|
|
1794
1902
|
this.strainPeaks.aimWithSliders = aimSkill.strainPeaks;
|
|
1795
1903
|
this.strainPeaks.aimWithoutSliders = aimSkillWithoutSliders.strainPeaks;
|
|
1796
|
-
this.
|
|
1904
|
+
this.attributes.aimDifficulty = this.mods.some((m) => m instanceof osuBase.ModAutopilot)
|
|
1905
|
+
? 0
|
|
1906
|
+
: this.starValue(aimSkill.difficultyValue());
|
|
1797
1907
|
if (this.aim) {
|
|
1798
1908
|
this.attributes.sliderFactor =
|
|
1799
1909
|
this.starValue(aimSkillWithoutSliders.difficultyValue()) /
|
|
1800
1910
|
this.aim;
|
|
1801
1911
|
}
|
|
1802
1912
|
if (this.mods.some((m) => m instanceof osuBase.ModRelax)) {
|
|
1803
|
-
this.
|
|
1913
|
+
this.attributes.aimDifficulty *= 0.9;
|
|
1804
1914
|
}
|
|
1805
|
-
this.attributes.
|
|
1915
|
+
this.attributes.aimDifficultStrainCount =
|
|
1916
|
+
aimSkill.countDifficultStrains();
|
|
1806
1917
|
this.calculateAimAttributes();
|
|
1807
1918
|
}
|
|
1808
1919
|
/**
|
|
1809
1920
|
* Calculates aim-related attributes.
|
|
1810
1921
|
*/
|
|
1811
1922
|
calculateAimAttributes() {
|
|
1812
|
-
|
|
1813
|
-
let maxStrain = 0;
|
|
1814
|
-
// Take the top 15% most difficult sliders based on velocity.
|
|
1923
|
+
this.attributes.difficultSliders = [];
|
|
1815
1924
|
const topDifficultSliders = [];
|
|
1816
1925
|
for (let i = 0; i < this.objects.length; ++i) {
|
|
1817
1926
|
const object = this.objects[i];
|
|
1818
|
-
objectStrains.push(object.aimStrainWithSliders);
|
|
1819
|
-
maxStrain = Math.max(maxStrain, object.aimStrainWithSliders);
|
|
1820
1927
|
const velocity = object.travelDistance / object.travelTime;
|
|
1821
1928
|
if (velocity > 0) {
|
|
1822
1929
|
topDifficultSliders.push({
|
|
1823
1930
|
index: i,
|
|
1824
1931
|
velocity: velocity,
|
|
1825
1932
|
});
|
|
1826
|
-
topDifficultSliders.sort((a, b) => b.velocity - a.velocity);
|
|
1827
|
-
while (topDifficultSliders.length >
|
|
1828
|
-
Math.ceil(0.15 * this.beatmap.hitObjects.sliders)) {
|
|
1829
|
-
topDifficultSliders.pop();
|
|
1830
|
-
}
|
|
1831
1933
|
}
|
|
1832
1934
|
}
|
|
1833
|
-
if (maxStrain) {
|
|
1834
|
-
this.attributes.aimNoteCount = objectStrains.reduce((total, next) => total + 1 / (1 + Math.exp(-((next / maxStrain) * 12 - 6))), 0);
|
|
1835
|
-
}
|
|
1836
1935
|
const velocitySum = topDifficultSliders.reduce((a, v) => a + v.velocity, 0);
|
|
1837
1936
|
for (const slider of topDifficultSliders) {
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1937
|
+
const difficultyRating = slider.velocity / velocitySum;
|
|
1938
|
+
// Only consider sliders that are fast enough.
|
|
1939
|
+
if (difficultyRating > 0.02) {
|
|
1940
|
+
this.attributes.difficultSliders.push({
|
|
1941
|
+
index: slider.index,
|
|
1942
|
+
difficultyRating: slider.velocity / velocitySum,
|
|
1943
|
+
});
|
|
1944
|
+
}
|
|
1945
|
+
}
|
|
1946
|
+
this.attributes.difficultSliders.sort((a, b) => b.difficultyRating - a.difficultyRating);
|
|
1947
|
+
// Take the top 15% most difficult sliders.
|
|
1948
|
+
while (this.attributes.difficultSliders.length >
|
|
1949
|
+
Math.ceil(0.15 * this.beatmap.hitObjects.sliders)) {
|
|
1950
|
+
this.attributes.difficultSliders.pop();
|
|
1842
1951
|
}
|
|
1843
1952
|
}
|
|
1844
1953
|
/**
|
|
1845
1954
|
* Called after tap skill calculation.
|
|
1846
1955
|
*
|
|
1847
|
-
* @param
|
|
1956
|
+
* @param tapSkillCheese The tap skill that considers cheesing.
|
|
1957
|
+
* @param tapSkillVibro The tap skill that considers vibro.
|
|
1848
1958
|
*/
|
|
1849
|
-
postCalculateTap(
|
|
1850
|
-
this.strainPeaks.speed =
|
|
1851
|
-
this.
|
|
1959
|
+
postCalculateTap(tapSkillCheese, tapSkillVibro) {
|
|
1960
|
+
this.strainPeaks.speed = tapSkillCheese.strainPeaks;
|
|
1961
|
+
this.attributes.tapDifficulty = this.mods.some((m) => m instanceof osuBase.ModRelax)
|
|
1962
|
+
? 0
|
|
1963
|
+
: this.starValue(tapSkillCheese.difficultyValue());
|
|
1964
|
+
if (this.tap) {
|
|
1965
|
+
this.attributes.vibroFactor =
|
|
1966
|
+
this.starValue(tapSkillVibro.difficultyValue()) / this.tap;
|
|
1967
|
+
}
|
|
1968
|
+
this.attributes.speedNoteCount = tapSkillCheese.relevantNoteCount();
|
|
1969
|
+
this.attributes.averageSpeedDeltaTime =
|
|
1970
|
+
tapSkillCheese.relevantDeltaTime();
|
|
1971
|
+
this.attributes.tapDifficultStrainCount =
|
|
1972
|
+
tapSkillCheese.countDifficultStrains();
|
|
1973
|
+
this.calculateTapAttributes();
|
|
1852
1974
|
}
|
|
1853
1975
|
/**
|
|
1854
|
-
* Calculates
|
|
1976
|
+
* Calculates tap-related attributes.
|
|
1855
1977
|
*/
|
|
1856
|
-
|
|
1978
|
+
calculateTapAttributes() {
|
|
1857
1979
|
this.attributes.possibleThreeFingeredSections = [];
|
|
1858
|
-
const
|
|
1859
|
-
const objectStrains = [];
|
|
1860
|
-
let maxStrain = 0;
|
|
1861
|
-
const maxSectionDeltaTime = 2000;
|
|
1980
|
+
const { threeFingerStrainThreshold } = DroidDifficultyCalculator;
|
|
1862
1981
|
const minSectionObjectCount = 5;
|
|
1863
|
-
let
|
|
1864
|
-
|
|
1982
|
+
let inSpeedSection = false;
|
|
1983
|
+
let firstSpeedObjectIndex = 0;
|
|
1984
|
+
for (let i = 2; i < this.objects.length; ++i) {
|
|
1865
1985
|
const current = this.objects[i];
|
|
1866
|
-
const
|
|
1867
|
-
if (
|
|
1868
|
-
|
|
1986
|
+
const prev = this.objects[i - 1];
|
|
1987
|
+
if (!inSpeedSection &&
|
|
1988
|
+
current.originalTapStrain >= threeFingerStrainThreshold) {
|
|
1989
|
+
inSpeedSection = true;
|
|
1990
|
+
firstSpeedObjectIndex = i;
|
|
1991
|
+
continue;
|
|
1869
1992
|
}
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
const
|
|
1873
|
-
|
|
1993
|
+
const currentDelta = current.deltaTime;
|
|
1994
|
+
const prevDelta = prev.deltaTime;
|
|
1995
|
+
const deltaRatio = Math.min(prevDelta, currentDelta) /
|
|
1996
|
+
Math.max(prevDelta, currentDelta);
|
|
1997
|
+
if (inSpeedSection &&
|
|
1998
|
+
(current.originalTapStrain < threeFingerStrainThreshold ||
|
|
1999
|
+
// Stop speed section on slowing down 1/2 rhythm change or anything slower.
|
|
2000
|
+
(prevDelta < currentDelta && deltaRatio <= 0.5) ||
|
|
2001
|
+
// Don't forget to manually add the last section, which would otherwise be ignored.
|
|
2002
|
+
i === this.objects.length - 1)) {
|
|
2003
|
+
const lastSpeedObjectIndex = i - (i === this.objects.length - 1 ? 0 : 1);
|
|
2004
|
+
inSpeedSection = false;
|
|
1874
2005
|
// Ignore sections that don't meet object count requirement.
|
|
1875
|
-
if (i -
|
|
1876
|
-
firstObjectIndex = i + 1;
|
|
1877
|
-
continue;
|
|
1878
|
-
}
|
|
1879
|
-
tempSections.push({
|
|
1880
|
-
firstObjectIndex,
|
|
1881
|
-
lastObjectIndex: i,
|
|
1882
|
-
});
|
|
1883
|
-
firstObjectIndex = i + 1;
|
|
1884
|
-
}
|
|
1885
|
-
}
|
|
1886
|
-
// Don't forget to manually add the last beatmap section, which would otherwise be ignored.
|
|
1887
|
-
if (this.objects.length - firstObjectIndex > minSectionObjectCount) {
|
|
1888
|
-
tempSections.push({
|
|
1889
|
-
firstObjectIndex,
|
|
1890
|
-
lastObjectIndex: this.objects.length - 1,
|
|
1891
|
-
});
|
|
1892
|
-
}
|
|
1893
|
-
// Refilter with tap strain in mind.
|
|
1894
|
-
const { threeFingerStrainThreshold } = DroidDifficultyCalculator;
|
|
1895
|
-
for (const section of tempSections) {
|
|
1896
|
-
let inSpeedSection = false;
|
|
1897
|
-
let newFirstObjectIndex = section.firstObjectIndex;
|
|
1898
|
-
for (let i = section.firstObjectIndex; i <= section.lastObjectIndex; ++i) {
|
|
1899
|
-
const current = this.objects[i];
|
|
1900
|
-
if (!inSpeedSection &&
|
|
1901
|
-
current.originalTapStrain >= threeFingerStrainThreshold) {
|
|
1902
|
-
inSpeedSection = true;
|
|
1903
|
-
newFirstObjectIndex = i;
|
|
2006
|
+
if (i - firstSpeedObjectIndex < minSectionObjectCount) {
|
|
1904
2007
|
continue;
|
|
1905
2008
|
}
|
|
1906
|
-
if (inSpeedSection &&
|
|
1907
|
-
current.originalTapStrain < threeFingerStrainThreshold) {
|
|
1908
|
-
inSpeedSection = false;
|
|
1909
|
-
this.attributes.possibleThreeFingeredSections.push({
|
|
1910
|
-
firstObjectIndex: newFirstObjectIndex,
|
|
1911
|
-
lastObjectIndex: i,
|
|
1912
|
-
sumStrain: this.calculateThreeFingerSummedStrain(newFirstObjectIndex, i),
|
|
1913
|
-
});
|
|
1914
|
-
}
|
|
1915
|
-
}
|
|
1916
|
-
// Don't forget to manually add the last beatmap section, which would otherwise be ignored.
|
|
1917
|
-
if (inSpeedSection) {
|
|
1918
2009
|
this.attributes.possibleThreeFingeredSections.push({
|
|
1919
|
-
firstObjectIndex:
|
|
1920
|
-
lastObjectIndex:
|
|
1921
|
-
sumStrain:
|
|
2010
|
+
firstObjectIndex: firstSpeedObjectIndex,
|
|
2011
|
+
lastObjectIndex: lastSpeedObjectIndex,
|
|
2012
|
+
sumStrain: Math.pow(this.objects
|
|
2013
|
+
.slice(firstSpeedObjectIndex, lastSpeedObjectIndex + 1)
|
|
2014
|
+
.reduce((a, v) => a +
|
|
2015
|
+
v.originalTapStrain /
|
|
2016
|
+
threeFingerStrainThreshold, 0), 0.75),
|
|
1922
2017
|
});
|
|
1923
2018
|
}
|
|
1924
2019
|
}
|
|
1925
|
-
if (maxStrain) {
|
|
1926
|
-
this.attributes.speedNoteCount = objectStrains.reduce((total, next) => total + 1 / (1 + Math.exp(-((next / maxStrain) * 12 - 6))), 0);
|
|
1927
|
-
}
|
|
1928
|
-
}
|
|
1929
|
-
/**
|
|
1930
|
-
* Calculates the sum of strains for possible three-fingered sections.
|
|
1931
|
-
*
|
|
1932
|
-
* @param firstObjectIndex The index of the first object in the section.
|
|
1933
|
-
* @param lastObjectIndex The index of the last object in the section.
|
|
1934
|
-
* @returns The summed strain of the section.
|
|
1935
|
-
*/
|
|
1936
|
-
calculateThreeFingerSummedStrain(firstObjectIndex, lastObjectIndex) {
|
|
1937
|
-
return Math.pow(this.objects
|
|
1938
|
-
.slice(firstObjectIndex, lastObjectIndex)
|
|
1939
|
-
.reduce((a, v) => a +
|
|
1940
|
-
v.originalTapStrain /
|
|
1941
|
-
DroidDifficultyCalculator.threeFingerStrainThreshold, 0), 0.75);
|
|
1942
2020
|
}
|
|
1943
2021
|
/**
|
|
1944
2022
|
* Called after rhythm skill calculation.
|
|
@@ -1946,7 +2024,7 @@ class DroidDifficultyCalculator extends DifficultyCalculator {
|
|
|
1946
2024
|
* @param rhythmSkill The rhythm skill.
|
|
1947
2025
|
*/
|
|
1948
2026
|
postCalculateRhythm(rhythmSkill) {
|
|
1949
|
-
this.
|
|
2027
|
+
this.attributes.rhythmDifficulty = this.mods.some((m) => m instanceof osuBase.ModRelax)
|
|
1950
2028
|
? 0
|
|
1951
2029
|
: this.starValue(rhythmSkill.difficultyValue());
|
|
1952
2030
|
}
|
|
@@ -1958,15 +2036,19 @@ class DroidDifficultyCalculator extends DifficultyCalculator {
|
|
|
1958
2036
|
*/
|
|
1959
2037
|
postCalculateFlashlight(flashlightSkill, flashlightSkillWithoutSliders) {
|
|
1960
2038
|
this.strainPeaks.flashlight = flashlightSkill.strainPeaks;
|
|
1961
|
-
this.
|
|
2039
|
+
this.attributes.flashlightDifficulty = this.starValue(flashlightSkill.difficultyValue());
|
|
1962
2040
|
if (this.flashlight) {
|
|
1963
2041
|
this.attributes.flashlightSliderFactor =
|
|
1964
2042
|
this.starValue(flashlightSkillWithoutSliders.difficultyValue()) / this.flashlight;
|
|
1965
2043
|
}
|
|
2044
|
+
if (this.mods.some((m) => m instanceof osuBase.ModAutopilot)) {
|
|
2045
|
+
this.attributes.flashlightDifficulty *= 0.3;
|
|
2046
|
+
}
|
|
1966
2047
|
if (this.mods.some((m) => m instanceof osuBase.ModRelax)) {
|
|
1967
|
-
this.
|
|
2048
|
+
this.attributes.flashlightDifficulty *= 0.7;
|
|
1968
2049
|
}
|
|
1969
|
-
this.attributes.
|
|
2050
|
+
this.attributes.flashlightDifficultStrainCount =
|
|
2051
|
+
flashlightSkill.countDifficultStrains();
|
|
1970
2052
|
}
|
|
1971
2053
|
/**
|
|
1972
2054
|
* Called after visual skill calculation.
|
|
@@ -1975,7 +2057,7 @@ class DroidDifficultyCalculator extends DifficultyCalculator {
|
|
|
1975
2057
|
* @param visualSkillWithoutSliders The visual skill that doesn't consider sliders.
|
|
1976
2058
|
*/
|
|
1977
2059
|
postCalculateVisual(visualSkillWithSliders, visualSkillWithoutSliders) {
|
|
1978
|
-
this.
|
|
2060
|
+
this.attributes.visualDifficulty = this.mods.some((m) => m instanceof osuBase.ModRelax)
|
|
1979
2061
|
? 0
|
|
1980
2062
|
: this.starValue(visualSkillWithSliders.difficultyValue());
|
|
1981
2063
|
if (this.visual) {
|
|
@@ -1983,33 +2065,51 @@ class DroidDifficultyCalculator extends DifficultyCalculator {
|
|
|
1983
2065
|
this.starValue(visualSkillWithoutSliders.difficultyValue()) /
|
|
1984
2066
|
this.visual;
|
|
1985
2067
|
}
|
|
2068
|
+
if (this.mods.some((m) => m instanceof osuBase.ModAutopilot)) {
|
|
2069
|
+
this.attributes.visualDifficulty *= 0.8;
|
|
2070
|
+
}
|
|
2071
|
+
this.attributes.visualDifficultStrainCount =
|
|
2072
|
+
visualSkillWithSliders.countDifficultStrains();
|
|
1986
2073
|
}
|
|
1987
2074
|
}
|
|
2075
|
+
/**
|
|
2076
|
+
* The strain threshold to start detecting for possible three-fingered section.
|
|
2077
|
+
*
|
|
2078
|
+
* Increasing this number will result in less sections being flagged.
|
|
2079
|
+
*/
|
|
2080
|
+
DroidDifficultyCalculator.threeFingerStrainThreshold = 175;
|
|
1988
2081
|
|
|
1989
2082
|
/**
|
|
1990
2083
|
* The base class of performance calculators.
|
|
1991
2084
|
*/
|
|
1992
2085
|
class PerformanceCalculator {
|
|
1993
2086
|
/**
|
|
1994
|
-
* The
|
|
1995
|
-
*/
|
|
1996
|
-
total = 0;
|
|
1997
|
-
/**
|
|
1998
|
-
* The calculated accuracy.
|
|
1999
|
-
*/
|
|
2000
|
-
computedAccuracy = new osuBase.Accuracy({});
|
|
2001
|
-
/**
|
|
2002
|
-
* Penalty for combo breaks.
|
|
2003
|
-
*/
|
|
2004
|
-
comboPenalty = 0;
|
|
2005
|
-
/**
|
|
2006
|
-
* The amount of misses that are filtered out from sliderbreaks.
|
|
2007
|
-
*/
|
|
2008
|
-
effectiveMissCount = 0;
|
|
2009
|
-
/**
|
|
2010
|
-
* Nerf factor used for nerfing beatmaps with very likely dropped sliderends.
|
|
2087
|
+
* @param difficultyAttributes The difficulty attributes to calculate.
|
|
2011
2088
|
*/
|
|
2012
|
-
|
|
2089
|
+
constructor(difficultyAttributes) {
|
|
2090
|
+
/**
|
|
2091
|
+
* The overall performance value.
|
|
2092
|
+
*/
|
|
2093
|
+
this.total = 0;
|
|
2094
|
+
/**
|
|
2095
|
+
* The calculated accuracy.
|
|
2096
|
+
*/
|
|
2097
|
+
this.computedAccuracy = new osuBase.Accuracy({});
|
|
2098
|
+
/**
|
|
2099
|
+
* The amount of misses that are filtered out from sliderbreaks.
|
|
2100
|
+
*/
|
|
2101
|
+
this.effectiveMissCount = 0;
|
|
2102
|
+
/**
|
|
2103
|
+
* Nerf factor used for nerfing beatmaps with very likely dropped sliderends.
|
|
2104
|
+
*/
|
|
2105
|
+
this.sliderNerfFactor = 1;
|
|
2106
|
+
if (this.isCacheableAttribute(difficultyAttributes)) {
|
|
2107
|
+
this.difficultyAttributes = Object.assign(Object.assign({}, difficultyAttributes), { mods: osuBase.ModUtil.pcStringToMods(difficultyAttributes.mods) });
|
|
2108
|
+
}
|
|
2109
|
+
else {
|
|
2110
|
+
this.difficultyAttributes = osuBase.Utils.deepCopy(difficultyAttributes);
|
|
2111
|
+
}
|
|
2112
|
+
}
|
|
2013
2113
|
/**
|
|
2014
2114
|
* Calculates the performance points of the beatmap.
|
|
2015
2115
|
*
|
|
@@ -2019,7 +2119,7 @@ class PerformanceCalculator {
|
|
|
2019
2119
|
calculate(options) {
|
|
2020
2120
|
this.handleOptions(options);
|
|
2021
2121
|
this.calculateValues();
|
|
2022
|
-
this.calculateTotalValue();
|
|
2122
|
+
this.total = this.calculateTotalValue();
|
|
2023
2123
|
return this;
|
|
2024
2124
|
}
|
|
2025
2125
|
/**
|
|
@@ -2050,11 +2150,11 @@ class PerformanceCalculator {
|
|
|
2050
2150
|
* @param options Options for performance calculation.
|
|
2051
2151
|
*/
|
|
2052
2152
|
handleOptions(options) {
|
|
2153
|
+
var _a;
|
|
2053
2154
|
const maxCombo = this.difficultyAttributes.maxCombo;
|
|
2054
2155
|
const miss = this.computedAccuracy.nmiss;
|
|
2055
|
-
const combo = options
|
|
2056
|
-
|
|
2057
|
-
if (options?.accPercent instanceof osuBase.Accuracy) {
|
|
2156
|
+
const combo = (_a = options === null || options === void 0 ? void 0 : options.combo) !== null && _a !== void 0 ? _a : maxCombo - miss;
|
|
2157
|
+
if ((options === null || options === void 0 ? void 0 : options.accPercent) instanceof osuBase.Accuracy) {
|
|
2058
2158
|
// Copy into new instance to not modify the original
|
|
2059
2159
|
this.computedAccuracy = new osuBase.Accuracy(options.accPercent);
|
|
2060
2160
|
if (this.computedAccuracy.n300 <= 0) {
|
|
@@ -2069,9 +2169,9 @@ class PerformanceCalculator {
|
|
|
2069
2169
|
}
|
|
2070
2170
|
else {
|
|
2071
2171
|
this.computedAccuracy = new osuBase.Accuracy({
|
|
2072
|
-
percent: options
|
|
2172
|
+
percent: options === null || options === void 0 ? void 0 : options.accPercent,
|
|
2073
2173
|
nobjects: this.totalHits,
|
|
2074
|
-
nmiss: options
|
|
2174
|
+
nmiss: (options === null || options === void 0 ? void 0 : options.miss) || 0,
|
|
2075
2175
|
});
|
|
2076
2176
|
}
|
|
2077
2177
|
this.effectiveMissCount = this.calculateEffectiveMissCount(combo, maxCombo);
|
|
@@ -2091,7 +2191,7 @@ class PerformanceCalculator {
|
|
|
2091
2191
|
Math.pow(this.difficultyAttributes.overallDifficulty /
|
|
2092
2192
|
13.33, 1.8)
|
|
2093
2193
|
: 1);
|
|
2094
|
-
const n50Multiplier = Math.max(0, this.difficultyAttributes.overallDifficulty > 0
|
|
2194
|
+
const n50Multiplier = Math.max(0, this.difficultyAttributes.overallDifficulty > 0
|
|
2095
2195
|
? 1 -
|
|
2096
2196
|
Math.pow(this.difficultyAttributes.overallDifficulty /
|
|
2097
2197
|
13.33, 5)
|
|
@@ -2105,7 +2205,7 @@ class PerformanceCalculator {
|
|
|
2105
2205
|
if (this.difficultyAttributes.sliderCount > 0) {
|
|
2106
2206
|
// We assume 15% of sliders in a beatmap are difficult since there's no way to tell from the performance calculator.
|
|
2107
2207
|
const estimateDifficultSliders = this.difficultyAttributes.sliderCount * 0.15;
|
|
2108
|
-
const estimateSliderEndsDropped = osuBase.MathUtils.clamp(Math.min(this.computedAccuracy.
|
|
2208
|
+
const estimateSliderEndsDropped = osuBase.MathUtils.clamp(Math.min(this.computedAccuracy.n100 +
|
|
2109
2209
|
this.computedAccuracy.n50 +
|
|
2110
2210
|
this.computedAccuracy.nmiss, maxCombo - combo), 0, estimateDifficultSliders);
|
|
2111
2211
|
this.sliderNerfFactor =
|
|
@@ -2116,6 +2216,22 @@ class PerformanceCalculator {
|
|
|
2116
2216
|
this.difficultyAttributes.sliderFactor;
|
|
2117
2217
|
}
|
|
2118
2218
|
}
|
|
2219
|
+
/**
|
|
2220
|
+
* Calculates a strain-based miss penalty.
|
|
2221
|
+
*
|
|
2222
|
+
* Strain-based miss penalty assumes that a player will miss on the hardest parts of a map,
|
|
2223
|
+
* so we use the amount of relatively difficult sections to adjust miss penalty
|
|
2224
|
+
* to make it more punishing on maps with lower amount of hard sections.
|
|
2225
|
+
*/
|
|
2226
|
+
calculateStrainBasedMissPenalty(difficultStrainCount) {
|
|
2227
|
+
if (this.effectiveMissCount === 0) {
|
|
2228
|
+
return 1;
|
|
2229
|
+
}
|
|
2230
|
+
return (0.96 /
|
|
2231
|
+
(this.effectiveMissCount /
|
|
2232
|
+
(4 * Math.pow(Math.log(difficultStrainCount), 0.94)) +
|
|
2233
|
+
1));
|
|
2234
|
+
}
|
|
2119
2235
|
/**
|
|
2120
2236
|
* Calculates the amount of misses + sliderbreaks from combo.
|
|
2121
2237
|
*/
|
|
@@ -2133,32 +2249,52 @@ class PerformanceCalculator {
|
|
|
2133
2249
|
}
|
|
2134
2250
|
return Math.max(this.computedAccuracy.nmiss, comboBasedMissCount);
|
|
2135
2251
|
}
|
|
2252
|
+
/**
|
|
2253
|
+
* Determines whether an attribute is a cacheable attribute.
|
|
2254
|
+
*
|
|
2255
|
+
* @param attributes The attributes to check.
|
|
2256
|
+
* @returns Whether the attributes are cacheable.
|
|
2257
|
+
*/
|
|
2258
|
+
isCacheableAttribute(attributes) {
|
|
2259
|
+
return typeof attributes.mods === "string";
|
|
2260
|
+
}
|
|
2136
2261
|
}
|
|
2137
2262
|
|
|
2138
2263
|
/**
|
|
2139
2264
|
* A performance points calculator that calculates performance points for osu!droid gamemode.
|
|
2140
2265
|
*/
|
|
2141
2266
|
class DroidPerformanceCalculator extends PerformanceCalculator {
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2267
|
+
constructor() {
|
|
2268
|
+
super(...arguments);
|
|
2269
|
+
/**
|
|
2270
|
+
* The aim performance value.
|
|
2271
|
+
*/
|
|
2272
|
+
this.aim = 0;
|
|
2273
|
+
/**
|
|
2274
|
+
* The tap performance value.
|
|
2275
|
+
*/
|
|
2276
|
+
this.tap = 0;
|
|
2277
|
+
/**
|
|
2278
|
+
* The accuracy performance value.
|
|
2279
|
+
*/
|
|
2280
|
+
this.accuracy = 0;
|
|
2281
|
+
/**
|
|
2282
|
+
* The flashlight performance value.
|
|
2283
|
+
*/
|
|
2284
|
+
this.flashlight = 0;
|
|
2285
|
+
/**
|
|
2286
|
+
* The visual performance value.
|
|
2287
|
+
*/
|
|
2288
|
+
this.visual = 0;
|
|
2289
|
+
this.finalMultiplier = 1.24;
|
|
2290
|
+
this.mode = osuBase.Modes.droid;
|
|
2291
|
+
this._aimSliderCheesePenalty = 1;
|
|
2292
|
+
this._flashlightSliderCheesePenalty = 1;
|
|
2293
|
+
this._visualSliderCheesePenalty = 1;
|
|
2294
|
+
this._tapPenalty = 1;
|
|
2295
|
+
this._deviation = 0;
|
|
2296
|
+
this._tapDeviation = 0;
|
|
2297
|
+
}
|
|
2162
2298
|
/**
|
|
2163
2299
|
* The penalty used to penalize the tap performance value.
|
|
2164
2300
|
*
|
|
@@ -2203,22 +2339,6 @@ class DroidPerformanceCalculator extends PerformanceCalculator {
|
|
|
2203
2339
|
get visualSliderCheesePenalty() {
|
|
2204
2340
|
return this._visualSliderCheesePenalty;
|
|
2205
2341
|
}
|
|
2206
|
-
difficultyAttributes;
|
|
2207
|
-
finalMultiplier = 1.24;
|
|
2208
|
-
mode = osuBase.Modes.droid;
|
|
2209
|
-
_aimSliderCheesePenalty = 1;
|
|
2210
|
-
_flashlightSliderCheesePenalty = 1;
|
|
2211
|
-
_visualSliderCheesePenalty = 1;
|
|
2212
|
-
_tapPenalty = 1;
|
|
2213
|
-
_deviation = 0;
|
|
2214
|
-
_tapDeviation = 0;
|
|
2215
|
-
/**
|
|
2216
|
-
* @param difficultyAttributes The difficulty attributes to calculate.
|
|
2217
|
-
*/
|
|
2218
|
-
constructor(difficultyAttributes) {
|
|
2219
|
-
super();
|
|
2220
|
-
this.difficultyAttributes = osuBase.Utils.deepCopy(difficultyAttributes);
|
|
2221
|
-
}
|
|
2222
2342
|
/**
|
|
2223
2343
|
* Applies a tap penalty value to this calculator.
|
|
2224
2344
|
*
|
|
@@ -2233,9 +2353,9 @@ class DroidPerformanceCalculator extends PerformanceCalculator {
|
|
|
2233
2353
|
if (value === this._tapPenalty) {
|
|
2234
2354
|
return;
|
|
2235
2355
|
}
|
|
2236
|
-
this.tap *= this._tapPenalty / value;
|
|
2237
2356
|
this._tapPenalty = value;
|
|
2238
|
-
this.
|
|
2357
|
+
this.tap = this.calculateTapValue();
|
|
2358
|
+
this.total = this.calculateTotalValue();
|
|
2239
2359
|
}
|
|
2240
2360
|
/**
|
|
2241
2361
|
* Applies an aim slider cheese penalty value to this calculator.
|
|
@@ -2255,8 +2375,8 @@ class DroidPerformanceCalculator extends PerformanceCalculator {
|
|
|
2255
2375
|
return;
|
|
2256
2376
|
}
|
|
2257
2377
|
this._aimSliderCheesePenalty = value;
|
|
2258
|
-
this.calculateAimValue();
|
|
2259
|
-
this.calculateTotalValue();
|
|
2378
|
+
this.aim = this.calculateAimValue();
|
|
2379
|
+
this.total = this.calculateTotalValue();
|
|
2260
2380
|
}
|
|
2261
2381
|
/**
|
|
2262
2382
|
* Applies a flashlight slider cheese penalty value to this calculator.
|
|
@@ -2276,8 +2396,8 @@ class DroidPerformanceCalculator extends PerformanceCalculator {
|
|
|
2276
2396
|
return;
|
|
2277
2397
|
}
|
|
2278
2398
|
this._flashlightSliderCheesePenalty = value;
|
|
2279
|
-
this.calculateFlashlightValue();
|
|
2280
|
-
this.calculateTotalValue();
|
|
2399
|
+
this.flashlight = this.calculateFlashlightValue();
|
|
2400
|
+
this.total = this.calculateTotalValue();
|
|
2281
2401
|
}
|
|
2282
2402
|
/**
|
|
2283
2403
|
* Applies a visual slider cheese penalty value to this calculator.
|
|
@@ -2297,80 +2417,96 @@ class DroidPerformanceCalculator extends PerformanceCalculator {
|
|
|
2297
2417
|
return;
|
|
2298
2418
|
}
|
|
2299
2419
|
this._visualSliderCheesePenalty = value;
|
|
2300
|
-
this.calculateVisualValue();
|
|
2301
|
-
this.calculateTotalValue();
|
|
2420
|
+
this.visual = this.calculateVisualValue();
|
|
2421
|
+
this.total = this.calculateTotalValue();
|
|
2302
2422
|
}
|
|
2303
2423
|
calculateValues() {
|
|
2304
2424
|
this._deviation = this.calculateDeviation();
|
|
2305
2425
|
this._tapDeviation = this.calculateTapDeviation();
|
|
2306
|
-
this.calculateAimValue();
|
|
2307
|
-
this.calculateTapValue();
|
|
2308
|
-
this.calculateAccuracyValue();
|
|
2309
|
-
this.calculateFlashlightValue();
|
|
2310
|
-
this.calculateVisualValue();
|
|
2426
|
+
this.aim = this.calculateAimValue();
|
|
2427
|
+
this.tap = this.calculateTapValue();
|
|
2428
|
+
this.accuracy = this.calculateAccuracyValue();
|
|
2429
|
+
this.flashlight = this.calculateFlashlightValue();
|
|
2430
|
+
this.visual = this.calculateVisualValue();
|
|
2311
2431
|
}
|
|
2312
2432
|
calculateTotalValue() {
|
|
2313
|
-
this.
|
|
2314
|
-
Math.pow(
|
|
2315
|
-
|
|
2316
|
-
|
|
2317
|
-
|
|
2318
|
-
Math.pow(this.visual, 1.1), 1 / 1.1) * this.finalMultiplier;
|
|
2433
|
+
return (Math.pow(Math.pow(this.aim, 1.1) +
|
|
2434
|
+
Math.pow(this.tap, 1.1) +
|
|
2435
|
+
Math.pow(this.accuracy, 1.1) +
|
|
2436
|
+
Math.pow(this.flashlight, 1.1) +
|
|
2437
|
+
Math.pow(this.visual, 1.1), 1 / 1.1) * this.finalMultiplier);
|
|
2319
2438
|
}
|
|
2320
2439
|
handleOptions(options) {
|
|
2321
|
-
|
|
2322
|
-
this.
|
|
2440
|
+
var _a, _b, _c, _d;
|
|
2441
|
+
this._tapPenalty = (_a = options === null || options === void 0 ? void 0 : options.tapPenalty) !== null && _a !== void 0 ? _a : 1;
|
|
2442
|
+
this._aimSliderCheesePenalty = (_b = options === null || options === void 0 ? void 0 : options.aimSliderCheesePenalty) !== null && _b !== void 0 ? _b : 1;
|
|
2323
2443
|
this._flashlightSliderCheesePenalty =
|
|
2324
|
-
options
|
|
2444
|
+
(_c = options === null || options === void 0 ? void 0 : options.flashlightSliderCheesePenalty) !== null && _c !== void 0 ? _c : 1;
|
|
2325
2445
|
this._visualSliderCheesePenalty =
|
|
2326
|
-
options
|
|
2446
|
+
(_d = options === null || options === void 0 ? void 0 : options.visualSliderCheesePenalty) !== null && _d !== void 0 ? _d : 1;
|
|
2327
2447
|
super.handleOptions(options);
|
|
2328
2448
|
}
|
|
2329
2449
|
/**
|
|
2330
2450
|
* Calculates the aim performance value of the beatmap.
|
|
2331
2451
|
*/
|
|
2332
2452
|
calculateAimValue() {
|
|
2333
|
-
|
|
2334
|
-
|
|
2335
|
-
|
|
2336
|
-
|
|
2337
|
-
this.aim *=
|
|
2338
|
-
0.97 *
|
|
2339
|
-
Math.pow(1 -
|
|
2340
|
-
Math.pow(this.effectiveMissCount / this.totalHits, 0.775), this.effectiveMissCount);
|
|
2341
|
-
}
|
|
2342
|
-
// Combo scaling
|
|
2343
|
-
this.aim *= this.comboPenalty;
|
|
2453
|
+
let aimValue = this.baseValue(Math.pow(this.difficultyAttributes.aimDifficulty, 0.8));
|
|
2454
|
+
aimValue *= Math.min(this.calculateStrainBasedMissPenalty(this.difficultyAttributes.aimDifficultStrainCount), this.proportionalMissPenalty);
|
|
2455
|
+
// Scale the aim value with estimated full combo deviation.
|
|
2456
|
+
aimValue *= this.calculateDeviationBasedLengthScaling();
|
|
2344
2457
|
// Scale the aim value with slider factor to nerf very likely dropped sliderends.
|
|
2345
|
-
|
|
2458
|
+
aimValue *= this.sliderNerfFactor;
|
|
2346
2459
|
// Scale the aim value with slider cheese penalty.
|
|
2347
|
-
|
|
2460
|
+
aimValue *= this._aimSliderCheesePenalty;
|
|
2348
2461
|
// Scale the aim value with deviation.
|
|
2349
|
-
|
|
2350
|
-
1.
|
|
2351
|
-
Math.pow(osuBase.ErrorFunction.erf(
|
|
2462
|
+
aimValue *=
|
|
2463
|
+
1.025 *
|
|
2464
|
+
Math.pow(osuBase.ErrorFunction.erf(25 / (Math.SQRT2 * this._deviation)), 0.475);
|
|
2465
|
+
// OD 7 SS stays the same.
|
|
2466
|
+
aimValue *= 0.98 + Math.pow(7, 2) / 2500;
|
|
2467
|
+
return aimValue;
|
|
2352
2468
|
}
|
|
2353
2469
|
/**
|
|
2354
2470
|
* Calculates the tap performance value of the beatmap.
|
|
2355
2471
|
*/
|
|
2356
2472
|
calculateTapValue() {
|
|
2357
|
-
|
|
2358
|
-
|
|
2359
|
-
|
|
2360
|
-
|
|
2361
|
-
|
|
2362
|
-
|
|
2363
|
-
|
|
2364
|
-
|
|
2365
|
-
|
|
2366
|
-
//
|
|
2367
|
-
this
|
|
2473
|
+
let tapValue = this.baseValue(this.difficultyAttributes.tapDifficulty);
|
|
2474
|
+
tapValue *= this.calculateStrainBasedMissPenalty(this.difficultyAttributes.tapDifficultStrainCount);
|
|
2475
|
+
// Scale the tap value with estimated full combo deviation.
|
|
2476
|
+
// Consider notes that are difficult to tap with respect to other notes, but
|
|
2477
|
+
// also cap the note count to prevent buffing filler patterns.
|
|
2478
|
+
tapValue *= this.calculateDeviationBasedLengthScaling(Math.min(this.difficultyAttributes.speedNoteCount, this.totalHits / 1.45));
|
|
2479
|
+
// Normalize the deviation to 300 BPM.
|
|
2480
|
+
const normalizedDeviation = this.tapDeviation *
|
|
2481
|
+
Math.max(1, 50 / this.difficultyAttributes.averageSpeedDeltaTime);
|
|
2482
|
+
// We expect the player to get 7500/x deviation when doubletapping x BPM.
|
|
2483
|
+
// Using this expectation, we penalize scores with deviation above 25.
|
|
2484
|
+
const averageBPM = 60000 / 4 / this.difficultyAttributes.averageSpeedDeltaTime;
|
|
2485
|
+
const adjustedDeviation = normalizedDeviation *
|
|
2486
|
+
(1 +
|
|
2487
|
+
1 /
|
|
2488
|
+
(1 +
|
|
2489
|
+
Math.exp(-(normalizedDeviation - 7500 / averageBPM) /
|
|
2490
|
+
((2 * 300) / averageBPM))));
|
|
2368
2491
|
// Scale the tap value with tap deviation.
|
|
2369
|
-
|
|
2370
|
-
1.
|
|
2371
|
-
Math.pow(osuBase.ErrorFunction.erf(
|
|
2492
|
+
tapValue *=
|
|
2493
|
+
1.05 *
|
|
2494
|
+
Math.pow(osuBase.ErrorFunction.erf(20 / (Math.SQRT2 * adjustedDeviation)), 0.6);
|
|
2495
|
+
// Additional scaling for tap value based on average BPM and how "vibroable" the beatmap is.
|
|
2496
|
+
// Higher BPMs require more precise tapping. When the deviation is too high,
|
|
2497
|
+
// it can be assumed that the player taps invariant to rhythm.
|
|
2498
|
+
// We harshen the punishment for such scenario.
|
|
2499
|
+
tapValue *=
|
|
2500
|
+
(1 - Math.pow(this.difficultyAttributes.vibroFactor, 6)) /
|
|
2501
|
+
(1 +
|
|
2502
|
+
Math.exp((this._tapDeviation - 7500 / averageBPM) /
|
|
2503
|
+
((2 * 300) / averageBPM))) +
|
|
2504
|
+
Math.pow(this.difficultyAttributes.vibroFactor, 6);
|
|
2372
2505
|
// Scale the tap value with three-fingered penalty.
|
|
2373
|
-
|
|
2506
|
+
tapValue /= this._tapPenalty;
|
|
2507
|
+
// OD 8 SS stays the same.
|
|
2508
|
+
tapValue *= 0.95 + Math.pow(8, 2) / 750;
|
|
2509
|
+
return tapValue;
|
|
2374
2510
|
}
|
|
2375
2511
|
/**
|
|
2376
2512
|
* Calculates the accuracy performance value of the beatmap.
|
|
@@ -2378,84 +2514,112 @@ class DroidPerformanceCalculator extends PerformanceCalculator {
|
|
|
2378
2514
|
calculateAccuracyValue() {
|
|
2379
2515
|
if (this.difficultyAttributes.mods.some((m) => m instanceof osuBase.ModRelax) ||
|
|
2380
2516
|
this.totalSuccessfulHits === 0) {
|
|
2381
|
-
|
|
2382
|
-
return;
|
|
2517
|
+
return 0;
|
|
2383
2518
|
}
|
|
2384
|
-
this.
|
|
2385
|
-
650 *
|
|
2386
|
-
Math.exp(-0.125 * this._deviation) *
|
|
2387
|
-
// The following function is to give higher reward for deviations lower than 25 (250 UR).
|
|
2388
|
-
(15 / (this._deviation + 15) + 0.65);
|
|
2389
|
-
// Bonus for many hitcircles - it's harder to keep good accuracy up for longer.
|
|
2519
|
+
let accuracyValue = 650 * Math.exp(-0.1 * this._deviation);
|
|
2390
2520
|
const ncircles = this.difficultyAttributes.mods.some((m) => m instanceof osuBase.ModScoreV2)
|
|
2391
2521
|
? this.totalHits - this.difficultyAttributes.spinnerCount
|
|
2392
2522
|
: this.difficultyAttributes.hitCircleCount;
|
|
2393
|
-
|
|
2523
|
+
// Bonus for many hitcircles - it's harder to keep good accuracy up for longer.
|
|
2524
|
+
accuracyValue *= Math.min(1.15, Math.sqrt(Math.log(1 + ((Math.E - 1) * ncircles) / 1000)));
|
|
2394
2525
|
// Scale the accuracy value with rhythm complexity.
|
|
2395
|
-
|
|
2526
|
+
accuracyValue *=
|
|
2396
2527
|
1.5 /
|
|
2397
2528
|
(1 +
|
|
2398
2529
|
Math.exp(-(this.difficultyAttributes.rhythmDifficulty - 1) / 2));
|
|
2530
|
+
// Penalize accuracy pp after the first miss.
|
|
2531
|
+
accuracyValue *= Math.pow(0.97, Math.max(0, this.effectiveMissCount - 1));
|
|
2399
2532
|
if (this.difficultyAttributes.mods.some((m) => m instanceof osuBase.ModFlashlight)) {
|
|
2400
|
-
|
|
2533
|
+
accuracyValue *= 1.02;
|
|
2401
2534
|
}
|
|
2535
|
+
return accuracyValue;
|
|
2402
2536
|
}
|
|
2403
2537
|
/**
|
|
2404
2538
|
* Calculates the flashlight performance value of the beatmap.
|
|
2405
2539
|
*/
|
|
2406
2540
|
calculateFlashlightValue() {
|
|
2407
2541
|
if (!this.difficultyAttributes.mods.some((m) => m instanceof osuBase.ModFlashlight)) {
|
|
2408
|
-
|
|
2409
|
-
return;
|
|
2410
|
-
}
|
|
2411
|
-
this.flashlight =
|
|
2412
|
-
Math.pow(this.difficultyAttributes.flashlightDifficulty, 1.6) * 25;
|
|
2413
|
-
// Combo scaling
|
|
2414
|
-
this.flashlight *= this.comboPenalty;
|
|
2415
|
-
if (this.effectiveMissCount > 0) {
|
|
2416
|
-
// Penalize misses by assessing # of misses relative to the total # of objects. Default a 3% reduction for any # of misses.
|
|
2417
|
-
this.flashlight *=
|
|
2418
|
-
0.97 *
|
|
2419
|
-
Math.pow(1 -
|
|
2420
|
-
Math.pow(this.effectiveMissCount / this.totalHits, 0.775), Math.pow(this.effectiveMissCount, 0.875));
|
|
2542
|
+
return 0;
|
|
2421
2543
|
}
|
|
2544
|
+
let flashlightValue = Math.pow(this.difficultyAttributes.flashlightDifficulty, 1.6) * 25;
|
|
2545
|
+
flashlightValue *= Math.min(this.calculateStrainBasedMissPenalty(this.difficultyAttributes.flashlightDifficultStrainCount), this.proportionalMissPenalty);
|
|
2422
2546
|
// Account for shorter maps having a higher ratio of 0 combo/100 combo flashlight radius.
|
|
2423
|
-
|
|
2547
|
+
flashlightValue *=
|
|
2424
2548
|
0.7 +
|
|
2425
2549
|
0.1 * Math.min(1, this.totalHits / 200) +
|
|
2426
2550
|
(this.totalHits > 200
|
|
2427
2551
|
? 0.2 * Math.min(1, (this.totalHits - 200) / 200)
|
|
2428
2552
|
: 0);
|
|
2429
2553
|
// Scale the flashlight value with slider cheese penalty.
|
|
2430
|
-
|
|
2554
|
+
flashlightValue *= this._flashlightSliderCheesePenalty;
|
|
2431
2555
|
// Scale the flashlight value with deviation.
|
|
2432
|
-
|
|
2556
|
+
flashlightValue *= osuBase.ErrorFunction.erf(50 / (Math.SQRT2 * this._deviation));
|
|
2557
|
+
return flashlightValue;
|
|
2433
2558
|
}
|
|
2434
2559
|
/**
|
|
2435
2560
|
* Calculates the visual performance value of the beatmap.
|
|
2436
2561
|
*/
|
|
2437
2562
|
calculateVisualValue() {
|
|
2438
|
-
this.
|
|
2439
|
-
|
|
2440
|
-
|
|
2441
|
-
|
|
2442
|
-
|
|
2443
|
-
0.97 *
|
|
2444
|
-
Math.pow(1 -
|
|
2445
|
-
Math.pow(this.effectiveMissCount / this.totalHits, 0.775), this.effectiveMissCount);
|
|
2446
|
-
}
|
|
2447
|
-
// Combo scaling
|
|
2448
|
-
this.visual *= this.comboPenalty;
|
|
2449
|
-
// Scale the visual value with object count to penalize short maps.
|
|
2450
|
-
this.visual *= Math.min(1, 1.650668 +
|
|
2451
|
-
(0.4845796 - 1.650668) /
|
|
2452
|
-
(1 + Math.pow(this.totalHits / 817.9306, 1.147469)));
|
|
2563
|
+
let visualValue = Math.pow(this.difficultyAttributes.visualDifficulty, 1.6) * 22.5;
|
|
2564
|
+
visualValue *= Math.min(this.calculateStrainBasedMissPenalty(this.difficultyAttributes.visualDifficultStrainCount), this.proportionalMissPenalty);
|
|
2565
|
+
// Scale the visual value with estimated full combo deviation.
|
|
2566
|
+
// As visual is easily "bypassable" with memorization, punish for memorization.
|
|
2567
|
+
visualValue *= this.calculateDeviationBasedLengthScaling(undefined, true);
|
|
2453
2568
|
// Scale the visual value with slider cheese penalty.
|
|
2454
|
-
|
|
2569
|
+
visualValue *= this._visualSliderCheesePenalty;
|
|
2455
2570
|
// Scale the visual value with deviation.
|
|
2456
|
-
|
|
2457
|
-
1.
|
|
2458
|
-
Math.pow(osuBase.ErrorFunction.erf(
|
|
2571
|
+
visualValue *=
|
|
2572
|
+
1.05 *
|
|
2573
|
+
Math.pow(osuBase.ErrorFunction.erf(25 / (Math.SQRT2 * this._deviation)), 0.775);
|
|
2574
|
+
// OD 5 SS stays the same.
|
|
2575
|
+
visualValue *= 0.98 + Math.pow(5, 2) / 2500;
|
|
2576
|
+
return visualValue;
|
|
2577
|
+
}
|
|
2578
|
+
/**
|
|
2579
|
+
* The object-based proportional miss penalty.
|
|
2580
|
+
*/
|
|
2581
|
+
get proportionalMissPenalty() {
|
|
2582
|
+
if (this.effectiveMissCount === 0) {
|
|
2583
|
+
return 1;
|
|
2584
|
+
}
|
|
2585
|
+
const missProportion = (this.totalHits - this.effectiveMissCount) / (this.totalHits + 1);
|
|
2586
|
+
const noMissProportion = this.totalHits / (this.totalHits + 1);
|
|
2587
|
+
return (
|
|
2588
|
+
// Aim deviation-based scale.
|
|
2589
|
+
(osuBase.ErrorFunction.erfInv(missProportion) /
|
|
2590
|
+
osuBase.ErrorFunction.erfInv(noMissProportion)) *
|
|
2591
|
+
// Cheesing-based scale (i.e. 50% misses is deliberately only hitting each other
|
|
2592
|
+
// note, 90% misses is deliberately only hitting 1 note every 10 notes).
|
|
2593
|
+
Math.pow(missProportion, 8));
|
|
2594
|
+
}
|
|
2595
|
+
/**
|
|
2596
|
+
* Calculates the object-based length scaling based on the deviation of a player for a full
|
|
2597
|
+
* combo in this beatmap, taking retries into account.
|
|
2598
|
+
*
|
|
2599
|
+
* @param objectCount The amount of objects to be considered. Defaults to the amount of
|
|
2600
|
+
* objects in this beatmap.
|
|
2601
|
+
* @param punishForMemorization Whether to punish the deviation for memorization. Defaults to `false`.
|
|
2602
|
+
*/
|
|
2603
|
+
calculateDeviationBasedLengthScaling(objectCount = this.totalHits, punishForMemorization = false) {
|
|
2604
|
+
// Assume a sample proportion of hits for a full combo to be `(n - 0.5) / n` due to
|
|
2605
|
+
// continuity correction, where `n` is the object count.
|
|
2606
|
+
const calculateProportion = (notes) => (notes - 0.5) / notes;
|
|
2607
|
+
// Keeping `x` notes as the benchmark, assume that a player will retry a beatmap
|
|
2608
|
+
// `max(1, x/n)` times relative to an `x`-note beatmap.
|
|
2609
|
+
const benchmarkNotes = 700;
|
|
2610
|
+
// Calculate the proportion equivalent to the bottom half of retry count percentile of
|
|
2611
|
+
// scores and take it as the player's "real" proportion.
|
|
2612
|
+
const retryProportion = (proportion, notes, tries) => proportion +
|
|
2613
|
+
Math.sqrt((2 * proportion * (1 - proportion)) / notes) *
|
|
2614
|
+
osuBase.ErrorFunction.erfInv(1 / tries - 1);
|
|
2615
|
+
// Using the proportion, we calculate the deviation based off that proportion and again
|
|
2616
|
+
// compared to the hit deviation for proportion `(n - 0.5) / n`.
|
|
2617
|
+
let multiplier = Math.max(0, osuBase.ErrorFunction.erfInv(retryProportion(calculateProportion(objectCount), objectCount, Math.max(1, benchmarkNotes / objectCount))) / osuBase.ErrorFunction.erfInv(calculateProportion(benchmarkNotes)) || 0);
|
|
2618
|
+
// Punish for memorization if needed.
|
|
2619
|
+
if (punishForMemorization) {
|
|
2620
|
+
multiplier *= Math.min(1, Math.sqrt(objectCount / benchmarkNotes));
|
|
2621
|
+
}
|
|
2622
|
+
return multiplier;
|
|
2459
2623
|
}
|
|
2460
2624
|
/**
|
|
2461
2625
|
* Estimates the player's tap deviation based on the OD, number of circles and sliders,
|
|
@@ -2475,41 +2639,63 @@ class DroidPerformanceCalculator extends PerformanceCalculator {
|
|
|
2475
2639
|
if (this.totalSuccessfulHits === 0) {
|
|
2476
2640
|
return Number.POSITIVE_INFINITY;
|
|
2477
2641
|
}
|
|
2478
|
-
const
|
|
2479
|
-
|
|
2480
|
-
const
|
|
2481
|
-
|
|
2482
|
-
|
|
2483
|
-
const
|
|
2484
|
-
const
|
|
2485
|
-
const
|
|
2486
|
-
const
|
|
2487
|
-
|
|
2488
|
-
|
|
2489
|
-
|
|
2490
|
-
//
|
|
2491
|
-
|
|
2492
|
-
|
|
2493
|
-
|
|
2494
|
-
|
|
2495
|
-
|
|
2496
|
-
|
|
2497
|
-
|
|
2498
|
-
|
|
2499
|
-
|
|
2500
|
-
|
|
2501
|
-
|
|
2502
|
-
|
|
2503
|
-
|
|
2504
|
-
|
|
2505
|
-
|
|
2642
|
+
const { clockRate } = this.difficultyAttributes;
|
|
2643
|
+
const hitWindow = this.getConvertedHitWindow();
|
|
2644
|
+
const hitWindow300 = hitWindow.greatWindow / clockRate;
|
|
2645
|
+
const hitWindow100 = hitWindow.okWindow / clockRate;
|
|
2646
|
+
const hitWindow50 = hitWindow.mehWindow / clockRate;
|
|
2647
|
+
const { n100, n50, nmiss } = this.computedAccuracy;
|
|
2648
|
+
const circleCount = this.difficultyAttributes.hitCircleCount;
|
|
2649
|
+
const missCountCircles = Math.min(nmiss, circleCount);
|
|
2650
|
+
const mehCountCircles = Math.min(n50, circleCount - missCountCircles);
|
|
2651
|
+
const okCountCircles = Math.min(n100, circleCount - missCountCircles - mehCountCircles);
|
|
2652
|
+
const greatCountCircles = Math.max(0, circleCount - missCountCircles - mehCountCircles - okCountCircles);
|
|
2653
|
+
// Assume 100s, 50s, and misses happen on circles. If there are less non-300s on circles than 300s,
|
|
2654
|
+
// compute the deviation on circles.
|
|
2655
|
+
if (greatCountCircles > 0) {
|
|
2656
|
+
// The probability that a player hits a circle is unknown, but we can estimate it to be
|
|
2657
|
+
// the number of greats on circles divided by the number of circles, and then add one
|
|
2658
|
+
// to the number of circles as a bias correction.
|
|
2659
|
+
const greatProbabilityCircle = greatCountCircles /
|
|
2660
|
+
(circleCount - missCountCircles - mehCountCircles + 1);
|
|
2661
|
+
// Compute the deviation assuming 300s and 100s are normally distributed, and 50s are uniformly distributed.
|
|
2662
|
+
// Begin with the normal distribution first.
|
|
2663
|
+
let deviationOnCircles = hitWindow300 /
|
|
2664
|
+
(Math.SQRT2 * osuBase.ErrorFunction.erfInv(greatProbabilityCircle));
|
|
2665
|
+
deviationOnCircles *= Math.sqrt(1 -
|
|
2666
|
+
(Math.sqrt(2 / Math.PI) *
|
|
2667
|
+
hitWindow100 *
|
|
2668
|
+
Math.exp(-0.5 *
|
|
2669
|
+
Math.pow(hitWindow100 / deviationOnCircles, 2))) /
|
|
2670
|
+
(deviationOnCircles *
|
|
2671
|
+
osuBase.ErrorFunction.erf(hitWindow100 /
|
|
2672
|
+
(Math.SQRT2 * deviationOnCircles))));
|
|
2673
|
+
// Then compute the variance for 50s.
|
|
2674
|
+
const mehVariance = (hitWindow50 * hitWindow50 +
|
|
2675
|
+
hitWindow100 * hitWindow50 +
|
|
2676
|
+
hitWindow100 * hitWindow100) /
|
|
2677
|
+
3;
|
|
2678
|
+
// Find the total deviation.
|
|
2679
|
+
deviationOnCircles = Math.sqrt(((greatCountCircles + okCountCircles) *
|
|
2680
|
+
Math.pow(deviationOnCircles, 2) +
|
|
2681
|
+
mehCountCircles * mehVariance) /
|
|
2682
|
+
(greatCountCircles + okCountCircles + mehCountCircles));
|
|
2683
|
+
return deviationOnCircles;
|
|
2684
|
+
}
|
|
2685
|
+
// If there are more non-300s than there are circles, compute the deviation on sliders instead.
|
|
2686
|
+
// Here, all that matters is whether or not the slider was missed, since it is impossible
|
|
2687
|
+
// to get a 100 or 50 on a slider by mis-tapping it.
|
|
2688
|
+
const sliderCount = this.difficultyAttributes.sliderCount;
|
|
2689
|
+
const missCountSliders = Math.min(sliderCount, nmiss - missCountCircles);
|
|
2690
|
+
const greatCountSliders = sliderCount - missCountSliders;
|
|
2691
|
+
// We only get here if nothing was hit. In this case, there is no estimate for deviation.
|
|
2692
|
+
// Note that this is never negative, so checking if this is only equal to 0 makes sense.
|
|
2693
|
+
if (greatCountSliders === 0) {
|
|
2506
2694
|
return Number.POSITIVE_INFINITY;
|
|
2507
2695
|
}
|
|
2508
|
-
const
|
|
2509
|
-
|
|
2510
|
-
|
|
2511
|
-
(Math.SQRT2 * osuBase.ErrorFunction.erfInv(greatProbabilitySlider));
|
|
2512
|
-
return Math.min(deviationOnCircles, deviationOnSliders);
|
|
2696
|
+
const greatProbabilitySlider = greatCountSliders / (sliderCount + 1);
|
|
2697
|
+
return (hitWindow50 /
|
|
2698
|
+
(Math.SQRT2 * osuBase.ErrorFunction.erfInv(greatProbabilitySlider)));
|
|
2513
2699
|
}
|
|
2514
2700
|
/**
|
|
2515
2701
|
* Does the same as {@link calculateDeviation}, but only for notes and inaccuracies that are relevant to tap difficulty.
|
|
@@ -2521,14 +2707,66 @@ class DroidPerformanceCalculator extends PerformanceCalculator {
|
|
|
2521
2707
|
if (this.totalSuccessfulHits === 0) {
|
|
2522
2708
|
return Number.POSITIVE_INFINITY;
|
|
2523
2709
|
}
|
|
2524
|
-
const
|
|
2525
|
-
const
|
|
2526
|
-
const
|
|
2527
|
-
|
|
2528
|
-
|
|
2710
|
+
const { speedNoteCount, clockRate } = this.difficultyAttributes;
|
|
2711
|
+
const hitWindow = this.getConvertedHitWindow();
|
|
2712
|
+
const hitWindow300 = hitWindow.greatWindow / clockRate;
|
|
2713
|
+
const hitWindow100 = hitWindow.okWindow / clockRate;
|
|
2714
|
+
const hitWindow50 = hitWindow.mehWindow / clockRate;
|
|
2715
|
+
const { n100, n50, nmiss } = this.computedAccuracy;
|
|
2716
|
+
// Assume a fixed ratio of non-300s hit in speed notes based on speed note count ratio and OD.
|
|
2717
|
+
// Graph: https://www.desmos.com/calculator/iskvgjkxr4
|
|
2718
|
+
const speedNoteRatio = speedNoteCount / this.totalHits;
|
|
2719
|
+
const nonGreatCount = n100 + n50 + nmiss;
|
|
2720
|
+
const nonGreatRatio = 1 -
|
|
2721
|
+
(Math.pow(Math.exp(Math.sqrt(hitWindow300)) + 1, 1 - speedNoteRatio) -
|
|
2722
|
+
1) /
|
|
2723
|
+
Math.exp(Math.sqrt(hitWindow300));
|
|
2724
|
+
const relevantCountGreat = Math.max(0, speedNoteCount - nonGreatCount * nonGreatRatio);
|
|
2725
|
+
const relevantCountOk = n100 * nonGreatRatio;
|
|
2726
|
+
const relevantCountMeh = n50 * nonGreatRatio;
|
|
2727
|
+
const relevantCountMiss = nmiss * nonGreatRatio;
|
|
2728
|
+
// Assume 100s, 50s, and misses happen on circles. If there are less non-300s on circles than 300s,
|
|
2729
|
+
// compute the deviation on circles.
|
|
2730
|
+
if (relevantCountGreat > 0) {
|
|
2731
|
+
// The probability that a player hits a circle is unknown, but we can estimate it to be
|
|
2732
|
+
// the number of greats on circles divided by the number of circles, and then add one
|
|
2733
|
+
// to the number of circles as a bias correction.
|
|
2734
|
+
const greatProbabilityCircle = relevantCountGreat /
|
|
2735
|
+
(speedNoteCount - relevantCountMiss - relevantCountMeh + 1);
|
|
2736
|
+
// Compute the deviation assuming 300s and 100s are normally distributed, and 50s are uniformly distributed.
|
|
2737
|
+
// Begin with the normal distribution first.
|
|
2738
|
+
let deviationOnCircles = hitWindow300 /
|
|
2739
|
+
(Math.SQRT2 * osuBase.ErrorFunction.erfInv(greatProbabilityCircle));
|
|
2740
|
+
deviationOnCircles *= Math.sqrt(1 -
|
|
2741
|
+
(Math.sqrt(2 / Math.PI) *
|
|
2742
|
+
hitWindow100 *
|
|
2743
|
+
Math.exp(-0.5 *
|
|
2744
|
+
Math.pow(hitWindow100 / deviationOnCircles, 2))) /
|
|
2745
|
+
(deviationOnCircles *
|
|
2746
|
+
osuBase.ErrorFunction.erf(hitWindow100 /
|
|
2747
|
+
(Math.SQRT2 * deviationOnCircles))));
|
|
2748
|
+
// Then compute the variance for 50s.
|
|
2749
|
+
const mehVariance = (hitWindow50 * hitWindow50 +
|
|
2750
|
+
hitWindow100 * hitWindow50 +
|
|
2751
|
+
hitWindow100 * hitWindow100) /
|
|
2752
|
+
3;
|
|
2753
|
+
// Find the total deviation.
|
|
2754
|
+
deviationOnCircles = Math.sqrt(((relevantCountGreat + relevantCountOk) *
|
|
2755
|
+
Math.pow(deviationOnCircles, 2) +
|
|
2756
|
+
relevantCountMeh * mehVariance) /
|
|
2757
|
+
(relevantCountGreat + relevantCountOk + relevantCountMeh));
|
|
2758
|
+
return deviationOnCircles;
|
|
2759
|
+
}
|
|
2760
|
+
return Number.POSITIVE_INFINITY;
|
|
2761
|
+
}
|
|
2762
|
+
getConvertedHitWindow() {
|
|
2763
|
+
const hitWindow300 = new osuBase.OsuHitWindow(this.difficultyAttributes.overallDifficulty).greatWindow;
|
|
2764
|
+
if (this.difficultyAttributes.mods.some((m) => m instanceof osuBase.ModPrecise)) {
|
|
2765
|
+
return new osuBase.PreciseDroidHitWindow(osuBase.PreciseDroidHitWindow.greatWindowToOD(hitWindow300 * this.difficultyAttributes.clockRate));
|
|
2766
|
+
}
|
|
2767
|
+
else {
|
|
2768
|
+
return new osuBase.DroidHitWindow(osuBase.DroidHitWindow.greatWindowToOD(hitWindow300 * this.difficultyAttributes.clockRate));
|
|
2529
2769
|
}
|
|
2530
|
-
const greatProbability = relevantCountGreat / (this.difficultyAttributes.speedNoteCount + 1);
|
|
2531
|
-
return (hitWindow300 / (Math.SQRT2 * osuBase.ErrorFunction.erfInv(greatProbability)));
|
|
2532
2770
|
}
|
|
2533
2771
|
toString() {
|
|
2534
2772
|
return (this.total.toFixed(2) +
|
|
@@ -2551,20 +2789,8 @@ class DroidPerformanceCalculator extends PerformanceCalculator {
|
|
|
2551
2789
|
* and to calculate a final difficulty value representing the difficulty of hitting all the processed objects.
|
|
2552
2790
|
*/
|
|
2553
2791
|
class OsuSkill extends StrainSkill {
|
|
2554
|
-
/**
|
|
2555
|
-
* The default multiplier applied to the final difficulty value after all other calculations.
|
|
2556
|
-
*
|
|
2557
|
-
* May be overridden via {@link difficultyMultiplier}.
|
|
2558
|
-
*/
|
|
2559
|
-
static defaultDifficultyMultiplier = 1.06;
|
|
2560
|
-
/**
|
|
2561
|
-
* The final multiplier to be applied to the final difficulty value after all other calculations.
|
|
2562
|
-
*/
|
|
2563
|
-
difficultyMultiplier = OsuSkill.defaultDifficultyMultiplier;
|
|
2564
2792
|
difficultyValue() {
|
|
2565
|
-
const strains = this.strainPeaks
|
|
2566
|
-
.slice()
|
|
2567
|
-
.sort((a, b) => b - a);
|
|
2793
|
+
const strains = this.strainPeaks.slice().sort((a, b) => b - a);
|
|
2568
2794
|
if (this.reducedSectionCount > 0) {
|
|
2569
2795
|
// We are reducing the highest strains first to account for extreme difficulty spikes.
|
|
2570
2796
|
for (let i = 0; i < Math.min(strains.length, this.reducedSectionCount); ++i) {
|
|
@@ -2575,17 +2801,17 @@ class OsuSkill extends StrainSkill {
|
|
|
2575
2801
|
}
|
|
2576
2802
|
// Difficulty is the weighted sum of the highest strains from every section.
|
|
2577
2803
|
// We're sorting from highest to lowest strain.
|
|
2578
|
-
|
|
2804
|
+
this.difficulty = 0;
|
|
2579
2805
|
let weight = 1;
|
|
2580
2806
|
for (const strain of strains) {
|
|
2581
2807
|
const addition = strain * weight;
|
|
2582
|
-
if (difficulty + addition === difficulty) {
|
|
2808
|
+
if (this.difficulty + addition === this.difficulty) {
|
|
2583
2809
|
break;
|
|
2584
2810
|
}
|
|
2585
|
-
difficulty += addition;
|
|
2811
|
+
this.difficulty += addition;
|
|
2586
2812
|
weight *= this.decayWeight;
|
|
2587
2813
|
}
|
|
2588
|
-
return
|
|
2814
|
+
return this.difficulty;
|
|
2589
2815
|
}
|
|
2590
2816
|
}
|
|
2591
2817
|
|
|
@@ -2608,7 +2834,7 @@ class OsuAimEvaluator extends AimEvaluator {
|
|
|
2608
2834
|
const last = current.previous(0);
|
|
2609
2835
|
if (current.object instanceof osuBase.Spinner ||
|
|
2610
2836
|
current.index <= 1 ||
|
|
2611
|
-
last
|
|
2837
|
+
(last === null || last === void 0 ? void 0 : last.object) instanceof osuBase.Spinner) {
|
|
2612
2838
|
return 0;
|
|
2613
2839
|
}
|
|
2614
2840
|
const lastLast = current.previous(1);
|
|
@@ -2715,36 +2941,38 @@ class OsuAimEvaluator extends AimEvaluator {
|
|
|
2715
2941
|
* Represents the skill required to correctly aim at every object in the map with a uniform CircleSize and normalized distances.
|
|
2716
2942
|
*/
|
|
2717
2943
|
class OsuAim extends OsuSkill {
|
|
2718
|
-
skillMultiplier = 23.55;
|
|
2719
|
-
strainDecayBase = 0.15;
|
|
2720
|
-
reducedSectionCount = 10;
|
|
2721
|
-
reducedSectionBaseline = 0.75;
|
|
2722
|
-
difficultyMultiplier = 1.06;
|
|
2723
|
-
decayWeight = 0.9;
|
|
2724
|
-
withSliders;
|
|
2725
2944
|
constructor(mods, withSliders) {
|
|
2726
2945
|
super(mods);
|
|
2946
|
+
this.strainDecayBase = 0.15;
|
|
2947
|
+
this.reducedSectionCount = 10;
|
|
2948
|
+
this.reducedSectionBaseline = 0.75;
|
|
2949
|
+
this.decayWeight = 0.9;
|
|
2950
|
+
this.currentAimStrain = 0;
|
|
2951
|
+
this.skillMultiplier = 25.18;
|
|
2727
2952
|
this.withSliders = withSliders;
|
|
2728
2953
|
}
|
|
2729
|
-
/**
|
|
2730
|
-
* @param current The hitobject to calculate.
|
|
2731
|
-
*/
|
|
2732
2954
|
strainValueAt(current) {
|
|
2733
|
-
this.
|
|
2734
|
-
this.
|
|
2955
|
+
this.currentAimStrain *= this.strainDecay(current.deltaTime);
|
|
2956
|
+
this.currentAimStrain +=
|
|
2735
2957
|
OsuAimEvaluator.evaluateDifficultyOf(current, this.withSliders) *
|
|
2736
2958
|
this.skillMultiplier;
|
|
2737
|
-
|
|
2959
|
+
this._objectStrains.push(this.currentAimStrain);
|
|
2960
|
+
return this.currentAimStrain;
|
|
2961
|
+
}
|
|
2962
|
+
calculateInitialStrain(time, current) {
|
|
2963
|
+
var _a, _b;
|
|
2964
|
+
return (this.currentAimStrain *
|
|
2965
|
+
this.strainDecay(time - ((_b = (_a = current.previous(0)) === null || _a === void 0 ? void 0 : _a.startTime) !== null && _b !== void 0 ? _b : 0)));
|
|
2738
2966
|
}
|
|
2739
2967
|
/**
|
|
2740
2968
|
* @param current The hitobject to save to.
|
|
2741
2969
|
*/
|
|
2742
2970
|
saveToHitObject(current) {
|
|
2743
2971
|
if (this.withSliders) {
|
|
2744
|
-
current.aimStrainWithSliders = this.
|
|
2972
|
+
current.aimStrainWithSliders = this.currentAimStrain;
|
|
2745
2973
|
}
|
|
2746
2974
|
else {
|
|
2747
|
-
current.aimStrainWithoutSliders = this.
|
|
2975
|
+
current.aimStrainWithoutSliders = this.currentAimStrain;
|
|
2748
2976
|
}
|
|
2749
2977
|
}
|
|
2750
2978
|
}
|
|
@@ -2753,10 +2981,6 @@ class OsuAim extends OsuSkill {
|
|
|
2753
2981
|
* An evaluator for calculating osu!standard speed skill.
|
|
2754
2982
|
*/
|
|
2755
2983
|
class OsuSpeedEvaluator extends SpeedEvaluator {
|
|
2756
|
-
/**
|
|
2757
|
-
* Spacing threshold for a single hitobject spacing.
|
|
2758
|
-
*/
|
|
2759
|
-
static SINGLE_SPACING_THRESHOLD = 125;
|
|
2760
2984
|
/**
|
|
2761
2985
|
* Evaluates the difficulty of tapping the current object, based on:
|
|
2762
2986
|
*
|
|
@@ -2765,67 +2989,70 @@ class OsuSpeedEvaluator extends SpeedEvaluator {
|
|
|
2765
2989
|
* - and how easily they can be cheesed.
|
|
2766
2990
|
*
|
|
2767
2991
|
* @param current The current object.
|
|
2768
|
-
* @param greatWindow The great hit window of the current object.
|
|
2769
2992
|
*/
|
|
2770
|
-
static evaluateDifficultyOf(current
|
|
2993
|
+
static evaluateDifficultyOf(current) {
|
|
2994
|
+
var _a;
|
|
2771
2995
|
if (current.object instanceof osuBase.Spinner) {
|
|
2772
2996
|
return 0;
|
|
2773
2997
|
}
|
|
2774
2998
|
const prev = current.previous(0);
|
|
2775
2999
|
let strainTime = current.strainTime;
|
|
2776
|
-
const greatWindowFull = greatWindow * 2;
|
|
2777
3000
|
// Nerf doubletappable doubles.
|
|
2778
|
-
const
|
|
2779
|
-
let doubletapness = 1;
|
|
2780
|
-
if (next) {
|
|
2781
|
-
const currentDeltaTime = Math.max(1, current.deltaTime);
|
|
2782
|
-
const nextDeltaTime = Math.max(1, next.deltaTime);
|
|
2783
|
-
const deltaDifference = Math.abs(nextDeltaTime - currentDeltaTime);
|
|
2784
|
-
const speedRatio = currentDeltaTime / Math.max(currentDeltaTime, deltaDifference);
|
|
2785
|
-
const windowRatio = Math.pow(Math.min(1, currentDeltaTime / greatWindowFull), 2);
|
|
2786
|
-
doubletapness = Math.pow(speedRatio, 1 - windowRatio);
|
|
2787
|
-
}
|
|
3001
|
+
const doubletapness = 1 - current.doubletapness;
|
|
2788
3002
|
// Cap deltatime to the OD 300 hitwindow.
|
|
2789
3003
|
// 0.93 is derived from making sure 260 BPM 1/4 OD8 streams aren't nerfed harshly, whilst 0.92 limits the effect of the cap.
|
|
2790
|
-
strainTime /= osuBase.MathUtils.clamp(strainTime /
|
|
2791
|
-
|
|
3004
|
+
strainTime /= osuBase.MathUtils.clamp(strainTime / current.fullGreatWindow / 0.93, 0.92, 1);
|
|
3005
|
+
// speedBonus will be 0.0 for BPM < 200
|
|
3006
|
+
let speedBonus = 0;
|
|
3007
|
+
// Add additional scaling bonus for streams/bursts higher than 200bpm
|
|
2792
3008
|
if (strainTime < this.minSpeedBonus) {
|
|
2793
|
-
speedBonus
|
|
3009
|
+
speedBonus =
|
|
2794
3010
|
0.75 * Math.pow((this.minSpeedBonus - strainTime) / 40, 2);
|
|
2795
3011
|
}
|
|
2796
|
-
const travelDistance = prev
|
|
3012
|
+
const travelDistance = (_a = prev === null || prev === void 0 ? void 0 : prev.travelDistance) !== null && _a !== void 0 ? _a : 0;
|
|
3013
|
+
// Cap distance at spacing threshold
|
|
2797
3014
|
const distance = Math.min(this.SINGLE_SPACING_THRESHOLD, travelDistance + current.minimumJumpDistance);
|
|
2798
|
-
|
|
2799
|
-
|
|
2800
|
-
|
|
2801
|
-
|
|
2802
|
-
|
|
3015
|
+
// Max distance bonus is 1 * `distance_multiplier` at single_spacing_threshold
|
|
3016
|
+
const distanceBonus = Math.pow(distance / this.SINGLE_SPACING_THRESHOLD, 3.95) *
|
|
3017
|
+
this.DISTANCE_MULTIPLIER;
|
|
3018
|
+
// Base difficulty with all bonuses
|
|
3019
|
+
const difficulty = ((1 + speedBonus + distanceBonus) * 1000) / strainTime;
|
|
3020
|
+
// Apply penalty if there's doubletappable doubles
|
|
3021
|
+
return difficulty * doubletapness;
|
|
2803
3022
|
}
|
|
2804
3023
|
}
|
|
3024
|
+
/**
|
|
3025
|
+
* Spacing threshold for a single hitobject spacing.
|
|
3026
|
+
*
|
|
3027
|
+
* About 1.25 circles distance between hitobject centers.
|
|
3028
|
+
*/
|
|
3029
|
+
OsuSpeedEvaluator.SINGLE_SPACING_THRESHOLD = 125;
|
|
3030
|
+
OsuSpeedEvaluator.DISTANCE_MULTIPLIER = 0.94;
|
|
2805
3031
|
|
|
2806
3032
|
/**
|
|
2807
3033
|
* An evaluator for calculating osu!standard Rhythm skill.
|
|
2808
3034
|
*/
|
|
2809
|
-
class OsuRhythmEvaluator
|
|
3035
|
+
class OsuRhythmEvaluator {
|
|
2810
3036
|
/**
|
|
2811
3037
|
* Calculates a rhythm multiplier for the difficulty of the tap associated
|
|
2812
3038
|
* with historic data of the current object.
|
|
2813
3039
|
*
|
|
2814
3040
|
* @param current The current object.
|
|
2815
|
-
* @param greatWindow The great hit window of the current object.
|
|
2816
3041
|
*/
|
|
2817
|
-
static evaluateDifficultyOf(current
|
|
3042
|
+
static evaluateDifficultyOf(current) {
|
|
2818
3043
|
if (current.object instanceof osuBase.Spinner) {
|
|
2819
3044
|
return 0;
|
|
2820
3045
|
}
|
|
2821
|
-
|
|
3046
|
+
const deltaDifferenceEpsilon = current.fullGreatWindow * 0.3;
|
|
2822
3047
|
let rhythmComplexitySum = 0;
|
|
2823
|
-
let
|
|
3048
|
+
let island = new Island(deltaDifferenceEpsilon);
|
|
3049
|
+
let previousIsland = new Island(deltaDifferenceEpsilon);
|
|
3050
|
+
const islandCounts = new Map();
|
|
2824
3051
|
// Store the ratio of the current start of an island to buff for tighter rhythms.
|
|
2825
3052
|
let startRatio = 0;
|
|
2826
3053
|
let firstDeltaSwitch = false;
|
|
2827
|
-
const historicalNoteCount = Math.min(current.index, 32);
|
|
2828
3054
|
let rhythmStart = 0;
|
|
3055
|
+
const historicalNoteCount = Math.min(current.index, this.historyObjectsMax);
|
|
2829
3056
|
while (rhythmStart < historicalNoteCount - 2 &&
|
|
2830
3057
|
current.startTime - current.previous(rhythmStart).startTime <
|
|
2831
3058
|
this.historyTimeMax) {
|
|
@@ -2836,98 +3063,133 @@ class OsuRhythmEvaluator extends RhythmEvaluator {
|
|
|
2836
3063
|
const prevObject = current.previous(i);
|
|
2837
3064
|
const lastObject = current.previous(i + 1);
|
|
2838
3065
|
// Scale note 0 to 1 from history to now.
|
|
2839
|
-
|
|
3066
|
+
const timeDecay = (this.historyTimeMax -
|
|
2840
3067
|
(current.startTime - currentObject.startTime)) /
|
|
2841
3068
|
this.historyTimeMax;
|
|
3069
|
+
const noteDecay = (historicalNoteCount - i) / historicalNoteCount;
|
|
2842
3070
|
// Either we're limited by time or limited by object count.
|
|
2843
|
-
currentHistoricalDecay = Math.min(
|
|
3071
|
+
const currentHistoricalDecay = Math.min(timeDecay, noteDecay);
|
|
2844
3072
|
const currentDelta = currentObject.strainTime;
|
|
2845
3073
|
const prevDelta = prevObject.strainTime;
|
|
2846
3074
|
const lastDelta = lastObject.strainTime;
|
|
3075
|
+
// Calculate how much current delta difference deserves a rhythm bonus
|
|
3076
|
+
// This function is meant to reduce rhythm bonus for deltas that are multiples of each other (i.e. 100 and 200)
|
|
3077
|
+
const deltaDifferenceRatio = Math.min(prevDelta, currentDelta) /
|
|
3078
|
+
Math.max(prevDelta, currentDelta);
|
|
2847
3079
|
const currentRatio = 1 +
|
|
2848
|
-
|
|
2849
|
-
Math.min(0.5, Math.pow(Math.sin(Math.PI /
|
|
2850
|
-
|
|
2851
|
-
|
|
2852
|
-
const
|
|
2853
|
-
|
|
2854
|
-
let effectiveRatio = windowPenalty * currentRatio;
|
|
3080
|
+
this.rhythmRatioMultiplier *
|
|
3081
|
+
Math.min(0.5, Math.pow(Math.sin(Math.PI / deltaDifferenceRatio), 2));
|
|
3082
|
+
// Reduce ratio bonus if delta difference is too big
|
|
3083
|
+
const fraction = Math.max(prevDelta / currentDelta, currentDelta / prevDelta);
|
|
3084
|
+
const fractionMultiplier = osuBase.MathUtils.clamp(2 - fraction / 8, 0, 1);
|
|
3085
|
+
const windowPenalty = Math.min(1, Math.max(0, Math.abs(prevDelta - currentDelta) - deltaDifferenceEpsilon) / deltaDifferenceEpsilon);
|
|
3086
|
+
let effectiveRatio = windowPenalty * currentRatio * fractionMultiplier;
|
|
2855
3087
|
if (firstDeltaSwitch) {
|
|
2856
|
-
if (prevDelta
|
|
2857
|
-
prevDelta * 1.25 >= currentDelta) {
|
|
3088
|
+
if (Math.abs(prevDelta - currentDelta) < deltaDifferenceEpsilon) {
|
|
2858
3089
|
// Island is still progressing, count size.
|
|
2859
|
-
|
|
2860
|
-
++islandSize;
|
|
2861
|
-
}
|
|
3090
|
+
island.addDelta(currentDelta);
|
|
2862
3091
|
}
|
|
2863
3092
|
else {
|
|
3093
|
+
// BPM change is into slider, this is easy acc window.
|
|
2864
3094
|
if (currentObject.object instanceof osuBase.Slider) {
|
|
2865
|
-
// BPM change is into slider, this is easy acc window.
|
|
2866
3095
|
effectiveRatio /= 8;
|
|
2867
3096
|
}
|
|
3097
|
+
// BPM change was from a slider, this is easier typically than circle -> circle.
|
|
3098
|
+
// Unintentional side effect is that bursts with kicksliders at the ends might have lower difficulty
|
|
3099
|
+
// than bursts without sliders.
|
|
2868
3100
|
if (prevObject.object instanceof osuBase.Slider) {
|
|
2869
|
-
|
|
2870
|
-
effectiveRatio /= 4;
|
|
2871
|
-
}
|
|
2872
|
-
if (previousIslandSize === islandSize) {
|
|
2873
|
-
// Repeated island size (ex: triplet -> triplet).
|
|
2874
|
-
effectiveRatio /= 4;
|
|
3101
|
+
effectiveRatio *= 0.3;
|
|
2875
3102
|
}
|
|
2876
|
-
|
|
2877
|
-
|
|
3103
|
+
// Repeated island polarity (2 -> 4, 3 -> 5).
|
|
3104
|
+
if (island.isSimilarPolarity(previousIsland)) {
|
|
2878
3105
|
effectiveRatio /= 2;
|
|
2879
3106
|
}
|
|
2880
|
-
|
|
2881
|
-
|
|
2882
|
-
|
|
2883
|
-
|
|
3107
|
+
// Previous increase happened a note ago.
|
|
3108
|
+
// Albeit this is a 1/1 -> 1/2-1/4 type of transition, we don't want to buff this.
|
|
3109
|
+
if (lastDelta > prevDelta + deltaDifferenceEpsilon &&
|
|
3110
|
+
prevDelta > currentDelta + deltaDifferenceEpsilon) {
|
|
2884
3111
|
effectiveRatio /= 8;
|
|
2885
3112
|
}
|
|
3113
|
+
// Repeated island size (ex: triplet -> triplet).
|
|
3114
|
+
// TODO: remove this nerf since its staying here only for balancing purposes because of the flawed ratio calculation
|
|
3115
|
+
if (previousIsland.deltaCount == island.deltaCount) {
|
|
3116
|
+
effectiveRatio /= 2;
|
|
3117
|
+
}
|
|
3118
|
+
let islandFound = false;
|
|
3119
|
+
for (const [currentIsland, count] of islandCounts) {
|
|
3120
|
+
if (!island.equals(currentIsland)) {
|
|
3121
|
+
continue;
|
|
3122
|
+
}
|
|
3123
|
+
islandFound = true;
|
|
3124
|
+
let islandCount = count;
|
|
3125
|
+
if (previousIsland.equals(island)) {
|
|
3126
|
+
// Only add island to island counts if they're going one after another.
|
|
3127
|
+
++islandCount;
|
|
3128
|
+
islandCounts.set(currentIsland, islandCount);
|
|
3129
|
+
}
|
|
3130
|
+
// Repeated island (ex: triplet -> triplet).
|
|
3131
|
+
// Graph: https://www.desmos.com/calculator/pj7an56zwf
|
|
3132
|
+
effectiveRatio *= Math.min(3 / islandCount, Math.pow(1 / islandCount, 2.75 / (1 + Math.exp(14 - 0.24 * island.delta))));
|
|
3133
|
+
break;
|
|
3134
|
+
}
|
|
3135
|
+
if (!islandFound) {
|
|
3136
|
+
islandCounts.set(island, 1);
|
|
3137
|
+
}
|
|
3138
|
+
// Scale down the difficulty if the object is doubletappable.
|
|
3139
|
+
effectiveRatio *= 1 - prevObject.doubletapness * 0.75;
|
|
2886
3140
|
rhythmComplexitySum +=
|
|
2887
|
-
|
|
2888
|
-
currentHistoricalDecay
|
|
2889
|
-
Math.sqrt(4 + islandSize)) /
|
|
2890
|
-
2) *
|
|
2891
|
-
Math.sqrt(4 + previousIslandSize)) /
|
|
2892
|
-
2;
|
|
3141
|
+
Math.sqrt(effectiveRatio * startRatio) *
|
|
3142
|
+
currentHistoricalDecay;
|
|
2893
3143
|
startRatio = effectiveRatio;
|
|
2894
|
-
|
|
2895
|
-
if (prevDelta
|
|
3144
|
+
previousIsland = island;
|
|
3145
|
+
if (prevDelta + deltaDifferenceEpsilon < currentDelta) {
|
|
2896
3146
|
// We're slowing down, stop counting.
|
|
2897
3147
|
// If we're speeding up, this stays as is and we keep counting island size.
|
|
2898
3148
|
firstDeltaSwitch = false;
|
|
2899
3149
|
}
|
|
2900
|
-
|
|
3150
|
+
island = new Island(currentDelta, deltaDifferenceEpsilon);
|
|
2901
3151
|
}
|
|
2902
3152
|
}
|
|
2903
|
-
else if (prevDelta >
|
|
2904
|
-
// We
|
|
3153
|
+
else if (prevDelta > currentDelta + deltaDifferenceEpsilon) {
|
|
3154
|
+
// We are speeding up.
|
|
2905
3155
|
// Begin counting island until we change speed again.
|
|
2906
3156
|
firstDeltaSwitch = true;
|
|
3157
|
+
// BPM change is into slider, this is easy acc window.
|
|
3158
|
+
if (currentObject.object instanceof osuBase.Slider) {
|
|
3159
|
+
effectiveRatio *= 0.6;
|
|
3160
|
+
}
|
|
3161
|
+
// BPM change was from a slider, this is easier typically than circle -> circle
|
|
3162
|
+
// Unintentional side effect is that bursts with kicksliders at the ends might have lower difficulty
|
|
3163
|
+
// than bursts without sliders
|
|
3164
|
+
if (prevObject.object instanceof osuBase.Slider) {
|
|
3165
|
+
effectiveRatio *= 0.6;
|
|
3166
|
+
}
|
|
2907
3167
|
startRatio = effectiveRatio;
|
|
2908
|
-
|
|
3168
|
+
island = new Island(currentDelta, deltaDifferenceEpsilon);
|
|
2909
3169
|
}
|
|
2910
3170
|
}
|
|
2911
|
-
return Math.sqrt(4 + rhythmComplexitySum * this.
|
|
3171
|
+
return (Math.sqrt(4 + rhythmComplexitySum * this.rhythmOverallMultiplier) /
|
|
3172
|
+
2);
|
|
2912
3173
|
}
|
|
2913
3174
|
}
|
|
3175
|
+
OsuRhythmEvaluator.historyTimeMax = 5000; // 5 seconds of calculateRhythmBonus max.
|
|
3176
|
+
OsuRhythmEvaluator.historyObjectsMax = 32;
|
|
3177
|
+
OsuRhythmEvaluator.rhythmOverallMultiplier = 0.95;
|
|
3178
|
+
OsuRhythmEvaluator.rhythmRatioMultiplier = 12;
|
|
2914
3179
|
|
|
2915
3180
|
/**
|
|
2916
3181
|
* Represents the skill required to press keys or tap with regards to keeping up with the speed at which objects need to be hit.
|
|
2917
3182
|
*/
|
|
2918
3183
|
class OsuSpeed extends OsuSkill {
|
|
2919
|
-
|
|
2920
|
-
|
|
2921
|
-
|
|
2922
|
-
|
|
2923
|
-
|
|
2924
|
-
|
|
2925
|
-
|
|
2926
|
-
|
|
2927
|
-
|
|
2928
|
-
constructor(mods, greatWindow) {
|
|
2929
|
-
super(mods);
|
|
2930
|
-
this.greatWindow = greatWindow;
|
|
3184
|
+
constructor() {
|
|
3185
|
+
super(...arguments);
|
|
3186
|
+
this.strainDecayBase = 0.3;
|
|
3187
|
+
this.reducedSectionCount = 5;
|
|
3188
|
+
this.reducedSectionBaseline = 0.75;
|
|
3189
|
+
this.decayWeight = 0.9;
|
|
3190
|
+
this.currentSpeedStrain = 0;
|
|
3191
|
+
this.currentRhythm = 0;
|
|
3192
|
+
this.skillMultiplier = 1.43;
|
|
2931
3193
|
}
|
|
2932
3194
|
/**
|
|
2933
3195
|
* @param current The hitobject to calculate.
|
|
@@ -2935,16 +3197,24 @@ class OsuSpeed extends OsuSkill {
|
|
|
2935
3197
|
strainValueAt(current) {
|
|
2936
3198
|
this.currentSpeedStrain *= this.strainDecay(current.strainTime);
|
|
2937
3199
|
this.currentSpeedStrain +=
|
|
2938
|
-
OsuSpeedEvaluator.evaluateDifficultyOf(current
|
|
3200
|
+
OsuSpeedEvaluator.evaluateDifficultyOf(current) *
|
|
2939
3201
|
this.skillMultiplier;
|
|
2940
|
-
this.currentRhythm = OsuRhythmEvaluator.evaluateDifficultyOf(current
|
|
2941
|
-
|
|
3202
|
+
this.currentRhythm = OsuRhythmEvaluator.evaluateDifficultyOf(current);
|
|
3203
|
+
const strain = this.currentSpeedStrain * this.currentRhythm;
|
|
3204
|
+
this._objectStrains.push(strain);
|
|
3205
|
+
return strain;
|
|
3206
|
+
}
|
|
3207
|
+
calculateInitialStrain(time, current) {
|
|
3208
|
+
var _a, _b;
|
|
3209
|
+
return (this.currentSpeedStrain *
|
|
3210
|
+
this.currentRhythm *
|
|
3211
|
+
this.strainDecay(time - ((_b = (_a = current.previous(0)) === null || _a === void 0 ? void 0 : _a.startTime) !== null && _b !== void 0 ? _b : 0)));
|
|
2942
3212
|
}
|
|
2943
3213
|
/**
|
|
2944
3214
|
* @param current The hitobject to save to.
|
|
2945
3215
|
*/
|
|
2946
3216
|
saveToHitObject(current) {
|
|
2947
|
-
current.
|
|
3217
|
+
current.speedStrain = this.currentSpeedStrain * this.currentRhythm;
|
|
2948
3218
|
current.rhythmMultiplier = this.currentRhythm;
|
|
2949
3219
|
}
|
|
2950
3220
|
}
|
|
@@ -2969,7 +3239,7 @@ class OsuFlashlightEvaluator extends FlashlightEvaluator {
|
|
|
2969
3239
|
if (current.object instanceof osuBase.Spinner) {
|
|
2970
3240
|
return 0;
|
|
2971
3241
|
}
|
|
2972
|
-
const scalingFactor = 52 / current.object.
|
|
3242
|
+
const scalingFactor = 52 / current.object.radius;
|
|
2973
3243
|
let smallDistNerf = 1;
|
|
2974
3244
|
let cumulativeStrainTime = 0;
|
|
2975
3245
|
let result = 0;
|
|
@@ -2980,7 +3250,7 @@ class OsuFlashlightEvaluator extends FlashlightEvaluator {
|
|
|
2980
3250
|
if (!(currentObject.object instanceof osuBase.Spinner)) {
|
|
2981
3251
|
const jumpDistance = current.object
|
|
2982
3252
|
.getStackedPosition(osuBase.Modes.osu)
|
|
2983
|
-
.subtract(currentObject.object.
|
|
3253
|
+
.subtract(currentObject.object.getStackedEndPosition(osuBase.Modes.osu)).length;
|
|
2984
3254
|
cumulativeStrainTime += last.strainTime;
|
|
2985
3255
|
// We want to nerf objects that can be easily seen within the Flashlight circle radius.
|
|
2986
3256
|
if (i === 0) {
|
|
@@ -2992,7 +3262,7 @@ class OsuFlashlightEvaluator extends FlashlightEvaluator {
|
|
|
2992
3262
|
const opacityBonus = 1 +
|
|
2993
3263
|
this.maxOpacityBonus *
|
|
2994
3264
|
(1 -
|
|
2995
|
-
current.opacityAt(currentObject.object.startTime, isHiddenMod
|
|
3265
|
+
current.opacityAt(currentObject.object.startTime, isHiddenMod));
|
|
2996
3266
|
result +=
|
|
2997
3267
|
(stackNerf * opacityBonus * scalingFactor * jumpDistance) /
|
|
2998
3268
|
cumulativeStrainTime;
|
|
@@ -3023,8 +3293,8 @@ class OsuFlashlightEvaluator extends FlashlightEvaluator {
|
|
|
3023
3293
|
// Longer sliders require more memorization.
|
|
3024
3294
|
sliderBonus *= pixelTravelDistance;
|
|
3025
3295
|
// Nerf sliders with repeats, as less memorization is required.
|
|
3026
|
-
if (current.object.
|
|
3027
|
-
sliderBonus /= current.object.
|
|
3296
|
+
if (current.object.repeatCount > 0)
|
|
3297
|
+
sliderBonus /= current.object.repeatCount + 1;
|
|
3028
3298
|
}
|
|
3029
3299
|
result += sliderBonus * this.sliderMultiplier;
|
|
3030
3300
|
return result;
|
|
@@ -3035,27 +3305,62 @@ class OsuFlashlightEvaluator extends FlashlightEvaluator {
|
|
|
3035
3305
|
* Represents the skill required to memorize and hit every object in a beatmap with the Flashlight mod enabled.
|
|
3036
3306
|
*/
|
|
3037
3307
|
class OsuFlashlight extends OsuSkill {
|
|
3038
|
-
skillMultiplier = 0.052;
|
|
3039
|
-
strainDecayBase = 0.15;
|
|
3040
|
-
reducedSectionCount = 0;
|
|
3041
|
-
reducedSectionBaseline = 1;
|
|
3042
|
-
decayWeight = 1;
|
|
3043
|
-
isHidden;
|
|
3044
3308
|
constructor(mods) {
|
|
3045
3309
|
super(mods);
|
|
3310
|
+
this.strainDecayBase = 0.15;
|
|
3311
|
+
this.reducedSectionCount = 0;
|
|
3312
|
+
this.reducedSectionBaseline = 1;
|
|
3313
|
+
this.decayWeight = 1;
|
|
3314
|
+
this.currentFlashlightStrain = 0;
|
|
3315
|
+
this.skillMultiplier = 0.05512;
|
|
3046
3316
|
this.isHidden = mods.some((m) => m instanceof osuBase.ModHidden);
|
|
3047
3317
|
}
|
|
3048
|
-
|
|
3049
|
-
|
|
3050
|
-
|
|
3318
|
+
difficultyValue() {
|
|
3319
|
+
return this.strainPeaks.reduce((a, b) => a + b, 0);
|
|
3320
|
+
}
|
|
3051
3321
|
strainValueAt(current) {
|
|
3052
|
-
this.
|
|
3053
|
-
this.
|
|
3322
|
+
this.currentFlashlightStrain *= this.strainDecay(current.deltaTime);
|
|
3323
|
+
this.currentFlashlightStrain +=
|
|
3054
3324
|
OsuFlashlightEvaluator.evaluateDifficultyOf(current, this.isHidden) * this.skillMultiplier;
|
|
3055
|
-
return this.
|
|
3325
|
+
return this.currentFlashlightStrain;
|
|
3326
|
+
}
|
|
3327
|
+
calculateInitialStrain(time, current) {
|
|
3328
|
+
var _a, _b;
|
|
3329
|
+
return (this.currentFlashlightStrain *
|
|
3330
|
+
this.strainDecay(time - ((_b = (_a = current.previous(0)) === null || _a === void 0 ? void 0 : _a.startTime) !== null && _b !== void 0 ? _b : 0)));
|
|
3056
3331
|
}
|
|
3057
3332
|
saveToHitObject(current) {
|
|
3058
|
-
current.
|
|
3333
|
+
current.flashlightStrain = this.currentFlashlightStrain;
|
|
3334
|
+
}
|
|
3335
|
+
}
|
|
3336
|
+
|
|
3337
|
+
/**
|
|
3338
|
+
* Represents an osu!standard hit object with difficulty calculation values.
|
|
3339
|
+
*/
|
|
3340
|
+
class OsuDifficultyHitObject extends DifficultyHitObject {
|
|
3341
|
+
constructor() {
|
|
3342
|
+
super(...arguments);
|
|
3343
|
+
/**
|
|
3344
|
+
* The speed strain generated by the hitobject.
|
|
3345
|
+
*/
|
|
3346
|
+
this.speedStrain = 0;
|
|
3347
|
+
/**
|
|
3348
|
+
* The flashlight strain generated by this hitobject.
|
|
3349
|
+
*/
|
|
3350
|
+
this.flashlightStrain = 0;
|
|
3351
|
+
this.radiusBuffThreshold = 30;
|
|
3352
|
+
this.mode = osuBase.Modes.osu;
|
|
3353
|
+
}
|
|
3354
|
+
get scalingFactor() {
|
|
3355
|
+
const radius = this.object.radius;
|
|
3356
|
+
// We will scale distances by this factor, so we can assume a uniform CircleSize among beatmaps.
|
|
3357
|
+
let scalingFactor = this.normalizedRadius / radius;
|
|
3358
|
+
// High circle size (small CS) bonus
|
|
3359
|
+
if (radius < this.radiusBuffThreshold) {
|
|
3360
|
+
scalingFactor *=
|
|
3361
|
+
1 + Math.min(this.radiusBuffThreshold - radius, 5) / 50;
|
|
3362
|
+
}
|
|
3363
|
+
return scalingFactor;
|
|
3059
3364
|
}
|
|
3060
3365
|
}
|
|
3061
3366
|
|
|
@@ -3063,35 +3368,50 @@ class OsuFlashlight extends OsuSkill {
|
|
|
3063
3368
|
* A difficulty calculator for osu!standard gamemode.
|
|
3064
3369
|
*/
|
|
3065
3370
|
class OsuDifficultyCalculator extends DifficultyCalculator {
|
|
3371
|
+
constructor() {
|
|
3372
|
+
super(...arguments);
|
|
3373
|
+
this.attributes = {
|
|
3374
|
+
speedDifficulty: 0,
|
|
3375
|
+
mods: [],
|
|
3376
|
+
starRating: 0,
|
|
3377
|
+
maxCombo: 0,
|
|
3378
|
+
aimDifficulty: 0,
|
|
3379
|
+
flashlightDifficulty: 0,
|
|
3380
|
+
speedNoteCount: 0,
|
|
3381
|
+
sliderFactor: 0,
|
|
3382
|
+
clockRate: 1,
|
|
3383
|
+
approachRate: 0,
|
|
3384
|
+
overallDifficulty: 0,
|
|
3385
|
+
hitCircleCount: 0,
|
|
3386
|
+
sliderCount: 0,
|
|
3387
|
+
spinnerCount: 0,
|
|
3388
|
+
aimDifficultStrainCount: 0,
|
|
3389
|
+
speedDifficultStrainCount: 0,
|
|
3390
|
+
};
|
|
3391
|
+
this.difficultyMultiplier = 0.0675;
|
|
3392
|
+
this.mode = osuBase.Modes.osu;
|
|
3393
|
+
}
|
|
3066
3394
|
/**
|
|
3067
3395
|
* The aim star rating of the beatmap.
|
|
3068
3396
|
*/
|
|
3069
|
-
aim
|
|
3397
|
+
get aim() {
|
|
3398
|
+
return this.attributes.aimDifficulty;
|
|
3399
|
+
}
|
|
3070
3400
|
/**
|
|
3071
3401
|
* The speed star rating of the beatmap.
|
|
3072
3402
|
*/
|
|
3073
|
-
speed
|
|
3403
|
+
get speed() {
|
|
3404
|
+
return this.attributes.speedDifficulty;
|
|
3405
|
+
}
|
|
3074
3406
|
/**
|
|
3075
3407
|
* The flashlight star rating of the beatmap.
|
|
3076
3408
|
*/
|
|
3077
|
-
flashlight
|
|
3078
|
-
|
|
3079
|
-
|
|
3080
|
-
|
|
3081
|
-
|
|
3082
|
-
|
|
3083
|
-
aimDifficulty: 0,
|
|
3084
|
-
flashlightDifficulty: 0,
|
|
3085
|
-
speedNoteCount: 0,
|
|
3086
|
-
sliderFactor: 0,
|
|
3087
|
-
approachRate: 0,
|
|
3088
|
-
overallDifficulty: 0,
|
|
3089
|
-
hitCircleCount: 0,
|
|
3090
|
-
sliderCount: 0,
|
|
3091
|
-
spinnerCount: 0,
|
|
3092
|
-
};
|
|
3093
|
-
difficultyMultiplier = 0.0675;
|
|
3094
|
-
mode = osuBase.Modes.osu;
|
|
3409
|
+
get flashlight() {
|
|
3410
|
+
return this.attributes.flashlightDifficulty;
|
|
3411
|
+
}
|
|
3412
|
+
get cacheableAttributes() {
|
|
3413
|
+
return Object.assign(Object.assign({}, this.attributes), { mods: osuBase.ModUtil.modsToOsuString(this.attributes.mods) });
|
|
3414
|
+
}
|
|
3095
3415
|
/**
|
|
3096
3416
|
* Calculates the aim star rating of the beatmap and stores it in this instance.
|
|
3097
3417
|
*/
|
|
@@ -3106,10 +3426,10 @@ class OsuDifficultyCalculator extends DifficultyCalculator {
|
|
|
3106
3426
|
*/
|
|
3107
3427
|
calculateSpeed() {
|
|
3108
3428
|
if (this.mods.some((m) => m instanceof osuBase.ModRelax)) {
|
|
3109
|
-
this.
|
|
3429
|
+
this.attributes.speedDifficulty = 0;
|
|
3110
3430
|
return;
|
|
3111
3431
|
}
|
|
3112
|
-
const speedSkill = new OsuSpeed(this.mods
|
|
3432
|
+
const speedSkill = new OsuSpeed(this.mods);
|
|
3113
3433
|
this.calculateSkills(speedSkill);
|
|
3114
3434
|
this.postCalculateSpeed(speedSkill);
|
|
3115
3435
|
}
|
|
@@ -3134,14 +3454,14 @@ class OsuDifficultyCalculator extends DifficultyCalculator {
|
|
|
3134
3454
|
if (basePerformanceValue > 1e-5) {
|
|
3135
3455
|
// Document for formula derivation:
|
|
3136
3456
|
// https://docs.google.com/document/d/10DZGYYSsT_yjz2Mtp6yIJld0Rqx4E-vVHupCqiM4TNI/edit
|
|
3137
|
-
this.
|
|
3138
|
-
Math.cbrt(1.
|
|
3457
|
+
this.attributes.starRating =
|
|
3458
|
+
Math.cbrt(1.15) *
|
|
3139
3459
|
0.027 *
|
|
3140
3460
|
(Math.cbrt((100000 / Math.pow(2, 1 / 1.1)) * basePerformanceValue) +
|
|
3141
3461
|
4);
|
|
3142
3462
|
}
|
|
3143
3463
|
else {
|
|
3144
|
-
this.
|
|
3464
|
+
this.attributes.starRating = 0;
|
|
3145
3465
|
}
|
|
3146
3466
|
}
|
|
3147
3467
|
calculateAll() {
|
|
@@ -3154,7 +3474,6 @@ class OsuDifficultyCalculator extends DifficultyCalculator {
|
|
|
3154
3474
|
const flashlightSkill = skills[3];
|
|
3155
3475
|
this.postCalculateAim(aimSkill, aimSkillWithoutSliders);
|
|
3156
3476
|
if (isRelax) {
|
|
3157
|
-
this.speed = 0;
|
|
3158
3477
|
this.attributes.speedDifficulty = 0;
|
|
3159
3478
|
}
|
|
3160
3479
|
else {
|
|
@@ -3174,25 +3493,30 @@ class OsuDifficultyCalculator extends DifficultyCalculator {
|
|
|
3174
3493
|
this.flashlight.toFixed(2) +
|
|
3175
3494
|
" flashlight)");
|
|
3176
3495
|
}
|
|
3177
|
-
|
|
3178
|
-
|
|
3179
|
-
|
|
3180
|
-
|
|
3496
|
+
generateDifficultyHitObjects(beatmap, clockRate) {
|
|
3497
|
+
var _a, _b;
|
|
3498
|
+
const difficultyObjects = [];
|
|
3499
|
+
const { objects } = beatmap.hitObjects;
|
|
3500
|
+
for (let i = 0; i < objects.length; ++i) {
|
|
3501
|
+
const difficultyObject = new OsuDifficultyHitObject(objects[i], (_a = objects[i - 1]) !== null && _a !== void 0 ? _a : null, (_b = objects[i - 2]) !== null && _b !== void 0 ? _b : null, difficultyObjects, clockRate);
|
|
3502
|
+
difficultyObject.computeProperties(clockRate, objects);
|
|
3503
|
+
difficultyObjects.push(difficultyObject);
|
|
3181
3504
|
}
|
|
3182
|
-
|
|
3183
|
-
ar: this.beatmap.difficulty.ar,
|
|
3184
|
-
mods: osuBase.ModUtil.removeSpeedChangingMods(this.mods),
|
|
3185
|
-
}).calculate().ar;
|
|
3186
|
-
osuBase.HitObjectStackEvaluator.applyStandardStacking(this.beatmap.formatVersion, this.beatmap.hitObjects.objects, ar, this.beatmap.general.stackLeniency);
|
|
3505
|
+
return difficultyObjects;
|
|
3187
3506
|
}
|
|
3188
3507
|
createSkills() {
|
|
3189
3508
|
return [
|
|
3190
3509
|
new OsuAim(this.mods, true),
|
|
3191
3510
|
new OsuAim(this.mods, false),
|
|
3192
|
-
new OsuSpeed(this.mods
|
|
3511
|
+
new OsuSpeed(this.mods),
|
|
3193
3512
|
new OsuFlashlight(this.mods),
|
|
3194
3513
|
];
|
|
3195
3514
|
}
|
|
3515
|
+
populateDifficultyAttributes(beatmap, clockRate) {
|
|
3516
|
+
super.populateDifficultyAttributes(beatmap, clockRate);
|
|
3517
|
+
const preempt = osuBase.BeatmapDifficulty.difficultyRange(beatmap.difficulty.ar, osuBase.HitObject.preemptMax, osuBase.HitObject.preemptMid, osuBase.HitObject.preemptMin) / clockRate;
|
|
3518
|
+
this.attributes.approachRate = osuBase.BeatmapDifficulty.inverseDifficultyRange(preempt, osuBase.HitObject.preemptMax, osuBase.HitObject.preemptMid, osuBase.HitObject.preemptMin);
|
|
3519
|
+
}
|
|
3196
3520
|
/**
|
|
3197
3521
|
* Called after aim skill calculation.
|
|
3198
3522
|
*
|
|
@@ -3202,19 +3526,20 @@ class OsuDifficultyCalculator extends DifficultyCalculator {
|
|
|
3202
3526
|
postCalculateAim(aimSkill, aimSkillWithoutSliders) {
|
|
3203
3527
|
this.strainPeaks.aimWithSliders = aimSkill.strainPeaks;
|
|
3204
3528
|
this.strainPeaks.aimWithoutSliders = aimSkillWithoutSliders.strainPeaks;
|
|
3205
|
-
this.
|
|
3529
|
+
this.attributes.aimDifficulty = this.starValue(aimSkill.difficultyValue());
|
|
3206
3530
|
if (this.aim) {
|
|
3207
3531
|
this.attributes.sliderFactor =
|
|
3208
3532
|
this.starValue(aimSkillWithoutSliders.difficultyValue()) /
|
|
3209
3533
|
this.aim;
|
|
3210
3534
|
}
|
|
3211
3535
|
if (this.mods.some((m) => m instanceof osuBase.ModTouchDevice)) {
|
|
3212
|
-
this.
|
|
3536
|
+
this.attributes.aimDifficulty = Math.pow(this.aim, 0.8);
|
|
3213
3537
|
}
|
|
3214
3538
|
if (this.mods.some((m) => m instanceof osuBase.ModRelax)) {
|
|
3215
|
-
this.
|
|
3539
|
+
this.attributes.aimDifficulty *= 0.9;
|
|
3216
3540
|
}
|
|
3217
|
-
this.attributes.
|
|
3541
|
+
this.attributes.aimDifficultStrainCount =
|
|
3542
|
+
aimSkill.countDifficultStrains();
|
|
3218
3543
|
}
|
|
3219
3544
|
/**
|
|
3220
3545
|
* Called after speed skill calculation.
|
|
@@ -3223,13 +3548,15 @@ class OsuDifficultyCalculator extends DifficultyCalculator {
|
|
|
3223
3548
|
*/
|
|
3224
3549
|
postCalculateSpeed(speedSkill) {
|
|
3225
3550
|
this.strainPeaks.speed = speedSkill.strainPeaks;
|
|
3226
|
-
this.
|
|
3551
|
+
this.attributes.speedDifficulty = this.starValue(speedSkill.difficultyValue());
|
|
3552
|
+
this.attributes.speedDifficultStrainCount =
|
|
3553
|
+
speedSkill.countDifficultStrains();
|
|
3227
3554
|
}
|
|
3228
3555
|
/**
|
|
3229
3556
|
* Calculates speed-related attributes.
|
|
3230
3557
|
*/
|
|
3231
3558
|
calculateSpeedAttributes() {
|
|
3232
|
-
const objectStrains = this.objects.map((v) => v.
|
|
3559
|
+
const objectStrains = this.objects.map((v) => v.speedStrain);
|
|
3233
3560
|
const maxStrain = Math.max(...objectStrains);
|
|
3234
3561
|
if (maxStrain) {
|
|
3235
3562
|
this.attributes.speedNoteCount = objectStrains.reduce((total, next) => total + 1 / (1 + Math.exp(-((next / maxStrain) * 12 - 6))), 0);
|
|
@@ -3242,55 +3569,13 @@ class OsuDifficultyCalculator extends DifficultyCalculator {
|
|
|
3242
3569
|
*/
|
|
3243
3570
|
postCalculateFlashlight(flashlightSkill) {
|
|
3244
3571
|
this.strainPeaks.flashlight = flashlightSkill.strainPeaks;
|
|
3245
|
-
this.
|
|
3572
|
+
this.attributes.flashlightDifficulty = this.starValue(flashlightSkill.difficultyValue());
|
|
3246
3573
|
if (this.mods.some((m) => m instanceof osuBase.ModTouchDevice)) {
|
|
3247
|
-
this.
|
|
3574
|
+
this.attributes.flashlightDifficulty = Math.pow(this.flashlight, 0.8);
|
|
3248
3575
|
}
|
|
3249
3576
|
if (this.mods.some((m) => m instanceof osuBase.ModRelax)) {
|
|
3250
|
-
this.
|
|
3577
|
+
this.attributes.flashlightDifficulty *= 0.7;
|
|
3251
3578
|
}
|
|
3252
|
-
this.attributes.flashlightDifficulty = this.flashlight;
|
|
3253
|
-
}
|
|
3254
|
-
}
|
|
3255
|
-
|
|
3256
|
-
/**
|
|
3257
|
-
* A difficulty calculator that calculates for both osu!droid and osu!standard gamemode.
|
|
3258
|
-
*/
|
|
3259
|
-
class MapStars {
|
|
3260
|
-
/**
|
|
3261
|
-
* The osu!droid difficulty calculator of the beatmap.
|
|
3262
|
-
*/
|
|
3263
|
-
droid;
|
|
3264
|
-
/**
|
|
3265
|
-
* The osu!standard difficulty calculator of the beatmap.
|
|
3266
|
-
*/
|
|
3267
|
-
osu;
|
|
3268
|
-
/**
|
|
3269
|
-
* Constructs this instance and calculates the given beatmap's osu!droid and osu!standard difficulty.
|
|
3270
|
-
*
|
|
3271
|
-
* @param beatmap The beatmap to calculate.
|
|
3272
|
-
* @param options Options for the difficulty calculation.
|
|
3273
|
-
*/
|
|
3274
|
-
constructor(beatmap, options) {
|
|
3275
|
-
const stats = new osuBase.MapStats({
|
|
3276
|
-
speedMultiplier: options?.stats?.speedMultiplier ?? 1,
|
|
3277
|
-
isForceAR: options?.stats?.isForceAR ?? false,
|
|
3278
|
-
oldStatistics: options?.stats?.oldStatistics ?? false,
|
|
3279
|
-
});
|
|
3280
|
-
this.droid = new DroidDifficultyCalculator(beatmap).calculate({
|
|
3281
|
-
...options,
|
|
3282
|
-
stats,
|
|
3283
|
-
});
|
|
3284
|
-
this.osu = new OsuDifficultyCalculator(beatmap).calculate({
|
|
3285
|
-
...options,
|
|
3286
|
-
stats,
|
|
3287
|
-
});
|
|
3288
|
-
}
|
|
3289
|
-
/**
|
|
3290
|
-
* Returns a string representative of the class.
|
|
3291
|
-
*/
|
|
3292
|
-
toString() {
|
|
3293
|
-
return `${this.droid.toString()}\n${this.osu.toString()}`;
|
|
3294
3579
|
}
|
|
3295
3580
|
}
|
|
3296
3581
|
|
|
@@ -3298,65 +3583,60 @@ class MapStars {
|
|
|
3298
3583
|
* A performance points calculator that calculates performance points for osu!standard gamemode.
|
|
3299
3584
|
*/
|
|
3300
3585
|
class OsuPerformanceCalculator extends PerformanceCalculator {
|
|
3301
|
-
|
|
3302
|
-
|
|
3303
|
-
|
|
3304
|
-
|
|
3305
|
-
|
|
3306
|
-
|
|
3307
|
-
|
|
3308
|
-
|
|
3309
|
-
|
|
3310
|
-
|
|
3311
|
-
|
|
3312
|
-
|
|
3313
|
-
|
|
3314
|
-
|
|
3315
|
-
|
|
3316
|
-
|
|
3317
|
-
|
|
3318
|
-
|
|
3319
|
-
|
|
3320
|
-
|
|
3321
|
-
|
|
3322
|
-
*/
|
|
3323
|
-
constructor(difficultyAttributes) {
|
|
3324
|
-
super();
|
|
3325
|
-
this.difficultyAttributes = osuBase.Utils.deepCopy(difficultyAttributes);
|
|
3586
|
+
constructor() {
|
|
3587
|
+
super(...arguments);
|
|
3588
|
+
/**
|
|
3589
|
+
* The aim performance value.
|
|
3590
|
+
*/
|
|
3591
|
+
this.aim = 0;
|
|
3592
|
+
/**
|
|
3593
|
+
* The speed performance value.
|
|
3594
|
+
*/
|
|
3595
|
+
this.speed = 0;
|
|
3596
|
+
/**
|
|
3597
|
+
* The accuracy performance value.
|
|
3598
|
+
*/
|
|
3599
|
+
this.accuracy = 0;
|
|
3600
|
+
/**
|
|
3601
|
+
* The flashlight performance value.
|
|
3602
|
+
*/
|
|
3603
|
+
this.flashlight = 0;
|
|
3604
|
+
this.finalMultiplier = 1.15;
|
|
3605
|
+
this.mode = osuBase.Modes.osu;
|
|
3606
|
+
this.comboPenalty = 1;
|
|
3326
3607
|
}
|
|
3327
3608
|
calculateValues() {
|
|
3328
|
-
this.calculateAimValue();
|
|
3329
|
-
this.calculateSpeedValue();
|
|
3330
|
-
this.calculateAccuracyValue();
|
|
3331
|
-
this.calculateFlashlightValue();
|
|
3609
|
+
this.aim = this.calculateAimValue();
|
|
3610
|
+
this.speed = this.calculateSpeedValue();
|
|
3611
|
+
this.accuracy = this.calculateAccuracyValue();
|
|
3612
|
+
this.flashlight = this.calculateFlashlightValue();
|
|
3332
3613
|
}
|
|
3333
3614
|
calculateTotalValue() {
|
|
3334
|
-
this.
|
|
3335
|
-
Math.pow(
|
|
3336
|
-
|
|
3337
|
-
|
|
3338
|
-
|
|
3615
|
+
return (Math.pow(Math.pow(this.aim, 1.1) +
|
|
3616
|
+
Math.pow(this.speed, 1.1) +
|
|
3617
|
+
Math.pow(this.accuracy, 1.1) +
|
|
3618
|
+
Math.pow(this.flashlight, 1.1), 1 / 1.1) * this.finalMultiplier);
|
|
3619
|
+
}
|
|
3620
|
+
handleOptions(options) {
|
|
3621
|
+
var _a;
|
|
3622
|
+
super.handleOptions(options);
|
|
3623
|
+
const maxCombo = this.difficultyAttributes.maxCombo;
|
|
3624
|
+
const miss = this.computedAccuracy.nmiss;
|
|
3625
|
+
const combo = (_a = options === null || options === void 0 ? void 0 : options.combo) !== null && _a !== void 0 ? _a : maxCombo - miss;
|
|
3626
|
+
this.comboPenalty = Math.min(Math.pow(combo / maxCombo, 0.8), 1);
|
|
3339
3627
|
}
|
|
3340
3628
|
/**
|
|
3341
3629
|
* Calculates the aim performance value of the beatmap.
|
|
3342
3630
|
*/
|
|
3343
3631
|
calculateAimValue() {
|
|
3344
|
-
|
|
3632
|
+
let aimValue = this.baseValue(this.difficultyAttributes.aimDifficulty);
|
|
3345
3633
|
// Longer maps are worth more
|
|
3346
3634
|
let lengthBonus = 0.95 + 0.4 * Math.min(1, this.totalHits / 2000);
|
|
3347
3635
|
if (this.totalHits > 2000) {
|
|
3348
3636
|
lengthBonus += Math.log10(this.totalHits / 2000) * 0.5;
|
|
3349
3637
|
}
|
|
3350
|
-
|
|
3351
|
-
|
|
3352
|
-
// Penalize misses by assessing # of misses relative to the total # of objects. Default a 3% reduction for any # of misses.
|
|
3353
|
-
this.aim *=
|
|
3354
|
-
0.97 *
|
|
3355
|
-
Math.pow(1 -
|
|
3356
|
-
Math.pow(this.effectiveMissCount / this.totalHits, 0.775), this.effectiveMissCount);
|
|
3357
|
-
}
|
|
3358
|
-
// Combo scaling
|
|
3359
|
-
this.aim *= this.comboPenalty;
|
|
3638
|
+
aimValue *= lengthBonus;
|
|
3639
|
+
aimValue *= this.calculateStrainBasedMissPenalty(this.difficultyAttributes.aimDifficultStrainCount);
|
|
3360
3640
|
const calculatedAR = this.difficultyAttributes.approachRate;
|
|
3361
3641
|
if (!this.difficultyAttributes.mods.some((m) => m instanceof osuBase.ModRelax)) {
|
|
3362
3642
|
// AR scaling
|
|
@@ -3368,145 +3648,131 @@ class OsuPerformanceCalculator extends PerformanceCalculator {
|
|
|
3368
3648
|
arFactor += 0.05 * (8 - calculatedAR);
|
|
3369
3649
|
}
|
|
3370
3650
|
// Buff for longer maps with high AR.
|
|
3371
|
-
|
|
3651
|
+
aimValue *= 1 + arFactor * lengthBonus;
|
|
3372
3652
|
}
|
|
3373
3653
|
// We want to give more reward for lower AR when it comes to aim and HD. This nerfs high AR and buffs lower AR.
|
|
3374
3654
|
if (this.difficultyAttributes.mods.some((m) => m instanceof osuBase.ModHidden)) {
|
|
3375
|
-
|
|
3655
|
+
aimValue *= 1 + 0.04 * (12 - calculatedAR);
|
|
3376
3656
|
}
|
|
3377
3657
|
// Scale the aim value with slider factor to nerf very likely dropped sliderends.
|
|
3378
|
-
|
|
3658
|
+
aimValue *= this.sliderNerfFactor;
|
|
3379
3659
|
// Scale the aim value with accuracy.
|
|
3380
|
-
|
|
3660
|
+
aimValue *= this.computedAccuracy.value();
|
|
3381
3661
|
// It is also important to consider accuracy difficulty when doing that.
|
|
3382
3662
|
const odScaling = Math.pow(this.difficultyAttributes.overallDifficulty, 2) / 2500;
|
|
3383
|
-
|
|
3663
|
+
aimValue *= 0.98 + odScaling;
|
|
3664
|
+
return aimValue;
|
|
3384
3665
|
}
|
|
3385
3666
|
/**
|
|
3386
3667
|
* Calculates the speed performance value of the beatmap.
|
|
3387
3668
|
*/
|
|
3388
3669
|
calculateSpeedValue() {
|
|
3389
3670
|
if (this.difficultyAttributes.mods.some((m) => m instanceof osuBase.ModRelax)) {
|
|
3390
|
-
|
|
3391
|
-
return;
|
|
3671
|
+
return 0;
|
|
3392
3672
|
}
|
|
3393
|
-
|
|
3394
|
-
this.speed = this.baseValue(this.difficultyAttributes.speedDifficulty);
|
|
3673
|
+
let speedValue = this.baseValue(this.difficultyAttributes.speedDifficulty);
|
|
3395
3674
|
// Longer maps are worth more
|
|
3396
3675
|
let lengthBonus = 0.95 + 0.4 * Math.min(1, this.totalHits / 2000);
|
|
3397
3676
|
if (this.totalHits > 2000) {
|
|
3398
3677
|
lengthBonus += Math.log10(this.totalHits / 2000) * 0.5;
|
|
3399
3678
|
}
|
|
3400
|
-
|
|
3401
|
-
|
|
3402
|
-
// Penalize misses by assessing # of misses relative to the total # of objects. Default a 3% reduction for any # of misses.
|
|
3403
|
-
this.speed *=
|
|
3404
|
-
0.97 *
|
|
3405
|
-
Math.pow(1 -
|
|
3406
|
-
Math.pow(this.effectiveMissCount / this.totalHits, 0.775), Math.pow(this.effectiveMissCount, 0.875));
|
|
3407
|
-
}
|
|
3408
|
-
// Combo scaling
|
|
3409
|
-
this.speed *= this.comboPenalty;
|
|
3679
|
+
speedValue *= lengthBonus;
|
|
3680
|
+
speedValue *= this.calculateStrainBasedMissPenalty(this.difficultyAttributes.speedDifficultStrainCount);
|
|
3410
3681
|
// AR scaling
|
|
3411
3682
|
const calculatedAR = this.difficultyAttributes.approachRate;
|
|
3412
3683
|
if (calculatedAR > 10.33) {
|
|
3413
3684
|
// Buff for longer maps with high AR.
|
|
3414
|
-
|
|
3685
|
+
speedValue *= 1 + 0.3 * (calculatedAR - 10.33) * lengthBonus;
|
|
3415
3686
|
}
|
|
3416
3687
|
if (this.difficultyAttributes.mods.some((m) => m instanceof osuBase.ModHidden)) {
|
|
3417
|
-
|
|
3688
|
+
speedValue *= 1 + 0.04 * (12 - calculatedAR);
|
|
3418
3689
|
}
|
|
3419
3690
|
// Calculate accuracy assuming the worst case scenario.
|
|
3420
3691
|
const countGreat = this.computedAccuracy.n300;
|
|
3421
3692
|
const countOk = this.computedAccuracy.n100;
|
|
3422
3693
|
const countMeh = this.computedAccuracy.n50;
|
|
3423
3694
|
const relevantTotalDiff = this.totalHits - this.difficultyAttributes.speedNoteCount;
|
|
3424
|
-
const relevantAccuracy = new osuBase.Accuracy(
|
|
3425
|
-
|
|
3426
|
-
|
|
3427
|
-
|
|
3428
|
-
|
|
3429
|
-
|
|
3695
|
+
const relevantAccuracy = new osuBase.Accuracy(this.difficultyAttributes.speedNoteCount > 0
|
|
3696
|
+
? {
|
|
3697
|
+
n300: Math.max(0, countGreat - relevantTotalDiff),
|
|
3698
|
+
n100: Math.max(0, countOk - Math.max(0, relevantTotalDiff - countGreat)),
|
|
3699
|
+
n50: Math.max(0, countMeh -
|
|
3700
|
+
Math.max(0, relevantTotalDiff - countGreat - countOk)),
|
|
3701
|
+
}
|
|
3702
|
+
: // Set accuracy to 0.
|
|
3703
|
+
{ n300: 0, nobjects: 1 });
|
|
3430
3704
|
// Scale the speed value with accuracy and OD.
|
|
3431
|
-
|
|
3705
|
+
speedValue *=
|
|
3432
3706
|
(0.95 +
|
|
3433
3707
|
Math.pow(this.difficultyAttributes.overallDifficulty, 2) /
|
|
3434
3708
|
750) *
|
|
3435
|
-
Math.pow((this.computedAccuracy.value(
|
|
3436
|
-
relevantAccuracy.value()) /
|
|
3437
|
-
2, (14.5 -
|
|
3438
|
-
Math.max(this.difficultyAttributes.overallDifficulty, 8)) /
|
|
3439
|
-
2);
|
|
3709
|
+
Math.pow((this.computedAccuracy.value() +
|
|
3710
|
+
relevantAccuracy.value(this.difficultyAttributes.speedNoteCount)) /
|
|
3711
|
+
2, (14.5 - this.difficultyAttributes.overallDifficulty) / 2);
|
|
3440
3712
|
// Scale the speed value with # of 50s to punish doubletapping.
|
|
3441
|
-
|
|
3713
|
+
speedValue *= Math.pow(0.99, Math.max(0, this.computedAccuracy.n50 - this.totalHits / 500));
|
|
3714
|
+
return speedValue;
|
|
3442
3715
|
}
|
|
3443
3716
|
/**
|
|
3444
3717
|
* Calculates the accuracy performance value of the beatmap.
|
|
3445
3718
|
*/
|
|
3446
3719
|
calculateAccuracyValue() {
|
|
3447
3720
|
if (this.difficultyAttributes.mods.some((m) => m instanceof osuBase.ModRelax)) {
|
|
3448
|
-
|
|
3449
|
-
return;
|
|
3721
|
+
return 0;
|
|
3450
3722
|
}
|
|
3451
3723
|
const ncircles = this.difficultyAttributes.mods.some((m) => m instanceof osuBase.ModScoreV2)
|
|
3452
3724
|
? this.totalHits - this.difficultyAttributes.spinnerCount
|
|
3453
3725
|
: this.difficultyAttributes.hitCircleCount;
|
|
3454
3726
|
if (ncircles === 0) {
|
|
3455
|
-
|
|
3456
|
-
return;
|
|
3727
|
+
return 0;
|
|
3457
3728
|
}
|
|
3458
|
-
const realAccuracy = new osuBase.Accuracy({
|
|
3459
|
-
...this.computedAccuracy,
|
|
3460
|
-
n300: this.computedAccuracy.n300 - (this.totalHits - ncircles),
|
|
3461
|
-
});
|
|
3729
|
+
const realAccuracy = new osuBase.Accuracy(Object.assign(Object.assign({}, this.computedAccuracy), { n300: this.computedAccuracy.n300 - (this.totalHits - ncircles) }));
|
|
3462
3730
|
// Lots of arbitrary values from testing.
|
|
3463
3731
|
// Considering to use derivation from perfect accuracy in a probabilistic manner - assume normal distribution
|
|
3464
|
-
this.
|
|
3465
|
-
|
|
3466
|
-
|
|
3467
|
-
|
|
3732
|
+
let accuracyValue = Math.pow(1.52163, this.difficultyAttributes.overallDifficulty) *
|
|
3733
|
+
// It is possible to reach a negative accuracy with this formula. Cap it at zero - zero points.
|
|
3734
|
+
Math.pow(realAccuracy.n300 < 0 ? 0 : realAccuracy.value(), 24) *
|
|
3735
|
+
2.83;
|
|
3468
3736
|
// Bonus for many hitcircles - it's harder to keep good accuracy up for longer
|
|
3469
|
-
|
|
3737
|
+
accuracyValue *= Math.min(1.15, Math.pow(ncircles / 1000, 0.3));
|
|
3470
3738
|
if (this.difficultyAttributes.mods.some((m) => m instanceof osuBase.ModHidden)) {
|
|
3471
|
-
|
|
3739
|
+
accuracyValue *= 1.08;
|
|
3472
3740
|
}
|
|
3473
3741
|
if (this.difficultyAttributes.mods.some((m) => m instanceof osuBase.ModFlashlight)) {
|
|
3474
|
-
|
|
3742
|
+
accuracyValue *= 1.02;
|
|
3475
3743
|
}
|
|
3744
|
+
return accuracyValue;
|
|
3476
3745
|
}
|
|
3477
3746
|
/**
|
|
3478
3747
|
* Calculates the flashlight performance value of the beatmap.
|
|
3479
3748
|
*/
|
|
3480
3749
|
calculateFlashlightValue() {
|
|
3481
3750
|
if (!this.difficultyAttributes.mods.some((m) => m instanceof osuBase.ModFlashlight)) {
|
|
3482
|
-
|
|
3483
|
-
return;
|
|
3751
|
+
return 0;
|
|
3484
3752
|
}
|
|
3485
|
-
|
|
3486
|
-
this.flashlight =
|
|
3487
|
-
Math.pow(this.difficultyAttributes.flashlightDifficulty, 2) * 25;
|
|
3753
|
+
let flashlightValue = Math.pow(this.difficultyAttributes.flashlightDifficulty, 2) * 25;
|
|
3488
3754
|
// Combo scaling
|
|
3489
|
-
|
|
3755
|
+
flashlightValue *= this.comboPenalty;
|
|
3490
3756
|
if (this.effectiveMissCount > 0) {
|
|
3491
3757
|
// Penalize misses by assessing # of misses relative to the total # of objects. Default a 3% reduction for any # of misses.
|
|
3492
|
-
|
|
3758
|
+
flashlightValue *=
|
|
3493
3759
|
0.97 *
|
|
3494
3760
|
Math.pow(1 -
|
|
3495
3761
|
Math.pow(this.effectiveMissCount / this.totalHits, 0.775), Math.pow(this.effectiveMissCount, 0.875));
|
|
3496
3762
|
}
|
|
3497
3763
|
// Account for shorter maps having a higher ratio of 0 combo/100 combo flashlight radius.
|
|
3498
|
-
|
|
3764
|
+
flashlightValue *=
|
|
3499
3765
|
0.7 +
|
|
3500
3766
|
0.1 * Math.min(1, this.totalHits / 200) +
|
|
3501
3767
|
(this.totalHits > 200
|
|
3502
3768
|
? 0.2 * Math.min(1, (this.totalHits - 200) / 200)
|
|
3503
3769
|
: 0);
|
|
3504
3770
|
// Scale the flashlight value with accuracy slightly.
|
|
3505
|
-
this.
|
|
3506
|
-
0.5 + this.computedAccuracy.value(this.totalHits) / 2;
|
|
3771
|
+
flashlightValue *= 0.5 + this.computedAccuracy.value() / 2;
|
|
3507
3772
|
// It is also important to consider accuracy difficulty when doing that.
|
|
3508
3773
|
const odScaling = Math.pow(this.difficultyAttributes.overallDifficulty, 2) / 2500;
|
|
3509
|
-
|
|
3774
|
+
flashlightValue *= 0.98 + odScaling;
|
|
3775
|
+
return flashlightValue;
|
|
3510
3776
|
}
|
|
3511
3777
|
toString() {
|
|
3512
3778
|
return (this.total.toFixed(2) +
|
|
@@ -3525,10 +3791,10 @@ class OsuPerformanceCalculator extends PerformanceCalculator {
|
|
|
3525
3791
|
exports.AimEvaluator = AimEvaluator;
|
|
3526
3792
|
exports.DifficultyCalculator = DifficultyCalculator;
|
|
3527
3793
|
exports.DifficultyHitObject = DifficultyHitObject;
|
|
3528
|
-
exports.DifficultyHitObjectCreator = DifficultyHitObjectCreator;
|
|
3529
3794
|
exports.DroidAim = DroidAim;
|
|
3530
3795
|
exports.DroidAimEvaluator = DroidAimEvaluator;
|
|
3531
3796
|
exports.DroidDifficultyCalculator = DroidDifficultyCalculator;
|
|
3797
|
+
exports.DroidDifficultyHitObject = DroidDifficultyHitObject;
|
|
3532
3798
|
exports.DroidFlashlight = DroidFlashlight;
|
|
3533
3799
|
exports.DroidFlashlightEvaluator = DroidFlashlightEvaluator;
|
|
3534
3800
|
exports.DroidPerformanceCalculator = DroidPerformanceCalculator;
|
|
@@ -3539,10 +3805,10 @@ exports.DroidTapEvaluator = DroidTapEvaluator;
|
|
|
3539
3805
|
exports.DroidVisual = DroidVisual;
|
|
3540
3806
|
exports.DroidVisualEvaluator = DroidVisualEvaluator;
|
|
3541
3807
|
exports.FlashlightEvaluator = FlashlightEvaluator;
|
|
3542
|
-
exports.MapStars = MapStars;
|
|
3543
3808
|
exports.OsuAim = OsuAim;
|
|
3544
3809
|
exports.OsuAimEvaluator = OsuAimEvaluator;
|
|
3545
3810
|
exports.OsuDifficultyCalculator = OsuDifficultyCalculator;
|
|
3811
|
+
exports.OsuDifficultyHitObject = OsuDifficultyHitObject;
|
|
3546
3812
|
exports.OsuFlashlight = OsuFlashlight;
|
|
3547
3813
|
exports.OsuFlashlightEvaluator = OsuFlashlightEvaluator;
|
|
3548
3814
|
exports.OsuPerformanceCalculator = OsuPerformanceCalculator;
|
|
@@ -3550,6 +3816,5 @@ exports.OsuRhythmEvaluator = OsuRhythmEvaluator;
|
|
|
3550
3816
|
exports.OsuSpeed = OsuSpeed;
|
|
3551
3817
|
exports.OsuSpeedEvaluator = OsuSpeedEvaluator;
|
|
3552
3818
|
exports.PerformanceCalculator = PerformanceCalculator;
|
|
3553
|
-
exports.RhythmEvaluator = RhythmEvaluator;
|
|
3554
3819
|
exports.SpeedEvaluator = SpeedEvaluator;
|
|
3555
3820
|
//# sourceMappingURL=index.js.map
|