1
0
mirror of https://github.com/ppy/osu.git synced 2026-06-07 04:13:38 +08:00
Files
osu-lazer/osu.Game.Rulesets.Mania/ManiaRuleset.cs
T
Bartłomiej Dach 1d3e682c5e Add score multiplier calculator API (#37822)
- Part of https://github.com/ppy/osu/issues/37818
- Continued from
https://github.com/ppy/osu/pull/37355#issuecomment-4449248107

## Overview

This PR introduces an alternative API, `ScoreMultiplierCalculator`, to
be used going forward for calculating mod multipliers.

The reason for introducing this new API is that it has been requested
that:
- For any two given mods, it should be possible to have the combined mod
multipliers of them in combination be *different* than the product of
the individual mods' multipliers in isolation, i.e. $mult( \\{ A, B \\}
) \neq mult( \\{ A \\} ) \cdot mult( \\{ B \\} )$.
- For an individual mod, it should be possible to have the mod
multipliers depend on a quantity that is *not* the presence of another
mod or the direct value of a setting on the mod.

This capability is being demonstrated in this PR via the
`osu.Game.Tests.Rulesets.Scoring.ScoreMultiplierCalculatorTest` test
fixture.

## Parity with `Mod.ScoreMultiplier`

This PR contains a `ScoreMultiplierCalculator` implementation for each
of the built-in four rulesets.

The abstract `osu.Game.Tests.Rulesets.RulesetScoreMultiplierTest` and
its four derived ruleset-specific test fixtures were written to ensure
that the new implementations do not diverge from the current state of
affairs.

`Mod.ScoreMultiplier` is not removed in this diff to keep size low. It
will be removed as a follow-up.

## Performance

This PR contains a benchmark comparing the current implementation via
`Mod.ScoreMultiplier` and the new `ScoreMultiplierCalculator` API.
Results below.

<details>

