@rian8337/osu-droid-replay-analyzer 4.0.0-beta.14 → 4.0.0-beta.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +707 -91
- package/dist/index.js.map +1 -1
- package/package.json +5 -5
- package/typings/index.d.ts +210 -35
package/dist/index.js
CHANGED
|
@@ -312,6 +312,574 @@ exports.HitResult = void 0;
|
|
|
312
312
|
HitResult[HitResult["great"] = 4] = "great";
|
|
313
313
|
})(exports.HitResult || (exports.HitResult = {}));
|
|
314
314
|
|
|
315
|
+
/**
|
|
316
|
+
* Utility to check whether or not a beatmap is three-fingered for rebalance scores.
|
|
317
|
+
*/
|
|
318
|
+
class RebalanceThreeFingerChecker {
|
|
319
|
+
/**
|
|
320
|
+
* The beatmap that is being analyzed.
|
|
321
|
+
*/
|
|
322
|
+
beatmap;
|
|
323
|
+
/**
|
|
324
|
+
* The data of the replay.
|
|
325
|
+
*/
|
|
326
|
+
data;
|
|
327
|
+
/**
|
|
328
|
+
* The difficulty attributes of the beatmap.
|
|
329
|
+
*/
|
|
330
|
+
difficultyAttributes;
|
|
331
|
+
/**
|
|
332
|
+
* The hitobjects to be analyzed.
|
|
333
|
+
*
|
|
334
|
+
* This is being maintained separately due to possible change in object scale.
|
|
335
|
+
*/
|
|
336
|
+
hitObjects;
|
|
337
|
+
/**
|
|
338
|
+
* The distance threshold between cursors to assume that two cursors are
|
|
339
|
+
* actually pressed with 1 finger in osu!pixels.
|
|
340
|
+
*
|
|
341
|
+
* This is used to prevent cases where a player would lift their finger
|
|
342
|
+
* too fast to the point where the 4th cursor instance or beyond is recorded
|
|
343
|
+
* as 1st, 2nd, or 3rd cursor instance.
|
|
344
|
+
*/
|
|
345
|
+
cursorDistancingDistanceThreshold = 60;
|
|
346
|
+
/**
|
|
347
|
+
* The threshold for the amount of cursors that are assumed to be pressed
|
|
348
|
+
* by a single finger.
|
|
349
|
+
*/
|
|
350
|
+
cursorDistancingCountThreshold = 10;
|
|
351
|
+
/**
|
|
352
|
+
* The threshold for the time difference of cursors that are assumed to be pressed
|
|
353
|
+
* by a single finger, in milliseconds.
|
|
354
|
+
*/
|
|
355
|
+
cursorDistancingTimeThreshold = 1000;
|
|
356
|
+
/**
|
|
357
|
+
* The amount of notes that has a tap strain exceeding `strainThreshold`.
|
|
358
|
+
*/
|
|
359
|
+
strainNoteCount;
|
|
360
|
+
/**
|
|
361
|
+
* The ratio threshold between non-3 finger cursors and 3-finger cursors.
|
|
362
|
+
*
|
|
363
|
+
* Increasing this number will increase detection accuracy, however
|
|
364
|
+
* it also increases the chance of falsely flagged plays.
|
|
365
|
+
*/
|
|
366
|
+
threeFingerRatioThreshold = 0.01;
|
|
367
|
+
/**
|
|
368
|
+
* Extended sections of the beatmap for drag detection.
|
|
369
|
+
*/
|
|
370
|
+
beatmapSections = [];
|
|
371
|
+
/**
|
|
372
|
+
* The hit window of this beatmap. Keep in mind that speed-changing mods do not change hit window length in game logic.
|
|
373
|
+
*/
|
|
374
|
+
hitWindow;
|
|
375
|
+
/**
|
|
376
|
+
* A reprocessed break points to match right on object time.
|
|
377
|
+
*
|
|
378
|
+
* This is used to increase detection accuracy since break points do not start right at the
|
|
379
|
+
* start of the hitobject before it and do not end right at the first hitobject after it.
|
|
380
|
+
*/
|
|
381
|
+
breakPointAccurateTimes = [];
|
|
382
|
+
/**
|
|
383
|
+
* A cursor occurrence nested array that only contains `movementType.DOWN` movement ID occurrences.
|
|
384
|
+
*
|
|
385
|
+
* Each index represents the cursor index.
|
|
386
|
+
*/
|
|
387
|
+
downCursorInstances = [];
|
|
388
|
+
/**
|
|
389
|
+
* Nerf factors from all sections that were three-fingered.
|
|
390
|
+
*/
|
|
391
|
+
nerfFactors = [];
|
|
392
|
+
/**
|
|
393
|
+
* Whether this score uses the Precise mod.
|
|
394
|
+
*/
|
|
395
|
+
isPrecise;
|
|
396
|
+
/**
|
|
397
|
+
* @param beatmap The beatmap to analyze.
|
|
398
|
+
* @param data The data of the replay.
|
|
399
|
+
* @param difficultyAttributes The difficulty attributes of the beatmap.
|
|
400
|
+
*/
|
|
401
|
+
constructor(beatmap, data, difficultyAttributes) {
|
|
402
|
+
this.beatmap = beatmap;
|
|
403
|
+
this.data = data;
|
|
404
|
+
this.difficultyAttributes = difficultyAttributes;
|
|
405
|
+
const od = osuBase.calculateDroidDifficultyStatistics({
|
|
406
|
+
overallDifficulty: beatmap.difficulty.od,
|
|
407
|
+
mods: this.difficultyAttributes.mods.filter((m) => m.isApplicableToDroid() &&
|
|
408
|
+
!osuBase.ModUtil.speedChangingMods.some((v) => v.acronym === m.acronym)),
|
|
409
|
+
convertOverallDifficulty: false,
|
|
410
|
+
}).overallDifficulty;
|
|
411
|
+
this.isPrecise = this.difficultyAttributes.mods.some((m) => m instanceof osuBase.ModPrecise);
|
|
412
|
+
this.hitWindow = new osuBase.DroidHitWindow(od);
|
|
413
|
+
this.strainNoteCount =
|
|
414
|
+
this.difficultyAttributes.possibleThreeFingeredSections.reduce((a, v) => a + v.lastObjectIndex - v.firstObjectIndex + 1, 0);
|
|
415
|
+
this.hitObjects = beatmap.hitObjects.objects;
|
|
416
|
+
}
|
|
417
|
+
/**
|
|
418
|
+
* Checks whether a beatmap is eligible to be detected for 3-finger.
|
|
419
|
+
*
|
|
420
|
+
* @param difficultyAttributes The difficulty attributes of the beatmap.
|
|
421
|
+
*/
|
|
422
|
+
static isEligibleToDetect(difficultyAttributes) {
|
|
423
|
+
return difficultyAttributes.possibleThreeFingeredSections.length > 0;
|
|
424
|
+
}
|
|
425
|
+
/**
|
|
426
|
+
* Checks if the given beatmap is 3-fingered and also returns the final penalty.
|
|
427
|
+
*
|
|
428
|
+
* The beatmap will be separated into sections and each section will be determined
|
|
429
|
+
* whether or not it is dragged.
|
|
430
|
+
*
|
|
431
|
+
* After that, each section will be assigned a nerf factor based on whether or not
|
|
432
|
+
* the section is 3-fingered. These nerf factors will be summed up into a final
|
|
433
|
+
* nerf factor, taking beatmap difficulty into account.
|
|
434
|
+
*/
|
|
435
|
+
check() {
|
|
436
|
+
if (this.strainNoteCount === 0) {
|
|
437
|
+
return { is3Finger: false, penalty: 1 };
|
|
438
|
+
}
|
|
439
|
+
this.getAccurateBreakPoints();
|
|
440
|
+
this.filterCursorInstances();
|
|
441
|
+
this.getBeatmapSections();
|
|
442
|
+
this.calculateNerfFactors();
|
|
443
|
+
const finalPenalty = this.calculateFinalPenalty();
|
|
444
|
+
return { is3Finger: finalPenalty > 1, penalty: finalPenalty };
|
|
445
|
+
}
|
|
446
|
+
/**
|
|
447
|
+
* Generates a new set of "accurate break points".
|
|
448
|
+
*
|
|
449
|
+
* This is done to increase detection accuracy since break points do not start right at the
|
|
450
|
+
* end of the hitobject before it and do not end right at the first hitobject after it.
|
|
451
|
+
*/
|
|
452
|
+
getAccurateBreakPoints() {
|
|
453
|
+
const objectData = this.data.hitObjectData;
|
|
454
|
+
for (const breakPoint of this.beatmap.events.breaks) {
|
|
455
|
+
const beforeIndex = osuBase.MathUtils.clamp(this.hitObjects.findIndex((o) => o.endTime >= breakPoint.startTime) - 1, 0, this.hitObjects.length - 2);
|
|
456
|
+
const objectBefore = this.hitObjects[beforeIndex];
|
|
457
|
+
const objectBeforeData = objectData[beforeIndex];
|
|
458
|
+
let timeBefore = objectBefore.endTime;
|
|
459
|
+
if (objectBefore instanceof osuBase.Circle) {
|
|
460
|
+
if (objectBeforeData.result !== exports.HitResult.miss) {
|
|
461
|
+
timeBefore += objectBeforeData.accuracy;
|
|
462
|
+
}
|
|
463
|
+
else {
|
|
464
|
+
timeBefore += this.hitWindow.hitWindowFor50(this.isPrecise);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
const afterIndex = beforeIndex + 1;
|
|
468
|
+
const objectAfter = this.hitObjects[afterIndex];
|
|
469
|
+
const objectAfterData = objectData[afterIndex];
|
|
470
|
+
let timeAfter = this.hitObjects[afterIndex].startTime;
|
|
471
|
+
if (objectAfter instanceof osuBase.Circle &&
|
|
472
|
+
objectAfterData.result !== exports.HitResult.miss) {
|
|
473
|
+
timeAfter += objectAfterData.accuracy;
|
|
474
|
+
}
|
|
475
|
+
this.breakPointAccurateTimes.push(new osuBase.BreakPoint({
|
|
476
|
+
startTime: timeBefore,
|
|
477
|
+
endTime: timeAfter,
|
|
478
|
+
}));
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
/**
|
|
482
|
+
* Filters the original cursor instances, returning only those with `movementType.DOWN` movement ID.
|
|
483
|
+
*
|
|
484
|
+
* This also filters cursors that are in break period or happen before start/after end of the beatmap.
|
|
485
|
+
*/
|
|
486
|
+
filterCursorInstances() {
|
|
487
|
+
const objectData = this.data.hitObjectData;
|
|
488
|
+
const firstObjectResult = objectData[0].result;
|
|
489
|
+
const lastObjectResult = objectData.at(-1).result;
|
|
490
|
+
const firstObject = this.hitObjects[0];
|
|
491
|
+
const lastObject = this.hitObjects.at(-1);
|
|
492
|
+
// For sliders, automatically set hit window length to be as lenient as possible.
|
|
493
|
+
let firstObjectHitWindow = this.hitWindow.hitWindowFor50(this.isPrecise);
|
|
494
|
+
if (firstObject instanceof osuBase.Circle) {
|
|
495
|
+
switch (firstObjectResult) {
|
|
496
|
+
case exports.HitResult.great:
|
|
497
|
+
firstObjectHitWindow = this.hitWindow.hitWindowFor300(this.isPrecise);
|
|
498
|
+
break;
|
|
499
|
+
case exports.HitResult.good:
|
|
500
|
+
firstObjectHitWindow = this.hitWindow.hitWindowFor100(this.isPrecise);
|
|
501
|
+
break;
|
|
502
|
+
default:
|
|
503
|
+
firstObjectHitWindow = this.hitWindow.hitWindowFor50(this.isPrecise);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
// For sliders, automatically set hit window length to be as lenient as possible.
|
|
507
|
+
let lastObjectHitWindow = this.hitWindow.hitWindowFor50(this.isPrecise);
|
|
508
|
+
if (lastObject instanceof osuBase.Circle) {
|
|
509
|
+
switch (lastObjectResult) {
|
|
510
|
+
case exports.HitResult.great:
|
|
511
|
+
lastObjectHitWindow = this.hitWindow.hitWindowFor300(this.isPrecise);
|
|
512
|
+
break;
|
|
513
|
+
case exports.HitResult.good:
|
|
514
|
+
lastObjectHitWindow = this.hitWindow.hitWindowFor100(this.isPrecise);
|
|
515
|
+
break;
|
|
516
|
+
default:
|
|
517
|
+
lastObjectHitWindow = this.hitWindow.hitWindowFor50(this.isPrecise);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
else if (lastObject instanceof osuBase.Slider) {
|
|
521
|
+
lastObjectHitWindow = Math.min(lastObject.spanDuration, lastObjectHitWindow);
|
|
522
|
+
}
|
|
523
|
+
// These hit time uses hit window length as threshold.
|
|
524
|
+
// This is because cursors aren't recorded exactly at hit time,
|
|
525
|
+
// probably due to the game's behavior.
|
|
526
|
+
const firstObjectHitTime = firstObject.startTime - firstObjectHitWindow;
|
|
527
|
+
const lastObjectHitTime = lastObject.startTime + lastObjectHitWindow;
|
|
528
|
+
for (const cursorInstance of this.data.cursorMovement) {
|
|
529
|
+
const validOccurrences = [];
|
|
530
|
+
for (const group of cursorInstance.occurrenceGroups) {
|
|
531
|
+
if (group.startTime < firstObjectHitTime) {
|
|
532
|
+
continue;
|
|
533
|
+
}
|
|
534
|
+
if (group.startTime > lastObjectHitTime) {
|
|
535
|
+
break;
|
|
536
|
+
}
|
|
537
|
+
if (this.breakPointAccurateTimes.some((v) => group.startTime >= v.startTime &&
|
|
538
|
+
group.endTime <= v.endTime)) {
|
|
539
|
+
continue;
|
|
540
|
+
}
|
|
541
|
+
validOccurrences.push(group.down);
|
|
542
|
+
}
|
|
543
|
+
this.downCursorInstances.push(validOccurrences);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
/**
|
|
547
|
+
* Divides the beatmap into sections, which will be used to
|
|
548
|
+
* detect dragged sections and improve detection speed.
|
|
549
|
+
*/
|
|
550
|
+
getBeatmapSections() {
|
|
551
|
+
const cursorLookupIndices = osuBase.Utils.initializeArray(this.downCursorInstances.length, 0);
|
|
552
|
+
for (const section of this.difficultyAttributes
|
|
553
|
+
.possibleThreeFingeredSections) {
|
|
554
|
+
const dragFingerIndex = this.findDragIndex(section);
|
|
555
|
+
const objects = [];
|
|
556
|
+
for (let i = section.firstObjectIndex; i <= section.lastObjectIndex; ++i) {
|
|
557
|
+
objects.push({
|
|
558
|
+
object: this.hitObjects[i],
|
|
559
|
+
...this.getObjectPressIndex(this.hitObjects[i], this.data.hitObjectData[i], cursorLookupIndices, dragFingerIndex),
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
this.beatmapSections.push({
|
|
563
|
+
...section,
|
|
564
|
+
dragFingerIndex: dragFingerIndex,
|
|
565
|
+
objects: objects,
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
/**
|
|
570
|
+
* Obtains the index of the nearest cursor of which an object was pressed in terms of time.
|
|
571
|
+
*
|
|
572
|
+
* @param object The object to obtain the index for.
|
|
573
|
+
* @param objectData The hit data of the object.
|
|
574
|
+
* @param cursorLookupIndices The cursor indices to start looking for the cursor from, to save computation time.
|
|
575
|
+
* @param excludedIndices The cursor indices that should not be checked.
|
|
576
|
+
* @returns The index of the cursor, -1 if the object was missed or it's a spinner.
|
|
577
|
+
*/
|
|
578
|
+
getObjectPressIndex(object, objectData, cursorLookupIndices, ...excludedCursorIndices) {
|
|
579
|
+
if (objectData.result === exports.HitResult.miss || object instanceof osuBase.Spinner) {
|
|
580
|
+
return {
|
|
581
|
+
pressingCursorInstanceIndex: -1,
|
|
582
|
+
pressingCursorIndex: -1,
|
|
583
|
+
};
|
|
584
|
+
}
|
|
585
|
+
const hitWindow50 = this.hitWindow.hitWindowFor50(this.isPrecise);
|
|
586
|
+
// Check for sliderbreaks and treat them as misses.
|
|
587
|
+
if (object instanceof osuBase.Slider &&
|
|
588
|
+
objectData.accuracy === Math.floor(hitWindow50) + 13) {
|
|
589
|
+
return {
|
|
590
|
+
pressingCursorInstanceIndex: -1,
|
|
591
|
+
pressingCursorIndex: -1,
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
// We are not directly using hit time to determine which cursor pressed the object
|
|
595
|
+
// to account for time difference between hit registration and object judgement.
|
|
596
|
+
let minHitTime = object.startTime;
|
|
597
|
+
let maxHitTime = object.startTime;
|
|
598
|
+
if (object instanceof osuBase.Circle) {
|
|
599
|
+
let hitWindowGap = hitWindow50;
|
|
600
|
+
switch (objectData.result) {
|
|
601
|
+
case exports.HitResult.great:
|
|
602
|
+
hitWindowGap = this.hitWindow.hitWindowFor300(this.isPrecise);
|
|
603
|
+
break;
|
|
604
|
+
case exports.HitResult.good:
|
|
605
|
+
hitWindowGap = this.hitWindow.hitWindowFor100(this.isPrecise);
|
|
606
|
+
break;
|
|
607
|
+
}
|
|
608
|
+
minHitTime -= hitWindowGap;
|
|
609
|
+
maxHitTime += hitWindowGap;
|
|
610
|
+
}
|
|
611
|
+
else if (object instanceof osuBase.Slider) {
|
|
612
|
+
minHitTime -= hitWindow50;
|
|
613
|
+
maxHitTime += Math.min(hitWindow50, object.spanDuration);
|
|
614
|
+
}
|
|
615
|
+
const hitTime = object.startTime + objectData.accuracy;
|
|
616
|
+
let nearestCursorInstanceIndex = null;
|
|
617
|
+
let nearestCursorIndex = null;
|
|
618
|
+
let nearestTime = Number.POSITIVE_INFINITY;
|
|
619
|
+
for (let i = 0; i < this.downCursorInstances.length; ++i) {
|
|
620
|
+
if (excludedCursorIndices.includes(i)) {
|
|
621
|
+
continue;
|
|
622
|
+
}
|
|
623
|
+
const cursors = this.downCursorInstances[i];
|
|
624
|
+
for (let j = cursorLookupIndices[i]; j < cursors.length; cursorLookupIndices[i] = ++j) {
|
|
625
|
+
const cursor = cursors[j];
|
|
626
|
+
if (cursor.time < minHitTime) {
|
|
627
|
+
continue;
|
|
628
|
+
}
|
|
629
|
+
if (cursor.time > maxHitTime) {
|
|
630
|
+
break;
|
|
631
|
+
}
|
|
632
|
+
const deltaTime = Math.abs(hitTime - cursor.time);
|
|
633
|
+
if (deltaTime > nearestTime) {
|
|
634
|
+
break;
|
|
635
|
+
}
|
|
636
|
+
nearestCursorInstanceIndex = i;
|
|
637
|
+
nearestCursorIndex = j;
|
|
638
|
+
nearestTime = deltaTime;
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
return {
|
|
642
|
+
pressingCursorInstanceIndex: nearestCursorInstanceIndex ?? -1,
|
|
643
|
+
pressingCursorIndex: nearestCursorIndex ?? -1,
|
|
644
|
+
};
|
|
645
|
+
}
|
|
646
|
+
/**
|
|
647
|
+
* Checks if a section is dragged and returns the index of the drag finger.
|
|
648
|
+
*
|
|
649
|
+
* If the section is not dragged, -1 will be returned.
|
|
650
|
+
*
|
|
651
|
+
* @param section The section to check.
|
|
652
|
+
*/
|
|
653
|
+
findDragIndex(section) {
|
|
654
|
+
const objectData = this.data.hitObjectData;
|
|
655
|
+
const firstObject = this.hitObjects[section.firstObjectIndex];
|
|
656
|
+
const lastObject = this.hitObjects[section.lastObjectIndex];
|
|
657
|
+
let firstObjectMinHitTime = firstObject.startTime;
|
|
658
|
+
if (firstObject instanceof osuBase.Circle) {
|
|
659
|
+
switch (objectData[section.firstObjectIndex].result) {
|
|
660
|
+
case exports.HitResult.great:
|
|
661
|
+
firstObjectMinHitTime -= this.hitWindow.hitWindowFor300(this.isPrecise);
|
|
662
|
+
break;
|
|
663
|
+
case exports.HitResult.good:
|
|
664
|
+
firstObjectMinHitTime -= this.hitWindow.hitWindowFor100(this.isPrecise);
|
|
665
|
+
break;
|
|
666
|
+
default:
|
|
667
|
+
firstObjectMinHitTime -= this.hitWindow.hitWindowFor50(this.isPrecise);
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
else if (firstObject instanceof osuBase.Slider) {
|
|
671
|
+
firstObjectMinHitTime -= this.hitWindow.hitWindowFor50(this.isPrecise);
|
|
672
|
+
}
|
|
673
|
+
let lastObjectMaxHitTime = lastObject.startTime;
|
|
674
|
+
if (lastObject instanceof osuBase.Circle) {
|
|
675
|
+
switch (objectData[section.lastObjectIndex].result) {
|
|
676
|
+
case exports.HitResult.great:
|
|
677
|
+
lastObjectMaxHitTime += this.hitWindow.hitWindowFor300(this.isPrecise);
|
|
678
|
+
break;
|
|
679
|
+
case exports.HitResult.good:
|
|
680
|
+
lastObjectMaxHitTime += this.hitWindow.hitWindowFor100(this.isPrecise);
|
|
681
|
+
break;
|
|
682
|
+
default:
|
|
683
|
+
lastObjectMaxHitTime += this.hitWindow.hitWindowFor50(this.isPrecise);
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
else if (lastObject instanceof osuBase.Slider) {
|
|
687
|
+
lastObjectMaxHitTime += Math.min(this.hitWindow.hitWindowFor50(this.isPrecise), lastObject.spanDuration);
|
|
688
|
+
}
|
|
689
|
+
// Since there may be more than 1 cursor instance index,
|
|
690
|
+
// we check which cursor instance follows hitobjects all over.
|
|
691
|
+
const cursorIndices = [];
|
|
692
|
+
for (let i = 0; i < this.data.cursorMovement.length; ++i) {
|
|
693
|
+
const c = this.data.cursorMovement[i];
|
|
694
|
+
if (c.occurrenceGroups.length === 0) {
|
|
695
|
+
continue;
|
|
696
|
+
}
|
|
697
|
+
// Do not include cursors that don't have an occurence in this section
|
|
698
|
+
// this speeds up checking process.
|
|
699
|
+
if (c.occurrenceGroups.filter((v) => v.startTime >= firstObjectMinHitTime &&
|
|
700
|
+
v.endTime <= lastObjectMaxHitTime).length === 0) {
|
|
701
|
+
continue;
|
|
702
|
+
}
|
|
703
|
+
// If this cursor instance doesn't move, it's not the cursor instance we want.
|
|
704
|
+
if (c.occurrenceGroups.filter((v) => v.moves.length > 0).length ===
|
|
705
|
+
0) {
|
|
706
|
+
continue;
|
|
707
|
+
}
|
|
708
|
+
cursorIndices.push(i);
|
|
709
|
+
}
|
|
710
|
+
const sectionObjects = this.hitObjects.slice(section.firstObjectIndex, section.lastObjectIndex + 1);
|
|
711
|
+
const sectionReplayObjectData = objectData.slice(section.firstObjectIndex, section.lastObjectIndex + 1);
|
|
712
|
+
const hitWindow50 = this.hitWindow.hitWindowFor50(this.isPrecise);
|
|
713
|
+
for (let i = 0; i < sectionObjects.length && cursorIndices.some((v) => v !== -1); ++i) {
|
|
714
|
+
const object = sectionObjects[i];
|
|
715
|
+
const objectData = sectionReplayObjectData[i];
|
|
716
|
+
if (object instanceof osuBase.Spinner ||
|
|
717
|
+
objectData.result === exports.HitResult.miss) {
|
|
718
|
+
continue;
|
|
719
|
+
}
|
|
720
|
+
// Exclude sliderbreaks.
|
|
721
|
+
if (object instanceof osuBase.Slider &&
|
|
722
|
+
objectData.accuracy === Math.floor(hitWindow50) + 13) {
|
|
723
|
+
continue;
|
|
724
|
+
}
|
|
725
|
+
const objectPosition = object.getStackedPosition(osuBase.Modes.droid);
|
|
726
|
+
const hitTime = object.startTime + objectData.accuracy;
|
|
727
|
+
// Observe the cursor position at the object's hit time.
|
|
728
|
+
for (let j = 0; j < cursorIndices.length; ++j) {
|
|
729
|
+
if (cursorIndices[j] === -1) {
|
|
730
|
+
continue;
|
|
731
|
+
}
|
|
732
|
+
const cursorData = this.data.cursorMovement[cursorIndices[j]];
|
|
733
|
+
const cursorGroup = cursorData.occurrenceGroups.find((v) => v.isActiveAt(hitTime));
|
|
734
|
+
if (!cursorGroup) {
|
|
735
|
+
continue;
|
|
736
|
+
}
|
|
737
|
+
const cursors = cursorGroup.allOccurrences;
|
|
738
|
+
let isInObject = false;
|
|
739
|
+
for (let k = 1; k < cursors.length; ++k) {
|
|
740
|
+
const cursor = cursors[k];
|
|
741
|
+
const prevCursor = cursors[k - 1];
|
|
742
|
+
// Only consider cursor at interval prev.time <= hitTime <= current.time.
|
|
743
|
+
if (prevCursor.time <= hitTime && cursor.time >= hitTime) {
|
|
744
|
+
switch (cursor.id) {
|
|
745
|
+
case exports.MovementType.up:
|
|
746
|
+
isInObject =
|
|
747
|
+
prevCursor.position.getDistance(objectPosition) <= object.radius;
|
|
748
|
+
break;
|
|
749
|
+
case exports.MovementType.move: {
|
|
750
|
+
// Interpolate movement.
|
|
751
|
+
const t = (hitTime - prevCursor.time) /
|
|
752
|
+
(cursor.time - prevCursor.time);
|
|
753
|
+
const cursorPosition = new osuBase.Vector2(osuBase.Interpolation.lerp(prevCursor.position.x, cursor.position.x, t), osuBase.Interpolation.lerp(prevCursor.position.y, cursor.position.y, t));
|
|
754
|
+
isInObject =
|
|
755
|
+
objectPosition.getDistance(cursorPosition) <= object.radius;
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
break;
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
if (!isInObject) {
|
|
762
|
+
cursorIndices[j] = -1;
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
return cursorIndices.find((v) => v !== -1) ?? -1;
|
|
767
|
+
}
|
|
768
|
+
/**
|
|
769
|
+
* Creates nerf factors by scanning through objects.
|
|
770
|
+
*/
|
|
771
|
+
calculateNerfFactors() {
|
|
772
|
+
for (const beatmapSection of this.beatmapSections) {
|
|
773
|
+
const cursorAmounts = osuBase.Utils.initializeArray(this.downCursorInstances.length, 0);
|
|
774
|
+
const cursorVectorTimes = [];
|
|
775
|
+
for (const object of beatmapSection.objects) {
|
|
776
|
+
if (object.pressingCursorIndex === -1 ||
|
|
777
|
+
object.pressingCursorInstanceIndex ===
|
|
778
|
+
beatmapSection.dragFingerIndex) {
|
|
779
|
+
continue;
|
|
780
|
+
}
|
|
781
|
+
++cursorAmounts[object.pressingCursorInstanceIndex];
|
|
782
|
+
const cursor = this.downCursorInstances[object.pressingCursorInstanceIndex][object.pressingCursorIndex];
|
|
783
|
+
cursorVectorTimes.push({
|
|
784
|
+
vector: new osuBase.Vector2(cursor.position.x, cursor.position.y),
|
|
785
|
+
time: cursor.time,
|
|
786
|
+
});
|
|
787
|
+
}
|
|
788
|
+
if (beatmapSection.dragFingerIndex !== -1) {
|
|
789
|
+
// Remove the drag index to prevent it from being picked up into the detection.
|
|
790
|
+
cursorAmounts.splice(beatmapSection.dragFingerIndex, 1);
|
|
791
|
+
}
|
|
792
|
+
// This index will be used to detect if a section is 3-fingered.
|
|
793
|
+
// If the section is dragged, the dragged instance will be ignored,
|
|
794
|
+
// hence why the index is 1 less than nondragged section.
|
|
795
|
+
const fingerSplitIndex = beatmapSection.dragFingerIndex !== -1 ? 2 : 3;
|
|
796
|
+
// Divide >=4th (3rd for drag) cursor instances with 1st + 2nd (+ 3rd for nondrag)
|
|
797
|
+
// to check if the section is 3-fingered.
|
|
798
|
+
const threeFingerRatio = cursorAmounts
|
|
799
|
+
.slice(fingerSplitIndex)
|
|
800
|
+
.reduce((acc, value) => acc + value, 0) /
|
|
801
|
+
Math.max(cursorAmounts
|
|
802
|
+
.slice(0, fingerSplitIndex)
|
|
803
|
+
.reduce((acc, value) => acc + value, 0), 1);
|
|
804
|
+
const similarPresses = [];
|
|
805
|
+
cursorVectorTimes.sort((a, b) => a.time - b.time);
|
|
806
|
+
for (const cursorVectorTime of cursorVectorTimes) {
|
|
807
|
+
const pressIndex = similarPresses.findIndex((v) => v.vector.getDistance(cursorVectorTime.vector) <=
|
|
808
|
+
this.cursorDistancingDistanceThreshold);
|
|
809
|
+
if (pressIndex !== -1) {
|
|
810
|
+
if (cursorVectorTime.time -
|
|
811
|
+
similarPresses[pressIndex].lastTime >=
|
|
812
|
+
this.cursorDistancingTimeThreshold) {
|
|
813
|
+
// If the previous press is too late, remove it from the
|
|
814
|
+
// list and register the current press as a new press.
|
|
815
|
+
similarPresses.splice(pressIndex, 1);
|
|
816
|
+
similarPresses.push({
|
|
817
|
+
vector: cursorVectorTime.vector,
|
|
818
|
+
count: 1,
|
|
819
|
+
lastTime: cursorVectorTime.time,
|
|
820
|
+
});
|
|
821
|
+
continue;
|
|
822
|
+
}
|
|
823
|
+
similarPresses[pressIndex].vector = cursorVectorTime.vector;
|
|
824
|
+
similarPresses[pressIndex].lastTime = cursorVectorTime.time;
|
|
825
|
+
++similarPresses[pressIndex].count;
|
|
826
|
+
}
|
|
827
|
+
else {
|
|
828
|
+
similarPresses.push({
|
|
829
|
+
vector: cursorVectorTime.vector,
|
|
830
|
+
count: 1,
|
|
831
|
+
lastTime: cursorVectorTime.time,
|
|
832
|
+
});
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
// Sort by highest count; assume the order is 3rd, 4th, 5th, ... finger
|
|
836
|
+
const threeFingerPresses = similarPresses
|
|
837
|
+
.filter((v) => v.count >= this.cursorDistancingCountThreshold)
|
|
838
|
+
.sort((a, b) => b.count - a.count)
|
|
839
|
+
.slice(fingerSplitIndex);
|
|
840
|
+
if (threeFingerRatio > this.threeFingerRatioThreshold ||
|
|
841
|
+
threeFingerPresses.length > 0) {
|
|
842
|
+
const objectCount = beatmapSection.lastObjectIndex -
|
|
843
|
+
beatmapSection.firstObjectIndex +
|
|
844
|
+
1;
|
|
845
|
+
// We can ignore the first 3 (2 for drag) filled cursor instances
|
|
846
|
+
// since they are guaranteed not 3 finger.
|
|
847
|
+
const threeFingerCursorAmounts = cursorAmounts
|
|
848
|
+
.slice(fingerSplitIndex)
|
|
849
|
+
.filter((amount) => amount > 0);
|
|
850
|
+
// Finger factor applies more penalty if more fingers were used.
|
|
851
|
+
const fingerFactor = threeFingerRatio > this.threeFingerRatioThreshold
|
|
852
|
+
? threeFingerCursorAmounts.reduce((acc, value, index) => acc +
|
|
853
|
+
Math.pow(((index + 1) * value * objectCount) /
|
|
854
|
+
this.strainNoteCount, 0.9), 1)
|
|
855
|
+
: Math.pow(threeFingerPresses.reduce((acc, value, index) => acc +
|
|
856
|
+
Math.pow(((index + 1) *
|
|
857
|
+
(value.count /
|
|
858
|
+
(this
|
|
859
|
+
.cursorDistancingCountThreshold *
|
|
860
|
+
2)) *
|
|
861
|
+
objectCount) /
|
|
862
|
+
this.strainNoteCount, 0.2), 1), 0.2);
|
|
863
|
+
// Length factor applies more penalty if there are more 3-fingered object.
|
|
864
|
+
const lengthFactor = 1 + Math.pow(objectCount / this.strainNoteCount, 1.2);
|
|
865
|
+
this.nerfFactors.push({
|
|
866
|
+
strainFactor: Math.max(1, beatmapSection.sumStrain),
|
|
867
|
+
fingerFactor,
|
|
868
|
+
lengthFactor,
|
|
869
|
+
});
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
/**
|
|
874
|
+
* Calculates the final penalty.
|
|
875
|
+
*/
|
|
876
|
+
calculateFinalPenalty() {
|
|
877
|
+
return this.nerfFactors.reduce((a, n) => a +
|
|
878
|
+
0.015 *
|
|
879
|
+
Math.pow(n.strainFactor * n.fingerFactor * n.lengthFactor, 1.05), 1);
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
|
|
315
883
|
/**
|
|
316
884
|
* Represents a replay data in an osu!droid replay.
|
|
317
885
|
*
|
|
@@ -337,8 +905,12 @@ class ReplayData {
|
|
|
337
905
|
convertedMods;
|
|
338
906
|
cursorMovement;
|
|
339
907
|
hitObjectData;
|
|
340
|
-
|
|
341
|
-
|
|
908
|
+
speedMultiplier;
|
|
909
|
+
forceCS;
|
|
910
|
+
forceAR;
|
|
911
|
+
forceOD;
|
|
912
|
+
forceHP;
|
|
913
|
+
flashlightFollowDelay;
|
|
342
914
|
constructor(values) {
|
|
343
915
|
this.replayVersion = values.replayVersion;
|
|
344
916
|
this.folderName = values.folderName;
|
|
@@ -357,8 +929,12 @@ class ReplayData {
|
|
|
357
929
|
this.convertedMods = values.convertedMods || [];
|
|
358
930
|
this.cursorMovement = values.cursorMovement;
|
|
359
931
|
this.hitObjectData = values.hitObjectData;
|
|
360
|
-
this.
|
|
361
|
-
this.
|
|
932
|
+
this.speedMultiplier = values.speedMultiplier || 1;
|
|
933
|
+
this.forceCS = values.forceCS;
|
|
934
|
+
this.forceAR = values.forceAR;
|
|
935
|
+
this.forceOD = values.forceOD;
|
|
936
|
+
this.forceHP = values.forceHP;
|
|
937
|
+
this.flashlightFollowDelay = values.flashlightFollowDelay;
|
|
362
938
|
}
|
|
363
939
|
}
|
|
364
940
|
|
|
@@ -378,10 +954,6 @@ class ThreeFingerChecker {
|
|
|
378
954
|
* The difficulty attributes of the beatmap.
|
|
379
955
|
*/
|
|
380
956
|
difficultyAttributes;
|
|
381
|
-
/**
|
|
382
|
-
* The true scale of objects.
|
|
383
|
-
*/
|
|
384
|
-
trueScale;
|
|
385
957
|
/**
|
|
386
958
|
* The distance threshold between cursors to assume that two cursors are
|
|
387
959
|
* actually pressed with 1 finger in osu!pixels.
|
|
@@ -458,20 +1030,16 @@ class ThreeFingerChecker {
|
|
|
458
1030
|
this.beatmap = beatmap;
|
|
459
1031
|
this.data = data;
|
|
460
1032
|
this.difficultyAttributes = difficultyAttributes;
|
|
461
|
-
const
|
|
462
|
-
|
|
1033
|
+
const od = osuBase.calculateDroidDifficultyStatistics({
|
|
1034
|
+
overallDifficulty: beatmap.difficulty.od,
|
|
463
1035
|
mods: this.difficultyAttributes.mods.filter((m) => m.isApplicableToDroid() &&
|
|
464
1036
|
!osuBase.ModUtil.speedChangingMods.some((v) => v.acronym === m.acronym)),
|
|
465
|
-
|
|
1037
|
+
convertOverallDifficulty: false,
|
|
1038
|
+
}).overallDifficulty;
|
|
466
1039
|
this.isPrecise = this.difficultyAttributes.mods.some((m) => m instanceof osuBase.ModPrecise);
|
|
467
|
-
this.hitWindow = new osuBase.DroidHitWindow(
|
|
1040
|
+
this.hitWindow = new osuBase.DroidHitWindow(od);
|
|
468
1041
|
this.strainNoteCount =
|
|
469
1042
|
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;
|
|
475
1043
|
}
|
|
476
1044
|
/**
|
|
477
1045
|
* Checks whether a beatmap is eligible to be detected for 3-finger.
|
|
@@ -564,11 +1132,13 @@ class ThreeFingerChecker {
|
|
|
564
1132
|
filterCursorInstances() {
|
|
565
1133
|
const { objects } = this.beatmap.hitObjects;
|
|
566
1134
|
const objectData = this.data.hitObjectData;
|
|
1135
|
+
const firstObject = objects[0];
|
|
1136
|
+
const lastObject = objects.at(-1);
|
|
567
1137
|
const firstObjectResult = objectData[0].result;
|
|
568
1138
|
const lastObjectResult = objectData.at(-1).result;
|
|
569
1139
|
// For sliders, automatically set hit window length to be as lenient as possible.
|
|
570
1140
|
let firstObjectHitWindow = this.hitWindow.hitWindowFor50(this.isPrecise);
|
|
571
|
-
if (
|
|
1141
|
+
if (firstObject instanceof osuBase.Circle) {
|
|
572
1142
|
switch (firstObjectResult) {
|
|
573
1143
|
case exports.HitResult.great:
|
|
574
1144
|
firstObjectHitWindow = this.hitWindow.hitWindowFor300(this.isPrecise);
|
|
@@ -582,7 +1152,7 @@ class ThreeFingerChecker {
|
|
|
582
1152
|
}
|
|
583
1153
|
// For sliders, automatically set hit window length to be as lenient as possible.
|
|
584
1154
|
let lastObjectHitWindow = this.hitWindow.hitWindowFor50(this.isPrecise);
|
|
585
|
-
if (
|
|
1155
|
+
if (lastObject instanceof osuBase.Circle) {
|
|
586
1156
|
switch (lastObjectResult) {
|
|
587
1157
|
case exports.HitResult.great:
|
|
588
1158
|
lastObjectHitWindow = this.hitWindow.hitWindowFor300(this.isPrecise);
|
|
@@ -597,8 +1167,8 @@ class ThreeFingerChecker {
|
|
|
597
1167
|
// These hit time uses hit window length as threshold.
|
|
598
1168
|
// This is because cursors aren't recorded exactly at hit time,
|
|
599
1169
|
// probably due to the game's behavior.
|
|
600
|
-
const firstObjectHitTime =
|
|
601
|
-
const lastObjectHitTime =
|
|
1170
|
+
const firstObjectHitTime = firstObject.startTime - firstObjectHitWindow;
|
|
1171
|
+
const lastObjectHitTime = lastObject.startTime + lastObjectHitWindow;
|
|
602
1172
|
for (let i = 0; i < this.data.cursorMovement.length; ++i) {
|
|
603
1173
|
const cursorInstance = this.data.cursorMovement[i];
|
|
604
1174
|
const validOccurrences = [];
|
|
@@ -720,7 +1290,7 @@ class ThreeFingerChecker {
|
|
|
720
1290
|
findDragIndex(sectionObjects, sectionReplayObjectData, cursorIndexes) {
|
|
721
1291
|
const hitWindow50 = this.hitWindow.hitWindowFor50(this.isPrecise);
|
|
722
1292
|
for (let i = 0; i < sectionObjects.length && cursorIndexes.every((v) => v !== -1); ++i) {
|
|
723
|
-
|
|
1293
|
+
const object = sectionObjects[i];
|
|
724
1294
|
const objectData = sectionReplayObjectData[i];
|
|
725
1295
|
if (object instanceof osuBase.Spinner ||
|
|
726
1296
|
objectData.result === exports.HitResult.miss) {
|
|
@@ -731,11 +1301,6 @@ class ThreeFingerChecker {
|
|
|
731
1301
|
objectData.accuracy === Math.floor(hitWindow50) + 13) {
|
|
732
1302
|
continue;
|
|
733
1303
|
}
|
|
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
1304
|
const objectPosition = object.getStackedPosition(osuBase.Modes.droid);
|
|
740
1305
|
const hitTime = object.startTime + objectData.accuracy;
|
|
741
1306
|
// Observe the cursor position at the object's hit time.
|
|
@@ -762,7 +1327,7 @@ class ThreeFingerChecker {
|
|
|
762
1327
|
switch (cursor.id) {
|
|
763
1328
|
case exports.MovementType.up:
|
|
764
1329
|
isInObject =
|
|
765
|
-
prevCursor.position.getDistance(objectPosition) <= object.
|
|
1330
|
+
prevCursor.position.getDistance(objectPosition) <= object.radius;
|
|
766
1331
|
break;
|
|
767
1332
|
case exports.MovementType.move:
|
|
768
1333
|
// Interpolate movement.
|
|
@@ -773,7 +1338,7 @@ class ThreeFingerChecker {
|
|
|
773
1338
|
(cursor.time - prevCursor.time);
|
|
774
1339
|
const cursorPosition = new osuBase.Vector2(osuBase.Interpolation.lerp(prevCursor.position.x, cursor.position.x, t), osuBase.Interpolation.lerp(prevCursor.position.y, cursor.position.y, t));
|
|
775
1340
|
isInObject =
|
|
776
|
-
objectPosition.getDistance(cursorPosition) <= object.
|
|
1341
|
+
objectPosition.getDistance(cursorPosition) <= object.radius;
|
|
777
1342
|
}
|
|
778
1343
|
}
|
|
779
1344
|
if (!isInObject) {
|
|
@@ -944,10 +1509,9 @@ class ThreeFingerChecker {
|
|
|
944
1509
|
* Calculates the final penalty.
|
|
945
1510
|
*/
|
|
946
1511
|
calculateFinalPenalty() {
|
|
947
|
-
return (
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
Math.pow(n.strainFactor * n.fingerFactor * n.lengthFactor, 1.05), 0));
|
|
1512
|
+
return this.nerfFactors.reduce((a, n) => a +
|
|
1513
|
+
0.015 *
|
|
1514
|
+
Math.pow(n.strainFactor * n.fingerFactor * n.lengthFactor, 1.05), 1);
|
|
951
1515
|
}
|
|
952
1516
|
}
|
|
953
1517
|
|
|
@@ -1049,12 +1613,13 @@ class TwoHandChecker {
|
|
|
1049
1613
|
constructor(calculator, data) {
|
|
1050
1614
|
this.calculator = calculator;
|
|
1051
1615
|
this.data = data;
|
|
1052
|
-
const
|
|
1053
|
-
|
|
1054
|
-
mods:
|
|
1616
|
+
const od = osuBase.calculateDroidDifficultyStatistics({
|
|
1617
|
+
overallDifficulty: calculator.beatmap.difficulty.od,
|
|
1618
|
+
mods: calculator.mods.filter((m) => m.isApplicableToDroid() &&
|
|
1055
1619
|
!osuBase.ModUtil.speedChangingMods.some((v) => v.acronym === m.acronym)),
|
|
1056
|
-
|
|
1057
|
-
|
|
1620
|
+
convertOverallDifficulty: false,
|
|
1621
|
+
}).overallDifficulty;
|
|
1622
|
+
this.hitWindow = new osuBase.DroidHitWindow(od);
|
|
1058
1623
|
this.hitWindow50 = this.hitWindow.hitWindowFor50(calculator.mods.some((m) => m instanceof osuBase.ModPrecise));
|
|
1059
1624
|
// this.csvString = `Mods,${
|
|
1060
1625
|
// data.convertedMods.reduce((a, m) => a + m.acronym, "") || "NM"
|
|
@@ -1237,9 +1802,8 @@ class TwoHandChecker {
|
|
|
1237
1802
|
const prevToCurrentMovement = object
|
|
1238
1803
|
.getStackedPosition(osuBase.Modes.droid)
|
|
1239
1804
|
.subtract(prevObjectEndPosition);
|
|
1240
|
-
const radius = object.getRadius(osuBase.Modes.droid);
|
|
1241
1805
|
// Don't consider objects that are too close to each other.
|
|
1242
|
-
if (prevToCurrentMovement.length <= radius) {
|
|
1806
|
+
if (prevToCurrentMovement.length <= object.radius) {
|
|
1243
1807
|
return new IndexedHitObject(diffObject, -1, -1, -1, null, false);
|
|
1244
1808
|
}
|
|
1245
1809
|
// The case for a one-handed object is that there will be a slight movement in the cursor towards
|
|
@@ -1368,7 +1932,6 @@ class TwoHandChecker {
|
|
|
1368
1932
|
cursorTime: object.startTime,
|
|
1369
1933
|
};
|
|
1370
1934
|
}
|
|
1371
|
-
const radius = object.getRadius(osuBase.Modes.droid);
|
|
1372
1935
|
let hitWindow = this.hitWindow50;
|
|
1373
1936
|
const isPrecise = this.data.convertedMods.some((m) => m instanceof osuBase.ModPrecise);
|
|
1374
1937
|
// For sliders, set the hit window to as lenient as possible.
|
|
@@ -1383,7 +1946,7 @@ class TwoHandChecker {
|
|
|
1383
1946
|
}
|
|
1384
1947
|
}
|
|
1385
1948
|
// TODO: what to do for head sliderbreaks?
|
|
1386
|
-
let nearestPosition = new osuBase.Vector2(Number.POSITIVE_INFINITY
|
|
1949
|
+
let nearestPosition = new osuBase.Vector2(Number.POSITIVE_INFINITY);
|
|
1387
1950
|
let nearestCursorIndex = 0;
|
|
1388
1951
|
let nearestGroupIndex = 0;
|
|
1389
1952
|
let nearestCursorGroupIndex = 0;
|
|
@@ -1391,12 +1954,12 @@ class TwoHandChecker {
|
|
|
1391
1954
|
const minimumActiveTime = object.startTime - hitWindow;
|
|
1392
1955
|
const maximumActiveTime = object.startTime + hitWindow;
|
|
1393
1956
|
for (let i = 0; i < this.data.cursorMovement.length; ++i) {
|
|
1394
|
-
if (nearestPosition.getDistance(objectPosition) <= radius) {
|
|
1957
|
+
if (nearestPosition.getDistance(objectPosition) <= object.radius) {
|
|
1395
1958
|
break;
|
|
1396
1959
|
}
|
|
1397
1960
|
const cursorData = this.data.cursorMovement[i];
|
|
1398
1961
|
for (let j = 0; j < cursorData.occurrenceGroups.length; ++j) {
|
|
1399
|
-
if (nearestPosition.getDistance(objectPosition) <= radius) {
|
|
1962
|
+
if (nearestPosition.getDistance(objectPosition) <= object.radius) {
|
|
1400
1963
|
break;
|
|
1401
1964
|
}
|
|
1402
1965
|
const cursorGroup = cursorData.occurrenceGroups[j];
|
|
@@ -1408,7 +1971,8 @@ class TwoHandChecker {
|
|
|
1408
1971
|
}
|
|
1409
1972
|
// Validate the down press first.
|
|
1410
1973
|
const { down } = cursorGroup;
|
|
1411
|
-
if (down.position.getDistance(objectPosition) <=
|
|
1974
|
+
if (down.position.getDistance(objectPosition) <=
|
|
1975
|
+
object.radius &&
|
|
1412
1976
|
Math.abs(down.time - object.startTime) <= hitWindow) {
|
|
1413
1977
|
if (objectIndex > 0) {
|
|
1414
1978
|
const prevObject = this.calculator.beatmap.hitObjects.objects[objectIndex - 1];
|
|
@@ -1479,12 +2043,13 @@ class TwoHandChecker {
|
|
|
1479
2043
|
nearestCursorGroupIndex = k;
|
|
1480
2044
|
nearestCursorTime = cursorGroup.down.time;
|
|
1481
2045
|
}
|
|
1482
|
-
if (distance <= radius) {
|
|
2046
|
+
if (distance <= object.radius) {
|
|
1483
2047
|
break;
|
|
1484
2048
|
}
|
|
1485
2049
|
}
|
|
1486
2050
|
}
|
|
1487
|
-
if (nearestPosition.getDistance(objectPosition) <=
|
|
2051
|
+
if (nearestPosition.getDistance(objectPosition) <=
|
|
2052
|
+
object.radius) {
|
|
1488
2053
|
break;
|
|
1489
2054
|
}
|
|
1490
2055
|
}
|
|
@@ -1532,7 +2097,7 @@ class TwoHandChecker {
|
|
|
1532
2097
|
else {
|
|
1533
2098
|
objectEndPosition = object.getStackedPosition(osuBase.Modes.droid);
|
|
1534
2099
|
}
|
|
1535
|
-
let nearestPosition = new osuBase.Vector2(Number.POSITIVE_INFINITY
|
|
2100
|
+
let nearestPosition = new osuBase.Vector2(Number.POSITIVE_INFINITY);
|
|
1536
2101
|
let nearestCursorIndex = 0;
|
|
1537
2102
|
let nearestGroupIndex = 0;
|
|
1538
2103
|
let nearestCursorGroupIndex = 0;
|
|
@@ -1657,12 +2222,13 @@ class SliderCheeseChecker {
|
|
|
1657
2222
|
this.beatmap = beatmap;
|
|
1658
2223
|
this.data = data;
|
|
1659
2224
|
this.difficultyAttributes = difficultyAttributes;
|
|
1660
|
-
const
|
|
1661
|
-
|
|
2225
|
+
const od = osuBase.calculateDroidDifficultyStatistics({
|
|
2226
|
+
overallDifficulty: beatmap.difficulty.od,
|
|
1662
2227
|
mods: this.difficultyAttributes.mods.filter((m) => m.isApplicableToDroid() &&
|
|
1663
2228
|
!osuBase.ModUtil.speedChangingMods.some((v) => v.acronym === m.acronym)),
|
|
1664
|
-
|
|
1665
|
-
|
|
2229
|
+
convertOverallDifficulty: false,
|
|
2230
|
+
}).overallDifficulty;
|
|
2231
|
+
this.hitWindow50 = new osuBase.DroidHitWindow(od).hitWindowFor50(this.difficultyAttributes.mods.some((m) => m instanceof osuBase.ModPrecise));
|
|
1666
2232
|
}
|
|
1667
2233
|
/**
|
|
1668
2234
|
* Checks if relevant sliders in the given beatmap was cheesed.
|
|
@@ -1689,12 +2255,7 @@ class SliderCheeseChecker {
|
|
|
1689
2255
|
const cheesedDifficultyRatings = [];
|
|
1690
2256
|
// Current loop indices are stored for efficiency.
|
|
1691
2257
|
const cursorLoopIndices = osuBase.Utils.initializeArray(10, 0);
|
|
1692
|
-
const
|
|
1693
|
-
cs: this.beatmap.difficulty.cs,
|
|
1694
|
-
mods: this.difficultyAttributes.mods,
|
|
1695
|
-
}).calculate({ mode: osuBase.Modes.droid }).cs;
|
|
1696
|
-
const scale = osuBase.CircleSizeCalculator.standardCSToStandardScale(circleSize);
|
|
1697
|
-
const acceptableRadius = 64 * scale * 2.4;
|
|
2258
|
+
const acceptableRadius = objects[0].radius * 2;
|
|
1698
2259
|
// Sort difficult sliders by index so that cursor loop indices work properly.
|
|
1699
2260
|
for (const difficultSlider of this.difficultyAttributes.difficultSliders
|
|
1700
2261
|
.slice()
|
|
@@ -1708,12 +2269,7 @@ class SliderCheeseChecker {
|
|
|
1708
2269
|
objectData.accuracy === Math.floor(this.hitWindow50) + 13) {
|
|
1709
2270
|
continue;
|
|
1710
2271
|
}
|
|
1711
|
-
|
|
1712
|
-
if (object.droidScale !== scale) {
|
|
1713
|
-
// Deep clone the object so that we can assign scale properly.
|
|
1714
|
-
object = osuBase.Utils.deepCopy(object);
|
|
1715
|
-
object.droidScale = scale;
|
|
1716
|
-
}
|
|
2272
|
+
const object = objects[difficultSlider.index];
|
|
1717
2273
|
const objectStartPosition = object.getStackedPosition(osuBase.Modes.droid);
|
|
1718
2274
|
// These time boundaries should consider the delta time between the previous and next
|
|
1719
2275
|
// object as well as their hit accuracy. However, they are somewhat complicated to
|
|
@@ -1812,14 +2368,6 @@ class SliderCheeseChecker {
|
|
|
1812
2368
|
continue;
|
|
1813
2369
|
}
|
|
1814
2370
|
const nestedObject = object.nestedHitObjects[i];
|
|
1815
|
-
nestedObject.droidScale = scale;
|
|
1816
|
-
// Special treatment for slider tail where its treated as a "legacy tail" in osu!standard.
|
|
1817
|
-
// In that case, its time is assumed to be 36ms behind the slider's end time. However, that
|
|
1818
|
-
// is not the case for osu!droid.
|
|
1819
|
-
if (nestedObject instanceof osuBase.SliderTail) {
|
|
1820
|
-
nestedObject.startTime = object.endTime;
|
|
1821
|
-
nestedObject.endTime = object.endTime;
|
|
1822
|
-
}
|
|
1823
2371
|
const nestedPosition = nestedObject.getStackedPosition(osuBase.Modes.droid);
|
|
1824
2372
|
while (occurrenceLoopIndex < allOccurrences.length &&
|
|
1825
2373
|
allOccurrences[occurrenceLoopIndex].time <
|
|
@@ -1936,13 +2484,14 @@ class ReplayAnalyzer {
|
|
|
1936
2484
|
*/
|
|
1937
2485
|
hasBeenCheckedFor2Hand = false;
|
|
1938
2486
|
/**
|
|
1939
|
-
* Whether this
|
|
2487
|
+
* Whether this replay has been checked against slider cheesing.
|
|
1940
2488
|
*/
|
|
1941
2489
|
hasBeenCheckedForSliderCheesing = false;
|
|
1942
2490
|
/**
|
|
1943
2491
|
* The amount of two-handed objects.
|
|
1944
2492
|
*/
|
|
1945
2493
|
twoHandedNoteCount = 0;
|
|
2494
|
+
convertedBeatmap;
|
|
1946
2495
|
// Sizes of primitive data types in Java (in bytes)
|
|
1947
2496
|
BYTE_LENGTH = 1;
|
|
1948
2497
|
SHORT_LENGTH = 2;
|
|
@@ -2103,11 +2652,33 @@ class ReplayAnalyzer {
|
|
|
2103
2652
|
determineRank();
|
|
2104
2653
|
}
|
|
2105
2654
|
if (resultObject.replayVersion >= 4) {
|
|
2106
|
-
const
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2655
|
+
const str = rawObject[7].split("|");
|
|
2656
|
+
for (const s of str) {
|
|
2657
|
+
switch (true) {
|
|
2658
|
+
// Forced stats
|
|
2659
|
+
case s.startsWith("CS"):
|
|
2660
|
+
resultObject.forceCS = parseFloat(s.replace("CS", ""));
|
|
2661
|
+
break;
|
|
2662
|
+
case s.startsWith("AR"):
|
|
2663
|
+
resultObject.forceAR = parseFloat(s.replace("AR", ""));
|
|
2664
|
+
break;
|
|
2665
|
+
case s.startsWith("OD"):
|
|
2666
|
+
resultObject.forceOD = parseFloat(s.replace("OD", ""));
|
|
2667
|
+
break;
|
|
2668
|
+
case s.startsWith("HP"):
|
|
2669
|
+
resultObject.forceHP = parseFloat(s.replace("HP", ""));
|
|
2670
|
+
break;
|
|
2671
|
+
// FL follow delay
|
|
2672
|
+
case s.startsWith("FLD"):
|
|
2673
|
+
resultObject.flashlightFollowDelay =
|
|
2674
|
+
parseFloat(s.replace("FLD", "")) || 0.12;
|
|
2675
|
+
break;
|
|
2676
|
+
// Speed multiplier
|
|
2677
|
+
case s.startsWith("x"):
|
|
2678
|
+
resultObject.speedMultiplier =
|
|
2679
|
+
parseFloat(s.replace("x", "")) || 1;
|
|
2680
|
+
break;
|
|
2681
|
+
}
|
|
2111
2682
|
}
|
|
2112
2683
|
}
|
|
2113
2684
|
let bufferIndex;
|
|
@@ -2272,12 +2843,12 @@ class ReplayAnalyzer {
|
|
|
2272
2843
|
this.beatmap instanceof osuRebalanceDifficultyCalculator.DroidDifficultyCalculator
|
|
2273
2844
|
? this.beatmap.beatmap
|
|
2274
2845
|
: this.beatmap;
|
|
2275
|
-
const objects = beatmap.hitObjects
|
|
2276
|
-
const
|
|
2277
|
-
|
|
2846
|
+
const { objects } = beatmap.hitObjects;
|
|
2847
|
+
const od = osuBase.calculateDroidDifficultyStatistics({
|
|
2848
|
+
overallDifficulty: beatmap.difficulty.od,
|
|
2278
2849
|
mods: this.data.convertedMods.filter((m) => !osuBase.ModUtil.speedChangingMods.some((v) => v.acronym === m.acronym)),
|
|
2279
|
-
}).
|
|
2280
|
-
const hitWindow50 = new osuBase.DroidHitWindow(
|
|
2850
|
+
}).overallDifficulty;
|
|
2851
|
+
const hitWindow50 = new osuBase.DroidHitWindow(od).hitWindowFor50(this.data.convertedMods.some((m) => m instanceof osuBase.ModPrecise));
|
|
2281
2852
|
// The accuracy of sliders is set to (50 hit window)ms + 13ms if their head was not hit:
|
|
2282
2853
|
// https://github.com/osudroid/osu-droid/blob/6306c68e3ffaf671eac794bf45cc95c0f3313a82/src/ru/nsu/ccfit/zuev/osu/game/Slider.java#L821
|
|
2283
2854
|
//
|
|
@@ -2291,8 +2862,8 @@ class ReplayAnalyzer {
|
|
|
2291
2862
|
accuracies.push(0);
|
|
2292
2863
|
continue;
|
|
2293
2864
|
}
|
|
2294
|
-
const accuracy = v
|
|
2295
|
-
if (o instanceof osuBase.Slider &&
|
|
2865
|
+
const { accuracy } = v;
|
|
2866
|
+
if (o instanceof osuBase.Slider && accuracy === sliderbreakHitOffset) {
|
|
2296
2867
|
accuracies.push(0);
|
|
2297
2868
|
continue;
|
|
2298
2869
|
}
|
|
@@ -2355,9 +2926,32 @@ class ReplayAnalyzer {
|
|
|
2355
2926
|
if (!this.beatmap || !this.data || !this.difficultyAttributes) {
|
|
2356
2927
|
return;
|
|
2357
2928
|
}
|
|
2358
|
-
|
|
2359
|
-
|
|
2360
|
-
|
|
2929
|
+
if (!this.convertedBeatmap) {
|
|
2930
|
+
const mods = this.data.convertedMods.slice();
|
|
2931
|
+
if ([
|
|
2932
|
+
this.data.forceCS,
|
|
2933
|
+
this.data.forceAR,
|
|
2934
|
+
this.data.forceOD,
|
|
2935
|
+
this.data.forceHP,
|
|
2936
|
+
].some((v) => v !== undefined)) {
|
|
2937
|
+
mods.push(new osuBase.ModDifficultyAdjust({
|
|
2938
|
+
cs: this.data.forceCS,
|
|
2939
|
+
ar: this.data.forceAR,
|
|
2940
|
+
od: this.data.forceOD,
|
|
2941
|
+
hp: this.data.forceHP,
|
|
2942
|
+
}));
|
|
2943
|
+
}
|
|
2944
|
+
this.convertedBeatmap ??= new osuBase.BeatmapConverter(this.beatmap instanceof osuBase.Beatmap
|
|
2945
|
+
? this.beatmap
|
|
2946
|
+
: this.beatmap.beatmap).convert({
|
|
2947
|
+
mode: osuBase.Modes.droid,
|
|
2948
|
+
mods: mods,
|
|
2949
|
+
customSpeedMultiplier: this.data.speedMultiplier,
|
|
2950
|
+
});
|
|
2951
|
+
}
|
|
2952
|
+
const threeFingerChecker = this.difficultyAttributes.mode === "rebalance"
|
|
2953
|
+
? new RebalanceThreeFingerChecker(this.convertedBeatmap, this.data, this.difficultyAttributes)
|
|
2954
|
+
: new ThreeFingerChecker(this.convertedBeatmap, this.data, this.difficultyAttributes);
|
|
2361
2955
|
const result = threeFingerChecker.check();
|
|
2362
2956
|
this.is3Finger = result.is3Finger;
|
|
2363
2957
|
this.tapPenalty = result.penalty;
|
|
@@ -2389,9 +2983,30 @@ class ReplayAnalyzer {
|
|
|
2389
2983
|
if (!this.beatmap || !this.data || !this.difficultyAttributes) {
|
|
2390
2984
|
return;
|
|
2391
2985
|
}
|
|
2392
|
-
|
|
2393
|
-
|
|
2394
|
-
|
|
2986
|
+
if (!this.convertedBeatmap) {
|
|
2987
|
+
const mods = this.data.convertedMods.slice();
|
|
2988
|
+
if ([
|
|
2989
|
+
this.data.forceCS,
|
|
2990
|
+
this.data.forceAR,
|
|
2991
|
+
this.data.forceOD,
|
|
2992
|
+
this.data.forceHP,
|
|
2993
|
+
].some((v) => v !== undefined)) {
|
|
2994
|
+
mods.push(new osuBase.ModDifficultyAdjust({
|
|
2995
|
+
cs: this.data.forceCS,
|
|
2996
|
+
ar: this.data.forceAR,
|
|
2997
|
+
od: this.data.forceOD,
|
|
2998
|
+
hp: this.data.forceHP,
|
|
2999
|
+
}));
|
|
3000
|
+
}
|
|
3001
|
+
this.convertedBeatmap ??= new osuBase.BeatmapConverter(this.beatmap instanceof osuBase.Beatmap
|
|
3002
|
+
? this.beatmap
|
|
3003
|
+
: this.beatmap.beatmap).convert({
|
|
3004
|
+
mode: osuBase.Modes.droid,
|
|
3005
|
+
mods: mods,
|
|
3006
|
+
customSpeedMultiplier: this.data.speedMultiplier,
|
|
3007
|
+
});
|
|
3008
|
+
}
|
|
3009
|
+
const sliderCheeseChecker = new SliderCheeseChecker(this.convertedBeatmap, this.data, this.difficultyAttributes);
|
|
2395
3010
|
this.sliderCheesePenalty = sliderCheeseChecker.check();
|
|
2396
3011
|
this.hasBeenCheckedForSliderCheesing = true;
|
|
2397
3012
|
}
|
|
@@ -2442,6 +3057,7 @@ class ReplayObjectData {
|
|
|
2442
3057
|
exports.CursorData = CursorData;
|
|
2443
3058
|
exports.CursorOccurrence = CursorOccurrence;
|
|
2444
3059
|
exports.CursorOccurrenceGroup = CursorOccurrenceGroup;
|
|
3060
|
+
exports.RebalanceThreeFingerChecker = RebalanceThreeFingerChecker;
|
|
2445
3061
|
exports.ReplayAnalyzer = ReplayAnalyzer;
|
|
2446
3062
|
exports.ReplayData = ReplayData;
|
|
2447
3063
|
exports.ReplayObjectData = ReplayObjectData;
|