@rian8337/osu-droid-replay-analyzer 4.0.0-beta.9 → 4.0.0-beta.90
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 +1954 -1467
- package/package.json +15 -14
- package/typings/index.d.ts +633 -377
- package/dist/index.js.map +0 -1
package/dist/index.js
CHANGED
|
@@ -1,11 +1,9 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
var osuBase = require('@rian8337/osu-base');
|
|
4
|
-
var osuDifficultyCalculator = require('@rian8337/osu-difficulty-calculator');
|
|
5
|
-
var osuRebalanceDifficultyCalculator = require('@rian8337/osu-rebalance-difficulty-calculator');
|
|
6
|
-
var unzipper = require('unzipper');
|
|
7
4
|
var javaDeserialization = require('java-deserialization');
|
|
8
5
|
var node_stream = require('node:stream');
|
|
6
|
+
var unzipper = require('unzipper');
|
|
9
7
|
|
|
10
8
|
function _interopNamespaceDefault(e) {
|
|
11
9
|
var n = Object.create(null);
|
|
@@ -48,430 +46,869 @@ exports.MovementType = void 0;
|
|
|
48
46
|
})(exports.MovementType || (exports.MovementType = {}));
|
|
49
47
|
|
|
50
48
|
/**
|
|
51
|
-
*
|
|
49
|
+
* Utility to check whether relevant sliders in a beatmap are cheesed for rebalance scores..
|
|
52
50
|
*/
|
|
53
|
-
class
|
|
51
|
+
class RebalanceSliderCheeseChecker {
|
|
54
52
|
/**
|
|
55
|
-
* The
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
/**
|
|
59
|
-
* The position of the occurrence.
|
|
53
|
+
* @param beatmap The beatmap to analyze.
|
|
54
|
+
* @param data The data of the replay.
|
|
55
|
+
* @param difficultyAttributes The difficulty attributes of the beatmap.
|
|
60
56
|
*/
|
|
61
|
-
|
|
57
|
+
constructor(beatmap, data, difficultyAttributes) {
|
|
58
|
+
this.beatmap = beatmap;
|
|
59
|
+
this.data = data;
|
|
60
|
+
this.difficultyAttributes = difficultyAttributes;
|
|
61
|
+
this.hitWindow50 = difficultyAttributes.mods.has(osuBase.ModPrecise)
|
|
62
|
+
? new osuBase.PreciseDroidHitWindow(beatmap.difficulty.od).mehWindow
|
|
63
|
+
: new osuBase.DroidHitWindow(beatmap.difficulty.od).mehWindow;
|
|
64
|
+
this.isHardRock = difficultyAttributes.mods.has(osuBase.ModHardRock);
|
|
65
|
+
}
|
|
62
66
|
/**
|
|
63
|
-
*
|
|
67
|
+
* Checks if relevant sliders in the given beatmap was cheesed.
|
|
64
68
|
*/
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
69
|
+
check() {
|
|
70
|
+
if (this.difficultyAttributes.difficultSliders.length === 0 ||
|
|
71
|
+
this.difficultyAttributes.sliderFactor === 1) {
|
|
72
|
+
return {
|
|
73
|
+
aimPenalty: 1,
|
|
74
|
+
flashlightPenalty: 1,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
const cheesedDifficultyRatings = this.checkSliderCheesing();
|
|
78
|
+
return this.calculateSliderCheesePenalty(cheesedDifficultyRatings);
|
|
70
79
|
}
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/**
|
|
74
|
-
* Represents a group of cursor occurrences representing a cursor instance's
|
|
75
|
-
* movement when a player places their finger on the screen.
|
|
76
|
-
*/
|
|
77
|
-
class CursorOccurrenceGroup {
|
|
78
80
|
/**
|
|
79
|
-
*
|
|
81
|
+
* Checks for sliders that were cheesed.
|
|
80
82
|
*/
|
|
81
|
-
|
|
82
|
-
|
|
83
|
+
checkSliderCheesing() {
|
|
84
|
+
const { objects } = this.beatmap.hitObjects;
|
|
85
|
+
const cheesedDifficultyRatings = [];
|
|
86
|
+
// Current loop indices are stored for efficiency.
|
|
87
|
+
const cursorLoopIndices = osuBase.Utils.initializeArray(this.data.cursorMovement.length, 0);
|
|
88
|
+
const acceptableRadius = objects[0].radius * 2;
|
|
89
|
+
// Sort difficult sliders by index so that cursor loop indices work properly.
|
|
90
|
+
for (const difficultSlider of this.difficultyAttributes.difficultSliders
|
|
91
|
+
.slice()
|
|
92
|
+
.sort((a, b) => a.index - b.index)) {
|
|
93
|
+
if (difficultSlider.index >= this.data.hitObjectData.length) {
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
const object = objects[difficultSlider.index];
|
|
97
|
+
const objectData = this.data.hitObjectData[difficultSlider.index];
|
|
98
|
+
// If a miss or slider break occurs, we disregard the check for that slider.
|
|
99
|
+
if (objectData.result === osuBase.HitResult.miss ||
|
|
100
|
+
-this.hitWindow50 > objectData.accuracy ||
|
|
101
|
+
objectData.accuracy >
|
|
102
|
+
Math.min(this.hitWindow50, object.duration)) {
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
const objectStartPosition = object.stackedPosition;
|
|
106
|
+
// These time boundaries should consider the delta time between the previous and next
|
|
107
|
+
// object as well as their hit accuracy. However, they are somewhat complicated to
|
|
108
|
+
// compute and the accuracy gain is small. As such, let's settle with 50 hit window.
|
|
109
|
+
const minTimeLimit = object.startTime - this.hitWindow50;
|
|
110
|
+
const maxTimeLimit = object.startTime + this.hitWindow50;
|
|
111
|
+
// Get the closest tap distance across all cursors.
|
|
112
|
+
const closestDistances = [];
|
|
113
|
+
const closestGroupIndices = [];
|
|
114
|
+
for (let i = 0; i < this.data.cursorMovement.length; ++i) {
|
|
115
|
+
const cursorGroups = this.data.cursorMovement[i].occurrenceGroups;
|
|
116
|
+
let closestDistance = Number.POSITIVE_INFINITY;
|
|
117
|
+
let closestIndex = cursorGroups.length;
|
|
118
|
+
for (let j = cursorLoopIndices[i]; j < cursorGroups.length; j = ++cursorLoopIndices[i]) {
|
|
119
|
+
const group = cursorGroups[j];
|
|
120
|
+
if (group.endTime < minTimeLimit) {
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
if (group.startTime > maxTimeLimit) {
|
|
124
|
+
break;
|
|
125
|
+
}
|
|
126
|
+
if (group.startTime >= minTimeLimit) {
|
|
127
|
+
const position = this.getCursorPosition(group.down);
|
|
128
|
+
const distance = position.getDistance(objectStartPosition);
|
|
129
|
+
if (closestDistance > distance) {
|
|
130
|
+
closestDistance = distance;
|
|
131
|
+
closestIndex = j;
|
|
132
|
+
}
|
|
133
|
+
if (closestDistance <= acceptableRadius / 2) {
|
|
134
|
+
break;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
// Normally, we check if there are cursor presses within the group's active time.
|
|
138
|
+
// However, some funky workarounds are used throughout the game for replays, so
|
|
139
|
+
// for the time being we only check for cursor distances across the group.
|
|
140
|
+
const { allOccurrences } = group;
|
|
141
|
+
for (let k = 1; k < allOccurrences.length; ++k) {
|
|
142
|
+
const cursor = allOccurrences[k];
|
|
143
|
+
const prevCursor = allOccurrences[k - 1];
|
|
144
|
+
let distance = Number.POSITIVE_INFINITY;
|
|
145
|
+
const currentPosition = this.getCursorPosition(cursor);
|
|
146
|
+
const prevPosition = this.getCursorPosition(prevCursor);
|
|
147
|
+
switch (cursor.id) {
|
|
148
|
+
case exports.MovementType.up:
|
|
149
|
+
distance =
|
|
150
|
+
prevPosition.getDistance(objectStartPosition);
|
|
151
|
+
break;
|
|
152
|
+
case exports.MovementType.move:
|
|
153
|
+
for (let mSecPassed = Math.max(prevCursor.time, minTimeLimit); mSecPassed <=
|
|
154
|
+
Math.min(cursor.time, maxTimeLimit); ++mSecPassed) {
|
|
155
|
+
const t = (mSecPassed - prevCursor.time) /
|
|
156
|
+
(cursor.time - prevCursor.time);
|
|
157
|
+
const cursorPosition = osuBase.Interpolation.lerp(prevPosition, currentPosition, t);
|
|
158
|
+
distance =
|
|
159
|
+
cursorPosition.getDistance(objectStartPosition);
|
|
160
|
+
if (closestDistance > distance) {
|
|
161
|
+
closestDistance = distance;
|
|
162
|
+
closestIndex = j;
|
|
163
|
+
}
|
|
164
|
+
if (closestDistance <=
|
|
165
|
+
acceptableRadius / 2) {
|
|
166
|
+
break;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
if (closestDistance > distance) {
|
|
171
|
+
closestDistance = distance;
|
|
172
|
+
closestIndex = j;
|
|
173
|
+
}
|
|
174
|
+
if (closestDistance <= acceptableRadius / 2) {
|
|
175
|
+
break;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
closestDistances.push(closestDistance);
|
|
180
|
+
closestGroupIndices.push(closestIndex);
|
|
181
|
+
if (cursorLoopIndices[i] > 0) {
|
|
182
|
+
// Decrement the index. The previous group may also have a role on the next slider.
|
|
183
|
+
--cursorLoopIndices[i];
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
const cursorIndex = closestDistances.indexOf(Math.min(...closestDistances));
|
|
187
|
+
const closestDistance = closestDistances[cursorIndex];
|
|
188
|
+
if (closestDistance > acceptableRadius / 2) {
|
|
189
|
+
cheesedDifficultyRatings.push(difficultSlider.difficultyRating);
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
const group = this.data.cursorMovement[cursorIndex].occurrenceGroups[closestGroupIndices[cursorIndex]];
|
|
193
|
+
let isCheesed = false;
|
|
194
|
+
// Track cursor movement to see if it lands on every tick.
|
|
195
|
+
let occurrenceLoopIndex = 1;
|
|
196
|
+
const { allOccurrences } = group;
|
|
197
|
+
for (let i = 1; i < object.nestedHitObjects.length; ++i) {
|
|
198
|
+
if (isCheesed) {
|
|
199
|
+
break;
|
|
200
|
+
}
|
|
201
|
+
const tickWasHit = objectData.tickset[i - 1];
|
|
202
|
+
if (!tickWasHit) {
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
const nestedObject = object.nestedHitObjects[i];
|
|
206
|
+
const nestedPosition = nestedObject.stackedPosition;
|
|
207
|
+
while (occurrenceLoopIndex < allOccurrences.length &&
|
|
208
|
+
allOccurrences[occurrenceLoopIndex].time <
|
|
209
|
+
nestedObject.startTime) {
|
|
210
|
+
++occurrenceLoopIndex;
|
|
211
|
+
}
|
|
212
|
+
if (occurrenceLoopIndex === allOccurrences.length) {
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
const cursor = allOccurrences[occurrenceLoopIndex];
|
|
216
|
+
const prevCursor = allOccurrences[occurrenceLoopIndex - 1];
|
|
217
|
+
const currentPosition = this.getCursorPosition(cursor);
|
|
218
|
+
const prevPosition = this.getCursorPosition(prevCursor);
|
|
219
|
+
switch (cursor.id) {
|
|
220
|
+
case exports.MovementType.move: {
|
|
221
|
+
// Interpolate cursor position during nested object time.
|
|
222
|
+
const t = (nestedObject.startTime - prevCursor.time) /
|
|
223
|
+
(cursor.time - prevCursor.time);
|
|
224
|
+
const cursorPosition = osuBase.Interpolation.lerp(prevPosition, currentPosition, t);
|
|
225
|
+
const distance = cursorPosition.getDistance(nestedPosition);
|
|
226
|
+
isCheesed = distance > acceptableRadius;
|
|
227
|
+
break;
|
|
228
|
+
}
|
|
229
|
+
case exports.MovementType.up:
|
|
230
|
+
isCheesed =
|
|
231
|
+
prevPosition.getDistance(nestedPosition) >
|
|
232
|
+
acceptableRadius;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
if (isCheesed) {
|
|
236
|
+
cheesedDifficultyRatings.push(difficultSlider.difficultyRating);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return cheesedDifficultyRatings;
|
|
83
240
|
}
|
|
84
241
|
/**
|
|
85
|
-
*
|
|
242
|
+
* Calculates the slider cheese penalty.
|
|
86
243
|
*/
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
244
|
+
calculateSliderCheesePenalty(cheesedDifficultyRatings) {
|
|
245
|
+
const summedDifficultyRating = Math.min(1, cheesedDifficultyRatings.reduce((a, v) => a + v, 0));
|
|
246
|
+
return {
|
|
247
|
+
aimPenalty: Math.max(this.difficultyAttributes.sliderFactor, Math.pow(1 -
|
|
248
|
+
summedDifficultyRating *
|
|
249
|
+
this.difficultyAttributes.sliderFactor, 2)),
|
|
250
|
+
flashlightPenalty: 1,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
getCursorPosition(cursor) {
|
|
254
|
+
if (this.isHardRock) {
|
|
255
|
+
return new osuBase.Vector2(cursor.position.x, osuBase.Playfield.baseSize.y - cursor.position.y);
|
|
90
256
|
}
|
|
91
|
-
|
|
257
|
+
return cursor.position;
|
|
92
258
|
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Utility to check whether or not a beatmap is three-fingered for rebalance scores.
|
|
263
|
+
*/
|
|
264
|
+
class RebalanceThreeFingerChecker {
|
|
93
265
|
/**
|
|
94
|
-
*
|
|
266
|
+
* @param beatmap The beatmap to analyze.
|
|
267
|
+
* @param data The data of the replay.
|
|
268
|
+
* @param difficultyAttributes The difficulty attributes of the beatmap.
|
|
95
269
|
*/
|
|
96
|
-
|
|
97
|
-
|
|
270
|
+
constructor(beatmap, data, difficultyAttributes) {
|
|
271
|
+
/**
|
|
272
|
+
* Extended sections of the beatmap for drag detection.
|
|
273
|
+
*/
|
|
274
|
+
this.beatmapSections = [];
|
|
275
|
+
/**
|
|
276
|
+
* A reprocessed break points to match right on object time.
|
|
277
|
+
*
|
|
278
|
+
* This is used to increase detection accuracy since break points do not start right at the
|
|
279
|
+
* start of the hitobject before it and do not end right at the first hitobject after it.
|
|
280
|
+
*/
|
|
281
|
+
this.breakPointAccurateTimes = [];
|
|
282
|
+
/**
|
|
283
|
+
* A cursor occurrence nested array that only contains `movementType.DOWN` movement ID occurrences.
|
|
284
|
+
*
|
|
285
|
+
* Each index represents the cursor index.
|
|
286
|
+
*/
|
|
287
|
+
this.downCursorInstances = [];
|
|
288
|
+
/**
|
|
289
|
+
* Nerf factors from all sections that were three-fingered.
|
|
290
|
+
*/
|
|
291
|
+
this.nerfFactors = [];
|
|
292
|
+
this.beatmap = beatmap;
|
|
293
|
+
this.data = data;
|
|
294
|
+
this.difficultyAttributes = difficultyAttributes;
|
|
295
|
+
this.isHardRock = difficultyAttributes.mods.has(osuBase.ModHardRock);
|
|
296
|
+
this.hitWindow = difficultyAttributes.mods.has(osuBase.ModPrecise)
|
|
297
|
+
? new osuBase.PreciseDroidHitWindow(beatmap.difficulty.od)
|
|
298
|
+
: new osuBase.DroidHitWindow(beatmap.difficulty.od);
|
|
98
299
|
}
|
|
99
300
|
/**
|
|
100
|
-
*
|
|
301
|
+
* Checks whether a beatmap is eligible to be detected for 3-finger.
|
|
101
302
|
*
|
|
102
|
-
*
|
|
303
|
+
* @param difficultyAttributes The difficulty attributes of the beatmap.
|
|
103
304
|
*/
|
|
104
|
-
|
|
105
|
-
return
|
|
305
|
+
static isEligibleToDetect(difficultyAttributes) {
|
|
306
|
+
return difficultyAttributes.possibleThreeFingeredSections.length > 0;
|
|
106
307
|
}
|
|
107
308
|
/**
|
|
108
|
-
*
|
|
309
|
+
* Checks if the given beatmap is 3-fingered and also returns the final penalty.
|
|
109
310
|
*
|
|
110
|
-
*
|
|
311
|
+
* The beatmap will be separated into sections and each section will be determined
|
|
312
|
+
* whether or not it is dragged.
|
|
313
|
+
*
|
|
314
|
+
* After that, each section will be assigned a nerf factor based on whether or not
|
|
315
|
+
* the section is 3-fingered. These nerf factors will be summed up into a final
|
|
316
|
+
* nerf factor, taking beatmap difficulty into account.
|
|
111
317
|
*/
|
|
112
|
-
|
|
113
|
-
if (
|
|
114
|
-
|
|
318
|
+
check() {
|
|
319
|
+
if (!RebalanceThreeFingerChecker.isEligibleToDetect(this.difficultyAttributes) ||
|
|
320
|
+
this.data.cursorMovement.filter((v) => v.occurrenceGroups.length > 0).length <= 3) {
|
|
321
|
+
return { is3Finger: false, penalty: 1 };
|
|
115
322
|
}
|
|
116
|
-
this.
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
return this._down.time;
|
|
123
|
-
}
|
|
124
|
-
/**
|
|
125
|
-
* The time at which this cursor occurrence group ends.
|
|
126
|
-
*/
|
|
127
|
-
get endTime() {
|
|
128
|
-
return this._up?.time ?? this._moves.at(-1)?.time ?? this._down.time;
|
|
323
|
+
this.getAccurateBreakPoints();
|
|
324
|
+
this.filterCursorInstances();
|
|
325
|
+
this.getBeatmapSections();
|
|
326
|
+
this.calculateNerfFactors();
|
|
327
|
+
const finalPenalty = this.calculateFinalPenalty();
|
|
328
|
+
return { is3Finger: finalPenalty > 1, penalty: finalPenalty };
|
|
129
329
|
}
|
|
130
330
|
/**
|
|
131
|
-
*
|
|
331
|
+
* Generates a new set of "accurate break points".
|
|
332
|
+
*
|
|
333
|
+
* This is done to increase detection accuracy since break points do not start right at the
|
|
334
|
+
* end of the hitobject before it and do not end right at the first hitobject after it.
|
|
132
335
|
*/
|
|
133
|
-
|
|
134
|
-
|
|
336
|
+
getAccurateBreakPoints() {
|
|
337
|
+
const objects = this.beatmap.hitObjects.objects;
|
|
338
|
+
const objectData = this.data.hitObjectData;
|
|
339
|
+
for (const breakPoint of this.beatmap.events.breaks) {
|
|
340
|
+
const beforeIndex = osuBase.MathUtils.clamp(objects.findIndex((o) => o.endTime >= breakPoint.startTime) - 1, 0, objects.length - 2);
|
|
341
|
+
const objectBefore = objects[beforeIndex];
|
|
342
|
+
const objectBeforeData = objectData[beforeIndex];
|
|
343
|
+
let timeBefore = objectBefore.endTime;
|
|
344
|
+
if (objectBefore instanceof osuBase.Circle) {
|
|
345
|
+
if (objectBeforeData.result !== osuBase.HitResult.miss) {
|
|
346
|
+
timeBefore += objectBeforeData.accuracy;
|
|
347
|
+
}
|
|
348
|
+
else {
|
|
349
|
+
timeBefore += this.hitWindow.mehWindow;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
const afterIndex = beforeIndex + 1;
|
|
353
|
+
const objectAfter = objects[afterIndex];
|
|
354
|
+
const objectAfterData = objectData[afterIndex];
|
|
355
|
+
let timeAfter = objectAfter.startTime;
|
|
356
|
+
if (objectAfter instanceof osuBase.Circle &&
|
|
357
|
+
objectAfterData.result !== osuBase.HitResult.miss) {
|
|
358
|
+
timeAfter += objectAfterData.accuracy;
|
|
359
|
+
}
|
|
360
|
+
this.breakPointAccurateTimes.push(new osuBase.BreakPoint(timeBefore, timeAfter));
|
|
361
|
+
}
|
|
135
362
|
}
|
|
136
363
|
/**
|
|
137
|
-
*
|
|
364
|
+
* Filters the original cursor instances, returning only those with `movementType.DOWN` movement ID.
|
|
138
365
|
*
|
|
139
|
-
* This
|
|
366
|
+
* This also filters cursors that are in break period or happen before start/after end of the beatmap.
|
|
140
367
|
*/
|
|
141
|
-
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
_up;
|
|
162
|
-
constructor(down, moves, up) {
|
|
163
|
-
this._down = down;
|
|
164
|
-
this._moves = moves;
|
|
165
|
-
// Re-set down cursor occurrence for checking.
|
|
166
|
-
this.down = down;
|
|
167
|
-
this.up = up;
|
|
168
|
-
}
|
|
169
|
-
/**
|
|
170
|
-
* Determines whether this cursor occurrence group is active at the specified time.
|
|
171
|
-
*
|
|
172
|
-
* @param time The time.
|
|
173
|
-
* @returns Whether this cursor occurrence group is active at the specified time.
|
|
174
|
-
*/
|
|
175
|
-
isActiveAt(time) {
|
|
176
|
-
return time >= this.startTime && time <= this.endTime;
|
|
177
|
-
}
|
|
178
|
-
/**
|
|
179
|
-
* Finds the cursor occurrence that is active at a given time.
|
|
180
|
-
*
|
|
181
|
-
* @param time The time.
|
|
182
|
-
* @returns The cursor occurrence at the given time, `null` if not found.
|
|
183
|
-
*/
|
|
184
|
-
cursorAt(time) {
|
|
185
|
-
if (!this.isActiveAt(time)) {
|
|
186
|
-
return null;
|
|
368
|
+
filterCursorInstances() {
|
|
369
|
+
const objects = this.beatmap.hitObjects.objects;
|
|
370
|
+
const objectData = this.data.hitObjectData;
|
|
371
|
+
const firstObjectResult = objectData[0].result;
|
|
372
|
+
const lastObjectResult = objectData.at(-1).result;
|
|
373
|
+
const firstObject = objects[0];
|
|
374
|
+
const lastObject = objects.at(-1);
|
|
375
|
+
// For sliders, automatically set hit window length to be as lenient as possible.
|
|
376
|
+
let firstObjectHitWindow = this.hitWindow.mehWindow;
|
|
377
|
+
if (firstObject instanceof osuBase.Circle) {
|
|
378
|
+
switch (firstObjectResult) {
|
|
379
|
+
case osuBase.HitResult.great:
|
|
380
|
+
firstObjectHitWindow = this.hitWindow.greatWindow;
|
|
381
|
+
break;
|
|
382
|
+
case osuBase.HitResult.good:
|
|
383
|
+
firstObjectHitWindow = this.hitWindow.okWindow;
|
|
384
|
+
break;
|
|
385
|
+
default:
|
|
386
|
+
firstObjectHitWindow = this.hitWindow.mehWindow;
|
|
387
|
+
}
|
|
187
388
|
}
|
|
188
|
-
|
|
189
|
-
|
|
389
|
+
// For sliders, automatically set hit window length to be as lenient as possible.
|
|
390
|
+
let lastObjectHitWindow = this.hitWindow.mehWindow;
|
|
391
|
+
if (lastObject instanceof osuBase.Circle) {
|
|
392
|
+
switch (lastObjectResult) {
|
|
393
|
+
case osuBase.HitResult.great:
|
|
394
|
+
lastObjectHitWindow = this.hitWindow.greatWindow;
|
|
395
|
+
break;
|
|
396
|
+
case osuBase.HitResult.good:
|
|
397
|
+
lastObjectHitWindow = this.hitWindow.okWindow;
|
|
398
|
+
break;
|
|
399
|
+
default:
|
|
400
|
+
lastObjectHitWindow = this.hitWindow.mehWindow;
|
|
401
|
+
}
|
|
190
402
|
}
|
|
191
|
-
if (
|
|
192
|
-
|
|
403
|
+
else if (lastObject instanceof osuBase.Slider) {
|
|
404
|
+
lastObjectHitWindow = Math.min(lastObject.spanDuration, lastObjectHitWindow);
|
|
193
405
|
}
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
406
|
+
// These hit time uses hit window length as threshold.
|
|
407
|
+
// This is because cursors aren't recorded exactly at hit time,
|
|
408
|
+
// probably due to the game's behavior.
|
|
409
|
+
const firstObjectHitTime = firstObject.startTime - firstObjectHitWindow;
|
|
410
|
+
const lastObjectHitTime = lastObject.startTime + lastObjectHitWindow;
|
|
411
|
+
for (const cursorInstance of this.data.cursorMovement) {
|
|
412
|
+
const validOccurrences = [];
|
|
413
|
+
for (const group of cursorInstance.occurrenceGroups) {
|
|
414
|
+
if (group.startTime < firstObjectHitTime) {
|
|
415
|
+
continue;
|
|
416
|
+
}
|
|
417
|
+
if (group.startTime > lastObjectHitTime) {
|
|
418
|
+
break;
|
|
419
|
+
}
|
|
420
|
+
if (this.breakPointAccurateTimes.some((v) => group.startTime >= v.startTime &&
|
|
421
|
+
group.endTime <= v.endTime)) {
|
|
422
|
+
continue;
|
|
423
|
+
}
|
|
424
|
+
validOccurrences.push(group.down);
|
|
206
425
|
}
|
|
426
|
+
this.downCursorInstances.push(validOccurrences);
|
|
207
427
|
}
|
|
208
|
-
// l will be the first cursor occurrence with time > this._moves[l].time, but we want the one before it
|
|
209
|
-
return this._moves[l - 1];
|
|
210
428
|
}
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
/**
|
|
214
|
-
* Represents a cursor instance in an osu!droid replay.
|
|
215
|
-
*
|
|
216
|
-
* Stores cursor movement data in the form of `CursorOccurrenceGroup`s.
|
|
217
|
-
*
|
|
218
|
-
* This is used when analyzing replays using replay analyzer.
|
|
219
|
-
*/
|
|
220
|
-
class CursorData {
|
|
221
429
|
/**
|
|
222
|
-
*
|
|
430
|
+
* Divides the beatmap into sections, which will be used to
|
|
431
|
+
* detect dragged sections and improve detection speed.
|
|
223
432
|
*/
|
|
224
|
-
|
|
433
|
+
getBeatmapSections() {
|
|
434
|
+
const beatmapObjects = this.beatmap.hitObjects.objects;
|
|
435
|
+
const aimCursorGroupLookupIndices = osuBase.Utils.initializeArray(this.downCursorInstances.length, 0);
|
|
436
|
+
// This intentionally starts from 1 because we need to look at the previous cursor.
|
|
437
|
+
const aimCursorLookupIndices = osuBase.Utils.initializeArray(this.downCursorInstances.length, 1);
|
|
438
|
+
const pressCursorLookupIndices = osuBase.Utils.initializeArray(this.downCursorInstances.length, 0);
|
|
439
|
+
for (const section of this.difficultyAttributes
|
|
440
|
+
.possibleThreeFingeredSections) {
|
|
441
|
+
const objects = [];
|
|
442
|
+
for (let i = section.firstObjectIndex; i <= section.lastObjectIndex; ++i) {
|
|
443
|
+
const object = beatmapObjects[i];
|
|
444
|
+
const objectData = this.data.hitObjectData[i];
|
|
445
|
+
objects.push({
|
|
446
|
+
object: object,
|
|
447
|
+
aimingCursorInstanceIndex: this.getObjectAimIndex(object, objectData, aimCursorGroupLookupIndices, aimCursorLookupIndices),
|
|
448
|
+
pressingCursorInstanceIndex: this.getObjectPressIndex(object, objectData, pressCursorLookupIndices),
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
this.beatmapSections.push(Object.assign(Object.assign({}, section), { objects: objects }));
|
|
452
|
+
}
|
|
453
|
+
}
|
|
225
454
|
/**
|
|
226
|
-
*
|
|
455
|
+
* Obtains the index of the cursor that aimed the object at the nearest time.
|
|
227
456
|
*
|
|
228
|
-
*
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
457
|
+
* @param object The object to obtain the index for.
|
|
458
|
+
* @param objectData The hit data of the object.
|
|
459
|
+
* @param cursorInstanceIndices The cursor indices to start looking for the cursor instance from, to save computation time.
|
|
460
|
+
* @param cursorGroupIndices The cursor indices to start looking for the cursor group from, to save computation time.
|
|
461
|
+
* @param cursorIndices The cursor indices to start looking for the cursor from, to save computation time.
|
|
462
|
+
* @returns The index of the cursor, -1 if the object was missed or it's a spinner.
|
|
463
|
+
*/
|
|
464
|
+
getObjectAimIndex(object, objectData, cursorGroupIndices, cursorIndices) {
|
|
465
|
+
if (objectData.result === osuBase.HitResult.miss || object instanceof osuBase.Spinner) {
|
|
466
|
+
return -1;
|
|
467
|
+
}
|
|
468
|
+
// Check for sliderbreaks and treat them as misses.
|
|
469
|
+
if (object instanceof osuBase.Slider &&
|
|
470
|
+
(-this.hitWindow.mehWindow > objectData.accuracy ||
|
|
471
|
+
objectData.accuracy >
|
|
472
|
+
Math.min(this.hitWindow.mehWindow, object.duration))) {
|
|
473
|
+
return -1;
|
|
474
|
+
}
|
|
475
|
+
const hitTime = object.startTime + objectData.accuracy;
|
|
476
|
+
const objectPosition = object.stackedPosition;
|
|
477
|
+
// We are maintaining the closest distance to the object.
|
|
478
|
+
// This is because the radius that is calculated is using an estimation.
|
|
479
|
+
// As such, it does not reflect the actual object radius in gameplay.
|
|
480
|
+
let closestDistance = Number.POSITIVE_INFINITY;
|
|
481
|
+
let nearestCursorIndex = -1;
|
|
482
|
+
// Observe the cursor position at the object's hit time.
|
|
483
|
+
for (let i = 0; i < this.data.cursorMovement.length; ++i) {
|
|
484
|
+
const cursorData = this.data.cursorMovement[i];
|
|
485
|
+
for (let j = cursorGroupIndices[i]; j < cursorData.occurrenceGroups.length; cursorGroupIndices[i] = ++j) {
|
|
486
|
+
const cursorGroup = cursorData.occurrenceGroups[j];
|
|
487
|
+
if (cursorGroup.endTime < hitTime) {
|
|
488
|
+
// Reset cursor index pointer.
|
|
489
|
+
cursorIndices[i] = 1;
|
|
490
|
+
continue;
|
|
491
|
+
}
|
|
492
|
+
if (cursorGroup.startTime > hitTime) {
|
|
493
|
+
break;
|
|
494
|
+
}
|
|
495
|
+
const cursors = cursorGroup.allOccurrences;
|
|
496
|
+
for (let k = cursorIndices[i]; k < cursors.length; cursorIndices[i] = ++k) {
|
|
497
|
+
const cursor = cursors[k];
|
|
498
|
+
const prevCursor = cursors[k - 1];
|
|
499
|
+
// Cursor is past the object's hit time.
|
|
500
|
+
if (prevCursor.time > hitTime) {
|
|
501
|
+
break;
|
|
502
|
+
}
|
|
503
|
+
// Cursor is before the object's hit time.
|
|
504
|
+
if (hitTime > cursor.time) {
|
|
505
|
+
continue;
|
|
506
|
+
}
|
|
507
|
+
let distance;
|
|
508
|
+
const currentPosition = this.getCursorPosition(cursor);
|
|
509
|
+
const prevPosition = this.getCursorPosition(prevCursor);
|
|
510
|
+
switch (cursor.id) {
|
|
511
|
+
case exports.MovementType.up:
|
|
512
|
+
distance = prevPosition.getDistance(objectPosition);
|
|
513
|
+
break;
|
|
514
|
+
case exports.MovementType.move: {
|
|
515
|
+
// Interpolate movement.
|
|
516
|
+
const t = (hitTime - prevCursor.time) /
|
|
517
|
+
(cursor.time - prevCursor.time);
|
|
518
|
+
const cursorPosition = osuBase.Interpolation.lerp(prevPosition, currentPosition, t);
|
|
519
|
+
distance =
|
|
520
|
+
objectPosition.getDistance(cursorPosition);
|
|
521
|
+
break;
|
|
522
|
+
}
|
|
523
|
+
case exports.MovementType.down:
|
|
524
|
+
continue;
|
|
525
|
+
}
|
|
526
|
+
if (closestDistance > distance) {
|
|
527
|
+
closestDistance = distance;
|
|
528
|
+
nearestCursorIndex = i;
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
// Reset cursor index pointer on end of group.
|
|
532
|
+
if (cursorIndices[i] === cursors.length) {
|
|
533
|
+
cursorIndices[i] = 1;
|
|
534
|
+
}
|
|
535
|
+
break;
|
|
536
|
+
}
|
|
537
|
+
// The previous object may still be hit with the same cursor group or cursor index.
|
|
538
|
+
cursorGroupIndices[i] = Math.max(0, cursorGroupIndices[i] - 1);
|
|
539
|
+
cursorIndices[i] = Math.max(1, cursorIndices[i] - 1);
|
|
540
|
+
}
|
|
541
|
+
return nearestCursorIndex;
|
|
232
542
|
}
|
|
233
543
|
/**
|
|
234
|
-
*
|
|
544
|
+
* Obtains the index of the nearest cursor of which an object was pressed in terms of time.
|
|
235
545
|
*
|
|
236
|
-
*
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
546
|
+
* @param object The object to obtain the index for.
|
|
547
|
+
* @param objectData The hit data of the object.
|
|
548
|
+
* @param cursorLookupIndices The cursor indices to start looking for the cursor from, to save computation time.
|
|
549
|
+
* @param excludedIndices The cursor indices that should not be checked.
|
|
550
|
+
* @returns The index of the cursor, -1 if the object was missed or it's a spinner.
|
|
551
|
+
*/
|
|
552
|
+
getObjectPressIndex(object, objectData, cursorLookupIndices) {
|
|
553
|
+
if (objectData.result === osuBase.HitResult.miss || object instanceof osuBase.Spinner) {
|
|
554
|
+
return -1;
|
|
555
|
+
}
|
|
556
|
+
// Check for sliderbreaks and treat them as misses.
|
|
557
|
+
if (object instanceof osuBase.Slider &&
|
|
558
|
+
(-this.hitWindow.mehWindow > objectData.accuracy ||
|
|
559
|
+
objectData.accuracy >
|
|
560
|
+
Math.min(this.hitWindow.mehWindow, object.duration))) {
|
|
561
|
+
return -1;
|
|
562
|
+
}
|
|
563
|
+
const hitTime = object.startTime + objectData.accuracy;
|
|
564
|
+
let nearestCursorInstanceIndex = -1;
|
|
565
|
+
let nearestTime = Number.POSITIVE_INFINITY;
|
|
566
|
+
for (let i = 0; i < this.downCursorInstances.length; ++i) {
|
|
567
|
+
const cursors = this.downCursorInstances[i];
|
|
568
|
+
let cursorNearestTime = Number.POSITIVE_INFINITY;
|
|
569
|
+
for (let j = cursorLookupIndices[i]; j < cursors.length; cursorLookupIndices[i] = ++j) {
|
|
570
|
+
const cursor = cursors[j];
|
|
571
|
+
if (cursor.time > hitTime) {
|
|
572
|
+
break;
|
|
573
|
+
}
|
|
574
|
+
cursorNearestTime = hitTime - cursor.time;
|
|
575
|
+
}
|
|
576
|
+
if (cursorNearestTime < nearestTime) {
|
|
577
|
+
nearestCursorInstanceIndex = i;
|
|
578
|
+
nearestTime = cursorNearestTime;
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
return nearestCursorInstanceIndex;
|
|
240
582
|
}
|
|
241
583
|
/**
|
|
242
|
-
*
|
|
584
|
+
* Creates nerf factors by scanning through objects.
|
|
243
585
|
*/
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
586
|
+
calculateNerfFactors() {
|
|
587
|
+
for (const beatmapSection of this.beatmapSections) {
|
|
588
|
+
const threeFingerCursorCounts = osuBase.Utils.initializeArray(Math.max(0, this.downCursorInstances.length - 2), 0);
|
|
589
|
+
for (const object of beatmapSection.objects) {
|
|
590
|
+
if (object.pressingCursorInstanceIndex === -1) {
|
|
591
|
+
continue;
|
|
592
|
+
}
|
|
593
|
+
if (object.aimingCursorInstanceIndex < 3) {
|
|
594
|
+
// The aim cursor is in the first three cursors. They are counted as non-3 finger.
|
|
595
|
+
switch (object.pressingCursorInstanceIndex) {
|
|
596
|
+
case 0:
|
|
597
|
+
case 1:
|
|
598
|
+
case 2:
|
|
599
|
+
break;
|
|
600
|
+
default:
|
|
601
|
+
++threeFingerCursorCounts[object.pressingCursorInstanceIndex - 3];
|
|
602
|
+
break;
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
else {
|
|
606
|
+
// The aim cursor is somewhere else. only count the first 2 cursors as non-3 finger.
|
|
607
|
+
switch (object.pressingCursorInstanceIndex) {
|
|
608
|
+
case 0:
|
|
609
|
+
case 1:
|
|
610
|
+
break;
|
|
611
|
+
default:
|
|
612
|
+
++threeFingerCursorCounts[object.pressingCursorInstanceIndex - 2];
|
|
613
|
+
break;
|
|
614
|
+
}
|
|
615
|
+
}
|
|
253
616
|
}
|
|
254
|
-
|
|
255
|
-
|
|
617
|
+
const threeFingerCursorCount = threeFingerCursorCounts.reduce((a, v) => a + v, 0);
|
|
618
|
+
if (threeFingerCursorCount === 0) {
|
|
619
|
+
continue;
|
|
620
|
+
}
|
|
621
|
+
const sectionObjectCount = beatmapSection.objects.length;
|
|
622
|
+
const threeFingeredObjectRatio = threeFingerCursorCount / sectionObjectCount;
|
|
623
|
+
const strainFactor = Math.max(1, beatmapSection.sumStrain * threeFingeredObjectRatio);
|
|
624
|
+
// Finger factor applies more penalty if more fingers were used.
|
|
625
|
+
const fingerFactor = threeFingerCursorCounts.reduce((acc, count, index) => acc +
|
|
626
|
+
Math.pow(((index + 1) * count) / sectionObjectCount, 0.9), 1);
|
|
627
|
+
// Length factor applies more penalty if there are more 3-fingered object.
|
|
628
|
+
const lengthFactor = 1 + Math.pow(threeFingeredObjectRatio, 0.8);
|
|
629
|
+
this.nerfFactors.push({
|
|
630
|
+
strainFactor: strainFactor,
|
|
631
|
+
fingerFactor: fingerFactor,
|
|
632
|
+
lengthFactor: lengthFactor,
|
|
633
|
+
});
|
|
634
|
+
}
|
|
256
635
|
}
|
|
257
636
|
/**
|
|
258
|
-
*
|
|
259
|
-
*
|
|
260
|
-
* This iterates all occurrence groups and as such should be used sparingly or stored locally.
|
|
637
|
+
* Calculates the final penalty.
|
|
261
638
|
*/
|
|
262
|
-
|
|
263
|
-
return this.
|
|
639
|
+
calculateFinalPenalty() {
|
|
640
|
+
return this.nerfFactors.reduce((a, n) => a +
|
|
641
|
+
0.015 *
|
|
642
|
+
Math.pow(n.strainFactor * n.fingerFactor * n.lengthFactor, 1.05), 1);
|
|
264
643
|
}
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
for (let i = 0; i < values.size; ++i) {
|
|
269
|
-
const occurrence = new CursorOccurrence(values.time[i], values.x[i], values.y[i], values.id[i]);
|
|
270
|
-
switch (occurrence.id) {
|
|
271
|
-
case exports.MovementType.down:
|
|
272
|
-
downOccurrence = occurrence;
|
|
273
|
-
break;
|
|
274
|
-
case exports.MovementType.move:
|
|
275
|
-
moveOccurrences.push(occurrence);
|
|
276
|
-
break;
|
|
277
|
-
case exports.MovementType.up:
|
|
278
|
-
if (downOccurrence) {
|
|
279
|
-
this.occurrenceGroups.push(new CursorOccurrenceGroup(downOccurrence, moveOccurrences, occurrence));
|
|
280
|
-
downOccurrence = null;
|
|
281
|
-
}
|
|
282
|
-
moveOccurrences = [];
|
|
283
|
-
}
|
|
284
|
-
}
|
|
285
|
-
// Add the final cursor occurrence group as the loop may not catch it for special cases.
|
|
286
|
-
if (downOccurrence && moveOccurrences.length > 0) {
|
|
287
|
-
this.occurrenceGroups.push(new CursorOccurrenceGroup(downOccurrence, moveOccurrences));
|
|
644
|
+
getCursorPosition(cursor) {
|
|
645
|
+
if (this.isHardRock) {
|
|
646
|
+
return new osuBase.Vector2(cursor.position.x, osuBase.Playfield.baseSize.y - cursor.position.y);
|
|
288
647
|
}
|
|
648
|
+
return cursor.position;
|
|
289
649
|
}
|
|
290
650
|
}
|
|
291
651
|
|
|
292
652
|
/**
|
|
293
|
-
*
|
|
653
|
+
* Utility to check whether relevant sliders in a beatmap are cheesed for live scores.
|
|
294
654
|
*/
|
|
295
|
-
|
|
296
|
-
(function (HitResult) {
|
|
297
|
-
/**
|
|
298
|
-
* Miss (0).
|
|
299
|
-
*/
|
|
300
|
-
HitResult[HitResult["miss"] = 1] = "miss";
|
|
655
|
+
class SliderCheeseChecker {
|
|
301
656
|
/**
|
|
302
|
-
*
|
|
657
|
+
* @param beatmap The beatmap to analyze.
|
|
658
|
+
* @param data The data of the replay.
|
|
659
|
+
* @param difficultyAttributes The difficulty attributes of the beatmap.
|
|
303
660
|
*/
|
|
304
|
-
|
|
661
|
+
constructor(beatmap, data, difficultyAttributes) {
|
|
662
|
+
this.beatmap = beatmap;
|
|
663
|
+
this.data = data;
|
|
664
|
+
this.difficultyAttributes = difficultyAttributes;
|
|
665
|
+
this.hitWindow50 = difficultyAttributes.mods.has(osuBase.ModPrecise)
|
|
666
|
+
? new osuBase.PreciseDroidHitWindow(beatmap.difficulty.od).mehWindow
|
|
667
|
+
: new osuBase.DroidHitWindow(beatmap.difficulty.od).mehWindow;
|
|
668
|
+
this.isHardRock = difficultyAttributes.mods.has(osuBase.ModHardRock);
|
|
669
|
+
}
|
|
305
670
|
/**
|
|
306
|
-
*
|
|
671
|
+
* Checks if relevant sliders in the given beatmap was cheesed.
|
|
307
672
|
*/
|
|
308
|
-
|
|
673
|
+
check() {
|
|
674
|
+
if (this.difficultyAttributes.difficultSliders.length === 0 ||
|
|
675
|
+
(this.difficultyAttributes.sliderFactor === 1 &&
|
|
676
|
+
this.difficultyAttributes.flashlightSliderFactor === 1)) {
|
|
677
|
+
return {
|
|
678
|
+
aimPenalty: 1,
|
|
679
|
+
flashlightPenalty: 1,
|
|
680
|
+
};
|
|
681
|
+
}
|
|
682
|
+
const cheesedDifficultyRatings = this.checkSliderCheesing();
|
|
683
|
+
return this.calculateSliderCheesePenalty(cheesedDifficultyRatings);
|
|
684
|
+
}
|
|
309
685
|
/**
|
|
310
|
-
*
|
|
686
|
+
* Checks for sliders that were cheesed.
|
|
311
687
|
*/
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
*
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
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
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
688
|
+
checkSliderCheesing() {
|
|
689
|
+
const { objects } = this.beatmap.hitObjects;
|
|
690
|
+
const cheesedDifficultyRatings = [];
|
|
691
|
+
// Current loop indices are stored for efficiency.
|
|
692
|
+
const cursorLoopIndices = osuBase.Utils.initializeArray(this.data.cursorMovement.length, 0);
|
|
693
|
+
const acceptableRadius = objects[0].radius * 2;
|
|
694
|
+
// Sort difficult sliders by index so that cursor loop indices work properly.
|
|
695
|
+
for (const difficultSlider of this.difficultyAttributes.difficultSliders
|
|
696
|
+
.slice()
|
|
697
|
+
.sort((a, b) => a.index - b.index)) {
|
|
698
|
+
if (difficultSlider.index >= this.data.hitObjectData.length) {
|
|
699
|
+
continue;
|
|
700
|
+
}
|
|
701
|
+
const object = objects[difficultSlider.index];
|
|
702
|
+
const objectData = this.data.hitObjectData[difficultSlider.index];
|
|
703
|
+
// If a miss or slider break occurs, we disregard the check for that slider.
|
|
704
|
+
if (objectData.result === osuBase.HitResult.miss) {
|
|
705
|
+
continue;
|
|
706
|
+
}
|
|
707
|
+
let lateHitThreshold = this.hitWindow50;
|
|
708
|
+
// Before replay version 8, the slider head's hit window is capped to the duration of the slider.
|
|
709
|
+
if (this.data.replayVersion < 8) {
|
|
710
|
+
lateHitThreshold = Math.min(lateHitThreshold, object.duration);
|
|
711
|
+
}
|
|
712
|
+
if (objectData.accuracy < -this.hitWindow50 ||
|
|
713
|
+
objectData.accuracy > lateHitThreshold) {
|
|
714
|
+
continue;
|
|
715
|
+
}
|
|
716
|
+
const objectStartPosition = object.stackedPosition;
|
|
717
|
+
// These time boundaries should consider the delta time between the previous and next
|
|
718
|
+
// object as well as their hit accuracy. However, they are somewhat complicated to
|
|
719
|
+
// compute and the accuracy gain is small. As such, let's settle with 50 hit window.
|
|
720
|
+
const minTimeLimit = object.startTime - this.hitWindow50;
|
|
721
|
+
const maxTimeLimit = object.startTime + this.hitWindow50;
|
|
722
|
+
// Get the closest tap distance across all cursors.
|
|
723
|
+
const closestDistances = [];
|
|
724
|
+
const closestGroupIndices = [];
|
|
725
|
+
for (let i = 0; i < this.data.cursorMovement.length; ++i) {
|
|
726
|
+
const cursorGroups = this.data.cursorMovement[i].occurrenceGroups;
|
|
727
|
+
let closestDistance = Number.POSITIVE_INFINITY;
|
|
728
|
+
let closestIndex = cursorGroups.length;
|
|
729
|
+
for (let j = cursorLoopIndices[i]; j < cursorGroups.length; j = ++cursorLoopIndices[i]) {
|
|
730
|
+
const group = cursorGroups[j];
|
|
731
|
+
if (group.endTime < minTimeLimit) {
|
|
732
|
+
continue;
|
|
733
|
+
}
|
|
734
|
+
if (group.startTime > maxTimeLimit) {
|
|
735
|
+
break;
|
|
736
|
+
}
|
|
737
|
+
if (group.startTime >= minTimeLimit) {
|
|
738
|
+
const position = this.getCursorPosition(group.down);
|
|
739
|
+
const distance = position.getDistance(objectStartPosition);
|
|
740
|
+
if (closestDistance > distance) {
|
|
741
|
+
closestDistance = distance;
|
|
742
|
+
closestIndex = j;
|
|
743
|
+
}
|
|
744
|
+
if (closestDistance <= acceptableRadius / 2) {
|
|
745
|
+
break;
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
// Normally, we check if there are cursor presses within the group's active time.
|
|
749
|
+
// However, some funky workarounds are used throughout the game for replays, so
|
|
750
|
+
// for the time being we only check for cursor distances across the group.
|
|
751
|
+
const { allOccurrences } = group;
|
|
752
|
+
for (let k = 1; k < allOccurrences.length; ++k) {
|
|
753
|
+
const cursor = allOccurrences[k];
|
|
754
|
+
const prevCursor = allOccurrences[k - 1];
|
|
755
|
+
let distance = Number.POSITIVE_INFINITY;
|
|
756
|
+
const currentPosition = this.getCursorPosition(cursor);
|
|
757
|
+
const prevPosition = this.getCursorPosition(prevCursor);
|
|
758
|
+
switch (cursor.id) {
|
|
759
|
+
case exports.MovementType.up:
|
|
760
|
+
distance =
|
|
761
|
+
prevPosition.getDistance(objectStartPosition);
|
|
762
|
+
break;
|
|
763
|
+
case exports.MovementType.move:
|
|
764
|
+
for (let mSecPassed = Math.max(prevCursor.time, minTimeLimit); mSecPassed <=
|
|
765
|
+
Math.min(cursor.time, maxTimeLimit); ++mSecPassed) {
|
|
766
|
+
const t = (mSecPassed - prevCursor.time) /
|
|
767
|
+
(cursor.time - prevCursor.time);
|
|
768
|
+
const cursorPosition = osuBase.Interpolation.lerp(prevPosition, currentPosition, t);
|
|
769
|
+
distance =
|
|
770
|
+
cursorPosition.getDistance(objectStartPosition);
|
|
771
|
+
if (closestDistance > distance) {
|
|
772
|
+
closestDistance = distance;
|
|
773
|
+
closestIndex = j;
|
|
774
|
+
}
|
|
775
|
+
if (closestDistance <=
|
|
776
|
+
acceptableRadius / 2) {
|
|
777
|
+
break;
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
if (closestDistance > distance) {
|
|
782
|
+
closestDistance = distance;
|
|
783
|
+
closestIndex = j;
|
|
784
|
+
}
|
|
785
|
+
if (closestDistance <= acceptableRadius / 2) {
|
|
786
|
+
break;
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
closestDistances.push(closestDistance);
|
|
791
|
+
closestGroupIndices.push(closestIndex);
|
|
792
|
+
if (cursorLoopIndices[i] > 0) {
|
|
793
|
+
// Decrement the index. The previous group may also have a role on the next slider.
|
|
794
|
+
--cursorLoopIndices[i];
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
const cursorIndex = closestDistances.indexOf(Math.min(...closestDistances));
|
|
798
|
+
const closestDistance = closestDistances[cursorIndex];
|
|
799
|
+
if (closestDistance > acceptableRadius / 2) {
|
|
800
|
+
cheesedDifficultyRatings.push(difficultSlider.difficultyRating);
|
|
801
|
+
continue;
|
|
802
|
+
}
|
|
803
|
+
const group = this.data.cursorMovement[cursorIndex].occurrenceGroups[closestGroupIndices[cursorIndex]];
|
|
804
|
+
let isCheesed = false;
|
|
805
|
+
// Track cursor movement to see if it lands on every tick.
|
|
806
|
+
let occurrenceLoopIndex = 1;
|
|
807
|
+
const { allOccurrences } = group;
|
|
808
|
+
for (let i = 1; i < object.nestedHitObjects.length; ++i) {
|
|
809
|
+
if (isCheesed) {
|
|
810
|
+
break;
|
|
811
|
+
}
|
|
812
|
+
const tickWasHit = objectData.tickset[i - 1];
|
|
813
|
+
if (!tickWasHit) {
|
|
814
|
+
continue;
|
|
815
|
+
}
|
|
816
|
+
const nestedObject = object.nestedHitObjects[i];
|
|
817
|
+
const nestedPosition = nestedObject.stackedPosition;
|
|
818
|
+
while (occurrenceLoopIndex < allOccurrences.length &&
|
|
819
|
+
allOccurrences[occurrenceLoopIndex].time <
|
|
820
|
+
nestedObject.startTime) {
|
|
821
|
+
++occurrenceLoopIndex;
|
|
822
|
+
}
|
|
823
|
+
if (occurrenceLoopIndex === allOccurrences.length) {
|
|
824
|
+
continue;
|
|
825
|
+
}
|
|
826
|
+
const cursor = allOccurrences[occurrenceLoopIndex];
|
|
827
|
+
const prevCursor = allOccurrences[occurrenceLoopIndex - 1];
|
|
828
|
+
const currentPosition = this.getCursorPosition(cursor);
|
|
829
|
+
const prevPosition = this.getCursorPosition(prevCursor);
|
|
830
|
+
switch (cursor.id) {
|
|
831
|
+
case exports.MovementType.move: {
|
|
832
|
+
// Interpolate cursor position during nested object time.
|
|
833
|
+
const t = (nestedObject.startTime - prevCursor.time) /
|
|
834
|
+
(cursor.time - prevCursor.time);
|
|
835
|
+
const cursorPosition = osuBase.Interpolation.lerp(prevPosition, currentPosition, t);
|
|
836
|
+
const distance = cursorPosition.getDistance(nestedPosition);
|
|
837
|
+
isCheesed = distance > acceptableRadius;
|
|
838
|
+
break;
|
|
839
|
+
}
|
|
840
|
+
case exports.MovementType.up:
|
|
841
|
+
isCheesed =
|
|
842
|
+
prevPosition.getDistance(nestedPosition) >
|
|
843
|
+
acceptableRadius;
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
if (isCheesed) {
|
|
847
|
+
cheesedDifficultyRatings.push(difficultSlider.difficultyRating);
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
return cheesedDifficultyRatings;
|
|
851
|
+
}
|
|
852
|
+
/**
|
|
853
|
+
* Calculates the slider cheese penalty.
|
|
854
|
+
*/
|
|
855
|
+
calculateSliderCheesePenalty(cheesedDifficultyRatings) {
|
|
856
|
+
const summedDifficultyRating = Math.min(1, cheesedDifficultyRatings.reduce((a, v) => a + v, 0));
|
|
857
|
+
return {
|
|
858
|
+
aimPenalty: Math.max(this.difficultyAttributes.sliderFactor, Math.pow(1 -
|
|
859
|
+
summedDifficultyRating *
|
|
860
|
+
this.difficultyAttributes.sliderFactor, 2)),
|
|
861
|
+
flashlightPenalty: Math.max(this.difficultyAttributes.flashlightSliderFactor, Math.pow(1 -
|
|
862
|
+
summedDifficultyRating *
|
|
863
|
+
this.difficultyAttributes.flashlightSliderFactor, 2)),
|
|
864
|
+
};
|
|
865
|
+
}
|
|
866
|
+
getCursorPosition(cursor) {
|
|
867
|
+
if (this.isHardRock) {
|
|
868
|
+
return new osuBase.Vector2(cursor.position.x, osuBase.Playfield.baseSize.y - cursor.position.y);
|
|
869
|
+
}
|
|
870
|
+
return cursor.position;
|
|
871
|
+
}
|
|
872
|
+
}
|
|
364
873
|
|
|
365
874
|
/**
|
|
366
|
-
* Utility to check whether or not a beatmap is three-fingered.
|
|
875
|
+
* Utility to check whether or not a beatmap is three-fingered for live scores.
|
|
367
876
|
*/
|
|
368
877
|
class ThreeFingerChecker {
|
|
369
|
-
/**
|
|
370
|
-
* The beatmap that is being analyzed.
|
|
371
|
-
*/
|
|
372
|
-
beatmap;
|
|
373
|
-
/**
|
|
374
|
-
* The data of the replay.
|
|
375
|
-
*/
|
|
376
|
-
data;
|
|
377
|
-
/**
|
|
378
|
-
* The difficulty attributes of the beatmap.
|
|
379
|
-
*/
|
|
380
|
-
difficultyAttributes;
|
|
381
|
-
/**
|
|
382
|
-
* The true scale of objects.
|
|
383
|
-
*/
|
|
384
|
-
trueScale;
|
|
385
|
-
/**
|
|
386
|
-
* The distance threshold between cursors to assume that two cursors are
|
|
387
|
-
* actually pressed with 1 finger in osu!pixels.
|
|
388
|
-
*
|
|
389
|
-
* This is used to prevent cases where a player would lift their finger
|
|
390
|
-
* too fast to the point where the 4th cursor instance or beyond is recorded
|
|
391
|
-
* as 1st, 2nd, or 3rd cursor instance.
|
|
392
|
-
*/
|
|
393
|
-
cursorDistancingDistanceThreshold = 60;
|
|
394
|
-
/**
|
|
395
|
-
* The threshold for the amount of cursors that are assumed to be pressed
|
|
396
|
-
* by a single finger.
|
|
397
|
-
*/
|
|
398
|
-
cursorDistancingCountThreshold = 10;
|
|
399
|
-
/**
|
|
400
|
-
* The threshold for the time difference of cursors that are assumed to be pressed
|
|
401
|
-
* by a single finger, in milliseconds.
|
|
402
|
-
*/
|
|
403
|
-
cursorDistancingTimeThreshold = 1000;
|
|
404
|
-
/**
|
|
405
|
-
* The amount of notes that has a tap strain exceeding `strainThreshold`.
|
|
406
|
-
*/
|
|
407
|
-
strainNoteCount;
|
|
408
|
-
/**
|
|
409
|
-
* The ratio threshold between non-3 finger cursors and 3-finger cursors.
|
|
410
|
-
*
|
|
411
|
-
* Increasing this number will increase detection accuracy, however
|
|
412
|
-
* it also increases the chance of falsely flagged plays.
|
|
413
|
-
*/
|
|
414
|
-
threeFingerRatioThreshold = 0.01;
|
|
415
|
-
/**
|
|
416
|
-
* Extended sections of the beatmap for drag detection.
|
|
417
|
-
*/
|
|
418
|
-
beatmapSections = [];
|
|
419
|
-
/**
|
|
420
|
-
* This threshold is used to filter out accidental taps.
|
|
421
|
-
*
|
|
422
|
-
* Increasing this number makes the filtration more sensitive, however it
|
|
423
|
-
* will also increase the chance of 3-fingered plays getting out from
|
|
424
|
-
* being flagged.
|
|
425
|
-
*/
|
|
426
|
-
accidentalTapThreshold = 400;
|
|
427
|
-
/**
|
|
428
|
-
* The hit window of this beatmap. Keep in mind that speed-changing mods do not change hit window length in game logic.
|
|
429
|
-
*/
|
|
430
|
-
hitWindow;
|
|
431
|
-
/**
|
|
432
|
-
* A reprocessed break points to match right on object time.
|
|
433
|
-
*
|
|
434
|
-
* This is used to increase detection accuracy since break points do not start right at the
|
|
435
|
-
* start of the hitobject before it and do not end right at the first hitobject after it.
|
|
436
|
-
*/
|
|
437
|
-
breakPointAccurateTimes = [];
|
|
438
|
-
/**
|
|
439
|
-
* A cursor occurrence nested array that only contains `movementType.DOWN` movement ID occurrences.
|
|
440
|
-
*
|
|
441
|
-
* Each index represents the cursor index.
|
|
442
|
-
*/
|
|
443
|
-
downCursorInstances = [];
|
|
444
|
-
/**
|
|
445
|
-
* Nerf factors from all sections that were three-fingered.
|
|
446
|
-
*/
|
|
447
|
-
nerfFactors = [];
|
|
448
|
-
/**
|
|
449
|
-
* Whether this score uses the Precise mod.
|
|
450
|
-
*/
|
|
451
|
-
isPrecise;
|
|
452
878
|
/**
|
|
453
879
|
* @param beatmap The beatmap to analyze.
|
|
454
880
|
* @param data The data of the replay.
|
|
455
881
|
* @param difficultyAttributes The difficulty attributes of the beatmap.
|
|
456
882
|
*/
|
|
457
883
|
constructor(beatmap, data, difficultyAttributes) {
|
|
884
|
+
/**
|
|
885
|
+
* Extended sections of the beatmap for drag detection.
|
|
886
|
+
*/
|
|
887
|
+
this.beatmapSections = [];
|
|
888
|
+
/**
|
|
889
|
+
* A reprocessed break points to match right on object time.
|
|
890
|
+
*
|
|
891
|
+
* This is used to increase detection accuracy since break points do not start right at the
|
|
892
|
+
* start of the hitobject before it and do not end right at the first hitobject after it.
|
|
893
|
+
*/
|
|
894
|
+
this.breakPointAccurateTimes = [];
|
|
895
|
+
/**
|
|
896
|
+
* A cursor occurrence nested array that only contains `movementType.DOWN` movement ID occurrences.
|
|
897
|
+
*
|
|
898
|
+
* Each index represents the cursor index.
|
|
899
|
+
*/
|
|
900
|
+
this.downCursorInstances = [];
|
|
901
|
+
/**
|
|
902
|
+
* Nerf factors from all sections that were three-fingered.
|
|
903
|
+
*/
|
|
904
|
+
this.nerfFactors = [];
|
|
458
905
|
this.beatmap = beatmap;
|
|
459
906
|
this.data = data;
|
|
460
907
|
this.difficultyAttributes = difficultyAttributes;
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
}).calculate({ mode: osuBase.Modes.droid, convertDroidOD: false });
|
|
466
|
-
this.isPrecise = this.difficultyAttributes.mods.some((m) => m instanceof osuBase.ModPrecise);
|
|
467
|
-
this.hitWindow = new osuBase.DroidHitWindow(stats.od);
|
|
468
|
-
this.strainNoteCount =
|
|
469
|
-
this.difficultyAttributes.possibleThreeFingeredSections.reduce((a, v) => a + v.lastObjectIndex - v.firstObjectIndex + 1, 0);
|
|
470
|
-
const circleSize = new osuBase.MapStats({
|
|
471
|
-
cs: this.beatmap.difficulty.cs,
|
|
472
|
-
mods: this.difficultyAttributes.mods,
|
|
473
|
-
}).calculate({ mode: osuBase.Modes.droid }).cs;
|
|
474
|
-
this.trueScale = (1 - (0.7 * (circleSize - 5)) / 5) / 2;
|
|
908
|
+
this.isHardRock = difficultyAttributes.mods.has(osuBase.ModHardRock);
|
|
909
|
+
this.hitWindow = difficultyAttributes.mods.has(osuBase.ModPrecise)
|
|
910
|
+
? new osuBase.PreciseDroidHitWindow(beatmap.difficulty.od)
|
|
911
|
+
: new osuBase.DroidHitWindow(beatmap.difficulty.od);
|
|
475
912
|
}
|
|
476
913
|
/**
|
|
477
914
|
* Checks whether a beatmap is eligible to be detected for 3-finger.
|
|
@@ -492,20 +929,13 @@ class ThreeFingerChecker {
|
|
|
492
929
|
* nerf factor, taking beatmap difficulty into account.
|
|
493
930
|
*/
|
|
494
931
|
check() {
|
|
495
|
-
if (this.
|
|
932
|
+
if (!ThreeFingerChecker.isEligibleToDetect(this.difficultyAttributes) ||
|
|
933
|
+
this.data.cursorMovement.filter((v) => v.occurrenceGroups.length > 0).length <= 3) {
|
|
496
934
|
return { is3Finger: false, penalty: 1 };
|
|
497
935
|
}
|
|
498
936
|
this.getAccurateBreakPoints();
|
|
499
937
|
this.filterCursorInstances();
|
|
500
|
-
if (this.downCursorInstances.filter((v) => v.length > 0).length <= 3) {
|
|
501
|
-
return { is3Finger: false, penalty: 1 };
|
|
502
|
-
}
|
|
503
938
|
this.getBeatmapSections();
|
|
504
|
-
this.detectDragPlay();
|
|
505
|
-
this.preventAccidentalTaps();
|
|
506
|
-
if (this.downCursorInstances.filter((v) => v.length > 0).length <= 3) {
|
|
507
|
-
return { is3Finger: false, penalty: 1 };
|
|
508
|
-
}
|
|
509
939
|
this.calculateNerfFactors();
|
|
510
940
|
const finalPenalty = this.calculateFinalPenalty();
|
|
511
941
|
return { is3Finger: finalPenalty > 1, penalty: finalPenalty };
|
|
@@ -514,46 +944,33 @@ class ThreeFingerChecker {
|
|
|
514
944
|
* Generates a new set of "accurate break points".
|
|
515
945
|
*
|
|
516
946
|
* This is done to increase detection accuracy since break points do not start right at the
|
|
517
|
-
*
|
|
947
|
+
* end of the hitobject before it and do not end right at the first hitobject after it.
|
|
518
948
|
*/
|
|
519
949
|
getAccurateBreakPoints() {
|
|
520
|
-
const
|
|
950
|
+
const objects = this.beatmap.hitObjects.objects;
|
|
521
951
|
const objectData = this.data.hitObjectData;
|
|
522
952
|
for (const breakPoint of this.beatmap.events.breaks) {
|
|
523
953
|
const beforeIndex = osuBase.MathUtils.clamp(objects.findIndex((o) => o.endTime >= breakPoint.startTime) - 1, 0, objects.length - 2);
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
let
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
default:
|
|
535
|
-
beforeIndexHitWindowLength = this.hitWindow.hitWindowFor50(this.isPrecise);
|
|
954
|
+
const objectBefore = objects[beforeIndex];
|
|
955
|
+
const objectBeforeData = objectData[beforeIndex];
|
|
956
|
+
let timeBefore = objectBefore.endTime;
|
|
957
|
+
if (objectBefore instanceof osuBase.Circle) {
|
|
958
|
+
if (objectBeforeData.result !== osuBase.HitResult.miss) {
|
|
959
|
+
timeBefore += objectBeforeData.accuracy;
|
|
960
|
+
}
|
|
961
|
+
else {
|
|
962
|
+
timeBefore += this.hitWindow.mehWindow;
|
|
963
|
+
}
|
|
536
964
|
}
|
|
537
|
-
timeBefore += beforeIndexHitWindowLength;
|
|
538
965
|
const afterIndex = beforeIndex + 1;
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
let
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
break;
|
|
546
|
-
case exports.HitResult.good:
|
|
547
|
-
afterIndexHitWindowLength = this.hitWindow.hitWindowFor100(this.isPrecise);
|
|
548
|
-
break;
|
|
549
|
-
default:
|
|
550
|
-
afterIndexHitWindowLength = this.hitWindow.hitWindowFor50(this.isPrecise);
|
|
966
|
+
const objectAfter = objects[afterIndex];
|
|
967
|
+
const objectAfterData = objectData[afterIndex];
|
|
968
|
+
let timeAfter = objectAfter.startTime;
|
|
969
|
+
if (objectAfter instanceof osuBase.Circle &&
|
|
970
|
+
objectAfterData.result !== osuBase.HitResult.miss) {
|
|
971
|
+
timeAfter += objectAfterData.accuracy;
|
|
551
972
|
}
|
|
552
|
-
|
|
553
|
-
this.breakPointAccurateTimes.push(new osuBase.BreakPoint({
|
|
554
|
-
startTime: timeBefore,
|
|
555
|
-
endTime: timeAfter,
|
|
556
|
-
}));
|
|
973
|
+
this.breakPointAccurateTimes.push(new osuBase.BreakPoint(timeBefore, timeAfter));
|
|
557
974
|
}
|
|
558
975
|
}
|
|
559
976
|
/**
|
|
@@ -562,48 +979,51 @@ class ThreeFingerChecker {
|
|
|
562
979
|
* This also filters cursors that are in break period or happen before start/after end of the beatmap.
|
|
563
980
|
*/
|
|
564
981
|
filterCursorInstances() {
|
|
565
|
-
const
|
|
982
|
+
const objects = this.beatmap.hitObjects.objects;
|
|
566
983
|
const objectData = this.data.hitObjectData;
|
|
567
984
|
const firstObjectResult = objectData[0].result;
|
|
568
985
|
const lastObjectResult = objectData.at(-1).result;
|
|
986
|
+
const firstObject = objects[0];
|
|
987
|
+
const lastObject = objects.at(-1);
|
|
569
988
|
// For sliders, automatically set hit window length to be as lenient as possible.
|
|
570
|
-
let firstObjectHitWindow = this.hitWindow.
|
|
571
|
-
if (
|
|
989
|
+
let firstObjectHitWindow = this.hitWindow.mehWindow;
|
|
990
|
+
if (firstObject instanceof osuBase.Circle) {
|
|
572
991
|
switch (firstObjectResult) {
|
|
573
|
-
case
|
|
574
|
-
firstObjectHitWindow = this.hitWindow.
|
|
992
|
+
case osuBase.HitResult.great:
|
|
993
|
+
firstObjectHitWindow = this.hitWindow.greatWindow;
|
|
575
994
|
break;
|
|
576
|
-
case
|
|
577
|
-
firstObjectHitWindow = this.hitWindow.
|
|
995
|
+
case osuBase.HitResult.good:
|
|
996
|
+
firstObjectHitWindow = this.hitWindow.okWindow;
|
|
578
997
|
break;
|
|
579
998
|
default:
|
|
580
|
-
firstObjectHitWindow = this.hitWindow.
|
|
999
|
+
firstObjectHitWindow = this.hitWindow.mehWindow;
|
|
581
1000
|
}
|
|
582
1001
|
}
|
|
583
1002
|
// For sliders, automatically set hit window length to be as lenient as possible.
|
|
584
|
-
let lastObjectHitWindow = this.hitWindow.
|
|
585
|
-
if (
|
|
1003
|
+
let lastObjectHitWindow = this.hitWindow.mehWindow;
|
|
1004
|
+
if (lastObject instanceof osuBase.Circle) {
|
|
586
1005
|
switch (lastObjectResult) {
|
|
587
|
-
case
|
|
588
|
-
lastObjectHitWindow = this.hitWindow.
|
|
1006
|
+
case osuBase.HitResult.great:
|
|
1007
|
+
lastObjectHitWindow = this.hitWindow.greatWindow;
|
|
589
1008
|
break;
|
|
590
|
-
case
|
|
591
|
-
lastObjectHitWindow = this.hitWindow.
|
|
1009
|
+
case osuBase.HitResult.good:
|
|
1010
|
+
lastObjectHitWindow = this.hitWindow.okWindow;
|
|
592
1011
|
break;
|
|
593
1012
|
default:
|
|
594
|
-
lastObjectHitWindow = this.hitWindow.
|
|
1013
|
+
lastObjectHitWindow = this.hitWindow.mehWindow;
|
|
595
1014
|
}
|
|
596
1015
|
}
|
|
1016
|
+
else if (lastObject instanceof osuBase.Slider) {
|
|
1017
|
+
lastObjectHitWindow = Math.min(lastObject.spanDuration, lastObjectHitWindow);
|
|
1018
|
+
}
|
|
597
1019
|
// These hit time uses hit window length as threshold.
|
|
598
1020
|
// This is because cursors aren't recorded exactly at hit time,
|
|
599
1021
|
// probably due to the game's behavior.
|
|
600
|
-
const firstObjectHitTime =
|
|
601
|
-
const lastObjectHitTime =
|
|
602
|
-
for (
|
|
603
|
-
const cursorInstance = this.data.cursorMovement[i];
|
|
1022
|
+
const firstObjectHitTime = firstObject.startTime - firstObjectHitWindow;
|
|
1023
|
+
const lastObjectHitTime = lastObject.startTime + lastObjectHitWindow;
|
|
1024
|
+
for (const cursorInstance of this.data.cursorMovement) {
|
|
604
1025
|
const validOccurrences = [];
|
|
605
|
-
for (
|
|
606
|
-
const group = cursorInstance.occurrenceGroups[j];
|
|
1026
|
+
for (const group of cursorInstance.occurrenceGroups) {
|
|
607
1027
|
if (group.startTime < firstObjectHitTime) {
|
|
608
1028
|
continue;
|
|
609
1029
|
}
|
|
@@ -624,329 +1044,231 @@ class ThreeFingerChecker {
|
|
|
624
1044
|
* detect dragged sections and improve detection speed.
|
|
625
1045
|
*/
|
|
626
1046
|
getBeatmapSections() {
|
|
1047
|
+
const beatmapObjects = this.beatmap.hitObjects.objects;
|
|
1048
|
+
const aimCursorGroupLookupIndices = osuBase.Utils.initializeArray(this.downCursorInstances.length, 0);
|
|
1049
|
+
// This intentionally starts from 1 because we need to look at the previous cursor.
|
|
1050
|
+
const aimCursorLookupIndices = osuBase.Utils.initializeArray(this.downCursorInstances.length, 1);
|
|
1051
|
+
const pressCursorLookupIndices = osuBase.Utils.initializeArray(this.downCursorInstances.length, 0);
|
|
627
1052
|
for (const section of this.difficultyAttributes
|
|
628
1053
|
.possibleThreeFingeredSections) {
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
for (let i = 0; i < this.beatmapSections.length; ++i) {
|
|
641
|
-
const dragIndex = this.checkDrag(this.beatmapSections[i]);
|
|
642
|
-
this.beatmapSections[i].dragFingerIndex = dragIndex;
|
|
643
|
-
this.beatmapSections[i].isDragged = dragIndex !== -1;
|
|
1054
|
+
const objects = [];
|
|
1055
|
+
for (let i = section.firstObjectIndex; i <= section.lastObjectIndex; ++i) {
|
|
1056
|
+
const object = beatmapObjects[i];
|
|
1057
|
+
const objectData = this.data.hitObjectData[i];
|
|
1058
|
+
objects.push({
|
|
1059
|
+
object: object,
|
|
1060
|
+
aimingCursorInstanceIndex: this.getObjectAimIndex(object, objectData, aimCursorGroupLookupIndices, aimCursorLookupIndices),
|
|
1061
|
+
pressingCursorInstanceIndex: this.getObjectPressIndex(object, objectData, pressCursorLookupIndices),
|
|
1062
|
+
});
|
|
1063
|
+
}
|
|
1064
|
+
this.beatmapSections.push(Object.assign(Object.assign({}, section), { objects: objects }));
|
|
644
1065
|
}
|
|
645
1066
|
}
|
|
646
1067
|
/**
|
|
647
|
-
*
|
|
648
|
-
*
|
|
649
|
-
* If the section is not dragged, -1 will be returned.
|
|
1068
|
+
* Obtains the index of the cursor that aimed the object at the nearest time.
|
|
650
1069
|
*
|
|
651
|
-
* @param
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
if (
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
firstObjectMinHitTime -= this.hitWindow.hitWindowFor50(this.isPrecise);
|
|
1070
|
+
* @param object The object to obtain the index for.
|
|
1071
|
+
* @param objectData The hit data of the object.
|
|
1072
|
+
* @param cursorInstanceIndices The cursor indices to start looking for the cursor instance from, to save computation time.
|
|
1073
|
+
* @param cursorGroupIndices The cursor indices to start looking for the cursor group from, to save computation time.
|
|
1074
|
+
* @param cursorIndices The cursor indices to start looking for the cursor from, to save computation time.
|
|
1075
|
+
* @returns The index of the cursor, -1 if the object was missed or it's a spinner.
|
|
1076
|
+
*/
|
|
1077
|
+
getObjectAimIndex(object, objectData, cursorGroupIndices, cursorIndices) {
|
|
1078
|
+
if (objectData.result === osuBase.HitResult.miss || object instanceof osuBase.Spinner) {
|
|
1079
|
+
return -1;
|
|
1080
|
+
}
|
|
1081
|
+
// Check for sliderbreaks and treat them as misses.
|
|
1082
|
+
if (object instanceof osuBase.Slider) {
|
|
1083
|
+
let lateHitThreshold = this.hitWindow.mehWindow;
|
|
1084
|
+
// Before replay version 8, the slider head's hit window is capped to the duration of the slider.
|
|
1085
|
+
if (this.data.replayVersion < 8) {
|
|
1086
|
+
lateHitThreshold = Math.min(lateHitThreshold, object.duration);
|
|
669
1087
|
}
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
}
|
|
674
|
-
let lastObjectMaxHitTime = lastObject.startTime;
|
|
675
|
-
if (lastObject instanceof osuBase.Circle) {
|
|
676
|
-
switch (objectData[section.lastObjectIndex].result) {
|
|
677
|
-
case exports.HitResult.great:
|
|
678
|
-
lastObjectMaxHitTime += this.hitWindow.hitWindowFor300(this.isPrecise);
|
|
679
|
-
break;
|
|
680
|
-
case exports.HitResult.good:
|
|
681
|
-
lastObjectMaxHitTime += this.hitWindow.hitWindowFor100(this.isPrecise);
|
|
682
|
-
break;
|
|
683
|
-
default:
|
|
684
|
-
lastObjectMaxHitTime += this.hitWindow.hitWindowFor50(this.isPrecise);
|
|
1088
|
+
if (objectData.accuracy < -this.hitWindow.mehWindow ||
|
|
1089
|
+
objectData.accuracy > lateHitThreshold) {
|
|
1090
|
+
return -1;
|
|
685
1091
|
}
|
|
686
1092
|
}
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
//
|
|
691
|
-
//
|
|
692
|
-
|
|
1093
|
+
const hitTime = object.startTime + objectData.accuracy;
|
|
1094
|
+
const objectPosition = object.stackedPosition;
|
|
1095
|
+
// We are maintaining the closest distance to the object.
|
|
1096
|
+
// This is because the radius that is calculated is using an estimation.
|
|
1097
|
+
// As such, it does not reflect the actual object radius in gameplay.
|
|
1098
|
+
let closestDistance = Number.POSITIVE_INFINITY;
|
|
1099
|
+
let nearestCursorIndex = -1;
|
|
1100
|
+
// Observe the cursor position at the object's hit time.
|
|
693
1101
|
for (let i = 0; i < this.data.cursorMovement.length; ++i) {
|
|
694
|
-
const
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
if (c.occurrenceGroups.filter((v) => v.startTime >= firstObjectMinHitTime &&
|
|
701
|
-
v.endTime <= lastObjectMaxHitTime).length === 0) {
|
|
702
|
-
continue;
|
|
703
|
-
}
|
|
704
|
-
// If this cursor instance doesn't move, it's not the cursor instance we want.
|
|
705
|
-
if (c.occurrenceGroups.filter((v) => v.moves.length > 0).length ===
|
|
706
|
-
0) {
|
|
707
|
-
continue;
|
|
708
|
-
}
|
|
709
|
-
cursorIndexes.push(i);
|
|
710
|
-
}
|
|
711
|
-
return this.findDragIndex(objects.slice(section.firstObjectIndex, section.lastObjectIndex + 1), objectData.slice(section.firstObjectIndex, section.lastObjectIndex + 1), cursorIndexes);
|
|
712
|
-
}
|
|
713
|
-
/**
|
|
714
|
-
* Finds the drag index of the section.
|
|
715
|
-
*
|
|
716
|
-
* @param sectionObjects The objects in the section.
|
|
717
|
-
* @param sectionReplayObjectData The hitobject data of all objects in the section.
|
|
718
|
-
* @param cursorIndexes The indexes of the cursor instance that has at least an occurrence in the section.
|
|
719
|
-
*/
|
|
720
|
-
findDragIndex(sectionObjects, sectionReplayObjectData, cursorIndexes) {
|
|
721
|
-
const hitWindow50 = this.hitWindow.hitWindowFor50(this.isPrecise);
|
|
722
|
-
for (let i = 0; i < sectionObjects.length && cursorIndexes.every((v) => v !== -1); ++i) {
|
|
723
|
-
let object = sectionObjects[i];
|
|
724
|
-
const objectData = sectionReplayObjectData[i];
|
|
725
|
-
if (object instanceof osuBase.Spinner ||
|
|
726
|
-
objectData.result === exports.HitResult.miss) {
|
|
727
|
-
continue;
|
|
728
|
-
}
|
|
729
|
-
// Exclude sliderbreaks.
|
|
730
|
-
if (object instanceof osuBase.Slider &&
|
|
731
|
-
objectData.accuracy === Math.floor(hitWindow50) + 13) {
|
|
732
|
-
continue;
|
|
733
|
-
}
|
|
734
|
-
if (object.droidScale !== this.trueScale) {
|
|
735
|
-
// Deep copy the instance so that we can assign scale.
|
|
736
|
-
object = osuBase.Utils.deepCopy(object);
|
|
737
|
-
object.droidScale = this.trueScale;
|
|
738
|
-
}
|
|
739
|
-
const objectPosition = object.getStackedPosition(osuBase.Modes.droid);
|
|
740
|
-
const hitTime = object.startTime + objectData.accuracy;
|
|
741
|
-
// Observe the cursor position at the object's hit time.
|
|
742
|
-
for (let j = 0; j < cursorIndexes.length; ++j) {
|
|
743
|
-
if (cursorIndexes[j] === -1) {
|
|
1102
|
+
const cursorData = this.data.cursorMovement[i];
|
|
1103
|
+
for (let j = cursorGroupIndices[i]; j < cursorData.occurrenceGroups.length; cursorGroupIndices[i] = ++j) {
|
|
1104
|
+
const cursorGroup = cursorData.occurrenceGroups[j];
|
|
1105
|
+
if (cursorGroup.endTime < hitTime) {
|
|
1106
|
+
// Reset cursor index pointer.
|
|
1107
|
+
cursorIndices[i] = 1;
|
|
744
1108
|
continue;
|
|
745
1109
|
}
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
if (!cursorGroup) {
|
|
749
|
-
continue;
|
|
1110
|
+
if (cursorGroup.startTime > hitTime) {
|
|
1111
|
+
break;
|
|
750
1112
|
}
|
|
751
1113
|
const cursors = cursorGroup.allOccurrences;
|
|
752
|
-
for (let k =
|
|
1114
|
+
for (let k = cursorIndices[i]; k < cursors.length; cursorIndices[i] = ++k) {
|
|
753
1115
|
const cursor = cursors[k];
|
|
754
1116
|
const prevCursor = cursors[k - 1];
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
}
|
|
758
|
-
if (prevCursor.time > object.startTime + hitWindow50) {
|
|
1117
|
+
// Cursor is past the object's hit time.
|
|
1118
|
+
if (prevCursor.time > hitTime) {
|
|
759
1119
|
break;
|
|
760
1120
|
}
|
|
761
|
-
|
|
1121
|
+
// Cursor is before the object's hit time.
|
|
1122
|
+
if (hitTime > cursor.time) {
|
|
1123
|
+
continue;
|
|
1124
|
+
}
|
|
1125
|
+
let distance;
|
|
1126
|
+
const currentPosition = this.getCursorPosition(cursor);
|
|
1127
|
+
const prevPosition = this.getCursorPosition(prevCursor);
|
|
762
1128
|
switch (cursor.id) {
|
|
763
1129
|
case exports.MovementType.up:
|
|
764
|
-
|
|
765
|
-
prevCursor.position.getDistance(objectPosition) <= object.getRadius(osuBase.Modes.droid);
|
|
1130
|
+
distance = prevPosition.getDistance(objectPosition);
|
|
766
1131
|
break;
|
|
767
|
-
case exports.MovementType.move:
|
|
1132
|
+
case exports.MovementType.move: {
|
|
768
1133
|
// Interpolate movement.
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
}
|
|
779
|
-
if (
|
|
780
|
-
|
|
781
|
-
|
|
1134
|
+
const t = (hitTime - prevCursor.time) /
|
|
1135
|
+
(cursor.time - prevCursor.time);
|
|
1136
|
+
const cursorPosition = osuBase.Interpolation.lerp(prevPosition, currentPosition, t);
|
|
1137
|
+
distance =
|
|
1138
|
+
objectPosition.getDistance(cursorPosition);
|
|
1139
|
+
break;
|
|
1140
|
+
}
|
|
1141
|
+
case exports.MovementType.down:
|
|
1142
|
+
continue;
|
|
1143
|
+
}
|
|
1144
|
+
if (closestDistance > distance) {
|
|
1145
|
+
closestDistance = distance;
|
|
1146
|
+
nearestCursorIndex = i;
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
// Reset cursor index pointer on end of group.
|
|
1150
|
+
if (cursorIndices[i] === cursors.length) {
|
|
1151
|
+
cursorIndices[i] = 1;
|
|
782
1152
|
}
|
|
1153
|
+
break;
|
|
783
1154
|
}
|
|
1155
|
+
// The previous object may still be hit with the same cursor group or cursor index.
|
|
1156
|
+
cursorGroupIndices[i] = Math.max(0, cursorGroupIndices[i] - 1);
|
|
1157
|
+
cursorIndices[i] = Math.max(1, cursorIndices[i] - 1);
|
|
784
1158
|
}
|
|
785
|
-
return
|
|
1159
|
+
return nearestCursorIndex;
|
|
786
1160
|
}
|
|
787
1161
|
/**
|
|
788
|
-
*
|
|
1162
|
+
* Obtains the index of the nearest cursor of which an object was pressed in terms of time.
|
|
789
1163
|
*
|
|
790
|
-
*
|
|
791
|
-
*
|
|
792
|
-
*
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
1164
|
+
* @param object The object to obtain the index for.
|
|
1165
|
+
* @param objectData The hit data of the object.
|
|
1166
|
+
* @param cursorLookupIndices The cursor indices to start looking for the cursor from, to save computation time.
|
|
1167
|
+
* @param excludedIndices The cursor indices that should not be checked.
|
|
1168
|
+
* @returns The index of the cursor, -1 if the object was missed or it's a spinner.
|
|
1169
|
+
*/
|
|
1170
|
+
getObjectPressIndex(object, objectData, cursorLookupIndices) {
|
|
1171
|
+
if (objectData.result === osuBase.HitResult.miss || object instanceof osuBase.Spinner) {
|
|
1172
|
+
return -1;
|
|
1173
|
+
}
|
|
1174
|
+
// Check for sliderbreaks and treat them as misses.
|
|
1175
|
+
if (object instanceof osuBase.Slider) {
|
|
1176
|
+
let lateHitThreshold = this.hitWindow.mehWindow;
|
|
1177
|
+
// Before replay version 8, the slider head's hit window is capped to the duration of the slider.
|
|
1178
|
+
if (this.data.replayVersion < 8) {
|
|
1179
|
+
lateHitThreshold = Math.min(lateHitThreshold, object.duration);
|
|
1180
|
+
}
|
|
1181
|
+
if (objectData.accuracy < -this.hitWindow.mehWindow ||
|
|
1182
|
+
objectData.accuracy > lateHitThreshold) {
|
|
1183
|
+
return -1;
|
|
1184
|
+
}
|
|
798
1185
|
}
|
|
799
|
-
const
|
|
800
|
-
|
|
1186
|
+
const hitTime = object.startTime + objectData.accuracy;
|
|
1187
|
+
let nearestCursorInstanceIndex = -1;
|
|
1188
|
+
let nearestTime = Number.POSITIVE_INFINITY;
|
|
801
1189
|
for (let i = 0; i < this.downCursorInstances.length; ++i) {
|
|
802
|
-
|
|
803
|
-
|
|
1190
|
+
const cursors = this.downCursorInstances[i];
|
|
1191
|
+
let cursorNearestTime = Number.POSITIVE_INFINITY;
|
|
1192
|
+
for (let j = cursorLookupIndices[i]; j < cursors.length; cursorLookupIndices[i] = ++j) {
|
|
1193
|
+
const cursor = cursors[j];
|
|
1194
|
+
if (cursor.time > hitTime) {
|
|
1195
|
+
break;
|
|
1196
|
+
}
|
|
1197
|
+
cursorNearestTime = hitTime - cursor.time;
|
|
804
1198
|
}
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
Math.ceil(objects.length / this.accidentalTapThreshold) &&
|
|
809
|
-
cursorInstances.length / totalCursorAmount <
|
|
810
|
-
this.threeFingerRatioThreshold * 2) {
|
|
811
|
-
--filledCursorAmount;
|
|
812
|
-
cursorInstances.length = 0;
|
|
1199
|
+
if (cursorNearestTime < nearestTime) {
|
|
1200
|
+
nearestCursorInstanceIndex = i;
|
|
1201
|
+
nearestTime = cursorNearestTime;
|
|
813
1202
|
}
|
|
814
|
-
this.downCursorInstances[i] = cursorInstances;
|
|
815
1203
|
}
|
|
1204
|
+
return nearestCursorInstanceIndex;
|
|
816
1205
|
}
|
|
817
1206
|
/**
|
|
818
1207
|
* Creates nerf factors by scanning through objects.
|
|
819
|
-
*
|
|
820
|
-
* This check will ignore all objects with speed strain below `strainThreshold`.
|
|
821
1208
|
*/
|
|
822
1209
|
calculateNerfFactors() {
|
|
823
|
-
const { objects } = this.beatmap.hitObjects;
|
|
824
|
-
const objectData = this.data.hitObjectData;
|
|
825
|
-
// We only filter cursor instances that are above the strain threshold.
|
|
826
|
-
// This minimalizes the amount of cursor instances to analyze.
|
|
827
1210
|
for (const beatmapSection of this.beatmapSections) {
|
|
828
|
-
const
|
|
829
|
-
const
|
|
830
|
-
(
|
|
831
|
-
exports.HitResult.miss
|
|
832
|
-
? objectData[beatmapSection.firstObjectIndex].accuracy
|
|
833
|
-
: -this.hitWindow.hitWindowFor50(this.isPrecise));
|
|
834
|
-
const endTime = objects[beatmapSection.lastObjectIndex].endTime +
|
|
835
|
-
(objectData[beatmapSection.lastObjectIndex].result !==
|
|
836
|
-
exports.HitResult.miss
|
|
837
|
-
? objectData[beatmapSection.lastObjectIndex].accuracy
|
|
838
|
-
: this.hitWindow.hitWindowFor50(this.isPrecise));
|
|
839
|
-
const cursorAmounts = [];
|
|
840
|
-
const cursorVectorTimes = [];
|
|
841
|
-
for (let i = 0; i < this.downCursorInstances.length; ++i) {
|
|
842
|
-
// Do not include drag cursor instance.
|
|
843
|
-
if (i === dragIndex) {
|
|
1211
|
+
const threeFingerCursorCounts = osuBase.Utils.initializeArray(Math.max(0, this.downCursorInstances.length - 2), 0);
|
|
1212
|
+
for (const object of beatmapSection.objects) {
|
|
1213
|
+
if (object.pressingCursorInstanceIndex === -1) {
|
|
844
1214
|
continue;
|
|
845
1215
|
}
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
1216
|
+
if (object.aimingCursorInstanceIndex < 3) {
|
|
1217
|
+
// The aim cursor is in the first three cursors. They are counted as non-3 finger.
|
|
1218
|
+
switch (object.pressingCursorInstanceIndex) {
|
|
1219
|
+
case 0:
|
|
1220
|
+
case 1:
|
|
1221
|
+
case 2:
|
|
1222
|
+
break;
|
|
1223
|
+
default:
|
|
1224
|
+
++threeFingerCursorCounts[object.pressingCursorInstanceIndex - 3];
|
|
1225
|
+
break;
|
|
856
1226
|
}
|
|
857
1227
|
}
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
.slice(fingerSplitIndex)
|
|
868
|
-
.reduce((acc, value) => acc + value, 0) /
|
|
869
|
-
cursorAmounts
|
|
870
|
-
.slice(0, fingerSplitIndex)
|
|
871
|
-
.reduce((acc, value) => acc + value, 0);
|
|
872
|
-
const similarPresses = [];
|
|
873
|
-
for (const cursorVectorTime of cursorVectorTimes) {
|
|
874
|
-
const pressIndex = similarPresses.findIndex((v) => v.vector.getDistance(cursorVectorTime.vector) <=
|
|
875
|
-
this.cursorDistancingDistanceThreshold);
|
|
876
|
-
if (pressIndex !== -1) {
|
|
877
|
-
if (cursorVectorTime.time -
|
|
878
|
-
similarPresses[pressIndex].lastTime >=
|
|
879
|
-
this.cursorDistancingTimeThreshold) {
|
|
880
|
-
similarPresses.splice(pressIndex, 1);
|
|
881
|
-
similarPresses.push({
|
|
882
|
-
vector: cursorVectorTime.vector,
|
|
883
|
-
count: 1,
|
|
884
|
-
lastTime: cursorVectorTime.time,
|
|
885
|
-
});
|
|
886
|
-
continue;
|
|
1228
|
+
else {
|
|
1229
|
+
// The aim cursor is somewhere else. only count the first 2 cursors as non-3 finger.
|
|
1230
|
+
switch (object.pressingCursorInstanceIndex) {
|
|
1231
|
+
case 0:
|
|
1232
|
+
case 1:
|
|
1233
|
+
break;
|
|
1234
|
+
default:
|
|
1235
|
+
++threeFingerCursorCounts[object.pressingCursorInstanceIndex - 2];
|
|
1236
|
+
break;
|
|
887
1237
|
}
|
|
888
|
-
similarPresses[pressIndex].vector = cursorVectorTime.vector;
|
|
889
|
-
similarPresses[pressIndex].lastTime = cursorVectorTime.time;
|
|
890
|
-
++similarPresses[pressIndex].count;
|
|
891
1238
|
}
|
|
892
|
-
else {
|
|
893
|
-
similarPresses.push({
|
|
894
|
-
vector: cursorVectorTime.vector,
|
|
895
|
-
count: 1,
|
|
896
|
-
lastTime: cursorVectorTime.time,
|
|
897
|
-
});
|
|
898
|
-
}
|
|
899
|
-
}
|
|
900
|
-
// Sort by highest count; assume the order is 3rd, 4th, 5th, ... finger
|
|
901
|
-
const validPresses = similarPresses
|
|
902
|
-
.filter((v) => v.count >= this.cursorDistancingCountThreshold)
|
|
903
|
-
.sort((a, b) => b.count - a.count)
|
|
904
|
-
.slice(2);
|
|
905
|
-
// Ignore cursor presses that are only 1 for now since they are very likely to be accidental
|
|
906
|
-
if ((threeFingerRatio > this.threeFingerRatioThreshold &&
|
|
907
|
-
cursorAmounts.filter((v) => v > 1).length >
|
|
908
|
-
fingerSplitIndex) ||
|
|
909
|
-
validPresses.length > 0) {
|
|
910
|
-
// Strain factor
|
|
911
|
-
const objectCount = beatmapSection.lastObjectIndex -
|
|
912
|
-
beatmapSection.firstObjectIndex +
|
|
913
|
-
1;
|
|
914
|
-
// We can ignore the first 3 (2 for drag) filled cursor instances
|
|
915
|
-
// since they are guaranteed not 3 finger.
|
|
916
|
-
const threeFingerCursorAmounts = cursorAmounts
|
|
917
|
-
.slice(fingerSplitIndex)
|
|
918
|
-
.filter((amount) => amount > 0);
|
|
919
|
-
// Finger factor applies more penalty if more fingers were used.
|
|
920
|
-
const fingerFactor = threeFingerRatio > this.threeFingerRatioThreshold
|
|
921
|
-
? threeFingerCursorAmounts.reduce((acc, value, index) => acc +
|
|
922
|
-
Math.pow(((index + 1) * value * objectCount) /
|
|
923
|
-
this.strainNoteCount, 0.9), 1)
|
|
924
|
-
: Math.pow(validPresses.reduce((acc, value, index) => acc +
|
|
925
|
-
Math.pow(((index + 1) *
|
|
926
|
-
(value.count /
|
|
927
|
-
(this
|
|
928
|
-
.cursorDistancingCountThreshold *
|
|
929
|
-
2)) *
|
|
930
|
-
objectCount) /
|
|
931
|
-
this.strainNoteCount, 0.2), 1), 0.2);
|
|
932
|
-
// Length factor applies more penalty if there are more 3-fingered object.
|
|
933
|
-
const lengthFactor = 1 + Math.pow(objectCount / this.strainNoteCount, 1.2);
|
|
934
|
-
this.nerfFactors.push({
|
|
935
|
-
strainFactor: Math.max(1, beatmapSection.sumStrain),
|
|
936
|
-
fingerFactor,
|
|
937
|
-
lengthFactor,
|
|
938
|
-
});
|
|
939
1239
|
}
|
|
1240
|
+
const threeFingerCursorCount = threeFingerCursorCounts.reduce((a, v) => a + v, 0);
|
|
1241
|
+
if (threeFingerCursorCount === 0) {
|
|
1242
|
+
continue;
|
|
1243
|
+
}
|
|
1244
|
+
const sectionObjectCount = beatmapSection.objects.length;
|
|
1245
|
+
const threeFingeredObjectRatio = threeFingerCursorCount / sectionObjectCount;
|
|
1246
|
+
const strainFactor = Math.max(1, beatmapSection.sumStrain * threeFingeredObjectRatio);
|
|
1247
|
+
// Finger factor applies more penalty if more fingers were used.
|
|
1248
|
+
const fingerFactor = threeFingerCursorCounts.reduce((acc, count, index) => acc +
|
|
1249
|
+
Math.pow(((index + 1) * count) / sectionObjectCount, 0.9), 1);
|
|
1250
|
+
// Length factor applies more penalty if there are more 3-fingered object.
|
|
1251
|
+
const lengthFactor = 1 + Math.pow(threeFingeredObjectRatio, 0.8);
|
|
1252
|
+
this.nerfFactors.push({
|
|
1253
|
+
strainFactor: strainFactor,
|
|
1254
|
+
fingerFactor: fingerFactor,
|
|
1255
|
+
lengthFactor: lengthFactor,
|
|
1256
|
+
});
|
|
940
1257
|
}
|
|
941
1258
|
}
|
|
942
1259
|
/**
|
|
943
1260
|
* Calculates the final penalty.
|
|
944
1261
|
*/
|
|
945
1262
|
calculateFinalPenalty() {
|
|
946
|
-
return (
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
1263
|
+
return this.nerfFactors.reduce((a, n) => a +
|
|
1264
|
+
0.015 *
|
|
1265
|
+
Math.pow(n.strainFactor * n.fingerFactor * n.lengthFactor, 1.05), 1);
|
|
1266
|
+
}
|
|
1267
|
+
getCursorPosition(cursor) {
|
|
1268
|
+
if (this.isHardRock) {
|
|
1269
|
+
return new osuBase.Vector2(cursor.position.x, osuBase.Playfield.baseSize.y - cursor.position.y);
|
|
1270
|
+
}
|
|
1271
|
+
return cursor.position;
|
|
950
1272
|
}
|
|
951
1273
|
}
|
|
952
1274
|
|
|
@@ -955,63 +1277,24 @@ class ThreeFingerChecker {
|
|
|
955
1277
|
*/
|
|
956
1278
|
class IndexedHitObject {
|
|
957
1279
|
/**
|
|
958
|
-
*
|
|
959
|
-
*
|
|
960
|
-
* If -1, the detection was unable to find any cursor that attempted to hit
|
|
961
|
-
* the hitobject or it did not meet the criteria for detection.
|
|
962
|
-
*/
|
|
963
|
-
cursorIndex;
|
|
964
|
-
/**
|
|
965
|
-
* The group index of the cursor within the cursor index that hits the hitobject.
|
|
966
|
-
*
|
|
967
|
-
* If -1, the detection was unable to find any cursor that attempted to hit
|
|
968
|
-
* the hitobject or it did not meet the criteria for detection.
|
|
969
|
-
*/
|
|
970
|
-
groupIndex;
|
|
971
|
-
/**
|
|
972
|
-
* The occurrence index within the group of the cursor within the cursor index that hits the hitobject.
|
|
973
|
-
*
|
|
974
|
-
* If -1, the detection was unable to find any cursor that attempted to hit
|
|
975
|
-
* the hitobject or it did not meet the criteria for detection.
|
|
976
|
-
*/
|
|
977
|
-
occurrenceIndex;
|
|
978
|
-
/**
|
|
979
|
-
* The angle of the movement of the cursor towards the next hitobject.
|
|
980
|
-
*/
|
|
981
|
-
angle;
|
|
982
|
-
/**
|
|
983
|
-
* If this is a slider, whether the slider was cheesed.
|
|
984
|
-
*/
|
|
985
|
-
sliderCheesed = false;
|
|
986
|
-
/**
|
|
987
|
-
* The underlying difficulty hitobject.
|
|
988
|
-
*/
|
|
989
|
-
object;
|
|
990
|
-
/**
|
|
991
|
-
* The position of the cursor at the end of this hitobject.
|
|
992
|
-
*
|
|
993
|
-
* Will be altered during detection.
|
|
994
|
-
*/
|
|
995
|
-
endCursorPosition;
|
|
996
|
-
/**
|
|
997
|
-
* Whether the hitobject is likely two-handed.
|
|
998
|
-
*/
|
|
999
|
-
is2Handed;
|
|
1000
|
-
/**
|
|
1001
|
-
* @param object The underlying difficulty hitobject.
|
|
1280
|
+
* @param object The underlying hitobject.
|
|
1002
1281
|
* @param cursorIndex The cursor index that moves towards the hitobject.
|
|
1003
1282
|
* @param groupIndex The group index of the cursor within the cursor index that hits the hitobject.
|
|
1004
1283
|
* @param occurrenceIndex The occurrence index within the group of the cursor within the cursor index that hits the hitobject.
|
|
1005
1284
|
* @param angle The angle of the movement of the cursor that moves towards the hitobject.
|
|
1006
1285
|
*/
|
|
1007
1286
|
constructor(object, cursorIndex, groupIndex, occurrenceIndex, angle, is2Handed) {
|
|
1287
|
+
/**
|
|
1288
|
+
* If this is a slider, whether the slider was cheesed.
|
|
1289
|
+
*/
|
|
1290
|
+
this.sliderCheesed = false;
|
|
1008
1291
|
this.object = object;
|
|
1009
1292
|
this.cursorIndex = cursorIndex;
|
|
1010
1293
|
this.groupIndex = groupIndex;
|
|
1011
1294
|
this.occurrenceIndex = occurrenceIndex;
|
|
1012
1295
|
this.angle = angle;
|
|
1013
1296
|
this.is2Handed = is2Handed;
|
|
1014
|
-
this.endCursorPosition = this.object.
|
|
1297
|
+
this.endCursorPosition = this.object.stackedEndPosition;
|
|
1015
1298
|
}
|
|
1016
1299
|
}
|
|
1017
1300
|
|
|
@@ -1020,41 +1303,26 @@ class IndexedHitObject {
|
|
|
1020
1303
|
* Utility to check whether or not a beatmap is two-handed.
|
|
1021
1304
|
*/
|
|
1022
1305
|
class TwoHandChecker {
|
|
1023
|
-
/**
|
|
1024
|
-
* The difficulty calculator that is being analyzed.
|
|
1025
|
-
*/
|
|
1026
|
-
calculator;
|
|
1027
|
-
/**
|
|
1028
|
-
* The data of the replay.
|
|
1029
|
-
*/
|
|
1030
|
-
data;
|
|
1031
|
-
/**
|
|
1032
|
-
* The hitobjects of the beatmap that have been assigned with their respective cursor index.
|
|
1033
|
-
*/
|
|
1034
|
-
indexedHitObjects = [];
|
|
1035
|
-
/**
|
|
1036
|
-
* The osu!droid hitwindow of the analyzed beatmap.
|
|
1037
|
-
*/
|
|
1038
|
-
hitWindow;
|
|
1039
|
-
/**
|
|
1040
|
-
* The 50 osu!droid hit window of the analyzed beatmap.
|
|
1041
|
-
*/
|
|
1042
|
-
hitWindow50;
|
|
1043
1306
|
// private csvString: string;
|
|
1044
1307
|
/**
|
|
1045
|
-
* @param
|
|
1308
|
+
* @param beatmap The beatmap to analyze.
|
|
1309
|
+
* @param attributes The difficulty attributes to analyze.
|
|
1046
1310
|
* @param data The data of the replay.
|
|
1047
1311
|
*/
|
|
1048
|
-
constructor(
|
|
1049
|
-
|
|
1312
|
+
constructor(beatmap, attributes, data) {
|
|
1313
|
+
/**
|
|
1314
|
+
* The hitobjects of the beatmap that have been assigned with their respective cursor index.
|
|
1315
|
+
*/
|
|
1316
|
+
this.indexedHitObjects = [];
|
|
1317
|
+
this.beatmap = beatmap;
|
|
1318
|
+
this.attributes = attributes;
|
|
1050
1319
|
this.data = data;
|
|
1051
|
-
const
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
this.
|
|
1057
|
-
this.hitWindow50 = this.hitWindow.hitWindowFor50(calculator.mods.some((m) => m instanceof osuBase.ModPrecise));
|
|
1320
|
+
const greatWindow = new osuBase.OsuHitWindow(attributes.overallDifficulty).greatWindow *
|
|
1321
|
+
attributes.clockRate;
|
|
1322
|
+
this.hitWindow = attributes.mods.has(osuBase.ModPrecise)
|
|
1323
|
+
? new osuBase.PreciseDroidHitWindow(osuBase.PreciseDroidHitWindow.greatWindowToOD(greatWindow))
|
|
1324
|
+
: new osuBase.DroidHitWindow(osuBase.DroidHitWindow.greatWindowToOD(greatWindow));
|
|
1325
|
+
this.isHardRock = attributes.mods.has(osuBase.ModHardRock);
|
|
1058
1326
|
// this.csvString = `Mods,${
|
|
1059
1327
|
// data.convertedMods.reduce((a, m) => a + m.acronym, "") || "NM"
|
|
1060
1328
|
// }\nCombo,${data.maxCombo}\nAccuracy,"${(
|
|
@@ -1114,26 +1382,36 @@ class TwoHandChecker {
|
|
|
1114
1382
|
// ),
|
|
1115
1383
|
// this.csvString
|
|
1116
1384
|
// );
|
|
1117
|
-
let twoHandedNoteCount = 0;
|
|
1118
|
-
const maxStrain = Math.max(
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1385
|
+
// let twoHandedNoteCount = 0;
|
|
1386
|
+
// const maxStrain = Math.max(
|
|
1387
|
+
// ...this.attributes.objects.map((v) => v.aimStrainWithSliders),
|
|
1388
|
+
// );
|
|
1389
|
+
// if (maxStrain) {
|
|
1390
|
+
// twoHandedNoteCount = this.indexedHitObjects.reduce(
|
|
1391
|
+
// (total, object) => {
|
|
1392
|
+
// if (!object.is2Handed) {
|
|
1393
|
+
// return total;
|
|
1394
|
+
// }
|
|
1395
|
+
// return (
|
|
1396
|
+
// total +
|
|
1397
|
+
// 1 /
|
|
1398
|
+
// (1 +
|
|
1399
|
+
// Math.exp(
|
|
1400
|
+
// -(
|
|
1401
|
+
// (object.object.aimStrainWithSliders /
|
|
1402
|
+
// maxStrain) *
|
|
1403
|
+
// 12 -
|
|
1404
|
+
// 6
|
|
1405
|
+
// ),
|
|
1406
|
+
// ))
|
|
1407
|
+
// );
|
|
1408
|
+
// },
|
|
1409
|
+
// 0,
|
|
1410
|
+
// );
|
|
1411
|
+
// }
|
|
1133
1412
|
return {
|
|
1134
|
-
is2Hand:
|
|
1135
|
-
|
|
1136
|
-
twoHandedNoteCount: twoHandedNoteCount,
|
|
1413
|
+
is2Hand: false, // 0 > this.attributes.aimNoteCount * 0.15,
|
|
1414
|
+
twoHandedNoteCount: 0,
|
|
1137
1415
|
};
|
|
1138
1416
|
}
|
|
1139
1417
|
/**
|
|
@@ -1141,8 +1419,7 @@ class TwoHandChecker {
|
|
|
1141
1419
|
*/
|
|
1142
1420
|
indexHitObjects() {
|
|
1143
1421
|
const indexes = [];
|
|
1144
|
-
for (let i = 0; i <
|
|
1145
|
-
Math.min(this.data.hitObjectData.length, this.calculator.objects.length); ++i) {
|
|
1422
|
+
for (let i = 0; i < this.data.hitObjectData.length; ++i) {
|
|
1146
1423
|
const indexedHitObject = this.getIndexedHitObject(i);
|
|
1147
1424
|
indexedHitObject.sliderCheesed = this.checkSliderCheesing(indexedHitObject, this.data.hitObjectData[i]);
|
|
1148
1425
|
indexes.push(indexedHitObject.cursorIndex);
|
|
@@ -1203,43 +1480,40 @@ class TwoHandChecker {
|
|
|
1203
1480
|
* @returns The cursor index that hits the given object, -1 if the index is not found, the object is a spinner, or the object was missed.
|
|
1204
1481
|
*/
|
|
1205
1482
|
getIndexedHitObject(objectIndex) {
|
|
1206
|
-
const
|
|
1207
|
-
const { object } = diffObject;
|
|
1483
|
+
const object = this.beatmap.hitObjects.objects[objectIndex];
|
|
1208
1484
|
// We don't care about the first object and spinners.
|
|
1209
1485
|
if (objectIndex === 0 || object instanceof osuBase.Spinner) {
|
|
1210
|
-
return new IndexedHitObject(
|
|
1486
|
+
return new IndexedHitObject(object, -1, -1, -1, null, false);
|
|
1211
1487
|
}
|
|
1212
1488
|
// We don't care if the aim strain is too low.
|
|
1213
1489
|
// if (diffObject.aimStrainWithSliders < 200) {
|
|
1214
1490
|
// return new IndexedHitObject(diffObject, -1, -1, -1, null, false);
|
|
1215
1491
|
// }
|
|
1216
|
-
const prevObject = this.
|
|
1492
|
+
const prevObject = this.beatmap.hitObjects.objects[objectIndex - 1];
|
|
1217
1493
|
const prevObjectData = this.data.hitObjectData[objectIndex - 1];
|
|
1218
1494
|
if (prevObject instanceof osuBase.Spinner ||
|
|
1219
|
-
prevObjectData.result ===
|
|
1220
|
-
return new IndexedHitObject(
|
|
1495
|
+
prevObjectData.result === osuBase.HitResult.miss) {
|
|
1496
|
+
return new IndexedHitObject(object, -1, -1, -1, null, false);
|
|
1221
1497
|
}
|
|
1222
|
-
const objectStartPosition = object.
|
|
1223
|
-
let prevObjectEndPosition = prevObject.
|
|
1498
|
+
const objectStartPosition = object.stackedPosition;
|
|
1499
|
+
let prevObjectEndPosition = prevObject.stackedEndPosition;
|
|
1224
1500
|
if (prevObject instanceof osuBase.Slider) {
|
|
1225
|
-
if (prevObject.
|
|
1226
|
-
const
|
|
1501
|
+
if (prevObject.distance > 0) {
|
|
1502
|
+
const endPosition = prevObject.stackedEndPosition;
|
|
1503
|
+
const lazyEndMovement = objectStartPosition.subtract(endPosition);
|
|
1227
1504
|
const actualEndMovement = objectStartPosition.subtract(prevObjectEndPosition);
|
|
1228
1505
|
if (lazyEndMovement.length < actualEndMovement.length) {
|
|
1229
|
-
prevObjectEndPosition =
|
|
1506
|
+
prevObjectEndPosition = endPosition;
|
|
1230
1507
|
}
|
|
1231
1508
|
}
|
|
1232
1509
|
else {
|
|
1233
|
-
prevObjectEndPosition = prevObject.
|
|
1510
|
+
prevObjectEndPosition = prevObject.stackedPosition;
|
|
1234
1511
|
}
|
|
1235
1512
|
}
|
|
1236
|
-
const prevToCurrentMovement = object
|
|
1237
|
-
.getStackedPosition(osuBase.Modes.droid)
|
|
1238
|
-
.subtract(prevObjectEndPosition);
|
|
1239
|
-
const radius = object.getRadius(osuBase.Modes.droid);
|
|
1513
|
+
const prevToCurrentMovement = object.stackedPosition.subtract(prevObjectEndPosition);
|
|
1240
1514
|
// Don't consider objects that are too close to each other.
|
|
1241
|
-
if (prevToCurrentMovement.length <= radius) {
|
|
1242
|
-
return new IndexedHitObject(
|
|
1515
|
+
if (prevToCurrentMovement.length <= object.radius) {
|
|
1516
|
+
return new IndexedHitObject(object, -1, -1, -1, null, false);
|
|
1243
1517
|
}
|
|
1244
1518
|
// The case for a one-handed object is that there will be a slight movement in the cursor towards
|
|
1245
1519
|
// the next object in fast patterns. We should not be worried about slow patterns as they will only
|
|
@@ -1259,12 +1533,12 @@ class TwoHandChecker {
|
|
|
1259
1533
|
this.indexedHitObjects[objectIndex - 1].endCursorPosition =
|
|
1260
1534
|
prevObjectInformation.position;
|
|
1261
1535
|
if (prevObjectInformation.position.x === Number.POSITIVE_INFINITY) {
|
|
1262
|
-
return new IndexedHitObject(
|
|
1536
|
+
return new IndexedHitObject(object, -1, -1, -1, null, false);
|
|
1263
1537
|
}
|
|
1264
1538
|
if (prevObjectInformation.cursorIndex ===
|
|
1265
1539
|
objectInformation.cursorIndex &&
|
|
1266
1540
|
prevObjectInformation.groupIndex === objectInformation.groupIndex) {
|
|
1267
|
-
return new IndexedHitObject(
|
|
1541
|
+
return new IndexedHitObject(object, objectInformation.cursorIndex, objectInformation.groupIndex, prevObjectInformation.occurrenceIndex, 0, false);
|
|
1268
1542
|
}
|
|
1269
1543
|
const cursorData = this.data.cursorMovement[prevObjectInformation.cursorIndex];
|
|
1270
1544
|
// There can be multiple angles to which the cursor moves towards the next object.
|
|
@@ -1276,13 +1550,15 @@ class TwoHandChecker {
|
|
|
1276
1550
|
for (let i = prevObjectInformation.occurrenceIndex + 1; i < cursors.length; ++i) {
|
|
1277
1551
|
const cursor = cursors[i];
|
|
1278
1552
|
const prevCursor = cursors[i - 1];
|
|
1279
|
-
|
|
1553
|
+
const currentPosition = this.getCursorPosition(cursor);
|
|
1554
|
+
const prevPosition = this.getCursorPosition(prevCursor);
|
|
1555
|
+
if (currentPosition.equals(prevPosition)) {
|
|
1280
1556
|
continue;
|
|
1281
1557
|
}
|
|
1282
1558
|
if (cursor.id === exports.MovementType.up) {
|
|
1283
1559
|
break;
|
|
1284
1560
|
}
|
|
1285
|
-
const currentMovement =
|
|
1561
|
+
const currentMovement = currentPosition.subtract(prevPosition);
|
|
1286
1562
|
const dot = prevToCurrentCursorMovement.dot(currentMovement);
|
|
1287
1563
|
const det = prevToCurrentCursorMovement.x * currentMovement.y -
|
|
1288
1564
|
prevToCurrentCursorMovement.y * currentMovement.x;
|
|
@@ -1344,9 +1620,9 @@ class TwoHandChecker {
|
|
|
1344
1620
|
// }
|
|
1345
1621
|
// }
|
|
1346
1622
|
if (!Number.isFinite(finalAngle)) {
|
|
1347
|
-
return new IndexedHitObject(
|
|
1623
|
+
return new IndexedHitObject(object, -1, -1, -1, null, false);
|
|
1348
1624
|
}
|
|
1349
|
-
return new IndexedHitObject(
|
|
1625
|
+
return new IndexedHitObject(object, prevObjectInformation.cursorIndex, prevObjectInformation.groupIndex, prevObjectInformation.occurrenceIndex, finalAngle, is2Handed);
|
|
1350
1626
|
}
|
|
1351
1627
|
/**
|
|
1352
1628
|
* Gets the position of the cursor that presses an object.
|
|
@@ -1355,9 +1631,9 @@ class TwoHandChecker {
|
|
|
1355
1631
|
* @returns The position of the cursor that presses the object.
|
|
1356
1632
|
*/
|
|
1357
1633
|
getCursorPositionForObjectStart(objectIndex) {
|
|
1358
|
-
const object = this.
|
|
1634
|
+
const object = this.beatmap.hitObjects.objects[objectIndex];
|
|
1359
1635
|
const data = this.data.hitObjectData[objectIndex];
|
|
1360
|
-
const objectPosition = object.
|
|
1636
|
+
const objectPosition = object.stackedPosition;
|
|
1361
1637
|
if (object instanceof osuBase.Spinner) {
|
|
1362
1638
|
return {
|
|
1363
1639
|
position: objectPosition,
|
|
@@ -1367,22 +1643,20 @@ class TwoHandChecker {
|
|
|
1367
1643
|
cursorTime: object.startTime,
|
|
1368
1644
|
};
|
|
1369
1645
|
}
|
|
1370
|
-
|
|
1371
|
-
let hitWindow = this.hitWindow50;
|
|
1372
|
-
const isPrecise = this.data.convertedMods.some((m) => m instanceof osuBase.ModPrecise);
|
|
1646
|
+
let hitWindow = this.hitWindow.mehWindow;
|
|
1373
1647
|
// For sliders, set the hit window to as lenient as possible.
|
|
1374
1648
|
if (object instanceof osuBase.Circle) {
|
|
1375
1649
|
switch (data.result) {
|
|
1376
|
-
case
|
|
1377
|
-
hitWindow = this.hitWindow.
|
|
1650
|
+
case osuBase.HitResult.great:
|
|
1651
|
+
hitWindow = this.hitWindow.greatWindow;
|
|
1378
1652
|
break;
|
|
1379
|
-
case
|
|
1380
|
-
hitWindow = this.hitWindow.
|
|
1653
|
+
case osuBase.HitResult.good:
|
|
1654
|
+
hitWindow = this.hitWindow.okWindow;
|
|
1381
1655
|
break;
|
|
1382
1656
|
}
|
|
1383
1657
|
}
|
|
1384
1658
|
// TODO: what to do for head sliderbreaks?
|
|
1385
|
-
let nearestPosition = new osuBase.Vector2(Number.POSITIVE_INFINITY
|
|
1659
|
+
let nearestPosition = new osuBase.Vector2(Number.POSITIVE_INFINITY);
|
|
1386
1660
|
let nearestCursorIndex = 0;
|
|
1387
1661
|
let nearestGroupIndex = 0;
|
|
1388
1662
|
let nearestCursorGroupIndex = 0;
|
|
@@ -1390,12 +1664,12 @@ class TwoHandChecker {
|
|
|
1390
1664
|
const minimumActiveTime = object.startTime - hitWindow;
|
|
1391
1665
|
const maximumActiveTime = object.startTime + hitWindow;
|
|
1392
1666
|
for (let i = 0; i < this.data.cursorMovement.length; ++i) {
|
|
1393
|
-
if (nearestPosition.getDistance(objectPosition) <= radius) {
|
|
1667
|
+
if (nearestPosition.getDistance(objectPosition) <= object.radius) {
|
|
1394
1668
|
break;
|
|
1395
1669
|
}
|
|
1396
1670
|
const cursorData = this.data.cursorMovement[i];
|
|
1397
1671
|
for (let j = 0; j < cursorData.occurrenceGroups.length; ++j) {
|
|
1398
|
-
if (nearestPosition.getDistance(objectPosition) <= radius) {
|
|
1672
|
+
if (nearestPosition.getDistance(objectPosition) <= object.radius) {
|
|
1399
1673
|
break;
|
|
1400
1674
|
}
|
|
1401
1675
|
const cursorGroup = cursorData.occurrenceGroups[j];
|
|
@@ -1407,13 +1681,14 @@ class TwoHandChecker {
|
|
|
1407
1681
|
}
|
|
1408
1682
|
// Validate the down press first.
|
|
1409
1683
|
const { down } = cursorGroup;
|
|
1410
|
-
|
|
1684
|
+
const downPosition = this.getCursorPosition(down);
|
|
1685
|
+
if (downPosition.getDistance(objectPosition) <= object.radius &&
|
|
1411
1686
|
Math.abs(down.time - object.startTime) <= hitWindow) {
|
|
1412
1687
|
if (objectIndex > 0) {
|
|
1413
|
-
const prevObject = this.
|
|
1688
|
+
const prevObject = this.beatmap.hitObjects.objects[objectIndex - 1];
|
|
1414
1689
|
if (down.time > prevObject.endTime) {
|
|
1415
1690
|
return {
|
|
1416
|
-
position:
|
|
1691
|
+
position: downPosition,
|
|
1417
1692
|
cursorIndex: i,
|
|
1418
1693
|
groupIndex: j,
|
|
1419
1694
|
occurrenceIndex: 0,
|
|
@@ -1423,7 +1698,7 @@ class TwoHandChecker {
|
|
|
1423
1698
|
}
|
|
1424
1699
|
else {
|
|
1425
1700
|
return {
|
|
1426
|
-
position:
|
|
1701
|
+
position: downPosition,
|
|
1427
1702
|
cursorIndex: i,
|
|
1428
1703
|
groupIndex: j,
|
|
1429
1704
|
occurrenceIndex: 0,
|
|
@@ -1443,7 +1718,7 @@ class TwoHandChecker {
|
|
|
1443
1718
|
}
|
|
1444
1719
|
let cursorPosition;
|
|
1445
1720
|
if (cursor.id === exports.MovementType.up) {
|
|
1446
|
-
cursorPosition = prevCursor
|
|
1721
|
+
cursorPosition = this.getCursorPosition(prevCursor);
|
|
1447
1722
|
const distance = cursorPosition.getDistance(objectPosition);
|
|
1448
1723
|
if (distance <
|
|
1449
1724
|
nearestPosition.getDistance(objectPosition)) {
|
|
@@ -1468,7 +1743,7 @@ class TwoHandChecker {
|
|
|
1468
1743
|
}
|
|
1469
1744
|
const t = (cursorGroup.down.time - prevCursor.time) /
|
|
1470
1745
|
(cursor.time - prevCursor.time);
|
|
1471
|
-
cursorPosition =
|
|
1746
|
+
cursorPosition = osuBase.Interpolation.lerp(this.getCursorPosition(prevCursor), this.getCursorPosition(cursor), t);
|
|
1472
1747
|
const distance = cursorPosition.getDistance(objectPosition);
|
|
1473
1748
|
if (distance <
|
|
1474
1749
|
nearestPosition.getDistance(objectPosition)) {
|
|
@@ -1478,12 +1753,13 @@ class TwoHandChecker {
|
|
|
1478
1753
|
nearestCursorGroupIndex = k;
|
|
1479
1754
|
nearestCursorTime = cursorGroup.down.time;
|
|
1480
1755
|
}
|
|
1481
|
-
if (distance <= radius) {
|
|
1756
|
+
if (distance <= object.radius) {
|
|
1482
1757
|
break;
|
|
1483
1758
|
}
|
|
1484
1759
|
}
|
|
1485
1760
|
}
|
|
1486
|
-
if (nearestPosition.getDistance(objectPosition) <=
|
|
1761
|
+
if (nearestPosition.getDistance(objectPosition) <=
|
|
1762
|
+
object.radius) {
|
|
1487
1763
|
break;
|
|
1488
1764
|
}
|
|
1489
1765
|
}
|
|
@@ -1514,24 +1790,25 @@ class TwoHandChecker {
|
|
|
1514
1790
|
* @returns The position of the cursor at the object's end position.
|
|
1515
1791
|
*/
|
|
1516
1792
|
getCursorPositionForObjectEnd(objectIndex) {
|
|
1517
|
-
const object = this.
|
|
1793
|
+
const object = this.beatmap.hitObjects.objects[objectIndex];
|
|
1518
1794
|
if (!(object instanceof osuBase.Slider)) {
|
|
1519
1795
|
return this.getCursorPositionForObjectStart(objectIndex);
|
|
1520
1796
|
}
|
|
1521
|
-
const nextObject = this.
|
|
1522
|
-
let objectEndPosition = object.
|
|
1523
|
-
if (object.
|
|
1524
|
-
const
|
|
1525
|
-
const
|
|
1797
|
+
const nextObject = this.beatmap.hitObjects.objects[objectIndex - 1];
|
|
1798
|
+
let objectEndPosition = object.stackedEndPosition;
|
|
1799
|
+
if (object.distance > 0 && nextObject) {
|
|
1800
|
+
const endPosition = object.stackedEndPosition;
|
|
1801
|
+
const nextStartPosition = nextObject.stackedPosition;
|
|
1802
|
+
const lazyEndMovement = nextStartPosition.subtract(endPosition);
|
|
1526
1803
|
const actualEndMovement = nextStartPosition.subtract(objectEndPosition);
|
|
1527
1804
|
if (lazyEndMovement.length < actualEndMovement.length) {
|
|
1528
|
-
objectEndPosition =
|
|
1805
|
+
objectEndPosition = endPosition;
|
|
1529
1806
|
}
|
|
1530
1807
|
}
|
|
1531
1808
|
else {
|
|
1532
|
-
objectEndPosition = object.
|
|
1809
|
+
objectEndPosition = object.stackedPosition;
|
|
1533
1810
|
}
|
|
1534
|
-
let nearestPosition = new osuBase.Vector2(Number.POSITIVE_INFINITY
|
|
1811
|
+
let nearestPosition = new osuBase.Vector2(Number.POSITIVE_INFINITY);
|
|
1535
1812
|
let nearestCursorIndex = 0;
|
|
1536
1813
|
let nearestGroupIndex = 0;
|
|
1537
1814
|
let nearestCursorGroupIndex = 0;
|
|
@@ -1552,18 +1829,18 @@ class TwoHandChecker {
|
|
|
1552
1829
|
let cursorPosition;
|
|
1553
1830
|
switch (cursor.id) {
|
|
1554
1831
|
case exports.MovementType.down:
|
|
1555
|
-
cursorPosition = cursor
|
|
1832
|
+
cursorPosition = this.getCursorPosition(cursor);
|
|
1556
1833
|
break;
|
|
1557
1834
|
case exports.MovementType.up: {
|
|
1558
1835
|
const prevCursor = cursors[k - 1];
|
|
1559
|
-
cursorPosition = prevCursor
|
|
1836
|
+
cursorPosition = this.getCursorPosition(prevCursor);
|
|
1560
1837
|
break;
|
|
1561
1838
|
}
|
|
1562
1839
|
case exports.MovementType.move: {
|
|
1563
1840
|
const prevCursor = cursors[k - 1];
|
|
1564
1841
|
const t = osuBase.MathUtils.clamp((object.endTime - prevCursor.time) /
|
|
1565
1842
|
(cursor.time - prevCursor.time), 0, 1);
|
|
1566
|
-
cursorPosition =
|
|
1843
|
+
cursorPosition = osuBase.Interpolation.lerp(this.getCursorPosition(prevCursor), this.getCursorPosition(cursor), t);
|
|
1567
1844
|
break;
|
|
1568
1845
|
}
|
|
1569
1846
|
}
|
|
@@ -1618,376 +1895,686 @@ class TwoHandChecker {
|
|
|
1618
1895
|
* @returns Whether the slider was cheesed.
|
|
1619
1896
|
*/
|
|
1620
1897
|
checkSliderCheesing(indexedHitObject, hitData) {
|
|
1621
|
-
if (!(indexedHitObject.object
|
|
1622
|
-
hitData.result ===
|
|
1898
|
+
if (!(indexedHitObject.object instanceof osuBase.Slider) ||
|
|
1899
|
+
hitData.result === osuBase.HitResult.miss ||
|
|
1623
1900
|
indexedHitObject.cursorIndex === -1) {
|
|
1624
1901
|
return false;
|
|
1625
1902
|
}
|
|
1626
1903
|
return false;
|
|
1627
1904
|
}
|
|
1905
|
+
getCursorPosition(cursor) {
|
|
1906
|
+
if (this.isHardRock) {
|
|
1907
|
+
return new osuBase.Vector2(cursor.position.x, osuBase.Playfield.baseSize.y - cursor.position.y);
|
|
1908
|
+
}
|
|
1909
|
+
return cursor.position;
|
|
1910
|
+
}
|
|
1628
1911
|
}
|
|
1629
1912
|
|
|
1630
1913
|
/**
|
|
1631
|
-
*
|
|
1914
|
+
* Represents a cursor's occurrence.
|
|
1632
1915
|
*/
|
|
1633
|
-
class
|
|
1916
|
+
class CursorOccurrence {
|
|
1917
|
+
constructor(time, x, y, id) {
|
|
1918
|
+
this.time = time;
|
|
1919
|
+
this.position = new osuBase.Vector2(x, y);
|
|
1920
|
+
this.id = id;
|
|
1921
|
+
}
|
|
1634
1922
|
/**
|
|
1635
|
-
*
|
|
1923
|
+
* Returns a string representation of this `CursorOccurrence`.
|
|
1636
1924
|
*/
|
|
1637
|
-
|
|
1925
|
+
toString() {
|
|
1926
|
+
let str = `${this.time.toString()}ms `;
|
|
1927
|
+
switch (this.id) {
|
|
1928
|
+
case exports.MovementType.down:
|
|
1929
|
+
str += "Down";
|
|
1930
|
+
break;
|
|
1931
|
+
case exports.MovementType.up:
|
|
1932
|
+
str += "Up";
|
|
1933
|
+
break;
|
|
1934
|
+
case exports.MovementType.move:
|
|
1935
|
+
str += "Move";
|
|
1936
|
+
break;
|
|
1937
|
+
}
|
|
1938
|
+
if (this.id !== exports.MovementType.up) {
|
|
1939
|
+
str += ` (${this.position.x.toFixed(2)}, ${this.position.y.toFixed(2)})`;
|
|
1940
|
+
}
|
|
1941
|
+
return str;
|
|
1942
|
+
}
|
|
1943
|
+
}
|
|
1944
|
+
|
|
1945
|
+
/**
|
|
1946
|
+
* Represents a group of cursor occurrences representing a cursor instance's
|
|
1947
|
+
* movement when a player places their finger on the screen.
|
|
1948
|
+
*/
|
|
1949
|
+
class CursorOccurrenceGroup {
|
|
1638
1950
|
/**
|
|
1639
|
-
* The
|
|
1951
|
+
* The cursor occurrence of movement type `movementType.DOWN`.
|
|
1640
1952
|
*/
|
|
1641
|
-
|
|
1953
|
+
get down() {
|
|
1954
|
+
return this._down;
|
|
1955
|
+
}
|
|
1642
1956
|
/**
|
|
1643
|
-
* The
|
|
1957
|
+
* The cursor occurrence of movement type `movementType.DOWN`.
|
|
1644
1958
|
*/
|
|
1645
|
-
|
|
1959
|
+
set down(value) {
|
|
1960
|
+
if (value.id !== exports.MovementType.down) {
|
|
1961
|
+
throw new TypeError("Attempting to set the down cursor occurrence to one with different movement type.");
|
|
1962
|
+
}
|
|
1963
|
+
this._down = value;
|
|
1964
|
+
}
|
|
1646
1965
|
/**
|
|
1647
|
-
* The
|
|
1966
|
+
* The cursor occurrences of movement type `movementType.MOVE`.
|
|
1648
1967
|
*/
|
|
1649
|
-
|
|
1968
|
+
get moves() {
|
|
1969
|
+
return this._moves;
|
|
1970
|
+
}
|
|
1650
1971
|
/**
|
|
1651
|
-
*
|
|
1652
|
-
*
|
|
1653
|
-
*
|
|
1972
|
+
* The cursor occurrence of movement type `movementType.UP`.
|
|
1973
|
+
*
|
|
1974
|
+
* May not exist, such as when the player holds their cursor until the end of a beatmap.
|
|
1654
1975
|
*/
|
|
1655
|
-
|
|
1656
|
-
this.
|
|
1657
|
-
this.data = data;
|
|
1658
|
-
this.difficultyAttributes = difficultyAttributes;
|
|
1659
|
-
const stats = new osuBase.MapStats({
|
|
1660
|
-
od: this.beatmap.difficulty.od,
|
|
1661
|
-
mods: this.difficultyAttributes.mods.filter((m) => m.isApplicableToDroid() &&
|
|
1662
|
-
!osuBase.ModUtil.speedChangingMods.some((v) => v.acronym === m.acronym)),
|
|
1663
|
-
}).calculate({ mode: osuBase.Modes.droid, convertDroidOD: false });
|
|
1664
|
-
this.hitWindow50 = new osuBase.DroidHitWindow(stats.od).hitWindowFor50(this.difficultyAttributes.mods.some((m) => m instanceof osuBase.ModPrecise));
|
|
1976
|
+
get up() {
|
|
1977
|
+
return this._up;
|
|
1665
1978
|
}
|
|
1666
1979
|
/**
|
|
1667
|
-
*
|
|
1980
|
+
* The cursor occurrence of movement type `movementType.UP`.
|
|
1981
|
+
*
|
|
1982
|
+
* May not exist, such as when the player holds their cursor until the end of a beatmap.
|
|
1668
1983
|
*/
|
|
1669
|
-
|
|
1670
|
-
if (
|
|
1671
|
-
(
|
|
1672
|
-
this.difficultyAttributes.flashlightSliderFactor === 1 &&
|
|
1673
|
-
this.difficultyAttributes.visualSliderFactor === 1)) {
|
|
1674
|
-
return {
|
|
1675
|
-
aimPenalty: 1,
|
|
1676
|
-
flashlightPenalty: 1,
|
|
1677
|
-
visualPenalty: 1,
|
|
1678
|
-
};
|
|
1984
|
+
set up(value) {
|
|
1985
|
+
if (value && value.id !== exports.MovementType.up) {
|
|
1986
|
+
throw new TypeError("Attempting to set the up cursor occurrence to one with different movement type.");
|
|
1679
1987
|
}
|
|
1680
|
-
|
|
1681
|
-
return this.calculateSliderCheesePenalty(cheesedDifficultyRatings);
|
|
1988
|
+
this._up = value;
|
|
1682
1989
|
}
|
|
1683
1990
|
/**
|
|
1684
|
-
*
|
|
1991
|
+
* The time at which this cursor occurrence group starts.
|
|
1685
1992
|
*/
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
const prevOccurrence = allOccurrences[k - 1];
|
|
1754
|
-
let distance = Number.POSITIVE_INFINITY;
|
|
1755
|
-
switch (occurrence.id) {
|
|
1756
|
-
case exports.MovementType.up:
|
|
1757
|
-
distance =
|
|
1758
|
-
prevOccurrence.position.getDistance(objectStartPosition);
|
|
1759
|
-
break;
|
|
1760
|
-
case exports.MovementType.move:
|
|
1761
|
-
for (let mSecPassed = Math.max(prevOccurrence.time, minTimeLimit); mSecPassed <=
|
|
1762
|
-
Math.min(occurrence.time, maxTimeLimit); ++mSecPassed) {
|
|
1763
|
-
const t = (mSecPassed - prevOccurrence.time) /
|
|
1764
|
-
(occurrence.time - prevOccurrence.time);
|
|
1765
|
-
const cursorPosition = new osuBase.Vector2(osuBase.Interpolation.lerp(prevOccurrence.position.x, occurrence.position.x, t), osuBase.Interpolation.lerp(prevOccurrence.position.y, occurrence.position.y, t));
|
|
1766
|
-
distance =
|
|
1767
|
-
cursorPosition.getDistance(objectStartPosition);
|
|
1768
|
-
if (closestDistance > distance) {
|
|
1769
|
-
closestDistance = distance;
|
|
1770
|
-
closestIndex = j;
|
|
1771
|
-
}
|
|
1772
|
-
if (closestDistance <=
|
|
1773
|
-
acceptableRadius / 2) {
|
|
1774
|
-
break;
|
|
1775
|
-
}
|
|
1776
|
-
}
|
|
1777
|
-
}
|
|
1778
|
-
if (closestDistance > distance) {
|
|
1779
|
-
closestDistance = distance;
|
|
1780
|
-
closestIndex = j;
|
|
1781
|
-
}
|
|
1782
|
-
if (closestDistance <= acceptableRadius / 2) {
|
|
1783
|
-
break;
|
|
1784
|
-
}
|
|
1785
|
-
}
|
|
1786
|
-
}
|
|
1787
|
-
closestDistances.push(closestDistance);
|
|
1788
|
-
closestGroupIndices.push(closestIndex);
|
|
1789
|
-
if (cursorLoopIndices[i] > 0) {
|
|
1790
|
-
// Decrement the index. The previous group may also have a role on the next slider.
|
|
1791
|
-
--cursorLoopIndices[i];
|
|
1792
|
-
}
|
|
1793
|
-
}
|
|
1794
|
-
const cursorIndex = closestDistances.indexOf(Math.min(...closestDistances));
|
|
1795
|
-
const closestDistance = closestDistances[cursorIndex];
|
|
1796
|
-
if (closestDistance > acceptableRadius / 2) {
|
|
1797
|
-
cheesedDifficultyRatings.push(difficultSlider.difficultyRating);
|
|
1798
|
-
continue;
|
|
1993
|
+
get startTime() {
|
|
1994
|
+
return this._down.time;
|
|
1995
|
+
}
|
|
1996
|
+
/**
|
|
1997
|
+
* The time at which this cursor occurrence group ends.
|
|
1998
|
+
*/
|
|
1999
|
+
get endTime() {
|
|
2000
|
+
var _a, _b, _c, _d;
|
|
2001
|
+
return (_d = (_b = (_a = this._up) === null || _a === void 0 ? void 0 : _a.time) !== null && _b !== void 0 ? _b : (_c = this._moves.at(-1)) === null || _c === void 0 ? void 0 : _c.time) !== null && _d !== void 0 ? _d : this._down.time;
|
|
2002
|
+
}
|
|
2003
|
+
/**
|
|
2004
|
+
* The duration this cursor occurrence group is active for.
|
|
2005
|
+
*/
|
|
2006
|
+
get duration() {
|
|
2007
|
+
return this.endTime - this.startTime;
|
|
2008
|
+
}
|
|
2009
|
+
/**
|
|
2010
|
+
* All cursor occurrences in this group.
|
|
2011
|
+
*
|
|
2012
|
+
* This iterates all occurrences and as such should be used sparingly or stored locally.
|
|
2013
|
+
*/
|
|
2014
|
+
get allOccurrences() {
|
|
2015
|
+
const cursors = [this._down, ...this._moves];
|
|
2016
|
+
if (this._up) {
|
|
2017
|
+
cursors.push(this._up);
|
|
2018
|
+
}
|
|
2019
|
+
return cursors;
|
|
2020
|
+
}
|
|
2021
|
+
constructor(down, moves, up) {
|
|
2022
|
+
this._down = down;
|
|
2023
|
+
this._moves = moves;
|
|
2024
|
+
// Re-set down cursor occurrence for checking.
|
|
2025
|
+
this.down = down;
|
|
2026
|
+
this.up = up;
|
|
2027
|
+
}
|
|
2028
|
+
/**
|
|
2029
|
+
* Determines whether this cursor occurrence group is active at the specified time.
|
|
2030
|
+
*
|
|
2031
|
+
* @param time The time.
|
|
2032
|
+
* @returns Whether this cursor occurrence group is active at the specified time.
|
|
2033
|
+
*/
|
|
2034
|
+
isActiveAt(time) {
|
|
2035
|
+
return time >= this.startTime && time <= this.endTime;
|
|
2036
|
+
}
|
|
2037
|
+
/**
|
|
2038
|
+
* Finds the cursor occurrence that is active at a given time.
|
|
2039
|
+
*
|
|
2040
|
+
* @param time The time.
|
|
2041
|
+
* @returns The cursor occurrence at the given time, `null` if not found.
|
|
2042
|
+
*/
|
|
2043
|
+
cursorAt(time) {
|
|
2044
|
+
var _a;
|
|
2045
|
+
if (!this.isActiveAt(time)) {
|
|
2046
|
+
return null;
|
|
2047
|
+
}
|
|
2048
|
+
if (this._down.time === time) {
|
|
2049
|
+
return this._down;
|
|
2050
|
+
}
|
|
2051
|
+
if (((_a = this._up) === null || _a === void 0 ? void 0 : _a.time) === time) {
|
|
2052
|
+
return this._up;
|
|
2053
|
+
}
|
|
2054
|
+
let l = 0;
|
|
2055
|
+
let r = this._moves.length - 2;
|
|
2056
|
+
while (l <= r) {
|
|
2057
|
+
const pivot = l + ((r - l) >> 1);
|
|
2058
|
+
if (this._moves[pivot].time < time) {
|
|
2059
|
+
l = pivot + 1;
|
|
1799
2060
|
}
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
// Track cursor movement to see if it lands on every tick.
|
|
1803
|
-
let occurrenceLoopIndex = 1;
|
|
1804
|
-
const { allOccurrences } = group;
|
|
1805
|
-
for (let i = 1; i < object.nestedHitObjects.length; ++i) {
|
|
1806
|
-
if (isCheesed) {
|
|
1807
|
-
break;
|
|
1808
|
-
}
|
|
1809
|
-
const tickWasHit = objectData.tickset[i - 1];
|
|
1810
|
-
if (!tickWasHit) {
|
|
1811
|
-
continue;
|
|
1812
|
-
}
|
|
1813
|
-
const nestedObject = object.nestedHitObjects[i];
|
|
1814
|
-
nestedObject.droidScale = scale;
|
|
1815
|
-
// Special treatment for slider tail where its treated as a "legacy tail" in osu!standard.
|
|
1816
|
-
// In that case, its time is assumed to be 36ms behind the slider's end time. However, that
|
|
1817
|
-
// is not the case for osu!droid.
|
|
1818
|
-
if (nestedObject instanceof osuBase.SliderTail) {
|
|
1819
|
-
nestedObject.startTime = object.endTime;
|
|
1820
|
-
nestedObject.endTime = object.endTime;
|
|
1821
|
-
}
|
|
1822
|
-
const nestedPosition = nestedObject.getStackedPosition(osuBase.Modes.droid);
|
|
1823
|
-
while (occurrenceLoopIndex < allOccurrences.length &&
|
|
1824
|
-
allOccurrences[occurrenceLoopIndex].time <
|
|
1825
|
-
nestedObject.startTime) {
|
|
1826
|
-
++occurrenceLoopIndex;
|
|
1827
|
-
}
|
|
1828
|
-
if (occurrenceLoopIndex === allOccurrences.length) {
|
|
1829
|
-
continue;
|
|
1830
|
-
}
|
|
1831
|
-
const occurrence = allOccurrences[occurrenceLoopIndex];
|
|
1832
|
-
const prevOccurrence = allOccurrences[occurrenceLoopIndex - 1];
|
|
1833
|
-
switch (occurrence.id) {
|
|
1834
|
-
case exports.MovementType.move: {
|
|
1835
|
-
// Interpolate cursor position during nested object time.
|
|
1836
|
-
const t = (nestedObject.startTime - prevOccurrence.time) /
|
|
1837
|
-
(occurrence.time - prevOccurrence.time);
|
|
1838
|
-
const cursorPosition = new osuBase.Vector2(osuBase.Interpolation.lerp(prevOccurrence.position.x, occurrence.position.x, t), osuBase.Interpolation.lerp(prevOccurrence.position.y, occurrence.position.y, t));
|
|
1839
|
-
const distance = cursorPosition.getDistance(nestedPosition);
|
|
1840
|
-
isCheesed = distance > acceptableRadius;
|
|
1841
|
-
break;
|
|
1842
|
-
}
|
|
1843
|
-
case exports.MovementType.up:
|
|
1844
|
-
isCheesed =
|
|
1845
|
-
prevOccurrence.position.getDistance(nestedPosition) > acceptableRadius;
|
|
1846
|
-
}
|
|
2061
|
+
else if (this._moves[pivot].time > time) {
|
|
2062
|
+
r = pivot - 1;
|
|
1847
2063
|
}
|
|
1848
|
-
|
|
1849
|
-
|
|
2064
|
+
else {
|
|
2065
|
+
return this._moves[pivot];
|
|
1850
2066
|
}
|
|
1851
2067
|
}
|
|
1852
|
-
|
|
2068
|
+
// l will be the first cursor occurrence with time > this._moves[l].time, but we want the one before it
|
|
2069
|
+
return this._moves[l - 1];
|
|
1853
2070
|
}
|
|
1854
2071
|
/**
|
|
1855
|
-
*
|
|
2072
|
+
* Returns a string representation of this `CursorOccurrenceGroup`.
|
|
1856
2073
|
*/
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
return {
|
|
1860
|
-
aimPenalty: Math.max(this.difficultyAttributes.sliderFactor, Math.pow(1 -
|
|
1861
|
-
summedDifficultyRating *
|
|
1862
|
-
this.difficultyAttributes.sliderFactor, 2)),
|
|
1863
|
-
flashlightPenalty: Math.max(this.difficultyAttributes.flashlightSliderFactor, Math.pow(1 -
|
|
1864
|
-
summedDifficultyRating *
|
|
1865
|
-
this.difficultyAttributes.flashlightSliderFactor, 2)),
|
|
1866
|
-
visualPenalty: Math.max(this.difficultyAttributes.visualSliderFactor, Math.pow(1 -
|
|
1867
|
-
summedDifficultyRating *
|
|
1868
|
-
this.difficultyAttributes.visualSliderFactor, 2)),
|
|
1869
|
-
};
|
|
2074
|
+
toString() {
|
|
2075
|
+
return `Down: ${this.down.time.toString()}ms (${this.down.position.x.toFixed(2)}, ${this.down.position.y.toFixed(2)}) | Moves: ${this.moves.length.toString()} | Up: ${this.up ? `${this.up.time.toString()}ms` : "N/A"}`;
|
|
1870
2076
|
}
|
|
1871
2077
|
}
|
|
1872
2078
|
|
|
1873
2079
|
/**
|
|
1874
|
-
*
|
|
2080
|
+
* Represents a cursor instance in an osu!droid replay.
|
|
1875
2081
|
*
|
|
1876
|
-
*
|
|
2082
|
+
* Stores cursor movement data in the form of `CursorOccurrenceGroup`s.
|
|
1877
2083
|
*
|
|
1878
|
-
*
|
|
2084
|
+
* This is used when analyzing replays using replay analyzer.
|
|
1879
2085
|
*/
|
|
1880
|
-
class
|
|
1881
|
-
/**
|
|
1882
|
-
* The score ID of the replay.
|
|
1883
|
-
*/
|
|
1884
|
-
scoreID;
|
|
1885
|
-
/**
|
|
1886
|
-
* The original odr file of the replay.
|
|
1887
|
-
*/
|
|
1888
|
-
originalODR = null;
|
|
1889
|
-
/**
|
|
1890
|
-
* The fixed odr file of the replay.
|
|
1891
|
-
*/
|
|
1892
|
-
fixedODR = null;
|
|
2086
|
+
class CursorData {
|
|
1893
2087
|
/**
|
|
1894
|
-
*
|
|
2088
|
+
* The time at which the first occurrence of this cursor instance occurs.
|
|
2089
|
+
*
|
|
2090
|
+
* Will return `null` if there are no occurrences.
|
|
1895
2091
|
*/
|
|
1896
|
-
|
|
2092
|
+
get earliestOccurrenceTime() {
|
|
2093
|
+
var _a, _b;
|
|
2094
|
+
return (_b = (_a = this.occurrenceGroups.at(0)) === null || _a === void 0 ? void 0 : _a.startTime) !== null && _b !== void 0 ? _b : null;
|
|
2095
|
+
}
|
|
1897
2096
|
/**
|
|
1898
|
-
*
|
|
2097
|
+
* The time at which the latest occurrence of this cursor instance occurs.
|
|
2098
|
+
*
|
|
2099
|
+
* Will return `null` if there are no occurrences.
|
|
1899
2100
|
*/
|
|
1900
|
-
|
|
2101
|
+
get latestOccurrenceTime() {
|
|
2102
|
+
var _a, _b;
|
|
2103
|
+
return (_b = (_a = this.occurrenceGroups.at(-1)) === null || _a === void 0 ? void 0 : _a.endTime) !== null && _b !== void 0 ? _b : null;
|
|
2104
|
+
}
|
|
1901
2105
|
/**
|
|
1902
|
-
* The
|
|
2106
|
+
* The amount of cursor occurrences of this cursor instance.
|
|
1903
2107
|
*/
|
|
1904
|
-
|
|
2108
|
+
get totalOccurrences() {
|
|
2109
|
+
return this.occurrenceGroups.reduce((a, v) => {
|
|
2110
|
+
// Down cursor.
|
|
2111
|
+
++a;
|
|
2112
|
+
// Move cursors.
|
|
2113
|
+
a += v.moves.length;
|
|
2114
|
+
if (v.up) {
|
|
2115
|
+
// Up cursor.
|
|
2116
|
+
++a;
|
|
2117
|
+
}
|
|
2118
|
+
return a;
|
|
2119
|
+
}, 0);
|
|
2120
|
+
}
|
|
1905
2121
|
/**
|
|
1906
|
-
*
|
|
2122
|
+
* All cursor occurrences of this cursor instnace.
|
|
2123
|
+
*
|
|
2124
|
+
* This iterates all occurrence groups and as such should be used sparingly or stored locally.
|
|
1907
2125
|
*/
|
|
1908
|
-
|
|
2126
|
+
get allOccurrences() {
|
|
2127
|
+
return this.occurrenceGroups.flatMap((v) => v.allOccurrences);
|
|
2128
|
+
}
|
|
2129
|
+
constructor(values) {
|
|
2130
|
+
/**
|
|
2131
|
+
* The occurrence groups of this cursor instance.
|
|
2132
|
+
*/
|
|
2133
|
+
this.occurrenceGroups = [];
|
|
2134
|
+
let downOccurrence = null;
|
|
2135
|
+
let moveOccurrences = [];
|
|
2136
|
+
for (let i = 0; i < values.size; ++i) {
|
|
2137
|
+
const occurrence = new CursorOccurrence(values.time[i], values.x[i], values.y[i], values.id[i]);
|
|
2138
|
+
switch (occurrence.id) {
|
|
2139
|
+
case exports.MovementType.down:
|
|
2140
|
+
downOccurrence = occurrence;
|
|
2141
|
+
break;
|
|
2142
|
+
case exports.MovementType.move:
|
|
2143
|
+
moveOccurrences.push(occurrence);
|
|
2144
|
+
break;
|
|
2145
|
+
case exports.MovementType.up:
|
|
2146
|
+
if (downOccurrence) {
|
|
2147
|
+
this.occurrenceGroups.push(new CursorOccurrenceGroup(downOccurrence, moveOccurrences, occurrence));
|
|
2148
|
+
downOccurrence = null;
|
|
2149
|
+
}
|
|
2150
|
+
moveOccurrences = [];
|
|
2151
|
+
}
|
|
2152
|
+
}
|
|
2153
|
+
// Add the final cursor occurrence group as the loop may not catch it for special cases.
|
|
2154
|
+
if (downOccurrence && moveOccurrences.length > 0) {
|
|
2155
|
+
this.occurrenceGroups.push(new CursorOccurrenceGroup(downOccurrence, moveOccurrences));
|
|
2156
|
+
}
|
|
2157
|
+
}
|
|
2158
|
+
}
|
|
2159
|
+
|
|
2160
|
+
/**
|
|
2161
|
+
* Represents a replay data in an osu!droid replay version 1 and 2.
|
|
2162
|
+
*
|
|
2163
|
+
* Stores generic information about an osu!droid replay.
|
|
2164
|
+
*
|
|
2165
|
+
* This is used when analyzing replays using replay analyzer.
|
|
2166
|
+
*/
|
|
2167
|
+
class ReplayData {
|
|
2168
|
+
constructor(values) {
|
|
2169
|
+
this.replayVersion = values.replayVersion;
|
|
2170
|
+
this.folderName = values.folderName;
|
|
2171
|
+
this.fileName = values.fileName;
|
|
2172
|
+
this.hash = values.hash;
|
|
2173
|
+
this.accuracy = values.accuracy;
|
|
2174
|
+
this.rank = values.rank;
|
|
2175
|
+
this.hit300k = values.hit300k;
|
|
2176
|
+
this.hit100k = values.hit100k;
|
|
2177
|
+
this.cursorMovement = values.cursorMovement;
|
|
2178
|
+
this.hitObjectData = values.hitObjectData;
|
|
2179
|
+
}
|
|
1909
2180
|
/**
|
|
1910
|
-
*
|
|
2181
|
+
* Whether the replay's version is 3 or later.
|
|
1911
2182
|
*/
|
|
1912
|
-
|
|
2183
|
+
isReplayV3() {
|
|
2184
|
+
return this.replayVersion >= 3;
|
|
2185
|
+
}
|
|
2186
|
+
}
|
|
2187
|
+
|
|
2188
|
+
/**
|
|
2189
|
+
* Represents a replay data for replay version 3 and later.
|
|
2190
|
+
*
|
|
2191
|
+
* Stores generic information about an osu!droid replay.
|
|
2192
|
+
*
|
|
2193
|
+
* This is used when analyzing replays using replay analyzer.
|
|
2194
|
+
*/
|
|
2195
|
+
class ReplayV3Data extends ReplayData {
|
|
1913
2196
|
/**
|
|
1914
|
-
*
|
|
2197
|
+
* The total score achieved in the play, after applying score multiplier from mods.
|
|
1915
2198
|
*/
|
|
1916
|
-
|
|
2199
|
+
get totalScore() {
|
|
2200
|
+
var _a;
|
|
2201
|
+
if (this.replayVersion < 8) {
|
|
2202
|
+
return this.score;
|
|
2203
|
+
}
|
|
2204
|
+
(_a = this.scoreMultiplier) !== null && _a !== void 0 ? _a : (this.scoreMultiplier = osuBase.ModUtil.calculateScoreMultiplier(this.convertedMods.values(), osuBase.Modes.droid));
|
|
2205
|
+
return Math.round(Math.fround(this.score * this.scoreMultiplier));
|
|
2206
|
+
}
|
|
2207
|
+
constructor(values) {
|
|
2208
|
+
super(values);
|
|
2209
|
+
this.time = values.time;
|
|
2210
|
+
this.score = values.score;
|
|
2211
|
+
this.maxCombo = values.maxCombo;
|
|
2212
|
+
this.isFullCombo = values.isFullCombo;
|
|
2213
|
+
this.playerName = values.playerName;
|
|
2214
|
+
this.convertedMods = values.convertedMods;
|
|
2215
|
+
}
|
|
2216
|
+
}
|
|
2217
|
+
|
|
2218
|
+
/******************************************************************************
|
|
2219
|
+
Copyright (c) Microsoft Corporation.
|
|
2220
|
+
|
|
2221
|
+
Permission to use, copy, modify, and/or distribute this software for any
|
|
2222
|
+
purpose with or without fee is hereby granted.
|
|
2223
|
+
|
|
2224
|
+
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
|
2225
|
+
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
|
2226
|
+
AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
|
2227
|
+
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
|
2228
|
+
LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
|
2229
|
+
OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
|
2230
|
+
PERFORMANCE OF THIS SOFTWARE.
|
|
2231
|
+
***************************************************************************** */
|
|
2232
|
+
/* global Reflect, Promise, SuppressedError, Symbol, Iterator */
|
|
2233
|
+
|
|
2234
|
+
|
|
2235
|
+
function __awaiter(thisArg, _arguments, P, generator) {
|
|
2236
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
2237
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
2238
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
2239
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
2240
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
2241
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
2242
|
+
});
|
|
2243
|
+
}
|
|
2244
|
+
|
|
2245
|
+
typeof SuppressedError === "function" ? SuppressedError : function (error, suppressed, message) {
|
|
2246
|
+
var e = new Error(message);
|
|
2247
|
+
return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
|
|
2248
|
+
};
|
|
2249
|
+
|
|
2250
|
+
/**
|
|
2251
|
+
* A replay analyzer that analyzes a replay from osu!droid.
|
|
2252
|
+
*
|
|
2253
|
+
* Created by reverse engineering the replay parser from the game itself, which can be found {@link https://github.com/osudroid/osu-droid/blob/master/src/ru/nsu/ccfit/zuev/osu/scoring/Replay.java here}.
|
|
2254
|
+
*
|
|
2255
|
+
* Once analyzed, the result can be accessed via the `data` property.
|
|
2256
|
+
*/
|
|
2257
|
+
class ReplayAnalyzer {
|
|
2258
|
+
constructor(values) {
|
|
2259
|
+
/**
|
|
2260
|
+
* The original odr file of the replay.
|
|
2261
|
+
*/
|
|
2262
|
+
this.originalODR = null;
|
|
2263
|
+
/**
|
|
2264
|
+
* The fixed odr file of the replay.
|
|
2265
|
+
*/
|
|
2266
|
+
this.fixedODR = null;
|
|
2267
|
+
/**
|
|
2268
|
+
* The results of the analyzer. `null` when initialized.
|
|
2269
|
+
*/
|
|
2270
|
+
this.data = null;
|
|
2271
|
+
/**
|
|
2272
|
+
* Penalty value used to penalize dpp for 2-hand.
|
|
2273
|
+
*/
|
|
2274
|
+
this.aimPenalty = 1;
|
|
2275
|
+
/**
|
|
2276
|
+
* Penalty value used to penalize dpp for 3 finger abuse.
|
|
2277
|
+
*/
|
|
2278
|
+
this.tapPenalty = 1;
|
|
2279
|
+
/**
|
|
2280
|
+
* Penalty values used to penalize dpp for slider cheesing.
|
|
2281
|
+
*/
|
|
2282
|
+
this.sliderCheesePenalty = {
|
|
2283
|
+
aimPenalty: 1,
|
|
2284
|
+
flashlightPenalty: 1,
|
|
2285
|
+
};
|
|
2286
|
+
/**
|
|
2287
|
+
* Whether this replay has been checked against 3 finger usage.
|
|
2288
|
+
*/
|
|
2289
|
+
this.hasBeenCheckedFor3Finger = false;
|
|
2290
|
+
/**
|
|
2291
|
+
* Whether this replay has been checked against 2 hand usage.
|
|
2292
|
+
*/
|
|
2293
|
+
this.hasBeenCheckedFor2Hand = false;
|
|
2294
|
+
/**
|
|
2295
|
+
* Whether this replay has been checked against slider cheesing.
|
|
2296
|
+
*/
|
|
2297
|
+
this.hasBeenCheckedForSliderCheesing = false;
|
|
2298
|
+
/**
|
|
2299
|
+
* The amount of two-handed objects.
|
|
2300
|
+
*/
|
|
2301
|
+
this.twoHandedNoteCount = 0;
|
|
2302
|
+
this.bufferOffset = 0;
|
|
2303
|
+
this.scoreID = values === null || values === void 0 ? void 0 : values.scoreID;
|
|
2304
|
+
this.beatmap = values === null || values === void 0 ? void 0 : values.map;
|
|
2305
|
+
this.difficultyAttributes = values === null || values === void 0 ? void 0 : values.difficultyAttributes;
|
|
2306
|
+
if (this.beatmap instanceof osuBase.DroidPlayableBeatmap) {
|
|
2307
|
+
this.playableBeatmap = this.beatmap;
|
|
2308
|
+
}
|
|
2309
|
+
}
|
|
1917
2310
|
/**
|
|
1918
|
-
*
|
|
2311
|
+
* Analyzes a replay.
|
|
1919
2312
|
*/
|
|
1920
|
-
|
|
2313
|
+
analyze() {
|
|
2314
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
2315
|
+
var _a;
|
|
2316
|
+
if (!this.originalODR && !this.fixedODR) {
|
|
2317
|
+
this.originalODR = yield this.downloadReplay();
|
|
2318
|
+
}
|
|
2319
|
+
if (!this.originalODR) {
|
|
2320
|
+
return this;
|
|
2321
|
+
}
|
|
2322
|
+
(_a = this.fixedODR) !== null && _a !== void 0 ? _a : (this.fixedODR = yield this.decompress().catch(() => null));
|
|
2323
|
+
if (!this.fixedODR) {
|
|
2324
|
+
return this;
|
|
2325
|
+
}
|
|
2326
|
+
this.parseReplay();
|
|
2327
|
+
return this;
|
|
2328
|
+
});
|
|
2329
|
+
}
|
|
1921
2330
|
/**
|
|
1922
|
-
*
|
|
2331
|
+
* Gets hit error information of the replay.
|
|
2332
|
+
*
|
|
2333
|
+
* `analyze()` must be called before calling this, and `beatmap` must be defined.
|
|
1923
2334
|
*/
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
2335
|
+
calculateHitError() {
|
|
2336
|
+
var _a, _b;
|
|
2337
|
+
if (!this.data || !this.beatmap) {
|
|
2338
|
+
return null;
|
|
2339
|
+
}
|
|
2340
|
+
const hitObjectData = this.data.hitObjectData;
|
|
2341
|
+
let positiveCount = 0;
|
|
2342
|
+
let negativeCount = 0;
|
|
2343
|
+
let positiveTotal = 0;
|
|
2344
|
+
let negativeTotal = 0;
|
|
2345
|
+
const { objects } = this.beatmap.hitObjects;
|
|
2346
|
+
const mods = this.data.isReplayV3()
|
|
2347
|
+
? this.data.convertedMods
|
|
2348
|
+
: ((_b = (_a = this.difficultyAttributes) === null || _a === void 0 ? void 0 : _a.mods) !== null && _b !== void 0 ? _b : new osuBase.ModMap());
|
|
2349
|
+
const adjustedDifficulty = new osuBase.BeatmapDifficulty(this.beatmap.difficulty);
|
|
2350
|
+
osuBase.ModUtil.applyModsToBeatmapDifficulty(adjustedDifficulty, osuBase.Modes.droid, mods);
|
|
2351
|
+
const mehWindow = mods.has(osuBase.ModPrecise)
|
|
2352
|
+
? new osuBase.PreciseDroidHitWindow(adjustedDifficulty.od).mehWindow
|
|
2353
|
+
: new osuBase.DroidHitWindow(adjustedDifficulty.od).mehWindow;
|
|
2354
|
+
const accuracies = [];
|
|
2355
|
+
for (let i = 0; i < hitObjectData.length; ++i) {
|
|
2356
|
+
const v = hitObjectData[i];
|
|
2357
|
+
const o = objects[i];
|
|
2358
|
+
if (o instanceof osuBase.Spinner || v.result === osuBase.HitResult.miss) {
|
|
2359
|
+
continue;
|
|
2360
|
+
}
|
|
2361
|
+
const { accuracy } = v;
|
|
2362
|
+
// Do not include slider breaks.
|
|
2363
|
+
if (o instanceof osuBase.Slider) {
|
|
2364
|
+
let lateHitThreshold = mehWindow;
|
|
2365
|
+
// Before replay version 8, the slider head's hit window is capped to the duration of the slider.
|
|
2366
|
+
if (this.data.replayVersion < 8) {
|
|
2367
|
+
lateHitThreshold = Math.min(mehWindow, o.duration);
|
|
2368
|
+
}
|
|
2369
|
+
if (-mehWindow > accuracy || accuracy > lateHitThreshold) {
|
|
2370
|
+
continue;
|
|
2371
|
+
}
|
|
2372
|
+
}
|
|
2373
|
+
accuracies.push(accuracy);
|
|
2374
|
+
if (accuracy >= 0) {
|
|
2375
|
+
positiveTotal += accuracy;
|
|
2376
|
+
++positiveCount;
|
|
2377
|
+
}
|
|
2378
|
+
else {
|
|
2379
|
+
negativeTotal += accuracy;
|
|
2380
|
+
++negativeCount;
|
|
2381
|
+
}
|
|
2382
|
+
}
|
|
2383
|
+
return {
|
|
2384
|
+
positiveAvg: positiveTotal / positiveCount || 0,
|
|
2385
|
+
negativeAvg: negativeTotal / negativeCount || 0,
|
|
2386
|
+
unstableRate: osuBase.MathUtils.calculateStandardDeviation(accuracies) * 10,
|
|
2387
|
+
};
|
|
2388
|
+
}
|
|
1929
2389
|
/**
|
|
1930
|
-
*
|
|
2390
|
+
* Obtains the amount of slider ticks and ends hit in the replay.
|
|
2391
|
+
*
|
|
2392
|
+
* This requires `analyze()` to be called first and `beatmap` to be defined.
|
|
2393
|
+
*
|
|
2394
|
+
* @returns Slider hit information or `null` if the replay has not been analyzed or the beatmap is not defined.
|
|
1931
2395
|
*/
|
|
1932
|
-
|
|
2396
|
+
obtainSliderHitInformation() {
|
|
2397
|
+
const { data, beatmap } = this;
|
|
2398
|
+
if (!data || !beatmap) {
|
|
2399
|
+
return null;
|
|
2400
|
+
}
|
|
2401
|
+
const sliderInformation = {
|
|
2402
|
+
tick: { obtained: 0, total: beatmap.hitObjects.sliderTicks },
|
|
2403
|
+
end: { obtained: 0, total: beatmap.hitObjects.sliders },
|
|
2404
|
+
};
|
|
2405
|
+
for (let i = 0; i < data.hitObjectData.length; ++i) {
|
|
2406
|
+
const object = beatmap.hitObjects.objects[i];
|
|
2407
|
+
const objectData = data.hitObjectData[i];
|
|
2408
|
+
if (objectData.result === osuBase.HitResult.miss ||
|
|
2409
|
+
!(object instanceof osuBase.Slider)) {
|
|
2410
|
+
continue;
|
|
2411
|
+
}
|
|
2412
|
+
// Exclude the head circle.
|
|
2413
|
+
for (let j = 1; j < object.nestedHitObjects.length; ++j) {
|
|
2414
|
+
const nested = object.nestedHitObjects[j];
|
|
2415
|
+
if (!objectData.tickset[j - 1]) {
|
|
2416
|
+
continue;
|
|
2417
|
+
}
|
|
2418
|
+
if (nested instanceof osuBase.SliderTick) {
|
|
2419
|
+
++sliderInformation.tick.obtained;
|
|
2420
|
+
}
|
|
2421
|
+
else if (nested instanceof osuBase.SliderTail) {
|
|
2422
|
+
++sliderInformation.end.obtained;
|
|
2423
|
+
}
|
|
2424
|
+
}
|
|
2425
|
+
}
|
|
2426
|
+
return sliderInformation;
|
|
2427
|
+
}
|
|
1933
2428
|
/**
|
|
1934
|
-
*
|
|
2429
|
+
* Simulates a hit window for the replay.
|
|
2430
|
+
*
|
|
2431
|
+
* This does not account for required spins in a spinner.
|
|
2432
|
+
*
|
|
2433
|
+
* Requires `analyze()` to be called first and `beatmap` to be defined.
|
|
2434
|
+
*
|
|
2435
|
+
* @param hitWindow The hit window to simulate.
|
|
2436
|
+
* @returns The accuracy of the replay based on the hit window, or `null` if the replay has not been analyzed or the beatmap is not defined.
|
|
1935
2437
|
*/
|
|
1936
|
-
|
|
2438
|
+
simulateHitWindow(hitWindow) {
|
|
2439
|
+
const { data, beatmap } = this;
|
|
2440
|
+
if (!data || !beatmap) {
|
|
2441
|
+
return null;
|
|
2442
|
+
}
|
|
2443
|
+
const accuracy = new osuBase.Accuracy({ n300: 0, n100: 0, n50: 0, nmiss: 0 });
|
|
2444
|
+
for (let i = 0; i < data.hitObjectData.length; ++i) {
|
|
2445
|
+
const object = beatmap.hitObjects.objects[i];
|
|
2446
|
+
const objectData = data.hitObjectData[i];
|
|
2447
|
+
const hitAccuracy = Math.abs(objectData.accuracy);
|
|
2448
|
+
let { result } = objectData;
|
|
2449
|
+
if (object instanceof osuBase.Circle) {
|
|
2450
|
+
if (hitAccuracy <= hitWindow.greatWindow) {
|
|
2451
|
+
result = osuBase.HitResult.great;
|
|
2452
|
+
}
|
|
2453
|
+
else if (hitAccuracy <= hitWindow.okWindow) {
|
|
2454
|
+
result = osuBase.HitResult.good;
|
|
2455
|
+
}
|
|
2456
|
+
else if (hitAccuracy <= hitWindow.mehWindow) {
|
|
2457
|
+
result = osuBase.HitResult.meh;
|
|
2458
|
+
}
|
|
2459
|
+
else {
|
|
2460
|
+
result = osuBase.HitResult.miss;
|
|
2461
|
+
}
|
|
2462
|
+
}
|
|
2463
|
+
else if (object instanceof osuBase.Slider) {
|
|
2464
|
+
if (hitAccuracy <=
|
|
2465
|
+
Math.min(hitWindow.mehWindow, object.duration)) {
|
|
2466
|
+
let ticksObtained = 1;
|
|
2467
|
+
for (let j = 1; j < object.nestedHitObjects.length; ++j) {
|
|
2468
|
+
if (objectData.tickset[j - 1]) {
|
|
2469
|
+
++ticksObtained;
|
|
2470
|
+
}
|
|
2471
|
+
}
|
|
2472
|
+
if (ticksObtained === object.nestedHitObjects.length) {
|
|
2473
|
+
result = osuBase.HitResult.great;
|
|
2474
|
+
}
|
|
2475
|
+
else if (ticksObtained >=
|
|
2476
|
+
Math.trunc(object.nestedHitObjects.length / 2)) {
|
|
2477
|
+
result = osuBase.HitResult.good;
|
|
2478
|
+
}
|
|
2479
|
+
else if (ticksObtained > 0) {
|
|
2480
|
+
result = osuBase.HitResult.meh;
|
|
2481
|
+
}
|
|
2482
|
+
else {
|
|
2483
|
+
result = osuBase.HitResult.miss;
|
|
2484
|
+
}
|
|
2485
|
+
}
|
|
2486
|
+
else {
|
|
2487
|
+
result = osuBase.HitResult.miss;
|
|
2488
|
+
}
|
|
2489
|
+
}
|
|
2490
|
+
switch (result) {
|
|
2491
|
+
case osuBase.HitResult.miss:
|
|
2492
|
+
++accuracy.nmiss;
|
|
2493
|
+
break;
|
|
2494
|
+
case osuBase.HitResult.meh:
|
|
2495
|
+
++accuracy.n50;
|
|
2496
|
+
break;
|
|
2497
|
+
case osuBase.HitResult.good:
|
|
2498
|
+
++accuracy.n100;
|
|
2499
|
+
break;
|
|
2500
|
+
case osuBase.HitResult.great:
|
|
2501
|
+
++accuracy.n300;
|
|
2502
|
+
break;
|
|
2503
|
+
}
|
|
2504
|
+
}
|
|
2505
|
+
return accuracy;
|
|
2506
|
+
}
|
|
1937
2507
|
/**
|
|
1938
|
-
*
|
|
2508
|
+
* Checks if a play is using 3 fingers.
|
|
2509
|
+
*
|
|
2510
|
+
* Requires `analyze()` to be called first and `map` and `difficultyAttributes` to be defined.
|
|
1939
2511
|
*/
|
|
1940
|
-
|
|
2512
|
+
checkFor3Finger() {
|
|
2513
|
+
var _a;
|
|
2514
|
+
if (!this.beatmap || !this.data || !this.difficultyAttributes) {
|
|
2515
|
+
return;
|
|
2516
|
+
}
|
|
2517
|
+
(_a = this.playableBeatmap) !== null && _a !== void 0 ? _a : (this.playableBeatmap = this.constructPlayableBeatmap());
|
|
2518
|
+
const threeFingerChecker = this.difficultyAttributes.mode === "rebalance"
|
|
2519
|
+
? new RebalanceThreeFingerChecker(this.playableBeatmap, this.data, this.difficultyAttributes)
|
|
2520
|
+
: new ThreeFingerChecker(this.playableBeatmap, this.data, this.difficultyAttributes);
|
|
2521
|
+
const result = threeFingerChecker.check();
|
|
2522
|
+
this.is3Finger = result.is3Finger;
|
|
2523
|
+
this.tapPenalty = result.penalty;
|
|
2524
|
+
this.hasBeenCheckedFor3Finger = true;
|
|
2525
|
+
}
|
|
1941
2526
|
/**
|
|
1942
|
-
*
|
|
2527
|
+
* Checks if a play is using 2 hands.
|
|
2528
|
+
*
|
|
2529
|
+
* Requires `analyze()` to be called first as well as `beatmap` and `difficultyAttributes` to be defined.
|
|
1943
2530
|
*/
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
INT_LENGTH = 4;
|
|
1949
|
-
FLOAT_LENGTH = 4;
|
|
1950
|
-
LONG_LENGTH = 8;
|
|
1951
|
-
constructor(values) {
|
|
1952
|
-
this.scoreID = values.scoreID;
|
|
1953
|
-
this.beatmap = values.map;
|
|
1954
|
-
this.difficultyAttributes = values.difficultyAttributes;
|
|
1955
|
-
if (this.beatmap && !(this.beatmap instanceof osuBase.Beatmap)) {
|
|
1956
|
-
this.difficultyAttributes = this.beatmap.attributes;
|
|
2531
|
+
checkFor2Hand() {
|
|
2532
|
+
var _a;
|
|
2533
|
+
if (!this.beatmap || !this.difficultyAttributes || !this.data) {
|
|
2534
|
+
return;
|
|
1957
2535
|
}
|
|
2536
|
+
(_a = this.playableBeatmap) !== null && _a !== void 0 ? _a : (this.playableBeatmap = this.constructPlayableBeatmap());
|
|
2537
|
+
const twoHandChecker = new TwoHandChecker(this.playableBeatmap, this.difficultyAttributes, this.data);
|
|
2538
|
+
const result = twoHandChecker.check();
|
|
2539
|
+
this.is2Hand = result.is2Hand;
|
|
2540
|
+
this.twoHandedNoteCount = result.twoHandedNoteCount;
|
|
2541
|
+
this.hasBeenCheckedFor2Hand = true;
|
|
1958
2542
|
}
|
|
1959
2543
|
/**
|
|
1960
|
-
*
|
|
2544
|
+
* Checks if a play has cheesed sliders.
|
|
2545
|
+
*
|
|
2546
|
+
* Requires `analyze()` to be called first and `map` and `difficultyAttributes` to be defined.
|
|
1961
2547
|
*/
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
if (!this.originalODR) {
|
|
1967
|
-
return this;
|
|
1968
|
-
}
|
|
1969
|
-
if (!this.fixedODR) {
|
|
1970
|
-
this.fixedODR = await this.decompress().catch(() => null);
|
|
1971
|
-
}
|
|
1972
|
-
if (!this.fixedODR) {
|
|
1973
|
-
return this;
|
|
2548
|
+
checkForSliderCheesing() {
|
|
2549
|
+
var _a;
|
|
2550
|
+
if (!this.beatmap || !this.data || !this.difficultyAttributes) {
|
|
2551
|
+
return;
|
|
1974
2552
|
}
|
|
1975
|
-
this.
|
|
1976
|
-
|
|
2553
|
+
(_a = this.playableBeatmap) !== null && _a !== void 0 ? _a : (this.playableBeatmap = this.constructPlayableBeatmap());
|
|
2554
|
+
const sliderCheeseChecker = this.difficultyAttributes.mode === "rebalance"
|
|
2555
|
+
? new RebalanceSliderCheeseChecker(this.playableBeatmap, this.data, this.difficultyAttributes)
|
|
2556
|
+
: new SliderCheeseChecker(this.playableBeatmap, this.data, this.difficultyAttributes);
|
|
2557
|
+
this.sliderCheesePenalty = sliderCheeseChecker.check();
|
|
2558
|
+
this.hasBeenCheckedForSliderCheesing = true;
|
|
1977
2559
|
}
|
|
1978
2560
|
/**
|
|
1979
2561
|
* Downloads the given score ID's replay.
|
|
1980
2562
|
*/
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
.
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
2563
|
+
downloadReplay() {
|
|
2564
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
2565
|
+
if (this.scoreID === undefined) {
|
|
2566
|
+
return null;
|
|
2567
|
+
}
|
|
2568
|
+
const apiRequestBuilder = new osuBase.DroidAPIRequestBuilder()
|
|
2569
|
+
.setRequireAPIkey(false)
|
|
2570
|
+
.setEndpoint("upload")
|
|
2571
|
+
.addParameter("", `${this.scoreID.toString()}.odr`);
|
|
2572
|
+
const result = yield apiRequestBuilder.sendRequest();
|
|
2573
|
+
if (result.statusCode !== 200) {
|
|
2574
|
+
return null;
|
|
2575
|
+
}
|
|
2576
|
+
return result.data;
|
|
2577
|
+
});
|
|
1991
2578
|
}
|
|
1992
2579
|
/**
|
|
1993
2580
|
* Decompresses a replay.
|
|
@@ -2001,17 +2588,20 @@ class ReplayAnalyzer {
|
|
|
2001
2588
|
stream.push(null);
|
|
2002
2589
|
stream
|
|
2003
2590
|
.pipe(unzipper.Parse())
|
|
2004
|
-
.on("entry",
|
|
2591
|
+
.on("entry", (entry) => {
|
|
2005
2592
|
const fileName = entry.path;
|
|
2006
2593
|
if (fileName === "data") {
|
|
2007
|
-
|
|
2594
|
+
resolve(entry.buffer());
|
|
2595
|
+
return;
|
|
2008
2596
|
}
|
|
2009
2597
|
else {
|
|
2010
2598
|
entry.autodrain();
|
|
2011
2599
|
}
|
|
2012
2600
|
})
|
|
2013
2601
|
.on("error", (e) => {
|
|
2014
|
-
setTimeout(() =>
|
|
2602
|
+
setTimeout(() => {
|
|
2603
|
+
reject(e);
|
|
2604
|
+
}, 2000);
|
|
2015
2605
|
});
|
|
2016
2606
|
});
|
|
2017
2607
|
}
|
|
@@ -2019,14 +2609,16 @@ class ReplayAnalyzer {
|
|
|
2019
2609
|
* Parses a replay after being downloaded and converted to a buffer.
|
|
2020
2610
|
*/
|
|
2021
2611
|
parseReplay() {
|
|
2612
|
+
if (!this.fixedODR) {
|
|
2613
|
+
return;
|
|
2614
|
+
}
|
|
2022
2615
|
// javaDeserialization can only somewhat parse some string field
|
|
2023
|
-
// the rest will be a buffer that we need to manually parse
|
|
2024
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
2616
|
+
// the rest will be a buffer that we need to manually parse.
|
|
2025
2617
|
let rawObject;
|
|
2026
2618
|
try {
|
|
2027
2619
|
rawObject = javaDeserialization__namespace.parse(this.fixedODR);
|
|
2028
2620
|
}
|
|
2029
|
-
catch {
|
|
2621
|
+
catch (_a) {
|
|
2030
2622
|
return;
|
|
2031
2623
|
}
|
|
2032
2624
|
const resultObject = {
|
|
@@ -2037,88 +2629,62 @@ class ReplayAnalyzer {
|
|
|
2037
2629
|
cursorMovement: [],
|
|
2038
2630
|
hitObjectData: [],
|
|
2039
2631
|
accuracy: new osuBase.Accuracy({ n300: 0 }),
|
|
2040
|
-
rank: "",
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
switch (true) {
|
|
2050
|
-
case resultObject.accuracy.value() === 1:
|
|
2051
|
-
if (isHidden) {
|
|
2052
|
-
resultObject.rank = "XH";
|
|
2053
|
-
}
|
|
2054
|
-
else {
|
|
2055
|
-
resultObject.rank = "X";
|
|
2056
|
-
}
|
|
2057
|
-
break;
|
|
2058
|
-
case hit300Ratio > 0.9 &&
|
|
2059
|
-
resultObject.accuracy.n50 / totalHits < 0.01 &&
|
|
2060
|
-
!resultObject.accuracy.nmiss:
|
|
2061
|
-
if (isHidden) {
|
|
2062
|
-
resultObject.rank = "SH";
|
|
2063
|
-
}
|
|
2064
|
-
else {
|
|
2065
|
-
resultObject.rank = "S";
|
|
2066
|
-
}
|
|
2067
|
-
break;
|
|
2068
|
-
case (hit300Ratio > 0.8 && !resultObject.accuracy.nmiss) ||
|
|
2069
|
-
hit300Ratio > 0.9:
|
|
2070
|
-
resultObject.rank = "A";
|
|
2071
|
-
break;
|
|
2072
|
-
case (hit300Ratio > 0.7 && !resultObject.accuracy.nmiss) ||
|
|
2073
|
-
hit300Ratio > 0.8:
|
|
2074
|
-
resultObject.rank = "B";
|
|
2075
|
-
break;
|
|
2076
|
-
case hit300Ratio > 0.6:
|
|
2077
|
-
resultObject.rank = "C";
|
|
2078
|
-
break;
|
|
2079
|
-
default:
|
|
2080
|
-
resultObject.rank = "D";
|
|
2081
|
-
}
|
|
2632
|
+
rank: "D",
|
|
2633
|
+
convertedMods: new osuBase.ModMap(),
|
|
2634
|
+
hit100k: 0,
|
|
2635
|
+
hit300k: 0,
|
|
2636
|
+
isFullCombo: false,
|
|
2637
|
+
maxCombo: 0,
|
|
2638
|
+
playerName: "",
|
|
2639
|
+
score: 0,
|
|
2640
|
+
time: new Date(0),
|
|
2082
2641
|
};
|
|
2083
2642
|
if (resultObject.replayVersion >= 3) {
|
|
2084
|
-
|
|
2085
|
-
resultObject.
|
|
2086
|
-
resultObject.
|
|
2087
|
-
resultObject.
|
|
2088
|
-
resultObject.
|
|
2089
|
-
resultObject.accuracy =
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
resultObject.isFullCombo = !!rawObject[4][44];
|
|
2643
|
+
const buf = rawObject[4];
|
|
2644
|
+
resultObject.time.setTime(Number(buf.readBigUInt64BE(0)));
|
|
2645
|
+
resultObject.hit300k = buf.readInt32BE(8);
|
|
2646
|
+
resultObject.accuracy.n300 = buf.readInt32BE(12);
|
|
2647
|
+
resultObject.hit100k = buf.readInt32BE(16);
|
|
2648
|
+
resultObject.accuracy.n100 = buf.readInt32BE(20);
|
|
2649
|
+
resultObject.accuracy.n50 = buf.readInt32BE(24);
|
|
2650
|
+
resultObject.accuracy.nmiss = buf.readInt32BE(28);
|
|
2651
|
+
resultObject.score = buf.readInt32BE(32);
|
|
2652
|
+
resultObject.maxCombo = buf.readInt32BE(36);
|
|
2653
|
+
resultObject.isFullCombo = resultObject.accuracy.value() === 1;
|
|
2096
2654
|
resultObject.playerName = rawObject[5];
|
|
2097
|
-
resultObject.
|
|
2098
|
-
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
resultObject.forcedAR = parseFloat(s[1].replace("AR", ""));
|
|
2655
|
+
if (resultObject.replayVersion >= 7) {
|
|
2656
|
+
resultObject.convertedMods = osuBase.ModUtil.deserializeMods(JSON.parse(rawObject[6]));
|
|
2657
|
+
}
|
|
2658
|
+
else {
|
|
2659
|
+
resultObject.convertedMods = this.convertDroidMods(resultObject.replayVersion, Object.values(rawObject[6]
|
|
2660
|
+
.elements));
|
|
2661
|
+
if (resultObject.replayVersion >= 4) {
|
|
2662
|
+
osuBase.DroidLegacyModConverter.parseExtraModString(resultObject.convertedMods, rawObject[7].split("|"));
|
|
2663
|
+
}
|
|
2107
2664
|
}
|
|
2665
|
+
resultObject.rank = this.calculateRank(resultObject);
|
|
2666
|
+
}
|
|
2667
|
+
if (resultObject.replayVersion <= 6) {
|
|
2668
|
+
resultObject.convertedMods.set(osuBase.ModReplayV6);
|
|
2108
2669
|
}
|
|
2109
2670
|
let bufferIndex;
|
|
2110
|
-
switch (
|
|
2111
|
-
|
|
2112
|
-
case
|
|
2113
|
-
bufferIndex =
|
|
2671
|
+
switch (resultObject.replayVersion) {
|
|
2672
|
+
case 1:
|
|
2673
|
+
case 2:
|
|
2674
|
+
bufferIndex = 4;
|
|
2114
2675
|
break;
|
|
2115
|
-
|
|
2116
|
-
case
|
|
2676
|
+
case 3:
|
|
2677
|
+
case 7:
|
|
2678
|
+
case 8:
|
|
2117
2679
|
bufferIndex = 7;
|
|
2118
2680
|
break;
|
|
2119
|
-
|
|
2681
|
+
case 4:
|
|
2682
|
+
case 5:
|
|
2683
|
+
case 6:
|
|
2684
|
+
bufferIndex = 8;
|
|
2685
|
+
break;
|
|
2120
2686
|
default:
|
|
2121
|
-
|
|
2687
|
+
throw new Error(`Unsupported replay version: ${resultObject.replayVersion.toString()}`);
|
|
2122
2688
|
}
|
|
2123
2689
|
const replayDataBufferArray = [];
|
|
2124
2690
|
while (bufferIndex < rawObject.length) {
|
|
@@ -2126,39 +2692,87 @@ class ReplayAnalyzer {
|
|
|
2126
2692
|
}
|
|
2127
2693
|
// Merge all cursor movement and hit object data section into one for better control when parsing
|
|
2128
2694
|
const replayDataBuffer = Buffer.concat(replayDataBufferArray);
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2695
|
+
this.bufferOffset = 0;
|
|
2696
|
+
this.parseMovementData(resultObject, replayDataBuffer);
|
|
2697
|
+
this.parseHitObjectData(resultObject, replayDataBuffer);
|
|
2698
|
+
this.parseOldReplayInformation(resultObject);
|
|
2699
|
+
switch (resultObject.replayVersion) {
|
|
2700
|
+
case 1:
|
|
2701
|
+
case 2:
|
|
2702
|
+
this.data = new ReplayData(resultObject);
|
|
2703
|
+
break;
|
|
2704
|
+
default:
|
|
2705
|
+
this.data = new ReplayV3Data(resultObject);
|
|
2706
|
+
}
|
|
2707
|
+
}
|
|
2708
|
+
/**
|
|
2709
|
+
* Converts replay mods to droid mod string.
|
|
2710
|
+
*/
|
|
2711
|
+
convertDroidMods(replayVersion, replayMods) {
|
|
2712
|
+
const replayModsConstants = {
|
|
2713
|
+
MOD_AUTO: osuBase.ModAuto,
|
|
2714
|
+
MOD_AUTOPILOT: osuBase.ModAutopilot,
|
|
2715
|
+
MOD_NOFAIL: osuBase.ModNoFail,
|
|
2716
|
+
MOD_EASY: osuBase.ModEasy,
|
|
2717
|
+
MOD_HIDDEN: osuBase.ModHidden,
|
|
2718
|
+
MOD_TRACEABLE: osuBase.ModTraceable,
|
|
2719
|
+
MOD_HARDROCK: osuBase.ModHardRock,
|
|
2720
|
+
MOD_DOUBLETIME: osuBase.ModDoubleTime,
|
|
2721
|
+
MOD_HALFTIME: osuBase.ModHalfTime,
|
|
2722
|
+
MOD_NIGHTCORE: osuBase.ModNightCore,
|
|
2723
|
+
MOD_PRECISE: osuBase.ModPrecise,
|
|
2724
|
+
MOD_SMALLCIRCLE: osuBase.ModSmallCircle,
|
|
2725
|
+
MOD_REALLYEASY: osuBase.ModReallyEasy,
|
|
2726
|
+
MOD_RELAX: osuBase.ModRelax,
|
|
2727
|
+
MOD_PERFECT: osuBase.ModPerfect,
|
|
2728
|
+
MOD_SUDDENDEATH: osuBase.ModSuddenDeath,
|
|
2729
|
+
MOD_SCOREV2: osuBase.ModScoreV2,
|
|
2730
|
+
MOD_FLASHLIGHT: osuBase.ModFlashlight,
|
|
2731
|
+
};
|
|
2732
|
+
const map = new osuBase.ModMap();
|
|
2733
|
+
for (const mod of replayMods) {
|
|
2734
|
+
for (const property in Object(replayModsConstants)) {
|
|
2735
|
+
if (!mod.includes(property)) {
|
|
2736
|
+
continue;
|
|
2737
|
+
}
|
|
2738
|
+
if (replayVersion <= 3 && mod === "MOD_NIGHTCORE") {
|
|
2739
|
+
// In replay v3, the NightCore mod is bugged. See ModOldNightCore's description.
|
|
2740
|
+
map.set(new osuBase.ModOldNightCore());
|
|
2741
|
+
}
|
|
2742
|
+
else {
|
|
2743
|
+
map.set(replayModsConstants[property]);
|
|
2744
|
+
}
|
|
2745
|
+
break;
|
|
2746
|
+
}
|
|
2747
|
+
}
|
|
2748
|
+
return map;
|
|
2749
|
+
}
|
|
2750
|
+
parseMovementData(resultObject, replayDataBuffer) {
|
|
2751
|
+
resultObject.cursorMovement.length = 0;
|
|
2752
|
+
const size = this.readInt(replayDataBuffer);
|
|
2753
|
+
for (let i = 0; i < size; i++) {
|
|
2754
|
+
const moveSize = this.readInt(replayDataBuffer);
|
|
2136
2755
|
const time = [];
|
|
2137
2756
|
const x = [];
|
|
2138
2757
|
const y = [];
|
|
2139
2758
|
const id = [];
|
|
2140
|
-
for (let
|
|
2141
|
-
time[
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
if (id[i] !== exports.MovementType.up) {
|
|
2759
|
+
for (let j = 0; j < moveSize; j++) {
|
|
2760
|
+
time[j] = this.readInt(replayDataBuffer);
|
|
2761
|
+
id[j] = time[j] & 3;
|
|
2762
|
+
time[j] >>= 2;
|
|
2763
|
+
if (id[j] !== exports.MovementType.up) {
|
|
2146
2764
|
if (resultObject.replayVersion >= 5) {
|
|
2147
|
-
x[
|
|
2148
|
-
|
|
2149
|
-
y[i] = replayDataBuffer.readFloatBE(bufferCounter);
|
|
2150
|
-
bufferCounter += this.FLOAT_LENGTH;
|
|
2765
|
+
x[j] = this.readFloat(replayDataBuffer);
|
|
2766
|
+
y[j] = this.readFloat(replayDataBuffer);
|
|
2151
2767
|
}
|
|
2152
2768
|
else {
|
|
2153
|
-
x[
|
|
2154
|
-
|
|
2155
|
-
y[i] = replayDataBuffer.readInt16BE(bufferCounter);
|
|
2156
|
-
bufferCounter += this.SHORT_LENGTH;
|
|
2769
|
+
x[j] = this.readShort(replayDataBuffer);
|
|
2770
|
+
y[j] = this.readShort(replayDataBuffer);
|
|
2157
2771
|
}
|
|
2158
2772
|
}
|
|
2159
2773
|
else {
|
|
2160
|
-
x[
|
|
2161
|
-
y[
|
|
2774
|
+
x[j] = -1;
|
|
2775
|
+
y[j] = -1;
|
|
2162
2776
|
}
|
|
2163
2777
|
}
|
|
2164
2778
|
resultObject.cursorMovement.push(new CursorData({
|
|
@@ -2169,25 +2783,23 @@ class ReplayAnalyzer {
|
|
|
2169
2783
|
id: id,
|
|
2170
2784
|
}));
|
|
2171
2785
|
}
|
|
2172
|
-
|
|
2173
|
-
|
|
2786
|
+
}
|
|
2787
|
+
parseHitObjectData(resultObject, replayDataBuffer) {
|
|
2788
|
+
resultObject.hitObjectData.length = 0;
|
|
2789
|
+
const replayObjectLength = this.readInt(replayDataBuffer);
|
|
2174
2790
|
// Parse result data
|
|
2175
2791
|
for (let i = 0; i < replayObjectLength; i++) {
|
|
2176
2792
|
const replayObjectData = {
|
|
2177
2793
|
accuracy: 0,
|
|
2178
2794
|
tickset: [],
|
|
2179
|
-
result:
|
|
2795
|
+
result: osuBase.HitResult.miss,
|
|
2180
2796
|
};
|
|
2181
|
-
replayObjectData.accuracy =
|
|
2182
|
-
|
|
2183
|
-
bufferCounter += this.SHORT_LENGTH;
|
|
2184
|
-
const len = replayDataBuffer.readInt8(bufferCounter);
|
|
2185
|
-
bufferCounter += this.BYTE_LENGTH;
|
|
2797
|
+
replayObjectData.accuracy = this.readShort(replayDataBuffer);
|
|
2798
|
+
const len = this.readByte(replayDataBuffer);
|
|
2186
2799
|
if (len > 0) {
|
|
2187
2800
|
const bytes = [];
|
|
2188
2801
|
for (let j = 0; j < len; j++) {
|
|
2189
|
-
bytes.push(
|
|
2190
|
-
bufferCounter += this.BYTE_LENGTH;
|
|
2802
|
+
bytes.push(this.readByte(replayDataBuffer));
|
|
2191
2803
|
}
|
|
2192
2804
|
// Int/int division in Java; numbers must be truncated to get actual number
|
|
2193
2805
|
for (let j = 0; j < len * 8; j++) {
|
|
@@ -2197,252 +2809,127 @@ class ReplayAnalyzer {
|
|
|
2197
2809
|
}
|
|
2198
2810
|
}
|
|
2199
2811
|
if (resultObject.replayVersion >= 1) {
|
|
2200
|
-
replayObjectData.result =
|
|
2201
|
-
replayDataBuffer.readInt8(bufferCounter);
|
|
2202
|
-
bufferCounter += this.BYTE_LENGTH;
|
|
2812
|
+
replayObjectData.result = this.readByte(replayDataBuffer);
|
|
2203
2813
|
}
|
|
2204
2814
|
resultObject.hitObjectData.push(replayObjectData);
|
|
2205
2815
|
}
|
|
2206
|
-
// Parse max combo, hit results, and accuracy in old replay version
|
|
2207
|
-
if (resultObject.replayVersion < 3) {
|
|
2208
|
-
const objects = (this.beatmap instanceof osuDifficultyCalculator.DroidDifficultyCalculator ||
|
|
2209
|
-
this.beatmap instanceof osuRebalanceDifficultyCalculator.DroidDifficultyCalculator
|
|
2210
|
-
? this.beatmap.beatmap
|
|
2211
|
-
: this.beatmap)?.hitObjects.objects;
|
|
2212
|
-
let grantsGekiOrKatu = true;
|
|
2213
|
-
for (let i = 0; i < resultObject.hitObjectData.length; ++i) {
|
|
2214
|
-
// Hit result
|
|
2215
|
-
const hitObjectData = resultObject.hitObjectData[i];
|
|
2216
|
-
const isNextNewCombo = objects
|
|
2217
|
-
? i + 1 !== objects.length
|
|
2218
|
-
? objects[i + 1].isNewCombo
|
|
2219
|
-
: true
|
|
2220
|
-
: false;
|
|
2221
|
-
switch (hitObjectData.result) {
|
|
2222
|
-
case exports.HitResult.miss:
|
|
2223
|
-
++resultObject.accuracy.nmiss;
|
|
2224
|
-
grantsGekiOrKatu = false;
|
|
2225
|
-
break;
|
|
2226
|
-
case exports.HitResult.meh:
|
|
2227
|
-
++resultObject.accuracy.n50;
|
|
2228
|
-
grantsGekiOrKatu = false;
|
|
2229
|
-
break;
|
|
2230
|
-
case exports.HitResult.good:
|
|
2231
|
-
++resultObject.accuracy.n100;
|
|
2232
|
-
if (grantsGekiOrKatu && isNextNewCombo) {
|
|
2233
|
-
resultObject.hit100k ??= 0;
|
|
2234
|
-
++resultObject.hit100k;
|
|
2235
|
-
}
|
|
2236
|
-
break;
|
|
2237
|
-
case exports.HitResult.great:
|
|
2238
|
-
++resultObject.accuracy.n300;
|
|
2239
|
-
if (grantsGekiOrKatu && isNextNewCombo) {
|
|
2240
|
-
resultObject.hit300k ??= 0;
|
|
2241
|
-
++resultObject.hit300k;
|
|
2242
|
-
}
|
|
2243
|
-
break;
|
|
2244
|
-
}
|
|
2245
|
-
if (isNextNewCombo) {
|
|
2246
|
-
grantsGekiOrKatu = true;
|
|
2247
|
-
}
|
|
2248
|
-
}
|
|
2249
|
-
determineRank();
|
|
2250
|
-
}
|
|
2251
|
-
this.data = new ReplayData(resultObject);
|
|
2252
2816
|
}
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
|
|
2258
|
-
calculateHitError() {
|
|
2259
|
-
if (!this.data || !this.beatmap) {
|
|
2260
|
-
return null;
|
|
2817
|
+
parseOldReplayInformation(resultObject) {
|
|
2818
|
+
var _a;
|
|
2819
|
+
// Parse max combo, hit results, and accuracy in old replay version
|
|
2820
|
+
if (resultObject.replayVersion >= 3) {
|
|
2821
|
+
return;
|
|
2261
2822
|
}
|
|
2262
|
-
const
|
|
2263
|
-
let
|
|
2264
|
-
|
|
2265
|
-
|
|
2266
|
-
let
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
|
|
2275
|
-
|
|
2276
|
-
|
|
2277
|
-
|
|
2278
|
-
|
|
2279
|
-
|
|
2280
|
-
|
|
2281
|
-
|
|
2282
|
-
|
|
2283
|
-
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
|
|
2289
|
-
|
|
2290
|
-
|
|
2291
|
-
|
|
2292
|
-
|
|
2293
|
-
|
|
2294
|
-
|
|
2295
|
-
accuracies.push(accuracy);
|
|
2296
|
-
if (accuracy >= 0) {
|
|
2297
|
-
positiveTotal += accuracy;
|
|
2298
|
-
++positiveCount;
|
|
2823
|
+
const objects = (_a = this.beatmap) === null || _a === void 0 ? void 0 : _a.hitObjects.objects;
|
|
2824
|
+
let grantsGekiOrKatu = true;
|
|
2825
|
+
resultObject.hit300k = 0;
|
|
2826
|
+
resultObject.hit100k = 0;
|
|
2827
|
+
for (let i = 0; i < resultObject.hitObjectData.length; ++i) {
|
|
2828
|
+
// Hit result
|
|
2829
|
+
const hitObjectData = resultObject.hitObjectData[i];
|
|
2830
|
+
const isNextNewCombo = objects
|
|
2831
|
+
? i + 1 !== objects.length
|
|
2832
|
+
? objects[i + 1].isNewCombo
|
|
2833
|
+
: true
|
|
2834
|
+
: false;
|
|
2835
|
+
switch (hitObjectData.result) {
|
|
2836
|
+
case osuBase.HitResult.miss:
|
|
2837
|
+
++resultObject.accuracy.nmiss;
|
|
2838
|
+
grantsGekiOrKatu = false;
|
|
2839
|
+
break;
|
|
2840
|
+
case osuBase.HitResult.meh:
|
|
2841
|
+
++resultObject.accuracy.n50;
|
|
2842
|
+
grantsGekiOrKatu = false;
|
|
2843
|
+
break;
|
|
2844
|
+
case osuBase.HitResult.good:
|
|
2845
|
+
++resultObject.accuracy.n100;
|
|
2846
|
+
if (grantsGekiOrKatu && isNextNewCombo) {
|
|
2847
|
+
++resultObject.hit100k;
|
|
2848
|
+
}
|
|
2849
|
+
break;
|
|
2850
|
+
case osuBase.HitResult.great:
|
|
2851
|
+
++resultObject.accuracy.n300;
|
|
2852
|
+
if (grantsGekiOrKatu && isNextNewCombo) {
|
|
2853
|
+
++resultObject.hit300k;
|
|
2854
|
+
}
|
|
2855
|
+
break;
|
|
2299
2856
|
}
|
|
2300
|
-
|
|
2301
|
-
|
|
2302
|
-
++negativeCount;
|
|
2857
|
+
if (isNextNewCombo) {
|
|
2858
|
+
grantsGekiOrKatu = true;
|
|
2303
2859
|
}
|
|
2304
2860
|
}
|
|
2305
|
-
|
|
2306
|
-
positiveAvg: positiveTotal / positiveCount || 0,
|
|
2307
|
-
negativeAvg: negativeTotal / negativeCount || 0,
|
|
2308
|
-
unstableRate: osuBase.MathUtils.calculateStandardDeviation(accuracies) * 10,
|
|
2309
|
-
};
|
|
2861
|
+
resultObject.rank = this.calculateRank(resultObject);
|
|
2310
2862
|
}
|
|
2311
|
-
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
|
|
2316
|
-
|
|
2317
|
-
|
|
2318
|
-
|
|
2319
|
-
|
|
2320
|
-
|
|
2321
|
-
|
|
2322
|
-
|
|
2323
|
-
|
|
2324
|
-
|
|
2325
|
-
|
|
2326
|
-
|
|
2327
|
-
|
|
2328
|
-
|
|
2329
|
-
|
|
2330
|
-
|
|
2331
|
-
|
|
2332
|
-
|
|
2333
|
-
|
|
2334
|
-
|
|
2335
|
-
|
|
2336
|
-
}
|
|
2337
|
-
if (!mod.includes(property)) {
|
|
2338
|
-
continue;
|
|
2339
|
-
}
|
|
2340
|
-
modString +=
|
|
2341
|
-
replayModsConstants[property];
|
|
2342
|
-
break;
|
|
2343
|
-
}
|
|
2863
|
+
calculateRank(resultObject) {
|
|
2864
|
+
const totalHits = resultObject.accuracy.n300 +
|
|
2865
|
+
resultObject.accuracy.n100 +
|
|
2866
|
+
resultObject.accuracy.n50 +
|
|
2867
|
+
resultObject.accuracy.nmiss;
|
|
2868
|
+
const isHidden = resultObject.convertedMods.has(osuBase.ModHidden) ||
|
|
2869
|
+
resultObject.convertedMods.has(osuBase.ModFlashlight);
|
|
2870
|
+
const hit300Ratio = resultObject.accuracy.n300 / totalHits;
|
|
2871
|
+
switch (true) {
|
|
2872
|
+
case resultObject.accuracy.value() === 1:
|
|
2873
|
+
return isHidden ? "XH" : "X";
|
|
2874
|
+
case hit300Ratio > 0.9 &&
|
|
2875
|
+
resultObject.accuracy.n50 / totalHits < 0.01 &&
|
|
2876
|
+
!resultObject.accuracy.nmiss:
|
|
2877
|
+
return isHidden ? "SH" : "S";
|
|
2878
|
+
case (hit300Ratio > 0.8 && !resultObject.accuracy.nmiss) ||
|
|
2879
|
+
hit300Ratio > 0.9:
|
|
2880
|
+
return "A";
|
|
2881
|
+
case (hit300Ratio > 0.7 && !resultObject.accuracy.nmiss) ||
|
|
2882
|
+
hit300Ratio > 0.8:
|
|
2883
|
+
return "B";
|
|
2884
|
+
case hit300Ratio > 0.6:
|
|
2885
|
+
return "C";
|
|
2886
|
+
default:
|
|
2887
|
+
return "D";
|
|
2344
2888
|
}
|
|
2345
|
-
return modString;
|
|
2346
2889
|
}
|
|
2347
|
-
|
|
2348
|
-
|
|
2349
|
-
|
|
2350
|
-
|
|
2351
|
-
|
|
2352
|
-
|
|
2353
|
-
|
|
2354
|
-
|
|
2355
|
-
|
|
2356
|
-
|
|
2357
|
-
|
|
2358
|
-
|
|
2359
|
-
const result = threeFingerChecker.check();
|
|
2360
|
-
this.is3Finger = result.is3Finger;
|
|
2361
|
-
this.tapPenalty = result.penalty;
|
|
2362
|
-
this.hasBeenCheckedFor3Finger = true;
|
|
2890
|
+
constructPlayableBeatmap() {
|
|
2891
|
+
var _a;
|
|
2892
|
+
if (this.beatmap instanceof osuBase.DroidPlayableBeatmap) {
|
|
2893
|
+
return this.beatmap;
|
|
2894
|
+
}
|
|
2895
|
+
if (!this.beatmap || !this.data) {
|
|
2896
|
+
throw new Error("Beatmap and replay data must be defined.");
|
|
2897
|
+
}
|
|
2898
|
+
const mods = this.data.isReplayV3()
|
|
2899
|
+
? this.data.convertedMods
|
|
2900
|
+
: (_a = this.difficultyAttributes) === null || _a === void 0 ? void 0 : _a.mods;
|
|
2901
|
+
return this.beatmap.createDroidPlayableBeatmap(mods);
|
|
2363
2902
|
}
|
|
2364
|
-
|
|
2365
|
-
|
|
2366
|
-
|
|
2367
|
-
|
|
2368
|
-
*/
|
|
2369
|
-
checkFor2Hand() {
|
|
2370
|
-
if (!(this.beatmap instanceof osuDifficultyCalculator.DroidDifficultyCalculator ||
|
|
2371
|
-
this.beatmap instanceof osuRebalanceDifficultyCalculator.DroidDifficultyCalculator) ||
|
|
2372
|
-
!this.data) {
|
|
2373
|
-
return;
|
|
2374
|
-
}
|
|
2375
|
-
const twoHandChecker = new TwoHandChecker(this.beatmap, this.data);
|
|
2376
|
-
const result = twoHandChecker.check();
|
|
2377
|
-
this.is2Hand = result.is2Hand;
|
|
2378
|
-
this.twoHandedNoteCount = result.twoHandedNoteCount;
|
|
2379
|
-
this.hasBeenCheckedFor2Hand = true;
|
|
2903
|
+
readByte(buffer) {
|
|
2904
|
+
const num = buffer.readInt8(this.bufferOffset);
|
|
2905
|
+
this.bufferOffset += 1;
|
|
2906
|
+
return num;
|
|
2380
2907
|
}
|
|
2381
|
-
|
|
2382
|
-
|
|
2383
|
-
|
|
2384
|
-
|
|
2385
|
-
*/
|
|
2386
|
-
checkForSliderCheesing() {
|
|
2387
|
-
if (!this.beatmap || !this.data || !this.difficultyAttributes) {
|
|
2388
|
-
return;
|
|
2389
|
-
}
|
|
2390
|
-
const sliderCheeseChecker = new SliderCheeseChecker(this.beatmap instanceof osuBase.Beatmap
|
|
2391
|
-
? this.beatmap
|
|
2392
|
-
: this.beatmap.beatmap, this.data, this.difficultyAttributes);
|
|
2393
|
-
this.sliderCheesePenalty = sliderCheeseChecker.check();
|
|
2394
|
-
this.hasBeenCheckedForSliderCheesing = true;
|
|
2908
|
+
readShort(buffer) {
|
|
2909
|
+
const num = buffer.readInt16BE(this.bufferOffset);
|
|
2910
|
+
this.bufferOffset += 2;
|
|
2911
|
+
return num;
|
|
2395
2912
|
}
|
|
2396
|
-
|
|
2397
|
-
|
|
2398
|
-
|
|
2399
|
-
|
|
2400
|
-
|
|
2401
|
-
|
|
2402
|
-
|
|
2403
|
-
|
|
2404
|
-
|
|
2405
|
-
class ReplayObjectData {
|
|
2406
|
-
/**
|
|
2407
|
-
* For circles, this is the offset at which the circle was hit. If the hit accuracy is 10000, it means the circle was tapped too late ([game source code](https://github.com/osudroid/osu-droid/blob/6306c68e3ffaf671eac794bf45cc95c0f3313a82/src/ru/nsu/ccfit/zuev/osu/game/HitCircle.java#L298-L306)).
|
|
2408
|
-
*
|
|
2409
|
-
* For sliders, this is the offset at which the slider head was hit. For
|
|
2410
|
-
* sliderbreaks, the accuracy would be `Math.floor(<hit window 50>ms) + 13ms` ([game source code](https://github.com/osudroid/osu-droid/blob/6306c68e3ffaf671eac794bf45cc95c0f3313a82/src/ru/nsu/ccfit/zuev/osu/game/Slider.java#L821)).
|
|
2411
|
-
*
|
|
2412
|
-
* For spinners, this is the total amount at which the spinner was spinned:
|
|
2413
|
-
* ```js
|
|
2414
|
-
* const rotations = Math.floor(data.accuracy / 4);
|
|
2415
|
-
* ```
|
|
2416
|
-
* The remainder of the division denotes the hit result of the spinner:
|
|
2417
|
-
* - `HitResult.great`: 3
|
|
2418
|
-
* - `HitResult.good`: 2
|
|
2419
|
-
* - `HitResult.meh`: 1
|
|
2420
|
-
* - `HitResult.miss`: 0
|
|
2421
|
-
*/
|
|
2422
|
-
accuracy;
|
|
2423
|
-
/**
|
|
2424
|
-
* The tickset of the hitobject.
|
|
2425
|
-
*
|
|
2426
|
-
* This is used to determine whether or not a slider event (tick, repeat, and end) is hit based on the order they appear.
|
|
2427
|
-
*/
|
|
2428
|
-
tickset;
|
|
2429
|
-
/**
|
|
2430
|
-
* The bitwise hit result of the hitobject.
|
|
2431
|
-
*/
|
|
2432
|
-
result;
|
|
2433
|
-
constructor(values) {
|
|
2434
|
-
this.accuracy = values.accuracy;
|
|
2435
|
-
this.tickset = values.tickset;
|
|
2436
|
-
this.result = values.result;
|
|
2913
|
+
readInt(buffer) {
|
|
2914
|
+
const num = buffer.readInt32BE(this.bufferOffset);
|
|
2915
|
+
this.bufferOffset += 4;
|
|
2916
|
+
return num;
|
|
2917
|
+
}
|
|
2918
|
+
readFloat(buffer) {
|
|
2919
|
+
const num = buffer.readFloatBE(this.bufferOffset);
|
|
2920
|
+
this.bufferOffset += 4;
|
|
2921
|
+
return num;
|
|
2437
2922
|
}
|
|
2438
2923
|
}
|
|
2439
2924
|
|
|
2440
2925
|
exports.CursorData = CursorData;
|
|
2441
2926
|
exports.CursorOccurrence = CursorOccurrence;
|
|
2442
2927
|
exports.CursorOccurrenceGroup = CursorOccurrenceGroup;
|
|
2928
|
+
exports.RebalanceSliderCheeseChecker = RebalanceSliderCheeseChecker;
|
|
2929
|
+
exports.RebalanceThreeFingerChecker = RebalanceThreeFingerChecker;
|
|
2443
2930
|
exports.ReplayAnalyzer = ReplayAnalyzer;
|
|
2444
2931
|
exports.ReplayData = ReplayData;
|
|
2445
|
-
exports.
|
|
2932
|
+
exports.ReplayV3Data = ReplayV3Data;
|
|
2446
2933
|
exports.SliderCheeseChecker = SliderCheeseChecker;
|
|
2447
2934
|
exports.ThreeFingerChecker = ThreeFingerChecker;
|
|
2448
2935
|
exports.TwoHandChecker = TwoHandChecker;
|