@rian8337/osu-difficulty-calculator 4.0.0-beta.14 → 4.0.0-beta.16
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 +594 -639
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/typings/index.d.ts +236 -276
package/dist/index.js
CHANGED
|
@@ -29,31 +29,143 @@ class AimEvaluator {
|
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
/**
|
|
32
|
-
*
|
|
32
|
+
* The base of a difficulty calculator.
|
|
33
33
|
*/
|
|
34
|
-
class
|
|
34
|
+
class DifficultyCalculator {
|
|
35
35
|
/**
|
|
36
|
-
* The
|
|
36
|
+
* The calculated beatmap.
|
|
37
37
|
*/
|
|
38
|
-
|
|
38
|
+
beatmap;
|
|
39
39
|
/**
|
|
40
|
-
* The
|
|
40
|
+
* The difficulty objects of the beatmap.
|
|
41
|
+
*/
|
|
42
|
+
objects = [];
|
|
43
|
+
/**
|
|
44
|
+
* The modifications applied.
|
|
45
|
+
*/
|
|
46
|
+
mods = [];
|
|
47
|
+
/**
|
|
48
|
+
* The total star rating of the beatmap.
|
|
49
|
+
*/
|
|
50
|
+
get total() {
|
|
51
|
+
return this.attributes.starRating;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* The difficulty statistics of the beatmap after modifications are applied.
|
|
55
|
+
*/
|
|
56
|
+
difficultyStatistics;
|
|
57
|
+
/**
|
|
58
|
+
* The strain peaks of various calculated difficulties.
|
|
59
|
+
*/
|
|
60
|
+
strainPeaks = {
|
|
61
|
+
aimWithSliders: [],
|
|
62
|
+
aimWithoutSliders: [],
|
|
63
|
+
speed: [],
|
|
64
|
+
flashlight: [],
|
|
65
|
+
};
|
|
66
|
+
/**
|
|
67
|
+
* Constructs a new instance of the calculator.
|
|
41
68
|
*
|
|
42
|
-
*
|
|
69
|
+
* @param beatmap The beatmap to calculate. This beatmap will be deep-cloned to prevent reference changes.
|
|
43
70
|
*/
|
|
44
|
-
|
|
71
|
+
constructor(beatmap) {
|
|
72
|
+
this.beatmap = beatmap;
|
|
73
|
+
this.difficultyStatistics = {
|
|
74
|
+
circleSize: beatmap.difficulty.cs,
|
|
75
|
+
approachRate: beatmap.difficulty.ar ?? beatmap.difficulty.od,
|
|
76
|
+
overallDifficulty: beatmap.difficulty.od,
|
|
77
|
+
healthDrain: beatmap.difficulty.hp,
|
|
78
|
+
overallSpeedMultiplier: 1,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
45
81
|
/**
|
|
46
|
-
*
|
|
82
|
+
* Calculates the star rating of the specified beatmap.
|
|
83
|
+
*
|
|
84
|
+
* The beatmap is analyzed in chunks of `sectionLength` duration.
|
|
85
|
+
* For each chunk the highest hitobject strains are added to
|
|
86
|
+
* a list which is then collapsed into a weighted sum, much
|
|
87
|
+
* like scores are weighted on a user's profile.
|
|
88
|
+
*
|
|
89
|
+
* For subsequent chunks, the initial max strain is calculated
|
|
90
|
+
* by decaying the previous hitobject's strain until the
|
|
91
|
+
* beginning of the new chunk.
|
|
92
|
+
*
|
|
93
|
+
* @param options Options for the difficulty calculation.
|
|
94
|
+
* @returns The current instance.
|
|
47
95
|
*/
|
|
48
|
-
|
|
96
|
+
calculate(options) {
|
|
97
|
+
this.mods = options?.mods ?? [];
|
|
98
|
+
const converted = new osuBase.BeatmapConverter(this.beatmap).convert({
|
|
99
|
+
mode: this.mode,
|
|
100
|
+
mods: this.mods,
|
|
101
|
+
customSpeedMultiplier: options?.customSpeedMultiplier,
|
|
102
|
+
});
|
|
103
|
+
this.difficultyStatistics = Object.seal(this.computeDifficultyStatistics(options));
|
|
104
|
+
this.populateDifficultyAttributes();
|
|
105
|
+
this.objects.push(...this.generateDifficultyHitObjects(converted));
|
|
106
|
+
this.calculateAll();
|
|
107
|
+
return this;
|
|
108
|
+
}
|
|
49
109
|
/**
|
|
50
|
-
*
|
|
110
|
+
* Calculates the skills provided.
|
|
111
|
+
*
|
|
112
|
+
* @param skills The skills to calculate.
|
|
51
113
|
*/
|
|
52
|
-
|
|
114
|
+
calculateSkills(...skills) {
|
|
115
|
+
// The first object doesn't generate a strain, so we begin calculating from the second object.
|
|
116
|
+
for (const object of this.objects.slice(1)) {
|
|
117
|
+
for (const skill of skills) {
|
|
118
|
+
skill.process(object);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
53
122
|
/**
|
|
54
|
-
*
|
|
123
|
+
* Populates the stored difficulty attributes with necessary data.
|
|
55
124
|
*/
|
|
56
|
-
|
|
125
|
+
populateDifficultyAttributes() {
|
|
126
|
+
this.attributes.approachRate = this.difficultyStatistics.approachRate;
|
|
127
|
+
this.attributes.hitCircleCount = this.beatmap.hitObjects.circles;
|
|
128
|
+
this.attributes.maxCombo = this.beatmap.maxCombo;
|
|
129
|
+
this.attributes.mods = this.mods.slice();
|
|
130
|
+
this.attributes.overallDifficulty =
|
|
131
|
+
this.difficultyStatistics.overallDifficulty;
|
|
132
|
+
this.attributes.sliderCount = this.beatmap.hitObjects.sliders;
|
|
133
|
+
this.attributes.spinnerCount = this.beatmap.hitObjects.spinners;
|
|
134
|
+
this.attributes.clockRate =
|
|
135
|
+
this.difficultyStatistics.overallSpeedMultiplier;
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Calculates the star rating value of a difficulty.
|
|
139
|
+
*
|
|
140
|
+
* @param difficulty The difficulty to calculate.
|
|
141
|
+
*/
|
|
142
|
+
starValue(difficulty) {
|
|
143
|
+
return Math.sqrt(difficulty) * this.difficultyMultiplier;
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Calculates the base performance value of a difficulty rating.
|
|
147
|
+
*
|
|
148
|
+
* @param rating The difficulty rating.
|
|
149
|
+
*/
|
|
150
|
+
basePerformanceValue(rating) {
|
|
151
|
+
return Math.pow(5 * Math.max(1, rating / 0.0675) - 4, 3) / 100000;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Represents a hit object with difficulty calculation values.
|
|
157
|
+
*/
|
|
158
|
+
class DifficultyHitObject {
|
|
159
|
+
/**
|
|
160
|
+
* The underlying hitobject.
|
|
161
|
+
*/
|
|
162
|
+
object;
|
|
163
|
+
/**
|
|
164
|
+
* The index of this hitobject in the list of all hitobjects.
|
|
165
|
+
*
|
|
166
|
+
* This is one less than the actual index of the hitobject in the beatmap.
|
|
167
|
+
*/
|
|
168
|
+
index;
|
|
57
169
|
/**
|
|
58
170
|
* The aim strain generated by the hitobject if sliders are considered.
|
|
59
171
|
*/
|
|
@@ -106,30 +218,71 @@ class DifficultyHitObject {
|
|
|
106
218
|
/**
|
|
107
219
|
* The amount of milliseconds elapsed between this hitobject and the last hitobject.
|
|
108
220
|
*/
|
|
109
|
-
deltaTime
|
|
221
|
+
deltaTime;
|
|
110
222
|
/**
|
|
111
223
|
* The amount of milliseconds elapsed since the start time of the previous hitobject, with a minimum of 25ms.
|
|
112
224
|
*/
|
|
113
|
-
strainTime
|
|
225
|
+
strainTime;
|
|
114
226
|
/**
|
|
115
227
|
* Adjusted start time of the hitobject, taking speed multiplier into account.
|
|
116
228
|
*/
|
|
117
|
-
startTime
|
|
229
|
+
startTime;
|
|
118
230
|
/**
|
|
119
231
|
* Adjusted end time of the hitobject, taking speed multiplier into account.
|
|
120
232
|
*/
|
|
121
|
-
endTime
|
|
233
|
+
endTime;
|
|
122
234
|
/**
|
|
123
235
|
* Other hitobjects in the beatmap, including this hitobject.
|
|
124
236
|
*/
|
|
125
237
|
hitObjects;
|
|
238
|
+
normalizedRadius = 50;
|
|
239
|
+
maximumSliderRadius = this.normalizedRadius * 2.4;
|
|
240
|
+
assumedSliderRadius = this.normalizedRadius * 1.8;
|
|
241
|
+
minDeltaTime = 25;
|
|
242
|
+
lastObject;
|
|
243
|
+
lastLastObject;
|
|
126
244
|
/**
|
|
245
|
+
* Note: You **must** call `computeProperties` at some point due to how TypeScript handles
|
|
246
|
+
* overridden properties (see [this](https://github.com/microsoft/TypeScript/issues/1617) GitHub issue.).
|
|
247
|
+
*
|
|
127
248
|
* @param object The underlying hitobject.
|
|
128
|
-
* @param
|
|
129
|
-
|
|
130
|
-
|
|
249
|
+
* @param lastObject The hitobject before this hitobject.
|
|
250
|
+
* @param lastLastObject The hitobject before the last hitobject.
|
|
251
|
+
* @param difficultyHitObjects All difficulty hitobjects in the processed beatmap.
|
|
252
|
+
* @param clockRate The clock rate of the beatmap.
|
|
253
|
+
* @param timePreempt The time preempt with clock rate.
|
|
254
|
+
* @param isForceAR Whether force AR is enabled.
|
|
255
|
+
* @param mode The gamemode to compute properties for.
|
|
256
|
+
*/
|
|
257
|
+
constructor(object, lastObject, lastLastObject, difficultyHitObjects, clockRate) {
|
|
131
258
|
this.object = object;
|
|
132
|
-
this.
|
|
259
|
+
this.lastObject = lastObject;
|
|
260
|
+
this.lastLastObject = lastLastObject;
|
|
261
|
+
this.hitObjects = difficultyHitObjects;
|
|
262
|
+
this.index = difficultyHitObjects.length - 1;
|
|
263
|
+
// Capped to 25ms to prevent difficulty calculation breaking from simultaneous objects.
|
|
264
|
+
this.startTime = object.startTime / clockRate;
|
|
265
|
+
this.endTime = object.endTime / clockRate;
|
|
266
|
+
if (lastObject) {
|
|
267
|
+
this.deltaTime = this.startTime - lastObject.startTime / clockRate;
|
|
268
|
+
this.strainTime = Math.max(this.deltaTime, this.minDeltaTime);
|
|
269
|
+
}
|
|
270
|
+
else {
|
|
271
|
+
this.deltaTime = 0;
|
|
272
|
+
this.strainTime = 0;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Computes the properties of this hitobject.
|
|
277
|
+
*
|
|
278
|
+
* @param clockRate The clock rate of the beatmap.
|
|
279
|
+
* @param hitObjects The hitobjects in the beatmap.
|
|
280
|
+
*/
|
|
281
|
+
computeProperties(clockRate,
|
|
282
|
+
// Required for `DroidDifficultyHitObject` override.
|
|
283
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
284
|
+
hitObjects) {
|
|
285
|
+
this.setDistances(clockRate);
|
|
133
286
|
}
|
|
134
287
|
/**
|
|
135
288
|
* Gets the difficulty hitobject at a specific index with respect to the current
|
|
@@ -171,322 +324,88 @@ class DifficultyHitObject {
|
|
|
171
324
|
// but this is an approximation and such a case is unlikely to be hit where this function is used.
|
|
172
325
|
return 0;
|
|
173
326
|
}
|
|
174
|
-
const fadeInStartTime = this.object.startTime - this.
|
|
175
|
-
const fadeInDuration = this.timeFadeIn;
|
|
327
|
+
const fadeInStartTime = this.object.startTime - this.object.timePreempt;
|
|
328
|
+
const fadeInDuration = this.object.timeFadeIn;
|
|
176
329
|
if (isHidden) {
|
|
177
330
|
const fadeOutStartTime = fadeInStartTime + fadeInDuration;
|
|
178
|
-
const fadeOutDuration = this.
|
|
331
|
+
const fadeOutDuration = this.object.timePreempt * osuBase.ModHidden.fadeOutDurationMultiplier;
|
|
179
332
|
return Math.min(osuBase.MathUtils.clamp((time - fadeInStartTime) / fadeInDuration, 0, 1), 1 -
|
|
180
333
|
osuBase.MathUtils.clamp((time - fadeOutStartTime) / fadeOutDuration, 0, 1));
|
|
181
334
|
}
|
|
182
335
|
return osuBase.MathUtils.clamp((time - fadeInStartTime) / fadeInDuration, 0, 1);
|
|
183
336
|
}
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
if (this.object instanceof osuBase.Spinner) {
|
|
195
|
-
return false;
|
|
196
|
-
}
|
|
197
|
-
const previous = this.previous(0);
|
|
198
|
-
if (!previous || previous.object instanceof osuBase.Spinner) {
|
|
199
|
-
return false;
|
|
200
|
-
}
|
|
201
|
-
if (this.deltaTime >= 5) {
|
|
202
|
-
return false;
|
|
203
|
-
}
|
|
204
|
-
if (considerDistance) {
|
|
205
|
-
const endPosition = this.object.getStackedPosition(osuBase.Modes.droid);
|
|
206
|
-
let distance = previous.object
|
|
207
|
-
.getStackedEndPosition(osuBase.Modes.droid)
|
|
208
|
-
.getDistance(endPosition);
|
|
209
|
-
if (previous.object instanceof osuBase.Slider &&
|
|
210
|
-
previous.object.lazyEndPosition) {
|
|
211
|
-
distance = Math.min(distance, previous.object.lazyEndPosition.getDistance(endPosition));
|
|
337
|
+
setDistances(clockRate) {
|
|
338
|
+
if (this.object instanceof osuBase.Slider) {
|
|
339
|
+
this.calculateSliderCursorPosition(this.object);
|
|
340
|
+
this.travelDistance = this.object.lazyTravelDistance;
|
|
341
|
+
// Bonus for repeat sliders until a better per nested object strain system can be achieved.
|
|
342
|
+
if (this.mode === osuBase.Modes.droid) {
|
|
343
|
+
this.travelDistance *= Math.pow(1 + this.object.repeatCount / 4, 1 / 4);
|
|
344
|
+
}
|
|
345
|
+
else {
|
|
346
|
+
this.travelDistance *= Math.pow(1 + this.object.repeatCount / 2.5, 1 / 2.5);
|
|
212
347
|
}
|
|
213
|
-
|
|
348
|
+
this.travelTime = Math.max(this.object.lazyTravelTime / clockRate, this.minDeltaTime);
|
|
214
349
|
}
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
* Represents an osu!droid hit object with difficulty calculation values.
|
|
221
|
-
*/
|
|
222
|
-
class DroidDifficultyHitObject extends DifficultyHitObject {
|
|
223
|
-
/**
|
|
224
|
-
* The tap strain generated by the hitobject.
|
|
225
|
-
*/
|
|
226
|
-
tapStrain = 0;
|
|
227
|
-
/**
|
|
228
|
-
* The tap strain generated by the hitobject if `strainTime` isn't modified by
|
|
229
|
-
* OD. This is used in three-finger detection.
|
|
230
|
-
*/
|
|
231
|
-
originalTapStrain = 0;
|
|
232
|
-
/**
|
|
233
|
-
* The rhythm strain generated by the hitobject.
|
|
234
|
-
*/
|
|
235
|
-
rhythmStrain = 0;
|
|
236
|
-
/**
|
|
237
|
-
* The flashlight strain generated by the hitobject if sliders are considered.
|
|
238
|
-
*/
|
|
239
|
-
flashlightStrainWithSliders = 0;
|
|
240
|
-
/**
|
|
241
|
-
* The flashlight strain generated by the hitobject if sliders are not considered.
|
|
242
|
-
*/
|
|
243
|
-
flashlightStrainWithoutSliders = 0;
|
|
244
|
-
/**
|
|
245
|
-
* The visual strain generated by the hitobject if sliders are considered.
|
|
246
|
-
*/
|
|
247
|
-
visualStrainWithSliders = 0;
|
|
248
|
-
/**
|
|
249
|
-
* The visual strain generated by the hitobject if sliders are not considered.
|
|
250
|
-
*/
|
|
251
|
-
visualStrainWithoutSliders = 0;
|
|
252
|
-
/**
|
|
253
|
-
* The note density of the hitobject.
|
|
254
|
-
*/
|
|
255
|
-
noteDensity = 1;
|
|
256
|
-
/**
|
|
257
|
-
* The overlapping factor of the hitobject.
|
|
258
|
-
*
|
|
259
|
-
* This is used to scale visual skill.
|
|
260
|
-
*/
|
|
261
|
-
overlappingFactor = 0;
|
|
262
|
-
/**
|
|
263
|
-
* @param object The underlying hitobject.
|
|
264
|
-
* @param hitObjects All difficulty hitobjects in the processed beatmap.
|
|
265
|
-
*/
|
|
266
|
-
constructor(object, hitObjects) {
|
|
267
|
-
super(object, hitObjects);
|
|
268
|
-
}
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
/**
|
|
272
|
-
* Represents an osu!standard hit object with difficulty calculation values.
|
|
273
|
-
*/
|
|
274
|
-
class OsuDifficultyHitObject extends DifficultyHitObject {
|
|
275
|
-
/**
|
|
276
|
-
* The speed strain generated by the hitobject.
|
|
277
|
-
*/
|
|
278
|
-
speedStrain = 0;
|
|
279
|
-
/**
|
|
280
|
-
* The flashlight strain generated by this hitobject.
|
|
281
|
-
*/
|
|
282
|
-
flashlightStrain = 0;
|
|
283
|
-
/**
|
|
284
|
-
* @param object The underlying hitobject.
|
|
285
|
-
* @param hitObjects All difficulty hitobjects in the processed beatmap.
|
|
286
|
-
*/
|
|
287
|
-
constructor(object, hitObjects) {
|
|
288
|
-
super(object, hitObjects);
|
|
289
|
-
}
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
/**
|
|
293
|
-
* A converter used to convert normal hitobjects into difficulty hitobjects.
|
|
294
|
-
*/
|
|
295
|
-
class DifficultyHitObjectCreator {
|
|
296
|
-
/**
|
|
297
|
-
* The threshold for small circle buff for osu!droid.
|
|
298
|
-
*/
|
|
299
|
-
DROID_CIRCLESIZE_BUFF_THRESHOLD = 70;
|
|
300
|
-
/**
|
|
301
|
-
* The threshold for small circle buff for osu!standard.
|
|
302
|
-
*/
|
|
303
|
-
PC_CIRCLESIZE_BUFF_THRESHOLD = 30;
|
|
304
|
-
/**
|
|
305
|
-
* The gamemode this creator is creating for.
|
|
306
|
-
*/
|
|
307
|
-
mode = osuBase.Modes.osu;
|
|
308
|
-
/**
|
|
309
|
-
* The base normalized radius of hitobjects.
|
|
310
|
-
*/
|
|
311
|
-
normalizedRadius = 50;
|
|
312
|
-
maximumSliderRadius = this.normalizedRadius * 2.4;
|
|
313
|
-
assumedSliderRadius = this.normalizedRadius * 1.8;
|
|
314
|
-
minDeltaTime = 25;
|
|
315
|
-
generateDifficultyObjects(params) {
|
|
316
|
-
params.preempt ??= 600;
|
|
317
|
-
this.mode = params.mode;
|
|
318
|
-
if (this.mode === osuBase.Modes.droid) {
|
|
319
|
-
this.maximumSliderRadius = this.normalizedRadius * 2;
|
|
350
|
+
// We don't need to calculate either angle or distance when one of the last->curr objects is a spinner.
|
|
351
|
+
if (!this.lastObject ||
|
|
352
|
+
this.object instanceof osuBase.Spinner ||
|
|
353
|
+
this.lastObject instanceof osuBase.Spinner) {
|
|
354
|
+
return;
|
|
320
355
|
}
|
|
321
|
-
|
|
322
|
-
const
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
// Cap to 25ms to prevent difficulty calculation breaking from simultaneous objects.
|
|
354
|
-
object.strainTime = Math.max(this.minDeltaTime, object.deltaTime);
|
|
355
|
-
if (object.object instanceof osuBase.Spinner) {
|
|
356
|
-
difficultyObjects.push(object);
|
|
357
|
-
continue;
|
|
358
|
-
}
|
|
359
|
-
if (object instanceof DroidDifficultyHitObject) {
|
|
360
|
-
// We'll have two visible object arrays. The first array contains objects before the current object starts in a reversed order,
|
|
361
|
-
// while the second array contains objects after the current object ends.
|
|
362
|
-
// For overlapping factor, we also need to consider previous visible objects.
|
|
363
|
-
const prevVisibleObjects = [];
|
|
364
|
-
const nextVisibleObjects = [];
|
|
365
|
-
for (let j = i + 1; j < params.objects.length; ++j) {
|
|
366
|
-
const o = params.objects[j];
|
|
367
|
-
if (o instanceof osuBase.Spinner) {
|
|
368
|
-
continue;
|
|
369
|
-
}
|
|
370
|
-
if (o.startTime / params.speedMultiplier >
|
|
371
|
-
object.endTime + object.timePreempt) {
|
|
372
|
-
break;
|
|
373
|
-
}
|
|
374
|
-
nextVisibleObjects.push(o);
|
|
375
|
-
}
|
|
376
|
-
for (let j = 0; j < object.index; ++j) {
|
|
377
|
-
const prev = object.previous(j);
|
|
378
|
-
if (prev.object instanceof osuBase.Spinner) {
|
|
379
|
-
continue;
|
|
380
|
-
}
|
|
381
|
-
if (prev.startTime >= object.startTime) {
|
|
382
|
-
continue;
|
|
383
|
-
}
|
|
384
|
-
if (prev.startTime <
|
|
385
|
-
object.startTime - object.timePreempt) {
|
|
386
|
-
break;
|
|
387
|
-
}
|
|
388
|
-
prevVisibleObjects.push(prev.object);
|
|
389
|
-
}
|
|
390
|
-
for (const hitObject of prevVisibleObjects) {
|
|
391
|
-
const distance = object.object
|
|
392
|
-
.getStackedPosition(this.mode)
|
|
393
|
-
.getDistance(hitObject.getStackedEndPosition(this.mode));
|
|
394
|
-
const deltaTime = object.startTime -
|
|
395
|
-
hitObject.endTime / params.speedMultiplier;
|
|
396
|
-
this.applyToOverlappingFactor(object, distance, deltaTime);
|
|
397
|
-
}
|
|
398
|
-
for (const hitObject of nextVisibleObjects) {
|
|
399
|
-
const distance = hitObject
|
|
400
|
-
.getStackedPosition(this.mode)
|
|
401
|
-
.getDistance(object.object.getStackedEndPosition(this.mode));
|
|
402
|
-
const deltaTime = hitObject.startTime / params.speedMultiplier -
|
|
403
|
-
object.endTime;
|
|
404
|
-
if (deltaTime >= 0) {
|
|
405
|
-
object.noteDensity +=
|
|
406
|
-
1 - deltaTime / object.timePreempt;
|
|
407
|
-
}
|
|
408
|
-
this.applyToOverlappingFactor(object, distance, deltaTime);
|
|
409
|
-
}
|
|
410
|
-
}
|
|
411
|
-
if (lastObject.object instanceof osuBase.Spinner) {
|
|
412
|
-
difficultyObjects.push(object);
|
|
413
|
-
continue;
|
|
414
|
-
}
|
|
415
|
-
const lastCursorPosition = this.getEndCursorPosition(lastObject.object);
|
|
416
|
-
object.lazyJumpDistance = object.object
|
|
356
|
+
// We will scale distances by this factor, so we can assume a uniform CircleSize among beatmaps.
|
|
357
|
+
const { scalingFactor } = this;
|
|
358
|
+
const lastCursorPosition = this.getEndCursorPosition(this.lastObject);
|
|
359
|
+
this.lazyJumpDistance = this.object
|
|
360
|
+
.getStackedPosition(this.mode)
|
|
361
|
+
.scale(scalingFactor)
|
|
362
|
+
.subtract(lastCursorPosition.scale(scalingFactor)).length;
|
|
363
|
+
this.minimumJumpTime = this.strainTime;
|
|
364
|
+
this.minimumJumpDistance = this.lazyJumpDistance;
|
|
365
|
+
if (this.lastObject instanceof osuBase.Slider) {
|
|
366
|
+
const lastTravelTime = Math.max(this.lastObject.lazyTravelTime / clockRate, this.minDeltaTime);
|
|
367
|
+
this.minimumJumpTime = Math.max(this.strainTime - lastTravelTime, this.minDeltaTime);
|
|
368
|
+
// 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.
|
|
369
|
+
//
|
|
370
|
+
// 1. The anti-flow pattern, where players cut the slider short in order to move to the next hitobject.
|
|
371
|
+
//
|
|
372
|
+
// <======o==> ← slider
|
|
373
|
+
// | ← most natural jump path
|
|
374
|
+
// o ← a follow-up hitcircle
|
|
375
|
+
//
|
|
376
|
+
// In this case the most natural jump path is approximated by LazyJumpDistance.
|
|
377
|
+
//
|
|
378
|
+
// 2. The flow pattern, where players follow through the slider to its visual extent into the next hitobject.
|
|
379
|
+
//
|
|
380
|
+
// <======o==>---o
|
|
381
|
+
// ↑
|
|
382
|
+
// most natural jump path
|
|
383
|
+
//
|
|
384
|
+
// 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.
|
|
385
|
+
//
|
|
386
|
+
// Thus, the player is assumed to jump the minimum of these two distances in all cases.
|
|
387
|
+
const tailJumpDistance = this.lastObject.tail
|
|
417
388
|
.getStackedPosition(this.mode)
|
|
418
|
-
.
|
|
419
|
-
.
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
// In this case the most natural jump path is approximated by LazyJumpDistance.
|
|
433
|
-
//
|
|
434
|
-
// 2. The flow pattern, where players follow through the slider to its visual extent into the next hitobject.
|
|
435
|
-
//
|
|
436
|
-
// <======o==>---o
|
|
437
|
-
// ↑
|
|
438
|
-
// most natural jump path
|
|
439
|
-
//
|
|
440
|
-
// 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.
|
|
441
|
-
//
|
|
442
|
-
// Thus, the player is assumed to jump the minimum of these two distances in all cases.
|
|
443
|
-
const tailJumpDistance = lastObject.object.tail
|
|
444
|
-
.getStackedPosition(this.mode)
|
|
445
|
-
.subtract(object.object.getStackedPosition(this.mode))
|
|
446
|
-
.length * scalingFactor;
|
|
447
|
-
object.minimumJumpDistance = Math.max(0, Math.min(object.lazyJumpDistance -
|
|
448
|
-
(this.maximumSliderRadius -
|
|
449
|
-
this.assumedSliderRadius), tailJumpDistance - this.maximumSliderRadius));
|
|
450
|
-
}
|
|
451
|
-
if (lastLastObject && !(lastLastObject.object instanceof osuBase.Spinner)) {
|
|
452
|
-
const lastLastCursorPosition = this.getEndCursorPosition(lastLastObject.object);
|
|
453
|
-
const v1 = lastLastCursorPosition.subtract(lastObject.object.getStackedPosition(this.mode));
|
|
454
|
-
const v2 = object.object
|
|
455
|
-
.getStackedPosition(this.mode)
|
|
456
|
-
.subtract(lastCursorPosition);
|
|
457
|
-
const dot = v1.dot(v2);
|
|
458
|
-
const det = v1.x * v2.y - v1.y * v2.x;
|
|
459
|
-
object.angle = Math.abs(Math.atan2(det, dot));
|
|
460
|
-
}
|
|
461
|
-
difficultyObjects.push(object);
|
|
389
|
+
.subtract(this.object.getStackedPosition(this.mode))
|
|
390
|
+
.length * scalingFactor;
|
|
391
|
+
this.minimumJumpDistance = Math.max(0, Math.min(this.lazyJumpDistance -
|
|
392
|
+
(this.maximumSliderRadius - this.assumedSliderRadius), tailJumpDistance - this.maximumSliderRadius));
|
|
393
|
+
}
|
|
394
|
+
if (this.lastLastObject && !(this.lastLastObject instanceof osuBase.Spinner)) {
|
|
395
|
+
const lastLastCursorPosition = this.getEndCursorPosition(this.lastLastObject);
|
|
396
|
+
const v1 = lastLastCursorPosition.subtract(this.lastObject.getStackedPosition(this.mode));
|
|
397
|
+
const v2 = this.object
|
|
398
|
+
.getStackedPosition(this.mode)
|
|
399
|
+
.subtract(lastCursorPosition);
|
|
400
|
+
const dot = v1.dot(v2);
|
|
401
|
+
const det = v1.x * v2.y - v1.y * v2.x;
|
|
402
|
+
this.angle = Math.abs(Math.atan2(det, dot));
|
|
462
403
|
}
|
|
463
|
-
return difficultyObjects;
|
|
464
404
|
}
|
|
465
|
-
/**
|
|
466
|
-
* Calculates a slider's cursor position.
|
|
467
|
-
*/
|
|
468
405
|
calculateSliderCursorPosition(slider) {
|
|
469
406
|
if (slider.lazyEndPosition) {
|
|
470
407
|
return;
|
|
471
408
|
}
|
|
472
|
-
// Droid doesn't have a legacy slider tail. Since beatmap parser defaults slider tail
|
|
473
|
-
// to legacy slider tail, it needs to be changed to real slider tail first.
|
|
474
|
-
if (this.mode === osuBase.Modes.droid) {
|
|
475
|
-
slider.tail.startTime += osuBase.Slider.legacyLastTickOffset;
|
|
476
|
-
slider.tail.endTime += osuBase.Slider.legacyLastTickOffset;
|
|
477
|
-
slider.nestedHitObjects.sort((a, b) => a.startTime - b.startTime);
|
|
478
|
-
// Temporary lazy end position until a real result can be derived.
|
|
479
|
-
slider.lazyEndPosition = slider.getStackedPosition(this.mode);
|
|
480
|
-
// Stop here if the slider has too short duration due to float number limitation.
|
|
481
|
-
// Incredibly close start and end time fluctuates travel distance and lazy
|
|
482
|
-
// end position heavily, which we do not want to happen.
|
|
483
|
-
//
|
|
484
|
-
// In the real game, this shouldn't happen. Perhaps we need to reinvestigate this
|
|
485
|
-
// in the future.
|
|
486
|
-
if (osuBase.Precision.almostEqualsNumber(slider.startTime, slider.endTime)) {
|
|
487
|
-
return;
|
|
488
|
-
}
|
|
489
|
-
}
|
|
490
409
|
// Not using slider.endTime due to legacy last tick offset.
|
|
491
410
|
slider.lazyTravelTime =
|
|
492
411
|
slider.nestedHitObjects.at(-1).startTime - slider.startTime;
|
|
@@ -502,228 +421,52 @@ class DifficultyHitObjectCreator {
|
|
|
502
421
|
.getStackedPosition(this.mode)
|
|
503
422
|
.add(slider.path.positionAt(endTimeMin));
|
|
504
423
|
let currentCursorPosition = slider.getStackedPosition(this.mode);
|
|
505
|
-
const scalingFactor = this.normalizedRadius / slider.
|
|
424
|
+
const scalingFactor = this.normalizedRadius / slider.radius;
|
|
506
425
|
for (let i = 1; i < slider.nestedHitObjects.length; ++i) {
|
|
507
426
|
const currentMovementObject = slider.nestedHitObjects[i];
|
|
508
427
|
let currentMovement = currentMovementObject
|
|
509
428
|
.getStackedPosition(this.mode)
|
|
510
429
|
.subtract(currentCursorPosition);
|
|
511
430
|
let currentMovementLength = scalingFactor * currentMovement.length;
|
|
512
|
-
// The amount of movement required so that the cursor position needs to be updated.
|
|
513
|
-
let requiredMovement = this.assumedSliderRadius;
|
|
514
|
-
if (i === slider.nestedHitObjects.length - 1) {
|
|
515
|
-
// The end of a slider has special aim rules due to the relaxed time constraint on position.
|
|
516
|
-
// There is both a lazy end position as well as the actual end slider position. We assume the player takes the simpler movement.
|
|
517
|
-
// For sliders that are circular, the lazy end position may actually be farther away than the sliders' true end.
|
|
518
|
-
// This code is designed to prevent buffing situations where lazy end is actually a less efficient movement.
|
|
519
|
-
const lazyMovement = slider.lazyEndPosition.subtract(currentCursorPosition);
|
|
520
|
-
if (lazyMovement.length < currentMovement.length) {
|
|
521
|
-
currentMovement = lazyMovement;
|
|
522
|
-
}
|
|
523
|
-
currentMovementLength = scalingFactor * currentMovement.length;
|
|
524
|
-
}
|
|
525
|
-
else if (currentMovementObject instanceof osuBase.SliderRepeat) {
|
|
526
|
-
// For a slider repeat, assume a tighter movement threshold to better assess repeat sliders.
|
|
527
|
-
requiredMovement = this.normalizedRadius;
|
|
528
|
-
}
|
|
529
|
-
if (currentMovementLength > requiredMovement) {
|
|
530
|
-
// This finds the positional delta from the required radius and the current position,
|
|
531
|
-
// and updates the currentCursorPosition accordingly, as well as rewarding distance.
|
|
532
|
-
currentCursorPosition = currentCursorPosition.add(currentMovement.scale((currentMovementLength - requiredMovement) /
|
|
533
|
-
currentMovementLength));
|
|
534
|
-
currentMovementLength *=
|
|
535
|
-
(currentMovementLength - requiredMovement) /
|
|
536
|
-
currentMovementLength;
|
|
537
|
-
slider.lazyTravelDistance += currentMovementLength;
|
|
538
|
-
}
|
|
539
|
-
if (i === slider.nestedHitObjects.length - 1) {
|
|
540
|
-
slider.lazyEndPosition = currentCursorPosition;
|
|
541
|
-
}
|
|
542
|
-
}
|
|
543
|
-
}
|
|
544
|
-
/**
|
|
545
|
-
* Gets the scaling factor of a radius.
|
|
546
|
-
*
|
|
547
|
-
* @param radius The radius to get the scaling factor from.
|
|
548
|
-
*/
|
|
549
|
-
getScalingFactor(radius) {
|
|
550
|
-
// We will scale distances by this factor, so we can assume a uniform CircleSize among beatmaps.
|
|
551
|
-
let scalingFactor = this.normalizedRadius / radius;
|
|
552
|
-
// High circle size (small CS) bonus
|
|
553
|
-
switch (this.mode) {
|
|
554
|
-
case osuBase.Modes.droid:
|
|
555
|
-
if (radius < this.DROID_CIRCLESIZE_BUFF_THRESHOLD) {
|
|
556
|
-
scalingFactor *=
|
|
557
|
-
1 +
|
|
558
|
-
Math.pow((this.DROID_CIRCLESIZE_BUFF_THRESHOLD - radius) /
|
|
559
|
-
50, 2);
|
|
560
|
-
}
|
|
561
|
-
break;
|
|
562
|
-
case osuBase.Modes.osu:
|
|
563
|
-
if (radius < this.PC_CIRCLESIZE_BUFF_THRESHOLD) {
|
|
564
|
-
scalingFactor *=
|
|
565
|
-
1 +
|
|
566
|
-
Math.min(this.PC_CIRCLESIZE_BUFF_THRESHOLD - radius, 5) /
|
|
567
|
-
50;
|
|
568
|
-
}
|
|
569
|
-
}
|
|
570
|
-
return scalingFactor;
|
|
571
|
-
}
|
|
572
|
-
/**
|
|
573
|
-
* Returns the end cursor position of a hitobject.
|
|
574
|
-
*/
|
|
575
|
-
getEndCursorPosition(object) {
|
|
576
|
-
let pos = object.getStackedPosition(this.mode);
|
|
577
|
-
if (object instanceof osuBase.Slider) {
|
|
578
|
-
this.calculateSliderCursorPosition(object);
|
|
579
|
-
pos = object.lazyEndPosition ?? pos;
|
|
580
|
-
}
|
|
581
|
-
return pos;
|
|
582
|
-
}
|
|
583
|
-
applyToOverlappingFactor(object, distance, deltaTime) {
|
|
584
|
-
// Penalize objects that are too close to the object in both distance
|
|
585
|
-
// and delta time to prevent stream maps from being overweighted.
|
|
586
|
-
object.overlappingFactor +=
|
|
587
|
-
Math.max(0, 1 - distance / (3 * object.object.getRadius(this.mode))) *
|
|
588
|
-
(7.5 /
|
|
589
|
-
(1 +
|
|
590
|
-
Math.exp(0.15 * (Math.max(deltaTime, this.minDeltaTime) - 75))));
|
|
591
|
-
}
|
|
592
|
-
}
|
|
593
|
-
|
|
594
|
-
/**
|
|
595
|
-
* The base of a difficulty calculator.
|
|
596
|
-
*/
|
|
597
|
-
class DifficultyCalculator {
|
|
598
|
-
/**
|
|
599
|
-
* The calculated beatmap.
|
|
600
|
-
*/
|
|
601
|
-
beatmap;
|
|
602
|
-
/**
|
|
603
|
-
* The difficulty objects of the beatmap.
|
|
604
|
-
*/
|
|
605
|
-
objects = [];
|
|
606
|
-
/**
|
|
607
|
-
* The modifications applied.
|
|
608
|
-
*/
|
|
609
|
-
mods = [];
|
|
610
|
-
/**
|
|
611
|
-
* The total star rating of the beatmap.
|
|
612
|
-
*/
|
|
613
|
-
total = 0;
|
|
614
|
-
/**
|
|
615
|
-
* The map statistics of the beatmap after modifications are applied.
|
|
616
|
-
*/
|
|
617
|
-
stats = new osuBase.MapStats();
|
|
618
|
-
/**
|
|
619
|
-
* The strain peaks of various calculated difficulties.
|
|
620
|
-
*/
|
|
621
|
-
strainPeaks = {
|
|
622
|
-
aimWithSliders: [],
|
|
623
|
-
aimWithoutSliders: [],
|
|
624
|
-
speed: [],
|
|
625
|
-
flashlight: [],
|
|
626
|
-
};
|
|
627
|
-
/**
|
|
628
|
-
* Constructs a new instance of the calculator.
|
|
629
|
-
*
|
|
630
|
-
* @param beatmap The beatmap to calculate. This beatmap will be deep-cloned to prevent reference changes.
|
|
631
|
-
*/
|
|
632
|
-
constructor(beatmap) {
|
|
633
|
-
this.beatmap = osuBase.Utils.deepCopy(beatmap);
|
|
634
|
-
}
|
|
635
|
-
/**
|
|
636
|
-
* Calculates the star rating of the specified beatmap.
|
|
637
|
-
*
|
|
638
|
-
* The beatmap is analyzed in chunks of `sectionLength` duration.
|
|
639
|
-
* For each chunk the highest hitobject strains are added to
|
|
640
|
-
* a list which is then collapsed into a weighted sum, much
|
|
641
|
-
* like scores are weighted on a user's profile.
|
|
642
|
-
*
|
|
643
|
-
* For subsequent chunks, the initial max strain is calculated
|
|
644
|
-
* by decaying the previous hitobject's strain until the
|
|
645
|
-
* beginning of the new chunk.
|
|
646
|
-
*
|
|
647
|
-
* @param options Options for the difficulty calculation.
|
|
648
|
-
* @returns The current instance.
|
|
649
|
-
*/
|
|
650
|
-
calculate(options) {
|
|
651
|
-
this.mods = options?.mods ?? [];
|
|
652
|
-
this.stats = new osuBase.MapStats({
|
|
653
|
-
cs: this.beatmap.difficulty.cs,
|
|
654
|
-
ar: this.beatmap.difficulty.ar,
|
|
655
|
-
od: this.beatmap.difficulty.od,
|
|
656
|
-
hp: this.beatmap.difficulty.hp,
|
|
657
|
-
mods: options?.mods,
|
|
658
|
-
speedMultiplier: options?.stats?.speedMultiplier ?? 1,
|
|
659
|
-
oldStatistics: options?.stats?.oldStatistics ?? false,
|
|
660
|
-
}).calculate({ mode: this.mode });
|
|
661
|
-
this.preProcess();
|
|
662
|
-
this.populateDifficultyAttributes();
|
|
663
|
-
this.generateDifficultyHitObjects();
|
|
664
|
-
this.calculateAll();
|
|
665
|
-
return this;
|
|
666
|
-
}
|
|
667
|
-
/**
|
|
668
|
-
* Generates difficulty hitobjects for this calculator.
|
|
669
|
-
*/
|
|
670
|
-
generateDifficultyHitObjects() {
|
|
671
|
-
this.objects.length = 0;
|
|
672
|
-
this.objects.push(...new DifficultyHitObjectCreator().generateDifficultyObjects({
|
|
673
|
-
objects: this.beatmap.hitObjects.objects,
|
|
674
|
-
circleSize: this.beatmap.difficulty.cs,
|
|
675
|
-
mods: this.mods,
|
|
676
|
-
speedMultiplier: this.stats.speedMultiplier,
|
|
677
|
-
mode: this.mode,
|
|
678
|
-
preempt: osuBase.MapStats.arToMS(this.stats.ar),
|
|
679
|
-
}));
|
|
680
|
-
}
|
|
681
|
-
/**
|
|
682
|
-
* Performs some pre-processing before proceeding with difficulty calculation.
|
|
683
|
-
*/
|
|
684
|
-
preProcess() {
|
|
685
|
-
}
|
|
686
|
-
/**
|
|
687
|
-
* Calculates the skills provided.
|
|
688
|
-
*
|
|
689
|
-
* @param skills The skills to calculate.
|
|
690
|
-
*/
|
|
691
|
-
calculateSkills(...skills) {
|
|
692
|
-
// The first object doesn't generate a strain, so we begin calculating from the second object.
|
|
693
|
-
for (const object of this.objects.slice(1)) {
|
|
694
|
-
for (const skill of skills) {
|
|
695
|
-
skill.process(object);
|
|
431
|
+
// The amount of movement required so that the cursor position needs to be updated.
|
|
432
|
+
let requiredMovement = this.assumedSliderRadius;
|
|
433
|
+
if (i === slider.nestedHitObjects.length - 1) {
|
|
434
|
+
// The end of a slider has special aim rules due to the relaxed time constraint on position.
|
|
435
|
+
// There is both a lazy end position as well as the actual end slider position. We assume the player takes the simpler movement.
|
|
436
|
+
// For sliders that are circular, the lazy end position may actually be farther away than the sliders' true end.
|
|
437
|
+
// This code is designed to prevent buffing situations where lazy end is actually a less efficient movement.
|
|
438
|
+
const lazyMovement = slider.lazyEndPosition.subtract(currentCursorPosition);
|
|
439
|
+
if (lazyMovement.length < currentMovement.length) {
|
|
440
|
+
currentMovement = lazyMovement;
|
|
441
|
+
}
|
|
442
|
+
currentMovementLength = scalingFactor * currentMovement.length;
|
|
443
|
+
}
|
|
444
|
+
else if (currentMovementObject instanceof osuBase.SliderRepeat) {
|
|
445
|
+
// For a slider repeat, assume a tighter movement threshold to better assess repeat sliders.
|
|
446
|
+
requiredMovement = this.normalizedRadius;
|
|
447
|
+
}
|
|
448
|
+
if (currentMovementLength > requiredMovement) {
|
|
449
|
+
// This finds the positional delta from the required radius and the current position,
|
|
450
|
+
// and updates the currentCursorPosition accordingly, as well as rewarding distance.
|
|
451
|
+
currentCursorPosition = currentCursorPosition.add(currentMovement.scale((currentMovementLength - requiredMovement) /
|
|
452
|
+
currentMovementLength));
|
|
453
|
+
currentMovementLength *=
|
|
454
|
+
(currentMovementLength - requiredMovement) /
|
|
455
|
+
currentMovementLength;
|
|
456
|
+
slider.lazyTravelDistance += currentMovementLength;
|
|
457
|
+
}
|
|
458
|
+
if (i === slider.nestedHitObjects.length - 1) {
|
|
459
|
+
slider.lazyEndPosition = currentCursorPosition;
|
|
696
460
|
}
|
|
697
461
|
}
|
|
698
462
|
}
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
this.attributes.mods = this.mods.slice();
|
|
707
|
-
this.attributes.overallDifficulty = this.stats.od;
|
|
708
|
-
this.attributes.sliderCount = this.beatmap.hitObjects.sliders;
|
|
709
|
-
this.attributes.spinnerCount = this.beatmap.hitObjects.spinners;
|
|
710
|
-
this.attributes.clockRate = this.stats.speedMultiplier;
|
|
711
|
-
}
|
|
712
|
-
/**
|
|
713
|
-
* Calculates the star rating value of a difficulty.
|
|
714
|
-
*
|
|
715
|
-
* @param difficulty The difficulty to calculate.
|
|
716
|
-
*/
|
|
717
|
-
starValue(difficulty) {
|
|
718
|
-
return Math.sqrt(difficulty) * this.difficultyMultiplier;
|
|
719
|
-
}
|
|
720
|
-
/**
|
|
721
|
-
* Calculates the base performance value of a difficulty rating.
|
|
722
|
-
*
|
|
723
|
-
* @param rating The difficulty rating.
|
|
724
|
-
*/
|
|
725
|
-
basePerformanceValue(rating) {
|
|
726
|
-
return Math.pow(5 * Math.max(1, rating / 0.0675) - 4, 3) / 100000;
|
|
463
|
+
getEndCursorPosition(object) {
|
|
464
|
+
let pos = object.getStackedPosition(this.mode);
|
|
465
|
+
if (object instanceof osuBase.Slider) {
|
|
466
|
+
this.calculateSliderCursorPosition(object);
|
|
467
|
+
pos = object.lazyEndPosition ?? pos;
|
|
468
|
+
}
|
|
469
|
+
return pos;
|
|
727
470
|
}
|
|
728
471
|
}
|
|
729
472
|
|
|
@@ -1163,7 +906,7 @@ class DroidFlashlightEvaluator extends FlashlightEvaluator {
|
|
|
1163
906
|
current.isOverlapping(true)) {
|
|
1164
907
|
return 0;
|
|
1165
908
|
}
|
|
1166
|
-
const scalingFactor = 52 / current.object.
|
|
909
|
+
const scalingFactor = 52 / current.object.radius;
|
|
1167
910
|
let smallDistNerf = 1;
|
|
1168
911
|
let cumulativeStrainTime = 0;
|
|
1169
912
|
let result = 0;
|
|
@@ -1219,8 +962,8 @@ class DroidFlashlightEvaluator extends FlashlightEvaluator {
|
|
|
1219
962
|
// Longer sliders require more memorization.
|
|
1220
963
|
sliderBonus *= pixelTravelDistance;
|
|
1221
964
|
// Nerf sliders with repeats, as less memorization is required.
|
|
1222
|
-
if (current.object.
|
|
1223
|
-
sliderBonus /= current.object.
|
|
965
|
+
if (current.object.repeatCount > 0)
|
|
966
|
+
sliderBonus /= current.object.repeatCount + 1;
|
|
1224
967
|
}
|
|
1225
968
|
result += sliderBonus * this.sliderMultiplier;
|
|
1226
969
|
return result;
|
|
@@ -1485,7 +1228,7 @@ class DroidVisualEvaluator {
|
|
|
1485
1228
|
}
|
|
1486
1229
|
// Do not consider objects that don't fall under time preempt.
|
|
1487
1230
|
if (current.object.startTime - previous.object.endTime >
|
|
1488
|
-
current.
|
|
1231
|
+
current.object.timePreempt) {
|
|
1489
1232
|
break;
|
|
1490
1233
|
}
|
|
1491
1234
|
strain +=
|
|
@@ -1500,7 +1243,7 @@ class DroidVisualEvaluator {
|
|
|
1500
1243
|
strain += Math.pow(400 - current.timePreempt, 1.3) / 100;
|
|
1501
1244
|
}
|
|
1502
1245
|
if (current.object instanceof osuBase.Slider && withSliders) {
|
|
1503
|
-
const scalingFactor = 50 / current.object.
|
|
1246
|
+
const scalingFactor = 50 / current.object.radius;
|
|
1504
1247
|
// Invert the scaling factor to determine the true travel distance independent of circle size.
|
|
1505
1248
|
const pixelTravelDistance = current.object.lazyTravelDistance / scalingFactor;
|
|
1506
1249
|
const currentVelocity = pixelTravelDistance / current.travelTime;
|
|
@@ -1577,6 +1320,180 @@ class DroidVisual extends DroidSkill {
|
|
|
1577
1320
|
}
|
|
1578
1321
|
}
|
|
1579
1322
|
|
|
1323
|
+
/**
|
|
1324
|
+
* Represents an osu!droid hit object with difficulty calculation values.
|
|
1325
|
+
*/
|
|
1326
|
+
class DroidDifficultyHitObject extends DifficultyHitObject {
|
|
1327
|
+
/**
|
|
1328
|
+
* The tap strain generated by the hitobject.
|
|
1329
|
+
*/
|
|
1330
|
+
tapStrain = 0;
|
|
1331
|
+
/**
|
|
1332
|
+
* The tap strain generated by the hitobject if `strainTime` isn't modified by
|
|
1333
|
+
* OD. This is used in three-finger detection.
|
|
1334
|
+
*/
|
|
1335
|
+
originalTapStrain = 0;
|
|
1336
|
+
/**
|
|
1337
|
+
* The rhythm strain generated by the hitobject.
|
|
1338
|
+
*/
|
|
1339
|
+
rhythmStrain = 0;
|
|
1340
|
+
/**
|
|
1341
|
+
* The flashlight strain generated by the hitobject if sliders are considered.
|
|
1342
|
+
*/
|
|
1343
|
+
flashlightStrainWithSliders = 0;
|
|
1344
|
+
/**
|
|
1345
|
+
* The flashlight strain generated by the hitobject if sliders are not considered.
|
|
1346
|
+
*/
|
|
1347
|
+
flashlightStrainWithoutSliders = 0;
|
|
1348
|
+
/**
|
|
1349
|
+
* The visual strain generated by the hitobject if sliders are considered.
|
|
1350
|
+
*/
|
|
1351
|
+
visualStrainWithSliders = 0;
|
|
1352
|
+
/**
|
|
1353
|
+
* The visual strain generated by the hitobject if sliders are not considered.
|
|
1354
|
+
*/
|
|
1355
|
+
visualStrainWithoutSliders = 0;
|
|
1356
|
+
/**
|
|
1357
|
+
* The note density of the hitobject.
|
|
1358
|
+
*/
|
|
1359
|
+
noteDensity = 1;
|
|
1360
|
+
/**
|
|
1361
|
+
* The overlapping factor of the hitobject.
|
|
1362
|
+
*
|
|
1363
|
+
* This is used to scale visual skill.
|
|
1364
|
+
*/
|
|
1365
|
+
overlappingFactor = 0;
|
|
1366
|
+
/**
|
|
1367
|
+
* Adjusted preempt time of the hitobject, taking speed multiplier into account.
|
|
1368
|
+
*/
|
|
1369
|
+
timePreempt;
|
|
1370
|
+
radiusBuffThreshold = 70;
|
|
1371
|
+
mode = osuBase.Modes.droid;
|
|
1372
|
+
maximumSliderRadius = this.normalizedRadius * 2;
|
|
1373
|
+
get scalingFactor() {
|
|
1374
|
+
const radius = this.object.radius;
|
|
1375
|
+
// We will scale distances by this factor, so we can assume a uniform CircleSize among beatmaps.
|
|
1376
|
+
let scalingFactor = this.normalizedRadius / radius;
|
|
1377
|
+
// High circle size (small CS) bonus
|
|
1378
|
+
if (radius < this.radiusBuffThreshold) {
|
|
1379
|
+
scalingFactor *=
|
|
1380
|
+
1 + Math.pow((this.radiusBuffThreshold - radius) / 50, 2);
|
|
1381
|
+
}
|
|
1382
|
+
return scalingFactor;
|
|
1383
|
+
}
|
|
1384
|
+
/**
|
|
1385
|
+
* Note: You **must** call `computeProperties` at some point due to how TypeScript handles
|
|
1386
|
+
* overridden properties (see [this](https://github.com/microsoft/TypeScript/issues/1617) GitHub issue.).
|
|
1387
|
+
*
|
|
1388
|
+
* @param object The underlying hitobject.
|
|
1389
|
+
* @param lastObject The hitobject before this hitobject.
|
|
1390
|
+
* @param lastLastObject The hitobject before the last hitobject.
|
|
1391
|
+
* @param difficultyHitObjects All difficulty hitobjects in the processed beatmap.
|
|
1392
|
+
* @param clockRate The clock rate of the beatmap.
|
|
1393
|
+
* @param isForceAR Whether force AR is enabled.
|
|
1394
|
+
*/
|
|
1395
|
+
constructor(object, lastObject, lastLastObject, difficultyHitObjects, clockRate, isForceAR) {
|
|
1396
|
+
super(object, lastObject, lastLastObject, difficultyHitObjects, clockRate);
|
|
1397
|
+
this.timePreempt = object.timePreempt;
|
|
1398
|
+
if (!isForceAR) {
|
|
1399
|
+
this.timePreempt /= clockRate;
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
computeProperties(clockRate, hitObjects) {
|
|
1403
|
+
super.computeProperties(clockRate, hitObjects);
|
|
1404
|
+
this.setVisuals(clockRate, hitObjects);
|
|
1405
|
+
}
|
|
1406
|
+
/**
|
|
1407
|
+
* Determines whether this hitobject is considered overlapping with the hitobject before it.
|
|
1408
|
+
*
|
|
1409
|
+
* Keep in mind that "overlapping" in this case is overlapping to the point where both hitobjects
|
|
1410
|
+
* can be hit with just a single tap in osu!droid.
|
|
1411
|
+
*
|
|
1412
|
+
* @param considerDistance Whether to consider the distance between both hitobjects.
|
|
1413
|
+
* @returns Whether the hitobject is considered overlapping.
|
|
1414
|
+
*/
|
|
1415
|
+
isOverlapping(considerDistance) {
|
|
1416
|
+
if (this.object instanceof osuBase.Spinner) {
|
|
1417
|
+
return false;
|
|
1418
|
+
}
|
|
1419
|
+
const previous = this.previous(0);
|
|
1420
|
+
if (!previous || previous.object instanceof osuBase.Spinner) {
|
|
1421
|
+
return false;
|
|
1422
|
+
}
|
|
1423
|
+
if (this.deltaTime >= 5) {
|
|
1424
|
+
return false;
|
|
1425
|
+
}
|
|
1426
|
+
if (considerDistance) {
|
|
1427
|
+
const endPosition = this.object.getStackedPosition(osuBase.Modes.droid);
|
|
1428
|
+
let distance = previous.object
|
|
1429
|
+
.getStackedEndPosition(osuBase.Modes.droid)
|
|
1430
|
+
.getDistance(endPosition);
|
|
1431
|
+
if (previous.object instanceof osuBase.Slider &&
|
|
1432
|
+
previous.object.lazyEndPosition) {
|
|
1433
|
+
distance = Math.min(distance, previous.object.lazyEndPosition.getDistance(endPosition));
|
|
1434
|
+
}
|
|
1435
|
+
return distance <= 2 * this.object.radius;
|
|
1436
|
+
}
|
|
1437
|
+
return true;
|
|
1438
|
+
}
|
|
1439
|
+
setVisuals(clockRate, hitObjects) {
|
|
1440
|
+
// We'll have two visible object arrays. The first array contains objects before the current object starts in a reversed order,
|
|
1441
|
+
// while the second array contains objects after the current object ends.
|
|
1442
|
+
// For overlapping factor, we also need to consider previous visible objects.
|
|
1443
|
+
const prevVisibleObjects = [];
|
|
1444
|
+
const nextVisibleObjects = [];
|
|
1445
|
+
for (let j = this.index + 2; j < hitObjects.length; ++j) {
|
|
1446
|
+
const o = hitObjects[j];
|
|
1447
|
+
if (o instanceof osuBase.Spinner) {
|
|
1448
|
+
continue;
|
|
1449
|
+
}
|
|
1450
|
+
if (o.startTime / clockRate > this.endTime + this.timePreempt) {
|
|
1451
|
+
break;
|
|
1452
|
+
}
|
|
1453
|
+
nextVisibleObjects.push(o);
|
|
1454
|
+
}
|
|
1455
|
+
for (let j = 0; j < this.index; ++j) {
|
|
1456
|
+
const prev = this.previous(j);
|
|
1457
|
+
if (prev.object instanceof osuBase.Spinner) {
|
|
1458
|
+
continue;
|
|
1459
|
+
}
|
|
1460
|
+
if (prev.startTime >= this.startTime) {
|
|
1461
|
+
continue;
|
|
1462
|
+
}
|
|
1463
|
+
if (prev.startTime < this.startTime - this.timePreempt) {
|
|
1464
|
+
break;
|
|
1465
|
+
}
|
|
1466
|
+
prevVisibleObjects.push(prev.object);
|
|
1467
|
+
}
|
|
1468
|
+
for (const hitObject of prevVisibleObjects) {
|
|
1469
|
+
const distance = this.object
|
|
1470
|
+
.getStackedPosition(this.mode)
|
|
1471
|
+
.getDistance(hitObject.getStackedEndPosition(this.mode));
|
|
1472
|
+
const deltaTime = this.startTime - hitObject.endTime / clockRate;
|
|
1473
|
+
this.applyToOverlappingFactor(distance, deltaTime);
|
|
1474
|
+
}
|
|
1475
|
+
for (const hitObject of nextVisibleObjects) {
|
|
1476
|
+
const distance = hitObject
|
|
1477
|
+
.getStackedPosition(this.mode)
|
|
1478
|
+
.getDistance(this.object.getStackedEndPosition(this.mode));
|
|
1479
|
+
const deltaTime = hitObject.startTime / clockRate - this.endTime;
|
|
1480
|
+
if (deltaTime >= 0) {
|
|
1481
|
+
this.noteDensity += 1 - deltaTime / this.timePreempt;
|
|
1482
|
+
}
|
|
1483
|
+
this.applyToOverlappingFactor(distance, deltaTime);
|
|
1484
|
+
}
|
|
1485
|
+
}
|
|
1486
|
+
applyToOverlappingFactor(distance, deltaTime) {
|
|
1487
|
+
// Penalize objects that are too close to the object in both distance
|
|
1488
|
+
// and delta time to prevent stream maps from being overweighted.
|
|
1489
|
+
this.overlappingFactor +=
|
|
1490
|
+
Math.max(0, 1 - distance / (3 * this.object.radius)) *
|
|
1491
|
+
(7.5 /
|
|
1492
|
+
(1 +
|
|
1493
|
+
Math.exp(0.15 * (Math.max(deltaTime, this.minDeltaTime) - 75))));
|
|
1494
|
+
}
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1580
1497
|
/**
|
|
1581
1498
|
* A difficulty calculator for osu!droid gamemode.
|
|
1582
1499
|
*/
|
|
@@ -1584,23 +1501,33 @@ class DroidDifficultyCalculator extends DifficultyCalculator {
|
|
|
1584
1501
|
/**
|
|
1585
1502
|
* The aim star rating of the beatmap.
|
|
1586
1503
|
*/
|
|
1587
|
-
aim
|
|
1504
|
+
get aim() {
|
|
1505
|
+
return this.attributes.aimDifficulty;
|
|
1506
|
+
}
|
|
1588
1507
|
/**
|
|
1589
1508
|
* The tap star rating of the beatmap.
|
|
1590
1509
|
*/
|
|
1591
|
-
tap
|
|
1510
|
+
get tap() {
|
|
1511
|
+
return this.attributes.tapDifficulty;
|
|
1512
|
+
}
|
|
1592
1513
|
/**
|
|
1593
1514
|
* The rhythm star rating of the beatmap.
|
|
1594
1515
|
*/
|
|
1595
|
-
rhythm
|
|
1516
|
+
get rhythm() {
|
|
1517
|
+
return this.attributes.rhythmDifficulty;
|
|
1518
|
+
}
|
|
1596
1519
|
/**
|
|
1597
1520
|
* The flashlight star rating of the beatmap.
|
|
1598
1521
|
*/
|
|
1599
|
-
flashlight
|
|
1522
|
+
get flashlight() {
|
|
1523
|
+
return this.attributes.flashlightDifficulty;
|
|
1524
|
+
}
|
|
1600
1525
|
/**
|
|
1601
1526
|
* The visual star rating of the beatmap.
|
|
1602
1527
|
*/
|
|
1603
|
-
visual
|
|
1528
|
+
get visual() {
|
|
1529
|
+
return this.attributes.visualDifficulty;
|
|
1530
|
+
}
|
|
1604
1531
|
/**
|
|
1605
1532
|
* The strain threshold to start detecting for possible three-fingered section.
|
|
1606
1533
|
*
|
|
@@ -1608,6 +1535,7 @@ class DroidDifficultyCalculator extends DifficultyCalculator {
|
|
|
1608
1535
|
*/
|
|
1609
1536
|
static threeFingerStrainThreshold = 175;
|
|
1610
1537
|
attributes = {
|
|
1538
|
+
mode: "live",
|
|
1611
1539
|
tapDifficulty: 0,
|
|
1612
1540
|
rhythmDifficulty: 0,
|
|
1613
1541
|
visualDifficulty: 0,
|
|
@@ -1662,6 +1590,9 @@ class DroidDifficultyCalculator extends DifficultyCalculator {
|
|
|
1662
1590
|
}
|
|
1663
1591
|
difficultyMultiplier = 0.18;
|
|
1664
1592
|
mode = osuBase.Modes.droid;
|
|
1593
|
+
calculate(options) {
|
|
1594
|
+
return super.calculate(options);
|
|
1595
|
+
}
|
|
1665
1596
|
/**
|
|
1666
1597
|
* Calculates the aim star rating of the beatmap and stores it in this instance.
|
|
1667
1598
|
*/
|
|
@@ -1675,7 +1606,7 @@ class DroidDifficultyCalculator extends DifficultyCalculator {
|
|
|
1675
1606
|
* Calculates the tap star rating of the beatmap and stores it in this instance.
|
|
1676
1607
|
*/
|
|
1677
1608
|
calculateTap() {
|
|
1678
|
-
const od = this.
|
|
1609
|
+
const od = this.difficultyStatistics.overallDifficulty;
|
|
1679
1610
|
const tapSkillCheese = new DroidTap(this.mods, od, true);
|
|
1680
1611
|
const tapSkillNoCheese = new DroidTap(this.mods, od, false);
|
|
1681
1612
|
this.calculateSkills(tapSkillCheese, tapSkillNoCheese);
|
|
@@ -1685,7 +1616,7 @@ class DroidDifficultyCalculator extends DifficultyCalculator {
|
|
|
1685
1616
|
* Calculates the rhythm star rating of the beatmap and stores it in this instance.
|
|
1686
1617
|
*/
|
|
1687
1618
|
calculateRhythm() {
|
|
1688
|
-
const rhythmSkill = new DroidRhythm(this.mods, this.
|
|
1619
|
+
const rhythmSkill = new DroidRhythm(this.mods, this.difficultyStatistics.overallDifficulty);
|
|
1689
1620
|
this.calculateSkills(rhythmSkill);
|
|
1690
1621
|
this.postCalculateRhythm(rhythmSkill);
|
|
1691
1622
|
}
|
|
@@ -1703,7 +1634,7 @@ class DroidDifficultyCalculator extends DifficultyCalculator {
|
|
|
1703
1634
|
*/
|
|
1704
1635
|
calculateVisual() {
|
|
1705
1636
|
if (this.mods.some((m) => m instanceof osuBase.ModRelax)) {
|
|
1706
|
-
this.
|
|
1637
|
+
this.attributes.visualDifficulty = 0;
|
|
1707
1638
|
return;
|
|
1708
1639
|
}
|
|
1709
1640
|
const visualSkill = new DroidVisual(this.mods, true);
|
|
@@ -1725,13 +1656,13 @@ class DroidDifficultyCalculator extends DifficultyCalculator {
|
|
|
1725
1656
|
if (basePerformanceValue > 1e-5) {
|
|
1726
1657
|
// Document for formula derivation:
|
|
1727
1658
|
// https://docs.google.com/document/d/10DZGYYSsT_yjz2Mtp6yIJld0Rqx4E-vVHupCqiM4TNI/edit
|
|
1728
|
-
this.
|
|
1659
|
+
this.attributes.starRating =
|
|
1729
1660
|
0.027 *
|
|
1730
1661
|
(Math.cbrt((100000 / Math.pow(2, 1 / 1.1)) * basePerformanceValue) +
|
|
1731
1662
|
4);
|
|
1732
1663
|
}
|
|
1733
1664
|
else {
|
|
1734
|
-
this.
|
|
1665
|
+
this.attributes.starRating = 0;
|
|
1735
1666
|
}
|
|
1736
1667
|
}
|
|
1737
1668
|
calculateAll() {
|
|
@@ -1766,15 +1697,31 @@ class DroidDifficultyCalculator extends DifficultyCalculator {
|
|
|
1766
1697
|
this.visual.toFixed(2) +
|
|
1767
1698
|
" visual)");
|
|
1768
1699
|
}
|
|
1769
|
-
|
|
1770
|
-
const
|
|
1771
|
-
|
|
1772
|
-
|
|
1700
|
+
generateDifficultyHitObjects(beatmap) {
|
|
1701
|
+
const difficultyObjects = [];
|
|
1702
|
+
const { objects } = beatmap.hitObjects;
|
|
1703
|
+
const difficultyAdjustMod = this.mods.find((m) => m instanceof osuBase.ModDifficultyAdjust);
|
|
1704
|
+
for (let i = 0; i < objects.length; ++i) {
|
|
1705
|
+
const difficultyObject = new DroidDifficultyHitObject(objects[i], objects[i - 1] ?? null, objects[i - 2] ?? null, difficultyObjects, this.difficultyStatistics.overallSpeedMultiplier, difficultyAdjustMod?.ar !== undefined);
|
|
1706
|
+
difficultyObject.computeProperties(this.difficultyStatistics.overallSpeedMultiplier, objects);
|
|
1707
|
+
difficultyObjects.push(difficultyObject);
|
|
1773
1708
|
}
|
|
1774
|
-
|
|
1709
|
+
return difficultyObjects;
|
|
1710
|
+
}
|
|
1711
|
+
computeDifficultyStatistics(options) {
|
|
1712
|
+
const { difficulty } = this.beatmap;
|
|
1713
|
+
return osuBase.calculateDroidDifficultyStatistics({
|
|
1714
|
+
circleSize: difficulty.cs,
|
|
1715
|
+
approachRate: difficulty.ar ?? difficulty.od,
|
|
1716
|
+
overallDifficulty: difficulty.od,
|
|
1717
|
+
healthDrain: difficulty.hp,
|
|
1718
|
+
mods: this.mods,
|
|
1719
|
+
customSpeedMultiplier: options?.customSpeedMultiplier,
|
|
1720
|
+
oldStatistics: options?.oldStatistics,
|
|
1721
|
+
});
|
|
1775
1722
|
}
|
|
1776
1723
|
createSkills() {
|
|
1777
|
-
const od = this.
|
|
1724
|
+
const od = this.difficultyStatistics.overallDifficulty;
|
|
1778
1725
|
return [
|
|
1779
1726
|
new DroidAim(this.mods, true),
|
|
1780
1727
|
new DroidAim(this.mods, false),
|
|
@@ -1797,16 +1744,15 @@ class DroidDifficultyCalculator extends DifficultyCalculator {
|
|
|
1797
1744
|
postCalculateAim(aimSkill, aimSkillWithoutSliders) {
|
|
1798
1745
|
this.strainPeaks.aimWithSliders = aimSkill.strainPeaks;
|
|
1799
1746
|
this.strainPeaks.aimWithoutSliders = aimSkillWithoutSliders.strainPeaks;
|
|
1800
|
-
this.
|
|
1747
|
+
this.attributes.aimDifficulty = this.starValue(aimSkill.difficultyValue());
|
|
1801
1748
|
if (this.aim) {
|
|
1802
1749
|
this.attributes.sliderFactor =
|
|
1803
1750
|
this.starValue(aimSkillWithoutSliders.difficultyValue()) /
|
|
1804
1751
|
this.aim;
|
|
1805
1752
|
}
|
|
1806
1753
|
if (this.mods.some((m) => m instanceof osuBase.ModRelax)) {
|
|
1807
|
-
this.
|
|
1754
|
+
this.attributes.aimDifficulty *= 0.9;
|
|
1808
1755
|
}
|
|
1809
|
-
this.attributes.aimDifficulty = this.aim;
|
|
1810
1756
|
this.calculateAimAttributes();
|
|
1811
1757
|
}
|
|
1812
1758
|
/**
|
|
@@ -1858,11 +1804,11 @@ class DroidDifficultyCalculator extends DifficultyCalculator {
|
|
|
1858
1804
|
postCalculateTap(tapSkillCheese) {
|
|
1859
1805
|
this.strainPeaks.speed = tapSkillCheese.strainPeaks;
|
|
1860
1806
|
if (this.mods.some((m) => m instanceof osuBase.ModRelax)) {
|
|
1861
|
-
this.
|
|
1807
|
+
this.attributes.tapDifficulty = 0;
|
|
1862
1808
|
this.attributes.possibleThreeFingeredSections = [];
|
|
1863
1809
|
}
|
|
1864
1810
|
else {
|
|
1865
|
-
this.
|
|
1811
|
+
this.attributes.tapDifficulty = this.starValue(tapSkillCheese.difficultyValue());
|
|
1866
1812
|
}
|
|
1867
1813
|
this.calculateTapAttributes();
|
|
1868
1814
|
}
|
|
@@ -1982,7 +1928,7 @@ class DroidDifficultyCalculator extends DifficultyCalculator {
|
|
|
1982
1928
|
* @param rhythmSkill The rhythm skill.
|
|
1983
1929
|
*/
|
|
1984
1930
|
postCalculateRhythm(rhythmSkill) {
|
|
1985
|
-
this.
|
|
1931
|
+
this.attributes.rhythmDifficulty = this.mods.some((m) => m instanceof osuBase.ModRelax)
|
|
1986
1932
|
? 0
|
|
1987
1933
|
: this.starValue(rhythmSkill.difficultyValue());
|
|
1988
1934
|
}
|
|
@@ -1994,13 +1940,13 @@ class DroidDifficultyCalculator extends DifficultyCalculator {
|
|
|
1994
1940
|
*/
|
|
1995
1941
|
postCalculateFlashlight(flashlightSkill, flashlightSkillWithoutSliders) {
|
|
1996
1942
|
this.strainPeaks.flashlight = flashlightSkill.strainPeaks;
|
|
1997
|
-
this.
|
|
1943
|
+
this.attributes.flashlightDifficulty = this.starValue(flashlightSkill.difficultyValue());
|
|
1998
1944
|
if (this.flashlight) {
|
|
1999
1945
|
this.attributes.flashlightSliderFactor =
|
|
2000
1946
|
this.starValue(flashlightSkillWithoutSliders.difficultyValue()) / this.flashlight;
|
|
2001
1947
|
}
|
|
2002
1948
|
if (this.mods.some((m) => m instanceof osuBase.ModRelax)) {
|
|
2003
|
-
this.
|
|
1949
|
+
this.attributes.flashlightDifficulty *= 0.7;
|
|
2004
1950
|
}
|
|
2005
1951
|
const objectStrains = this.objects.map((v) => v.flashlightStrainWithSliders);
|
|
2006
1952
|
const maxStrain = Math.max(...objectStrains);
|
|
@@ -2008,7 +1954,6 @@ class DroidDifficultyCalculator extends DifficultyCalculator {
|
|
|
2008
1954
|
this.attributes.flashlightDifficultStrainCount =
|
|
2009
1955
|
objectStrains.reduce((total, next) => total + Math.pow(next / maxStrain, 4), 0);
|
|
2010
1956
|
}
|
|
2011
|
-
this.attributes.flashlightDifficulty = this.flashlight;
|
|
2012
1957
|
}
|
|
2013
1958
|
/**
|
|
2014
1959
|
* Called after visual skill calculation.
|
|
@@ -2017,7 +1962,7 @@ class DroidDifficultyCalculator extends DifficultyCalculator {
|
|
|
2017
1962
|
* @param visualSkillWithoutSliders The visual skill that doesn't consider sliders.
|
|
2018
1963
|
*/
|
|
2019
1964
|
postCalculateVisual(visualSkillWithSliders, visualSkillWithoutSliders) {
|
|
2020
|
-
this.
|
|
1965
|
+
this.attributes.visualDifficulty = this.mods.some((m) => m instanceof osuBase.ModRelax)
|
|
2021
1966
|
? 0
|
|
2022
1967
|
: this.starValue(visualSkillWithSliders.difficultyValue());
|
|
2023
1968
|
if (this.visual) {
|
|
@@ -3069,9 +3014,9 @@ class OsuSpeed extends OsuSkill {
|
|
|
3069
3014
|
currentRhythm = 0;
|
|
3070
3015
|
skillMultiplier = 1375;
|
|
3071
3016
|
greatWindow;
|
|
3072
|
-
constructor(mods,
|
|
3017
|
+
constructor(mods, overallDifficulty) {
|
|
3073
3018
|
super(mods);
|
|
3074
|
-
this.greatWindow =
|
|
3019
|
+
this.greatWindow = new osuBase.OsuHitWindow(overallDifficulty).hitWindowFor300();
|
|
3075
3020
|
}
|
|
3076
3021
|
/**
|
|
3077
3022
|
* @param current The hitobject to calculate.
|
|
@@ -3118,7 +3063,7 @@ class OsuFlashlightEvaluator extends FlashlightEvaluator {
|
|
|
3118
3063
|
if (current.object instanceof osuBase.Spinner) {
|
|
3119
3064
|
return 0;
|
|
3120
3065
|
}
|
|
3121
|
-
const scalingFactor = 52 / current.object.
|
|
3066
|
+
const scalingFactor = 52 / current.object.radius;
|
|
3122
3067
|
let smallDistNerf = 1;
|
|
3123
3068
|
let cumulativeStrainTime = 0;
|
|
3124
3069
|
let result = 0;
|
|
@@ -3172,8 +3117,8 @@ class OsuFlashlightEvaluator extends FlashlightEvaluator {
|
|
|
3172
3117
|
// Longer sliders require more memorization.
|
|
3173
3118
|
sliderBonus *= pixelTravelDistance;
|
|
3174
3119
|
// Nerf sliders with repeats, as less memorization is required.
|
|
3175
|
-
if (current.object.
|
|
3176
|
-
sliderBonus /= current.object.
|
|
3120
|
+
if (current.object.repeatCount > 0)
|
|
3121
|
+
sliderBonus /= current.object.repeatCount + 1;
|
|
3177
3122
|
}
|
|
3178
3123
|
result += sliderBonus * this.sliderMultiplier;
|
|
3179
3124
|
return result;
|
|
@@ -3210,6 +3155,46 @@ class OsuFlashlight extends OsuSkill {
|
|
|
3210
3155
|
}
|
|
3211
3156
|
}
|
|
3212
3157
|
|
|
3158
|
+
/**
|
|
3159
|
+
* Represents an osu!standard hit object with difficulty calculation values.
|
|
3160
|
+
*/
|
|
3161
|
+
class OsuDifficultyHitObject extends DifficultyHitObject {
|
|
3162
|
+
/**
|
|
3163
|
+
* The speed strain generated by the hitobject.
|
|
3164
|
+
*/
|
|
3165
|
+
speedStrain = 0;
|
|
3166
|
+
/**
|
|
3167
|
+
* The flashlight strain generated by this hitobject.
|
|
3168
|
+
*/
|
|
3169
|
+
flashlightStrain = 0;
|
|
3170
|
+
radiusBuffThreshold = 30;
|
|
3171
|
+
mode = osuBase.Modes.osu;
|
|
3172
|
+
get scalingFactor() {
|
|
3173
|
+
const radius = this.object.radius;
|
|
3174
|
+
// We will scale distances by this factor, so we can assume a uniform CircleSize among beatmaps.
|
|
3175
|
+
let scalingFactor = this.normalizedRadius / radius;
|
|
3176
|
+
// High circle size (small CS) bonus
|
|
3177
|
+
if (radius < this.radiusBuffThreshold) {
|
|
3178
|
+
scalingFactor *=
|
|
3179
|
+
1 + Math.min(this.radiusBuffThreshold - radius, 5) / 50;
|
|
3180
|
+
}
|
|
3181
|
+
return scalingFactor;
|
|
3182
|
+
}
|
|
3183
|
+
/**
|
|
3184
|
+
* Note: You **must** call `computeProperties` at some point due to how TypeScript handles
|
|
3185
|
+
* overridden properties (see [this](https://github.com/microsoft/TypeScript/issues/1617) GitHub issue.).
|
|
3186
|
+
*
|
|
3187
|
+
* @param object The underlying hitobject.
|
|
3188
|
+
* @param lastObject The hitobject before this hitobject.
|
|
3189
|
+
* @param lastLastObject The hitobject before the last hitobject.
|
|
3190
|
+
* @param difficultyHitObjects All difficulty hitobjects in the processed beatmap.
|
|
3191
|
+
* @param clockRate The clock rate of the beatmap.
|
|
3192
|
+
*/
|
|
3193
|
+
constructor(object, lastObject, lastLastObject, difficultyHitObjects, clockRate) {
|
|
3194
|
+
super(object, lastObject, lastLastObject, difficultyHitObjects, clockRate);
|
|
3195
|
+
}
|
|
3196
|
+
}
|
|
3197
|
+
|
|
3213
3198
|
/**
|
|
3214
3199
|
* A difficulty calculator for osu!standard gamemode.
|
|
3215
3200
|
*/
|
|
@@ -3217,15 +3202,21 @@ class OsuDifficultyCalculator extends DifficultyCalculator {
|
|
|
3217
3202
|
/**
|
|
3218
3203
|
* The aim star rating of the beatmap.
|
|
3219
3204
|
*/
|
|
3220
|
-
aim
|
|
3205
|
+
get aim() {
|
|
3206
|
+
return this.attributes.aimDifficulty;
|
|
3207
|
+
}
|
|
3221
3208
|
/**
|
|
3222
3209
|
* The speed star rating of the beatmap.
|
|
3223
3210
|
*/
|
|
3224
|
-
speed
|
|
3211
|
+
get speed() {
|
|
3212
|
+
return this.attributes.speedDifficulty;
|
|
3213
|
+
}
|
|
3225
3214
|
/**
|
|
3226
3215
|
* The flashlight star rating of the beatmap.
|
|
3227
3216
|
*/
|
|
3228
|
-
flashlight
|
|
3217
|
+
get flashlight() {
|
|
3218
|
+
return this.attributes.flashlightDifficulty;
|
|
3219
|
+
}
|
|
3229
3220
|
attributes = {
|
|
3230
3221
|
speedDifficulty: 0,
|
|
3231
3222
|
mods: [],
|
|
@@ -3264,10 +3255,10 @@ class OsuDifficultyCalculator extends DifficultyCalculator {
|
|
|
3264
3255
|
*/
|
|
3265
3256
|
calculateSpeed() {
|
|
3266
3257
|
if (this.mods.some((m) => m instanceof osuBase.ModRelax)) {
|
|
3267
|
-
this.
|
|
3258
|
+
this.attributes.speedDifficulty = 0;
|
|
3268
3259
|
return;
|
|
3269
3260
|
}
|
|
3270
|
-
const speedSkill = new OsuSpeed(this.mods,
|
|
3261
|
+
const speedSkill = new OsuSpeed(this.mods, this.difficultyStatistics.overallDifficulty);
|
|
3271
3262
|
this.calculateSkills(speedSkill);
|
|
3272
3263
|
this.postCalculateSpeed(speedSkill);
|
|
3273
3264
|
}
|
|
@@ -3292,14 +3283,14 @@ class OsuDifficultyCalculator extends DifficultyCalculator {
|
|
|
3292
3283
|
if (basePerformanceValue > 1e-5) {
|
|
3293
3284
|
// Document for formula derivation:
|
|
3294
3285
|
// https://docs.google.com/document/d/10DZGYYSsT_yjz2Mtp6yIJld0Rqx4E-vVHupCqiM4TNI/edit
|
|
3295
|
-
this.
|
|
3286
|
+
this.attributes.starRating =
|
|
3296
3287
|
Math.cbrt(1.14) *
|
|
3297
3288
|
0.027 *
|
|
3298
3289
|
(Math.cbrt((100000 / Math.pow(2, 1 / 1.1)) * basePerformanceValue) +
|
|
3299
3290
|
4);
|
|
3300
3291
|
}
|
|
3301
3292
|
else {
|
|
3302
|
-
this.
|
|
3293
|
+
this.attributes.starRating = 0;
|
|
3303
3294
|
}
|
|
3304
3295
|
}
|
|
3305
3296
|
calculateAll() {
|
|
@@ -3312,7 +3303,6 @@ class OsuDifficultyCalculator extends DifficultyCalculator {
|
|
|
3312
3303
|
const flashlightSkill = skills[3];
|
|
3313
3304
|
this.postCalculateAim(aimSkill, aimSkillWithoutSliders);
|
|
3314
3305
|
if (isRelax) {
|
|
3315
|
-
this.speed = 0;
|
|
3316
3306
|
this.attributes.speedDifficulty = 0;
|
|
3317
3307
|
}
|
|
3318
3308
|
else {
|
|
@@ -3332,22 +3322,32 @@ class OsuDifficultyCalculator extends DifficultyCalculator {
|
|
|
3332
3322
|
this.flashlight.toFixed(2) +
|
|
3333
3323
|
" flashlight)");
|
|
3334
3324
|
}
|
|
3335
|
-
|
|
3336
|
-
const
|
|
3337
|
-
|
|
3338
|
-
|
|
3325
|
+
generateDifficultyHitObjects() {
|
|
3326
|
+
const difficultyObjects = [];
|
|
3327
|
+
const { objects } = this.beatmap.hitObjects;
|
|
3328
|
+
for (let i = 0; i < objects.length; ++i) {
|
|
3329
|
+
const difficultyObject = new OsuDifficultyHitObject(objects[i], objects[i - 1] ?? null, objects[i - 2] ?? null, difficultyObjects, this.difficultyStatistics.overallSpeedMultiplier);
|
|
3330
|
+
difficultyObject.computeProperties(this.difficultyStatistics.overallSpeedMultiplier, objects);
|
|
3331
|
+
difficultyObjects.push(difficultyObject);
|
|
3339
3332
|
}
|
|
3340
|
-
|
|
3341
|
-
|
|
3342
|
-
|
|
3343
|
-
}
|
|
3344
|
-
osuBase.
|
|
3333
|
+
return difficultyObjects;
|
|
3334
|
+
}
|
|
3335
|
+
computeDifficultyStatistics(options) {
|
|
3336
|
+
const { difficulty } = this.beatmap;
|
|
3337
|
+
return osuBase.calculateOsuDifficultyStatistics({
|
|
3338
|
+
circleSize: difficulty.cs,
|
|
3339
|
+
approachRate: difficulty.ar ?? difficulty.od,
|
|
3340
|
+
overallDifficulty: difficulty.od,
|
|
3341
|
+
healthDrain: difficulty.hp,
|
|
3342
|
+
mods: options?.mods,
|
|
3343
|
+
customSpeedMultiplier: options?.customSpeedMultiplier,
|
|
3344
|
+
});
|
|
3345
3345
|
}
|
|
3346
3346
|
createSkills() {
|
|
3347
3347
|
return [
|
|
3348
3348
|
new OsuAim(this.mods, true),
|
|
3349
3349
|
new OsuAim(this.mods, false),
|
|
3350
|
-
new OsuSpeed(this.mods,
|
|
3350
|
+
new OsuSpeed(this.mods, this.difficultyStatistics.overallDifficulty),
|
|
3351
3351
|
new OsuFlashlight(this.mods),
|
|
3352
3352
|
];
|
|
3353
3353
|
}
|
|
@@ -3360,19 +3360,18 @@ class OsuDifficultyCalculator extends DifficultyCalculator {
|
|
|
3360
3360
|
postCalculateAim(aimSkill, aimSkillWithoutSliders) {
|
|
3361
3361
|
this.strainPeaks.aimWithSliders = aimSkill.strainPeaks;
|
|
3362
3362
|
this.strainPeaks.aimWithoutSliders = aimSkillWithoutSliders.strainPeaks;
|
|
3363
|
-
this.
|
|
3363
|
+
this.attributes.aimDifficulty = this.starValue(aimSkill.difficultyValue());
|
|
3364
3364
|
if (this.aim) {
|
|
3365
3365
|
this.attributes.sliderFactor =
|
|
3366
3366
|
this.starValue(aimSkillWithoutSliders.difficultyValue()) /
|
|
3367
3367
|
this.aim;
|
|
3368
3368
|
}
|
|
3369
3369
|
if (this.mods.some((m) => m instanceof osuBase.ModTouchDevice)) {
|
|
3370
|
-
this.
|
|
3370
|
+
this.attributes.aimDifficulty = Math.pow(this.aim, 0.8);
|
|
3371
3371
|
}
|
|
3372
3372
|
if (this.mods.some((m) => m instanceof osuBase.ModRelax)) {
|
|
3373
|
-
this.
|
|
3373
|
+
this.attributes.aimDifficulty *= 0.9;
|
|
3374
3374
|
}
|
|
3375
|
-
this.attributes.aimDifficulty = this.aim;
|
|
3376
3375
|
}
|
|
3377
3376
|
/**
|
|
3378
3377
|
* Called after speed skill calculation.
|
|
@@ -3381,7 +3380,7 @@ class OsuDifficultyCalculator extends DifficultyCalculator {
|
|
|
3381
3380
|
*/
|
|
3382
3381
|
postCalculateSpeed(speedSkill) {
|
|
3383
3382
|
this.strainPeaks.speed = speedSkill.strainPeaks;
|
|
3384
|
-
this.
|
|
3383
|
+
this.attributes.speedDifficulty = this.starValue(speedSkill.difficultyValue());
|
|
3385
3384
|
}
|
|
3386
3385
|
/**
|
|
3387
3386
|
* Calculates speed-related attributes.
|
|
@@ -3400,55 +3399,13 @@ class OsuDifficultyCalculator extends DifficultyCalculator {
|
|
|
3400
3399
|
*/
|
|
3401
3400
|
postCalculateFlashlight(flashlightSkill) {
|
|
3402
3401
|
this.strainPeaks.flashlight = flashlightSkill.strainPeaks;
|
|
3403
|
-
this.
|
|
3402
|
+
this.attributes.flashlightDifficulty = this.starValue(flashlightSkill.difficultyValue());
|
|
3404
3403
|
if (this.mods.some((m) => m instanceof osuBase.ModTouchDevice)) {
|
|
3405
|
-
this.
|
|
3404
|
+
this.attributes.flashlightDifficulty = Math.pow(this.flashlight, 0.8);
|
|
3406
3405
|
}
|
|
3407
3406
|
if (this.mods.some((m) => m instanceof osuBase.ModRelax)) {
|
|
3408
|
-
this.
|
|
3407
|
+
this.attributes.flashlightDifficulty *= 0.7;
|
|
3409
3408
|
}
|
|
3410
|
-
this.attributes.flashlightDifficulty = this.flashlight;
|
|
3411
|
-
}
|
|
3412
|
-
}
|
|
3413
|
-
|
|
3414
|
-
/**
|
|
3415
|
-
* A difficulty calculator that calculates for both osu!droid and osu!standard gamemode.
|
|
3416
|
-
*/
|
|
3417
|
-
class MapStars {
|
|
3418
|
-
/**
|
|
3419
|
-
* The osu!droid difficulty calculator of the beatmap.
|
|
3420
|
-
*/
|
|
3421
|
-
droid;
|
|
3422
|
-
/**
|
|
3423
|
-
* The osu!standard difficulty calculator of the beatmap.
|
|
3424
|
-
*/
|
|
3425
|
-
osu;
|
|
3426
|
-
/**
|
|
3427
|
-
* Constructs this instance and calculates the given beatmap's osu!droid and osu!standard difficulty.
|
|
3428
|
-
*
|
|
3429
|
-
* @param beatmap The beatmap to calculate.
|
|
3430
|
-
* @param options Options for the difficulty calculation.
|
|
3431
|
-
*/
|
|
3432
|
-
constructor(beatmap, options) {
|
|
3433
|
-
const stats = new osuBase.MapStats({
|
|
3434
|
-
speedMultiplier: options?.stats?.speedMultiplier ?? 1,
|
|
3435
|
-
isForceAR: options?.stats?.isForceAR ?? false,
|
|
3436
|
-
oldStatistics: options?.stats?.oldStatistics ?? false,
|
|
3437
|
-
});
|
|
3438
|
-
this.droid = new DroidDifficultyCalculator(beatmap).calculate({
|
|
3439
|
-
...options,
|
|
3440
|
-
stats,
|
|
3441
|
-
});
|
|
3442
|
-
this.osu = new OsuDifficultyCalculator(beatmap).calculate({
|
|
3443
|
-
...options,
|
|
3444
|
-
stats,
|
|
3445
|
-
});
|
|
3446
|
-
}
|
|
3447
|
-
/**
|
|
3448
|
-
* Returns a string representative of the class.
|
|
3449
|
-
*/
|
|
3450
|
-
toString() {
|
|
3451
|
-
return `${this.droid.toString()}\n${this.osu.toString()}`;
|
|
3452
3409
|
}
|
|
3453
3410
|
}
|
|
3454
3411
|
|
|
@@ -3675,7 +3632,6 @@ class OsuPerformanceCalculator extends PerformanceCalculator {
|
|
|
3675
3632
|
exports.AimEvaluator = AimEvaluator;
|
|
3676
3633
|
exports.DifficultyCalculator = DifficultyCalculator;
|
|
3677
3634
|
exports.DifficultyHitObject = DifficultyHitObject;
|
|
3678
|
-
exports.DifficultyHitObjectCreator = DifficultyHitObjectCreator;
|
|
3679
3635
|
exports.DroidAim = DroidAim;
|
|
3680
3636
|
exports.DroidAimEvaluator = DroidAimEvaluator;
|
|
3681
3637
|
exports.DroidDifficultyCalculator = DroidDifficultyCalculator;
|
|
@@ -3690,7 +3646,6 @@ exports.DroidTapEvaluator = DroidTapEvaluator;
|
|
|
3690
3646
|
exports.DroidVisual = DroidVisual;
|
|
3691
3647
|
exports.DroidVisualEvaluator = DroidVisualEvaluator;
|
|
3692
3648
|
exports.FlashlightEvaluator = FlashlightEvaluator;
|
|
3693
|
-
exports.MapStars = MapStars;
|
|
3694
3649
|
exports.OsuAim = OsuAim;
|
|
3695
3650
|
exports.OsuAimEvaluator = OsuAimEvaluator;
|
|
3696
3651
|
exports.OsuDifficultyCalculator = OsuDifficultyCalculator;
|