@rian8337/osu-difficulty-calculator 4.0.0-beta.76 → 4.0.0-beta.78
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 +580 -480
- package/package.json +3 -3
- package/typings/index.d.ts +98 -103
package/dist/index.js
CHANGED
|
@@ -249,12 +249,8 @@ class DifficultyHitObject {
|
|
|
249
249
|
* Computes the properties of this hitobject.
|
|
250
250
|
*
|
|
251
251
|
* @param clockRate The clock rate of the beatmap.
|
|
252
|
-
* @param hitObjects The hitobjects in the beatmap.
|
|
253
252
|
*/
|
|
254
|
-
computeProperties(clockRate
|
|
255
|
-
// Required for `DroidDifficultyHitObject` override.
|
|
256
|
-
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
257
|
-
hitObjects) {
|
|
253
|
+
computeProperties(clockRate) {
|
|
258
254
|
this.calculateSliderCursorPosition();
|
|
259
255
|
this.setDistances(clockRate);
|
|
260
256
|
}
|
|
@@ -348,7 +344,11 @@ class DifficultyHitObject {
|
|
|
348
344
|
return;
|
|
349
345
|
}
|
|
350
346
|
// We will scale distances by this factor, so we can assume a uniform CircleSize among beatmaps.
|
|
351
|
-
|
|
347
|
+
let scalingFactor = DifficultyHitObject.normalizedRadius / this.object.radius;
|
|
348
|
+
// High circle size (small CS) bonus
|
|
349
|
+
if (this.mode === osuBase.Modes.osu && this.object.radius < 30) {
|
|
350
|
+
scalingFactor *= 1 + Math.min(30 - this.object.radius, 5) / 50;
|
|
351
|
+
}
|
|
352
352
|
const lastCursorPosition = this.lastDifficultyObject !== null
|
|
353
353
|
? this.getEndCursorPosition(this.lastDifficultyObject)
|
|
354
354
|
: this.lastObject.stackedPosition;
|
|
@@ -503,16 +503,8 @@ DifficultyHitObject.minDeltaTime = 25;
|
|
|
503
503
|
* Represents an osu!droid hit object with difficulty calculation values.
|
|
504
504
|
*/
|
|
505
505
|
class DroidDifficultyHitObject extends DifficultyHitObject {
|
|
506
|
-
get
|
|
507
|
-
|
|
508
|
-
// We will scale distances by this factor, so we can assume a uniform CircleSize among beatmaps.
|
|
509
|
-
let scalingFactor = DifficultyHitObject.normalizedRadius / radius;
|
|
510
|
-
// High circle size (small CS) bonus
|
|
511
|
-
if (radius < this.radiusBuffThreshold) {
|
|
512
|
-
scalingFactor *=
|
|
513
|
-
1 + Math.pow((this.radiusBuffThreshold - radius) / 50, 2);
|
|
514
|
-
}
|
|
515
|
-
return scalingFactor;
|
|
506
|
+
get smallCircleBonus() {
|
|
507
|
+
return Math.max(1, 1 + Math.pow((70 - this.object.radius) / 50, 2));
|
|
516
508
|
}
|
|
517
509
|
/**
|
|
518
510
|
* Note: You **must** call `computeProperties` at some point due to how TypeScript handles
|
|
@@ -547,13 +539,9 @@ class DroidDifficultyHitObject extends DifficultyHitObject {
|
|
|
547
539
|
*/
|
|
548
540
|
this.flashlightStrainWithoutSliders = 0;
|
|
549
541
|
/**
|
|
550
|
-
* The
|
|
551
|
-
*/
|
|
552
|
-
this.visualStrainWithSliders = 0;
|
|
553
|
-
/**
|
|
554
|
-
* The visual strain generated by the hitobject if sliders are not considered.
|
|
542
|
+
* The reading difficulty generated by the hitobject.
|
|
555
543
|
*/
|
|
556
|
-
this.
|
|
544
|
+
this.readingDifficulty = 0;
|
|
557
545
|
/**
|
|
558
546
|
* The note density of the hitobject.
|
|
559
547
|
*/
|
|
@@ -569,10 +557,6 @@ class DroidDifficultyHitObject extends DifficultyHitObject {
|
|
|
569
557
|
this.maximumSliderRadius = DifficultyHitObject.normalizedRadius * 2;
|
|
570
558
|
this.timePreempt = object.timePreempt / clockRate;
|
|
571
559
|
}
|
|
572
|
-
computeProperties(clockRate, hitObjects) {
|
|
573
|
-
super.computeProperties(clockRate, hitObjects);
|
|
574
|
-
this.setVisuals(clockRate, hitObjects);
|
|
575
|
-
}
|
|
576
560
|
opacityAt(time, mods) {
|
|
577
561
|
// Traceable hides the primary piece of a hit circle (that is, its body), so consider it as fully invisible.
|
|
578
562
|
if (this.object instanceof osuBase.Circle && mods.has(osuBase.ModTraceable)) {
|
|
@@ -649,60 +633,6 @@ class DroidDifficultyHitObject extends DifficultyHitObject {
|
|
|
649
633
|
}
|
|
650
634
|
return true;
|
|
651
635
|
}
|
|
652
|
-
setVisuals(clockRate, hitObjects) {
|
|
653
|
-
// We'll have two visible object arrays. The first array contains objects before the current object starts in a reversed order,
|
|
654
|
-
// while the second array contains objects after the current object ends.
|
|
655
|
-
// For overlapping factor, we also need to consider previous visible objects.
|
|
656
|
-
const prevVisibleObjects = [];
|
|
657
|
-
const nextVisibleObjects = [];
|
|
658
|
-
for (let j = this.index + 2; j < hitObjects.length; ++j) {
|
|
659
|
-
const o = hitObjects[j];
|
|
660
|
-
if (o instanceof osuBase.Spinner) {
|
|
661
|
-
continue;
|
|
662
|
-
}
|
|
663
|
-
if (o.startTime / clockRate > this.endTime + this.timePreempt) {
|
|
664
|
-
break;
|
|
665
|
-
}
|
|
666
|
-
nextVisibleObjects.push(o);
|
|
667
|
-
}
|
|
668
|
-
for (let j = 0; j < this.index; ++j) {
|
|
669
|
-
const prev = this.previous(j);
|
|
670
|
-
if (prev.object instanceof osuBase.Spinner) {
|
|
671
|
-
continue;
|
|
672
|
-
}
|
|
673
|
-
if (prev.startTime >= this.startTime) {
|
|
674
|
-
continue;
|
|
675
|
-
}
|
|
676
|
-
if (prev.startTime < this.startTime - this.timePreempt) {
|
|
677
|
-
break;
|
|
678
|
-
}
|
|
679
|
-
prevVisibleObjects.push(prev.object);
|
|
680
|
-
}
|
|
681
|
-
for (const hitObject of prevVisibleObjects) {
|
|
682
|
-
const distance = this.object.stackedPosition.getDistance(hitObject.stackedEndPosition);
|
|
683
|
-
const deltaTime = this.startTime - hitObject.endTime / clockRate;
|
|
684
|
-
this.applyToOverlappingFactor(distance, deltaTime);
|
|
685
|
-
}
|
|
686
|
-
for (const hitObject of nextVisibleObjects) {
|
|
687
|
-
const distance = hitObject.stackedPosition.getDistance(this.object.stackedEndPosition);
|
|
688
|
-
const deltaTime = hitObject.startTime / clockRate - this.endTime;
|
|
689
|
-
if (deltaTime >= 0) {
|
|
690
|
-
this.noteDensity += 1 - deltaTime / this.timePreempt;
|
|
691
|
-
}
|
|
692
|
-
this.applyToOverlappingFactor(distance, deltaTime);
|
|
693
|
-
}
|
|
694
|
-
}
|
|
695
|
-
applyToOverlappingFactor(distance, deltaTime) {
|
|
696
|
-
// Penalize objects that are too close to the object in both distance
|
|
697
|
-
// and delta time to prevent stream maps from being overweighted.
|
|
698
|
-
this.overlappingFactor +=
|
|
699
|
-
Math.max(0, 1 - distance / (2.5 * this.object.radius)) *
|
|
700
|
-
(7.5 /
|
|
701
|
-
(1 +
|
|
702
|
-
Math.exp(0.15 *
|
|
703
|
-
(Math.max(deltaTime, DifficultyHitObject.minDeltaTime) -
|
|
704
|
-
75))));
|
|
705
|
-
}
|
|
706
636
|
}
|
|
707
637
|
|
|
708
638
|
/**
|
|
@@ -740,6 +670,7 @@ class DroidAimEvaluator {
|
|
|
740
670
|
}
|
|
741
671
|
const last = current.previous(0);
|
|
742
672
|
const lastLast = current.previous(1);
|
|
673
|
+
const last2 = current.previous(2);
|
|
743
674
|
const radius = DroidDifficultyHitObject.normalizedRadius;
|
|
744
675
|
const diameter = DroidDifficultyHitObject.normalizedDiameter;
|
|
745
676
|
// Calculate the velocity to the current hitobject, which starts with a base distance / time assuming the last object is a hitcircle.
|
|
@@ -771,36 +702,35 @@ class DroidAimEvaluator {
|
|
|
771
702
|
let wiggleBonus = 0;
|
|
772
703
|
// Start strain with regular velocity.
|
|
773
704
|
let strain = currentVelocity;
|
|
774
|
-
if (
|
|
775
|
-
// If rhythms are the same.
|
|
776
|
-
Math.max(current.strainTime, last.strainTime) <
|
|
777
|
-
1.25 * Math.min(current.strainTime, last.strainTime) &&
|
|
778
|
-
current.angle !== null &&
|
|
779
|
-
last.angle !== null) {
|
|
705
|
+
if (current.angle !== null && last.angle !== null) {
|
|
780
706
|
const currentAngle = current.angle;
|
|
781
707
|
const lastAngle = last.angle;
|
|
782
708
|
// Rewarding angles, take the smaller velocity as base.
|
|
783
709
|
const angleBonus = Math.min(currentVelocity, prevVelocity);
|
|
710
|
+
// If rhythms are the same.
|
|
711
|
+
if (Math.max(current.strainTime, last.strainTime) <
|
|
712
|
+
1.25 * Math.min(current.strainTime, last.strainTime)) {
|
|
713
|
+
acuteAngleBonus = this.calculateAcuteAngleBonus(current.angle);
|
|
714
|
+
acuteAngleBonus *=
|
|
715
|
+
0.08 +
|
|
716
|
+
0.92 *
|
|
717
|
+
(1 -
|
|
718
|
+
Math.min(acuteAngleBonus, Math.pow(this.calculateAcuteAngleBonus(lastAngle), 3)));
|
|
719
|
+
// Apply acute angle bonus for BPM above 300 1/2 and distance more than 1.25 diameter
|
|
720
|
+
acuteAngleBonus *=
|
|
721
|
+
angleBonus *
|
|
722
|
+
osuBase.MathUtils.smootherstep(osuBase.MathUtils.millisecondsToBPM(current.strainTime, 2), 300, 450) *
|
|
723
|
+
osuBase.MathUtils.smootherstep(current.lazyJumpDistance, diameter * 1.25, diameter * 2.5);
|
|
724
|
+
}
|
|
784
725
|
wideAngleBonus = this.calculateWideAngleBonus(current.angle);
|
|
785
|
-
acuteAngleBonus = this.calculateAcuteAngleBonus(current.angle);
|
|
786
726
|
// Penalize angle repetition.
|
|
787
727
|
wideAngleBonus *=
|
|
788
728
|
1 -
|
|
789
729
|
Math.min(wideAngleBonus, Math.pow(this.calculateWideAngleBonus(lastAngle), 3));
|
|
790
|
-
acuteAngleBonus *=
|
|
791
|
-
0.08 +
|
|
792
|
-
0.92 *
|
|
793
|
-
(1 -
|
|
794
|
-
Math.min(acuteAngleBonus, Math.pow(this.calculateAcuteAngleBonus(lastAngle), 3)));
|
|
795
730
|
// Apply full wide angle bonus for distance more than one diameter
|
|
796
731
|
wideAngleBonus *=
|
|
797
732
|
angleBonus *
|
|
798
733
|
osuBase.MathUtils.smootherstep(current.lazyJumpDistance, 0, diameter);
|
|
799
|
-
// Apply acute angle bonus for BPM above 300 1/2 and distance more than one diameter
|
|
800
|
-
acuteAngleBonus *=
|
|
801
|
-
angleBonus *
|
|
802
|
-
osuBase.MathUtils.smootherstep(osuBase.MathUtils.millisecondsToBPM(current.strainTime, 2), 300, 400) *
|
|
803
|
-
osuBase.MathUtils.smootherstep(current.lazyJumpDistance, diameter, diameter * 2);
|
|
804
734
|
// Apply wiggle bonus for jumps that are [radius, 3*diameter] in distance, with < 110 angle
|
|
805
735
|
// https://www.desmos.com/calculator/dp0v0nvowc
|
|
806
736
|
wiggleBonus =
|
|
@@ -811,6 +741,15 @@ class DroidAimEvaluator {
|
|
|
811
741
|
osuBase.MathUtils.smootherstep(last.lazyJumpDistance, radius, diameter) *
|
|
812
742
|
Math.pow(osuBase.MathUtils.reverseLerp(last.lazyJumpDistance, diameter * 3, diameter), 1.8) *
|
|
813
743
|
osuBase.MathUtils.smootherstep(lastAngle, osuBase.MathUtils.degreesToRadians(110), osuBase.MathUtils.degreesToRadians(60));
|
|
744
|
+
if (last2 !== null) {
|
|
745
|
+
// If objects just go back and forth through a middle point - don't give as much wide bonus.
|
|
746
|
+
// Use previous(2) and previous(0) because angles calculation is done prevprev-prev-curr, so any
|
|
747
|
+
// object's angle's center point is always the previous object.
|
|
748
|
+
const distance = last2.object.stackedPosition.getDistance(last.object.stackedPosition);
|
|
749
|
+
if (distance < 1) {
|
|
750
|
+
wideAngleBonus *= 1 - 0.35 * (1 - distance);
|
|
751
|
+
}
|
|
752
|
+
}
|
|
814
753
|
}
|
|
815
754
|
if (Math.max(prevVelocity, currentVelocity)) {
|
|
816
755
|
// We want to use the average velocity over the whole object when awarding differences, not the individual jump and slider path velocities.
|
|
@@ -821,8 +760,8 @@ class DroidAimEvaluator {
|
|
|
821
760
|
(current.lazyJumpDistance + last.travelDistance) /
|
|
822
761
|
current.strainTime;
|
|
823
762
|
// Scale with ratio of difference compared to half the max distance.
|
|
824
|
-
const distanceRatio =
|
|
825
|
-
Math.max(prevVelocity, currentVelocity)
|
|
763
|
+
const distanceRatio = osuBase.MathUtils.smoothstep(Math.abs(prevVelocity - currentVelocity) /
|
|
764
|
+
Math.max(prevVelocity, currentVelocity), 0, 1);
|
|
826
765
|
// Reward for % distance up to 125 / strainTime for overlaps where velocity is still changing.
|
|
827
766
|
const overlapVelocityBuff = Math.min(125 / Math.min(current.strainTime, last.strainTime), Math.abs(prevVelocity - currentVelocity));
|
|
828
767
|
velocityChangeBonus = overlapVelocityBuff * distanceRatio;
|
|
@@ -835,9 +774,11 @@ class DroidAimEvaluator {
|
|
|
835
774
|
sliderBonus = last.travelDistance / last.travelTime;
|
|
836
775
|
}
|
|
837
776
|
strain += wiggleBonus * this.wiggleMultiplier;
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
777
|
+
strain += velocityChangeBonus * this.velocityChangeMultiplier;
|
|
778
|
+
// Add in acute angle bonus or wide angle bonus, whichever is larger.
|
|
779
|
+
strain += Math.max(acuteAngleBonus * this.acuteAngleMultiplier, wideAngleBonus * this.wideAngleMultiplier);
|
|
780
|
+
// Apply high circle size bonus
|
|
781
|
+
strain *= current.smallCircleBonus;
|
|
841
782
|
// Add in additional slider velocity bonus.
|
|
842
783
|
if (withSliders) {
|
|
843
784
|
strain +=
|
|
@@ -861,7 +802,12 @@ class DroidAimEvaluator {
|
|
|
861
802
|
const travelDistance = (_a = prev === null || prev === void 0 ? void 0 : prev.travelDistance) !== null && _a !== void 0 ? _a : 0;
|
|
862
803
|
const distance = travelDistance + current.minimumJumpDistance;
|
|
863
804
|
const shortDistancePenalty = Math.min(1, Math.pow(distance / this.singleSpacingThreshold, 3.5));
|
|
864
|
-
return (200 *
|
|
805
|
+
return ((200 *
|
|
806
|
+
speedBonus *
|
|
807
|
+
shortDistancePenalty *
|
|
808
|
+
// Apply reduced small circle bonus for flow aim difficulty since it does not scale as hard as snap aim.
|
|
809
|
+
Math.sqrt(current.smallCircleBonus)) /
|
|
810
|
+
current.strainTime);
|
|
865
811
|
}
|
|
866
812
|
static calculateWideAngleBonus(angle) {
|
|
867
813
|
return osuBase.MathUtils.smoothstep(angle, osuBase.MathUtils.degreesToRadians(40), osuBase.MathUtils.degreesToRadians(140));
|
|
@@ -870,8 +816,8 @@ class DroidAimEvaluator {
|
|
|
870
816
|
return osuBase.MathUtils.smoothstep(angle, osuBase.MathUtils.degreesToRadians(140), osuBase.MathUtils.degreesToRadians(40));
|
|
871
817
|
}
|
|
872
818
|
}
|
|
873
|
-
DroidAimEvaluator.wideAngleMultiplier = 1.
|
|
874
|
-
DroidAimEvaluator.acuteAngleMultiplier = 2.
|
|
819
|
+
DroidAimEvaluator.wideAngleMultiplier = 1.6;
|
|
820
|
+
DroidAimEvaluator.acuteAngleMultiplier = 2.4;
|
|
875
821
|
DroidAimEvaluator.sliderMultiplier = 1.35;
|
|
876
822
|
DroidAimEvaluator.velocityChangeMultiplier = 0.75;
|
|
877
823
|
DroidAimEvaluator.wiggleMultiplier = 1.02;
|
|
@@ -944,7 +890,7 @@ class StrainSkill extends Skill {
|
|
|
944
890
|
*
|
|
945
891
|
* The result is scaled by clock rate as it affects the total number of strains.
|
|
946
892
|
*/
|
|
947
|
-
|
|
893
|
+
countTopWeightedStrains() {
|
|
948
894
|
if (this.difficulty === 0) {
|
|
949
895
|
return 0;
|
|
950
896
|
}
|
|
@@ -1034,21 +980,18 @@ class DroidAim extends DroidSkill {
|
|
|
1034
980
|
this.skillMultiplier = 26.5;
|
|
1035
981
|
this.currentAimStrain = 0;
|
|
1036
982
|
this.sliderStrains = [];
|
|
983
|
+
this.maxSliderStrain = 0;
|
|
1037
984
|
this.withSliders = withSliders;
|
|
1038
985
|
}
|
|
1039
986
|
/**
|
|
1040
987
|
* Obtains the amount of sliders that are considered difficult in terms of relative strain.
|
|
1041
988
|
*/
|
|
1042
989
|
countDifficultSliders() {
|
|
1043
|
-
if (this.sliderStrains.length === 0) {
|
|
1044
|
-
return 0;
|
|
1045
|
-
}
|
|
1046
|
-
const maxSliderStrain = osuBase.MathUtils.max(this.sliderStrains);
|
|
1047
|
-
if (maxSliderStrain === 0) {
|
|
990
|
+
if (this.sliderStrains.length === 0 || this.maxSliderStrain === 0) {
|
|
1048
991
|
return 0;
|
|
1049
992
|
}
|
|
1050
993
|
return this.sliderStrains.reduce((total, strain) => total +
|
|
1051
|
-
1 / (1 + Math.exp(-((strain / maxSliderStrain) * 12 - 6))), 0);
|
|
994
|
+
1 / (1 + Math.exp(-((strain / this.maxSliderStrain) * 12 - 6))), 0);
|
|
1052
995
|
}
|
|
1053
996
|
strainValueAt(current) {
|
|
1054
997
|
this.currentAimStrain *= this.strainDecay(current.deltaTime);
|
|
@@ -1057,6 +1000,7 @@ class DroidAim extends DroidSkill {
|
|
|
1057
1000
|
this.skillMultiplier;
|
|
1058
1001
|
if (current.object instanceof osuBase.Slider) {
|
|
1059
1002
|
this.sliderStrains.push(this.currentAimStrain);
|
|
1003
|
+
this.maxSliderStrain = Math.max(this.maxSliderStrain, this.currentAimStrain);
|
|
1060
1004
|
}
|
|
1061
1005
|
return this.currentAimStrain;
|
|
1062
1006
|
}
|
|
@@ -1089,10 +1033,10 @@ class DroidDifficultyAttributes extends DifficultyAttributes {
|
|
|
1089
1033
|
super(cacheableAttributes);
|
|
1090
1034
|
this.tapDifficulty = 0;
|
|
1091
1035
|
this.rhythmDifficulty = 0;
|
|
1092
|
-
this.
|
|
1036
|
+
this.readingDifficulty = 0;
|
|
1093
1037
|
this.tapDifficultStrainCount = 0;
|
|
1094
1038
|
this.flashlightDifficultStrainCount = 0;
|
|
1095
|
-
this.
|
|
1039
|
+
this.readingDifficultNoteCount = 0;
|
|
1096
1040
|
this.averageSpeedDeltaTime = 0;
|
|
1097
1041
|
this.vibroFactor = 1;
|
|
1098
1042
|
if (!cacheableAttributes) {
|
|
@@ -1100,13 +1044,13 @@ class DroidDifficultyAttributes extends DifficultyAttributes {
|
|
|
1100
1044
|
}
|
|
1101
1045
|
this.tapDifficulty = cacheableAttributes.tapDifficulty;
|
|
1102
1046
|
this.rhythmDifficulty = cacheableAttributes.rhythmDifficulty;
|
|
1103
|
-
this.
|
|
1047
|
+
this.readingDifficulty = cacheableAttributes.readingDifficulty;
|
|
1104
1048
|
this.tapDifficultStrainCount =
|
|
1105
1049
|
cacheableAttributes.tapDifficultStrainCount;
|
|
1106
1050
|
this.flashlightDifficultStrainCount =
|
|
1107
1051
|
cacheableAttributes.flashlightDifficultStrainCount;
|
|
1108
|
-
this.
|
|
1109
|
-
cacheableAttributes.
|
|
1052
|
+
this.readingDifficultNoteCount =
|
|
1053
|
+
cacheableAttributes.readingDifficultNoteCount;
|
|
1110
1054
|
this.averageSpeedDeltaTime = cacheableAttributes.averageSpeedDeltaTime;
|
|
1111
1055
|
this.vibroFactor = cacheableAttributes.vibroFactor;
|
|
1112
1056
|
}
|
|
@@ -1116,7 +1060,7 @@ class DroidDifficultyAttributes extends DifficultyAttributes {
|
|
|
1116
1060
|
`${this.tapDifficulty.toFixed(2)} tap, ` +
|
|
1117
1061
|
`${this.rhythmDifficulty.toFixed(2)} rhythm, ` +
|
|
1118
1062
|
`${this.flashlightDifficulty.toFixed(2)} flashlight, ` +
|
|
1119
|
-
`${this.
|
|
1063
|
+
`${this.readingDifficulty.toFixed(2)} reading)`);
|
|
1120
1064
|
}
|
|
1121
1065
|
}
|
|
1122
1066
|
|
|
@@ -1265,6 +1209,312 @@ class DroidFlashlight extends DroidSkill {
|
|
|
1265
1209
|
}
|
|
1266
1210
|
}
|
|
1267
1211
|
|
|
1212
|
+
/**
|
|
1213
|
+
* An evaluator for calculating osu!droid reading skill.
|
|
1214
|
+
*/
|
|
1215
|
+
class DroidReadingEvaluator {
|
|
1216
|
+
static evaluateDifficultyOf(current, clockRate, mods) {
|
|
1217
|
+
if (current.object instanceof osuBase.Spinner ||
|
|
1218
|
+
// Exclude overlapping objects that can be tapped at once.
|
|
1219
|
+
current.isOverlapping(true) ||
|
|
1220
|
+
current.index <= 0) {
|
|
1221
|
+
return 0;
|
|
1222
|
+
}
|
|
1223
|
+
const constantAngleNerfFactor = this.getConstantAngleNerfFactor(current);
|
|
1224
|
+
// Only allow velocity to buff.
|
|
1225
|
+
const velocityFactor = Math.max(1, current.minimumJumpDistance / current.strainTime);
|
|
1226
|
+
let pastObjectDifficultyInfluence = 0;
|
|
1227
|
+
for (const prev of this.retrievePastVisibleObjects(current)) {
|
|
1228
|
+
let prevDifficulty = current.opacityAt(prev.object.startTime, this.emptyModMap);
|
|
1229
|
+
// Small distances mean objects may be cheesed, so it does not matter whether they are arranged confusingly.
|
|
1230
|
+
prevDifficulty *= osuBase.MathUtils.smootherstep(prev.lazyJumpDistance, 15, this.distanceInfluenceThreshold);
|
|
1231
|
+
// Account less for objects close to the maximum reading window.
|
|
1232
|
+
prevDifficulty *= this.getTimeNerfFactor(current.startTime - prev.startTime);
|
|
1233
|
+
pastObjectDifficultyInfluence += prevDifficulty;
|
|
1234
|
+
}
|
|
1235
|
+
// Value higher note densities exponentially.
|
|
1236
|
+
let noteDensityDifficulty = Math.pow(pastObjectDifficultyInfluence, 1.45) *
|
|
1237
|
+
0.9 *
|
|
1238
|
+
constantAngleNerfFactor *
|
|
1239
|
+
velocityFactor;
|
|
1240
|
+
// Award only denser than average beatmaps.
|
|
1241
|
+
noteDensityDifficulty = Math.max(0, noteDensityDifficulty - this.densityDifficultyBase);
|
|
1242
|
+
// Apply a soft cap to general density reading to account for partial memorization.
|
|
1243
|
+
noteDensityDifficulty =
|
|
1244
|
+
Math.pow(noteDensityDifficulty, 0.8) * this.densityMultiplier;
|
|
1245
|
+
let hiddenDifficulty = 0;
|
|
1246
|
+
if (mods.has(osuBase.ModHidden)) {
|
|
1247
|
+
const timeSpentInvisible = this.getDurationSpentInvisible(current) / clockRate;
|
|
1248
|
+
// Value time spent invisible exponentially.
|
|
1249
|
+
const timeSpentInvisibleFactor = Math.pow(timeSpentInvisible, 2.1) * 0.0001;
|
|
1250
|
+
// Buff current object if upcoming objects are dense. This is on the basis that part of
|
|
1251
|
+
// Hidden difficulty is the uncertainty of the current cursor position in relation to
|
|
1252
|
+
// future notes.
|
|
1253
|
+
const futureObjectDifficultyInfluence = this.calculateCurrentVisibleObjectsDensity(current);
|
|
1254
|
+
// Account for both past and current densities.
|
|
1255
|
+
const densityFactor = Math.pow(Math.max(1, futureObjectDifficultyInfluence +
|
|
1256
|
+
pastObjectDifficultyInfluence -
|
|
1257
|
+
2), 2.2) * 3.1;
|
|
1258
|
+
hiddenDifficulty +=
|
|
1259
|
+
(timeSpentInvisibleFactor + densityFactor) *
|
|
1260
|
+
constantAngleNerfFactor *
|
|
1261
|
+
velocityFactor *
|
|
1262
|
+
0.007;
|
|
1263
|
+
// Apply a soft cap to general Hidden reading to account for partial memorization.
|
|
1264
|
+
hiddenDifficulty =
|
|
1265
|
+
Math.pow(hiddenDifficulty, 0.65) * this.hiddenMultiplier;
|
|
1266
|
+
const prev = current.previous(0);
|
|
1267
|
+
// Buff perfect stacks only if the current object is completely invisible at the
|
|
1268
|
+
// time the previous object was clicked.
|
|
1269
|
+
if (current.lazyJumpDistance === 0 &&
|
|
1270
|
+
current.opacityAt(prev.object.startTime + prev.object.timePreempt, mods) === 0 &&
|
|
1271
|
+
prev.startTime + prev.timePreempt > current.startTime) {
|
|
1272
|
+
hiddenDifficulty +=
|
|
1273
|
+
(this.hiddenMultiplier * 1303) /
|
|
1274
|
+
// Perfect stacks are harder the less time between notes.
|
|
1275
|
+
Math.pow(current.strainTime, 1.5);
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1278
|
+
// Arbitrary curve for the base value preempt difficulty should have as approach rate increases.
|
|
1279
|
+
// https://www.desmos.com/calculator/9v2wrms1ie
|
|
1280
|
+
const preemptDifficulty = (Math.pow((this.preemptStartingPoint -
|
|
1281
|
+
current.timePreempt +
|
|
1282
|
+
Math.abs(current.timePreempt - this.preemptStartingPoint)) /
|
|
1283
|
+
2, 2.35) /
|
|
1284
|
+
this.preemptBalancingFactor) *
|
|
1285
|
+
constantAngleNerfFactor *
|
|
1286
|
+
velocityFactor;
|
|
1287
|
+
let sliderDifficulty = 0;
|
|
1288
|
+
if (current.object instanceof osuBase.Slider) {
|
|
1289
|
+
const scalingFactor = 50 / current.object.radius;
|
|
1290
|
+
// Invert the scaling factor to determine the true travel distance independent of circle size.
|
|
1291
|
+
const pixelTravelDistance = current.lazyTravelDistance / scalingFactor;
|
|
1292
|
+
const currentVelocity = pixelTravelDistance / current.travelTime;
|
|
1293
|
+
const spanTravelDistance = pixelTravelDistance / current.object.spanCount;
|
|
1294
|
+
sliderDifficulty +=
|
|
1295
|
+
// Reward sliders based on velocity, while also avoiding overbuffing extremely fast sliders.
|
|
1296
|
+
Math.min(4, currentVelocity * 0.8) *
|
|
1297
|
+
// Longer sliders require more reading.
|
|
1298
|
+
(spanTravelDistance / 125);
|
|
1299
|
+
let cumulativeStrainTime = 0;
|
|
1300
|
+
// Reward for velocity changes based on last few sliders.
|
|
1301
|
+
for (let i = 0; i < Math.min(current.index, 4); ++i) {
|
|
1302
|
+
const last = current.previous(i);
|
|
1303
|
+
cumulativeStrainTime += last.strainTime;
|
|
1304
|
+
if (!(last.object instanceof osuBase.Slider) ||
|
|
1305
|
+
// Exclude overlapping objects that can be tapped at once.
|
|
1306
|
+
last.isOverlapping(true)) {
|
|
1307
|
+
continue;
|
|
1308
|
+
}
|
|
1309
|
+
// Invert the scaling factor to determine the true travel distance independent of circle size.
|
|
1310
|
+
const lastPixelTravelDistance = last.lazyTravelDistance / scalingFactor;
|
|
1311
|
+
const lastVelocity = lastPixelTravelDistance / last.travelTime;
|
|
1312
|
+
const lastSpanTravelDistance = lastPixelTravelDistance / last.object.spanCount;
|
|
1313
|
+
sliderDifficulty +=
|
|
1314
|
+
// Reward past sliders based on velocity changes, while also
|
|
1315
|
+
// avoiding overbuffing extremely fast velocity changes.
|
|
1316
|
+
Math.min(4, 0.8 * Math.abs(currentVelocity - lastVelocity)) *
|
|
1317
|
+
// Longer sliders require more reading.
|
|
1318
|
+
(lastSpanTravelDistance / 150) *
|
|
1319
|
+
// Avoid overbuffing past sliders.
|
|
1320
|
+
Math.min(1, 250 / cumulativeStrainTime);
|
|
1321
|
+
}
|
|
1322
|
+
}
|
|
1323
|
+
return (preemptDifficulty +
|
|
1324
|
+
hiddenDifficulty +
|
|
1325
|
+
noteDensityDifficulty +
|
|
1326
|
+
sliderDifficulty);
|
|
1327
|
+
}
|
|
1328
|
+
/**
|
|
1329
|
+
* Retrieves a list of objects that are visible at the point in time the current object needs to be hit.
|
|
1330
|
+
*
|
|
1331
|
+
* @param current The current object.
|
|
1332
|
+
*/
|
|
1333
|
+
static *retrievePastVisibleObjects(current) {
|
|
1334
|
+
for (let i = 0; i < current.index; ++i) {
|
|
1335
|
+
const prev = current.previous(i);
|
|
1336
|
+
if (!prev ||
|
|
1337
|
+
current.startTime - prev.startTime > this.readingWindowSize ||
|
|
1338
|
+
// The previous object is not visible at the time the current object needs to be hit.
|
|
1339
|
+
prev.startTime + prev.timePreempt < current.startTime) {
|
|
1340
|
+
break;
|
|
1341
|
+
}
|
|
1342
|
+
if (prev.isOverlapping(true)) {
|
|
1343
|
+
continue;
|
|
1344
|
+
}
|
|
1345
|
+
yield prev;
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
/**
|
|
1349
|
+
* Calculates the density of objects visible at the point in time the current object needs to be hit.
|
|
1350
|
+
*
|
|
1351
|
+
* @param current The current object.
|
|
1352
|
+
*/
|
|
1353
|
+
static calculateCurrentVisibleObjectsDensity(current) {
|
|
1354
|
+
let visibleObjectCount = 0;
|
|
1355
|
+
let index = 0;
|
|
1356
|
+
let next = current.next(0);
|
|
1357
|
+
while (next) {
|
|
1358
|
+
if (next.startTime - current.startTime > this.readingWindowSize ||
|
|
1359
|
+
// The next object is not visible at the time the current object needs to be hit.
|
|
1360
|
+
current.startTime + current.timePreempt < next.startTime) {
|
|
1361
|
+
break;
|
|
1362
|
+
}
|
|
1363
|
+
if (next.isOverlapping(true)) {
|
|
1364
|
+
continue;
|
|
1365
|
+
}
|
|
1366
|
+
const timeNerfFactor = this.getTimeNerfFactor(next.startTime - current.startTime);
|
|
1367
|
+
visibleObjectCount +=
|
|
1368
|
+
next.opacityAt(current.object.startTime, this.emptyModMap) *
|
|
1369
|
+
timeNerfFactor;
|
|
1370
|
+
next = current.next(++index);
|
|
1371
|
+
}
|
|
1372
|
+
return visibleObjectCount;
|
|
1373
|
+
}
|
|
1374
|
+
/**
|
|
1375
|
+
* Returns the time an object spends invisible with the Hidden mod at the current approach rate.
|
|
1376
|
+
*
|
|
1377
|
+
* @param current The current object.
|
|
1378
|
+
*/
|
|
1379
|
+
static getDurationSpentInvisible(current) {
|
|
1380
|
+
const { object } = current;
|
|
1381
|
+
const fadeOutStartTime = object.startTime - object.timePreempt + object.timeFadeIn;
|
|
1382
|
+
const fadeOutDuration = object.timePreempt * osuBase.ModHidden.fadeOutDurationMultiplier;
|
|
1383
|
+
return (fadeOutStartTime +
|
|
1384
|
+
fadeOutDuration -
|
|
1385
|
+
(object.startTime - object.timePreempt));
|
|
1386
|
+
}
|
|
1387
|
+
/**
|
|
1388
|
+
* Calculates a factor of how often the current object's angle has been repeated in a certain time frame.
|
|
1389
|
+
* It does this by checking the difference in angle between current and past objects and sums them up
|
|
1390
|
+
* based on a range of similarity.
|
|
1391
|
+
*
|
|
1392
|
+
* @param current The current object.
|
|
1393
|
+
*/
|
|
1394
|
+
static getConstantAngleNerfFactor(current) {
|
|
1395
|
+
const maxTimeLimit = 2000; // 2 seconds
|
|
1396
|
+
const minTimeLimit = 200;
|
|
1397
|
+
let constantAngleCount = 0;
|
|
1398
|
+
let index = 0;
|
|
1399
|
+
let currentTimeGap = 0;
|
|
1400
|
+
while (currentTimeGap < maxTimeLimit) {
|
|
1401
|
+
const loopObj = current.previous(index);
|
|
1402
|
+
if (!loopObj) {
|
|
1403
|
+
break;
|
|
1404
|
+
}
|
|
1405
|
+
if (loopObj.angle !== null && current.angle !== null) {
|
|
1406
|
+
const angleDifference = Math.abs(current.angle - loopObj.angle);
|
|
1407
|
+
// Account less for objects that are close to the time limit.
|
|
1408
|
+
const longIntervalFactor = osuBase.MathUtils.clamp(1 -
|
|
1409
|
+
(loopObj.strainTime - minTimeLimit) /
|
|
1410
|
+
(maxTimeLimit - minTimeLimit), 0, 1);
|
|
1411
|
+
constantAngleCount +=
|
|
1412
|
+
Math.cos(3 * Math.min(Math.PI / 6, angleDifference)) *
|
|
1413
|
+
longIntervalFactor;
|
|
1414
|
+
}
|
|
1415
|
+
currentTimeGap = current.startTime - loopObj.startTime;
|
|
1416
|
+
index++;
|
|
1417
|
+
}
|
|
1418
|
+
return osuBase.MathUtils.clamp(2 / constantAngleCount, 0.2, 1);
|
|
1419
|
+
}
|
|
1420
|
+
static getTimeNerfFactor(deltaTime) {
|
|
1421
|
+
return osuBase.MathUtils.clamp(2 - deltaTime / (this.readingWindowSize / 2), 0, 1);
|
|
1422
|
+
}
|
|
1423
|
+
}
|
|
1424
|
+
DroidReadingEvaluator.emptyModMap = new osuBase.ModMap();
|
|
1425
|
+
DroidReadingEvaluator.readingWindowSize = 3000; // 3 seconds
|
|
1426
|
+
DroidReadingEvaluator.distanceInfluenceThreshold = DroidDifficultyHitObject.normalizedDiameter * 1.25; // 1.25 circles distance between centers
|
|
1427
|
+
DroidReadingEvaluator.hiddenMultiplier = 0.85;
|
|
1428
|
+
DroidReadingEvaluator.densityMultiplier = 0.8;
|
|
1429
|
+
DroidReadingEvaluator.densityDifficultyBase = 1.5;
|
|
1430
|
+
DroidReadingEvaluator.preemptBalancingFactor = 220000;
|
|
1431
|
+
DroidReadingEvaluator.preemptStartingPoint = 475; // AR 9.83 in milliseconds
|
|
1432
|
+
|
|
1433
|
+
/**
|
|
1434
|
+
* Represents the skill required to read every object in the map.
|
|
1435
|
+
*/
|
|
1436
|
+
class DroidReading extends Skill {
|
|
1437
|
+
constructor(mods, clockRate, hitObjects) {
|
|
1438
|
+
super(mods);
|
|
1439
|
+
this.clockRate = clockRate;
|
|
1440
|
+
this.hitObjects = hitObjects;
|
|
1441
|
+
this.noteDifficulties = [];
|
|
1442
|
+
this.strainDecayBase = 0.8;
|
|
1443
|
+
this.skillMultiplier = 2;
|
|
1444
|
+
this.currentNoteDifficulty = 0;
|
|
1445
|
+
this.difficulty = 0;
|
|
1446
|
+
this.noteWeightSum = 0;
|
|
1447
|
+
}
|
|
1448
|
+
process(current) {
|
|
1449
|
+
this.currentNoteDifficulty *= this.strainDecay(current.deltaTime);
|
|
1450
|
+
this.currentNoteDifficulty +=
|
|
1451
|
+
DroidReadingEvaluator.evaluateDifficultyOf(current, this.clockRate, this.mods) * this.skillMultiplier;
|
|
1452
|
+
const difficulty = this.currentNoteDifficulty * current.rhythmMultiplier;
|
|
1453
|
+
this.noteDifficulties.push(difficulty);
|
|
1454
|
+
current.readingDifficulty = difficulty;
|
|
1455
|
+
}
|
|
1456
|
+
difficultyValue() {
|
|
1457
|
+
if (this.hitObjects.length === 0) {
|
|
1458
|
+
return 0;
|
|
1459
|
+
}
|
|
1460
|
+
// Notes with 0 difficulty are excluded to avoid worst-case time complexity of the following sort (e.g. /b/2351871).
|
|
1461
|
+
// These notes will not contribute to the difficulty.
|
|
1462
|
+
const peaks = this.noteDifficulties.filter((d) => d > 0);
|
|
1463
|
+
// Start time at first object.
|
|
1464
|
+
const reducedDuration = this.hitObjects[0].startTime / this.clockRate + 60 * 1000;
|
|
1465
|
+
// Assume the first few seconds are completely memorized.
|
|
1466
|
+
let reducedCount = 0;
|
|
1467
|
+
for (const object of this.hitObjects) {
|
|
1468
|
+
if (object.startTime / this.clockRate > reducedDuration) {
|
|
1469
|
+
break;
|
|
1470
|
+
}
|
|
1471
|
+
++reducedCount;
|
|
1472
|
+
}
|
|
1473
|
+
for (let i = 0; i < Math.min(peaks.length, reducedCount); ++i) {
|
|
1474
|
+
peaks[i] *= Math.log10(osuBase.Interpolation.lerp(1, 10, osuBase.MathUtils.clamp(i / reducedCount, 0, 1)));
|
|
1475
|
+
}
|
|
1476
|
+
peaks.sort((a, b) => b - a);
|
|
1477
|
+
// Difficulty is the weighted sum of the highest notes.
|
|
1478
|
+
// We're sorting from highest to lowest note.
|
|
1479
|
+
this.difficulty = 0;
|
|
1480
|
+
this.noteWeightSum = 0;
|
|
1481
|
+
for (let i = 0; i < peaks.length; ++i) {
|
|
1482
|
+
// Use a harmonic sum for note which effectively buffs maps with more notes, especially if
|
|
1483
|
+
// note difficulties are consistent. Constants are arbitrary and give good values.
|
|
1484
|
+
// https://www.desmos.com/calculator/5eb60faf4c
|
|
1485
|
+
const weight = (1 + 1 / (1 + i)) / (Math.pow(i, 0.8) + 1 + 1 / (1 + i));
|
|
1486
|
+
if (weight === 0) {
|
|
1487
|
+
// Shortcut to avoid unnecessary iterations.
|
|
1488
|
+
break;
|
|
1489
|
+
}
|
|
1490
|
+
this.noteWeightSum += weight;
|
|
1491
|
+
this.difficulty += peaks[i] * weight;
|
|
1492
|
+
}
|
|
1493
|
+
return this.difficulty;
|
|
1494
|
+
}
|
|
1495
|
+
/**
|
|
1496
|
+
* Returns the number of relevant objects weighted against the top note.
|
|
1497
|
+
*/
|
|
1498
|
+
countTopWeightedNotes() {
|
|
1499
|
+
if (this.noteDifficulties.length === 0 ||
|
|
1500
|
+
this.difficulty === 0 ||
|
|
1501
|
+
this.noteWeightSum === 0) {
|
|
1502
|
+
return 0;
|
|
1503
|
+
}
|
|
1504
|
+
// What would the top note be if all note values were identical
|
|
1505
|
+
const consistentTopNote = this.difficulty / this.noteWeightSum;
|
|
1506
|
+
if (consistentTopNote === 0) {
|
|
1507
|
+
return 0;
|
|
1508
|
+
}
|
|
1509
|
+
// Use a weighted sum of all notes. Constants are arbitrary and give nice values
|
|
1510
|
+
return this.noteDifficulties.reduce((total, next) => total +
|
|
1511
|
+
1.1 / (1 + Math.exp(-5 * (next / consistentTopNote - 1.15))), 0);
|
|
1512
|
+
}
|
|
1513
|
+
strainDecay(ms) {
|
|
1514
|
+
return Math.pow(this.strainDecayBase, ms / 1000);
|
|
1515
|
+
}
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1268
1518
|
class Island {
|
|
1269
1519
|
constructor(delta, deltaDifferenceEpsilon) {
|
|
1270
1520
|
this.delta = Number.MAX_SAFE_INTEGER;
|
|
@@ -1346,21 +1596,23 @@ class DroidRhythmEvaluator {
|
|
|
1346
1596
|
const noteDecay = (validPrevious.length - i) / validPrevious.length;
|
|
1347
1597
|
// Either we're limited by time or limited by object count.
|
|
1348
1598
|
const currentHistoricalDecay = Math.min(timeDecay, noteDecay);
|
|
1349
|
-
|
|
1350
|
-
const
|
|
1351
|
-
const
|
|
1352
|
-
|
|
1353
|
-
//
|
|
1354
|
-
|
|
1355
|
-
|
|
1599
|
+
// Use custom cap value to ensure that that at this point delta time is actually zero.
|
|
1600
|
+
const currentDelta = Math.max(currentObject.deltaTime, 1e-7);
|
|
1601
|
+
const prevDelta = Math.max(prevObject.deltaTime, 1e-7);
|
|
1602
|
+
const lastDelta = Math.max(lastObject.deltaTime, 1e-7);
|
|
1603
|
+
// Calculate how much current delta difference deserves a rhythm bonus.
|
|
1604
|
+
// This function is meant to reduce rhythm bonus for deltas that are multiples of each other (i.e. 100 and 200).
|
|
1605
|
+
const deltaDifference = Math.max(prevDelta, currentDelta) /
|
|
1606
|
+
Math.min(prevDelta, currentDelta);
|
|
1607
|
+
// Take only the fractional part of the value since we are only interested in punishing multiples.
|
|
1608
|
+
const deltaDifferenceFraction = deltaDifference - Math.trunc(deltaDifference);
|
|
1356
1609
|
const currentRatio = 1 +
|
|
1357
1610
|
this.rhythmRatioMultiplier *
|
|
1358
|
-
Math.min(0.5,
|
|
1611
|
+
Math.min(0.5, osuBase.MathUtils.smoothstepBellCurve(deltaDifferenceFraction));
|
|
1359
1612
|
// Reduce ratio bonus if delta difference is too big
|
|
1360
|
-
const
|
|
1361
|
-
const fractionMultiplier = osuBase.MathUtils.clamp(2 - fraction / 8, 0, 1);
|
|
1613
|
+
const differenceMultiplier = osuBase.MathUtils.clamp(2 - deltaDifference / 8, 0, 1);
|
|
1362
1614
|
const windowPenalty = Math.min(1, Math.max(0, Math.abs(prevDelta - currentDelta) - deltaDifferenceEpsilon) / deltaDifferenceEpsilon);
|
|
1363
|
-
let effectiveRatio = windowPenalty * currentRatio *
|
|
1615
|
+
let effectiveRatio = windowPenalty * currentRatio * differenceMultiplier;
|
|
1364
1616
|
if (firstDeltaSwitch) {
|
|
1365
1617
|
if (Math.abs(prevDelta - currentDelta) < deltaDifferenceEpsilon) {
|
|
1366
1618
|
// Island is still progressing, count size.
|
|
@@ -1454,7 +1706,7 @@ class DroidRhythmEvaluator {
|
|
|
1454
1706
|
DroidRhythmEvaluator.historyTimeMax = 5000; // 5 seconds of calculateRhythmBonus max.
|
|
1455
1707
|
DroidRhythmEvaluator.historyObjectsMax = 32;
|
|
1456
1708
|
DroidRhythmEvaluator.rhythmOverallMultiplier = 0.95;
|
|
1457
|
-
DroidRhythmEvaluator.rhythmRatioMultiplier =
|
|
1709
|
+
DroidRhythmEvaluator.rhythmRatioMultiplier = 15;
|
|
1458
1710
|
|
|
1459
1711
|
/**
|
|
1460
1712
|
* Represents the skill required to properly follow a beatmap's rhythm.
|
|
@@ -1471,10 +1723,11 @@ class DroidRhythm extends DroidSkill {
|
|
|
1471
1723
|
this.useSliderAccuracy = mods.has(osuBase.ModScoreV2);
|
|
1472
1724
|
}
|
|
1473
1725
|
strainValueAt(current) {
|
|
1474
|
-
this.
|
|
1475
|
-
|
|
1726
|
+
const rhythmMultiplier = DroidRhythmEvaluator.evaluateDifficultyOf(current, this.useSliderAccuracy);
|
|
1727
|
+
const doubletapness = 1 - current.doubletapness;
|
|
1476
1728
|
this.currentRhythmStrain *= this.strainDecay(current.deltaTime);
|
|
1477
|
-
this.currentRhythmStrain +=
|
|
1729
|
+
this.currentRhythmStrain += (rhythmMultiplier - 1) * doubletapness;
|
|
1730
|
+
this.currentRhythmMultiplier = rhythmMultiplier * doubletapness;
|
|
1478
1731
|
return this.currentRhythmStrain;
|
|
1479
1732
|
}
|
|
1480
1733
|
calculateInitialStrain(time, current) {
|
|
@@ -1552,6 +1805,7 @@ class DroidTap extends DroidSkill {
|
|
|
1552
1805
|
this.currentRhythmMultiplier = 0;
|
|
1553
1806
|
this.skillMultiplier = 1.375;
|
|
1554
1807
|
this._objectDeltaTimes = [];
|
|
1808
|
+
this.maxStrain = 0;
|
|
1555
1809
|
this.considerCheesability = considerCheesability;
|
|
1556
1810
|
this.strainTimeCap = strainTimeCap;
|
|
1557
1811
|
}
|
|
@@ -1559,33 +1813,27 @@ class DroidTap extends DroidSkill {
|
|
|
1559
1813
|
* The amount of notes that are relevant to the difficulty.
|
|
1560
1814
|
*/
|
|
1561
1815
|
relevantNoteCount() {
|
|
1562
|
-
if (this._objectStrains.length === 0) {
|
|
1563
|
-
return 0;
|
|
1564
|
-
}
|
|
1565
|
-
const maxStrain = osuBase.MathUtils.max(this._objectStrains);
|
|
1566
|
-
if (maxStrain === 0) {
|
|
1816
|
+
if (this._objectStrains.length === 0 || this.maxStrain === 0) {
|
|
1567
1817
|
return 0;
|
|
1568
1818
|
}
|
|
1569
|
-
return this._objectStrains.reduce((total, next) => total + 1 / (1 + Math.exp(-((next / maxStrain) * 12 - 6))), 0);
|
|
1819
|
+
return this._objectStrains.reduce((total, next) => total + 1 / (1 + Math.exp(-((next / this.maxStrain) * 12 - 6))), 0);
|
|
1570
1820
|
}
|
|
1571
1821
|
/**
|
|
1572
1822
|
* The delta time relevant to the difficulty.
|
|
1573
1823
|
*/
|
|
1574
1824
|
relevantDeltaTime() {
|
|
1575
|
-
if (this._objectStrains.length === 0) {
|
|
1576
|
-
return 0;
|
|
1577
|
-
}
|
|
1578
|
-
const maxStrain = osuBase.MathUtils.max(this._objectStrains);
|
|
1579
|
-
if (maxStrain === 0) {
|
|
1825
|
+
if (this._objectStrains.length === 0 || this.maxStrain === 0) {
|
|
1580
1826
|
return 0;
|
|
1581
1827
|
}
|
|
1582
1828
|
return (this._objectDeltaTimes.reduce((total, next, index) => total +
|
|
1583
|
-
|
|
1829
|
+
next /
|
|
1584
1830
|
(1 +
|
|
1585
|
-
Math.exp(-((this._objectStrains[index] /
|
|
1831
|
+
Math.exp(-((this._objectStrains[index] /
|
|
1832
|
+
this.maxStrain) *
|
|
1586
1833
|
25 -
|
|
1587
1834
|
20))), 0) /
|
|
1588
|
-
this._objectStrains.reduce((total, next) => total +
|
|
1835
|
+
this._objectStrains.reduce((total, next) => total +
|
|
1836
|
+
1 / (1 + Math.exp(-((next / this.maxStrain) * 25 - 20))), 0));
|
|
1589
1837
|
}
|
|
1590
1838
|
strainValueAt(current) {
|
|
1591
1839
|
this.currentTapStrain *= this.strainDecay(current.strainTime);
|
|
@@ -1593,7 +1841,9 @@ class DroidTap extends DroidSkill {
|
|
|
1593
1841
|
DroidTapEvaluator.evaluateDifficultyOf(current, this.considerCheesability, this.strainTimeCap) * this.skillMultiplier;
|
|
1594
1842
|
this.currentRhythmMultiplier = current.rhythmMultiplier;
|
|
1595
1843
|
this._objectDeltaTimes.push(current.deltaTime);
|
|
1596
|
-
|
|
1844
|
+
const strain = this.currentTapStrain * this.currentRhythmMultiplier;
|
|
1845
|
+
this.maxStrain = Math.max(this.maxStrain, strain);
|
|
1846
|
+
return strain;
|
|
1597
1847
|
}
|
|
1598
1848
|
calculateInitialStrain(time, current) {
|
|
1599
1849
|
var _a, _b;
|
|
@@ -1621,153 +1871,6 @@ class DroidTap extends DroidSkill {
|
|
|
1621
1871
|
}
|
|
1622
1872
|
}
|
|
1623
1873
|
|
|
1624
|
-
/**
|
|
1625
|
-
* An evaluator for calculating osu!droid visual skill.
|
|
1626
|
-
*/
|
|
1627
|
-
class DroidVisualEvaluator {
|
|
1628
|
-
/**
|
|
1629
|
-
* Evaluates the difficulty of reading the current object, based on:
|
|
1630
|
-
*
|
|
1631
|
-
* - note density of the current object,
|
|
1632
|
-
* - overlapping factor of the current object,
|
|
1633
|
-
* - the preempt time of the current object,
|
|
1634
|
-
* - the visual opacity of the current object,
|
|
1635
|
-
* - the velocity of the current object if it's a slider,
|
|
1636
|
-
* - past objects' velocity if they are sliders,
|
|
1637
|
-
* - and whether the Hidden mod is enabled.
|
|
1638
|
-
*
|
|
1639
|
-
* @param current The current object.
|
|
1640
|
-
* @param mods The mods used.
|
|
1641
|
-
* @param withSliders Whether to take slider difficulty into account.
|
|
1642
|
-
*/
|
|
1643
|
-
static evaluateDifficultyOf(current, mods, withSliders) {
|
|
1644
|
-
if (current.object instanceof osuBase.Spinner ||
|
|
1645
|
-
// Exclude overlapping objects that can be tapped at once.
|
|
1646
|
-
current.isOverlapping(true) ||
|
|
1647
|
-
current.index === 0) {
|
|
1648
|
-
return 0;
|
|
1649
|
-
}
|
|
1650
|
-
// Start with base density and give global bonus for Hidden.
|
|
1651
|
-
// Add density caps for sanity.
|
|
1652
|
-
let strain;
|
|
1653
|
-
if (mods.has(osuBase.ModHidden)) {
|
|
1654
|
-
strain = Math.min(30, Math.pow(current.noteDensity, 3));
|
|
1655
|
-
}
|
|
1656
|
-
else if (mods.has(osuBase.ModTraceable)) {
|
|
1657
|
-
// Give more bonus for hit circles due to there being no circle piece.
|
|
1658
|
-
if (current.object instanceof osuBase.Circle) {
|
|
1659
|
-
strain = Math.min(25, Math.pow(current.noteDensity, 2.5));
|
|
1660
|
-
}
|
|
1661
|
-
else {
|
|
1662
|
-
strain = Math.min(22.5, Math.pow(current.noteDensity, 2.25));
|
|
1663
|
-
}
|
|
1664
|
-
}
|
|
1665
|
-
else {
|
|
1666
|
-
strain = Math.min(20, Math.pow(current.noteDensity, 2));
|
|
1667
|
-
}
|
|
1668
|
-
// Bonus based on how visible the object is.
|
|
1669
|
-
for (let i = 0; i < Math.min(current.index, 10); ++i) {
|
|
1670
|
-
const previous = current.previous(i);
|
|
1671
|
-
if (previous.object instanceof osuBase.Spinner ||
|
|
1672
|
-
// Exclude overlapping objects that can be tapped at once.
|
|
1673
|
-
previous.isOverlapping(true)) {
|
|
1674
|
-
continue;
|
|
1675
|
-
}
|
|
1676
|
-
// Do not consider objects that don't fall under time preempt.
|
|
1677
|
-
if (current.object.startTime - previous.object.endTime >
|
|
1678
|
-
current.object.timePreempt) {
|
|
1679
|
-
break;
|
|
1680
|
-
}
|
|
1681
|
-
strain +=
|
|
1682
|
-
(1 - current.opacityAt(previous.object.startTime, mods)) / 4;
|
|
1683
|
-
}
|
|
1684
|
-
if (current.timePreempt < 400) {
|
|
1685
|
-
// Give bonus for AR higher than 10.33.
|
|
1686
|
-
strain += Math.pow(400 - current.timePreempt, 1.35) / 100;
|
|
1687
|
-
}
|
|
1688
|
-
// Scale the value with overlapping factor.
|
|
1689
|
-
strain /= 10 * (1 + current.overlappingFactor);
|
|
1690
|
-
if (current.object instanceof osuBase.Slider && withSliders) {
|
|
1691
|
-
const scalingFactor = 50 / current.object.radius;
|
|
1692
|
-
// Invert the scaling factor to determine the true travel distance independent of circle size.
|
|
1693
|
-
const pixelTravelDistance = current.lazyTravelDistance / scalingFactor;
|
|
1694
|
-
const currentVelocity = pixelTravelDistance / current.travelTime;
|
|
1695
|
-
const spanTravelDistance = pixelTravelDistance / current.object.spanCount;
|
|
1696
|
-
strain +=
|
|
1697
|
-
// Reward sliders based on velocity, while also avoiding overbuffing extremely fast sliders.
|
|
1698
|
-
Math.min(6, currentVelocity * 1.5) *
|
|
1699
|
-
// Longer sliders require more reading.
|
|
1700
|
-
(spanTravelDistance / 100);
|
|
1701
|
-
let cumulativeStrainTime = 0;
|
|
1702
|
-
// Reward for velocity changes based on last few sliders.
|
|
1703
|
-
for (let i = 0; i < Math.min(current.index, 4); ++i) {
|
|
1704
|
-
const last = current.previous(i);
|
|
1705
|
-
cumulativeStrainTime += last.strainTime;
|
|
1706
|
-
if (!(last.object instanceof osuBase.Slider) ||
|
|
1707
|
-
// Exclude overlapping objects that can be tapped at once.
|
|
1708
|
-
last.isOverlapping(true)) {
|
|
1709
|
-
continue;
|
|
1710
|
-
}
|
|
1711
|
-
// Invert the scaling factor to determine the true travel distance independent of circle size.
|
|
1712
|
-
const lastPixelTravelDistance = last.lazyTravelDistance / scalingFactor;
|
|
1713
|
-
const lastVelocity = lastPixelTravelDistance / last.travelTime;
|
|
1714
|
-
const lastSpanTravelDistance = lastPixelTravelDistance / last.object.spanCount;
|
|
1715
|
-
strain +=
|
|
1716
|
-
// Reward past sliders based on velocity changes, while also
|
|
1717
|
-
// avoiding overbuffing extremely fast velocity changes.
|
|
1718
|
-
Math.min(10, 2.5 * Math.abs(currentVelocity - lastVelocity)) *
|
|
1719
|
-
// Longer sliders require more reading.
|
|
1720
|
-
(lastSpanTravelDistance / 125) *
|
|
1721
|
-
// Avoid overbuffing past sliders.
|
|
1722
|
-
Math.min(1, 300 / cumulativeStrainTime);
|
|
1723
|
-
}
|
|
1724
|
-
}
|
|
1725
|
-
return strain;
|
|
1726
|
-
}
|
|
1727
|
-
}
|
|
1728
|
-
|
|
1729
|
-
/**
|
|
1730
|
-
* Represents the skill required to read every object in the map.
|
|
1731
|
-
*/
|
|
1732
|
-
class DroidVisual extends DroidSkill {
|
|
1733
|
-
constructor(mods, withSliders) {
|
|
1734
|
-
super(mods);
|
|
1735
|
-
this.starsPerDouble = 1.025;
|
|
1736
|
-
this.reducedSectionCount = 10;
|
|
1737
|
-
this.reducedSectionBaseline = 0.75;
|
|
1738
|
-
this.strainDecayBase = 0.1;
|
|
1739
|
-
this.currentVisualStrain = 0;
|
|
1740
|
-
this.currentRhythmMultiplier = 1;
|
|
1741
|
-
this.skillMultiplier = 11.2;
|
|
1742
|
-
this.withSliders = withSliders;
|
|
1743
|
-
}
|
|
1744
|
-
strainValueAt(current) {
|
|
1745
|
-
this.currentVisualStrain *= this.strainDecay(current.deltaTime);
|
|
1746
|
-
this.currentVisualStrain +=
|
|
1747
|
-
DroidVisualEvaluator.evaluateDifficultyOf(current, this.mods, this.withSliders) * this.skillMultiplier;
|
|
1748
|
-
this.currentRhythmMultiplier = current.rhythmMultiplier;
|
|
1749
|
-
return this.currentVisualStrain * this.currentRhythmMultiplier;
|
|
1750
|
-
}
|
|
1751
|
-
calculateInitialStrain(time, current) {
|
|
1752
|
-
var _a, _b;
|
|
1753
|
-
return (this.currentVisualStrain *
|
|
1754
|
-
this.currentRhythmMultiplier *
|
|
1755
|
-
this.strainDecay(time - ((_b = (_a = current.previous(0)) === null || _a === void 0 ? void 0 : _a.startTime) !== null && _b !== void 0 ? _b : 0)));
|
|
1756
|
-
}
|
|
1757
|
-
getObjectStrain() {
|
|
1758
|
-
return this.currentVisualStrain * this.currentRhythmMultiplier;
|
|
1759
|
-
}
|
|
1760
|
-
saveToHitObject(current) {
|
|
1761
|
-
const strain = this.currentVisualStrain * this.currentRhythmMultiplier;
|
|
1762
|
-
if (this.withSliders) {
|
|
1763
|
-
current.visualStrainWithSliders = strain;
|
|
1764
|
-
}
|
|
1765
|
-
else {
|
|
1766
|
-
current.visualStrainWithoutSliders = strain;
|
|
1767
|
-
}
|
|
1768
|
-
}
|
|
1769
|
-
}
|
|
1770
|
-
|
|
1771
1874
|
/**
|
|
1772
1875
|
* Holds data that can be used to calculate osu!droid performance points as well
|
|
1773
1876
|
* as doing some analysis using the replay of a score.
|
|
@@ -1778,19 +1881,15 @@ class ExtendedDroidDifficultyAttributes extends DroidDifficultyAttributes {
|
|
|
1778
1881
|
this.mode = "live";
|
|
1779
1882
|
this.possibleThreeFingeredSections = [];
|
|
1780
1883
|
this.difficultSliders = [];
|
|
1781
|
-
this.aimNoteCount = 0;
|
|
1782
1884
|
this.flashlightSliderFactor = 1;
|
|
1783
|
-
this.visualSliderFactor = 1;
|
|
1784
1885
|
if (!cacheableAttributes) {
|
|
1785
1886
|
return;
|
|
1786
1887
|
}
|
|
1787
1888
|
this.possibleThreeFingeredSections =
|
|
1788
1889
|
cacheableAttributes.possibleThreeFingeredSections;
|
|
1789
1890
|
this.difficultSliders = cacheableAttributes.difficultSliders;
|
|
1790
|
-
this.aimNoteCount = cacheableAttributes.aimNoteCount;
|
|
1791
1891
|
this.flashlightSliderFactor =
|
|
1792
1892
|
cacheableAttributes.flashlightSliderFactor;
|
|
1793
|
-
this.visualSliderFactor = cacheableAttributes.visualSliderFactor;
|
|
1794
1893
|
}
|
|
1795
1894
|
}
|
|
1796
1895
|
|
|
@@ -1816,31 +1915,28 @@ class DroidDifficultyCalculator extends DifficultyCalculator {
|
|
|
1816
1915
|
attributes.hitCircleCount = beatmap.hitObjects.circles;
|
|
1817
1916
|
attributes.sliderCount = beatmap.hitObjects.sliders;
|
|
1818
1917
|
attributes.spinnerCount = beatmap.hitObjects.spinners;
|
|
1918
|
+
let greatWindow;
|
|
1919
|
+
if (attributes.mods.has(osuBase.ModPrecise)) {
|
|
1920
|
+
greatWindow = new osuBase.PreciseDroidHitWindow(beatmap.difficulty.od)
|
|
1921
|
+
.greatWindow;
|
|
1922
|
+
}
|
|
1923
|
+
else {
|
|
1924
|
+
greatWindow = new osuBase.DroidHitWindow(beatmap.difficulty.od).greatWindow;
|
|
1925
|
+
}
|
|
1926
|
+
attributes.overallDifficulty = osuBase.OsuHitWindow.greatWindowToOD(greatWindow / attributes.clockRate);
|
|
1819
1927
|
this.populateAimAttributes(attributes, skills, objects);
|
|
1820
1928
|
this.populateTapAttributes(attributes, skills, objects);
|
|
1821
1929
|
this.populateRhythmAttributes(attributes, skills);
|
|
1822
1930
|
this.populateFlashlightAttributes(attributes, skills);
|
|
1823
|
-
this.
|
|
1824
|
-
if (beatmap.mods.has(osuBase.ModRelax)) {
|
|
1825
|
-
attributes.aimDifficulty *= 0.9;
|
|
1826
|
-
attributes.tapDifficulty = 0;
|
|
1827
|
-
attributes.rhythmDifficulty = 0;
|
|
1828
|
-
attributes.flashlightDifficulty *= 0.7;
|
|
1829
|
-
attributes.visualDifficulty = 0;
|
|
1830
|
-
}
|
|
1831
|
-
else if (beatmap.mods.has(osuBase.ModAutopilot)) {
|
|
1832
|
-
attributes.aimDifficulty = 0;
|
|
1833
|
-
attributes.flashlightDifficulty *= 0.3;
|
|
1834
|
-
attributes.visualDifficulty *= 0.8;
|
|
1835
|
-
}
|
|
1931
|
+
this.populateReadingAttributes(attributes, skills);
|
|
1836
1932
|
const aimPerformanceValue = this.basePerformanceValue(Math.pow(attributes.aimDifficulty, 0.8));
|
|
1837
1933
|
const tapPerformanceValue = this.basePerformanceValue(attributes.tapDifficulty);
|
|
1838
1934
|
const flashlightPerformanceValue = Math.pow(attributes.flashlightDifficulty, 1.6) * 25;
|
|
1839
|
-
const
|
|
1935
|
+
const readingPerformanceValue = Math.pow(Math.pow(attributes.readingDifficulty, 2) * 25, 0.8);
|
|
1840
1936
|
const basePerformanceValue = Math.pow(Math.pow(aimPerformanceValue, 1.1) +
|
|
1841
1937
|
Math.pow(tapPerformanceValue, 1.1) +
|
|
1842
1938
|
Math.pow(flashlightPerformanceValue, 1.1) +
|
|
1843
|
-
Math.pow(
|
|
1939
|
+
Math.pow(readingPerformanceValue, 1.1), 1 / 1.1);
|
|
1844
1940
|
if (basePerformanceValue > 1e-5) {
|
|
1845
1941
|
// Document for formula derivation:
|
|
1846
1942
|
// https://docs.google.com/document/d/10DZGYYSsT_yjz2Mtp6yIJld0Rqx4E-vVHupCqiM4TNI/edit
|
|
@@ -1852,15 +1948,6 @@ class DroidDifficultyCalculator extends DifficultyCalculator {
|
|
|
1852
1948
|
else {
|
|
1853
1949
|
attributes.starRating = 0;
|
|
1854
1950
|
}
|
|
1855
|
-
let greatWindow;
|
|
1856
|
-
if (attributes.mods.has(osuBase.ModPrecise)) {
|
|
1857
|
-
greatWindow = new osuBase.PreciseDroidHitWindow(beatmap.difficulty.od)
|
|
1858
|
-
.greatWindow;
|
|
1859
|
-
}
|
|
1860
|
-
else {
|
|
1861
|
-
greatWindow = new osuBase.DroidHitWindow(beatmap.difficulty.od).greatWindow;
|
|
1862
|
-
}
|
|
1863
|
-
attributes.overallDifficulty = osuBase.OsuHitWindow.greatWindowToOD(greatWindow / attributes.clockRate);
|
|
1864
1951
|
return attributes;
|
|
1865
1952
|
}
|
|
1866
1953
|
createPlayableBeatmap(beatmap, mods) {
|
|
@@ -1873,7 +1960,7 @@ class DroidDifficultyCalculator extends DifficultyCalculator {
|
|
|
1873
1960
|
const { objects } = beatmap.hitObjects;
|
|
1874
1961
|
for (let i = 0; i < objects.length; ++i) {
|
|
1875
1962
|
const difficultyObject = new DroidDifficultyHitObject(objects[i], (_a = objects[i - 1]) !== null && _a !== void 0 ? _a : null, difficultyObjects, clockRate, i - 1);
|
|
1876
|
-
difficultyObject.computeProperties(clockRate
|
|
1963
|
+
difficultyObject.computeProperties(clockRate);
|
|
1877
1964
|
difficultyObjects.push(difficultyObject);
|
|
1878
1965
|
}
|
|
1879
1966
|
return difficultyObjects;
|
|
@@ -1886,13 +1973,13 @@ class DroidDifficultyCalculator extends DifficultyCalculator {
|
|
|
1886
1973
|
skills.push(new DroidAim(mods, false));
|
|
1887
1974
|
}
|
|
1888
1975
|
if (!mods.has(osuBase.ModRelax)) {
|
|
1889
|
-
// Tap
|
|
1976
|
+
// Tap skills depend on rhythm skill, so we put it first
|
|
1890
1977
|
skills.push(new DroidRhythm(mods));
|
|
1891
1978
|
skills.push(new DroidTap(mods, true));
|
|
1892
1979
|
skills.push(new DroidTap(mods, false));
|
|
1893
|
-
skills.push(new
|
|
1894
|
-
skills.push(new DroidVisual(mods, false));
|
|
1980
|
+
skills.push(new DroidTap(mods, true, 50));
|
|
1895
1981
|
}
|
|
1982
|
+
skills.push(new DroidReading(mods, beatmap.speedMultiplier, beatmap.hitObjects.objects));
|
|
1896
1983
|
if (mods.has(osuBase.ModFlashlight)) {
|
|
1897
1984
|
skills.push(new DroidFlashlight(mods, true));
|
|
1898
1985
|
skills.push(new DroidFlashlight(mods, false));
|
|
@@ -1911,12 +1998,18 @@ class DroidDifficultyCalculator extends DifficultyCalculator {
|
|
|
1911
1998
|
populateAimAttributes(attributes, skills, objects) {
|
|
1912
1999
|
const aim = skills.find((s) => s instanceof DroidAim && s.withSliders);
|
|
1913
2000
|
const aimNoSlider = skills.find((s) => s instanceof DroidAim && !s.withSliders);
|
|
1914
|
-
if (!aim || !aimNoSlider) {
|
|
2001
|
+
if (!aim || !aimNoSlider || attributes.mods.has(osuBase.ModAutopilot)) {
|
|
2002
|
+
attributes.aimDifficulty = 0;
|
|
2003
|
+
attributes.aimDifficultSliderCount = 0;
|
|
2004
|
+
attributes.aimDifficultStrainCount = 0;
|
|
1915
2005
|
return;
|
|
1916
2006
|
}
|
|
1917
2007
|
attributes.aimDifficulty = this.calculateRating(aim);
|
|
1918
2008
|
attributes.aimDifficultSliderCount = aim.countDifficultSliders();
|
|
1919
|
-
attributes.aimDifficultStrainCount = aim.
|
|
2009
|
+
attributes.aimDifficultStrainCount = aim.countTopWeightedStrains();
|
|
2010
|
+
if (attributes.mods.has(osuBase.ModRelax)) {
|
|
2011
|
+
attributes.aimDifficulty *= 0.9;
|
|
2012
|
+
}
|
|
1920
2013
|
const topDifficultSliders = [];
|
|
1921
2014
|
for (let i = 0; i < objects.length; ++i) {
|
|
1922
2015
|
const object = objects[i];
|
|
@@ -1955,18 +2048,22 @@ class DroidDifficultyCalculator extends DifficultyCalculator {
|
|
|
1955
2048
|
}
|
|
1956
2049
|
populateTapAttributes(attributes, skills, objects) {
|
|
1957
2050
|
const tap = skills.find((s) => s instanceof DroidTap && s.considerCheesability);
|
|
1958
|
-
|
|
2051
|
+
const tapVibro = skills.find((s) => s instanceof DroidTap &&
|
|
2052
|
+
s.considerCheesability &&
|
|
2053
|
+
s.strainTimeCap !== undefined);
|
|
2054
|
+
if (!tap || !tapVibro || attributes.mods.has(osuBase.ModRelax)) {
|
|
2055
|
+
attributes.tapDifficulty = 0;
|
|
2056
|
+
attributes.tapDifficultStrainCount = 0;
|
|
2057
|
+
attributes.speedNoteCount = 0;
|
|
2058
|
+
attributes.averageSpeedDeltaTime = 0;
|
|
2059
|
+
attributes.vibroFactor = 1;
|
|
1959
2060
|
return;
|
|
1960
2061
|
}
|
|
1961
2062
|
attributes.tapDifficulty = this.calculateRating(tap);
|
|
1962
|
-
attributes.tapDifficultStrainCount = tap.
|
|
2063
|
+
attributes.tapDifficultStrainCount = tap.countTopWeightedStrains();
|
|
1963
2064
|
attributes.speedNoteCount = tap.relevantNoteCount();
|
|
1964
2065
|
attributes.averageSpeedDeltaTime = tap.relevantDeltaTime();
|
|
1965
2066
|
if (attributes.tapDifficulty > 0) {
|
|
1966
|
-
const tapVibro = new DroidTap(attributes.mods, true, attributes.averageSpeedDeltaTime);
|
|
1967
|
-
for (const object of objects) {
|
|
1968
|
-
tapVibro.process(object);
|
|
1969
|
-
}
|
|
1970
2067
|
attributes.vibroFactor =
|
|
1971
2068
|
this.calculateRating(tapVibro) / attributes.tapDifficulty;
|
|
1972
2069
|
}
|
|
@@ -2013,20 +2110,29 @@ class DroidDifficultyCalculator extends DifficultyCalculator {
|
|
|
2013
2110
|
}
|
|
2014
2111
|
populateRhythmAttributes(attributes, skills) {
|
|
2015
2112
|
const rhythm = skills.find((s) => s instanceof DroidRhythm);
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2113
|
+
attributes.rhythmDifficulty =
|
|
2114
|
+
rhythm && !attributes.mods.has(osuBase.ModRelax)
|
|
2115
|
+
? this.calculateRating(rhythm)
|
|
2116
|
+
: 0;
|
|
2020
2117
|
}
|
|
2021
2118
|
populateFlashlightAttributes(attributes, skills) {
|
|
2022
2119
|
const flashlight = skills.find((s) => s instanceof DroidFlashlight && s.withSliders);
|
|
2023
2120
|
const flashlightNoSliders = skills.find((s) => s instanceof DroidFlashlight && !s.withSliders);
|
|
2024
2121
|
if (!flashlight || !flashlightNoSliders) {
|
|
2122
|
+
attributes.flashlightDifficulty = 0;
|
|
2123
|
+
attributes.flashlightDifficultStrainCount = 0;
|
|
2124
|
+
attributes.flashlightSliderFactor = 1;
|
|
2025
2125
|
return;
|
|
2026
2126
|
}
|
|
2027
2127
|
attributes.flashlightDifficulty = this.calculateRating(flashlight);
|
|
2028
2128
|
attributes.flashlightDifficultStrainCount =
|
|
2029
|
-
flashlight.
|
|
2129
|
+
flashlight.countTopWeightedStrains();
|
|
2130
|
+
if (attributes.mods.has(osuBase.ModRelax)) {
|
|
2131
|
+
attributes.flashlightDifficulty *= 0.7;
|
|
2132
|
+
}
|
|
2133
|
+
else if (attributes.mods.has(osuBase.ModAutopilot)) {
|
|
2134
|
+
attributes.flashlightDifficulty *= 0.4;
|
|
2135
|
+
}
|
|
2030
2136
|
if (attributes.flashlightDifficulty > 0) {
|
|
2031
2137
|
attributes.flashlightSliderFactor =
|
|
2032
2138
|
this.calculateRating(flashlightNoSliders) /
|
|
@@ -2036,22 +2142,25 @@ class DroidDifficultyCalculator extends DifficultyCalculator {
|
|
|
2036
2142
|
attributes.flashlightSliderFactor = 1;
|
|
2037
2143
|
}
|
|
2038
2144
|
}
|
|
2039
|
-
|
|
2040
|
-
const
|
|
2041
|
-
|
|
2042
|
-
|
|
2145
|
+
populateReadingAttributes(attributes, skills) {
|
|
2146
|
+
const reading = skills.find((s) => s instanceof DroidReading);
|
|
2147
|
+
if (!reading) {
|
|
2148
|
+
attributes.readingDifficulty = 0;
|
|
2149
|
+
attributes.readingDifficultNoteCount = 0;
|
|
2043
2150
|
return;
|
|
2044
2151
|
}
|
|
2045
|
-
attributes.
|
|
2046
|
-
attributes.
|
|
2047
|
-
if (attributes.
|
|
2048
|
-
attributes.
|
|
2049
|
-
this.calculateRating(visualNoSliders) /
|
|
2050
|
-
attributes.visualDifficulty;
|
|
2152
|
+
attributes.readingDifficulty = this.calculateRating(reading);
|
|
2153
|
+
attributes.readingDifficultNoteCount = reading.countTopWeightedNotes();
|
|
2154
|
+
if (attributes.mods.has(osuBase.ModRelax)) {
|
|
2155
|
+
attributes.readingDifficulty *= 0.7;
|
|
2051
2156
|
}
|
|
2052
|
-
else {
|
|
2053
|
-
attributes.
|
|
2157
|
+
else if (attributes.mods.has(osuBase.ModAutopilot)) {
|
|
2158
|
+
attributes.readingDifficulty *= 0.4;
|
|
2054
2159
|
}
|
|
2160
|
+
// Consider accuracy difficulty.
|
|
2161
|
+
const ratingMultiplier = 0.75 +
|
|
2162
|
+
Math.pow(Math.max(0, attributes.overallDifficulty), 2.2) / 800;
|
|
2163
|
+
attributes.readingDifficulty *= Math.sqrt(ratingMultiplier);
|
|
2055
2164
|
}
|
|
2056
2165
|
}
|
|
2057
2166
|
/**
|
|
@@ -2322,14 +2431,13 @@ class DroidPerformanceCalculator extends PerformanceCalculator {
|
|
|
2322
2431
|
*/
|
|
2323
2432
|
this.flashlight = 0;
|
|
2324
2433
|
/**
|
|
2325
|
-
* The
|
|
2434
|
+
* The reading performance value.
|
|
2326
2435
|
*/
|
|
2327
|
-
this.
|
|
2436
|
+
this.reading = 0;
|
|
2328
2437
|
this.finalMultiplier = 1.24;
|
|
2329
2438
|
this.mode = osuBase.Modes.droid;
|
|
2330
2439
|
this._aimSliderCheesePenalty = 1;
|
|
2331
2440
|
this._flashlightSliderCheesePenalty = 1;
|
|
2332
|
-
this._visualSliderCheesePenalty = 1;
|
|
2333
2441
|
this._tapPenalty = 1;
|
|
2334
2442
|
this._deviation = 0;
|
|
2335
2443
|
this._tapDeviation = 0;
|
|
@@ -2370,14 +2478,6 @@ class DroidPerformanceCalculator extends PerformanceCalculator {
|
|
|
2370
2478
|
get flashlightSliderCheesePenalty() {
|
|
2371
2479
|
return this._flashlightSliderCheesePenalty;
|
|
2372
2480
|
}
|
|
2373
|
-
/**
|
|
2374
|
-
* The penalty used to penalize the visual performance value.
|
|
2375
|
-
*
|
|
2376
|
-
* Can be properly obtained by analyzing the replay associated with the score.
|
|
2377
|
-
*/
|
|
2378
|
-
get visualSliderCheesePenalty() {
|
|
2379
|
-
return this._visualSliderCheesePenalty;
|
|
2380
|
-
}
|
|
2381
2481
|
/**
|
|
2382
2482
|
* Applies a tap penalty value to this calculator.
|
|
2383
2483
|
*
|
|
@@ -2438,27 +2538,6 @@ class DroidPerformanceCalculator extends PerformanceCalculator {
|
|
|
2438
2538
|
this.flashlight = this.calculateFlashlightValue();
|
|
2439
2539
|
this.total = this.calculateTotalValue();
|
|
2440
2540
|
}
|
|
2441
|
-
/**
|
|
2442
|
-
* Applies a visual slider cheese penalty value to this calculator.
|
|
2443
|
-
*
|
|
2444
|
-
* The visual and total performance value will be recalculated afterwards.
|
|
2445
|
-
*
|
|
2446
|
-
* @param value The slider cheese penalty value. Must be between 0 and 1.
|
|
2447
|
-
*/
|
|
2448
|
-
applyVisualSliderCheesePenalty(value) {
|
|
2449
|
-
if (value < 0) {
|
|
2450
|
-
throw new RangeError("New visual slider cheese penalty must be greater than or equal to zero.");
|
|
2451
|
-
}
|
|
2452
|
-
if (value > 1) {
|
|
2453
|
-
throw new RangeError("New visual slider cheese penalty must be less than or equal to one.");
|
|
2454
|
-
}
|
|
2455
|
-
if (value === this._visualSliderCheesePenalty) {
|
|
2456
|
-
return;
|
|
2457
|
-
}
|
|
2458
|
-
this._visualSliderCheesePenalty = value;
|
|
2459
|
-
this.visual = this.calculateVisualValue();
|
|
2460
|
-
this.total = this.calculateTotalValue();
|
|
2461
|
-
}
|
|
2462
2541
|
calculateValues() {
|
|
2463
2542
|
this._deviation = this.calculateDeviation();
|
|
2464
2543
|
this._tapDeviation = this.calculateTapDeviation();
|
|
@@ -2466,23 +2545,21 @@ class DroidPerformanceCalculator extends PerformanceCalculator {
|
|
|
2466
2545
|
this.tap = this.calculateTapValue();
|
|
2467
2546
|
this.accuracy = this.calculateAccuracyValue();
|
|
2468
2547
|
this.flashlight = this.calculateFlashlightValue();
|
|
2469
|
-
this.
|
|
2548
|
+
this.reading = this.calculateReadingValue();
|
|
2470
2549
|
}
|
|
2471
2550
|
calculateTotalValue() {
|
|
2472
2551
|
return (Math.pow(Math.pow(this.aim, 1.1) +
|
|
2473
2552
|
Math.pow(this.tap, 1.1) +
|
|
2474
2553
|
Math.pow(this.accuracy, 1.1) +
|
|
2475
2554
|
Math.pow(this.flashlight, 1.1) +
|
|
2476
|
-
Math.pow(this.
|
|
2555
|
+
Math.pow(this.reading, 1.1), 1 / 1.1) * this.finalMultiplier);
|
|
2477
2556
|
}
|
|
2478
2557
|
handleOptions(options) {
|
|
2479
|
-
var _a, _b, _c
|
|
2558
|
+
var _a, _b, _c;
|
|
2480
2559
|
this._tapPenalty = (_a = options === null || options === void 0 ? void 0 : options.tapPenalty) !== null && _a !== void 0 ? _a : 1;
|
|
2481
2560
|
this._aimSliderCheesePenalty = (_b = options === null || options === void 0 ? void 0 : options.aimSliderCheesePenalty) !== null && _b !== void 0 ? _b : 1;
|
|
2482
2561
|
this._flashlightSliderCheesePenalty =
|
|
2483
2562
|
(_c = options === null || options === void 0 ? void 0 : options.flashlightSliderCheesePenalty) !== null && _c !== void 0 ? _c : 1;
|
|
2484
|
-
this._visualSliderCheesePenalty =
|
|
2485
|
-
(_d = options === null || options === void 0 ? void 0 : options.visualSliderCheesePenalty) !== null && _d !== void 0 ? _d : 1;
|
|
2486
2563
|
super.handleOptions(options);
|
|
2487
2564
|
}
|
|
2488
2565
|
/**
|
|
@@ -2541,6 +2618,7 @@ class DroidPerformanceCalculator extends PerformanceCalculator {
|
|
|
2541
2618
|
Math.exp((this._tapDeviation - 7500 / averageBPM) /
|
|
2542
2619
|
((2 * 300) / averageBPM))) +
|
|
2543
2620
|
Math.pow(this.difficultyAttributes.vibroFactor, 6);
|
|
2621
|
+
tapValue *= this.calculateTapHighDeviationNerf();
|
|
2544
2622
|
// Scale the tap value with three-fingered penalty.
|
|
2545
2623
|
tapValue /= this._tapPenalty;
|
|
2546
2624
|
// OD 8 SS stays the same.
|
|
@@ -2595,23 +2673,20 @@ class DroidPerformanceCalculator extends PerformanceCalculator {
|
|
|
2595
2673
|
return flashlightValue;
|
|
2596
2674
|
}
|
|
2597
2675
|
/**
|
|
2598
|
-
* Calculates the
|
|
2676
|
+
* Calculates the reading performance value of the beatmap.
|
|
2599
2677
|
*/
|
|
2600
|
-
|
|
2601
|
-
let
|
|
2602
|
-
|
|
2603
|
-
// Scale the
|
|
2604
|
-
// As
|
|
2605
|
-
|
|
2606
|
-
// Scale the
|
|
2607
|
-
|
|
2608
|
-
|
|
2609
|
-
visualValue *=
|
|
2610
|
-
1.05 *
|
|
2611
|
-
Math.pow(osuBase.ErrorFunction.erf(25 / (Math.SQRT2 * this._deviation)), 0.775);
|
|
2678
|
+
calculateReadingValue() {
|
|
2679
|
+
let readingValue = Math.pow(Math.pow(this.difficultyAttributes.readingDifficulty, 2) * 25, 0.8);
|
|
2680
|
+
readingValue *= Math.min(this.calculateStrainBasedMissPenalty(this.difficultyAttributes.readingDifficultNoteCount), this.proportionalMissPenalty);
|
|
2681
|
+
// Scale the reading value with estimated full combo deviation.
|
|
2682
|
+
// As reading is easily "bypassable" with memorization, punish for memorization.
|
|
2683
|
+
readingValue *= this.calculateDeviationBasedLengthScaling(undefined, true);
|
|
2684
|
+
// Scale the reading value with deviation.
|
|
2685
|
+
readingValue *=
|
|
2686
|
+
1.05 * osuBase.ErrorFunction.erf(25 / (Math.SQRT2 * this._deviation));
|
|
2612
2687
|
// OD 5 SS stays the same.
|
|
2613
|
-
|
|
2614
|
-
return
|
|
2688
|
+
readingValue *= 0.98 + Math.pow(5, 2) / 2500;
|
|
2689
|
+
return readingValue;
|
|
2615
2690
|
}
|
|
2616
2691
|
/**
|
|
2617
2692
|
* The object-based proportional miss penalty.
|
|
@@ -2660,7 +2735,7 @@ class DroidPerformanceCalculator extends PerformanceCalculator {
|
|
|
2660
2735
|
return multiplier;
|
|
2661
2736
|
}
|
|
2662
2737
|
/**
|
|
2663
|
-
* Estimates the player's
|
|
2738
|
+
* Estimates the player's deviation based on the OD, number of circles and sliders,
|
|
2664
2739
|
* and number of 300s, 100s, 50s, and misses, assuming the player's mean hit error is 0.
|
|
2665
2740
|
*
|
|
2666
2741
|
* The estimation is consistent in that two SS scores on the same map
|
|
@@ -2683,19 +2758,21 @@ class DroidPerformanceCalculator extends PerformanceCalculator {
|
|
|
2683
2758
|
const hitWindow100 = hitWindow.okWindow / clockRate;
|
|
2684
2759
|
const hitWindow50 = hitWindow.mehWindow / clockRate;
|
|
2685
2760
|
const { n100, n50, nmiss } = this.computedAccuracy;
|
|
2686
|
-
|
|
2687
|
-
|
|
2688
|
-
|
|
2689
|
-
|
|
2690
|
-
const
|
|
2761
|
+
let objectCount = this.difficultyAttributes.hitCircleCount;
|
|
2762
|
+
if (this.mods.has(osuBase.ModScoreV2)) {
|
|
2763
|
+
objectCount += this.difficultyAttributes.sliderCount;
|
|
2764
|
+
}
|
|
2765
|
+
const missCount = Math.min(nmiss, objectCount);
|
|
2766
|
+
const mehCount = Math.min(n50, objectCount - missCount);
|
|
2767
|
+
const okCount = Math.min(n100, objectCount - missCount - mehCount);
|
|
2768
|
+
const greatCount = Math.max(0, objectCount - missCount - mehCount - okCount);
|
|
2691
2769
|
// Assume 100s, 50s, and misses happen on circles. If there are less non-300s on circles than 300s,
|
|
2692
2770
|
// compute the deviation on circles.
|
|
2693
|
-
if (
|
|
2771
|
+
if (greatCount > 0) {
|
|
2694
2772
|
// The probability that a player hits a circle is unknown, but we can estimate it to be
|
|
2695
2773
|
// the number of greats on circles divided by the number of circles, and then add one
|
|
2696
2774
|
// to the number of circles as a bias correction.
|
|
2697
|
-
const greatProbabilityCircle =
|
|
2698
|
-
(circleCount - missCountCircles - mehCountCircles + 1);
|
|
2775
|
+
const greatProbabilityCircle = greatCount / (objectCount - missCount - mehCount + 1);
|
|
2699
2776
|
// Compute the deviation assuming 300s and 100s are normally distributed, and 50s are uniformly distributed.
|
|
2700
2777
|
// Begin with the normal distribution first.
|
|
2701
2778
|
let deviationOnCircles = hitWindow300 /
|
|
@@ -2714,17 +2791,16 @@ class DroidPerformanceCalculator extends PerformanceCalculator {
|
|
|
2714
2791
|
hitWindow100 * hitWindow100) /
|
|
2715
2792
|
3;
|
|
2716
2793
|
// Find the total deviation.
|
|
2717
|
-
deviationOnCircles = Math.sqrt(((
|
|
2718
|
-
|
|
2719
|
-
|
|
2720
|
-
(greatCountCircles + okCountCircles + mehCountCircles));
|
|
2794
|
+
deviationOnCircles = Math.sqrt(((greatCount + okCount) * Math.pow(deviationOnCircles, 2) +
|
|
2795
|
+
mehCount * mehVariance) /
|
|
2796
|
+
(greatCount + okCount + mehCount));
|
|
2721
2797
|
return deviationOnCircles;
|
|
2722
2798
|
}
|
|
2723
2799
|
// If there are more non-300s than there are circles, compute the deviation on sliders instead.
|
|
2724
2800
|
// Here, all that matters is whether or not the slider was missed, since it is impossible
|
|
2725
2801
|
// to get a 100 or 50 on a slider by mis-tapping it.
|
|
2726
2802
|
const sliderCount = this.difficultyAttributes.sliderCount;
|
|
2727
|
-
const missCountSliders = Math.min(sliderCount, nmiss -
|
|
2803
|
+
const missCountSliders = Math.min(sliderCount, nmiss - missCount);
|
|
2728
2804
|
const greatCountSliders = sliderCount - missCountSliders;
|
|
2729
2805
|
// We only get here if nothing was hit. In this case, there is no estimate for deviation.
|
|
2730
2806
|
// Note that this is never negative, so checking if this is only equal to 0 makes sense.
|
|
@@ -2745,7 +2821,7 @@ class DroidPerformanceCalculator extends PerformanceCalculator {
|
|
|
2745
2821
|
if (this.totalSuccessfulHits === 0) {
|
|
2746
2822
|
return Number.POSITIVE_INFINITY;
|
|
2747
2823
|
}
|
|
2748
|
-
const {
|
|
2824
|
+
const { clockRate, speedNoteCount } = this.difficultyAttributes;
|
|
2749
2825
|
const hitWindow = this.getConvertedHitWindow();
|
|
2750
2826
|
const hitWindow300 = hitWindow.greatWindow / clockRate;
|
|
2751
2827
|
const hitWindow100 = hitWindow.okWindow / clockRate;
|
|
@@ -2754,48 +2830,83 @@ class DroidPerformanceCalculator extends PerformanceCalculator {
|
|
|
2754
2830
|
// Assume a fixed ratio of non-300s hit in speed notes based on speed note count ratio and OD.
|
|
2755
2831
|
// Graph: https://www.desmos.com/calculator/iskvgjkxr4
|
|
2756
2832
|
const speedNoteRatio = speedNoteCount / this.totalHits;
|
|
2757
|
-
const nonGreatCount = n100 + n50 + nmiss;
|
|
2758
2833
|
const nonGreatRatio = 1 -
|
|
2759
2834
|
(Math.pow(Math.exp(Math.sqrt(hitWindow300)) + 1, 1 - speedNoteRatio) -
|
|
2760
2835
|
1) /
|
|
2761
2836
|
Math.exp(Math.sqrt(hitWindow300));
|
|
2762
|
-
|
|
2763
|
-
const
|
|
2764
|
-
const relevantCountMeh = n50 * nonGreatRatio;
|
|
2765
|
-
const
|
|
2766
|
-
|
|
2767
|
-
|
|
2768
|
-
|
|
2769
|
-
|
|
2770
|
-
|
|
2771
|
-
|
|
2772
|
-
const greatProbabilityCircle = relevantCountGreat /
|
|
2773
|
-
(speedNoteCount - relevantCountMiss - relevantCountMeh + 1);
|
|
2774
|
-
// Compute the deviation assuming 300s and 100s are normally distributed, and 50s are uniformly distributed.
|
|
2775
|
-
// Begin with the normal distribution first.
|
|
2776
|
-
let deviationOnCircles = hitWindow300 /
|
|
2777
|
-
(Math.SQRT2 * osuBase.ErrorFunction.erfInv(greatProbabilityCircle));
|
|
2778
|
-
deviationOnCircles *= Math.sqrt(1 -
|
|
2779
|
-
(Math.sqrt(2 / Math.PI) *
|
|
2780
|
-
hitWindow100 *
|
|
2781
|
-
Math.exp(-0.5 *
|
|
2782
|
-
Math.pow(hitWindow100 / deviationOnCircles, 2))) /
|
|
2783
|
-
(deviationOnCircles *
|
|
2784
|
-
osuBase.ErrorFunction.erf(hitWindow100 /
|
|
2785
|
-
(Math.SQRT2 * deviationOnCircles))));
|
|
2786
|
-
// Then compute the variance for 50s.
|
|
2787
|
-
const mehVariance = (hitWindow50 * hitWindow50 +
|
|
2788
|
-
hitWindow100 * hitWindow50 +
|
|
2789
|
-
hitWindow100 * hitWindow100) /
|
|
2790
|
-
3;
|
|
2791
|
-
// Find the total deviation.
|
|
2792
|
-
deviationOnCircles = Math.sqrt(((relevantCountGreat + relevantCountOk) *
|
|
2793
|
-
Math.pow(deviationOnCircles, 2) +
|
|
2794
|
-
relevantCountMeh * mehVariance) /
|
|
2795
|
-
(relevantCountGreat + relevantCountOk + relevantCountMeh));
|
|
2796
|
-
return deviationOnCircles;
|
|
2837
|
+
// Assume worst case - all non-300s happened in speed notes.
|
|
2838
|
+
const relevantCountMiss = Math.min(nmiss * nonGreatRatio, speedNoteCount);
|
|
2839
|
+
const relevantCountMeh = Math.min(n50 * nonGreatRatio, speedNoteCount - relevantCountMiss);
|
|
2840
|
+
const relevantCountOk = Math.min(n100 * nonGreatRatio, speedNoteCount - relevantCountMiss - relevantCountMeh);
|
|
2841
|
+
const relevantCountGreat = Math.max(0, speedNoteCount -
|
|
2842
|
+
relevantCountMiss -
|
|
2843
|
+
relevantCountMeh -
|
|
2844
|
+
relevantCountOk);
|
|
2845
|
+
if (relevantCountGreat + relevantCountOk + relevantCountMeh <= 0) {
|
|
2846
|
+
return Number.POSITIVE_INFINITY;
|
|
2797
2847
|
}
|
|
2798
|
-
|
|
2848
|
+
// The sample proportion of successful hits.
|
|
2849
|
+
const n = Math.max(1, relevantCountGreat + relevantCountOk);
|
|
2850
|
+
const p = relevantCountGreat / n;
|
|
2851
|
+
// 99% critical value for the normal distribution (one-tailed).
|
|
2852
|
+
const z = 2.32634787404;
|
|
2853
|
+
// We can be 99% confident that the population proportion is at least this value.
|
|
2854
|
+
const pLowerBound = Math.min(p, (n * p + Math.pow(z, 2) / 2) / (n + Math.pow(z, 2)) -
|
|
2855
|
+
(z / (n + Math.pow(z, 2))) *
|
|
2856
|
+
Math.sqrt(n * p * (1 - p) + Math.pow(z, 2) / 4));
|
|
2857
|
+
let deviation;
|
|
2858
|
+
// Tested maximum precision for the deviation calculation.
|
|
2859
|
+
if (pLowerBound > 0.01) {
|
|
2860
|
+
// Compute deviation assuming 300s and 109s are normally distributed.
|
|
2861
|
+
deviation =
|
|
2862
|
+
hitWindow300 / (Math.SQRT2 * osuBase.ErrorFunction.erfInv(pLowerBound));
|
|
2863
|
+
// Subtract the deviation provided by tails that land outside the 100 hit window from the deviation computed above.
|
|
2864
|
+
// This is equivalent to calculating the deviation of a normal distribution truncated at +-okHitWindow.
|
|
2865
|
+
const hitWindow100TailAmount = (Math.sqrt(2 / Math.PI) *
|
|
2866
|
+
hitWindow100 *
|
|
2867
|
+
Math.exp(-0.5 * Math.pow(hitWindow100 / deviation, 2))) /
|
|
2868
|
+
(deviation *
|
|
2869
|
+
osuBase.ErrorFunction.erf(hitWindow100 / (Math.SQRT2 * deviation)));
|
|
2870
|
+
deviation *= Math.sqrt(1 - hitWindow100TailAmount);
|
|
2871
|
+
}
|
|
2872
|
+
else {
|
|
2873
|
+
// A tested limit value for the case of a score only containing 100s.
|
|
2874
|
+
deviation = hitWindow100 / Math.sqrt(3);
|
|
2875
|
+
}
|
|
2876
|
+
// Compute and add the variance for 50s, assuming that they are uniformly distriubted.
|
|
2877
|
+
const mehVariance = (hitWindow50 * hitWindow50 +
|
|
2878
|
+
hitWindow100 * hitWindow50 +
|
|
2879
|
+
hitWindow100 * hitWindow100) /
|
|
2880
|
+
3;
|
|
2881
|
+
deviation = Math.sqrt(((relevantCountGreat + relevantCountOk) * Math.pow(deviation, 2) +
|
|
2882
|
+
relevantCountMeh * mehVariance) /
|
|
2883
|
+
(relevantCountGreat + relevantCountOk + relevantCountMeh));
|
|
2884
|
+
return deviation;
|
|
2885
|
+
}
|
|
2886
|
+
/**
|
|
2887
|
+
* Calculates a multiplier for tap to account for improper tapping based on the deviation and tap difficulty.
|
|
2888
|
+
*
|
|
2889
|
+
* [Graph](https://www.desmos.com/calculator/z5l9ebrwpi)
|
|
2890
|
+
*/
|
|
2891
|
+
calculateTapHighDeviationNerf() {
|
|
2892
|
+
if (this.tapDeviation == Number.POSITIVE_INFINITY) {
|
|
2893
|
+
return 0;
|
|
2894
|
+
}
|
|
2895
|
+
const tapValue = this.baseValue(this.difficultyAttributes.tapDifficulty);
|
|
2896
|
+
// Decide a point where the PP value achieved compared to the tap deviation is assumed to be tapped
|
|
2897
|
+
// improperly. Any PP above this point is considered "excess" tap difficulty. This is used to cause
|
|
2898
|
+
// PP above the cutoff to scale logarithmically towards the original tap value thus nerfing the value.
|
|
2899
|
+
const excessTapDifficultyCutoff = 100 + 250 * Math.pow(25 / this.tapDeviation, 6.5);
|
|
2900
|
+
if (tapValue <= excessTapDifficultyCutoff) {
|
|
2901
|
+
return 1;
|
|
2902
|
+
}
|
|
2903
|
+
const scale = 50;
|
|
2904
|
+
const adjustedTapValue = scale *
|
|
2905
|
+
(Math.log((tapValue - excessTapDifficultyCutoff) / scale + 1) +
|
|
2906
|
+
excessTapDifficultyCutoff / scale);
|
|
2907
|
+
// 250 UR and less are considered tapped correctly to ensure that normal scores will be punished as little as possible.
|
|
2908
|
+
const t = 1 - osuBase.Interpolation.reverseLerp(this.tapDeviation, 25, 30);
|
|
2909
|
+
return osuBase.Interpolation.lerp(adjustedTapValue, tapValue, t) / tapValue;
|
|
2799
2910
|
}
|
|
2800
2911
|
getConvertedHitWindow() {
|
|
2801
2912
|
const hitWindow300 = new osuBase.OsuHitWindow(this.difficultyAttributes.overallDifficulty).greatWindow;
|
|
@@ -2817,8 +2928,8 @@ class DroidPerformanceCalculator extends PerformanceCalculator {
|
|
|
2817
2928
|
" acc, " +
|
|
2818
2929
|
this.flashlight.toFixed(2) +
|
|
2819
2930
|
" flashlight, " +
|
|
2820
|
-
this.
|
|
2821
|
-
"
|
|
2931
|
+
this.reading.toFixed(2) +
|
|
2932
|
+
" reading)");
|
|
2822
2933
|
}
|
|
2823
2934
|
}
|
|
2824
2935
|
|
|
@@ -2836,19 +2947,10 @@ class OsuDifficultyHitObject extends DifficultyHitObject {
|
|
|
2836
2947
|
* The flashlight strain generated by this hitobject.
|
|
2837
2948
|
*/
|
|
2838
2949
|
this.flashlightStrain = 0;
|
|
2839
|
-
this.radiusBuffThreshold = 30;
|
|
2840
2950
|
this.mode = osuBase.Modes.osu;
|
|
2841
2951
|
}
|
|
2842
|
-
get
|
|
2843
|
-
|
|
2844
|
-
// We will scale distances by this factor, so we can assume a uniform CircleSize among beatmaps.
|
|
2845
|
-
let scalingFactor = DifficultyHitObject.normalizedRadius / radius;
|
|
2846
|
-
// High circle size (small CS) bonus
|
|
2847
|
-
if (radius < this.radiusBuffThreshold) {
|
|
2848
|
-
scalingFactor *=
|
|
2849
|
-
1 + Math.min(this.radiusBuffThreshold - radius, 5) / 50;
|
|
2850
|
-
}
|
|
2851
|
-
return scalingFactor;
|
|
2952
|
+
get smallCircleBonus() {
|
|
2953
|
+
return Math.max(1, 1 + (30 - this.object.radius) / 40);
|
|
2852
2954
|
}
|
|
2853
2955
|
}
|
|
2854
2956
|
|
|
@@ -3439,19 +3541,16 @@ class OsuSpeed extends OsuSkill {
|
|
|
3439
3541
|
this.currentSpeedStrain = 0;
|
|
3440
3542
|
this.currentRhythm = 0;
|
|
3441
3543
|
this.skillMultiplier = 1.46;
|
|
3544
|
+
this.maxStrain = 0;
|
|
3442
3545
|
}
|
|
3443
3546
|
/**
|
|
3444
3547
|
* The amount of notes that are relevant to the difficulty.
|
|
3445
3548
|
*/
|
|
3446
3549
|
relevantNoteCount() {
|
|
3447
|
-
if (this._objectStrains.length === 0) {
|
|
3448
|
-
return 0;
|
|
3449
|
-
}
|
|
3450
|
-
const maxStrain = osuBase.MathUtils.max(this._objectStrains);
|
|
3451
|
-
if (maxStrain === 0) {
|
|
3550
|
+
if (this._objectStrains.length === 0 || this.maxStrain === 0) {
|
|
3452
3551
|
return 0;
|
|
3453
3552
|
}
|
|
3454
|
-
return this._objectStrains.reduce((total, next) => total + 1 / (1 + Math.exp(-((next / maxStrain) * 12 - 6))), 0);
|
|
3553
|
+
return this._objectStrains.reduce((total, next) => total + 1 / (1 + Math.exp(-((next / this.maxStrain) * 12 - 6))), 0);
|
|
3455
3554
|
}
|
|
3456
3555
|
/**
|
|
3457
3556
|
* @param current The hitobject to calculate.
|
|
@@ -3464,6 +3563,7 @@ class OsuSpeed extends OsuSkill {
|
|
|
3464
3563
|
this.currentRhythm = OsuRhythmEvaluator.evaluateDifficultyOf(current);
|
|
3465
3564
|
const strain = this.currentSpeedStrain * this.currentRhythm;
|
|
3466
3565
|
this._objectStrains.push(strain);
|
|
3566
|
+
this.maxStrain = Math.max(this.maxStrain, strain);
|
|
3467
3567
|
return strain;
|
|
3468
3568
|
}
|
|
3469
3569
|
calculateInitialStrain(time, current) {
|
|
@@ -3549,7 +3649,7 @@ class OsuDifficultyCalculator extends DifficultyCalculator {
|
|
|
3549
3649
|
const { objects } = beatmap.hitObjects;
|
|
3550
3650
|
for (let i = 1; i < objects.length; ++i) {
|
|
3551
3651
|
const difficultyObject = new OsuDifficultyHitObject(objects[i], objects[i - 1], difficultyObjects, clockRate, i - 1);
|
|
3552
|
-
difficultyObject.computeProperties(clockRate
|
|
3652
|
+
difficultyObject.computeProperties(clockRate);
|
|
3553
3653
|
difficultyObjects.push(difficultyObject);
|
|
3554
3654
|
}
|
|
3555
3655
|
return difficultyObjects;
|
|
@@ -3586,7 +3686,7 @@ class OsuDifficultyCalculator extends DifficultyCalculator {
|
|
|
3586
3686
|
}
|
|
3587
3687
|
attributes.aimDifficulty = this.calculateRating(aim);
|
|
3588
3688
|
attributes.aimDifficultSliderCount = aim.countDifficultSliders();
|
|
3589
|
-
attributes.aimDifficultStrainCount = aim.
|
|
3689
|
+
attributes.aimDifficultStrainCount = aim.countTopWeightedStrains();
|
|
3590
3690
|
if (attributes.aimDifficulty > 0) {
|
|
3591
3691
|
attributes.sliderFactor =
|
|
3592
3692
|
this.calculateRating(aimNoSlider) / attributes.aimDifficulty;
|
|
@@ -3602,7 +3702,7 @@ class OsuDifficultyCalculator extends DifficultyCalculator {
|
|
|
3602
3702
|
}
|
|
3603
3703
|
attributes.speedDifficulty = this.calculateRating(speed);
|
|
3604
3704
|
attributes.speedNoteCount = speed.relevantNoteCount();
|
|
3605
|
-
attributes.speedDifficultStrainCount = speed.
|
|
3705
|
+
attributes.speedDifficultStrainCount = speed.countTopWeightedStrains();
|
|
3606
3706
|
}
|
|
3607
3707
|
populateFlashlightAttributes(attributes, skills) {
|
|
3608
3708
|
const flashlight = skills.find((s) => s instanceof OsuFlashlight);
|
|
@@ -3953,12 +4053,12 @@ exports.DroidDifficultyHitObject = DroidDifficultyHitObject;
|
|
|
3953
4053
|
exports.DroidFlashlight = DroidFlashlight;
|
|
3954
4054
|
exports.DroidFlashlightEvaluator = DroidFlashlightEvaluator;
|
|
3955
4055
|
exports.DroidPerformanceCalculator = DroidPerformanceCalculator;
|
|
4056
|
+
exports.DroidReading = DroidReading;
|
|
4057
|
+
exports.DroidReadingEvaluator = DroidReadingEvaluator;
|
|
3956
4058
|
exports.DroidRhythm = DroidRhythm;
|
|
3957
4059
|
exports.DroidRhythmEvaluator = DroidRhythmEvaluator;
|
|
3958
4060
|
exports.DroidTap = DroidTap;
|
|
3959
4061
|
exports.DroidTapEvaluator = DroidTapEvaluator;
|
|
3960
|
-
exports.DroidVisual = DroidVisual;
|
|
3961
|
-
exports.DroidVisualEvaluator = DroidVisualEvaluator;
|
|
3962
4062
|
exports.ExtendedDroidDifficultyAttributes = ExtendedDroidDifficultyAttributes;
|
|
3963
4063
|
exports.OsuAim = OsuAim;
|
|
3964
4064
|
exports.OsuAimEvaluator = OsuAimEvaluator;
|