1
0
mirror of https://github.com/ppy/osu.git synced 2026-05-26 10:49:57 +08:00
Files
osu-lazer/osu.Game/Screens/SelectV2/BeatmapTitleWedge_DifficultyDisplay.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

380 lines
18 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 System.Threading;
using System.Threading.Tasks;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Extensions.Color4Extensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Cursor;
using osu.Framework.Graphics.Shapes;
using osu.Game.Beatmaps;
using osu.Game.Beatmaps.Drawables;
using osu.Game.Configuration;
using osu.Game.Graphics;
using osu.Game.Graphics.Containers;
using osu.Game.Graphics.Sprites;
using osu.Game.Localisation;
using osu.Game.Online;
using osu.Game.Online.Chat;
using osu.Game.Overlays;
using osu.Game.Overlays.Mods;
using osu.Game.Rulesets;
using osu.Game.Rulesets.Mods;
using osuTK.Graphics;
namespace osu.Game.Screens.SelectV2
{
public partial class BeatmapTitleWedge
{
public partial class DifficultyDisplay : CompositeDrawable
{
private const float border_weight = 2;
[Resolved]
private IBindable<WorkingBeatmap> beatmap { get; set; } = null!;
[Resolved]
private IBindable<RulesetInfo> ruleset { get; set; } = null!;
[Resolved]
private IBindable<IReadOnlyList<Mod>> mods { get; set; } = null!;
private ModSettingChangeTracker? settingChangeTracker;
[Resolved]
private BeatmapDifficultyCache difficultyCache { get; set; } = null!;
[Resolved]
private OsuColour colours { get; set; } = null!;
private StarRatingDisplay starRatingDisplay = null!;
private FillFlowContainer nameLine = null!;
private OsuSpriteText difficultyText = null!;
private OsuSpriteText mappedByText = null!;
private OsuHoverContainer mapperLink = null!;
private OsuSpriteText mapperText = null!;
private GridContainer ratingAndNameContainer = null!;
private DifficultyStatisticsDisplay countStatisticsDisplay = null!;
private AdjustableDifficultyStatisticsDisplay difficultyStatisticsDisplay = null!;
private CancellationTokenSource? cancellationSource;
public DifficultyDisplay()
{
RelativeSizeAxes = Axes.X;
AutoSizeAxes = Axes.Y;
}
[BackgroundDependencyLoader]
private void load(OverlayColourProvider colourProvider)
{
Masking = true;
CornerRadius = 10;
Shear = OsuGame.SHEAR;
InternalChildren = new Drawable[]
{
new WedgeBackground(),
new FillFlowContainer
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Direction = FillDirection.Vertical,
Children = new Drawable[]
{
new ShearAligningWrapper(ratingAndNameContainer = new GridContainer
{
Shear = -OsuGame.SHEAR,
AlwaysPresent = true,
RelativeSizeAxes = Axes.X,
Height = 20,
Margin = new MarginPadding { Vertical = 5f },
Padding = new MarginPadding { Left = SongSelect.WEDGE_CONTENT_MARGIN },
RowDimensions = new[] { new Dimension(GridSizeMode.AutoSize) },
ColumnDimensions = new[]
{
new Dimension(GridSizeMode.AutoSize),
new Dimension(GridSizeMode.Absolute, 6),
new Dimension(),
},
Content = new[]
{
new[]
{
starRatingDisplay = new StarRatingDisplay(default, animated: true)
{
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
},
Empty(),
nameLine = new FillFlowContainer
{
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Direction = FillDirection.Horizontal,
Margin = new MarginPadding { Bottom = 2f },
Children = new Drawable[]
{
difficultyText = new TruncatingSpriteText
{
Anchor = Anchor.BottomLeft,
Origin = Anchor.BottomLeft,
Font = OsuFont.Style.Body.With(weight: FontWeight.SemiBold),
},
mappedByText = new OsuSpriteText
{
Anchor = Anchor.BottomLeft,
Origin = Anchor.BottomLeft,
Text = " mapped by ",
Font = OsuFont.Style.Body,
},
mapperLink = new MapperLinkContainer
{
AutoSizeAxes = Axes.Both,
Anchor = Anchor.BottomLeft,
Origin = Anchor.BottomLeft,
Child = mapperText = new TruncatingSpriteText
{
Shadow = true,
Font = OsuFont.Style.Body.With(weight: FontWeight.SemiBold),
},
},
},
},
}
},
}),
new ShearAligningWrapper(new Container
{
Shear = -OsuGame.SHEAR,
RelativeSizeAxes = Axes.X,
Height = 53,
Padding = new MarginPadding { Bottom = border_weight, Right = border_weight },
Child = new Container
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Masking = true,
CornerRadius = 10 - border_weight,
Shear = OsuGame.SHEAR,
Children = new Drawable[]
{
new Box
{
RelativeSizeAxes = Axes.Both,
Colour = colourProvider.Background5.Opacity(0.8f),
},
new GridContainer
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Padding = new MarginPadding { Left = SongSelect.WEDGE_CONTENT_MARGIN, Right = 20f, Vertical = 7.5f },
Shear = -OsuGame.SHEAR,
RowDimensions = new[] { new Dimension(GridSizeMode.AutoSize) },
ColumnDimensions = new[]
{
new Dimension(),
new Dimension(GridSizeMode.Absolute, 30),
new Dimension(GridSizeMode.AutoSize),
},
Content = new[]
{
new[]
{
countStatisticsDisplay = new DifficultyStatisticsDisplay
{
RelativeSizeAxes = Axes.X,
},
Empty(),
difficultyStatisticsDisplay = new AdjustableDifficultyStatisticsDisplay(autoSize: true),
}
},
}
},
}
}),
}
},
};
}
protected override void LoadComplete()
{
base.LoadComplete();
beatmap.BindValueChanged(_ => updateDisplay());
ruleset.BindValueChanged(_ => updateDisplay());
mods.BindValueChanged(m =>
{
settingChangeTracker?.Dispose();
updateDifficultyStatistics();
if (m.NewValue.Any())
{
settingChangeTracker = new ModSettingChangeTracker(m.NewValue);
settingChangeTracker.SettingChanged += _ => updateDifficultyStatistics();
}
}, true);
updateDisplay();
}
[Resolved]
private ILinkHandler? linkHandler { get; set; }
private void updateDisplay()
{
cancellationSource?.Cancel();
cancellationSource = new CancellationTokenSource();
if (beatmap.IsDefault)
{
ratingAndNameContainer.FadeOut(300, Easing.OutQuint);
countStatisticsDisplay.Statistics = Array.Empty<StatisticDifficulty.Data>();
}
else
{
ratingAndNameContainer.FadeIn(300, Easing.OutQuint);
difficultyText.Text = beatmap.Value.BeatmapInfo.DifficultyName;
mapperLink.Action = () => linkHandler?.HandleLink(new LinkDetails(LinkAction.OpenUserProfile, beatmap.Value.Metadata.Author));
mapperText.Text = beatmap.Value.Metadata.Author.Username;
}
starRatingDisplay.Current = (Bindable<StarDifficulty>)difficultyCache.GetBindableDifficulty(beatmap.Value.BeatmapInfo, cancellationSource.Token, SongSelect.SELECTION_DEBOUNCE);
updateCountStatistics(cancellationSource.Token);
updateDifficultyStatistics();
}
private void updateCountStatistics(CancellationToken cancellationToken)
{
if (beatmap.IsDefault)
{
countStatisticsDisplay.Statistics = Array.Empty<StatisticDifficulty.Data>();
return;
}
Task.Run(() =>
{
// This can take time as it is a synchronous task.
// TODO: We're calling `GetPlayableBeatmap` multiple times every map load at song select.
var playableBeatmap = beatmap.Value.GetPlayableBeatmap(ruleset.Value);
var statistics = playableBeatmap.GetStatistics()
.Select(s => new StatisticDifficulty.Data(s.Name, s.BarDisplayLength ?? 0, s.BarDisplayLength ?? 0, 1, s.Content))
.ToList();
Schedule(() =>
{
if (cancellationToken.IsCancellationRequested)
return;
countStatisticsDisplay.Statistics = statistics;
});
}, cancellationToken);
}
private void updateDifficultyStatistics() => Scheduler.AddOnce(() =>
{
if (beatmap.IsDefault || ruleset.Value == null)
{
difficultyStatisticsDisplay.TooltipContent = null;
difficultyStatisticsDisplay.Statistics = Array.Empty<StatisticDifficulty.Data>();
return;
}
BeatmapDifficulty originalDifficulty = beatmap.Value.BeatmapInfo.Difficulty;
BeatmapDifficulty adjustedDifficulty = new BeatmapDifficulty(originalDifficulty);
foreach (var mod in mods.Value.OfType<IApplicableToDifficulty>())
mod.ApplyToDifficulty(adjustedDifficulty);
Ruleset rulesetInstance = ruleset.Value.CreateInstance();
adjustedDifficulty = rulesetInstance.GetAdjustedDisplayDifficulty(adjustedDifficulty, mods.Value);
difficultyStatisticsDisplay.TooltipContent = new AdjustedAttributesTooltip.Data(originalDifficulty, adjustedDifficulty);
StatisticDifficulty.Data firstStatistic;
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)rulesetInstance;
// For the time being, the key count is static no matter what, because:
// - The method doesn't have knowledge of the active keymods. Doing so may require considerations for filtering.
// - Using the difficulty adjustment mod to adjust OD doesn't have an effect on conversion.
int keyCount = legacyRuleset.GetKeyCount(beatmap.Value.BeatmapInfo, mods.Value);
firstStatistic = new StatisticDifficulty.Data(SongSelectStrings.KeyCount, keyCount, keyCount, 10);
break;
default:
firstStatistic = new StatisticDifficulty.Data(SongSelectStrings.CircleSize, originalDifficulty.CircleSize, adjustedDifficulty.CircleSize, 10);
break;
}
difficultyStatisticsDisplay.Statistics = new[]
{
firstStatistic,
new StatisticDifficulty.Data(SongSelectStrings.ApproachRate, originalDifficulty.ApproachRate, adjustedDifficulty.ApproachRate, 10),
new StatisticDifficulty.Data(SongSelectStrings.Accuracy, originalDifficulty.OverallDifficulty, adjustedDifficulty.OverallDifficulty, 10),
new StatisticDifficulty.Data(SongSelectStrings.HPDrain, originalDifficulty.DrainRate, adjustedDifficulty.DrainRate, 10),
};
});
protected override void Update()
{
base.Update();
difficultyText.MaxWidth = Math.Max(nameLine.DrawWidth - mappedByText.DrawWidth - mapperText.DrawWidth - 20, 0);
// Use difficulty colour until it gets too dark to be visible against dark backgrounds.
Color4 col = starRatingDisplay.DisplayedStars.Value >= OsuColour.STAR_DIFFICULTY_DEFINED_COLOUR_CUTOFF ? colours.Orange1 : starRatingDisplay.DisplayedDifficultyColour;
difficultyText.Colour = col;
mappedByText.Colour = col;
countStatisticsDisplay.AccentColour = col;
difficultyStatisticsDisplay.AccentColour = col;
}
private partial class MapperLinkContainer : OsuHoverContainer
{
[BackgroundDependencyLoader]
private void load(OverlayColourProvider? overlayColourProvider, OsuColour colours)
{
TooltipText = ContextMenuStrings.ViewProfile;
IdleColour = overlayColourProvider?.Light2 ?? colours.Blue;
}
}
private partial class AdjustableDifficultyStatisticsDisplay : DifficultyStatisticsDisplay, IHasCustomTooltip<AdjustedAttributesTooltip.Data>
{
[Resolved]
private OverlayColourProvider colourProvider { get; set; } = null!;
public ITooltip<AdjustedAttributesTooltip.Data> GetCustomTooltip() => new AdjustedAttributesTooltip(colourProvider);
public AdjustedAttributesTooltip.Data? TooltipContent { get; set; }
public AdjustableDifficultyStatisticsDisplay(bool autoSize)
: base(autoSize)
{
}
}
}
}
}