1
0
mirror of https://github.com/ppy/osu.git synced 2026-05-28 03:53:45 +08:00
Files
osu-lazer/osu.Game/Screens/Select/Details/AdvancedStats.cs
T
Bartłomiej Dach 131f828e6a Attempt to properly quantify the impact of mania Hard Rock / Easy mod application on overall difficulty
In stable mania, Hard Rock and Easy mods do not work the same way as
they do on all of the rulesets. The difference is that mania HR and EZ,
rather than apply a multiplier to the map's original Overall Difficulty,
apply multipliers to *the durations of hit windows themselves*.

Prior to the last release, lazer was oblivious to this reality and just
treated mania HR / EZ as it did every other ruleset. Last release, for
the sake for gameplay parity across rulesets, the mods in question were
adjusted to match stable, but in the process, it started looking like HR
/ EZ did not change OD anymore.

The problem is that they do, but applying a multiplier to the map's OD
and applying a multiplier to the hit window duration is not the same
thing. The second thing is actually *much harsher* in magnitude, to the
point where applying HR to any map is almost guaranteed to exceed "the
effective OD" of 10, and applying EZ to any map is almost guaranteed to
result in "negative effective OD".

This change attempts to convey that reality by displaying "effective
OD", similar to what's already done in other rulesets when rate-changing
mods are active. Note that the values this will display *do not match*
stable *and that is correct*, because stable song select *lies* about
the actual impact on OD by just assuming it can treat all rulesets in
the same way.

---

Would close https://github.com/ppy/osu/issues/34150 I guess.

And yes I would like *all of the above* to land on the changelog if
possible if this is merged.

For further convincing that this makes any semblance of sense please see
the following: https://www.desmos.com/calculator/yigt7jycdv
2025-07-15 09:40:55 +02:00

