Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion osu.Android.props
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
<NoWarn>$(NoWarn);XA1040</NoWarn>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="ppy.osu.Framework.Android" Version="2026.1008.0" />
<PackageReference Include="ppy.osu.Framework.Android" Version="2026.1019.0" />
</ItemGroup>
<PropertyGroup>
<!-- Fody does not handle Android build well, and warns when unchanged.
Expand Down
89 changes: 89 additions & 0 deletions osu.Game.Tests/Visual/UserInterface/TestSceneScoreAttributeText.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
// 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.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<IScoreInfo>))]
private Bindable<IScoreInfo> score = new Bindable<IScoreInfo>();

[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<SpriteText>().Single().Text.ToString();
}
}
251 changes: 251 additions & 0 deletions osu.Game/Skinning/Components/ScoreAttributeText.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,251 @@
// 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 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<ScoreAttribute> Attribute { get; } = new Bindable<ScoreAttribute>();

[SettingSource(typeof(BeatmapAttributeTextStrings), nameof(BeatmapAttributeTextStrings.Template), nameof(BeatmapAttributeTextStrings.TemplateDescription))]
public Bindable<string> Template { get; } = new Bindable<string>("{Label}: {Value}");

[Resolved]
private IBindable<IScoreInfo> score { get; set; } = null!;

private readonly Bindable<ScoringMode> scoringMode = new Bindable<ScoringMode>();
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<object?> values = new List<object?>
{
getLabelString(Attribute.Value),
getValueString(Attribute.Value, ruleset)
};

foreach (var type in Enum.GetValues<ScoreAttribute>())
{
string replaced = numberedTemplate.Replace($@"{{{{{type}}}}}", $@"{{{values.Count}}}");

if (numberedTemplate != replaced)
{
numberedTemplate = replaced;
values.Add(getValueString(type, ruleset));
}
}

foreach (var type in Enum.GetValues<HitResult>())
{
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 static LocalisableString formatMods(IEnumerable<IConfiguredMod> mods, Ruleset ruleset)
{
var parts = new List<LocalisableString>();

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,
}
}
2 changes: 1 addition & 1 deletion osu.Game/osu.Game.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Realm" Version="20.1.0"/>
<PackageReference Include="ppy.osu.Framework" Version="2026.1008.0"/>
<PackageReference Include="ppy.osu.Framework" Version="2026.1019.0"/>
<PackageReference Include="ppy.osu.Game.Resources" Version="2026.1008.0"/>
<PackageReference Include="Sentry" Version="6.11.0"/>
<PackageReference Include="SharpCompress" Version="0.50.4"/>
Expand Down
2 changes: 1 addition & 1 deletion osu.iOS.props
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,6 @@
<MtouchInterpreter>-all</MtouchInterpreter>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="ppy.osu.Framework.iOS" Version="2026.1008.0" />
<PackageReference Include="ppy.osu.Framework.iOS" Version="2026.1019.0" />
</ItemGroup>
</Project>
Loading