From b4c2a4f0f174d02c13a00b8aab0cff94a4a08320 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Fri, 16 Oct 2026 11:55:47 +0200 Subject: [PATCH 1/3] Add score attribute text skinnable component Like "beatmap attribute text", but for scores. This'll come in handy to implement the text in the top left corner of the legacy results screen design. Supported placeholders: - Username - TotalScore - MaxCombo - Accuracy - Date - PP (where populated, which it won't be on local scores, but I'll worry about that later) - Rank, - Mods - Statistics via `{HitResult}Label`, `{HitResult}`, and `{HitResult}Max` --- .../TestSceneScoreAttributeText.cs | 89 +++++++ .../Skinning/Components/ScoreAttributeText.cs | 251 ++++++++++++++++++ 2 files changed, 340 insertions(+) create mode 100644 osu.Game.Tests/Visual/UserInterface/TestSceneScoreAttributeText.cs create mode 100644 osu.Game/Skinning/Components/ScoreAttributeText.cs diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneScoreAttributeText.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneScoreAttributeText.cs new file mode 100644 index 000000000000..51d2d34d676d --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneScoreAttributeText.cs @@ -0,0 +1,89 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Testing; +using osu.Game.Configuration; +using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.Osu.Mods; +using osu.Game.Rulesets.Scoring; +using osu.Game.Scoring; +using osu.Game.Skinning.Components; +using osu.Game.Tests.Resources; + +namespace osu.Game.Tests.Visual.UserInterface +{ + public partial class TestSceneScoreAttributeText : OsuTestScene + { + [Cached(typeof(IBindable))] + private Bindable score = new Bindable(); + + [Resolved] + private OsuConfigManager configManager { get; set; } = null!; + + private ScoreAttributeText text = null!; + + [SetUpSteps] + public void SetUp() + { + AddStep("set up score", () => + { + var scoreInfo = TestResources.CreateTestScoreInfo(new OsuRuleset().RulesetInfo); + scoreInfo.Date = new DateTimeOffset(2026, 10, 16, 9, 30, 0, TimeSpan.FromHours(2)); + scoreInfo.Mods = + [ + new OsuModHidden(), + new OsuModDoubleTime { SpeedChange = { Value = 1.3 } }, + ]; + scoreInfo.PP = 134; + score.Value = scoreInfo; + }); + AddStep("create component", () => + { + Child = text = new ScoreAttributeText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre + }; + }); + } + + [TestCase(ScoreAttribute.Username, "Played by: peppy")] + [TestCase(ScoreAttribute.MaxCombo, "Max Combo: 999x")] + [TestCase(ScoreAttribute.Accuracy, "Accuracy: 95.00%")] + [TestCase(ScoreAttribute.Date, "Date: 10/16/2026 09:30:00")] + [TestCase(ScoreAttribute.PP, "Performance: 134pp")] + [TestCase(ScoreAttribute.Rank, "Rank: S")] + [TestCase(ScoreAttribute.Mods, "Mods: HD, DT (Speed change: 1.30x)")] + public void TestAttributeDisplay(ScoreAttribute attribute, string expectedText) + { + AddStep($"set attribute: {attribute}", () => text.Attribute.Value = attribute); + AddAssert("check correct text", getText, () => Is.EqualTo(expectedText)); + } + + [Test] + public void TestTotalScoreDisplayRespectsScoringMode() + { + AddStep("set total score attribute", () => text.Attribute.Value = ScoreAttribute.TotalScore); + AddStep("set standardised scoring", () => configManager.SetValue(OsuSetting.ScoreDisplayMode, ScoringMode.Standardised)); + AddAssert("check correct text", getText, () => Is.EqualTo("Total Score: 284,537")); + AddStep("set classic scoring", () => configManager.SetValue(OsuSetting.ScoreDisplayMode, ScoringMode.Classic)); + AddAssert("check correct text", getText, () => Is.EqualTo("Total Score: 8,766,110")); + } + + [Test] + public void TestAccessToStatistics() + { + AddStep("set template", () => text.Template.Value = @"{LargeTickHitLabel}: {LargeTickHit} / {LargeTickHitMax}"); + AddAssert("check correct text", getText, () => Is.EqualTo("slider tick: 100 / 150")); + } + + private string getText() => text.ChildrenOfType().Single().Text.ToString(); + } +} diff --git a/osu.Game/Skinning/Components/ScoreAttributeText.cs b/osu.Game/Skinning/Components/ScoreAttributeText.cs new file mode 100644 index 000000000000..b33ff03c9e45 --- /dev/null +++ b/osu.Game/Skinning/Components/ScoreAttributeText.cs @@ -0,0 +1,251 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using JetBrains.Annotations; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions.LocalisationExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; +using osu.Game.Configuration; +using osu.Game.Graphics.Sprites; +using osu.Game.Localisation; +using osu.Game.Localisation.SkinComponents; +using osu.Game.Resources.Localisation.Web; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Scoring; +using osu.Game.Scoring; +using osu.Game.Scoring.Legacy; +using osu.Game.Utils; + +namespace osu.Game.Skinning.Components +{ + [UsedImplicitly] + public partial class ScoreAttributeText : FontAdjustableSkinComponent + { + public const float DEFAULT_TEXT_SIZE = 40; + + [SettingSource(typeof(BeatmapAttributeTextStrings), nameof(BeatmapAttributeTextStrings.Attribute))] + public Bindable Attribute { get; } = new Bindable(); + + [SettingSource(typeof(BeatmapAttributeTextStrings), nameof(BeatmapAttributeTextStrings.Template), nameof(BeatmapAttributeTextStrings.TemplateDescription))] + public Bindable Template { get; } = new Bindable("{Label}: {Value}"); + + [Resolved] + private IBindable score { get; set; } = null!; + + private readonly Bindable scoringMode = new Bindable(); + private readonly OsuSpriteText text; + + public ScoreAttributeText() + { + AutoSizeAxes = Axes.Both; + + InternalChildren = new Drawable[] + { + text = new OsuSpriteText + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + } + }; + } + + // TODO: temporary to avoid `SkinDeserialisationTest` failures. + // Remove when the legacy skin supplies a default layout of results with this component included. + public bool IsEditable => false; + + [BackgroundDependencyLoader] + private void load(OsuConfigManager configManager) + { + configManager.BindWith(OsuSetting.ScoreDisplayMode, scoringMode); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + Attribute.BindValueChanged(_ => updateText()); + Template.BindValueChanged(_ => updateText()); + + score.BindValueChanged(_ => updateText()); + scoringMode.BindValueChanged(_ => updateText()); + updateText(); + } + + private void updateText() + { + var ruleset = score.Value.Ruleset.CreateInstance(); + + string numberedTemplate = Template.Value + .Replace("{", "{{") + .Replace("}", "}}") + .Replace(@"{{Label}}", "{0}") + .Replace(@"{{Value}}", "{1}"); + + List values = new List + { + getLabelString(Attribute.Value), + getValueString(Attribute.Value, ruleset) + }; + + foreach (var type in Enum.GetValues()) + { + string replaced = numberedTemplate.Replace($@"{{{{{type}}}}}", $@"{{{values.Count}}}"); + + if (numberedTemplate != replaced) + { + numberedTemplate = replaced; + values.Add(getValueString(type, ruleset)); + } + } + + foreach (var type in Enum.GetValues()) + { + string replaced = numberedTemplate.Replace($@"{{{{{type}Label}}}}", $@"{{{values.Count}}}"); + + if (numberedTemplate != replaced) + { + numberedTemplate = replaced; + values.Add(ruleset.GetDisplayNameForHitResult(type)); + } + + replaced = numberedTemplate.Replace($@"{{{{{type}}}}}", $@"{{{values.Count}}}"); + + if (numberedTemplate != replaced) + { + numberedTemplate = replaced; + values.Add(score.Value.Statistics.GetValueOrDefault(type).ToLocalisableString(@"N0")); + } + + replaced = numberedTemplate.Replace($@"{{{{{type}Max}}}}", $@"{{{values.Count}}}"); + + if (numberedTemplate != replaced) + { + numberedTemplate = replaced; + values.Add(score.Value.MaximumStatistics.GetValueOrDefault(type).ToLocalisableString(@"N0")); + } + } + + text.Text = LocalisableString.Format(numberedTemplate, values.ToArray()); + } + + private LocalisableString getLabelString(ScoreAttribute attribute) + { + switch (attribute) + { + case ScoreAttribute.Username: + return ScoresStrings.ShowPlayerBy; + + case ScoreAttribute.TotalScore: + return BeatmapsetsStrings.ShowScoreboardHeadersScoreTotal; + + case ScoreAttribute.MaxCombo: + return BeatmapsetsStrings.ShowScoreboardHeadersCombo; + + case ScoreAttribute.Accuracy: + return BeatmapsetsStrings.ShowScoreboardHeadersAccuracy; + + case ScoreAttribute.Date: + return BeatmapLeaderboardWedgeStrings.Date; + + case ScoreAttribute.PP: + return ResultsScreenStrings.PerformancePointsHeader; + + case ScoreAttribute.Rank: + return BeatmapsetsStrings.ShowScoreboardHeadersRank; + + case ScoreAttribute.Mods: + return BeatmapsetsStrings.ShowScoreboardHeadersMods; + + default: + return string.Empty; + } + } + + private LocalisableString getValueString(ScoreAttribute attribute, Ruleset ruleset) + { + switch (attribute) + { + case ScoreAttribute.Username: + return score.Value.User.Username; + + case ScoreAttribute.TotalScore: + return score.Value.GetDisplayScore(scoringMode.Value).ToLocalisableString(@"N0"); + + case ScoreAttribute.MaxCombo: + return $@"{score.Value.MaxCombo:N0}x"; + + case ScoreAttribute.Accuracy: + return score.Value.Accuracy.FormatAccuracy(); + + case ScoreAttribute.Date: + return score.Value.Date.ToLocalisableString(@"G"); + + case ScoreAttribute.PP: + return score.Value.PP != null ? LocalisableString.Interpolate($@"{score.Value.PP}pp") : "-"; + + case ScoreAttribute.Rank: + return score.Value.Rank.ToLocalisableString(); + + case ScoreAttribute.Mods: + return formatMods(score.Value.Mods, ruleset); + + default: + return string.Empty; + } + } + + private LocalisableString formatMods(IEnumerable mods, Ruleset ruleset) + { + var parts = new List(); + + foreach (var mod in mods) + { + if (parts.Count > 0) + parts.Add(", "); + + parts.Add(mod.Acronym); + + var modInstance = mod.ToMod(ruleset); + + if (!modInstance.UsesDefaultConfiguration) + { + parts.Add(" ("); + + foreach (var setting in modInstance.SettingDescription) + { + parts.Add(setting.setting); + parts.Add(": "); + parts.Add(setting.value); + } + + parts.Add(")"); + } + } + + return LocalisableString.Concat(parts.ToArray()); + } + + protected override void SetFont(FontUsage font) => text.Font = font.With(size: DEFAULT_TEXT_SIZE); + + protected override void SetTextColour(Colour4 textColour) => text.Colour = textColour; + } + + // WARNING: DO NOT ADD ANY VALUES TO THIS ENUM ANYWHERE ELSE THAN AT THE END. + // Doing so will break existing user skins. + public enum ScoreAttribute + { + Username, + TotalScore, + MaxCombo, + Accuracy, + Date, + PP, + Rank, + Mods, + } +} From 278cca61ace69adb910d02dc157e395d13453096 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Mon, 19 Oct 2026 22:10:27 +0900 Subject: [PATCH 2/3] Update framework --- osu.Android.props | 2 +- osu.Game/osu.Game.csproj | 2 +- osu.iOS.props | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/osu.Android.props b/osu.Android.props index 8f3c947d86c1..3d75bab700b8 100644 --- a/osu.Android.props +++ b/osu.Android.props @@ -15,7 +15,7 @@ $(NoWarn);XA1040 - +