diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index cbd0231fdb..57694d7f57 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -21,7 +21,7 @@ ] }, "ppy.localisationanalyser.tools": { - "version": "2022.607.0", + "version": "2022.809.0", "commands": [ "localisation" ] diff --git a/.globalconfig b/.globalconfig index 462dbc74ed..a7b652c454 100644 --- a/.globalconfig +++ b/.globalconfig @@ -53,3 +53,7 @@ dotnet_diagnostic.CA2225.severity = none # Banned APIs dotnet_diagnostic.RS0030.severity = error + +# Temporarily disable analysing CanBeNull = true in NRT contexts due to mobile issues. +# See: https://github.com/ppy/osu/pull/19677 +dotnet_diagnostic.OSUF001.severity = none \ No newline at end of file diff --git a/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/Beatmaps/PippidonBeatmapConverter.cs b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/Beatmaps/PippidonBeatmapConverter.cs index 8f0b31ef1b..0a4fa84ce1 100644 --- a/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/Beatmaps/PippidonBeatmapConverter.cs +++ b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/Beatmaps/PippidonBeatmapConverter.cs @@ -21,8 +21,11 @@ namespace osu.Game.Rulesets.Pippidon.Beatmaps public PippidonBeatmapConverter(IBeatmap beatmap, Ruleset ruleset) : base(beatmap, ruleset) { - minPosition = beatmap.HitObjects.Min(getUsablePosition); - maxPosition = beatmap.HitObjects.Max(getUsablePosition); + if (beatmap.HitObjects.Any()) + { + minPosition = beatmap.HitObjects.Min(getUsablePosition); + maxPosition = beatmap.HitObjects.Max(getUsablePosition); + } } public override bool CanConvert() => Beatmap.HitObjects.All(h => h is IHasXPosition && h is IHasYPosition); diff --git a/osu.Android.props b/osu.Android.props index 6dbc6cc377..17a6178641 100644 --- a/osu.Android.props +++ b/osu.Android.props @@ -51,8 +51,8 @@ - - + + diff --git a/osu.Game.Rulesets.Catch/CatchRuleset.cs b/osu.Game.Rulesets.Catch/CatchRuleset.cs index f832d99807..ed151855b1 100644 --- a/osu.Game.Rulesets.Catch/CatchRuleset.cs +++ b/osu.Game.Rulesets.Catch/CatchRuleset.cs @@ -23,6 +23,7 @@ using osu.Game.Rulesets.Difficulty; using osu.Game.Rulesets.Scoring; using System; using osu.Framework.Extensions.EnumExtensions; +using osu.Framework.Localisation; using osu.Game.Rulesets.Catch.Edit; using osu.Game.Rulesets.Catch.Skinning.Legacy; using osu.Game.Rulesets.Edit; @@ -162,7 +163,7 @@ namespace osu.Game.Rulesets.Catch }; } - public override string GetDisplayNameForHitResult(HitResult result) + public override LocalisableString GetDisplayNameForHitResult(HitResult result) { switch (result) { diff --git a/osu.Game.Rulesets.Catch/Mods/CatchModEasy.cs b/osu.Game.Rulesets.Catch/Mods/CatchModEasy.cs index 16ef56d845..cac5b9aa6a 100644 --- a/osu.Game.Rulesets.Catch/Mods/CatchModEasy.cs +++ b/osu.Game.Rulesets.Catch/Mods/CatchModEasy.cs @@ -1,12 +1,13 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Framework.Localisation; using osu.Game.Rulesets.Mods; namespace osu.Game.Rulesets.Catch.Mods { public class CatchModEasy : ModEasyWithExtraLives { - public override string Description => @"Larger fruits, more forgiving HP drain, less accuracy required, and three lives!"; + public override LocalisableString Description => @"Larger fruits, more forgiving HP drain, less accuracy required, and three lives!"; } } diff --git a/osu.Game.Rulesets.Catch/Mods/CatchModFloatingFruits.cs b/osu.Game.Rulesets.Catch/Mods/CatchModFloatingFruits.cs index 63203dd57c..e12181d051 100644 --- a/osu.Game.Rulesets.Catch/Mods/CatchModFloatingFruits.cs +++ b/osu.Game.Rulesets.Catch/Mods/CatchModFloatingFruits.cs @@ -3,6 +3,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; using osu.Game.Rulesets.Catch.Objects; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.UI; @@ -14,7 +15,7 @@ namespace osu.Game.Rulesets.Catch.Mods { public override string Name => "Floating Fruits"; public override string Acronym => "FF"; - public override string Description => "The fruits are... floating?"; + public override LocalisableString Description => "The fruits are... floating?"; public override double ScoreMultiplier => 1; public override IconUsage? Icon => FontAwesome.Solid.Cloud; diff --git a/osu.Game.Rulesets.Catch/Mods/CatchModHidden.cs b/osu.Game.Rulesets.Catch/Mods/CatchModHidden.cs index 51516edacd..d68430b64f 100644 --- a/osu.Game.Rulesets.Catch/Mods/CatchModHidden.cs +++ b/osu.Game.Rulesets.Catch/Mods/CatchModHidden.cs @@ -3,6 +3,7 @@ using System.Linq; using osu.Framework.Graphics; +using osu.Framework.Localisation; using osu.Game.Rulesets.Catch.Objects; using osu.Game.Rulesets.Catch.Objects.Drawables; using osu.Game.Rulesets.Catch.UI; @@ -14,7 +15,7 @@ namespace osu.Game.Rulesets.Catch.Mods { public class CatchModHidden : ModHidden, IApplicableToDrawableRuleset { - public override string Description => @"Play with fading fruits."; + public override LocalisableString Description => @"Play with fading fruits."; public override double ScoreMultiplier => UsesDefaultConfiguration ? 1.06 : 1; private const double fade_out_offset_multiplier = 0.6; diff --git a/osu.Game.Rulesets.Catch/Mods/CatchModMirror.cs b/osu.Game.Rulesets.Catch/Mods/CatchModMirror.cs index a97e940a64..4cd2efdc2f 100644 --- a/osu.Game.Rulesets.Catch/Mods/CatchModMirror.cs +++ b/osu.Game.Rulesets.Catch/Mods/CatchModMirror.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System.Linq; +using osu.Framework.Localisation; using osu.Game.Beatmaps; using osu.Game.Rulesets.Catch.Beatmaps; using osu.Game.Rulesets.Catch.Objects; @@ -14,7 +15,7 @@ namespace osu.Game.Rulesets.Catch.Mods { public class CatchModMirror : ModMirror, IApplicableToBeatmap { - public override string Description => "Fruits are flipped horizontally."; + public override LocalisableString Description => "Fruits are flipped horizontally."; /// /// is used instead of , diff --git a/osu.Game.Rulesets.Catch/Mods/CatchModNoScope.cs b/osu.Game.Rulesets.Catch/Mods/CatchModNoScope.cs index a24a6227fe..9038153e20 100644 --- a/osu.Game.Rulesets.Catch/Mods/CatchModNoScope.cs +++ b/osu.Game.Rulesets.Catch/Mods/CatchModNoScope.cs @@ -3,6 +3,7 @@ using System; using osu.Framework.Bindables; +using osu.Framework.Localisation; using osu.Game.Rulesets.Mods; using osu.Framework.Utils; using osu.Game.Configuration; @@ -14,7 +15,7 @@ namespace osu.Game.Rulesets.Catch.Mods { public class CatchModNoScope : ModNoScope, IUpdatableByPlayfield { - public override string Description => "Where's the catcher?"; + public override LocalisableString Description => "Where's the catcher?"; [SettingSource( "Hidden at combo", diff --git a/osu.Game.Rulesets.Catch/Mods/CatchModRelax.cs b/osu.Game.Rulesets.Catch/Mods/CatchModRelax.cs index 60f1614d98..69ae8328e9 100644 --- a/osu.Game.Rulesets.Catch/Mods/CatchModRelax.cs +++ b/osu.Game.Rulesets.Catch/Mods/CatchModRelax.cs @@ -5,6 +5,7 @@ using osu.Framework.Graphics; using osu.Framework.Input; using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; +using osu.Framework.Localisation; using osu.Game.Rulesets.Catch.Objects; using osu.Game.Rulesets.Catch.UI; using osu.Game.Rulesets.Mods; @@ -16,7 +17,7 @@ namespace osu.Game.Rulesets.Catch.Mods { public class CatchModRelax : ModRelax, IApplicableToDrawableRuleset, IApplicableToPlayer { - public override string Description => @"Use the mouse to control the catcher."; + public override LocalisableString Description => @"Use the mouse to control the catcher."; private DrawableRuleset drawableRuleset = null!; diff --git a/osu.Game.Rulesets.Mania/ManiaRuleset.cs b/osu.Game.Rulesets.Mania/ManiaRuleset.cs index 4723416c30..ac6060ceed 100644 --- a/osu.Game.Rulesets.Mania/ManiaRuleset.cs +++ b/osu.Game.Rulesets.Mania/ManiaRuleset.cs @@ -15,6 +15,7 @@ using osu.Framework.Extensions.EnumExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; using osu.Framework.Input.Bindings; +using osu.Framework.Localisation; using osu.Game.Graphics; using osu.Game.Rulesets.Mania.Replays; using osu.Game.Rulesets.Replays.Types; @@ -311,7 +312,7 @@ namespace osu.Game.Rulesets.Mania return Array.Empty(); } - public override string GetVariantName(int variant) + public override LocalisableString GetVariantName(int variant) { switch (getPlayfieldType(variant)) { @@ -356,7 +357,7 @@ namespace osu.Game.Rulesets.Mania }; } - public override string GetDisplayNameForHitResult(HitResult result) + public override LocalisableString GetDisplayNameForHitResult(HitResult result) { switch (result) { diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModConstantSpeed.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModConstantSpeed.cs index 73dfaaa878..66269f5572 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModConstantSpeed.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModConstantSpeed.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; using osu.Game.Configuration; using osu.Game.Rulesets.Mania.Objects; using osu.Game.Rulesets.Mania.UI; @@ -18,7 +19,7 @@ namespace osu.Game.Rulesets.Mania.Mods public override double ScoreMultiplier => 0.9; - public override string Description => "No more tricky speed changes!"; + public override LocalisableString Description => "No more tricky speed changes!"; public override IconUsage? Icon => FontAwesome.Solid.Equals; diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModDualStages.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModDualStages.cs index c78bf72979..2457aa75d7 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModDualStages.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModDualStages.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Framework.Localisation; using osu.Game.Beatmaps; using osu.Game.Rulesets.Mania.Beatmaps; using osu.Game.Rulesets.Mods; @@ -11,7 +12,7 @@ namespace osu.Game.Rulesets.Mania.Mods { public override string Name => "Dual Stages"; public override string Acronym => "DS"; - public override string Description => @"Double the stages, double the fun!"; + public override LocalisableString Description => @"Double the stages, double the fun!"; public override ModType Type => ModType.Conversion; public override double ScoreMultiplier => 1; diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModEasy.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModEasy.cs index 4093aeb2a7..5c8cd6a5ae 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModEasy.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModEasy.cs @@ -1,12 +1,13 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Framework.Localisation; using osu.Game.Rulesets.Mods; namespace osu.Game.Rulesets.Mania.Mods { public class ManiaModEasy : ModEasyWithExtraLives { - public override string Description => @"More forgiving HP drain, less accuracy required, and three lives!"; + public override LocalisableString Description => @"More forgiving HP drain, less accuracy required, and three lives!"; } } diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModFadeIn.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModFadeIn.cs index f80c9e1f7c..c6e9c339f4 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModFadeIn.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModFadeIn.cs @@ -3,6 +3,7 @@ using System; using System.Linq; +using osu.Framework.Localisation; using osu.Game.Rulesets.Mania.UI; namespace osu.Game.Rulesets.Mania.Mods @@ -11,7 +12,7 @@ namespace osu.Game.Rulesets.Mania.Mods { public override string Name => "Fade In"; public override string Acronym => "FI"; - public override string Description => @"Keys appear out of nowhere!"; + public override LocalisableString Description => @"Keys appear out of nowhere!"; public override double ScoreMultiplier => 1; public override Type[] IncompatibleMods => base.IncompatibleMods.Append(typeof(ManiaModHidden)).ToArray(); diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModHidden.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModHidden.cs index e3ac624a6e..eeb6e94fc7 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModHidden.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModHidden.cs @@ -3,13 +3,14 @@ using System; using System.Linq; +using osu.Framework.Localisation; using osu.Game.Rulesets.Mania.UI; namespace osu.Game.Rulesets.Mania.Mods { public class ManiaModHidden : ManiaModPlayfieldCover { - public override string Description => @"Keys fade out before you hit them!"; + public override LocalisableString Description => @"Keys fade out before you hit them!"; public override double ScoreMultiplier => 1; public override Type[] IncompatibleMods => base.IncompatibleMods.Append(typeof(ManiaModFadeIn)).ToArray(); diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModHoldOff.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModHoldOff.cs index a65938184c..ca9bc89473 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModHoldOff.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModHoldOff.cs @@ -8,6 +8,7 @@ using osu.Game.Rulesets.Mania.Objects; using osu.Game.Rulesets.Mods; using osu.Framework.Graphics.Sprites; using System.Collections.Generic; +using osu.Framework.Localisation; using osu.Game.Rulesets.Mania.Beatmaps; namespace osu.Game.Rulesets.Mania.Mods @@ -20,7 +21,7 @@ namespace osu.Game.Rulesets.Mania.Mods public override double ScoreMultiplier => 1; - public override string Description => @"Replaces all hold notes with normal notes."; + public override LocalisableString Description => @"Replaces all hold notes with normal notes."; public override IconUsage? Icon => FontAwesome.Solid.DotCircle; diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModInvert.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModInvert.cs index 4cbdaee323..ef9154d180 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModInvert.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModInvert.cs @@ -5,6 +5,7 @@ using System; using System.Collections.Generic; using System.Linq; using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; using osu.Game.Audio; using osu.Game.Beatmaps; using osu.Game.Rulesets.Mania.Beatmaps; @@ -20,7 +21,7 @@ namespace osu.Game.Rulesets.Mania.Mods public override string Acronym => "IN"; public override double ScoreMultiplier => 1; - public override string Description => "Hold the keys. To the beat."; + public override LocalisableString Description => "Hold the keys. To the beat."; public override IconUsage? Icon => FontAwesome.Solid.YinYang; diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModKey1.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModKey1.cs index 948979505c..31f52610e9 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModKey1.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModKey1.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Framework.Localisation; + namespace osu.Game.Rulesets.Mania.Mods { public class ManiaModKey1 : ManiaKeyMod @@ -8,6 +10,6 @@ namespace osu.Game.Rulesets.Mania.Mods public override int KeyCount => 1; public override string Name => "One Key"; public override string Acronym => "1K"; - public override string Description => @"Play with one key."; + public override LocalisableString Description => @"Play with one key."; } } diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModKey10.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModKey10.cs index 684370fc3d..67e65b887a 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModKey10.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModKey10.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Framework.Localisation; + namespace osu.Game.Rulesets.Mania.Mods { public class ManiaModKey10 : ManiaKeyMod @@ -8,6 +10,6 @@ namespace osu.Game.Rulesets.Mania.Mods public override int KeyCount => 10; public override string Name => "Ten Keys"; public override string Acronym => "10K"; - public override string Description => @"Play with ten keys."; + public override LocalisableString Description => @"Play with ten keys."; } } diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModKey2.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModKey2.cs index de91902ca8..0f8148d252 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModKey2.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModKey2.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Framework.Localisation; + namespace osu.Game.Rulesets.Mania.Mods { public class ManiaModKey2 : ManiaKeyMod @@ -8,6 +10,6 @@ namespace osu.Game.Rulesets.Mania.Mods public override int KeyCount => 2; public override string Name => "Two Keys"; public override string Acronym => "2K"; - public override string Description => @"Play with two keys."; + public override LocalisableString Description => @"Play with two keys."; } } diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModKey3.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModKey3.cs index 8575a96bde..0f8af7940c 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModKey3.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModKey3.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Framework.Localisation; + namespace osu.Game.Rulesets.Mania.Mods { public class ManiaModKey3 : ManiaKeyMod @@ -8,6 +10,6 @@ namespace osu.Game.Rulesets.Mania.Mods public override int KeyCount => 3; public override string Name => "Three Keys"; public override string Acronym => "3K"; - public override string Description => @"Play with three keys."; + public override LocalisableString Description => @"Play with three keys."; } } diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModKey4.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModKey4.cs index 54ea3afa07..d3a4546dce 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModKey4.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModKey4.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Framework.Localisation; + namespace osu.Game.Rulesets.Mania.Mods { public class ManiaModKey4 : ManiaKeyMod @@ -8,6 +10,6 @@ namespace osu.Game.Rulesets.Mania.Mods public override int KeyCount => 4; public override string Name => "Four Keys"; public override string Acronym => "4K"; - public override string Description => @"Play with four keys."; + public override LocalisableString Description => @"Play with four keys."; } } diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModKey5.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModKey5.cs index e9a9bba5bd..693182a952 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModKey5.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModKey5.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Framework.Localisation; + namespace osu.Game.Rulesets.Mania.Mods { public class ManiaModKey5 : ManiaKeyMod @@ -8,6 +10,6 @@ namespace osu.Game.Rulesets.Mania.Mods public override int KeyCount => 5; public override string Name => "Five Keys"; public override string Acronym => "5K"; - public override string Description => @"Play with five keys."; + public override LocalisableString Description => @"Play with five keys."; } } diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModKey6.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModKey6.cs index b9606d1cb5..ab911292f7 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModKey6.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModKey6.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Framework.Localisation; + namespace osu.Game.Rulesets.Mania.Mods { public class ManiaModKey6 : ManiaKeyMod @@ -8,6 +10,6 @@ namespace osu.Game.Rulesets.Mania.Mods public override int KeyCount => 6; public override string Name => "Six Keys"; public override string Acronym => "6K"; - public override string Description => @"Play with six keys."; + public override LocalisableString Description => @"Play with six keys."; } } diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModKey7.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModKey7.cs index b80d794085..ab401ef1d0 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModKey7.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModKey7.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Framework.Localisation; + namespace osu.Game.Rulesets.Mania.Mods { public class ManiaModKey7 : ManiaKeyMod @@ -8,6 +10,6 @@ namespace osu.Game.Rulesets.Mania.Mods public override int KeyCount => 7; public override string Name => "Seven Keys"; public override string Acronym => "7K"; - public override string Description => @"Play with seven keys."; + public override LocalisableString Description => @"Play with seven keys."; } } diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModKey8.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModKey8.cs index 3462d634a4..b3e8a45dda 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModKey8.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModKey8.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Framework.Localisation; + namespace osu.Game.Rulesets.Mania.Mods { public class ManiaModKey8 : ManiaKeyMod @@ -8,6 +10,6 @@ namespace osu.Game.Rulesets.Mania.Mods public override int KeyCount => 8; public override string Name => "Eight Keys"; public override string Acronym => "8K"; - public override string Description => @"Play with eight keys."; + public override LocalisableString Description => @"Play with eight keys."; } } diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModKey9.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModKey9.cs index 83c505c048..5972cbf0fe 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModKey9.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModKey9.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Framework.Localisation; + namespace osu.Game.Rulesets.Mania.Mods { public class ManiaModKey9 : ManiaKeyMod @@ -8,6 +10,6 @@ namespace osu.Game.Rulesets.Mania.Mods public override int KeyCount => 9; public override string Name => "Nine Keys"; public override string Acronym => "9K"; - public override string Description => @"Play with nine keys."; + public override LocalisableString Description => @"Play with nine keys."; } } diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModMirror.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModMirror.cs index 9c3744ea98..f9690b4298 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModMirror.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModMirror.cs @@ -5,6 +5,7 @@ using osu.Framework.Extensions.IEnumerableExtensions; using osu.Game.Rulesets.Mania.Objects; using osu.Game.Rulesets.Mods; using System.Linq; +using osu.Framework.Localisation; using osu.Game.Beatmaps; using osu.Game.Rulesets.Mania.Beatmaps; @@ -12,7 +13,7 @@ namespace osu.Game.Rulesets.Mania.Mods { public class ManiaModMirror : ModMirror, IApplicableToBeatmap { - public override string Description => "Notes are flipped horizontally."; + public override LocalisableString Description => "Notes are flipped horizontally."; public void ApplyToBeatmap(IBeatmap beatmap) { diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModRandom.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModRandom.cs index dfb02408d2..6ff070d703 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModRandom.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModRandom.cs @@ -4,6 +4,7 @@ using System; using System.Linq; using osu.Framework.Extensions.IEnumerableExtensions; +using osu.Framework.Localisation; using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Rulesets.Mania.Beatmaps; @@ -14,7 +15,7 @@ namespace osu.Game.Rulesets.Mania.Mods { public class ManiaModRandom : ModRandom, IApplicableToBeatmap { - public override string Description => @"Shuffle around the keys!"; + public override LocalisableString Description => @"Shuffle around the keys!"; public void ApplyToBeatmap(IBeatmap beatmap) { diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderSnapping.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderSnapping.cs index a72f2031c9..e864afe056 100644 --- a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderSnapping.cs +++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderSnapping.cs @@ -12,6 +12,7 @@ using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Input.Bindings; using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Osu.Edit; using osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components; using osu.Game.Rulesets.Osu.Objects; @@ -55,9 +56,9 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor { ControlPoints = { - new PathControlPoint(Vector2.Zero), - new PathControlPoint(OsuPlayfield.BASE_SIZE * 2 / 5), - new PathControlPoint(OsuPlayfield.BASE_SIZE * 3 / 5) + new PathControlPoint(Vector2.Zero, PathType.PerfectCurve), + new PathControlPoint(new Vector2(136, 205)), + new PathControlPoint(new Vector2(-4, 226)) } } })); @@ -99,8 +100,8 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor AddStep("move mouse to new point location", () => { var firstPiece = this.ChildrenOfType().Single(piece => piece.ControlPoint == slider.Path.ControlPoints[0]); - var secondPiece = this.ChildrenOfType().Single(piece => piece.ControlPoint == slider.Path.ControlPoints[1]); - InputManager.MoveMouseTo((firstPiece.ScreenSpaceDrawQuad.Centre + secondPiece.ScreenSpaceDrawQuad.Centre) / 2); + var pos = slider.Path.PositionAt(0.25d) + slider.Position; + InputManager.MoveMouseTo(firstPiece.Parent.ToScreenSpace(pos)); }); AddStep("move slider end", () => { @@ -175,6 +176,23 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor assertSliderSnapped(false); } + [Test] + public void TestRotatingSliderRetainsPerfectControlPointType() + { + OsuSelectionHandler selectionHandler; + + AddAssert("first control point perfect", () => slider.Path.ControlPoints[0].Type == PathType.PerfectCurve); + + AddStep("select slider", () => EditorBeatmap.SelectedHitObjects.Add(slider)); + AddStep("rotate 90 degrees ccw", () => + { + selectionHandler = this.ChildrenOfType().Single(); + selectionHandler.HandleRotation(-90); + }); + + AddAssert("first control point still perfect", () => slider.Path.ControlPoints[0].Type == PathType.PerfectCurve); + } + [Test] public void TestFlippingSliderDoesNotSnap() { @@ -200,6 +218,23 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor assertSliderSnapped(false); } + [Test] + public void TestFlippingSliderRetainsPerfectControlPointType() + { + OsuSelectionHandler selectionHandler; + + AddAssert("first control point perfect", () => slider.Path.ControlPoints[0].Type == PathType.PerfectCurve); + + AddStep("select slider", () => EditorBeatmap.SelectedHitObjects.Add(slider)); + AddStep("flip slider horizontally", () => + { + selectionHandler = this.ChildrenOfType().Single(); + selectionHandler.OnPressed(new KeyBindingPressEvent(InputManager.CurrentState, GlobalAction.EditorFlipVertically)); + }); + + AddAssert("first control point still perfect", () => slider.Path.ControlPoints[0].Type == PathType.PerfectCurve); + } + [Test] public void TestReversingSliderDoesNotSnap() { diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerRotation.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerRotation.cs index b7f91c22f4..c01b2576e8 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerRotation.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerRotation.cs @@ -199,14 +199,14 @@ namespace osu.Game.Rulesets.Osu.Tests AddStep("adjust track rate", () => ((MasterGameplayClockContainer)Player.GameplayClockContainer).UserPlaybackRate.Value = rate); addSeekStep(1000); - AddAssert("progress almost same", () => expectedProgress, () => Is.EqualTo(drawableSpinner.Progress).Within(0.05)); - AddAssert("spm almost same", () => expectedSpm, () => Is.EqualTo(drawableSpinner.SpinsPerMinute.Value).Within(2.0)); + AddAssert("progress almost same", () => drawableSpinner.Progress, () => Is.EqualTo(expectedProgress).Within(0.05)); + AddAssert("spm almost same", () => drawableSpinner.SpinsPerMinute.Value, () => Is.EqualTo(expectedSpm).Within(2.0)); } private void addSeekStep(double time) { AddStep($"seek to {time}", () => Player.GameplayClockContainer.Seek(time)); - AddUntilStep("wait for seek to finish", () => time, () => Is.EqualTo(Player.DrawableRuleset.FrameStableClock.CurrentTime).Within(100)); + AddUntilStep("wait for seek to finish", () => Player.DrawableRuleset.FrameStableClock.CurrentTime, () => Is.EqualTo(time).Within(100)); } private void transformReplay(Func replayTransformation) => AddStep("set replay", () => diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointVisualiser.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointVisualiser.cs index 22cbab8938..c24f78e430 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointVisualiser.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointVisualiser.cs @@ -166,8 +166,10 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components case NotifyCollectionChangedAction.Remove: foreach (var point in e.OldItems.Cast()) { - Pieces.RemoveAll(p => p.ControlPoint == point); - Connections.RemoveAll(c => c.ControlPoint == point); + foreach (var piece in Pieces.Where(p => p.ControlPoint == point).ToArray()) + piece.RemoveAndDisposeImmediately(); + foreach (var connection in Connections.Where(c => c.ControlPoint == point).ToArray()) + connection.RemoveAndDisposeImmediately(); } // If removing before the end of the path, diff --git a/osu.Game.Rulesets.Osu/Edit/OsuSelectionHandler.cs b/osu.Game.Rulesets.Osu/Edit/OsuSelectionHandler.cs index f3c0a05bc2..061c5008c5 100644 --- a/osu.Game.Rulesets.Osu/Edit/OsuSelectionHandler.cs +++ b/osu.Game.Rulesets.Osu/Edit/OsuSelectionHandler.cs @@ -127,13 +127,16 @@ namespace osu.Game.Rulesets.Osu.Edit { didFlip = true; - foreach (var point in slider.Path.ControlPoints) - { - point.Position = new Vector2( - (direction == Direction.Horizontal ? -1 : 1) * point.Position.X, - (direction == Direction.Vertical ? -1 : 1) * point.Position.Y - ); - } + var controlPoints = slider.Path.ControlPoints.Select(p => + new PathControlPoint(new Vector2( + (direction == Direction.Horizontal ? -1 : 1) * p.Position.X, + (direction == Direction.Vertical ? -1 : 1) * p.Position.Y + ), p.Type)).ToArray(); + + // Importantly, update as a single operation so automatic adjustment of control points to different + // curve types does not unexpectedly trigger and change the slider's shape. + slider.Path.ControlPoints.Clear(); + slider.Path.ControlPoints.AddRange(controlPoints); } } @@ -183,8 +186,13 @@ namespace osu.Game.Rulesets.Osu.Edit if (h is IHasPath path) { - foreach (var point in path.Path.ControlPoints) - point.Position = RotatePointAroundOrigin(point.Position, Vector2.Zero, delta); + var controlPoints = path.Path.ControlPoints.Select(p => + new PathControlPoint(RotatePointAroundOrigin(p.Position, Vector2.Zero, delta), p.Type)).ToArray(); + + // Importantly, update as a single operation so automatic adjustment of control points to different + // curve types does not unexpectedly trigger and change the slider's shape. + path.Path.ControlPoints.Clear(); + path.Path.ControlPoints.AddRange(controlPoints); } } diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModAlternate.cs b/osu.Game.Rulesets.Osu/Mods/OsuModAlternate.cs index d88cb17e84..9bf5d33d4a 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModAlternate.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModAlternate.cs @@ -4,6 +4,7 @@ using System; using System.Linq; using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; namespace osu.Game.Rulesets.Osu.Mods { @@ -11,7 +12,7 @@ namespace osu.Game.Rulesets.Osu.Mods { public override string Name => @"Alternate"; public override string Acronym => @"AL"; - public override string Description => @"Don't use the same key twice in a row!"; + public override LocalisableString Description => @"Don't use the same key twice in a row!"; public override IconUsage? Icon => FontAwesome.Solid.Keyboard; public override Type[] IncompatibleMods => base.IncompatibleMods.Concat(new[] { typeof(OsuModSingleTap) }).ToArray(); diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModApproachDifferent.cs b/osu.Game.Rulesets.Osu/Mods/OsuModApproachDifferent.cs index e6889403a3..ec93f19e17 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModApproachDifferent.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModApproachDifferent.cs @@ -5,6 +5,7 @@ using System; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; using osu.Game.Configuration; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects.Drawables; @@ -16,7 +17,7 @@ namespace osu.Game.Rulesets.Osu.Mods { public override string Name => "Approach Different"; public override string Acronym => "AD"; - public override string Description => "Never trust the approach circles..."; + public override LocalisableString Description => "Never trust the approach circles..."; public override double ScoreMultiplier => 1; public override IconUsage? Icon { get; } = FontAwesome.Regular.Circle; diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModAutopilot.cs b/osu.Game.Rulesets.Osu/Mods/OsuModAutopilot.cs index 9229c0393d..6772cfe0be 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModAutopilot.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModAutopilot.cs @@ -6,6 +6,7 @@ using System.Collections.Generic; using System.Linq; using osu.Framework.Graphics.Sprites; using osu.Framework.Input.StateChanges; +using osu.Framework.Localisation; using osu.Game.Graphics; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu.Objects; @@ -20,7 +21,7 @@ namespace osu.Game.Rulesets.Osu.Mods public override string Acronym => "AP"; public override IconUsage? Icon => OsuIcon.ModAutopilot; public override ModType Type => ModType.Automation; - public override string Description => @"Automatic cursor movement - just follow the rhythm."; + public override LocalisableString Description => @"Automatic cursor movement - just follow the rhythm."; public override double ScoreMultiplier => 0.1; public override Type[] IncompatibleMods => new[] { typeof(OsuModSpunOut), typeof(ModRelax), typeof(ModFailCondition), typeof(ModNoFail), typeof(ModAutoplay), typeof(OsuModMagnetised), typeof(OsuModRepel) }; @@ -30,8 +31,6 @@ namespace osu.Game.Rulesets.Osu.Mods private OsuInputManager inputManager = null!; - private IFrameStableClock gameplayClock = null!; - private List replayFrames = null!; private int currentFrame; @@ -40,7 +39,7 @@ namespace osu.Game.Rulesets.Osu.Mods { if (currentFrame == replayFrames.Count - 1) return; - double time = gameplayClock.CurrentTime; + double time = playfield.Clock.CurrentTime; // Very naive implementation of autopilot based on proximity to replay frames. // TODO: this needs to be based on user interactions to better match stable (pausing until judgement is registered). @@ -55,8 +54,6 @@ namespace osu.Game.Rulesets.Osu.Mods public void ApplyToDrawableRuleset(DrawableRuleset drawableRuleset) { - gameplayClock = drawableRuleset.FrameStableClock; - // Grab the input manager to disable the user's cursor, and for future use inputManager = (OsuInputManager)drawableRuleset.KeyBindingInputManager; inputManager.AllowUserCursorMovement = false; diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModBlinds.cs b/osu.Game.Rulesets.Osu/Mods/OsuModBlinds.cs index 56665db770..4c72667f15 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModBlinds.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModBlinds.cs @@ -8,6 +8,7 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Textures; +using osu.Framework.Localisation; using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Rulesets.Mods; @@ -22,7 +23,7 @@ namespace osu.Game.Rulesets.Osu.Mods public class OsuModBlinds : Mod, IApplicableToDrawableRuleset, IApplicableToHealthProcessor { public override string Name => "Blinds"; - public override string Description => "Play with blinds on your screen."; + public override LocalisableString Description => "Play with blinds on your screen."; public override string Acronym => "BL"; public override IconUsage? Icon => FontAwesome.Solid.Adjust; diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModDeflate.cs b/osu.Game.Rulesets.Osu/Mods/OsuModDeflate.cs index ee6a7815e2..e624660410 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModDeflate.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModDeflate.cs @@ -3,6 +3,7 @@ using osu.Framework.Bindables; using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; using osu.Game.Configuration; namespace osu.Game.Rulesets.Osu.Mods @@ -15,7 +16,7 @@ namespace osu.Game.Rulesets.Osu.Mods public override IconUsage? Icon => FontAwesome.Solid.CompressArrowsAlt; - public override string Description => "Hit them at the right size!"; + public override LocalisableString Description => "Hit them at the right size!"; [SettingSource("Starting Size", "The initial size multiplier applied to all objects.")] public override BindableNumber StartScale { get; } = new BindableFloat diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModEasy.cs b/osu.Game.Rulesets.Osu/Mods/OsuModEasy.cs index 06b5b6cfb8..281b36e70e 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModEasy.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModEasy.cs @@ -1,12 +1,13 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Framework.Localisation; using osu.Game.Rulesets.Mods; namespace osu.Game.Rulesets.Osu.Mods { public class OsuModEasy : ModEasyWithExtraLives { - public override string Description => @"Larger circles, more forgiving HP drain, less accuracy required, and three lives!"; + public override LocalisableString Description => @"Larger circles, more forgiving HP drain, less accuracy required, and three lives!"; } } diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModGrow.cs b/osu.Game.Rulesets.Osu/Mods/OsuModGrow.cs index 182d6eeb4b..b77c887cd3 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModGrow.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModGrow.cs @@ -3,6 +3,7 @@ using osu.Framework.Bindables; using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; using osu.Game.Configuration; namespace osu.Game.Rulesets.Osu.Mods @@ -15,7 +16,7 @@ namespace osu.Game.Rulesets.Osu.Mods public override IconUsage? Icon => FontAwesome.Solid.ArrowsAltV; - public override string Description => "Hit them at the right size!"; + public override LocalisableString Description => "Hit them at the right size!"; [SettingSource("Starting Size", "The initial size multiplier applied to all objects.")] public override BindableNumber StartScale { get; } = new BindableFloat diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModHidden.cs b/osu.Game.Rulesets.Osu/Mods/OsuModHidden.cs index 97f201b2cc..996ee1cddb 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModHidden.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModHidden.cs @@ -6,6 +6,7 @@ using System.Diagnostics; using System.Linq; using osu.Framework.Graphics; using osu.Framework.Bindables; +using osu.Framework.Localisation; using osu.Game.Configuration; using osu.Game.Beatmaps; using osu.Game.Rulesets.Mods; @@ -22,7 +23,7 @@ namespace osu.Game.Rulesets.Osu.Mods [SettingSource("Only fade approach circles", "The main object body will not fade when enabled.")] public Bindable OnlyFadeApproachCircles { get; } = new BindableBool(); - public override string Description => @"Play with no approach circles and fading circles/sliders."; + public override LocalisableString Description => @"Play with no approach circles and fading circles/sliders."; public override double ScoreMultiplier => UsesDefaultConfiguration ? 1.06 : 1; public override Type[] IncompatibleMods => new[] { typeof(IRequiresApproachCircles), typeof(OsuModSpinIn) }; diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModMagnetised.cs b/osu.Game.Rulesets.Osu/Mods/OsuModMagnetised.cs index 7f7d6f70d2..fbde9e0491 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModMagnetised.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModMagnetised.cs @@ -4,6 +4,8 @@ using System; using osu.Framework.Bindables; using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; +using osu.Framework.Timing; using osu.Framework.Utils; using osu.Game.Configuration; using osu.Game.Rulesets.Mods; @@ -22,12 +24,10 @@ namespace osu.Game.Rulesets.Osu.Mods public override string Acronym => "MG"; public override IconUsage? Icon => FontAwesome.Solid.Magnet; public override ModType Type => ModType.Fun; - public override string Description => "No need to chase the circles – your cursor is a magnet!"; + public override LocalisableString Description => "No need to chase the circles – your cursor is a magnet!"; public override double ScoreMultiplier => 0.5; public override Type[] IncompatibleMods => new[] { typeof(OsuModAutopilot), typeof(OsuModWiggle), typeof(OsuModTransform), typeof(ModAutoplay), typeof(OsuModRelax), typeof(OsuModRepel) }; - private IFrameStableClock gameplayClock = null!; - [SettingSource("Attraction strength", "How strong the pull is.", 0)] public BindableFloat AttractionStrength { get; } = new BindableFloat(0.5f) { @@ -38,8 +38,6 @@ namespace osu.Game.Rulesets.Osu.Mods public void ApplyToDrawableRuleset(DrawableRuleset drawableRuleset) { - gameplayClock = drawableRuleset.FrameStableClock; - // Hide judgment displays and follow points as they won't make any sense. // Judgements can potentially be turned on in a future where they display at a position relative to their drawable counterpart. drawableRuleset.Playfield.DisplayJudgements.Value = false; @@ -55,27 +53,27 @@ namespace osu.Game.Rulesets.Osu.Mods switch (drawable) { case DrawableHitCircle circle: - easeTo(circle, cursorPos); + easeTo(playfield.Clock, circle, cursorPos); break; case DrawableSlider slider: if (!slider.HeadCircle.Result.HasResult) - easeTo(slider, cursorPos); + easeTo(playfield.Clock, slider, cursorPos); else - easeTo(slider, cursorPos - slider.Ball.DrawPosition); + easeTo(playfield.Clock, slider, cursorPos - slider.Ball.DrawPosition); break; } } } - private void easeTo(DrawableHitObject hitObject, Vector2 destination) + private void easeTo(IFrameBasedClock clock, DrawableHitObject hitObject, Vector2 destination) { double dampLength = Interpolation.Lerp(3000, 40, AttractionStrength.Value); - float x = (float)Interpolation.DampContinuously(hitObject.X, destination.X, dampLength, gameplayClock.ElapsedFrameTime); - float y = (float)Interpolation.DampContinuously(hitObject.Y, destination.Y, dampLength, gameplayClock.ElapsedFrameTime); + float x = (float)Interpolation.DampContinuously(hitObject.X, destination.X, dampLength, clock.ElapsedFrameTime); + float y = (float)Interpolation.DampContinuously(hitObject.Y, destination.Y, dampLength, clock.ElapsedFrameTime); hitObject.Position = new Vector2(x, y); } diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModMirror.cs b/osu.Game.Rulesets.Osu/Mods/OsuModMirror.cs index 3faca0b01f..0a54d58718 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModMirror.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModMirror.cs @@ -3,6 +3,7 @@ using System; using osu.Framework.Bindables; +using osu.Framework.Localisation; using osu.Game.Configuration; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects; @@ -13,7 +14,7 @@ namespace osu.Game.Rulesets.Osu.Mods { public class OsuModMirror : ModMirror, IApplicableToHitObject { - public override string Description => "Flip objects on the chosen axes."; + public override LocalisableString Description => "Flip objects on the chosen axes."; public override Type[] IncompatibleMods => new[] { typeof(ModHardRock) }; [SettingSource("Mirrored axes", "Choose which axes objects are mirrored over.")] diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModNoScope.cs b/osu.Game.Rulesets.Osu/Mods/OsuModNoScope.cs index 3eb8982f5d..817f7b599c 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModNoScope.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModNoScope.cs @@ -4,6 +4,7 @@ using System; using System.Linq; using osu.Framework.Bindables; +using osu.Framework.Localisation; using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Configuration; @@ -17,7 +18,7 @@ namespace osu.Game.Rulesets.Osu.Mods { public class OsuModNoScope : ModNoScope, IUpdatableByPlayfield, IApplicableToBeatmap { - public override string Description => "Where's the cursor?"; + public override LocalisableString Description => "Where's the cursor?"; private PeriodTracker spinnerPeriods = null!; diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModRandom.cs b/osu.Game.Rulesets.Osu/Mods/OsuModRandom.cs index 4f83154728..96c02a508b 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModRandom.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModRandom.cs @@ -3,6 +3,7 @@ using System; using System.Linq; +using osu.Framework.Localisation; using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Rulesets.Mods; @@ -18,7 +19,7 @@ namespace osu.Game.Rulesets.Osu.Mods /// public class OsuModRandom : ModRandom, IApplicableToBeatmap { - public override string Description => "It never gets boring!"; + public override LocalisableString Description => "It never gets boring!"; public override Type[] IncompatibleMods => base.IncompatibleMods.Append(typeof(OsuModTarget)).ToArray(); diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModRelax.cs b/osu.Game.Rulesets.Osu/Mods/OsuModRelax.cs index 908bb34ed6..fac1cbfd47 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModRelax.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModRelax.cs @@ -5,6 +5,7 @@ using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; +using osu.Framework.Localisation; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Osu.Objects; @@ -18,7 +19,7 @@ namespace osu.Game.Rulesets.Osu.Mods { public class OsuModRelax : ModRelax, IUpdatableByPlayfield, IApplicableToDrawableRuleset, IApplicableToPlayer { - public override string Description => @"You don't need to click. Give your clicking/tapping fingers a break from the heat of things."; + public override LocalisableString Description => @"You don't need to click. Give your clicking/tapping fingers a break from the heat of things."; public override Type[] IncompatibleMods => base.IncompatibleMods.Concat(new[] { typeof(OsuModAutopilot), typeof(OsuModMagnetised), typeof(OsuModAlternate), typeof(OsuModSingleTap) }).ToArray(); /// diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModRepel.cs b/osu.Game.Rulesets.Osu/Mods/OsuModRepel.cs index 211987ee32..911363a27e 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModRepel.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModRepel.cs @@ -2,8 +2,9 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Diagnostics; using osu.Framework.Bindables; +using osu.Framework.Localisation; +using osu.Framework.Timing; using osu.Framework.Utils; using osu.Game.Configuration; using osu.Game.Rulesets.Mods; @@ -22,12 +23,10 @@ namespace osu.Game.Rulesets.Osu.Mods public override string Name => "Repel"; public override string Acronym => "RP"; public override ModType Type => ModType.Fun; - public override string Description => "Hit objects run away!"; + public override LocalisableString Description => "Hit objects run away!"; public override double ScoreMultiplier => 1; public override Type[] IncompatibleMods => new[] { typeof(OsuModAutopilot), typeof(OsuModWiggle), typeof(OsuModTransform), typeof(ModAutoplay), typeof(OsuModMagnetised) }; - private IFrameStableClock? gameplayClock; - [SettingSource("Repulsion strength", "How strong the repulsion is.", 0)] public BindableFloat RepulsionStrength { get; } = new BindableFloat(0.5f) { @@ -38,8 +37,6 @@ namespace osu.Game.Rulesets.Osu.Mods public void ApplyToDrawableRuleset(DrawableRuleset drawableRuleset) { - gameplayClock = drawableRuleset.FrameStableClock; - // Hide judgment displays and follow points as they won't make any sense. // Judgements can potentially be turned on in a future where they display at a position relative to their drawable counterpart. drawableRuleset.Playfield.DisplayJudgements.Value = false; @@ -68,29 +65,27 @@ namespace osu.Game.Rulesets.Osu.Mods switch (drawable) { case DrawableHitCircle circle: - easeTo(circle, destination, cursorPos); + easeTo(playfield.Clock, circle, destination, cursorPos); break; case DrawableSlider slider: if (!slider.HeadCircle.Result.HasResult) - easeTo(slider, destination, cursorPos); + easeTo(playfield.Clock, slider, destination, cursorPos); else - easeTo(slider, destination - slider.Ball.DrawPosition, cursorPos); + easeTo(playfield.Clock, slider, destination - slider.Ball.DrawPosition, cursorPos); break; } } } - private void easeTo(DrawableHitObject hitObject, Vector2 destination, Vector2 cursorPos) + private void easeTo(IFrameBasedClock clock, DrawableHitObject hitObject, Vector2 destination, Vector2 cursorPos) { - Debug.Assert(gameplayClock != null); - double dampLength = Vector2.Distance(hitObject.Position, cursorPos) / (0.04 * RepulsionStrength.Value + 0.04); - float x = (float)Interpolation.DampContinuously(hitObject.X, destination.X, dampLength, gameplayClock.ElapsedFrameTime); - float y = (float)Interpolation.DampContinuously(hitObject.Y, destination.Y, dampLength, gameplayClock.ElapsedFrameTime); + float x = (float)Interpolation.DampContinuously(hitObject.X, destination.X, dampLength, clock.ElapsedFrameTime); + float y = (float)Interpolation.DampContinuously(hitObject.Y, destination.Y, dampLength, clock.ElapsedFrameTime); hitObject.Position = new Vector2(x, y); } diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModSingleTap.cs b/osu.Game.Rulesets.Osu/Mods/OsuModSingleTap.cs index b170d30448..91731b25cf 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModSingleTap.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModSingleTap.cs @@ -3,6 +3,7 @@ using System; using System.Linq; +using osu.Framework.Localisation; namespace osu.Game.Rulesets.Osu.Mods { @@ -10,7 +11,7 @@ namespace osu.Game.Rulesets.Osu.Mods { public override string Name => @"Single Tap"; public override string Acronym => @"SG"; - public override string Description => @"You must only use one key!"; + public override LocalisableString Description => @"You must only use one key!"; public override Type[] IncompatibleMods => base.IncompatibleMods.Concat(new[] { typeof(OsuModAlternate) }).ToArray(); protected override bool CheckValidNewAction(OsuAction action) => LastAcceptedAction == null || LastAcceptedAction == action; diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModSpinIn.cs b/osu.Game.Rulesets.Osu/Mods/OsuModSpinIn.cs index 95e7d13ee7..b0533d0cfa 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModSpinIn.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModSpinIn.cs @@ -4,6 +4,7 @@ using System; using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Osu.Objects; @@ -18,7 +19,7 @@ namespace osu.Game.Rulesets.Osu.Mods public override string Acronym => "SI"; public override IconUsage? Icon => FontAwesome.Solid.Undo; public override ModType Type => ModType.Fun; - public override string Description => "Circles spin in. No approach circles."; + public override LocalisableString Description => "Circles spin in. No approach circles."; public override double ScoreMultiplier => 1; // todo: this mod needs to be incompatible with "hidden" due to forcing the circle to remain opaque, diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModSpunOut.cs b/osu.Game.Rulesets.Osu/Mods/OsuModSpunOut.cs index d9ab749ad3..9708800daa 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModSpunOut.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModSpunOut.cs @@ -4,6 +4,7 @@ using System; using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; using osu.Framework.Utils; using osu.Game.Graphics; using osu.Game.Rulesets.Mods; @@ -18,7 +19,7 @@ namespace osu.Game.Rulesets.Osu.Mods public override string Acronym => "SO"; public override IconUsage? Icon => OsuIcon.ModSpunOut; public override ModType Type => ModType.Automation; - public override string Description => @"Spinners will be automatically completed."; + public override LocalisableString Description => @"Spinners will be automatically completed."; public override double ScoreMultiplier => 0.9; public override Type[] IncompatibleMods => new[] { typeof(ModAutoplay), typeof(OsuModAutopilot), typeof(OsuModTarget) }; diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModStrictTracking.cs b/osu.Game.Rulesets.Osu/Mods/OsuModStrictTracking.cs index 0b34ab28a3..67b19124e1 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModStrictTracking.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModStrictTracking.cs @@ -4,6 +4,7 @@ using System; using System.Linq; using System.Threading; +using osu.Framework.Localisation; using osu.Game.Beatmaps; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Mods; @@ -23,7 +24,7 @@ namespace osu.Game.Rulesets.Osu.Mods public override string Name => @"Strict Tracking"; public override string Acronym => @"ST"; public override ModType Type => ModType.DifficultyIncrease; - public override string Description => @"Once you start a slider, follow precisely or get a miss."; + public override LocalisableString Description => @"Once you start a slider, follow precisely or get a miss."; public override double ScoreMultiplier => 1.0; public override Type[] IncompatibleMods => new[] { typeof(ModClassic), typeof(OsuModTarget) }; diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModTarget.cs b/osu.Game.Rulesets.Osu/Mods/OsuModTarget.cs index 623157a427..82260db818 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModTarget.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModTarget.cs @@ -7,6 +7,7 @@ using System.Linq; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; using osu.Framework.Utils; using osu.Game.Audio; using osu.Game.Beatmaps; @@ -39,7 +40,7 @@ namespace osu.Game.Rulesets.Osu.Mods public override string Acronym => "TP"; public override ModType Type => ModType.Conversion; public override IconUsage? Icon => OsuIcon.ModTarget; - public override string Description => @"Practice keeping up with the beat of the song."; + public override LocalisableString Description => @"Practice keeping up with the beat of the song."; public override double ScoreMultiplier => 1; public override Type[] IncompatibleMods => base.IncompatibleMods.Concat(new[] diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModTouchDevice.cs b/osu.Game.Rulesets.Osu/Mods/OsuModTouchDevice.cs index 7276cc753c..fd5c46a226 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModTouchDevice.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModTouchDevice.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Framework.Localisation; using osu.Game.Rulesets.Mods; namespace osu.Game.Rulesets.Osu.Mods @@ -9,7 +10,7 @@ namespace osu.Game.Rulesets.Osu.Mods { public override string Name => "Touch Device"; public override string Acronym => "TD"; - public override string Description => "Automatically applied to plays on devices with a touchscreen."; + public override LocalisableString Description => "Automatically applied to plays on devices with a touchscreen."; public override double ScoreMultiplier => 1; public override ModType Type => ModType.System; diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModTraceable.cs b/osu.Game.Rulesets.Osu/Mods/OsuModTraceable.cs index d862d36670..25d05a88a8 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModTraceable.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModTraceable.cs @@ -4,6 +4,7 @@ using System; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; +using osu.Framework.Localisation; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Osu.Objects.Drawables; @@ -16,7 +17,7 @@ namespace osu.Game.Rulesets.Osu.Mods public override string Name => "Traceable"; public override string Acronym => "TC"; public override ModType Type => ModType.Fun; - public override string Description => "Put your faith in the approach circles..."; + public override LocalisableString Description => "Put your faith in the approach circles..."; public override double ScoreMultiplier => 1; public override Type[] IncompatibleMods => new[] { typeof(IHidesApproachCircles) }; diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModTransform.cs b/osu.Game.Rulesets.Osu/Mods/OsuModTransform.cs index 4354ecbe9a..2354cd50ae 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModTransform.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModTransform.cs @@ -4,6 +4,7 @@ using System; using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Osu.Objects; @@ -18,7 +19,7 @@ namespace osu.Game.Rulesets.Osu.Mods public override string Acronym => "TR"; public override IconUsage? Icon => FontAwesome.Solid.ArrowsAlt; public override ModType Type => ModType.Fun; - public override string Description => "Everything rotates. EVERYTHING."; + public override LocalisableString Description => "Everything rotates. EVERYTHING."; public override double ScoreMultiplier => 1; public override Type[] IncompatibleMods => new[] { typeof(OsuModWiggle), typeof(OsuModMagnetised), typeof(OsuModRepel) }; diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModWiggle.cs b/osu.Game.Rulesets.Osu/Mods/OsuModWiggle.cs index 3f1c3aa812..a45338d91f 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModWiggle.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModWiggle.cs @@ -5,6 +5,7 @@ using System; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; using osu.Game.Configuration; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects.Drawables; @@ -20,7 +21,7 @@ namespace osu.Game.Rulesets.Osu.Mods public override string Acronym => "WG"; public override IconUsage? Icon => FontAwesome.Solid.Certificate; public override ModType Type => ModType.Fun; - public override string Description => "They just won't stay still..."; + public override LocalisableString Description => "They just won't stay still..."; public override double ScoreMultiplier => 1; public override Type[] IncompatibleMods => new[] { typeof(OsuModTransform), typeof(OsuModMagnetised), typeof(OsuModRepel) }; diff --git a/osu.Game.Rulesets.Osu/OsuRuleset.cs b/osu.Game.Rulesets.Osu/OsuRuleset.cs index 302194e91a..7f58f29d4b 100644 --- a/osu.Game.Rulesets.Osu/OsuRuleset.cs +++ b/osu.Game.Rulesets.Osu/OsuRuleset.cs @@ -32,6 +32,7 @@ using osu.Game.Skinning; using System; using System.Linq; using osu.Framework.Extensions.EnumExtensions; +using osu.Framework.Localisation; using osu.Game.Rulesets.Osu.Edit.Setup; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.Skinning.Legacy; @@ -253,7 +254,7 @@ namespace osu.Game.Rulesets.Osu }; } - public override string GetDisplayNameForHitResult(HitResult result) + public override LocalisableString GetDisplayNameForHitResult(HitResult result) { switch (result) { diff --git a/osu.Game.Rulesets.Osu/UI/OsuResumeOverlay.cs b/osu.Game.Rulesets.Osu/UI/OsuResumeOverlay.cs index fc3f89a836..412505331b 100644 --- a/osu.Game.Rulesets.Osu/UI/OsuResumeOverlay.cs +++ b/osu.Game.Rulesets.Osu/UI/OsuResumeOverlay.cs @@ -11,6 +11,7 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; +using osu.Framework.Localisation; using osu.Game.Rulesets.Osu.UI.Cursor; using osu.Game.Screens.Play; using osuTK; @@ -28,7 +29,7 @@ namespace osu.Game.Rulesets.Osu.UI public override CursorContainer LocalCursor => State.Value == Visibility.Visible ? localCursorContainer : null; - protected override string Message => "Click the orange cursor to resume"; + protected override LocalisableString Message => "Click the orange cursor to resume"; [BackgroundDependencyLoader] private void load() diff --git a/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableTaikoMascot.cs b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableTaikoMascot.cs index ef95358d34..9163f994c5 100644 --- a/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableTaikoMascot.cs +++ b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableTaikoMascot.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System.Collections.Generic; using System.Linq; using Humanizer; @@ -36,7 +34,7 @@ namespace osu.Game.Rulesets.Taiko.Tests.Skinning TimeRange = { Value = 5000 }, }; - private TaikoScoreProcessor scoreProcessor; + private TaikoScoreProcessor scoreProcessor = null!; private IEnumerable mascots => this.ChildrenOfType(); @@ -65,6 +63,8 @@ namespace osu.Game.Rulesets.Taiko.Tests.Skinning [Test] public void TestInitialState() { + AddStep("set beatmap", () => setBeatmap()); + AddStep("create mascot", () => SetContents(_ => new DrawableTaikoMascot { RelativeSizeAxes = Axes.Both })); AddAssert("mascot initially idle", () => allMascotsIn(TaikoMascotAnimationState.Idle)); @@ -89,9 +89,7 @@ namespace osu.Game.Rulesets.Taiko.Tests.Skinning [Test] public void TestIdleState() { - AddStep("set beatmap", () => setBeatmap()); - - createDrawableRuleset(); + prepareDrawableRulesetAndBeatmap(false); assertStateAfterResult(new JudgementResult(new Hit(), new TaikoJudgement()) { Type = HitResult.Great }, TaikoMascotAnimationState.Idle); assertStateAfterResult(new JudgementResult(new Hit.StrongNestedHit(), new TaikoStrongJudgement()) { Type = HitResult.IgnoreMiss }, TaikoMascotAnimationState.Idle); @@ -100,9 +98,7 @@ namespace osu.Game.Rulesets.Taiko.Tests.Skinning [Test] public void TestKiaiState() { - AddStep("set beatmap", () => setBeatmap(true)); - - createDrawableRuleset(); + prepareDrawableRulesetAndBeatmap(true); assertStateAfterResult(new JudgementResult(new Hit(), new TaikoJudgement()) { Type = HitResult.Ok }, TaikoMascotAnimationState.Kiai); assertStateAfterResult(new JudgementResult(new Hit(), new TaikoStrongJudgement()) { Type = HitResult.IgnoreMiss }, TaikoMascotAnimationState.Kiai); @@ -112,9 +108,7 @@ namespace osu.Game.Rulesets.Taiko.Tests.Skinning [Test] public void TestMissState() { - AddStep("set beatmap", () => setBeatmap()); - - createDrawableRuleset(); + prepareDrawableRulesetAndBeatmap(false); assertStateAfterResult(new JudgementResult(new Hit(), new TaikoJudgement()) { Type = HitResult.Great }, TaikoMascotAnimationState.Idle); assertStateAfterResult(new JudgementResult(new Hit(), new TaikoJudgement()) { Type = HitResult.Miss }, TaikoMascotAnimationState.Fail); @@ -126,9 +120,7 @@ namespace osu.Game.Rulesets.Taiko.Tests.Skinning [TestCase(false)] public void TestClearStateOnComboMilestone(bool kiai) { - AddStep("set beatmap", () => setBeatmap(kiai)); - - createDrawableRuleset(); + prepareDrawableRulesetAndBeatmap(kiai); AddRepeatStep("reach 49 combo", () => applyNewResult(new JudgementResult(new Hit(), new TaikoJudgement()) { Type = HitResult.Great }), 49); @@ -139,9 +131,7 @@ namespace osu.Game.Rulesets.Taiko.Tests.Skinning [TestCase(false, TaikoMascotAnimationState.Idle)] public void TestClearStateOnClearedSwell(bool kiai, TaikoMascotAnimationState expectedStateAfterClear) { - AddStep("set beatmap", () => setBeatmap(kiai)); - - createDrawableRuleset(); + prepareDrawableRulesetAndBeatmap(kiai); assertStateAfterResult(new JudgementResult(new Swell(), new TaikoSwellJudgement()) { Type = HitResult.Great }, TaikoMascotAnimationState.Clear); AddUntilStep($"state reverts to {expectedStateAfterClear.ToString().ToLowerInvariant()}", () => allMascotsIn(expectedStateAfterClear)); @@ -175,25 +165,27 @@ namespace osu.Game.Rulesets.Taiko.Tests.Skinning scoreProcessor.ApplyBeatmap(Beatmap.Value.Beatmap); } - private void createDrawableRuleset() + private void prepareDrawableRulesetAndBeatmap(bool kiai) { - AddUntilStep("wait for beatmap to be loaded", () => Beatmap.Value.Track.IsLoaded); + AddStep("set beatmap", () => setBeatmap(kiai)); AddStep("create drawable ruleset", () => { - Beatmap.Value.Track.Start(); - SetContents(_ => { var ruleset = new TaikoRuleset(); return new DrawableTaikoRuleset(ruleset, Beatmap.Value.GetPlayableBeatmap(ruleset.RulesetInfo)); }); }); + + AddUntilStep("wait for track to be loaded", () => MusicController.TrackLoaded); + AddStep("start track", () => MusicController.CurrentTrack.Restart()); + AddUntilStep("wait for track started", () => MusicController.IsPlaying); } private void assertStateAfterResult(JudgementResult judgementResult, TaikoMascotAnimationState expectedState) { - TaikoMascotAnimationState[] mascotStates = null; + TaikoMascotAnimationState[] mascotStates = null!; AddStep($"{judgementResult.Type.ToString().ToLowerInvariant()} result for {judgementResult.Judgement.GetType().Name.Humanize(LetterCasing.LowerCase)}", () => @@ -204,7 +196,7 @@ namespace osu.Game.Rulesets.Taiko.Tests.Skinning Schedule(() => mascotStates = animatedMascots.Select(mascot => mascot.State.Value).ToArray()); }); - AddAssert($"state is {expectedState.ToString().ToLowerInvariant()}", () => mascotStates.All(state => state == expectedState)); + AddAssert($"state is {expectedState.ToString().ToLowerInvariant()}", () => mascotStates.Distinct(), () => Is.EquivalentTo(new[] { expectedState })); } private void applyNewResult(JudgementResult judgementResult) diff --git a/osu.Game.Rulesets.Taiko.Tests/TaikoDifficultyCalculatorTest.cs b/osu.Game.Rulesets.Taiko.Tests/TaikoDifficultyCalculatorTest.cs index 579b461624..425f72cadc 100644 --- a/osu.Game.Rulesets.Taiko.Tests/TaikoDifficultyCalculatorTest.cs +++ b/osu.Game.Rulesets.Taiko.Tests/TaikoDifficultyCalculatorTest.cs @@ -16,13 +16,13 @@ namespace osu.Game.Rulesets.Taiko.Tests { protected override string ResourceAssembly => "osu.Game.Rulesets.Taiko"; - [TestCase(1.9971301024093662d, 200, "diffcalc-test")] - [TestCase(1.9971301024093662d, 200, "diffcalc-test-strong")] + [TestCase(3.1098944660126882d, 200, "diffcalc-test")] + [TestCase(3.1098944660126882d, 200, "diffcalc-test-strong")] public void Test(double expectedStarRating, int expectedMaxCombo, string name) => base.Test(expectedStarRating, expectedMaxCombo, name); - [TestCase(3.1645810961313674d, 200, "diffcalc-test")] - [TestCase(3.1645810961313674d, 200, "diffcalc-test-strong")] + [TestCase(4.0974106752474251d, 200, "diffcalc-test")] + [TestCase(4.0974106752474251d, 200, "diffcalc-test-strong")] public void TestClockRateAdjusted(double expectedStarRating, int expectedMaxCombo, string name) => Test(expectedStarRating, expectedMaxCombo, name, new TaikoModDoubleTime()); diff --git a/osu.Game.Rulesets.Taiko/Difficulty/Evaluators/ColourEvaluator.cs b/osu.Game.Rulesets.Taiko/Difficulty/Evaluators/ColourEvaluator.cs new file mode 100644 index 0000000000..7d88be2f70 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Difficulty/Evaluators/ColourEvaluator.cs @@ -0,0 +1,67 @@ +// 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 osu.Game.Rulesets.Difficulty.Preprocessing; +using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing; +using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Colour; +using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Colour.Data; + +namespace osu.Game.Rulesets.Taiko.Difficulty.Evaluators +{ + public class ColourEvaluator + { + /// + /// A sigmoid function. It gives a value between (middle - height/2) and (middle + height/2). + /// + /// The input value. + /// The center of the sigmoid, where the largest gradient occurs and value is equal to middle. + /// The radius of the sigmoid, outside of which values are near the minimum/maximum. + /// The middle of the sigmoid output. + /// The height of the sigmoid output. This will be equal to max value - min value. + private static double sigmoid(double val, double center, double width, double middle, double height) + { + double sigmoid = Math.Tanh(Math.E * -(val - center) / width); + return sigmoid * (height / 2) + middle; + } + + /// + /// Evaluate the difficulty of the first note of a . + /// + public static double EvaluateDifficultyOf(MonoStreak monoStreak) + { + return sigmoid(monoStreak.Index, 2, 2, 0.5, 1) * EvaluateDifficultyOf(monoStreak.Parent) * 0.5; + } + + /// + /// Evaluate the difficulty of the first note of a . + /// + public static double EvaluateDifficultyOf(AlternatingMonoPattern alternatingMonoPattern) + { + return sigmoid(alternatingMonoPattern.Index, 2, 2, 0.5, 1) * EvaluateDifficultyOf(alternatingMonoPattern.Parent); + } + + /// + /// Evaluate the difficulty of the first note of a . + /// + public static double EvaluateDifficultyOf(RepeatingHitPatterns repeatingHitPattern) + { + return 2 * (1 - sigmoid(repeatingHitPattern.RepetitionInterval, 2, 2, 0.5, 1)); + } + + public static double EvaluateDifficultyOf(DifficultyHitObject hitObject) + { + TaikoDifficultyHitObjectColour colour = ((TaikoDifficultyHitObject)hitObject).Colour; + double difficulty = 0.0d; + + if (colour.MonoStreak != null) // Difficulty for MonoStreak + difficulty += EvaluateDifficultyOf(colour.MonoStreak); + if (colour.AlternatingMonoPattern != null) // Difficulty for AlternatingMonoPattern + difficulty += EvaluateDifficultyOf(colour.AlternatingMonoPattern); + if (colour.RepeatingHitPattern != null) // Difficulty for RepeatingHitPattern + difficulty += EvaluateDifficultyOf(colour.RepeatingHitPattern); + + return difficulty; + } + } +} diff --git a/osu.Game.Rulesets.Taiko/Difficulty/Evaluators/StaminaEvaluator.cs b/osu.Game.Rulesets.Taiko/Difficulty/Evaluators/StaminaEvaluator.cs new file mode 100644 index 0000000000..49b3ae2e19 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Difficulty/Evaluators/StaminaEvaluator.cs @@ -0,0 +1,53 @@ +// 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 osu.Game.Rulesets.Difficulty.Preprocessing; +using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing; +using osu.Game.Rulesets.Taiko.Objects; + +namespace osu.Game.Rulesets.Taiko.Difficulty.Evaluators +{ + public class StaminaEvaluator + { + /// + /// Applies a speed bonus dependent on the time since the last hit performed using this key. + /// + /// The interval between the current and previous note hit using the same key. + private static double speedBonus(double interval) + { + // Cap to 600bpm 1/4, 25ms note interval, 50ms key interval + // Interval will be capped at a very small value to avoid infinite/negative speed bonuses. + // TODO - This is a temporary measure as we need to implement methods of detecting playstyle-abuse of SpeedBonus. + interval = Math.Max(interval, 50); + + return 30 / interval; + } + + /// + /// Evaluates the minimum mechanical stamina required to play the current object. This is calculated using the + /// maximum possible interval between two hits using the same key, by alternating 2 keys for each colour. + /// + public static double EvaluateDifficultyOf(DifficultyHitObject current) + { + if (current.BaseObject is not Hit) + { + return 0.0; + } + + // Find the previous hit object hit by the current key, which is two notes of the same colour prior. + TaikoDifficultyHitObject taikoCurrent = (TaikoDifficultyHitObject)current; + TaikoDifficultyHitObject? keyPrevious = taikoCurrent.PreviousMono(1); + + if (keyPrevious == null) + { + // There is no previous hit object hit by the current key + return 0.0; + } + + double objectStrain = 0.5; // Add a base strain to all objects + objectStrain += speedBonus(taikoCurrent.StartTime - keyPrevious.StartTime); + return objectStrain; + } + } +} diff --git a/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/Colour/Data/AlternatingMonoPattern.cs b/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/Colour/Data/AlternatingMonoPattern.cs new file mode 100644 index 0000000000..7910a8262b --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/Colour/Data/AlternatingMonoPattern.cs @@ -0,0 +1,53 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; + +namespace osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Colour.Data +{ + /// + /// Encodes a list of s. + /// s with the same are grouped together. + /// + public class AlternatingMonoPattern + { + /// + /// s that are grouped together within this . + /// + public readonly List MonoStreaks = new List(); + + /// + /// The parent that contains this + /// + public RepeatingHitPatterns Parent = null!; + + /// + /// Index of this within it's parent + /// + public int Index; + + /// + /// The first in this . + /// + public TaikoDifficultyHitObject FirstHitObject => MonoStreaks[0].FirstHitObject; + + /// + /// Determine if this is a repetition of another . This + /// is a strict comparison and is true if and only if the colour sequence is exactly the same. + /// + public bool IsRepetitionOf(AlternatingMonoPattern other) + { + return HasIdenticalMonoLength(other) && + other.MonoStreaks.Count == MonoStreaks.Count && + other.MonoStreaks[0].HitType == MonoStreaks[0].HitType; + } + + /// + /// Determine if this has the same mono length of another . + /// + public bool HasIdenticalMonoLength(AlternatingMonoPattern other) + { + return other.MonoStreaks[0].RunLength == MonoStreaks[0].RunLength; + } + } +} diff --git a/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/Colour/Data/MonoStreak.cs b/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/Colour/Data/MonoStreak.cs new file mode 100644 index 0000000000..174988bed7 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/Colour/Data/MonoStreak.cs @@ -0,0 +1,46 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Game.Rulesets.Difficulty.Preprocessing; +using osu.Game.Rulesets.Taiko.Objects; +using System.Collections.Generic; + +namespace osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Colour.Data +{ + /// + /// Encode colour information for a sequence of s. Consecutive s + /// of the same are encoded within the same . + /// + public class MonoStreak + { + /// + /// List of s that are encoded within this . + /// + public List HitObjects { get; private set; } = new List(); + + /// + /// The parent that contains this + /// + public AlternatingMonoPattern Parent = null!; + + /// + /// Index of this within it's parent + /// + public int Index; + + /// + /// The first in this . + /// + public TaikoDifficultyHitObject FirstHitObject => HitObjects[0]; + + /// + /// The hit type of all objects encoded within this + /// + public HitType? HitType => (HitObjects[0].BaseObject as Hit)?.Type; + + /// + /// How long the mono pattern encoded within is + /// + public int RunLength => HitObjects.Count; + } +} diff --git a/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/Colour/Data/RepeatingHitPatterns.cs b/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/Colour/Data/RepeatingHitPatterns.cs new file mode 100644 index 0000000000..fe0dc6dd9a --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/Colour/Data/RepeatingHitPatterns.cs @@ -0,0 +1,94 @@ +// 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; + +namespace osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Colour.Data +{ + /// + /// Encodes a list of s, grouped together by back and forth repetition of the same + /// . Also stores the repetition interval between this and the previous . + /// + public class RepeatingHitPatterns + { + /// + /// Maximum amount of s to look back to find a repetition. + /// + private const int max_repetition_interval = 16; + + /// + /// The s that are grouped together within this . + /// + public readonly List AlternatingMonoPatterns = new List(); + + /// + /// The parent in this + /// + public TaikoDifficultyHitObject FirstHitObject => AlternatingMonoPatterns[0].FirstHitObject; + + /// + /// The previous . This is used to determine the repetition interval. + /// + public readonly RepeatingHitPatterns? Previous; + + /// + /// How many between the current and previous identical . + /// If no repetition is found this will have a value of + 1. + /// + public int RepetitionInterval { get; private set; } = max_repetition_interval + 1; + + public RepeatingHitPatterns(RepeatingHitPatterns? previous) + { + Previous = previous; + } + + /// + /// Returns true if other is considered a repetition of this pattern. This is true if other's first two payloads + /// have identical mono lengths. + /// + private bool isRepetitionOf(RepeatingHitPatterns other) + { + if (AlternatingMonoPatterns.Count != other.AlternatingMonoPatterns.Count) return false; + + for (int i = 0; i < Math.Min(AlternatingMonoPatterns.Count, 2); i++) + { + if (!AlternatingMonoPatterns[i].HasIdenticalMonoLength(other.AlternatingMonoPatterns[i])) return false; + } + + return true; + } + + /// + /// Finds the closest previous that has the identical . + /// Interval is defined as the amount of chunks between the current and repeated patterns. + /// + public void FindRepetitionInterval() + { + if (Previous == null) + { + RepetitionInterval = max_repetition_interval + 1; + return; + } + + RepeatingHitPatterns? other = Previous; + int interval = 1; + + while (interval < max_repetition_interval) + { + if (isRepetitionOf(other)) + { + RepetitionInterval = Math.Min(interval, max_repetition_interval); + return; + } + + other = other.Previous; + if (other == null) break; + + ++interval; + } + + RepetitionInterval = max_repetition_interval + 1; + } + } +} diff --git a/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/Colour/TaikoColourDifficultyPreprocessor.cs b/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/Colour/TaikoColourDifficultyPreprocessor.cs new file mode 100644 index 0000000000..d19e05f4e0 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/Colour/TaikoColourDifficultyPreprocessor.cs @@ -0,0 +1,167 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using osu.Game.Rulesets.Difficulty.Preprocessing; +using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Colour.Data; +using osu.Game.Rulesets.Taiko.Objects; + +namespace osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Colour +{ + /// + /// Utility class to perform various encodings. + /// + public static class TaikoColourDifficultyPreprocessor + { + /// + /// Processes and encodes a list of s into a list of s, + /// assigning the appropriate s to each , + /// and pre-evaluating colour difficulty of each . + /// + public static void ProcessAndAssign(List hitObjects) + { + List hitPatterns = encode(hitObjects); + + // Assign indexing and encoding data to all relevant objects. Only the first note of each encoding type is + // assigned with the relevant encodings. + foreach (var repeatingHitPattern in hitPatterns) + { + repeatingHitPattern.FirstHitObject.Colour.RepeatingHitPattern = repeatingHitPattern; + + // The outermost loop is kept a ForEach loop since it doesn't need index information, and we want to + // keep i and j for AlternatingMonoPattern's and MonoStreak's index respectively, to keep it in line with + // documentation. + for (int i = 0; i < repeatingHitPattern.AlternatingMonoPatterns.Count; ++i) + { + AlternatingMonoPattern monoPattern = repeatingHitPattern.AlternatingMonoPatterns[i]; + monoPattern.Parent = repeatingHitPattern; + monoPattern.Index = i; + monoPattern.FirstHitObject.Colour.AlternatingMonoPattern = monoPattern; + + for (int j = 0; j < monoPattern.MonoStreaks.Count; ++j) + { + MonoStreak monoStreak = monoPattern.MonoStreaks[j]; + monoStreak.Parent = monoPattern; + monoStreak.Index = j; + monoStreak.FirstHitObject.Colour.MonoStreak = monoStreak; + } + } + } + } + + /// + /// Encodes a list of s into a list of s. + /// + private static List encode(List data) + { + List monoStreaks = encodeMonoStreak(data); + List alternatingMonoPatterns = encodeAlternatingMonoPattern(monoStreaks); + List repeatingHitPatterns = encodeRepeatingHitPattern(alternatingMonoPatterns); + + return repeatingHitPatterns; + } + + /// + /// Encodes a list of s into a list of s. + /// + private static List encodeMonoStreak(List data) + { + List monoStreaks = new List(); + MonoStreak? currentMonoStreak = null; + + for (int i = 0; i < data.Count; i++) + { + TaikoDifficultyHitObject taikoObject = (TaikoDifficultyHitObject)data[i]; + + // This ignores all non-note objects, which may or may not be the desired behaviour + TaikoDifficultyHitObject? previousObject = taikoObject.PreviousNote(0); + + // If this is the first object in the list or the colour changed, create a new mono streak + if (currentMonoStreak == null || previousObject == null || (taikoObject.BaseObject as Hit)?.Type != (previousObject.BaseObject as Hit)?.Type) + { + currentMonoStreak = new MonoStreak(); + monoStreaks.Add(currentMonoStreak); + } + + // Add the current object to the encoded payload. + currentMonoStreak.HitObjects.Add(taikoObject); + } + + return monoStreaks; + } + + /// + /// Encodes a list of s into a list of s. + /// + private static List encodeAlternatingMonoPattern(List data) + { + List monoPatterns = new List(); + AlternatingMonoPattern? currentMonoPattern = null; + + for (int i = 0; i < data.Count; i++) + { + // Start a new AlternatingMonoPattern if the previous MonoStreak has a different mono length, or if this is the first MonoStreak in the list. + if (currentMonoPattern == null || data[i].RunLength != data[i - 1].RunLength) + { + currentMonoPattern = new AlternatingMonoPattern(); + monoPatterns.Add(currentMonoPattern); + } + + // Add the current MonoStreak to the encoded payload. + currentMonoPattern.MonoStreaks.Add(data[i]); + } + + return monoPatterns; + } + + /// + /// Encodes a list of s into a list of s. + /// + private static List encodeRepeatingHitPattern(List data) + { + List hitPatterns = new List(); + RepeatingHitPatterns? currentHitPattern = null; + + for (int i = 0; i < data.Count; i++) + { + // Start a new RepeatingHitPattern. AlternatingMonoPatterns that should be grouped together will be handled later within this loop. + currentHitPattern = new RepeatingHitPatterns(currentHitPattern); + + // Determine if future AlternatingMonoPatterns should be grouped. + bool isCoupled = i < data.Count - 2 && data[i].IsRepetitionOf(data[i + 2]); + + if (!isCoupled) + { + // If not, add the current AlternatingMonoPattern to the encoded payload and continue. + currentHitPattern.AlternatingMonoPatterns.Add(data[i]); + } + else + { + // If so, add the current AlternatingMonoPattern to the encoded payload and start repeatedly checking if the + // subsequent AlternatingMonoPatterns should be grouped by increasing i and doing the appropriate isCoupled check. + while (isCoupled) + { + currentHitPattern.AlternatingMonoPatterns.Add(data[i]); + i++; + isCoupled = i < data.Count - 2 && data[i].IsRepetitionOf(data[i + 2]); + } + + // Skip over viewed data and add the rest to the payload + currentHitPattern.AlternatingMonoPatterns.Add(data[i]); + currentHitPattern.AlternatingMonoPatterns.Add(data[i + 1]); + i++; + } + + hitPatterns.Add(currentHitPattern); + } + + // Final pass to find repetition intervals + for (int i = 0; i < hitPatterns.Count; i++) + { + hitPatterns[i].FindRepetitionInterval(); + } + + return hitPatterns; + } + } +} diff --git a/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/Colour/TaikoDifficultyHitObjectColour.cs b/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/Colour/TaikoDifficultyHitObjectColour.cs new file mode 100644 index 0000000000..9c147eee9c --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/Colour/TaikoDifficultyHitObjectColour.cs @@ -0,0 +1,28 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Colour.Data; + +namespace osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Colour +{ + /// + /// Stores colour compression information for a . + /// + public class TaikoDifficultyHitObjectColour + { + /// + /// The that encodes this note, only present if this is the first note within a + /// + public MonoStreak? MonoStreak; + + /// + /// The that encodes this note, only present if this is the first note within a + /// + public AlternatingMonoPattern? AlternatingMonoPattern; + + /// + /// The that encodes this note, only present if this is the first note within a + /// + public RepeatingHitPatterns? RepeatingHitPattern; + } +} diff --git a/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/TaikoDifficultyHitObjectRhythm.cs b/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/Rhythm/TaikoDifficultyHitObjectRhythm.cs similarity index 95% rename from osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/TaikoDifficultyHitObjectRhythm.cs rename to osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/Rhythm/TaikoDifficultyHitObjectRhythm.cs index 526d20e7d7..a273d7e2ea 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/TaikoDifficultyHitObjectRhythm.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/Rhythm/TaikoDifficultyHitObjectRhythm.cs @@ -1,9 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - -namespace osu.Game.Rulesets.Taiko.Difficulty.Preprocessing +namespace osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Rhythm { /// /// Represents a rhythm change in a taiko map. diff --git a/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/TaikoDifficultyHitObject.cs b/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/TaikoDifficultyHitObject.cs index 4c7b140832..4aaee50c18 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/TaikoDifficultyHitObject.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/Preprocessing/TaikoDifficultyHitObject.cs @@ -1,14 +1,14 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System; using System.Collections.Generic; using System.Linq; using osu.Game.Rulesets.Difficulty.Preprocessing; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Taiko.Objects; +using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Colour; +using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Rhythm; namespace osu.Game.Rulesets.Taiko.Difficulty.Preprocessing { @@ -17,21 +17,36 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Preprocessing /// public class TaikoDifficultyHitObject : DifficultyHitObject { + /// + /// The list of all of the same colour as this in the beatmap. + /// + private readonly IReadOnlyList? monoDifficultyHitObjects; + + /// + /// The index of this in . + /// + public readonly int MonoIndex; + + /// + /// The list of all that is either a regular note or finisher in the beatmap + /// + private readonly IReadOnlyList noteDifficultyHitObjects; + + /// + /// The index of this in . + /// + public readonly int NoteIndex; + /// /// The rhythm required to hit this hit object. /// public readonly TaikoDifficultyHitObjectRhythm Rhythm; /// - /// The hit type of this hit object. + /// Colour data for this hit object. This is used by colour evaluator to calculate colour difficulty, but can be used + /// by other skills in the future. /// - public readonly HitType? HitType; - - /// - /// Whether the object should carry a penalty due to being hittable using special techniques - /// making it easier to do so. - /// - public bool StaminaCheese; + public readonly TaikoDifficultyHitObjectColour Colour; /// /// Creates a new difficulty hit object. @@ -40,15 +55,44 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Preprocessing /// The gameplay preceding . /// The gameplay preceding . /// The rate of the gameplay clock. Modified by speed-changing mods. - /// The list of s in the current beatmap. - /// /// The position of this in the list. - public TaikoDifficultyHitObject(HitObject hitObject, HitObject lastObject, HitObject lastLastObject, double clockRate, List objects, int index) + /// The list of all s in the current beatmap. + /// The list of centre (don) s in the current beatmap. + /// The list of rim (kat) s in the current beatmap. + /// The list of s that is a hit (i.e. not a drumroll or swell) in the current beatmap. + /// The position of this in the list. + public TaikoDifficultyHitObject(HitObject hitObject, HitObject lastObject, HitObject lastLastObject, double clockRate, + List objects, + List centreHitObjects, + List rimHitObjects, + List noteObjects, int index) : base(hitObject, lastObject, clockRate, objects, index) { - var currentHit = hitObject as Hit; + noteDifficultyHitObjects = noteObjects; + // Create the Colour object, its properties should be filled in by TaikoDifficultyPreprocessor + Colour = new TaikoDifficultyHitObjectColour(); Rhythm = getClosestRhythm(lastObject, lastLastObject, clockRate); - HitType = currentHit?.Type; + + switch ((hitObject as Hit)?.Type) + { + case HitType.Centre: + MonoIndex = centreHitObjects.Count; + centreHitObjects.Add(this); + monoDifficultyHitObjects = centreHitObjects; + break; + + case HitType.Rim: + MonoIndex = rimHitObjects.Count; + rimHitObjects.Add(this); + monoDifficultyHitObjects = rimHitObjects; + break; + } + + if (hitObject is Hit) + { + NoteIndex = noteObjects.Count; + noteObjects.Add(this); + } } /// @@ -87,5 +131,13 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Preprocessing return common_rhythms.OrderBy(x => Math.Abs(x.Ratio - ratio)).First(); } + + public TaikoDifficultyHitObject? PreviousMono(int backwardsIndex) => monoDifficultyHitObjects?.ElementAtOrDefault(MonoIndex - (backwardsIndex + 1)); + + public TaikoDifficultyHitObject? NextMono(int forwardsIndex) => monoDifficultyHitObjects?.ElementAtOrDefault(MonoIndex + (forwardsIndex + 1)); + + public TaikoDifficultyHitObject? PreviousNote(int backwardsIndex) => noteDifficultyHitObjects.ElementAtOrDefault(NoteIndex - (backwardsIndex + 1)); + + public TaikoDifficultyHitObject? NextNote(int forwardsIndex) => noteDifficultyHitObjects.ElementAtOrDefault(NoteIndex + (forwardsIndex + 1)); } } diff --git a/osu.Game.Rulesets.Taiko/Difficulty/Skills/Colour.cs b/osu.Game.Rulesets.Taiko/Difficulty/Skills/Colour.cs index 3727c0e4b7..2d45b5eed0 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/Skills/Colour.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/Skills/Colour.cs @@ -1,15 +1,10 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - -using System; using osu.Game.Rulesets.Difficulty.Preprocessing; using osu.Game.Rulesets.Difficulty.Skills; -using osu.Game.Rulesets.Difficulty.Utils; using osu.Game.Rulesets.Mods; -using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing; -using osu.Game.Rulesets.Taiko.Objects; +using osu.Game.Rulesets.Taiko.Difficulty.Evaluators; namespace osu.Game.Rulesets.Taiko.Difficulty.Skills { @@ -18,29 +13,12 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Skills /// public class Colour : StrainDecaySkill { - protected override double SkillMultiplier => 1; - protected override double StrainDecayBase => 0.4; + protected override double SkillMultiplier => 0.12; - /// - /// Maximum number of entries to keep in . - /// - private const int mono_history_max_length = 5; - - /// - /// Queue with the lengths of the last most recent mono (single-colour) patterns, - /// with the most recent value at the end of the queue. - /// - private readonly LimitedCapacityQueue monoHistory = new LimitedCapacityQueue(mono_history_max_length); - - /// - /// The of the last object hit before the one being considered. - /// - private HitType? previousHitType; - - /// - /// Length of the current mono pattern. - /// - private int currentMonoLength; + // This is set to decay slower than other skills, due to the fact that only the first note of each encoding class + // having any difficulty values, and we want to allow colour difficulty to be able to build up even on + // slower maps. + protected override double StrainDecayBase => 0.8; public Colour(Mod[] mods) : base(mods) @@ -49,95 +27,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Skills protected override double StrainValueOf(DifficultyHitObject current) { - // changing from/to a drum roll or a swell does not constitute a colour change. - // hits spaced more than a second apart are also exempt from colour strain. - if (!(current.LastObject is Hit && current.BaseObject is Hit && current.DeltaTime < 1000)) - { - monoHistory.Clear(); - - var currentHit = current.BaseObject as Hit; - currentMonoLength = currentHit != null ? 1 : 0; - previousHitType = currentHit?.Type; - - return 0.0; - } - - var taikoCurrent = (TaikoDifficultyHitObject)current; - - double objectStrain = 0.0; - - if (previousHitType != null && taikoCurrent.HitType != previousHitType) - { - // The colour has changed. - objectStrain = 1.0; - - if (monoHistory.Count < 2) - { - // There needs to be at least two streaks to determine a strain. - objectStrain = 0.0; - } - else if ((monoHistory[^1] + currentMonoLength) % 2 == 0) - { - // The last streak in the history is guaranteed to be a different type to the current streak. - // If the total number of notes in the two streaks is even, nullify this object's strain. - objectStrain = 0.0; - } - - objectStrain *= repetitionPenalties(); - currentMonoLength = 1; - } - else - { - currentMonoLength += 1; - } - - previousHitType = taikoCurrent.HitType; - return objectStrain; + return ColourEvaluator.EvaluateDifficultyOf(current); } - - /// - /// The penalty to apply due to the length of repetition in colour streaks. - /// - private double repetitionPenalties() - { - const int most_recent_patterns_to_compare = 2; - double penalty = 1.0; - - monoHistory.Enqueue(currentMonoLength); - - for (int start = monoHistory.Count - most_recent_patterns_to_compare - 1; start >= 0; start--) - { - if (!isSamePattern(start, most_recent_patterns_to_compare)) - continue; - - int notesSince = 0; - for (int i = start; i < monoHistory.Count; i++) notesSince += monoHistory[i]; - penalty *= repetitionPenalty(notesSince); - break; - } - - return penalty; - } - - /// - /// Determines whether the last patterns have repeated in the history - /// of single-colour note sequences, starting from . - /// - private bool isSamePattern(int start, int mostRecentPatternsToCompare) - { - for (int i = 0; i < mostRecentPatternsToCompare; i++) - { - if (monoHistory[start + i] != monoHistory[monoHistory.Count - mostRecentPatternsToCompare + i]) - return false; - } - - return true; - } - - /// - /// Calculates the strain penalty for a colour pattern repetition. - /// - /// The number of notes since the last repetition of the pattern. - private double repetitionPenalty(int notesSince) => Math.Min(1.0, 0.032 * notesSince); } } diff --git a/osu.Game.Rulesets.Taiko/Difficulty/Skills/Peaks.cs b/osu.Game.Rulesets.Taiko/Difficulty/Skills/Peaks.cs new file mode 100644 index 0000000000..ec8e754c5c --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Difficulty/Skills/Peaks.cs @@ -0,0 +1,93 @@ +// 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 System.Linq; +using osu.Game.Rulesets.Difficulty.Preprocessing; +using osu.Game.Rulesets.Difficulty.Skills; +using osu.Game.Rulesets.Mods; + +namespace osu.Game.Rulesets.Taiko.Difficulty.Skills +{ + public class Peaks : Skill + { + private const double rhythm_skill_multiplier = 0.2 * final_multiplier; + private const double colour_skill_multiplier = 0.375 * final_multiplier; + private const double stamina_skill_multiplier = 0.375 * final_multiplier; + + private const double final_multiplier = 0.0625; + + private readonly Rhythm rhythm; + private readonly Colour colour; + private readonly Stamina stamina; + + public double ColourDifficultyValue => colour.DifficultyValue() * colour_skill_multiplier; + public double RhythmDifficultyValue => rhythm.DifficultyValue() * rhythm_skill_multiplier; + public double StaminaDifficultyValue => stamina.DifficultyValue() * stamina_skill_multiplier; + + public Peaks(Mod[] mods) + : base(mods) + { + rhythm = new Rhythm(mods); + colour = new Colour(mods); + stamina = new Stamina(mods); + } + + /// + /// Returns the p-norm of an n-dimensional vector. + /// + /// The value of p to calculate the norm for. + /// The coefficients of the vector. + private double norm(double p, params double[] values) => Math.Pow(values.Sum(x => Math.Pow(x, p)), 1 / p); + + public override void Process(DifficultyHitObject current) + { + rhythm.Process(current); + colour.Process(current); + stamina.Process(current); + } + + /// + /// Returns the combined star rating of the beatmap, calculated using peak strains from all sections of the map. + /// + /// + /// For each section, the peak strains of all separate skills are combined into a single peak strain for the section. + /// The resulting partial rating of the beatmap is a weighted sum of the combined peaks (higher peaks are weighted more). + /// + public override double DifficultyValue() + { + List peaks = new List(); + + var colourPeaks = colour.GetCurrentStrainPeaks().ToList(); + var rhythmPeaks = rhythm.GetCurrentStrainPeaks().ToList(); + var staminaPeaks = stamina.GetCurrentStrainPeaks().ToList(); + + for (int i = 0; i < colourPeaks.Count; i++) + { + double colourPeak = colourPeaks[i] * colour_skill_multiplier; + double rhythmPeak = rhythmPeaks[i] * rhythm_skill_multiplier; + double staminaPeak = staminaPeaks[i] * stamina_skill_multiplier; + + double peak = norm(1.5, colourPeak, staminaPeak); + peak = norm(2, peak, rhythmPeak); + + // Sections with 0 strain are excluded to avoid worst-case time complexity of the following sort (e.g. /b/2351871). + // These sections will not contribute to the difficulty. + if (peak > 0) + peaks.Add(peak); + } + + double difficulty = 0; + double weight = 1; + + foreach (double strain in peaks.OrderByDescending(d => d)) + { + difficulty += strain * weight; + weight *= 0.9; + } + + return difficulty; + } + } +} diff --git a/osu.Game.Rulesets.Taiko/Difficulty/Skills/SingleKeyStamina.cs b/osu.Game.Rulesets.Taiko/Difficulty/Skills/SingleKeyStamina.cs deleted file mode 100644 index c2e1dd4f82..0000000000 --- a/osu.Game.Rulesets.Taiko/Difficulty/Skills/SingleKeyStamina.cs +++ /dev/null @@ -1,44 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -#nullable disable - -using osu.Game.Rulesets.Difficulty.Preprocessing; -using osu.Game.Rulesets.Difficulty.Skills; - -namespace osu.Game.Rulesets.Taiko.Difficulty.Skills -{ - /// - /// Stamina of a single key, calculated based on repetition speed. - /// - public class SingleKeyStamina - { - private double? previousHitTime; - - /// - /// Similar to - /// - public double StrainValueOf(DifficultyHitObject current) - { - if (previousHitTime == null) - { - previousHitTime = current.StartTime; - return 0; - } - - double objectStrain = 0.5; - objectStrain += speedBonus(current.StartTime - previousHitTime.Value); - previousHitTime = current.StartTime; - return objectStrain; - } - - /// - /// Applies a speed bonus dependent on the time since the last hit performed using this key. - /// - /// The duration between the current and previous note hit using the same key. - private double speedBonus(double notePairDuration) - { - return 175 / (notePairDuration + 100); - } - } -} diff --git a/osu.Game.Rulesets.Taiko/Difficulty/Skills/Stamina.cs b/osu.Game.Rulesets.Taiko/Difficulty/Skills/Stamina.cs index 67b628a814..344004bcf6 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/Skills/Stamina.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/Skills/Stamina.cs @@ -6,8 +6,7 @@ using osu.Game.Rulesets.Difficulty.Preprocessing; using osu.Game.Rulesets.Difficulty.Skills; using osu.Game.Rulesets.Mods; -using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing; -using osu.Game.Rulesets.Taiko.Objects; +using osu.Game.Rulesets.Taiko.Difficulty.Evaluators; namespace osu.Game.Rulesets.Taiko.Difficulty.Skills { @@ -19,31 +18,9 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Skills /// public class Stamina : StrainDecaySkill { - protected override double SkillMultiplier => 1; + protected override double SkillMultiplier => 1.1; protected override double StrainDecayBase => 0.4; - private readonly SingleKeyStamina[] centreKeyStamina = - { - new SingleKeyStamina(), - new SingleKeyStamina() - }; - - private readonly SingleKeyStamina[] rimKeyStamina = - { - new SingleKeyStamina(), - new SingleKeyStamina() - }; - - /// - /// Current index into for a centre hit. - /// - private int centreKeyIndex; - - /// - /// Current index into for a rim hit. - /// - private int rimKeyIndex; - /// /// Creates a skill. /// @@ -53,32 +30,9 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Skills { } - /// - /// Get the next to use for the given . - /// - /// The current . - private SingleKeyStamina getNextSingleKeyStamina(TaikoDifficultyHitObject current) - { - // Alternate key for the same color. - if (current.HitType == HitType.Centre) - { - centreKeyIndex = (centreKeyIndex + 1) % 2; - return centreKeyStamina[centreKeyIndex]; - } - - rimKeyIndex = (rimKeyIndex + 1) % 2; - return rimKeyStamina[rimKeyIndex]; - } - protected override double StrainValueOf(DifficultyHitObject current) { - if (!(current.BaseObject is Hit)) - { - return 0.0; - } - - TaikoDifficultyHitObject hitObject = (TaikoDifficultyHitObject)current; - return getNextSingleKeyStamina(hitObject).StrainValueOf(hitObject); + return StaminaEvaluator.EvaluateDifficultyOf(current); } } } diff --git a/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyAttributes.cs b/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyAttributes.cs index 380ab4a4fc..72452e27b3 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyAttributes.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyAttributes.cs @@ -29,13 +29,10 @@ namespace osu.Game.Rulesets.Taiko.Difficulty public double ColourDifficulty { get; set; } /// - /// The perceived approach rate inclusive of rate-adjusting mods (DT/HT/etc). + /// The difficulty corresponding to the hardest parts of the map. /// - /// - /// Rate-adjusting mods don't directly affect the approach rate difficulty value, but have a perceived effect as a result of adjusting audio timing. - /// - [JsonProperty("approach_rate")] - public double ApproachRate { get; set; } + [JsonProperty("peak_difficulty")] + public double PeakDifficulty { get; set; } /// /// The perceived hit window for a GREAT hit inclusive of rate-adjusting mods (DT/HT/etc). diff --git a/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs b/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs index 9267d1ee3c..ea2f04a3d9 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs @@ -13,6 +13,7 @@ using osu.Game.Rulesets.Difficulty.Skills; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing; +using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Colour; using osu.Game.Rulesets.Taiko.Difficulty.Skills; using osu.Game.Rulesets.Taiko.Mods; using osu.Game.Rulesets.Taiko.Objects; @@ -22,9 +23,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty { public class TaikoDifficultyCalculator : DifficultyCalculator { - private const double rhythm_skill_multiplier = 0.014; - private const double colour_skill_multiplier = 0.01; - private const double stamina_skill_multiplier = 0.021; + private const double difficulty_multiplier = 1.35; public override int Version => 20220701; @@ -33,12 +32,13 @@ namespace osu.Game.Rulesets.Taiko.Difficulty { } - protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clockRate) => new Skill[] + protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clockRate) { - new Colour(mods), - new Rhythm(mods), - new Stamina(mods) - }; + return new Skill[] + { + new Peaks(mods) + }; + } protected override Mod[] DifficultyAdjustmentMods => new Mod[] { @@ -50,18 +50,23 @@ namespace osu.Game.Rulesets.Taiko.Difficulty protected override IEnumerable CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate) { - List taikoDifficultyHitObjects = new List(); + List difficultyHitObjects = new List(); + List centreObjects = new List(); + List rimObjects = new List(); + List noteObjects = new List(); for (int i = 2; i < beatmap.HitObjects.Count; i++) { - taikoDifficultyHitObjects.Add( + difficultyHitObjects.Add( new TaikoDifficultyHitObject( - beatmap.HitObjects[i], beatmap.HitObjects[i - 1], beatmap.HitObjects[i - 2], clockRate, taikoDifficultyHitObjects, taikoDifficultyHitObjects.Count - ) + beatmap.HitObjects[i], beatmap.HitObjects[i - 1], beatmap.HitObjects[i - 2], clockRate, difficultyHitObjects, + centreObjects, rimObjects, noteObjects, difficultyHitObjects.Count) ); } - return taikoDifficultyHitObjects; + TaikoColourDifficultyPreprocessor.ProcessAndAssign(difficultyHitObjects); + + return difficultyHitObjects; } protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills, double clockRate) @@ -69,28 +74,24 @@ namespace osu.Game.Rulesets.Taiko.Difficulty if (beatmap.HitObjects.Count == 0) return new TaikoDifficultyAttributes { Mods = mods }; - var colour = (Colour)skills[0]; - var rhythm = (Rhythm)skills[1]; - var stamina = (Stamina)skills[2]; + var combined = (Peaks)skills[0]; - double colourRating = colour.DifficultyValue() * colour_skill_multiplier; - double rhythmRating = rhythm.DifficultyValue() * rhythm_skill_multiplier; - double staminaRating = stamina.DifficultyValue() * stamina_skill_multiplier; + double colourRating = combined.ColourDifficultyValue * difficulty_multiplier; + double rhythmRating = combined.RhythmDifficultyValue * difficulty_multiplier; + double staminaRating = combined.StaminaDifficultyValue * difficulty_multiplier; - double staminaPenalty = simpleColourPenalty(staminaRating, colourRating); - staminaRating *= staminaPenalty; + double combinedRating = combined.DifficultyValue() * difficulty_multiplier; + double starRating = rescale(combinedRating * 1.4); - //TODO : This is a temporary fix for the stamina rating of converts, due to their low colour variance. - if (beatmap.BeatmapInfo.Ruleset.OnlineID == 0 && colourRating < 0.05) + // TODO: This is temporary measure as we don't detect abuse of multiple-input playstyles of converts within the current system. + if (beatmap.BeatmapInfo.Ruleset.OnlineID == 0) { - staminaPenalty *= 0.25; + starRating *= 0.925; + // For maps with low colour variance and high stamina requirement, multiple inputs are more likely to be abused. + if (colourRating < 2 && staminaRating > 8) + starRating *= 0.80; } - double combinedRating = locallyCombinedDifficulty(colour, rhythm, stamina, staminaPenalty); - double separatedRating = norm(1.5, colourRating, rhythmRating, staminaRating); - double starRating = 1.4 * separatedRating + 0.5 * combinedRating; - starRating = rescale(starRating); - HitWindows hitWindows = new TaikoHitWindows(); hitWindows.SetDifficulty(beatmap.Difficulty.OverallDifficulty); @@ -101,75 +102,14 @@ namespace osu.Game.Rulesets.Taiko.Difficulty StaminaDifficulty = staminaRating, RhythmDifficulty = rhythmRating, ColourDifficulty = colourRating, + PeakDifficulty = combinedRating, GreatHitWindow = hitWindows.WindowFor(HitResult.Great) / clockRate, MaxCombo = beatmap.HitObjects.Count(h => h is Hit), }; } /// - /// Calculates the penalty for the stamina skill for maps with low colour difficulty. - /// - /// - /// Some maps (especially converts) can be easy to read despite a high note density. - /// This penalty aims to reduce the star rating of such maps by factoring in colour difficulty to the stamina skill. - /// - private double simpleColourPenalty(double staminaDifficulty, double colorDifficulty) - { - if (colorDifficulty <= 0) return 0.79 - 0.25; - - return 0.79 - Math.Atan(staminaDifficulty / colorDifficulty - 12) / Math.PI / 2; - } - - /// - /// Returns the p-norm of an n-dimensional vector. - /// - /// The value of p to calculate the norm for. - /// The coefficients of the vector. - private double norm(double p, params double[] values) => Math.Pow(values.Sum(x => Math.Pow(x, p)), 1 / p); - - /// - /// Returns the partial star rating of the beatmap, calculated using peak strains from all sections of the map. - /// - /// - /// For each section, the peak strains of all separate skills are combined into a single peak strain for the section. - /// The resulting partial rating of the beatmap is a weighted sum of the combined peaks (higher peaks are weighted more). - /// - private double locallyCombinedDifficulty(Colour colour, Rhythm rhythm, Stamina stamina, double staminaPenalty) - { - List peaks = new List(); - - var colourPeaks = colour.GetCurrentStrainPeaks().ToList(); - var rhythmPeaks = rhythm.GetCurrentStrainPeaks().ToList(); - var staminaPeaks = stamina.GetCurrentStrainPeaks().ToList(); - - for (int i = 0; i < colourPeaks.Count; i++) - { - double colourPeak = colourPeaks[i] * colour_skill_multiplier; - double rhythmPeak = rhythmPeaks[i] * rhythm_skill_multiplier; - double staminaPeak = staminaPeaks[i] * stamina_skill_multiplier * staminaPenalty; - - double peak = norm(2, colourPeak, rhythmPeak, staminaPeak); - - // Sections with 0 strain are excluded to avoid worst-case time complexity of the following sort (e.g. /b/2351871). - // These sections will not contribute to the difficulty. - if (peak > 0) - peaks.Add(peak); - } - - double difficulty = 0; - double weight = 1; - - foreach (double strain in peaks.OrderByDescending(d => d)) - { - difficulty += strain * weight; - weight *= 0.9; - } - - return difficulty; - } - - /// - /// Applies a final re-scaling of the star rating to bring maps with recorded full combos below 9.5 stars. + /// Applies a final re-scaling of the star rating. /// /// The raw star rating value before re-scaling. private double rescale(double sr) diff --git a/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceAttributes.cs b/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceAttributes.cs index 68d0038b24..b61c13a2df 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceAttributes.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceAttributes.cs @@ -17,6 +17,9 @@ namespace osu.Game.Rulesets.Taiko.Difficulty [JsonProperty("accuracy")] public double Accuracy { get; set; } + [JsonProperty("effective_miss_count")] + public double EffectiveMissCount { get; set; } + public override IEnumerable GetAttributesForDisplay() { foreach (var attribute in base.GetAttributesForDisplay()) diff --git a/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs b/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs index 2c2dbddf13..95a1e8bc66 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs @@ -21,6 +21,8 @@ namespace osu.Game.Rulesets.Taiko.Difficulty private int countMeh; private int countMiss; + private double effectiveMissCount; + public TaikoPerformanceCalculator() : base(new TaikoRuleset()) { @@ -35,7 +37,11 @@ namespace osu.Game.Rulesets.Taiko.Difficulty countMeh = score.Statistics.GetValueOrDefault(HitResult.Meh); countMiss = score.Statistics.GetValueOrDefault(HitResult.Miss); - double multiplier = 1.12; // This is being adjusted to keep the final pp value scaled around what it used to be when changing things + // The effectiveMissCount is calculated by gaining a ratio for totalSuccessfulHits and increasing the miss penalty for shorter object counts lower than 1000. + if (totalSuccessfulHits > 0) + effectiveMissCount = Math.Max(1.0, 1000.0 / totalSuccessfulHits) * countMiss; + + double multiplier = 1.13; if (score.Mods.Any(m => m is ModHidden)) multiplier *= 1.075; @@ -55,6 +61,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty { Difficulty = difficultyValue, Accuracy = accuracyValue, + EffectiveMissCount = effectiveMissCount, Total = totalValue }; } @@ -66,18 +73,21 @@ namespace osu.Game.Rulesets.Taiko.Difficulty double lengthBonus = 1 + 0.1 * Math.Min(1.0, totalHits / 1500.0); difficultyValue *= lengthBonus; - difficultyValue *= Math.Pow(0.986, countMiss); + difficultyValue *= Math.Pow(0.986, effectiveMissCount); if (score.Mods.Any(m => m is ModEasy)) - difficultyValue *= 0.980; + difficultyValue *= 0.985; if (score.Mods.Any(m => m is ModHidden)) difficultyValue *= 1.025; - if (score.Mods.Any(m => m is ModFlashlight)) - difficultyValue *= 1.05 * lengthBonus; + if (score.Mods.Any(m => m is ModHardRock)) + difficultyValue *= 1.050; - return difficultyValue * Math.Pow(score.Accuracy, 1.5); + if (score.Mods.Any(m => m is ModFlashlight)) + difficultyValue *= 1.050 * lengthBonus; + + return difficultyValue * Math.Pow(score.Accuracy, 2.0); } private double computeAccuracyValue(ScoreInfo score, TaikoDifficultyAttributes attributes) @@ -85,18 +95,20 @@ namespace osu.Game.Rulesets.Taiko.Difficulty if (attributes.GreatHitWindow <= 0) return 0; - double accuracyValue = Math.Pow(140.0 / attributes.GreatHitWindow, 1.1) * Math.Pow(score.Accuracy, 12.0) * 27; + double accuracyValue = Math.Pow(60.0 / attributes.GreatHitWindow, 1.1) * Math.Pow(score.Accuracy, 8.0) * Math.Pow(attributes.StarRating, 0.4) * 27.0; double lengthBonus = Math.Min(1.15, Math.Pow(totalHits / 1500.0, 0.3)); accuracyValue *= lengthBonus; - // Slight HDFL Bonus for accuracy. + // Slight HDFL Bonus for accuracy. A clamp is used to prevent against negative values if (score.Mods.Any(m => m is ModFlashlight) && score.Mods.Any(m => m is ModHidden)) - accuracyValue *= 1.10 * lengthBonus; + accuracyValue *= Math.Max(1.050, 1.075 * lengthBonus); return accuracyValue; } private int totalHits => countGreat + countOk + countMeh + countMiss; + + private int totalSuccessfulHits => countGreat + countOk + countMeh; } } diff --git a/osu.Game.Rulesets.Taiko/Edit/Blueprints/TaikoSpanPlacementBlueprint.cs b/osu.Game.Rulesets.Taiko/Edit/Blueprints/TaikoSpanPlacementBlueprint.cs index e49043e58e..23a005190a 100644 --- a/osu.Game.Rulesets.Taiko/Edit/Blueprints/TaikoSpanPlacementBlueprint.cs +++ b/osu.Game.Rulesets.Taiko/Edit/Blueprints/TaikoSpanPlacementBlueprint.cs @@ -67,7 +67,7 @@ namespace osu.Game.Rulesets.Taiko.Edit.Blueprints return; base.OnMouseUp(e); - EndPlacement(true); + EndPlacement(spanPlacementObject.Duration > 0); } public override void UpdateTimeAndPosition(SnapResult result) diff --git a/osu.Game.Rulesets.Taiko/Mods/TaikoModEasy.cs b/osu.Game.Rulesets.Taiko/Mods/TaikoModEasy.cs index ad6fdf59e2..009f2854f8 100644 --- a/osu.Game.Rulesets.Taiko/Mods/TaikoModEasy.cs +++ b/osu.Game.Rulesets.Taiko/Mods/TaikoModEasy.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Framework.Localisation; using osu.Game.Beatmaps; using osu.Game.Rulesets.Mods; @@ -8,7 +9,7 @@ namespace osu.Game.Rulesets.Taiko.Mods { public class TaikoModEasy : ModEasy { - public override string Description => @"Beats move slower, and less accuracy required!"; + public override LocalisableString Description => @"Beats move slower, and less accuracy required!"; /// /// Multiplier factor added to the scrolling speed. diff --git a/osu.Game.Rulesets.Taiko/Mods/TaikoModHidden.cs b/osu.Game.Rulesets.Taiko/Mods/TaikoModHidden.cs index 4c802978e3..4708ef9bf0 100644 --- a/osu.Game.Rulesets.Taiko/Mods/TaikoModHidden.cs +++ b/osu.Game.Rulesets.Taiko/Mods/TaikoModHidden.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Graphics; +using osu.Framework.Localisation; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Drawables; @@ -15,7 +16,7 @@ namespace osu.Game.Rulesets.Taiko.Mods { public class TaikoModHidden : ModHidden, IApplicableToDrawableRuleset { - public override string Description => @"Beats fade out before you hit them!"; + public override LocalisableString Description => @"Beats fade out before you hit them!"; public override double ScoreMultiplier => UsesDefaultConfiguration ? 1.06 : 1; /// diff --git a/osu.Game.Rulesets.Taiko/Mods/TaikoModRandom.cs b/osu.Game.Rulesets.Taiko/Mods/TaikoModRandom.cs index 307a37bf2e..c0be0290e6 100644 --- a/osu.Game.Rulesets.Taiko/Mods/TaikoModRandom.cs +++ b/osu.Game.Rulesets.Taiko/Mods/TaikoModRandom.cs @@ -3,6 +3,7 @@ using System; using System.Linq; +using osu.Framework.Localisation; using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Rulesets.Mods; @@ -13,7 +14,7 @@ namespace osu.Game.Rulesets.Taiko.Mods { public class TaikoModRandom : ModRandom, IApplicableToBeatmap { - public override string Description => @"Shuffle around the colours!"; + public override LocalisableString Description => @"Shuffle around the colours!"; public override Type[] IncompatibleMods => base.IncompatibleMods.Append(typeof(TaikoModSwap)).ToArray(); public void ApplyToBeatmap(IBeatmap beatmap) diff --git a/osu.Game.Rulesets.Taiko/Mods/TaikoModRelax.cs b/osu.Game.Rulesets.Taiko/Mods/TaikoModRelax.cs index 7be70d9ac3..d1e9ab1428 100644 --- a/osu.Game.Rulesets.Taiko/Mods/TaikoModRelax.cs +++ b/osu.Game.Rulesets.Taiko/Mods/TaikoModRelax.cs @@ -1,12 +1,13 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Framework.Localisation; using osu.Game.Rulesets.Mods; namespace osu.Game.Rulesets.Taiko.Mods { public class TaikoModRelax : ModRelax { - public override string Description => @"No ninja-like spinners, demanding drumrolls or unexpected katu's."; + public override LocalisableString Description => @"No ninja-like spinners, demanding drumrolls or unexpected katu's."; } } diff --git a/osu.Game.Rulesets.Taiko/Mods/TaikoModSwap.cs b/osu.Game.Rulesets.Taiko/Mods/TaikoModSwap.cs index 3cb337c41d..fc3913f56d 100644 --- a/osu.Game.Rulesets.Taiko/Mods/TaikoModSwap.cs +++ b/osu.Game.Rulesets.Taiko/Mods/TaikoModSwap.cs @@ -3,6 +3,7 @@ using System; using System.Linq; +using osu.Framework.Localisation; using osu.Game.Beatmaps; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Taiko.Beatmaps; @@ -14,7 +15,7 @@ namespace osu.Game.Rulesets.Taiko.Mods { public override string Name => "Swap"; public override string Acronym => "SW"; - public override string Description => @"Dons become kats, kats become dons"; + public override LocalisableString Description => @"Dons become kats, kats become dons"; public override ModType Type => ModType.Conversion; public override double ScoreMultiplier => 1; public override Type[] IncompatibleMods => base.IncompatibleMods.Append(typeof(ModRandom)).ToArray(); diff --git a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableDrumRoll.cs b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableDrumRoll.cs index 04ed6d0b87..68e334332e 100644 --- a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableDrumRoll.cs +++ b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableDrumRoll.cs @@ -13,6 +13,7 @@ using osu.Game.Rulesets.Objects.Drawables; using osuTK.Graphics; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Primitives; using osu.Framework.Input.Events; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Objects; @@ -30,6 +31,8 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables /// private const int rolling_hits_for_engaged_colour = 5; + public override Quad ScreenSpaceDrawQuad => MainPiece.Drawable.ScreenSpaceDrawQuad; + /// /// Rolling number of tick hits. This increases for hits and decreases for misses. /// diff --git a/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyCirclePiece.cs b/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyCirclePiece.cs index 7f813e7b27..399bd9260d 100644 --- a/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyCirclePiece.cs +++ b/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyCirclePiece.cs @@ -7,6 +7,7 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Animations; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Primitives; using osu.Game.Graphics; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Taiko.Objects; @@ -20,6 +21,9 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Legacy { private Drawable backgroundLayer; + // required for editor blueprints (not sure why these circle pieces are zero size). + public override Quad ScreenSpaceDrawQuad => backgroundLayer.ScreenSpaceDrawQuad; + public LegacyCirclePiece() { RelativeSizeAxes = Axes.Both; diff --git a/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyDrumRoll.cs b/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyDrumRoll.cs index d3bf70e603..040d8ff965 100644 --- a/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyDrumRoll.cs +++ b/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyDrumRoll.cs @@ -6,6 +6,7 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Primitives; using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Textures; using osu.Game.Graphics; @@ -16,11 +17,22 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Legacy { public class LegacyDrumRoll : CompositeDrawable, IHasAccentColour { + public override Quad ScreenSpaceDrawQuad + { + get + { + var headDrawQuad = headCircle.ScreenSpaceDrawQuad; + var tailDrawQuad = tailCircle.ScreenSpaceDrawQuad; + + return new Quad(headDrawQuad.TopLeft, tailDrawQuad.TopRight, headDrawQuad.BottomLeft, tailDrawQuad.BottomRight); + } + } + private LegacyCirclePiece headCircle; private Sprite body; - private Sprite end; + private Sprite tailCircle; public LegacyDrumRoll() { @@ -32,7 +44,7 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Legacy { InternalChildren = new Drawable[] { - end = new Sprite + tailCircle = new Sprite { Anchor = Anchor.CentreRight, Origin = Anchor.CentreLeft, @@ -82,7 +94,7 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Legacy headCircle.AccentColour = colour; body.Colour = colour; - end.Colour = colour; + tailCircle.Colour = colour; } } } diff --git a/osu.Game.Rulesets.Taiko/TaikoRuleset.cs b/osu.Game.Rulesets.Taiko/TaikoRuleset.cs index 223e268d7f..04bb08395b 100644 --- a/osu.Game.Rulesets.Taiko/TaikoRuleset.cs +++ b/osu.Game.Rulesets.Taiko/TaikoRuleset.cs @@ -25,6 +25,7 @@ using osu.Game.Scoring; using System; using System.Linq; using osu.Framework.Extensions.EnumExtensions; +using osu.Framework.Localisation; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Taiko.Edit; using osu.Game.Rulesets.Taiko.Objects; @@ -192,7 +193,7 @@ namespace osu.Game.Rulesets.Taiko }; } - public override string GetDisplayNameForHitResult(HitResult result) + public override LocalisableString GetDisplayNameForHitResult(HitResult result) { switch (result) { diff --git a/osu.Game.Tests/Mods/ModUtilsTest.cs b/osu.Game.Tests/Mods/ModUtilsTest.cs index 3b391f6756..aa41fd830b 100644 --- a/osu.Game.Tests/Mods/ModUtilsTest.cs +++ b/osu.Game.Tests/Mods/ModUtilsTest.cs @@ -5,6 +5,7 @@ using System; using System.Linq; using Moq; using NUnit.Framework; +using osu.Framework.Localisation; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu.Mods; using osu.Game.Utils; @@ -320,7 +321,7 @@ namespace osu.Game.Tests.Mods public class InvalidMultiplayerMod : Mod { public override string Name => string.Empty; - public override string Description => string.Empty; + public override LocalisableString Description => string.Empty; public override string Acronym => string.Empty; public override double ScoreMultiplier => 1; public override bool HasImplementation => true; @@ -331,7 +332,7 @@ namespace osu.Game.Tests.Mods private class InvalidMultiplayerFreeMod : Mod { public override string Name => string.Empty; - public override string Description => string.Empty; + public override LocalisableString Description => string.Empty; public override string Acronym => string.Empty; public override double ScoreMultiplier => 1; public override bool HasImplementation => true; diff --git a/osu.Game.Tests/Mods/TestCustomisableModRuleset.cs b/osu.Game.Tests/Mods/TestCustomisableModRuleset.cs index 9e3354935a..7df5448ff7 100644 --- a/osu.Game.Tests/Mods/TestCustomisableModRuleset.cs +++ b/osu.Game.Tests/Mods/TestCustomisableModRuleset.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using osu.Framework.Bindables; +using osu.Framework.Localisation; using osu.Game.Beatmaps; using osu.Game.Configuration; using osu.Game.Rulesets; @@ -60,7 +61,7 @@ namespace osu.Game.Tests.Mods { public override double ScoreMultiplier => 1.0; - public override string Description => "This is a customisable test mod."; + public override LocalisableString Description => "This is a customisable test mod."; public override ModType Type => ModType.Conversion; diff --git a/osu.Game.Tests/NonVisual/DifficultyAdjustmentModCombinationsTest.cs b/osu.Game.Tests/NonVisual/DifficultyAdjustmentModCombinationsTest.cs index 8d1c266473..6637d640b2 100644 --- a/osu.Game.Tests/NonVisual/DifficultyAdjustmentModCombinationsTest.cs +++ b/osu.Game.Tests/NonVisual/DifficultyAdjustmentModCombinationsTest.cs @@ -7,6 +7,7 @@ using System; using System.Collections.Generic; using System.Linq; using NUnit.Framework; +using osu.Framework.Localisation; using osu.Game.Beatmaps; using osu.Game.Rulesets.Difficulty; using osu.Game.Rulesets.Difficulty.Preprocessing; @@ -160,7 +161,7 @@ namespace osu.Game.Tests.NonVisual { public override string Name => nameof(ModA); public override string Acronym => nameof(ModA); - public override string Description => string.Empty; + public override LocalisableString Description => string.Empty; public override double ScoreMultiplier => 1; public override Type[] IncompatibleMods => new[] { typeof(ModIncompatibleWithA), typeof(ModIncompatibleWithAAndB) }; @@ -169,7 +170,7 @@ namespace osu.Game.Tests.NonVisual private class ModB : Mod { public override string Name => nameof(ModB); - public override string Description => string.Empty; + public override LocalisableString Description => string.Empty; public override string Acronym => nameof(ModB); public override double ScoreMultiplier => 1; @@ -180,7 +181,7 @@ namespace osu.Game.Tests.NonVisual { public override string Name => nameof(ModC); public override string Acronym => nameof(ModC); - public override string Description => string.Empty; + public override LocalisableString Description => string.Empty; public override double ScoreMultiplier => 1; } @@ -188,7 +189,7 @@ namespace osu.Game.Tests.NonVisual { public override string Name => $"Incompatible With {nameof(ModA)}"; public override string Acronym => $"Incompatible With {nameof(ModA)}"; - public override string Description => string.Empty; + public override LocalisableString Description => string.Empty; public override double ScoreMultiplier => 1; public override Type[] IncompatibleMods => new[] { typeof(ModA) }; @@ -207,7 +208,7 @@ namespace osu.Game.Tests.NonVisual { public override string Name => $"Incompatible With {nameof(ModA)} and {nameof(ModB)}"; public override string Acronym => $"Incompatible With {nameof(ModA)} and {nameof(ModB)}"; - public override string Description => string.Empty; + public override LocalisableString Description => string.Empty; public override double ScoreMultiplier => 1; public override Type[] IncompatibleMods => new[] { typeof(ModA), typeof(ModB) }; diff --git a/osu.Game.Tests/NonVisual/GameplayClockTest.cs b/osu.Game.Tests/NonVisual/GameplayClockContainerTest.cs similarity index 59% rename from osu.Game.Tests/NonVisual/GameplayClockTest.cs rename to osu.Game.Tests/NonVisual/GameplayClockContainerTest.cs index 162734f9da..f9f4ead644 100644 --- a/osu.Game.Tests/NonVisual/GameplayClockTest.cs +++ b/osu.Game.Tests/NonVisual/GameplayClockContainerTest.cs @@ -1,39 +1,31 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System.Collections.Generic; -using System.Linq; using NUnit.Framework; -using osu.Framework.Bindables; using osu.Framework.Timing; using osu.Game.Screens.Play; namespace osu.Game.Tests.NonVisual { [TestFixture] - public class GameplayClockTest + public class GameplayClockContainerTest { [TestCase(0)] [TestCase(1)] public void TestTrueGameplayRateWithZeroAdjustment(double underlyingClockRate) { var framedClock = new FramedClock(new ManualClock { Rate = underlyingClockRate }); - var gameplayClock = new TestGameplayClock(framedClock); - - gameplayClock.MutableNonGameplayAdjustments.Add(new BindableDouble()); + var gameplayClock = new TestGameplayClockContainer(framedClock); Assert.That(gameplayClock.TrueGameplayRate, Is.EqualTo(0)); } - private class TestGameplayClock : GameplayClock + private class TestGameplayClockContainer : GameplayClockContainer { - public List> MutableNonGameplayAdjustments { get; } = new List>(); + public override IEnumerable NonGameplayAdjustments => new[] { 0.0 }; - public override IEnumerable NonGameplayAdjustments => MutableNonGameplayAdjustments.Select(b => b.Value); - - public TestGameplayClock(IFrameBasedClock underlyingClock) + public TestGameplayClockContainer(IFrameBasedClock underlyingClock) : base(underlyingClock) { } diff --git a/osu.Game.Tests/Online/TestAPIModJsonSerialization.cs b/osu.Game.Tests/Online/TestAPIModJsonSerialization.cs index 7458508c7a..17709fb10f 100644 --- a/osu.Game.Tests/Online/TestAPIModJsonSerialization.cs +++ b/osu.Game.Tests/Online/TestAPIModJsonSerialization.cs @@ -9,6 +9,7 @@ using Newtonsoft.Json; using Newtonsoft.Json.Linq; using NUnit.Framework; using osu.Framework.Bindables; +using osu.Framework.Localisation; using osu.Game.Beatmaps; using osu.Game.Configuration; using osu.Game.Online.API; @@ -182,7 +183,7 @@ namespace osu.Game.Tests.Online { public override string Name => "Test Mod"; public override string Acronym => "TM"; - public override string Description => "This is a test mod."; + public override LocalisableString Description => "This is a test mod."; public override double ScoreMultiplier => 1; [SettingSource("Test")] @@ -199,7 +200,7 @@ namespace osu.Game.Tests.Online { public override string Name => "Test Mod"; public override string Acronym => "TMTR"; - public override string Description => "This is a test mod."; + public override LocalisableString Description => "This is a test mod."; public override double ScoreMultiplier => 1; [SettingSource("Initial rate", "The starting speed of the track")] diff --git a/osu.Game.Tests/Online/TestAPIModMessagePackSerialization.cs b/osu.Game.Tests/Online/TestAPIModMessagePackSerialization.cs index a89d68bf15..b17414e026 100644 --- a/osu.Game.Tests/Online/TestAPIModMessagePackSerialization.cs +++ b/osu.Game.Tests/Online/TestAPIModMessagePackSerialization.cs @@ -7,6 +7,7 @@ using System.Collections.Generic; using MessagePack; using NUnit.Framework; using osu.Framework.Bindables; +using osu.Framework.Localisation; using osu.Game.Beatmaps; using osu.Game.Configuration; using osu.Game.Online.API; @@ -102,7 +103,7 @@ namespace osu.Game.Tests.Online { public override string Name => "Test Mod"; public override string Acronym => "TM"; - public override string Description => "This is a test mod."; + public override LocalisableString Description => "This is a test mod."; public override double ScoreMultiplier => 1; [SettingSource("Test")] @@ -119,7 +120,7 @@ namespace osu.Game.Tests.Online { public override string Name => "Test Mod"; public override string Acronym => "TMTR"; - public override string Description => "This is a test mod."; + public override LocalisableString Description => "This is a test mod."; public override double ScoreMultiplier => 1; [SettingSource("Initial rate", "The starting speed of the track")] @@ -154,7 +155,7 @@ namespace osu.Game.Tests.Online { public override string Name => "Test Mod"; public override string Acronym => "TM"; - public override string Description => "This is a test mod."; + public override LocalisableString Description => "This is a test mod."; public override double ScoreMultiplier => 1; [SettingSource("Test")] diff --git a/osu.Game.Tests/OnlinePlay/TestSceneCatchUpSyncManager.cs b/osu.Game.Tests/OnlinePlay/TestSceneCatchUpSyncManager.cs index 6c639ee539..6015c92663 100644 --- a/osu.Game.Tests/OnlinePlay/TestSceneCatchUpSyncManager.cs +++ b/osu.Game.Tests/OnlinePlay/TestSceneCatchUpSyncManager.cs @@ -4,11 +4,13 @@ #nullable disable using System; +using System.Collections.Generic; using NUnit.Framework; -using osu.Framework.Bindables; +using osu.Framework.Graphics; using osu.Framework.Testing; using osu.Framework.Timing; using osu.Game.Screens.OnlinePlay.Multiplayer.Spectate; +using osu.Game.Screens.Play; using osu.Game.Tests.Visual; namespace osu.Game.Tests.OnlinePlay @@ -16,20 +18,34 @@ namespace osu.Game.Tests.OnlinePlay [HeadlessTest] public class TestSceneCatchUpSyncManager : OsuTestScene { - private TestManualClock master; - private CatchUpSyncManager syncManager; + private GameplayClockContainer master; + private SpectatorSyncManager syncManager; - private TestSpectatorPlayerClock player1; - private TestSpectatorPlayerClock player2; + private Dictionary clocksById; + private SpectatorPlayerClock player1; + private SpectatorPlayerClock player2; [SetUp] public void Setup() { - syncManager = new CatchUpSyncManager(master = new TestManualClock()); - syncManager.AddPlayerClock(player1 = new TestSpectatorPlayerClock(1)); - syncManager.AddPlayerClock(player2 = new TestSpectatorPlayerClock(2)); + syncManager = new SpectatorSyncManager(master = new GameplayClockContainer(new TestManualClock())); + player1 = syncManager.CreateManagedClock(); + player2 = syncManager.CreateManagedClock(); - Schedule(() => Child = syncManager); + clocksById = new Dictionary + { + { player1, 1 }, + { player2, 2 } + }; + + Schedule(() => + { + Children = new Drawable[] + { + syncManager, + master + }; + }); } [Test] @@ -48,7 +64,7 @@ namespace osu.Game.Tests.OnlinePlay public void TestReadyPlayersStartWhenReadyForMaximumDelayTime() { setWaiting(() => player1, false); - AddWaitStep($"wait {CatchUpSyncManager.MAXIMUM_START_DELAY} milliseconds", (int)Math.Ceiling(CatchUpSyncManager.MAXIMUM_START_DELAY / TimePerAction)); + AddWaitStep($"wait {SpectatorSyncManager.MAXIMUM_START_DELAY} milliseconds", (int)Math.Ceiling(SpectatorSyncManager.MAXIMUM_START_DELAY / TimePerAction)); assertPlayerClockState(() => player1, true); assertPlayerClockState(() => player2, false); } @@ -58,7 +74,7 @@ namespace osu.Game.Tests.OnlinePlay { setAllWaiting(false); - setMasterTime(CatchUpSyncManager.SYNC_TARGET + 1); + setMasterTime(SpectatorSyncManager.SYNC_TARGET + 1); assertCatchingUp(() => player1, false); } @@ -67,7 +83,7 @@ namespace osu.Game.Tests.OnlinePlay { setAllWaiting(false); - setMasterTime(CatchUpSyncManager.MAX_SYNC_OFFSET + 1); + setMasterTime(SpectatorSyncManager.MAX_SYNC_OFFSET + 1); assertCatchingUp(() => player1, true); assertCatchingUp(() => player2, true); } @@ -77,8 +93,8 @@ namespace osu.Game.Tests.OnlinePlay { setAllWaiting(false); - setMasterTime(CatchUpSyncManager.MAX_SYNC_OFFSET + 1); - setPlayerClockTime(() => player1, CatchUpSyncManager.SYNC_TARGET + 1); + setMasterTime(SpectatorSyncManager.MAX_SYNC_OFFSET + 1); + setPlayerClockTime(() => player1, SpectatorSyncManager.SYNC_TARGET + 1); assertCatchingUp(() => player1, true); } @@ -87,8 +103,8 @@ namespace osu.Game.Tests.OnlinePlay { setAllWaiting(false); - setMasterTime(CatchUpSyncManager.MAX_SYNC_OFFSET + 2); - setPlayerClockTime(() => player1, CatchUpSyncManager.SYNC_TARGET); + setMasterTime(SpectatorSyncManager.MAX_SYNC_OFFSET + 2); + setPlayerClockTime(() => player1, SpectatorSyncManager.SYNC_TARGET); assertCatchingUp(() => player1, false); assertCatchingUp(() => player2, true); } @@ -98,7 +114,7 @@ namespace osu.Game.Tests.OnlinePlay { setAllWaiting(false); - setPlayerClockTime(() => player1, -CatchUpSyncManager.SYNC_TARGET); + setPlayerClockTime(() => player1, -SpectatorSyncManager.SYNC_TARGET); assertCatchingUp(() => player1, false); assertPlayerClockState(() => player1, true); } @@ -108,7 +124,7 @@ namespace osu.Game.Tests.OnlinePlay { setAllWaiting(false); - setPlayerClockTime(() => player1, -CatchUpSyncManager.SYNC_TARGET - 1); + setPlayerClockTime(() => player1, -SpectatorSyncManager.SYNC_TARGET - 1); // This is a silent catchup, where IsCatchingUp = false but IsRunning = false also. assertCatchingUp(() => player1, false); @@ -129,13 +145,13 @@ namespace osu.Game.Tests.OnlinePlay assertPlayerClockState(() => player1, false); } - private void setWaiting(Func playerClock, bool waiting) - => AddStep($"set player clock {playerClock().Id} waiting = {waiting}", () => playerClock().WaitingOnFrames.Value = waiting); + private void setWaiting(Func playerClock, bool waiting) + => AddStep($"set player clock {clocksById[playerClock()]} waiting = {waiting}", () => playerClock().WaitingOnFrames = waiting); private void setAllWaiting(bool waiting) => AddStep($"set all player clocks waiting = {waiting}", () => { - player1.WaitingOnFrames.Value = waiting; - player2.WaitingOnFrames.Value = waiting; + player1.WaitingOnFrames = waiting; + player2.WaitingOnFrames = waiting; }); private void setMasterTime(double time) @@ -144,51 +160,14 @@ namespace osu.Game.Tests.OnlinePlay /// /// clock.Time = master.Time - offsetFromMaster /// - private void setPlayerClockTime(Func playerClock, double offsetFromMaster) - => AddStep($"set player clock {playerClock().Id} = master - {offsetFromMaster}", () => playerClock().Seek(master.CurrentTime - offsetFromMaster)); + private void setPlayerClockTime(Func playerClock, double offsetFromMaster) + => AddStep($"set player clock {clocksById[playerClock()]} = master - {offsetFromMaster}", () => playerClock().Seek(master.CurrentTime - offsetFromMaster)); - private void assertCatchingUp(Func playerClock, bool catchingUp) => - AddAssert($"player clock {playerClock().Id} {(catchingUp ? "is" : "is not")} catching up", () => playerClock().IsCatchingUp == catchingUp); + private void assertCatchingUp(Func playerClock, bool catchingUp) => + AddAssert($"player clock {clocksById[playerClock()]} {(catchingUp ? "is" : "is not")} catching up", () => playerClock().IsCatchingUp == catchingUp); - private void assertPlayerClockState(Func playerClock, bool running) - => AddAssert($"player clock {playerClock().Id} {(running ? "is" : "is not")} running", () => playerClock().IsRunning == running); - - private class TestSpectatorPlayerClock : TestManualClock, ISpectatorPlayerClock - { - public Bindable WaitingOnFrames { get; } = new Bindable(true); - - public bool IsCatchingUp { get; set; } - - public IFrameBasedClock Source - { - set => throw new NotImplementedException(); - } - - public readonly int Id; - - public TestSpectatorPlayerClock(int id) - { - Id = id; - - WaitingOnFrames.BindValueChanged(waiting => - { - if (waiting.NewValue) - Stop(); - else - Start(); - }); - } - - public void ProcessFrame() - { - } - - public double ElapsedFrameTime => 0; - - public double FramesPerSecond => 0; - - public FrameTimeInfo TimeInfo => default; - } + private void assertPlayerClockState(Func playerClock, bool running) + => AddAssert($"player clock {clocksById[playerClock()]} {(running ? "is" : "is not")} running", () => playerClock().IsRunning == running); private class TestManualClock : ManualClock, IAdjustableClock { diff --git a/osu.Game.Tests/Rulesets/Scoring/ScoreProcessorTest.cs b/osu.Game.Tests/Rulesets/Scoring/ScoreProcessorTest.cs index ff282fff62..44ebdad2e4 100644 --- a/osu.Game.Tests/Rulesets/Scoring/ScoreProcessorTest.cs +++ b/osu.Game.Tests/Rulesets/Scoring/ScoreProcessorTest.cs @@ -3,6 +3,7 @@ #nullable disable +using System; using System.Collections.Generic; using System.Linq; using NUnit.Framework; @@ -314,15 +315,55 @@ namespace osu.Game.Tests.Rulesets.Scoring }), Is.EqualTo(expectedScore).Within(0.5d)); } +#pragma warning disable CS0618 + [Test] + public void TestLegacyComboIncrease() + { + Assert.That(HitResult.LegacyComboIncrease.IncreasesCombo(), Is.True); + Assert.That(HitResult.LegacyComboIncrease.BreaksCombo(), Is.False); + Assert.That(HitResult.LegacyComboIncrease.AffectsCombo(), Is.True); + Assert.That(HitResult.LegacyComboIncrease.AffectsAccuracy(), Is.False); + Assert.That(HitResult.LegacyComboIncrease.IsBasic(), Is.False); + Assert.That(HitResult.LegacyComboIncrease.IsTick(), Is.False); + Assert.That(HitResult.LegacyComboIncrease.IsBonus(), Is.False); + Assert.That(HitResult.LegacyComboIncrease.IsHit(), Is.True); + Assert.That(HitResult.LegacyComboIncrease.IsScorable(), Is.True); + Assert.That(HitResultExtensions.ALL_TYPES, Does.Not.Contain(HitResult.LegacyComboIncrease)); + + // Cannot be used to apply results. + Assert.Throws(() => scoreProcessor.ApplyBeatmap(new Beatmap + { + HitObjects = { new TestHitObject(HitResult.LegacyComboIncrease) } + })); + + ScoreInfo testScore = new ScoreInfo + { + MaxCombo = 1, + Statistics = new Dictionary + { + { HitResult.Great, 1 } + }, + MaximumStatistics = new Dictionary + { + { HitResult.Great, 1 }, + { HitResult.LegacyComboIncrease, 1 } + } + }; + + double totalScore = new TestScoreProcessor().ComputeFinalScore(ScoringMode.Standardised, testScore); + Assert.That(totalScore, Is.EqualTo(750_000)); // 500K from accuracy (100%), and 250K from combo (50%). + } +#pragma warning restore CS0618 + private class TestRuleset : Ruleset { - public override IEnumerable GetModsFor(ModType type) => throw new System.NotImplementedException(); + public override IEnumerable GetModsFor(ModType type) => throw new NotImplementedException(); - public override DrawableRuleset CreateDrawableRulesetWith(IBeatmap beatmap, IReadOnlyList mods = null) => throw new System.NotImplementedException(); + public override DrawableRuleset CreateDrawableRulesetWith(IBeatmap beatmap, IReadOnlyList mods = null) => throw new NotImplementedException(); - public override IBeatmapConverter CreateBeatmapConverter(IBeatmap beatmap) => throw new System.NotImplementedException(); + public override IBeatmapConverter CreateBeatmapConverter(IBeatmap beatmap) => throw new NotImplementedException(); - public override DifficultyCalculator CreateDifficultyCalculator(IWorkingBeatmap beatmap) => throw new System.NotImplementedException(); + public override DifficultyCalculator CreateDifficultyCalculator(IWorkingBeatmap beatmap) => throw new NotImplementedException(); public override string Description => string.Empty; public override string ShortName => string.Empty; @@ -352,5 +393,33 @@ namespace osu.Game.Tests.Rulesets.Scoring this.maxResult = maxResult; } } + + private class TestScoreProcessor : ScoreProcessor + { + protected override double DefaultAccuracyPortion => 0.5; + protected override double DefaultComboPortion => 0.5; + + public TestScoreProcessor() + : base(new TestRuleset()) + { + } + + // ReSharper disable once MemberHidesStaticFromOuterClass + private class TestRuleset : Ruleset + { + protected override IEnumerable GetValidHitResults() => new[] { HitResult.Great }; + + public override IEnumerable GetModsFor(ModType type) => throw new NotImplementedException(); + + public override DrawableRuleset CreateDrawableRulesetWith(IBeatmap beatmap, IReadOnlyList mods = null) => throw new NotImplementedException(); + + public override IBeatmapConverter CreateBeatmapConverter(IBeatmap beatmap) => throw new NotImplementedException(); + + public override DifficultyCalculator CreateDifficultyCalculator(IWorkingBeatmap beatmap) => throw new NotImplementedException(); + + public override string Description => string.Empty; + public override string ShortName => string.Empty; + } + } } } diff --git a/osu.Game.Tests/Visual/Editing/TestSceneEditorSeekSnapping.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorSeekSnapping.cs index d24baa6f63..2465512dae 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneEditorSeekSnapping.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneEditorSeekSnapping.cs @@ -173,6 +173,7 @@ namespace osu.Game.Tests.Visual.Editing reset(); AddStep("Seek(49)", () => Clock.Seek(49)); + checkTime(49); AddStep("SeekForward, Snap", () => Clock.SeekForward(true)); checkTime(50); AddStep("Seek(49.999)", () => Clock.Seek(49.999)); @@ -207,6 +208,7 @@ namespace osu.Game.Tests.Visual.Editing reset(); AddStep("Seek(450)", () => Clock.Seek(450)); + checkTime(450); AddStep("SeekBackward", () => Clock.SeekBackward()); checkTime(400); AddStep("SeekBackward", () => Clock.SeekBackward()); @@ -228,6 +230,7 @@ namespace osu.Game.Tests.Visual.Editing reset(); AddStep("Seek(450)", () => Clock.Seek(450)); + checkTime(450); AddStep("SeekBackward, Snap", () => Clock.SeekBackward(true)); checkTime(400); AddStep("SeekBackward, Snap", () => Clock.SeekBackward(true)); @@ -252,6 +255,7 @@ namespace osu.Game.Tests.Visual.Editing reset(); AddStep("Seek(451)", () => Clock.Seek(451)); + checkTime(451); AddStep("SeekBackward, Snap", () => Clock.SeekBackward(true)); checkTime(450); AddStep("Seek(450.999)", () => Clock.Seek(450.999)); @@ -276,6 +280,7 @@ namespace osu.Game.Tests.Visual.Editing double lastTime = 0; AddStep("Seek(0)", () => Clock.Seek(0)); + checkTime(0); for (int i = 0; i < 9; i++) { diff --git a/osu.Game.Tests/Visual/Editing/TestScenePlaybackControl.cs b/osu.Game.Tests/Visual/Editing/TestScenePlaybackControl.cs index 78f650f0fa..674476d644 100644 --- a/osu.Game.Tests/Visual/Editing/TestScenePlaybackControl.cs +++ b/osu.Game.Tests/Visual/Editing/TestScenePlaybackControl.cs @@ -1,13 +1,9 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics; -using osu.Game.Beatmaps; -using osu.Game.Screens.Edit; using osu.Game.Screens.Edit.Components; using osuTK; @@ -19,19 +15,12 @@ namespace osu.Game.Tests.Visual.Editing [BackgroundDependencyLoader] private void load() { - var clock = new EditorClock { IsCoupled = false }; - Dependencies.CacheAs(clock); - - var playback = new PlaybackControl + Child = new PlaybackControl { Anchor = Anchor.Centre, Origin = Anchor.Centre, Size = new Vector2(200, 100) }; - - Beatmap.Value = CreateWorkingBeatmap(new Beatmap()); - - Child = playback; } } } diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneHUDOverlay.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneHUDOverlay.cs index 3e2698bc05..da6604a653 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneHUDOverlay.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneHUDOverlay.cs @@ -39,7 +39,7 @@ namespace osu.Game.Tests.Visual.Gameplay private GameplayState gameplayState = TestGameplayState.Create(new OsuRuleset()); [Cached(typeof(IGameplayClock))] - private readonly IGameplayClock gameplayClock = new GameplayClock(new FramedClock()); + private readonly IGameplayClock gameplayClock = new GameplayClockContainer(new FramedClock()); // best way to check without exposing. private Drawable hideTarget => hudOverlay.KeyCounter; diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneLeadIn.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneLeadIn.cs index 0d80d29cab..25251bf1d6 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneLeadIn.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneLeadIn.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System.Linq; using NUnit.Framework; using osu.Framework.Graphics; @@ -19,7 +17,7 @@ namespace osu.Game.Tests.Visual.Gameplay { public class TestSceneLeadIn : RateAdjustedBeatmapTestScene { - private LeadInPlayer player; + private LeadInPlayer player = null!; private const double lenience_ms = 10; @@ -36,11 +34,7 @@ namespace osu.Game.Tests.Visual.Gameplay BeatmapInfo = { AudioLeadIn = leadIn } }); - AddStep("check first frame time", () => - { - Assert.That(player.FirstFrameClockTime, Is.Not.Null); - Assert.That(player.FirstFrameClockTime.Value, Is.EqualTo(expectedStartTime).Within(lenience_ms)); - }); + checkFirstFrameTime(expectedStartTime); } [TestCase(1000, 0)] @@ -59,11 +53,7 @@ namespace osu.Game.Tests.Visual.Gameplay loadPlayerWithBeatmap(new TestBeatmap(new OsuRuleset().RulesetInfo), storyboard); - AddStep("check first frame time", () => - { - Assert.That(player.FirstFrameClockTime, Is.Not.Null); - Assert.That(player.FirstFrameClockTime.Value, Is.EqualTo(expectedStartTime).Within(lenience_ms)); - }); + checkFirstFrameTime(expectedStartTime); } [TestCase(1000, 0, false)] @@ -97,14 +87,13 @@ namespace osu.Game.Tests.Visual.Gameplay loadPlayerWithBeatmap(new TestBeatmap(new OsuRuleset().RulesetInfo), storyboard); - AddStep("check first frame time", () => - { - Assert.That(player.FirstFrameClockTime, Is.Not.Null); - Assert.That(player.FirstFrameClockTime.Value, Is.EqualTo(expectedStartTime).Within(lenience_ms)); - }); + checkFirstFrameTime(expectedStartTime); } - private void loadPlayerWithBeatmap(IBeatmap beatmap, Storyboard storyboard = null) + private void checkFirstFrameTime(double expectedStartTime) => + AddAssert("check first frame time", () => player.FirstFrameClockTime, () => Is.EqualTo(expectedStartTime).Within(lenience_ms)); + + private void loadPlayerWithBeatmap(IBeatmap beatmap, Storyboard? storyboard = null) { AddStep("create player", () => { @@ -126,12 +115,8 @@ namespace osu.Game.Tests.Visual.Gameplay public new GameplayClockContainer GameplayClockContainer => base.GameplayClockContainer; - public double GameplayStartTime => DrawableRuleset.GameplayStartTime; - public double FirstHitObjectTime => DrawableRuleset.Objects.First().StartTime; - public double GameplayClockTime => GameplayClockContainer.CurrentTime; - protected override void UpdateAfterChildren() { base.UpdateAfterChildren(); diff --git a/osu.Game.Tests/Visual/Gameplay/TestScenePlayerLoader.cs b/osu.Game.Tests/Visual/Gameplay/TestScenePlayerLoader.cs index b0d7eadaa7..922529cf19 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestScenePlayerLoader.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestScenePlayerLoader.cs @@ -13,6 +13,7 @@ using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Localisation; using osu.Framework.Utils; using osu.Framework.Screens; using osu.Framework.Testing; @@ -459,7 +460,7 @@ namespace osu.Game.Tests.Visual.Gameplay public override string Name => string.Empty; public override string Acronym => string.Empty; public override double ScoreMultiplier => 1; - public override string Description => string.Empty; + public override LocalisableString Description => string.Empty; public bool Applied { get; private set; } diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditorMultipleSkins.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditorMultipleSkins.cs index e29101ba8d..6c02ddab14 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditorMultipleSkins.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditorMultipleSkins.cs @@ -30,7 +30,7 @@ namespace osu.Game.Tests.Visual.Gameplay private GameplayState gameplayState = TestGameplayState.Create(new OsuRuleset()); [Cached(typeof(IGameplayClock))] - private readonly IGameplayClock gameplayClock = new GameplayClock(new FramedClock()); + private readonly IGameplayClock gameplayClock = new GameplayClockContainer(new FramedClock()); [SetUpSteps] public void SetUpSteps() diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableHUDOverlay.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableHUDOverlay.cs index 00e4171eac..485c76ac5c 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableHUDOverlay.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableHUDOverlay.cs @@ -37,7 +37,7 @@ namespace osu.Game.Tests.Visual.Gameplay private GameplayState gameplayState = TestGameplayState.Create(new OsuRuleset()); [Cached(typeof(IGameplayClock))] - private readonly IGameplayClock gameplayClock = new GameplayClock(new FramedClock()); + private readonly IGameplayClock gameplayClock = new GameplayClockContainer(new FramedClock()); private IEnumerable hudOverlays => CreatedDrawables.OfType(); diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorScreen.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorScreen.cs index a2e3ab7318..bab613bed7 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorScreen.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorScreen.cs @@ -202,7 +202,7 @@ namespace osu.Game.Tests.Visual.Multiplayer checkPausedInstant(PLAYER_2_ID, true); // Wait for the start delay seconds... - AddWaitStep("wait maximum start delay seconds", (int)(CatchUpSyncManager.MAXIMUM_START_DELAY / TimePerAction)); + AddWaitStep("wait maximum start delay seconds", (int)(SpectatorSyncManager.MAXIMUM_START_DELAY / TimePerAction)); // Player 1 should start playing by itself, player 2 should remain paused. checkPausedInstant(PLAYER_1_ID, false); @@ -318,7 +318,7 @@ namespace osu.Game.Tests.Visual.Multiplayer loadSpectateScreen(); sendFrames(PLAYER_1_ID, 300); - AddWaitStep("wait maximum start delay seconds", (int)(CatchUpSyncManager.MAXIMUM_START_DELAY / TimePerAction)); + AddWaitStep("wait maximum start delay seconds", (int)(SpectatorSyncManager.MAXIMUM_START_DELAY / TimePerAction)); checkPaused(PLAYER_1_ID, false); sendFrames(PLAYER_2_ID, 300); diff --git a/osu.Game.Tests/Visual/Settings/TestSceneSettingsSource.cs b/osu.Game.Tests/Visual/Settings/TestSceneSettingsSource.cs index 98de712703..dc2a687bd5 100644 --- a/osu.Game.Tests/Visual/Settings/TestSceneSettingsSource.cs +++ b/osu.Game.Tests/Visual/Settings/TestSceneSettingsSource.cs @@ -7,6 +7,7 @@ using NUnit.Framework; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Localisation; using osu.Game.Configuration; using osu.Game.Overlays.Settings; using osuTK; @@ -39,6 +40,9 @@ namespace osu.Game.Tests.Visual.Settings [SettingSource("Sample bool", "Clicking this changes a setting")] public BindableBool TickBindable { get; } = new BindableBool(); + [SettingSource(typeof(TestStrings), nameof(TestStrings.LocalisableLabel), nameof(TestStrings.LocalisableDescription))] + public BindableBool LocalisableBindable { get; } = new BindableBool(true); + [SettingSource("Sample float", "Change something for a mod")] public BindableFloat SliderBindable { get; } = new BindableFloat { @@ -75,5 +79,11 @@ namespace osu.Game.Tests.Visual.Settings Value1, Value2 } + + private class TestStrings + { + public static LocalisableString LocalisableLabel => new LocalisableString("Sample localisable label"); + public static LocalisableString LocalisableDescription => new LocalisableString("Sample localisable description"); + } } } diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarousel.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarousel.cs index bb9e83a21c..c3e485d56b 100644 --- a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarousel.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarousel.cs @@ -244,8 +244,12 @@ namespace osu.Game.Tests.Visual.SongSelect const int total_set_count = 200; - for (int i = 0; i < total_set_count; i++) - sets.Add(TestResources.CreateTestBeatmapSetInfo()); + AddStep("Populuate beatmap sets", () => + { + sets.Clear(); + for (int i = 0; i < total_set_count; i++) + sets.Add(TestResources.CreateTestBeatmapSetInfo()); + }); loadBeatmaps(sets); @@ -275,8 +279,12 @@ namespace osu.Game.Tests.Visual.SongSelect const int total_set_count = 20; - for (int i = 0; i < total_set_count; i++) - sets.Add(TestResources.CreateTestBeatmapSetInfo(3)); + AddStep("Populuate beatmap sets", () => + { + sets.Clear(); + for (int i = 0; i < total_set_count; i++) + sets.Add(TestResources.CreateTestBeatmapSetInfo(3)); + }); loadBeatmaps(sets); @@ -493,18 +501,23 @@ namespace osu.Game.Tests.Visual.SongSelect const string zzz_string = "zzzzz"; - for (int i = 0; i < 20; i++) + AddStep("Populuate beatmap sets", () => { - var set = TestResources.CreateTestBeatmapSetInfo(); + sets.Clear(); - if (i == 4) - set.Beatmaps.ForEach(b => b.Metadata.Artist = zzz_string); + for (int i = 0; i < 20; i++) + { + var set = TestResources.CreateTestBeatmapSetInfo(); - if (i == 16) - set.Beatmaps.ForEach(b => b.Metadata.Author.Username = zzz_string); + if (i == 4) + set.Beatmaps.ForEach(b => b.Metadata.Artist = zzz_string); - sets.Add(set); - } + if (i == 16) + set.Beatmaps.ForEach(b => b.Metadata.Author.Username = zzz_string); + + sets.Add(set); + } + }); loadBeatmaps(sets); @@ -521,21 +534,27 @@ namespace osu.Game.Tests.Visual.SongSelect public void TestSortingStability() { var sets = new List(); + int idOffset = 0; - for (int i = 0; i < 10; i++) + AddStep("Populuate beatmap sets", () => { - var set = TestResources.CreateTestBeatmapSetInfo(); + sets.Clear(); - // only need to set the first as they are a shared reference. - var beatmap = set.Beatmaps.First(); + for (int i = 0; i < 10; i++) + { + var set = TestResources.CreateTestBeatmapSetInfo(); - beatmap.Metadata.Artist = $"artist {i / 2}"; - beatmap.Metadata.Title = $"title {9 - i}"; + // only need to set the first as they are a shared reference. + var beatmap = set.Beatmaps.First(); - sets.Add(set); - } + beatmap.Metadata.Artist = $"artist {i / 2}"; + beatmap.Metadata.Title = $"title {9 - i}"; - int idOffset = sets.First().OnlineID; + sets.Add(set); + } + + idOffset = sets.First().OnlineID; + }); loadBeatmaps(sets); @@ -556,26 +575,32 @@ namespace osu.Game.Tests.Visual.SongSelect public void TestSortingStabilityWithNewItems() { List sets = new List(); + int idOffset = 0; - for (int i = 0; i < 3; i++) + AddStep("Populuate beatmap sets", () => { - var set = TestResources.CreateTestBeatmapSetInfo(3); + sets.Clear(); - // only need to set the first as they are a shared reference. - var beatmap = set.Beatmaps.First(); + for (int i = 0; i < 3; i++) + { + var set = TestResources.CreateTestBeatmapSetInfo(3); - beatmap.Metadata.Artist = "same artist"; - beatmap.Metadata.Title = "same title"; + // only need to set the first as they are a shared reference. + var beatmap = set.Beatmaps.First(); - sets.Add(set); - } + beatmap.Metadata.Artist = "same artist"; + beatmap.Metadata.Title = "same title"; - int idOffset = sets.First().OnlineID; + sets.Add(set); + } + + idOffset = sets.First().OnlineID; + }); loadBeatmaps(sets); AddStep("Sort by artist", () => carousel.Filter(new FilterCriteria { Sort = SortMode.Artist }, false)); - AddAssert("Items remain in original order", () => carousel.BeatmapSets.Select((set, index) => set.OnlineID == idOffset + index).All(b => b)); + assertOriginalOrderMaintained(); AddStep("Add new item", () => { @@ -590,10 +615,16 @@ namespace osu.Game.Tests.Visual.SongSelect carousel.UpdateBeatmapSet(set); }); - AddAssert("Items remain in original order", () => carousel.BeatmapSets.Select((set, index) => set.OnlineID == idOffset + index).All(b => b)); + assertOriginalOrderMaintained(); AddStep("Sort by title", () => carousel.Filter(new FilterCriteria { Sort = SortMode.Title }, false)); - AddAssert("Items remain in original order", () => carousel.BeatmapSets.Select((set, index) => set.OnlineID == idOffset + index).All(b => b)); + assertOriginalOrderMaintained(); + + void assertOriginalOrderMaintained() + { + AddAssert("Items remain in original order", + () => carousel.BeatmapSets.Select(s => s.OnlineID), () => Is.EqualTo(carousel.BeatmapSets.Select((set, index) => idOffset + index))); + } } [Test] @@ -601,13 +632,18 @@ namespace osu.Game.Tests.Visual.SongSelect { List sets = new List(); - for (int i = 0; i < 3; i++) + AddStep("Populuate beatmap sets", () => { - var set = TestResources.CreateTestBeatmapSetInfo(3); - set.Beatmaps[0].StarRating = 3 - i; - set.Beatmaps[2].StarRating = 6 + i; - sets.Add(set); - } + sets.Clear(); + + for (int i = 0; i < 3; i++) + { + var set = TestResources.CreateTestBeatmapSetInfo(3); + set.Beatmaps[0].StarRating = 3 - i; + set.Beatmaps[2].StarRating = 6 + i; + sets.Add(set); + } + }); loadBeatmaps(sets); @@ -759,8 +795,13 @@ namespace osu.Game.Tests.Visual.SongSelect { List manySets = new List(); - for (int i = 1; i <= 50; i++) - manySets.Add(TestResources.CreateTestBeatmapSetInfo(3)); + AddStep("Populuate beatmap sets", () => + { + manySets.Clear(); + + for (int i = 1; i <= 50; i++) + manySets.Add(TestResources.CreateTestBeatmapSetInfo(3)); + }); loadBeatmaps(manySets); @@ -791,6 +832,8 @@ namespace osu.Game.Tests.Visual.SongSelect AddStep("populate maps", () => { + manySets.Clear(); + for (int i = 0; i < 10; i++) { manySets.Add(TestResources.CreateTestBeatmapSetInfo(3, new[] diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneModSelectOverlay.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneModSelectOverlay.cs index 07473aa55b..4c43a2fdcd 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneModSelectOverlay.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneModSelectOverlay.cs @@ -1,16 +1,16 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System; using System.Collections.Generic; using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Localisation; using osu.Framework.Testing; using osu.Framework.Utils; using osu.Game.Graphics.UserInterface; @@ -29,10 +29,18 @@ namespace osu.Game.Tests.Visual.UserInterface [TestFixture] public class TestSceneModSelectOverlay : OsuManualInputManagerTestScene { - [Resolved] - private RulesetStore rulesetStore { get; set; } + protected override bool UseFreshStoragePerRun => true; - private UserModSelectOverlay modSelectOverlay; + private RulesetStore rulesetStore = null!; + + private TestModSelectOverlay modSelectOverlay = null!; + + [BackgroundDependencyLoader] + private void load() + { + Dependencies.Cache(rulesetStore = new RealmRulesetStore(Realm)); + Dependencies.Cache(Realm); + } [SetUpSteps] public void SetUpSteps() @@ -40,11 +48,31 @@ namespace osu.Game.Tests.Visual.UserInterface AddStep("clear contents", Clear); AddStep("reset ruleset", () => Ruleset.Value = rulesetStore.GetRuleset(0)); AddStep("reset mods", () => SelectedMods.SetDefault()); + AddStep("set up presets", () => + { + Realm.Write(r => + { + r.RemoveAll(); + r.Add(new ModPreset + { + Name = "AR0", + Description = "Too... many... circles...", + Ruleset = r.Find(OsuRuleset.SHORT_NAME), + Mods = new[] + { + new OsuModDifficultyAdjust + { + ApproachRate = { Value = 0 } + } + } + }); + }); + }); } private void createScreen() { - AddStep("create screen", () => Child = modSelectOverlay = new UserModSelectOverlay + AddStep("create screen", () => Child = modSelectOverlay = new TestModSelectOverlay { RelativeSizeAxes = Axes.Both, State = { Value = Visibility.Visible }, @@ -137,7 +165,7 @@ namespace osu.Game.Tests.Visual.UserInterface AddUntilStep("any column dimmed", () => this.ChildrenOfType().Any(column => !column.Active.Value)); - ModSelectColumn lastColumn = null; + ModSelectColumn lastColumn = null!; AddAssert("last column dimmed", () => !this.ChildrenOfType().Last().Active.Value); AddStep("request scroll to last column", () => @@ -165,18 +193,22 @@ namespace osu.Game.Tests.Visual.UserInterface AddStep("select customisable mod", () => SelectedMods.Value = new[] { new OsuModDoubleTime() }); assertCustomisationToggleState(disabled: false, active: false); - AddStep("select mod requiring configuration", () => SelectedMods.Value = new[] { new OsuModDifficultyAdjust() }); + AddStep("select mod requiring configuration externally", () => SelectedMods.Value = new[] { new OsuModDifficultyAdjust() }); + assertCustomisationToggleState(disabled: false, active: false); + + AddStep("reset mods", () => SelectedMods.SetDefault()); + AddStep("select difficulty adjust via panel", () => getPanelForMod(typeof(OsuModDifficultyAdjust)).TriggerClick()); assertCustomisationToggleState(disabled: false, active: true); AddStep("dismiss mod customisation via toggle", () => { - InputManager.MoveMouseTo(modSelectOverlay.ChildrenOfType().Single()); + InputManager.MoveMouseTo(modSelectOverlay.CustomisationButton); InputManager.Click(MouseButton.Left); }); assertCustomisationToggleState(disabled: false, active: false); AddStep("reset mods", () => SelectedMods.SetDefault()); - AddStep("select mod requiring configuration", () => SelectedMods.Value = new[] { new OsuModDifficultyAdjust() }); + AddStep("select difficulty adjust via panel", () => getPanelForMod(typeof(OsuModDifficultyAdjust)).TriggerClick()); assertCustomisationToggleState(disabled: false, active: true); AddStep("dismiss mod customisation via keyboard", () => InputManager.Key(Key.Escape)); @@ -188,11 +220,18 @@ namespace osu.Game.Tests.Visual.UserInterface AddStep("select mod without configuration", () => SelectedMods.Value = new[] { new OsuModAutoplay() }); assertCustomisationToggleState(disabled: true, active: false); - AddStep("select mod requiring configuration", () => SelectedMods.Value = new[] { new OsuModDifficultyAdjust() }); + AddStep("select difficulty adjust via panel", () => getPanelForMod(typeof(OsuModDifficultyAdjust)).TriggerClick()); assertCustomisationToggleState(disabled: false, active: true); AddStep("select mod without configuration", () => SelectedMods.Value = new[] { new OsuModAutoplay() }); assertCustomisationToggleState(disabled: true, active: false); // config was dismissed without explicit user action. + + AddStep("select mod preset with mod requiring configuration", () => + { + InputManager.MoveMouseTo(this.ChildrenOfType().First()); + InputManager.Click(MouseButton.Left); + }); + assertCustomisationToggleState(disabled: false, active: false); } [Test] @@ -201,7 +240,7 @@ namespace osu.Game.Tests.Visual.UserInterface createScreen(); assertCustomisationToggleState(disabled: true, active: false); - AddStep("select mod requiring configuration", () => SelectedMods.Value = new[] { new OsuModDifficultyAdjust() }); + AddStep("select difficulty adjust via panel", () => getPanelForMod(typeof(OsuModDifficultyAdjust)).TriggerClick()); assertCustomisationToggleState(disabled: false, active: true); AddStep("move mouse to settings area", () => InputManager.MoveMouseTo(this.ChildrenOfType().Single())); @@ -224,11 +263,11 @@ namespace osu.Game.Tests.Visual.UserInterface [Test] public void TestSettingsNotCrossPolluting() { - Bindable> selectedMods2 = null; - ModSelectOverlay modSelectOverlay2 = null; + Bindable> selectedMods2 = null!; + ModSelectOverlay modSelectOverlay2 = null!; createScreen(); - AddStep("select diff adjust", () => SelectedMods.Value = new Mod[] { new OsuModDifficultyAdjust() }); + AddStep("select difficulty adjust via panel", () => getPanelForMod(typeof(OsuModDifficultyAdjust)).TriggerClick()); AddStep("set setting", () => modSelectOverlay.ChildrenOfType>().First().Current.Value = 8); @@ -353,7 +392,7 @@ namespace osu.Game.Tests.Visual.UserInterface public void TestExternallySetModIsReplacedByOverlayInstance() { Mod external = new OsuModDoubleTime(); - Mod overlayButtonMod = null; + Mod overlayButtonMod = null!; createScreen(); changeRuleset(0); @@ -458,14 +497,14 @@ namespace osu.Game.Tests.Visual.UserInterface createScreen(); changeRuleset(0); - AddStep("select difficulty adjust", () => SelectedMods.Value = new Mod[] { new OsuModDifficultyAdjust() }); + AddStep("select difficulty adjust via panel", () => getPanelForMod(typeof(OsuModDifficultyAdjust)).TriggerClick()); assertCustomisationToggleState(disabled: false, active: true); - AddAssert("back button disabled", () => !this.ChildrenOfType().First().Enabled.Value); + AddAssert("back button disabled", () => !modSelectOverlay.BackButton.Enabled.Value); AddStep("dismiss customisation area", () => InputManager.Key(Key.Escape)); AddStep("click back button", () => { - InputManager.MoveMouseTo(this.ChildrenOfType().First()); + InputManager.MoveMouseTo(modSelectOverlay.BackButton); InputManager.Click(MouseButton.Left); }); AddAssert("mod select hidden", () => modSelectOverlay.State.Value == Visibility.Hidden); @@ -474,7 +513,7 @@ namespace osu.Game.Tests.Visual.UserInterface [Test] public void TestColumnHiding() { - AddStep("create screen", () => Child = modSelectOverlay = new UserModSelectOverlay + AddStep("create screen", () => Child = modSelectOverlay = new TestModSelectOverlay { RelativeSizeAxes = Axes.Both, State = { Value = Visibility.Visible }, @@ -527,20 +566,26 @@ namespace osu.Game.Tests.Visual.UserInterface private void assertCustomisationToggleState(bool disabled, bool active) { - ShearedToggleButton getToggle() => modSelectOverlay.ChildrenOfType().Single(); - - AddAssert($"customisation toggle is {(disabled ? "" : "not ")}disabled", () => getToggle().Active.Disabled == disabled); - AddAssert($"customisation toggle is {(active ? "" : "not ")}active", () => getToggle().Active.Value == active); + AddAssert($"customisation toggle is {(disabled ? "" : "not ")}disabled", () => modSelectOverlay.CustomisationButton.AsNonNull().Active.Disabled == disabled); + AddAssert($"customisation toggle is {(active ? "" : "not ")}active", () => modSelectOverlay.CustomisationButton.AsNonNull().Active.Value == active); } private ModPanel getPanelForMod(Type modType) => modSelectOverlay.ChildrenOfType().Single(panel => panel.Mod.GetType() == modType); + private class TestModSelectOverlay : UserModSelectOverlay + { + protected override bool ShowPresets => true; + + public new ShearedButton BackButton => base.BackButton; + public new ShearedToggleButton? CustomisationButton => base.CustomisationButton; + } + private class TestUnimplementedMod : Mod { public override string Name => "Unimplemented mod"; public override string Acronym => "UM"; - public override string Description => "A mod that is not implemented."; + public override LocalisableString Description => "A mod that is not implemented."; public override double ScoreMultiplier => 1; public override ModType Type => ModType.Conversion; } diff --git a/osu.Game/Configuration/SettingSourceAttribute.cs b/osu.Game/Configuration/SettingSourceAttribute.cs index 21e67363c3..630b65ae82 100644 --- a/osu.Game/Configuration/SettingSourceAttribute.cs +++ b/osu.Game/Configuration/SettingSourceAttribute.cs @@ -9,6 +9,7 @@ using System.Linq; using System.Reflection; using JetBrains.Annotations; using osu.Framework.Bindables; +using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Extensions.TypeExtensions; using osu.Framework.Graphics; using osu.Framework.Localisation; @@ -43,12 +44,47 @@ namespace osu.Game.Configuration /// public Type? SettingControlType { get; set; } + public SettingSourceAttribute(Type declaringType, string label, string? description = null) + { + Label = getLocalisableStringFromMember(label) ?? string.Empty; + Description = getLocalisableStringFromMember(description) ?? string.Empty; + + LocalisableString? getLocalisableStringFromMember(string? member) + { + if (member == null) + return null; + + var property = declaringType.GetMember(member, BindingFlags.Static | BindingFlags.Public).FirstOrDefault(); + + if (property == null) + return null; + + switch (property) + { + case FieldInfo f: + return (LocalisableString)f.GetValue(null).AsNonNull(); + + case PropertyInfo p: + return (LocalisableString)p.GetValue(null).AsNonNull(); + + default: + throw new InvalidOperationException($"Member \"{member}\" was not found in type {declaringType} (must be a static field or property)"); + } + } + } + public SettingSourceAttribute(string? label, string? description = null) { Label = label ?? string.Empty; Description = description ?? string.Empty; } + public SettingSourceAttribute(Type declaringType, string label, string description, int orderPosition) + : this(declaringType, label, description) + { + OrderPosition = orderPosition; + } + public SettingSourceAttribute(string label, string description, int orderPosition) : this(label, description) { diff --git a/osu.Game/Database/RealmAccess.cs b/osu.Game/Database/RealmAccess.cs index 0f2e724567..e23fc912df 100644 --- a/osu.Game/Database/RealmAccess.cs +++ b/osu.Game/Database/RealmAccess.cs @@ -69,8 +69,9 @@ namespace osu.Game.Database /// 21 2022-07-27 Migrate collections to realm (BeatmapCollection). /// 22 2022-07-31 Added ModPreset. /// 23 2022-08-01 Added LastLocalUpdate to BeatmapInfo. + /// 24 2022-08-22 Added MaximumStatistics to ScoreInfo. /// - private const int schema_version = 23; + private const int schema_version = 24; /// /// Lock object which is held during sections, blocking realm retrieval during blocking periods. diff --git a/osu.Game/Graphics/UserInterfaceV2/ColourPalette.cs b/osu.Game/Graphics/UserInterfaceV2/ColourPalette.cs index 001ccc7f87..f61d6db8b1 100644 --- a/osu.Game/Graphics/UserInterfaceV2/ColourPalette.cs +++ b/osu.Game/Graphics/UserInterfaceV2/ColourPalette.cs @@ -13,6 +13,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; using osuTK; @@ -26,9 +27,9 @@ namespace osu.Game.Graphics.UserInterfaceV2 { public BindableList Colours { get; } = new BindableList(); - private string colourNamePrefix = "Colour"; + private LocalisableString colourNamePrefix = "Colour"; - public string ColourNamePrefix + public LocalisableString ColourNamePrefix { get => colourNamePrefix; set diff --git a/osu.Game/Graphics/UserInterfaceV2/LabelledColourPalette.cs b/osu.Game/Graphics/UserInterfaceV2/LabelledColourPalette.cs index 7b9684e3ef..b144f8f696 100644 --- a/osu.Game/Graphics/UserInterfaceV2/LabelledColourPalette.cs +++ b/osu.Game/Graphics/UserInterfaceV2/LabelledColourPalette.cs @@ -5,6 +5,7 @@ using osu.Framework.Bindables; using osu.Framework.Graphics; +using osu.Framework.Localisation; namespace osu.Game.Graphics.UserInterfaceV2 { @@ -17,7 +18,7 @@ namespace osu.Game.Graphics.UserInterfaceV2 public BindableList Colours => Component.Colours; - public string ColourNamePrefix + public LocalisableString ColourNamePrefix { get => Component.ColourNamePrefix; set => Component.ColourNamePrefix = value; diff --git a/osu.Game/Localisation/EditorSetupStrings.cs b/osu.Game/Localisation/EditorSetupStrings.cs new file mode 100644 index 0000000000..0431b9cf76 --- /dev/null +++ b/osu.Game/Localisation/EditorSetupStrings.cs @@ -0,0 +1,204 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Localisation; + +namespace osu.Game.Localisation +{ + public static class EditorSetupStrings + { + private const string prefix = @"osu.Game.Resources.Localisation.EditorSetup"; + + /// + /// "Beatmap Setup" + /// + public static LocalisableString BeatmapSetup => new TranslatableString(getKey(@"beatmap_setup"), @"Beatmap Setup"); + + /// + /// "change general settings of your beatmap" + /// + public static LocalisableString BeatmapSetupDescription => new TranslatableString(getKey(@"beatmap_setup_description"), @"change general settings of your beatmap"); + + /// + /// "Colours" + /// + public static LocalisableString ColoursHeader => new TranslatableString(getKey(@"colours_header"), @"Colours"); + + /// + /// "Hit circle / Slider Combos" + /// + public static LocalisableString HitCircleSliderCombos => new TranslatableString(getKey(@"hit_circle_slider_combos"), @"Hit circle / Slider Combos"); + + /// + /// "Design" + /// + public static LocalisableString DesignHeader => new TranslatableString(getKey(@"design_header"), @"Design"); + + /// + /// "Enable countdown" + /// + public static LocalisableString EnableCountdown => new TranslatableString(getKey(@"enable_countdown"), @"Enable countdown"); + + /// + /// "If enabled, an "Are you ready? 3, 2, 1, GO!" countdown will be inserted at the beginning of the beatmap, assuming there is enough time to do so." + /// + public static LocalisableString CountdownDescription => new TranslatableString(getKey(@"countdown_description"), @"If enabled, an ""Are you ready? 3, 2, 1, GO!"" countdown will be inserted at the beginning of the beatmap, assuming there is enough time to do so."); + + /// + /// "Countdown speed" + /// + public static LocalisableString CountdownSpeed => new TranslatableString(getKey(@"countdown_speed"), @"Countdown speed"); + + /// + /// "If the countdown sounds off-time, use this to make it appear one or more beats early." + /// + public static LocalisableString CountdownOffsetDescription => new TranslatableString(getKey(@"countdown_offset_description"), @"If the countdown sounds off-time, use this to make it appear one or more beats early."); + + /// + /// "Countdown offset" + /// + public static LocalisableString CountdownOffset => new TranslatableString(getKey(@"countdown_offset"), @"Countdown offset"); + + /// + /// "Widescreen support" + /// + public static LocalisableString WidescreenSupport => new TranslatableString(getKey(@"widescreen_support"), @"Widescreen support"); + + /// + /// "Allows storyboards to use the full screen space, rather than be confined to a 4:3 area." + /// + public static LocalisableString WidescreenSupportDescription => new TranslatableString(getKey(@"widescreen_support_description"), @"Allows storyboards to use the full screen space, rather than be confined to a 4:3 area."); + + /// + /// "Epilepsy warning" + /// + public static LocalisableString EpilepsyWarning => new TranslatableString(getKey(@"epilepsy_warning"), @"Epilepsy warning"); + + /// + /// "Recommended if the storyboard or video contain scenes with rapidly flashing colours." + /// + public static LocalisableString EpilepsyWarningDescription => new TranslatableString(getKey(@"epilepsy_warning_description"), @"Recommended if the storyboard or video contain scenes with rapidly flashing colours."); + + /// + /// "Letterbox during breaks" + /// + public static LocalisableString LetterboxDuringBreaks => new TranslatableString(getKey(@"letterbox_during_breaks"), @"Letterbox during breaks"); + + /// + /// "Adds horizontal letterboxing to give a cinematic look during breaks." + /// + public static LocalisableString LetterboxDuringBreaksDescription => new TranslatableString(getKey(@"letterbox_during_breaks_description"), @"Adds horizontal letterboxing to give a cinematic look during breaks."); + + /// + /// "Samples match playback rate" + /// + public static LocalisableString SamplesMatchPlaybackRate => new TranslatableString(getKey(@"samples_match_playback_rate"), @"Samples match playback rate"); + + /// + /// "When enabled, all samples will speed up or slow down when rate-changing mods are enabled." + /// + public static LocalisableString SamplesMatchPlaybackRateDescription => new TranslatableString(getKey(@"samples_match_playback_rate_description"), @"When enabled, all samples will speed up or slow down when rate-changing mods are enabled."); + + /// + /// "The size of all hit objects" + /// + public static LocalisableString CircleSizeDescription => new TranslatableString(getKey(@"circle_size_description"), @"The size of all hit objects"); + + /// + /// "The rate of passive health drain throughout playable time" + /// + public static LocalisableString DrainRateDescription => new TranslatableString(getKey(@"drain_rate_description"), @"The rate of passive health drain throughout playable time"); + + /// + /// "The speed at which objects are presented to the player" + /// + public static LocalisableString ApproachRateDescription => new TranslatableString(getKey(@"approach_rate_description"), @"The speed at which objects are presented to the player"); + + /// + /// "The harshness of hit windows and difficulty of special objects (ie. spinners)" + /// + public static LocalisableString OverallDifficultyDescription => new TranslatableString(getKey(@"overall_difficulty_description"), @"The harshness of hit windows and difficulty of special objects (ie. spinners)"); + + /// + /// "Metadata" + /// + public static LocalisableString MetadataHeader => new TranslatableString(getKey(@"metadata_header"), @"Metadata"); + + /// + /// "Romanised Artist" + /// + public static LocalisableString RomanisedArtist => new TranslatableString(getKey(@"romanised_artist"), @"Romanised Artist"); + + /// + /// "Romanised Title" + /// + public static LocalisableString RomanisedTitle => new TranslatableString(getKey(@"romanised_title"), @"Romanised Title"); + + /// + /// "Creator" + /// + public static LocalisableString Creator => new TranslatableString(getKey(@"creator"), @"Creator"); + + /// + /// "Difficulty Name" + /// + public static LocalisableString DifficultyName => new TranslatableString(getKey(@"difficulty_name"), @"Difficulty Name"); + + /// + /// "Resources" + /// + public static LocalisableString ResourcesHeader => new TranslatableString(getKey(@"resources_header"), @"Resources"); + + /// + /// "Audio Track" + /// + public static LocalisableString AudioTrack => new TranslatableString(getKey(@"audio_track"), @"Audio Track"); + + /// + /// "Click to select a track" + /// + public static LocalisableString ClickToSelectTrack => new TranslatableString(getKey(@"click_to_select_track"), @"Click to select a track"); + + /// + /// "Click to replace the track" + /// + public static LocalisableString ClickToReplaceTrack => new TranslatableString(getKey(@"click_to_replace_track"), @"Click to replace the track"); + + /// + /// "Click to select a background image" + /// + public static LocalisableString ClickToSelectBackground => new TranslatableString(getKey(@"click_to_select_background"), @"Click to select a background image"); + + /// + /// "Click to replace the background image" + /// + public static LocalisableString ClickToReplaceBackground => new TranslatableString(getKey(@"click_to_replace_background"), @"Click to replace the background image"); + + /// + /// "Ruleset ({0})" + /// + public static LocalisableString RulesetHeader(string arg0) => new TranslatableString(getKey(@"ruleset"), @"Ruleset ({0})", arg0); + + /// + /// "Combo" + /// + public static LocalisableString ComboColourPrefix => new TranslatableString(getKey(@"combo_colour_prefix"), @"Combo"); + + /// + /// "Artist" + /// + public static LocalisableString Artist => new TranslatableString(getKey(@"artist"), @"Artist"); + + /// + /// "Title" + /// + public static LocalisableString Title => new TranslatableString(getKey(@"title"), @"Title"); + + /// + /// "Difficulty" + /// + public static LocalisableString DifficultyHeader => new TranslatableString(getKey(@"difficulty_header"), @"Difficulty"); + + private static string getKey(string key) => $@"{prefix}:{key}"; + } +} diff --git a/osu.Game/Online/API/DummyAPIAccess.cs b/osu.Game/Online/API/DummyAPIAccess.cs index 07d544260e..7dc34d1293 100644 --- a/osu.Game/Online/API/DummyAPIAccess.cs +++ b/osu.Game/Online/API/DummyAPIAccess.cs @@ -128,5 +128,13 @@ namespace osu.Game.Online.API IBindable IAPIProvider.Activity => Activity; public void FailNextLogin() => shouldFailNextLogin = true; + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + // Ensure (as much as we can) that any pending tasks are run. + Scheduler.Update(); + } } } diff --git a/osu.Game/Online/API/Requests/Responses/SoloScoreInfo.cs b/osu.Game/Online/API/Requests/Responses/SoloScoreInfo.cs index e2e5ea4239..16aa800cb0 100644 --- a/osu.Game/Online/API/Requests/Responses/SoloScoreInfo.cs +++ b/osu.Game/Online/API/Requests/Responses/SoloScoreInfo.cs @@ -74,6 +74,9 @@ namespace osu.Game.Online.API.Requests.Responses [JsonProperty("statistics")] public Dictionary Statistics { get; set; } = new Dictionary(); + [JsonProperty("maximum_statistics")] + public Dictionary MaximumStatistics { get; set; } = new Dictionary(); + #region osu-web API additions (not stored to database). [JsonProperty("id")] @@ -153,6 +156,7 @@ namespace osu.Game.Online.API.Requests.Responses MaxCombo = MaxCombo, Rank = Rank, Statistics = Statistics, + MaximumStatistics = MaximumStatistics, Date = EndedAt, Hash = HasReplay ? "online" : string.Empty, // TODO: temporary? Mods = mods, @@ -174,6 +178,7 @@ namespace osu.Game.Online.API.Requests.Responses Passed = score.Passed, Mods = score.APIMods, Statistics = score.Statistics.Where(kvp => kvp.Value != 0).ToDictionary(kvp => kvp.Key, kvp => kvp.Value), + MaximumStatistics = score.MaximumStatistics.Where(kvp => kvp.Value != 0).ToDictionary(kvp => kvp.Key, kvp => kvp.Value), }; public long OnlineID => ID ?? -1; diff --git a/osu.Game/Online/Chat/ExternalLinkOpener.cs b/osu.Game/Online/Chat/ExternalLinkOpener.cs index e557b9933e..587159179f 100644 --- a/osu.Game/Online/Chat/ExternalLinkOpener.cs +++ b/osu.Game/Online/Chat/ExternalLinkOpener.cs @@ -1,27 +1,27 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - +using System; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; +using osu.Framework.Graphics.Sprites; using osu.Framework.Platform; using osu.Game.Configuration; using osu.Game.Overlays; -using osu.Game.Overlays.Chat; +using osu.Game.Overlays.Dialog; namespace osu.Game.Online.Chat { public class ExternalLinkOpener : Component { [Resolved] - private GameHost host { get; set; } + private GameHost host { get; set; } = null!; [Resolved(CanBeNull = true)] - private IDialogOverlay dialogOverlay { get; set; } + private IDialogOverlay? dialogOverlay { get; set; } - private Bindable externalLinkWarning; + private Bindable externalLinkWarning = null!; [BackgroundDependencyLoader(true)] private void load(OsuConfigManager config) @@ -31,10 +31,39 @@ namespace osu.Game.Online.Chat public void OpenUrlExternally(string url, bool bypassWarning = false) { - if (!bypassWarning && externalLinkWarning.Value) - dialogOverlay.Push(new ExternalLinkDialog(url, () => host.OpenUrlExternally(url))); + if (!bypassWarning && externalLinkWarning.Value && dialogOverlay != null) + dialogOverlay.Push(new ExternalLinkDialog(url, () => host.OpenUrlExternally(url), () => host.GetClipboard()?.SetText(url))); else host.OpenUrlExternally(url); } + + public class ExternalLinkDialog : PopupDialog + { + public ExternalLinkDialog(string url, Action openExternalLinkAction, Action copyExternalLinkAction) + { + HeaderText = "Just checking..."; + BodyText = $"You are about to leave osu! and open the following link in a web browser:\n\n{url}"; + + Icon = FontAwesome.Solid.ExclamationTriangle; + + Buttons = new PopupDialogButton[] + { + new PopupDialogOkButton + { + Text = @"Yes. Go for it.", + Action = openExternalLinkAction + }, + new PopupDialogCancelButton + { + Text = @"Copy URL to the clipboard instead.", + Action = copyExternalLinkAction + }, + new PopupDialogCancelButton + { + Text = @"No! Abort mission!" + }, + }; + } + } } } diff --git a/osu.Game/Online/Leaderboards/LeaderboardScoreTooltip.cs b/osu.Game/Online/Leaderboards/LeaderboardScoreTooltip.cs index 53a4719050..2f3ece0e3b 100644 --- a/osu.Game/Online/Leaderboards/LeaderboardScoreTooltip.cs +++ b/osu.Game/Online/Leaderboards/LeaderboardScoreTooltip.cs @@ -10,6 +10,8 @@ using osuTK; using osu.Game.Graphics.Sprites; using osu.Game.Graphics; using osu.Framework.Allocation; +using osu.Framework.Extensions.LocalisationExtensions; +using osu.Framework.Localisation; using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.UI; @@ -126,7 +128,7 @@ namespace osu.Game.Online.Leaderboards private class HitResultCell : CompositeDrawable { - private readonly string displayName; + private readonly LocalisableString displayName; private readonly HitResult result; private readonly int count; @@ -134,7 +136,7 @@ namespace osu.Game.Online.Leaderboards { AutoSizeAxes = Axes.Both; - displayName = stat.DisplayName; + displayName = stat.DisplayName.ToUpper(); result = stat.Result; count = stat.Count; } @@ -153,7 +155,7 @@ namespace osu.Game.Online.Leaderboards new OsuSpriteText { Font = OsuFont.Torus.With(size: 12, weight: FontWeight.SemiBold), - Text = displayName.ToUpperInvariant(), + Text = displayName.ToUpper(), Colour = colours.ForHitResult(result), }, new OsuSpriteText diff --git a/osu.Game/Online/PollingComponent.cs b/osu.Game/Online/PollingComponent.cs index d54b8ca75d..fcea650e2d 100644 --- a/osu.Game/Online/PollingComponent.cs +++ b/osu.Game/Online/PollingComponent.cs @@ -16,7 +16,7 @@ namespace osu.Game.Online /// /// A component which requires a constant polling process. /// - public abstract class PollingComponent : CompositeDrawable // switch away from Component because InternalChildren are used in usages. + public abstract class PollingComponent : CompositeComponent { private double? lastTimePolled; diff --git a/osu.Game/Online/Rooms/OnlinePlayBeatmapAvailabilityTracker.cs b/osu.Game/Online/Rooms/OnlinePlayBeatmapAvailabilityTracker.cs index 2fd8445980..bb8ec4f6ff 100644 --- a/osu.Game/Online/Rooms/OnlinePlayBeatmapAvailabilityTracker.cs +++ b/osu.Game/Online/Rooms/OnlinePlayBeatmapAvailabilityTracker.cs @@ -27,13 +27,10 @@ namespace osu.Game.Online.Rooms /// This differs from a regular download tracking composite as this accounts for the /// databased beatmap set's checksum, to disallow from playing with an altered version of the beatmap. /// - public class OnlinePlayBeatmapAvailabilityTracker : CompositeDrawable + public class OnlinePlayBeatmapAvailabilityTracker : CompositeComponent { public readonly IBindable SelectedItem = new Bindable(); - // Required to allow child components to update. Can potentially be replaced with a `CompositeComponent` class if or when we make one. - protected override bool RequiresChildrenUpdate => true; - [Resolved] private RealmAccess realm { get; set; } = null!; diff --git a/osu.Game/Overlays/BeatmapSet/Scores/ScoreTable.cs b/osu.Game/Overlays/BeatmapSet/Scores/ScoreTable.cs index 5463c7a50f..11aefd435d 100644 --- a/osu.Game/Overlays/BeatmapSet/Scores/ScoreTable.cs +++ b/osu.Game/Overlays/BeatmapSet/Scores/ScoreTable.cs @@ -59,7 +59,7 @@ namespace osu.Game.Overlays.BeatmapSet.Scores /// /// The statistics that appear in the table, in order of appearance. /// - private readonly List<(HitResult result, string displayName)> statisticResultTypes = new List<(HitResult, string)>(); + private readonly List<(HitResult result, LocalisableString displayName)> statisticResultTypes = new List<(HitResult, LocalisableString)>(); private bool showPerformancePoints; @@ -114,7 +114,7 @@ namespace osu.Game.Overlays.BeatmapSet.Scores if (result.IsBonus()) continue; - string displayName = ruleset.GetDisplayNameForHitResult(result); + var displayName = ruleset.GetDisplayNameForHitResult(result); columns.Add(new TableColumn(displayName, Anchor.CentreLeft, new Dimension(GridSizeMode.Distributed, minSize: 35, maxSize: 60))); statisticResultTypes.Add((result, displayName)); diff --git a/osu.Game/Overlays/Chat/ExternalLinkDialog.cs b/osu.Game/Overlays/Chat/ExternalLinkDialog.cs deleted file mode 100644 index f0d39346e0..0000000000 --- a/osu.Game/Overlays/Chat/ExternalLinkDialog.cs +++ /dev/null @@ -1,35 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -#nullable disable - -using System; -using osu.Framework.Graphics.Sprites; -using osu.Game.Overlays.Dialog; - -namespace osu.Game.Overlays.Chat -{ - public class ExternalLinkDialog : PopupDialog - { - public ExternalLinkDialog(string url, Action openExternalLinkAction) - { - HeaderText = "Just checking..."; - BodyText = $"You are about to leave osu! and open the following link in a web browser:\n\n{url}"; - - Icon = FontAwesome.Solid.ExclamationTriangle; - - Buttons = new PopupDialogButton[] - { - new PopupDialogOkButton - { - Text = @"Yes. Go for it.", - Action = openExternalLinkAction - }, - new PopupDialogCancelButton - { - Text = @"No! Abort mission!" - }, - }; - } - } -} diff --git a/osu.Game/Overlays/Mods/DifficultyMultiplierDisplay.cs b/osu.Game/Overlays/Mods/DifficultyMultiplierDisplay.cs index 255d01466f..835883fb93 100644 --- a/osu.Game/Overlays/Mods/DifficultyMultiplierDisplay.cs +++ b/osu.Game/Overlays/Mods/DifficultyMultiplierDisplay.cs @@ -31,10 +31,7 @@ namespace osu.Game.Overlays.Mods set => current.Current = value; } - private readonly BindableNumberWithCurrent current = new BindableNumberWithCurrent(1) - { - Precision = 0.01 - }; + private readonly BindableNumberWithCurrent current = new BindableNumberWithCurrent(1); private readonly Box underlayBackground; private readonly Box contentBackground; diff --git a/osu.Game/Overlays/Mods/ModColumn.cs b/osu.Game/Overlays/Mods/ModColumn.cs index 7a2c727a00..b9f7114f74 100644 --- a/osu.Game/Overlays/Mods/ModColumn.cs +++ b/osu.Game/Overlays/Mods/ModColumn.cs @@ -66,7 +66,10 @@ namespace osu.Game.Overlays.Mods private IModHotkeyHandler hotkeyHandler = null!; private Task? latestLoadTask; - internal bool ItemsLoaded => latestLoadTask?.IsCompleted == true; + private ICollection? latestLoadedPanels; + internal bool ItemsLoaded => latestLoadTask?.IsCompleted == true && latestLoadedPanels?.All(panel => panel.Parent != null) == true; + + public override bool IsPresent => base.IsPresent || Scheduler.HasPendingTasks; public ModColumn(ModType modType, bool allowIncompatibleSelection) { @@ -130,7 +133,8 @@ namespace osu.Game.Overlays.Mods { cancellationTokenSource?.Cancel(); - var panels = availableMods.Select(mod => CreateModPanel(mod).With(panel => panel.Shear = Vector2.Zero)); + var panels = availableMods.Select(mod => CreateModPanel(mod).With(panel => panel.Shear = Vector2.Zero)).ToArray(); + latestLoadedPanels = panels; latestLoadTask = LoadComponentsAsync(panels, loaded => { diff --git a/osu.Game/Overlays/Mods/ModPanel.cs b/osu.Game/Overlays/Mods/ModPanel.cs index 6ef6ab0595..7bdb9511ac 100644 --- a/osu.Game/Overlays/Mods/ModPanel.cs +++ b/osu.Game/Overlays/Mods/ModPanel.cs @@ -57,6 +57,18 @@ namespace osu.Game.Overlays.Mods Filtered.BindValueChanged(_ => updateFilterState(), true); } + protected override void Select() + { + modState.PendingConfiguration = Mod.RequiresConfiguration; + Active.Value = true; + } + + protected override void Deselect() + { + modState.PendingConfiguration = false; + Active.Value = false; + } + #region Filtering support private void updateFilterState() diff --git a/osu.Game/Overlays/Mods/ModPresetPanel.cs b/osu.Game/Overlays/Mods/ModPresetPanel.cs index a259645479..b314a19142 100644 --- a/osu.Game/Overlays/Mods/ModPresetPanel.cs +++ b/osu.Game/Overlays/Mods/ModPresetPanel.cs @@ -37,8 +37,6 @@ namespace osu.Game.Overlays.Mods Title = preset.Value.Name; Description = preset.Value.Description; - - Action = toggleRequestedByUser; } [BackgroundDependencyLoader] @@ -54,15 +52,19 @@ namespace osu.Game.Overlays.Mods selectedMods.BindValueChanged(_ => selectedModsChanged(), true); } - private void toggleRequestedByUser() + protected override void Select() + { + // if the preset is not active at the point of the user click, then set the mods using the preset directly, discarding any previous selections, + // which will also have the side effect of activating the preset (see `updateActiveState()`). + selectedMods.Value = Preset.Value.Mods.ToArray(); + } + + protected override void Deselect() { - // if the preset is not active at the point of the user click, then set the mods using the preset directly, discarding any previous selections. // if the preset is active when the user has clicked it, then it means that the set of active mods is exactly equal to the set of mods in the preset // (there are no other active mods than what the preset specifies, and the mod settings match exactly). // therefore it's safe to just clear selected mods, since it will have the effect of toggling the preset off. - selectedMods.Value = !Active.Value - ? Preset.Value.Mods.ToArray() - : Array.Empty(); + selectedMods.Value = Array.Empty(); } private void selectedModsChanged() diff --git a/osu.Game/Overlays/Mods/ModSelectColumn.cs b/osu.Game/Overlays/Mods/ModSelectColumn.cs index 0224631577..d5dc079628 100644 --- a/osu.Game/Overlays/Mods/ModSelectColumn.cs +++ b/osu.Game/Overlays/Mods/ModSelectColumn.cs @@ -159,12 +159,15 @@ namespace osu.Game.Overlays.Mods int wordIndex = 0; - headerText.AddText(text, t => + ITextPart part = headerText.AddText(text, t => { if (wordIndex == 0) t.Font = t.Font.With(weight: FontWeight.SemiBold); wordIndex += 1; }); + + // Reset the index so that if the parts are refreshed (e.g. through changes in localisation) the correct word is re-emboldened. + part.DrawablePartsRecreated += _ => wordIndex = 0; } [BackgroundDependencyLoader] diff --git a/osu.Game/Overlays/Mods/ModSelectOverlay.cs b/osu.Game/Overlays/Mods/ModSelectOverlay.cs index adc008e1f7..b993aca0ca 100644 --- a/osu.Game/Overlays/Mods/ModSelectOverlay.cs +++ b/osu.Game/Overlays/Mods/ModSelectOverlay.cs @@ -87,7 +87,7 @@ namespace osu.Game.Overlays.Mods { if (AllowCustomisation) { - yield return customisationButton = new ShearedToggleButton(BUTTON_WIDTH) + yield return CustomisationButton = new ShearedToggleButton(BUTTON_WIDTH) { Text = ModSelectOverlayStrings.ModCustomisation, Active = { BindTarget = customisationVisible } @@ -107,11 +107,11 @@ namespace osu.Game.Overlays.Mods private ColumnScrollContainer columnScroll = null!; private ColumnFlowContainer columnFlow = null!; private FillFlowContainer footerButtonFlow = null!; - private ShearedButton backButton = null!; private DifficultyMultiplierDisplay? multiplierDisplay; - private ShearedToggleButton? customisationButton; + protected ShearedButton BackButton { get; private set; } = null!; + protected ShearedToggleButton? CustomisationButton { get; private set; } private Sample? columnAppearSample; @@ -214,7 +214,7 @@ namespace osu.Game.Overlays.Mods Horizontal = 70 }, Spacing = new Vector2(10), - ChildrenEnumerable = CreateFooterButtons().Prepend(backButton = new ShearedButton(BUTTON_WIDTH) + ChildrenEnumerable = CreateFooterButtons().Prepend(BackButton = new ShearedButton(BUTTON_WIDTH) { Text = CommonStrings.Back, Action = Hide, @@ -247,8 +247,8 @@ namespace osu.Game.Overlays.Mods modSettingChangeTracker?.Dispose(); updateMultiplier(); - updateCustomisation(val); updateFromExternalSelection(); + updateCustomisation(); if (AllowCustomisation) { @@ -356,25 +356,26 @@ namespace osu.Game.Overlays.Mods multiplierDisplay.Current.Value = multiplier; } - private void updateCustomisation(ValueChangedEvent> valueChangedEvent) + private void updateCustomisation() { - if (customisationButton == null) + if (CustomisationButton == null) return; - bool anyCustomisableMod = false; - bool anyModWithRequiredCustomisationAdded = false; + bool anyCustomisableModActive = false; + bool anyModPendingConfiguration = false; - foreach (var mod in SelectedMods.Value) + foreach (var modState in allAvailableMods) { - anyCustomisableMod |= mod.GetSettingsSourceProperties().Any(); - anyModWithRequiredCustomisationAdded |= valueChangedEvent.OldValue.All(m => m.GetType() != mod.GetType()) && mod.RequiresConfiguration; + anyCustomisableModActive |= modState.Active.Value && modState.Mod.GetSettingsSourceProperties().Any(); + anyModPendingConfiguration |= modState.PendingConfiguration; + modState.PendingConfiguration = false; } - if (anyCustomisableMod) + if (anyCustomisableModActive) { customisationVisible.Disabled = false; - if (anyModWithRequiredCustomisationAdded && !customisationVisible.Value) + if (anyModPendingConfiguration && !customisationVisible.Value) customisationVisible.Value = true; } else @@ -394,7 +395,7 @@ namespace osu.Game.Overlays.Mods foreach (var button in footerButtonFlow) { - if (button != customisationButton) + if (button != CustomisationButton) button.Enabled.Value = !customisationVisible.Value; } @@ -587,14 +588,14 @@ namespace osu.Game.Overlays.Mods { if (customisationVisible.Value) { - Debug.Assert(customisationButton != null); - customisationButton.TriggerClick(); + Debug.Assert(CustomisationButton != null); + CustomisationButton.TriggerClick(); if (!immediate) return; } - backButton.TriggerClick(); + BackButton.TriggerClick(); } } @@ -708,7 +709,18 @@ namespace osu.Game.Overlays.Mods FinishTransforms(); } - protected override bool RequiresChildrenUpdate => base.RequiresChildrenUpdate || (Column as ModColumn)?.SelectionAnimationRunning == true; + protected override bool RequiresChildrenUpdate + { + get + { + bool result = base.RequiresChildrenUpdate; + + if (Column is ModColumn modColumn) + result |= !modColumn.ItemsLoaded || modColumn.SelectionAnimationRunning; + + return result; + } + } private void updateState() { diff --git a/osu.Game/Overlays/Mods/ModSelectPanel.cs b/osu.Game/Overlays/Mods/ModSelectPanel.cs index b3df00f8f9..27abface0c 100644 --- a/osu.Game/Overlays/Mods/ModSelectPanel.cs +++ b/osu.Game/Overlays/Mods/ModSelectPanel.cs @@ -143,9 +143,25 @@ namespace osu.Game.Overlays.Mods } }; - Action = () => Active.Toggle(); + Action = () => + { + if (!Active.Value) + Select(); + else + Deselect(); + }; } + /// + /// Performs all actions necessary to select this . + /// + protected abstract void Select(); + + /// + /// Performs all actions necessary to deselect this . + /// + protected abstract void Deselect(); + [BackgroundDependencyLoader] private void load(AudioManager audio, ISamplePlaybackDisabler? samplePlaybackDisabler) { diff --git a/osu.Game/Overlays/Mods/ModState.cs b/osu.Game/Overlays/Mods/ModState.cs index 79880b85a5..3ee890e876 100644 --- a/osu.Game/Overlays/Mods/ModState.cs +++ b/osu.Game/Overlays/Mods/ModState.cs @@ -24,6 +24,13 @@ namespace osu.Game.Overlays.Mods /// public BindableBool Active { get; } = new BindableBool(); + /// + /// Whether the mod requires further customisation. + /// This flag is read by the to determine if the customisation panel should be opened after a mod change + /// and cleared after reading. + /// + public bool PendingConfiguration { get; set; } + /// /// Whether the mod is currently filtered out due to not matching imposed criteria. /// diff --git a/osu.Game/Overlays/Profile/Header/TopHeaderContainer.cs b/osu.Game/Overlays/Profile/Header/TopHeaderContainer.cs index 7e079c8341..67a6df3228 100644 --- a/osu.Game/Overlays/Profile/Header/TopHeaderContainer.cs +++ b/osu.Game/Overlays/Profile/Header/TopHeaderContainer.cs @@ -14,6 +14,7 @@ using osu.Framework.Graphics.Shapes; using osu.Framework.Localisation; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.Cursor; using osu.Game.Graphics.UserInterface; using osu.Game.Online.API; using osu.Game.Online.API.Requests.Responses; @@ -70,85 +71,90 @@ namespace osu.Game.Overlays.Profile.Header Masking = true, CornerRadius = avatar_size * 0.25f, }, - new Container + new OsuContextMenuContainer { RelativeSizeAxes = Axes.Y, AutoSizeAxes = Axes.X, - Padding = new MarginPadding { Left = 10 }, - Children = new Drawable[] + Child = new Container { - new FillFlowContainer + RelativeSizeAxes = Axes.Y, + AutoSizeAxes = Axes.X, + Padding = new MarginPadding { Left = 10 }, + Children = new Drawable[] { - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Vertical, - Children = new Drawable[] + new FillFlowContainer { - new FillFlowContainer + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Children = new Drawable[] { - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Horizontal, - Children = new Drawable[] + new FillFlowContainer { - usernameText = new OsuSpriteText + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Children = new Drawable[] { - Font = OsuFont.GetFont(size: 24, weight: FontWeight.Regular) - }, - openUserExternally = new ExternalLinkButton - { - Margin = new MarginPadding { Left = 5 }, - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - }, - } - }, - titleText = new OsuSpriteText - { - Font = OsuFont.GetFont(size: 18, weight: FontWeight.Regular) - }, - } - }, - new FillFlowContainer - { - Origin = Anchor.BottomLeft, - Anchor = Anchor.BottomLeft, - Direction = FillDirection.Vertical, - AutoSizeAxes = Axes.Both, - Children = new Drawable[] - { - supporterTag = new SupporterIcon - { - Height = 20, - Margin = new MarginPadding { Top = 5 } - }, - new Box - { - RelativeSizeAxes = Axes.X, - Height = 1.5f, - Margin = new MarginPadding { Top = 10 }, - Colour = colourProvider.Light1, - }, - new FillFlowContainer - { - AutoSizeAxes = Axes.Both, - Margin = new MarginPadding { Top = 5 }, - Direction = FillDirection.Horizontal, - Children = new Drawable[] - { - userFlag = new UpdateableFlag - { - Size = new Vector2(28, 20), - ShowPlaceholderOnUnknown = false, - }, - userCountryText = new OsuSpriteText - { - Font = OsuFont.GetFont(size: 17.5f, weight: FontWeight.Regular), - Margin = new MarginPadding { Left = 10 }, - Origin = Anchor.CentreLeft, - Anchor = Anchor.CentreLeft, - Colour = colourProvider.Light1, + usernameText = new OsuSpriteText + { + Font = OsuFont.GetFont(size: 24, weight: FontWeight.Regular) + }, + openUserExternally = new ExternalLinkButton + { + Margin = new MarginPadding { Left = 5 }, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + }, } - } - }, + }, + titleText = new OsuSpriteText + { + Font = OsuFont.GetFont(size: 18, weight: FontWeight.Regular) + }, + } + }, + new FillFlowContainer + { + Origin = Anchor.BottomLeft, + Anchor = Anchor.BottomLeft, + Direction = FillDirection.Vertical, + AutoSizeAxes = Axes.Both, + Children = new Drawable[] + { + supporterTag = new SupporterIcon + { + Height = 20, + Margin = new MarginPadding { Top = 5 } + }, + new Box + { + RelativeSizeAxes = Axes.X, + Height = 1.5f, + Margin = new MarginPadding { Top = 10 }, + Colour = colourProvider.Light1, + }, + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Margin = new MarginPadding { Top = 5 }, + Direction = FillDirection.Horizontal, + Children = new Drawable[] + { + userFlag = new UpdateableFlag + { + Size = new Vector2(28, 20), + ShowPlaceholderOnUnknown = false, + }, + userCountryText = new OsuSpriteText + { + Font = OsuFont.GetFont(size: 17.5f, weight: FontWeight.Regular), + Margin = new MarginPadding { Left = 10 }, + Origin = Anchor.CentreLeft, + Anchor = Anchor.CentreLeft, + Colour = colourProvider.Light1, + } + } + }, + } } } } diff --git a/osu.Game/Overlays/Settings/Sections/Input/VariantBindingsSubsection.cs b/osu.Game/Overlays/Settings/Sections/Input/VariantBindingsSubsection.cs index 23f91fba4b..a0f069b3bb 100644 --- a/osu.Game/Overlays/Settings/Sections/Input/VariantBindingsSubsection.cs +++ b/osu.Game/Overlays/Settings/Sections/Input/VariantBindingsSubsection.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using osu.Framework.Localisation; using osu.Game.Rulesets; diff --git a/osu.Game/Rulesets/Mods/IMod.cs b/osu.Game/Rulesets/Mods/IMod.cs index 30fa1ea8cb..1f9e26c9d7 100644 --- a/osu.Game/Rulesets/Mods/IMod.cs +++ b/osu.Game/Rulesets/Mods/IMod.cs @@ -3,6 +3,7 @@ using System; using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; namespace osu.Game.Rulesets.Mods { @@ -21,7 +22,7 @@ namespace osu.Game.Rulesets.Mods /// /// The user readable description of this mod. /// - string Description { get; } + LocalisableString Description { get; } /// /// The type of this mod. diff --git a/osu.Game/Rulesets/Mods/Mod.cs b/osu.Game/Rulesets/Mods/Mod.cs index 0f3d758f74..e4c91d3037 100644 --- a/osu.Game/Rulesets/Mods/Mod.cs +++ b/osu.Game/Rulesets/Mods/Mod.cs @@ -9,6 +9,7 @@ using Newtonsoft.Json; using osu.Framework.Bindables; using osu.Framework.Extensions.TypeExtensions; using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; using osu.Framework.Testing; using osu.Game.Configuration; using osu.Game.Rulesets.UI; @@ -34,7 +35,7 @@ namespace osu.Game.Rulesets.Mods public virtual ModType Type => ModType.Fun; [JsonIgnore] - public abstract string Description { get; } + public abstract LocalisableString Description { get; } /// /// The tooltip to display for this mod when used in a . diff --git a/osu.Game/Rulesets/Mods/ModAdaptiveSpeed.cs b/osu.Game/Rulesets/Mods/ModAdaptiveSpeed.cs index 7b84db844b..697b303689 100644 --- a/osu.Game/Rulesets/Mods/ModAdaptiveSpeed.cs +++ b/osu.Game/Rulesets/Mods/ModAdaptiveSpeed.cs @@ -6,6 +6,7 @@ using System.Collections.Generic; using System.Linq; using osu.Framework.Audio; using osu.Framework.Bindables; +using osu.Framework.Localisation; using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Configuration; @@ -23,7 +24,7 @@ namespace osu.Game.Rulesets.Mods public override string Acronym => "AS"; - public override string Description => "Let track speed adapt to you."; + public override LocalisableString Description => "Let track speed adapt to you."; public override ModType Type => ModType.Fun; diff --git a/osu.Game/Rulesets/Mods/ModAutoplay.cs b/osu.Game/Rulesets/Mods/ModAutoplay.cs index ab49dd5575..6cafe0716d 100644 --- a/osu.Game/Rulesets/Mods/ModAutoplay.cs +++ b/osu.Game/Rulesets/Mods/ModAutoplay.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; using osu.Game.Beatmaps; using osu.Game.Graphics; using osu.Game.Replays; @@ -17,7 +18,7 @@ namespace osu.Game.Rulesets.Mods public override string Acronym => "AT"; public override IconUsage? Icon => OsuIcon.ModAuto; public override ModType Type => ModType.Automation; - public override string Description => "Watch a perfect automated play through the song."; + public override LocalisableString Description => "Watch a perfect automated play through the song."; public override double ScoreMultiplier => 1; public bool PerformFail() => false; diff --git a/osu.Game/Rulesets/Mods/ModBarrelRoll.cs b/osu.Game/Rulesets/Mods/ModBarrelRoll.cs index bacb953f76..0c301d293f 100644 --- a/osu.Game/Rulesets/Mods/ModBarrelRoll.cs +++ b/osu.Game/Rulesets/Mods/ModBarrelRoll.cs @@ -5,6 +5,7 @@ using System; using osu.Framework.Bindables; using osu.Framework.Extensions; using osu.Framework.Graphics; +using osu.Framework.Localisation; using osu.Game.Configuration; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.UI; @@ -34,7 +35,7 @@ namespace osu.Game.Rulesets.Mods public override string Name => "Barrel Roll"; public override string Acronym => "BR"; - public override string Description => "The whole playfield is on a wheel!"; + public override LocalisableString Description => "The whole playfield is on a wheel!"; public override double ScoreMultiplier => 1; public override string SettingDescription => $"{SpinSpeed.Value:N2} rpm {Direction.Value.GetDescription().ToLowerInvariant()}"; diff --git a/osu.Game/Rulesets/Mods/ModCinema.cs b/osu.Game/Rulesets/Mods/ModCinema.cs index 99c4e71d1f..ae661c5f25 100644 --- a/osu.Game/Rulesets/Mods/ModCinema.cs +++ b/osu.Game/Rulesets/Mods/ModCinema.cs @@ -4,6 +4,7 @@ using System; using System.Linq; using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; using osu.Game.Graphics; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.UI; @@ -27,7 +28,7 @@ namespace osu.Game.Rulesets.Mods public override string Name => "Cinema"; public override string Acronym => "CN"; public override IconUsage? Icon => OsuIcon.ModCinema; - public override string Description => "Watch the video without visual distractions."; + public override LocalisableString Description => "Watch the video without visual distractions."; public override Type[] IncompatibleMods => base.IncompatibleMods.Append(typeof(ModAutoplay)).ToArray(); diff --git a/osu.Game/Rulesets/Mods/ModClassic.cs b/osu.Game/Rulesets/Mods/ModClassic.cs index 1159955e11..55b16297e2 100644 --- a/osu.Game/Rulesets/Mods/ModClassic.cs +++ b/osu.Game/Rulesets/Mods/ModClassic.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; namespace osu.Game.Rulesets.Mods { @@ -15,7 +16,7 @@ namespace osu.Game.Rulesets.Mods public override IconUsage? Icon => FontAwesome.Solid.History; - public override string Description => "Feeling nostalgic?"; + public override LocalisableString Description => "Feeling nostalgic?"; public override ModType Type => ModType.Conversion; } diff --git a/osu.Game/Rulesets/Mods/ModDaycore.cs b/osu.Game/Rulesets/Mods/ModDaycore.cs index 9e8e44229e..de1a5ab56c 100644 --- a/osu.Game/Rulesets/Mods/ModDaycore.cs +++ b/osu.Game/Rulesets/Mods/ModDaycore.cs @@ -4,6 +4,7 @@ using osu.Framework.Audio; using osu.Framework.Bindables; using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; namespace osu.Game.Rulesets.Mods { @@ -12,7 +13,7 @@ namespace osu.Game.Rulesets.Mods public override string Name => "Daycore"; public override string Acronym => "DC"; public override IconUsage? Icon => null; - public override string Description => "Whoaaaaa..."; + public override LocalisableString Description => "Whoaaaaa..."; private readonly BindableNumber tempoAdjust = new BindableDouble(1); private readonly BindableNumber freqAdjust = new BindableDouble(1); diff --git a/osu.Game/Rulesets/Mods/ModDifficultyAdjust.cs b/osu.Game/Rulesets/Mods/ModDifficultyAdjust.cs index b7435ec3ec..f4c6be4f77 100644 --- a/osu.Game/Rulesets/Mods/ModDifficultyAdjust.cs +++ b/osu.Game/Rulesets/Mods/ModDifficultyAdjust.cs @@ -5,6 +5,7 @@ using System; using System.Linq; using osu.Framework.Bindables; using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; using osu.Game.Beatmaps; using osu.Game.Configuration; @@ -14,7 +15,7 @@ namespace osu.Game.Rulesets.Mods { public override string Name => @"Difficulty Adjust"; - public override string Description => @"Override a beatmap's difficulty settings."; + public override LocalisableString Description => @"Override a beatmap's difficulty settings."; public override string Acronym => "DA"; diff --git a/osu.Game/Rulesets/Mods/ModDoubleTime.cs b/osu.Game/Rulesets/Mods/ModDoubleTime.cs index 1c71f5d055..d8a41ae658 100644 --- a/osu.Game/Rulesets/Mods/ModDoubleTime.cs +++ b/osu.Game/Rulesets/Mods/ModDoubleTime.cs @@ -3,6 +3,7 @@ using osu.Framework.Bindables; using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; using osu.Game.Configuration; using osu.Game.Graphics; @@ -14,7 +15,7 @@ namespace osu.Game.Rulesets.Mods public override string Acronym => "DT"; public override IconUsage? Icon => OsuIcon.ModDoubleTime; public override ModType Type => ModType.DifficultyIncrease; - public override string Description => "Zoooooooooom..."; + public override LocalisableString Description => "Zoooooooooom..."; [SettingSource("Speed increase", "The actual increase to apply")] public override BindableNumber SpeedChange { get; } = new BindableDouble diff --git a/osu.Game/Rulesets/Mods/ModFlashlight.cs b/osu.Game/Rulesets/Mods/ModFlashlight.cs index 210dd56137..558605efc3 100644 --- a/osu.Game/Rulesets/Mods/ModFlashlight.cs +++ b/osu.Game/Rulesets/Mods/ModFlashlight.cs @@ -11,6 +11,7 @@ using osu.Framework.Graphics.Rendering; using osu.Framework.Graphics.Rendering.Vertices; using osu.Framework.Graphics.Shaders; using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; using osu.Game.Beatmaps.Timing; using osu.Game.Configuration; using osu.Game.Graphics; @@ -30,7 +31,7 @@ namespace osu.Game.Rulesets.Mods public override string Acronym => "FL"; public override IconUsage? Icon => OsuIcon.ModFlashlight; public override ModType Type => ModType.DifficultyIncrease; - public override string Description => "Restricted view area."; + public override LocalisableString Description => "Restricted view area."; [SettingSource("Flashlight size", "Multiplier applied to the default flashlight size.")] public abstract BindableFloat SizeMultiplier { get; } diff --git a/osu.Game/Rulesets/Mods/ModHalfTime.cs b/osu.Game/Rulesets/Mods/ModHalfTime.cs index 13d89e30d6..8d8b97e79e 100644 --- a/osu.Game/Rulesets/Mods/ModHalfTime.cs +++ b/osu.Game/Rulesets/Mods/ModHalfTime.cs @@ -3,6 +3,7 @@ using osu.Framework.Bindables; using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; using osu.Game.Configuration; using osu.Game.Graphics; @@ -14,7 +15,7 @@ namespace osu.Game.Rulesets.Mods public override string Acronym => "HT"; public override IconUsage? Icon => OsuIcon.ModHalftime; public override ModType Type => ModType.DifficultyReduction; - public override string Description => "Less zoom..."; + public override LocalisableString Description => "Less zoom..."; [SettingSource("Speed decrease", "The actual decrease to apply")] public override BindableNumber SpeedChange { get; } = new BindableDouble diff --git a/osu.Game/Rulesets/Mods/ModHardRock.cs b/osu.Game/Rulesets/Mods/ModHardRock.cs index 0a5348a8cf..2886e59c54 100644 --- a/osu.Game/Rulesets/Mods/ModHardRock.cs +++ b/osu.Game/Rulesets/Mods/ModHardRock.cs @@ -3,6 +3,7 @@ using System; using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; using osu.Game.Beatmaps; using osu.Game.Graphics; @@ -14,7 +15,7 @@ namespace osu.Game.Rulesets.Mods public override string Acronym => "HR"; public override IconUsage? Icon => OsuIcon.ModHardRock; public override ModType Type => ModType.DifficultyIncrease; - public override string Description => "Everything just got a bit harder..."; + public override LocalisableString Description => "Everything just got a bit harder..."; public override Type[] IncompatibleMods => new[] { typeof(ModEasy), typeof(ModDifficultyAdjust) }; public void ReadFromDifficulty(IBeatmapDifficultyInfo difficulty) diff --git a/osu.Game/Rulesets/Mods/ModMuted.cs b/osu.Game/Rulesets/Mods/ModMuted.cs index 55d5abfa82..9735d6b536 100644 --- a/osu.Game/Rulesets/Mods/ModMuted.cs +++ b/osu.Game/Rulesets/Mods/ModMuted.cs @@ -22,7 +22,7 @@ namespace osu.Game.Rulesets.Mods public override string Name => "Muted"; public override string Acronym => "MU"; public override IconUsage? Icon => FontAwesome.Solid.VolumeMute; - public override string Description => "Can you still feel the rhythm without music?"; + public override LocalisableString Description => "Can you still feel the rhythm without music?"; public override ModType Type => ModType.Fun; public override double ScoreMultiplier => 1; } diff --git a/osu.Game/Rulesets/Mods/ModNightcore.cs b/osu.Game/Rulesets/Mods/ModNightcore.cs index c4417ec509..099bf386f3 100644 --- a/osu.Game/Rulesets/Mods/ModNightcore.cs +++ b/osu.Game/Rulesets/Mods/ModNightcore.cs @@ -7,6 +7,7 @@ using osu.Framework.Audio.Track; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; using osu.Game.Audio; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Beatmaps.Timing; @@ -23,7 +24,7 @@ namespace osu.Game.Rulesets.Mods public override string Name => "Nightcore"; public override string Acronym => "NC"; public override IconUsage? Icon => OsuIcon.ModNightcore; - public override string Description => "Uguuuuuuuu..."; + public override LocalisableString Description => "Uguuuuuuuu..."; } public abstract class ModNightcore : ModNightcore, IApplicableToDrawableRuleset diff --git a/osu.Game/Rulesets/Mods/ModNoFail.cs b/osu.Game/Rulesets/Mods/ModNoFail.cs index 5ebae17228..31bb4338b3 100644 --- a/osu.Game/Rulesets/Mods/ModNoFail.cs +++ b/osu.Game/Rulesets/Mods/ModNoFail.cs @@ -3,6 +3,7 @@ using System; using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; using osu.Game.Graphics; namespace osu.Game.Rulesets.Mods @@ -13,7 +14,7 @@ namespace osu.Game.Rulesets.Mods public override string Acronym => "NF"; public override IconUsage? Icon => OsuIcon.ModNoFail; public override ModType Type => ModType.DifficultyReduction; - public override string Description => "You can't fail, no matter what."; + public override LocalisableString Description => "You can't fail, no matter what."; public override double ScoreMultiplier => 0.5; public override Type[] IncompatibleMods => new[] { typeof(ModRelax), typeof(ModFailCondition), typeof(ModAutoplay) }; } diff --git a/osu.Game/Rulesets/Mods/ModNoMod.cs b/osu.Game/Rulesets/Mods/ModNoMod.cs index 1009c5bc42..5dd4b317e7 100644 --- a/osu.Game/Rulesets/Mods/ModNoMod.cs +++ b/osu.Game/Rulesets/Mods/ModNoMod.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; namespace osu.Game.Rulesets.Mods { @@ -12,7 +13,7 @@ namespace osu.Game.Rulesets.Mods { public override string Name => "No Mod"; public override string Acronym => "NM"; - public override string Description => "No mods applied."; + public override LocalisableString Description => "No mods applied."; public override double ScoreMultiplier => 1; public override IconUsage? Icon => FontAwesome.Solid.Ban; public override ModType Type => ModType.System; diff --git a/osu.Game/Rulesets/Mods/ModPerfect.cs b/osu.Game/Rulesets/Mods/ModPerfect.cs index 9016a24f8d..804f23b6b7 100644 --- a/osu.Game/Rulesets/Mods/ModPerfect.cs +++ b/osu.Game/Rulesets/Mods/ModPerfect.cs @@ -4,6 +4,7 @@ using System; using System.Linq; using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; using osu.Game.Graphics; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Scoring; @@ -17,7 +18,7 @@ namespace osu.Game.Rulesets.Mods public override IconUsage? Icon => OsuIcon.ModPerfect; public override ModType Type => ModType.DifficultyIncrease; public override double ScoreMultiplier => 1; - public override string Description => "SS or quit."; + public override LocalisableString Description => "SS or quit."; public override Type[] IncompatibleMods => base.IncompatibleMods.Append(typeof(ModSuddenDeath)).ToArray(); diff --git a/osu.Game/Rulesets/Mods/ModSuddenDeath.cs b/osu.Game/Rulesets/Mods/ModSuddenDeath.cs index c8b835f78a..4e4e8662e8 100644 --- a/osu.Game/Rulesets/Mods/ModSuddenDeath.cs +++ b/osu.Game/Rulesets/Mods/ModSuddenDeath.cs @@ -4,6 +4,7 @@ using System; using System.Linq; using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; using osu.Game.Graphics; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Scoring; @@ -16,7 +17,7 @@ namespace osu.Game.Rulesets.Mods public override string Acronym => "SD"; public override IconUsage? Icon => OsuIcon.ModSuddenDeath; public override ModType Type => ModType.DifficultyIncrease; - public override string Description => "Miss and fail."; + public override LocalisableString Description => "Miss and fail."; public override double ScoreMultiplier => 1; public override Type[] IncompatibleMods => base.IncompatibleMods.Append(typeof(ModPerfect)).ToArray(); diff --git a/osu.Game/Rulesets/Mods/ModWindDown.cs b/osu.Game/Rulesets/Mods/ModWindDown.cs index 08bd44f7bd..e84bdab69c 100644 --- a/osu.Game/Rulesets/Mods/ModWindDown.cs +++ b/osu.Game/Rulesets/Mods/ModWindDown.cs @@ -5,6 +5,7 @@ using System; using System.Linq; using osu.Framework.Bindables; using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; using osu.Game.Configuration; namespace osu.Game.Rulesets.Mods @@ -13,7 +14,7 @@ namespace osu.Game.Rulesets.Mods { public override string Name => "Wind Down"; public override string Acronym => "WD"; - public override string Description => "Sloooow doooown..."; + public override LocalisableString Description => "Sloooow doooown..."; public override IconUsage? Icon => FontAwesome.Solid.ChevronCircleDown; public override double ScoreMultiplier => 1.0; diff --git a/osu.Game/Rulesets/Mods/ModWindUp.cs b/osu.Game/Rulesets/Mods/ModWindUp.cs index df8f781148..39cee50f96 100644 --- a/osu.Game/Rulesets/Mods/ModWindUp.cs +++ b/osu.Game/Rulesets/Mods/ModWindUp.cs @@ -5,6 +5,7 @@ using System; using System.Linq; using osu.Framework.Bindables; using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; using osu.Game.Configuration; namespace osu.Game.Rulesets.Mods @@ -13,7 +14,7 @@ namespace osu.Game.Rulesets.Mods { public override string Name => "Wind Up"; public override string Acronym => "WU"; - public override string Description => "Can you keep up?"; + public override LocalisableString Description => "Can you keep up?"; public override IconUsage? Icon => FontAwesome.Solid.ChevronCircleUp; public override double ScoreMultiplier => 1.0; diff --git a/osu.Game/Rulesets/Mods/MultiMod.cs b/osu.Game/Rulesets/Mods/MultiMod.cs index 1c41c6b8b3..9fbc0ddd97 100644 --- a/osu.Game/Rulesets/Mods/MultiMod.cs +++ b/osu.Game/Rulesets/Mods/MultiMod.cs @@ -3,6 +3,7 @@ using System; using System.Linq; +using osu.Framework.Localisation; namespace osu.Game.Rulesets.Mods { @@ -10,7 +11,7 @@ namespace osu.Game.Rulesets.Mods { public override string Name => string.Empty; public override string Acronym => string.Empty; - public override string Description => string.Empty; + public override LocalisableString Description => string.Empty; public override double ScoreMultiplier => 0; public Mod[] Mods { get; } diff --git a/osu.Game/Rulesets/Mods/UnknownMod.cs b/osu.Game/Rulesets/Mods/UnknownMod.cs index 72de0ad653..abe05996ff 100644 --- a/osu.Game/Rulesets/Mods/UnknownMod.cs +++ b/osu.Game/Rulesets/Mods/UnknownMod.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Framework.Localisation; + namespace osu.Game.Rulesets.Mods { public class UnknownMod : Mod @@ -12,7 +14,7 @@ namespace osu.Game.Rulesets.Mods public override string Name => $"Unknown mod ({OriginalAcronym})"; public override string Acronym => $"{OriginalAcronym}??"; - public override string Description => "This mod could not be resolved by the game."; + public override LocalisableString Description => "This mod could not be resolved by the game."; public override double ScoreMultiplier => 0; public override bool UserPlayable => false; diff --git a/osu.Game/Rulesets/RealmRulesetStore.cs b/osu.Game/Rulesets/RealmRulesetStore.cs index 62a7a13c07..dba7f47f2f 100644 --- a/osu.Game/Rulesets/RealmRulesetStore.cs +++ b/osu.Game/Rulesets/RealmRulesetStore.cs @@ -5,8 +5,8 @@ using System; using System.Collections.Generic; using System.Linq; using osu.Framework.Extensions.ObjectExtensions; -using osu.Framework.Logging; using osu.Framework.Platform; +using osu.Game.Beatmaps; using osu.Game.Database; namespace osu.Game.Rulesets @@ -68,8 +68,14 @@ namespace osu.Game.Rulesets { try { - var resolvedType = Type.GetType(r.InstantiationInfo) - ?? throw new RulesetLoadException(@"Type could not be resolved"); + var resolvedType = Type.GetType(r.InstantiationInfo); + + if (resolvedType == null) + { + // ruleset DLL was probably deleted. + r.Available = false; + continue; + } var instanceInfo = (Activator.CreateInstance(resolvedType) as Ruleset)?.RulesetInfo ?? throw new RulesetLoadException(@"Instantiation failure"); @@ -83,17 +89,35 @@ namespace osu.Game.Rulesets r.InstantiationInfo = instanceInfo.InstantiationInfo; r.Available = true; + testRulesetCompatibility(r); + detachedRulesets.Add(r.Clone()); } catch (Exception ex) { r.Available = false; - Logger.Log($"Could not load ruleset {r}: {ex.Message}"); + LogFailedLoad(r.Name, ex); } } availableRulesets.AddRange(detachedRulesets.OrderBy(r => r)); }); } + + private void testRulesetCompatibility(RulesetInfo rulesetInfo) + { + // do various operations to ensure that we are in a good state. + // if we can avoid loading the ruleset at this point (rather than erroring later in runtime) then that is preferred. + var instance = rulesetInfo.CreateInstance(); + + instance.CreateAllMods(); + instance.CreateIcon(); + instance.CreateResourceStore(); + + var beatmap = new Beatmap(); + var converter = instance.CreateBeatmapConverter(beatmap); + + instance.CreateBeatmapProcessor(converter.Convert()); + } } } diff --git a/osu.Game/Rulesets/Ruleset.cs b/osu.Game/Rulesets/Ruleset.cs index c1ec6c30ef..0968d78ed7 100644 --- a/osu.Game/Rulesets/Ruleset.cs +++ b/osu.Game/Rulesets/Ruleset.cs @@ -28,6 +28,7 @@ using osu.Game.Users; using JetBrains.Annotations; using osu.Framework.Extensions; using osu.Framework.Extensions.EnumExtensions; +using osu.Framework.Localisation; using osu.Framework.Testing; using osu.Game.Extensions; using osu.Game.Rulesets.Filter; @@ -288,7 +289,7 @@ namespace osu.Game.Rulesets /// /// The variant. /// A descriptive name of the variant. - public virtual string GetVariantName(int variant) => string.Empty; + public virtual LocalisableString GetVariantName(int variant) => string.Empty; /// /// For rulesets which support legacy (osu-stable) replay conversion, this method will create an empty replay frame @@ -313,7 +314,7 @@ namespace osu.Game.Rulesets /// /// All valid s along with a display-friendly name. /// - public IEnumerable<(HitResult result, string displayName)> GetHitResults() + public IEnumerable<(HitResult result, LocalisableString displayName)> GetHitResults() { var validResults = GetValidHitResults(); @@ -351,7 +352,7 @@ namespace osu.Game.Rulesets /// /// The result type to get the name for. /// The display name. - public virtual string GetDisplayNameForHitResult(HitResult result) => result.GetDescription(); + public virtual LocalisableString GetDisplayNameForHitResult(HitResult result) => result.GetLocalisableDescription(); /// /// Creates ruleset-specific beatmap filter criteria to be used on the song select screen. diff --git a/osu.Game/Rulesets/RulesetStore.cs b/osu.Game/Rulesets/RulesetStore.cs index 6b3e43cc1c..fdbcd0ed1e 100644 --- a/osu.Game/Rulesets/RulesetStore.cs +++ b/osu.Game/Rulesets/RulesetStore.cs @@ -138,7 +138,7 @@ namespace osu.Game.Rulesets } catch (Exception e) { - Logger.Error(e, $"Failed to load ruleset {filename}"); + LogFailedLoad(filename, e); } } @@ -158,7 +158,7 @@ namespace osu.Game.Rulesets } catch (Exception e) { - Logger.Error(e, $"Failed to add ruleset {assembly}"); + LogFailedLoad(assembly.FullName, e); } } @@ -173,6 +173,12 @@ namespace osu.Game.Rulesets AppDomain.CurrentDomain.AssemblyResolve -= resolveRulesetDependencyAssembly; } + protected void LogFailedLoad(string name, Exception exception) + { + Logger.Log($"Could not load ruleset {name}. Please check for an update from the developer.", level: LogLevel.Error); + Logger.Log($"Ruleset load failed: {exception}"); + } + #region Implementation of IRulesetStore IRulesetInfo? IRulesetStore.GetRuleset(int id) => GetRuleset(id); diff --git a/osu.Game/Rulesets/Scoring/HitResult.cs b/osu.Game/Rulesets/Scoring/HitResult.cs index bfa256fc20..5047fdea82 100644 --- a/osu.Game/Rulesets/Scoring/HitResult.cs +++ b/osu.Game/Rulesets/Scoring/HitResult.cs @@ -119,8 +119,20 @@ namespace osu.Game.Rulesets.Scoring [EnumMember(Value = "ignore_hit")] [Order(12)] IgnoreHit, + + /// + /// A special result used as a padding value for legacy rulesets. It is a hit type and affects combo, but does not affect the base score (does not affect accuracy). + /// + /// + /// DO NOT USE. + /// + [EnumMember(Value = "legacy_combo_increase")] + [Order(99)] + [Obsolete("Do not use.")] + LegacyComboIncrease = 99 } +#pragma warning disable CS0618 public static class HitResultExtensions { /// @@ -150,6 +162,7 @@ namespace osu.Game.Rulesets.Scoring case HitResult.Perfect: case HitResult.LargeTickHit: case HitResult.LargeTickMiss: + case HitResult.LegacyComboIncrease: return true; default: @@ -161,13 +174,25 @@ namespace osu.Game.Rulesets.Scoring /// Whether a affects the accuracy portion of the score. /// public static bool AffectsAccuracy(this HitResult result) - => IsScorable(result) && !IsBonus(result); + { + // LegacyComboIncrease is a special type which is neither a basic, tick, bonus, or accuracy-affecting result. + if (result == HitResult.LegacyComboIncrease) + return false; + + return IsScorable(result) && !IsBonus(result); + } /// /// Whether a is a non-tick and non-bonus result. /// public static bool IsBasic(this HitResult result) - => IsScorable(result) && !IsTick(result) && !IsBonus(result); + { + // LegacyComboIncrease is a special type which is neither a basic, tick, bonus, or accuracy-affecting result. + if (result == HitResult.LegacyComboIncrease) + return false; + + return IsScorable(result) && !IsTick(result) && !IsBonus(result); + } /// /// Whether a should be counted as a tick. @@ -225,12 +250,19 @@ namespace osu.Game.Rulesets.Scoring /// /// Whether a is scorable. /// - public static bool IsScorable(this HitResult result) => result >= HitResult.Miss && result < HitResult.IgnoreMiss; + public static bool IsScorable(this HitResult result) + { + // LegacyComboIncrease is not actually scorable (in terms of usable by rulesets for that purpose), but needs to be defined as such to be correctly included in statistics output. + if (result == HitResult.LegacyComboIncrease) + return true; + + return result >= HitResult.Miss && result < HitResult.IgnoreMiss; + } /// /// An array of all scorable s. /// - public static readonly HitResult[] ALL_TYPES = ((HitResult[])Enum.GetValues(typeof(HitResult))).ToArray(); + public static readonly HitResult[] ALL_TYPES = ((HitResult[])Enum.GetValues(typeof(HitResult))).Except(new[] { HitResult.LegacyComboIncrease }).ToArray(); /// /// Whether a is valid within a given range. @@ -251,4 +283,5 @@ namespace osu.Game.Rulesets.Scoring return result > minResult && result < maxResult; } } +#pragma warning restore CS0618 } diff --git a/osu.Game/Rulesets/Scoring/JudgementProcessor.cs b/osu.Game/Rulesets/Scoring/JudgementProcessor.cs index 12fe0056bb..bc8f2c22f3 100644 --- a/osu.Game/Rulesets/Scoring/JudgementProcessor.cs +++ b/osu.Game/Rulesets/Scoring/JudgementProcessor.cs @@ -61,6 +61,11 @@ namespace osu.Game.Rulesets.Scoring /// The to apply. public void ApplyResult(JudgementResult result) { +#pragma warning disable CS0618 + if (result.Type == HitResult.LegacyComboIncrease) + throw new ArgumentException(@$"A {nameof(HitResult.LegacyComboIncrease)} hit result cannot be applied."); +#pragma warning restore CS0618 + JudgedHits++; lastAppliedResult = result; diff --git a/osu.Game/Rulesets/Scoring/ScoreProcessor.cs b/osu.Game/Rulesets/Scoring/ScoreProcessor.cs index 1deac9f08a..5b21caee84 100644 --- a/osu.Game/Rulesets/Scoring/ScoreProcessor.cs +++ b/osu.Game/Rulesets/Scoring/ScoreProcessor.cs @@ -128,8 +128,7 @@ namespace osu.Game.Rulesets.Scoring private bool beatmapApplied; private readonly Dictionary scoreResultCounts = new Dictionary(); - - private Dictionary? maximumResultCounts; + private readonly Dictionary maximumResultCounts = new Dictionary(); private readonly List hitEvents = new List(); private HitObject? lastHitObject; @@ -405,8 +404,6 @@ namespace osu.Game.Rulesets.Scoring return ScoreRank.D; } - public int GetStatistic(HitResult result) => scoreResultCounts.GetValueOrDefault(result); - /// /// Resets this ScoreProcessor to a default state. /// @@ -421,7 +418,9 @@ namespace osu.Game.Rulesets.Scoring if (storeResults) { maximumScoringValues = currentScoringValues; - maximumResultCounts = new Dictionary(scoreResultCounts); + + maximumResultCounts.Clear(); + maximumResultCounts.AddRange(scoreResultCounts); } scoreResultCounts.Clear(); @@ -449,7 +448,10 @@ namespace osu.Game.Rulesets.Scoring score.HitEvents = hitEvents; foreach (var result in HitResultExtensions.ALL_TYPES) - score.Statistics[result] = GetStatistic(result); + score.Statistics[result] = scoreResultCounts.GetValueOrDefault(result); + + foreach (var result in HitResultExtensions.ALL_TYPES) + score.MaximumStatistics[result] = maximumResultCounts.GetValueOrDefault(result); // Populate total score after everything else. score.TotalScore = (long)Math.Round(ComputeFinalScore(ScoringMode.Standardised, score)); @@ -534,6 +536,9 @@ namespace osu.Game.Rulesets.Scoring { extractScoringValues(scoreInfo.Statistics, out current, out maximum); current.MaxCombo = scoreInfo.MaxCombo; + + if (scoreInfo.MaximumStatistics.Count > 0) + extractScoringValues(scoreInfo.MaximumStatistics, out _, out maximum); } /// @@ -589,7 +594,8 @@ namespace osu.Game.Rulesets.Scoring if (result.IsBonus()) current.BonusScore += count * Judgement.ToNumericResult(result); - else + + if (result.AffectsAccuracy()) { // The maximum result of this judgement if it wasn't a miss. // E.g. For a GOOD judgement, the max result is either GREAT/PERFECT depending on which one the ruleset uses (osu!: GREAT, osu!mania: PERFECT). diff --git a/osu.Game/Rulesets/UI/FrameStabilityContainer.cs b/osu.Game/Rulesets/UI/FrameStabilityContainer.cs index e41802808f..8f3e077050 100644 --- a/osu.Game/Rulesets/UI/FrameStabilityContainer.cs +++ b/osu.Game/Rulesets/UI/FrameStabilityContainer.cs @@ -86,7 +86,7 @@ namespace osu.Game.Rulesets.UI this.gameplayStartTime = gameplayStartTime; } - [BackgroundDependencyLoader] + [BackgroundDependencyLoader(true)] private void load(IGameplayClock? gameplayClock) { if (gameplayClock != null) diff --git a/osu.Game/Scoring/HitResultDisplayStatistic.cs b/osu.Game/Scoring/HitResultDisplayStatistic.cs index 4603ff053e..20deff4875 100644 --- a/osu.Game/Scoring/HitResultDisplayStatistic.cs +++ b/osu.Game/Scoring/HitResultDisplayStatistic.cs @@ -3,6 +3,7 @@ #nullable disable +using osu.Framework.Localisation; using osu.Game.Rulesets.Scoring; namespace osu.Game.Scoring @@ -30,9 +31,9 @@ namespace osu.Game.Scoring /// /// A custom display name for the result type. May be provided by rulesets to give better clarity. /// - public string DisplayName { get; } + public LocalisableString DisplayName { get; } - public HitResultDisplayStatistic(HitResult result, int count, int? maxCount, string displayName) + public HitResultDisplayStatistic(HitResult result, int count, int? maxCount, LocalisableString displayName) { Result = result; Count = count; diff --git a/osu.Game/Scoring/ScoreImporter.cs b/osu.Game/Scoring/ScoreImporter.cs index 0902f1636b..45f827354e 100644 --- a/osu.Game/Scoring/ScoreImporter.cs +++ b/osu.Game/Scoring/ScoreImporter.cs @@ -73,6 +73,9 @@ namespace osu.Game.Scoring if (string.IsNullOrEmpty(model.StatisticsJson)) model.StatisticsJson = JsonConvert.SerializeObject(model.Statistics); + + if (string.IsNullOrEmpty(model.MaximumStatisticsJson)) + model.MaximumStatisticsJson = JsonConvert.SerializeObject(model.MaximumStatistics); } protected override void PostImport(ScoreInfo model, Realm realm, bool batchImport) diff --git a/osu.Game/Scoring/ScoreInfo.cs b/osu.Game/Scoring/ScoreInfo.cs index d32d611a27..25a7bad9e8 100644 --- a/osu.Game/Scoring/ScoreInfo.cs +++ b/osu.Game/Scoring/ScoreInfo.cs @@ -63,6 +63,9 @@ namespace osu.Game.Scoring [MapTo("Statistics")] public string StatisticsJson { get; set; } = string.Empty; + [MapTo("MaximumStatistics")] + public string MaximumStatisticsJson { get; set; } = string.Empty; + public ScoreInfo(BeatmapInfo? beatmap = null, RulesetInfo? ruleset = null, RealmUser? realmUser = null) { Ruleset = ruleset ?? new RulesetInfo(); @@ -133,6 +136,7 @@ namespace osu.Game.Scoring var clone = (ScoreInfo)this.Detach().MemberwiseClone(); clone.Statistics = new Dictionary(clone.Statistics); + clone.MaximumStatistics = new Dictionary(clone.MaximumStatistics); clone.RealmUser = new RealmUser { OnlineID = RealmUser.OnlineID, @@ -181,6 +185,24 @@ namespace osu.Game.Scoring set => statistics = value; } + private Dictionary? maximumStatistics; + + [Ignored] + public Dictionary MaximumStatistics + { + get + { + if (maximumStatistics != null) + return maximumStatistics; + + if (!string.IsNullOrEmpty(MaximumStatisticsJson)) + maximumStatistics = JsonConvert.DeserializeObject>(MaximumStatisticsJson); + + return maximumStatistics ??= new Dictionary(); + } + set => maximumStatistics = value; + } + private Mod[]? mods; [Ignored] diff --git a/osu.Game/Screens/Edit/Setup/ColoursSection.cs b/osu.Game/Screens/Edit/Setup/ColoursSection.cs index 621abf5580..e3fcdedd1b 100644 --- a/osu.Game/Screens/Edit/Setup/ColoursSection.cs +++ b/osu.Game/Screens/Edit/Setup/ColoursSection.cs @@ -7,12 +7,13 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Localisation; using osu.Game.Graphics.UserInterfaceV2; +using osu.Game.Localisation; namespace osu.Game.Screens.Edit.Setup { internal class ColoursSection : SetupSection { - public override LocalisableString Title => "Colours"; + public override LocalisableString Title => EditorSetupStrings.ColoursHeader; private LabelledColourPalette comboColours; @@ -23,9 +24,9 @@ namespace osu.Game.Screens.Edit.Setup { comboColours = new LabelledColourPalette { - Label = "Hitcircle / Slider Combos", + Label = EditorSetupStrings.HitCircleSliderCombos, FixedLabelWidth = LABEL_WIDTH, - ColourNamePrefix = "Combo" + ColourNamePrefix = EditorSetupStrings.ComboColourPrefix } }; diff --git a/osu.Game/Screens/Edit/Setup/DesignSection.cs b/osu.Game/Screens/Edit/Setup/DesignSection.cs index 5cec730440..cc3e9b91ab 100644 --- a/osu.Game/Screens/Edit/Setup/DesignSection.cs +++ b/osu.Game/Screens/Edit/Setup/DesignSection.cs @@ -13,6 +13,7 @@ using osu.Framework.Localisation; using osu.Game.Beatmaps; using osu.Game.Graphics.UserInterfaceV2; using osuTK; +using osu.Game.Localisation; namespace osu.Game.Screens.Edit.Setup { @@ -29,7 +30,7 @@ namespace osu.Game.Screens.Edit.Setup private LabelledSwitchButton letterboxDuringBreaks; private LabelledSwitchButton samplesMatchPlaybackRate; - public override LocalisableString Title => "Design"; + public override LocalisableString Title => EditorSetupStrings.DesignHeader; [BackgroundDependencyLoader] private void load() @@ -38,9 +39,9 @@ namespace osu.Game.Screens.Edit.Setup { EnableCountdown = new LabelledSwitchButton { - Label = "Enable countdown", + Label = EditorSetupStrings.EnableCountdown, Current = { Value = Beatmap.BeatmapInfo.Countdown != CountdownType.None }, - Description = "If enabled, an \"Are you ready? 3, 2, 1, GO!\" countdown will be inserted at the beginning of the beatmap, assuming there is enough time to do so." + Description = EditorSetupStrings.CountdownDescription }, CountdownSettings = new FillFlowContainer { @@ -52,41 +53,41 @@ namespace osu.Game.Screens.Edit.Setup { CountdownSpeed = new LabelledEnumDropdown { - Label = "Countdown speed", + Label = EditorSetupStrings.CountdownSpeed, Current = { Value = Beatmap.BeatmapInfo.Countdown != CountdownType.None ? Beatmap.BeatmapInfo.Countdown : CountdownType.Normal }, Items = Enum.GetValues(typeof(CountdownType)).Cast().Where(type => type != CountdownType.None) }, CountdownOffset = new LabelledNumberBox { - Label = "Countdown offset", + Label = EditorSetupStrings.CountdownOffset, Current = { Value = Beatmap.BeatmapInfo.CountdownOffset.ToString() }, - Description = "If the countdown sounds off-time, use this to make it appear one or more beats early.", + Description = EditorSetupStrings.CountdownOffsetDescription, } } }, Empty(), widescreenSupport = new LabelledSwitchButton { - Label = "Widescreen support", - Description = "Allows storyboards to use the full screen space, rather than be confined to a 4:3 area.", + Label = EditorSetupStrings.WidescreenSupport, + Description = EditorSetupStrings.WidescreenSupportDescription, Current = { Value = Beatmap.BeatmapInfo.WidescreenStoryboard } }, epilepsyWarning = new LabelledSwitchButton { - Label = "Epilepsy warning", - Description = "Recommended if the storyboard or video contain scenes with rapidly flashing colours.", + Label = EditorSetupStrings.EpilepsyWarning, + Description = EditorSetupStrings.EpilepsyWarningDescription, Current = { Value = Beatmap.BeatmapInfo.EpilepsyWarning } }, letterboxDuringBreaks = new LabelledSwitchButton { - Label = "Letterbox during breaks", - Description = "Adds horizontal letterboxing to give a cinematic look during breaks.", + Label = EditorSetupStrings.LetterboxDuringBreaks, + Description = EditorSetupStrings.LetterboxDuringBreaksDescription, Current = { Value = Beatmap.BeatmapInfo.LetterboxInBreaks } }, samplesMatchPlaybackRate = new LabelledSwitchButton { - Label = "Samples match playback rate", - Description = "When enabled, all samples will speed up or slow down when rate-changing mods are enabled.", + Label = EditorSetupStrings.SamplesMatchPlaybackRate, + Description = EditorSetupStrings.SamplesMatchPlaybackRateDescription, Current = { Value = Beatmap.BeatmapInfo.SamplesMatchPlaybackRate } } }; diff --git a/osu.Game/Screens/Edit/Setup/DifficultySection.cs b/osu.Game/Screens/Edit/Setup/DifficultySection.cs index ce44445683..01e31bd688 100644 --- a/osu.Game/Screens/Edit/Setup/DifficultySection.cs +++ b/osu.Game/Screens/Edit/Setup/DifficultySection.cs @@ -11,6 +11,7 @@ using osu.Framework.Localisation; using osu.Game.Beatmaps; using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Resources.Localisation.Web; +using osu.Game.Localisation; namespace osu.Game.Screens.Edit.Setup { @@ -21,7 +22,7 @@ namespace osu.Game.Screens.Edit.Setup private LabelledSliderBar approachRateSlider; private LabelledSliderBar overallDifficultySlider; - public override LocalisableString Title => "Difficulty"; + public override LocalisableString Title => EditorSetupStrings.DifficultyHeader; [BackgroundDependencyLoader] private void load() @@ -32,7 +33,7 @@ namespace osu.Game.Screens.Edit.Setup { Label = BeatmapsetsStrings.ShowStatsCs, FixedLabelWidth = LABEL_WIDTH, - Description = "The size of all hit objects", + Description = EditorSetupStrings.CircleSizeDescription, Current = new BindableFloat(Beatmap.Difficulty.CircleSize) { Default = BeatmapDifficulty.DEFAULT_DIFFICULTY, @@ -45,7 +46,7 @@ namespace osu.Game.Screens.Edit.Setup { Label = BeatmapsetsStrings.ShowStatsDrain, FixedLabelWidth = LABEL_WIDTH, - Description = "The rate of passive health drain throughout playable time", + Description = EditorSetupStrings.DrainRateDescription, Current = new BindableFloat(Beatmap.Difficulty.DrainRate) { Default = BeatmapDifficulty.DEFAULT_DIFFICULTY, @@ -58,7 +59,7 @@ namespace osu.Game.Screens.Edit.Setup { Label = BeatmapsetsStrings.ShowStatsAr, FixedLabelWidth = LABEL_WIDTH, - Description = "The speed at which objects are presented to the player", + Description = EditorSetupStrings.ApproachRateDescription, Current = new BindableFloat(Beatmap.Difficulty.ApproachRate) { Default = BeatmapDifficulty.DEFAULT_DIFFICULTY, @@ -71,7 +72,7 @@ namespace osu.Game.Screens.Edit.Setup { Label = BeatmapsetsStrings.ShowStatsAccuracy, FixedLabelWidth = LABEL_WIDTH, - Description = "The harshness of hit windows and difficulty of special objects (ie. spinners)", + Description = EditorSetupStrings.OverallDifficultyDescription, Current = new BindableFloat(Beatmap.Difficulty.OverallDifficulty) { Default = BeatmapDifficulty.DEFAULT_DIFFICULTY, diff --git a/osu.Game/Screens/Edit/Setup/MetadataSection.cs b/osu.Game/Screens/Edit/Setup/MetadataSection.cs index 928e5bc3b6..1da7a87f83 100644 --- a/osu.Game/Screens/Edit/Setup/MetadataSection.cs +++ b/osu.Game/Screens/Edit/Setup/MetadataSection.cs @@ -10,6 +10,7 @@ using osu.Framework.Localisation; using osu.Game.Beatmaps; using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Resources.Localisation.Web; +using osu.Game.Localisation; namespace osu.Game.Screens.Edit.Setup { @@ -26,7 +27,7 @@ namespace osu.Game.Screens.Edit.Setup private LabelledTextBox sourceTextBox; private LabelledTextBox tagsTextBox; - public override LocalisableString Title => "Metadata"; + public override LocalisableString Title => EditorSetupStrings.MetadataHeader; [BackgroundDependencyLoader] private void load() @@ -35,22 +36,22 @@ namespace osu.Game.Screens.Edit.Setup Children = new[] { - ArtistTextBox = createTextBox("Artist", + ArtistTextBox = createTextBox(EditorSetupStrings.Artist, !string.IsNullOrEmpty(metadata.ArtistUnicode) ? metadata.ArtistUnicode : metadata.Artist), - RomanisedArtistTextBox = createTextBox("Romanised Artist", + RomanisedArtistTextBox = createTextBox(EditorSetupStrings.RomanisedArtist, !string.IsNullOrEmpty(metadata.Artist) ? metadata.Artist : MetadataUtils.StripNonRomanisedCharacters(metadata.ArtistUnicode)), Empty(), - TitleTextBox = createTextBox("Title", + TitleTextBox = createTextBox(EditorSetupStrings.Title, !string.IsNullOrEmpty(metadata.TitleUnicode) ? metadata.TitleUnicode : metadata.Title), - RomanisedTitleTextBox = createTextBox("Romanised Title", + RomanisedTitleTextBox = createTextBox(EditorSetupStrings.RomanisedTitle, !string.IsNullOrEmpty(metadata.Title) ? metadata.Title : MetadataUtils.StripNonRomanisedCharacters(metadata.ArtistUnicode)), Empty(), - creatorTextBox = createTextBox("Creator", metadata.Author.Username), - difficultyTextBox = createTextBox("Difficulty Name", Beatmap.BeatmapInfo.DifficultyName), + creatorTextBox = createTextBox(EditorSetupStrings.Creator, metadata.Author.Username), + difficultyTextBox = createTextBox(EditorSetupStrings.DifficultyName, Beatmap.BeatmapInfo.DifficultyName), sourceTextBox = createTextBox(BeatmapsetsStrings.ShowInfoSource, metadata.Source), tagsTextBox = createTextBox(BeatmapsetsStrings.ShowInfoTags, metadata.Tags) }; diff --git a/osu.Game/Screens/Edit/Setup/ResourcesSection.cs b/osu.Game/Screens/Edit/Setup/ResourcesSection.cs index 69953bf659..cbaa2d8b42 100644 --- a/osu.Game/Screens/Edit/Setup/ResourcesSection.cs +++ b/osu.Game/Screens/Edit/Setup/ResourcesSection.cs @@ -10,6 +10,7 @@ using osu.Framework.Graphics; using osu.Framework.Localisation; using osu.Game.Beatmaps; using osu.Game.Overlays; +using osu.Game.Localisation; namespace osu.Game.Screens.Edit.Setup { @@ -18,7 +19,7 @@ namespace osu.Game.Screens.Edit.Setup private LabelledFileChooser audioTrackChooser; private LabelledFileChooser backgroundChooser; - public override LocalisableString Title => "Resources"; + public override LocalisableString Title => EditorSetupStrings.ResourcesHeader; [Resolved] private MusicController music { get; set; } @@ -42,13 +43,13 @@ namespace osu.Game.Screens.Edit.Setup { backgroundChooser = new LabelledFileChooser(".jpg", ".jpeg", ".png") { - Label = "Background", + Label = GameplaySettingsStrings.BackgroundHeader, FixedLabelWidth = LABEL_WIDTH, TabbableContentContainer = this }, audioTrackChooser = new LabelledFileChooser(".mp3", ".ogg") { - Label = "Audio Track", + Label = EditorSetupStrings.AudioTrack, FixedLabelWidth = LABEL_WIDTH, TabbableContentContainer = this }, @@ -143,12 +144,12 @@ namespace osu.Game.Screens.Edit.Setup private void updatePlaceholderText() { audioTrackChooser.Text = audioTrackChooser.Current.Value == null - ? "Click to select a track" - : "Click to replace the track"; + ? EditorSetupStrings.ClickToSelectTrack + : EditorSetupStrings.ClickToReplaceTrack; backgroundChooser.Text = backgroundChooser.Current.Value == null - ? "Click to select a background image" - : "Click to replace the background image"; + ? EditorSetupStrings.ClickToSelectBackground + : EditorSetupStrings.ClickToReplaceBackground; } } } diff --git a/osu.Game/Screens/Edit/Setup/RulesetSetupSection.cs b/osu.Game/Screens/Edit/Setup/RulesetSetupSection.cs index db0641feba..d6664e860b 100644 --- a/osu.Game/Screens/Edit/Setup/RulesetSetupSection.cs +++ b/osu.Game/Screens/Edit/Setup/RulesetSetupSection.cs @@ -5,12 +5,13 @@ using osu.Framework.Localisation; using osu.Game.Rulesets; +using osu.Game.Localisation; namespace osu.Game.Screens.Edit.Setup { public abstract class RulesetSetupSection : SetupSection { - public sealed override LocalisableString Title => $"Ruleset ({rulesetInfo.Name})"; + public sealed override LocalisableString Title => EditorSetupStrings.RulesetHeader(rulesetInfo.Name); private readonly RulesetInfo rulesetInfo; diff --git a/osu.Game/Screens/Edit/Setup/SetupScreenHeader.cs b/osu.Game/Screens/Edit/Setup/SetupScreenHeader.cs index c531f1da90..9486b3728b 100644 --- a/osu.Game/Screens/Edit/Setup/SetupScreenHeader.cs +++ b/osu.Game/Screens/Edit/Setup/SetupScreenHeader.cs @@ -4,6 +4,7 @@ #nullable disable using osu.Framework.Allocation; +using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; @@ -11,6 +12,7 @@ using osu.Framework.Graphics.UserInterface; using osu.Game.Graphics.Containers; using osu.Game.Overlays; using osuTK.Graphics; +using osu.Game.Localisation; namespace osu.Game.Screens.Edit.Setup { @@ -77,8 +79,8 @@ namespace osu.Game.Screens.Edit.Setup { public SetupScreenTitle() { - Title = "beatmap setup"; - Description = "change general settings of your beatmap"; + Title = EditorSetupStrings.BeatmapSetup.ToLower(); + Description = EditorSetupStrings.BeatmapSetupDescription; IconTexture = "Icons/Hexacons/social"; } } diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/CatchUpSpectatorPlayerClock.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/CatchUpSpectatorPlayerClock.cs deleted file mode 100644 index 34388bf9b1..0000000000 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/CatchUpSpectatorPlayerClock.cs +++ /dev/null @@ -1,96 +0,0 @@ -// 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 osu.Framework.Bindables; -using osu.Framework.Timing; - -namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate -{ - /// - /// A which catches up using rate adjustment. - /// - public class CatchUpSpectatorPlayerClock : ISpectatorPlayerClock - { - /// - /// The catch up rate. - /// - public const double CATCHUP_RATE = 2; - - /// - /// The source clock. - /// - public IFrameBasedClock? Source { get; set; } - - public double CurrentTime { get; private set; } - - public bool IsRunning { get; private set; } - - public void Reset() => CurrentTime = 0; - - public void Start() => IsRunning = true; - - public void Stop() => IsRunning = false; - - void IAdjustableClock.Start() - { - // Our running state should only be managed by an ISyncManager, ignore calls from external sources. - } - - void IAdjustableClock.Stop() - { - // Our running state should only be managed by an ISyncManager, ignore calls from external sources. - } - - public bool Seek(double position) - { - CurrentTime = position; - return true; - } - - public void ResetSpeedAdjustments() - { - } - - public double Rate => IsCatchingUp ? CATCHUP_RATE : 1; - - double IAdjustableClock.Rate - { - get => Rate; - set => throw new NotSupportedException(); - } - - double IClock.Rate => Rate; - - public void ProcessFrame() - { - ElapsedFrameTime = 0; - FramesPerSecond = 0; - - if (Source == null) - return; - - Source.ProcessFrame(); - - if (IsRunning) - { - double elapsedSource = Source.ElapsedFrameTime; - double elapsed = elapsedSource * Rate; - - CurrentTime += elapsed; - ElapsedFrameTime = elapsed; - FramesPerSecond = Source.FramesPerSecond; - } - } - - public double ElapsedFrameTime { get; private set; } - - public double FramesPerSecond { get; private set; } - - public FrameTimeInfo TimeInfo => new FrameTimeInfo { Elapsed = ElapsedFrameTime, Current = CurrentTime }; - - public Bindable WaitingOnFrames { get; } = new Bindable(true); - - public bool IsCatchingUp { get; set; } - } -} diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/ISpectatorPlayerClock.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/ISpectatorPlayerClock.cs deleted file mode 100644 index 194a3bdcc2..0000000000 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/ISpectatorPlayerClock.cs +++ /dev/null @@ -1,44 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -#nullable disable - -using osu.Framework.Bindables; -using osu.Framework.Timing; - -namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate -{ - /// - /// A clock which is used by s and managed by an . - /// - public interface ISpectatorPlayerClock : IFrameBasedClock, IAdjustableClock - { - /// - /// Starts this . - /// - new void Start(); - - /// - /// Stops this . - /// - new void Stop(); - - /// - /// Whether this clock is waiting on frames to continue playback. - /// - Bindable WaitingOnFrames { get; } - - /// - /// Whether this clock is behind the master clock and running at a higher rate to catch up to it. - /// - /// - /// Of note, this will be false if this clock is *ahead* of the master clock. - /// - bool IsCatchingUp { get; set; } - - /// - /// The source clock - /// - IFrameBasedClock Source { set; } - } -} diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/ISyncManager.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/ISyncManager.cs deleted file mode 100644 index 577100d4ba..0000000000 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/ISyncManager.cs +++ /dev/null @@ -1,44 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -#nullable disable - -using System; -using osu.Framework.Bindables; -using osu.Framework.Timing; - -namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate -{ - /// - /// Manages the synchronisation between one or more s in relation to a master clock. - /// - public interface ISyncManager - { - /// - /// An event which is invoked when gameplay is ready to start. - /// - event Action ReadyToStart; - - /// - /// The master clock which player clocks should synchronise to. - /// - IAdjustableClock MasterClock { get; } - - /// - /// An event which is invoked when the state of is changed. - /// - IBindable MasterState { get; } - - /// - /// Adds an to manage. - /// - /// The to add. - void AddPlayerClock(ISpectatorPlayerClock clock); - - /// - /// Removes an , stopping it from being managed by this . - /// - /// The to remove. - void RemovePlayerClock(ISpectatorPlayerClock clock); - } -} diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorPlayer.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorPlayer.cs index 68eae76030..d351d121c6 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorPlayer.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorPlayer.cs @@ -2,7 +2,6 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Allocation; -using osu.Framework.Bindables; using osu.Game.Beatmaps; using osu.Game.Scoring; using osu.Game.Screens.Play; @@ -14,15 +13,14 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate /// public class MultiSpectatorPlayer : SpectatorPlayer { - private readonly Bindable waitingOnFrames = new Bindable(true); - private readonly ISpectatorPlayerClock spectatorPlayerClock; + private readonly SpectatorPlayerClock spectatorPlayerClock; /// /// Creates a new . /// /// The score containing the player's replay. /// The clock controlling the gameplay running state. - public MultiSpectatorPlayer(Score score, ISpectatorPlayerClock spectatorPlayerClock) + public MultiSpectatorPlayer(Score score, SpectatorPlayerClock spectatorPlayerClock) : base(score, new PlayerConfiguration { AllowUserInteraction = false }) { this.spectatorPlayerClock = spectatorPlayerClock; @@ -31,8 +29,6 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate [BackgroundDependencyLoader] private void load() { - spectatorPlayerClock.WaitingOnFrames.BindTo(waitingOnFrames); - HUDOverlay.PlayerSettingsOverlay.Expire(); HUDOverlay.HoldToQuit.Expire(); } @@ -40,9 +36,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate protected override void Update() { // The player clock's running state is controlled externally, but the local pausing state needs to be updated to start/stop gameplay. - CatchUpSpectatorPlayerClock catchUpClock = (CatchUpSpectatorPlayerClock)GameplayClockContainer.SourceClock; - - if (catchUpClock.IsRunning) + if (GameplayClockContainer.SourceClock.IsRunning) GameplayClockContainer.Start(); else GameplayClockContainer.Stop(); @@ -55,7 +49,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate base.UpdateAfterChildren(); // This is required because the frame stable clock is set to WaitingOnFrames = false for one frame. - waitingOnFrames.Value = DrawableRuleset.FrameStableClock.WaitingOnFrames.Value || Score.Replay.Frames.Count == 0; + spectatorPlayerClock.WaitingOnFrames = DrawableRuleset.FrameStableClock.WaitingOnFrames.Value || Score.Replay.Frames.Count == 0; } protected override GameplayClockContainer CreateGameplayClockContainer(WorkingBeatmap beatmap, double gameplayStart) diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorScreen.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorScreen.cs index 7ed0be50e5..cb797d7aff 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorScreen.cs @@ -1,16 +1,12 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System; using System.Linq; -using JetBrains.Annotations; using osu.Framework.Allocation; -using osu.Framework.Bindables; +using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Game.Beatmaps; using osu.Game.Graphics; using osu.Game.Online.Multiplayer; using osu.Game.Online.Rooms; @@ -42,18 +38,17 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate protected override UserActivity InitialActivity => new UserActivity.SpectatingMultiplayerGame(Beatmap.Value.BeatmapInfo, Ruleset.Value); [Resolved] - private OsuColour colours { get; set; } + private OsuColour colours { get; set; } = null!; [Resolved] - private MultiplayerClient multiplayerClient { get; set; } + private MultiplayerClient multiplayerClient { get; set; } = null!; private readonly PlayerArea[] instances; - private MasterGameplayClockContainer masterClockContainer; - private ISyncManager syncManager; - private PlayerGrid grid; - private MultiSpectatorLeaderboard leaderboard; - private PlayerArea currentAudioSource; - private bool canStartMasterClock; + private MasterGameplayClockContainer masterClockContainer = null!; + private SpectatorSyncManager syncManager = null!; + private PlayerGrid grid = null!; + private MultiSpectatorLeaderboard leaderboard = null!; + private PlayerArea? currentAudioSource; private readonly Room room; private readonly MultiplayerRoomUser[] users; @@ -78,57 +73,58 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate FillFlowContainer leaderboardFlow; Container scoreDisplayContainer; - masterClockContainer = CreateMasterGameplayClockContainer(Beatmap.Value); - - InternalChildren = new[] + InternalChildren = new Drawable[] { - (Drawable)(syncManager = new CatchUpSyncManager(masterClockContainer)), - masterClockContainer.WithChild(new GridContainer + masterClockContainer = new MasterGameplayClockContainer(Beatmap.Value, 0) { - RelativeSizeAxes = Axes.Both, - RowDimensions = new[] { new Dimension(GridSizeMode.AutoSize) }, - Content = new[] + Child = new GridContainer { - new Drawable[] + RelativeSizeAxes = Axes.Both, + RowDimensions = new[] { new Dimension(GridSizeMode.AutoSize) }, + Content = new[] { - scoreDisplayContainer = new Container + new Drawable[] { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y - }, - }, - new Drawable[] - { - new GridContainer - { - RelativeSizeAxes = Axes.Both, - ColumnDimensions = new[] { new Dimension(GridSizeMode.AutoSize) }, - Content = new[] + scoreDisplayContainer = new Container { - new Drawable[] + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y + }, + }, + new Drawable[] + { + new GridContainer + { + RelativeSizeAxes = Axes.Both, + ColumnDimensions = new[] { new Dimension(GridSizeMode.AutoSize) }, + Content = new[] { - leaderboardFlow = new FillFlowContainer + new Drawable[] { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Vertical, - Spacing = new Vector2(5) - }, - grid = new PlayerGrid { RelativeSizeAxes = Axes.Both } + leaderboardFlow = new FillFlowContainer + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Spacing = new Vector2(5) + }, + grid = new PlayerGrid { RelativeSizeAxes = Axes.Both } + } } } } } } - }) + }, + syncManager = new SpectatorSyncManager(masterClockContainer) + { + ReadyToStart = performInitialSeek, + } }; for (int i = 0; i < Users.Count; i++) - { - grid.Add(instances[i] = new PlayerArea(Users[i], masterClockContainer)); - syncManager.AddPlayerClock(instances[i].GameplayClock); - } + grid.Add(instances[i] = new PlayerArea(Users[i], syncManager.CreateManagedClock())); LoadComponentAsync(leaderboard = new MultiSpectatorLeaderboard(users) { @@ -161,9 +157,6 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate base.LoadComplete(); masterClockContainer.Reset(); - - syncManager.ReadyToStart += onReadyToStart; - syncManager.MasterState.BindValueChanged(onMasterStateChanged, true); } protected override void Update() @@ -173,7 +166,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate if (!isCandidateAudioSource(currentAudioSource?.GameplayClock)) { currentAudioSource = instances.Where(i => isCandidateAudioSource(i.GameplayClock)) - .OrderBy(i => Math.Abs(i.GameplayClock.CurrentTime - syncManager.MasterClock.CurrentTime)) + .OrderBy(i => Math.Abs(i.GameplayClock.CurrentTime - syncManager.CurrentMasterTime)) .FirstOrDefault(); foreach (var instance in instances) @@ -181,40 +174,21 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate } } - private bool isCandidateAudioSource([CanBeNull] ISpectatorPlayerClock clock) - => clock?.IsRunning == true && !clock.IsCatchingUp && !clock.WaitingOnFrames.Value; + private bool isCandidateAudioSource(SpectatorPlayerClock? clock) + => clock?.IsRunning == true && !clock.IsCatchingUp && !clock.WaitingOnFrames; - private void onReadyToStart() + private void performInitialSeek() { // Seek the master clock to the gameplay time. // This is chosen as the first available frame in the players' replays, which matches the seek by each individual SpectatorPlayer. double startTime = instances.Where(i => i.Score != null) - .SelectMany(i => i.Score.Replay.Frames) + .SelectMany(i => i.Score.AsNonNull().Replay.Frames) .Select(f => f.Time) .DefaultIfEmpty(0) .Min(); masterClockContainer.StartTime = startTime; masterClockContainer.Reset(true); - - // Although the clock has been started, this flag is set to allow for later synchronisation state changes to also be able to start it. - canStartMasterClock = true; - } - - private void onMasterStateChanged(ValueChangedEvent state) - { - switch (state.NewValue) - { - case MasterClockState.Synchronised: - if (canStartMasterClock) - masterClockContainer.Start(); - - break; - - case MasterClockState.TooFarAhead: - masterClockContainer.Stop(); - break; - } } protected override void OnNewPlayingUserState(int userId, SpectatorState spectatorState) @@ -242,7 +216,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate var instance = instances.Single(i => i.UserId == userId); instance.FadeColour(colours.Gray4, 400, Easing.OutQuint); - syncManager.RemovePlayerClock(instance.GameplayClock); + syncManager.RemoveManagedClock(instance.GameplayClock); } public override bool OnBackButton() @@ -256,7 +230,5 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate return base.OnBackButton(); } - - protected virtual MasterGameplayClockContainer CreateMasterGameplayClockContainer(WorkingBeatmap beatmap) => new MasterGameplayClockContainer(beatmap, 0); } } diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/PlayerArea.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/PlayerArea.cs index 302d04b531..a1fbdc10de 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/PlayerArea.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/PlayerArea.cs @@ -1,17 +1,13 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System; using System.Collections.Generic; -using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Framework.Timing; using osu.Game.Beatmaps; using osu.Game.Graphics.UserInterface; using osu.Game.Rulesets; @@ -29,7 +25,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate /// /// Raised after is called on . /// - public event Action OnGameplayStarted; + public event Action? OnGameplayStarted; /// /// Whether a is loaded in the area. @@ -42,25 +38,27 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate public readonly int UserId; /// - /// The used to control the gameplay running state of a loaded . + /// The used to control the gameplay running state of a loaded . /// - [NotNull] - public readonly ISpectatorPlayerClock GameplayClock = new CatchUpSpectatorPlayerClock(); + public readonly SpectatorPlayerClock GameplayClock; /// /// The currently-loaded score. /// - [CanBeNull] - public Score Score { get; private set; } + public Score? Score { get; private set; } + + [Resolved] + private IBindable beatmap { get; set; } = null!; private readonly BindableDouble volumeAdjustment = new BindableDouble(); private readonly Container gameplayContent; private readonly LoadingLayer loadingLayer; - private OsuScreenStack stack; + private OsuScreenStack? stack; - public PlayerArea(int userId, IFrameBasedClock masterClock) + public PlayerArea(int userId, SpectatorPlayerClock clock) { UserId = userId; + GameplayClock = clock; RelativeSizeAxes = Axes.Both; Masking = true; @@ -77,14 +75,9 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate }; audioContainer.AddAdjustment(AdjustableProperty.Volume, volumeAdjustment); - - GameplayClock.Source = masterClock; } - [Resolved] - private IBindable beatmap { get; set; } - - public void LoadScore([NotNull] Score score) + public void LoadScore(Score score) { if (Score != null) throw new InvalidOperationException($"Cannot load a new score on a {nameof(PlayerArea)} that has an existing score."); diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/SpectatorPlayerClock.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/SpectatorPlayerClock.cs new file mode 100644 index 0000000000..7801f22437 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/SpectatorPlayerClock.cs @@ -0,0 +1,100 @@ +// 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 osu.Framework.Timing; +using osu.Game.Screens.Play; + +namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate +{ + /// + /// A clock which catches up using rate adjustment. + /// + public class SpectatorPlayerClock : IFrameBasedClock, IAdjustableClock + { + /// + /// The catch up rate. + /// + private const double catchup_rate = 2; + + private readonly GameplayClockContainer masterClock; + + public double CurrentTime { get; private set; } + + /// + /// Whether this clock is waiting on frames to continue playback. + /// + public bool WaitingOnFrames { get; set; } = true; + + /// + /// Whether this clock is behind the master clock and running at a higher rate to catch up to it. + /// + /// + /// Of note, this will be false if this clock is *ahead* of the master clock. + /// + public bool IsCatchingUp { get; set; } + + /// + /// Whether this spectator clock should be running. + /// Use instead of / to control time. + /// + public bool IsRunning { get; set; } + + public SpectatorPlayerClock(GameplayClockContainer masterClock) + { + this.masterClock = masterClock; + } + + public void Reset() => CurrentTime = 0; + + public void Start() + { + // Our running state should only be managed by SpectatorSyncManager via IsRunning. + } + + public void Stop() + { + // Our running state should only be managed by an SpectatorSyncManager via IsRunning. + } + + public bool Seek(double position) + { + CurrentTime = position; + return true; + } + + public void ResetSpeedAdjustments() + { + } + + public double Rate + { + get => IsCatchingUp ? catchup_rate : 1; + set => throw new NotImplementedException(); + } + + public void ProcessFrame() + { + if (IsRunning) + { + double elapsedSource = masterClock.ElapsedFrameTime; + double elapsed = elapsedSource * Rate; + + CurrentTime += elapsed; + ElapsedFrameTime = elapsed; + FramesPerSecond = masterClock.FramesPerSecond; + } + else + { + ElapsedFrameTime = 0; + FramesPerSecond = 0; + } + } + + public double ElapsedFrameTime { get; private set; } + + public double FramesPerSecond { get; private set; } + + public FrameTimeInfo TimeInfo => new FrameTimeInfo { Elapsed = ElapsedFrameTime, Current = CurrentTime }; + } +} diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/CatchUpSyncManager.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/SpectatorSyncManager.cs similarity index 66% rename from osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/CatchUpSyncManager.cs rename to osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/SpectatorSyncManager.cs index 663025923c..8d087aa25c 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/CatchUpSyncManager.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/SpectatorSyncManager.cs @@ -1,22 +1,19 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System; using System.Collections.Generic; -using System.Diagnostics; using System.Linq; -using osu.Framework.Bindables; using osu.Framework.Graphics; -using osu.Framework.Timing; +using osu.Framework.Logging; +using osu.Game.Screens.Play; namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate { /// - /// A which synchronises de-synced player clocks through catchup. + /// Manages the synchronisation between one or more s in relation to a master clock. /// - public class CatchUpSyncManager : Component, ISyncManager + public class SpectatorSyncManager : Component { /// /// The offset from the master clock to which player clocks should remain within to be considered in-sync. @@ -33,40 +30,53 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate /// public const double MAXIMUM_START_DELAY = 15000; - public event Action ReadyToStart; + /// + /// An event which is invoked when gameplay is ready to start. + /// + public Action? ReadyToStart; + + public double CurrentMasterTime => masterClock.CurrentTime; /// /// The master clock which is used to control the timing of all player clocks clocks. /// - public IAdjustableClock MasterClock { get; } - - public IBindable MasterState => masterState; + private readonly GameplayClockContainer masterClock; /// /// The player clocks. /// - private readonly List playerClocks = new List(); + private readonly List playerClocks = new List(); - private readonly Bindable masterState = new Bindable(); + private MasterClockState masterState = MasterClockState.Synchronised; private bool hasStarted; + private double? firstStartAttemptTime; - public CatchUpSyncManager(IAdjustableClock master) + public SpectatorSyncManager(GameplayClockContainer master) { - MasterClock = master; + masterClock = master; } - public void AddPlayerClock(ISpectatorPlayerClock clock) + /// + /// Create a new managed . + /// + /// The newly created . + public SpectatorPlayerClock CreateManagedClock() { - Debug.Assert(!playerClocks.Contains(clock)); + var clock = new SpectatorPlayerClock(masterClock); playerClocks.Add(clock); + return clock; } - public void RemovePlayerClock(ISpectatorPlayerClock clock) + /// + /// Removes an , stopping it from being managed by this . + /// + /// The to remove. + public void RemoveManagedClock(SpectatorPlayerClock clock) { playerClocks.Remove(clock); - clock.Stop(); + clock.IsRunning = false; } protected override void Update() @@ -77,7 +87,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate { // Ensure all player clocks are stopped until the start succeeds. foreach (var clock in playerClocks) - clock.Stop(); + clock.IsRunning = false; return; } @@ -97,7 +107,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate if (playerClocks.Count == 0) return false; - int readyCount = playerClocks.Count(s => !s.WaitingOnFrames.Value); + int readyCount = playerClocks.Count(s => !s.WaitingOnFrames); if (readyCount == playerClocks.Count) return performStart(); @@ -130,7 +140,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate // How far this player's clock is out of sync, compared to the master clock. // A negative value means the player is running fast (ahead); a positive value means the player is running behind (catching up). - double timeDelta = MasterClock.CurrentTime - clock.CurrentTime; + double timeDelta = masterClock.CurrentTime - clock.CurrentTime; // Check that the player clock isn't too far ahead. // This is a quiet case in which the catchup is done by the master clock, so IsCatchingUp is not set on the player clock. @@ -139,15 +149,12 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate // Importantly, set the clock to a non-catchup state. if this isn't done, updateMasterState may incorrectly pause the master clock // when it is required to be running (ie. if all players are ahead of the master). clock.IsCatchingUp = false; - clock.Stop(); + clock.IsRunning = false; continue; } // Make sure the player clock is running if it can. - if (!clock.WaitingOnFrames.Value) - clock.Start(); - else - clock.Stop(); + clock.IsRunning = !clock.WaitingOnFrames; if (clock.IsCatchingUp) { @@ -169,8 +176,26 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate /// private void updateMasterState() { - bool anyInSync = playerClocks.Any(s => !s.IsCatchingUp); - masterState.Value = anyInSync ? MasterClockState.Synchronised : MasterClockState.TooFarAhead; + MasterClockState newState = playerClocks.Any(s => !s.IsCatchingUp) ? MasterClockState.Synchronised : MasterClockState.TooFarAhead; + + if (masterState == newState) + return; + + masterState = newState; + Logger.Log($"{nameof(SpectatorSyncManager)}'s master clock become {masterState}"); + + switch (masterState) + { + case MasterClockState.Synchronised: + if (hasStarted) + masterClock.Start(); + + break; + + case MasterClockState.TooFarAhead: + masterClock.Stop(); + break; + } } } } diff --git a/osu.Game/Screens/Play/GameplayClock.cs b/osu.Game/Screens/Play/GameplayClock.cs deleted file mode 100644 index b650922173..0000000000 --- a/osu.Game/Screens/Play/GameplayClock.cs +++ /dev/null @@ -1,76 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -#nullable disable - -using System.Collections.Generic; -using System.Linq; -using osu.Framework.Bindables; -using osu.Framework.Timing; -using osu.Framework.Utils; - -namespace osu.Game.Screens.Play -{ - /// - /// A clock which is used for gameplay elements that need to follow audio time 1:1. - /// Exposed via DI by . - /// - /// The main purpose of this clock is to stop components using it from accidentally processing the main - /// , as this should only be done once to ensure accuracy. - /// - /// - public class GameplayClock : IGameplayClock - { - internal readonly IFrameBasedClock UnderlyingClock; - - public readonly BindableBool IsPaused = new BindableBool(); - - IBindable IGameplayClock.IsPaused => IsPaused; - - public virtual IEnumerable NonGameplayAdjustments => Enumerable.Empty(); - - public GameplayClock(IFrameBasedClock underlyingClock) - { - UnderlyingClock = underlyingClock; - } - - public double? StartTime { get; internal set; } - - public double CurrentTime => UnderlyingClock.CurrentTime; - - public double Rate => UnderlyingClock.Rate; - - public double TrueGameplayRate - { - get - { - double baseRate = Rate; - - foreach (double adjustment in NonGameplayAdjustments) - { - if (Precision.AlmostEquals(adjustment, 0)) - return 0; - - baseRate /= adjustment; - } - - return baseRate; - } - } - - public bool IsRunning => UnderlyingClock.IsRunning; - - public void ProcessFrame() - { - // intentionally not updating the underlying clock (handled externally). - } - - public double ElapsedFrameTime => UnderlyingClock.ElapsedFrameTime; - - public double FramesPerSecond => UnderlyingClock.FramesPerSecond; - - public FrameTimeInfo TimeInfo => UnderlyingClock.TimeInfo; - - public IClock Source => UnderlyingClock; - } -} diff --git a/osu.Game/Screens/Play/GameplayClockContainer.cs b/osu.Game/Screens/Play/GameplayClockContainer.cs index 27b37094ad..ac846b45c4 100644 --- a/osu.Game/Screens/Play/GameplayClockContainer.cs +++ b/osu.Game/Screens/Play/GameplayClockContainer.cs @@ -3,18 +3,19 @@ using System; using System.Collections.Generic; +using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; -using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Logging; using osu.Framework.Timing; +using osu.Framework.Utils; namespace osu.Game.Screens.Play { /// - /// Encapsulates gameplay timing logic and provides a via DI for gameplay components to use. + /// Encapsulates gameplay timing logic and provides a via DI for gameplay components to use. /// public class GameplayClockContainer : Container, IAdjustableClock, IGameplayClock { @@ -23,15 +24,8 @@ namespace osu.Game.Screens.Play /// public IBindable IsPaused => isPaused; - private readonly BindableBool isPaused = new BindableBool(true); - /// - /// The adjustable source clock used for gameplay. Should be used for seeks and clock control. - /// - protected readonly DecoupleableInterpolatingFramedClock AdjustableSource; - - /// - /// The source clock. + /// The source clock. Should generally not be used for any timekeeping purposes. /// public IClock SourceClock { get; private set; } @@ -40,8 +34,6 @@ namespace osu.Game.Screens.Play /// public event Action? OnSeek; - private double? startTime; - /// /// The time from which the clock should start. Will be seeked to on calling . /// @@ -49,24 +41,21 @@ namespace osu.Game.Screens.Play /// If not set, a value of zero will be used. /// Importantly, the value will be inferred from the current ruleset in unless specified. /// - public double? StartTime - { - get => startTime; - set - { - startTime = value; + public double? StartTime { get; set; } - if (GameplayClock.IsNotNull()) - GameplayClock.StartTime = value; - } - } - - public IEnumerable NonGameplayAdjustments => GameplayClock.NonGameplayAdjustments; + public virtual IEnumerable NonGameplayAdjustments => Enumerable.Empty(); /// /// The final clock which is exposed to gameplay components. /// - protected GameplayClock GameplayClock { get; private set; } = null!; + protected IFrameBasedClock FramedClock { get; private set; } + + private readonly BindableBool isPaused = new BindableBool(true); + + /// + /// The adjustable source clock used for gameplay. Should be used for seeks and clock control. + /// + private readonly DecoupleableInterpolatingFramedClock decoupledClock; /// /// Creates a new . @@ -78,21 +67,21 @@ namespace osu.Game.Screens.Play RelativeSizeAxes = Axes.Both; - AdjustableSource = new DecoupleableInterpolatingFramedClock { IsCoupled = false }; + decoupledClock = new DecoupleableInterpolatingFramedClock { IsCoupled = false }; IsPaused.BindValueChanged(OnIsPausedChanged); + + // this will be replaced during load, but non-null for tests which don't add this component to the hierarchy. + FramedClock = new FramedClock(); } protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) { var dependencies = new DependencyContainer(base.CreateChildDependencies(parent)); - GameplayClock = CreateGameplayClock(AdjustableSource); + FramedClock = CreateGameplayClock(decoupledClock); dependencies.CacheAs(this); - GameplayClock.StartTime = StartTime; - GameplayClock.IsPaused.BindTo(isPaused); - return dependencies; } @@ -103,13 +92,13 @@ namespace osu.Game.Screens.Play { ensureSourceClockSet(); - if (!AdjustableSource.IsRunning) + if (!decoupledClock.IsRunning) { // Seeking the decoupled clock to its current time ensures that its source clock will be seeked to the same time // This accounts for the clock source potentially taking time to enter a completely stopped state - Seek(GameplayClock.CurrentTime); + Seek(FramedClock.CurrentTime); - AdjustableSource.Start(); + decoupledClock.Start(); } isPaused.Value = false; @@ -123,10 +112,10 @@ namespace osu.Game.Screens.Play { Logger.Log($"{nameof(GameplayClockContainer)} seeking to {time}"); - AdjustableSource.Seek(time); + decoupledClock.Seek(time); // Manually process to make sure the gameplay clock is correctly updated after a seek. - GameplayClock.UnderlyingClock.ProcessFrame(); + FramedClock.ProcessFrame(); OnSeek?.Invoke(); } @@ -143,7 +132,7 @@ namespace osu.Game.Screens.Play public void Reset(bool startClock = false) { // Manually stop the source in order to not affect the IsPaused state. - AdjustableSource.Stop(); + decoupledClock.Stop(); if (!IsPaused.Value || startClock) Start(); @@ -156,10 +145,10 @@ namespace osu.Game.Screens.Play /// Changes the source clock. /// /// The new source. - protected void ChangeSource(IClock sourceClock) => AdjustableSource.ChangeSource(SourceClock = sourceClock); + protected void ChangeSource(IClock sourceClock) => decoupledClock.ChangeSource(SourceClock = sourceClock); /// - /// Ensures that the is set to , if it hasn't been given a source yet. + /// Ensures that the is set to , if it hasn't been given a source yet. /// This is usually done before a seek to avoid accidentally seeking only the adjustable source in decoupled mode, /// but not the actual source clock. /// That will pretty much only happen on the very first call of this method, as the source clock is passed in the constructor, @@ -167,39 +156,39 @@ namespace osu.Game.Screens.Play /// private void ensureSourceClockSet() { - if (AdjustableSource.Source == null) + if (decoupledClock.Source == null) ChangeSource(SourceClock); } protected override void Update() { if (!IsPaused.Value) - GameplayClock.UnderlyingClock.ProcessFrame(); + FramedClock.ProcessFrame(); base.Update(); } /// - /// Invoked when the value of is changed to start or stop the clock. + /// Invoked when the value of is changed to start or stop the clock. /// /// Whether the clock should now be paused. protected virtual void OnIsPausedChanged(ValueChangedEvent isPaused) { if (isPaused.NewValue) - AdjustableSource.Stop(); + decoupledClock.Stop(); else - AdjustableSource.Start(); + decoupledClock.Start(); } /// - /// Creates the final which is exposed via DI to be used by gameplay components. + /// Creates the final which is exposed via DI to be used by gameplay components. /// /// /// Any intermediate clocks such as platform offsets should be applied here. /// /// The providing the source time. - /// The final . - protected virtual GameplayClock CreateGameplayClock(IFrameBasedClock source) => new GameplayClock(source); + /// The final . + protected virtual IFrameBasedClock CreateGameplayClock(IFrameBasedClock source) => source; #region IAdjustableClock @@ -215,15 +204,15 @@ namespace osu.Game.Screens.Play double IAdjustableClock.Rate { - get => GameplayClock.Rate; + get => FramedClock.Rate; set => throw new NotSupportedException(); } - public double Rate => GameplayClock.Rate; + public double Rate => FramedClock.Rate; - public double CurrentTime => GameplayClock.CurrentTime; + public double CurrentTime => FramedClock.CurrentTime; - public bool IsRunning => GameplayClock.IsRunning; + public bool IsRunning => FramedClock.IsRunning; #endregion @@ -232,12 +221,28 @@ namespace osu.Game.Screens.Play // Handled via update. Don't process here to safeguard from external usages potentially processing frames additional times. } - public double ElapsedFrameTime => GameplayClock.ElapsedFrameTime; + public double ElapsedFrameTime => FramedClock.ElapsedFrameTime; - public double FramesPerSecond => GameplayClock.FramesPerSecond; + public double FramesPerSecond => FramedClock.FramesPerSecond; - public FrameTimeInfo TimeInfo => GameplayClock.TimeInfo; + public FrameTimeInfo TimeInfo => FramedClock.TimeInfo; - public double TrueGameplayRate => GameplayClock.TrueGameplayRate; + public double TrueGameplayRate + { + get + { + double baseRate = Rate; + + foreach (double adjustment in NonGameplayAdjustments) + { + if (Precision.AlmostEquals(adjustment, 0)) + return 0; + + baseRate /= adjustment; + } + + return baseRate; + } + } } } diff --git a/osu.Game/Screens/Play/MasterGameplayClockContainer.cs b/osu.Game/Screens/Play/MasterGameplayClockContainer.cs index 587d2d40a1..d26f0c6311 100644 --- a/osu.Game/Screens/Play/MasterGameplayClockContainer.cs +++ b/osu.Game/Screens/Play/MasterGameplayClockContainer.cs @@ -35,8 +35,6 @@ namespace osu.Game.Screens.Play /// public const double MINIMUM_SKIP_TIME = 1000; - protected Track Track => (Track)SourceClock; - public readonly BindableNumber UserPlaybackRate = new BindableDouble(1) { Default = 1, @@ -51,10 +49,10 @@ namespace osu.Game.Screens.Play private readonly WorkingBeatmap beatmap; - private HardwareCorrectionOffsetClock userGlobalOffsetClock = null!; - private HardwareCorrectionOffsetClock userBeatmapOffsetClock = null!; - private HardwareCorrectionOffsetClock platformOffsetClock = null!; - private MasterGameplayClock masterGameplayClock = null!; + private OffsetCorrectionClock userGlobalOffsetClock = null!; + private OffsetCorrectionClock userBeatmapOffsetClock = null!; + private OffsetCorrectionClock platformOffsetClock = null!; + private Bindable userAudioOffset = null!; private IDisposable? beatmapOffsetSubscription; @@ -67,6 +65,10 @@ namespace osu.Game.Screens.Play [Resolved] private OsuConfigManager config { get; set; } = null!; + private readonly List> nonGameplayAdjustments = new List>(); + + public override IEnumerable NonGameplayAdjustments => nonGameplayAdjustments.Select(b => b.Value); + /// /// Create a new master gameplay clock container. /// @@ -134,7 +136,7 @@ namespace osu.Game.Screens.Play this.TransformBindableTo(pauseFreqAdjust, 0, 200, Easing.Out).OnComplete(_ => { if (IsPaused.Value == isPaused.NewValue) - AdjustableSource.Stop(); + base.OnIsPausedChanged(isPaused); }); } else @@ -143,14 +145,14 @@ namespace osu.Game.Screens.Play else { if (isPaused.NewValue) - AdjustableSource.Stop(); + base.OnIsPausedChanged(isPaused); // If not yet loaded, we still want to ensure relevant state is correct, as it is used for offset calculations. pauseFreqAdjust.Value = isPaused.NewValue ? 0 : 1; // We must also process underlying gameplay clocks to update rate-adjusted offsets with the new frequency adjustment. // Without doing this, an initial seek may be performed with the wrong offset. - GameplayClock.UnderlyingClock.ProcessFrame(); + FramedClock.ProcessFrame(); } } @@ -179,29 +181,27 @@ namespace osu.Game.Screens.Play /// public void Skip() { - if (GameplayClock.CurrentTime > skipTargetTime - MINIMUM_SKIP_TIME) + if (FramedClock.CurrentTime > skipTargetTime - MINIMUM_SKIP_TIME) return; double skipTarget = skipTargetTime - MINIMUM_SKIP_TIME; - if (GameplayClock.CurrentTime < 0 && skipTarget > 6000) + if (FramedClock.CurrentTime < 0 && skipTarget > 6000) // double skip exception for storyboards with very long intros skipTarget = 0; Seek(skipTarget); } - protected override GameplayClock CreateGameplayClock(IFrameBasedClock source) + protected override IFrameBasedClock CreateGameplayClock(IFrameBasedClock source) { // Lazer's audio timings in general doesn't match stable. This is the result of user testing, albeit limited. // This only seems to be required on windows. We need to eventually figure out why, with a bit of luck. - platformOffsetClock = new HardwareCorrectionOffsetClock(source, pauseFreqAdjust) { Offset = RuntimeInfo.OS == RuntimeInfo.Platform.Windows ? 15 : 0 }; + platformOffsetClock = new OffsetCorrectionClock(source, pauseFreqAdjust) { Offset = RuntimeInfo.OS == RuntimeInfo.Platform.Windows ? 15 : 0 }; // the final usable gameplay clock with user-set offsets applied. - userGlobalOffsetClock = new HardwareCorrectionOffsetClock(platformOffsetClock, pauseFreqAdjust); - userBeatmapOffsetClock = new HardwareCorrectionOffsetClock(userGlobalOffsetClock, pauseFreqAdjust); - - return masterGameplayClock = new MasterGameplayClock(userBeatmapOffsetClock); + userGlobalOffsetClock = new OffsetCorrectionClock(platformOffsetClock, pauseFreqAdjust); + return userBeatmapOffsetClock = new OffsetCorrectionClock(userGlobalOffsetClock, pauseFreqAdjust); } /// @@ -221,11 +221,14 @@ namespace osu.Game.Screens.Play if (speedAdjustmentsApplied) return; - Track.AddAdjustment(AdjustableProperty.Frequency, pauseFreqAdjust); - Track.AddAdjustment(AdjustableProperty.Tempo, UserPlaybackRate); + if (SourceClock is not Track track) + return; - masterGameplayClock.MutableNonGameplayAdjustments.Add(pauseFreqAdjust); - masterGameplayClock.MutableNonGameplayAdjustments.Add(UserPlaybackRate); + track.AddAdjustment(AdjustableProperty.Frequency, pauseFreqAdjust); + track.AddAdjustment(AdjustableProperty.Tempo, UserPlaybackRate); + + nonGameplayAdjustments.Add(pauseFreqAdjust); + nonGameplayAdjustments.Add(UserPlaybackRate); speedAdjustmentsApplied = true; } @@ -235,11 +238,14 @@ namespace osu.Game.Screens.Play if (!speedAdjustmentsApplied) return; - Track.RemoveAdjustment(AdjustableProperty.Frequency, pauseFreqAdjust); - Track.RemoveAdjustment(AdjustableProperty.Tempo, UserPlaybackRate); + if (SourceClock is not Track track) + return; - masterGameplayClock.MutableNonGameplayAdjustments.Remove(pauseFreqAdjust); - masterGameplayClock.MutableNonGameplayAdjustments.Remove(UserPlaybackRate); + track.RemoveAdjustment(AdjustableProperty.Frequency, pauseFreqAdjust); + track.RemoveAdjustment(AdjustableProperty.Tempo, UserPlaybackRate); + + nonGameplayAdjustments.Remove(pauseFreqAdjust); + nonGameplayAdjustments.Remove(UserPlaybackRate); speedAdjustmentsApplied = false; } @@ -252,63 +258,8 @@ namespace osu.Game.Screens.Play } ControlPointInfo IBeatSyncProvider.ControlPoints => beatmap.Beatmap.ControlPointInfo; - IClock IBeatSyncProvider.Clock => GameplayClock; + IClock IBeatSyncProvider.Clock => this; + ChannelAmplitudes IHasAmplitudes.CurrentAmplitudes => beatmap.TrackLoaded ? beatmap.Track.CurrentAmplitudes : ChannelAmplitudes.Empty; - - private class HardwareCorrectionOffsetClock : FramedOffsetClock - { - private readonly BindableDouble pauseRateAdjust; - - private double offset; - - public new double Offset - { - get => offset; - set - { - if (value == offset) - return; - - offset = value; - - updateOffset(); - } - } - - public double RateAdjustedOffset => base.Offset; - - public HardwareCorrectionOffsetClock(IClock source, BindableDouble pauseRateAdjust) - : base(source) - { - this.pauseRateAdjust = pauseRateAdjust; - } - - public override void ProcessFrame() - { - base.ProcessFrame(); - updateOffset(); - } - - private void updateOffset() - { - // changing this during the pause transform effect will cause a potentially large offset to be suddenly applied as we approach zero rate. - if (pauseRateAdjust.Value == 1) - { - // we always want to apply the same real-time offset, so it should be adjusted by the difference in playback rate (from realtime) to achieve this. - base.Offset = Offset * Rate; - } - } - } - - private class MasterGameplayClock : GameplayClock - { - public readonly List> MutableNonGameplayAdjustments = new List>(); - public override IEnumerable NonGameplayAdjustments => MutableNonGameplayAdjustments.Select(b => b.Value); - - public MasterGameplayClock(FramedOffsetClock underlyingClock) - : base(underlyingClock) - { - } - } } } diff --git a/osu.Game/Screens/Play/OffsetCorrectionClock.cs b/osu.Game/Screens/Play/OffsetCorrectionClock.cs new file mode 100644 index 0000000000..207980f45c --- /dev/null +++ b/osu.Game/Screens/Play/OffsetCorrectionClock.cs @@ -0,0 +1,53 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Bindables; +using osu.Framework.Timing; + +namespace osu.Game.Screens.Play +{ + public class OffsetCorrectionClock : FramedOffsetClock + { + private readonly BindableDouble pauseRateAdjust; + + private double offset; + + public new double Offset + { + get => offset; + set + { + if (value == offset) + return; + + offset = value; + + updateOffset(); + } + } + + public double RateAdjustedOffset => base.Offset; + + public OffsetCorrectionClock(IClock source, BindableDouble pauseRateAdjust) + : base(source) + { + this.pauseRateAdjust = pauseRateAdjust; + } + + public override void ProcessFrame() + { + base.ProcessFrame(); + updateOffset(); + } + + private void updateOffset() + { + // changing this during the pause transform effect will cause a potentially large offset to be suddenly applied as we approach zero rate. + if (pauseRateAdjust.Value == 1) + { + // we always want to apply the same real-time offset, so it should be adjusted by the difference in playback rate (from realtime) to achieve this. + base.Offset = Offset * Rate; + } + } + } +} diff --git a/osu.Game/Screens/Play/ResumeOverlay.cs b/osu.Game/Screens/Play/ResumeOverlay.cs index 2be1f93f80..7ed95c4ce3 100644 --- a/osu.Game/Screens/Play/ResumeOverlay.cs +++ b/osu.Game/Screens/Play/ResumeOverlay.cs @@ -8,6 +8,7 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; +using osu.Framework.Localisation; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osuTK; @@ -31,7 +32,7 @@ namespace osu.Game.Screens.Play protected const float TRANSITION_TIME = 500; - protected abstract string Message { get; } + protected abstract LocalisableString Message { get; } public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => true; diff --git a/osu.Game/Screens/Play/SubmittingPlayer.cs b/osu.Game/Screens/Play/SubmittingPlayer.cs index 02a95ae9eb..be77304076 100644 --- a/osu.Game/Screens/Play/SubmittingPlayer.cs +++ b/osu.Game/Screens/Play/SubmittingPlayer.cs @@ -8,7 +8,6 @@ using System.Linq; using System.Threading.Tasks; using JetBrains.Annotations; using osu.Framework.Allocation; -using osu.Framework.Extensions; using osu.Framework.Logging; using osu.Framework.Screens; using osu.Game.Beatmaps; @@ -84,7 +83,10 @@ namespace osu.Game.Screens.Play api.Queue(req); - tcs.Task.WaitSafely(); + // Generally a timeout would not happen here as APIAccess will timeout first. + if (!tcs.Task.Wait(60000)) + handleTokenFailure(new InvalidOperationException("Token retrieval timed out (request never run)")); + return true; void handleTokenFailure(Exception exception) diff --git a/osu.Game/Screens/Ranking/Statistics/StatisticContainer.cs b/osu.Game/Screens/Ranking/Statistics/StatisticContainer.cs index 078ca97737..1505585205 100644 --- a/osu.Game/Screens/Ranking/Statistics/StatisticContainer.cs +++ b/osu.Game/Screens/Ranking/Statistics/StatisticContainer.cs @@ -8,6 +8,7 @@ using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; +using osu.Framework.Localisation; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osuTK; @@ -59,7 +60,7 @@ namespace osu.Game.Screens.Ranking.Statistics private static Drawable createHeader(StatisticItem item) { - if (string.IsNullOrEmpty(item.Name)) + if (LocalisableString.IsNullOrEmpty(item.Name)) return Empty(); return new FillFlowContainer diff --git a/osu.Game/Screens/Ranking/Statistics/StatisticItem.cs b/osu.Game/Screens/Ranking/Statistics/StatisticItem.cs index 0e5cce59f8..e3ac054d1b 100644 --- a/osu.Game/Screens/Ranking/Statistics/StatisticItem.cs +++ b/osu.Game/Screens/Ranking/Statistics/StatisticItem.cs @@ -7,6 +7,7 @@ using System; using JetBrains.Annotations; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Localisation; namespace osu.Game.Screens.Ranking.Statistics { @@ -18,7 +19,7 @@ namespace osu.Game.Screens.Ranking.Statistics /// /// The name of this item. /// - public readonly string Name; + public readonly LocalisableString Name; /// /// A function returning the content to be displayed. @@ -44,11 +45,11 @@ namespace osu.Game.Screens.Ranking.Statistics /// /// Creates a new , to be displayed inside a in the results screen. /// - /// The name of the item. Can be to hide the item header. + /// The name of the item. Can be to hide the item header. /// A function returning the content to be displayed. /// Whether this item requires hit events. If true, will not be called if no hit events are available. /// The of this item. This can be thought of as the column dimension of an encompassing . - public StatisticItem([NotNull] string name, [NotNull] Func createContent, bool requiresHitEvents = false, [CanBeNull] Dimension dimension = null) + public StatisticItem(LocalisableString name, [NotNull] Func createContent, bool requiresHitEvents = false, [CanBeNull] Dimension dimension = null) { Name = name; RequiresHitEvents = requiresHitEvents; diff --git a/osu.Game/Screens/Select/Carousel/CarouselBeatmap.cs b/osu.Game/Screens/Select/Carousel/CarouselBeatmap.cs index 5b17b412ae..03490ff37b 100644 --- a/osu.Game/Screens/Select/Carousel/CarouselBeatmap.cs +++ b/osu.Game/Screens/Select/Carousel/CarouselBeatmap.cs @@ -55,8 +55,6 @@ namespace osu.Game.Screens.Select.Carousel match &= !criteria.Artist.HasFilter || criteria.Artist.Matches(BeatmapInfo.Metadata.Artist) || criteria.Artist.Matches(BeatmapInfo.Metadata.ArtistUnicode); - match &= criteria.Sort != SortMode.DateRanked || BeatmapInfo.BeatmapSet?.DateRanked != null; - match &= !criteria.UserStarDifficulty.HasFilter || criteria.UserStarDifficulty.IsInRange(BeatmapInfo.StarRating); if (match && criteria.SearchTerms.Length > 0) diff --git a/osu.Game/Screens/Select/Carousel/CarouselBeatmapSet.cs b/osu.Game/Screens/Select/Carousel/CarouselBeatmapSet.cs index 59d9318962..6c134a4ab8 100644 --- a/osu.Game/Screens/Select/Carousel/CarouselBeatmapSet.cs +++ b/osu.Game/Screens/Select/Carousel/CarouselBeatmapSet.cs @@ -99,6 +99,13 @@ namespace osu.Game.Screens.Select.Carousel case SortMode.Difficulty: return compareUsingAggregateMax(otherSet, b => b.StarRating); + + case SortMode.DateSubmitted: + // Beatmaps which have no submitted date should already be filtered away in this mode. + if (BeatmapSet.DateSubmitted == null || otherSet.BeatmapSet.DateSubmitted == null) + return 0; + + return otherSet.BeatmapSet.DateSubmitted.Value.CompareTo(BeatmapSet.DateSubmitted.Value); } } @@ -122,7 +129,12 @@ namespace osu.Game.Screens.Select.Carousel public override void Filter(FilterCriteria criteria) { base.Filter(criteria); - Filtered.Value = Items.All(i => i.Filtered.Value); + bool match = Items.All(i => i.Filtered.Value); + + match &= criteria.Sort != SortMode.DateRanked || BeatmapSet?.DateRanked != null; + match &= criteria.Sort != SortMode.DateSubmitted || BeatmapSet?.DateSubmitted != null; + + Filtered.Value = match; } public override string ToString() => BeatmapSet.ToString(); diff --git a/osu.Game/Screens/Select/Filter/SortMode.cs b/osu.Game/Screens/Select/Filter/SortMode.cs index 1e60ea3bac..c77bdbfbc6 100644 --- a/osu.Game/Screens/Select/Filter/SortMode.cs +++ b/osu.Game/Screens/Select/Filter/SortMode.cs @@ -20,6 +20,9 @@ namespace osu.Game.Screens.Select.Filter [LocalisableDescription(typeof(SortStrings), nameof(SortStrings.ArtistTracksBpm))] BPM, + [Description("Date Submitted")] + DateSubmitted, + [Description("Date Added")] DateAdded, diff --git a/osu.Game/Screens/Select/SongSelect.cs b/osu.Game/Screens/Select/SongSelect.cs index 0c2ca6d4af..ece94b5365 100644 --- a/osu.Game/Screens/Select/SongSelect.cs +++ b/osu.Game/Screens/Select/SongSelect.cs @@ -928,7 +928,7 @@ namespace osu.Game.Screens.Select } } - private class SoloModSelectOverlay : UserModSelectOverlay + internal class SoloModSelectOverlay : UserModSelectOverlay { protected override bool ShowPresets => true; } diff --git a/osu.Game/osu.Game.csproj b/osu.Game/osu.Game.csproj index d67f8415e7..0613db891b 100644 --- a/osu.Game/osu.Game.csproj +++ b/osu.Game/osu.Game.csproj @@ -31,13 +31,13 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive - - + + diff --git a/osu.iOS.props b/osu.iOS.props index 463af1143f..bf1e4e350c 100644 --- a/osu.iOS.props +++ b/osu.iOS.props @@ -61,8 +61,8 @@ - - + + @@ -84,7 +84,7 @@ - +