| Method | Times | Mods | Mean | Error | StdDev | Gen0 | Allocated |
|---------------------- |------ |---------------------
|--------------:|------------:|------------:|--------:|----------:|
| ViaModScoreMultiplier | 1 | mods (...)tings [27] | 121.171 ns | 1.5284
ns | 1.4297 ns | 0.0782 | 656 B |
| ViaCalculator | 1 | mods (...)tings [27] | 248.509 ns | 1.9313 ns |
1.6127 ns | 0.1364 | 1144 B |
| ViaModScoreMultiplier | 1 | multiple mods | 128.357 ns | 0.4282 ns |
0.4006 ns | 0.0782 | 656 B |
| ViaCalculator | 1 | multiple mods | 252.953 ns | 1.2860 ns | 1.2029 ns
| 0.1364 | 1144 B |
| ViaModScoreMultiplier | 1 | no mods | 3.007 ns | 0.0345 ns | 0.0288 ns
| - | - |
| ViaCalculator | 1 | no mods | 14.802 ns | 0.0616 ns | 0.0576 ns |
0.0134 | 112 B |
| ViaModScoreMultiplier | 1 | single mod | 40.271 ns | 0.1238 ns |
0.1098 ns | 0.0258 | 216 B |
| ViaCalculator | 1 | single mod | 113.033 ns | 0.3140 ns | 0.2937 ns |
0.0842 | 704 B |
| ViaModScoreMultiplier | 1 | single mod 2 | 3.653 ns | 0.0384 ns |
0.0359 ns | 0.0038 | 32 B |
| ViaCalculator | 1 | single mod 2 | 78.172 ns | 0.0680 ns | 0.0603 ns |
0.0621 | 520 B |
| ViaModScoreMultiplier | 10 | mods (...)tings [27] | 1,169.609 ns |
4.3058 ns | 4.0276 ns | 0.7839 | 6560 B |
| ViaCalculator | 10 | mods (...)tings [27] | 2,575.264 ns | 21.2705 ns
| 19.8964 ns | 1.3657 | 11440 B |
| ViaModScoreMultiplier | 10 | multiple mods | 1,171.775 ns | 6.2332 ns
| 5.2050 ns | 0.7839 | 6560 B |
| ViaCalculator | 10 | multiple mods | 2,579.593 ns | 22.1010 ns |
20.6733 ns | 1.3657 | 11440 B |
| ViaModScoreMultiplier | 10 | no mods | 35.943 ns | 0.1665 ns | 0.1476
ns | - | - |
| ViaCalculator | 10 | no mods | 154.980 ns | 0.2381 ns | 0.1988 ns |
0.1338 | 1120 B |
| ViaModScoreMultiplier | 10 | single mod | 404.185 ns | 1.3190 ns |
1.2338 ns | 0.2580 | 2160 B |
| ViaCalculator | 10 | single mod | 1,167.279 ns | 6.1641 ns | 5.7659 ns
| 0.8411 | 7040 B |
| ViaModScoreMultiplier | 10 | single mod 2 | 42.128 ns | 0.2878 ns |
0.2692 ns | 0.0382 | 320 B |
| ViaCalculator | 10 | single mod 2 | 775.435 ns | 2.3318 ns | 2.1811 ns
| 0.6208 | 5200 B |
| ViaModScoreMultiplier | 100 | mods (...)tings [27] | 11,623.346 ns |
51.7174 ns | 43.1863 ns | 7.8430 | 65600 B |
| ViaCalculator | 100 | mods (...)tings [27] | 25,252.987 ns | 44.4352
ns | 39.3906 ns | 13.6719 | 114400 B |
| ViaModScoreMultiplier | 100 | multiple mods | 11,928.536 ns | 35.2079
ns | 32.9334 ns | 7.8430 | 65600 B |
| ViaCalculator | 100 | multiple mods | 25,399.378 ns | 152.4597 ns |
127.3108 ns | 13.6719 | 114400 B |
| ViaModScoreMultiplier | 100 | no mods | 328.158 ns | 0.5827 ns |
0.5165 ns | - | - |
| ViaCalculator | 100 | no mods | 1,517.485 ns | 10.2304 ns | 9.5695 ns
| 1.3390 | 11200 B |
| ViaModScoreMultiplier | 100 | single mod | 3,986.251 ns | 24.2523 ns |
21.4991 ns | 2.5787 | 21600 B |
| ViaCalculator | 100 | single mod | 11,479.514 ns | 23.3738 ns |
20.7203 ns | 8.4076 | 70400 B |
| ViaModScoreMultiplier | 100 | single mod 2 | 385.679 ns | 3.5190 ns |
3.2917 ns | 0.3824 | 3200 B |
| ViaCalculator | 100 | single mod 2 | 7,658.646 ns | 21.8274 ns |
19.3494 ns | 6.2103 | 52000 B |

</details>

While the calculator is obviously slower, in my view it is not
egregiously so. The main overheads both time- and memory-wise are
collection allocations for the dictionary and the set which I consider
to be directly caused by the requested additional complexity and as such
I don't really consider them eliminable.

I have tried and applied some micro-optimisations
(e2469ce338,
cb33abec17), albeit with negligible
effect. I have also tried to key the mods by `Acronym` instead of by
`Type` and the difference was basically nil.

<details>
<summary>patch for keying by acronym</summary>