385 lines
16 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.
#nullable disable
using System;
using osuTK.Graphics;
using osu.Framework.Allocation;
using osu.Framework.Extensions.Color4Extensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Cursor;
using osu.Game.Graphics;
using osu.Game.Graphics.Sprites;
using osu.Game.Graphics.UserInterface;
using osu.Game.Beatmaps;
using osu.Framework.Bindables;
using System.Collections.Generic;
using osu.Game.Rulesets.Mods;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using osu.Framework.Extensions;
using osu.Framework.Localisation;
using osu.Framework.Threading;
using osu.Framework.Utils;
using osu.Game.Configuration;
using osu.Game.Resources.Localisation.Web;
using osu.Game.Rulesets;
using osu.Game.Overlays.Mods;
using osu.Game.Utils;
namespace osu.Game.Screens.Select.Details
{
public partial class AdvancedStats : Container, IHasCustomTooltip<AdjustedAttributesTooltip.Data>
{
[Resolved]
private BeatmapDifficultyCache difficultyCache { get; set; }
protected readonly StatisticRow FirstValue, HpDrain, Accuracy, ApproachRate;
private readonly StatisticRow starDifficulty;
public ITooltip<AdjustedAttributesTooltip.Data> GetCustomTooltip() => new AdjustedAttributesTooltip();
public AdjustedAttributesTooltip.Data TooltipContent { get; private set; }
private IBeatmapInfo beatmapInfo;
public IBeatmapInfo BeatmapInfo
{
get => beatmapInfo;
set
{
if (value == beatmapInfo) return;
beatmapInfo = value;
updateStatistics();
}
}
/// <summary>
/// Ruleset to be used for certain elements of display.
/// When set, this will override the set <see cref="Beatmap"/>'s own ruleset.
/// </summary>
/// <remarks>
/// No checks are done as to whether the ruleset specified is valid for the currently <see cref="BeatmapInfo"/>.
/// </remarks>
public Bindable<RulesetInfo> Ruleset { get; } = new Bindable<RulesetInfo>();
/// <summary>
/// Mods to be used for certain elements of display.
/// </summary>
/// <remarks>
/// No checks are done as to whether the mods specified are valid for the current <see cref="Ruleset"/>.
/// </remarks>
public Bindable<IReadOnlyList<Mod>> Mods { get; } = new Bindable<IReadOnlyList<Mod>>(Array.Empty<Mod>());
public AdvancedStats(int columns = 1)
{
switch (columns)
{
case 1:
Child = new FillFlowContainer
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Children = new[]
{
FirstValue = new StatisticRow(), // circle size/key amount
HpDrain = new StatisticRow { Title = BeatmapsetsStrings.ShowStatsDrain },
Accuracy = new StatisticRow { Title = BeatmapsetsStrings.ShowStatsAccuracy },
ApproachRate = new StatisticRow { Title = BeatmapsetsStrings.ShowStatsAr },
starDifficulty = new StatisticRow(10, true) { Title = BeatmapsetsStrings.ShowStatsStars },
},
};
break;
case 2:
Child = new FillFlowContainer
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Direction = FillDirection.Full,
Children = new[]
{
FirstValue = new StatisticRow
{
Width = 0.5f,
Padding = new MarginPadding { Right = 5, Vertical = 2.5f },
}, // circle size/key amount
HpDrain = new StatisticRow
{
Title = BeatmapsetsStrings.ShowStatsDrain,
Width = 0.5f,
Padding = new MarginPadding { Left = 5, Vertical = 2.5f },
},
Accuracy = new StatisticRow
{
Title = BeatmapsetsStrings.ShowStatsAccuracy,
Width = 0.5f,
Padding = new MarginPadding { Right = 5, Vertical = 2.5f },
},
ApproachRate = new StatisticRow
{
Title = BeatmapsetsStrings.ShowStatsAr,
Width = 0.5f,
Padding = new MarginPadding { Left = 5, Vertical = 2.5f },
},
starDifficulty = new StatisticRow(10, true)
{
Title = BeatmapsetsStrings.ShowStatsStars,
Width = 0.5f,
Padding = new MarginPadding { Right = 5, Vertical = 2.5f },
},
},
};
break;
}
}
[BackgroundDependencyLoader]
private void load(OsuColour colours)
{
starDifficulty.AccentColour = colours.Yellow;
}
protected override void LoadComplete()
{
base.LoadComplete();
Ruleset.BindValueChanged(_ => updateStatistics());
Mods.BindValueChanged(modsChanged, true);
}
private ModSettingChangeTracker modSettingChangeTracker;
private ScheduledDelegate debouncedStatisticsUpdate;
private void modsChanged(ValueChangedEvent<IReadOnlyList<Mod>> mods)
{
modSettingChangeTracker?.Dispose();
modSettingChangeTracker = new ModSettingChangeTracker(mods.NewValue);
modSettingChangeTracker.SettingChanged += _ =>
{
debouncedStatisticsUpdate?.Cancel();
debouncedStatisticsUpdate = Scheduler.AddDelayed(updateStatistics, 100);
};
updateStatistics();
}
private void updateStatistics()
{
IBeatmapDifficultyInfo baseDifficulty = BeatmapInfo?.Difficulty;
BeatmapDifficulty adjustedDifficulty = null;
if (baseDifficulty != null)
{
BeatmapDifficulty originalDifficulty = new BeatmapDifficulty(baseDifficulty);
foreach (var mod in Mods.Value.OfType<IApplicableToDifficulty>())
mod.ApplyToDifficulty(originalDifficulty);
adjustedDifficulty = originalDifficulty;
if (Ruleset.Value != null)
{
adjustedDifficulty = Ruleset.Value.CreateInstance().GetAdjustedDisplayDifficulty(originalDifficulty, Mods.Value);
TooltipContent = new AdjustedAttributesTooltip.Data(originalDifficulty, adjustedDifficulty);
}
}
switch (Ruleset.Value?.OnlineID)
{
case 3:
// Account for mania differences locally for now.
// Eventually this should be handled in a more modular way, allowing rulesets to return arbitrary difficulty attributes.
ILegacyRuleset legacyRuleset = (ILegacyRuleset)Ruleset.Value.CreateInstance();
// For the time being, the key count is static no matter what, because:
// a) The method doesn't have knowledge of the active keymods. Doing so may require considerations for filtering.
// b) Using the difficulty adjustment mod to adjust OD doesn't have an effect on conversion.
int keyCount = baseDifficulty == null ? 0 : legacyRuleset.GetKeyCount(BeatmapInfo, Mods.Value);
FirstValue.Title = BeatmapsetsStrings.ShowStatsCsMania;
FirstValue.Value = (keyCount, keyCount);
break;
default:
FirstValue.Title = BeatmapsetsStrings.ShowStatsCs;
FirstValue.Value = (baseDifficulty?.CircleSize ?? 0, adjustedDifficulty?.CircleSize);
break;
}
HpDrain.Value = (baseDifficulty?.DrainRate ?? 0, adjustedDifficulty?.DrainRate);
Accuracy.Value = (baseDifficulty?.OverallDifficulty ?? 0, adjustedDifficulty?.OverallDifficulty);
ApproachRate.Value = (baseDifficulty?.ApproachRate ?? 0, adjustedDifficulty?.ApproachRate);
updateStarDifficulty();
}
private CancellationTokenSource starDifficultyCancellationSource;
/// <summary>
/// Updates the displayed star difficulty statistics with the values provided by the currently-selected beatmap, ruleset, and selected mods.
/// </summary>
/// <remarks>
/// This is scheduled to avoid scenarios wherein a ruleset changes first before selected mods do,
/// potentially resulting in failure during difficulty calculation due to incomplete bindable state updates.
/// </remarks>
private void updateStarDifficulty() => Scheduler.AddOnce(() =>
{
starDifficultyCancellationSource?.Cancel();
if (BeatmapInfo == null)
return;
starDifficultyCancellationSource = new CancellationTokenSource();
var normalStarDifficultyTask = difficultyCache.GetDifficultyAsync(BeatmapInfo, Ruleset.Value, null, starDifficultyCancellationSource.Token);
var moddedStarDifficultyTask = difficultyCache.GetDifficultyAsync(BeatmapInfo, Ruleset.Value, Mods.Value, starDifficultyCancellationSource.Token);
Task.WhenAll(normalStarDifficultyTask, moddedStarDifficultyTask).ContinueWith(_ => Schedule(() =>
{
var normalDifficulty = normalStarDifficultyTask.GetResultSafely();
var moddedDifficulty = moddedStarDifficultyTask.GetResultSafely();
if (normalDifficulty == null || moddedDifficulty == null)
return;
starDifficulty.Value = ((float)normalDifficulty.Value.Stars.FloorToDecimalDigits(2), (float)moddedDifficulty.Value.Stars.FloorToDecimalDigits(2));
}), starDifficultyCancellationSource.Token, TaskContinuationOptions.OnlyOnRanToCompletion, TaskScheduler.Current);
});
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);
modSettingChangeTracker?.Dispose();
starDifficultyCancellationSource?.Cancel();
}
public partial class StatisticRow : Container, IHasAccentColour
{
private const float value_width = 25;
private const float name_width = 70;
private readonly float maxValue;
private readonly bool forceDecimalPlaces;
private readonly OsuSpriteText name, valueText;
private readonly Bar bar;
public readonly Bar ModBar;
[Resolved]
private OsuColour colours { get; set; }
public LocalisableString Title
{
get => name.Text;
set => name.Text = value;
}
private (float baseValue, float? adjustedValue)? value;
public (float baseValue, float? adjustedValue) Value
{
get => value ?? (0, null);
set
{
if (value == this.value)
return;
this.value = value;
bar.Length = value.baseValue / maxValue;
valueText.Text = (value.adjustedValue ?? value.baseValue).ToString(forceDecimalPlaces ? "0.00" : "0.##");
ModBar.Length = (value.adjustedValue ?? 0) / maxValue;
if (Precision.AlmostEquals(value.baseValue, value.adjustedValue ?? value.baseValue, 0.05f))
ModBar.AccentColour = valueText.Colour = Color4.White;
else if (value.adjustedValue > value.baseValue)
ModBar.AccentColour = valueText.Colour = colours.Red;
else if (value.adjustedValue < value.baseValue)
ModBar.AccentColour = valueText.Colour = colours.BlueDark;
}
}
public Color4 AccentColour
{
get => bar.AccentColour;
set => bar.AccentColour = value;
}
public StatisticRow(float maxValue = 10, bool forceDecimalPlaces = false)
{
this.maxValue = maxValue;
this.forceDecimalPlaces = forceDecimalPlaces;
RelativeSizeAxes = Axes.X;
AutoSizeAxes = Axes.Y;
Padding = new MarginPadding { Vertical = 2.5f };
Children = new Drawable[]
{
new Container
{
Width = name_width,
AutoSizeAxes = Axes.Y,
// osu-web uses 1.25 line-height, which at 12px font size makes the element 14px tall - this compentates that difference
Padding = new MarginPadding { Vertical = 1 },
Child = name = new OsuSpriteText
{
Font = OsuFont.GetFont(size: 12)
},
},
new Container
{
RelativeSizeAxes = Axes.Both,
Padding = new MarginPadding { Left = name_width + 10, Right = value_width + 10 },
Children = new Drawable[]
{
new Container
{
Origin = Anchor.CentreLeft,
Anchor = Anchor.CentreLeft,
RelativeSizeAxes = Axes.X,
Height = 5,
CornerRadius = 2,
Masking = true,
Children = new Drawable[]
{
bar = new Bar
{
RelativeSizeAxes = Axes.Both,
BackgroundColour = Color4.White.Opacity(0.5f),
},
ModBar = new Bar
{
RelativeSizeAxes = Axes.Both,
Alpha = 0.5f,
},
}
},
}
},
new Container
{
Anchor = Anchor.TopRight,
Origin = Anchor.TopRight,
Width = value_width,
RelativeSizeAxes = Axes.Y,
Child = valueText = new OsuSpriteText
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Font = OsuFont.GetFont(size: 12)
},
},
};
}
}
}
}