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