@rian8337/osu-difficulty-calculator 4.0.0-beta.48 → 4.0.0-beta.49

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 CHANGED
@@ -27,6 +27,7 @@ AimEvaluator.wideAngleMultiplier = 1.5;
27
27
  AimEvaluator.acuteAngleMultiplier = 1.95;
28
28
  AimEvaluator.sliderMultiplier = 1.35;
29
29
  AimEvaluator.velocityChangeMultiplier = 0.75;
30
+ AimEvaluator.wiggleMultiplier = 1.02;
30
31
 
31
32
  /**
32
33
  * The base of a difficulty calculator.
@@ -175,6 +176,12 @@ class DifficultyCalculator {
175
176
  * Represents a hit object with difficulty calculation values.
176
177
  */
177
178
  class DifficultyHitObject {
179
+ /**
180
+ * The normalized diameter of the hitobject.
181
+ */
182
+ static get normalizedDiameter() {
183
+ return this.normalizedRadius * 2;
184
+ }
178
185
  /**
179
186
  * Note: You **must** call `computeProperties` at some point due to how TypeScript handles
180
187
  * overridden properties (see [this](https://github.com/microsoft/TypeScript/issues/1617) GitHub issue).
@@ -236,9 +243,8 @@ class DifficultyHitObject {
236
243
  * Calculated as the angle between the circles (current-2, current-1, current).
237
244
  */
238
245
  this.angle = null;
239
- this.normalizedRadius = 50;
240
- this.maximumSliderRadius = this.normalizedRadius * 2.4;
241
- this.assumedSliderRadius = this.normalizedRadius * 1.8;
246
+ this.maximumSliderRadius = DifficultyHitObject.normalizedRadius * 2.4;
247
+ this.assumedSliderRadius = DifficultyHitObject.normalizedRadius * 1.8;
242
248
  this.object = object;
243
249
  this.lastObject = lastObject;
244
250
  this.lastLastObject = lastLastObject;
@@ -442,7 +448,7 @@ class DifficultyHitObject {
442
448
  .getStackedPosition(this.mode)
443
449
  .add(slider.path.positionAt(endTimeMin));
444
450
  let currentCursorPosition = slider.getStackedPosition(this.mode);
445
- const scalingFactor = this.normalizedRadius / slider.radius;
451
+ const scalingFactor = DifficultyHitObject.normalizedRadius / slider.radius;
446
452
  for (let i = 1; i < slider.nestedHitObjects.length; ++i) {
447
453
  const currentMovementObject = slider.nestedHitObjects[i];
448
454
  let currentMovement = currentMovementObject
@@ -464,7 +470,7 @@ class DifficultyHitObject {
464
470
  }
465
471
  else if (currentMovementObject instanceof osuBase.SliderRepeat) {
466
472
  // For a slider repeat, assume a tighter movement threshold to better assess repeat sliders.
467
- requiredMovement = this.normalizedRadius;
473
+ requiredMovement = DifficultyHitObject.normalizedRadius;
468
474
  }
469
475
  if (currentMovementLength > requiredMovement) {
470
476
  // This finds the positional delta from the required radius and the current position,
@@ -491,11 +497,211 @@ class DifficultyHitObject {
491
497
  return pos;
492
498
  }
493
499
  }
500
+ /**
501
+ * The normalized radius of the hitobject.
502
+ */
503
+ DifficultyHitObject.normalizedRadius = 50;
494
504
  /**
495
505
  * The lowest possible delta time value.
496
506
  */
497
507
  DifficultyHitObject.minDeltaTime = 25;
498
508
 
509
+ /**
510
+ * Represents an osu!droid hit object with difficulty calculation values.
511
+ */
512
+ class DroidDifficultyHitObject extends DifficultyHitObject {
513
+ get scalingFactor() {
514
+ const radius = this.object.radius;
515
+ // We will scale distances by this factor, so we can assume a uniform CircleSize among beatmaps.
516
+ let scalingFactor = DifficultyHitObject.normalizedRadius / radius;
517
+ // High circle size (small CS) bonus
518
+ if (radius < this.radiusBuffThreshold) {
519
+ scalingFactor *=
520
+ 1 + Math.pow((this.radiusBuffThreshold - radius) / 50, 2);
521
+ }
522
+ return scalingFactor;
523
+ }
524
+ /**
525
+ * Note: You **must** call `computeProperties` at some point due to how TypeScript handles
526
+ * overridden properties (see [this](https://github.com/microsoft/TypeScript/issues/1617) GitHub issue).
527
+ *
528
+ * @param object The underlying hitobject.
529
+ * @param lastObject The hitobject before this hitobject.
530
+ * @param lastLastObject The hitobject before the last hitobject.
531
+ * @param difficultyHitObjects All difficulty hitobjects in the processed beatmap.
532
+ * @param clockRate The clock rate of the beatmap.
533
+ */
534
+ constructor(object, lastObject, lastLastObject, difficultyHitObjects, clockRate) {
535
+ super(object, lastObject, lastLastObject, difficultyHitObjects, clockRate);
536
+ /**
537
+ * The tap strain generated by the hitobject.
538
+ */
539
+ this.tapStrain = 0;
540
+ /**
541
+ * The tap strain generated by the hitobject if `strainTime` isn't modified by
542
+ * OD. This is used in three-finger detection.
543
+ */
544
+ this.originalTapStrain = 0;
545
+ /**
546
+ * The rhythm strain generated by the hitobject.
547
+ */
548
+ this.rhythmStrain = 0;
549
+ /**
550
+ * The flashlight strain generated by the hitobject if sliders are considered.
551
+ */
552
+ this.flashlightStrainWithSliders = 0;
553
+ /**
554
+ * The flashlight strain generated by the hitobject if sliders are not considered.
555
+ */
556
+ this.flashlightStrainWithoutSliders = 0;
557
+ /**
558
+ * The visual strain generated by the hitobject if sliders are considered.
559
+ */
560
+ this.visualStrainWithSliders = 0;
561
+ /**
562
+ * The visual strain generated by the hitobject if sliders are not considered.
563
+ */
564
+ this.visualStrainWithoutSliders = 0;
565
+ /**
566
+ * The note density of the hitobject.
567
+ */
568
+ this.noteDensity = 1;
569
+ /**
570
+ * The overlapping factor of the hitobject.
571
+ *
572
+ * This is used to scale visual skill.
573
+ */
574
+ this.overlappingFactor = 0;
575
+ this.radiusBuffThreshold = 70;
576
+ this.mode = osuBase.Modes.droid;
577
+ this.maximumSliderRadius = DifficultyHitObject.normalizedRadius * 2;
578
+ this.timePreempt = object.timePreempt / clockRate;
579
+ }
580
+ computeProperties(clockRate, hitObjects) {
581
+ super.computeProperties(clockRate, hitObjects);
582
+ this.setVisuals(clockRate, hitObjects);
583
+ }
584
+ /**
585
+ * Determines whether this hitobject is considered overlapping with the hitobject before it.
586
+ *
587
+ * Keep in mind that "overlapping" in this case is overlapping to the point where both hitobjects
588
+ * can be hit with just a single tap in osu!droid.
589
+ *
590
+ * In the case of sliders, it is considered overlapping if all nested hitobjects can be hit with
591
+ * one aim motion.
592
+ *
593
+ * @param considerDistance Whether to consider the distance between both hitobjects.
594
+ * @returns Whether the hitobject is considered overlapping.
595
+ */
596
+ isOverlapping(considerDistance) {
597
+ if (this.object instanceof osuBase.Spinner) {
598
+ return false;
599
+ }
600
+ const prev = this.previous(0);
601
+ if (!prev || prev.object instanceof osuBase.Spinner) {
602
+ return false;
603
+ }
604
+ if (this.object.startTime !== prev.object.startTime) {
605
+ return false;
606
+ }
607
+ if (!considerDistance) {
608
+ return true;
609
+ }
610
+ const distanceThreshold = 2 * this.object.radius;
611
+ const startPosition = this.object.getStackedPosition(osuBase.Modes.droid);
612
+ const prevStartPosition = prev.object.getStackedPosition(osuBase.Modes.droid);
613
+ // We need to consider two cases:
614
+ //
615
+ // Case 1: Current object is a circle, or previous object is a circle.
616
+ // In this case, we only need to check if their positions are close enough to be tapped together.
617
+ //
618
+ // Case 2: Both objects are sliders.
619
+ // In this case, we need to check if all nested hitobjects can be hit together.
620
+ // To start with, check if the starting positions can be tapped together.
621
+ if (startPosition.getDistance(prevStartPosition) > distanceThreshold) {
622
+ return false;
623
+ }
624
+ if (this.object instanceof osuBase.Circle || prev.object instanceof osuBase.Circle) {
625
+ return true;
626
+ }
627
+ // Check if all nested hitobjects can be hit together.
628
+ for (let i = 1; i < this.object.nestedHitObjects.length; ++i) {
629
+ const position = this.object.nestedHitObjects[i].getStackedPosition(osuBase.Modes.droid);
630
+ const prevPosition = prevStartPosition.add(prev.object.curvePositionAt(i / (this.object.nestedHitObjects.length - 1)));
631
+ if (position.getDistance(prevPosition) > distanceThreshold) {
632
+ return false;
633
+ }
634
+ }
635
+ // Do the same for the previous slider as well.
636
+ for (let i = 1; i < prev.object.nestedHitObjects.length; ++i) {
637
+ const prevPosition = prev.object.nestedHitObjects[i].getStackedPosition(osuBase.Modes.droid);
638
+ const position = startPosition.add(this.object.curvePositionAt(i / (prev.object.nestedHitObjects.length - 1)));
639
+ if (prevPosition.getDistance(position) > distanceThreshold) {
640
+ return false;
641
+ }
642
+ }
643
+ return true;
644
+ }
645
+ setVisuals(clockRate, hitObjects) {
646
+ // We'll have two visible object arrays. The first array contains objects before the current object starts in a reversed order,
647
+ // while the second array contains objects after the current object ends.
648
+ // For overlapping factor, we also need to consider previous visible objects.
649
+ const prevVisibleObjects = [];
650
+ const nextVisibleObjects = [];
651
+ for (let j = this.index + 2; j < hitObjects.length; ++j) {
652
+ const o = hitObjects[j];
653
+ if (o instanceof osuBase.Spinner) {
654
+ continue;
655
+ }
656
+ if (o.startTime / clockRate > this.endTime + this.timePreempt) {
657
+ break;
658
+ }
659
+ nextVisibleObjects.push(o);
660
+ }
661
+ for (let j = 0; j < this.index; ++j) {
662
+ const prev = this.previous(j);
663
+ if (prev.object instanceof osuBase.Spinner) {
664
+ continue;
665
+ }
666
+ if (prev.startTime >= this.startTime) {
667
+ continue;
668
+ }
669
+ if (prev.startTime < this.startTime - this.timePreempt) {
670
+ break;
671
+ }
672
+ prevVisibleObjects.push(prev.object);
673
+ }
674
+ for (const hitObject of prevVisibleObjects) {
675
+ const distance = this.object
676
+ .getStackedPosition(this.mode)
677
+ .getDistance(hitObject.getStackedEndPosition(this.mode));
678
+ const deltaTime = this.startTime - hitObject.endTime / clockRate;
679
+ this.applyToOverlappingFactor(distance, deltaTime);
680
+ }
681
+ for (const hitObject of nextVisibleObjects) {
682
+ const distance = hitObject
683
+ .getStackedPosition(this.mode)
684
+ .getDistance(this.object.getStackedEndPosition(this.mode));
685
+ const deltaTime = hitObject.startTime / clockRate - this.endTime;
686
+ if (deltaTime >= 0) {
687
+ this.noteDensity += 1 - deltaTime / this.timePreempt;
688
+ }
689
+ this.applyToOverlappingFactor(distance, deltaTime);
690
+ }
691
+ }
692
+ applyToOverlappingFactor(distance, deltaTime) {
693
+ // Penalize objects that are too close to the object in both distance
694
+ // and delta time to prevent stream maps from being overweighted.
695
+ this.overlappingFactor +=
696
+ Math.max(0, 1 - distance / (2.5 * this.object.radius)) *
697
+ (7.5 /
698
+ (1 +
699
+ Math.exp(0.15 *
700
+ (Math.max(deltaTime, DifficultyHitObject.minDeltaTime) -
701
+ 75))));
702
+ }
703
+ }
704
+
499
705
  /**
500
706
  * An evaluator for calculating osu!droid Aim skill.
501
707
  */
@@ -531,6 +737,8 @@ class DroidAimEvaluator extends AimEvaluator {
531
737
  }
532
738
  const last = current.previous(0);
533
739
  const lastLast = current.previous(1);
740
+ const radius = DroidDifficultyHitObject.normalizedRadius;
741
+ const diameter = DroidDifficultyHitObject.normalizedDiameter;
534
742
  // Calculate the velocity to the current hitobject, which starts with a base distance / time assuming the last object is a hitcircle.
535
743
  let currentVelocity = current.lazyJumpDistance / current.strainTime;
536
744
  // But if the last object is a slider, then we extend the travel velocity through the slider into the current object.
@@ -557,6 +765,7 @@ class DroidAimEvaluator extends AimEvaluator {
557
765
  let acuteAngleBonus = 0;
558
766
  let sliderBonus = 0;
559
767
  let velocityChangeBonus = 0;
768
+ let wiggleBonus = 0;
560
769
  // Start strain with regular velocity.
561
770
  let strain = currentVelocity;
562
771
  if (
@@ -566,40 +775,40 @@ class DroidAimEvaluator extends AimEvaluator {
566
775
  current.angle !== null &&
567
776
  last.angle !== null &&
568
777
  lastLast.angle !== null) {
778
+ const currentAngle = current.angle;
779
+ const lastAngle = last.angle;
569
780
  // Rewarding angles, take the smaller velocity as base.
570
781
  const angleBonus = Math.min(currentVelocity, prevVelocity);
571
782
  wideAngleBonus = this.calculateWideAngleBonus(current.angle);
572
783
  acuteAngleBonus = this.calculateAcuteAngleBonus(current.angle);
573
- // Only buff deltaTime exceeding 300 BPM 1/2.
574
- if (current.strainTime > 100) {
575
- acuteAngleBonus = 0;
576
- }
577
- else {
578
- acuteAngleBonus *=
579
- // Multiply by previous angle, we don't want to buff unless this is a wiggle type pattern.
580
- this.calculateAcuteAngleBonus(last.angle) *
581
- // The maximum velocity we buff is equal to 125 / strainTime.
582
- Math.min(angleBonus, 125 / current.strainTime) *
583
- // Scale buff from 300 BPM 1/2 to 400 BPM 1/2.
584
- Math.pow(Math.sin((Math.PI / 2) *
585
- Math.min(1, (100 - current.strainTime) / 25)), 2) *
586
- // Buff distance exceeding 50 (radius) up to 100 (diameter).
587
- Math.pow(Math.sin(((Math.PI / 2) *
588
- (osuBase.MathUtils.clamp(current.lazyJumpDistance, 50, 100) -
589
- 50)) /
590
- 50), 2);
591
- }
592
- // Penalize wide angles if they're repeated, reducing the penalty as last.angle gets more acute.
784
+ // Penalize angle repetition.
593
785
  wideAngleBonus *=
594
- angleBonus *
595
- (1 -
596
- Math.min(wideAngleBonus, Math.pow(this.calculateWideAngleBonus(last.angle), 3)));
597
- // Penalize acute angles if they're repeated, reducing the penalty as lastLast.angle gets more obtuse.
786
+ 1 -
787
+ Math.min(wideAngleBonus, Math.pow(this.calculateWideAngleBonus(lastAngle), 3));
598
788
  acuteAngleBonus *=
599
- 0.5 +
600
- 0.5 *
789
+ 0.08 +
790
+ 0.92 *
601
791
  (1 -
602
- Math.min(acuteAngleBonus, Math.pow(this.calculateAcuteAngleBonus(lastLast.angle), 3)));
792
+ Math.min(acuteAngleBonus, Math.pow(this.calculateAcuteAngleBonus(lastAngle), 3)));
793
+ // Apply full wide angle bonus for distance more than one diameter
794
+ wideAngleBonus *=
795
+ angleBonus *
796
+ osuBase.MathUtils.smootherstep(current.lazyJumpDistance, 0, diameter);
797
+ // Apply acute angle bonus for BPM above 300 1/2 and distance more than one diameter
798
+ acuteAngleBonus *=
799
+ angleBonus *
800
+ osuBase.MathUtils.smootherstep(osuBase.MathUtils.millisecondsToBPM(current.strainTime, 2), 300, 400) *
801
+ osuBase.MathUtils.smootherstep(current.lazyJumpDistance, diameter, diameter * 2);
802
+ // Apply wiggle bonus for jumps that are [radius, 3*diameter] in distance, with < 110 angle
803
+ // https://www.desmos.com/calculator/dp0v0nvowc
804
+ wiggleBonus =
805
+ angleBonus *
806
+ osuBase.MathUtils.smootherstep(current.lazyJumpDistance, radius, diameter) *
807
+ Math.pow(osuBase.MathUtils.reverseLerp(current.lazyJumpDistance, diameter * 3, diameter), 1.8) *
808
+ osuBase.MathUtils.smootherstep(currentAngle, osuBase.MathUtils.degreesToRadians(110), osuBase.MathUtils.degreesToRadians(60)) *
809
+ osuBase.MathUtils.smootherstep(last.lazyJumpDistance, radius, diameter) *
810
+ Math.pow(osuBase.MathUtils.reverseLerp(last.lazyJumpDistance, diameter * 3, diameter), 1.8) *
811
+ osuBase.MathUtils.smootherstep(lastAngle, osuBase.MathUtils.degreesToRadians(110), osuBase.MathUtils.degreesToRadians(60));
603
812
  }
604
813
  if (Math.max(prevVelocity, currentVelocity)) {
605
814
  // We want to use the average velocity over the whole object when awarding differences, not the individual jump and slider path velocities.
@@ -623,6 +832,7 @@ class DroidAimEvaluator extends AimEvaluator {
623
832
  // Reward sliders based on velocity.
624
833
  sliderBonus = last.travelDistance / last.travelTime;
625
834
  }
835
+ strain += wiggleBonus * this.wiggleMultiplier;
626
836
  // Add in acute angle bonus or wide angle bonus + velocity change bonus, whichever is larger.
627
837
  strain += Math.max(acuteAngleBonus * this.acuteAngleMultiplier, wideAngleBonus * this.wideAngleMultiplier +
628
838
  velocityChangeBonus * this.velocityChangeMultiplier);
@@ -648,10 +858,17 @@ class DroidAimEvaluator extends AimEvaluator {
648
858
  const shortDistancePenalty = Math.pow(Math.min(this.singleSpacingThreshold, travelDistance + current.minimumJumpDistance) / this.singleSpacingThreshold, 3.5);
649
859
  return (200 * speedBonus * shortDistancePenalty) / current.strainTime;
650
860
  }
861
+ static calculateWideAngleBonus(angle) {
862
+ return osuBase.MathUtils.smoothstep(angle, osuBase.MathUtils.degreesToRadians(40), osuBase.MathUtils.degreesToRadians(140));
863
+ }
864
+ static calculateAcuteAngleBonus(angle) {
865
+ return osuBase.MathUtils.smoothstep(angle, osuBase.MathUtils.degreesToRadians(140), osuBase.MathUtils.degreesToRadians(40));
866
+ }
651
867
  }
652
- DroidAimEvaluator.wideAngleMultiplier = 1.65;
653
- DroidAimEvaluator.sliderMultiplier = 1.5;
654
- DroidAimEvaluator.velocityChangeMultiplier = 0.85;
868
+ DroidAimEvaluator.wideAngleMultiplier = 1.5;
869
+ DroidAimEvaluator.acuteAngleMultiplier = 2.6;
870
+ DroidAimEvaluator.sliderMultiplier = 1.35;
871
+ DroidAimEvaluator.velocityChangeMultiplier = 0.75;
655
872
  DroidAimEvaluator.singleSpacingThreshold = 100;
656
873
  // 200 1/4 BPM delta time
657
874
  DroidAimEvaluator.minSpeedBonus = 75;
@@ -805,15 +1022,33 @@ class DroidAim extends DroidSkill {
805
1022
  this.reducedSectionCount = 10;
806
1023
  this.reducedSectionBaseline = 0.75;
807
1024
  this.starsPerDouble = 1.05;
808
- this.skillMultiplier = 24.55;
1025
+ this.skillMultiplier = 25.6;
809
1026
  this.currentAimStrain = 0;
1027
+ this.sliderStrains = [];
810
1028
  this.withSliders = withSliders;
811
1029
  }
1030
+ /**
1031
+ * Obtains the amount of sliders that are considered difficult in terms of relative strain.
1032
+ */
1033
+ countDifficultSliders() {
1034
+ if (this.sliderStrains.length === 0) {
1035
+ return 0;
1036
+ }
1037
+ const maxSliderStrain = Math.max(...this.sliderStrains);
1038
+ if (maxSliderStrain === 0) {
1039
+ return 0;
1040
+ }
1041
+ return this.sliderStrains.reduce((total, strain) => total +
1042
+ 1 / (1 + Math.exp(-((strain / maxSliderStrain) * 12 - 6))), 0);
1043
+ }
812
1044
  strainValueAt(current) {
813
1045
  this.currentAimStrain *= this.strainDecay(current.deltaTime);
814
1046
  this.currentAimStrain +=
815
1047
  DroidAimEvaluator.evaluateDifficultyOf(current, this.withSliders) *
816
1048
  this.skillMultiplier;
1049
+ if (current.object instanceof osuBase.Slider) {
1050
+ this.sliderStrains.push(this.currentAimStrain);
1051
+ }
817
1052
  return this.currentAimStrain;
818
1053
  }
819
1054
  calculateInitialStrain(time, current) {
@@ -884,7 +1119,7 @@ class DroidTapEvaluator extends SpeedEvaluator {
884
1119
  0.75 *
885
1120
  Math.pow(osuBase.ErrorFunction.erf((this.minSpeedBonus - strainTime) / 40), 2);
886
1121
  }
887
- return (speedBonus * Math.pow(doubletapness, 1.5)) / strainTime;
1122
+ return (speedBonus * Math.pow(doubletapness, 1.5) * 1000) / strainTime;
888
1123
  }
889
1124
  }
890
1125
 
@@ -906,7 +1141,7 @@ class DroidTap extends DroidSkill {
906
1141
  this.starsPerDouble = 1.1;
907
1142
  this.currentTapStrain = 0;
908
1143
  this.currentRhythmMultiplier = 0;
909
- this.skillMultiplier = 1375;
1144
+ this.skillMultiplier = 1.375;
910
1145
  this._objectDeltaTimes = [];
911
1146
  this.considerCheesability = considerCheesability;
912
1147
  this.strainTimeCap = strainTimeCap;
@@ -1021,13 +1256,13 @@ class DroidFlashlightEvaluator extends FlashlightEvaluator {
1021
1256
  let angleRepeatCount = 0;
1022
1257
  for (let i = 0; i < Math.min(current.index, 10); ++i) {
1023
1258
  const currentObject = current.previous(i);
1259
+ cumulativeStrainTime += last.strainTime;
1024
1260
  if (!(currentObject.object instanceof osuBase.Spinner) &&
1025
1261
  // Exclude overlapping objects that can be tapped at once.
1026
1262
  !currentObject.isOverlapping(false)) {
1027
1263
  const jumpDistance = current.object
1028
1264
  .getStackedPosition(osuBase.Modes.droid)
1029
1265
  .subtract(currentObject.object.getStackedEndPosition(osuBase.Modes.droid)).length;
1030
- cumulativeStrainTime += last.strainTime;
1031
1266
  // We want to nerf objects that can be easily seen within the Flashlight circle radius.
1032
1267
  if (i === 0) {
1033
1268
  smallDistNerf = Math.min(1, jumpDistance / 75);
@@ -1263,7 +1498,7 @@ class DroidRhythmEvaluator {
1263
1498
  }
1264
1499
  // Repeated island (ex: triplet -> triplet).
1265
1500
  // Graph: https://www.desmos.com/calculator/pj7an56zwf
1266
- effectiveRatio *= Math.min(3 / islandCount, Math.pow(1 / islandCount, 2.75 / (1 + Math.exp(14 - 0.24 * island.delta))));
1501
+ effectiveRatio *= Math.min(3 / islandCount, Math.pow(1 / islandCount, osuBase.MathUtils.offsetLogistic(island.delta, 58.33, 0.24, 2.75)));
1267
1502
  break;
1268
1503
  }
1269
1504
  if (!islandFound) {
@@ -1487,202 +1722,6 @@ class DroidVisual extends DroidSkill {
1487
1722
  }
1488
1723
  }
1489
1724
 
1490
- /**
1491
- * Represents an osu!droid hit object with difficulty calculation values.
1492
- */
1493
- class DroidDifficultyHitObject extends DifficultyHitObject {
1494
- get scalingFactor() {
1495
- const radius = this.object.radius;
1496
- // We will scale distances by this factor, so we can assume a uniform CircleSize among beatmaps.
1497
- let scalingFactor = this.normalizedRadius / radius;
1498
- // High circle size (small CS) bonus
1499
- if (radius < this.radiusBuffThreshold) {
1500
- scalingFactor *=
1501
- 1 + Math.pow((this.radiusBuffThreshold - radius) / 50, 2);
1502
- }
1503
- return scalingFactor;
1504
- }
1505
- /**
1506
- * Note: You **must** call `computeProperties` at some point due to how TypeScript handles
1507
- * overridden properties (see [this](https://github.com/microsoft/TypeScript/issues/1617) GitHub issue).
1508
- *
1509
- * @param object The underlying hitobject.
1510
- * @param lastObject The hitobject before this hitobject.
1511
- * @param lastLastObject The hitobject before the last hitobject.
1512
- * @param difficultyHitObjects All difficulty hitobjects in the processed beatmap.
1513
- * @param clockRate The clock rate of the beatmap.
1514
- */
1515
- constructor(object, lastObject, lastLastObject, difficultyHitObjects, clockRate) {
1516
- super(object, lastObject, lastLastObject, difficultyHitObjects, clockRate);
1517
- /**
1518
- * The tap strain generated by the hitobject.
1519
- */
1520
- this.tapStrain = 0;
1521
- /**
1522
- * The tap strain generated by the hitobject if `strainTime` isn't modified by
1523
- * OD. This is used in three-finger detection.
1524
- */
1525
- this.originalTapStrain = 0;
1526
- /**
1527
- * The rhythm strain generated by the hitobject.
1528
- */
1529
- this.rhythmStrain = 0;
1530
- /**
1531
- * The flashlight strain generated by the hitobject if sliders are considered.
1532
- */
1533
- this.flashlightStrainWithSliders = 0;
1534
- /**
1535
- * The flashlight strain generated by the hitobject if sliders are not considered.
1536
- */
1537
- this.flashlightStrainWithoutSliders = 0;
1538
- /**
1539
- * The visual strain generated by the hitobject if sliders are considered.
1540
- */
1541
- this.visualStrainWithSliders = 0;
1542
- /**
1543
- * The visual strain generated by the hitobject if sliders are not considered.
1544
- */
1545
- this.visualStrainWithoutSliders = 0;
1546
- /**
1547
- * The note density of the hitobject.
1548
- */
1549
- this.noteDensity = 1;
1550
- /**
1551
- * The overlapping factor of the hitobject.
1552
- *
1553
- * This is used to scale visual skill.
1554
- */
1555
- this.overlappingFactor = 0;
1556
- this.radiusBuffThreshold = 70;
1557
- this.mode = osuBase.Modes.droid;
1558
- this.maximumSliderRadius = this.normalizedRadius * 2;
1559
- this.timePreempt = object.timePreempt / clockRate;
1560
- }
1561
- computeProperties(clockRate, hitObjects) {
1562
- super.computeProperties(clockRate, hitObjects);
1563
- this.setVisuals(clockRate, hitObjects);
1564
- }
1565
- /**
1566
- * Determines whether this hitobject is considered overlapping with the hitobject before it.
1567
- *
1568
- * Keep in mind that "overlapping" in this case is overlapping to the point where both hitobjects
1569
- * can be hit with just a single tap in osu!droid.
1570
- *
1571
- * In the case of sliders, it is considered overlapping if all nested hitobjects can be hit with
1572
- * one aim motion.
1573
- *
1574
- * @param considerDistance Whether to consider the distance between both hitobjects.
1575
- * @returns Whether the hitobject is considered overlapping.
1576
- */
1577
- isOverlapping(considerDistance) {
1578
- if (this.object instanceof osuBase.Spinner) {
1579
- return false;
1580
- }
1581
- const prev = this.previous(0);
1582
- if (!prev || prev.object instanceof osuBase.Spinner) {
1583
- return false;
1584
- }
1585
- if (this.object.startTime !== prev.object.startTime) {
1586
- return false;
1587
- }
1588
- if (!considerDistance) {
1589
- return true;
1590
- }
1591
- const distanceThreshold = 2 * this.object.radius;
1592
- const startPosition = this.object.getStackedPosition(osuBase.Modes.droid);
1593
- const prevStartPosition = prev.object.getStackedPosition(osuBase.Modes.droid);
1594
- // We need to consider two cases:
1595
- //
1596
- // Case 1: Current object is a circle, or previous object is a circle.
1597
- // In this case, we only need to check if their positions are close enough to be tapped together.
1598
- //
1599
- // Case 2: Both objects are sliders.
1600
- // In this case, we need to check if all nested hitobjects can be hit together.
1601
- // To start with, check if the starting positions can be tapped together.
1602
- if (startPosition.getDistance(prevStartPosition) > distanceThreshold) {
1603
- return false;
1604
- }
1605
- if (this.object instanceof osuBase.Circle || prev.object instanceof osuBase.Circle) {
1606
- return true;
1607
- }
1608
- // Check if all nested hitobjects can be hit together.
1609
- for (let i = 1; i < this.object.nestedHitObjects.length; ++i) {
1610
- const position = this.object.nestedHitObjects[i].getStackedPosition(osuBase.Modes.droid);
1611
- const prevPosition = prevStartPosition.add(prev.object.curvePositionAt(i / (this.object.nestedHitObjects.length - 1)));
1612
- if (position.getDistance(prevPosition) > distanceThreshold) {
1613
- return false;
1614
- }
1615
- }
1616
- // Do the same for the previous slider as well.
1617
- for (let i = 1; i < prev.object.nestedHitObjects.length; ++i) {
1618
- const prevPosition = prev.object.nestedHitObjects[i].getStackedPosition(osuBase.Modes.droid);
1619
- const position = startPosition.add(this.object.curvePositionAt(i / (prev.object.nestedHitObjects.length - 1)));
1620
- if (prevPosition.getDistance(position) > distanceThreshold) {
1621
- return false;
1622
- }
1623
- }
1624
- return true;
1625
- }
1626
- setVisuals(clockRate, hitObjects) {
1627
- // We'll have two visible object arrays. The first array contains objects before the current object starts in a reversed order,
1628
- // while the second array contains objects after the current object ends.
1629
- // For overlapping factor, we also need to consider previous visible objects.
1630
- const prevVisibleObjects = [];
1631
- const nextVisibleObjects = [];
1632
- for (let j = this.index + 2; j < hitObjects.length; ++j) {
1633
- const o = hitObjects[j];
1634
- if (o instanceof osuBase.Spinner) {
1635
- continue;
1636
- }
1637
- if (o.startTime / clockRate > this.endTime + this.timePreempt) {
1638
- break;
1639
- }
1640
- nextVisibleObjects.push(o);
1641
- }
1642
- for (let j = 0; j < this.index; ++j) {
1643
- const prev = this.previous(j);
1644
- if (prev.object instanceof osuBase.Spinner) {
1645
- continue;
1646
- }
1647
- if (prev.startTime >= this.startTime) {
1648
- continue;
1649
- }
1650
- if (prev.startTime < this.startTime - this.timePreempt) {
1651
- break;
1652
- }
1653
- prevVisibleObjects.push(prev.object);
1654
- }
1655
- for (const hitObject of prevVisibleObjects) {
1656
- const distance = this.object
1657
- .getStackedPosition(this.mode)
1658
- .getDistance(hitObject.getStackedEndPosition(this.mode));
1659
- const deltaTime = this.startTime - hitObject.endTime / clockRate;
1660
- this.applyToOverlappingFactor(distance, deltaTime);
1661
- }
1662
- for (const hitObject of nextVisibleObjects) {
1663
- const distance = hitObject
1664
- .getStackedPosition(this.mode)
1665
- .getDistance(this.object.getStackedEndPosition(this.mode));
1666
- const deltaTime = hitObject.startTime / clockRate - this.endTime;
1667
- if (deltaTime >= 0) {
1668
- this.noteDensity += 1 - deltaTime / this.timePreempt;
1669
- }
1670
- this.applyToOverlappingFactor(distance, deltaTime);
1671
- }
1672
- }
1673
- applyToOverlappingFactor(distance, deltaTime) {
1674
- // Penalize objects that are too close to the object in both distance
1675
- // and delta time to prevent stream maps from being overweighted.
1676
- this.overlappingFactor +=
1677
- Math.max(0, 1 - distance / (2.5 * this.object.radius)) *
1678
- (7.5 /
1679
- (1 +
1680
- Math.exp(0.15 *
1681
- (Math.max(deltaTime, DifficultyHitObject.minDeltaTime) -
1682
- 75))));
1683
- }
1684
- }
1685
-
1686
1725
  /**
1687
1726
  * A difficulty calculator for osu!droid gamemode.
1688
1727
  */
@@ -1691,6 +1730,7 @@ class DroidDifficultyCalculator extends DifficultyCalculator {
1691
1730
  super(...arguments);
1692
1731
  this.attributes = {
1693
1732
  mode: "live",
1733
+ aimDifficultSliderCount: 0,
1694
1734
  tapDifficulty: 0,
1695
1735
  rhythmDifficulty: 0,
1696
1736
  visualDifficulty: 0,
@@ -1918,6 +1958,8 @@ class DroidDifficultyCalculator extends DifficultyCalculator {
1918
1958
  }
1919
1959
  this.attributes.aimDifficultStrainCount =
1920
1960
  aimSkill.countDifficultStrains();
1961
+ this.attributes.aimDifficultSliderCount =
1962
+ aimSkill.countDifficultSliders();
1921
1963
  this.calculateAimAttributes();
1922
1964
  }
1923
1965
  /**
@@ -2051,6 +2093,9 @@ class DroidDifficultyCalculator extends DifficultyCalculator {
2051
2093
  if (this.mods.some((m) => m instanceof osuBase.ModRelax)) {
2052
2094
  this.attributes.flashlightDifficulty *= 0.7;
2053
2095
  }
2096
+ else if (this.mods.some((m) => m instanceof osuBase.ModAutopilot)) {
2097
+ this.attributes.flashlightDifficulty *= 0.4;
2098
+ }
2054
2099
  this.attributes.flashlightDifficultStrainCount =
2055
2100
  flashlightSkill.countDifficultStrains();
2056
2101
  }
@@ -3356,7 +3401,7 @@ class OsuDifficultyHitObject extends DifficultyHitObject {
3356
3401
  get scalingFactor() {
3357
3402
  const radius = this.object.radius;
3358
3403
  // We will scale distances by this factor, so we can assume a uniform CircleSize among beatmaps.
3359
- let scalingFactor = this.normalizedRadius / radius;
3404
+ let scalingFactor = DifficultyHitObject.normalizedRadius / radius;
3360
3405
  // High circle size (small CS) bonus
3361
3406
  if (radius < this.radiusBuffThreshold) {
3362
3407
  scalingFactor *=
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rian8337/osu-difficulty-calculator",
3
- "version": "4.0.0-beta.48",
3
+ "version": "4.0.0-beta.49",
4
4
  "description": "A module for calculating osu!standard beatmap difficulty and performance value with respect to the current difficulty and performance algorithm.",
5
5
  "keywords": [
6
6
  "osu",
@@ -38,5 +38,5 @@
38
38
  "publishConfig": {
39
39
  "access": "public"
40
40
  },
41
- "gitHead": "da34d3703153595c0314e3a6bb62a0b989bae53a"
41
+ "gitHead": "a352562e37332f9eea56f47628d1d2280aea4437"
42
42
  }
@@ -10,6 +10,7 @@ declare abstract class AimEvaluator {
10
10
  protected static readonly acuteAngleMultiplier: number;
11
11
  protected static readonly sliderMultiplier: number;
12
12
  protected static readonly velocityChangeMultiplier: number;
13
+ protected static readonly wiggleMultiplier: number;
13
14
  /**
14
15
  * Calculates the bonus of wide angles.
15
16
  */
@@ -197,8 +198,15 @@ declare abstract class DifficultyHitObject {
197
198
  * Other hitobjects in the beatmap, including this hitobject.
198
199
  */
199
200
  protected readonly hitObjects: readonly DifficultyHitObject[];
201
+ /**
202
+ * The normalized radius of the hitobject.
203
+ */
204
+ static readonly normalizedRadius: number;
205
+ /**
206
+ * The normalized diameter of the hitobject.
207
+ */
208
+ static get normalizedDiameter(): number;
200
209
  protected abstract readonly mode: Modes;
201
- protected readonly normalizedRadius = 50;
202
210
  protected readonly maximumSliderRadius: number;
203
211
  protected readonly assumedSliderRadius: number;
204
212
  /**
@@ -647,7 +655,12 @@ declare class DroidAim extends DroidSkill {
647
655
  private readonly skillMultiplier;
648
656
  private readonly withSliders;
649
657
  private currentAimStrain;
658
+ private readonly sliderStrains;
650
659
  constructor(mods: Mod[], withSliders: boolean);
660
+ /**
661
+ * Obtains the amount of sliders that are considered difficult in terms of relative strain.
662
+ */
663
+ countDifficultSliders(): number;
651
664
  protected strainValueAt(current: DroidDifficultyHitObject): number;
652
665
  protected calculateInitialStrain(time: number, current: DroidDifficultyHitObject): number;
653
666
  protected getObjectStrain(): number;
@@ -661,9 +674,10 @@ declare class DroidAim extends DroidSkill {
661
674
  * An evaluator for calculating osu!droid Aim skill.
662
675
  */
663
676
  declare abstract class DroidAimEvaluator extends AimEvaluator {
664
- protected static readonly wideAngleMultiplier = 1.65;
665
- protected static readonly sliderMultiplier = 1.5;
666
- protected static readonly velocityChangeMultiplier = 0.85;
677
+ protected static readonly wideAngleMultiplier = 1.5;
678
+ protected static readonly acuteAngleMultiplier = 2.6;
679
+ protected static readonly sliderMultiplier = 1.35;
680
+ protected static readonly velocityChangeMultiplier = 0.75;
667
681
  private static readonly singleSpacingThreshold;
668
682
  private static readonly minSpeedBonus;
669
683
  /**
@@ -686,12 +700,18 @@ declare abstract class DroidAimEvaluator extends AimEvaluator {
686
700
  * Calculates the flow aim strain of a hitobject.
687
701
  */
688
702
  private static flowAimStrainOf;
703
+ protected static calculateWideAngleBonus(angle: number): number;
704
+ protected static calculateAcuteAngleBonus(angle: number): number;
689
705
  }
690
706
 
691
707
  /**
692
708
  * Holds data that can be used to calculate osu!droid performance points.
693
709
  */
694
710
  interface DroidDifficultyAttributes extends DifficultyAttributes {
711
+ /**
712
+ * The number of sliders weighted by difficulty.
713
+ */
714
+ aimDifficultSliderCount: number;
695
715
  /**
696
716
  * The difficulty corresponding to the tap skill.
697
717
  */