```diff
diff --git a/osu.Game/Rulesets/Scoring/ScoreMultiplierCalculator.cs b/osu.Game/Rulesets/Scoring/ScoreMultiplierCalculator.cs
index 772f9d178b..7f5907cbda 100644
--- a/osu.Game/Rulesets/Scoring/ScoreMultiplierCalculator.cs
+++ b/osu.Game/Rulesets/Scoring/ScoreMultiplierCalculator.cs
@@ -13,26 +13,26 @@ namespace osu.Game.Rulesets.Scoring
     /// </summary>
     public class ScoreMultiplierCalculator
     {
-        private static readonly List<(Type[] mods, Func<Mod[], double> multiplier)> combination_multipliers = [];
-        private static readonly Dictionary<Type, Func<Mod, ScoreMultiplierCalculator, double>> single_multipliers_with_context = [];
-        private static readonly Dictionary<Type, Func<Mod, double>> single_multipliers = [];
+        private static readonly List<(string[] modAcronyms, Func<Mod[], double> multiplier)> combination_multipliers = [];
+        private static readonly Dictionary<string, Func<Mod, ScoreMultiplierCalculator, double>> single_multipliers_with_context = [];
+        private static readonly Dictionary<string, Func<Mod, double>> single_multipliers = [];
 
         /// <summary>
         /// Defines a flat, setting-independent score multiplier for the given <typeparamref name="TMod"/>.
         /// </summary>
         public static void Single<TMod>(double hasMultiplier)
-            where TMod : Mod
+            where TMod : Mod, new()
         {
-            single_multipliers[typeof(TMod)] = _ => hasMultiplier;
+            single_multipliers[new TMod().Acronym] = _ => hasMultiplier;
         }
 
         /// <summary>
         /// Defines a setting-dependent score multiplier for the given <typeparamref name="TMod"/>.
         /// </summary>
         public static void Single<TMod>(Func<TMod, double> hasMultiplier)
-            where TMod : Mod
+            where TMod : Mod, new()
         {
-            single_multipliers[typeof(TMod)] = mod => hasMultiplier.Invoke((TMod)mod);
+            single_multipliers[new TMod().Acronym] = mod => hasMultiplier.Invoke((TMod)mod);
         }
 
         /// <summary>
@@ -40,20 +40,20 @@ public static void Single<TMod>(Func<TMod, double> hasMultiplier)
         /// The multiplier calculation is given additional context to calculate the multiplier via the <typeparamref name="TContext"/> type instance.
         /// </summary>
         public static void Single<TMod, TContext>(Func<TMod, TContext, double> hasMultiplier)
-            where TMod : Mod
+            where TMod : Mod, new()
             where TContext : ScoreMultiplierCalculator
         {
-            single_multipliers_with_context[typeof(TMod)] = (mod, context) => hasMultiplier.Invoke((TMod)mod, (TContext)context);
+            single_multipliers_with_context[new TMod().Acronym] = (mod, context) => hasMultiplier.Invoke((TMod)mod, (TContext)context);
         }
 
         /// <summary>
         /// Defines a score multiplier specific to when both <typeparamref name="T1"/> and <typeparamref name="T2"/> mods are present.
         /// </summary>
         public static void Combination<T1, T2>(Func<T1, T2, double> hasMultiplier)
-            where T1 : Mod
-            where T2 : Mod
+            where T1 : Mod, new()
+            where T2 : Mod, new()
         {
-            combination_multipliers.Add(([typeof(T1), typeof(T2)], mods => hasMultiplier((T1)mods[0], (T2)mods[1])));
+            combination_multipliers.Add(([new T1().Acronym, new T2().Acronym], mods => hasMultiplier((T1)mods[0], (T2)mods[1])));
         }
 
         /// <summary>
@@ -61,7 +61,7 @@ public static void Combination<T1, T2>(Func<T1, T2, double> hasMultiplier)
         /// </summary>
         public double CalculateFor(IEnumerable<Mod> mods)
         {
-            var allModsByType = mods.ToDictionary(m => m.GetType());
+            var allModsByType = mods.ToDictionary(m => m.Acronym);
 
             if (allModsByType.Count == 0)
                 return 1;
@@ -83,7 +83,7 @@ public double CalculateFor(IEnumerable<Mod> mods)
                 }
             }
 
-            foreach (var modType in remainingModTypes)
+            foreach (string modType in remainingModTypes)
             {
                 if (single_multipliers.TryGetValue(modType, out var multiplier))
                     result *= multiplier(allModsByType[modType]);

```

</details>

One particular parallel thread that may warrant follow-up is that
`Mod.UsesDefaultConfiguration` is disproportionately expensive due to
calling into regexes via Humanizer internals.

