@rian8337/osu-difficulty-calculator 1.0.0
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/LICENSE +21 -0
- package/README.md +11 -0
- package/dist/DroidPerformanceCalculator.js +248 -0
- package/dist/DroidStarRating.js +175 -0
- package/dist/MapStars.js +50 -0
- package/dist/OsuPerformanceCalculator.js +217 -0
- package/dist/OsuStarRating.js +147 -0
- package/dist/base/DifficultyAttributes.js +2 -0
- package/dist/base/PerformanceCalculator.js +125 -0
- package/dist/base/Skill.js +29 -0
- package/dist/base/StarRating.js +130 -0
- package/dist/base/StrainSkill.js +75 -0
- package/dist/index.js +25 -0
- package/dist/preprocessing/DifficultyHitObject.js +89 -0
- package/dist/preprocessing/DifficultyHitObjectCreator.js +237 -0
- package/dist/skills/DroidAim.js +209 -0
- package/dist/skills/DroidFlashlight.js +59 -0
- package/dist/skills/DroidSkill.js +16 -0
- package/dist/skills/DroidTap.js +188 -0
- package/dist/skills/OsuAim.js +170 -0
- package/dist/skills/OsuFlashlight.js +60 -0
- package/dist/skills/OsuSkill.js +37 -0
- package/dist/skills/OsuSpeed.js +175 -0
- package/package.json +37 -0
- package/typings/index.d.ts +1052 -0
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.StrainSkill = void 0;
|
|
4
|
+
const Skill_1 = require("./Skill");
|
|
5
|
+
/**
|
|
6
|
+
* Used to processes strain values of difficulty hitobjects, keep track of strain levels caused by the processed objects
|
|
7
|
+
* and to calculate a final difficulty value representing the difficulty of hitting all the processed objects.
|
|
8
|
+
*/
|
|
9
|
+
class StrainSkill extends Skill_1.Skill {
|
|
10
|
+
constructor() {
|
|
11
|
+
super(...arguments);
|
|
12
|
+
/**
|
|
13
|
+
* The strain of currently calculated hitobject.
|
|
14
|
+
*/
|
|
15
|
+
this.currentStrain = 0;
|
|
16
|
+
/**
|
|
17
|
+
* The current section's strain peak.
|
|
18
|
+
*/
|
|
19
|
+
this.currentSectionPeak = 0;
|
|
20
|
+
/**
|
|
21
|
+
* Strain peaks are stored here.
|
|
22
|
+
*/
|
|
23
|
+
this.strainPeaks = [];
|
|
24
|
+
this.sectionLength = 400;
|
|
25
|
+
this.currentSectionEnd = 0;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Calculates the strain value of a hitobject and stores the value in it. This value is affected by previously processed objects.
|
|
29
|
+
*
|
|
30
|
+
* @param current The hitobject to process.
|
|
31
|
+
*/
|
|
32
|
+
process(current) {
|
|
33
|
+
// The first object doesn't generate a strain, so we begin with an incremented section end
|
|
34
|
+
if (this.previous.length === 0) {
|
|
35
|
+
this.currentSectionEnd =
|
|
36
|
+
Math.ceil(current.startTime / this.sectionLength) *
|
|
37
|
+
this.sectionLength;
|
|
38
|
+
}
|
|
39
|
+
while (current.startTime > this.currentSectionEnd) {
|
|
40
|
+
this.saveCurrentPeak();
|
|
41
|
+
this.startNewSectionFrom(this.currentSectionEnd);
|
|
42
|
+
this.currentSectionEnd += this.sectionLength;
|
|
43
|
+
}
|
|
44
|
+
this.currentStrain = this.strainValueAt(current);
|
|
45
|
+
this.saveToHitObject(current);
|
|
46
|
+
this.currentSectionPeak = Math.max(this.currentStrain, this.currentSectionPeak);
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Saves the current peak strain level to the list of strain peaks, which will be used to calculate an overall difficulty.
|
|
50
|
+
*/
|
|
51
|
+
saveCurrentPeak() {
|
|
52
|
+
this.strainPeaks.push(this.currentSectionPeak);
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Sets the initial strain level for a new section.
|
|
56
|
+
*
|
|
57
|
+
* @param offset The beginning of the new section in milliseconds, adjusted by speed multiplier.
|
|
58
|
+
*/
|
|
59
|
+
startNewSectionFrom(offset) {
|
|
60
|
+
// The maximum strain of the new section is not zero by default, strain decays as usual regardless of section boundaries.
|
|
61
|
+
// This means we need to capture the strain level at the beginning of the new section, and use that as the initial peak level.
|
|
62
|
+
this.currentSectionPeak =
|
|
63
|
+
this.currentStrain *
|
|
64
|
+
this.strainDecay(offset - this.previous[0].startTime);
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Calculates strain decay for a specified time frame.
|
|
68
|
+
*
|
|
69
|
+
* @param ms The time frame to calculate.
|
|
70
|
+
*/
|
|
71
|
+
strainDecay(ms) {
|
|
72
|
+
return Math.pow(this.strainDecayBase, ms / 1000);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
exports.StrainSkill = StrainSkill;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
Object.defineProperty(o, k2, { enumerable: true, get: function() { return m[k]; } });
|
|
5
|
+
}) : (function(o, m, k, k2) {
|
|
6
|
+
if (k2 === undefined) k2 = k;
|
|
7
|
+
o[k2] = m[k];
|
|
8
|
+
}));
|
|
9
|
+
var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
10
|
+
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
11
|
+
};
|
|
12
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
13
|
+
__exportStar(require("./preprocessing/DifficultyHitObject"), exports);
|
|
14
|
+
__exportStar(require("./preprocessing/DifficultyHitObjectCreator"), exports);
|
|
15
|
+
__exportStar(require("./skills/DroidAim"), exports);
|
|
16
|
+
__exportStar(require("./skills/DroidFlashlight"), exports);
|
|
17
|
+
__exportStar(require("./DroidPerformanceCalculator"), exports);
|
|
18
|
+
__exportStar(require("./DroidStarRating"), exports);
|
|
19
|
+
__exportStar(require("./skills/DroidTap"), exports);
|
|
20
|
+
__exportStar(require("./MapStars"), exports);
|
|
21
|
+
__exportStar(require("./skills/OsuAim"), exports);
|
|
22
|
+
__exportStar(require("./skills/OsuFlashlight"), exports);
|
|
23
|
+
__exportStar(require("./OsuPerformanceCalculator"), exports);
|
|
24
|
+
__exportStar(require("./skills/OsuSpeed"), exports);
|
|
25
|
+
__exportStar(require("./OsuStarRating"), exports);
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.DifficultyHitObject = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* Represents an osu!standard hit object with difficulty calculation values.
|
|
6
|
+
*/
|
|
7
|
+
class DifficultyHitObject {
|
|
8
|
+
/**
|
|
9
|
+
* @param object The underlying hitobject.
|
|
10
|
+
*/
|
|
11
|
+
constructor(object) {
|
|
12
|
+
/**
|
|
13
|
+
* The aim strain generated by the hitobject if sliders are considered.
|
|
14
|
+
*/
|
|
15
|
+
this.aimStrainWithSliders = 0;
|
|
16
|
+
/**
|
|
17
|
+
* The aim strain generated by the hitobject if sliders are not considered.
|
|
18
|
+
*/
|
|
19
|
+
this.aimStrainWithoutSliders = 0;
|
|
20
|
+
/**
|
|
21
|
+
* The tap strain generated by the hitobject.
|
|
22
|
+
*/
|
|
23
|
+
this.tapStrain = 0;
|
|
24
|
+
/**
|
|
25
|
+
* The tap strain generated by the hitobject if `strainTime` isn't modified by
|
|
26
|
+
* OD. This is used in three-finger detection.
|
|
27
|
+
*/
|
|
28
|
+
this.originalTapStrain = 0;
|
|
29
|
+
/**
|
|
30
|
+
* The rhythm multiplier generated by the hitobject.
|
|
31
|
+
*/
|
|
32
|
+
this.rhythmMultiplier = 0;
|
|
33
|
+
/**
|
|
34
|
+
* The flashlight strain generated by the hitobject.
|
|
35
|
+
*/
|
|
36
|
+
this.flashlightStrain = 0;
|
|
37
|
+
/**
|
|
38
|
+
* The normalized distance from the "lazy" end position of the previous hitobject to the start position of this hitobject.
|
|
39
|
+
*
|
|
40
|
+
* The "lazy" end position is the position at which the cursor ends up if the previous hitobject is followed with as minimal movement as possible (i.e. on the edge of slider follow circles).
|
|
41
|
+
*/
|
|
42
|
+
this.lazyJumpDistance = 0;
|
|
43
|
+
/**
|
|
44
|
+
* The normalized shortest distance to consider for a jump between the previous hitobject and this hitobject.
|
|
45
|
+
*
|
|
46
|
+
* This is bounded from above by `lazyJumpDistance`, and is smaller than the former if a more natural path is able to be taken through the previous hitobject.
|
|
47
|
+
*
|
|
48
|
+
* Suppose a linear slider - circle pattern. Following the slider lazily (see: `lazyJumpDistance`) will result in underestimating the true end position of the slider as being closer towards the start position.
|
|
49
|
+
* As a result, `lazyJumpDistance` overestimates the jump distance because the player is able to take a more natural path by following through the slider to its end,
|
|
50
|
+
* such that the jump is felt as only starting from the slider's true end position.
|
|
51
|
+
*
|
|
52
|
+
* Now consider a slider - circle pattern where the circle is stacked along the path inside the slider.
|
|
53
|
+
* In this case, the lazy end position correctly estimates the true end position of the slider and provides the more natural movement path.
|
|
54
|
+
*/
|
|
55
|
+
this.minimumJumpDistance = 0;
|
|
56
|
+
/**
|
|
57
|
+
* The time taken to travel through `minimumJumpDistance`, with a minimum value of 25ms.
|
|
58
|
+
*/
|
|
59
|
+
this.minimumJumpTime = 0;
|
|
60
|
+
/**
|
|
61
|
+
* The normalized distance between the start and end position of this hitobject.
|
|
62
|
+
*/
|
|
63
|
+
this.travelDistance = 0;
|
|
64
|
+
/**
|
|
65
|
+
* The time taken to travel through `travelDistance`, with a minimum value of 25ms for a non-zero distance.
|
|
66
|
+
*/
|
|
67
|
+
this.travelTime = 0;
|
|
68
|
+
/**
|
|
69
|
+
* Angle the player has to take to hit this hitobject.
|
|
70
|
+
*
|
|
71
|
+
* Calculated as the angle between the circles (current-2, current-1, current).
|
|
72
|
+
*/
|
|
73
|
+
this.angle = null;
|
|
74
|
+
/**
|
|
75
|
+
* The amount of milliseconds elapsed between this hitobject and the last hitobject.
|
|
76
|
+
*/
|
|
77
|
+
this.deltaTime = 0;
|
|
78
|
+
/**
|
|
79
|
+
* The amount of milliseconds elapsed since the start time of the previous hitobject, with a minimum of 25ms.
|
|
80
|
+
*/
|
|
81
|
+
this.strainTime = 0;
|
|
82
|
+
/**
|
|
83
|
+
* Adjusted start time of the hitobject, taking speed multiplier into account.
|
|
84
|
+
*/
|
|
85
|
+
this.startTime = 0;
|
|
86
|
+
this.object = object;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
exports.DifficultyHitObject = DifficultyHitObject;
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.DifficultyHitObjectCreator = void 0;
|
|
4
|
+
const osu_base_1 = require("@rian8337/osu-base");
|
|
5
|
+
const DifficultyHitObject_1 = require("./DifficultyHitObject");
|
|
6
|
+
/**
|
|
7
|
+
* A converter used to convert normal hitobjects into difficulty hitobjects.
|
|
8
|
+
*/
|
|
9
|
+
class DifficultyHitObjectCreator {
|
|
10
|
+
constructor() {
|
|
11
|
+
/**
|
|
12
|
+
* The threshold for small circle buff for osu!droid.
|
|
13
|
+
*/
|
|
14
|
+
this.DROID_CIRCLESIZE_BUFF_THRESHOLD = 52.5;
|
|
15
|
+
/**
|
|
16
|
+
* The threshold for small circle buff for osu!standard.
|
|
17
|
+
*/
|
|
18
|
+
this.PC_CIRCLESIZE_BUFF_THRESHOLD = 30;
|
|
19
|
+
/**
|
|
20
|
+
* The gamemode this creator is creating for.
|
|
21
|
+
*/
|
|
22
|
+
this.mode = osu_base_1.modes.osu;
|
|
23
|
+
/**
|
|
24
|
+
* The base normalized radius of hitobjects.
|
|
25
|
+
*/
|
|
26
|
+
this.normalizedRadius = 50;
|
|
27
|
+
this.maximumSliderRadius = this.normalizedRadius * 2.4;
|
|
28
|
+
this.assumedSliderRadius = this.normalizedRadius * 1.8;
|
|
29
|
+
this.minDeltaTime = 25;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Generates difficulty hitobjects for difficulty calculation.
|
|
33
|
+
*/
|
|
34
|
+
generateDifficultyObjects(params) {
|
|
35
|
+
this.mode = params.mode;
|
|
36
|
+
const circleSize = params.circleSize;
|
|
37
|
+
const scale = (1 - (0.7 * (circleSize - 5)) / 5) / 2;
|
|
38
|
+
params.objects[0].scale = scale;
|
|
39
|
+
const scalingFactor = this.getScalingFactor(params.objects[0].radius);
|
|
40
|
+
const difficultyObjects = [];
|
|
41
|
+
for (let i = 0; i < params.objects.length; ++i) {
|
|
42
|
+
const object = new DifficultyHitObject_1.DifficultyHitObject(params.objects[i]);
|
|
43
|
+
object.object.scale = scale;
|
|
44
|
+
if (object.object instanceof osu_base_1.Slider) {
|
|
45
|
+
object.object.nestedHitObjects.forEach((h) => {
|
|
46
|
+
h.scale = scale;
|
|
47
|
+
});
|
|
48
|
+
this.calculateSliderCursorPosition(object.object);
|
|
49
|
+
object.travelDistance = object.object.lazyTravelDistance;
|
|
50
|
+
object.travelTime = Math.max(object.object.lazyTravelTime / params.speedMultiplier, this.minDeltaTime);
|
|
51
|
+
}
|
|
52
|
+
const lastObject = difficultyObjects[i - 1];
|
|
53
|
+
const lastLastObject = difficultyObjects[i - 2];
|
|
54
|
+
object.startTime = object.object.startTime / params.speedMultiplier;
|
|
55
|
+
if (!lastObject) {
|
|
56
|
+
difficultyObjects.push(object);
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
object.deltaTime =
|
|
60
|
+
(object.object.startTime - lastObject.object.startTime) /
|
|
61
|
+
params.speedMultiplier;
|
|
62
|
+
// Cap to 25ms to prevent difficulty calculation breaking from simulatenous objects.
|
|
63
|
+
object.strainTime = Math.max(this.minDeltaTime, object.deltaTime);
|
|
64
|
+
if (object.object instanceof osu_base_1.Spinner ||
|
|
65
|
+
lastObject.object instanceof osu_base_1.Spinner) {
|
|
66
|
+
difficultyObjects.push(object);
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
const lastCursorPosition = this.getEndCursorPosition(lastObject.object);
|
|
70
|
+
object.lazyJumpDistance = object.object.stackedPosition
|
|
71
|
+
.scale(scalingFactor)
|
|
72
|
+
.subtract(lastCursorPosition.scale(scalingFactor)).length;
|
|
73
|
+
object.minimumJumpTime = object.strainTime;
|
|
74
|
+
object.minimumJumpDistance = object.lazyJumpDistance;
|
|
75
|
+
if (lastObject.object instanceof osu_base_1.Slider) {
|
|
76
|
+
object.minimumJumpTime = Math.max(object.strainTime - lastObject.travelTime, this.minDeltaTime);
|
|
77
|
+
// There are two types of slider-to-object patterns to consider in order to better approximate the real movement a player will take to jump between the hitobjects.
|
|
78
|
+
//
|
|
79
|
+
// 1. The anti-flow pattern, where players cut the slider short in order to move to the next hitobject.
|
|
80
|
+
//
|
|
81
|
+
// <======o==> ← slider
|
|
82
|
+
// | ← most natural jump path
|
|
83
|
+
// o ← a follow-up hitcircle
|
|
84
|
+
//
|
|
85
|
+
// In this case the most natural jump path is approximated by LazyJumpDistance.
|
|
86
|
+
//
|
|
87
|
+
// 2. The flow pattern, where players follow through the slider to its visual extent into the next hitobject.
|
|
88
|
+
//
|
|
89
|
+
// <======o==>---o
|
|
90
|
+
// ↑
|
|
91
|
+
// most natural jump path
|
|
92
|
+
//
|
|
93
|
+
// In this case the most natural jump path is better approximated by a new distance called "tailJumpDistance" - the distance between the slider's tail and the next hitobject.
|
|
94
|
+
//
|
|
95
|
+
// Thus, the player is assumed to jump the minimum of these two distances in all cases.
|
|
96
|
+
const tailJumpDistance = lastObject.object.tailCircle.stackedPosition.subtract(object.object.stackedPosition).length * scalingFactor;
|
|
97
|
+
object.minimumJumpDistance = Math.max(0, Math.min(object.lazyJumpDistance -
|
|
98
|
+
(this.maximumSliderRadius -
|
|
99
|
+
this.assumedSliderRadius), tailJumpDistance - this.maximumSliderRadius));
|
|
100
|
+
}
|
|
101
|
+
if (lastLastObject && !(lastLastObject.object instanceof osu_base_1.Spinner)) {
|
|
102
|
+
const lastLastCursorPosition = this.getEndCursorPosition(lastLastObject.object);
|
|
103
|
+
const v1 = lastLastCursorPosition.subtract(lastObject.object.stackedPosition);
|
|
104
|
+
const v2 = object.object.stackedPosition.subtract(lastCursorPosition);
|
|
105
|
+
const dot = v1.dot(v2);
|
|
106
|
+
const det = v1.x * v2.y - v1.y * v2.x;
|
|
107
|
+
object.angle = Math.abs(Math.atan2(det, dot));
|
|
108
|
+
}
|
|
109
|
+
difficultyObjects.push(object);
|
|
110
|
+
}
|
|
111
|
+
return difficultyObjects;
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Calculates a slider's cursor position.
|
|
115
|
+
*/
|
|
116
|
+
calculateSliderCursorPosition(slider) {
|
|
117
|
+
if (slider.lazyEndPosition) {
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
// Droid doesn't have a legacy slider tail. Since beatmap parser defaults slider tail
|
|
121
|
+
// to legacy slider tail, it needs to be changed to real slider tail first.
|
|
122
|
+
if (this.mode === osu_base_1.modes.droid) {
|
|
123
|
+
slider.tailCircle.startTime += osu_base_1.Slider.legacyLastTickOffset;
|
|
124
|
+
slider.tailCircle.endTime += osu_base_1.Slider.legacyLastTickOffset;
|
|
125
|
+
slider.nestedHitObjects.sort((a, b) => {
|
|
126
|
+
return a.startTime - b.startTime;
|
|
127
|
+
});
|
|
128
|
+
// Temporary lazy end position until a real result can be derived.
|
|
129
|
+
slider.lazyEndPosition = slider.stackedPosition;
|
|
130
|
+
// Stop here if the slider has too short duration due to float number limitation.
|
|
131
|
+
// Incredibly close start and end time fluctuates travel distance and lazy
|
|
132
|
+
// end position heavily, which we do not want to happen.
|
|
133
|
+
//
|
|
134
|
+
// In the real game, this shouldn't happen. Perhaps we need to reinvestigate this
|
|
135
|
+
// in the future.
|
|
136
|
+
if (osu_base_1.Precision.almostEqualsNumber(slider.startTime, slider.endTime)) {
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
// Not using slider.endTime due to legacy last tick offset.
|
|
141
|
+
slider.lazyTravelTime =
|
|
142
|
+
slider.nestedHitObjects.at(-1).startTime - slider.startTime;
|
|
143
|
+
let endTimeMin = slider.lazyTravelTime / slider.spanDuration;
|
|
144
|
+
if (endTimeMin % 2 >= 1) {
|
|
145
|
+
endTimeMin = 1 - (endTimeMin % 1);
|
|
146
|
+
}
|
|
147
|
+
else {
|
|
148
|
+
endTimeMin %= 1;
|
|
149
|
+
}
|
|
150
|
+
// Temporary lazy end position until a real result can be derived.
|
|
151
|
+
slider.lazyEndPosition = slider.stackedPosition.add(slider.path.positionAt(endTimeMin));
|
|
152
|
+
let currentCursorPosition = slider.stackedPosition;
|
|
153
|
+
const scalingFactor = this.normalizedRadius / slider.radius;
|
|
154
|
+
for (let i = 1; i < slider.nestedHitObjects.length; ++i) {
|
|
155
|
+
const currentMovementObject = slider.nestedHitObjects[i];
|
|
156
|
+
let currentMovement = currentMovementObject.stackedPosition.subtract(currentCursorPosition);
|
|
157
|
+
let currentMovementLength = scalingFactor * currentMovement.length;
|
|
158
|
+
// The amount of movement required so that the cursor position needs to be updated.
|
|
159
|
+
let requiredMovement = this.assumedSliderRadius;
|
|
160
|
+
if (i === slider.nestedHitObjects.length - 1) {
|
|
161
|
+
// The end of a slider has special aim rules due to the relaxed time constraint on position.
|
|
162
|
+
// There is both a lazy end position as well as the actual end slider position. We assume the player takes the simpler movement.
|
|
163
|
+
// For sliders that are circular, the lazy end position may actually be farther away than the sliders' true end.
|
|
164
|
+
// This code is designed to prevent buffing situations where lazy end is actually a less efficient movement.
|
|
165
|
+
const lazyMovement = slider.lazyEndPosition.subtract(currentCursorPosition);
|
|
166
|
+
if (lazyMovement.length < currentMovement.length) {
|
|
167
|
+
currentMovement = lazyMovement;
|
|
168
|
+
}
|
|
169
|
+
currentMovementLength = scalingFactor * currentMovement.length;
|
|
170
|
+
}
|
|
171
|
+
else if (currentMovementObject instanceof osu_base_1.RepeatPoint) {
|
|
172
|
+
// For a slider repeat, assume a tighter movement threshold to better assess repeat sliders.
|
|
173
|
+
requiredMovement = this.normalizedRadius;
|
|
174
|
+
}
|
|
175
|
+
if (currentMovementLength > requiredMovement) {
|
|
176
|
+
// This finds the positional delta from the required radius and the current position,
|
|
177
|
+
// and updates the currentCursorPosition accordingly, as well as rewarding distance.
|
|
178
|
+
currentCursorPosition = currentCursorPosition.add(currentMovement.scale((currentMovementLength - requiredMovement) /
|
|
179
|
+
currentMovementLength));
|
|
180
|
+
currentMovementLength *=
|
|
181
|
+
(currentMovementLength - requiredMovement) /
|
|
182
|
+
currentMovementLength;
|
|
183
|
+
slider.lazyTravelDistance += currentMovementLength;
|
|
184
|
+
}
|
|
185
|
+
if (i === slider.nestedHitObjects.length - 1) {
|
|
186
|
+
slider.lazyEndPosition = currentCursorPosition;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
// Bonus for repeat sliders until a better per nested object strain system can be achieved.
|
|
190
|
+
if (this.mode === osu_base_1.modes.droid) {
|
|
191
|
+
slider.lazyTravelDistance *= Math.pow(1 + slider.repeatPoints / 4, 1 / 4);
|
|
192
|
+
}
|
|
193
|
+
else {
|
|
194
|
+
slider.lazyTravelDistance *= Math.pow(1 + slider.repeatPoints / 2.5, 1 / 2.5);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Gets the scaling factor of a radius.
|
|
199
|
+
*
|
|
200
|
+
* @param radius The radius to get the scaling factor from.
|
|
201
|
+
*/
|
|
202
|
+
getScalingFactor(radius) {
|
|
203
|
+
// We will scale distances by this factor, so we can assume a uniform CircleSize among beatmaps.
|
|
204
|
+
let scalingFactor = this.normalizedRadius / radius;
|
|
205
|
+
// High circle size (small CS) bonus
|
|
206
|
+
switch (this.mode) {
|
|
207
|
+
case osu_base_1.modes.droid:
|
|
208
|
+
if (radius < this.DROID_CIRCLESIZE_BUFF_THRESHOLD) {
|
|
209
|
+
scalingFactor *=
|
|
210
|
+
1 +
|
|
211
|
+
Math.min(this.DROID_CIRCLESIZE_BUFF_THRESHOLD - radius, 20) /
|
|
212
|
+
40;
|
|
213
|
+
}
|
|
214
|
+
break;
|
|
215
|
+
case osu_base_1.modes.osu:
|
|
216
|
+
if (radius < this.PC_CIRCLESIZE_BUFF_THRESHOLD) {
|
|
217
|
+
scalingFactor *=
|
|
218
|
+
1 +
|
|
219
|
+
Math.min(this.PC_CIRCLESIZE_BUFF_THRESHOLD - radius, 5) /
|
|
220
|
+
50;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return scalingFactor;
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Returns the end cursor position of a hitobject.
|
|
227
|
+
*/
|
|
228
|
+
getEndCursorPosition(object) {
|
|
229
|
+
let pos = object.stackedPosition;
|
|
230
|
+
if (object instanceof osu_base_1.Slider) {
|
|
231
|
+
this.calculateSliderCursorPosition(object);
|
|
232
|
+
pos = object.lazyEndPosition ?? pos;
|
|
233
|
+
}
|
|
234
|
+
return pos;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
exports.DifficultyHitObjectCreator = DifficultyHitObjectCreator;
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.DroidAim = void 0;
|
|
4
|
+
const osu_base_1 = require("@rian8337/osu-base");
|
|
5
|
+
const DroidSkill_1 = require("./DroidSkill");
|
|
6
|
+
/**
|
|
7
|
+
* Represents the skill required to correctly aim at every object in the map with a uniform CircleSize and normalized distances.
|
|
8
|
+
*/
|
|
9
|
+
class DroidAim extends DroidSkill_1.DroidSkill {
|
|
10
|
+
constructor(mods, withSliders) {
|
|
11
|
+
super(mods);
|
|
12
|
+
this.skillMultiplier = 23.25;
|
|
13
|
+
this.strainDecayBase = 0.15;
|
|
14
|
+
this.reducedSectionCount = 10;
|
|
15
|
+
this.reducedSectionBaseline = 0.75;
|
|
16
|
+
this.starsPerDouble = 1.05;
|
|
17
|
+
/**
|
|
18
|
+
* Spacing threshold for a single hitobject spacing.
|
|
19
|
+
*/
|
|
20
|
+
this.SINGLE_SPACING_THRESHOLD = 175;
|
|
21
|
+
// ~200 1/2 BPM jumps
|
|
22
|
+
this.minSpeedBonus = 150;
|
|
23
|
+
this.wideAngleMultiplier = 1.5;
|
|
24
|
+
this.acuteAngleMultiplier = 2;
|
|
25
|
+
this.sliderMultiplier = 1.5;
|
|
26
|
+
this.velocityChangeMultiplier = 0.75;
|
|
27
|
+
this.withSliders = withSliders;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* @param current The hitobject to calculate.
|
|
31
|
+
*/
|
|
32
|
+
strainValueOf(current) {
|
|
33
|
+
if (current.object instanceof osu_base_1.Spinner) {
|
|
34
|
+
return 0;
|
|
35
|
+
}
|
|
36
|
+
return this.aimStrainOf(current) + this.movementStrainOf(current);
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Calculates the aim strain of a hitobject.
|
|
40
|
+
*/
|
|
41
|
+
aimStrainOf(current) {
|
|
42
|
+
if (this.previous.length <= 1 ||
|
|
43
|
+
this.previous[0].object instanceof osu_base_1.Spinner) {
|
|
44
|
+
return 0;
|
|
45
|
+
}
|
|
46
|
+
const last = this.previous[0];
|
|
47
|
+
const lastLast = this.previous[1];
|
|
48
|
+
// Calculate the velocity to the current hitobject, which starts with a base distance / time assuming the last object is a hitcircle.
|
|
49
|
+
let currentVelocity = current.lazyJumpDistance / current.strainTime;
|
|
50
|
+
// But if the last object is a slider, then we extend the travel velocity through the slider into the current object.
|
|
51
|
+
if (last.object instanceof osu_base_1.Slider &&
|
|
52
|
+
last.object.ticks > 1 &&
|
|
53
|
+
this.withSliders) {
|
|
54
|
+
// Calculate the slider velocity from slider head to slider end.
|
|
55
|
+
const travelVelocity = last.travelDistance / last.travelTime;
|
|
56
|
+
// Calculate the movement velocity from slider end to current object.
|
|
57
|
+
const movementVelocity = current.minimumJumpDistance / current.minimumJumpTime;
|
|
58
|
+
// Take the larger total combined velocity.
|
|
59
|
+
currentVelocity = Math.max(currentVelocity, movementVelocity + travelVelocity);
|
|
60
|
+
}
|
|
61
|
+
// As above, do the same for the previous hitobject.
|
|
62
|
+
let prevVelocity = last.lazyJumpDistance / last.strainTime;
|
|
63
|
+
if (lastLast.object instanceof osu_base_1.Slider &&
|
|
64
|
+
lastLast.object.ticks > 1 &&
|
|
65
|
+
this.withSliders) {
|
|
66
|
+
const travelVelocity = lastLast.travelDistance / lastLast.travelTime;
|
|
67
|
+
const movementVelocity = last.minimumJumpDistance / last.minimumJumpTime;
|
|
68
|
+
prevVelocity = Math.max(prevVelocity, movementVelocity + travelVelocity);
|
|
69
|
+
}
|
|
70
|
+
let wideAngleBonus = 0;
|
|
71
|
+
let acuteAngleBonus = 0;
|
|
72
|
+
let sliderBonus = 0;
|
|
73
|
+
let velocityChangeBonus = 0;
|
|
74
|
+
// Start strain with regular velocity.
|
|
75
|
+
let strain = currentVelocity;
|
|
76
|
+
if (Math.max(current.strainTime, last.strainTime) <
|
|
77
|
+
1.25 * Math.min(current.strainTime, last.strainTime)) {
|
|
78
|
+
// If rhythms are the same.
|
|
79
|
+
if (current.angle !== null &&
|
|
80
|
+
last.angle !== null &&
|
|
81
|
+
lastLast.angle !== null) {
|
|
82
|
+
// Rewarding angles, take the smaller velocity as base.
|
|
83
|
+
const angleBonus = Math.min(currentVelocity, prevVelocity);
|
|
84
|
+
wideAngleBonus = this.calculateWideAngleBonus(current.angle);
|
|
85
|
+
acuteAngleBonus = this.calculateAcuteAngleBonus(current.angle);
|
|
86
|
+
// Only buff deltaTime exceeding 300 BPM 1/2.
|
|
87
|
+
if (current.strainTime > 100) {
|
|
88
|
+
acuteAngleBonus = 0;
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
acuteAngleBonus *=
|
|
92
|
+
// Multiply by previous angle, we don't want to buff unless this is a wiggle type pattern.
|
|
93
|
+
this.calculateAcuteAngleBonus(last.angle) *
|
|
94
|
+
// The maximum velocity we buff is equal to 125 / strainTime.
|
|
95
|
+
Math.min(angleBonus, 125 / current.strainTime) *
|
|
96
|
+
// Scale buff from 300 BPM 1/2 to 400 BPM 1/2.
|
|
97
|
+
Math.pow(Math.sin((Math.PI / 2) *
|
|
98
|
+
Math.min(1, (100 - current.strainTime) / 25)), 2) *
|
|
99
|
+
// Buff distance exceeding 50 (radius) up to 100 (diameter).
|
|
100
|
+
Math.pow(Math.sin(((Math.PI / 2) *
|
|
101
|
+
(osu_base_1.MathUtils.clamp(current.lazyJumpDistance, 50, 100) -
|
|
102
|
+
50)) /
|
|
103
|
+
50), 2);
|
|
104
|
+
}
|
|
105
|
+
// Penalize wide angles if they're repeated, reducing the penalty as last.angle gets more acute.
|
|
106
|
+
wideAngleBonus *=
|
|
107
|
+
angleBonus *
|
|
108
|
+
(1 -
|
|
109
|
+
Math.min(wideAngleBonus, Math.pow(this.calculateWideAngleBonus(last.angle), 3)));
|
|
110
|
+
// Penalize acute angles if they're repeated, reducing the penalty as lastLast.angle gets more obtuse.
|
|
111
|
+
acuteAngleBonus *=
|
|
112
|
+
0.5 +
|
|
113
|
+
0.5 *
|
|
114
|
+
(1 -
|
|
115
|
+
Math.min(acuteAngleBonus, Math.pow(this.calculateAcuteAngleBonus(lastLast.angle), 3)));
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
if (Math.max(prevVelocity, currentVelocity)) {
|
|
119
|
+
// We want to use the average velocity over the whole object when awarding differences, not the individual jump and slider path velocities.
|
|
120
|
+
prevVelocity =
|
|
121
|
+
(last.lazyJumpDistance + lastLast.travelDistance) /
|
|
122
|
+
last.strainTime;
|
|
123
|
+
currentVelocity =
|
|
124
|
+
(current.lazyJumpDistance + last.travelDistance) /
|
|
125
|
+
current.strainTime;
|
|
126
|
+
// Scale with ratio of difference compared to half the max distance.
|
|
127
|
+
const distanceRatio = Math.pow(Math.sin(((Math.PI / 2) * Math.abs(prevVelocity - currentVelocity)) /
|
|
128
|
+
Math.max(prevVelocity, currentVelocity)), 2);
|
|
129
|
+
// Reward for % distance up to 125 / strainTime for overlaps where velocity is still changing.
|
|
130
|
+
const overlapVelocityBuff = Math.min(125 / Math.min(current.strainTime, last.strainTime), Math.abs(prevVelocity - currentVelocity));
|
|
131
|
+
// Reward for % distance slowed down compared to previous, paying attention to not award overlap.
|
|
132
|
+
const nonOverlapVelocityBuff = Math.abs(prevVelocity - currentVelocity) *
|
|
133
|
+
// Do not award overlap.
|
|
134
|
+
Math.pow(Math.sin((Math.PI / 2) *
|
|
135
|
+
Math.min(1, Math.min(current.lazyJumpDistance, last.lazyJumpDistance) / 100)), 2);
|
|
136
|
+
// Choose the largest bonus, multiplied by ratio.
|
|
137
|
+
velocityChangeBonus =
|
|
138
|
+
Math.max(overlapVelocityBuff, nonOverlapVelocityBuff) *
|
|
139
|
+
distanceRatio;
|
|
140
|
+
// Penalize for rhythm changes.
|
|
141
|
+
velocityChangeBonus *= Math.pow(Math.min(current.strainTime, last.strainTime) /
|
|
142
|
+
Math.max(current.strainTime, last.strainTime), 2);
|
|
143
|
+
}
|
|
144
|
+
if (last.travelTime) {
|
|
145
|
+
// Reward sliders based on velocity.
|
|
146
|
+
sliderBonus = last.travelDistance / last.travelTime;
|
|
147
|
+
}
|
|
148
|
+
// Add in acute angle bonus or wide angle bonus + velocity change bonus, whichever is larger.
|
|
149
|
+
strain += Math.max(acuteAngleBonus * this.acuteAngleMultiplier, wideAngleBonus * this.wideAngleMultiplier +
|
|
150
|
+
velocityChangeBonus * this.velocityChangeMultiplier);
|
|
151
|
+
// Add in additional slider velocity bonus.
|
|
152
|
+
if (this.withSliders) {
|
|
153
|
+
strain += sliderBonus * this.sliderMultiplier;
|
|
154
|
+
}
|
|
155
|
+
return strain;
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Calculates the movement strain of a hitobject.
|
|
159
|
+
*/
|
|
160
|
+
movementStrainOf(current) {
|
|
161
|
+
let speedBonus = 1;
|
|
162
|
+
if (current.strainTime < this.minSpeedBonus) {
|
|
163
|
+
speedBonus +=
|
|
164
|
+
0.75 *
|
|
165
|
+
Math.pow((this.minSpeedBonus - current.strainTime) / 45, 2);
|
|
166
|
+
}
|
|
167
|
+
const travelDistance = this.previous[0]?.travelDistance ?? 0;
|
|
168
|
+
const distance = Math.min(this.SINGLE_SPACING_THRESHOLD, travelDistance + current.minimumJumpDistance);
|
|
169
|
+
return ((50 *
|
|
170
|
+
speedBonus *
|
|
171
|
+
Math.pow(distance / this.SINGLE_SPACING_THRESHOLD, 5)) /
|
|
172
|
+
current.strainTime);
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* @param current The hitobject to calculate.
|
|
176
|
+
*/
|
|
177
|
+
strainValueAt(current) {
|
|
178
|
+
this.currentStrain *= this.strainDecay(current.deltaTime);
|
|
179
|
+
this.currentStrain +=
|
|
180
|
+
this.strainValueOf(current) * this.skillMultiplier;
|
|
181
|
+
return this.currentStrain;
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* @param current The hitobject to save to.
|
|
185
|
+
*/
|
|
186
|
+
saveToHitObject(current) {
|
|
187
|
+
if (this.withSliders) {
|
|
188
|
+
current.aimStrainWithSliders = this.currentStrain;
|
|
189
|
+
}
|
|
190
|
+
else {
|
|
191
|
+
current.aimStrainWithoutSliders = this.currentStrain;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Calculates the bonus of wide angles.
|
|
196
|
+
*/
|
|
197
|
+
calculateWideAngleBonus(angle) {
|
|
198
|
+
return Math.pow(Math.sin((3 / 4) *
|
|
199
|
+
(Math.min((5 / 6) * Math.PI, Math.max(Math.PI / 6, angle)) -
|
|
200
|
+
Math.PI / 6)), 2);
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Calculates the bonus of acute angles.
|
|
204
|
+
*/
|
|
205
|
+
calculateAcuteAngleBonus(angle) {
|
|
206
|
+
return 1 - this.calculateWideAngleBonus(angle);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
exports.DroidAim = DroidAim;
|