@rian8337/osu-base 4.0.0-beta.55 → 4.0.0-beta.58

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.
Files changed (3) hide show
  1. package/dist/index.js +1470 -1422
  2. package/package.json +2 -2
  3. package/typings/index.d.ts +71 -74
package/dist/index.js CHANGED
@@ -388,143 +388,6 @@ exports.Modes = void 0;
388
388
  Modes["osu"] = "osu";
389
389
  })(exports.Modes || (exports.Modes = {}));
390
390
 
391
- /**
392
- * Represents a mod.
393
- */
394
- class Mod {
395
- constructor() {
396
- /**
397
- * `Mod`s that are incompatible with this `Mod`.
398
- */
399
- this.incompatibleMods = new Set();
400
- }
401
- /**
402
- * Serializes this `Mod` to a `SerializedMod`.
403
- */
404
- serialize() {
405
- var _a;
406
- const serialized = {
407
- acronym: this.acronym,
408
- settings: (_a = this.serializeSettings()) !== null && _a !== void 0 ? _a : undefined,
409
- };
410
- if (!serialized.settings) {
411
- delete serialized.settings;
412
- }
413
- return serialized;
414
- }
415
- /**
416
- * Copies the settings of a `SerializedMod` to this `Mod`.
417
- *
418
- * @param mod The `SerializedMod` to copy the settings from. Must be the same `Mod` type.
419
- * @throws {TypeError} If the `SerializedMod` is not the same type as this `Mod`.
420
- */
421
- copySettings(mod) {
422
- if (mod.acronym !== this.acronym) {
423
- throw new TypeError(`Cannot copy settings from ${mod.acronym} to ${this.acronym}`);
424
- }
425
- }
426
- /**
427
- * Whether this `Mod` can be applied to osu!droid.
428
- */
429
- isApplicableToDroid() {
430
- return "droidRanked" in this;
431
- }
432
- /**
433
- * Whether this `Mod` can be applied to osu!standard.
434
- */
435
- isApplicableToOsu() {
436
- return "osuRanked" in this;
437
- }
438
- /**
439
- * Whether this `Mod` can be applied to osu!standard, specifically the osu!stable client.
440
- */
441
- isApplicableToOsuStable() {
442
- return "bitwise" in this;
443
- }
444
- /**
445
- * Whether this `Mod` can be applied to a `Beatmap`.
446
- */
447
- isApplicableToBeatmap() {
448
- return "applyToBeatmap" in this;
449
- }
450
- /**
451
- * Whether this `Mod` can be applied to a `BeatmapDifficulty`.
452
- */
453
- isApplicableToDifficulty() {
454
- return "applyToDifficulty" in this;
455
- }
456
- /**
457
- * Whether this `Mod` can be applied to a `BeatmapDifficulty` relative to other `Mod`s and settings.
458
- */
459
- isApplicableToDifficultyWithSettings() {
460
- return "applyToDifficultyWithSettings" in this;
461
- }
462
- /**
463
- * Whether this `Mod` can be applied to a `HitObject`.
464
- */
465
- isApplicableToHitObject() {
466
- return "applyToHitObject" in this;
467
- }
468
- /**
469
- * Whether this `Mod` can be applied to a `HitObject` relative to other `Mod`s and settings.
470
- */
471
- isApplicableToHitObjectWithSettings() {
472
- return "applyToHitObjectWithSettings" in this;
473
- }
474
- /**
475
- * Whether this `Mod` can be applied to a track's playback rate.
476
- */
477
- isApplicableToTrackRate() {
478
- return "applyToRate" in this;
479
- }
480
- /**
481
- * Whether this `Mod` is migratable to a new `Mod` in osu!droid.
482
- */
483
- isMigratableDroidMod() {
484
- return "migrateDroidMod" in this;
485
- }
486
- /**
487
- * Serializes the settings of this `Mod` to an object that can be converted to a JSON.
488
- *
489
- * @returns The serialized settings of this `Mod`, or `null` if there are no settings.
490
- */
491
- serializeSettings() {
492
- return null;
493
- }
494
- /**
495
- * Returns the string representation of this `Mod`.
496
- */
497
- toString() {
498
- return this.acronym;
499
- }
500
- }
501
-
502
- /**
503
- * Represents the ScoreV2 mod.
504
- */
505
- class ModScoreV2 extends Mod {
506
- constructor() {
507
- super(...arguments);
508
- this.acronym = "V2";
509
- this.name = "ScoreV2";
510
- this.droidRanked = false;
511
- this.osuRanked = false;
512
- this.bitwise = 1 << 29;
513
- }
514
- get isDroidRelevant() {
515
- return true;
516
- }
517
- calculateDroidScoreMultiplier() {
518
- return 1;
519
- }
520
- get isOsuRelevant() {
521
- return true;
522
- }
523
- get osuScoreMultiplier() {
524
- return 1;
525
- }
526
- }
527
-
528
391
  /**
529
392
  * Bitmask constant of object types. This is needed as osu! uses bits to determine object types.
530
393
  */