<img width="1517" height="517" alt="Screenshot_2026-05-19_at_10 58 30"
src="https://github.com/user-attachments/assets/68309a8c-74e7-4f96-8ef9-62868eeca337"
/>
2026-05-19 21:37:17 +09:00

542 lines
22 KiB
C#

// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using System.Linq;
using osu.Framework.Extensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Input.Bindings;
using osu.Framework.Localisation;
using osu.Game.Beatmaps;
using osu.Game.Beatmaps.Legacy;
using osu.Game.Configuration;
using osu.Game.Graphics;
using osu.Game.Localisation;
using osu.Game.Overlays.Settings;
using osu.Game.Rulesets.Configuration;
using osu.Game.Rulesets.Difficulty;
using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Filter;
using osu.Game.Rulesets.Mania.Beatmaps;
using osu.Game.Rulesets.Mania.Configuration;
using osu.Game.Rulesets.Mania.Difficulty;
using osu.Game.Rulesets.Mania.Edit;
using osu.Game.Rulesets.Mania.Edit.Setup;
using osu.Game.Rulesets.Mania.Mods;
using osu.Game.Rulesets.Mania.Replays;
using osu.Game.Rulesets.Mania.Scoring;
using osu.Game.Rulesets.Mania.Skinning.Argon;
using osu.Game.Rulesets.Mania.Skinning.Default;
using osu.Game.Rulesets.Mania.Skinning.Legacy;
using osu.Game.Rulesets.Mania.UI;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Replays.Types;
using osu.Game.Rulesets.Scoring;
using osu.Game.Rulesets.Scoring.Legacy;
using osu.Game.Rulesets.UI;
using osu.Game.Scoring;
using osu.Game.Screens.Edit.Setup;
using osu.Game.Screens.Ranking.Statistics;
using osu.Game.Skinning;
namespace osu.Game.Rulesets.Mania
{
public class ManiaRuleset : Ruleset, ILegacyRuleset
{
/// <summary>
/// The maximum number of supported keys in a single stage.
/// </summary>
public const int MAX_STAGE_KEYS = 10;
public override DrawableRuleset CreateDrawableRulesetWith(IBeatmap beatmap, IReadOnlyList<Mod>? mods = null) => new DrawableManiaRuleset(this, beatmap, mods);
public override ScoreProcessor CreateScoreProcessor() => new ManiaScoreProcessor();
public override HealthProcessor CreateHealthProcessor(double drainStartTime) => new ManiaHealthProcessor(drainStartTime);
public override IBeatmapConverter CreateBeatmapConverter(IBeatmap beatmap) => new ManiaBeatmapConverter(beatmap, this);
public override PerformanceCalculator CreatePerformanceCalculator() => new ManiaPerformanceCalculator();
public const string SHORT_NAME = "mania";
public override string RulesetAPIVersionSupported => CURRENT_RULESET_API_VERSION;
public override HitObjectComposer CreateHitObjectComposer() => new ManiaHitObjectComposer(this);
public override IBeatmapVerifier CreateBeatmapVerifier() => new ManiaBeatmapVerifier();
public override ISkin? CreateSkinTransformer(ISkin skin, IBeatmap beatmap)
{
switch (skin)
{
case TrianglesSkin:
return new ManiaTrianglesSkinTransformer(skin, beatmap);
case ArgonSkin:
return new ManiaArgonSkinTransformer(skin, beatmap);
case DefaultLegacySkin:
case RetroSkin:
return new ManiaClassicSkinTransformer(skin, beatmap);
case LegacySkin:
return new ManiaLegacySkinTransformer(skin, beatmap);
}
return null;
}
public override IEnumerable<Mod> ConvertFromLegacyMods(LegacyMods mods)
{
if (mods.HasFlag(LegacyMods.Nightcore))
yield return new ManiaModNightcore();
else if (mods.HasFlag(LegacyMods.DoubleTime))
yield return new ManiaModDoubleTime();
if (mods.HasFlag(LegacyMods.Perfect))
yield return new ManiaModPerfect();
else if (mods.HasFlag(LegacyMods.SuddenDeath))
yield return new ManiaModSuddenDeath();
if (mods.HasFlag(LegacyMods.Cinema))
yield return new ManiaModCinema();
else if (mods.HasFlag(LegacyMods.Autoplay))
yield return new ManiaModAutoplay();
if (mods.HasFlag(LegacyMods.Easy))
yield return new ManiaModEasy();
if (mods.HasFlag(LegacyMods.FadeIn))
yield return new ManiaModFadeIn();
if (mods.HasFlag(LegacyMods.Flashlight))
yield return new ManiaModFlashlight();
if (mods.HasFlag(LegacyMods.HalfTime))
yield return new ManiaModHalfTime();
if (mods.HasFlag(LegacyMods.HardRock))
yield return new ManiaModHardRock();
if (mods.HasFlag(LegacyMods.Hidden))
yield return new ManiaModHidden();
if (mods.HasFlag(LegacyMods.Key1))
yield return new ManiaModKey1();
if (mods.HasFlag(LegacyMods.Key2))
yield return new ManiaModKey2();
if (mods.HasFlag(LegacyMods.Key3))
yield return new ManiaModKey3();
if (mods.HasFlag(LegacyMods.Key4))
yield return new ManiaModKey4();
if (mods.HasFlag(LegacyMods.Key5))
yield return new ManiaModKey5();
if (mods.HasFlag(LegacyMods.Key6))
yield return new ManiaModKey6();
if (mods.HasFlag(LegacyMods.Key7))
yield return new ManiaModKey7();
if (mods.HasFlag(LegacyMods.Key8))
yield return new ManiaModKey8();
if (mods.HasFlag(LegacyMods.Key9))
yield return new ManiaModKey9();
if (mods.HasFlag(LegacyMods.KeyCoop))
yield return new ManiaModDualStages();
if (mods.HasFlag(LegacyMods.NoFail))
yield return new ManiaModNoFail();
if (mods.HasFlag(LegacyMods.Random))
yield return new ManiaModRandom();
if (mods.HasFlag(LegacyMods.Mirror))
yield return new ManiaModMirror();
if (mods.HasFlag(LegacyMods.ScoreV2))
yield return new ManiaModScoreV2();
}
public override LegacyMods ConvertToLegacyMods(Mod[] mods)
{
var value = base.ConvertToLegacyMods(mods);
foreach (var mod in mods)
{
switch (mod)
{
case ManiaModKey1:
value |= LegacyMods.Key1;
break;
case ManiaModKey2:
value |= LegacyMods.Key2;
break;
case ManiaModKey3:
value |= LegacyMods.Key3;
break;
case ManiaModKey4:
value |= LegacyMods.Key4;
break;
case ManiaModKey5:
value |= LegacyMods.Key5;
break;
case ManiaModKey6:
value |= LegacyMods.Key6;
break;
case ManiaModKey7:
value |= LegacyMods.Key7;
break;
case ManiaModKey8:
value |= LegacyMods.Key8;
break;
case ManiaModKey9:
value |= LegacyMods.Key9;
break;
case ManiaModDualStages:
value |= LegacyMods.KeyCoop;
break;
case ManiaModFadeIn:
value |= LegacyMods.FadeIn;
value &= ~LegacyMods.Hidden; // this is toggled on in the base call due to inheritance, but we don't want that.
break;
case ManiaModMirror:
value |= LegacyMods.Mirror;
break;
case ManiaModRandom:
value |= LegacyMods.Random;
break;
}
}
return value;
}
public override IEnumerable<Mod> GetModsFor(ModType type)
{
switch (type)
{
case ModType.DifficultyReduction:
return new Mod[]
{
new ManiaModEasy(),
new ManiaModNoFail(),
new MultiMod(new ManiaModHalfTime(), new ManiaModDaycore()),
new ManiaModNoRelease(),
};
case ModType.DifficultyIncrease:
return new Mod[]
{
new ManiaModHardRock(),
new MultiMod(new ManiaModSuddenDeath(), new ManiaModPerfect()),
new MultiMod(new ManiaModDoubleTime(), new ManiaModNightcore()),
new MultiMod(new ManiaModFadeIn(), new ManiaModHidden(), new ManiaModCover()),
new ManiaModFlashlight(),
new ModAccuracyChallenge(),
};
case ModType.Conversion:
return new Mod[]
{
new ManiaModRandom(),
new ManiaModDualStages(),
new ManiaModMirror(),
new ManiaModDifficultyAdjust(),
new ManiaModClassic(),
new ManiaModInvert(),
new ManiaModConstantSpeed(),
new ManiaModHoldOff(),
new MultiMod(
new ManiaModKey1(),
new ManiaModKey2(),
new ManiaModKey3(),
new ManiaModKey4(),
new ManiaModKey5(),
new ManiaModKey6(),
new ManiaModKey7(),
new ManiaModKey8(),
new ManiaModKey9(),
new ManiaModKey10()
),
};
case ModType.Automation:
return new Mod[]
{
new MultiMod(new ManiaModAutoplay(), new ManiaModCinema()),
};
case ModType.Fun:
return new Mod[]
{
new MultiMod(new ModWindUp(), new ModWindDown()),
new ManiaModMuted(),
new ModAdaptiveSpeed()
};
case ModType.System:
return new Mod[]
{
new ManiaModScoreV2(),
};
default:
return Array.Empty<Mod>();
}
}
public override ScoreMultiplierCalculator CreateScoreMultiplierCalculator() => new ManiaScoreMultiplierCalculator();
public override string Description => "osu!mania";
public override string ShortName => SHORT_NAME;
public override string PlayingVerb => "Smashing keys";
public override Drawable CreateIcon() => new SpriteIcon { Icon = OsuIcon.RulesetMania };
public override DifficultyCalculator CreateDifficultyCalculator(IWorkingBeatmap beatmap) => new ManiaDifficultyCalculator(RulesetInfo, beatmap);
public int LegacyID => 3;
public ILegacyScoreSimulator CreateLegacyScoreSimulator() => new ManiaLegacyScoreSimulator();
public override IConvertibleReplayFrame CreateConvertibleReplayFrame() => new ManiaReplayFrame();
public override IRulesetConfigManager CreateConfig(SettingsStore? settings) => new ManiaRulesetConfigManager(settings, RulesetInfo);
public override RulesetSettingsSubsection CreateSettings() => new ManiaSettingsSubsection(this);
public override LocalisableString VariantDescription => "Keys";
public override IEnumerable<int> AvailableVariants
{
get
{
for (int i = 1; i <= MAX_STAGE_KEYS; i++)
yield return (int)PlayfieldType.Single + i;
for (int i = 2; i <= MAX_STAGE_KEYS * 2; i += 2)
yield return (int)PlayfieldType.Dual + i;
}
}
public override IEnumerable<KeyBinding> GetDefaultKeyBindings(int variant = 0)
{
switch (getPlayfieldType(variant))
{
case PlayfieldType.Single:
return new SingleStageVariantGenerator(variant).GenerateMappings();
case PlayfieldType.Dual:
return new DualStageVariantGenerator(getDualStageKeyCount(variant)).GenerateMappings();
}
return Array.Empty<KeyBinding>();
}
public override LocalisableString GetVariantName(int variant)
{
switch (getPlayfieldType(variant))
{
default:
return $"{variant}K";
case PlayfieldType.Dual:
{
int keys = getDualStageKeyCount(variant);
return $"{keys}K + {keys}K";
}
}
}
/// <summary>
/// Finds the number of keys for each stage in a <see cref="PlayfieldType.Dual"/> variant.
/// </summary>
/// <param name="variant">The variant.</param>
private int getDualStageKeyCount(int variant) => (variant - (int)PlayfieldType.Dual) / 2;
/// <summary>
/// Finds the <see cref="PlayfieldType"/> that corresponds to a variant value.
/// </summary>
/// <param name="variant">The variant value.</param>
/// <returns>The <see cref="PlayfieldType"/> that corresponds to <paramref name="variant"/>.</returns>
private PlayfieldType getPlayfieldType(int variant)
{
return (PlayfieldType)Enum.GetValues(typeof(PlayfieldType)).Cast<int>().OrderDescending().First(v => variant >= v);
}
public override IEnumerable<HitResult> GetValidHitResults()
{
return new[]
{
HitResult.Perfect,
HitResult.Great,
HitResult.Good,
HitResult.Ok,
HitResult.Meh,
HitResult.Miss,
HitResult.IgnoreHit,
HitResult.ComboBreak,
HitResult.IgnoreMiss,
};
}
public override StatisticItem[] CreateStatisticsForScore(ScoreInfo score, IBeatmap playableBeatmap) => new[]
{
new StatisticItem("Performance Breakdown", () => new PerformanceBreakdownChart(score, playableBeatmap)
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y
}),
new StatisticItem("Timing Distribution", () => new HitEventTimingDistributionGraph(score.HitEvents)
{
RelativeSizeAxes = Axes.X,
Height = 250
}, true),
new StatisticItem("Statistics", () => new SimpleStatisticTable(2, new SimpleStatisticItem[]
{
new AverageHitError(score.HitEvents),
new UnstableRate(score.HitEvents)
}), true)
};
/// <seealso cref="ManiaHitWindows"/>
public override BeatmapDifficulty GetAdjustedDisplayDifficulty(IBeatmapInfo beatmapInfo, IReadOnlyCollection<Mod> mods)
{
BeatmapDifficulty adjustedDifficulty = base.GetAdjustedDisplayDifficulty(beatmapInfo, mods);
// notably, in mania, hit windows are designed to be independent of track playback rate (see `ManiaHitWindows.SpeedMultiplier`).
// *however*, to not make matters *too* simple, mania Hard Rock and Easy differ from all other rulesets
// in that they apply multipliers *to hit window durations directly* rather than to the Overall Difficulty attribute itself.
// because the duration of hit window durations as a function of OD is not a linear function,
// this means that multiplying the OD is *not* the same thing as multiplying the hit window duration.
// in fact, the second operation is *much* harsher and will produce values much farther outside of normal operating range
// (even negative in the case of Easy).
// stable handles this wrong on song select and just assumes that it can handle mania EZ / HR the same way as all other rulesets.
double perfectHitWindow = IBeatmapDifficultyInfo.DifficultyRange(adjustedDifficulty.OverallDifficulty, ManiaHitWindows.PERFECT_WINDOW_RANGE);
if (mods.Any(m => m is ManiaModHardRock))
perfectHitWindow /= ManiaModHardRock.HIT_WINDOW_DIFFICULTY_MULTIPLIER;
else if (mods.Any(m => m is ManiaModEasy))
perfectHitWindow /= ManiaModEasy.HIT_WINDOW_DIFFICULTY_MULTIPLIER;
adjustedDifficulty.OverallDifficulty = (float)IBeatmapDifficultyInfo.InverseDifficultyRange(perfectHitWindow, ManiaHitWindows.PERFECT_WINDOW_RANGE);
adjustedDifficulty.CircleSize = ManiaBeatmapConverter.GetColumnCount(LegacyBeatmapConversionDifficultyInfo.FromBeatmapInfo(beatmapInfo), mods);
return adjustedDifficulty;
}
public override IEnumerable<RulesetBeatmapAttribute> GetBeatmapAttributesForDisplay(IBeatmapInfo beatmapInfo, IReadOnlyCollection<Mod> mods)
{
// a special touch-up of key count is required to the original difficulty, since key conversion mods are not `IApplicableToDifficulty`
var originalDifficulty = new BeatmapDifficulty(beatmapInfo.Difficulty)
{
CircleSize = ManiaBeatmapConverter.GetColumnCount(LegacyBeatmapConversionDifficultyInfo.FromBeatmapInfo(beatmapInfo), [])
};
var adjustedDifficulty = GetAdjustedDisplayDifficulty(beatmapInfo, mods);
var colours = new OsuColour();
yield return new RulesetBeatmapAttribute(SongSelectStrings.KeyCount, @"KC", originalDifficulty.CircleSize, adjustedDifficulty.CircleSize, 18)
{
Description = "Affects the number of key columns on the playfield."
};
var hitWindows = new ManiaHitWindows();
hitWindows.SetDifficulty(adjustedDifficulty.OverallDifficulty);
hitWindows.IsConvert = !beatmapInfo.Ruleset.Equals(RulesetInfo);
hitWindows.ClassicModActive = mods.Any(m => m is ManiaModClassic);
yield return new RulesetBeatmapAttribute(SongSelectStrings.Accuracy, @"OD", originalDifficulty.OverallDifficulty, adjustedDifficulty.OverallDifficulty, 10)
{
Description = "Affects timing requirements for notes.",
AdditionalMetrics = hitWindows.GetAllAvailableWindows()
.Reverse()
.Select(window => new RulesetBeatmapAttribute.AdditionalMetric(
$"{window.result.GetDescription().ToUpperInvariant()} hit window",
LocalisableString.Interpolate($@"±{hitWindows.WindowFor(window.result):0.##} ms"),
colours.ForHitResult(window.result)
)).ToArray()
};
yield return new RulesetBeatmapAttribute(SongSelectStrings.HPDrain, @"HP", originalDifficulty.DrainRate, adjustedDifficulty.DrainRate, 10)
{
Description = "Affects the harshness of health drain and the health penalties for missing."
};
}
public override IEnumerable<RulesetBeatmapAttribute> GetBeatmapAttributesForRankedPlayCard(IBeatmapInfo beatmapInfo, IReadOnlyCollection<Mod> mods)
{
var attributes = GetBeatmapAttributesForDisplay(beatmapInfo, mods).ToList();
// Key count attribute isn't relevant to ranked play (it's decided by the pool).
attributes.RemoveAll(a => a.Acronym == "KC");
float holdNoteRatio = beatmapInfo.TotalObjectCount == 0 ? 0 : (float)beatmapInfo.EndTimeObjectCount / beatmapInfo.TotalObjectCount;
attributes.Insert(0, new RulesetBeatmapAttribute("Hold notes", @"HN", holdNoteRatio, holdNoteRatio, 1)
{
ValueFormat = "P0"
});
return attributes;
}
public override IRulesetFilterCriteria CreateRulesetFilterCriteria()
{
return new ManiaFilterCriteria();
}
public override IEnumerable<Drawable> CreateEditorSetupSections() =>
[
new MetadataSection(),
new ManiaDifficultySection(),
new ResourcesSection(),
new DesignSection(),
];
public int GetKeyCount(IBeatmapInfo beatmapInfo, IReadOnlyList<Mod>? mods = null)
=> ManiaBeatmapConverter.GetColumnCount(LegacyBeatmapConversionDifficultyInfo.FromBeatmapInfo(beatmapInfo), mods);
public override int GetVariantForBeatmap(IBeatmapInfo beatmapInfo, IReadOnlyList<Mod>? mods = null)
=> GetKeyCount(beatmapInfo, mods);
}
public enum PlayfieldType
{
/// <summary>
/// Columns are grouped into a single stage.
/// Number of columns in this stage lies at (item - Single).
/// </summary>
Single = 0,
/// <summary>
/// Columns are grouped into two stages.
/// Overall number of columns lies at (item - Dual), further computation is required for
/// number of columns in each individual stage.
/// </summary>
Dual = 1000,
}
}