@@ -766,18 +629,20 @@ class CircleSizeCalculator {
766
629
  * @param mods The mods to apply.
767
630
  * @returns The calculated osu!droid scale.
768
631
  */
769
- static droidCSToDroidScale(cs, mods = []) {
632
+ static droidCSToDroidScale(cs, mods) {
770
633
  // Create a dummy beatmap difficulty for circle size calculation.
771
634
  const difficulty = new BeatmapDifficulty();
772
635
  difficulty.cs = cs;
773
- for (const mod of mods) {
774
- if (mod.isApplicableToDifficulty()) {
775
- mod.applyToDifficulty(exports.Modes.droid, difficulty);
636
+ if (mods !== undefined) {
637
+ for (const mod of mods.values()) {
638
+ if (mod.isApplicableToDifficulty()) {
639
+ mod.applyToDifficulty(exports.Modes.droid, difficulty);
640
+ }
776
641
  }
777
- }
778
- for (const mod of mods) {
779
- if (mod.isApplicableToDifficultyWithSettings()) {
780
- mod.applyToDifficultyWithSettings(exports.Modes.droid, difficulty, mods);
642
+ for (const mod of mods.values()) {
643
+ if (mod.isApplicableToDifficultyWithSettings()) {
644
+ mod.applyToDifficultyWithSettings(exports.Modes.droid, difficulty, mods);
645
+ }
781
646
  }
782
647
  }
783
648
  return Math.max(((this.assumedDroidHeight / 480) *
@@ -1310,68 +1175,311 @@ HitObject.preemptMin = 450;
1310
1175
  HitObject.controlPointLeniency = 1;
1311
1176
 
1312
1177
  /**
1313
- * Represents a circle in a beatmap.
1314
- *
1315
- * All we need from circles is their position. All positions
1316
- * stored in the objects are in playfield coordinates (512*384
1317
- * rectangle).
1178
+ * Represents a mod.
1318
1179
  */
1319
- class Circle extends HitObject {
1320
- constructor(values) {
1321
- super(values);
1180
+ class Mod {
1181
+ constructor() {
1182
+ /**
1183
+ * `Mod`s that are incompatible with this `Mod`.
1184
+ */
1185
+ this.incompatibleMods = new Set();
1322
1186
  }
1323
- toString() {
1324
- return `Position: [${this._position.x}, ${this._position.y}]`;
1187
+ /**
1188
+ * Serializes this `Mod` to a `SerializedMod`.
1189
+ */
1190
+ serialize() {
1191
+ var _a;
1192
+ const serialized = {
1193
+ acronym: this.acronym,
1194
+ settings: (_a = this.serializeSettings()) !== null && _a !== void 0 ? _a : undefined,
1195
+ };
1196
+ if (!serialized.settings) {
1197
+ delete serialized.settings;
1198
+ }
1199
+ return serialized;
1325
1200
  }
1326
- }
1327
-
1328
- /**
1329
- * Represents a hitobject that can be nested within a slider.
1330
- */
1331
- class SliderNestedHitObject extends HitObject {
1332
- constructor(values) {
1333
- super(values);
1334
- this.spanIndex = values.spanIndex;
1335
- this.spanStartTime = values.spanStartTime;
1201
+ /**
1202
+ * Copies the settings of a `SerializedMod` to this `Mod`.
1203
+ *
1204
+ * @param mod The `SerializedMod` to copy the settings from. Must be the same `Mod` type.
1205
+ * @throws {TypeError} If the `SerializedMod` is not the same type as this `Mod`.
1206
+ */
1207
+ copySettings(mod) {
1208
+ if (mod.acronym !== this.acronym) {
1209
+ throw new TypeError(`Cannot copy settings from ${mod.acronym} to ${this.acronym}`);
1210
+ }
1336
1211
  }
1337
- toString() {
1338
- return `Position: [${this._position.x}, ${this._position.y}], span index: ${this.spanIndex}, span start time: ${this.spanStartTime}`;
1212
+ /**
1213
+ * Whether this `Mod` can be applied to osu!droid.
1214
+ */
1215
+ isApplicableToDroid() {
1216
+ return "droidRanked" in this;
1339
1217
  }
1340
- }
1341
-
1342
- /**
1343
- * Represents the head of a slider.
1344
- */
1345
- class SliderHead extends SliderNestedHitObject {
1346
- constructor(values) {
1347
- super(Object.assign(Object.assign({}, values), { spanIndex: 0, spanStartTime: values.startTime }));
1218
+ /**
1219
+ * Whether this `Mod` can be applied to osu!standard.
1220
+ */
1221
+ isApplicableToOsu() {
1222
+ return "osuRanked" in this;
1348
1223
  }
1349
- }
1350
-
1351
- /**
1352
- * An empty `HitWindow` that does not have any hit windows.
1353
- *
1354
- * No time values are provided (meaning instantaneous hit or miss).
1355
- */
1356
- class EmptyHitWindow extends HitWindow {
1357
- constructor() {
1358
- super(0);
1224
+ /**
1225
+ * Whether this `Mod` can be applied to osu!standard, specifically the osu!stable client.
1226
+ */
1227
+ isApplicableToOsuStable() {
1228
+ return "bitwise" in this;
1359
1229
  }
1360
- get greatWindow() {
1361
- return 0;
1230
+ /**
1231
+ * Whether this `Mod` can be applied to a `Beatmap`.
1232
+ */
1233
+ isApplicableToBeatmap() {
1234
+ return "applyToBeatmap" in this;
1362
1235
  }
1363
- get okWindow() {
1364
- return 0;
1236
+ /**
1237
+ * Whether this `Mod` can be applied to a `BeatmapDifficulty`.
1238
+ */
1239
+ isApplicableToDifficulty() {
1240
+ return "applyToDifficulty" in this;
1365
1241
  }
1366
- get mehWindow() {
1367
- return 0;
1242
+ /**
1243
+ * Whether this `Mod` can be applied to a `BeatmapDifficulty` relative to other `Mod`s and settings.
1244
+ */
1245
+ isApplicableToDifficultyWithSettings() {
1246
+ return "applyToDifficultyWithSettings" in this;
1368
1247
  }
1369
- }
1370
-
1371
- /**
1372
- * Represents a nested hit object that is at the end of a slider path (either repeat or tail).
1373
- */
1374
- class SliderEndCircle extends SliderNestedHitObject {
1248
+ /**
1249
+ * Whether this `Mod` can be applied to a `HitObject`.
1250
+ */
1251
+ isApplicableToHitObject() {
1252
+ return "applyToHitObject" in this;
1253
+ }
1254
+ /**
1255
+ * Whether this `Mod` can be applied to a `HitObject` relative to other `Mod`s and settings.
1256
+ */
1257
+ isApplicableToHitObjectWithSettings() {
1258
+ return "applyToHitObjectWithSettings" in this;
1259
+ }
1260
+ /**
1261
+ * Whether this `Mod` can be applied to a track's playback rate.
1262
+ */
1263
+ isApplicableToTrackRate() {
1264
+ return "applyToRate" in this;
1265
+ }
1266
+ /**
1267
+ * Whether this `Mod` is migratable to a new `Mod` in osu!droid.
1268
+ */
1269
+ isMigratableDroidMod() {
1270
+ return "migrateDroidMod" in this;
1271
+ }
1272
+ /**
1273
+ * Serializes the settings of this `Mod` to an object that can be converted to a JSON.
1274
+ *
1275
+ * @returns The serialized settings of this `Mod`, or `null` if there are no settings.
1276
+ */
1277
+ serializeSettings() {
1278
+ return null;
1279
+ }
1280
+ /**
1281
+ * Returns the string representation of this `Mod`.
1282
+ */
1283
+ toString() {
1284
+ return this.acronym;
1285
+ }
1286
+ }
1287
+
1288
+ /**
1289
+ * Represents the Relax mod.
1290
+ */
1291
+ class ModRelax extends Mod {
1292
+ constructor() {
1293
+ super();
1294
+ this.acronym = "RX";
1295
+ this.name = "Relax";
1296
+ this.droidRanked = false;
1297
+ this.osuRanked = false;
1298
+ this.bitwise = 1 << 7;
1299
+ this.incompatibleMods.add(ModAuto).add(ModAutopilot);
1300
+ }
1301
+ get isDroidRelevant() {
1302
+ return true;
1303
+ }
1304
+ calculateDroidScoreMultiplier() {
1305
+ return 0.001;
1306
+ }
1307
+ get isOsuRelevant() {
1308
+ return true;
1309
+ }
1310
+ get osuScoreMultiplier() {
1311
+ return 0;
1312
+ }
1313
+ }
1314
+
1315
+ /**
1316
+ * Represents the Autopilot mod.
1317
+ */
1318
+ class ModAutopilot extends Mod {
1319
+ constructor() {
1320
+ super();
1321
+ this.acronym = "AP";
1322
+ this.name = "Autopilot";
1323
+ this.droidRanked = false;
1324
+ this.osuRanked = false;
1325
+ this.bitwise = 1 << 13;
1326
+ this.incompatibleMods.add(ModRelax).add(ModAuto);
1327
+ }
1328
+ get isDroidRelevant() {
1329
+ return true;
1330
+ }
1331
+ calculateDroidScoreMultiplier() {
1332
+ return 0.001;
1333
+ }
1334
+ get isOsuRelevant() {
1335
+ return true;
1336
+ }
1337
+ get osuScoreMultiplier() {
1338
+ return 0;
1339
+ }
1340
+ }
1341
+
1342
+ /**
1343
+ * Represents the Auto mod.
1344
+ */
1345
+ class ModAuto extends Mod {
1346
+ constructor() {
1347
+ super();
1348
+ this.acronym = "AT";
1349
+ this.name = "Autoplay";
1350
+ this.droidRanked = false;
1351
+ this.osuRanked = false;
1352
+ this.bitwise = 1 << 11;
1353
+ this.incompatibleMods.add(ModAutopilot).add(ModRelax);
1354
+ }
1355
+ get isDroidRelevant() {
1356
+ return true;
1357
+ }
1358
+ calculateDroidScoreMultiplier() {
1359
+ return 1;
1360
+ }
1361
+ get isOsuRelevant() {
1362
+ return true;
1363
+ }
1364
+ get osuScoreMultiplier() {
1365
+ return 1;
1366
+ }
1367
+ }
1368
+
1369
+ /**
1370
+ * Represents a `Mod` that adjusts the playback rate of a track.
1371
+ */
1372
+ class ModRateAdjust extends Mod {
1373
+ /**
1374
+ * The generic osu!droid score multiplier of this `Mod`.
1375
+ */
1376
+ get droidScoreMultiplier() {
1377
+ return this.trackRateMultiplier >= 1
1378
+ ? 1 + (this.trackRateMultiplier - 1) * 0.24
1379
+ : Math.pow(0.3, (1 - this.trackRateMultiplier) * 4);
1380
+ }
1381
+ /**
1382
+ * Generic getter to determine if this `ModRateAdjust` is relevant.
1383
+ */
1384
+ get isRelevant() {
1385
+ return this.trackRateMultiplier !== 1;
1386
+ }
1387
+ applyToRate(time, rate) {
1388
+ return rate * this.trackRateMultiplier;
1389
+ }
1390
+ }
1391
+
1392
+ /**
1393
+ * Represents the Custom Speed mod.
1394
+ *
1395
+ * This is a replacement `Mod` for speed modify in osu!droid and custom rates in osu!lazer.
1396
+ */
1397
+ class ModCustomSpeed extends ModRateAdjust {
1398
+ constructor(trackRateMultiplier = 1) {
1399
+ super();
1400
+ this.acronym = "CS";
1401
+ this.name = "Custom Speed";
1402
+ this.droidRanked = true;
1403
+ this.osuRanked = false;
1404
+ this.trackRateMultiplier = trackRateMultiplier;
1405
+ }
1406
+ copySettings(mod) {
1407
+ var _a, _b;
1408
+ super.copySettings(mod);
1409
+ this.trackRateMultiplier =
1410
+ (_b = (_a = mod.settings) === null || _a === void 0 ? void 0 : _a.rateMultiplier) !== null && _b !== void 0 ? _b : this.trackRateMultiplier;
1411
+ }
1412
+ get isDroidRelevant() {
1413
+ return this.isRelevant;
1414
+ }
1415
+ calculateDroidScoreMultiplier() {
1416
+ return this.droidScoreMultiplier;
1417
+ }
1418
+ get isOsuRelevant() {
1419
+ return this.isRelevant;
1420
+ }
1421
+ get osuScoreMultiplier() {
1422
+ // Round to the nearest multiple of 0.1.
1423
+ let value = Math.trunc(this.trackRateMultiplier * 10) / 10;
1424
+ // Offset back to 0.
1425
+ --value;
1426
+ return this.trackRateMultiplier >= 1 ? 1 + value / 5 : 0.6 + value;
1427
+ }
1428
+ serializeSettings() {
1429
+ return { rateMultiplier: this.trackRateMultiplier };
1430
+ }
1431
+ toString() {
1432
+ return `${super.toString()} (${this.trackRateMultiplier.toFixed(2)}x)`;
1433
+ }
1434
+ }
1435
+
1436
+ /**
1437
+ * Represents a hitobject that can be nested within a slider.
1438
+ */
1439
+ class SliderNestedHitObject extends HitObject {
1440
+ constructor(values) {
1441
+ super(values);
1442
+ this.spanIndex = values.spanIndex;
1443
+ this.spanStartTime = values.spanStartTime;
1444
+ }
1445
+ toString() {
1446
+ return `Position: [${this._position.x}, ${this._position.y}], span index: ${this.spanIndex}, span start time: ${this.spanStartTime}`;
1447
+ }
1448
+ }
1449
+
1450
+ /**
1451
+ * Represents the head of a slider.
1452
+ */
1453
+ class SliderHead extends SliderNestedHitObject {
1454
+ constructor(values) {
1455
+ super(Object.assign(Object.assign({}, values), { spanIndex: 0, spanStartTime: values.startTime }));
1456
+ }
1457
+ }
1458
+
1459
+ /**
1460
+ * An empty `HitWindow` that does not have any hit windows.
1461
+ *
1462
+ * No time values are provided (meaning instantaneous hit or miss).
1463
+ */
1464
+ class EmptyHitWindow extends HitWindow {
1465
+ constructor() {
1466
+ super(0);
1467
+ }
1468
+ get greatWindow() {
1469
+ return 0;
1470
+ }
1471
+ get okWindow() {
1472
+ return 0;
1473
+ }
1474
+ get mehWindow() {
1475
+ return 0;
1476
+ }
1477
+ }
1478
+
1479
+ /**
1480
+ * Represents a nested hit object that is at the end of a slider path (either repeat or tail).
1481
+ */
1482
+ class SliderEndCircle extends SliderNestedHitObject {
1375
1483
  constructor(values) {
1376
1484
  super(values);
1377
1485
  this.sliderSpanDuration = values.sliderSpanDuration;
@@ -1941,731 +2049,7 @@ Slider.baseTickSample = new BankHitSampleInfo("slidertick");
1941
2049
  Slider.legacyLastTickOffset = 36;
1942
2050
 
1943
2051
  /**
1944
- * Represents the osu! playfield.
1945
- */
1946
- class Playfield {
1947
- }
1948
- /**
1949
- * The size of the playfield, which is 512x384.
1950
- */
1951
- Playfield.baseSize = new Vector2(512, 384);
1952
-
1953
- /**
1954
- * Represents a spinner in a beatmap.
1955
- *
1956
- * All we need from spinners is their duration. The
1957
- * position of a spinner is always at 256x192.
1958
- */
1959
- class Spinner extends HitObject {
1960
- get endTime() {
1961
- return this._endTime;
1962
- }
1963
- constructor(values) {
1964
- super(Object.assign(Object.assign({}, values), { position: Playfield.baseSize.divide(2) }));
1965
- this._endTime = values.endTime;
1966
- }
1967
- applySamples(controlPoints) {
1968
- super.applySamples(controlPoints);
1969
- const samplePoints = controlPoints.sample.between(this.startTime + HitObject.controlPointLeniency, this.endTime + HitObject.controlPointLeniency);
1970
- this.auxiliarySamples.length = 0;
1971
- this.auxiliarySamples.push(new SequenceHitSampleInfo(samplePoints.map((s) => new TimedHitSampleInfo(s.time, s.applyTo(Spinner.baseSpinnerSpinSample)))));
1972
- this.auxiliarySamples.push(new SequenceHitSampleInfo(samplePoints.map((s) => new TimedHitSampleInfo(s.time, s.applyTo(Spinner.baseSpinnerBonusSample)))));
1973
- }
1974
- getStackedPosition() {
1975
- return this.position;
1976
- }
1977
- getStackedEndPosition() {
1978
- return this.position;
1979
- }
1980
- createHitWindow() {
1981
- return new EmptyHitWindow();
1982
- }
1983
- toString() {
1984
- return `Position: [${this._position.x}, ${this._position.y}], duration: ${this.duration}`;
1985
- }
1986
- }
1987
- Spinner.baseSpinnerSpinSample = new BankHitSampleInfo("spinnerspin");
1988
- Spinner.baseSpinnerBonusSample = new BankHitSampleInfo("spinnerbonus");
1989
-
1990
- /**
1991
- * Contains information about hit objects of a beatmap.
1992
- */
1993
- class BeatmapHitObjects {
1994
- constructor() {
1995
- this._objects = [];
1996
- this._circles = 0;
1997
- this._sliders = 0;
1998
- this._spinners = 0;
1999
- }
2000
- /**
2001
- * The objects of the beatmap.
2002
- */
2003
- get objects() {
2004
- return this._objects;
2005
- }
2006
- /**
2007
- * The amount of circles in the beatmap.
2008
- */
2009
- get circles() {
2010
- return this._circles;
2011
- }
2012
- /**
2013
- * The amount of sliders in the beatmap.
2014
- */
2015
- get sliders() {
2016
- return this._sliders;
2017
- }
2018
- /**
2019
- * The amount of spinners in the beatmap.
2020
- */
2021
- get spinners() {
2022
- return this._spinners;
2023
- }
2024
- /**
2025
- * The amount of slider ticks in the beatmap.
2026
- *
2027
- * This iterates through all objects and should be stored locally or used sparingly.
2028
- */
2029
- get sliderTicks() {
2030
- return this.objects.reduce((acc, cur) => (cur instanceof Slider ? acc + cur.ticks : acc), 0);
2031
- }
2032
- /**
2033
- * The amount of sliderends in the beatmap.
2034
- */
2035
- get sliderEnds() {
2036
- return this.sliders;
2037
- }
2038
- /**
2039
- * The amount of slider repeat points in the beatmap.
2040
- *
2041
- * This iterates through all objects and should be stored locally or used sparingly.
2042
- */
2043
- get sliderRepeatPoints() {
2044
- return this.objects.reduce((acc, cur) => (cur instanceof Slider ? acc + cur.repeatCount : acc), 0);
2045
- }
2046
- /**
2047
- * Adds hitobjects.
2048
- *
2049
- * The sorting order of hitobjects will be maintained.
2050
- *
2051
- * @param objects The hitobjects to add.
2052
- */
2053
- add(...objects) {
2054
- for (const object of objects) {
2055
- // Objects may be out of order *only* if a user has manually edited an .osu file.
2056
- // Unfortunately there are "ranked" maps in this state (example: https://osu.ppy.sh/s/594828).
2057
- // Finding index is used to guarantee that the parsing order of hitobjects with equal start times is maintained (stably-sorted).
2058
- this._objects.splice(this.findInsertionIndex(object.startTime), 0, object);
2059
- if (object instanceof Circle) {
2060
- ++this._circles;
2061
- }
2062
- else if (object instanceof Slider) {
2063
- ++this._sliders;
2064
- }
2065
- else {
2066
- ++this._spinners;
2067
- }
2068
- }
2069
- }
2070
- /**
2071
- * Removes a hitobject at an index.
2072
- *
2073
- * @param index The index of the hitobject to remove.
2074
- * @returns The hitobject that was removed, `null` if no hitobject was removed.
2075
- */
2076
- removeAt(index) {
2077
- var _a;
2078
- const object = (_a = this._objects.splice(index, 1)[0]) !== null && _a !== void 0 ? _a : null;
2079
- if (object instanceof Circle) {
2080
- --this._circles;
2081
- }
2082
- else if (object instanceof Slider) {
2083
- --this._sliders;
2084
- }
2085
- else if (object instanceof Spinner) {
2086
- --this._spinners;
2087
- }
2088
- return object;
2089
- }
2090
- /**
2091
- * Clears all hitobjects.
2092
- */
2093
- clear() {
2094
- this._objects.length = 0;
2095
- this._circles = 0;
2096
- this._sliders = 0;
2097
- this._spinners = 0;
2098
- }
2099
- /**
2100
- * Finds the insertion index of a hitobject in a given time.
2101
- *
2102
- * @param startTime The start time of the hitobject.
2103
- */
2104
- findInsertionIndex(startTime) {
2105
- if (this._objects.length === 0 ||
2106
- startTime < this._objects[0].startTime) {
2107
- return 0;
2108
- }
2109
- if (startTime >= this._objects.at(-1).startTime) {
2110
- return this._objects.length;
2111
- }
2112
- let l = 0;
2113
- let r = this._objects.length - 2;
2114
- while (l <= r) {
2115
- const pivot = l + ((r - l) >> 1);
2116
- if (this._objects[pivot].startTime < startTime) {
2117
- l = pivot + 1;
2118
- }
2119
- else if (this._objects[pivot].startTime > startTime) {
2120
- r = pivot - 1;
2121
- }
2122
- else {
2123
- return pivot;
2124
- }
2125
- }
2126
- return l;
2127
- }
2128
- }
2129
-
2130
- /**
2131
- * Converts a beatmap for another mode.
2132
- */
2133
- class BeatmapConverter {
2134
- constructor(beatmap) {
2135
- this.beatmap = beatmap;
2136
- }
2137
- /**
2138
- * Converts the beatmap.
2139
- *
2140
- * @returns The converted beatmap.
2141
- */
2142
- convert() {
2143
- const converted = new Beatmap(this.beatmap);
2144
- // Shallow clone isn't enough to ensure we don't mutate some beatmap properties unexpectedly.
2145
- converted.difficulty = new BeatmapDifficulty(this.beatmap.difficulty);
2146
- converted.hitObjects = this.convertHitObjects();
2147
- return converted;
2148
- }
2149
- convertHitObjects() {
2150
- const hitObjects = new BeatmapHitObjects();
2151
- this.beatmap.hitObjects.objects.forEach((hitObject) => {
2152
- hitObjects.add(this.convertHitObject(hitObject));
2153
- });
2154
- return hitObjects;
2155
- }
2156
- convertHitObject(hitObject) {
2157
- let object;
2158
- if (hitObject instanceof Circle) {
2159
- object = new Circle({
2160
- startTime: hitObject.startTime,
2161
- position: hitObject.position,
2162
- newCombo: hitObject.isNewCombo,
2163
- type: hitObject.type,
2164
- comboOffset: hitObject.comboOffset,
2165
- });
2166
- }
2167
- else if (hitObject instanceof Slider) {
2168
- object = new Slider({
2169
- startTime: hitObject.startTime,
2170
- position: hitObject.position,
2171
- newCombo: hitObject.isNewCombo,
2172
- type: hitObject.type,
2173
- path: hitObject.path,
2174
- repeatCount: hitObject.repeatCount,
2175
- nodeSamples: hitObject.nodeSamples,
2176
- comboOffset: hitObject.comboOffset,
2177
- tickDistanceMultiplier:
2178
- // Prior to v8, speed multipliers don't adjust for how many ticks are generated over the same distance.
2179
- // This results in more (or less) ticks being generated in <v8 maps for the same time duration.
2180
- this.beatmap.formatVersion < 8
2181
- ? 1 /
2182
- this.beatmap.controlPoints.difficulty.controlPointAt(hitObject.startTime).speedMultiplier
2183
- : 1,
2184
- });
2185
- }
2186
- else {
2187
- object = new Spinner({
2188
- startTime: hitObject.startTime,
2189
- endTime: hitObject.endTime,
2190
- type: hitObject.type,
2191
- });
2192
- }
2193
- object.samples = hitObject.samples;
2194
- object.auxiliarySamples = hitObject.auxiliarySamples;
2195
- return object;
2196
- }
2197
- }
2198
-
2199
- /**
2200
- * Provides functionality to alter a beatmap after it has been converted.
2201
- */
2202
- class BeatmapProcessor {
2203
- constructor(beatmap) {
2204
- this.beatmap = beatmap;
2205
- }
2206
- /**
2207
- * Processes the converted beatmap prior to `HitObject.applyDefaults` being invoked.
2208
- *
2209
- * Nested hitobjects generated during `HitObject.applyDefaults` will not be present by this point,
2210
- * and no mods will have been applied to the hitobjects.
2211
- *
2212
- * This can only be used to add alterations to hitobjects generated directly through the conversion process.
2213
- */
2214
- preProcess() {
2215
- let last = null;
2216
- for (const object of this.beatmap.hitObjects.objects) {
2217
- object.updateComboInformation(last);
2218
- last = object;
2219
- }
2220
- // Mark the last object in the beatmap as last in combo.
2221
- if (last) {
2222
- last.isLastInCombo = true;
2223
- }
2224
- }
2225
- /**
2226
- * Processes the converted beatmap after `HitObject.applyDefaults` has been invoked.
2227
- *
2228
- * Nested hitobjects generated during `HitObject.applyDefaults` wil be present by this point,
2229
- * and mods will have been applied to all hitobjects.
2230
- *
2231
- * This should be used to add alterations to hitobjects while they are in their most playable state.
2232
- *
2233
- * @param mode The mode to add alterations for.
2234
- */
2235
- postProcess(mode) {
2236
- const objects = this.beatmap.hitObjects.objects;
2237
- if (objects.length === 0) {
2238
- return;
2239
- }
2240
- // Reset stacking
2241
- objects.forEach((h) => {
2242
- h.stackHeight = 0;
2243
- });
2244
- switch (mode) {
2245
- case exports.Modes.droid:
2246
- this.applyDroidStacking();
2247
- break;
2248
- case exports.Modes.osu:
2249
- if (this.beatmap.formatVersion >= 6) {
2250
- this.applyStandardStacking();
2251
- }
2252
- else {
2253
- this.applyStandardOldStacking();
2254
- }
2255
- break;
2256
- }
2257
- }
2258
- applyDroidStacking() {
2259
- const objects = this.beatmap.hitObjects.objects;
2260
- if (objects.length === 0) {
2261
- return;
2262
- }
2263
- const convertedScale = CircleSizeCalculator.standardScaleToDroidScale(objects[0].scale);
2264
- for (let i = 0; i < objects.length - 1; ++i) {
2265
- const current = objects[i];
2266
- const next = objects[i + 1];
2267
- if (current instanceof Circle &&
2268
- next.startTime - current.startTime <
2269
- 2000 * this.beatmap.general.stackLeniency &&
2270
- next.position.getDistance(current.position) <
2271
- Math.sqrt(convertedScale)) {
2272
- next.stackHeight = current.stackHeight + 1;
2273
- }
2274
- }
2275
- }
2276
- applyStandardStacking() {
2277
- const objects = this.beatmap.hitObjects.objects;
2278
- const startIndex = 0;
2279
- const endIndex = objects.length - 1;
2280
- let extendedEndIndex = endIndex;
2281
- if (endIndex < objects.length - 1) {
2282
- for (let i = endIndex; i >= startIndex; --i) {
2283
- let stackBaseIndex = i;
2284
- for (let n = stackBaseIndex + 1; n < objects.length; ++n) {
2285
- const stackBaseObject = objects[stackBaseIndex];
2286
- if (stackBaseObject instanceof Spinner) {
2287
- break;
2288
- }
2289
- const objectN = objects[n];
2290
- if (objectN instanceof Spinner) {
2291
- break;
2292
- }
2293
- const stackThreshold = objectN.timePreempt *
2294
- this.beatmap.general.stackLeniency;
2295
- if (objectN.startTime - stackBaseObject.endTime >
2296
- stackThreshold) {
2297
- // We are no longer within stacking range of the next object.
2298
- break;
2299
- }
2300
- const endPositionDistanceCheck = stackBaseObject instanceof Slider
2301
- ? stackBaseObject.endPosition.getDistance(objectN.position) < BeatmapProcessor.stackDistance
2302
- : false;
2303
- if (stackBaseObject.position.getDistance(objectN.position) <
2304
- BeatmapProcessor.stackDistance ||
2305
- endPositionDistanceCheck) {
2306
- stackBaseIndex = n;
2307
- // Hit objects after the specified update range haven't been reset yet
2308
- objectN.stackHeight = 0;
2309
- }
2310
- }
2311
- if (stackBaseIndex > extendedEndIndex) {
2312
- extendedEndIndex = stackBaseIndex;
2313
- if (extendedEndIndex === objects.length - 1) {
2314
- break;
2315
- }
2316
- }
2317
- }
2318
- }
2319
- // Reverse pass for stack calculation.
2320
- let extendedStartIndex = startIndex;
2321
- for (let i = extendedEndIndex; i > startIndex; --i) {
2322
- let n = i;
2323
- // We should check every note which has not yet got a stack.
2324
- // Consider the case we have two inter-wound stacks and this will make sense.
2325
- //
2326
- // o <-1 o <-2
2327
- // o <-3 o <-4
2328
- //
2329
- // We first process starting from 4 and handle 2,
2330
- // then we come backwards on the i loop iteration until we reach 3 and handle 1.
2331
- // 2 and 1 will be ignored in the i loop because they already have a stack value.
2332
- let objectI = objects[i];
2333
- if (objectI.stackHeight !== 0 || objectI instanceof Spinner) {
2334
- continue;
2335
- }
2336
- const stackThreshold = objectI.timePreempt * this.beatmap.general.stackLeniency;
2337
- // If this object is a hit circle, then we enter this "special" case.
2338
- // It either ends with a stack of hit circles only, or a stack of hit circles that are underneath a slider.
2339
- // Any other case is handled by the "instanceof Slider" code below this.
2340
- if (objectI instanceof Circle) {
2341
- while (--n >= 0) {
2342
- const objectN = objects[n];
2343
- if (objectN instanceof Spinner) {
2344
- continue;
2345
- }
2346
- if (objectI.startTime - objectN.endTime > stackThreshold) {
2347
- // We are no longer within stacking range of the previous object.
2348
- break;
2349
- }
2350
- // Hit objects before the specified update range haven't been reset yet
2351
- if (n < extendedStartIndex) {
2352
- objectN.stackHeight = 0;
2353
- extendedStartIndex = n;
2354
- }
2355
- // This is a special case where hit circles are moved DOWN and RIGHT (negative stacking) if they are under the *last* slider in a stacked pattern.
2356
- // o==o <- slider is at original location
2357
- // o <- hitCircle has stack of -1
2358
- // o <- hitCircle has stack of -2
2359
- if (objectN instanceof Slider &&
2360
- objectN.endPosition.getDistance(objectI.position) <
2361
- BeatmapProcessor.stackDistance) {
2362
- const offset = objectI.stackHeight - objectN.stackHeight + 1;
2363
- for (let j = n + 1; j <= i; ++j) {
2364
- // For each object which was declared under this slider, we will offset it to appear *below* the slider end (rather than above).
2365
- const objectJ = objects[j];
2366
- if (objectN.endPosition.getDistance(objectJ.position) < BeatmapProcessor.stackDistance) {
2367
- objectJ.stackHeight -= offset;
2368
- }
2369
- }
2370
- // We have hit a slider. We should restart calculation using this as the new base.
2371
- // Breaking here will mean that the slider still has a stack count of 0, so will be handled in the i-outer-loop.
2372
- break;
2373
- }
2374
- if (objectN.position.getDistance(objectI.position) <
2375
- BeatmapProcessor.stackDistance) {
2376
- // Keep processing as if there are no sliders. If we come across a slider, this gets cancelled out.
2377
- // NOTE: Sliders with start positions stacking are a special case that is also handled here.
2378
- objectN.stackHeight = objectI.stackHeight + 1;
2379
- objectI = objectN;
2380
- }
2381
- }
2382
- }
2383
- else if (objectI instanceof Slider) {
2384
- // We have hit the first slider in a possible stack.
2385
- // From this point on, we ALWAYS stack positive regardless.
2386
- while (--n >= startIndex) {
2387
- const objectN = objects[n];
2388
- if (objectN instanceof Spinner) {
2389
- continue;
2390
- }
2391
- if (objectI.startTime - objectN.startTime >
2392
- stackThreshold) {
2393
- // We are no longer within stacking range of the previous object.
2394
- break;
2395
- }
2396
- if (objectN.endPosition.getDistance(objectI.position) <
2397
- BeatmapProcessor.stackDistance) {
2398
- objectN.stackHeight = objectI.stackHeight + 1;
2399
- objectI = objectN;
2400
- }
2401
- }
2402
- }
2403
- }
2404
- }
2405
- applyStandardOldStacking() {
2406
- const objects = this.beatmap.hitObjects.objects;
2407
- for (let i = 0; i < objects.length; ++i) {
2408
- const currentObject = objects[i];
2409
- if (currentObject.stackHeight !== 0 &&
2410
- !(currentObject instanceof Slider)) {
2411
- continue;
2412
- }
2413
- let startTime = currentObject.endTime;
2414
- let sliderStack = 0;
2415
- const stackThreshold = currentObject.timePreempt * this.beatmap.general.stackLeniency;
2416
- for (let j = i + 1; j < objects.length; ++j) {
2417
- if (objects[j].startTime - stackThreshold > startTime) {
2418
- break;
2419
- }
2420
- // Note the use of `startTime` in the code below doesn't match osu!stable's use of `endTime`.
2421
- // This is because in osu!stable's implementation, `UpdateCalculations` is not called on the inner-loop hitobject (j)
2422
- // and therefore it does not have a correct `endTime`, but instead the default of `endTime = startTime`.
2423
- //
2424
- // Effects of this can be seen on https://osu.ppy.sh/beatmapsets/243#osu/1146 at sliders around 86647 ms, where
2425
- // if we use `endTime` here it would result in unexpected stacking.
2426
- //
2427
- // Reference: https://github.com/ppy/osu/pull/24188
2428
- if (objects[j].position.getDistance(currentObject.position) <
2429
- BeatmapProcessor.stackDistance) {
2430
- ++currentObject.stackHeight;
2431
- startTime = objects[j].startTime;
2432
- }
2433
- else if (objects[j].position.getDistance(currentObject.endPosition) <
2434
- BeatmapProcessor.stackDistance) {
2435
- // Case for sliders - bump notes down and right, rather than up and left.
2436
- ++sliderStack;
2437
- objects[j].stackHeight -= sliderStack;
2438
- startTime = objects[j].startTime;
2439
- }
2440
- }
2441
- }
2442
- }
2443
- }
2444
- BeatmapProcessor.stackDistance = 3;
2445
-
2446
- /**
2447
- * Represents the hit window of osu!droid _with_ the Precise mod.
2448
- */
2449
- class PreciseDroidHitWindow extends HitWindow {
2450
- /**
2451
- * Calculates the overall difficulty value of a great (300) hit window.
2452
- *
2453
- * @param value The value of the hit window, in milliseconds.
2454
- * @returns The overall difficulty value.
2455
- */
2456
- static greatWindowToOD(value) {
2457
- return 5 - (value - 55) / 6;
2458
- }
2459
- /**
2460
- * Calculates the overall difficulty value of a good (100) hit window.
2461
- *
2462
- * @param value The value of the hit window, in milliseconds.
2463
- * @returns The overall difficulty value.
2464
- */
2465
- static okWindowToOD(value) {
2466
- return 5 - (value - 120) / 8;
2467
- }
2468
- /**
2469
- * Calculates the overall difficulty value of a meh (50) hit window.
2470
- *
2471
- * @param value The value of the hit window, in milliseconds.
2472
- * @returns The overall difficulty value.
2473
- */
2474
- static mehWindowToOD(value) {
2475
- return 5 - (value - 180) / 10;
2476
- }
2477
- get greatWindow() {
2478
- return 55 + 6 * (5 - this.overallDifficulty);
2479
- }
2480
- get okWindow() {
2481
- return 120 + 8 * (5 - this.overallDifficulty);
2482
- }
2483
- get mehWindow() {
2484
- return 180 + 10 * (5 - this.overallDifficulty);
2485
- }
2486
- }
2487
-
2488
- /**
2489
- * Represents the Precise mod.
2490
- */
2491
- class ModPrecise extends Mod {
2492
- constructor() {
2493
- super(...arguments);
2494
- this.acronym = "PR";
2495
- this.name = "Precise";
2496
- this.droidRanked = true;
2497
- }
2498
- get isDroidRelevant() {
2499
- return true;
2500
- }
2501
- calculateDroidScoreMultiplier() {
2502
- return 1.06;
2503
- }
2504
- applyToHitObject(mode, hitObject) {
2505
- var _a, _b;
2506
- if (mode !== exports.Modes.droid || hitObject instanceof Spinner) {
2507
- return;
2508
- }
2509
- if (hitObject instanceof Slider) {
2510
- // For sliders, the hit window is enforced in the head - everything else is an instant hit or miss.
2511
- hitObject.head.hitWindow = new PreciseDroidHitWindow((_a = hitObject.head.hitWindow) === null || _a === void 0 ? void 0 : _a.overallDifficulty);
2512
- }
2513
- else {
2514
- hitObject.hitWindow = new PreciseDroidHitWindow((_b = hitObject.hitWindow) === null || _b === void 0 ? void 0 : _b.overallDifficulty);
2515
- }
2516
- }
2517
- }
2518
-
2519
- /**
2520
- * Represents the Relax mod.
2521
- */
2522
- class ModRelax extends Mod {
2523
- constructor() {
2524
- super();
2525
- this.acronym = "RX";
2526
- this.name = "Relax";
2527
- this.droidRanked = false;
2528
- this.osuRanked = false;
2529
- this.bitwise = 1 << 7;
2530
- this.incompatibleMods.add(ModAuto).add(ModAutopilot);
2531
- }
2532
- get isDroidRelevant() {
2533
- return true;
2534
- }
2535
- calculateDroidScoreMultiplier() {
2536
- return 0.001;
2537
- }
2538
- get isOsuRelevant() {
2539
- return true;
2540
- }
2541
- get osuScoreMultiplier() {
2542
- return 0;
2543
- }
2544
- }
2545
-
2546
- /**
2547
- * Represents the Autopilot mod.
2548
- */
2549
- class ModAutopilot extends Mod {
2550
- constructor() {
2551
- super();
2552
- this.acronym = "AP";
2553
- this.name = "Autopilot";
2554
- this.droidRanked = false;
2555
- this.osuRanked = false;
2556
- this.bitwise = 1 << 13;
2557
- this.incompatibleMods.add(ModRelax).add(ModAuto);
2558
- }
2559
- get isDroidRelevant() {
2560
- return true;
2561
- }
2562
- calculateDroidScoreMultiplier() {
2563
- return 0.001;
2564
- }
2565
- get isOsuRelevant() {
2566
- return true;
2567
- }
2568
- get osuScoreMultiplier() {
2569
- return 0;
2570
- }
2571
- }
2572
-
2573
- /**
2574
- * Represents the Auto mod.
2575
- */
2576
- class ModAuto extends Mod {
2577
- constructor() {
2578
- super();
2579
- this.acronym = "AT";
2580
- this.name = "Autoplay";
2581
- this.droidRanked = false;
2582
- this.osuRanked = false;
2583
- this.bitwise = 1 << 11;
2584
- this.incompatibleMods.add(ModAutopilot).add(ModRelax);
2585
- }
2586
- get isDroidRelevant() {
2587
- return true;
2588
- }
2589
- calculateDroidScoreMultiplier() {
2590
- return 1;
2591
- }
2592
- get isOsuRelevant() {
2593
- return true;
2594
- }
2595
- get osuScoreMultiplier() {
2596
- return 1;
2597
- }
2598
- }
2599
-
2600
- /**
2601
- * Represents a `Mod` that adjusts the playback rate of a track.
2602
- */
2603
- class ModRateAdjust extends Mod {
2604
- /**
2605
- * The generic osu!droid score multiplier of this `Mod`.
2606
- */
2607
- get droidScoreMultiplier() {
2608
- return this.trackRateMultiplier >= 1
2609
- ? 1 + (this.trackRateMultiplier - 1) * 0.24
2610
- : Math.pow(0.3, (1 - this.trackRateMultiplier) * 4);
2611
- }
2612
- /**
2613
- * Generic getter to determine if this `ModRateAdjust` is relevant.
2614
- */
2615
- get isRelevant() {
2616
- return this.trackRateMultiplier !== 1;
2617
- }
2618
- applyToRate(time, rate) {
2619
- return rate * this.trackRateMultiplier;
2620
- }
2621
- }
2622
-
2623
- /**
2624
- * Represents the Custom Speed mod.
2625
- *
2626
- * This is a replacement `Mod` for speed modify in osu!droid and custom rates in osu!lazer.
2627
- */
2628
- class ModCustomSpeed extends ModRateAdjust {
2629
- constructor(trackRateMultiplier = 1) {
2630
- super();
2631
- this.acronym = "CS";
2632
- this.name = "Custom Speed";
2633
- this.droidRanked = true;
2634
- this.osuRanked = false;
2635
- this.trackRateMultiplier = trackRateMultiplier;
2636
- }
2637
- copySettings(mod) {
2638
- var _a, _b;
2639
- super.copySettings(mod);
2640
- this.trackRateMultiplier =
2641
- (_b = (_a = mod.settings) === null || _a === void 0 ? void 0 : _a.rateMultiplier) !== null && _b !== void 0 ? _b : this.trackRateMultiplier;
2642
- }
2643
- get isDroidRelevant() {
2644
- return this.isRelevant;
2645
- }
2646
- calculateDroidScoreMultiplier() {
2647
- return this.droidScoreMultiplier;
2648
- }
2649
- get isOsuRelevant() {
2650
- return this.isRelevant;
2651
- }
2652
- get osuScoreMultiplier() {
2653
- // Round to the nearest multiple of 0.1.
2654
- let value = Math.trunc(this.trackRateMultiplier * 10) / 10;
2655
- // Offset back to 0.
2656
- --value;
2657
- return this.trackRateMultiplier >= 1 ? 1 + value / 5 : 0.6 + value;
2658
- }
2659
- serializeSettings() {
2660
- return { rateMultiplier: this.trackRateMultiplier };
2661
- }
2662
- toString() {
2663
- return `${super.toString()} (${this.trackRateMultiplier.toFixed(2)}x)`;
2664
- }
2665
- }
2666
-
2667
- /**
2668
- * Represents the Difficulty Adjust mod.
2052
+ * Represents the Difficulty Adjust mod.
2669
2053
  */
2670
2054
  class ModDifficultyAdjust extends Mod {
2671
2055
  get isRelevant() {
@@ -2721,7 +2105,7 @@ class ModDifficultyAdjust extends Mod {
2721
2105
  get osuScoreMultiplier() {
2722
2106
  return 0.5;
2723
2107
  }
2724
- applyToDifficultyWithSettings(mode, difficulty, mods) {
2108
+ applyToDifficultyWithSettings(_, difficulty, mods) {
2725
2109
  var _a, _b, _c, _d;
2726
2110
  difficulty.cs = (_a = this.cs) !== null && _a !== void 0 ? _a : difficulty.cs;
2727
2111
  difficulty.ar = (_b = this.ar) !== null && _b !== void 0 ? _b : difficulty.ar;
@@ -2731,19 +2115,22 @@ class ModDifficultyAdjust extends Mod {
2731
2115
  // This makes the player perceive the AR as is under all speed multipliers.
2732
2116
  if (this.ar !== undefined) {
2733
2117
  const preempt = BeatmapDifficulty.difficultyRange(this.ar, HitObject.preemptMax, HitObject.preemptMid, HitObject.preemptMin);
2734
- const trackRate = this.calculateTrackRate(mods);
2118
+ const trackRate = this.calculateTrackRate(mods.values());
2735
2119
  difficulty.ar = BeatmapDifficulty.inverseDifficultyRange(preempt * trackRate, HitObject.preemptMax, HitObject.preemptMid, HitObject.preemptMin);
2736
2120
  }
2737
2121
  }
2738
- applyToHitObjectWithSettings(mode, hitObject, mods) {
2122
+ applyToHitObjectWithSettings(_, hitObject, mods) {
2739
2123
  // Special case for force AR, where the AR value is kept constant with respect to game time.
2740
2124
  // This makes the player perceive the fade in animation as is under all speed multipliers.
2741
2125
  if (this.ar === undefined) {
2742
2126
  return;
2743
2127
  }
2744
- // IMPORTANT: This does not use `ModUtil.calculateRateWithMods` to avoid circular dependency.
2745
- const trackRate = this.calculateTrackRate(mods);
2746
- hitObject.timeFadeIn *= trackRate;
2128
+ this.applyFadeAdjustment(hitObject, mods);
2129
+ if (hitObject instanceof Slider) {
2130
+ for (const nested of hitObject.nestedHitObjects) {
2131
+ this.applyFadeAdjustment(nested, mods);
2132
+ }
2133
+ }
2747
2134
  }
2748
2135
  serializeSettings() {
2749
2136
  if (this.cs === undefined &&
@@ -2767,11 +2154,24 @@ class ModDifficultyAdjust extends Mod {
2767
2154
  }
2768
2155
  return settings;
2769
2156
  }
2157
+ applyFadeAdjustment(hitObject, mods) {
2158
+ // IMPORTANT: These do not use `ModUtil.calculateRateWithMods` to avoid circular dependency.
2159
+ const initialTrackRate = this.calculateTrackRate(mods.values());
2160
+ const currentTrackRate = this.calculateTrackRate(mods.values(), hitObject.startTime);
2161
+ // Cancel the rate that was initially applied to timePreempt (via applyToDifficulty above and
2162
+ // HitObject.applyDefaults) and apply the current one.
2163
+ hitObject.timePreempt *= currentTrackRate / initialTrackRate;
2164
+ hitObject.timeFadeIn *= currentTrackRate;
2165
+ }
2770
2166
  calculateTrackRate(mods, time = 0) {
2771
2167
  // IMPORTANT: This does not use `ModUtil.calculateRateWithMods` to avoid circular dependency.
2772
- return mods.reduce((rate, mod) => mod.isApplicableToTrackRate()
2773
- ? mod.applyToRate(time, rate)
2774
- : rate, 1);
2168
+ let rate = 1;
2169
+ for (const mod of mods) {
2170
+ if (mod.isApplicableToTrackRate()) {
2171
+ rate = mod.applyToRate(time, rate);
2172
+ }
2173
+ }
2174
+ return rate;
2775
2175
  }
2776
2176
  toString() {
2777
2177
  const settings = [];
@@ -2875,6 +2275,16 @@ class ModDoubleTime extends ModRateAdjust {
2875
2275
  }
2876
2276
  }
2877
2277
 
2278
+ /**
2279
+ * Represents the osu! playfield.
2280
+ */
2281
+ class Playfield {
2282
+ }
2283
+ /**
2284
+ * The size of the playfield, which is 512x384.
2285
+ */
2286
+ Playfield.baseSize = new Vector2(512, 384);
2287
+
2878
2288
  /**
2879
2289
  * Types of slider paths.
2880
2290
  */
@@ -3418,17 +2828,202 @@ class SliderPath {
3418
2828
  }
3419
2829
 
3420
2830
  /**
3421
- * Represents the HardRock mod.
2831
+ * Represents the HardRock mod.
2832
+ */
2833
+ class ModHardRock extends Mod {
2834
+ constructor() {
2835
+ super();
2836
+ this.acronym = "HR";
2837
+ this.name = "HardRock";
2838
+ this.droidRanked = true;
2839
+ this.osuRanked = true;
2840
+ this.bitwise = 1 << 4;
2841
+ this.incompatibleMods.add(ModEasy);
2842
+ }
2843
+ get isDroidRelevant() {
2844
+ return true;
2845
+ }
2846
+ calculateDroidScoreMultiplier() {
2847
+ return 1.06;
2848
+ }
2849
+ get isOsuRelevant() {
2850
+ return true;
2851
+ }
2852
+ get osuScoreMultiplier() {
2853
+ return 1.06;
2854
+ }
2855
+ applyToDifficulty(mode, difficulty) {
2856
+ switch (mode) {
2857
+ case exports.Modes.droid: {
2858
+ const scale = CircleSizeCalculator.droidCSToDroidScale(difficulty.cs);
2859
+ difficulty.cs = CircleSizeCalculator.droidScaleToDroidCS(scale - 0.125);
2860
+ break;
2861
+ }
2862
+ case exports.Modes.osu:
2863
+ // CS uses a custom 1.3 ratio.
2864
+ difficulty.cs = this.applySetting(difficulty.cs, 1.3);
2865
+ break;
2866
+ }
2867
+ difficulty.ar = this.applySetting(difficulty.ar);
2868
+ difficulty.od = this.applySetting(difficulty.od);
2869
+ difficulty.hp = this.applySetting(difficulty.hp);
2870
+ }
2871
+ applyToHitObject(_, hitObject) {
2872
+ // Reflect the position of the hit object.
2873
+ hitObject.position = this.reflectVector(hitObject.position);
2874
+ if (!(hitObject instanceof Slider)) {
2875
+ return;
2876
+ }
2877
+ // Reflect the control points of the slider. This will reflect the positions of head and tail circles.
2878
+ hitObject.path = new SliderPath({
2879
+ pathType: hitObject.path.pathType,
2880
+ controlPoints: hitObject.path.controlPoints.map((v) => this.reflectControlPoint(v)),
2881
+ expectedDistance: hitObject.path.expectedDistance,
2882
+ });
2883
+ // Reflect the position of slider ticks and repeats.
2884
+ hitObject.nestedHitObjects.slice(1, -1).forEach((obj) => {
2885
+ obj.position = this.reflectVector(obj.position);
2886
+ });
2887
+ }
2888
+ reflectVector(vector) {
2889
+ return new Vector2(vector.x, Playfield.baseSize.y - vector.y);
2890
+ }
2891
+ reflectControlPoint(vector) {
2892
+ return new Vector2(vector.x, -vector.y);
2893
+ }
2894
+ applySetting(value, ratio = 1.4) {
2895
+ return Math.min(value * ratio, 10);
2896
+ }
2897
+ }
2898
+
2899
+ /**
2900
+ * Represents the Easy mod.
2901
+ */
2902
+ class ModEasy extends Mod {
2903
+ constructor() {
2904
+ super();
2905
+ this.acronym = "EZ";
2906
+ this.name = "Easy";
2907
+ this.droidRanked = true;
2908
+ this.osuRanked = true;
2909
+ this.bitwise = 1 << 1;
2910
+ this.incompatibleMods.add(ModHardRock);
2911
+ }
2912
+ get isDroidRelevant() {
2913
+ return true;
2914
+ }
2915
+ calculateDroidScoreMultiplier() {
2916
+ return 0.5;
2917
+ }
2918
+ get isOsuRelevant() {
2919
+ return true;
2920
+ }
2921
+ get osuScoreMultiplier() {
2922
+ return 0.5;
2923
+ }
2924
+ applyToDifficulty(mode, difficulty) {
2925
+ switch (mode) {
2926
+ case exports.Modes.droid: {
2927
+ const scale = CircleSizeCalculator.droidCSToDroidScale(difficulty.cs);
2928
+ difficulty.cs = CircleSizeCalculator.droidScaleToDroidCS(scale + 0.125);
2929
+ break;
2930
+ }
2931
+ case exports.Modes.osu:
2932
+ difficulty.cs /= 2;
2933
+ }
2934
+ difficulty.ar /= 2;
2935
+ difficulty.od /= 2;
2936
+ difficulty.hp /= 2;
2937
+ }
2938
+ }
2939
+
2940
+ /**
2941
+ * Represents the Flashlight mod.
2942
+ */
2943
+ class ModFlashlight extends Mod {
2944
+ constructor() {
2945
+ super(...arguments);
2946
+ this.acronym = "FL";
2947
+ this.name = "Flashlight";
2948
+ this.droidRanked = true;
2949
+ this.osuRanked = true;
2950
+ this.bitwise = 1 << 10;
2951
+ /**
2952
+ * The amount of seconds until the Flashlight follow area reaches the cursor.
2953
+ */
2954
+ this.followDelay = ModFlashlight.defaultFollowDelay;
2955
+ }
2956
+ copySettings(mod) {
2957
+ var _a, _b;
2958
+ super.copySettings(mod);
2959
+ this.followDelay =
2960
+ (_b = (_a = mod.settings) === null || _a === void 0 ? void 0 : _a.areaFollowDelay) !== null && _b !== void 0 ? _b : this.followDelay;
2961
+ }
2962
+ get isDroidRelevant() {
2963
+ return true;
2964
+ }
2965
+ calculateDroidScoreMultiplier() {
2966
+ return 1.12;
2967
+ }
2968
+ get isOsuRelevant() {
2969
+ return true;
2970
+ }
2971
+ get osuScoreMultiplier() {
2972
+ return 1.12;
2973
+ }
2974
+ serializeSettings() {
2975
+ return { areaFollowDelay: this.followDelay };
2976
+ }
2977
+ toString() {
2978
+ if (this.followDelay === ModFlashlight.defaultFollowDelay) {
2979
+ return super.toString();
2980
+ }
2981
+ return `${super.toString()} (${this.followDelay.toFixed(2)}s follow delay)`;
2982
+ }
2983
+ }
2984
+ /**
2985
+ * The default amount of seconds until the Flashlight follow area reaches the cursor.
2986
+ */
2987
+ ModFlashlight.defaultFollowDelay = 0.12;
2988
+
2989
+ /**
2990
+ * Represents the Traceable mod.
2991
+ */
2992
+ class ModTraceable extends Mod {
2993
+ constructor() {
2994
+ super();
2995
+ this.acronym = "TC";
2996
+ this.name = "Traceable";
2997
+ this.droidRanked = false;
2998
+ this.osuRanked = false;
2999
+ this.incompatibleMods.add(ModHidden);
3000
+ }
3001
+ get isDroidRelevant() {
3002
+ return true;
3003
+ }
3004
+ calculateDroidScoreMultiplier() {
3005
+ return 1.06;
3006
+ }
3007
+ get isOsuRelevant() {
3008
+ return true;
3009
+ }
3010
+ get osuScoreMultiplier() {
3011
+ return 1;
3012
+ }
3013
+ }
3014
+
3015
+ /**
3016
+ * Represents the Hidden mod.
3422
3017
  */
3423
- class ModHardRock extends Mod {
3018
+ class ModHidden extends Mod {
3424
3019
  constructor() {
3425
3020
  super();
3426
- this.acronym = "HR";
3427
- this.name = "HardRock";
3021
+ this.acronym = "HD";
3022
+ this.name = "Hidden";
3428
3023
  this.droidRanked = true;
3429
3024
  this.osuRanked = true;
3430
- this.bitwise = 1 << 4;
3431
- this.incompatibleMods.add(ModEasy);
3025
+ this.bitwise = 1 << 3;
3026
+ this.incompatibleMods.add(ModTraceable);
3432
3027
  }
3433
3028
  get isDroidRelevant() {
3434
3029
  return true;
@@ -3442,62 +3037,86 @@ class ModHardRock extends Mod {
3442
3037
  get osuScoreMultiplier() {
3443
3038
  return 1.06;
3444
3039
  }
3445
- applyToDifficulty(mode, difficulty) {
3446
- switch (mode) {
3447
- case exports.Modes.droid: {
3448
- const scale = CircleSizeCalculator.droidCSToDroidScale(difficulty.cs);
3449
- difficulty.cs = CircleSizeCalculator.droidScaleToDroidCS(scale - 0.125);
3450
- break;
3040
+ applyToBeatmap(beatmap) {
3041
+ const applyFadeInAdjustment = (hitObject) => {
3042
+ hitObject.timeFadeIn =
3043
+ hitObject.timePreempt * ModHidden.fadeInDurationMultiplier;
3044
+ if (hitObject instanceof Slider) {
3045
+ hitObject.nestedHitObjects.forEach(applyFadeInAdjustment);
3451
3046
  }
3452
- case exports.Modes.osu:
3453
- // CS uses a custom 1.3 ratio.
3454
- difficulty.cs = this.applySetting(difficulty.cs, 1.3);
3455
- break;
3456
- }
3457
- difficulty.ar = this.applySetting(difficulty.ar);
3458
- difficulty.od = this.applySetting(difficulty.od);
3459
- difficulty.hp = this.applySetting(difficulty.hp);
3047
+ };
3048
+ beatmap.hitObjects.objects.forEach(applyFadeInAdjustment);
3460
3049
  }
3461
- applyToHitObject(_, hitObject) {
3462
- // Reflect the position of the hit object.
3463
- hitObject.position = this.reflectVector(hitObject.position);
3464
- if (!(hitObject instanceof Slider)) {
3465
- return;
3466
- }
3467
- // Reflect the control points of the slider. This will reflect the positions of head and tail circles.
3468
- hitObject.path = new SliderPath({
3469
- pathType: hitObject.path.pathType,
3470
- controlPoints: hitObject.path.controlPoints.map((v) => this.reflectControlPoint(v)),
3471
- expectedDistance: hitObject.path.expectedDistance,
3472
- });
3473
- // Reflect the position of slider ticks and repeats.
3474
- hitObject.nestedHitObjects.slice(1, -1).forEach((obj) => {
3475
- obj.position = this.reflectVector(obj.position);
3476
- });
3050
+ }
3051
+ ModHidden.fadeInDurationMultiplier = 0.4;
3052
+ ModHidden.fadeOutDurationMultiplier = 0.3;
3053
+
3054
+ /**
3055
+ * Represents the SuddenDeath mod.
3056
+ */
3057
+ class ModSuddenDeath extends Mod {
3058
+ constructor() {
3059
+ super();
3060
+ this.acronym = "SD";
3061
+ this.name = "Sudden Death";
3062
+ this.droidRanked = true;
3063
+ this.osuRanked = true;
3064
+ this.bitwise = 1 << 5;
3065
+ this.incompatibleMods.add(ModNoFail).add(ModPerfect);
3477
3066
  }
3478
- reflectVector(vector) {
3479
- return new Vector2(vector.x, Playfield.baseSize.y - vector.y);
3067
+ get isDroidRelevant() {
3068
+ return true;
3480
3069
  }
3481
- reflectControlPoint(vector) {
3482
- return new Vector2(vector.x, -vector.y);
3070
+ calculateDroidScoreMultiplier() {
3071
+ return 1;
3483
3072
  }
3484
- applySetting(value, ratio = 1.4) {
3485
- return Math.min(value * ratio, 10);
3073
+ get isOsuRelevant() {
3074
+ return true;
3075
+ }
3076
+ get osuScoreMultiplier() {
3077
+ return 1;
3486
3078
  }
3487
3079
  }
3488
3080
 
3489
3081
  /**
3490
- * Represents the Easy mod.
3082
+ * Represents the Perfect mod.
3491
3083
  */
3492
- class ModEasy extends Mod {
3084
+ class ModPerfect extends Mod {
3493
3085
  constructor() {
3494
3086
  super();
3495
- this.acronym = "EZ";
3496
- this.name = "Easy";
3087
+ this.acronym = "PF";
3088
+ this.name = "Perfect";
3497
3089
  this.droidRanked = true;
3498
3090
  this.osuRanked = true;
3499
- this.bitwise = 1 << 1;
3500
- this.incompatibleMods.add(ModHardRock);
3091
+ this.bitwise = 1 << 14;
3092
+ this.incompatibleMods.add(ModNoFail).add(ModSuddenDeath);
3093
+ }
3094
+ get isDroidRelevant() {
3095
+ return true;
3096
+ }
3097
+ calculateDroidScoreMultiplier() {
3098
+ return 1;
3099
+ }
3100
+ get isOsuRelevant() {
3101
+ return true;
3102
+ }
3103
+ get osuScoreMultiplier() {
3104
+ return 1;
3105
+ }
3106
+ }
3107
+
3108
+ /**
3109
+ * Represents the NoFail mod.
3110
+ */
3111
+ class ModNoFail extends Mod {
3112
+ constructor() {
3113
+ super();
3114
+ this.acronym = "NF";
3115
+ this.name = "NoFail";
3116
+ this.droidRanked = true;
3117
+ this.osuRanked = true;
3118
+ this.bitwise = 1 << 0;
3119
+ this.incompatibleMods.add(ModPerfect).add(ModSuddenDeath);
3501
3120
  }
3502
3121
  get isDroidRelevant() {
3503
3122
  return true;
@@ -3511,88 +3130,179 @@ class ModEasy extends Mod {
3511
3130
  get osuScoreMultiplier() {
3512
3131
  return 0.5;
3513
3132
  }
3514
- applyToDifficulty(mode, difficulty) {
3515
- switch (mode) {
3516
- case exports.Modes.droid: {
3517
- const scale = CircleSizeCalculator.droidCSToDroidScale(difficulty.cs);
3518
- difficulty.cs = CircleSizeCalculator.droidScaleToDroidCS(scale + 0.125);
3519
- break;
3520
- }
3521
- case exports.Modes.osu:
3522
- difficulty.cs /= 2;
3133
+ }
3134
+
3135
+ /**
3136
+ * Represents a spinner in a beatmap.
3137
+ *
3138
+ * All we need from spinners is their duration. The
3139
+ * position of a spinner is always at 256x192.
3140
+ */
3141
+ class Spinner extends HitObject {
3142
+ get endTime() {
3143
+ return this._endTime;
3144
+ }
3145
+ constructor(values) {
3146
+ super(Object.assign(Object.assign({}, values), { position: Playfield.baseSize.divide(2) }));
3147
+ this._endTime = values.endTime;
3148
+ }
3149
+ applySamples(controlPoints) {
3150
+ super.applySamples(controlPoints);
3151
+ const samplePoints = controlPoints.sample.between(this.startTime + HitObject.controlPointLeniency, this.endTime + HitObject.controlPointLeniency);
3152
+ this.auxiliarySamples.length = 0;
3153
+ this.auxiliarySamples.push(new SequenceHitSampleInfo(samplePoints.map((s) => new TimedHitSampleInfo(s.time, s.applyTo(Spinner.baseSpinnerSpinSample)))));
3154
+ this.auxiliarySamples.push(new SequenceHitSampleInfo(samplePoints.map((s) => new TimedHitSampleInfo(s.time, s.applyTo(Spinner.baseSpinnerBonusSample)))));
3155
+ }
3156
+ getStackedPosition() {
3157
+ return this.position;
3158
+ }
3159
+ getStackedEndPosition() {
3160
+ return this.position;
3161
+ }
3162
+ createHitWindow() {
3163
+ return new EmptyHitWindow();
3164
+ }
3165
+ toString() {
3166
+ return `Position: [${this._position.x}, ${this._position.y}], duration: ${this.duration}`;
3167
+ }
3168
+ }
3169
+ Spinner.baseSpinnerSpinSample = new BankHitSampleInfo("spinnerspin");
3170
+ Spinner.baseSpinnerBonusSample = new BankHitSampleInfo("spinnerbonus");
3171
+
3172
+ /**
3173
+ * Represents the hit window of osu!droid _with_ the Precise mod.
3174
+ */
3175
+ class PreciseDroidHitWindow extends HitWindow {
3176
+ /**
3177
+ * Calculates the overall difficulty value of a great (300) hit window.
3178
+ *
3179
+ * @param value The value of the hit window, in milliseconds.
3180
+ * @returns The overall difficulty value.
3181
+ */
3182
+ static greatWindowToOD(value) {
3183
+ return 5 - (value - 55) / 6;
3184
+ }
3185
+ /**
3186
+ * Calculates the overall difficulty value of a good (100) hit window.
3187
+ *
3188
+ * @param value The value of the hit window, in milliseconds.
3189
+ * @returns The overall difficulty value.
3190
+ */
3191
+ static okWindowToOD(value) {
3192
+ return 5 - (value - 120) / 8;
3193
+ }
3194
+ /**
3195
+ * Calculates the overall difficulty value of a meh (50) hit window.
3196
+ *
3197
+ * @param value The value of the hit window, in milliseconds.
3198
+ * @returns The overall difficulty value.
3199
+ */
3200
+ static mehWindowToOD(value) {
3201
+ return 5 - (value - 180) / 10;
3202
+ }
3203
+ get greatWindow() {
3204
+ return 55 + 6 * (5 - this.overallDifficulty);
3205
+ }
3206
+ get okWindow() {
3207
+ return 120 + 8 * (5 - this.overallDifficulty);
3208
+ }
3209
+ get mehWindow() {
3210
+ return 180 + 10 * (5 - this.overallDifficulty);
3211
+ }
3212
+ }
3213
+
3214
+ /**
3215
+ * Represents the Precise mod.
3216
+ */
3217
+ class ModPrecise extends Mod {
3218
+ constructor() {
3219
+ super(...arguments);
3220
+ this.acronym = "PR";
3221
+ this.name = "Precise";
3222
+ this.droidRanked = true;
3223
+ }
3224
+ get isDroidRelevant() {
3225
+ return true;
3226
+ }
3227
+ calculateDroidScoreMultiplier() {
3228
+ return 1.06;
3229
+ }
3230
+ applyToHitObject(mode, hitObject) {
3231
+ var _a, _b;
3232
+ if (mode !== exports.Modes.droid || hitObject instanceof Spinner) {
3233
+ return;
3234
+ }
3235
+ if (hitObject instanceof Slider) {
3236
+ // For sliders, the hit window is enforced in the head - everything else is an instant hit or miss.
3237
+ hitObject.head.hitWindow = new PreciseDroidHitWindow((_a = hitObject.head.hitWindow) === null || _a === void 0 ? void 0 : _a.overallDifficulty);
3238
+ }
3239
+ else {
3240
+ hitObject.hitWindow = new PreciseDroidHitWindow((_b = hitObject.hitWindow) === null || _b === void 0 ? void 0 : _b.overallDifficulty);
3523
3241
  }
3524
- difficulty.ar /= 2;
3525
- difficulty.od /= 2;
3526
- difficulty.hp /= 2;
3527
3242
  }
3528
3243
  }
3529
3244
 
3530
3245
  /**
3531
- * Represents the Flashlight mod.
3246
+ * Represents the ReallyEasy mod.
3532
3247
  */
3533
- class ModFlashlight extends Mod {
3248
+ class ModReallyEasy extends Mod {
3534
3249
  constructor() {
3535
3250
  super(...arguments);
3536
- this.acronym = "FL";
3537
- this.name = "Flashlight";
3538
- this.droidRanked = true;
3539
- this.osuRanked = true;
3540
- this.bitwise = 1 << 10;
3541
- /**
3542
- * The amount of seconds until the Flashlight follow area reaches the cursor.
3543
- */
3544
- this.followDelay = ModFlashlight.defaultFollowDelay;
3545
- }
3546
- copySettings(mod) {
3547
- var _a, _b;
3548
- super.copySettings(mod);
3549
- this.followDelay =
3550
- (_b = (_a = mod.settings) === null || _a === void 0 ? void 0 : _a.areaFollowDelay) !== null && _b !== void 0 ? _b : this.followDelay;
3251
+ this.acronym = "RE";
3252
+ this.name = "ReallyEasy";
3253
+ this.droidRanked = false;
3551
3254
  }
3552
3255
  get isDroidRelevant() {
3553
3256
  return true;
3554
3257
  }
3555
3258
  calculateDroidScoreMultiplier() {
3556
- return 1.12;
3557
- }
3558
- get isOsuRelevant() {
3559
- return true;
3560
- }
3561
- get osuScoreMultiplier() {
3562
- return 1.12;
3563
- }
3564
- serializeSettings() {
3565
- return { areaFollowDelay: this.followDelay };
3259
+ return 0.4;
3566
3260
  }
3567
- toString() {
3568
- if (this.followDelay === ModFlashlight.defaultFollowDelay) {
3569
- return super.toString();
3261
+ applyToDifficultyWithSettings(mode, difficulty, mods) {
3262
+ var _a;
3263
+ if (mode !== exports.Modes.droid) {
3264
+ return;
3265
+ }
3266
+ const difficultyAdjustMod = mods.get(ModDifficultyAdjust);
3267
+ if ((difficultyAdjustMod === null || difficultyAdjustMod === void 0 ? void 0 : difficultyAdjustMod.ar) === undefined) {
3268
+ if (mods.has(ModEasy)) {
3269
+ difficulty.ar *= 2;
3270
+ difficulty.ar -= 0.5;
3271
+ }
3272
+ const customSpeed = mods.get(ModCustomSpeed);
3273
+ difficulty.ar -= 0.5;
3274
+ difficulty.ar -= ((_a = customSpeed === null || customSpeed === void 0 ? void 0 : customSpeed.trackRateMultiplier) !== null && _a !== void 0 ? _a : 1) - 1;
3275
+ }
3276
+ if ((difficultyAdjustMod === null || difficultyAdjustMod === void 0 ? void 0 : difficultyAdjustMod.cs) === undefined) {
3277
+ const scale = CircleSizeCalculator.droidCSToDroidScale(difficulty.cs);
3278
+ difficulty.cs = CircleSizeCalculator.droidScaleToDroidCS(scale + 0.125);
3279
+ }
3280
+ if ((difficultyAdjustMod === null || difficultyAdjustMod === void 0 ? void 0 : difficultyAdjustMod.od) === undefined) {
3281
+ difficulty.od /= 2;
3282
+ }
3283
+ if ((difficultyAdjustMod === null || difficultyAdjustMod === void 0 ? void 0 : difficultyAdjustMod.hp) === undefined) {
3284
+ difficulty.hp /= 2;
3570
3285
  }
3571
- return `${super.toString()} (${this.followDelay.toFixed(2)}s follow delay)`;
3572
3286
  }
3573
3287
  }
3574
- /**
3575
- * The default amount of seconds until the Flashlight follow area reaches the cursor.
3576
- */
3577
- ModFlashlight.defaultFollowDelay = 0.12;
3578
3288
 
3579
3289
  /**
3580
- * Represents the Traceable mod.
3290
+ * Represents the ScoreV2 mod.
3581
3291
  */
3582
- class ModTraceable extends Mod {
3292
+ class ModScoreV2 extends Mod {
3583
3293
  constructor() {
3584
- super();
3585
- this.acronym = "TC";
3586
- this.name = "Traceable";
3294
+ super(...arguments);
3295
+ this.acronym = "V2";
3296
+ this.name = "ScoreV2";
3587
3297
  this.droidRanked = false;
3588
3298
  this.osuRanked = false;
3589
- this.incompatibleMods.add(ModHidden);
3299
+ this.bitwise = 1 << 29;
3590
3300
  }
3591
3301
  get isDroidRelevant() {
3592
3302
  return true;
3593
3303
  }
3594
3304
  calculateDroidScoreMultiplier() {
3595
- return 1.06;
3305
+ return 1;
3596
3306
  }
3597
3307
  get isOsuRelevant() {
3598
3308
  return true;
@@ -3603,17 +3313,16 @@ class ModTraceable extends Mod {
3603
3313
  }
3604
3314
 
3605
3315
  /**
3606
- * Represents the Hidden mod.
3316
+ * Represents the SmallCircle mod.
3317
+ *
3318
+ * This is a legacy osu!droid mod that may still be exist when parsing replays.
3607
3319
  */
3608
- class ModHidden extends Mod {
3320
+ class ModSmallCircle extends Mod {
3609
3321
  constructor() {
3610
- super();
3611
- this.acronym = "HD";
3612
- this.name = "Hidden";
3613
- this.droidRanked = true;
3614
- this.osuRanked = true;
3615
- this.bitwise = 1 << 3;
3616
- this.incompatibleMods.add(ModTraceable);
3322
+ super(...arguments);
3323
+ this.acronym = "SC";
3324
+ this.name = "SmallCircle";
3325
+ this.droidRanked = false;
3617
3326
  }
3618
3327
  get isDroidRelevant() {
3619
3328
  return true;
@@ -3621,71 +3330,55 @@ class ModHidden extends Mod {
3621
3330
  calculateDroidScoreMultiplier() {
3622
3331
  return 1.06;
3623
3332
  }
3624
- get isOsuRelevant() {
3625
- return true;
3626
- }
3627
- get osuScoreMultiplier() {
3628
- return 1.06;
3333
+ migrateDroidMod(difficulty) {
3334
+ return new ModDifficultyAdjust({ cs: difficulty.cs + 4 });
3629
3335
  }
3630
- applyToBeatmap(beatmap) {
3631
- const applyFadeInAdjustment = (hitObject) => {
3632
- hitObject.timeFadeIn =
3633
- hitObject.timePreempt * ModHidden.fadeInDurationMultiplier;
3634
- if (hitObject instanceof Slider) {
3635
- hitObject.nestedHitObjects.forEach(applyFadeInAdjustment);
3336
+ applyToDifficulty(mode, difficulty) {
3337
+ switch (mode) {
3338
+ case exports.Modes.droid: {
3339
+ const scale = CircleSizeCalculator.droidCSToDroidScale(difficulty.cs);
3340
+ difficulty.cs = CircleSizeCalculator.droidScaleToDroidCS(scale -
3341
+ ((CircleSizeCalculator.assumedDroidHeight / 480) *
3342
+ (4 * 4.48) *
3343
+ 2) /
3344
+ 128);
3345
+ break;
3636
3346
  }
3637
- };
3638
- beatmap.hitObjects.objects.forEach(applyFadeInAdjustment);
3347
+ case exports.Modes.osu:
3348
+ difficulty.cs += 4;
3349
+ }
3639
3350
  }
3640
3351
  }
3641
- ModHidden.fadeInDurationMultiplier = 0.4;
3642
- ModHidden.fadeOutDurationMultiplier = 0.3;
3643
3352
 
3644
3353
  /**
3645
- * Represents the SuddenDeath mod.
3354
+ * Represents the SpunOut mod.
3646
3355
  */
3647
- class ModSuddenDeath extends Mod {
3356
+ class ModSpunOut extends Mod {
3648
3357
  constructor() {
3649
- super();
3650
- this.acronym = "SD";
3651
- this.name = "Sudden Death";
3652
- this.droidRanked = true;
3358
+ super(...arguments);
3359
+ this.acronym = "SO";
3360
+ this.name = "SpunOut";
3653
3361
  this.osuRanked = true;
3654
- this.bitwise = 1 << 5;
3655
- this.incompatibleMods.add(ModNoFail).add(ModPerfect);
3656
- }
3657
- get isDroidRelevant() {
3658
- return true;
3659
- }
3660
- calculateDroidScoreMultiplier() {
3661
- return 1;
3362
+ this.bitwise = 1 << 12;
3662
3363
  }
3663
3364
  get isOsuRelevant() {
3664
3365
  return true;
3665
3366
  }
3666
3367
  get osuScoreMultiplier() {
3667
- return 1;
3368
+ return 0.9;
3668
3369
  }
3669
3370
  }
3670
3371
 
3671
3372
  /**
3672
- * Represents the Perfect mod.
3373
+ * Represents the TouchDevice mod.
3673
3374
  */
3674
- class ModPerfect extends Mod {
3375
+ class ModTouchDevice extends Mod {
3675
3376
  constructor() {
3676
- super();
3677
- this.acronym = "PF";
3678
- this.name = "Perfect";
3679
- this.droidRanked = true;
3377
+ super(...arguments);
3378
+ this.acronym = "TD";
3379
+ this.name = "TouchDevice";
3680
3380
  this.osuRanked = true;
3681
- this.bitwise = 1 << 14;
3682
- this.incompatibleMods.add(ModNoFail).add(ModSuddenDeath);
3683
- }
3684
- get isDroidRelevant() {
3685
- return true;
3686
- }
3687
- calculateDroidScoreMultiplier() {
3688
- return 1;
3381
+ this.bitwise = 1 << 2;
3689
3382
  }
3690
3383
  get isOsuRelevant() {
3691
3384
  return true;
@@ -3696,417 +3389,771 @@ class ModPerfect extends Mod {
3696
3389
  }
3697
3390
 
3698
3391
  /**
3699
- * Represents the NoFail mod.
3392
+ * Utilities for mods.
3700
3393
  */
3701
- class ModNoFail extends Mod {
3702
- constructor() {
3703
- super();
3704
- this.acronym = "NF";
3705
- this.name = "NoFail";
3706
- this.droidRanked = true;
3707
- this.osuRanked = true;
3708
- this.bitwise = 1 << 0;
3709
- this.incompatibleMods.add(ModPerfect).add(ModSuddenDeath);
3710
- }
3711
- get isDroidRelevant() {
3712
- return true;
3713
- }
3714
- calculateDroidScoreMultiplier() {
3715
- return 0.5;
3394
+ class ModUtil {
3395
+ /**
3396
+ * Gets a list of mods from a PC modbits.
3397
+ *
3398
+ * @param modbits The modbits.
3399
+ * @returns The list of mods.
3400
+ */
3401
+ static pcModbitsToMods(modbits) {
3402
+ const map = new ModMap();
3403
+ if (modbits === 0) {
3404
+ return map;
3405
+ }
3406
+ for (const modType of this.allMods.values()) {
3407
+ const mod = new modType();
3408
+ if (mod.isApplicableToOsuStable() && (mod.bitwise & modbits) > 0) {
3409
+ map.set(mod);
3410
+ }
3411
+ }
3412
+ return map;
3716
3413
  }
3717
- get isOsuRelevant() {
3718
- return true;
3414
+ /**
3415
+ * Serializes a list of `Mod`s.
3416
+ *
3417
+ * @param mods The list of `Mod`s to serialize.
3418
+ * @returns The serialized list of `Mod`s.
3419
+ */
3420
+ static serializeMods(mods) {
3421
+ const serializedMods = [];
3422
+ for (const mod of mods) {
3423
+ serializedMods.push(mod.serialize());
3424
+ }
3425
+ return serializedMods;
3719
3426
  }
3720
- get osuScoreMultiplier() {
3721
- return 0.5;
3427
+ /**
3428
+ * Deserializes a list of `SerializedMod`s.
3429
+ *
3430
+ * @param mods The list of `SerializedMod`s to deserialize.
3431
+ * @returns The deserialized list of `Mod`s.
3432
+ */
3433
+ static deserializeMods(mods) {
3434
+ const map = new ModMap();
3435
+ for (const serializedMod of mods) {
3436
+ const modType = this.allMods.get(serializedMod.acronym);
3437
+ if (!modType) {
3438
+ continue;
3439
+ }
3440
+ const mod = new modType();
3441
+ if (serializedMod.settings) {
3442
+ mod.copySettings(serializedMod);
3443
+ }
3444
+ map.set(mod);
3445
+ }
3446
+ return map;
3722
3447
  }
3723
- }
3724
-
3725
- /**
3726
- * Represents the ReallyEasy mod.
3727
- */
3728
- class ModReallyEasy extends Mod {
3729
- constructor() {
3730
- super(...arguments);
3731
- this.acronym = "RE";
3732
- this.name = "ReallyEasy";
3733
- this.droidRanked = false;
3448
+ /**
3449
+ * Gets a list of mods from a PC mod string, such as "HDHR".
3450
+ *
3451
+ * @param str The string.
3452
+ * @returns The list of mods.
3453
+ */
3454
+ static pcStringToMods(str) {
3455
+ const map = new ModMap();
3456
+ str = str.toLowerCase();
3457
+ while (str) {
3458
+ let nchars = 1;
3459
+ for (const acronym of this.allMods.keys()) {
3460
+ if (str.startsWith(acronym.toLowerCase())) {
3461
+ const modType = this.allMods.get(acronym);
3462
+ map.set(modType);
3463
+ nchars = acronym.length;
3464
+ break;
3465
+ }
3466
+ }
3467
+ str = str.slice(nchars);
3468
+ }
3469
+ return map;
3734
3470
  }
3735
- get isDroidRelevant() {
3736
- return true;
3471
+ /**
3472
+ * Converts a list of mods into its osu!standard string counterpart.
3473
+ *
3474
+ * @param mods The array of mods to convert.
3475
+ * @returns The string representing the mods in osu!standard.
3476
+ */
3477
+ static modsToOsuString(mods) {
3478
+ let str = "";
3479
+ for (const mod of mods) {
3480
+ if (mod instanceof ModDifficultyAdjust) {
3481
+ continue;
3482
+ }
3483
+ str += mod.acronym;
3484
+ }
3485
+ return str;
3737
3486
  }
3738
- calculateDroidScoreMultiplier() {
3739
- return 0.4;
3487
+ /**
3488
+ * Converts an array of `Mod`s into an ordered string based on {@link allMods}.
3489
+ *
3490
+ * @param mods The array of `Mod`s to convert.
3491
+ * @returns The string representing the `Mod`s in ordered form.
3492
+ */
3493
+ static modsToOrderedString(mods) {
3494
+ const strs = [];
3495
+ for (const modType of this.allMods.values()) {
3496
+ for (const mod of mods) {
3497
+ if (mod instanceof modType) {
3498
+ strs.push(mod.toString());
3499
+ break;
3500
+ }
3501
+ }
3502
+ }
3503
+ return strs.join();
3740
3504
  }
3741
- applyToDifficultyWithSettings(mode, difficulty, mods) {
3742
- var _a;
3743
- if (mode !== exports.Modes.droid) {
3744
- return;
3505
+ /**
3506
+ * Removes speed-changing mods from an array of mods.
3507
+ *
3508
+ * @param mods The array of mods.
3509
+ * @returns A new array with speed changing mods filtered out.
3510
+ */
3511
+ static removeSpeedChangingMods(mods) {
3512
+ return mods.filter((m) => !(m instanceof ModRateAdjust));
3513
+ }
3514
+ /**
3515
+ * Applies the selected `Mod`s to a `BeatmapDifficulty`.
3516
+ *
3517
+ * @param difficulty The `BeatmapDifficulty` to apply the `Mod`s to.
3518
+ * @param mode The game mode to apply the `Mod`s for.
3519
+ * @param mods The selected `Mod`s. Defaults to No Mod.
3520
+ * @param withRateChange Whether to apply rate changes. Defaults to `false`.
3521
+ */
3522
+ static applyModsToBeatmapDifficulty(difficulty, mode, mods, withRateChange = false) {
3523
+ if (mods !== undefined) {
3524
+ for (const mod of mods.values()) {
3525
+ if (mod.isApplicableToDifficulty()) {
3526
+ mod.applyToDifficulty(mode, difficulty);
3527
+ }
3528
+ }
3745
3529
  }
3746
- const difficultyAdjustMod = mods.find((m) => m instanceof ModDifficultyAdjust);
3747
- if ((difficultyAdjustMod === null || difficultyAdjustMod === void 0 ? void 0 : difficultyAdjustMod.ar) === undefined) {
3748
- if (mods.some((m) => m instanceof ModEasy)) {
3749
- difficulty.ar *= 2;
3750
- difficulty.ar -= 0.5;
3530
+ let rate = 1;
3531
+ if (mods !== undefined) {
3532
+ for (const mod of mods.values()) {
3533
+ if (mod.isApplicableToDifficultyWithSettings()) {
3534
+ mod.applyToDifficultyWithSettings(mode, difficulty, mods);
3535
+ }
3536
+ if (mod.isApplicableToTrackRate()) {
3537
+ rate = mod.applyToRate(0, rate);
3538
+ }
3751
3539
  }
3752
- const customSpeed = mods.find((m) => m instanceof ModCustomSpeed);
3753
- difficulty.ar -= 0.5;
3754
- difficulty.ar -= ((_a = customSpeed === null || customSpeed === void 0 ? void 0 : customSpeed.trackRateMultiplier) !== null && _a !== void 0 ? _a : 1) - 1;
3755
3540
  }
3756
- if ((difficultyAdjustMod === null || difficultyAdjustMod === void 0 ? void 0 : difficultyAdjustMod.cs) === undefined) {
3757
- const scale = CircleSizeCalculator.droidCSToDroidScale(difficulty.cs);
3758
- difficulty.cs = CircleSizeCalculator.droidScaleToDroidCS(scale + 0.125);
3541
+ if (!withRateChange) {
3542
+ return;
3759
3543
  }
3760
- if ((difficultyAdjustMod === null || difficultyAdjustMod === void 0 ? void 0 : difficultyAdjustMod.od) === undefined) {
3761
- difficulty.od /= 2;
3544
+ // Apply rate adjustments
3545
+ const preempt = BeatmapDifficulty.difficultyRange(difficulty.ar, HitObject.preemptMax, HitObject.preemptMid, HitObject.preemptMin) / rate;
3546
+ difficulty.ar = BeatmapDifficulty.inverseDifficultyRange(preempt, HitObject.preemptMax, HitObject.preemptMid, HitObject.preemptMin);
3547
+ switch (mode) {
3548
+ case exports.Modes.droid:
3549
+ if (mods === null || mods === void 0 ? void 0 : mods.has(ModPrecise)) {
3550
+ const hitWindow = new PreciseDroidHitWindow(difficulty.od);
3551
+ difficulty.od = PreciseDroidHitWindow.greatWindowToOD(hitWindow.greatWindow / rate);
3552
+ }
3553
+ else {
3554
+ const hitWindow = new DroidHitWindow(difficulty.od);
3555
+ difficulty.od = DroidHitWindow.greatWindowToOD(hitWindow.greatWindow / rate);
3556
+ }
3557
+ break;
3558
+ case exports.Modes.osu: {
3559
+ const hitWindow = new OsuHitWindow(difficulty.od);
3560
+ difficulty.od = OsuHitWindow.greatWindowToOD(hitWindow.greatWindow / rate);
3561
+ break;
3562
+ }
3762
3563
  }
3763
- if ((difficultyAdjustMod === null || difficultyAdjustMod === void 0 ? void 0 : difficultyAdjustMod.hp) === undefined) {
3764
- difficulty.hp /= 2;
3564
+ }
3565
+ /**
3566
+ * Calculates the playback rate for the track with the selected `Mod`s at the given time.
3567
+ *
3568
+ * @param mods The list of selected `Mod`s.
3569
+ * @param time The time at which the playback rate is queried, in milliseconds. Defaults to 0.
3570
+ * @returns The rate with `Mod`s.
3571
+ */
3572
+ static calculateRateWithMods(mods, time = 0) {
3573
+ let rate = 1;
3574
+ for (const mod of mods) {
3575
+ if (mod.isApplicableToTrackRate()) {
3576
+ rate = mod.applyToRate(time, rate);
3577
+ }
3765
3578
  }
3579
+ return rate;
3766
3580
  }
3767
3581
  }
3582
+ /**
3583
+ * All `Mod`s that exists, mapped by their acronym.
3584
+ */
3585
+ ModUtil.allMods = (() => {
3586
+ const mods = [
3587
+ // Janky order to keep the order on what players are used to
3588
+ ModAuto,
3589
+ ModRelax,
3590
+ ModAutopilot,
3591
+ ModEasy,
3592
+ ModNoFail,
3593
+ ModHidden,
3594
+ ModTraceable,
3595
+ ModDoubleTime,
3596
+ ModNightCore,
3597
+ ModHalfTime,
3598
+ ModCustomSpeed,
3599
+ ModHardRock,
3600
+ ModDifficultyAdjust,
3601
+ ModFlashlight,
3602
+ ModSuddenDeath,
3603
+ ModPerfect,
3604
+ ModPrecise,
3605
+ ModReallyEasy,
3606
+ ModScoreV2,
3607
+ ModSmallCircle,
3608
+ ModSpunOut,
3609
+ ModTouchDevice,
3610
+ ];
3611
+ const map = new Map();
3612
+ for (const mod of mods) {
3613
+ map.set(new mod().acronym, mod);
3614
+ }
3615
+ return map;
3616
+ })();
3768
3617
 
3769
3618
  /**
3770
- * Represents the SmallCircle mod.
3619
+ * A map that stores `Mod`s depending on their type.
3771
3620
  *
3772
- * This is a legacy osu!droid mod that may still be exist when parsing replays.
3621
+ * This also has additional utilities to eliminate unnecessary `Mod`s.
3773
3622
  */
3774
- class ModSmallCircle extends Mod {
3775
- constructor() {
3776
- super(...arguments);
3777
- this.acronym = "SC";
3778
- this.name = "SmallCircle";
3779
- this.droidRanked = false;
3780
- }
3781
- get isDroidRelevant() {
3782
- return true;
3783
- }
3784
- calculateDroidScoreMultiplier() {
3785
- return 1.06;
3786
- }
3787
- migrateDroidMod(difficulty) {
3788
- return new ModDifficultyAdjust({ cs: difficulty.cs + 4 });
3789
- }
3790
- applyToDifficulty(mode, difficulty) {
3791
- switch (mode) {
3792
- case exports.Modes.droid: {
3793
- const scale = CircleSizeCalculator.droidCSToDroidScale(difficulty.cs);
3794
- difficulty.cs = CircleSizeCalculator.droidScaleToDroidCS(scale -
3795
- ((CircleSizeCalculator.assumedDroidHeight / 480) *
3796
- (4 * 4.48) *
3797
- 2) /
3798
- 128);
3799
- break;
3623
+ class ModMap extends Map {
3624
+ /**
3625
+ * Whether this map is empty.
3626
+ */
3627
+ get isEmpty() {
3628
+ return this.size === 0;
3629
+ }
3630
+ constructor(iterable) {
3631
+ if (Array.isArray(iterable)) {
3632
+ for (const [key, value] of iterable) {
3633
+ // Ensure the mod type corresponds to the mod instance.
3634
+ if (key !== value.constructor) {
3635
+ throw new TypeError(`Key ${key.name} does not match value ${value.constructor.name}`);
3636
+ }
3637
+ }
3638
+ }
3639
+ super(iterable);
3640
+ }
3641
+ get(key) {
3642
+ return super.get(key);
3643
+ }
3644
+ set(keyOrValue) {
3645
+ const key = (keyOrValue instanceof Mod ? keyOrValue.constructor : keyOrValue);
3646
+ const value = keyOrValue instanceof Mod ? keyOrValue : new key();
3647
+ // Ensure the mod type corresponds to the mod instance.
3648
+ if (key !== value.constructor) {
3649
+ throw new TypeError(`Key ${key.name} does not match value ${value.constructor.name}`);
3650
+ }
3651
+ const existing = this.get(key);
3652
+ // If all difficulty statistics are set, all other difficulty adjusting mods are irrelevant, so we remove them.
3653
+ // This prevents potential abuse cases where score multipliers from non-affecting mods stack (i.e., forcing
3654
+ // all difficulty statistics while using the Hard Rock mod).
3655
+ const removeDifficultyAdjustmentMods = value instanceof ModDifficultyAdjust &&
3656
+ value.cs !== undefined &&
3657
+ value.ar !== undefined &&
3658
+ value.od !== undefined &&
3659
+ value.hp !== undefined;
3660
+ if (removeDifficultyAdjustmentMods) {
3661
+ this.delete(ModEasy);
3662
+ this.delete(ModHardRock);
3663
+ this.delete(ModReallyEasy);
3664
+ this.delete(ModSmallCircle);
3665
+ }
3666
+ // Check if there are any mods that are incompatible with the new mod.
3667
+ // If so, remove them.
3668
+ for (const incompatibleMod of value.incompatibleMods) {
3669
+ for (const mod of this.values()) {
3670
+ if (mod instanceof incompatibleMod) {
3671
+ this.delete(mod.constructor);
3672
+ }
3800
3673
  }
3801
- case exports.Modes.osu:
3802
- difficulty.cs += 4;
3803
3674
  }
3675
+ super.set(key, value);
3676
+ return existing !== null && existing !== void 0 ? existing : null;
3677
+ }
3678
+ /**
3679
+ * Serializes all `Mod`s that are in this map.
3680
+ */
3681
+ serializeMods() {
3682
+ return ModUtil.serializeMods(this.values());
3804
3683
  }
3805
3684
  }
3806
3685
 
3807
3686
  /**
3808
- * Represents the SpunOut mod.
3687
+ * Represents a circle in a beatmap.
3688
+ *
3689
+ * All we need from circles is their position. All positions
3690
+ * stored in the objects are in playfield coordinates (512*384
3691
+ * rectangle).
3809
3692
  */
3810
- class ModSpunOut extends Mod {
3811
- constructor() {
3812
- super(...arguments);
3813
- this.acronym = "SO";
3814
- this.name = "SpunOut";
3815
- this.osuRanked = true;
3816
- this.bitwise = 1 << 12;
3817
- }
3818
- get isOsuRelevant() {
3819
- return true;
3693
+ class Circle extends HitObject {
3694
+ constructor(values) {
3695
+ super(values);
3820
3696
  }
3821
- get osuScoreMultiplier() {
3822
- return 0.9;
3697
+ toString() {
3698
+ return `Position: [${this._position.x}, ${this._position.y}]`;
3823
3699
  }
3824
3700
  }
3825
3701
 
3826
3702
  /**
3827
- * Represents the TouchDevice mod.
3703
+ * Contains information about hit objects of a beatmap.
3828
3704
  */
3829
- class ModTouchDevice extends Mod {
3705
+ class BeatmapHitObjects {
3830
3706
  constructor() {
3831
- super(...arguments);
3832
- this.acronym = "TD";
3833
- this.name = "TouchDevice";
3834
- this.osuRanked = true;
3835
- this.bitwise = 1 << 2;
3707
+ this._objects = [];
3708
+ this._circles = 0;
3709
+ this._sliders = 0;
3710
+ this._spinners = 0;
3836
3711
  }
3837
- get isOsuRelevant() {
3838
- return true;
3712
+ /**
3713
+ * The objects of the beatmap.
3714
+ */
3715
+ get objects() {
3716
+ return this._objects;
3839
3717
  }
3840
- get osuScoreMultiplier() {
3841
- return 1;
3718
+ /**
3719
+ * The amount of circles in the beatmap.
3720
+ */
3721
+ get circles() {
3722
+ return this._circles;
3842
3723
  }
3843
- }
3844
-
3845
- /**
3846
- * Utilities for mods.
3847
- */
3848
- class ModUtil {
3849
3724
  /**
3850
- * Gets a list of mods from a PC modbits.
3725
+ * The amount of sliders in the beatmap.
3726
+ */
3727
+ get sliders() {
3728
+ return this._sliders;
3729
+ }
3730
+ /**
3731
+ * The amount of spinners in the beatmap.
3732
+ */
3733
+ get spinners() {
3734
+ return this._spinners;
3735
+ }
3736
+ /**
3737
+ * The amount of slider ticks in the beatmap.
3851
3738
  *
3852
- * @param modbits The modbits.
3853
- * @param options Options for parsing behavior.
3739
+ * This iterates through all objects and should be stored locally or used sparingly.
3854
3740
  */
3855
- static pcModbitsToMods(modbits, options) {
3856
- if (modbits === 0) {
3857
- return [];
3858
- }
3859
- const mods = [];
3860
- for (const modType of this.allMods.values()) {
3861
- const mod = new modType();
3862
- if (mod.isApplicableToOsuStable() && (mod.bitwise & modbits) > 0) {
3863
- mods.push(mod);
3864
- }
3865
- }
3866
- return this.processParsingOptions(mods, options);
3741
+ get sliderTicks() {
3742
+ return this.objects.reduce((acc, cur) => (cur instanceof Slider ? acc + cur.ticks : acc), 0);
3867
3743
  }
3868
3744
  /**
3869
- * Serializes a list of `Mod`s.
3745
+ * The amount of sliderends in the beatmap.
3746
+ */
3747
+ get sliderEnds() {
3748
+ return this.sliders;
3749
+ }
3750
+ /**
3751
+ * The amount of slider repeat points in the beatmap.
3870
3752
  *
3871
- * @param mods The list of `Mod`s to serialize.
3872
- * @returns The serialized list of `Mod`s.
3753
+ * This iterates through all objects and should be stored locally or used sparingly.
3873
3754
  */
3874
- static serializeMods(mods) {
3875
- const serializedMods = [];
3876
- for (const mod of mods) {
3877
- serializedMods.push(mod.serialize());
3878
- }
3879
- return serializedMods;
3755
+ get sliderRepeatPoints() {
3756
+ return this.objects.reduce((acc, cur) => (cur instanceof Slider ? acc + cur.repeatCount : acc), 0);
3880
3757
  }
3881
3758
  /**
3882
- * Deserializes a list of `SerializedMod`s.
3759
+ * Adds hitobjects.
3883
3760
  *
3884
- * @param mods The list of `SerializedMod`s to deserialize.
3885
- * @returns The deserialized list of `Mod`s.
3761
+ * The sorting order of hitobjects will be maintained.
3762
+ *
3763
+ * @param objects The hitobjects to add.
3886
3764
  */
3887
- static deserializeMods(mods) {
3888
- const deserializedMods = [];
3889
- for (const serializedMod of mods) {
3890
- const modType = this.allMods.get(serializedMod.acronym);
3891
- if (!modType) {
3892
- continue;
3765
+ add(...objects) {
3766
+ for (const object of objects) {
3767
+ // Objects may be out of order *only* if a user has manually edited an .osu file.
3768
+ // Unfortunately there are "ranked" maps in this state (example: https://osu.ppy.sh/s/594828).
3769
+ // Finding index is used to guarantee that the parsing order of hitobjects with equal start times is maintained (stably-sorted).
3770
+ this._objects.splice(this.findInsertionIndex(object.startTime), 0, object);
3771
+ if (object instanceof Circle) {
3772
+ ++this._circles;
3893
3773
  }
3894
- const mod = new modType();
3895
- if (serializedMod.settings) {
3896
- mod.copySettings(serializedMod);
3774
+ else if (object instanceof Slider) {
3775
+ ++this._sliders;
3776
+ }
3777
+ else {
3778
+ ++this._spinners;
3897
3779
  }
3898
- deserializedMods.push(mod);
3899
3780
  }
3900
- return deserializedMods;
3901
3781
  }
3902
3782
  /**
3903
- * Gets a list of mods from a PC mod string, such as "HDHR".
3783
+ * Removes a hitobject at an index.
3904
3784
  *
3905
- * @param str The string.
3906
- * @param options Options for parsing behavior.
3785
+ * @param index The index of the hitobject to remove.
3786
+ * @returns The hitobject that was removed, `null` if no hitobject was removed.
3907
3787
  */
3908
- static pcStringToMods(str, options) {
3909
- const finalMods = [];
3910
- str = str.toLowerCase();
3911
- while (str) {
3912
- let nchars = 1;
3913
- for (const acronym of this.allMods.keys()) {
3914
- if (str.startsWith(acronym.toLowerCase())) {
3915
- const modType = this.allMods.get(acronym);
3916
- finalMods.push(new modType());
3917
- nchars = acronym.length;
3918
- break;
3919
- }
3920
- }
3921
- str = str.slice(nchars);
3788
+ removeAt(index) {
3789
+ var _a;
3790
+ const object = (_a = this._objects.splice(index, 1)[0]) !== null && _a !== void 0 ? _a : null;
3791
+ if (object instanceof Circle) {
3792
+ --this._circles;
3922
3793
  }
3923
- return this.processParsingOptions(finalMods, options);
3794
+ else if (object instanceof Slider) {
3795
+ --this._sliders;
3796
+ }
3797
+ else if (object instanceof Spinner) {
3798
+ --this._spinners;
3799
+ }
3800
+ return object;
3924
3801
  }
3925
3802
  /**
3926
- * Converts an array of mods into its osu!standard string counterpart.
3927
- *
3928
- * @param mods The array of mods to convert.
3929
- * @returns The string representing the mods in osu!standard.
3803
+ * Clears all hitobjects.
3930
3804
  */
3931
- static modsToOsuString(mods) {
3932
- return mods.reduce((a, v) => {
3933
- if (v instanceof ModDifficultyAdjust) {
3934
- return a;
3935
- }
3936
- return a + v.acronym;
3937
- }, "");
3805
+ clear() {
3806
+ this._objects.length = 0;
3807
+ this._circles = 0;
3808
+ this._sliders = 0;
3809
+ this._spinners = 0;
3938
3810
  }
3939
3811
  /**
3940
- * Converts an array of `Mod`s into an ordered string based on {@link allMods}.
3812
+ * Finds the insertion index of a hitobject in a given time.
3941
3813
  *
3942
- * @param mods The array of `Mod`s to convert.
3943
- * @returns The string representing the `Mod`s in ordered form.
3814
+ * @param startTime The start time of the hitobject.
3944
3815
  */
3945
- static modsToOrderedString(mods) {
3946
- const strs = [];
3947
- for (const modType of this.allMods.values()) {
3948
- for (const mod of mods) {
3949
- if (mod instanceof modType) {
3950
- strs.push(mod.toString());
3951
- break;
3952
- }
3816
+ findInsertionIndex(startTime) {
3817
+ if (this._objects.length === 0 ||
3818
+ startTime < this._objects[0].startTime) {
3819
+ return 0;
3820
+ }
3821
+ if (startTime >= this._objects.at(-1).startTime) {
3822
+ return this._objects.length;
3823
+ }
3824
+ let l = 0;
3825
+ let r = this._objects.length - 2;
3826
+ while (l <= r) {
3827
+ const pivot = l + ((r - l) >> 1);
3828
+ if (this._objects[pivot].startTime < startTime) {
3829
+ l = pivot + 1;
3830
+ }
3831
+ else if (this._objects[pivot].startTime > startTime) {
3832
+ r = pivot - 1;
3833
+ }
3834
+ else {
3835
+ return pivot;
3953
3836
  }
3954
3837
  }
3955
- return strs.join();
3838
+ return l;
3956
3839
  }
3957
- /**
3958
- * Checks for mods that are duplicated.
3959
- *
3960
- * @param mods The mods to check for.
3961
- * @returns Mods that have been filtered.
3962
- */
3963
- static checkDuplicateMods(mods) {
3964
- return Array.from(new Set(mods));
3840
+ }
3841
+
3842
+ /**
3843
+ * Converts a beatmap for another mode.
3844
+ */
3845
+ class BeatmapConverter {
3846
+ constructor(beatmap) {
3847
+ this.beatmap = beatmap;
3965
3848
  }
3966
3849
  /**
3967
- * Checks for mods that are incompatible with each other.
3850
+ * Converts the beatmap.
3968
3851
  *
3969
- * @param mods The mods to check for.
3970
- * @returns Mods that have been filtered.
3852
+ * @returns The converted beatmap.
3971
3853
  */
3972
- static checkIncompatibleMods(mods) {
3973
- for (let i = 0; i < mods.length; ++i) {
3974
- const mod = mods[i];
3975
- for (const incompatibleMod of mod.incompatibleMods) {
3976
- if (mods.some((m) => m !== mod && m instanceof incompatibleMod)) {
3977
- mods = mods.filter((m) =>
3978
- // Keep the mod itself.
3979
- m === mod || !(m instanceof incompatibleMod));
3980
- }
3981
- }
3854
+ convert() {
3855
+ const converted = new Beatmap(this.beatmap);
3856
+ // Shallow clone isn't enough to ensure we don't mutate some beatmap properties unexpectedly.
3857
+ converted.difficulty = new BeatmapDifficulty(this.beatmap.difficulty);
3858
+ converted.hitObjects = this.convertHitObjects();
3859
+ return converted;
3860
+ }
3861
+ convertHitObjects() {
3862
+ const hitObjects = new BeatmapHitObjects();
3863
+ this.beatmap.hitObjects.objects.forEach((hitObject) => {
3864
+ hitObjects.add(this.convertHitObject(hitObject));
3865
+ });
3866
+ return hitObjects;
3867
+ }
3868
+ convertHitObject(hitObject) {
3869
+ let object;
3870
+ if (hitObject instanceof Circle) {
3871
+ object = new Circle({
3872
+ startTime: hitObject.startTime,
3873
+ position: hitObject.position,
3874
+ newCombo: hitObject.isNewCombo,
3875
+ type: hitObject.type,
3876
+ comboOffset: hitObject.comboOffset,
3877
+ });
3878
+ }
3879
+ else if (hitObject instanceof Slider) {
3880
+ object = new Slider({
3881
+ startTime: hitObject.startTime,
3882
+ position: hitObject.position,
3883
+ newCombo: hitObject.isNewCombo,
3884
+ type: hitObject.type,
3885
+ path: hitObject.path,
3886
+ repeatCount: hitObject.repeatCount,
3887
+ nodeSamples: hitObject.nodeSamples,
3888
+ comboOffset: hitObject.comboOffset,
3889
+ tickDistanceMultiplier:
3890
+ // Prior to v8, speed multipliers don't adjust for how many ticks are generated over the same distance.
3891
+ // This results in more (or less) ticks being generated in <v8 maps for the same time duration.
3892
+ this.beatmap.formatVersion < 8
3893
+ ? 1 /
3894
+ this.beatmap.controlPoints.difficulty.controlPointAt(hitObject.startTime).speedMultiplier
3895
+ : 1,
3896
+ });
3897
+ }
3898
+ else {
3899
+ object = new Spinner({
3900
+ startTime: hitObject.startTime,
3901
+ endTime: hitObject.endTime,
3902
+ type: hitObject.type,
3903
+ });
3982
3904
  }
3983
- return mods;
3905
+ object.samples = hitObject.samples;
3906
+ object.auxiliarySamples = hitObject.auxiliarySamples;
3907
+ return object;
3908
+ }
3909
+ }
3910
+
3911
+ /**
3912
+ * Provides functionality to alter a beatmap after it has been converted.
3913
+ */
3914
+ class BeatmapProcessor {
3915
+ constructor(beatmap) {
3916
+ this.beatmap = beatmap;
3984
3917
  }
3985
3918
  /**
3986
- * Removes speed-changing mods from an array of mods.
3919
+ * Processes the converted beatmap prior to `HitObject.applyDefaults` being invoked.
3987
3920
  *
3988
- * @param mods The array of mods.
3989
- * @returns A new array with speed changing mods filtered out.
3921
+ * Nested hitobjects generated during `HitObject.applyDefaults` will not be present by this point,
3922
+ * and no mods will have been applied to the hitobjects.
3923
+ *
3924
+ * This can only be used to add alterations to hitobjects generated directly through the conversion process.
3990
3925
  */
3991
- static removeSpeedChangingMods(mods) {
3992
- return mods.filter((m) => !(m instanceof ModRateAdjust));
3926
+ preProcess() {
3927
+ let last = null;
3928
+ for (const object of this.beatmap.hitObjects.objects) {
3929
+ object.updateComboInformation(last);
3930
+ last = object;
3931
+ }
3932
+ // Mark the last object in the beatmap as last in combo.
3933
+ if (last) {
3934
+ last.isLastInCombo = true;
3935
+ }
3993
3936
  }
3994
3937
  /**
3995
- * Applies the selected `Mod`s to a `BeatmapDifficulty`.
3938
+ * Processes the converted beatmap after `HitObject.applyDefaults` has been invoked.
3996
3939
  *
3997
- * @param difficulty The `BeatmapDifficulty` to apply the `Mod`s to.
3998
- * @param mode The game mode to apply the `Mod`s for.
3999
- * @param mods The selected `Mod`s.
4000
- * @param withRateChange Whether to apply rate changes.
4001
- * @param oldStatistics Whether to enforce old statistics. Some `Mod`s behave differently with this flag.
3940
+ * Nested hitobjects generated during `HitObject.applyDefaults` wil be present by this point,
3941
+ * and mods will have been applied to all hitobjects.
3942
+ *
3943
+ * This should be used to add alterations to hitobjects while they are in their most playable state.
3944
+ *
3945
+ * @param mode The mode to add alterations for.
4002
3946
  */
4003
- static applyModsToBeatmapDifficulty(difficulty, mode, mods, withRateChange = false) {
4004
- for (const mod of mods) {
4005
- if (mod.isApplicableToDifficulty()) {
4006
- mod.applyToDifficulty(mode, difficulty);
4007
- }
4008
- }
4009
- let rate = 1;
4010
- for (const mod of mods) {
4011
- if (mod.isApplicableToDifficultyWithSettings()) {
4012
- mod.applyToDifficultyWithSettings(mode, difficulty, mods);
4013
- }
4014
- if (mod.isApplicableToTrackRate()) {
4015
- rate = mod.applyToRate(0, rate);
4016
- }
4017
- }
4018
- if (!withRateChange) {
3947
+ postProcess(mode) {
3948
+ const objects = this.beatmap.hitObjects.objects;
3949
+ if (objects.length === 0) {
4019
3950
  return;
4020
3951
  }
4021
- // Apply rate adjustments
4022
- const preempt = BeatmapDifficulty.difficultyRange(difficulty.ar, HitObject.preemptMax, HitObject.preemptMid, HitObject.preemptMin) / rate;
4023
- difficulty.ar = BeatmapDifficulty.inverseDifficultyRange(preempt, HitObject.preemptMax, HitObject.preemptMid, HitObject.preemptMin);
3952
+ // Reset stacking
3953
+ objects.forEach((h) => {
3954
+ h.stackHeight = 0;
3955
+ });
4024
3956
  switch (mode) {
4025
3957
  case exports.Modes.droid:
4026
- if (mods.some((m) => m instanceof ModPrecise)) {
4027
- const hitWindow = new PreciseDroidHitWindow(difficulty.od);
4028
- difficulty.od = PreciseDroidHitWindow.greatWindowToOD(hitWindow.greatWindow / rate);
3958
+ this.applyDroidStacking();
3959
+ break;
3960
+ case exports.Modes.osu:
3961
+ if (this.beatmap.formatVersion >= 6) {
3962
+ this.applyStandardStacking();
4029
3963
  }
4030
3964
  else {
4031
- const hitWindow = new DroidHitWindow(difficulty.od);
4032
- difficulty.od = DroidHitWindow.greatWindowToOD(hitWindow.greatWindow / rate);
3965
+ this.applyStandardOldStacking();
4033
3966
  }
4034
3967
  break;
4035
- case exports.Modes.osu: {
4036
- const hitWindow = new OsuHitWindow(difficulty.od);
4037
- difficulty.od = OsuHitWindow.greatWindowToOD(hitWindow.greatWindow / rate);
4038
- break;
4039
- }
4040
3968
  }
4041
3969
  }
4042
- /**
4043
- * Calculates the playback rate for the track with the selected `Mod`s at the given time.
4044
- *
4045
- * @param mods The list of selected `Mod`s.
4046
- * @param time The time at which the playback rate is queried, in milliseconds. Defaults to 0.
4047
- * @returns The rate with `Mod`s.
4048
- */
4049
- static calculateRateWithMods(mods, time = 0) {
4050
- let rate = 1;
4051
- for (const mod of mods) {
4052
- if (mod.isApplicableToTrackRate()) {
4053
- rate = mod.applyToRate(time, rate);
3970
+ applyDroidStacking() {
3971
+ const objects = this.beatmap.hitObjects.objects;
3972
+ if (objects.length === 0) {
3973
+ return;
3974
+ }
3975
+ const convertedScale = CircleSizeCalculator.standardScaleToDroidScale(objects[0].scale);
3976
+ for (let i = 0; i < objects.length - 1; ++i) {
3977
+ const current = objects[i];
3978
+ const next = objects[i + 1];
3979
+ if (current instanceof Circle &&
3980
+ next.startTime - current.startTime <
3981
+ 2000 * this.beatmap.general.stackLeniency &&
3982
+ next.position.getDistance(current.position) <
3983
+ Math.sqrt(convertedScale)) {
3984
+ next.stackHeight = current.stackHeight + 1;
4054
3985
  }
4055
3986
  }
4056
- return rate;
4057
3987
  }
4058
- /**
4059
- * Processes parsing options.
4060
- *
4061
- * @param mods The mods to process.
4062
- * @param options The options to process.
4063
- * @returns The processed mods.
4064
- */
4065
- static processParsingOptions(mods, options) {
4066
- if ((options === null || options === void 0 ? void 0 : options.checkDuplicate) !== false) {
4067
- mods = this.checkDuplicateMods(mods);
3988
+ applyStandardStacking() {
3989
+ const objects = this.beatmap.hitObjects.objects;
3990
+ const startIndex = 0;
3991
+ const endIndex = objects.length - 1;
3992
+ let extendedEndIndex = endIndex;
3993
+ if (endIndex < objects.length - 1) {
3994
+ for (let i = endIndex; i >= startIndex; --i) {
3995
+ let stackBaseIndex = i;
3996
+ for (let n = stackBaseIndex + 1; n < objects.length; ++n) {
3997
+ const stackBaseObject = objects[stackBaseIndex];
3998
+ if (stackBaseObject instanceof Spinner) {
3999
+ break;
4000
+ }
4001
+ const objectN = objects[n];
4002
+ if (objectN instanceof Spinner) {
4003
+ break;
4004
+ }
4005
+ const stackThreshold = objectN.timePreempt *
4006
+ this.beatmap.general.stackLeniency;
4007
+ if (objectN.startTime - stackBaseObject.endTime >
4008
+ stackThreshold) {
4009
+ // We are no longer within stacking range of the next object.
4010
+ break;
4011
+ }
4012
+ const endPositionDistanceCheck = stackBaseObject instanceof Slider
4013
+ ? stackBaseObject.endPosition.getDistance(objectN.position) < BeatmapProcessor.stackDistance
4014
+ : false;
4015
+ if (stackBaseObject.position.getDistance(objectN.position) <
4016
+ BeatmapProcessor.stackDistance ||
4017
+ endPositionDistanceCheck) {
4018
+ stackBaseIndex = n;
4019
+ // Hit objects after the specified update range haven't been reset yet
4020
+ objectN.stackHeight = 0;
4021
+ }
4022
+ }
4023
+ if (stackBaseIndex > extendedEndIndex) {
4024
+ extendedEndIndex = stackBaseIndex;
4025
+ if (extendedEndIndex === objects.length - 1) {
4026
+ break;
4027
+ }
4028
+ }
4029
+ }
4030
+ }
4031
+ // Reverse pass for stack calculation.
4032
+ let extendedStartIndex = startIndex;
4033
+ for (let i = extendedEndIndex; i > startIndex; --i) {
4034
+ let n = i;
4035
+ // We should check every note which has not yet got a stack.
4036
+ // Consider the case we have two inter-wound stacks and this will make sense.
4037
+ //
4038
+ // o <-1 o <-2
4039
+ // o <-3 o <-4
4040
+ //
4041
+ // We first process starting from 4 and handle 2,
4042
+ // then we come backwards on the i loop iteration until we reach 3 and handle 1.
4043
+ // 2 and 1 will be ignored in the i loop because they already have a stack value.
4044
+ let objectI = objects[i];
4045
+ if (objectI.stackHeight !== 0 || objectI instanceof Spinner) {
4046
+ continue;
4047
+ }
4048
+ const stackThreshold = objectI.timePreempt * this.beatmap.general.stackLeniency;
4049
+ // If this object is a hit circle, then we enter this "special" case.
4050
+ // It either ends with a stack of hit circles only, or a stack of hit circles that are underneath a slider.
4051
+ // Any other case is handled by the "instanceof Slider" code below this.
4052
+ if (objectI instanceof Circle) {
4053
+ while (--n >= 0) {
4054
+ const objectN = objects[n];
4055
+ if (objectN instanceof Spinner) {
4056
+ continue;
4057
+ }
4058
+ if (objectI.startTime - objectN.endTime > stackThreshold) {
4059
+ // We are no longer within stacking range of the previous object.
4060
+ break;
4061
+ }
4062
+ // Hit objects before the specified update range haven't been reset yet
4063
+ if (n < extendedStartIndex) {
4064
+ objectN.stackHeight = 0;
4065
+ extendedStartIndex = n;
4066
+ }
4067
+ // This is a special case where hit circles are moved DOWN and RIGHT (negative stacking) if they are under the *last* slider in a stacked pattern.
4068
+ // o==o <- slider is at original location
4069
+ // o <- hitCircle has stack of -1
4070
+ // o <- hitCircle has stack of -2
4071
+ if (objectN instanceof Slider &&
4072
+ objectN.endPosition.getDistance(objectI.position) <
4073
+ BeatmapProcessor.stackDistance) {
4074
+ const offset = objectI.stackHeight - objectN.stackHeight + 1;
4075
+ for (let j = n + 1; j <= i; ++j) {
4076
+ // For each object which was declared under this slider, we will offset it to appear *below* the slider end (rather than above).
4077
+ const objectJ = objects[j];
4078
+ if (objectN.endPosition.getDistance(objectJ.position) < BeatmapProcessor.stackDistance) {
4079
+ objectJ.stackHeight -= offset;
4080
+ }
4081
+ }
4082
+ // We have hit a slider. We should restart calculation using this as the new base.
4083
+ // Breaking here will mean that the slider still has a stack count of 0, so will be handled in the i-outer-loop.
4084
+ break;
4085
+ }
4086
+ if (objectN.position.getDistance(objectI.position) <
4087
+ BeatmapProcessor.stackDistance) {
4088
+ // Keep processing as if there are no sliders. If we come across a slider, this gets cancelled out.
4089
+ // NOTE: Sliders with start positions stacking are a special case that is also handled here.
4090
+ objectN.stackHeight = objectI.stackHeight + 1;
4091
+ objectI = objectN;
4092
+ }
4093
+ }
4094
+ }
4095
+ else if (objectI instanceof Slider) {
4096
+ // We have hit the first slider in a possible stack.
4097
+ // From this point on, we ALWAYS stack positive regardless.
4098
+ while (--n >= startIndex) {
4099
+ const objectN = objects[n];
4100
+ if (objectN instanceof Spinner) {
4101
+ continue;
4102
+ }
4103
+ if (objectI.startTime - objectN.startTime >
4104
+ stackThreshold) {
4105
+ // We are no longer within stacking range of the previous object.
4106
+ break;
4107
+ }
4108
+ if (objectN.endPosition.getDistance(objectI.position) <
4109
+ BeatmapProcessor.stackDistance) {
4110
+ objectN.stackHeight = objectI.stackHeight + 1;
4111
+ objectI = objectN;
4112
+ }
4113
+ }
4114
+ }
4068
4115
  }
4069
- if ((options === null || options === void 0 ? void 0 : options.checkIncompatible) !== false) {
4070
- mods = this.checkIncompatibleMods(mods);
4116
+ }
4117
+ applyStandardOldStacking() {
4118
+ const objects = this.beatmap.hitObjects.objects;
4119
+ for (let i = 0; i < objects.length; ++i) {
4120
+ const currentObject = objects[i];
4121
+ if (currentObject.stackHeight !== 0 &&
4122
+ !(currentObject instanceof Slider)) {
4123
+ continue;
4124
+ }
4125
+ let startTime = currentObject.endTime;
4126
+ let sliderStack = 0;
4127
+ const stackThreshold = currentObject.timePreempt * this.beatmap.general.stackLeniency;
4128
+ for (let j = i + 1; j < objects.length; ++j) {
4129
+ if (objects[j].startTime - stackThreshold > startTime) {
4130
+ break;
4131
+ }
4132
+ // Note the use of `startTime` in the code below doesn't match osu!stable's use of `endTime`.
4133
+ // This is because in osu!stable's implementation, `UpdateCalculations` is not called on the inner-loop hitobject (j)
4134
+ // and therefore it does not have a correct `endTime`, but instead the default of `endTime = startTime`.
4135
+ //
4136
+ // Effects of this can be seen on https://osu.ppy.sh/beatmapsets/243#osu/1146 at sliders around 86647 ms, where
4137
+ // if we use `endTime` here it would result in unexpected stacking.
4138
+ //
4139
+ // Reference: https://github.com/ppy/osu/pull/24188
4140
+ if (objects[j].position.getDistance(currentObject.position) <
4141
+ BeatmapProcessor.stackDistance) {
4142
+ ++currentObject.stackHeight;
4143
+ startTime = objects[j].startTime;
4144
+ }
4145
+ else if (objects[j].position.getDistance(currentObject.endPosition) <
4146
+ BeatmapProcessor.stackDistance) {
4147
+ // Case for sliders - bump notes down and right, rather than up and left.
4148
+ ++sliderStack;
4149
+ objects[j].stackHeight -= sliderStack;
4150
+ startTime = objects[j].startTime;
4151
+ }
4152
+ }
4071
4153
  }
4072
- return mods;
4073
4154
  }
4074
4155
  }
4075
- /**
4076
- * All `Mod`s that exists, mapped by their acronym.
4077
- */
4078
- ModUtil.allMods = (() => {
4079
- const mods = [
4080
- // Janky order to keep the order on what players are used to
4081
- ModAuto,
4082
- ModRelax,
4083
- ModAutopilot,
4084
- ModEasy,
4085
- ModNoFail,
4086
- ModHidden,
4087
- ModTraceable,
4088
- ModDoubleTime,
4089
- ModNightCore,
4090
- ModHalfTime,
4091
- ModCustomSpeed,
4092
- ModHardRock,
4093
- ModDifficultyAdjust,
4094
- ModFlashlight,
4095
- ModSuddenDeath,
4096
- ModPerfect,
4097
- ModPrecise,
4098
- ModReallyEasy,
4099
- ModScoreV2,
4100
- ModSmallCircle,
4101
- ModSpunOut,
4102
- ModTouchDevice,
4103
- ];
4104
- const map = new Map();
4105
- for (const mod of mods) {
4106
- map.set(new mod().acronym, mod);
4107
- }
4108
- return map;
4109
- })();
4156
+ BeatmapProcessor.stackDistance = 3;
4110
4157
 
4111
4158
  /**
4112
4159
  * Represents a beatmap that is in a playable state for a specific game mode.
@@ -4137,8 +4184,8 @@ class PlayableBeatmap {
4137
4184
  this.colors = baseBeatmap.colors;
4138
4185
  this.hitObjects = baseBeatmap.hitObjects;
4139
4186
  this.maxCombo = baseBeatmap.maxCombo;
4140
- this.mods = mods.slice();
4141
- this.speedMultiplier = ModUtil.calculateRateWithMods(this.mods);
4187
+ this.mods = mods;
4188
+ this.speedMultiplier = ModUtil.calculateRateWithMods(this.mods.values());
4142
4189
  }
4143
4190
  }
4144
4191
 
@@ -4147,7 +4194,7 @@ class PlayableBeatmap {
4147
4194
  */
4148
4195
  class DroidPlayableBeatmap extends PlayableBeatmap {
4149
4196
  createHitWindow() {
4150
- if (this.mods.some((m) => m instanceof ModPrecise)) {
4197
+ if (this.mods.has(ModPrecise)) {
4151
4198
  return new PreciseDroidHitWindow(this.difficulty.od);
4152
4199
  }
4153
4200
  else {
@@ -5008,7 +5055,7 @@ class Beatmap {
5008
5055
  * @param mods The `Mod`s to apply to the `Beatmap`. Defaults to No Mod.
5009
5056
  * @return The constructed `DroidPlayableBeatmap`.
5010
5057
  */
5011
- createDroidPlayableBeatmap(mods = []) {
5058
+ createDroidPlayableBeatmap(mods = new ModMap()) {
5012
5059
  return new DroidPlayableBeatmap(this.createPlayableBeatmap(mods, exports.Modes.droid), mods);
5013
5060
  }
5014
5061
  /**
@@ -5020,11 +5067,11 @@ class Beatmap {
5020
5067
  * @param mods The `Mod`s to apply to the `Beatmap`. Defaults to No Mod.
5021
5068
  * @return The constructed `OsuPlayableBeatmap`.
5022
5069
  */
5023
- createOsuPlayableBeatmap(mods = []) {
5070
+ createOsuPlayableBeatmap(mods = new ModMap()) {
5024
5071
  return new OsuPlayableBeatmap(this.createPlayableBeatmap(mods, exports.Modes.osu), mods);
5025
5072
  }
5026
5073
  createPlayableBeatmap(mods, mode) {
5027
- if (this.mode === mode && mods.length === 0) {
5074
+ if (this.mode === mode && mods.size === 0) {
5028
5075
  // Beatmap is already in a playable state.
5029
5076
  return this;
5030
5077
  }
@@ -8186,14 +8233,14 @@ class DroidLegacyModConverter {
8186
8233
  * @returns An array of `Mod`s.
8187
8234
  */
8188
8235
  static convert(str, difficulty) {
8236
+ const map = new ModMap();
8189
8237
  if (!str) {
8190
- return [];
8238
+ return map;
8191
8239
  }
8192
8240
  const data = str.split("|");
8193
8241
  if (!data[0]) {
8194
- return [];
8242
+ return map;
8195
8243
  }
8196
- const mods = [];
8197
8244
  for (const c of data[0]) {
8198
8245
  const modType = this.droidLegacyStorableMods.get(c);
8199
8246
  if (!modType) {
@@ -8201,24 +8248,24 @@ class DroidLegacyModConverter {
8201
8248
  }
8202
8249
  const mod = new modType();
8203
8250
  if (mod.isMigratableDroidMod() && difficulty) {
8204
- mods.push(mod.migrateDroidMod(difficulty));
8251
+ map.set(mod.migrateDroidMod(difficulty));
8205
8252
  }
8206
8253
  else {
8207
- mods.push(mod);
8254
+ map.set(mod);
8208
8255
  }
8209
8256
  }
8210
8257
  if (data.length > 1) {
8211
- this.parseExtraModString(mods, data.slice(1));
8258
+ this.parseExtraModString(map, data.slice(1));
8212
8259
  }
8213
- return mods;
8260
+ return map;
8214
8261
  }
8215
8262
  /**
8216
8263
  * Parses the extra strings of a mod string.
8217
8264
  *
8218
- * @param mods The current `Mod`s.
8265
+ * @param map The current `Mod`s.
8219
8266
  * @param extraStrings The extra strings to parse.
8220
8267
  */
8221
- static parseExtraModString(mods, extraStrings) {
8268
+ static parseExtraModString(map, extraStrings) {
8222
8269
  let customCS;
8223
8270
  let customAR;
8224
8271
  let customOD;
@@ -8240,20 +8287,20 @@ class DroidLegacyModConverter {
8240
8287
  break;
8241
8288
  // FL follow delay
8242
8289
  case s.startsWith("FLD"): {
8243
- let flashlight = mods.find((m) => m instanceof ModFlashlight);
8290
+ let flashlight = map.get(ModFlashlight);
8244
8291
  if (!flashlight) {
8245
8292
  flashlight = new ModFlashlight();
8246
- mods.push(flashlight);
8293
+ map.set(flashlight);
8247
8294
  }
8248
8295
  flashlight.followDelay = parseFloat(s.slice(3));
8249
8296
  break;
8250
8297
  }
8251
8298
  // Speed multiplier
8252
8299
  case s.startsWith("x"): {
8253
- let customSpeed = mods.find((m) => m instanceof ModCustomSpeed);
8300
+ let customSpeed = map.get(ModCustomSpeed);
8254
8301
  if (!customSpeed) {
8255
8302
  customSpeed = new ModCustomSpeed();
8256
- mods.push(customSpeed);
8303
+ map.set(customSpeed);
8257
8304
  }
8258
8305
  customSpeed.trackRateMultiplier = parseFloat(s.slice(1));
8259
8306
  break;
@@ -8264,7 +8311,7 @@ class DroidLegacyModConverter {
8264
8311
  customAR !== undefined ||
8265
8312
  customOD !== undefined ||
8266
8313
  customHP !== undefined) {
8267
- mods.push(new ModDifficultyAdjust({
8314
+ map.set(new ModDifficultyAdjust({
8268
8315
  cs: customCS,
8269
8316
  ar: customAR,
8270
8317
  od: customOD,
@@ -9817,6 +9864,7 @@ exports.ModFlashlight = ModFlashlight;
9817
9864
  exports.ModHalfTime = ModHalfTime;
9818
9865
  exports.ModHardRock = ModHardRock;
9819
9866
  exports.ModHidden = ModHidden;
9867
+ exports.ModMap = ModMap;
9820
9868
  exports.ModNightCore = ModNightCore;
9821
9869
  exports.ModNoFail = ModNoFail;
9822
9870
  exports.ModOldNightCore = ModOldNightCore;